Documentation navigation
Pod CrashLoopBackOff
Runbook for a container that pulls its image fine but crashes on startup, triggered by the PodCrashLoopBackOff Grafana-managed alert (frontmatter alert: block in this runbook, provisioned by the runbook_alerts Terraform module → infrastructure/prod/synapse-alerts). This covers both regular containers and init containers (the or in the expr unions kube_pod_container_status_waiting_reason and kube_pod_init_container_status_waiting_reason). There is no matching Mimir-ruler alert.
For a container that cannot pull its image (ErrImagePull / ImagePullBackOff), see the sibling runbook 01-pod-imagepull.md — that is a pull failure, not a crash.
Live examples (2026-08-05)
Two real firing instances, used below to illustrate the branches:
CrashLoopBackOff—agent-apionprd-par01(production, regular container). Imagecorti.azurecr.io/ai-agents/agent-api:v2.32.0pulls fine (already present on machinein events); the container exits on startup withpanic: failed to parse storage config: env: required environment variable "AZURE_STORAGE_CONNECTION_STRING" is not set— a missing env var.CrashLoopBackOff—agent-api-init-dbondev-neu/prd-par01(init container). The init container crashes before the main container starts; the pod stays inInit:CrashLoopBackOffand never reachesRunning.
Meaning
CrashLoopBackOff is not a pull failure — the image is on the node, the container starts, and then the process inside exits repeatedly. The kubelet restarts it with exponential backoff, hence the Back-off restarting failed container event. The kubelet's waiting.message reads back-off ... restarting failed container=<container> pod=<pod>; there are no Failed to pull image events (unlike ImagePullBackOff).
kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"} (regular containers) and kube_pod_init_container_status_waiting_reason{reason="CrashLoopBackOff"} (init containers) are gauges from kube-state-metrics (deployed by the synapse-client chart, scraped on the cluster and remote-written to synapse-server Mimir). The metric is 1 while the container is in that waiting state and absent when the container is running — so == 1 filters to currently-stuck containers, and the or unions regular + init containers into one alert stream. Each stuck container becomes its own alert instance with exactly the labels the expr preserves — k8s_cluster_name, environment, namespace, pod, container, and reason. sum by (...) intentionally drops the scrape-integration labels (job from the kube-state-metrics integration, instance, source), so the alert reads as a workload problem for the stuck container — not as a problem with kube-state-metrics itself. Pre-aggregating in the expr (rather than at the alert) is what strips those labels; alert-level label transforms would keep them on the series.
An init-container crash loop is operationally distinct: the pod never reaches Running (it stays Init:CrashLoopBackOff), so the main containers never start. The diagnosis is the same — read the init container's logs — but the blast radius is the whole pod.
Impact
- The container is restarting repeatedly. If it is a main serving container, the service is degraded/down.
- An init container crash loop blocks the pod entirely — no main container starts, even if their images and config are fine.
- A misconfigured rollout (bad env var, bad config) can affect the whole fleet if it is the in-production Deployment. The 15m
forrides out a normal rolling update; a pod still stuck after 15m is a real problem.
Diagnosis
The alert labels carry k8s_cluster_name, namespace, pod, container, and environment. From the alert in Grafana, note all of them. The container label tells you which container; check kubectl describe pod to see whether it is an init container or a regular container.
1. Confirm it is a crash, not a pull failure
export KUBECONFIG=~/.kube/<cluster>.yaml # e.g. prd-par01-admin.yaml
NS=<namespace>
POD=<pod>
kubectl -n $NS describe pod $POD | sed -n '/^Events:/,$p'
Confirm the events show Back-off restarting failed container and Pulled (image already present on machine), not Failed to pull image. If there are pull errors, switch to the sibling runbook 01-pod-imagepull.md.
2. Get the container logs
The crashed process's exit reason is in the logs. For a regular container:
kubectl -n $NS logs $POD -c <container> --previous --tail=50 # the crashed instance's logs (—previous is key)
kubectl -n $NS logs $POD -c <container> --tail=50 # current attempt
For an init container crash loop (the pod shows Init:Error / Init:CrashLoopBackOff), target the init container by name:
kubectl -n $NS logs $POD -c <container> --previous --tail=50 # <container> is the init container name from the alert
# or all init containers at once:
kubectl -n $NS logs $POD --all-containers --init-containers --previous --tail=50
--previous is essential — it reads the logs of the last exited instance, which contains the crash message. Without it you get the current (restarting) attempt, which is usually empty.
3. Identify the app-level cause
The log's last lines tell you why the process exited. Common causes:
- Bad config / missing env var / missing secret — the app exits on startup validation. This is the live
agent-apicase:panic: failed to parse storage config: env: required environment variable "AZURE_STORAGE_CONNECTION_STRING" is not set— the chart/ExternalSecret did not inject the var. Confirm with:
For a missing ExternalSecret key, confirm the key exists in the source Key Vault / external secret store and that ESO has synced it (kubectl -n $NS get pod $POD -o jsonpath='{range .spec.containers[?(@.name=="<container>")].env[*]}{.name}={.value}{"\n"}{end}' kubectl -n $NS get pod $POD -o jsonpath='{range .spec.containers[?(@.name=="<container>")].envFrom[*].secretRef.name}{"\n"}{end}' kubectl -n $NS get externalsecret -o wide # is the source Secret synced?kubectl get externalsecret -n $NS). - DB migration failure — an
init-dbcontainer fails to connect to the DB or a migration is non-reversible. Check the DB is reachable from the namespace (NetworkPolicy on 5432 — see06-databases/). - Liveness probe too aggressive — the probe fails before the app is ready and the kubelet kills it. Check
kubectl describe podforLiveness probe failedand the probe'sinitialDelaySecondsvs the app's startup time. - OOM during init — the container is OOMKilled on startup. Check
kubectl describe podforOOMKilledand the memory limit vs the app's actual startup peak (kubectl top pod). - Wrong entrypoint / args — a chart override changed
command/argsto something that exits. Checkkubectl get pod $POD -o jsonpath='{.spec.containers[?(@.name=="<container>")].command}'.
This branch is usually the owning app team's domain, not platform. Route by the team label once per-team routing is wired; until then, platform triages and re-routes.
Mitigation
Step 1 — Activate PIM
Activate the required Azure Privileged Identity Management roles before touching production (MultiTenantEuOnlyAccessPimRoles / DevOpsPimRoles / Platform Team (privileged)), as in 03-kafka/01-consumer-lag.md.
Step 2 — Fix the crash
- Bad config / missing secret — fix the chart values or ExternalSecret in a PR. For a missing ExternalSecret key, confirm the key exists in the source Key Vault / external secret store and that ESO has synced it.
- DB migration failure — see
06-databases/01-cnpg-backup-failure.mdif the DB itself is down. If the migration is stuck, the owning team must fix/revert the migration; rolling back the image tag to pre-migration is the fast unblock. - Probe / OOM — adjust the chart values (liveness
initialDelaySeconds, memory limit) in a PR.
Step 3 — Unstick a stuck rollout (emergency)
If a bad rollout is taking the service down and the GitOps fix will take too long, scale the good ReplicaSet back up while the fix merges:
# Find the ReplicaSets; the bad one is at 0 desired-after-fix, the last good one is the previous:
kubectl -n $NS get rs -l app=<app>
kubectl -n $NS scale rs/<last-good-rs> --replicas=<desired>
This is a temporary hold — ArgoCD self-heal will reconcile once the PR merges. Only use this to keep the service alive; it does not fix the root cause.
Verify
- The alert clears once the container reaches
Running(thewaiting.reasonseries disappears, so== 1is no longer true) sustained for thefor: 15mwindow. Practically it clears within a few scrape intervals after the pod is healthy. kubectl -n $NS get pod $PODshowsRunningwith the containerReady(or, for an init container, the pod progresses pastInit:toRunning).kubectl -n $NS describe pod $PODshows no recentBack-off restarting failed containerevents.- For a config/secret fix, the pod env includes the previously-missing var:
kubectl -n $NS get pod $POD -o jsonpath='{range .spec.containers[?(@.name=="<container>")].env[*]}{.name}{"\n"}{end}'lists it.