Kubernetes Debugging in Production
Kubernetes debugging is not hard once you have a repeatable mental model. The problem is that most engineers learn it reactively — something breaks in production, they grep through logs for an hour, stumble on the answer, and move on without internalizing the pattern. The next incident feels just as novel.
This post is an attempt to systematize the investigation. Every Kubernetes failure mode falls into one of six categories: the pod crashes immediately on startup, the pod gets OOMKilled, the pod never schedules, DNS resolution fails, network policies block traffic, or TLS certificates are expired or misconfigured. Each category has a canonical set of commands and a specific thing to look for. Once you know the flowchart, most incidents resolve in under ten minutes.
The commands below assume kubectl with appropriate cluster access. Everything that works in staging works in production — the difference is how much pressure you feel while running it.
The Five-Step Mental Model
Before going through each failure category, the general sequence that applies to every broken pod:
1. Status — kubectl get pod <name> -o wide
What state is the pod in? The status column — Pending, CrashLoopBackOff, OOMKilled, Running — tells you which category you are in before you look at anything else. The -o wide flag adds the node name and pod IP, which matter more than you would expect.
2. Events — kubectl describe pod <name>, Events section
The events timeline is the most information-dense output in Kubernetes. It records every scheduling decision, image pull attempt, container start, kill, and back-off. Read it chronologically. It tells you what Kubernetes did, in order, and usually explains why things went wrong.
3. Logs — kubectl logs <name> --previous
What did the application output before it died? The --previous flag is the one you need in a crash loop; it shows the last completed container run rather than the current (empty or truncated) one.
4. Describe — the rest of kubectl describe pod
Verify the configuration: image, resource requests and limits, probes, volume mounts, environment variables, node affinity, tolerations. Configuration errors are invisible until you look.
5. Node — kubectl describe node <name>
If steps 1–4 do not explain the failure, the problem is beneath the pod layer. Node conditions (memory pressure, disk pressure), allocated resources, and node-level events are where to look.
The split between infrastructure problems and application problems runs through step 5. Signs it is infrastructure: multiple pods failing simultaneously, the pod never leaves Pending, or node conditions are not Ready. Signs it is application: the pod schedules and pulls the image, then immediately crashes or produces error output.
CrashLoopBackOff
CrashLoopBackOff means the container is exiting repeatedly and Kubernetes is applying an exponential back-off before restarting it. The back-off starts at 10 seconds and doubles — 10s, 20s, 40s, 80s, 160s — capping at 5 minutes. By the time you are looking at a pod with 15 restarts, the gap between crashes and restarts is 5 minutes. The application may only be alive for a few seconds in each cycle.
Reading the exit code
The exit code is the first thing to check:
|
|
Or read it from kubectl describe pod in the Last State block:
Last State: Terminated
Reason: Error
Exit Code: 137
Started: Fri, 23 May 2026 10:32:01 +0000
Finished: Fri, 23 May 2026 10:32:15 +0000
Exit code meanings:
| Code | Signal | Meaning |
|---|---|---|
| 0 | — | Clean exit; not a crash |
| 1 | — | Generic application error; check logs |
| 2 | — | Shell builtin misuse; bad entrypoint script |
| 127 | — | Command not found; wrong entrypoint path |
| 137 | SIGKILL | OOM or explicit kill; see OOMKilled section |
| 139 | SIGSEGV | Segmentation fault; memory access violation |
| 143 | SIGTERM | Application did not handle graceful shutdown |
| 255 | — | Entry point script failure before app started |
Exit codes 137 and 143 are the most common in production. Exit 137 is almost always either OOMKilled (the kernel killed the process) or a runaway container killed by a liveness probe. Exit 143 means the container received SIGTERM during a graceful shutdown window and did not exit in time—or the liveness probe declared the container unhealthy and triggered a SIGTERM.
Logs from the last crash
|
|
If the container exits before writing anything useful, the logs will be empty. In that case, check whether the problem is at entrypoint evaluation time rather than application startup—a missing environment variable, a missing secret mount, or a wrong binary path. The describe pod output will show the exact command being run under Containers → Command.
Health probe timing
A common pattern for spurious CrashLoopBackOff on pods that otherwise work: the liveness probe fires before the application has finished initializing and repeatedly kills the container. The application starts normally, handles no traffic, then gets killed 30 seconds in because the probe decided it was unhealthy. Logs show a clean startup. The application never panicked. The pod just dies.
Kubernetes 1.18 added the startupProbe for exactly this scenario. It runs exclusively during startup, and once it succeeds, the liveness probe takes over. Until the startup probe succeeds, the liveness probe does not fire:
|
|
The startupProbe prevents the liveness probe from interfering during slow starts. The initialDelaySeconds field on livenessProbe is the older alternative; startupProbe is the correct tool for this problem.
Ephemeral debugging
When the container crashes too quickly to exec into, use an ephemeral debug container that shares the pod’s namespaces but runs a separate image:
|
|
From inside the ephemeral container, you can inspect the filesystem at /proc/<pid>/root, check environment variables, and test whether expected files and commands are present. The ephemeral container shares the pod’s network namespace, so you can also test connectivity from the pod’s IP.
OOMKilled
OOMKilled means the Linux kernel’s OOM killer terminated the process because the container’s cgroup memory limit was exceeded. Exit code 137 and Reason: OOMKilled in kubectl describe pod confirm it:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Fri, 23 May 2026 14:02:00 +0000
Finished: Fri, 23 May 2026 14:02:31 +0000
Note the distinction: Reason: OOMKilled versus Reason: Error with exit code 137. A SIGKILL sent for a reason other than OOM (a failing liveness probe, an external kill -9) also produces exit code 137 but shows Reason: Error. Look at the Reason field, not just the exit code.
Resource visibility
|
|
From inside a running container, the actual cgroup memory usage (not the inflated value top may show):
|
|
The top command inside a container can lie — it reads from /proc/meminfo which on some kernel configurations reflects the node’s total memory, not the container’s limit. Trust the cgroup values.
QoS classes and eviction order
Kubernetes assigns one of three Quality of Service classes to every pod based on its resource spec. When a node is under memory pressure and must evict pods, it evicts in BestEffort → Burstable → Guaranteed order:
Guaranteed: requests equal limits for all containers. These pods are evicted last and are never OOMKilled due to node-level pressure (only if they exceed their own limits).
|
|
Burstable: at least one container has a request or limit, but requests do not equal limits. Evicted second.
|
|
BestEffort: no requests or limits set on any container. Evicted first. Never use this for anything stateful or latency-sensitive.
The common mistake is setting limits without requests. This looks like Guaranteed to the scheduler (it sees a resource specification) but Kubernetes actually creates a Burstable pod if requests are absent. Set both, and set them based on observed usage.
JVM containers
Java applications are the most frequent OOMKilled offenders because the JVM manages its own heap separately from the OS, and without container-awareness, it will allocate heap based on the total node memory rather than the container limit. A pod with a 512 MiB limit on a 64 GiB node will have the JVM try to allocate a multi-gigabyte heap and immediately get killed.
The fix is two flags:
-XX:+UseContainerSupport # Read cgroup limits instead of node memory
-XX:MaxRAMPercentage=75.0 # Allocate up to 75% of cgroup limit as heap
UseContainerSupport is available from Java 8u191, Java 10, and all Java 11+ builds. It is not enabled by default in older Java 8 releases. Without it, -Xmx is calculated relative to node memory.
MaxRAMPercentage=75.0 leaves 25% of the container’s memory limit for the JVM’s off-heap needs: metaspace, code cache, direct buffers, and native libraries. Setting -Xmx too close to the limit (e.g., -Xmx490m on a 512 MiB container) leaves no room for these and results in OOMKilled despite having set a limit explicitly.
Vertical Pod Autoscaler for right-sizing
VPA in recommendation-only mode observes actual usage and suggests correct requests and limits without making changes:
|
|
After a few days of traffic, inspect the recommendations:
|
|
The output includes lowerBound, target, and upperBound for each container. Use target for requests and upperBound (or 1.2 × target) for limits. This eliminates the guesswork from initial sizing and catches gradual memory growth before it causes incidents.
Pending Pods
A pod in Pending state has not been scheduled to any node. The scheduler has evaluated all nodes and rejected all of them, or it is waiting for a dependency (a PVC, an init container) before it can proceed.
Reading the scheduler’s rejection reason
|
|
The Events section will contain a FailedScheduling event with a message that looks like:
0/5 nodes are available: 2 Insufficient cpu, 3 node(s) had taint {dedicated: worker}, that the pod didn't tolerate.
Read this as: “the scheduler evaluated 5 nodes. 2 failed because they do not have enough CPU. 3 have a taint this pod does not tolerate.” The numbers add up to more than 5 because multiple failure reasons can apply to the same node.
Common rejection messages and what they mean:
| Message | Cause | Fix |
|---|---|---|
Insufficient cpu |
Node CPU allocations are exhausted | Scale node group or reduce pod CPU request |
Insufficient memory |
Node memory allocations are exhausted | Same |
node(s) had taint ... that the pod didn't tolerate |
Pod needs a toleration | Add tolerations to pod spec |
node(s) didn't match Pod's node affinity |
nodeSelector or affinity has no matching nodes | Check node labels with kubectl get nodes --show-labels |
0/5 nodes available: 5 node(s) were unschedulable |
All nodes are cordoned | kubectl uncordon <node> or investigate node health |
unbound immediate PersistentVolumeClaims |
PVC is not bound to a PV | See PVC section below |
Node resource exhaustion
|
|
When CPU or memory requests are at 95%+ of node capacity, new pods cannot schedule even if the node appears to have headroom in kubectl top node (which shows actual usage, not allocated requests). The scheduler respects requests, not actual utilization.
Taints and tolerations
|
|
A NoSchedule taint prevents pods without the matching toleration from scheduling. A NoExecute taint additionally evicts pods already running on the node. To run on a tainted node, the pod needs:
|
|
PVC binding failures
|
|
For dynamic provisioning, check that the CSI driver pods are running:
|
|
DNS Failures
Every pod’s /etc/resolv.conf is injected by kubelet at startup:
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5 edns0
The nameserver is the ClusterIP of the kube-dns service. The search domains are appended to unqualified names before resolution. The ndots:5 option means any name with fewer than 5 dots is treated as relative and tried against each search domain before being tried as an absolute name.
This has an unintuitive performance consequence: a query for api.github.com (2 dots, fewer than 5) is tried as api.github.com.default.svc.cluster.local, then api.github.com.svc.cluster.local, then api.github.com.cluster.local, then api.github.com (the actual target). Each failed search domain query hits CoreDNS and possibly an upstream resolver. Under load, this creates measurable DNS latency. The fix is to always use FQDNs for external names (trailing dot: api.github.com.) or to lower ndots in the pod’s dnsConfig.
Testing DNS from inside a pod
|
|
The FQDN format for Kubernetes services is <service-name>.<namespace>.svc.cluster.local. Cross-namespace service calls require the full qualified name; the short form only works within the same namespace.
CoreDNS not running
|
|
Network policy blocking port 53
This is the most common DNS failure after default-deny policies are applied. The pods can’t resolve names because the egress rule to kube-dns on port 53 (both UDP and TCP) was not included:
|
|
Both UDP and TCP on port 53 are required. DNS uses UDP for normal queries and falls back to TCP for responses larger than 512 bytes (common with DNSSEC or large TXT records).
CoreDNS configuration
The CoreDNS ConfigMap in kube-system controls forwarding and caching:
|
|
The Corefile inside typically looks like:
.:53 {
errors
health {
lameduck 5s
}
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
cache 30
loop
reload
loadbalance
}
The forward directive controls where non-cluster DNS queries go. The cache 30 line caches responses for 30 seconds. If you are seeing DNS failures under high load, increasing the cache TTL or adding additional CoreDNS replicas is more effective than tuning application retry logic.
Network Policy Blocks
Network policies are enforced by the CNI plugin, not by Kubernetes itself. If your CNI does not support NetworkPolicy (Flannel without a policy controller, for example), kubectl get networkpolicy will show your policies but they will have no effect. Confirm policy enforcement is active before assuming network policies are responsible for a connectivity failure.
Checking what policies apply
|
|
The critical fields in the describe output:
Pod Selector: app=my-app <- Which pods does this policy govern?
Allowing ingress traffic from:
Pods Selectors: app=frontend <- Only these pods can send traffic in
Ports: 8080/TCP
Allowing egress traffic to:
Ports: 5432/TCP <- Only outbound to port 5432 allowed
An empty Pod Selector ({}) means the policy applies to all pods in the namespace.
The default-deny pattern
Many security-conscious setups apply a default-deny policy to a namespace before adding allow rules:
|
|
This policy allows no traffic at all — neither inbound nor outbound — from any pod in the namespace. Once it is applied, every connection must be explicitly permitted. If pods suddenly cannot reach anything after a namespace migration, this policy is usually the reason.
Testing connectivity directly
|
|
The nicolaka/netshoot image is the standard Kubernetes network debugging image. It runs in the target pod’s network namespace, so all connectivity tests reflect what the application container can actually reach.
From inside netshoot:
|
|
CNI-specific tooling
Cilium: The hubble CLI provides flow-level visibility into what traffic was allowed and dropped:
|
|
Calico: calicoctl shows Calico-native policies that may not appear in kubectl get networkpolicy (GlobalNetworkPolicy resources):
|
|
The forgotten egress rules
The most common network policy mistake in practice is applying ingress rules without the corresponding egress rules. A pod that is correctly allowed to receive traffic from the frontend cannot make outbound calls to the database if there is no egress rule permitting it. The failure looks like a timeout on the database connection rather than a refused connection, which sends engineers looking at database logs instead of network policies.
Egress rules needed for a typical backend pod:
|
|
Certificate Errors
TLS failures in Kubernetes fall into two categories: application pods failing to establish connections to services (expired or untrusted certificates), and the Kubernetes API server failing to call admission webhooks (webhook certificate issues).
Application TLS debugging
|
|
To check an existing TLS secret directly:
|
|
cert-manager certificate issuance chain
cert-manager manages certificates through a chain of resources: Certificate → CertificateRequest → Order (for ACME issuers). Failures can occur at any stage.
|
|
The most common cert-manager failure in production: the ACME challenge endpoint is not reachable. For HTTP-01 validation, Let’s Encrypt must be able to reach http://<your-domain>/.well-known/acme-challenge/<token>. If your Ingress controller does not route that path, or a firewall blocks inbound HTTP, the challenge fails and the certificate never issues.
Webhook certificate failures
When an admission webhook’s certificate expires, kubectl apply starts failing with:
Error from server (InternalError): error when creating "resource.yaml":
Internal error occurred: failed calling webhook "validation.my-operator.io":
Post "https://my-operator-webhook.kube-system.svc:443/validate":
x509: certificate has expired or is not yet valid
Diagnose:
|
|
Emergency bypass if a broken webhook is blocking all applies (use with caution):
|
|
Control plane certificate expiry (kubeadm clusters)
For clusters managed with kubeadm, all control plane certificates expire after one year by default:
|
|
Output:
CERTIFICATE EXPIRES RESIDUAL TIME CA
admin.conf May 23, 2027 10:15 UTC 364d no
apiserver May 23, 2027 10:15 UTC 364d no
apiserver-etcd-client May 23, 2027 10:15 UTC 364d no
apiserver-kubelet-client May 23, 2027 10:15 UTC 364d no
controller-manager.conf May 23, 2027 10:15 UTC 364d no
etcd-healthcheck-client May 23, 2027 10:15 UTC 364d no
etcd-peer May 23, 2027 10:15 UTC 364d no
etcd-server May 23, 2027 10:15 UTC 364d no
front-proxy-client May 23, 2027 10:15 UTC 364d no
scheduler.conf May 23, 2027 10:15 UTC 364d no
Renew before expiry:
|
|
kubeadm automatically renews certificates when a control plane upgrade is performed, which is why clusters that are regularly upgraded rarely hit this problem. Clusters that are never upgraded and are more than a year old are the risk.
The Debugging Toolkit
Events sorted by time
|
|
This is the most underused command in daily Kubernetes work. The events log is a chronological record of everything that happened in a namespace — scheduling decisions, image pulls, probe failures, back-offs, volume mounts. It is far more readable than searching through kubectl describe output for individual pods, especially during incidents involving multiple pods or a rolling deployment that went sideways.
|
|
Rollout inspection and rollback
|
|
kubectl rollout undo is the fastest response to a bad deploy. It immediately starts rolling instances back to the previous ReplicaSet without any manifests or CI/CD involvement.
Node-level debugging
When pods on a specific node are failing and the problem is not visible from the pod layer, debug the node directly:
|
|
This creates a pod running in the node’s host namespaces with the node’s root filesystem mounted at /host. From there:
|
|
Multi-pod log streaming
|
|
Port-forward for service testing
|
|
Port-forwarding bypasses Ingress, load balancers, and network policies on the ingress path. If the application responds over port-forward but not through the normal route, the problem is in the routing layer (Ingress configuration, Service selector, or network policy on the Ingress controller) rather than in the application.
Quick Reference
Failure mode First command Key output to find
─────────────────────────────────────────────────────────────────────────────────
CrashLoopBackOff kubectl logs <pod> --previous Application error message
kubectl describe pod <pod> Exit Code, Last State Reason
OOMKilled kubectl describe pod <pod> Reason: OOMKilled
kubectl top pod <pod> Memory usage vs limit
Pending kubectl describe pod <pod> Events: 0/N nodes available
kubectl describe node <node> Allocated resources, Taints
DNS failure kubectl run -it debug --image= nslookup output
busybox -- nslookup <svc>
kubectl get pod -n kube-system CoreDNS pods running?
Network policy kubectl describe networkpolicy Ingress/Egress rules
kubectl debug -it <pod> curl/nc from pod's network
--image=nicolaka/netshoot
Cert error openssl x509 -noout -dates notAfter date
kubectl describe certificate Conditions: Reason, Message
kubectl describe order Challenge status (ACME)
Any pod kubectl get events Chronological incident log
--sort-by=.lastTimestamp
The five-step sequence — status, events, logs, describe, node — applies to every entry in that table. The specific commands per failure mode narrow down which step you spend time on. Most failures resolve at step 2 (events) or step 3 (logs). When they do not, the problem is almost always configuration (step 4) or a degraded node (step 5).
Comments