stuff I figured out the hard way

Key points:

  • A container is a process with namespaces (what it sees) and cgroups (what it uses).
  • Over the memory limit: killed (OOMKilled, exit 137). Over the CPU limit: throttled.
  • Never point a liveness probe at a dependency.
  • Endpoint removal and SIGTERM happen at once - delay shutdown with a preStop hook.
  • Pods without requests are BestEffort: evicted first, invisible to the scheduler.

A container is a process with limits

The kernel has no “container” object - just a process started with:

  • Namespaces - what it sees: process list (pid), network stack (net), mounts (mnt), hostname (uts), IPC, optionally user IDs (user).
  • cgroups - what it uses: CPU, memory, process count, I/O.
  • A root filesystem from the image, plus dropped capabilities and seccomp filters.
flowchart TB
    KUBELET[kubelet] -->|CRI over gRPC| CONTAINERD["containerd / CRI-O<br/>images, snapshots"]
    CONTAINERD --> RUNC["runc<br/>creates namespaces + cgroups,<br/>starts the process"]
    RUNC --> PROC[Your process]

What a pod is

Containers in a pod share the network namespace of a tiny pause container that starts first. So they:

  • share one IP and talk over localhost
  • can’t both listen on the same port
  • can share volumes, but otherwise have separate filesystems

That’s why sidecars (proxies, log shippers) work: same network, same lifecycle.

Requests and limits become cgroups

SettingWhat the kernel does
CPU requestRelative weight. Under contention, CPU is shared in proportion to requests
CPU limitHard quota per 100 ms period. Use it up and the container is throttled until the next period - even on an idle node
Memory requestUsed by the scheduler and eviction ranking. Not enforced by the kernel
Memory limitHard cap. Exceed it and the kernel kills the process: OOMKilled, exit code 137

Default: set memory limits; many teams skip CPU limits. CPU is compressible - starved means slower. Memory isn’t - over means killed. See the app that was starved of memory its whole life.

QoS classes

Every pod gets a class from its requests and limits:

ClassRuleUnder memory pressure
GuaranteedEvery container has CPU and memory requests equal to limitsKilled last
BurstableAt least one request or limit set, but not GuaranteedIn between
BestEffortNo requests or limits at allKilled first
kubectl get pod my-pod -o jsonpath='{.status.qosClass}'

Eviction vs OOM kill

  • OOM kill - the kernel kills a process over its own memory limit, or picks a victim when the node runs out. The container restarts in place.
  • Eviction - the kubelet sees the node low on memory, disk or PIDs and evicts whole pods before the kernel acts. Order: pods over their requests first, then by priority and by how far over. Evicted pods reschedule elsewhere only if a controller owns them.

Probes

ProbeQuestionOn failure
StartupHas it finished starting?Keep waiting; other probes are paused until it passes
ReadinessShould it get traffic right now?Removed from Service endpoints. Not restarted
LivenessIs it stuck beyond repair?Container restarted

Never point liveness at a dependency. If the database is down and every liveness check calls it, every pod restarts at once and still can’t reach it. Check only the process itself.

How a pod shuts down

sequenceDiagram
    participant API as API server
    participant EP as EndpointSlice controller
    participant K as Kubelet
    participant C as Container
    API->>API: Pod gets deletionTimestamp
    par in parallel
        API->>EP: remove from endpoints
    and
        API->>K: pod terminating
        K->>C: run preStop hook
        K->>C: SIGTERM
    end
    K->>C: SIGKILL after terminationGracePeriodSeconds (30s)

Delay shutdown with a preStop hook and finish in-flight work on SIGTERM. Endpoint removal and SIGTERM happen at the same time, and load balancers and kube-proxy on other nodes notice late. An app that exits immediately fails the requests still arriving.

spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: app
      lifecycle:
        preStop:
          exec:
            command: ["sleep", "10"]   # newer clusters: sleep: {seconds: 10}

preStop time counts toward the grace period - leave room for the app’s own shutdown.

Exit codes and states

You seeMeans
Exit code 137SIGKILL (128 + 9): OOM kill, or grace period ran out
Exit code 143SIGTERM (128 + 15): normal shutdown
Exit code 1 (or other)The app failed. Check its logs
CrashLoopBackOffKeeps crashing. Restarts back off from 10 s, doubling up to 5 minutes
ImagePullBackOffWrong image name or tag, or missing registry credentials
CreateContainerConfigErrorA referenced Secret or ConfigMap key doesn’t exist
kubectl logs my-pod --previous                    # logs from the crashed container
kubectl describe pod my-pod | grep -A5 'Last State'

Stronger isolation

Run untrusted code under another runtime via a RuntimeClass. All containers on a node share one kernel; a kernel exploit escapes them all.

  • gVisor (runsc) - a user-space kernel intercepts syscalls. Strong isolation, some performance cost, not every syscall supported.
  • Kata Containers - each pod in a lightweight VM with its own kernel. VM-level isolation, more memory per pod.

Gotchas

  • CPU throttling at low average usage. The quota is enforced every 100 ms, so a container averaging 20% of its limit still gets throttled in bursts. Watch container_cpu_cfs_throttled_periods_total.
  • Runtimes ignore container limits. Older JVMs size the heap from node memory and get OOM killed; Go needs GOMEMLIMIT to GC before the limit, and before Go 1.25 sized GOMAXPROCS from every node CPU. Set these explicitly.
  • BestEffort pods go first. Without requests a pod is evicted first and the scheduler thinks it uses nothing. Set requests.
  • Readiness failing everywhere at once. If every pod fails readiness together, the whole Service leaves rotation. Keep readiness about this pod’s ability to serve.
  • PID 1 gets no default signal handling. A shell-script entrypoint may never forward SIGTERM, so every shutdown waits the full grace period and ends in SIGKILL. Use exec in scripts, or a tiny init like tini.