Hands-on lab
Lab: A Deployment That Will Not Stay Up
A service crash-looping after a routine config change, with logs that show a normal startup and no error anywhere. Work out what is killing it.
Scenario
A payments service began crash-looping after a routine deployment. The application logs show a normal startup sequence that simply stops. There is no error, no stack trace, and the same image runs fine in staging.
What you will practise
- Read lastState.terminated rather than pod status
- Distinguish an application crash from an externally killed container
- Recognise the signature of a probe killing a slow-starting process
- Choose between startupProbe and loosened liveness settings, and justify it
The situation
At 14:05 a deployment of payments-api went out. It contained a configuration change enabling a
new fraud-scoring model. The image tag also changed, as it does on every deploy.
By 14:12 the service was down. Every replica is crash-looping.
The application team says the change is trivial and the same image works in staging.
What you have
Previous container logs
No error. No stack trace. The log just stops.
Terminated state
Events
The workload spec
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { cpu: 2, memory: 2Gi }
Stop and work it
Write down:
- What is
exitCode: 137withreason: Errorand noOOMKilledtelling you? - Do the timestamps support or contradict an application crash?
- What single fact in the evidence identifies the cause?
Working the evidence
Exit code 137 without OOMKilled
137 is 128 + 9: killed by SIGKILL. The container did not exit on its own — something killed it.
If it had been the memory limit, reason would be OOMKilled. It is Error, so the kill came
from somewhere else.
That single field eliminates “the application crashed”. You are now looking for what did the killing.
The timestamps
startedAt: 14:11:02
finishedAt: 14:11:32
──── exactly 30 seconds
Thirty seconds, consistently, across every restart. Application crashes are rarely that punctual. A fixed interval points at something on a timer.
Compare it to the liveness probe:
initialDelaySeconds: 10 probing starts at t+10
periodSeconds: 10 checks at t+10, t+20, t+30
failureThreshold: 3 three failures → kill at t+30
The probe configuration predicts a kill at exactly thirty seconds. The container died at exactly thirty seconds.
The last log line
{"msg":"loading fraud model","file":"fraud-v4.bin"}
The last thing the application did was start loading a model file — and then nothing. Not a
crash: it was still working. It had not yet reached the point of binding its HTTP listener, which
is why the probe got connection refused rather than an error response.
connection refused is the important detail. It means nothing was listening on the port. An
application that had started and then broken would return a 500 or time out. Refused means the
listener does not exist yet.
Why staging worked
The new fraud model is larger than the previous one. In staging, with a smaller dataset and a warm local cache, it loads in under ten seconds. In production it takes longer than thirty.
The application is entirely healthy. It is being killed for not finishing its startup within a window that was sized for the previous model.
The fix
Add a startupProbe. It suspends the liveness and readiness probes until the application has
started, then hands over.
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 10 # allows up to 5 minutes to start
livenessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10 # no initialDelaySeconds needed now
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5
Why not just raise initialDelaySeconds?
It would stop the crash loop, and it is the wrong fix.
initialDelaySeconds: 120 on the liveness probe means that for the whole two minutes, a
genuinely hung process is also not detected. You have traded away your ability to notice a real
hang in order to accommodate a slow start.
A startupProbe separates the two concerns: generous while starting, strict once started. That is
exactly what the three-probe design is for.
Validation
# 1. Rollout completes
kubectl rollout status deployment/payments-api -n payments --timeout=300s
# 2. Restart count stops moving -- check, wait, check again
kubectl get pods -n payments -l app=payments-api \
-o custom-columns='POD:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount'
sleep 300
kubectl get pods -n payments -l app=payments-api \
-o custom-columns='POD:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount'
# 3. Endpoints match replica count
kubectl get endpoints payments-api -n payments -o jsonpath='{.subsets[*].addresses[*].ip}' | wc -w
# 4. No new Unhealthy events
kubectl get events -n payments --field-selector reason=Unhealthy --sort-by=.lastTimestamp | tail
Then measure the actual startup time, so the failureThreshold is set from data rather than
guessed:
kubectl logs <pod> -n payments | head -1 # first log line timestamp
# compare to the first successful readiness probe
What to take away
lastState.terminated before anything else. Exit code and reason together classify most
failures in one command. 137 with OOMKilled and 137 without are different incidents.
Regular timing means something on a timer. Consistent intervals between restarts point at probes, not at application logic.
connection refused means not started. Not broken — not yet listening. That distinction
separates “still starting” from “started and failed”.
Slow startup is a configuration problem, not an application problem. When startup time changes, the probe configuration has to change with it. This is worth checking on any deploy that changes what an application loads at boot.
Verification status
This resource has not been executed end to end in a lab environment. Commands and configuration are reviewed by an engineer, but treat them as reference rather than as a tested procedure.
Author
James Joyner
Builds and operates the infrastructure layers underneath production AI systems.
James founded Inside The AI Stack to publish the kind of infrastructure and operations material he wanted while running production systems: specific, tested where it claims to be tested, and written by someone who has had to fix the thing at 3am. He works across AI infrastructure, private cloud, and platform engineering, and reviews every technical resource published here before it is marked as verified.
- AI infrastructure
- OpenStack operations
- Kubernetes
- Terraform
- Linux systems engineering
- Observability
Continue from here
Related resources chosen because they are the next thing you would actually need — not because they share a keyword.
Kubernetes Workload Troubleshooting
A single diagnostic path for workloads that will not schedule, will not stay up, or keep getting evicted — driven by events and previous logs rather than guesswork.
Production Kubernetes Operations
The operational settings that decide whether a workload survives a node failure or a rollout: requests and limits, the three probes, disruption budgets, and scheduling.
CrashLoopBackOff Triage
A structured procedure for a pod that will not stay running, covering exit-code classification, probe-induced restarts, OOM kills, and validation that the fix held.
Newsletter
Inside The AI Stack Brief
A practical weekly briefing on AI engineering, infrastructure, production operations, and the technologies powering the AI stack.
One email a week. No sponsorship placements inside the technical sections. Unsubscribe in one click.