Cilium
Cilium is this platform’s CNI, its kube-proxy replacement, and the Gateway
API data plane — one component doing all three jobs. It installs in Stage 2
of the EKS bootstrap,
after Stage 1’s temporary VPC-CNI and kube-proxy have already gotten the
nodes to Ready. None of what follows has a source document anywhere else
in the repository — it lives only in CLAUDE.md, Helm values comments, and
OpenTofu resource comments, and every item here has cost real debugging time
at least once.
Replacing the CNI and kube-proxy
Stage 1 (opentofu/eks/init/main.tf) installs the vpc-cni and kube-proxy
EKS addons with before_compute = true purely to get nodes Ready quickly;
both are already scheduled for replacement. One detail matters beyond that:
vpc-cni is configured with WARM_ENI_TARGET=0 —
vpc-cni = {
before_compute = true
most_recent = true
configuration_values = jsonencode({
env = {
WARM_ENI_TARGET = "0"
WARM_IP_TARGET = "1"
}
})
}— because without it, VPC-CNI pre-warms secondary ENIs in the primary
10.0.x.x subnets, and Cilium reuses those instead of creating fresh ENIs in
the 100.64.x.x pod subnets once it takes over.
Stage 2 (opentofu/eks/configure/main.tf) then does three things in order,
each patching a DaemonSet’s nodeSelector to an impossible label rather than
deleting the EKS addon — declarative, no local-exec:
disable_vpc_cnipatchesaws-nodeso it schedules on no nodes.helm_release.ciliuminstalls Cilium withkubeProxyReplacement: true— eBPF-based service routing replaceskube-proxyoutright.disable_kube_proxypatches thekube-proxyDaemonSet the same way.
Cilium’s operator.unmanagedPodWatcher then restarts any pod not managed by
Cilium (CoreDNS, the EBS CSI driver, …) automatically, so no manual restart
step is needed after the CNI swap.
IPAM: prefix delegation on the secondary CIDR
Pods draw their IPs from the secondary CIDR 100.64.0.0/16, not from the
VPC’s primary range, via AWS prefix delegation:
# opentofu/eks/init/helm_values/cilium.yaml
eni:
enabled: true
subnetTagsFilter:
- "cilium.io/pod-subnet=true"
awsEnablePrefixDelegation: true
ipam:
mode: eni
routingMode: nativesubnetTagsFilter is how Cilium finds the right subnets, and the tagging
rule is a hard trap: the 100.64.x.x subnets must not carry the
kubernetes.io/role/cni tag —
# opentofu/network/network.tf
# NOTE: Do NOT add "kubernetes.io/role/cni" tag here!
"cilium.io/pod-subnet" = "true" # For future use when Cilium bug #43493 is fixed— because VPC-CNI uses that tag for its own subnet discovery during Stage 1
bootstrap. Tag the pod subnets with it and VPC-CNI claims them too, creating
orphan ENIs the moment Cilium takes over in Stage 2. cilium.io/pod-subnet=true
is the only tag these subnets should carry.
Prefix delegation only benefits nodes created after Cilium is running. Stage 1 bootstrap nodes predate it, so their ENIs get individually-allocated secondary IPs and never convert — a permanent ceiling of roughly 42 pod IPs per node instead of the ~240 a Karpenter-provisioned node gets. See AWS Foundations for the recycle step that works around it.
The CNI ConfigMap and its manual cniVersion
Cilium reads its ENI settings (first-interface-index, subnet-tags,
disable-prefix-delegation) from a ConfigMap the platform owns,
opentofu/eks/configure/cilium-cni-config.tf, referenced from the chart via
cni.configMap: cilium-cni-configuration. That has a consequence: setting
cni.configMap means the chart’s own cniVersion default never applies —
this repository’s ConfigMap is authoritative, so cniVersion has to be
bumped by hand on every Cilium minor that changes the CNI standard version
(Cilium 1.20 moved it 0.3.1 → 1.0.0):
"cni-config" = jsonencode({
cniVersion = "1.0.0"
name = "cilium"
plugins = [{
cniVersion = "1.0.0"
type = "cilium-cni"
eni = {
"first-interface-index" = 1
"subnet-tags" = { "cilium.io/pod-subnet" = "true" }
"disable-prefix-delegation" = false
}
}]
})There’s no automated check for this drift — it’s a line to remember on every Cilium minor upgrade, not something CI catches.
WireGuard is load-bearing, not an optimisation
encryption.type: wireguard, and do not replace it with
ztunnel transparent encryption, while
cilium#43493 is open. It is
the workaround for a routing bug, not a security nice-to-have.# opentofu/eks/init/helm_values/cilium.yaml
encryption:
enabled: true
type: wireguardUnder native routing with ENI-mode prefix delegation, the BPF ipcache sets
the hastunnel flag incorrectly for remote pods — the Gateway API L7 proxy
(Envoy) then fails on cross-node backend connections. WireGuard’s
node-to-node tunnels bypass that faulty routing logic; that’s the actual
fix, and it’s why encryption is enabled here for correctness, not primarily
for defense in depth.
This is AWS-specific. ADR-0005
rescopes it for the GKE port: with ipam.mode=kubernetes there instead of
eni, the code path #43493 lives in is never taken, so WireGuard is expected
to be unnecessary there — pending the empirical cross-node L7 test that
confirms it.
The Gateway API CRD startup-probe trap
cilium-operator probes for the Gateway API CRDs exactly once, at
startup. If any are missing, it logs Required GatewayAPI resources are not found, permanently disables its Gateway API controller, and never
retries — no crash, no alert. Every GatewayClass then sits at
Accepted=Unknown (“Waiting for controller”), every Gateway stays
unprogrammed, HTTPRoutes get no status.parents, and any App claim that
owns a route is stuck READY=False — a failure that reads as a broken app,
not a missing CRD.
This isn’t hypothetical: on 2026-08-19, Cilium 1.20’s new requirement for
backendtlspolicies broke Gateway API on a rebuild because
gateway_api_crds_urls in opentofu/eks/configure/locals.tf didn’t have it
yet, and Flux’s own CRD directory applied it two seconds too late for the
operator’s one-shot probe to see.
Recover: kubectl rollout restart -n kube-system deployment/cilium-operator
reruns the probe. Fix durably: add the missing CRD’s URL to
gateway_api_crds_urls. That list is append-only — the count index of the
existing entries must not shift, or tofu destroys and recreates every live
CRD, taking every Gateway and HTTPRoute with it. See
Gateway API for
the resource model these CRDs back.
Other gotchas worth knowing
devices: "eth+ pod-id-link+"— explicit device list, so Cilium doesn’t pick uptailscale0’s MTU by scanning all interfaces.socketLB.hostNamespaceOnly: true— required for Tailscale pods to work. Whenfalse, Cilium’s socket load-balancer intercepts DNS inside pod network namespaces, which breaks anything that manipulates its own networking the way Tailscale does (tailscale/tailscale#15478).envoy.xdsModeis deliberately left unset so Cilium ≥ 1.20 picks its new default,ads, instead of the legacysplitmode. SettingupgradeCompatibility: "1.19"anywhere would silently pin it back.
Related
- AWS Foundations — the two-stage bootstrap this section assumes, and the node-recycle step for the prefix-delegation ceiling.
- Gateway API —
the routing model Cilium implements as
io.cilium/gateway-controller. - Private Access — Tailscale’s own interaction with this data plane.