Skip to content

Part 10: IPAM Deep Dive ​

Review baseline: Cilium 1.20.2 (stable, 2026-09-16); Cilium CLI 0.20.1; Hubble CLI 1.20.2. Items marked [1.21 pre-release] come from v1.21.0-pre.3 and may change before GA. Last Updated: October 15, 2026

Part 4 introduced the IPAM modes and their Helm fragments. This chapter goes underneath: who owns which address, what happens between cilium-cni ADD and a running endpoint, how to size a pool before you install, how the ENI allocator decides to call EC2, and how LB IPAM and Node IPAM differ from Pod IPAM. It is the companion to Overlay and Native Routing (how the addresses are routed) and BGP, Load Balancing, Egress and Edge (how LB addresses are advertised).

What IPAM Is, and What It Is Not ​

Four different address families get allocated in a Kubernetes cluster by four different owners. Mixing them up is the most common source of "IPAM" bugs.

AddressOwnerCilium involvement
Pod (endpoint) IPsCilium IPAM (this chapter, the mode subsections)Allocates, tracks and releases
Service ClusterIPskube-apiserver (--service-cluster-ip-range)None; Cilium only programs them into the datapath
LoadBalancer Service IPsLB IPAM (CiliumLoadBalancerIPPool) or a cloud controllerOperator-side allocation only; advertisement is separate (BGP Control Plane or L2 Announcements)
Node-as-load-balancer addressesNode IPAM (loadBalancerClass: io.cilium/node)Operator copies node addresses into the Service status

CiliumPodIPPool never allocates ClusterIPs, and CiliumLoadBalancerIPPool never allocates Pod IPs. CiliumCIDRGroup (see CiliumCIDRGroup below) allocates nothing: it is a named set of CIDRs for policy.

The Big Picture: the CiliumNode Handshake ​

Almost every mode is a conversation between two components through the CiliumNode custom resource (one per node, named like the node):

                        +-----------------------------+
                        |        CiliumNode <node>    |
                        |  spec.ipam.podCIDRs         |  cluster-pool
                        |  spec.ipam.pools.requested  |  multi-pool / eni (agent writes)
                        |  spec.ipam.pools.allocated  |  multi-pool / eni (operator writes)
                        |  spec.ipam.pool             |  crd (external allocator writes)
                        |  status.ipam.*              |  used IPs, operator-status.error
                        |  status.eni.enis            |  eni (operator writes)
                        +--------------+--------------+
                 watch/update          |          watch/update
        +------------------------+     |     +--------------------------+
        |  cilium-agent (node)   |<----+---->|  cilium-operator         |
        |  local pod-IP allocator|           |  node CIDRs, EC2/Azure   |
        |  CNI /ipam REST        |           |  API calls, pool alloc   |
        +------------------------+           +--------------------------+

The agent allocates individual Pod IPs out of whatever the node currently owns. The operator (or Kubernetes, or an external system, depending on the mode) decides what the node owns. The agent creates its own CiliumNode in cluster-pool, multi-pool and eni modes (agent option auto-create-cilium-node-resource, default true). The operator ignores any CiliumNode annotated ipam.cilium.io/ignore: "true", which is the escape hatch for nodes managed by something else.

Agent allocator wiring per mode (1.20.2) ​

ipam.modeNode capacity comes fromAgent-side allocator
kubernetesNode.spec.podCIDRs (or annotations)host-scope allocator over the node CIDR
cluster-pool (default)operator, via CiliumNode.spec.ipam.podCIDRshost-scope allocator over the node CIDR
multi-pooloperator, via spec.ipam.pools.allocatedmulti-pool manager
enioperator (EC2 API), via status.eni.enis converted to spec.ipam.pools.allocatedENI multi-pool allocator (new in 1.20)
crd, azure, alibabacloudCiliumNode.spec.ipam.poolCRD allocator
delegated-pluginanother CNI IPAM pluginno-op allocator

Helm interactions to remember: eni.enabled=true forces ipam: eni regardless of ipam.mode; ipam.mode=eni without eni.enabled fails chart validation. There is no gke value: self-managed GKE uses kubernetes.

Node-local addresses ​

Cilium takes addresses out of each node's range for itself: the cilium_host router IP, the health endpoint IP and (when Ingress is enabled) the ingress IP. In cilium-dbg status --all-addresses they appear as (router), (health) and (ingress). The host-scope allocator also excludes the first (network) and last (broadcast) address of the node CIDR. Capacity math (see Sizing math) must subtract these.

Pod IP Lifecycle ​

The path from the kubelet to a running endpoint, for every mode except delegated-plugin:

kubelet -> CRI -> cilium-cni ADD
                     |
                     | 1. remove stale host-side interface name, if any
                     | 2. POST /ipam   (unix socket to cilium-agent)
                     |       owner = <namespace>/<pod>, expiration = true
                     v
                 cilium-agent: IpamPostIpamHandler
                     | 3. empty pool?  -> pick pool (annotations/selectors, multi-pool)
                     | 4. AllocateNextWithExpiration
                     |       -> response: IPv4/IPv6, pool, cidrs, gateway,
                     |          master-mac, interface-number, skip-masquerade,
                     |          expiration-uuid, host-addressing (router IP)
                     |       -> start 10 minute expiration timer
                     v
                 cilium-cni: create veth (or netkit), set IPs/routes in the pod netns
                     | 5. PUT /endpoint/{id}
                     v
                 cilium-agent: build endpoint, identity, datapath
                     | 6. endpoint creation stops the expiration timer
                     v
                 pod Running

 failure at any point after step 4 -> deferred DELETE /ipam/{ip}?pool=<pool>
 CNI DEL -> endpoint delete -> agent releases the IP (metric ipam_events_total{action="release"})

Key mechanics, each verified in the 1.20.2 source:

  • The REST calls are POST /ipam, POST /ipam/{ip} and DELETE /ipam/{ip} on the agent API (api/v1/openapi.yaml). The CNI plugin calls IPAMAllocate("", podName, "", true); the last argument is the expiration flag.
  • Expiration timer: an IP allocated with expiration is auto-released after defaults.IPAMExpiration, 10 minutes, unless endpoint creation stops the timer. This closes the window where the CNI plugin crashes after allocating but before creating the endpoint, which would otherwise leak the address until the agent restarts.
  • Restore: when the agent restarts, it re-claims the IPs of running endpoints (shown as [restored] in cilium-dbg status --all-addresses). The option --bypass-ip-availability-upon-restore exists only for ENI/Azure upgrade cases.
  • ENI-class modes (eni, azure, alibabacloud): the response also carries the ENI MAC, gateway and CIDRs. The plugin sets the parent interface index from the MAC (ENI only) and the agent installs per-endpoint routing rules on the host. This is why those modes force endpoint routes.
  • delegated-plugin: the CNI plugin itself runs DelegateAdd against the configured IPAM binary (for example Azure's azure-ipam on AKS) and marks the endpoint ExternalIpam, so the agent never frees that IP; the plugin calls DelegateDel on DEL. Cilium does no Pod IP accounting here.

Inspection:

bash
# All addresses the agent knows, with owners (ns/pod); multi-pool shows pool/ip
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status --all-addresses

# Allocation summary line, for example "IPAM: IPv4: N/M allocated from <cidr>"
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status | grep -i ipam

A Pod stuck in ContainerCreating with the CNI error unable to allocate IP via local cilium agent means step 2-4 failed; the Troubleshooting section maps that to a root cause per mode.

Mode Catalog and Decision Matrix ​

Feature comparison ​

Adapted from the upstream IPAM concepts page for 1.20.2.

FeatureKubernetes host scopeCluster scope (default)Multi-poolCRD-backedAWS ENIAzure IPAMGKE (via host scope)
Tunnel routingyesyesyesnononono
Direct routingyesyesyesyesyesyesyes
CIDR configurationKubernetesCiliumCiliumexternalAWSAzureGCP
Multiple CIDRs per clusternoyesyesn/an/an/an/a
Multiple CIDRs per nodenonoyesn/an/an/an/a
Dynamic CIDR/IP allocationnonoyesyesyesyesno

Maturity: cluster-pool, kubernetes, crd and eni are stable. Multi-pool carried a "Beta" banner in 1.18 and the banner is gone from 1.19 onward (this chapter treats it as stable from 1.19; upstream does not use the word "GA"). ENI IPv6 is beta in 1.20. Cluster-pool to multi-pool online migration is new in 1.20.

Decision matrix ​

This is guidance synthesized from the upstream documentation and the constraints of each platform, not an upstream table.

EnvironmentRecommended IPAMWhy and what to watch
EKS, VPC-routable Pod IPs wanted (security-group targets, ALB/NLB IP targets, no SNAT)eni, plus prefix delegation on NitroNeeds operator IAM permissions, native routing, aws-node removed. Density is bounded by instance ENI/IP limits unless prefix delegation is used. IPv6 is beta. Not applicable to Fargate or EKS Auto Mode nodes
EKS, scarce VPC IPs or high densitycluster-pool with VXLAN (Helm default) or multi-poolPods SNAT to the node IP; the EKS control plane cannot reach Pod IPs, so admission webhooks must run on the host network or be reached via a Service
EKS Hybrid Nodes (on-prem nodes)cluster-pool with VXLAN; pool list = the EKS "remote pod networks"; mask sized by Pods per nodeAWS-supported configuration is IPv4 only, VXLAN, cluster-scope IPAM, BGP Control Plane. Do not change the pool list or mask after deploy. eni can never apply on-prem
On-prem, simplecluster-pool with tunnelOperator-managed, no dependency on kube-controller-manager node CIDRs
On-prem, routable Pods, several ranges per site or tenantmulti-pool + BGP CiliumPodIPPool advertisements, per-site default pool via CiliumNodeConfigNeeds kubeProxyReplacement=true and bpf.masquerade=true; pool CIDRs must not overlap
Cluster whose control plane already assigns node CIDRs (kubeadm with --allocate-node-cidrs, GKE)kubernetesStatic, one CIDR per family per node
Addresses come from an external systemcrdSingle IPs only, direct routing only
AKSAzure CNI Powered by Cilium (delegated-plugin) or BYOCNI with any Cilium IPAMDo not apply the upstream azure IPAM on AKS
Self-managed Azure VMsazureResource-group and identity flags; reworked in 1.21 pre-release

Changing modes on a live cluster ​

Upstream's position: do not change the IPAM mode of a running cluster except through a documented migration, and "the safest path is a fresh cluster". The only documented online path is cluster-pool to multi-pool (see the migration subsection). Everything else (kubernetes to cluster-pool, anything to ENI, overlay to ENI) means a new cluster or a blue/green node group, because Pod IP ranges, routing and (on EKS) webhook reachability all change. Using CiliumNodeConfig to mix IPAM modes per node group is not documented upstream and this chapter does not recommend it.

Kubernetes Host Scope and Cluster Scope ​

Kubernetes host scope (ipam.mode=kubernetes) ​

At startup the agent waits for the node's PodCIDR for each enabled family, from Node.spec.podCIDRs / spec.podCIDR or the annotations network.cilium.io/ipv4-pod-cidr and network.cilium.io/ipv6-pod-cidr (plus ipv4/6-cilium-host, ipv4/6-health-ip and ipv4/6-Ingress-ip). kube-controller-manager must run with --allocate-node-cidrs (and --cluster-cidr, --node-cidr-mask-size).

yaml
ipam:
  mode: kubernetes
k8s:
  requireIPv4PodCIDR: true     # only effective with ipam.mode=kubernetes
  requireIPv6PodCIDR: false

The agent auto-enables k8s-require-ipv4-pod-cidr when IPv4 is enabled in this mode, so the explicit setting is mostly documentation. Limits: one CIDR per node per family, fixed at node registration, no dynamic growth. Self-managed GKE uses this mode with ipv4NativeRoutingCIDR set to the cluster CIDR.

Cluster scope / cluster-pool (default) ​

The operator owns node prefix allocation. Each node gets a CIDR written to CiliumNode.spec.ipam.podCIDRs; the agent blocks at startup until that field is populated for every enabled family, then runs the same host-scope allocator over it. The operator re-adopts existing allocations after a restart; a conflict produces node has CIDRs allocated (...) that conflict with requested CIDRs.

Helm values (1.20.2 defaults):

yaml
ipam:
  mode: cluster-pool
  operator:
    clusterPoolIPv4PodCIDRList: ["10.0.0.0/8"]   # default
    clusterPoolIPv4MaskSize: 24                  # default
    clusterPoolIPv6PodCIDRList: ["fd00::/104"]   # default
    clusterPoolIPv6MaskSize: 120                 # Helm default (operator flag default is 112)

The rendered ConfigMap keys are cluster-pool-ipv4-cidr (space-joined list), cluster-pool-ipv4-mask-size, cluster-pool-ipv6-cidr and cluster-pool-ipv6-mask-size. The operator refuses to start when IPv4 is enabled with an empty list ("cluster-pool-ipv4-cidr must be provided when using ClusterPool") or when a list is set for a disabled family.

Sizing math ​

The node CIDR allocator is pkg/ipam/cidrset. For each pool CIDR with prefix length P and node mask M:

nodes per pool CIDR      = 2^(M - P)
addresses per node       = 2^(32 - M)          (IPv4)     2^(128 - M) (IPv6)
usable Pod IPs per node  = addresses - 2 (network, broadcast) - up to 3 (router, health, ingress)

Constraints from the source: for IPv6, M - P must not exceed 16 (clusterSubnetMaxDiff = 16, error ErrCIDRSetSubNetTooBig) because the allocator keeps an uncompressed bitmap; IPv4 only requires M <= 32. Upstream guidance: a node mask of /30 is the minimum and /29 or larger is recommended.

Pool CIDRNode maskNodesAddresses per nodeNote
10.0.0.0/8 (default)/2465,5362562^(24-8); usable about 251
10.244.0.0/16/24256256typical small cluster
10.244.0.0/16/261,024642^(26-16); usable about 59
10.244.0.0/16/284,09616lab sizing; about 11 usable
fd00::/104 (default)/12065,536256IPv6 default, difference is exactly 16
fd00:10:244::/96/11265,53665,536maximum allowed difference of 16

The first four rows are computed from the formula above; "usable" subtracts the two reserved addresses and the three Cilium-owned addresses, so treat it as an upper bound. Per-node Pod density also depends on maxPods in the kubelet: size the mask for maxPods plus headroom for rolling updates (old and new Pods overlap).

Mutation rules (upstream, enforced by behaviour rather than validation):

  • Never edit or remove an existing entry in clusterPoolIPv4PodCIDRList. Append a new CIDR to grow.
  • clusterPoolIPv4MaskSize cannot change on a live cluster.
  • Errors from the allocator (allocator full) appear at CiliumNode.status.ipam.operator-status.error.
bash
kubectl get ciliumnodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.ipam.podCIDRs}{"\t"}{.status.ipam.operator-status}{"\n"}{end}'

Overlap hazard: the default 10.0.0.0/8 overlaps most corporate and VPC ranges. If it overlaps the node network you lose node connectivity. Always set the list explicitly.

nativeRoutingCIDRFromClusterPool (Helm, default false; new in 1.20: absent from the 1.18.14 and 1.19.8 values.yaml, present in 1.20.2 and 1.21): derives the native-routing CIDR from the pool, valid only with cluster-pool and exactly one CIDR per enabled family, otherwise the template fails.

Dual-stack ​

yaml
ipam:
  mode: cluster-pool
  operator:
    clusterPoolIPv4PodCIDRList: ["10.244.0.0/16"]
    clusterPoolIPv4MaskSize: 24
    clusterPoolIPv6PodCIDRList: ["fd00:10:244::/96"]
    clusterPoolIPv6MaskSize: 112       # 112 - 96 = 16, the maximum allowed
ipv4: { enabled: true }
ipv6: { enabled: true }

Setting ipv6.enabled does not create an IPv6 Service CIDR or underlay IPv6 connectivity. Per mode: host scope needs both podCIDRs entries and k8s.requireIPv6PodCIDR; multi-pool pools may carry ipv4, ipv6 or both, and the family-specific annotations ipam.cilium.io/ipv4-pool and ipam.cilium.io/ipv6-pool exist for this; ENI IPv6 is beta (see Release, use-primary-address, IPv6); LB IPAM follows the Service's ipFamilyPolicy (see LB IPAM below). The AWS-supported EKS Hybrid Nodes configuration is IPv4 only.

Multi-Pool IPAM ​

Concept ​

Multi-pool replaces "one CIDR list for the whole cluster" with named pools, each a CiliumPodIPPool. A node can hold blocks from several pools at once, requested on demand by the agent. Use it for tenants with separate ranges, for routable subsets of Pods (BGP advertisement per pool), for per-site ranges, and for source-IP-preserving pools.

Prerequisites and install ​

The upstream tutorial requires kubeProxyReplacement=true and bpf.masquerade=true. Known limitation: iptables-based masquerading needs egressMasqueradeInterfaces (upstream issue 22273), so eBPF masquerade is the supported path. Pool CIDRs must not overlap one another (the IP cache maps a prefix to one identity).

yaml
ipam:
  mode: multi-pool
  multiPoolPreAllocation: "default=8,mars=4"
  operator:
    autoCreateCiliumPodIPPools:
      default:
        ipv4: { cidrs: ["10.10.0.0/16"], maskSize: 27 }
      mars:
        ipv4: { cidrs: ["10.20.0.0/16"], maskSize: 27 }
kubeProxyReplacement: true
bpf: { masquerade: true }

autoCreateCiliumPodIPPools only creates pools that do not exist; it never updates them. It maps to the operator flag --auto-create-cilium-pod-ip-pools <pool>=ipv4-cidrs:...;ipv4-mask-size:...[;allow-first-ip:...][;allow-last-ip:...], whose grammar cannot express podSelector, namespaceSelector or (1.21) pool[].reservedRanges. Create those pools as custom resources.

CiliumPodIPPool ​

yaml
apiVersion: cilium.io/v2alpha1      # 1.20.x; use cilium.io/v2 from 1.21 [1.21 pre-release]
kind: CiliumPodIPPool
metadata:
  name: mars
spec:
  # allowFirstIP / allowLastIP are fields of spec (not of spec.ipv4); default false,
  # ignored for /31 /32 /127 /128
  ipv4:
    cidrs: ["10.20.0.0/16"]
    maskSize: 27
  podSelector:
    matchLabels:
      io.kubernetes.pod.namespace: tenant-mars   # synthetic label
  namespaceSelector:
    matchLabels:
      tenant: mars

Cluster-scoped, short name cpip. maskSize, allowFirstIP and allowLastIP are immutable (CEL self == oldSelf). The first and last address of each allocated per-node block are reserved unless allowFirstIP / allowLastIP are set. A pool with no block for a family is skipped for that family.

API version note. 1.20.2 serves cilium.io/v2alpha1. [1.21 pre-release] cilium.io/v2 becomes the storage version and v2alpha1 is deprecated; v2 also caps cidrs at 32 items and adds spec.ipv4.pool[] / spec.ipv6.pool[] (see Updating pools safely). Update manifests and tooling to v2 when you move to 1.21.

Pool selection ​

Evaluated once, at Pod creation (changing an annotation on a running Pod has no effect). Precedence, from the source (pkg/ipam/metadata/manager.go) and docs:

  1. Pod annotation: ipam.cilium.io/ipv4-pool / ipam.cilium.io/ipv6-pool (per family) take precedence over ipam.cilium.io/ip-pool.
  2. Namespace annotation: the same keys on the Pod's namespace.
  3. CiliumPodIPPool selectors: podSelector and namespaceSelector (both must match when both are set). Exactly one pool per family must match, otherwise allocation fails with an error and an event.
  4. Default pool: ipam-default-ip-pool (default default), unless the Pod or namespace carries ipam.cilium.io/require-pool-match: "true", in which case allocation is blocked.

The correct annotation key is ipam.cilium.io/ip-pool (pkg/annotation/k8s.go). Some upstream tutorial text writes ipam.cilium.io/ipam-pool; that key does nothing.

bash
kubectl annotate namespace tenant-mars ipam.cilium.io/ip-pool=mars
yaml
apiVersion: v1
kind: Pod
metadata:
  name: pinned
  annotations:
    ipam.cilium.io/ip-pool: mars
spec:
  containers:
  - name: app
    image: registry.k8s.io/pause:3.10

How the agent asks for capacity ​

The agent writes demand into CiliumNode.spec.ipam.pools.requested[] {pool, needed{ipv4-addrs, ipv6-addrs}}. The operator carves per-node blocks of the pool's maskSize until the node's available addresses meet needed, and appends them to spec.ipam.pools.allocated[] {pool, cidrs[], allowFirstIP, allowLastIP}. The agent removes blocks that are completely free and above demand. Errors go to status.ipam.operator-status.error.

The documented formula is neededIPs = roundUp(inUse + pending + preAlloc, preAlloc). The code (neededIPCeil in pkg/ipam/multipool_manager.go) rounds to the next-but-one multiple, so with preAlloc = P a demand of 0 requests P, 1 through P requests 2P, P+1 requests 3P. The effect is a buffer of at least one pre-allocation unit. The agent flag is --ipam-multi-pool-pre-allocation (Helm ipam.multiPoolPreAllocation); empty means default=8 and other pools default to 0. The count applies per enabled family.

Because blocks are allocated at maskSize granularity, choose maskSize so a block is a few times larger than preAlloc (for example /27 = 32 addresses with preAlloc=8), or nodes will repeatedly acquire and release many tiny blocks.

Per-node default pool, masquerade, routing ​

Per-node default pool with CiliumNodeConfig (cilium.io/v2, namespace kube-system). Changes apply only after the agent Pod restarts.

yaml
apiVersion: cilium.io/v2
kind: CiliumNodeConfig
metadata:
  name: ip-pool-dc1
  namespace: kube-system
spec:
  defaults:
    ipam-default-ip-pool: dc1-pool
  nodeSelector:
    matchLabels:
      topology.kubernetes.io/zone: dc1

Do not use this override on the pool named for a cluster-pool migration (see the cluster-pool to multi-pool migration subsection).

Masquerade. The agent option --only-masquerade-default-pool (requires eBPF masquerade) or the per-pool annotation ipam.cilium.io/skip-masquerade="true" on the CiliumPodIPPool preserves the Pod source IP for non-default pools; it affects new Pods. There is no dedicated Helm key for the agent option; use extraConfig.only-masquerade-default-pool: "true" (this exact path was not run in this review).

Routing. Pool CIDRs can be announced with the BGP Control Plane (advertisementType: CiliumPodIPPool, see Part 11) or reached through autoDirectNodeRoutes on a shared L2 segment.

Updating pools safely, and reservedRanges [1.21 pre-release] ​

Rules: new pools and extra CIDRs may be added live; a CIDR in use by a node must not be removed; an in-use pool must not be deleted (the operator logs CIDR from pool still in use by node). The 1.20 procedure to swap a CIDR is: edit the pool, restart the operator, then for each node group cordon, drain, delete its CiliumNode objects, restart agents and uncordon, one group at a time.

[1.21 pre-release] cilium.io/v2 adds per-CIDR configuration with reservedRanges, so you can retire a CIDR without orphaning allocations:

yaml
apiVersion: cilium.io/v2
kind: CiliumPodIPPool
metadata:
  name: default
spec:
  ipv4:
    cidrs: ["10.10.0.0/16", "10.20.0.0/16"]
    maskSize: 24
    pool:
    - cidr: 10.10.0.0/16
      reservedRanges:
      - start: 10.10.0.0
        end: 10.10.255.255

Existing node blocks from 10.10.0.0/16 keep working; new blocks come only from 10.20.0.0/16. After every node group has migrated, remove the old CIDR from cidrs and the matching pool entry. Each pool[].cidr must be one of cidrs (CEL-validated).

Cluster-pool to multi-pool online migration (1.20+) ​

Absent in 1.19 and earlier. Operator options: enable-cluster-pool-to-multi-pool-migration (default false), multi-pool-migration-workers (default 16), ipam-default-ip-pool.

Preconditions: the migration is one-way (no rollback); every existing Pod lands in the default pool; the default pool must exist before the operator restarts (a custom resource or autoCreateCiliumPodIPPools), must cover all allocated per-node CIDRs and use the same mask size; do not override the default pool name per node.

bash
helm upgrade cilium cilium/cilium --version 1.20.2 -n kube-system --reuse-values \
  --set ipam.mode=multi-pool \
  --set ipam.operator.autoCreateCiliumPodIPPools.default.ipv4.cidrs='{10.0.0.0/8}' \
  --set ipam.operator.autoCreateCiliumPodIPPools.default.ipv4.maskSize=24 \
  --set-string extraConfig.enable-cluster-pool-to-multi-pool-migration=true \
  --set operator.rollOutPods=true \
  --set rollOutCiliumPods=false

Then: wait until each CiliumNode lists its existing PodCIDR under spec.ipam.pools.allocated; if the BGP Control Plane advertises PodCIDRs, change the CiliumBGPAdvertisement from advertisementType: PodCIDR to CiliumPodIPPool selecting the default pool before restarting agents (expect a transient route withdrawal, because agents still in cluster-pool mode stop advertising migrated allocations); then restart agents, all at once or incrementally. Mixed cluster-pool and multi-pool agents keep connectivity during the rollout. Do not schedule non-default-pool Pods until every agent has restarted.

Limits and the BGP interplay ​

No overlapping CIDRs between pools; iptables masquerade is not supported without egressMasqueradeInterfaces; for disjoint pools consider ip-masq-agent. Pool CIDR changes to in-use ranges require the drain procedure above.

CRD-Backed IPAM (ipam.mode=crd) ​

The agent watches the CiliumNode with its node name. spec.ipam.pool is a map ip -> {owner, resource} of available single IPs (CIDRs are not supported); used IPs are reported in status.ipam.used, with a status update at most once per 15 seconds (Helm ipam.ciliumNodeUpdateRate). IPv6 uses spec.ipam.ipv6-pool and status.ipam.ipv6-used. Direct routing only.

At startup the agent logs Waiting for initial IP to become available in '<node>' custom resource and needs at least one IP (at least two when health checking is on). RBAC needs full access to ciliumnodes and ciliumnodes/status. Upstream prose still says available/inuse; the schema is pool/used, so follow the installed CRD.

AWS ENI IPAM ​

Architecture (1.20.2) ​

The operator is the only component that calls the EC2 API. It writes the ENI inventory to CiliumNode.status.eni.enis. The agent converts that inventory into CIDRs in spec.ipam.pools.allocated for pool default: a secondary IP becomes a /32, a delegated prefix becomes a /28 (IPv4) or /80 (IPv6). The agent allocates Pod IPs locally and reports demand in spec.ipam.pools.requested. In 1.20 ENI mode there is no CiliumPodIPPool and no ip-pool annotation. In 1.19 and 1.18, ENI used the CRD allocator instead (spec.ipam.pool, status.ipam.used). A 1.19 to 1.20 rolling upgrade is supported: the operator distinguishes new agents by spec.ipam.pools.requested and the absence of status.ipam.used.

ENI mode forces native routing and endpoint routes (eni.enabled=true already sets them, so routingMode: native and endpointRoutes.enabled in a values file are redundant) and the agent refuses ENI combined with tunnelling. enableIPv4Masquerade defaults to false in ENI mode since 1.18. [1.21 pre-release] enableIPv6Masquerade also defaults to false in ENI mode.

Install and node preparation ​

Remove the VPC CNI (aws-node) by patching its DaemonSet with an impossible nodeSelector (upstream uses io.cilium/aws-node-enabled=true) and install with eni.enabled=true. Create node groups with the startup taint node.cilium.io/agent-not-ready=true:NoExecute so workloads wait for the agent. Disable NetworkManager / systemd-networkd management of eth* interfaces other than eth0, otherwise the OS can strip secondary IPs (relevant on Amazon Linux 2023). If you replace the VPC CNI, flush AWS-SNAT-CHAIN-* and AWS-CONNMARK-CHAIN-* iptables chains.

IAM privileges for the operator (all listed upstream; scope by resource tags where possible): ec2:DeleteNetworkInterface, DescribeInstanceTypes, DescribeNetworkInterfaces, DescribeSubnets, DescribeVpcs, DescribeRouteTables (required since 1.18), DescribeSecurityGroups, CreateNetworkInterface, AttachNetworkInterface, ModifyNetworkInterfaceAttribute, AssignPrivateIpAddresses, CreateTags; plus DescribeTags (ENI garbage collection, on by default), UnassignPrivateIpAddresses (release), AssignIpv6Addresses (IPv6), DescribeInstances (when --instance-tags-filter is used). [1.21 pre-release] optional GetSecurityGroupsForVpc (falls back with one warning), and DescribeManagedPrefixLists / GetManagedPrefixListEntries. Upstream documents only the assign/unassign private-IP actions for prefix operations; confirm the exact IAM actions against the AWS documentation before writing a least-privilege policy. Supply credentials via eni.iamRole (IRSA) or the cilium-aws secret.

Helm values ​

yaml
eni:
  enabled: true
  iamRole: arn:aws:iam::<account-id>:role/cilium-operator
  awsEnablePrefixDelegation: true
  awsReleaseExcessIPs: true
  eniTags:
    team: platform
  gcInterval: 5m                        # "0s" disables ENI garbage collection
  nodeSpec:                             # per-node settings, since 1.19
    firstInterfaceIndex: 1              # keep eth0 for node traffic
    subnetTags: ["pod-subnet=true"]
    securityGroupTags: ["cilium-pod-sg=true"]
    excludeInterfaceTags: ["no-cilium=true"]
    deleteOnTermination: true
ipam:
  nodeSpec:
    ipamMinAllocate: 16
    ipamPreAllocate: 8
  operator:
    externalAPILimitQPS: 8
    externalAPILimitBurstSize: 40

Tag-list entries render as comma-joined key=value strings in the ConfigMap (for example eni-subnet-tags). Two kinds of filter exist and they are not interchangeable: the operator-level eni.subnetIDsFilter, eni.subnetTagsFilter and eni.instanceTagsFilter restrict which instances/subnets the operator manages and assume each instance already has an ENI in a matching subnet at deploy time; to steer only the ENIs Cilium creates, use the per-node eni.nodeSpec values (or the eni section of a custom CNI configuration, which takes precedence over Helm for the same field). The operator API limiter defaults are 4 QPS with burst 20.

Operator flags worth knowing (cmdref cilium-operator-aws): --aws-enable-prefix-delegation, --aws-release-excess-ips, --excess-ip-release-delay (default 180 seconds), --eni-gc-interval (5 minutes), --eni-tags, --parallel-alloc-workers (50), --limit-ipam-api-qps, --limit-ipam-api-burst, --aws-max-results-per-call.

Allocation algorithm ​

Per-node knobs (in CiliumNode.spec.ipam): min-allocate, pre-allocate (treated as 8 when 0), max-allocate, max-above-watermark (no agent flag or Helm key: set it on the CiliumNode or in the CNI configuration ipam section), static-ip-tags. Formulas from operator/pkg/ipam/nodemanager/node.go:

deficit (needed) = max( preAllocate + used - available ,  minAllocate - available )
                   clamped to maxAllocate if set;  floor 0
excess  (only with --aws-release-excess-ips)
                 = max( available - used - preAllocate - maxAboveWatermark , 0 )
                   (while used is small, keep available >= minAllocate + maxAboveWatermark)
per-cycle allocation on the chosen ENI
                 = min( AvailableOnSubnet,
                        min( AvailableOnENI, needed + maxAboveWatermark + surgeAllocate ) )
   where surgeAllocate = pendingPods - needed

Worked example: preAllocate=8, minAllocate=16, a node with 10 available and 4 used: deficit = max(8 + 4 - 10, 16 - 10) = 6, so the operator assigns at least 6 more. Pre-allocate is lowered automatically on small instances to min(8, (adapters - firstIndex) x (ipsPerENI - 1)). The default max-allocate is the instance limit: (adapters - firstInterfaceIndex) x (ipv4PerENI - 1), multiplied by 16 with prefix delegation. With usePrimaryAddress each ENI contributes one extra address.

Scheduling: deficit is evaluated on every CiliumNode update plus a full scan every minute; the work queue is processed on demand but at most once per second, largest deficit first, and allocation runs before release.

Where the IPs come from: the operator iterates attached ENIs with index >= first-interface-index that have free capacity and subnet addresses; if none, it creates an ENI. Subnet candidates are in the same VPC and AZ, narrowed by subnet-ids (takes priority) or subnet-tags (all must match), choosing the subnet with the most free addresses. With neither set it uses the node's primary-ENI subnet, then a subnet in the same route table, then the largest subnet in the VPC/AZ. Security groups: spec.eni.security-groups, else security-group-tags, else inherit eth0's groups. ENIs are described Cilium-CNI (<instance-id>) and tagged with the cluster tags plus --eni-tags. API calls: CreateNetworkInterface, AttachNetworkInterface, ModifyNetworkInterfaceAttribute (delete-on-termination, default true), AssignPrivateIpAddresses.

Prefix delegation ​

eni.awsEnablePrefixDelegation=true (node opt-out: eni.nodeSpec.disablePrefixDelegation) makes the operator assign /28 prefixes (16 addresses) instead of single secondary IPs, multiplying per-ENI capacity by 16. Constraints from the source:

  • Effective only on Nitro or bare-metal instance types.
  • No mixed mode: it is not used on a node that already holds secondary /32 IPs on any ENI (the primary ENI IP is ignored for this check).
  • Allocation uses AssignENIPrefixes with count ceil(needed / 16). If the subnet returns InsufficientPrefixesInSubnet, the operator logs that it will not allocate prefixes on this node anymore and falls back to /32 secondary IPs.
  • The subnet needs contiguous free /28 blocks; a fragmented subnet can run out of prefixes before it runs out of addresses (AWS-side behaviour; see the AWS prefix-delegation documentation).

Release, use-primary-address, IPv6 ​

Release (IPv4 only, requires --aws-release-excess-ips=true): the agent removes completely free CIDRs from spec.ipam.pools.allocated; the operator waits --excess-ip-release-delay (default 180 seconds), then calls UnassignPrivateIpAddresses (/32) or unassigns the prefix (/28). ENIs are not detached or deleted by release; stale ENIs are removed by ENI GC (tags io.cilium/cilium-managed=true and io.cilium/cluster-name=<name>). The 1.19-style status.ipam.release-ips handshake only applies to legacy CRD-allocator agents.

usePrimaryAddress: lets the ENI primary private IP be given to Pods (+1 per ENI). [1.21 pre-release] the operator flag --aws-use-primary-address is removed; set eni.nodeSpec.usePrimaryAddress=true (agent flag --eni-use-primary-address, stored in CiliumNode.spec.eni.use-primary-address). Helm users are unaffected because the chart only wired the agent side.

IPv6 (beta in 1.20; the agent logs a beta warning): ipam.mode=eni, eni.enabled=true, ipv6.enabled=true. Each node gets exactly one delegated /80 prefix, assigned at ENI creation or via AssignIpv6Addresses and never released; there are no watermarks; the subnet must be dual-stack, and IPv6-only subnets are unsupported because each ENI needs a primary IPv4 address. Upstream's EKS requirements page still says ENI is IPv4-only; the ENI page and the runtime beta warning are the more current source.

ENI caveats ​

Pod density is bounded by (ENIs - firstIndex) x (IPs per ENI - 1) (x16 with prefixes). Pods share the node ENI security groups unless you configure spec.eni.security-groups per node group. Clusters using Cluster Mesh with cluster IDs 128-255 plus ENI hit a known policy-drop issue (see Part 13). Removed in 1.20 (still present in 1.19): CiliumNode.spec.eni.min-allocate, pre-allocate, max-above-watermark and instance-id; the same values live under spec.ipam. Chaining on top of the VPC CNI is a different design and is not covered here.

Azure, AKS and Alibaba Cloud ​

AKS. Azure CNI Powered by Cilium is managed: ipam: delegated-plugin with CNI configuration "ipam": {"type": "azure-ipam"} and local-router-ipv4: 169.254.23.0. Cilium assigns no Pod IPs. BYOCNI clusters can use any Cilium IPAM; Helm azure.enabled is incompatible with BYOCNI, use aksbyocni.enabled.

Self-managed Azure (upstream --ipam=azure). Operator flags --azure-subscription-id, --azure-resource-group (for VMSS, the resource group that contains the VMSS; a wrong group yields no addresses), --azure-user-assigned-identity-id (a client ID, not a resource ID) and --azure-use-primary-address; authentication is the DefaultAzureCredential chain. Per-node: azure.nodeSpec.azureInterfaceName. In 1.20 the operator publishes status.azure.interfaces[] with a new subnet{id,cidr} object (the flat cidr and addresses[].subnet are deprecated). [1.21 pre-release] Azure moves onto the shared cloud multi-pool framework (the same one as ENI): every succeeded address becomes a host prefix in the default pool, excess IPs are no longer released, and the agent waits up to 5 minutes for a valid IPv4 subnet CIDR, using it as the native-routing CIDR when --ipv4-native-routing-cidr is unset.

Alibaba Cloud. ipam.mode=alibabacloud with alibabacloud.enabled and per-node alibabacloud.nodeSpec.{vSwitches,vSwitchTags,securityGroups,securityGroupTags}; CRD allocator; the installation guide replaces Terway/Flannel and uses a cilium-alibabacloud secret. This chapter does not go beyond this; Alibaba limits and IPv6 behaviour were not examined.

Beyond Pod IPs: LB IPAM, Node IPAM, CiliumCIDRGroup ​

LB IPAM and CiliumLoadBalancerIPPool ​

LB IPAM allocates addresses for type: LoadBalancer Services. It runs in the operator (operator/pkg/lbipam), is always compiled in (Helm enableLBIPAM, default true) and stays dormant until the first pool exists. It allocates only: to make an address reachable you also need BGP Control Plane (loadBalancerClass: io.cilium/bgp-control-plane) or L2 Announcements (io.cilium/l2-announcer), covered in Part 11.

Security scope. LB IPAM pools must be private ranges. LB IPAM VIPs are not reachable inside a VPC without BGP or routing integration, so on EKS use an internal AWS load balancer; the public path is always CloudFront to an internal ALB/NLB. LB IPAM fits on-prem and hybrid sites with BGP or L2 announcement. Upstream examples use public-looking ranges such as 20.0.0.0/8; this chapter uses RFC 1918 ranges.

yaml
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
  name: blue-pool
spec:
  blocks:
  - cidr: 10.0.10.0/24
  - start: 10.0.20.100        # start and stop form ONE block; keep them in one list item
    stop: 10.0.20.200
  serviceSelector:
    matchLabels:
      color: blue
  allowFirstLastIPs: "No"     # default "Yes"; "No" reserves network/broadcast; cidr blocks only
  # disabled: true            # stop new allocations, keep existing ones

Facts: API cilium.io/v2 (v2alpha1 first marked deprecated in 1.19.8), cluster-scoped, short names ippools, ippool, lbippool, lbippools; print columns Disabled, Conflicting, IPs Available. serviceSelector accepts labels plus the synthetic labels io.kubernetes.service.namespace and io.kubernetes.service.name. Overlapping pools: the later one is marked Conflicting (condition cilium.io/PoolConflict) and allocates nothing. Pool conditions also include cilium.io/IPsTotal, IPsAvailable, IPsUsed. Editing a pool can reassign existing Service IPs (upstream issue 40358), so prefer adding a new pool or setting disabled: true.

Service handling: a LoadBalancer Service without a loadBalancerClass is served by LB IPAM unless Helm defaultLBServiceIPAM is none (values: lbipam, nodeipam, none); a Service with an unknown class is ignored. The Service condition cilium.io/IPAMRequestSatisfied reports reason satisfied or no_pool. Address families: single-stack by default (IPv4 when both are enabled), PreferDualStack tries both, RequireDualStack stays unsatisfied if a family is disabled.

Requesting specific addresses: annotation lbipam.cilium.io/ips: "10.0.10.100,10.0.10.101" (the legacy .spec.loadBalancerIP is deprecated). The pool's serviceSelector still applies, and you must not request the first or last address of a pool with allowFirstLastIPs: "No".

Sharing an IP between Services:

yaml
metadata:
  annotations:
    lbipam.cilium.io/sharing-key: "web-vip"
    lbipam.cilium.io/sharing-cross-namespace: "ns1,ns2"   # or "*"

In 1.20 two Services with the same sharing key share one IP when their ports/protocols do not conflict; conflicting ones receive different IPs from the same key's set. [1.21 pre-release] the compatibility check is stricter: Services must use the same externalTrafficPolicy, and with Local both must select the same Pods. The new annotation lbipam.cilium.io/sharing-permit-different-pods: "true" disables that safety check and is unsafe with Cilium BGP or L2 announcements (they are not L4 aware, so different nodes can answer for the same IP).

Metrics (documented with the prefix cilium_; the controller runs in the operator, whose registry may override the namespace, so the runtime prefix may be cilium_operator_lbipam_*. This was not verified at runtime: check your scrape for the actual names): cilium_lbipam_conflicting_pools, cilium_lbipam_ips_available{pool}, cilium_lbipam_ips_used{pool}, cilium_lbipam_services_matching, cilium_lbipam_services_unsatisfied.

Datapath note (1.19): north-south TCP/UDP to an LB IPAM or ClusterIP address is dropped when the port does not match a provisioned Service.

Node IPAM ​

nodeIPAM.enabled=true (default false; sets enable-node-ipam) lets a Service with loadBalancerClass: io.cilium/node (or defaultLBServiceIPAM: nodeipam) publish node addresses as its load balancer ingress. After enabling on an existing install, restart the operator (kubectl -n kube-system rollout restart deployment/cilium-operator). The operator fills status.loadBalancer.ingress with each selected node's ExternalIP if present, else InternalIP, honouring ipFamilies. externalTrafficPolicy: Cluster uses all nodes; Local uses nodes hosting selected Pods (EndpointSlices required; not usable with Cilium Ingress/Gateway dummy endpoints). Nodes labelled node.kubernetes.io/exclude-from-external-load-balancers or tainted ToBeDeletedByClusterAutoscaler are excluded; the Service annotation io.cilium.nodeipam/match-node-labels restricts nodes by label.

Node IPAM publishes node addresses as service addresses. Use it only where node IPs are private; it is not a public-exposure mechanism on AWS.

CiliumCIDRGroup ​

CiliumCIDRGroup is a named list of CIDRs (spec.externalCIDRs) that policies reference with cidrGroupRef or cidrGroupSelector instead of inlining CIDRs. A group gets one security identity regardless of size, which scales better for large lists, and Hubble annotates flows with the group name. It is cluster-scoped with short name ccg. Use cilium.io/v2; v2alpha1 is deprecated (some upstream examples still show it).

yaml
apiVersion: cilium.io/v2
kind: CiliumCIDRGroup
metadata:
  name: corp-ranges
  labels:
    tier: internal
spec:
  externalCIDRs:
  - 10.30.0.0/16
  - 192.168.100.0/24
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: egress-corp
spec:
  endpointSelector:
    matchLabels:
      app: batch
  egress:
  - toCIDRSet:
    - cidrGroupRef: corp-ranges

Operations: Inspecting, Metrics, Upgrades ​

bash
# per-node view of what each node owns
kubectl get ciliumnodes -o custom-columns=NAME:.metadata.name,CIDRS:.spec.ipam.podCIDRs
kubectl get ciliumnode <node> -o yaml        # .spec.ipam, .status.ipam, .status.eni.enis
kubectl get ciliumpodippools                 # multi-pool; short name cpip
kubectl get ippools                          # LB IPAM pools
kubectl -n kube-system logs deploy/cilium-operator | grep -i ipam

Agent metrics: cilium_ipam_capacity{family,cidr} (the cidr label is the node CIDR in cluster-pool and kubernetes modes, otherwise empty), cilium_ipam_events_total{action,family}, cilium_ip_addresses{family}.

Operator metrics (prefix cilium_operator_; enable with operator.prometheus.enabled): ipam_available_ips{target_node}, ipam_used_ips, ipam_needed_ips, ipam_ip_allocation_ops{subnet_id}, ipam_ip_release_ops, ipam_interface_creation_ops, ipam_interface_candidates, ipam_empty_interface_slots, ipam_available_ips_per_subnet{subnet_id,availability_zone}, ipam_nodes{category=total|in-deficit|at-capacity}, ipam_resync_total, ipam_allocation_duration_seconds, ipam_release_duration_seconds, ipam_background_sync_duration_seconds. The upstream metrics table is stale: it omits interface_candidates, empty_interface_slots, available_ips_per_subnet and background_sync_duration_seconds; the list above follows the source. Cloud API call metrics are not under ipam_: the subsystem is the cloud name, so on AWS they are cilium_operator_ec2_api_duration_seconds{operation,response_code} and cilium_operator_ec2_api_rate_limit_duration_seconds{operation} (Azure and Alibaba Cloud use their own subsystem). 1.20 removed ipam_ips and ipam_available_interfaces.

Suggested alerts: ipam_nodes{category="at-capacity"} > 0, a rising ipam_nodes{category="in-deficit"}, ipam_available_ips_per_subnet below a threshold per AZ, sustained ec2_api_rate_limit_duration_seconds, and lbipam_conflicting_pools > 0.

Upgrade notes. Rolling 1.19 to 1.20 is supported for ENI. [1.21 pre-release] the operator ClusterRole gains list/patch on ciliumpodippools (storage-version migration), and the --aws-use-primary-address operator flag is removed (see Release, use-primary-address, IPv6). If you pass it directly on the operator rather than through Helm, replace it with eni.nodeSpec.usePrimaryAddress=true before upgrading.

Labs ​

The labs were not executed against a live cluster in this review. They were checked against upstream source and documentation (see Validation Limits). Describe what you observe against the expected behaviour in words; do not expect identical formatting.

Lab 1: Cluster-pool sizing and exhaustion (kind) ​

Prerequisites: kind with a two-worker cluster, Cilium installed from Helm with the default cluster-pool, kubectl, Helm.

  1. Install with a deliberately small node mask:

    bash
    helm upgrade --install cilium cilium/cilium --version 1.20.2 -n kube-system \
      --set ipam.mode=cluster-pool \
      --set ipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}' \
      --set ipam.operator.clusterPoolIPv4MaskSize=28
  2. Read each node's block: kubectl get ciliumnodes -o custom-columns=NAME:.metadata.name,CIDRS:.spec.ipam.podCIDRs. Each node owns a /28 (16 addresses).

  3. Pin a Deployment to one worker with a nodeSelector and scale it to 20 replicas.

  4. Expected: the first Pods run; once the node's usable addresses (16 minus network, broadcast and the router/health/ingress addresses) are used, remaining Pods stay ContainerCreating with the CNI allocation error. cilium-dbg status shows the node range fully allocated.

  5. Compute the expected limit with the Sizing math formula and compare to the observed count.

Cleanup: delete the Deployment. Rollback: the mask is immutable on a live cluster, so reinstall the cluster rather than editing the value.

Lab 2: Multi-pool selection (kind) ​

Prerequisites: kind, Cilium 1.20.2 with ipam.mode=multi-pool, kubeProxyReplacement=true, bpf.masquerade=true and the default and mars pools from the multi-pool install values.

  1. Create two namespaces. Annotate one: kubectl annotate namespace ns-a ipam.cilium.io/ip-pool=mars.
  2. Run a Pod in each namespace and inspect kubectl get pods -A -o wide. Expected: the annotated namespace's Pod gets an address from 10.20.0.0/16, the other from 10.10.0.0/16.
  3. Run a Pod in the unannotated namespace with the Pod annotation ipam.cilium.io/ip-pool: mars. Expected: the Pod annotation wins over the namespace default.
  4. Annotate a third namespace ipam.cilium.io/require-pool-match: "true" without a matching pool and start a Pod. Expected: it stays pending with a pool-match error.
  5. Inspect kubectl get ciliumnode <n> -o yaml and read spec.ipam.pools.requested and allocated; cilium-dbg status --all-addresses shows pool/ip entries.

Cleanup: delete the namespaces and, if desired, the cluster.

Lab 3: Cluster-pool to multi-pool migration (kind) ​

Prerequisites: a kind cluster on 1.20.2 with cluster-pool and Pods running. This is one-way; use a disposable cluster.

  1. Record kubectl get ciliumnodes -o yaml | grep -A3 podCIDRs.
  2. Run the Helm upgrade from the cluster-pool to multi-pool migration subsection with a default pool that covers the recorded CIDRs and the same mask.
  3. Wait for each node's PodCIDR to appear under spec.ipam.pools.allocated.
  4. Restart agents (kubectl -n kube-system rollout restart ds/cilium). Expected: existing Pods keep their addresses and appear as restored; Pods can still reach each other across nodes during the rollout.

Rollback: not supported. Recreate the cluster.

Lab 4: LB IPAM with L2 announcements (kind, private range) ​

Prerequisites: kind with l2announcements.enabled=true and kubeProxyReplacement=true; choose a free range inside the kind Docker network (private RFC 1918 space).

  1. Apply a CiliumLoadBalancerIPPool with a small start/stop block and a serviceSelector.
  2. Apply an L2 announcement policy (see Part 11) and a LoadBalancer Service whose labels match.
  3. Expected: kubectl get ippools shows IPs available decreasing, the Service gets an external IP from the block, and the Service condition cilium.io/IPAMRequestSatisfied reports satisfied.
  4. Create a second pool with an overlapping block. Expected: it is marked Conflicting and allocates nothing.
  5. Set disabled: true on the first pool, create another Service. Expected: no new allocation from it while the existing Service keeps its IP.

Cleanup: delete the Services, pools and policy.

Lab 5: EKS ENI mode with prefix delegation (sandbox account) ​

Prerequisites: a disposable EKS cluster in a private-subnet-only VPC, nodes on Nitro instance types, no public IPs on nodes, node security group admitting only the VPC/cluster security-group references (no 0.0.0.0/0 or ::/0), operator IAM per the install and node preparation subsection, and the AWS CLI profile for the target sandbox account.

  1. Create the node group with taint node.cilium.io/agent-not-ready=true:NoExecute. Disable the VPC CNI (do not install the managed add-on, or patch aws-node with the impossible node selector).
  2. Install Cilium with eni.enabled=true, eni.awsEnablePrefixDelegation=true, eni.awsReleaseExcessIPs=true, eni.iamRole set, and the other values from the Helm values subsection.
  3. Verify the inventory: kubectl get ciliumnode <n> -o jsonpath='{.status.eni.enis}' lists ENIs with prefixes; aws ec2 describe-network-interfaces --filters Name=description,Values='Cilium-CNI*' returns the same ENIs. Expected: spec.ipam.pools.allocated holds /28 blocks.
  4. Scale a Deployment past one /28 (more than 16 Pods on a node). Expected: the operator allocates another prefix; cilium_operator_ipam_available_ips stays above used + preAllocate.
  5. Scale back down and wait longer than 180 seconds. Expected: unused prefixes are released and the ENI remains attached.

Cleanup: scale to zero, uninstall Cilium, delete the node group and any leftover Cilium-CNI ENIs (ENI GC removes tagged orphans when enabled). Rollback: re-enable the VPC CNI add-on on a fresh node group; do not switch IPAM on the same live nodes.

Lab 6: Node IPAM (kind) ​

Prerequisites: kind with Cilium and nodeIPAM.enabled=true (restart the operator after enabling).

  1. Create a LoadBalancer Service with loadBalancerClass: io.cilium/node.
  2. Expected: status.loadBalancer.ingress lists node InternalIPs (kind nodes have no ExternalIP).
  3. Label one node node.kubernetes.io/exclude-from-external-load-balancers. Expected: that node's address drops out of the list.

Cleanup: delete the Service and label.

Version Delta ​

Topic1.181.191.201.21 pre-release
Multi-pool maturityBeta bannerbanner removedstablestable
Pool selectionip-pool annotation only+ ipv4-pool/ipv6-pool annotations and podSelector/namespaceSelector (already in 1.18.14); require-pool-match, skip-masquerade, --only-masquerade-default-pool first appear in 1.19+ allowFirstIP/allowLastIPsame + pool[].reservedRanges
CiliumPodIPPool APIv2alpha1v2alpha1v2alpha1v2 storage, v2alpha1 deprecated, cidrs max 32
cluster-pool to multi-pool migrationnononew (enable-cluster-pool-to-multi-pool-migration)same
ENI agent modelCRD allocatorCRD allocatormulti-pool allocator; IPv6 beta (/80); release via pools.allocatedcloud multi-pool framework shared with Azure
ENI per-node Helm (eni.nodeSpec, ipam.nodeSpec)CNI conf onlyaddedsamesame
ENI masquerade defaultsenableIPv4Masquerade false (new)enableIPv6Masquerade false (new)
ENI IAM+ DescribeRouteTables+ optional GetSecurityGroupsForVpc, DescribeManagedPrefixLists, GetManagedPrefixListEntries
Operator --aws-use-primary-addresspresentpresentpresentremoved
CiliumNode.spec.eni.{min,pre}-allocate, max-above-watermark, instance-idpresentpresentremovedremoved
Operator IPAM metricsper-node available/used/needed_ips and interface_candidates already presentsameipam_ips, ipam_available_interfaces removed
Azure IPAMCRD allocatorCRD allocatorstatus.azure.interfaces[].subnetmulti-pool protocol; no excess release
CiliumLoadBalancerIPPool APIv2v2alpha1 deprecatedv2v2
CiliumCIDRGroup APIv2alpha1 deprecatedv2v2v2
LB IPAM sharingkey + cross-namespaceport-conflict rulestricter compatibility + sharing-permit-different-pods

Troubleshooting ​

Symptom: Pod stays ContainerCreating; kubectl describe pod shows unable to allocate IP via local cilium agent.

ModeLikely causeCheckFix
cluster-pool / kubernetesnode range full (IPAM: IPv4: N/M allocated)cilium-dbg status, --all-addressesreduce density, add nodes; mask is immutable, plan a new pool/cluster for a larger one
cluster-poolpool exhaustedstatus.ipam.operator-status.error = allocator fullappend a CIDR to the list (never edit entries)
cluster-poolagent never startsCiliumNode lacks spec.ipam.podCIDRscheck operator logs, overlapping CIDRs, conflict errors
kubernetesno node PodCIDRkubectl get node -o jsonpath='{.spec.podCIDRs}'enable --allocate-node-cidrs
multi-poolneeded exceeds pool free spaceoperator-status.error: failed to allocate ipv4 address for node ... from pool ...add CIDR to the pool or add a pool
multi-poolpool not found or not yet syncedkubectl get cpip; errors ErrPoolNotReadyYet, ErrManagerPoolsNotSyncedcreate the pool before Pods; check the default pool exists
multi-pooltwo pools matcherror multiple CiliumPodIPPools match podtighten selectors
multi-poolrequire-pool-matchannotation on Pod or namespaceadd a matching pool or annotation
ENIinstance ENI/IP limitipam_nodes{category="at-capacity"}larger instance, prefix delegation
ENIsubnet out of IPsipam_available_ips_per_subnetadd a subnet via tags/IDs; with PD check for fragmentation (fallback to /32)
ENIEC2 throttlingec2_api_rate_limit_duration_secondstune externalAPILimitQPS / externalAPILimitBurstSize
ENImissing IAM actionoperator logadd the action listed in the install and node preparation subsection
ENIOS removed secondary IPsNetworkManager / systemd-networkd logsexclude eth* except eth0 from management
anyCNI crash leaked an IPIP absent from endpoints but heldwait for the 10 minute expiration; restart agent to reconcile

For LB IPAM: Service has no external IP, check the pool's Conflicting flag, the cilium.io/IPAMRequestSatisfied condition, serviceSelector match, and that something (BGP or L2) advertises the address. Remember that IPAM modes are not toggles: a mismatch between the running mode and the Helm value after a change is the signal to stop and plan a migration, not to edit the ConfigMap.

Validation Limits and Sources ​

The labs were not executed against a live cluster in this review. Commands, field names, defaults, formulas and flags were checked against the Cilium source and documentation at the cited tags. Items not verified: the IAM action names for prefix assignment and release (upstream lists only the private-IP actions); the Helm path for only-masquerade-default-pool (use extraConfig); Alibaba Cloud limits; and the 1.21 items, which come from the pre-release tag v1.21.0-pre.3. Version labels for Azure, reservedRanges and the cloud multi-pool framework may change before GA. The ENI examples target a private-subnet sandbox; no example here opens a security group to the internet, creates an internet-facing load balancer, or attaches a public IP to compute.

Sources (v1.20.2 unless noted):

Quiz ​

Test yourself with the Part 10 quiz.