labsim: rehearse the IPAM switch on 3 nodes, and catch the trap in it
Rehearsed kubernetes -> cluster-pool on the 3-node labsim cluster, which is the
transition production faces. The switch itself is undramatic: agents stayed up,
the operator adopted the pool, and the agent-not-ready taint deadlock did NOT
occur. That deadlock is specific to ADDING IPv6 -- the agent blocks on an IPv6
pod CIDR that does not exist yet. A v4-only mode switch does not hit it.
The real hazard is quieter. The operator does not preserve which node held which
/24: two nodes swapped CIDRs. Their existing pods kept their old addresses,
which now fall outside the node's range, so every other node routes that prefix
to the wrong node. Cross-node ping to those pods dropped 100% while every pod
stayed Running and every node stayed Ready. Nothing in `kubectl get pods` shows
it.
So "pods kept the same address" is the FAILURE signal here, not the reassurance
it looks like. cilium-ipam-switch.sh verify now flags pods sitting outside their
node's CIDR, which is the check that decides whether a recycle is optional
(it is not) or mandatory (it is).
Recycling every deploy/ds/sts restored it: all pods back inside their node CIDR,
cross-node ping 0% loss. Sequence proven end to end:
preflight -> apply -> restart operator then agents -> unstick if needed ->
recycle all workloads -> verify
Also fixed the recycle hint the script printed: `kubectl rollout restart
deploy,ds,sts -A` is not valid (`unknown shorthand flag: 'A'`), so anyone
following it under pressure would have got an error instead of a recycle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-24 23:13:20 +01:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# Procedure around a Cilium IPAM mode change. Works against any cluster, so the
|
|
|
|
|
# rehearsal in labsim and the real thing in production run the SAME steps.
|
|
|
|
|
#
|
|
|
|
|
# It deliberately does NOT change the mode itself. In labsim that is `helm
|
|
|
|
|
# upgrade`; in production Pulumi owns the release and a script racing it would
|
|
|
|
|
# just reintroduce drift. What this owns is everything around the apply -- the
|
|
|
|
|
# evidence, the deadlock, and the verdict.
|
|
|
|
|
#
|
|
|
|
|
# ./cilium-ipam-switch.sh preflight record what the cluster looks like now
|
|
|
|
|
# ./cilium-ipam-switch.sh unstick break the agent-not-ready taint deadlock
|
|
|
|
|
# ./cilium-ipam-switch.sh verify compare against preflight, report renumbering
|
|
|
|
|
#
|
|
|
|
|
# KUBECONFIG=... ./cilium-ipam-switch.sh preflight
|
labsim: the IPAM switch needs no pod recycle when the CIDR sources agree
Re-ran the 3-node rehearsal, this time from a state that matches production
rather than one I had accidentally skewed.
The earlier "two nodes swapped CIDRs" result was an artefact of my own setup:
labsim had been running cluster-pool with per-node CIDRs that differed from
node.spec, I flipped it to ipam=kubernetes (which resyncs CiliumNode from
node.spec), and flipping back therefore looked like a renumber. Production has
only ever run ipam=kubernetes, and all five nodes were checked: CiliumNode and
node.spec agree everywhere.
From that matching state the switch is close to a non-event: per-node CIDRs
unchanged, every pod still inside its node's range, nothing stranded, no recycle
required. The only disruption is the cilium DaemonSet restarting itself -- one
agent sat in Init:0/6 and one node briefly took the agent-not-ready taint, both
of which cleared on their own. Cross-node connectivity verified after.
So the recycle is CONDITIONAL, not a fixed step, and `verify` is what decides.
Documented both cases in the script header, because the dangerous one is silent:
stranded pods report Running and Ready while being unreachable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-24 23:20:16 +01:00
|
|
|
#
|
|
|
|
|
# Whether a recycle is needed is CONDITIONAL, and `verify` is what decides it.
|
|
|
|
|
#
|
|
|
|
|
# The operator does not preserve which node held which /24 -- it adopts whatever
|
|
|
|
|
# CiliumNode.spec.ipam.podCIDRs already says. So:
|
|
|
|
|
#
|
|
|
|
|
# * If CiliumNode already agrees with node.spec.podCIDRs on every node -- which
|
|
|
|
|
# is the case for any cluster that has only ever run ipam=kubernetes, because
|
|
|
|
|
# the operator syncs one from the other -- the pool adopts the existing
|
|
|
|
|
# allocation, no node is renumbered, and NO pod recycle is needed. Verified
|
|
|
|
|
# on the 3-node labsim cluster: CIDRs unchanged, nothing stranded, the only
|
|
|
|
|
# blip was the cilium DaemonSet restarting itself.
|
|
|
|
|
#
|
|
|
|
|
# * If the two sources DISAGREE, nodes can swap /24s. Their running pods keep
|
|
|
|
|
# addresses that no longer fall inside the node's range, every other node
|
|
|
|
|
# routes that prefix to the wrong node, and those pods go unreachable
|
|
|
|
|
# cross-node while still showing Running. Then a full recycle is mandatory.
|
|
|
|
|
#
|
|
|
|
|
# Do not skip `verify` on the assumption of the good case. Run it and read it.
|
labsim: rehearse the IPAM switch on 3 nodes, and catch the trap in it
Rehearsed kubernetes -> cluster-pool on the 3-node labsim cluster, which is the
transition production faces. The switch itself is undramatic: agents stayed up,
the operator adopted the pool, and the agent-not-ready taint deadlock did NOT
occur. That deadlock is specific to ADDING IPv6 -- the agent blocks on an IPv6
pod CIDR that does not exist yet. A v4-only mode switch does not hit it.
The real hazard is quieter. The operator does not preserve which node held which
/24: two nodes swapped CIDRs. Their existing pods kept their old addresses,
which now fall outside the node's range, so every other node routes that prefix
to the wrong node. Cross-node ping to those pods dropped 100% while every pod
stayed Running and every node stayed Ready. Nothing in `kubectl get pods` shows
it.
So "pods kept the same address" is the FAILURE signal here, not the reassurance
it looks like. cilium-ipam-switch.sh verify now flags pods sitting outside their
node's CIDR, which is the check that decides whether a recycle is optional
(it is not) or mandatory (it is).
Recycling every deploy/ds/sts restored it: all pods back inside their node CIDR,
cross-node ping 0% loss. Sequence proven end to end:
preflight -> apply -> restart operator then agents -> unstick if needed ->
recycle all workloads -> verify
Also fixed the recycle hint the script printed: `kubectl rollout restart
deploy,ds,sts -A` is not valid (`unknown shorthand flag: 'A'`), so anyone
following it under pressure would have got an error instead of a recycle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-24 23:13:20 +01:00
|
|
|
set -uo pipefail
|
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
|
|
STATE="${STATE:-$SCRIPT_DIR/.ipam-switch-state}"
|
|
|
|
|
K="kubectl"
|
|
|
|
|
|
|
|
|
|
say() { printf '\033[0;36m[ipam]\033[0m %s\n' "$*"; }
|
|
|
|
|
warn() { printf '\033[1;33m[ipam]\033[0m %s\n' "$*" >&2; }
|
|
|
|
|
|
|
|
|
|
snapshot() {
|
|
|
|
|
echo "## nodes"
|
|
|
|
|
$K get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.podCIDRs}{"\n"}{end}' 2>/dev/null
|
|
|
|
|
echo "## ciliumnodes"
|
|
|
|
|
$K get ciliumnode -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.ipam.podCIDRs}{"\n"}{end}' 2>/dev/null
|
|
|
|
|
echo "## pods"
|
|
|
|
|
$K get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.status.podIP}{"\n"}{end}' 2>/dev/null \
|
|
|
|
|
| grep -vP '\t$' | sort
|
|
|
|
|
echo "## ipam"
|
|
|
|
|
$K -n kube-system get cm cilium-config -o jsonpath='{.data.ipam}' 2>/dev/null; echo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd_preflight() {
|
|
|
|
|
mkdir -p "$STATE"
|
|
|
|
|
snapshot > "$STATE/before.txt"
|
|
|
|
|
say "recorded $(grep -c . "$STATE/before.txt") lines -> $STATE/before.txt"
|
|
|
|
|
say "mode now: $(sed -n '/^## ipam/,$p' "$STATE/before.txt" | tail -1)"
|
|
|
|
|
# The pod inventory is the rollback reference: if the switch renumbers, this
|
|
|
|
|
# is the only record of what an address USED to be.
|
|
|
|
|
say "pods on the pod network: $(sed -n '/^## pods/,/^## ipam/p' "$STATE/before.txt" | grep -c '10\.')"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# The deadlock, in one place because it WILL happen and doing it by hand under
|
|
|
|
|
# time pressure is how the wrong node gets untainted:
|
|
|
|
|
# agent has no pod CIDR -> agent not ready -> node keeps
|
|
|
|
|
# node.cilium.io/agent-not-ready:NoSchedule -> the operator that would assign
|
|
|
|
|
# the CIDR cannot schedule -> agent still has no pod CIDR.
|
|
|
|
|
# Removing the taint is safe: it exists to keep normal workloads off a node
|
|
|
|
|
# without working networking, and the operator is precisely the thing that fixes
|
|
|
|
|
# that. Kubernetes re-adds it on the next agent restart.
|
|
|
|
|
cmd_unstick() {
|
|
|
|
|
local stuck=0
|
|
|
|
|
for n in $($K get nodes -o name 2>/dev/null); do
|
|
|
|
|
$K get "$n" -o jsonpath='{.spec.taints[*].key}' 2>/dev/null | grep -q 'agent-not-ready' || continue
|
|
|
|
|
warn "${n#node/} carries agent-not-ready; removing so the operator can schedule"
|
|
|
|
|
$K taint "$n" node.cilium.io/agent-not-ready- >/dev/null 2>&1 && stuck=$((stuck+1))
|
|
|
|
|
done
|
|
|
|
|
[ "$stuck" -eq 0 ] && say "no node was stuck" || say "cleared $stuck node(s)"
|
|
|
|
|
local pend
|
|
|
|
|
pend="$($K -n kube-system get pods -l io.cilium/app=operator --no-headers 2>/dev/null | grep -c Pending)"
|
|
|
|
|
[ "${pend:-0}" -gt 0 ] && warn "$pend operator pod(s) still Pending — check tolerations, not just taints"
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd_verify() {
|
|
|
|
|
[ -f "$STATE/before.txt" ] || { warn "no preflight snapshot; nothing to compare"; return 1; }
|
|
|
|
|
snapshot > "$STATE/after.txt"
|
|
|
|
|
echo
|
|
|
|
|
say "mode: $(sed -n '/^## ipam/,$p' "$STATE/before.txt" | tail -1) -> $(sed -n '/^## ipam/,$p' "$STATE/after.txt" | tail -1)"
|
|
|
|
|
|
|
|
|
|
# The question that decides the size of the maintenance window: did per-node
|
|
|
|
|
# CIDRs survive, or was every node renumbered (and every pod with it)?
|
|
|
|
|
local moved=0
|
|
|
|
|
while IFS=$'\t' read -r node cidr; do
|
|
|
|
|
[ -z "${node:-}" ] && continue
|
|
|
|
|
local now; now="$(sed -n '/^## ciliumnodes/,/^## pods/p' "$STATE/after.txt" | awk -F'\t' -v n="$node" '$1==n{print $2}')"
|
|
|
|
|
if [ -n "$now" ] && [ "$now" != "$cidr" ]; then
|
|
|
|
|
printf ' %-16s %s -> %s\n' "$node" "$cidr" "$now"; moved=$((moved+1))
|
|
|
|
|
fi
|
|
|
|
|
done < <(sed -n '/^## ciliumnodes/,/^## pods/p' "$STATE/before.txt" | grep -P '\t')
|
|
|
|
|
if [ "$moved" -eq 0 ]; then
|
|
|
|
|
say "per-node CIDRs UNCHANGED — the pool adopted the existing allocation"
|
|
|
|
|
else
|
|
|
|
|
warn "$moved node(s) renumbered — every pod on them must be recycled"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
local before after same
|
|
|
|
|
before="$(sed -n '/^## pods/,/^## ipam/p' "$STATE/before.txt" | grep -P '\t10\.' | wc -l)"
|
|
|
|
|
after="$(sed -n '/^## pods/,/^## ipam/p' "$STATE/after.txt" | grep -P '\t10\.' | wc -l)"
|
|
|
|
|
same="$(comm -12 <(sed -n '/^## pods/,/^## ipam/p' "$STATE/before.txt" | grep -P '\t10\.' | sort) \
|
|
|
|
|
<(sed -n '/^## pods/,/^## ipam/p' "$STATE/after.txt" | grep -P '\t10\.' | sort) | wc -l)"
|
|
|
|
|
say "pods: $before before, $after after, $same kept the SAME address"
|
|
|
|
|
# Keeping the address is NOT the good outcome. If a node's CIDR moved, its
|
|
|
|
|
# existing pods keep IPs that no longer fall inside it, every other node routes
|
|
|
|
|
# that prefix to the WRONG node, and those pods go unreachable cross-node while
|
|
|
|
|
# looking perfectly healthy. Observed in labsim: two nodes swapped CIDRs and
|
|
|
|
|
# cross-node ping to their pods dropped 100%, with every pod still Running.
|
|
|
|
|
# This is the check that decides whether a recycle is optional or mandatory.
|
|
|
|
|
local stranded=0
|
|
|
|
|
while read -r ns name ip node; do
|
|
|
|
|
[ -z "${node:-}" ] && continue
|
|
|
|
|
local cidr; cidr="$($K get ciliumnode "$node" -o jsonpath='{.spec.ipam.podCIDRs[0]}' 2>/dev/null)"
|
|
|
|
|
[ -z "$cidr" ] && continue
|
|
|
|
|
case "$ip" in
|
|
|
|
|
"${cidr%.*/*}".*) ;;
|
|
|
|
|
*) printf ' STRANDED %-40s %-15s on %s (now %s)\n' "$ns/$name" "$ip" "$node" "$cidr"; stranded=$((stranded+1)) ;;
|
|
|
|
|
esac
|
|
|
|
|
done < <($K get pods -A -o jsonpath='{range .items[?(@.status.podIP)]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.podIP}{" "}{.spec.nodeName}{"\n"}{end}' 2>/dev/null | grep -E ' 10\.')
|
|
|
|
|
if [ "$stranded" -gt 0 ]; then
|
|
|
|
|
warn "$stranded pod(s) sit OUTSIDE their node CIDR — unreachable cross-node until recycled"
|
|
|
|
|
warn "recycle: for ns in $(kubectl get ns -o name | cut -d/ -f2); do kubectl -n $ns rollout restart deploy,ds,sts 2>/dev/null; done"
|
|
|
|
|
else
|
|
|
|
|
say "every pod is inside its node CIDR — no recycle needed"
|
|
|
|
|
fi
|
|
|
|
|
say "not-Running pods: $($K get pods -A --no-headers 2>/dev/null | grep -vcE 'Running|Completed')"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "${1:-}" in
|
|
|
|
|
preflight) cmd_preflight ;;
|
|
|
|
|
unstick) cmd_unstick ;;
|
|
|
|
|
verify) cmd_verify ;;
|
|
|
|
|
*) sed -n '2,16p' "$0"; exit 1 ;;
|
|
|
|
|
esac
|