AWS Containers

EKS Pods Stuck Pending or CrashLoopBackOff: The Complete Troubleshooting Playbook

kubectl get pods returns three columns you did not want to see: a pod that has been Pending for nine minutes, another cycling CrashLoopBackOff with RESTARTS climbing past forty, and a third stuck on ImagePullBackOff. The deployment says READY 0/3, the on-call channel is filling up, and the single thing standing between you and a fix is a command you have not run yet: kubectl describe pod and, specifically, the Events block at the bottom of it. Almost every EKS pod incident is won or lost in the first sixty seconds — on whether you read the pod’s state, its container’s Last State, Reason, and Exit Code, and the scheduler’s own words in Events, or whether you start guessing: bumping memory on a pod that was actually starved of an IP address, or “fixing” IRSA on an ImagePullBackOff that the kubelet was never going to solve with IRSA in the first place.

A pod fails to reach Running in a small number of very specific ways, and each one writes an exact string. 0/3 nodes are available: 3 Insufficient cpu, failed to assign an IP address to container, pod has unbound immediate PersistentVolumeClaims, Back-off pulling image, Reason: OOMKilled, Liveness probe failed — these are not vague. Each maps one-to-one onto a phase of the pod’s journey (schedule → assign IP → mount volume → pull image → start container → pass probes) and onto exactly one fix. The entire skill of operating Kubernetes on Amazon EKS at 2 a.m. is naming the class fast from the state and the event string, not shotgunning changes at a manifest.

This is the reference you keep open during the incident. It treats a pod as what it actually is — a workload that must clear the scheduler, get a real VPC IP from the AWS VPC CNI, bind any volume through the EBS CSI driver, be pulled by the kubelet under the node instance role, and then survive its liveness and readiness probes — and it walks every failure class that pages you, in kubectl, aws CLI, and Terraform, with real event strings, real IAM actions, and real limits. The bulk is a scannable master playbook, a waiting-reason and exit-code reference, and a decision table, plus the three nastiest failures in full: VPC CNI IP exhaustion, OOMKilled versus a liveness-probe kill, and the IRSA-versus-node-role image-pull confusion. It maps to the container and troubleshooting domains of SAA-C03, DVA-C02 and SOA-C02.

What problem this solves

Kubernetes is declarative and self-healing, which is exactly why a stuck pod is so disorienting: you asked for three replicas, the control plane keeps trying to give you three, and the failure is buried in a status field or an event you have to go read. There is no crash dialog. A pod in Pending is not an error the cluster shouts — it is the scheduler quietly saying “I looked at every node and none of them fit,” and unless you open kubectl describe pod and read the Events, you will never see the sentence that tells you why. A CrashLoopBackOff pod is even more deceptive: it did start, it is being restarted, and the logs you get from kubectl logs are from the current attempt — which may show nothing, because the real error was printed by the container that already died. You need kubectl logs --previous, and if you do not know that, the pod is an opaque wall.

What breaks without this skill is delivery velocity and uptime, quietly and repeatedly. A team pushes a deployment with a resources.requests.cpu of 4 onto a cluster of t3.medium nodes (2 vCPU each), and every pod sits Pending forever with Insufficient cpu — a one-line manifest typo that reads like a cluster outage. Another team scales a service from 20 to 200 pods on a handful of large nodes, runs the subnets dry of IP addresses, and watches new pods hang in ContainerCreating with failed to assign an IP address to container — the nodes have plenty of CPU, so CPU dashboards look fine and the real limit (VPC IPs) is invisible unless you know to look. A third wires a database password into a Secret, references a key that does not exist, and gets CreateContainerConfigError — which they misread as a crash and “fix” by restarting, forever. A fourth sets a livenessProbe with initialDelaySeconds: 5 on an app that takes 45 seconds to warm its JVM, and the app is killed and restarted before it can ever answer — a CrashLoopBackOff caused entirely by the health check, not the app. None of these are exotic. They are the default behaviour of the platform when you do not know which phase does what.

Who hits this: anyone running EKS past the first tutorial. It bites hardest on teams new to the AWS VPC CNI (real VPC IPs, not an overlay — subnets exhaust), on anyone who confuses the node role with IRSA (image pull versus app runtime), on teams that treat memory limits as advisory (they are a hard OOM ceiling), and on anyone who copied a livenessProbe from a blog without matching it to their app’s real startup time. The fix is almost never “delete the pod and hope.” It is: read the state, read the Events and Last State, name the phase, and apply the one change that phase needs.

Here is the whole failure field on one screen — the state you see, the class, the exact signal, and the one-line fix — so you can orient before the deep dive:

Pod state you see Class The signal you’ll see The one-line fix
Pending Insufficient capacity 0/N nodes are available: N Insufficient cpu/memory Right-size requests; let Autoscaler/Karpenter add a node (check max)
Pending Taint / affinity had untolerated taint {...} / didn't match Pod's node affinity/selector Add tolerations; fix nodeSelector/labels/affinity
Pending PVC unbound pod has unbound immediate PersistentVolumeClaims Install EBS CSI + IRSA; default StorageClass; same-AZ
ContainerCreating (stuck) CNI IP exhaustion failed to assign an IP address to container Prefix delegation; bigger instance; more subnet IPs
ImagePullBackOff Image pull ErrImagePullBack-off pulling image Node-role ECR perms; fix tag; add route; mirror Docker Hub
CrashLoopBackOff App exit / config Last State: Terminated, Exit Code: 1 Read logs --previous; supply config/secret
CrashLoopBackOff OOMKilled Reason: OOMKilled, Exit Code: 137 Raise memory limit; fix the leak
CrashLoopBackOff Liveness kill Liveness probe failed + Killing container Add startupProbe; raise initialDelaySeconds
Running 0/1 Readiness never ready Pod up, READY 0/1, out of Service endpoints Fix readiness path/deps; pod is live but not ready
Node NotReady Node health kubectl get nodes shows NotReady Check aws-node/kubelet, disk/memory pressure

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with the Kubernetes and EKS basics: a Pod is the smallest deployable unit (one or more containers sharing a network namespace and IP), a Deployment manages a ReplicaSet that keeps N pod replicas running, the kube-scheduler assigns each pending pod to a node, and the kubelet on that node pulls images and starts containers. You should know that on EKS the control plane is AWS-managed (you never see the API server hosts), that worker nodes run as a managed node group (an EC2 Auto Scaling group), Karpenter, or Fargate, and that the AWS VPC CNI (aws-node DaemonSet) gives every pod a real IP from your VPC subnets. You should be able to run kubectl against a cluster (aws eks update-kubeconfig), read YAML, and know what requests and limits, a Secret, a ConfigMap, and a PersistentVolumeClaim are. IAM roles and policies, VPC subnets and route tables, and Linux exit codes should be familiar.

This sits in the Containers track and is the operational companion to the design- and setup-side references. The decision that put you on EKS rather than ECS or plain Fargate is made in Choosing Your AWS Container Path: ECS vs EKS vs Fargate, and the broader compute picture is in AWS Compute: EC2 vs Lambda vs ECS vs EKS. Standing up the cluster and node groups themselves — the eksctl/managed-node-group hands-on that this article assumes you have already done — and the IRSA deep-dive that gives a pod its own fine-grained IAM (distinct from the node role that pulls images, which this article leans on heavily) are the natural companions to read alongside this one. The image side — building, tagging, pushing and pulling from a registry, and the exact ECR permissions a pull needs — is covered in Push and Pull with Amazon ECR, Hands-On. The ECS analogue of this playbook, worth reading for the shared patterns (the two-roles split, the private-subnet pull path), is ECS Task Won’t Start: The Complete Troubleshooting Playbook. And because so many “won’t start” failures are really “can’t reach ECR or the API server from a private subnet,” the network-path diagnosis lives in The VPC Connectivity Troubleshooting Playbook.

Before the deep dive, fix the mental model of where each failure lives, so you look in the right place first:

Layer What lives here Failure classes it causes First place to look
Scheduler Node fit: requests, taints, affinity, spread Pending: Insufficient cpu/memory, untolerated taint, affinity kubectl describe pod → Events
Autoscaling Cluster Autoscaler / Karpenter add nodes Pending that never resolves (at max, no fit) Autoscaler / Karpenter controller logs
VPC CNI (network) Real VPC IP per pod from ENIs Stuck ContainerCreating: no free IP describe pod; aws-node logs; max-pods
Storage (CSI) PVC → PV bind via EBS CSI Pending: unbound PVC; AZ conflict kubectl get pvc; get sc; CSI pods
Image pull (kubelet) ECR auth + route + tag, under node role ImagePullBackOff / ErrImagePull describe pod Events suffix
Container runtime Start, config, probes, limits CrashLoopBackOff, OOMKilled, CreateContainerConfigError logs --previous; describe Last State
Node kubelet, aws-node, kube-proxy, pressure Node NotReady; mass evictions kubectl describe node; kube-system pods
Cluster DNS CoreDNS resolves service/external names no such host; app can’t reach deps kubectl logs -n kube-system deploy/coredns

Core concepts

The pod lifecycle — every phase can fail differently

A pod has a top-level phase (Pending, Running, Succeeded, Failed, Unknown) and, underneath it, a container state for each container (Waiting, Running, Terminated) with a human reason. kubectl get pods shows a blend of these in the STATUS column — sometimes the phase (Pending), sometimes the container’s waiting reason (ImagePullBackOff, CrashLoopBackOff), sometimes a transient (ContainerCreating). Knowing which is which tells you where in the journey the pod died.

What STATUS shows It’s really… Phase of the journey Failures that live here
Pending Pod phase Not yet scheduled (or scheduled, waiting on IP/volume) Insufficient requests, taints, affinity, PVC unbound
ContainerCreating Container Waiting Scheduled; kubelet setting up sandbox CNI IP exhaustion, volume mount failure
ErrImagePull / ImagePullBackOff Container Waiting.reason Pulling the image Auth, route, wrong tag, rate limit
CreateContainerConfigError Container Waiting.reason Building the container config Missing ConfigMap/Secret/key referenced
RunContainerError / CreateContainerError Container Waiting.reason Starting the container Bad command, mount, or runtime error
Running (READY 1/1) Pod phase + ready Started and passing readiness Steady state
Running (READY 0/1) Running, not ready Started but readiness failing Readiness probe / dependency not up
CrashLoopBackOff Container Waiting.reason Started, exited, backing off restart App exit, OOMKill, liveness kill
Completed / CrashLoopBackOff (Job) Terminated Ran to completion (or keeps failing) Job exit code
Terminating (stuck) Deletion Being deleted, blocked Finalizer, preStop, terminationGracePeriod

The four commands — your entire diagnostic toolkit

You confirm every class below with the same small set of commands. Learn to reach for them in this order.

Command What it tells you When to reach for it
kubectl describe pod <pod> Events (scheduler + kubelet), container Last State, Reason, Exit Code, probe results Always first — names the class
kubectl logs <pod> --previous (-p) The crashed container’s own stdout/stderr (the attempt that died) Any CrashLoopBackOff
kubectl logs <pod> -c <container> -f The current container’s live logs Running-but-misbehaving
kubectl get events --sort-by=.lastTimestamp The cluster-wide timeline (scale-ups, evictions, probe fails) When the pod event is a symptom of a node/cluster event
kubectl top pod / kubectl top node Real CPU/memory vs requests/limits (needs metrics-server) OOM suspicion, right-sizing
kubectl get pod <pod> -o yaml The full status, including containerStatuses[].state and .lastState When you need the exact machine fields
kubectl get nodes -o wide / describe node Node Ready conditions, taints, allocatable, pressure Pending with no fit; NotReady
kubectl get pvc / kubectl get sc PVC bind status and available StorageClasses Pending on a volume

The single habit that separates a two-minute fix from a two-hour outage: run kubectl describe pod and read the Events block before you change anything. The scheduler and kubelet narrate exactly what went wrong there, in plain English, at the bottom of the output.

phase vs container state vs reason — reading the status field

The confusion that trips people is that STATUS in kubectl get pods is not a single field. A pod can be phase: Pending because it is unscheduled, or phase: Pending because it is scheduled but its container is Waiting with reason ContainerCreating. The authoritative machine fields are .status.phase and .status.containerStatuses[].state / .lastState.

Field Where Example values Read it for
.status.phase Pod Pending, Running, Succeeded, Failed Where in life the pod is
.status.conditions Pod PodScheduled, Initialized, Ready, ContainersReady Which gate it cleared
.status.containerStatuses[].state Container waiting{reason}, running, terminated{reason,exitCode} Current container state
.status.containerStatuses[].lastState Container terminated{reason: OOMKilled, exitCode: 137} Why the previous attempt died
.status.containerStatuses[].restartCount Container integer, climbing CrashLoop severity
.status.containerStatuses[].ready Container true/false Whether readiness passes

The scheduler’s job — why “Pending” happens

The kube-scheduler watches for pods with no assigned node and, for each, runs two stages: filtering (which nodes can run this pod — enough allocatable CPU/memory for the pod’s requests, taints tolerated, node affinity/selector matched, ports free, volume topology satisfiable) and scoring (of the survivors, which is best). If filtering leaves zero nodes, the pod stays Pending and the scheduler writes a FailedScheduling event that literally lists the reasons per node: 0/5 nodes are available: 3 Insufficient cpu, 2 node(s) had untolerated taint. That event is the diagnosis. Crucially, the scheduler filters on requests, not limits and not actual usage — a pod requesting 2 CPU will not schedule onto a node with 1.5 free even if it would only ever use 0.1.

Read describe, events, and logs first

This is the reference you scan while READY is stuck at 0/3. First the commands, then the four lookup tables — scheduler events, container waiting-reasons, exit codes, and the decision table — that turn a string into a class.

# The one command you run first — Events name the class; Last State + Exit Code name the death
kubectl describe pod my-app-7d9f8-abcde
# ...scroll to the bottom:
#   Events:
#     Warning  FailedScheduling  0/5 nodes are available: 3 Insufficient cpu, 2 had untolerated taint
#   -- or, for a crash:
#     Last State:  Terminated
#       Reason:    OOMKilled
#       Exit Code: 137

# The crashed container's own last words (NOT the current attempt)
kubectl logs my-app-7d9f8-abcde --previous -c my-app

# The cluster-wide timeline, newest last — catches evictions, scale-ups, node NotReady
kubectl get events --sort-by=.lastTimestamp | tail -30

# Real usage vs requests/limits (needs metrics-server; confirms OOM and right-sizing)
kubectl top pod my-app-7d9f8-abcde --containers

The scheduler-event reference (why a pod is Pending)

When a pod is Pending, the FailedScheduling event lists a reason per node. Read the reason; it is the class.

Event message (excerpt) Root cause it points to Confirm Fix
Insufficient cpu No node has enough free CPU for the pod’s requests.cpu kubectl describe node → Allocatable vs Allocated Lower the request, or add/enlarge nodes
Insufficient memory Same, for requests.memory Node Allocatable memory Lower request; bigger nodes; scale out
Insufficient ephemeral-storage Requested ephemeral storage exceeds node free disk Node ephemeral-storage allocatable Lower request; bigger disk
too many pods / Insufficient pods Node hit its max-pods (CNI IP cap) kubectl get node -o jsonpath='{..allocatable.pods}' Prefix delegation; bigger instance; more nodes
had untolerated taint {key: value} Pod lacks a toleration for a node taint kubectl describe node → Taints Add a matching toleration (or remove the taint)
didn't match Pod's node affinity/selector nodeSelector/nodeAffinity matches no node’s labels Compare pod’s selector to kubectl get nodes --show-labels Fix labels or the selector
had volume node affinity conflict Pod’s PV is in an AZ with no schedulable node kubectl describe pv; node AZ labels Same-AZ node; WaitForFirstConsumer
unbound immediate PersistentVolumeClaims PVC hasn’t bound (no CSI / SC / provisioner) kubectl get pvc, get sc Install EBS CSI + IRSA; default SC
didn't match pod topology spread constraints topologySpreadConstraints can’t be satisfied The constraint’s maxSkew/topologyKey Loosen skew; whenUnsatisfiable: ScheduleAnyway
had taint {node.kubernetes.io/not-ready} The only nodes are themselves NotReady kubectl get nodes Fix the node (below), not the pod
pod didn't trigger scale-up: max node group size reached Cluster Autoscaler is at the ASG max Autoscaler logs; node group max size Raise max size; Karpenter; smaller pod
pod didn't trigger scale-up (...) no matching instance type No node group / NodePool offers an instance the pod fits Autoscaler/Karpenter logs Add a node group/NodePool with a fitting type

The container waiting-reason reference

When the pod is scheduled but the container is Waiting, .state.waiting.reason names the class.

reason Meaning Typical root cause Confirm
ContainerCreating kubelet is setting up the sandbox Normal briefly; if stuck: CNI IP or volume mount describe pod Events (FailedCreatePodSandBox)
ErrImagePull First image-pull attempt failed Auth, route, wrong tag, rate limit describe pod Events suffix
ImagePullBackOff Kubelet is backing off after repeated pull failures Same as ErrImagePull, persisting Events; the suffix names it
InvalidImageName The image reference is syntactically invalid Typo in image: (bad registry/tag chars) kubectl get pod -o yaml image field
ErrImageNeverPull imagePullPolicy: Never and image absent on node Image not pre-loaded on the node Pod spec imagePullPolicy
CreateContainerConfigError Container config can’t be built Referenced ConfigMap/Secret/key doesn’t exist describe pod names the missing object
CreateContainerError Container couldn’t be created Bad command, mount, or hostPath describe pod Events
RunContainerError Container created but couldn’t start Runtime error, device, seccomp describe pod; kubelet logs
CrashLoopBackOff Container started, exited, kubelet backing off App exit, OOM, liveness kill logs --previous; Last State

The exit-code reference

For CrashLoopBackOff, the container’s Exit Code in Last State is the real diagnosis. Codes above 128 encode the killing signal as 128 + signal.

Exit code Meaning Typical EKS cause Fix
0 Clean exit A one-shot process used as a long-running container Keep it long-running, or run it as a Job/CronJob
1 General application error App threw on boot: bad config, missing env, DB unreachable logs --previous; supply config; fix the startup bug
126 Command found but not executable Entrypoint lacks execute bit / wrong file type chmod +x; fix the entrypoint
127 Command not found Bad command/args, missing binary, wrong path Fix the command; ensure the binary is in the image
128 Invalid exit / bad exec Container exec failure Check command/args and the image
137 SIGKILL (128+9) OOMKilled at the memory limit, or a SIGTERM the app ignored then force-killed Raise memory limit; fix leak; handle SIGTERM
139 SIGSEGV (128+11) Segfault — native crash, corrupt binary, arch mismatch Fix the native bug; check base image/arch
143 SIGTERM (128+15) Graceful stop (rollout, scale-in, or a liveness/eviction kill the app handled) Usually expected; confirm it’s not a probe kill
255 Exit status out of range / exit(-1) App-specific fatal; uncaught top-level error logs --previous; the app defines it

The exit codes that fool people: 137 looks like a crash but is usually an OOMKill — confirm with Reason: OOMKilled in describe, because a plain SIGKILL (a node draining, a liveness kill escalated) also shows 137 without the OOM reason. And exit 0 on a container that keeps restarting means a job image (a script that finishes) was deployed as a long-running Deployment; it completes, exits 0, and the ReplicaSet relaunches it forever — the fix is a Job/CronJob, not a Deployment.

The decision table — state/string → class → first move

When the clock is running, collapse everything above into one lookup: read the state and the event string, name the class, take the first action.

If you see… It’s probably… Do this first
Pending + Insufficient cpu/memory Requests too big / no capacity Right-size requests; check Autoscaler/Karpenter isn’t at max
Pending + untolerated taint Missing toleration Add the toleration (or fix the taint)
Pending + didn't match node affinity/selector Label/selector mismatch Fix nodeSelector/labels
Pending + unbound ... PersistentVolumeClaims Storage not provisioning Install EBS CSI + IRSA; default StorageClass
Pending + volume node affinity conflict EBS/pod AZ mismatch Same-AZ node; WaitForFirstConsumer
Stuck ContainerCreating + failed to assign an IP CNI IP exhaustion Prefix delegation; bigger instance; subnet IPs
ImagePullBackOff + not found Wrong image/tag Fix the tag; pin a digest
ImagePullBackOff + denied/no basic auth ECR/registry auth Node-role ECR perms; imagePullSecret; repo policy
ImagePullBackOff + i/o timeout No route to ECR Add NAT or ECR+S3 endpoints
ImagePullBackOff + toomanyrequests Docker Hub 429 Mirror to ECR / public.ecr.aws / authenticate
CreateContainerConfigError Missing ConfigMap/Secret/key Create it / fix the key name
CrashLoopBackOff + exit 1 App error on boot logs --previous; supply config/secret
CrashLoopBackOff + OOMKilled/137 Memory limit too low / leak Raise limit; fix leak
CrashLoopBackOff + Liveness probe failed Probe killing a slow starter startupProbe; raise initialDelaySeconds
Running 0/1 forever Readiness failing Fix readiness path/deps (pod is live, not ready)
Node NotReady Node/CNI/kubelet/pressure describe node; kube-system pods

Pending: no node will take the pod

Pending is the scheduler telling you it filtered every node and none survived. The FailedScheduling event names the reason per node — read it first, then match it to one of the five causes below.

Insufficient CPU/memory requests (and the autoscaler not scaling)

The most common Pending: the pod’s requests exceed what any node has free. Remember the scheduler filters on requests, so a pod that uses 100 MiB but requests 8 GiB needs a node with 8 GiB free.

Symptom Root cause Confirm Fix
Pending, Insufficient cpu requests.cpu > any node’s allocatable free CPU kubectl describe node → Allocated resources Lower the request; add/enlarge nodes
Pending, Insufficient memory requests.memory > free memory on every node Same Right-size; scale out; bigger instance
Pending even though nodes look idle Requests are set high “to be safe”; usage is low kubectl top nodes vs requests Set requests to real usage + headroom
Pending that never resolves Autoscaler/Karpenter can’t or won’t add a node Autoscaler/Karpenter controller logs See the scale-up table below
Pending after a scale-out Node group at max size Node group --scaling-config; CA logs max node group size reached Raise max; add capacity

On EKS you add nodes automatically with one of two systems, and they fail to scale for different reasons. Knowing which you run tells you where to look.

Aspect Cluster Autoscaler (CA) Karpenter
How it adds nodes Increases an ASG’s desired count (fixed node groups) Provisions right-sized EC2 directly from a NodePool
Won’t scale when ASG at maxSize; pod fits no node group’s type NodePool limits (cpu/memory) reached; no matching instance
Common “stuck Pending” Max size reached; pod bigger than any group’s node NodePool limit hit; constraints exclude all types
Where to look kubectl logs -n kube-system deploy/cluster-autoscaler kubectl logs -n karpenter deploy/karpenter
Event on the pod pod didn't trigger scale-up: max node group size reached did not schedule pod ... incompatible with nodepool
Fix lever Raise maxSize; add a node group with a fitting type Raise NodePool limits; widen instance requirements

The trap: a node group whose instances are t3.medium (2 vCPU, 4 GiB) will never satisfy a pod that requests 3 CPU, and CA knows it — so it does not even try to scale, and the pod sits Pending with pod didn't trigger scale-up. The fix is not “wait longer”; it is to add a node group (or Karpenter requirement) that offers a large-enough instance, or to shrink the request.

Node selector, affinity, and taints/tolerations

A pod can be unschedulable even with idle nodes, because you (or a controller) told the scheduler to avoid them. Three mechanisms do this.

Mechanism What it does Failure event Fix
nodeSelector Hard: pod only lands on nodes with these labels didn't match Pod's node affinity/selector Fix the label on nodes or the selector
nodeAffinity (required) Hard: richer label expressions Same event Correct the expression / node labels
nodeAffinity (preferred) Soft: a scoring preference, not a filter (doesn’t block scheduling) N/A — it never causes Pending
podAffinity (required) Must co-locate with matching pods didn't match pod affinity rules Ensure the target pods exist/are labelled
podAntiAffinity (required) Must not co-locate; needs spare nodes didn't match pod anti-affinity rules Add nodes; relax to preferred
Taint + toleration Node repels pods that lack a matching toleration had untolerated taint {key: value} Add the toleration, or remove the taint

Some taints are set by Kubernetes automatically and are worth recognizing on sight — a pod stuck behind one of these usually means the node is the problem, not the pod.

Well-known taint Set when Effect What it means for your pod
node.kubernetes.io/not-ready Node condition Ready=False NoSchedule/NoExecute Node is unhealthy — fix the node
node.kubernetes.io/unreachable Node controller lost contact NoExecute kubelet/network problem on the node
node.kubernetes.io/disk-pressure Low disk NoSchedule Node evicting; free disk / bigger volume
node.kubernetes.io/memory-pressure Low memory NoSchedule Node under memory pressure
node.kubernetes.io/pid-pressure PID exhaustion NoSchedule Too many processes on the node
node.kubernetes.io/unschedulable kubectl cordon NoSchedule Node cordoned for maintenance
node.cloudprovider.kubernetes.io/uninitialized Node just joined NoSchedule Cloud controller hasn’t initialized it
Custom (e.g. dedicated=gpu:NoSchedule) You set it NoSchedule Pod needs a matching toleration

PVC unbound: EBS CSI, StorageClass, AZ mismatch

A pod that mounts a PersistentVolumeClaim cannot schedule until that claim binds to a PersistentVolume. On modern EKS this requires the EBS CSI driver — it is no longer built in, so a fresh cluster with no CSI addon leaves every dynamic PVC Pending forever.

Symptom Root cause Confirm Fix
Pending, unbound immediate PersistentVolumeClaims PVC never binds kubectl get pvc shows Pending Install aws-ebs-csi-driver addon + IRSA
PVC Pending, no events No default StorageClass kubectl get sc (none marked default) Mark one default; set storageClassName
PVC Pending with a StorageClass CSI controller can’t provision (IAM) kubectl logs -n kube-system deploy/ebs-csi-controller Grant the CSI IRSA role EBS perms
Pod Pending, volume node affinity conflict EBS volume in AZ-a, only nodes in AZ-b kubectl describe pvnodeAffinity AZ Node in the volume’s AZ; WaitForFirstConsumer
PVC binds but pod stuck ContainerCreating Attach/mount failure describe podFailedAttachVolume/FailedMount Check CSI node DaemonSet; volume not already attached
WaitForFirstConsumer PVC “stuck” Pending Normal until a pod consumes it It’s expected; binds when the pod schedules No action — this is correct behaviour

The AZ trap is the subtle one: EBS volumes are AZ-scoped, so a volume created in ap-south-1a can only attach to a node in ap-south-1a. If the scheduler puts the pod on a node in 1b, you get volume node affinity conflict. The fix that prevents it entirely is volumeBindingMode: WaitForFirstConsumer on the StorageClass — the volume is only provisioned after the scheduler picks a node, guaranteeing they share an AZ.

# Terraform: the EBS CSI driver as an EKS addon, wired to an IRSA role (so PVCs can bind)
resource "aws_eks_addon" "ebs_csi" {
  cluster_name             = aws_eks_cluster.this.name
  addon_name               = "aws-ebs-csi-driver"
  service_account_role_arn = aws_iam_role.ebs_csi_irsa.arn  # least-priv EBS perms
  resolve_conflicts_on_update = "OVERWRITE"
}
# A StorageClass that avoids the AZ trap by binding only when a pod consumes it
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
  annotations: { storageclass.kubernetes.io/is-default-class: "true" }
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer   # <-- provisions in the pod's AZ, not eagerly
parameters: { type: gp3, encrypted: "true" }

VPC CNI IP exhaustion — the nastiest Pending cousin

This one hides in plain sight because the pod is not, strictly, Pending — it is scheduled and then stuck in ContainerCreating, because the AWS VPC CNI could not hand it an IP. On EKS every pod gets a real VPC IP from an ENI attached to its node, so the number of pods a node can run is capped by IP capacity, not just CPU/memory.

The max-pods a node supports (without prefix delegation) is (ENIs × (IPv4-per-ENI − 1)) + 2. That cap is small on small instances, and it is the invisible ceiling that a scale-out hits first.

Instance type ENIs IPv4 / ENI max-pods (no prefix) max-pods (prefix delegation)
t3.small 3 4 11 (capped at 110)
t3.medium 3 6 17 ~110
m5.large 3 10 29 ~110
m5.xlarge 4 15 58 ~110
m5.2xlarge 4 15 58 ~110
m5.4xlarge 8 30 234 (capped at 250 by default)
Symptom Root cause Confirm Fix
Stuck ContainerCreating, failed to assign an IP address to container ENIs have no free secondary IP describe pod Events; aws-node logs InsufficientFreeAddressesOnInterface Enable prefix delegation; bigger instance
Pending, too many pods Node hit max-pods kubectl get node -o jsonpath='{..allocatable.pods}' Prefix delegation; more/bigger nodes
Subnets run dry cluster-wide Small subnets, many pods Subnet free IPs in the console/CLI Bigger subnets; secondary CIDR; prefix delegation
New pods fail only during a scale-up Warm-pool IPs exhausted under burst aws-node env WARM_IP_TARGET/MINIMUM_IP_TARGET Tune warm targets; prefix delegation

Prefix delegation is the real fix: instead of assigning individual secondary IPs, the CNI assigns /28 prefixes (16 IPs) to each ENI, multiplying capacity ~16× and letting a node run up to 110 (small) or 250 pods. Enable it on the aws-node DaemonSet (or the VPC CNI addon) and the IP ceiling stops being the bottleneck.

# Enable prefix delegation on the VPC CNI so each ENI carries /28 prefixes (16 IPs each)
kubectl set env daemonset aws-node -n kube-system ENABLE_PREFIX_DELEGATION=true
kubectl set env daemonset aws-node -n kube-system WARM_PREFIX_TARGET=1
# New/replaced nodes then advertise a much higher allocatable pods count.
# Terraform: same, but as declarative addon configuration (survives addon updates)
resource "aws_eks_addon" "vpc_cni" {
  cluster_name  = aws_eks_cluster.this.name
  addon_name    = "vpc-cni"
  configuration_values = jsonencode({
    env = { ENABLE_PREFIX_DELEGATION = "true", WARM_PREFIX_TARGET = "1" }
  })
}

Pod topology spread and anti-affinity

The last Pending class: you asked to spread pods across zones or nodes and there is nowhere left to spread to.

Constraint What it enforces Why it causes Pending Fix
topologySpreadConstraints (DoNotSchedule) Even spread across a topologyKey within maxSkew No node keeps the skew within bounds Loosen maxSkew; whenUnsatisfiable: ScheduleAnyway
podAntiAffinity (requiredDuringScheduling) One pod per node/zone Fewer eligible nodes than replicas Add nodes; relax to preferred
topologyKey: kubernetes.io/hostname One replica per node Replicas > nodes Scale nodes; use zone spread instead

ImagePullBackOff and ErrImagePull

ErrImagePull is the first failed pull; ImagePullBackOff is the kubelet backing off after repeated failures (backoff grows to a 5-minute cap). The container never starts. Read the suffix in the describe pod Events — it is the actual diagnosis.

Event suffix / symptom Root cause Confirm Fix
... not found / manifest unknown Wrong image name or :tag/digest The event names the ref; check the repo Push the tag, or pin an existing @sha256: digest
... no basic auth credentials / denied ECR/registry auth failed Node role’s ECR perms; repo policy Attach AmazonEC2ContainerRegistryReadOnly to the node role
... dial tcp ...:443: i/o timeout No route to the registry Private subnet, no NAT/endpoints Add NAT or ECR (api+dkr) + S3 endpoints
... toomanyrequests: ... rate limit Docker Hub anonymous 429 Pulling nginx:latest etc. from Docker Hub Mirror to ECR / use public.ecr.aws / authenticate
... no matching manifest for linux/arm64 Arch mismatch Image arch vs node arch (Graviton?) Build multi-arch; match node arch
... x509 / TLS error Cert / proxy interception Corporate proxy, custom CA Trust the CA; fix the proxy
InvalidImageName Malformed image: string kubectl get pod -o yaml Correct the registry/repo/tag syntax

The exact ECR permissions a pull needs (on the node role)

An ECR pull needs four actions. On EKS the kubelet performs the pull using the node instance role, so these live on the node role — the managed AmazonEC2ContainerRegistryReadOnly policy grants exactly them.

Action Resource Why
ecr:GetAuthorizationToken * Get a registry auth token (account-wide)
ecr:BatchCheckLayerAvailability repo ARN Check which layers are already present
ecr:GetDownloadUrlForLayer repo ARN Get the (S3-backed) URL for each layer
ecr:BatchGetImage repo ARN Fetch the image manifest

For a cross-account private ECR pull, the node role in account B is not enough — the repository in account A also needs a repository policy allowing account B (or its node role) to pull. And for a private Docker Hub or third-party registry, you attach an imagePullSecret to the pod’s ServiceAccount. None of these is IRSA — which is the confusion the next section clears up.

IRSA vs the node role for image pull — the confusion that wastes hours

Here is the single most misdiagnosed ImagePullBackOff: a team has set up IRSA (IAM Roles for Service Accounts) so their pod can call S3/DynamoDB, they hit ImagePullBackOff on a private ECR image, and they spend an hour adding ECR permissions to the IRSA role — with no effect. The reason is a timing fact about how identities are delivered.

Question Node instance role IRSA (pod role)
Who uses it The kubelet (node agent) Your application container (its AWS SDK)
When Before the container starts — during image pull After the container is running
Delivered how EC2 instance profile on the node Projected OIDC token mounted into the pod
Governs Image pull from ECR; CNI; logs The app’s own AWS API calls at runtime
Fixes ImagePullBackOff? Yes — this is the one to change No — it isn’t active until after the pull

The image pull happens before your container exists, performed by the kubelet using the node’s EC2 instance profile. IRSA credentials are injected into the running container via a projected token — they are simply not present at pull time. So the fix for an ECR-auth ImagePullBackOff is always the node role (or a repo policy, or an imagePullSecret), never IRSA. IRSA is the right fix for the opposite symptom: the pod is Running and your app logs AccessDenied on an S3 call.

CrashLoopBackOff: the container keeps dying

CrashLoopBackOff means the container started, then exited, and the kubelet is restarting it with an exponential back-off (roughly 10s, 20s, 40s… capped at 5 minutes). The pod is not broken at pull or schedule — it ran your code and your code (or the platform) killed it. The two commands that crack it: kubectl logs --previous (the dead container’s own words) and kubectl describe pod (the Last State, Reason, and Exit Code).

App exits non-zero, or a missing config/secret

Symptom Exit Code / reason Root cause Fix
Cycles immediately, exit 1 Error, exit 1 App threw on boot (bad config, DB unreachable, missing env) logs --previous; supply config; fix the dep
CreateContainerConfigError (not CrashLoop) Referenced ConfigMap/Secret/key doesn’t exist describe names it; create it / fix key name
Exit 127 Error, exit 127 Bad command/args, missing binary Correct the command; verify the binary path
Exit 126 Error, exit 126 Entrypoint not executable chmod +x; fix the file
Exit 0, keeps restarting Completed then restart A one-shot/job image run as a Deployment Run as a Job/CronJob, or keep it long-running
Exit 139 Error, exit 139 Segfault — native crash or arch mismatch Fix the native bug; check base image/arch

A crucial distinction: a missing environment variable value (the app reads DB_HOST, it’s empty, the app throws) is a CrashLoopBackOff with exit 1 — the container started. But a missing referenced object (envFrom a Secret that doesn’t exist, or a secretKeyRef to a key that isn’t there) is CreateContainerConfigError — the container never started, because the kubelet couldn’t assemble its config. The first you debug with logs --previous; the second describe tells you outright, naming the missing ConfigMap/Secret.

The liveness probe killing a slow-starting app

The cruelest CrashLoopBackOff, because the app is fine — it is just slow to boot, and the liveness probe is killing it before it finishes. A liveness probe that starts checking at initialDelaySeconds: 5 against an app that needs 45 seconds to warm up will fail, the kubelet will kill the container (Killing container ... failed liveness probe), restart it, and the cycle repeats forever. The RESTARTS count climbs, but logs --previous shows a healthy startup that was interrupted.

Symptom Root cause Confirm Fix
Restarts climb; logs -p looks healthy but cut off Liveness probe fails during slow startup describeLiveness probe failed + Killing container Add a startupProbe; raise initialDelaySeconds/failureThreshold
Restarts under load, not at boot Liveness times out when app is busy describe → periodic Liveness probe failed Raise timeoutSeconds/failureThreshold; fix latency
Running 0/1, never gets traffic Readiness (not liveness) failing describeReadiness probe failed Fix readiness path/deps; separate from liveness
Liveness passes locally, fails in-cluster Probe hits a path/port the app doesn’t serve kubectl exec + curl the probe path Point the probe at a real endpoint/port

The right tool for slow starters is the startupProbe: it gates the liveness/readiness probes until the app has booted, giving a generous window once without loosening the steady-state liveness check. Use it instead of inflating initialDelaySeconds everywhere.

# A startupProbe gives a slow app up to 5 minutes to boot (30 × 10s), THEN liveness takes over
startupProbe:
  httpGet: { path: /healthz, port: 8080 }
  failureThreshold: 30      # 30 failures allowed...
  periodSeconds: 10         # ...checked every 10s = 300s startup budget
livenessProbe:              # only active AFTER startupProbe first succeeds
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 10
  failureThreshold: 3       # tight once running: 3 × 10s = 30s to declare dead
readinessProbe:             # gates traffic; failing here = Running 0/1, not a restart
  httpGet: { path: /ready, port: 8080 }
  periodSeconds: 5

OOMKilled (exit 137) — the memory-limit death

If a container exceeds its memory limit, the Linux kernel’s OOM killer terminates it and the kubelet records Reason: OOMKilled, Exit Code: 137. This is not the app being buggy — it is the app hitting a ceiling you set (or the default). It is distinct from node memory pressure, where the kubelet evicts whole pods to save the node.

Setting Level Behaviour Gotcha
resources.requests.memory Container Scheduling floor (guaranteed) Scheduler uses this; doesn’t cap usage
resources.limits.memory Container Hard cap — exceed it → OOMKill (137) Too low → OOMKilled under load; not “buggy app”
No limits.memory Container Can use up to node capacity One pod can trigger node memory pressure → evictions
requests == limits (memory) Container Guaranteed QoS class Least likely to be evicted; but a tight limit still OOMs
Node memory pressure Node kubelet evicts BestEffort/Burstable pods Different from OOMKill — read the event

Confirm an OOMKill two ways: describe pod shows Last State: Terminated, Reason: OOMKilled, Exit Code: 137, and kubectl top pod (or Container Insights) shows memory at ~100% of the limit just before the kill. The backoff cadence is worth memorizing so you can tell a crash loop from a slow retry.

Restart # Back-off delay Cumulative time
1 ~10 s 10 s
2 ~20 s 30 s
3 ~40 s 70 s
4 ~80 s 150 s
5 ~160 s 310 s
6+ 300 s cap +5 min each

Probes and QoS reference

Probes and QoS classes decide when a container is killed or evicted, so they underpin half the CrashLoop and eviction failures above.

Probe Question it asks On failure Use for
livenessProbe “Is the process wedged?” Restart the container Deadlock/hang detection
readinessProbe “Can it serve traffic now?” Remove from Service endpoints (no restart) Load-balancing gate
startupProbe “Has it finished booting?” Restart if startup budget exceeded; gates the other two Slow-starting apps
Probe setting Default What it controls When to change
initialDelaySeconds 0 Delay before the first check Raise for slow boots (or use startupProbe)
periodSeconds 10 Interval between checks Lower for faster detection
timeoutSeconds 1 How long a check may take Raise for slow endpoints (avoid false kills)
failureThreshold 3 Consecutive fails before acting Raise to tolerate blips
successThreshold 1 Consecutive successes to recover Readiness flap control
QoS class Set by Eviction priority When you get it
Guaranteed requests == limits for cpu and memory Evicted last Critical workloads
Burstable Requests set, but < limits (or partial) Evicted after BestEffort The common case
BestEffort No requests or limits Evicted first Never for production

Node NotReady and DNS failures

Two failures are not about the pod at all — the node is sick, or cluster DNS is broken — but they surface as unschedulable or crash-looping pods, so they belong in the playbook.

Node NotReady

kubectl get nodes showing NotReady means the node’s Ready condition is False — the kubelet has stopped reporting healthy, so the scheduler taints it and places nothing there (and may evict what’s running).

Root cause Confirm Fix
aws-node (VPC CNI) pod not running kubectl get pods -n kube-system -l k8s-app=aws-node Fix CNI DaemonSet; node role AmazonEKS_CNI_Policy
kubelet down / unhealthy kubectl describe node conditions; SSM to the node, journalctl -u kubelet Restart kubelet; check the node’s bootstrap
DiskPressure describe nodeDiskPressure=True Free disk; bigger volume; prune images
MemoryPressure describe nodeMemoryPressure=True Bigger instance; set limits; evict noisy pods
PIDPressure describe nodePIDPressure=True Fewer processes; bigger instance
Node never joined the cluster Node not in kubectl get nodes at all aws-auth ConfigMap / EKS access entry; node role; SG
kube-proxy not running kubectl get pods -n kube-system -l k8s-app=kube-proxy Fix the DaemonSet; Service networking breaks without it
Instance unhealthy / terminated EC2 console; ASG activity Let the ASG replace it; check the launch template

A node that never even appears in kubectl get nodes is almost always an authorization problem: the node’s IAM role isn’t mapped in the aws-auth ConfigMap (or a modern EKS access entry), or the node role lacks AmazonEKSWorkerNodePolicy/AmazonEKS_CNI_Policy/AmazonEC2ContainerRegistryReadOnly, or the node’s security group can’t reach the cluster API endpoint.

Node role policy Grants Symptom if missing
AmazonEKSWorkerNodePolicy Node ↔ control-plane operations Node never becomes Ready
AmazonEKS_CNI_Policy VPC CNI to manage ENIs/IPs aws-node fails; no pod IPs
AmazonEC2ContainerRegistryReadOnly Pull images from ECR ImagePullBackOff on private ECR

CoreDNS and DNS failures

When pods can’t resolve my-svc or api.example.com, the culprit is usually CoreDNS (the cluster DNS, running as a Deployment in kube-system) or the path to it.

Symptom Root cause Confirm Fix
lookup ... no such host / i/o timeout CoreDNS pods down or overloaded kubectl get pods -n kube-system -l k8s-app=kube-dns Scale CoreDNS; check it’s Running
DNS works then flakes under load Too few CoreDNS replicas kubectl top CoreDNS; query latency Scale replicas; NodeLocal DNSCache
Only external names fail ndots:5 search-domain overhead / upstream kubectl exec + nslookup internal vs external Tune ndots; check upstream resolver
All DNS fails on some nodes Security group blocks UDP/TCP 53 Node/cluster SG rules Allow 53 within the cluster SG
CoreDNS CrashLoopBackOff with plugin/loop Resolver loop in the node’s /etc/resolv.conf kubectl logs -n kube-system deploy/coredns Fix upstream resolver; the loop plugin config
Service resolves, connection refused kube-proxy not programming iptables/IPVS kubectl get pods -n kube-system -l k8s-app=kube-proxy Fix kube-proxy DaemonSet
# The canonical DNS smoke test from inside the cluster
kubectl run -it --rm dns-test --image=busybox:1.36 --restart=Never -- \
  nslookup kubernetes.default.svc.cluster.local
# Expect: an answer with 10.100.0.1 (the kube-dns/CoreDNS service ClusterIP)

Architecture at a glance

The diagram traces a pod’s real journey to Running and pins each failure class to the exact gate where it bites. Read it left to right: you kubectl apply a pod spec and the API server + kube-scheduler try to place it; the schedule gate filters nodes on requests, taints, and affinity (a miss → Pending), then the VPC CNI must hand the pod a real IP (no free IP → stuck ContainerCreating) and any PVC must bind through the EBS CSI driver (unbound → Pending); the kubelet then pulls the image from ECR under the node role (auth/route/tag failure → ImagePullBackOff); the container runs and can still die from a bad config, an OOMKill at the memory limit, or a liveness probe firing too early (→ CrashLoopBackOff); and the outcome is Running/Ready or a restart. Every one of the six numbered badges is a class you confirm by reading kubectl describe pod (Events + Last State), kubectl logs --previous, kubectl get events, and kubectl top.

Amazon EKS pod startup failure map showing a pod moving left to right through five zones — a submit zone with the pod spec and the EKS API server plus kube-scheduler, a schedule zone that checks node fit (CPU/memory/taints), the VPC CNI IP assignment, and PVC binding, an image-pull zone where the kubelet pulls from ECR under the node role, a run/crash zone where the container starts and runs liveness probes under a memory limit, and a ready/confirm zone that reaches Running-and-Ready or is diagnosed with kubectl describe, logs --previous, and events — with six numbered badges marking no-schedulable-node Pending at the node-fit hop, VPC CNI IP exhaustion at the CNI hop, PVC unbound at the storage hop, ImagePullBackOff at the ECR pull hop, CrashLoopBackOff and OOMKilled at the container-run hop, and the confirm-from-kubectl habit at the outcome

Real-world scenario

Streamline Analytics, a data startup running its ingestion API on EKS in ap-south-1, scaled a service from 20 to 200 replicas ahead of a customer launch and took the API down for thirty-five minutes — through three separate failures that surfaced in sequence, each fixed only after someone finally read kubectl describe pod.

The first failure hit at replica ~120: new pods hung in ContainerCreating, not Pending, which sent the on-call engineer down the wrong path — “the scheduler is fine, nodes have CPU, why won’t they start?” The nodes were m5.large (a max-pods of 29 without prefix delegation), and at 120 pods across four nodes the ENIs were out of secondary IPs. The tell was in the pod Events: failed to assign an IP address to container ... InsufficientFreeAddressesOnInterface. They enabled prefix delegation on the VPC CNI (ENABLE_PREFIX_DELEGATION=true, WARM_PREFIX_TARGET=1), and as the node group rolled to replace nodes, max-pods jumped to 110 per node and the IP wall disappeared. The lesson they wrote down: on EKS, a node’s real pod ceiling is IPs, not CPU — and IP exhaustion looks like ContainerCreating, not Pending.

The second failure appeared as the pods that did get IPs went straight to ImagePullBackOff. The image lived in a private ECR repo in a different AWS account (a shared platform account), and the node role in the workload account had AmazonEC2ContainerRegistryReadOnly — which lets it pull from its own account’s ECR, but the cross-account repo’s repository policy did not grant the workload account’s node role. The engineer’s first instinct was to “fix IRSA,” and twenty minutes evaporated adding ECR permissions to the IRSA role — with no effect, because the kubelet pulls under the node role, before the container (and IRSA) exist. Once they read that the pull identity is the node role, the fix was a one-line repository policy in the platform account granting the workload node role ecr:BatchGetImage and friends. Pulls succeeded.

The third failure was the subtle one. With IPs and images working, pods reached Running and then began CrashLoopBackOff — but only about a third of them, and kubectl logs on a crashing pod showed nothing useful. kubectl logs --previous was the unlock: the JVM-based service took ~40 seconds to warm up, and the livenessProbe had initialDelaySeconds: 10, failureThreshold: 3 — a 40-second boot against a 40-second liveness budget, so under the heavier scheduling contention of a 200-pod rollout, the slower-starting pods tripped the probe and were killed mid-boot (describe showed Liveness probe failed and Killing container). They added a startupProbe with a 300-second budget (failureThreshold: 30, periodSeconds: 10) that gated liveness until boot completed, and left the steady-state liveness tight. READY climbed to 200/200 and held. Every one of the three was readable from describe/logs --previous in the first two minutes — had they started there instead of guessing.

Advantages and disadvantages

EKS’s Kubernetes model is self-healing and declarative, which is both its strength and the reason a stuck pod is so opaque: the cluster keeps trying, loudly restarting and rescheduling, but the why is in a status field you must go read.

Advantages (of the EKS/Kubernetes model) Disadvantages (the traps to manage)
Self-healing: crashed pods restart, failed nodes drain automatically A crash loop cycles quietly until you look at describe/logs -p
Every failure writes an exact Event / Reason / Exit Code You must read them — get pods alone hides the cause
Real VPC IPs per pod: native security groups, flow logs, routing Subnets and max-pods exhaust; IP is the hidden ceiling
Fine-grained IAM per pod via IRSA IRSA vs node role confusion breaks image-pull diagnosis
Probes give real liveness/readiness/startup control A mis-tuned liveness probe causes CrashLoopBackOff
Rich scheduling: affinity, taints, spread, topology The same features make pods unschedulable in subtle ways
Portable, huge ecosystem, CNCF-standard More moving parts than ECS; a steeper operational floor

The advantages dominate for teams that need Kubernetes portability, fine-grained scheduling, and a large ecosystem, and who invest in reading the platform’s signals. The disadvantages dominate when you treat EKS like “ECS with more YAML”: ignoring the VPC CNI’s IP math, copying probes from a blog, or confusing the two identities. The skill is configuring the guardrails — prefix delegation, right-sized requests, a startupProbe, the node-role ECR permissions — before the incident, and knowing the Event string already contains the answer.

Hands-on lab

You will reproduce and fix the three states that cause the most EKS pod outages — a Pending pod (an impossible CPU request), a CrashLoopBackOff (a bad command), and an ImagePullBackOff (a private-ECR image that won’t pull) — reading the diagnosis from kubectl describe/logs --previous each time. This assumes you already have a small EKS cluster with a managed node group and kubectl configured (if not, stand one up first with the companion EKS cluster-setup hands-on). Everything here is free-tier-adjacent: the pods are tiny and short-lived; the only real cost is the cluster’s control plane (~$0.10/hour) and its nodes, which you already run. Work in ap-south-1.

Step 0 — Point kubectl at the cluster and confirm nodes.

export AWS_DEFAULT_REGION=ap-south-1
aws eks update-kubeconfig --name lab-eks
kubectl get nodes -o wide          # expect: 1-2 nodes, STATUS Ready
kubectl create namespace lab 2>/dev/null || true

Step 1 — Reproduce Pending with an impossible CPU request. Ask for more CPU than any node has, so the scheduler can filter every node out.

cat <<'YAML' | kubectl apply -n lab -f -
apiVersion: v1
kind: Pod
metadata: { name: pending-demo }
spec:
  containers:
    - name: app
      image: public.ecr.aws/nginx/nginx:stable
      resources: { requests: { cpu: "64" } }   # 64 vCPU — no node has this
YAML
sleep 5
kubectl get pod pending-demo -n lab             # STATUS: Pending
kubectl describe pod pending-demo -n lab | sed -n '/Events:/,$p'

Expected: STATUS: Pending, and the Events block ends with Warning FailedScheduling ... 0/N nodes are available: N Insufficient cpu. The image and everything else are fine — this is purely a request no node can satisfy. That fingerprint (Insufficient cpu) is the whole diagnosis.

Step 2 — Fix the Pending pod (right-size the request).

kubectl delete pod pending-demo -n lab
cat <<'YAML' | kubectl apply -n lab -f -
apiVersion: v1
kind: Pod
metadata: { name: pending-demo }
spec:
  containers:
    - name: app
      image: public.ecr.aws/nginx/nginx:stable
      resources: { requests: { cpu: "100m" } }  # 0.1 vCPU — fits easily
YAML
sleep 10
kubectl get pod pending-demo -n lab             # STATUS: Running

Expected: STATUS: Running. Same pod, same image — the only change was a request the node could satisfy. (In production the other fix is to let Cluster Autoscaler/Karpenter add a node — but only if a node type large enough exists.) Clean up: kubectl delete pod pending-demo -n lab.

Step 3 — Reproduce CrashLoopBackOff with a bad command. Give the container a command that exits non-zero immediately.

cat <<'YAML' | kubectl apply -n lab -f -
apiVersion: v1
kind: Pod
metadata: { name: crash-demo }
spec:
  containers:
    - name: app
      image: public.ecr.aws/docker/library/busybox:1.36
      command: ["sh", "-c", "echo starting; sleep 2; exit 1"]  # exits 1, forever
YAML
sleep 30
kubectl get pod crash-demo -n lab               # STATUS: CrashLoopBackOff, RESTARTS climbing
kubectl describe pod crash-demo -n lab | grep -A3 'Last State'
kubectl logs crash-demo -n lab --previous       # the crashed attempt's own words

Expected: STATUS: CrashLoopBackOff, RESTARTS incrementing, Last State: Terminated, Reason: Error, Exit Code: 1, and kubectl logs --previous printing starting (proving the container ran and then exited 1). This is the habit: --previous shows the dead container, not the one about to start.

Step 4 — Fix the CrashLoopBackOff (a command that stays up).

kubectl delete pod crash-demo -n lab
cat <<'YAML' | kubectl apply -n lab -f -
apiVersion: v1
kind: Pod
metadata: { name: crash-demo }
spec:
  containers:
    - name: app
      image: public.ecr.aws/docker/library/busybox:1.36
      command: ["sh", "-c", "echo healthy; sleep 3600"]  # stays alive
YAML
sleep 10
kubectl get pod crash-demo -n lab               # STATUS: Running, RESTARTS 0

Expected: STATUS: Running, RESTARTS 0. Clean up: kubectl delete pod crash-demo -n lab.

Step 5 — Reproduce ImagePullBackOff from a private ECR repo. Create an empty private repo and reference a tag that was never pushed — a reliable, safe way to force the pull failure without touching your node role.

ACCT=$(aws sts get-caller-identity --query Account --output text)
aws ecr create-repository --repository-name lab-app >/dev/null 2>&1 || true
cat <<YAML | kubectl apply -n lab -f -
apiVersion: v1
kind: Pod
metadata: { name: pull-demo }
spec:
  containers:
    - name: app
      image: ${ACCT}.dkr.ecr.ap-south-1.amazonaws.com/lab-app:does-not-exist
YAML
sleep 30
kubectl get pod pull-demo -n lab                # STATUS: ImagePullBackOff
kubectl describe pod pull-demo -n lab | grep -A2 -i 'Failed'

Expected: STATUS: ImagePullBackOff, and Events containing Failed to pull image ... not found (the repo exists but the does-not-exist tag doesn’t). Read the suffix: not found is a wrong-tag problem, not a permissions problem. Had the suffix been no basic auth credentials/denied, the fix would be the node role’s ECR permissions (AmazonEC2ContainerRegistryReadOnly) or a cross-account repository policy — never IRSA, because the kubelet pulls before the container’s IRSA identity exists.

Step 6 — Fix the ImagePullBackOff (a valid public image).

kubectl delete pod pull-demo -n lab
kubectl run pull-demo -n lab --image=public.ecr.aws/nginx/nginx:stable
sleep 15
kubectl get pod pull-demo -n lab                # STATUS: Running

Expected: STATUS: Running. Same pod shape, a valid image reference this time.

Validation checklist. You reproduced three distinct states and confirmed each from its own fingerprint:

Step State reproduced The fingerprint The fix you applied
1–2 Pending FailedScheduling ... Insufficient cpu; image valid Right-size requests.cpu (prod: scale nodes)
3–4 CrashLoopBackOff Last State: Terminated, Exit Code: 1; logs -p shows the crash Fix the command so the process stays up
5–6 ImagePullBackOff Failed to pull image ... not found Correct the image/tag (perms path: node role, not IRSA)

Teardown.

kubectl delete namespace lab
aws ecr delete-repository --repository-name lab-app --force >/dev/null 2>&1 || true
# The cluster + node group keep costing money — delete them if this was a throwaway:
# eksctl delete cluster --name lab-eks   (or terraform destroy)

Cost note. The pods ran for seconds and cost effectively nothing. The cluster control plane bills ~$0.10/hour (~₹9/hr, ~₹6,000/mo if left up) and the nodes bill as EC2 — so the meaningful charge is the cluster itself. Delete the whole cluster if it was created just for this lab.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. First the scannable master table you read while READY is stuck at 0/N, then the three nastiest failures in full. Every row is a real failure with the state you see, the class, the root cause, the exact kubectl command to confirm, and the fix.

# State + symptom Class Root cause Confirm (kubectl) Fix
1 Pending, Insufficient cpu/memory Schedule Requests exceed any node’s free capacity describe pod Events; describe node allocatable Right-size requests; scale/enlarge nodes
2 Pending, max node group size reached Autoscale Cluster Autoscaler at ASG max logs -n kube-system deploy/cluster-autoscaler Raise max size; add a fitting node group
3 Pending, no matching instance type Autoscale No node group/NodePool offers a fitting type Karpenter/CA logs Add a node group/NodePool with the right type
4 Pending, untolerated taint {...} Schedule Missing toleration describe node → Taints Add the toleration or remove the taint
5 Pending, didn't match node affinity/selector Schedule Label/selector mismatch get nodes --show-labels vs pod selector Fix labels or nodeSelector/affinity
6 Pending, unbound ... PersistentVolumeClaims Storage No EBS CSI / StorageClass / provisioner get pvc; get sc; CSI controller logs Install EBS CSI + IRSA; default StorageClass
7 Pending, volume node affinity conflict Storage EBS volume AZ ≠ node AZ describe pv nodeAffinity Same-AZ node; WaitForFirstConsumer
8 Stuck ContainerCreating, failed to assign an IP CNI VPC CNI IP exhaustion describe pod; aws-node logs; max-pods Prefix delegation; bigger instance; subnet IPs
9 Pending, too many pods CNI Node hit max-pods cap get node -o jsonpath='{..allocatable.pods}' Prefix delegation; more/bigger nodes
10 ImagePullBackOff, not found Image Wrong image name/tag describe pod Events suffix Fix the tag; pin a @sha256: digest
11 ImagePullBackOff, denied/no basic auth Image ECR/registry auth (node role / repo policy) describe pod; node role policies Attach AmazonEC2ContainerRegistryReadOnly to node role; repo policy
12 ImagePullBackOff, i/o timeout Image No route to ECR from a private subnet describe pod; route table; endpoints Add NAT or ECR (api+dkr) + S3 endpoints
13 ImagePullBackOff, toomanyrequests Image Docker Hub anonymous 429 The event suffix Mirror to ECR / public.ecr.aws / authenticate
14 CreateContainerConfigError Config Referenced ConfigMap/Secret/key missing describe pod names the object Create it; fix the key/name
15 CrashLoopBackOff, exit 1 App exit App threw on boot logs --previous; describe Last State Supply config/secret; fix the startup bug
16 CrashLoopBackOff, OOMKilled/137 OOM Container hit its memory limit describe Reason: OOMKilled; top pod Raise limits.memory; fix the leak
17 CrashLoopBackOff, Liveness probe failed Probe Liveness kills a slow-starting app describeKilling container + Liveness probe failed Add startupProbe; raise initialDelaySeconds
18 CrashLoopBackOff, exit 127 / exec format error App/arch Bad command, or x86 image on Graviton logs -p; image arch vs node arch Fix command; build multi-arch/match arch
19 Running 0/1 forever Readiness Readiness probe failing describeReadiness probe failed Fix readiness path/deps (pod is live, not ready)
20 Node NotReady Node kubelet/CNI/pressure describe node; kube-system pods Fix aws-node/kubelet; free disk/memory
21 Pod Running but AccessDenied in app logs IRSA App’s own AWS call denied (not pull) App logs; the IRSA role policy Add the action to the IRSA role (not node)
22 no such host / DNS timeouts DNS CoreDNS down / SG blocks 53 get pods -n kube-system -l k8s-app=kube-dns Scale CoreDNS; open 53 in the cluster SG

The three that cause the most damage, expanded:

A. VPC CNI IP exhaustion (the “it’s not Pending, it’s ContainerCreating” ghost). A service scales out and new pods hang — but in ContainerCreating, not Pending, which is the trap: the pod was scheduled (the scheduler found a node with CPU), so people stare at CPU dashboards while the real limit is invisible. On EKS the AWS VPC CNI gives every pod a real VPC IP from ENIs attached to the node, and each instance type has a hard max-pods = (ENIs × (IPs-per-ENI − 1)) + 2; an m5.large tops out at 29 pods regardless of how much CPU is free. When the ENIs run dry, the kubelet can’t create the pod sandbox and describe pod shows failed to assign an IP address to container / the aws-node logs show InsufficientFreeAddressesOnInterface. Confirm: kubectl describe pod for the sandbox event, kubectl logs -n kube-system -l k8s-app=aws-node for the ipamd error, and kubectl get node -o jsonpath='{..allocatable.pods}' for the ceiling. Fix: enable prefix delegation (ENABLE_PREFIX_DELEGATION=true on the VPC CNI addon), which assigns /28 prefixes and raises max-pods to ~110–250; or use a larger instance (more ENIs); or add subnet capacity / a secondary VPC CIDR when the subnets (not just the node) are exhausted. The mistake is “add CPU” — that changes nothing when the ceiling is IPs.

B. OOMKilled versus a liveness-probe kill (two restarts that look identical). Both make RESTARTS climb and both show Last State: Terminated, but they need opposite fixes, and telling them apart takes one field. An OOMKill happens when the container exceeds its limits.memory: describe pod shows Reason: OOMKilled, Exit Code: 137, and kubectl top pod shows memory pinned at ~100% of the limit just before the kill — the fix is to raise the memory limit (or fix a genuine leak), never to restart. A liveness kill happens when the app is healthy but too slow (or briefly too busy) to answer the probe: describe pod shows Warning Unhealthy: Liveness probe failed followed by Normal Killing: ... failed liveness probe, the exit code is whatever the app returns on SIGTERM (often 143), and logs --previous shows a normal startup that was interrupted — the fix is a startupProbe (or a higher initialDelaySeconds/failureThreshold), never more memory. Reading 137 + OOMKilled versus Liveness probe failed in the Events is the entire diagnosis; guessing wrong doubles the outage.

C. IRSA versus the node role for image pull (the identity you can’t fix with IRSA). A team with IRSA configured hits ImagePullBackOff on a private ECR image and pours time into the IRSA role’s ECR permissions — with zero effect — because of a timing fact: the kubelet performs the image pull using the node instance role, before your container exists, and IRSA credentials are only projected into the running container. They are not present at pull time. So an ECR-auth pull failure (no basic auth credentials, denied) is fixed on the node role (AmazonEC2ContainerRegistryReadOnly), or — for a cross-account repo — with a repository policy granting the node role, or — for a private third-party registry — with an imagePullSecret on the pod’s ServiceAccount. Confirm: read the describe pod suffix (denied/no basic auth = auth, not route or tag), then check the node role’s attached policies, not IRSA. The tell that you have the right identity: IRSA is the correct fix only for the opposite symptom — a pod that is already Running and whose app logs AccessDenied on its own S3/DynamoDB call. Start-time pull = node role; run-time app call = IRSA.

Best practices

Security notes

Cost & sizing

EKS charges ~$0.10/hour per cluster for the control plane (~₹6,000/month, flat, regardless of size) plus whatever runs your pods — EC2 nodes (managed node groups or Karpenter) or Fargate (per-pod vCPU/GB-seconds). The failure classes above have direct cost consequences: over-requesting CPU/memory “to avoid Pending” pays for nodes you don’t use, and a crash-looping pod burns node capacity restarting forever.

Cost driver How it’s billed Right-size by Rough figure
Control plane Per cluster-hour Consolidate clusters; fewer, bigger ~$0.10/hr (~₹6,000/mo) flat
EC2 nodes Instance price Match requests to real usage; bin-pack; Karpenter consolidation e.g. m5.large ~₹7–8/hr
Fargate pods vCPU-sec + GB-sec Right-size pod requests; no idle nodes Per-pod; good for spiky/small
Over-requested pods Nodes you must add to fit inflated requests kubectl top → set requests to usage + headroom Often 30–50% waste
Spot nodes Up to ~70% off on-demand Interruptible workers + on-demand base Big savings; handle interruptions
NAT gateway Hourly + per-GB VPC endpoints for AWS egress ~₹3–4/hr + data — often the biggest line
EBS volumes (PVC) Per-GB-month + IOPS gp3 over gp2; delete orphaned PVs gp3 ~₹7/GB-mo (ap-south-1, varies)
CloudWatch/Container Insights Per metric + GB logs Sample; set log retention Grows quietly without retention

Sizing rules of thumb: set pod requests from measured usage (kubectl top, Container Insights) plus modest headroom — this is the single biggest lever on node cost, because the scheduler packs nodes by requests. Use prefix delegation so you get full pod density per node (an m5.large running 110 pods instead of 29 is ~4× the density for the same instance cost). Prefer Karpenter or capacity-provider consolidation to bin-pack and scale down idle nodes, and put interruptible workers on Spot with an on-demand base. Compare shared VPC endpoints (a few rupees/hour, flat) against a NAT gateway for private-subnet AWS egress — endpoints usually win on cost and security. In INR terms, a minimal EKS footprint (one cluster + two small nodes) starts around ₹10,000–13,000/month before the workload — the control plane and the NAT/endpoint decision are usually where the real money is.

Interview & exam questions

Q1. A pod has been Pending for ten minutes and you have thirty seconds. What’s the first command and what do you read? kubectl describe pod <pod>, and you read the Events block at the bottom — the scheduler writes a FailedScheduling event that lists the reason per node (Insufficient cpu, untolerated taint, unbound PersistentVolumeClaims). That string is the class; you fix what it names. (SAA-C03, SOA-C02)

Q2. A pod is stuck in ContainerCreating, not Pending, with failed to assign an IP address to container. What’s happening and how do you fix it? VPC CNI IP exhaustion: the pod was scheduled but the AWS VPC CNI has no free ENI IP to give it, so the sandbox can’t be created. The node hit its max-pods cap. Fix with prefix delegation (ENABLE_PREFIX_DELEGATION=true), a larger instance (more ENIs), or more subnet capacity. Adding CPU does nothing. (SAA-C03, ANS)

Q3. ImagePullBackOff on a private ECR image, and your team has IRSA set up. Where do you grant the permission? On the node instance role (AmazonEC2ContainerRegistryReadOnly), or a cross-account repository policynot IRSA. The kubelet pulls the image under the node role before the container starts, so IRSA credentials (projected into the running container) are not available at pull time. (DVA-C02, SAA-C03)

Q4. Distinguish an OOMKill from a liveness-probe kill; both show climbing restarts. OOMKill: describe pod shows Reason: OOMKilled, Exit Code: 137, memory at the limit — fix by raising limits.memory. Liveness kill: describe shows Liveness probe failed + Killing container, logs --previous shows a healthy-but-interrupted startup — fix with a startupProbe/higher initialDelaySeconds. Same symptom, opposite fixes. (SOA-C02, DVA-C02)

Q5. What is the difference between CrashLoopBackOff and CreateContainerConfigError? CrashLoopBackOff means the container started and then exited (app error, OOM, liveness kill) — debug with logs --previous. CreateContainerConfigError means the container never started because a referenced ConfigMap/Secret/key doesn’t exist — describe pod names the missing object; create it or fix the key. (DVA-C02)

Q6. A pod is Running but READY 0/1 and gets no traffic. What’s wrong? Its readiness probe is failing, so the pod is removed from its Service’s endpoints — it’s alive but not accepting traffic. This is not a crash (no restart). Check describe pod for Readiness probe failed, and fix the readiness endpoint or the dependency it checks. (SOA-C02)

Q7. A PVC-backed pod is Pending with unbound immediate PersistentVolumeClaims. Walk the diagnosis. kubectl get pvc (is it Pending?), kubectl get sc (is there a default StorageClass?), and the EBS CSI controller pods/logs in kube-system. On modern EKS the CSI driver isn’t built in — if it’s not installed (as an addon with IRSA) or there’s no StorageClass, dynamic PVCs never bind. (SAA-C03, SOA-C02)

Q8. Why might a pod be Pending even though kubectl top nodes shows the nodes are nearly idle? The scheduler filters on requests, not actual usage. A pod requesting 4 CPU won’t schedule onto a node with 2 free even if every pod there is idle. Either the requests are oversized (right-size them) or the node genuinely lacks the requested headroom (scale). (SAA-C03)

Q9. Cluster Autoscaler isn’t adding a node for a Pending pod. Name two reasons. (1) The node group is at its max size (pod didn't trigger scale-up: max node group size reached). (2) No node group offers an instance type the pod could ever fit (a pod requesting 8 CPU with only 2-CPU node groups) — CA won’t scale a group that can’t help. Karpenter’s analogue is a NodePool limits cap or no matching instance. (SAA-C03)

Q10. A node shows NotReady. What are the first three things you check? kubectl describe node conditions (DiskPressure/MemoryPressure/Ready), the aws-node (VPC CNI) and kube-proxy pods in kube-system (a failed CNI keeps a node NotReady), and the node role’s policies / aws-auth-or-access-entry mapping (a node that never joined is usually an auth gap). (SOA-C02, SAA-C03)

Q11. Pods can’t resolve service names (no such host). Where do you look? CoreDNS: kubectl get pods -n kube-system -l k8s-app=kube-dns (are they Running?), kubectl logs -n kube-system deploy/coredns, and a smoke test (kubectl run --rm -it ... nslookup kubernetes.default). Also confirm the cluster security group allows UDP/TCP 53 and that kube-proxy is healthy. (ANS, SOA-C02)

Q12. What does exit code 137 mean, and why isn’t it always an OOMKill? 137 is 128 + 9 = SIGKILL. It’s usually an OOMKill (confirm with Reason: OOMKilled), but a plain SIGKILL from a node draining, an eviction, or a liveness kill escalated after SIGTERM also yields 137 without the OOM reason. Read the Reason, not just the code. (SOA-C02, DVA-C02)

Quick check

  1. A pod is Pending. What single command do you run, and which part of its output names the cause?
  2. A pod is stuck in ContainerCreating with failed to assign an IP address to container. What’s the class, and what’s the real fix (and the non-fix)?
  3. ImagePullBackOff on a private ECR image — which IAM identity do you fix, and why is IRSA a trap here?
  4. Two pods have climbing RESTARTS. One shows Reason: OOMKilled, Exit Code: 137; the other shows Liveness probe failed. What are the two different fixes?
  5. What’s the difference between CrashLoopBackOff and CreateContainerConfigError, and which command diagnoses each?

Answers

  1. kubectl describe pod <pod> — read the Events block at the bottom. The scheduler’s FailedScheduling event lists the reason per node (Insufficient cpu, untolerated taint, unbound PersistentVolumeClaims, didn't match node affinity/selector). That string is the class.
  2. VPC CNI IP exhaustion. The pod was scheduled but the AWS VPC CNI has no free ENI IP, so the sandbox can’t be created; the node hit its max-pods cap. Fix: enable prefix delegation, use a bigger instance, or add subnet IPs. Non-fix: adding CPU/memory — the ceiling is IPs, not compute.
  3. Fix the node instance role (AmazonEC2ContainerRegistryReadOnly) or a cross-account repository policy. The kubelet pulls the image under the node role before the container exists, so IRSA credentials — which are only projected into the running container — aren’t present at pull time and can’t fix a pull failure.
  4. OOMKill: raise limits.memory (or fix the leak) — the container hit its hard memory ceiling. Liveness kill: add a startupProbe (or raise initialDelaySeconds/failureThreshold) — a healthy but slow-starting app was killed by the probe before it booted. Same symptom (climbing restarts), opposite fixes.
  5. CrashLoopBackOff = the container started and exited (app error/OOM/liveness) — diagnose with kubectl logs --previous. CreateContainerConfigError = the container never started because a referenced ConfigMap/Secret/key is missing — kubectl describe pod names the missing object.

Glossary

Term Definition
Pod The smallest deployable unit — one or more containers sharing a network namespace and a single VPC IP on EKS.
Pending Pod phase meaning it hasn’t been scheduled onto a node (or is scheduled but waiting on an IP/volume).
CrashLoopBackOff A container that started, exited, and is being restarted by the kubelet with exponential back-off (to a 5-min cap).
ImagePullBackOff The kubelet couldn’t pull the image and is backing off — auth, route, wrong tag, or rate limit.
ContainerCreating The kubelet is setting up the sandbox; if stuck, usually CNI IP exhaustion or a volume-mount failure.
OOMKilled The container exceeded its limits.memory and was killed by the kernel OOM killer (Exit Code: 137).
AWS VPC CNI The aws-node DaemonSet that gives each pod a real VPC IP from ENIs; its max-pods cap is the hidden IP ceiling.
Prefix delegation A VPC CNI mode assigning /28 (16-IP) prefixes per ENI, multiplying max-pods ~16× (up to 110–250).
Node instance role The EC2 role the kubelet uses to pull images, manage ENIs, and write logs — before containers run.
IRSA IAM Roles for Service Accounts — a per-pod IAM identity projected into the running container for its own AWS calls.
livenessProbe A health check that restarts the container on failure; misuse (too-tight timing) causes CrashLoopBackOff.
readinessProbe A health check that removes a pod from Service endpoints on failure (no restart) — gates traffic.
startupProbe A one-time boot-budget probe that gates liveness/readiness until a slow app has started.
EBS CSI driver The addon that provisions and attaches EBS volumes for PVCs; not built in — dynamic PVCs need it installed.
Cluster Autoscaler / Karpenter The two systems that add nodes for unschedulable pods (ASG-based vs right-sized-EC2).
Taint / toleration A node repels pods that lack a matching toleration; unmatched taints cause Pending.

Next steps

AWSEKSKubernetesContainersTroubleshootingVPC CNIIRSACrashLoopBackOff
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading