Investigation & Containment
Investigation
Read the evidence before you change a number. Every command names the cluster with --context (Chapter 01); on your kind cluster the same commands answer for your own develop.
Why did the container stop? Not the events - the container’s status:
kubectl --context kind-sre-control-plane -n develop get pods -l app=backend \ -o custom-columns='POD:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,LAST:.status.containerStatuses[0].lastState.terminated.reason,EXIT:.status.containerStatuses[0].lastState.terminated.exitCode'On your cluster:
RESTARTS 0,LAST <none>- nothing has died. In the story:OOMKilledand137. The reason is inlastStatebecause the container was restarted; the kubelet’s events only sayBack-off restarting failed container. Exit code 137 alone is not proof: anySIGKILLgives 137.OOMKilledsays the kernel’s OOM killer ended it - usually at the container’s own limit, but not always: a node that runs out of memory before the kubelet can evict anything gets the kernel’s node-wide OOM killer, and its victim showsOOMKilledtoo. Step 4 tells the two apart.What did it ask for, and what class is it?
kubectl --context kind-sre-control-plane -n develop get pods -l app=backend \ -o custom-columns='POD:.metadata.name,QOS:.status.qosClass,MEM_REQ:.spec.containers[0].resources.requests.memory,MEM_LIM:.spec.containers[0].resources.limits.memory,CPU_REQ:.spec.containers[0].resources.requests.cpu,CPU_LIM:.spec.containers[0].resources.limits.cpu'Burstable,64Mirequested,256Milimit -develop’s values; staging and production run with128Miand the same256Milimit. The limit is where the kernel kills it.What does it really use?
kubectl --context kind-sre-control-plane -n develop top pod -l app=backendOne number, now - around
20Mion an idle kind cluster.kubectl topshows the working set: an estimate of the memory the container cannot easily give back - its total use minus the file cache the kernel can reclaim. The limit is enforced on the container’s whole cgroup (memory.currentagainstmemory.max), and the kernel reclaims cache before it kills, so do not expect the kill exactly whentopreaches the limit; the kubelet uses the working set to rank pods when the node runs short. Read it as an estimate and a trend. A single reading says nothing about a trend; the question “a size or a leak?” needs the same number over time - the memory graph in Grafana (Chapter 11), orkubectl toprepeated under the same load.Is it the container - or the node?
OOMKilledalone does not say which: the kernel’s OOM killer leaves the same reason at the container’s own limit and when the whole node runs out. The node holds the evidence - its conditions, the pods it evicted, and aSystemOOMevent:kubectl --context kind-sre-control-plane get nodes \ -o custom-columns='NODE:.metadata.name,MEMORY_PRESSURE:.status.conditions[?(@.type=="MemoryPressure")].status,DISK_PRESSURE:.status.conditions[?(@.type=="DiskPressure")].status' kubectl --context kind-sre-control-plane get pods -A --field-selector status.phase=FailedFalse,FalseandNo resources found. An evicted pod would be listed here asFailed, withEvictedin its status - a kubelet decision for the node, not the kernel’s for one container. When the node ran out before the kubelet could evict, the kubelet records aSystemOOMevent on the node instead:kubectl --context kind-sre-control-plane get events -A --field-selector reason=SystemOOMNo resources found- no node-wide OOM. AnOOMKilledcontainer with noSystemOOMat that time, and a working set near its limit, hit its own limit; with aSystemOOM, the node did.What does the namespace allow?
kubectl --context kind-sre-control-plane -n develop describe resourcequota compute-quota kubectl --context kind-sre-control-plane -n develop describe limitrange default-container-limitsThe quota:
UsedagainstHardfor the namespace’s total -requests.memory 288Miof1Gi. The LimitRange: per container,Min 32Mi,Max 512Mi, and the defaults a container without numbers would get. In the story,1Giper container was over theMax- refused at pod creation, whatever the quota said.Would the change fit? In your clone of the platform repository - the check the pull request ran:
scripts/check-app-resources.shOne line per namespace - the worst case against the quota - and
check-app-resources: OK. With the story’s1Giit printed the two problems instead.
The root cause is not the limit and not the quota. It is memory that grows without end: the limit only made it visible - in one container, early, instead of on the whole node, later.
Containment
- Roll back the release in Git. Flux brings the previous image back (Chapter 03); the restarts stop. The limit is not touched.
- Find the growth. A working set that levels off is a size; one that climbs from the release on is a leak. Fix the code - here, a cache with a maximum size.
- Size from evidence, in
developfirst: the working set under realistic load, plus headroom. If the limit really must change, it changes in staging and production together, andcheck-app-resources.shchecks the worst case before the merge (Workflow).
A bigger number hides a leak; it does not fix it. Read the container’s status and the trend first - change a limit only for a size you have measured.
Pause and Predict: The quota in production shows requests.cpu Used 260m of Hard 1, and kubectl top shows the pods using far less. A pull request raises the backend’s CPU request from 100m to 250m - two pods would need only 300m more, and the namespace has room. Will check-app-resources.sh let it through? (It counts the backend at its HPA maximum plus the rolling-update surge.)