CrashLoopBackOff After a Release
Scenario
It is 14:10 on a Tuesday. The payments-api Deployment was updated 12 minutes ago as part of a normal release train — image tag v2.31.0 replacing v2.30.4. The rollout is stuck: 3 of 6 replicas are CrashLoopBackOff, the remaining 3 are still the old ReplicaSet and are carrying all traffic at roughly 2.2× their usual load. Error rate at the edge is 12% and climbing as the surviving pods start shedding requests.
You have a 15-minute stabilisation budget before the incident is escalated to a customer-facing status page update. The temptation is to type kubectl rollout undo immediately. Sometimes that is correct. Sometimes it restores a service that is broken for a reason unrelated to the image, and you have burned your only fast lever while the real cause keeps running.
The job in the next 15 minutes is not "find the root cause". It is stabilise, while collecting enough evidence that the root cause is findable afterwards.
Context
| Property | Value |
|---|---|
| Kubernetes | v1.29, managed control plane, 3 AZs |
| Workload | payments-api, Deployment, 6 replicas, maxUnavailable: 0, maxSurge: 2 |
| Runtime | Go 1.22 service, distroless image |
| Config | ConfigMap payments-api-config, Secret payments-api-secrets (External Secrets Operator) |
| Dependencies | PostgreSQL (RDS), Redis (ElastiCache), an internal fraud-check gRPC service |
| Probes | readinessProbe and livenessProbe on /healthz, no startupProbe |
| Delivery | Argo CD, auto-sync enabled, self-heal on |
The Argo CD detail matters and is easy to forget under pressure: with auto-sync and self-heal enabled, kubectl rollout undo will be reverted by the controller within a sync interval. Any imperative fix is temporary unless you also stop the reconciler or change Git.
Symptoms
KubePodCrashLoopingfiring for 3 pods in namespacepayments.- Edge error rate 12%, p99 latency on the surviving pods up 4×.
- Rollout stalled —
kubectl rollout statusnever returns. - No node-level alerts. Other workloads on the same nodes are healthy.
Impact
Technical. Effective capacity is halved while demand is unchanged. The three old pods are absorbing traffic they were never load-tested for; they are the next thing to fall over. The rollout is stuck rather than failed, so Kubernetes will keep the broken ReplicaSet alive and keep retrying.
Business. Payment authorisation failures. Unlike a read path, a failed payment is not retried transparently by the user — a meaningful share of those 12% are abandoned carts, not deferred ones. There is also a reconciliation tail: requests that failed after the downstream processor accepted them create pending charges that need manual cleanup.
Requirements
- Restore full capacity within 15 minutes.
- Do not lose the diagnostic state — logs of the crashed containers must be captured before they are garbage collected.
- Do not create a second incident (a rollback to a version whose database migration has already been applied forward is its own outage).
- Produce evidence that distinguishes image-caused from config-caused failure.
Constraints
- Zero-downtime requirement:
maxUnavailable: 0was chosen deliberately; reducing it to force the rollout through would trade one failure for another. - Forward-only migrations: the team's migration policy is expand/contract, so a rollback is safe only if
v2.31.0did not run a contracting migration. - Argo CD self-heal: imperative changes are reverted automatically.
- On-call is one engineer. No parallel investigation.
Initial Architecture
┌──────────────┐
Internet ─────────▶│ Ingress/ALB │
└──────┬───────┘
│
┌──────▼───────┐
│ Service │ payments-api:8080
└──────┬───────┘
│ endpoints = READY pods only
┌────────────────────┼────────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ RS v2.30.4│ │ RS v2.31.0│ │ RS v2.31.0│
│ 3 pods │ │ CrashLoop │ │ CrashLoop │
│ Ready │ │ 0/1 Ready │ │ 0/1 Ready │
└───────────┘ └───────────┘ └───────────┘
│
├──▶ PostgreSQL (RDS, multi-AZ)
├──▶ Redis (ElastiCache)
└──▶ fraud-check (gRPC, in-cluster)Note what the diagram already tells you: the Service only routes to ready pods, so the crashing pods are not serving errors — they are serving nothing. The 12% error rate is coming from the overloaded survivors. That reframes the priority: the fastest way to cut the error rate is to add capacity to the working ReplicaSet, not to fix the broken one.
Investigation
1. Establish the blast radius before touching anything
kubectl get pods -n payments -o wide \
--sort-by=.status.containerStatuses[0].restartCountYou want three facts from this: how many pods are affected, whether they are spread across nodes or concentrated on one, and how fast the restart counter is climbing. Concentrated on one node points at the node (disk, kernel, a failing CNI). Spread evenly points at the workload itself. Here they are spread across all three AZs — the node is not the problem.
2. Read the pod's own account of what happened
kubectl describe pod -n payments payments-api-7d9f8b6c4-x2ktnThe three fields that matter, in order:
Last State: TerminatedwithReasonandExit Code. This is the single most informative field on the page.Eventsat the bottom — image pull failures, probe failures, scheduling problems and volume mount failures all surface here and nowhere else.Ready: Falseplus a non-zeroRestart Count— confirms the pod never reached readiness rather than becoming unready later.
The exit code narrows the search dramatically:
| Exit code | Meaning | Where to look next |
|---|---|---|
0 | Process exited cleanly | Container has no long-running foreground process; entrypoint is wrong |
1 | Generic application error | Application logs — usually config or a failed dependency |
2 | Shell misuse / bad flag | Entrypoint or args |
126 | Command found but not executable | File mode, wrong architecture |
127 | Command not found | Bad entrypoint path, missing shell in a distroless image |
137 | SIGKILL (128+9) | OOMKilled, or a liveness probe kill that ignored SIGTERM |
139 | SIGSEGV (128+11) | Native crash, bad CGO, corrupt binary |
143 | SIGTERM (128+15) | Graceful shutdown requested — usually a liveness probe or eviction |
137 is not automatically OOM. Check Reason: OOMKilled explicitly. A container that ignores SIGTERM and is killed after terminationGracePeriod also exits 137, and the fix is entirely different — see OOMKilled Under Load for the distinction.
In this incident: Exit Code: 1, Reason: Error, no OOMKilled.
3. Read the logs of the container that died, not the one that is starting
kubectl logs -n payments payments-api-7d9f8b6c4-x2ktn --previous--previous is the whole point. Without it you get the logs of the current attempt, which may still be in its first 200 ms and show nothing. With it you get the terminated container's output — which is where a Go service prints its fatal startup error.
If the pod has restarted enough times that the previous container has been garbage collected, capture what exists immediately:
for p in $(kubectl get pods -n payments -l app=payments-api \
-o jsonpath='{.items[*].metadata.name}'); do
kubectl logs -n payments "$p" --previous --timestamps > "/tmp/$p.prev.log" 2>&1
kubectl describe pod -n payments "$p" > "/tmp/$p.describe.txt"
doneDo this before mitigating. Rolling back deletes the pods and the evidence with them.
4. Events give you the ordering that logs do not
kubectl get events -n payments --sort-by=.lastTimestamp | tail -40Events are cluster-scoped truth about scheduling and lifecycle: FailedMount, ErrImagePull, Unhealthy, BackOff, FailedScheduling. Two patterns worth recognising instantly:
Unhealthy: Liveness probe failedbefore any application error → the app is fine and the probe is wrong (too aggressive, or pointing at a port that only opens after warm-up).BackOff: Back-off restarting failed containerwith no other event → the container is exiting on its own; the answer is in the logs, not in Kubernetes.
5. Diff what actually changed
This is the step most people skip, and it is the one that separates a 15-minute incident from a 90-minute one. The image tag changed — but did anything else?
# What Argo CD thinks changed
argocd app diff payments-api
# What the ReplicaSets differ by
kubectl get rs -n payments -l app=payments-api \
--sort-by=.metadata.creationTimestamp
kubectl describe rs -n payments payments-api-7d9f8b6c4 | head -40
# Has the ConfigMap changed independently of the image?
kubectl get configmap -n payments payments-api-config -o yaml \
| grep -A2 'creationTimestamp\|resourceVersion'A ConfigMap edited an hour before the deploy, by someone else, is one of the most common "the release broke it" causes that has nothing to do with the release. The deploy simply became the first thing to read the broken config.
6. Reproduce outside the crash loop
If the logs are ambiguous, run the image with the same config but without the entrypoint, so it cannot crash:
kubectl run payments-debug -n payments --rm -it --restart=Never \
--image=<registry>/payments-api:v2.31.0 \
--overrides='{"spec":{"containers":[{"name":"payments-debug",
"image":"<registry>/payments-api:v2.31.0",
"command":["/bin/sh"],"stdin":true,"tty":true,
"envFrom":[{"configMapRef":{"name":"payments-api-config"}}]}]}}' \
-- shWith a distroless image there is no shell, so instead use an ephemeral debug container attached to a live (crashing) pod, which shares its namespaces:
kubectl debug -n payments payments-api-7d9f8b6c4-x2ktn \
-it --image=busybox:1.36 --target=payments-apiFrom inside, verify the three things that config bugs break: DNS resolution of each dependency, TCP reachability, and the actual mounted values.
nslookup fraud-check.payments.svc.cluster.local
nc -zv payments-db.abc123.eu-west-1.rds.amazonaws.com 5432
cat /etc/payments/config.yamlRoot Cause
Candidate causes, and the evidence that separates them:
| Candidate | Confirming evidence | Ruled out by |
|---|---|---|
| Bad image / broken binary | Exit 127 or 139; crash before any log line | Application logged a structured startup error |
| Missing or malformed config | Exit 1 with a parse/validation error naming the key | Config identical to the working ReplicaSet |
| Missing secret | CreateContainerConfigError, or auth failure in logs | Secret exists and ExternalSecret is SecretSynced |
| Failed migration / schema mismatch | SQL error naming a column or table | Migration job completed and schema matches |
| Dependency unavailable | Connection refused / timeout to a named host | Dependency healthy from a debug pod |
| OOM at startup | Reason: OOMKilled, exit 137 | Reason: Error, exit 1 |
| Probe too aggressive | Unhealthy events precede the app error | No Unhealthy events before the first crash |
In this case the --previous logs read:
{"level":"fatal","ts":"2026-09-06T14:00:11Z","msg":"config validation failed",
"error":"fraud_check.timeout_ms: required field missing"}Root cause: v2.31.0 added a required configuration key, fraud_check.timeout_ms, and validated it at startup. The corresponding ConfigMap change lived in a separate Git repository (payments-config) from the application chart, and its pull request had not merged. The image shipped; the config did not.
The deeper cause is structural, not human: the config schema and the config value were in two repositories with no coupling between them, so nothing could have caught the mismatch before runtime.
Solution
Three moves, in this order.
Immediate (minutes 0–5): restore capacity, not correctness
The surviving ReplicaSet is healthy. Give it the capacity it needs:
kubectl scale deployment payments-api -n payments --replicas=10Because maxSurge: 2 and maxUnavailable: 0, this brings up more new (crashing) pods too — which is useless. So first pause the rollout, which pins the Deployment and lets the old ReplicaSet scale:
kubectl rollout pause deployment/payments-api -n paymentsPausing is the underused lever here. It stops the controller from creating more doomed pods, keeps the working pods alive, and — unlike rollout undo — is non-destructive and instantly reversible.
Mitigation (minutes 5–10): roll back through Git, not kubectl
With Argo CD self-heal on, the durable rollback is a Git revert:
git -C payments-chart revert --no-edit <deploy-commit-sha>
git -C payments-chart push
argocd app sync payments-apiIf Git is unavailable or too slow, disable self-heal first so the imperative rollback survives:
argocd app set payments-api --sync-policy none
kubectl rollout undo deployment/payments-api -n paymentsBefore rolling back, answer one question: did v2.31.0 run a contracting migration? If it dropped a column that v2.30.4 still writes to, the rollback is a second outage.
kubectl get jobs -n payments -l app=payments-api-migrate \
--sort-by=.metadata.creationTimestamp
kubectl logs -n payments job/payments-api-migrate-v2-31-0 | tail -30Here the migration was additive (a new nullable column), so rollback is safe.
Fix forward (minutes 10–15): if the config is the only thing missing
Rolling back is not always the fastest route. When the missing piece is a single config key, applying it is one commit and no schema risk:
# payments-config/overlays/prod/payments-api-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: payments-api-config
namespace: payments
data:
config.yaml: |
fraud_check:
endpoint: fraud-check.payments.svc.cluster.local:9090
timeout_ms: 250 # ← the key v2.31.0 requires
max_retries: 2A ConfigMap mounted as a volume updates in place, but the application only re-reads it if it watches the file. A ConfigMap consumed via envFrom does not update in a running container at all. Neither triggers a rollout by itself. If you change config and nothing happens, that is why — you need a checksum annotation on the pod template.
Architecture
The fix that prevents recurrence changes the shape of the delivery path, not just the value of one key:
BEFORE — schema and value in different repos, coupled only by hope
payments-api repo ──build──▶ image v2.31.0 ──┐
(defines required keys) ├──▶ cluster ──▶ crash
payments-config repo ─────── config v9 ──────┘
(supplies values, PR unmerged)
AFTER — schema travels with the image, validated before the cluster sees it
payments-api repo
├── config.schema.json (generated from the Go struct tags)
└── build ──▶ image + schema attached as an OCI artifact
│
payments-config repo │
└── values ──▶ CI: validate values against schema ──┐
│ fails PR
┌─────────────────────────────┘
▼
Argo CD PreSync hook
(re-validates at deploy time)
│
▼
Rollout with startupProbe
(fails fast, no traffic shifted)Implementation
1. Make a bad config fail the deploy, not the pod
An Argo CD PreSync hook runs to completion before any workload is updated. A failing hook aborts the sync — the existing pods are never touched.
apiVersion: batch/v1
kind: Job
metadata:
name: payments-api-config-validate
namespace: payments
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: Never
containers:
- name: validate
image: <registry>/payments-api:v2.31.0
args: ["--config", "/etc/payments/config.yaml", "--validate-only"]
volumeMounts:
- name: config
mountPath: /etc/payments
readOnly: true
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities: { drop: ["ALL"] }
volumes:
- name: config
configMap:
name: payments-api-configThe --validate-only flag is the important part and it belongs in the application, not in a script. The binary that will consume the config is the only thing that can authoritatively say the config is valid for it.
2. Roll config changes properly
Without this annotation, editing a ConfigMap changes nothing that is running.
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
namespace: payments
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 2
selector:
matchLabels: { app: payments-api }
template:
metadata:
labels: { app: payments-api }
annotations:
# Changes to the ConfigMap now produce a new pod template hash,
# so a config-only change rolls like any other change.
checksum/config: "{{ include (print $.Template.BasePath \"/configmap.yaml\") . | sha256sum }}"
spec:
terminationGracePeriodSeconds: 45
containers:
- name: payments-api
image: <registry>/payments-api:v2.31.0
ports:
- { name: http, containerPort: 8080 }
# Fail fast and loudly at startup rather than crash-looping silently.
startupProbe:
httpGet: { path: /healthz/startup, port: http }
periodSeconds: 2
failureThreshold: 30 # 60s budget for a slow start
readinessProbe:
httpGet: { path: /healthz/ready, port: http }
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet: { path: /healthz/live, port: http }
periodSeconds: 10
failureThreshold: 3
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { memory: 512Mi }Three probes, three questions. startupProbe: has it finished booting? readinessProbe: should it receive traffic right now? livenessProbe: is it wedged and in need of a restart? Using one probe for all three is the most common probe mistake, and it produces exactly this failure mode — a slow start gets killed by liveness, forever.
3. Separate the health endpoints
A single /healthz that checks dependencies is a footgun: when the database blips, liveness fails, every pod restarts simultaneously, and a recoverable dependency problem becomes a total outage.
// /healthz/live — is this process wedged? Nothing external. Never fails
// because a dependency is down.
mux.HandleFunc("/healthz/live", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// /healthz/ready — should the Service send me traffic? Dependencies count here,
// because a pod that cannot reach the DB should be removed from endpoints.
mux.HandleFunc("/healthz/ready", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
if err := db.PingContext(ctx); err != nil {
http.Error(w, "db: "+err.Error(), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
// /healthz/startup — has initialisation completed? Flips once and stays true.
mux.HandleFunc("/healthz/startup", func(w http.ResponseWriter, r *http.Request) {
if !initialised.Load() {
http.Error(w, "initialising", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})4. Capture crash evidence automatically
Crash-loop logs are lost to garbage collection. A small controller that copies them out on transition turns "we did not have the logs" into a non-event.
#!/usr/bin/env bash
# crashloop-capture.sh — run from a CronJob every minute in the cluster.
set -euo pipefail
NS="${1:-payments}"
DEST="${2:-/var/log/crash-capture}"
mkdir -p "$DEST"
kubectl get pods -n "$NS" \
-o jsonpath='{range .items[?(@.status.containerStatuses[0].restartCount>0)]}{.metadata.name}{"\n"}{end}' \
| while read -r pod; do
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
out="$DEST/${NS}_${pod}_${stamp}"
kubectl logs -n "$NS" "$pod" --previous --timestamps > "${out}.prev.log" 2>/dev/null || true
kubectl describe pod -n "$NS" "$pod" > "${out}.describe.txt" 2>/dev/null || true
doneIn practice, prefer an operator that watches Pod events rather than a polling CronJob — KubeRescue is built around exactly this pattern of detecting a failure state and acting on it under policy.
Observability
The alert that should have fired first is not KubePodCrashLooping — it is a rollout that has not converged.
# A Deployment whose desired and updated-available replicas disagree
# for longer than a normal rollout takes.
(
kube_deployment_spec_replicas{namespace="payments"}
-
kube_deployment_status_replicas_available{namespace="payments"}
) > 0Alert on this for: 10m and you catch a stuck rollout before the surviving pods are overloaded — which is the actual customer-facing failure.
Supporting signals:
# Restart rate per pod — the definitive crash-loop signal.
increase(kube_pod_container_status_restarts_total{namespace="payments"}[15m]) > 3
# Containers currently waiting in CrashLoopBackOff.
kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"} == 1
# Capacity headroom on the surviving ReplicaSet — this is the one that
# predicts the *next* failure.
sum(rate(http_requests_total{app="payments-api"}[5m]))
/ count(kube_pod_status_ready{condition="true", pod=~"payments-api-.*"})Logs. The fatal line must be structured and must name the failing field. "config validation failed: fraud_check.timeout_ms: required field missing" is a 30-second diagnosis. "startup error" is a 30-minute one. Ship stdout to Loki and keep a namespace/pod/container label set so --previous logs remain queryable after the pod is gone.
Dashboard panels, in the order an on-call reads them: rollout progress (desired vs available), restart count by pod, exit-code distribution, error rate at the edge, and per-pod request rate (which reveals overload of the survivors).
Security
- Do not paste logs into a shared channel unfiltered. A payments service's startup logs routinely contain connection strings, and a fatal error often includes the value that failed to parse.
- The debug path matters:
kubectl debug --targetshares the target's process and network namespaces, so an engineer with debug rights effectively has the container's network identity. Gatepods/ephemeralcontainersbehind a break-glass role, not the default on-call role. --validate-onlymust never log secret values — validate and report the key name only.- Ephemeral debug images (
busybox,netshoot) should come from your own registry and be scanned, not pulled from Docker Hub at 03:00.
Reliability
Failure domain: this failure was workload-scoped, not node- or zone-scoped. The evidence was the even spread across AZs. Always establish that spread first, because it eliminates entire categories of cause.
maxUnavailable: 0is what saved you. It is why 3 old pods survived to serve traffic. WithmaxUnavailable: 1andmaxSurge: 1, the same bug would have taken the service to zero.PodDisruptionBudget protects against voluntary disruption during the incident — a node drain at the wrong moment would take out the survivors:
yamlapiVersion: policy/v1 kind: PodDisruptionBudget metadata: { name: payments-api, namespace: payments } spec: minAvailable: 3 selector: matchLabels: { app: payments-api }progressDeadlineSeconds(default 600) marks the DeploymentProgressDeadlineExceeded— make sure something alerts on that condition rather than waiting for a human to notice a hangingrollout status.Rollback safety comes from the migration policy, not from Kubernetes. Expand/contract (add nullable column → deploy code that writes both → deploy code that reads new → drop old, one release later) is what makes
rollout undoa safe reflex.
Cost Considerations
The costs here are mostly indirect:
- Overprovisioning as insurance. Running 6 replicas where 4 would do is paying continuously for headroom during failed rollouts. Whether that is worth it depends on how often rollouts fail — measure it rather than assuming.
- Log retention. Crash-loop pods can produce a very high log rate (a pod restarting every 10 s for an hour). Retention costs scale with that, and the spike is exactly when you cannot afford to be dropping lines. Rate limiting at the collector is safer than reducing retention.
- Emergency scale-out (the
--replicas=10above) costs real money if the cluster autoscaler adds nodes. Budget for it, and make sure the scale-down is in the runbook — forgotten emergency capacity is a recurring line item. - The cheapest fix in this entire document is the
PreSyncvalidation job. It costs 50 mCPU for a few seconds per deploy and prevents the whole incident.
Trade-offs
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| First action | rollout pause + scale old RS | rollout undo immediately | Pause is non-destructive and preserves evidence; undo is irreversible and can trip a migration mismatch |
| Rollback mechanism | Git revert through Argo CD | kubectl rollout undo | Self-heal reverts imperative changes; Git revert is durable and auditable |
| Config validation | PreSync hook running the real binary | A CI schema check only | CI validates the config repo in isolation; the hook validates the combination actually being deployed |
| Probe design | Three separate probes | One /healthz for all | A shared endpoint turns a dependency blip into a cluster-wide restart storm |
| Startup failure handling | startupProbe + fail fast | Long liveness initialDelaySeconds | initialDelaySeconds is a fixed guess; startupProbe adapts and reports distinctly |
| Config coupling | Schema shipped with the image | Keep repos independent | Independence was the root cause; coupling the schema is the minimum viable fix |
The honest cost of the chosen approach: the PreSync hook adds 10–20 seconds to every deploy and introduces a new thing that can fail (a hook that hangs blocks all deploys). Set activeDeadlineSeconds on it and alert on hook failures.
Failure Scenarios
Things that still break after all of the above:
- The PreSync hook passes and the app still crashes. Validation covers syntax and required keys, not semantics —
timeout_ms: 1is valid and catastrophic. Mitigation: canary the release to one replica first. - A dependency fails during rollout. New pods cannot reach the database, so readiness never succeeds and the rollout stalls. This looks identical to a bad image from the outside. The distinguishing evidence: old pods are also unhealthy.
- The registry is unavailable.
ErrImagePull/ImagePullBackOffrather thanCrashLoopBackOff— different symptom, and the rollback also fails if the old image was garbage-collected from the node. SetimagePullPolicy: IfNotPresentfor pinned digests and keep old images in the registry lifecycle policy. - Rollback into a contracted schema. The worst case in this document. Prevented by policy, not tooling.
- Self-heal fights you. Someone disables self-heal to mitigate and forgets to re-enable it. Two weeks later the cluster has silently drifted. Alert on
argocd_app_info{sync_policy="none"}persisting beyond an hour.
Runbook
Trigger: KubePodCrashLooping or DeploymentRolloutStuck in payments.
1. Confirm scope
kubectl get pods -n payments -o wide
→ spread across nodes? yes → workload no → suspect the node
2. Stop making it worse
kubectl rollout pause deployment/payments-api -n payments
3. Capture evidence (BEFORE mitigating)
./crashloop-capture.sh payments /tmp/incident-$(date +%s)
4. Classify
kubectl describe pod -n payments <pod> | grep -A5 "Last State"
→ OOMKilled / 137 → see 04-kubernetes/oomkilled-under-load.md
→ 127 / 126 → image or entrypoint; roll back
→ 1 → read --previous logs; usually config or dependency
5. Restore capacity from the healthy ReplicaSet
kubectl scale deployment payments-api -n payments --replicas=10
6. Check rollback safety
kubectl logs -n payments job/payments-api-migrate-<version> | tail -30
→ contracting migration present? DO NOT ROLL BACK. Fix forward.
7. Mitigate
git revert <sha> && git push && argocd app sync payments-api
(or: argocd app set payments-api --sync-policy none
kubectl rollout undo deployment/payments-api -n payments)
8. Verify
kubectl rollout status deployment/payments-api -n payments --timeout=5m
watch edge error rate for 10 minutes at pre-incident levels
9. Restore steady state
kubectl scale deployment payments-api -n payments --replicas=6
argocd app set payments-api --sync-policy automated --self-heal
kubectl rollout resume deployment/payments-api -n payments
10. Open the postmortem doc while the timeline is still in your terminal
history.Prevention
| Control | Catches | Where it runs |
|---|---|---|
| Config schema generated from the app's own types | Missing/renamed required keys | Application build |
| CI validation of config repo against the schema | Bad values before merge | Config repo PR |
Argo CD PreSync validation job | Bad combinations of image + config | Deploy time, before pods change |
startupProbe | Slow starts being liveness-killed | Runtime |
| Separate live/ready/startup endpoints | Dependency blips causing restart storms | Runtime |
checksum/config annotation | Config changes that silently do not roll | Chart |
| Canary one replica before full rollout | Semantically valid but wrong config | Deploy |
maxUnavailable: 0 | Total capacity loss on a bad release | Deployment spec |
| Expand/contract migrations | Unsafe rollbacks | Team policy + migration lint |
The single highest-leverage item is the first one. Everything else catches the failure later and more expensively.
Postmortem
Incident: payments-api partial outage — 2026-09-06, 14:12–14:41 UTC Severity: SEV-2 · Duration: 29 min · Error budget consumed: 41% of month
Timeline (UTC)
| Time | Event |
|---|---|
| 13:58 | v2.31.0 merged; Argo CD begins sync |
| 14:00 | First new pod starts and exits 1 |
| 14:04 | Third restart; CrashLoopBackOff |
| 14:10 | KubePodCrashLooping fires; on-call paged |
| 14:12 | Edge error rate crosses 5%; incident declared |
| 14:15 | Rollout paused; evidence captured |
| 14:18 | --previous logs identify the missing config key |
| 14:22 | Migration confirmed additive; rollback deemed safe |
| 14:25 | Config PR merged in payments-config (fix forward chosen over rollback) |
| 14:31 | Argo CD sync completes; new pods pass readiness |
| 14:38 | Error rate back to baseline |
| 14:41 | Incident closed; replicas returned to 6 |
Root cause
v2.31.0 introduced a required configuration key. The value lived in a different repository whose pull request had not merged. No mechanism coupled the two, so the mismatch was only discoverable at container start.
Contributing factors
- Config schema and config values in separate repositories with no shared validation.
- No
startupProbe; the failure presented as a generic crash loop. - No alert on stalled rollouts — detection depended on the crash-loop alert, which fires later.
- The release ran outside the change-freeze calendar with no canary stage.
--previouslog capture was manual; two pods had already GC'd their logs.
Detection
Automated, via KubePodCrashLooping, 12 minutes after the first failure. Detection was the weakest link — a stalled-rollout alert would have fired at roughly 14:04.
Mitigation
Rollout paused, healthy ReplicaSet scaled to absorb load, then fixed forward by merging the missing config.
Resolution
Config key added; rollout completed normally.
Corrective actions
| # | Action | Owner | Due |
|---|---|---|---|
| 1 | Generate config.schema.json from application types; publish with the image | payments | 2026-09-20 |
| 2 | Add schema validation to payments-config CI | platform | 2026-09-20 |
| 3 | Add Argo CD PreSync validation hook | platform | 2026-09-13 |
| 4 | Add startupProbe and split health endpoints | payments | 2026-09-13 |
| 5 | Alert on DeploymentRolloutStuck (for: 10m) | platform | 2026-09-13 |
| 6 | Automate crash-loop log capture | platform | 2026-09-27 |
| 7 | Add single-replica canary stage to the release pipeline | platform | 2026-10-04 |
Interview Questions
- A pod is in
CrashLoopBackOff. Walk me through your first five commands and what each one rules out. kubectl logs <pod>returns nothing. What do you do next, and why?- What is the difference between exit code 137 with
Reason: OOMKilledand exit code 137 without it? - Your liveness and readiness probes both hit
/healthz, which checks the database. The database has a 30-second blip. Describe what happens to the cluster. - Argo CD has auto-sync and self-heal enabled. You run
kubectl rollout undoand the service recovers, then breaks again four minutes later. Why? - When is rolling back the wrong mitigation for a bad deploy?
- You change a ConfigMap and nothing happens. Give two reasons and the fix for each.
- How does CrashLoopBackOff's backoff behave, and why does the delay make diagnosis harder over time?
- Design a control that would have caught this failure before any pod was created. Where in the pipeline does it belong, and what does it cost?
maxUnavailable: 0kept half the capacity alive here. When would you deliberately not use it?
Key Takeaways
- A crash-looping pod is not serving errors — it is serving nothing. The customer-visible failure comes from the pods that survived. Restore their capacity first.
- Pause before you undo.
rollout pauseis non-destructive, reversible, and preserves the evidence thatrollout undodeletes. - The exit code is the fastest classifier available. Read
Last Statebefore you read logs. --previousor nothing. Current-container logs from a crash loop are usually empty.- A rollback is only safe if your migrations are. That is a policy property, not a Kubernetes feature.
- Config that the application requires but does not ship with is a latent outage. Couple the schema to the binary.
- One health endpoint for three questions turns a dependency blip into a restart storm.
Related Projects
- KubeRescue — a Go engine for autonomous Kubernetes failure detection and policy-driven auto-remediation. The detection half of this case study (identify
CrashLoopBackOff, classify by exit code, capture evidence, act under policy) is precisely the problem it addresses. - Kubernetes Platform — the GitOps, CI/CD and observability wiring that the prevention controls in this document assume exists.
Related case studies: OOMKilled Under Load
Planned: Pods Stuck in Pending · Argo CD Configuration Drift · Rollback Safety and Failed Deployments.