Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

advancedkubernetes-pod-lifecycle~40 min

CrashLoopBackOff

Reported symptoms

  • Two of six `billing` Pods are in CrashLoopBackOff; the other four are Running and serving traffic normally
  • All six belong to the same ReplicaSet and run the same image digest
  • `kubectl rollout history deployment/billing` shows revision 14, unchanged for 26 days - nobody deployed anything
  • The two failing Pods are the two that were rescheduled during node maintenance last night
  • An engineer deleted one of the healthy Pods to "reset" it, and its replacement joined the crash loop
  • Error rate is roughly a third of billing requests, and it got worse after the manual Pod delete

Evidence

  • · `kubectl describe pod` on a failing Pod shows the image pulled and the container started, then Terminated with Reason Error and Exit Code 1
  • · Restart Count is climbing and the event is `Back-off restarting failed container billing`
  • · `kubectl logs --previous` on a failing Pod shows a fatal configuration parse error naming an unknown field
  • · `kubectl exec` into a healthy Pod and reading the mounted config file shows a file that does not contain that field
  • · `kubectl get configmap billing-config -o yaml` does contain the field
  • · `kubectl get configmap billing-config -o jsonpath={.metadata.managedFields[*].time}` shows a write 21 days ago
  • · The Deployment mounts one ConfigMap key with `subPath`, at a different filename
  • · The change log for the last 48 hours contains one entry: a node drained for a kernel patch
Diagnosis and resolutionclick to reveal

Root cause

The configuration that breaks the application was applied 21 days before anything broke. A ConfigMap edit added a field the running application version does not accept, and because the Pod template did not change, no rollout was triggered and no Pod restarted. The four surviving replicas were started before the edit and had already read their configuration into memory; a process reads its config file at startup and does not read it again, so they carried on unaffected and would have done indefinitely. The two failing replicas are new Pods created when a node was drained for a kernel patch, and a new Pod projects the ConfigMap as it is now. The `subPath` mount is what made the divergence visible on disk as well as in memory: the kubelet does not update a `subPath` mount when the ConfigMap changes, so the healthy Pods are serving from a config file that no longer matches the object it came from. The drain did not cause this outage; it revealed it. Deleting a healthy Pod produced a third crasher for the same reason, which is why the fault appeared to be spreading when in fact the operator was converting known-good Pods into broken ones one at a time.

Remediation

Stop making it worse first: do not delete, drain, scale or restart anything further, because every Pod restart converts a working replica into a failing one and the four survivors are the only thing keeping the service up. The restore is to make the current ConfigMap valid for the running application version - revert the added field to the state the four healthy Pods are demonstrably running under - and then deliberately recreate the failing Pods so they project the corrected configuration. Reverting the ConfigMap is the right direction rather than rolling the Deployment forward to a version that understands the field, because a rollout replaces all six Pods and would turn a partial outage into a total one if the new version has any other problem. Holding is a real option and should be recorded as one: if the field was added for a release that ships this week, it is legitimate to leave the ConfigMap alone, run at four replicas, block all node maintenance and all Pod deletion in that namespace, and fix it in the release - provided somebody owns that decision, the freeze is communicated, and it has an end time. What is not legitimate is doing nothing without saying so, because the next eviction chooses for you.

Verification

All six Pods Running and Ready is the weakest possible check here and it will pass for the wrong reason, because four of them never restarted. The verification that means something is to delete one healthy Pod on purpose and watch its replacement reach Ready: until a Pod that starts from the current ConfigMap survives startup, nothing has been proven about the current ConfigMap. Confirm the file the container actually sees matches the object it came from by reading the mounted path inside a freshly created Pod and comparing it with `kubectl get configmap -o yaml`. Watch restart counts stay flat for at least one backoff interval - the kubelet backoff reaches five minutes, so a Pod that has been quiet for sixty seconds has proven very little. Finally confirm the application is serving rather than merely running: error rate back to baseline, and the readiness endpoint returning success from all six replicas.

Prevention

The defect is not the bad field; it is that a configuration change could reach production and take effect at an unpredictable time chosen by an unrelated event. Make config changes roll out like code: use a `configMapGenerator` hash suffix or a checksum annotation on the Pod template so that editing config changes the Pod template and triggers a normal, observable, revertible rollout. That has a cost worth stating - config can no longer be used as a hot-tuning knob, and teams that genuinely need runtime tuning should use a reload mechanism the application supports rather than an in-place edit nobody observes. Mount ConfigMaps as a directory rather than with `subPath`, so the file on disk at least tracks the object. Validate configuration against the running application version in CI, since a field the binary does not accept is a defect that a parser can catch for nothing. Consider `immutable: true` for release-baked config, which turns an in-place edit into an API error. And treat a workload that has not restarted in weeks as carrying unknown latent divergence: a scheduled rolling restart converts a future surprise into a routine event during working hours.

Reported symptoms

The billing error-rate alert fires at 08:05. Six replicas, two of them in CrashLoopBackOff, four Running:

NAME                       READY   STATUS             RESTARTS        AGE
billing-6c9d84f7b5-2xk4m   1/1     Running            0               26d
billing-6c9d84f7b5-4tqzn   0/1     CrashLoopBackOff   9 (2m14s ago)   6h
billing-6c9d84f7b5-8hzpl   1/1     Running            0               26d
billing-6c9d84f7b5-j7wvc   1/1     Running            0               26d
billing-6c9d84f7b5-q2rmb   0/1     CrashLoopBackOff   9 (1m52s ago)   6h
billing-6c9d84f7b5-vd8kf   1/1     Running            0               26d

Everything about this list says the six Pods are interchangeable. They share a ReplicaSet, so they share a Pod template. They share an image digest. They share a node pool. Two of them are broken and four are not.

The change log offers nothing useful. kubectl rollout history deployment/billing -n prod shows revision 14, created 26 days ago. The application team has not merged to the release branch this week. The only entry in the platform change log for the last 48 hours is a node drained at 02:10 for a kernel patch - and the ages line up: the two failing Pods are six hours old, the four healthy ones are 26 days old.

At 08:20, before anyone had read the logs, an engineer deleted billing-6c9d84f7b5-2xk4m on the theory that a stuck Pod might clear. Its replacement went into CrashLoopBackOff within ninety seconds. The incident channel now believes the fault is spreading, and the natural next move - restarting the rest - is the worst available action.

Evidence provided

Read-only / Safethe container starts and exits immediately
$ kubectl describe pod billing-6c9d84f7b5-4tqzn -n prod | sed -n '/Containers:/,/Conditions:/p'
Containers:
billing:
Image:          registry.example.com/billing@sha256:9b2f...c41a
State:          Waiting
Reason:       CrashLoopBackOff
Last State:     Terminated
Reason:       Error
Exit Code:    1
Started:      Tue, 11 Aug 2026 08:22:41 +0000
Finished:     Tue, 11 Aug 2026 08:22:41 +0000
Ready:          False
Restart Count:  9

Illustrative output

Read-only / Safethe image is fine; the container is not
$ kubectl get events -n prod --field-selector involvedObject.name=billing-6c9d84f7b5-4tqzn --sort-by=.lastTimestamp
LAST SEEN   TYPE      REASON      MESSAGE
6h          Normal    Scheduled   Successfully assigned prod/billing-6c9d84f7b5-4tqzn to node-04
6h          Normal    Pulled      Container image already present on machine
6h          Normal    Created     Created container billing
6h          Normal    Started     Started container billing
2m          Warning   BackOff     Back-off restarting failed container billing

Illustrative output

Read-only / Safethe application says exactly what is wrong
$ kubectl logs billing-6c9d84f7b5-4tqzn -n prod -c billing --previous
2026-08-11T08:22:41Z INFO  billing 3.4.1 starting
2026-08-11T08:22:41Z INFO  loading configuration from /etc/billing/billing.yaml
2026-08-11T08:22:41Z FATAL config: /etc/billing/billing.yaml: unknown field retry_budget

Illustrative output

Read-only / Safea healthy Pod has no such field in its config file
$ kubectl exec -n prod billing-6c9d84f7b5-8hzpl -c billing -- grep -c retry_budget /etc/billing/billing.yaml
0
command terminated with exit code 1

Illustrative output

Read-only / Safethe ConfigMap does have it
$ kubectl get configmap billing-config -n prod -o jsonpath={.data.config} | grep -n retry_budget
14:  retry_budget: 0.25

Illustrative output

Read-only / Safethe last write to this object, 21 days ago
$ kubectl get configmap billing-config -n prod --show-managed-fields -o yaml | grep -E 'manager:|time:'
    manager: kubectl-client-side-apply
time: "2026-07-21T14:32:08Z"

Illustrative output

Read-only / Safehow the config reaches the container
$ kubectl get deployment billing -n prod -o yaml | sed -n '/volumeMounts:/,/volumes:/p'
        volumeMounts:
- mountPath: /etc/billing/billing.yaml
name: config
subPath: config
volumes:
- configMap:
name: billing-config
name: config

Illustrative output

Work the evidence before reading on

The application is telling you precisely what it objects to, so the interesting question is not what is wrong with the config. It is why four Pods do not care.

  1. A healthy Pod’s config file does not contain retry_budget. The ConfigMap does. Both statements are true right now, and the healthy Pod mounts that ConfigMap. What can make a mounted file differ from the object it is mounted from?
  2. The last write to the ConfigMap was 21 days ago. The oldest Pods are 26 days old and the failing ones are 6 hours old. Sort those three numbers and say which Pods were created before the write and which after.
  3. Deleting a healthy Pod produced another crasher. If the fault were spreading between Pods, what would you expect a deletion to do? Is there any reading of this evidence in which the delete was the cause rather than the reveal?
  4. rollout history shows nothing for 26 days. What kind of change to a workload does not appear in rollout history?

Before continuing: six Pods, one ConfigMap, one image. Name the event that broke the service and the date it happened - they are not the same date.

Root cause

1. The breaking change landed 21 days before the outage

Someone added retry_budget to billing-config on 21 July, ahead of a release that was going to use it. The application version running in production, 3.4.1, rejects unknown fields when it parses its configuration and exits non-zero.

That edit should have broken production immediately, and it did not, for a reason so ordinary it is easy to look straight past: editing a ConfigMap does not restart anything. The Deployment’s Pod template did not change, so there was no new ReplicaSet, no rollout, no revision, and no event. Six processes that had already read their configuration at startup continued to run on what they had read. A process does not re-read a file it is not watching, and this one is not watching.

So the cluster entered a state where the running configuration and the declared configuration disagreed, and stayed there for three weeks with no observable symptom of any kind.

2. The drain converted latent divergence into an outage

At 02:10 a node was drained for a kernel patch. Two billing Pods on that node were evicted, and the ReplicaSet created two replacements on other nodes.

A new Pod projects the ConfigMap as it exists at that moment. Those two Pods therefore started with retry_budget present, parsed it, rejected it, and exited - repeatedly, into the kubelet’s exponential backoff, which is what CrashLoopBackOff means.

The drain is not the cause. It is an entirely routine operation that would have been safe on any day before 21 July, and the same reveal would eventually have come from a node failure, an eviction under memory pressure, a cluster autoscaler scale-down, or a scale-up adding a seventh replica. The change log looks empty because the change that mattered was not the kind of change anybody logs.

3. subPath is why the healthy Pods’ files are stale too

This is the piece of evidence that makes the diagnosis land, and it deserves to be stated exactly.

The Deployment mounts a single ConfigMap key, config, with subPath, onto the filename /etc/billing/billing.yaml that the application insists on - which is the one genuinely good reason to reach for subPath. The kubelet does not update a subPath mount when its source ConfigMap changes. A directory mount is refreshed within the kubelet’s sync period; a subPath mount is resolved once and left alone.

That is why grep retry_budget inside a healthy Pod finds nothing while the same grep against the ConfigMap finds it on line 14. Without subPath, the file inside the healthy Pods would have been updated weeks ago and the process would still have been running happily on what it read at startup - the outage would have been identical and the disk evidence would have been gone. subPath did not cause this incident. It is the reason you can see it.

4. The delete did not spread anything

Deleting a healthy Pod created a new Pod. A new Pod reads the current ConfigMap. The current ConfigMap is the broken one. The engineer converted a working replica into a failing one, and the service went from four healthy replicas to three.

Under this failure mode every restart is a one-way door. That reframes the entire response: the four old Pods are not “the ones that have not failed yet”, they are the only remaining instances of a configuration that works, and they are irreplaceable until the ConfigMap is fixed.

Resolution

  1. Freeze anything that can restart a Pod in this namespace: pause the HPA or note its current floor, suspend node maintenance, and tell the incident channel explicitly that kubectl delete pod and rollout restart are forbidden until the ConfigMap is fixed. This is the first action because the failure mode punishes exactly the reflexes people have.
  2. Capture the working configuration while it still exists: kubectl exec -n prod <healthy-pod> -c billing -- cat /etc/billing/billing.yaml > /tmp/billing-running.yaml. If the last four Pods are lost before the ConfigMap is corrected, this file is the only record of what production was actually running.
  3. Diff it against the object: kubectl get configmap billing-config -n prod -o jsonpath={.data.config} > /tmp/billing-declared.yaml and diff /tmp/billing-running.yaml /tmp/billing-declared.yaml. The diff is the incident in one screen, and it belongs in the write-up.
  4. Decide between reverting the config and rolling the application forward. Reverting affects only the failing Pods and leaves the four survivors untouched; rolling forward to a version that accepts the field replaces all six and converts a partial outage into a full deployment under incident conditions. Revert unless the field is load-bearing for something already live.
  5. Decide explicitly whether to hold. Running at four replicas with restarts frozen is a legitimate position if the release that needs the field ships within hours - but only with a named owner, a stated risk (any eviction costs a replica), and an end time. Record the decision; an unrecorded hold is just a delay with better manners.
  6. Revert the field in the source repository first, not in the cluster. A kubectl edit fixes production and leaves the next GitOps sync or kubectl apply to put it straight back.
  7. Apply the corrected ConfigMap and confirm the object no longer contains the field.
  8. Recreate the failing Pods deliberately: kubectl delete pod on the two crashers only. They are already contributing nothing, so this is the one restart that costs you nothing, and it is what makes them project the corrected configuration.
  9. Watch one replacement Pod through to Ready before touching the second, so that if the revert was wrong you learn it while three healthy Pods are still serving.
  10. Only once the service is stable, fix the mechanism: add a checksum annotation or a configMapGenerator hash to the Pod template so config edits produce a rollout, and change the mount from subPath to a directory. Both are ordinary changes that deserve an ordinary review, not incident-time edits.

Verification

  1. The failing Pods recovered. Both replacements reach 1/1 Running with Restart Count 0, and kubectl logs shows the startup line followed by the application serving rather than a fatal line.
  2. A newly created Pod survives startup. Delete one of the old healthy Pods on purpose and watch its replacement reach Ready. Until a Pod that started from the current ConfigMap is healthy, nothing has been verified - and skipping this step is what leaves the same landmine armed.
  3. The file matches the object. Read /etc/billing/billing.yaml inside a freshly created Pod and diff it against kubectl get configmap billing-config -o jsonpath output. They must be identical; before the fix they were not.
  4. Restart counts stay flat for longer than the backoff. The kubelet backoff grows to five minutes, so watch for at least ten before calling it stable. A Pod quiet for sixty seconds has proven almost nothing.
  5. The service is serving, not merely running. Error rate returns to its pre-incident baseline and the readiness endpoint succeeds from all six replicas. Ready is a claim by the probe; the error rate is the claim by the users.
  6. The mechanism change works. After adding the checksum annotation, make a trivial config edit in a non-production environment and confirm it produces a new Deployment revision and a normal rolling update. If it does not, the annotation is decorative.
  7. The check can fail. In the same non-production environment, apply a config the running version rejects and confirm the rollout stops with unavailable replicas rather than silently arming a future incident. This is the whole point of the change.

Prevention

  • Make config changes roll out. A configMapGenerator hash suffix or a checksum annotation on the Pod template turns a ConfigMap edit into a Pod template change, which means a revision, a rolling update, a health gate and a rollout undo. Everything this incident lacked follows from that one change.
  • Accept the cost of that honestly. Once config edits trigger rollouts they are no longer a hot-tuning knob, and a team that genuinely needs runtime tuning needs a reload path the application supports - a watched file, a SIGHUP handler, or a reloader sidecar - rather than an in-place edit nobody observes.
  • Mount ConfigMaps as directories. subPath freezes the file at container creation and hides the divergence from anyone who looks. Use it only when the application demands an exact filename in a directory it also writes to, and write down why.
  • Validate config against the binary in CI. A field the application rejects is catchable by running the application’s own config parser in the pipeline. This is cheap, deterministic, and would have failed the pull request in July.
  • Consider immutable: true for release-baked config. It converts an in-place edit into an API error, which forces the change through the release path where it belongs.
  • Restart on a schedule. A Deployment that has not restarted in 26 days is carrying an unknown quantity of latent divergence between what it is running and what is declared. A periodic rolling restart during working hours turns that into a routine, observed event instead of something a node failure discovers at 02:10.
  • Alert on the shape, not just the symptom. Pods of one ReplicaSet in mixed states, with a large age gap between the healthy and the failing, is a specific and unusual signal. It says “new Pods cannot start” long before the error rate does.