Amazon EKS

Your EKS nodes run out of IPs long before they run out of compute — here's how to fix it

The default VPC CNI hands every pod a real VPC IP, and that generosity is the ceiling — prefix delegation lifts it, custom networking protects your routable space, and IPv6 removes it entirely.

Deep dive·platform & cloud engineers·

Scope & versions. Amazon VPC CNI (amazon-vpc-cni-k8s / the aws-node daemonset) as documented mid-2026. Prefix delegation requires CNI 1.9.0+ and AWS Nitro instances; Enhanced Subnet Discovery is default since 1.18.0; Karpenter references are v1 (kubelet block on the EC2NodeClass). Re-verify max-pods numbers against the repo's eni-max-pods.txt and the 110/250 caps against the AWS containers blog before you rely on them.

Out of the box, EKS feels magical — until a flash sale scales your microservices from 10 to 150 replicas and half of them wedge in Pending. You check the nodes and nothing looks wrong: CPU and memory sit at a lazy 40%, nothing throttled, nothing OOM. Then the kubelet log: FailedCreatePodSandBox ... failed to assign an IP address to container. The node didn't run out of compute — it ran out of IP addresses. That's a ceiling most teams never see until it drops on them in production.

TL;DR

  • A pod on EKS gets a real, routable VPC IP by default — the VPC CNI assigns each pod a secondary IP off an ENI attached to the node. That's the feature and the trap.
  • The pod ceiling is ENIs × (IPs per ENI − 1) + 2, not your CPU/memory. A t3.medium caps at 17 pods and an m5.large at 29 — often long before compute is used up.
  • Prefix delegation is the first, low-risk fix: hand each ENI slot a /28 (16 IPs) instead of one IP. An m5.large jumps from 29 toward compute-bound density — but only if you also raise kubelet max-pods, or nothing changes.
  • Custom networking is the enterprise safeguard: park pods on a non-routable secondary CIDR (100.64.0.0/10) so they stop consuming scarce routable corporate IPv4. It costs one ENI's worth of pods and leans on primary-ENI SNAT for egress.
  • The two stack into the standard large-cluster topology (~290 addressable pods/node on an m5.large, capped at the recommended 110). IPv6 is the clean-slate escape that deletes the problem — but can't run custom networking.
One idea: the wall is IP addresses, not silicon. Everything below raises the IP ceiling — no bigger instances required.

Why a full node still can't place a pod — reading the failure

Before changing anything, work out which wall you hit — the two share an error text but have different fixes:

Failed create pod sandbox: rpc error: ... networkPlugin cni
failed to set up pod ... network: add cmd: failed to assign
an IP address to container

The message means one thing — the CNI had no IP to hand the pod — but for two independent reasons. Tell them apart with one look at the subnet's free-IPv4 count in the VPC console:

Axis A — node ENI/IP ceilingAxis B — subnet CIDR exhaustion
SymptomPods Pending on a node that still has idle CPU/memory; the same FailedCreatePodSandBox error.Deploys fail intermittently across nodes; same error, but nodes are otherwise healthy.
Where you lookNode has hit its max-pods formula (§3): maximum ENIs and IPs already attached.VPC console → the subnet's available IPv4 = 0. A team at Compass diagnosed exactly this — private-subnet free IPs at zero while compute was fine.
FixPrefix delegation (§4).More address space: a secondary CIDR / custom networking (§5) or Enhanced Subnet Discovery (§6).

One trap: EC2 will launch an instance into a nearly-full subnet, and only then does the CNI find no IPs to assign. The instance reports healthy; the pods are stranded.

How the default CNI spends IPs — and where the wall is

On EKS, every pod gets a real, routable VPC IP, not an overlay address — to the rest of your VPC a pod looks exactly like an EC2 instance. That's why Flow Logs, security groups, and existing tooling "just work" for pods, and it's also the root of exhaustion. The aws-node daemonset does it with two parts: a CNI binary that wires up each pod, and ipamd, a daemon that keeps a warm pool of IPs ready and hands them out from ENIs attached to the node.

Flow flow-getip — how a pod gets its IP. When the pool is dry, the pod can't start; that dry pool is the entire ceiling.
#Step
1Pod scheduled → the CNI binary asks ipamd for a free IP.
2ipamd hands one from its pre-warmed pool (no per-pod EC2 API call).
3That IP is a secondary IP on an ENI attached to the node — one IP per slot in default mode; a slot of a /28 prefix in prefix mode.
4Pool dry → no IP to give → pod stuck Pending with FailedCreatePodSandBox.

So the pod count a node can run comes down to two hardware facts of the instance type — how many ENIs it attaches, and how many IPs each ENI supports:

max-pods = (number of ENIs for the instance type
            × (IPs per ENI − 1))
           + 2

The − 1 is the ENI's own primary address (never given to a pod); the + 2 adds back kube-proxy and aws-node, which run hostNetwork on the node's IP. An m5.large (3 ENIs, 10 IPs/ENI) gives (3 × 9) + 2 = 29 pods — on 2 vCPUs and 8 GiB that could run far more modest pods. The IP model, not the silicon, stops you.

Secondary-IP-mode limits from the CNI repo's eni-max-pods.txt. Small and medium nodes wall out far below their compute.
InstancevCPU / memoryMax pods (secondary-IP mode)
t3.micro2 / 1 GiB4
t3.small2 / 2 GiB11
t3.medium2 / 4 GiB17
t3.large2 / 8 GiB35
m5.large / c5.large2 / 8 GiB29
m5.xlarge4 / 16 GiB58

A t3.medium full of small pods — sidecars, a mesh, event-driven jobs — would pack 30–50 pods of compute, but pod 18 has no IP. That is the "40% idle, still Pending" symptom in one line.

Why an idle node still hoards IPs. ipamd keeps a warm pool pre-attached so startup doesn't hammer the EC2 API: WARM_ENI_TARGET keeps spare ENIs, WARM_IP_TARGET spare IPs, MINIMUM_IP_TARGET a launch-time floor, and a freed IP sits in a 30-second cool-down before reuse. Good for latency — but a node can hold more addresses than it uses, which matters when you're rationing them.

Fix 1 — give each ENI slot a /28, not a single IP (prefix delegation)

Reach for this one first — it's the least disruptive, and it hits the node ceiling head-on. Flip ENABLE_PREFIX_DELEGATION=true and the CNI gives each ENI slot a whole /28 prefix — 16 IPs — instead of one. ENI and slot counts stay the same, but each slot is now 16 pods, so addressable capacity becomes ENIs × (slots per ENI − 1) × 16 — roughly 432 on an m5.large. You're not meant to run 432 pods on 2 vCPUs: AWS recommends capping max-pods at 110 for ≤30 vCPUs and 250 above that — limits from Kubernetes/EKS scalability testing (kubelet, kube-proxy, control-plane load), not IP availability (per the AWS containers blog). The point is that IPs stop being what stops you; the bottleneck moves back to CPU and memory.

Two hard prerequisites: VPC CNI 1.9.0+ and Nitro-based instances (unsupported on older Xen types). Existing nodes won't pick up prefixes until recycled, so roll them after enabling it. The warm knob is now WARM_PREFIX_TARGET (default 1, one spare /28); WARM_IP_TARGET or MINIMUM_IP_TARGET override it for finer control on tight subnets, at the cost of more EC2 API calls.

The step everyone forgets: turning on prefixes gives the node more IPs, but kubelet keeps enforcing whatever max-pods it was bootstrapped with — the old secondary-IP number. You must raise it explicitly, or the node sits on hundreds of free IPs while kubelet refuses to place pod 30.
kubectl set env daemonset aws-node -n kube-system \
  ENABLE_PREFIX_DELEGATION=true
Requires VPC CNI 1.9.0+ and Nitro instances. Recycle nodes to pick up prefixes.
./max-pods-calculator.sh \
  --instance-type m5.large \
  --cni-version 1.9.0 \
  --cni-prefix-delegation-enabled
Feed the result into the node's kubelet config via the EKS AMI NodeConfig, a launch template, or the node group (with --use-max-pods=false).
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  # ... amiFamily, role, subnetSelectorTerms, securityGroupSelectorTerms ...
  kubelet:
    maxPods: 110        # match your instance density under prefix delegation
Karpenter defaults to --max-pods=110 but can compute it wrong under prefix delegation (§6) — pin it here.

The one real caveat — prefix fragmentation

A /28 needs 16 contiguous addresses. On a subnet used for a while — IPs allocated and freed in a patchwork — there may be plenty free but no contiguous block of 16, so prefix attachment fails:

InsufficientCidrBlocks: There are not enough free cidr
blocks in the specified subnet

Keep prefix space contiguous with VPC subnet CIDR reservations, or run the cluster in fresh, dedicated subnets — this is what catches teams retrofitting prefix delegation onto an old, busy cluster.

If you just need to unblock scaling, prefix delegation is the fix — stop here. The rest is for teams protecting scarce routable IPv4 and operating this at fleet scale.

Fix 2 — park pods on non-routable space (custom networking)

Prefix delegation fixes how many pods fit on a node. It does nothing for the other wall: running out of routable corporate IPv4 entirely. When your VPC is carved from a tightly-managed RFC1918 range shared across an org, every pod IP is a scarce routable address someone had to budget for. Custom networking attaches a secondary, non-routable CIDR and puts pods there — the recommended range is the CG-NAT shared space 100.64.0.0/10 (RFC 6598), unlikely to collide with corporate RFC1918. Only the node's secondary ENIs move to it; the primary ENI stays routable, carrying the node plus host-network pods (aws-node, kube-proxy).

The cost — you lose one ENI's worth of pods

Because the primary ENI no longer hands its IPs to pods, the formula loses an ENI:

without custom networking:  (ENIs × (IPs − 1)) + 2
with custom networking:     ((ENIs − 1) × (IPs − 1)) + 2

For an m5.large that's 29 → 20 pods in plain secondary-IP mode — which is exactly why you combine it with prefix delegation: with /28 prefixes on the secondary ENIs, ((3 − 1) × ((10 − 1) × 16)) + 2 = 290 addressable pods (still capped at 110 for compute). The lost primary ENI barely matters once each slot holds 16 IPs.

Egress — how a non-routable pod still gets out

So how does a pod on a non-routable 100.64.x address ever reach the internet or the corporate network? Source NAT on the primary ENI. The CNI default AWS_VPC_K8S_CNI_EXTERNALSNAT=false SNATs any traffic bound outside the VPC's CIDRs to the node's routable primary IP on the way out — the pod's non-routable address never appears on the wire off-VPC.

Two things to internalize. Nothing outside the VPC can route to a 100.64.x pod — fine for outbound and load-balanced services, a problem if something on-prem expects direct pod reachability. And don't flip AWS_VPC_K8S_CNI_EXTERNALSNAT=true casually: it stops SNAT on the assumption your network routes the pod CIDR itself; if it can't, egress silently breaks. One more: with security groups for pods, a SecurityGroupPolicy wins over the ENIConfig security groups for matched pods.

Standing it up

You configure it with one env var plus an ENIConfig per AZ, but in practice bake it into Terraform. It's disruptive and forward-only — only new nodes carrying the k8s.amazonaws.com/eniConfig label are affected, so plan a node-group replacement (enable, provision fresh nodes, drain the old). before_compute = true on the CNI add-on lands the config before any node joins, so nodes come up already on the secondary CIDR.

kubectl set env daemonset aws-node -n kube-system \
  AWS_VPC_K8S_CNI_CUSTOM_NETWORK_CFG=true

# auto-assign the right ENIConfig to each node by AZ
kubectl set env daemonset aws-node -n kube-system \
  ENI_CONFIG_LABEL_DEF=topology.kubernetes.io/zone
apiVersion: crd.k8s.amazonaws.com/v1alpha1
kind: ENIConfig
metadata:
  name: us-west-2a
spec:
  securityGroups:
    - sg-0123456789abcdef0
  subnet: subnet-0a1b2c3d4e5f6a7b8
One ENIConfig per AZ, each pointing at a secondary-CIDR subnet in that zone.
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  secondary_cidr_blocks = [local.secondary_vpc_cidr]  # e.g. "100.64.0.0/16"

  azs = local.azs
  private_subnets = concat(
    [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 4, k)],
    [for k, v in local.azs : cidrsubnet(local.secondary_vpc_cidr, 2, k)],
  )
}

module "eks" {
  source = "terraform-aws-modules/eks/aws"

  cluster_addons = {
    vpc-cni = {
      before_compute = true        # configure the CNI before nodes join
      most_recent    = true
      configuration_values = jsonencode({
        env = {
          AWS_VPC_K8S_CNI_CUSTOM_NETWORK_CFG = "true"
          ENI_CONFIG_LABEL_DEF               = "topology.kubernetes.io/zone"
        }
      })
    }
  }
}

# One ENIConfig per AZ, mapped to the secondary-CIDR subnets
resource "kubectl_manifest" "eni_config" {
  for_each = zipmap(local.azs, slice(module.vpc.private_subnets, 3, 6))

  yaml_body = yamlencode({
    apiVersion = "crd.k8s.amazonaws.com/v1alpha1"
    kind       = "ENIConfig"
    metadata   = { name = each.key }
    spec = {
      securityGroups = [module.eks.node_security_group_id]
      subnet         = each.value
    }
  })
}

Stacking them, and the lighter escapes — what to run at fleet scale

The standard large-cluster topology is both fixes together: /28 prefixes on secondary ENIs on the non-routable CIDR — ~290 addressable pods/node on an m5.large, capped at 110. Beyond that, pick the escape that matches your constraint:

Which fix when — match the tool to the wall you hit.
FixWhat it doesCostDisruptionWhen it wins
Prefix delegation/28 per slot, 16× the IPsmust raise max-pods; CNI 1.9+/Nitro; fragmentation riskLow — recycle nodesNode hits ENI/IP ceiling with compute idle. Do this first.
Custom networkingPods on non-routable 100.64/10lose 1 ENI of pods; SNAT egress; per-AZ ENIConfigHigh — new nodes only, drain/replaceRoutable RFC1918 IPv4 is the scarce resource.
Enhanced Subnet DiscoveryCNI attaches ENIs from added, tagged subnetsstill consumes routable IPsLow — no ENIConfig, no fleet replaceYou just need more routable addresses. Default since CNI 1.18.0.
IPv6Pods get IPv6; scarcity goneone-way; no custom networking; dual-stack / app workGreenfield onlyNew cluster — deletes the problem instead of managing it.

Karpenter — where the max-pods trap moves

Karpenter deliberately sets --use-max-pods=false --max-pods=110, because the ENI-based max-pods-calculator.sh doesn't understand prefixes. That works until it doesn't: in a documented case (#6102) prefix-delegated Karpenter nodes came up with a too-low max-pods — a c7g.medium reporting 8 pods when it should support ~98 — while the identical AMI on a managed node group computed it correctly. Pin maxPods in the EC2NodeClass kubelet block (§4, tab 3), and enable prefix delegation as usual:

kubectl set env daemonset aws-node -n kube-system \
  ENABLE_PREFIX_DELEGATION=true WARM_PREFIX_TARGET=1

Karpenter's general guidance for IP pressure is three levers: enable prefix delegation, keep maxPods at or below real instance density, and use larger subnets (/20+) or steer subnetSelectorTerms away from IP-starved ones.

Warm-pool tuning as a stopgap. When subnets are tight right now, shrink the warm pool: set MINIMUM_IP_TARGET near your real pods/node and cap spares with WARM_IP_TARGET. Two warnings: WARM_IP_TARGET too low causes extra EC2 API calls and throttling (pair it with MINIMUM_IP_TARGET on large clusters), and — the surprise — these settings reset to defaults when you update the managed CNI add-on. Back them up and re-apply.

IPv6 — the clean slate that makes this moot

For a new cluster, AWS best-practices guidance is to consider IPv6 first. A /64 pod subnet holds ~18 quintillion addresses, so exhaustion simply doesn't exist; prefix mode is the default and only option (a /80 per ENI slot), assigned only at node startup, which removes the API-throttling large IPv4 clusters feel. The catches are real: you cannot use custom networking with IPv6 (and don't need to — scarcity is its whole reason), and IPv6 is a one-way decision with dual-stack and app-compatibility implications. Right first choice for greenfield; rarely a quick retrofit.

Pitfalls — the ways these fixes silently don't work

Each of these turns a fix into a non-fix (or an incident). The last row is the meta-mistake: fixing the wrong wall.
PitfallSymptomFix
Enabled prefixes, forgot max-pods"Prefix delegation did nothing" — pods still cap at the old secondary-IP numberSet max-pods from the calculator, or pin it in the Karpenter EC2NodeClass.kubelet
Prefixes on a fragmented subnetInsufficientCidrBlocks despite free IPs — no contiguous /28VPC subnet CIDR reservations, or a dedicated VPC/subnets
WARM_* reset on add-on upgradeCarefully-tuned warm values vanish after a managed CNI updateBack up and re-apply after every upgrade
Assuming custom networking migrates nodesOld nodes never switch — it's forward-onlyProvision new labeled nodes, then drain and replace the old fleet
Expecting pods reachable on their 100.64 IPOn-prem / other VPCs can't reach the pod directlyExpose via load balancers; plan SNAT/return path; don't flip EXTERNALSNAT=true unless the CIDR routes
Fighting an overridden security groupA firewall rule seems ignored for some podsA SecurityGroupPolicy SG takes precedence over the ENIConfig SG for matched pods
Confusing the two wallsApplying the wrong fixCheck the subnet's free-IP count first: node ceiling → prefix delegation; subnet empty → more address space

The short version: turn on prefix delegation today, and remember to raise max-pods. Add custom networking when routable IPv4 is the real constraint. Reach for Enhanced Subnet Discovery when you just need a little more routable space. And if you're starting fresh, look hard at IPv6 before you build any of this.

References

  1. Amazon VPC CNI — EKS Best Practices Guide. aws.github.io/aws-eks-best-practices/networking/vpc-cni
  2. Amazon VPC CNI — Amazon EKS docs. docs.aws.amazon.com/eks/latest/best-practices/vpc-cni.html
  3. Canonical per-instance limits: eni-max-pods.txt, amazon-vpc-cni-k8s. github.com/aws/amazon-vpc-cni-k8s/.../eni-max-pods.txt
  4. Prefix Mode for Linux — EKS Best Practices Guide. aws.github.io/aws-eks-best-practices/networking/prefix-mode/index_linux
  5. Amazon VPC CNI increases pods per node limits — AWS Containers blog. aws.amazon.com/blogs/containers/amazon-vpc-cni-increases-pods-per-node-limits
  6. Optimizing IP Address Utilization — EKS Best Practices Guide. docs.aws.amazon.com/eks/latest/best-practices/ip-opt.html
  7. Custom Networking — EKS Best Practices Guide. aws.github.io/aws-eks-best-practices/networking/custom-networking
  8. Customize the secondary network interface in Amazon EKS nodes (custom networking tutorial) — EKS User Guide. docs.aws.amazon.com/eks/latest/userguide/cni-custom-network-tutorial.html
  9. Troubleshoot EKS pods stuck in ContainerCreating — AWS re:Post. repost.aws/knowledge-center/eks-failed-create-pod-sandbox
  10. Issue #1791, "failed to assign an IP address to container" — amazon-vpc-cni-k8s. github.com/aws/amazon-vpc-cni-k8s/issues/1791
  11. Issue #6102, Karpenter nodes don't get proper max-pods with prefix delegation — karpenter-provider-aws. github.com/aws/karpenter-provider-aws/issues/6102
  12. NodeClasses (spec.kubelet) — Karpenter docs. karpenter.sh/docs/concepts/nodeclasses
  13. Troubleshooting (IP exhaustion) — Karpenter docs. karpenter.sh/docs/troubleshooting
  14. Rocky Chen, "Experiences for IP Addresses Shortage on EKS Clusters" — Compass True North. medium.com/compass-true-north/experiences-for-ip-addresses-shortage-on-eks-clusters
  15. Maximizing Amazon EKS Pod Density: Why Karpenter Ignores AWS's Default Limits — CloudKeeper. cloudkeeper.com/insights/blog/maximizing-amazon-eks-pod-density
  16. VPC CNI Custom Networking — Terraform AWS EKS Blueprints. aws-ia.github.io/terraform-aws-eks-blueprints/snippets/vpc-cni-custom-networking
  17. Learn about IPv6 addresses to clusters, Pods, and services — EKS User Guide. docs.aws.amazon.com/eks/latest/userguide/cni-ipv6.html
  18. Prefix Delegation lab — EKS Workshop. eksworkshop.com/docs/networking/vpc-cni/prefix