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.
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. At3.mediumcaps at 17 pods and anm5.largeat 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. Anm5.largejumps from 29 toward compute-bound density — but only if you also raise kubeletmax-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.
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 containerThe 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 ceiling | Axis B — subnet CIDR exhaustion | |
|---|---|---|
| Symptom | Pods 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 look | Node 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. |
| Fix | Prefix 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.
| # | Step |
|---|---|
| 1 | Pod scheduled → the CNI binary asks ipamd for a free IP. |
| 2 | ipamd hands one from its pre-warmed pool (no per-pod EC2 API call). |
| 3 | That 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. |
| 4 | Pool 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.
Pending, and CPU/memory sit ~40% idle — on a t3.medium, at pod 18.| Instance | vCPU / memory | Max pods (secondary-IP mode) |
|---|---|---|
t3.micro | 2 / 1 GiB | 4 |
t3.small | 2 / 2 GiB | 11 |
t3.medium | 2 / 4 GiB | 17 |
t3.large | 2 / 8 GiB | 35 |
m5.large / c5.large | 2 / 8 GiB | 29 |
m5.xlarge | 4 / 16 GiB | 58 |
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.
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.
/28 of 16 IPs and the ceiling lifts to compute-bound (capped 110) — but only after you raise kubelet max-pods.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.
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
./max-pods-calculator.sh \
--instance-type m5.large \
--cni-version 1.9.0 \
--cni-prefix-delegation-enabled
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
--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).
100.64.0.0/10 while the primary ENI keeps its routable IP — corporate IPv4 preserved, at the cost of one ENI's pods.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.
| # | Hop |
|---|---|
| 1 | Pod on 100.64.x sends outbound → SNAT'd at the node's primary ENI to the routable node IP. |
| 2 | Traffic leaves the node's routable primary subnet toward the NAT gateway / IGW. |
| 3 | Out to corp / on-prem / internet as the routable node IP — the pod's 100.64 address never appears on the wire. |
100.64 address.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
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:
| Fix | What it does | Cost | Disruption | When it wins |
|---|---|---|---|---|
| Prefix delegation | /28 per slot, 16× the IPs | must raise max-pods; CNI 1.9+/Nitro; fragmentation risk | Low — recycle nodes | Node hits ENI/IP ceiling with compute idle. Do this first. |
| Custom networking | Pods on non-routable 100.64/10 | lose 1 ENI of pods; SNAT egress; per-AZ ENIConfig | High — new nodes only, drain/replace | Routable RFC1918 IPv4 is the scarce resource. |
| Enhanced Subnet Discovery | CNI attaches ENIs from added, tagged subnets | still consumes routable IPs | Low — no ENIConfig, no fleet replace | You just need more routable addresses. Default since CNI 1.18.0. |
| IPv6 | Pods get IPv6; scarcity gone | one-way; no custom networking; dual-stack / app work | Greenfield only | New 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.
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
| Pitfall | Symptom | Fix |
|---|---|---|
Enabled prefixes, forgot max-pods | "Prefix delegation did nothing" — pods still cap at the old secondary-IP number | Set max-pods from the calculator, or pin it in the Karpenter EC2NodeClass.kubelet |
| Prefixes on a fragmented subnet | InsufficientCidrBlocks despite free IPs — no contiguous /28 | VPC subnet CIDR reservations, or a dedicated VPC/subnets |
WARM_* reset on add-on upgrade | Carefully-tuned warm values vanish after a managed CNI update | Back up and re-apply after every upgrade |
| Assuming custom networking migrates nodes | Old nodes never switch — it's forward-only | Provision new labeled nodes, then drain and replace the old fleet |
| Expecting pods reachable on their 100.64 IP | On-prem / other VPCs can't reach the pod directly | Expose via load balancers; plan SNAT/return path; don't flip EXTERNALSNAT=true unless the CIDR routes |
| Fighting an overridden security group | A firewall rule seems ignored for some pods | A SecurityGroupPolicy SG takes precedence over the ENIConfig SG for matched pods |
| Confusing the two walls | Applying the wrong fix | Check 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
- Amazon VPC CNI — EKS Best Practices Guide. aws.github.io/aws-eks-best-practices/networking/vpc-cni
- Amazon VPC CNI — Amazon EKS docs. docs.aws.amazon.com/eks/latest/best-practices/vpc-cni.html
- Canonical per-instance limits:
eni-max-pods.txt,amazon-vpc-cni-k8s. github.com/aws/amazon-vpc-cni-k8s/.../eni-max-pods.txt - Prefix Mode for Linux — EKS Best Practices Guide. aws.github.io/aws-eks-best-practices/networking/prefix-mode/index_linux
- Amazon VPC CNI increases pods per node limits — AWS Containers blog. aws.amazon.com/blogs/containers/amazon-vpc-cni-increases-pods-per-node-limits
- Optimizing IP Address Utilization — EKS Best Practices Guide. docs.aws.amazon.com/eks/latest/best-practices/ip-opt.html
- Custom Networking — EKS Best Practices Guide. aws.github.io/aws-eks-best-practices/networking/custom-networking
- 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
- Troubleshoot EKS pods stuck in ContainerCreating — AWS re:Post. repost.aws/knowledge-center/eks-failed-create-pod-sandbox
- Issue #1791, "failed to assign an IP address to container" —
amazon-vpc-cni-k8s. github.com/aws/amazon-vpc-cni-k8s/issues/1791 - Issue #6102, Karpenter nodes don't get proper max-pods with prefix delegation —
karpenter-provider-aws. github.com/aws/karpenter-provider-aws/issues/6102 - NodeClasses (spec.kubelet) — Karpenter docs. karpenter.sh/docs/concepts/nodeclasses
- Troubleshooting (IP exhaustion) — Karpenter docs. karpenter.sh/docs/troubleshooting
- 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
- Maximizing Amazon EKS Pod Density: Why Karpenter Ignores AWS's Default Limits — CloudKeeper. cloudkeeper.com/insights/blog/maximizing-amazon-eks-pod-density
- VPC CNI Custom Networking — Terraform AWS EKS Blueprints. aws-ia.github.io/terraform-aws-eks-blueprints/snippets/vpc-cni-custom-networking
- Learn about IPv6 addresses to clusters, Pods, and services — EKS User Guide. docs.aws.amazon.com/eks/latest/userguide/cni-ipv6.html
- Prefix Delegation lab — EKS Workshop. eksworkshop.com/docs/networking/vpc-cni/prefix