Back to Blog
EngineeringJuly 13, 2026·17 min

Real-World Kubernetes Pitfalls and How to Debug Them

Kubernetes makes the easy things trivial and the hard things invisible. After five years running production clusters, this post covers the specific failure modes that keep showing up — from CNI races to OOMKilled mysteries to headless service hairpinning — and the debugging workflows that actually catch them.

kubernetesdebuggingdevopscontainersnetworkingobservabilitysreproductiontroubleshooting

# Real-World Kubernetes Pitfalls and How to Debug Them

Kubernetes has this magical ability to turn a single machine problem into a distributed systems mystery. A process OOMs — and now you're hunting through five nodes, three replicasets, and a service mesh to figure out which pod died and why the replacement isn't receiving traffic.

I've spent five years running production clusters — small (3 nodes) and large (200+ nodes). The same dozen failure modes keep recurring. The tools change — maybe you use kubectl-node-shell instead of SSH, maybe you have Grafana dashboards now — but the root causes are remarkably stable.

This post is the catalog I wish I'd had. Each pitfall comes with the symptoms, the root cause, the debugging workflow, and the prevention.


Pitfall #1: The CNI Race (DNS Resolution Intermittently Fails)

Symptoms: Pods start, crash, restart in a loop with Temporary failure in name resolution. Or: DNS works for 30 seconds after pod creation, then stops. Or: some pods can resolve svc.cluster.local$, others can't. Root Cause: The Container Network Interface (CNI) plugin and the DNS configuration race at pod creation time. The kubelet creates the pod sandbox, the CNI plugin configures the network namespace, then the kubelet writes /etc/resolv.conf$. If the CNI hasn't finished writing the namespace's DNS configuration by the time the kubelet writes resolv.conf$, the pod starts with a broken or empty DNS config.

This is especially common with Cilium and Calico in large clusters (50+ nodes) where CNI churn is high. The race window is milliseconds wide but it hits constantly at scale.

Debugging workflow:
# Step 1: Confirm it's DNS, not application logic
# Exec into the failing pod and try a lookup
kubectl exec -it <pod> -- nslookup kubernetes.default.svc.cluster.local

# Step 2: Check the actual resolv.conf
kubectl exec <pod> -- cat /etc/resolv.conf
# Expected: nameserver pointing to kube-dns / CoreDNS (usually 10.96.0.10 or 10.43.0.10)
# Broken: empty, wrong IP, or pointing to a dead node's IP

# Step 3: Check CoreDNS itself — is it running?
kubectl get pods -n kube-system -l k8s-app=kube-dns
# If CoreDNS is crashing or has high restart count, that's a separate issue

# Step 4: Check CoreDNS logs for SERVFAIL on internal lookups
kubectl logs -n kube-system -l k8s-app=kube-dns --tail=50

# Step 5: Test DNS from a known-good pod (a debug pod with tools)
kubectl run dns-test --image=busybox:1.36 --rm -it -- nslookup kubernetes.default
The thing most people miss: Check if your node has the correct
/etc/resolv.conf on the host. Some cloud VM images override it with a cloud-provider DNS server that doesn't understand .cluster.local:
# On the node itself
cat /etc/resolv.conf
# If it points to cloud provider DNS (e.g., 169.254.169.254 for AWS),
# your pods may inherit this if CoreDNS is misconfigured
Prevention:
FixEffortReliability
Set resolvConf in CoreDNS ConfigMap to point to a working upstreamLowMedium — hides the symptom
Use NodeLocal DNSCache (DaemonSet that caches DNS locally on each node)MediumHigh — reduces pressure on CoreDNS and avoids CNI race entirely
Pin CNI plugin version and lock kubelet version togetherLowHigh — version drift is a common trigger
Add a postStart lifecycle hook with a small sleep + retryLowPatches the symptom, but buy time

The NodeLocal DNSCache DaemonSet is the single best fix. It installs a local DNS cache on each node, eliminating the per-pod race entirely:

# Deploy NodeLocal DNSCache
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/aws/deploy.yaml
# No, that's wrong — use the actual addon:
# https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/
My opinion: If you're running clusters with more than 20 nodes and haven't deployed NodeLocal DNSCache, you're bleeding engineering hours to DNS flakes. Install it this week.

Pitfall #2: The OOMKilled Mystery (Pod Restarts But Memory Limits Look Fine)

Symptoms: Pods crash with
OOMKilled$ status, but kubectl top pod shows memory usage below the limit. Or: memory usage spikes to exactly the limit for a few seconds, then the pod restarts, but the spike doesn't show in your metrics because your scrape interval was 15 seconds and the spike lasted 3. Root Cause: Three things, in order of likelihood:
  1. Memory limits are too tight — The container hits the cgroup memory limit faster than your metrics can catch it. The OOM killer fires, the kernel terminates the process, and by the time Prometheus scrapes, the pod is already restarting with zero memory usage.

  1. Memory is in the cgroup that your metrics miss — Page cache, slab, or tmpfs usage is counted against the cgroup limit but isn't visible via standard kubectl top pod or the /metrics endpoint. Your app's heap looks fine, but the OS-level memory consumption pushes past the limit.

  1. Java (and other GC'd languages) don't play nice — JVM heap + native memory + Metaspace + thread stacks + JIT code cache + direct buffers add up to more than -Xmx$. The JVM doesn't know about the cgroup limit.

Debugging workflow:
# Step 1: Confirm OOMKilled
kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
# Should return "OOMKilled"

# Step 2: Check the actual cgroup memory stats at the time of crash
# You need node-level access for this
kubectl node-shell <node>  # requires krew plugin: node-shell

# Inside the node, find the cgroup path for the dead container
# Container IDs are visible via: kubectl describe pod <pod>
cat /sys/fs/cgroup/memory/kubepods/burstable/<pod-id>/<container-id>/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/kubepods/burstable/<pod-id>/<container-id>/memory.limit_in_bytes

# If you're on cgroup v2 (most modern distros):
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<pod-id>.slice/memory.current
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<pod-id>.slice/memory.max
Step 3: Check for page cache bloat. Your app might read files into memory that stays as page cache:
# Inside the container's cgroup
grep '^cache ' /sys/fs/cgroup/memory/kubepods/burstable/<pod-id>/<container-id>/memory.stat
# If cache is > 50% of limit, page cache is your OOM
``$

**Step 4: For Java specifically, check off-heap memory:**
bash

# Inside the container, before it crashes

# Native memory tracking

jcmd <pid> VM.native_memory summary

# Or via /proc

cat /proc/<pid>/smaps | grep -i "pss|rss" | awk '{sum+=$2} END {print sum " kB"}'

$

Prevention:
ScenarioFix
Tight limits + bursty allocationSet memory request closer to usage, set limit to 2x request
Page cache bloatSet memory: request high enough to include cache, or clear cache periodically with sync; echo 3 > /proc/sys/vm/drop_caches
Java off-heapSet container memory limit to 1.5x -Xmx$. Use -XX:MaxRAMPercentage=75 instead of fixed -Xmx.
Any language with file I/OMonitor container_memory_working_set_bytes not container_memory_rss. Working set includes cache.

The critical Kubernetes resource monitoring insight: PromQL container_memory_working_set_bytes is closer to what the kernel counts for OOM than container_memory_rss$. If you're alerting on RSS, you're alerting too late or missing events entirely.


Pitfall #3: The Headless Service Hairpin (Traffic to Self Hangs)

Symptoms: A pod tries to connect to another pod via a headless service (
clusterIP: None$) and the connection hangs or goes to itself. "I have three replicas, but when they talk to each other, one pod keeps talking to itself!" Root Cause: Headless services with service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"$ (or using StatefulSet with publishNotReadyAddresses: true$) return all endpoints in DNS, including the calling pod's own IP. When a pod does DNS lookup for service-name$, it gets all N pod IPs, and if the client library or connection pool picks its own IP, it hairpins to itself.

This is especially common with:

  • StatefulSets where pods discover peers via headless service DNS
  • Gossip protocols (Cassandra, RabbitMQ, Consul) that use headless services for member list
  • Databases where each node needs to know about all other nodes

Debugging workflow:
# Step 1: Verify it's a hairpin issue
# Check from the pod — what IPs does it resolve?
kubectl exec <pod> -- nslookup <headless-service-name>
# If the result includes the calling pod's own IP, you have potential hairpin

# Step 2: Check if the service is truly headless
kubectl get svc <service-name> -o jsonpath='{.spec.clusterIP}'
# Should be "None" for headless

# Step 3: Check which pod IP is being used when the connection hangs
# Enable debug logging in the application to log peer IPs
# Or use tcpdump:
kubectl exec <pod> -- tcpdump -ni eth0 port <peer-port> -c 20
``$

**Prevention:**

The most practical fix: **don't use headless services for pod-to-pod communication that includes self-discovery.** Use an API-based discovery mechanism (like a registration endpoint) instead.

If you must use headless:
typescript

// Application-level fix — filter out own IP in the client

import { createConnectionPool } from "some-db-driver";

import * as dns from "dns";

import * as os from "os";

async function discoverPeers(serviceName: string): Promise<string[]> {

const ownIP = getOwnPodIP();

const addresses = await dns.promises.resolve4(serviceName);

// Filter out self!

return addresses.filter((ip) => ip !== ownIP);

}

function getOwnPodIP(): string {

// Kubernetes sets the pod IP as the canonical hostname

// Fallback: read from environment or /etc/hosts

return process.env.POD_IP || os.hostname();

}

$

Another option — use a regular ClusterIP service (not headless) for internal communication and only use headless for actual peer discovery:

apiVersion: v1
kind: Service
metadata:
  name: myapp-headless  # For peer discovery (DNS gives all IPs)
spec:
  clusterIP: None
  selector:
    app: myapp
---
apiVersion: v1
kind: Service
metadata:
  name: myapp  # For regular pod-to-pod traffic (ClusterIP load balances)
spec:
  clusterIP: 10.96.0.50
  selector:
    app: myapp
  ports:
    - port: 8080

The pods use the ClusterIP service for regular traffic (which round-robins across pods including self, but that's fine for stateless requests) and the headless service only for cluster membership discovery.


Pitfall #4: The PersistentVolume Claim That Never Binds (Or Binds to the Wrong Thing)

Symptoms: A PVC stays in
Pending forever. Or: it binds, but the pod fails to start with FailedMount errors that mention "volume" and "already mounted" or "wrong filesystem type." Root Cause: Usually one of three things:
  1. StorageClass doesn't exist — The PVC references storageClassName: fast-ssd$ but no StorageClass named fast-ssd$ exists in the cluster.
  2. Zone mismatch — The PV is in us-east-1a$, but the pod is scheduled on a node in us-east-1b$. EBS and GCE PDs are zonal — they can't be mounted across zones.
  3. AccessModes conflict — PVC requests ReadWriteMany$, but the underlying storage only supports ReadWriteOnce$.
  4. Capacity exhaustion — The CSI driver has a limit on the number of volumes per node (AWS EBS: max 40 per node on nitro instances; GCE PD: max 128 per node).

Debugging workflow:
# Step 1: Why is the PVC pending?
kubectl describe pvc <pvc-name>
# Look for Events section — Kubernetes tells you exactly why
# Common messages:
#   "no persistent volumes available" → no PV matches
#   "failed to provision volume with StorageClass" → CSI driver issue
#   "waiting for first consumer to be created" → volume binding mode is WaitForFirstConsumer

# Step 2: Check the StorageClass
kubectl get storageclass
kubectl describe storageclass <name>

# Step 3: If using WaitForFirstConsumer, check pod scheduling
kubectl get pod <pod-name> -o wide
# The pod might be Pending too, waiting for a node

# Step 4: Check CSI driver pods (if using dynamic provisioning)
kubectl get pods -n kube-system | grep csi

# Step 5: Check node volume limits
kubectl describe node <node-name> | grep -A5 "Attached Volumes|Allocatable"
# Look for counts like: ephemeral-storage: 85710412Ki
# CSI-attached volumes: 25 (if this is near 40, you're at the limit)
``$

**Prevention:**

| Root Cause | Fix |
|---|---|
| Missing StorageClass | Verify `kubectl get storageclass$. Default StorageClass is set via annotation `storageclass.kubernetes.io/is-default-class: "true"$ |
| Zone mismatch | Use `WaitForFirstConsumer` binding mode — it delays volume provisioning until the pod is scheduled, ensuring zone alignment |
| AccessMode conflict | Check that your storage provider supports the mode. Most NFS/CephFS solutions support RWX. Most cloud block storage (EBS, PD) only supports RWO. |
| Node volume limit | Use `volumeAttachmentLimit` on CSI driver. Spread workloads across nodes. Use larger EBS volumes shared across multiple pods via NFS or EFS. |
| FS type mismatch | Explicitly set `fsType: ext4` in StorageClass parameters. Some CSI drivers default to `xfs$ which the pod may not handle. |

The most insidious variant: **a PVC that binds but the pod fails to mount because of a security context mismatch.** The PV is owned by root, but the pod runs as non-root:
bash

# Check PV mount options and permissions

kubectl get pv <pv-name> -o yaml | grep -A5 "mountOptions|persistentVolumeReclaimPolicy"

# Check the CSI driver's mount behavior

kubectl logs -n kube-system <csi-controller-pod> --tail=20

$

The fix: add an initContainer that chowns the mount path, or configure the CSI driver to set correct permissions at provision time via csi.storage.k8s.io/pod.safe-to-evict: "true"$ and StorageClass parameters like gid: 1000$.


Pitfall #5: The Service Mesh That Made Everything Worse

Symptoms: Latency increased by 5-20ms per hop. Pods crash with
ENVOY_CONNECTION_FAILURE$. kubectl port-forward stops working. mTLS handshake failures between pods that used to communicate fine. Root Cause: You installed a service mesh (Istio / Linkerd / Consul Connect) thinking it would solve all your observability and security problems. Instead, it became your biggest dependency — because now every pod's network traffic goes through a sidecar proxy that can crash, misconfigure, or degrade. The specific failures I see most often:
  1. Sidecar startup race — The Envoy proxy takes longer to initialize than the application container. The app starts, tries to make an outbound connection, but Envoy isn't ready yet. Connection fails. Pod restart.

  1. mTLS certificate rotation — Istio rotates certificates every 24 hours. If the SDS agent fails to refresh the certificate (DNS outage, control plane overload), mutual TLS starts failing silently — the connection is rejected, but the error message is a generic "connection reset by peer."

  1. Circuit breaker cascade — A single slow upstream triggers circuit breakers across the entire mesh, causing cascading failures that look like a capacity problem but are actually a misconfigured outlier detection.

  1. Protocol detection failure — The sidecar misdetects HTTP/2 as TCP and strips all the headers. Your gRPC calls fail with cryptic UNAVAILABLE errors.

Debugging workflow:
# Step 1: Check if the sidecar is actually running
kubectl get pod <pod> -o jsonpath='{.spec.containers[*].name}'
# Should include "istio-proxy" or "linkerd-proxy"

# Step 2: Check sidecar logs — usually more useful than app logs for mesh issues
kubectl logs <pod> -c istio-proxy --tail=50

# Step 3: Check sidecar readiness
kubectl exec <pod> -c istio-proxy -- pilot-agent request GET /ready
# Should return 200

# Step 4: Check for mTLS cert issues
kubectl exec <pod> -c istio-proxy -- openssl s_client -connect <peer>:<port> 2>&1 | grep "certificate|error"

# Step 5: Dump Envoy config to see what routes/settings are applied
kubectl exec <pod> -c istio-proxy -- curl -s http://localhost:15000/config_dump | jq '.configs[0].dynamicActiveSecrets' | head -30

# Step 6: Check for circuit breaker triggering
kubectl exec <pod> -c istio-proxy -- curl -s http://localhost:15000/stats | grep -i "circuit|outlier|eject"
``$

**Prevention:**

| Problem | Fix |
|---|---|
| Sidecar startup race | Set `holdApplicationUntilProxyStarts: true$ (Istio 1.16+). This delays the app container start until the sidecar is healthy. |
| mTLS cert rotation flake | Increase certificate TTL to 7 days in IstioConfigMap. Add a liveness probe to the sidecar. |
| Circuit breaker cascade | Start with permissive outlier detection. Never enable circuit breakers with default settings in production. |
| Protocol detection | Explicitly set `appProtocol: grpc` or `appProtocol: http2` in your Service definition. Don't let the mesh guess. |

**My opinion:** Service meshes add value at scale (200+ services, multiple teams, compliance-mandated mTLS). Below that threshold, they add complexity without proportional benefit. If you're running 20 microservices and thinking "let's install Istio," start with mutual TLS via Linkerd — it's dramatically simpler — or skip the mesh entirely and use mTLS at the application layer with a library like `spiffe/spire$.

---

## Pitfall #6: The Node That's "Healthy" But Doesn't Schedule

**Symptoms:** A node shows `Ready` status, but new pods never land on it. Or: pods get stuck in `Pending$. `kubectl describe pod` shows "0/4 nodes are available" but all nodes say Ready.

**Root Cause:** The node is Ready from the kubelet's perspective, but the scheduler's internal cache is stale. This happens when:

1. The kubelet stops heartbeating to the API server
2. The node controller hasn't marked it `NotReady` yet (default timeout is 40 seconds for `node-monitor-grace-period`)
3. The scheduler's node cache (which lists ready nodes independently) hasn't been updated
4. Node conditions are fine, but taints that would normally repel pods are still present

**Debugging workflow:**
bash

# Step 1: Check node status — is it really ready?

kubectl get nodes

# If it shows Ready but you suspect issues, check conditions:

kubectl describe node | grep -A5 "Conditions"

# Look for: MemoryPressure, DiskPressure, PIDPressure, NetworkUnavailable

# Any of these set to True will block scheduling

# Step 2: Check the scheduler's view of the node

kubectl get pods -n kube-system -l component=kube-scheduler -o name | head -1 | xargs kubectl logs -n kube-system --tail=20

# Look for messages about "node not found in cache" or "failed to list nodes"

# Step 3: Check taints — are there taints that don't match tolerations?

kubectl describe node | grep -A10 "Taints"

# Common problematic taints:

# node.kubernetes.io/unreachable

# node.kubernetes.io/out-of-disk

# node.kubernetes.io/memory-pressure

# Step 4: Check if the kubelet is actually heartbeating

# This is the most common cause. Check node lease:

kubectl get lease -n kube-node-lease -o wide | grep

# The renewTime should be within the last 40 seconds

# Step 5: Check kubelet logs on the node

kubectl node-shell -- journalctl -u kubelet --no-pager --since "5 minutes ago" | tail -30

# Look for: "Failed to update node status", "out of disk", "eviction manager"

`$

The one that gets everyone: A taint of node.kubernetes.io/unreachable:NoSchedule that was automatically applied when the node controller lost contact for >40 seconds, but the underlying problem (network partition, kubelet OOM) was transient and the node recovered — but nobody removed the taint:
# Remove the stale taint
kubectl taint nodes <node-name> node.kubernetes.io/unreachable:NoSchedule-
``$

---

## Pitfall #7: The ConfigMap Update That Never Arrives

**Symptoms:** You update a ConfigMap, but pods keep using the old values for hours. Even after restarting the pods, the old config persists.

**Root Cause:** Two separate issues, often confused:

**Issue A — SubPath mounts don't update:** If you mount a ConfigMap via `subPath$, the kernel doesn't support atomic symlink swapping on subPath mounts. The mounted file is a regular file, not a symlink to the auto-updating ConfigMap store. The pod will never see ConfigMap updates for subPath mounts without a restart.

**Issue B — The pod's in-memory cache is stale:** The ConfigMap file on disk is updated (the atomic symlink swap happens), but the application reads its configuration once at startup and never re-reads from disk. The file changes but the process doesn't care.

**Debugging workflow:**
bash

# Step 1: Check if the ConfigMap actually updated

kubectl get configmap <name> -o yaml | grep <key>

# Step 2: Check what the pod sees — exec in and check file contents

kubectl exec <pod> -- cat /etc/config/<key>

# Step 3: Check if the mount is using subPath

kubectl get pod <pod> -o yaml | grep -A2 "subPath"

# If you see subPath: anywhere in the volume mounts, ConfigMap auto-update is broken

# Step 4: Check the actual inode on the filesystem — this reveals the symlink trick

kubectl exec <pod> -- stat /etc/config/<key>

# If it shows a symlink (-> ..data/<key>), the auto-update mechanism is working

# If it shows a regular file, you're on a subPath mount

$

Prevention:
ScenarioSolution
You need subPathAccept that updates require pod restart. Use a rollout restart strategy.
You need live updatesMount the entire ConfigMap directory, not individual files via subPath. Use a watcher in the app (e.g., fsnotify in Go, chokidar in Node).
App reads config once at startupImplement SIGHUP handler or use a sidecar that sends SIGHUP on file change. Or use a tool like reload.sh or confd$.
You need both subPath AND updatesCopy the file from ConfigMap into an initContainer, write to an EmptyDir, mount the EmptyDir. The initContainer runs on each restart.

The most pragmatic solution: use a deployment rollout with ConfigMap checksums. Annotate your pod template with a hash of the ConfigMap data. When the ConfigMap changes, the annotation changes, triggering a rolling update:

# In your deployment YAML or Helm template
kubectl annotate deployment <name>   configmap.reloader.stakater.com/reload="<configmap-name>"
``$

Or roll your own:
yaml

# deployment.yaml

spec:

template:

metadata:

annotations:

checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}

`$

This is the Helm convention and it works. The pod restarts, picks up the new ConfigMap, and you're done.


The Universal Debugging Toolkit

Regardless of which pitfall you're debugging, these commands form the base of any Kubernetes investigation:

`bash

# 1. Pod status — not just Ready, but conditions

kubectl get pod <pod> -o jsonpath='{.status.conditions[*].type}{"

"}'

# 2. Events — the most underused resource

kubectl get events --all-namespaces --sort-by='.lastTimestamp' | tail -20

# 3. Container resource usage — actual vs. request/limit

kubectl top pod <pod> --containers

# 4. The audit of recent API calls (if enabled)

kubectl logs -n kube-system -l component=kube-apiserver --tail=20

# 5. The kubelet's perspective — often tells you what the API server doesn't

kubectl node-shell <node> -- crictl ps

# 6. Node conditions — not just Ready

kubectl get node <node> -o jsonpath='{.status.conditions[*].type}{"

"}{.status.conditions[*].status}{"

"}'

$

And the most important tool: a structured approach. Before you run any command, write down:

  1. What is the observed symptom? (pod restarting, DNS failing, PVC pending)
  2. What is the expected behaviour? (pod should stay up, DNS should resolve, PVC should bind)
  3. What is the smallest possible test that confirms or rules out your current hypothesis?

This sounds obvious, but in the heat of production debugging, it's the first thing people abandon. They start reading logs at random, kubectl describing everything in the namespace, and 20 minutes later they're checking the Istio control plane logs when the problem was a missing ConfigMap.

Key Takeaways

  1. DNS flakiness is usually a CNI race, not a CoreDNS issue. Deploy NodeLocal DNSCache.
  2. OOMKilled with memory below limit means you're monitoring the wrong metric. Watch container_memory_working_set_bytes$, not RSS.
  3. Headless services hairpin by design. Filter out the calling pod's IP in peer discovery.
  4. PVC binding failures are almost always zone mismatches or missing StorageClasses. Use `WaitForFirstConsumer$.
  5. Service meshes are not free. The sidecar startup race, cert rotation, and circuit breakers will eventually bite you.
  6. Nodes can be "Ready" but unschedulable. Check taints and scheduler cache freshness.
  7. ConfigMaps via subPath are write-once. Use deployment rollouts with checksum annotations.

The cluster will break. The question isn't "if" but "when." When it does, have a structured debugging workflow, not a panic-and-random-kubectl approach. Your 3 AM self will thank you.

Got a project that needs illuminating?

We bring clarity to complex software challenges. Let's talk.

Get In Touch