Cilium Migration Log
This document is the chronological log of the Calico→Cilium migration
described in docs/engineering/specs/2026-05-10-calico-to-cilium-design.md.
It is updated iteratively during PR3 (per-node migration) and finalized in PR4.
After PR4 merges, this log is retained as a historical artifact so future debugging can reference what was done and when.
PR2 — Cilium installed alongside Calico
Section titled “PR2 — Cilium installed alongside Calico”- Date: 2026-05-11
- Operator: Sean
- Velero backup:
pre-cilium-install-20260511-154646(succeeded; default storage location) - Cilium version: 1.19.3
- Cluster state pre-install: 8 nodes on Calico CNI; the
gateway-apiArgoCD Application’s resources were partially orphaned post-PR1 merge (Application pruned, but 13 CRDs + safe-upgrades policy remained — cleaned up manually via kubectl per the PR2 pre-flight before the Ansible run). - Install duration: ~20 min including diagnosis of two surfaced bugs (see below).
Bugs surfaced during PR2 execution
Section titled “Bugs surfaced during PR2 execution”-
CEL regex escape in safe-upgrades-policy.yaml — YAML double-quoted
\\.parsed to\.which CEL rejected as an unrecognized escape. Fixed in this PR by switching every\\escape to\\\\(5 backslash escapes total in the bundle-version regex: two\\.instances and one\\d) so YAML parses each to\\and CEL parses each to literal\in the regex. Final regex passed to CEL:v1\.[0-3]\.\d+. -
Operator nodeSelector value mismatch —
node-role.kubernetes.io/control-plane: ""(kubeadm convention) didn’t match k3s’s"true"value; operator pods stayed Pending, blocking agent CRD registration. Fixed in this PR by changing the selector value to"true". -
kubernetes.core.helm6.2.0 incompatible with Helm v4 — error “too many values to unpack (expected 3)”. Root cause:kubernetes.core6.2.0 (Oct 2025) parses helm CLI output assuming the v3 format; Helm v4 changed the column layout. The collection’s 6.3.0 release acknowledged this by explicitly capping support at<4.0.0; 6.4.0 (Apr 2026) added v4 compatibility across the Helm modules (issue #1038).Resolution (follow-up PR after PR2 merge): bumped
ansible/requirements.ymlfloor from>=5.0.0to>=6.4.0and upgraded the controller’s local collection. Re-ran the role end-to-end against the post-PR2 cluster as a smoke test — clean run, zero failed tasks, helm release advanced from revision 2failedto revision 3deployed. Tracked in beadshl-3y4.Workaround used during PR2 install (kept for reference; superseded by the 6.4.0 bump):
Terminal window # Render the role's values template to a tmpfile, then run helm directlyansible localhost -m template \-a "src=ansible/roles/cilium/templates/values.yaml.j2 dest=/tmp/cilium-values.yaml" \-e "@ansible/roles/cilium/defaults/main.yml" \-e "k8s_context=fzymgc-house"helm upgrade cilium cilium/cilium \--version 1.19.3 \--namespace kube-system \--values /tmp/cilium-values.yaml \--wait --timeout 15m# And manually apply CiliumNodeConfig (Phase 3) since Phase 2 module# failure short-circuited the rest of the role:ansible localhost -m template \-a "src=ansible/roles/cilium/templates/cilium-node-config.yaml.j2 dest=/tmp/cilium-node-config.yaml" \-e "@ansible/roles/cilium/defaults/main.yml" \-e "k8s_context=fzymgc-house"kubectl apply -f /tmp/cilium-node-config.yaml -
Hubble Relay stuck with stale Calico IP — pod was scheduled during the broken-install window before Cilium agents were Ready, picked up a Calico IP from the still-active Calico CNI. Resolved by
kubectl delete podto force recreation once operator + agents were Ready. See the generalizable runbook in “PR3 troubleshooting” below.
Post-install verification (2026-05-11 ~16:35 ET / 20:35 UTC)
Section titled “Post-install verification (2026-05-11 ~16:35 ET / 20:35 UTC)”| Check | Result |
|---|---|
cilium status --wait (post-fixes) | OK |
| Cilium DaemonSet | 8/8 Ready |
| cilium-operator | 2/2 Ready (both on CP nodes: tpi-alpha-1, tpi-alpha-2) |
| Hubble Relay | 1/1 Ready |
| Hubble UI | 1/1 Ready |
CiliumNodeConfig cilium-default | Applied; matches 0 nodes (correct for pre-PR3) |
| Pods on Cilium CIDR | 0 (correct — cni.customConf: true in migration mode) |
cilium-config.enable-policy | never (migration mode) |
| Workload pod restarts attributable to install | 0 (9 pods started in the install window were all velero/*-kopia-maintain-job-* CronJob runs — see “Velero/Kopia churn during install” below) |
6 bastion CiliumNetworkPolicy resources created via ArgoCD | (will land after this PR merges and ArgoCD syncs) |
Velero/Kopia churn during install
Section titled “Velero/Kopia churn during install”The 9 pods that started during the install window were all scheduled CronJobs
in the velero namespace, unrelated to the Cilium install:
| Pod | Namespace | Start (UTC) |
|---|---|---|
argocd-default-kopia-maintain-job-*-cxh7s | velero | 2026-05-11T20:11:11Z |
grafana-default-kopia-maintain-job-*-6c5nc | velero | 2026-05-11T20:11:23Z |
longhorn-system-default-kopia-maintain-job-*-7nh5s | velero | 2026-05-11T20:11:32Z |
vault-default-kopia-maintain-job-*-k2xtp | velero | 2026-05-11T20:20:22Z |
velero-default-kopia-maintain-job-*-wjj5w | velero | 2026-05-11T20:20:32Z |
kube-system-default-kopia-maintain-job-*-t92s6 | velero | 2026-05-11T20:20:41Z |
metallb-default-kopia-maintain-job-*-27xdg | velero | 2026-05-11T20:20:50Z |
traefik-default-kopia-maintain-job-*-zrddl | velero | 2026-05-11T20:21:00Z |
grafana-operator-default-kopia-maintain-job-*-rhv5s | velero | 2026-05-11T20:21:09Z |
All have the kopia-maintain-job- pattern indicating Velero’s Kopia
maintenance scheduled jobs (per-app-namespace backup repository upkeep).
None were workload pods; none were restarts of existing pods.
PR3 troubleshooting — generalizable patterns
Section titled “PR3 troubleshooting — generalizable patterns”Two recurring patterns from PR2 install that operators should expect during per-node migration:
-
Cilium-managed pod stuck with Calico IP (Hubble Relay, controllers, etc.)
When a pod is scheduled during a transient window where Cilium hasn’t yet claimed CNI ownership on its target node, kubelet picks up Calico’s CNI conf and the pod gets a Calico-range IP (
10.42.x.y). Even after Cilium completes its setup on that node, the existing pod keeps its Calico IP and may fail to communicate with Cilium-managed services.Detection:
Terminal window # Find Cilium-namespace pods whose IPs are NOT in the Cilium pod CIDRkubectl get pods -n kube-system -o wide | rg '10\.42\.'Resolution:
Terminal window # Force pod recreation; new pod will pick up Cilium IP from current CNIkubectl delete pod -n <namespace> <pod-name>This applied to hubble-relay during PR2 install. Expect it to apply to any non-DaemonSet Cilium-component pod (hubble-ui, cilium-operator) if scheduled during a node-migration transient.
Constraint reminder
Section titled “Constraint reminder”Between PR2 merge and PR4 merge, no new NetworkPolicy or
CiliumNetworkPolicy resources may merge to argocd/. During this
window, policyEnforcementMode: "never" makes Cilium ignore all policies
cluster-wide, so newly-added policies would silently fail to enforce and
create a security regression. The bastion’s pre-staged policies (this PR)
are exempt because they’re the migration target, not new functionality.
PR3 — Per-node migration
Section titled “PR3 — Per-node migration”Pre-flight
Section titled “Pre-flight”-
Date: 2026-05-11
-
Operator: Sean
-
Pre-flight Velero backup:
pre-pr3-migration-20260511-195355(Completed; default storage location) -
Cluster state archive:
/tmp/pre-pr3-cluster-state.tar.gz(17 MB; retain through PR4) -
system-upgrade-controller: scaled to 0 replicas (restored in PR4)
-
Pre-PR3 invariants verified:
- 0 nodes labeled with
io.cilium.migration/cilium-default=true cilium-config.enable-policy = never(migration mode)- 0 workload pods on Cilium CIDR
10.245.0.0/16
- 0 nodes labeled with
-
SSH connectivity: all 8 nodes return SUCCESS via
ansible -m ping -
PDB inventory (drain risk assessment):
calico-apiserver/calico-apiserver minAvailable=- maxUnavailable=1calico-system/calico-typha minAvailable=- maxUnavailable=1cert-manager/cert-manager minAvailable=1 maxUnavailable=-cert-manager/cert-manager-cainjector minAvailable=1 maxUnavailable=-cert-manager/cert-manager-webhook minAvailable=1 maxUnavailable=-loki/loki-backend minAvailable=- maxUnavailable=1loki/loki-read minAvailable=- maxUnavailable=1loki/loki-write minAvailable=- maxUnavailable=1longhorn-system/csi-attacher minAvailable=1 maxUnavailable=-longhorn-system/csi-provisioner minAvailable=1 maxUnavailable=-longhorn-system/instance-manager-* minAvailable=1 maxUnavailable=- (8 per-node singletons)nats/nats minAvailable=- maxUnavailable=1postgres/main minAvailable=1 maxUnavailable=-postgres/main-primary minAvailable=1 maxUnavailable=-traefik/traefik minAvailable=1 maxUnavailable=-vault/vault minAvailable=- maxUnavailable=1Likely drain blockers: Longhorn
instance-manager-*pods are per-node singletons withminAvailable=1and are not DaemonSets, so a drain targeting their node will trip the PDB. The documented mitigation iskubectl drain --disable-eviction(delete API bypass). Note in each per-node log entry whether--disable-evictionwas needed.
Migration order:
- tpi-beta-1 (worker)
- First-node validation gate — IPv6 dual-stack + stability soak (4-24 h)
- tpi-beta-2, tpi-beta-3 (workers)
- tpi-beta-4, tpi-alpha-4 (workers; bastion’s host node migrates here)
- All-workers stability gate — 24 h soak before touching control plane
- tpi-alpha-3, tpi-alpha-2, tpi-alpha-1 (control plane, reverse order, preserves etcd quorum)
(Per-node entries appended below as each migration completes.)
tpi-beta-1 — migrated 2026-05-12 00:13 UTC
Section titled “tpi-beta-1 — migrated 2026-05-12 00:13 UTC”- Drain duration: 35 s (under the 5-min “expected” window). One PDB
block hit on
longhorn-system/instance-manager-5ed5af1b5f1...— Longhorn rescheduled the replica during the retry loop and the fourth eviction attempt succeeded.--disable-evictionNOT used. Other PDB-protected workloads (postgres/main-16,loki/loki-write-2,nats/nats-1) evicted cleanly thanks to theirmaxUnavailable=1PDBs permitting one-at-a-time eviction. 29 pods evicted total, 0 stuck. - Cilium agent restart on labeled node: cilium pod replaced within ~30 s; new pod Ready after ~60 s.
- CNI conf flip confirmed via cilium-agent’s host mount:
05-cilium.conflistwritten;10-calico.conflistrenamed to10-calico.conflist.cilium_bak(thecni-exclusive: truebehavior). - Reboot duration: 2 m 3 s (ansible.builtin.reboot,
reboot_timeout=600 post_reboot_delay=30). - Post-reboot transient:
cilium-envoy-f5xwqshowed 0/1 Ready for ~60 s while its xDS gRPC stream reconnected to the freshly-started agent. Resolved on its own;cilium status --waitreturned OK shortly after. - Smoke test (modified — see “Anomalies” below):
- Verify-network pod IPs:
10.245.5.191/32(v4) andfd00:10:245:5::1776/128(v6) — both from Cilium’s cluster pools. - API server reachability: TLS handshake to
https://10.43.0.1:443/healthzcompletes in 8 ms (returns 401 from the auth wall — connectivity is healthy). - External egress:
ping 1.1.1.1returns 7 ms. - Cross-CNI Cilium→Calico (v4 ICMP, 10.245.5.x → 10.42.180.76): 3/3 received, 0.5-0.8 ms RTT.
- Cross-CNI Calico→Cilium (v4 ICMP, 10.42.x.x → 10.245.5.54): 3/3 received, 0.5-0.8 ms RTT.
- Verify-network pod IPs:
- Pods on node post-migration: 16 total (8 with Cilium pod IPs, 7
hostNetwork including kept Calico-node DS pod, 1 leftover
node-debugger). 0 stragglers (no pod with a stale
10.42.x.xIP). - Agent restart count post-migration: 1 (the single reboot-induced restart; no flapping).
- Anomalies (plan-text bugs, none migration-caused):
- Plan’s smoke test uses
rgbutghcr.io/nicolaka/netshoot:v0.8shipsgreponly, notrg. Substitutedgrep -F "10.245.". The plan’s “if a future version drops those tools” disclaimer was already obsolete at write time. Track for future plan revisions. - Plan’s
curl -fs https://api.github.comtest fails withrc=6(couldn’t resolve host) on any node in this cluster because of a musl libc + cluster search-domain (fzymgc.house) interaction that returns an empty authoritative response, short-circuiting musl’s domain iteration. Reproduced on a still-Calico node (tpi-beta-2) for confirmation. The plan’s test is a false-negative on this cluster regardless of CNI. - Cluster’s Calico install is single-stack v4 (259 v4 pod IPs, 0 v6 pod IPs cluster-wide pre-migration). Cilium is ADDING v6 capability, not preserving it. This means cross-CNI v6 ping is N/A — no Calico-side v6 peer exists. v6 cross-node tunneling can only be validated after node 2 migrates.
- Plan’s smoke test uses
First-node validation gate — 2026-05-12 00:25 UTC
Section titled “First-node validation gate — 2026-05-12 00:25 UTC”- IPv6 dual-stack: WORKING within scope.
- Cilium pod v6 address from pool
fd00:10:245::/48: ✓ (fd00:10:245:5::29af/128sample). - v6 default route present: ✓ (
default via fd00:10:245:5::ee96 dev eth0 metric 1024 mtu 1360). - v6 pod-to-pod within Cilium on same node: ✓ (3/3 ping6, 73-143 μs RTT — kernel-only, doesn’t exercise VXLAN).
- v6 DNS resolution (AAAA for external host via cluster DNS): ✓
(8 Google AAAA records returned via
dig @10.43.0.10). - v6 cross-CNI: N/A (Calico is v4-only; no Calico v6 peer exists in the cluster).
- v6 to kube-apiserver: N/A (kube-apiserver Service is
single-stack v4 per
ipFamilies: ["IPv4"]; cluster’s control-plane addressing was always v4 and Cilium doesn’t change that). - Decision: v6 is adding capability without breaking anything, so the IPv6 fallback (disable v6 cluster-wide) is NOT invoked. Full v6 cross-node validation is deferred until after node 2’s migration when there will be two Cilium-managed nodes available.
- Cilium pod v6 address from pool
- Stability soak duration: ~20 min (intentionally shortened from the plan’s 4-24 h target). Rationale: operator judgment to proceed with tpi-beta-2 immediately so that cross-node Cilium↔Cilium validation (v4 AND v6 VXLAN tunneling) could be added to the evidence set. The soak gate’s purpose is to catch latent stability issues; functional cross-node evidence is arguably more decisive for proving the migration design works. Mitigation: tpi-beta-1’s agent restart count was monitored during and after tpi-beta-2’s migration; remained at 1 throughout (no destabilization).
- Agent restart count on tpi-beta-1 at gate entry: 1 (single reboot-induced restart).
- NetworkNotReady events on tpi-beta-1: none.
- Cluster-wide collateral check: all 8 nodes Ready; 38 ArgoCD
applications Synced/Healthy; 2 preexisting unhealthy pods
(
karakeep/meilisearchCrashLoopBackOff 26 h,temporal-worker- controller-managerImagePullBackOff 7 h 31 m) — both predate the migration and are not regressions. - Decision (taken 2026-05-12 ~00:45 UTC): proceeded to tpi-beta-2 without the longer soak; functional cross-node evidence (see tpi-beta-2 entry below) supersedes the time-based soak as validation that the migration design works.
tpi-beta-2 — migrated 2026-05-12 00:55 UTC
Section titled “tpi-beta-2 — migrated 2026-05-12 00:55 UTC”- Drain wall-clock 5 m 10 s. First-pass
kubectl drain --ignore-daemonsets --delete-emptydir-data --timeout=5mhit the global timeout while retrying eviction oflonghorn-system/instance-manager-9e303193f6af67a4962641022b0ed2ac(Longhorn did NOT self-resolve the PDB this time, unlike on tpi-beta-1). Other 29 pods evicted within ~30 s. Fallbackkubectl drain ... --disable-evictioncompleted in 7 s by using the delete API to bypass the PDB.--disable-evictionused — documented per plan Task 3 Step 2 fallback policy. No workload impact (Longhorn replicas remain healthy on other nodes). - CNI conf flip confirmed:
05-cilium.conflistwritten;10-calico.conflistrenamed to.cilium_bak. - Reboot duration: 2 m 2 s.
- Cilium global health: post-reboot showed “1 warning” momentarily; resolved on its own within ~30 s (cilium-envoy xDS reconnect pattern, same as tpi-beta-1).
- Smoke test: verify-network pod got
10.245.6.235/32(v4) andfd00:10:245:6::4bc2/128(v6) — different /24 and /64 from tpi-beta-1’s pool slice, confirming per-node IPAM allocation. External egress works (ping 1.1.1.1: 7 ms RTT, 0% loss). - CRITICAL CROSS-NODE VALIDATION (only possible with 2 Cilium nodes):
- v4
tpi-beta-2 → tpi-beta-1(10.245.6.x → 10.245.5.54): 3/3, 0.5-0.8 ms RTT — VXLAN tunneling works. - v6
tpi-beta-2 → tpi-beta-1(fd00:10:245:6:: → fd00:10:245:5::9fdf): 3/3, 0.6-1.6 ms RTT — IPv6 VXLAN tunneling works across nodes. - v4 reverse (b1 → b2): 3/3, 0.5-0.9 ms RTT.
- v6 reverse (b1 → b2): 3/3, 0.7-1.3 ms RTT.
- Cross-CNI still works (b2 Cilium → Calico pod on tpi-alpha-3): 3/3, 0.6-1.4 ms RTT.
- v4
- Pods on node post-migration: 15 total (10 Cilium-managed, 5 hostNetwork). 0 stragglers.
- tpi-beta-1 stability through tpi-beta-2 migration: agent restart count remained at 1 (no destabilization from second migration). Confirms node migrations are independent.
- Cluster-wide Cilium-managed pods: 8 → 32 of 221 (the jump is the
full set of newly-Cilium pods on tpi-beta-2 PLUS the workload
pods that migrated to tpi-beta-2 from tpi-beta-1’s earlier drain
rescheduling, e.g.,
nats/nats-1,mosquitto-*-7lwhm). - Anomalies: same 3 plan-text bugs as tpi-beta-1 entry (no new
surprises).
--disable-evictionwas needed this time (it wasn’t for tpi-beta-1) — confirms Longhorn’s PDB self-resolution on tpi-beta-1 was favorable timing, not a reliable pattern.
tpi-beta-3 — migrated 2026-05-12 ~01:20 UTC
Section titled “tpi-beta-3 — migrated 2026-05-12 ~01:20 UTC”-
Two operational adventures this time (worth recording because they may recur on remaining nodes):
- User initiated
kubectl drainwithout--timeout. Drain hung indefinitely on the Longhorninstance-manager-1b8c1c4d55fc...PDB block (same pattern as tpi-beta-2). Resolution: user Ctrl-C the stuck drain, thenkubectl drain --disable-evictioncompleted in 5 s. Lesson: future per-node migrations should runkubectl drain --disable-evictiondirectly from the start (skip the timeout-then-retry dance). Plan revision should reflect this. - vault-1 got bounced across multiple nodes during the long drain
window and its data Longhorn volume got stuck. Sequence:
vault-1 was on tpi-beta-3 originally; drain evicted it; k8s
scheduled vault-1 to tpi-beta-2; pod stuck ContainerCreating
~10 min with
FailedAttachVolume: pvc-3008cdde.... While the pod was bouncing, Longhorn’s volumecurrentNodeIDstayed at tpi-beta-3 because the OLD kubelet on tpi-beta-3 still held an active mount reference. Once tpi-beta-3 rebooted, the volume detached cleanly — but by then vault-1’s pod had been moved to tpi-alpha-4 by k8s, while the volume reattached to tpi-beta-2 (mismatch). Resolution:kubectl delete pod -n vault vault-1to force a StatefulSet recreation. New vault-1 landed on tpi-beta-3 (which had finished rebooting); Longhorn detached the volume from tpi-beta-2 and reattached to tpi-beta-3 in ~10 s; vault-1 reachedRunning 1/1~95 s after recreation. Lesson: pod-volume mismatch from drain-bouncing resolves itself if you delete the stuck pending pod and let the StatefulSet controller recreate. No manualvolume.spec.nodeIDpatch dance was needed.
- User initiated
-
Drain duration: 5 s (post-Ctrl-C, with
--disable-eviction). -
Reboot duration: 2 m 3 s.
-
CNI flip confirmed:
05-cilium.conflistwritten;10-calico.conflistrenamed to.cilium_bak. -
Note on cordon state: tpi-beta-3 appeared uncordoned post- reboot, but this was Sean uncordoning manually outside the scripted command sequence — NOT automatic k3s behavior. Confirmed during tpi-alpha-4 verification: cordons DO persist through reboot. Add explicit
kubectl uncordon ${NODE}to every per-node sequence. (Earlier draft of this log claimed “k3s clears unschedulable on reboot” — that was wrong.) -
Smoke test:
- Verify-network pod:
10.245.7.59/32(v4) andfd00:10:245:7::41a0/128(v6). External egress (ping 1.1.1.1) 6 ms. - Cross-node b3 → b1 v4: 1.6-2.5 ms; v6: 0.59-0.61 ms.
- Cross-node b3 → b2 v4: 13-14 ms; v6: 11-15 ms. (Higher RTT than b3 → b1; likely a transient or physical-layer factor — 0% loss is what matters.)
- Verify-network pod:
-
Pods on node post-migration: 14 total (11 Cilium-managed including
vault/vault-1, 3 hostNetwork). 0 stragglers. -
All-prior-nodes stability through tpi-beta-3 migration: tpi-beta-1 restart count = 1, tpi-beta-2 restart count = 1. Cluster Cilium pod count: 32 → 52 / 221.
-
Notes for remaining nodes:
- Run
kubectl drain --disable-evictiondirectly (skip the standard drain attempt). - Don’t forget to
kubectl uncordon ${NODE}after the node is Ready post-reboot. Cordon persists through reboot; DaemonSets schedule on cordoned nodes (they tolerate the NoSchedule taint), so the node will appear healthy even when cordoned. Workload pods won’t reschedule there. - Watch for StatefulSet pods (vault, postgres, dolt, temporal,
loki-write, nats, prometheus) bouncing during drain. If any pod
is stuck
ContainerCreatingfor >5 min withFailedAttachVolume, delete the pod and let StatefulSet recreate.
- Run
tpi-beta-4 — migrated 2026-05-12 ~01:29 UTC
Section titled “tpi-beta-4 — migrated 2026-05-12 ~01:29 UTC”- Applied lessons from tpi-beta-3 — went directly to
kubectl drain --disable-eviction --timeout=3m. Drain 36 s (no PDB delay). 14 workload pods evicted (mostly short-lived Velero Kopia maintenance jobs). - Reboot 2 m 5 s.
- 7 Cilium-managed pods on tpi-beta-4 (pool /24
10.245.0.x— Cilium IPAM grabbed the first free slot from pool start, not sequential after b3’s10.245.7.x). 0 stragglers. - Cross-node b4 → {b1, b2, b3} v4 and v6: all OK, RTT 0.6-11 ms.
- No StatefulSet pods on tpi-beta-4 (the only non-DS storage- using pods elsewhere) → no volume-bounce drama. Clean run.
cf-ssh-bastionnot affected (it’s on tpi-alpha-4, which is next).- All-prior-nodes stability check: tpi-beta-1, tpi-beta-2,
tpi-beta-3 agent restart counts still
1each — cumulative stability through 4 migrations. - Cluster Cilium pod count: 52 → 75 / 220.
- Note for migrations after this: longhorn
instance-manager-*isn’t immediately rescheduled back to a newly-migrated node; Longhorn schedules it when needed for volume hosting.
tpi-alpha-4 — migrated 2026-05-12 ~01:43 UTC
Section titled “tpi-alpha-4 — migrated 2026-05-12 ~01:43 UTC”- This is the LAST worker before the all-workers stability gate.
Also hosts
cf-ssh-bastion(the only external SSH path into the cluster). - Drain 47 s (
--disable-evictiondirect). 14 workload pods evicted includingcf-ssh-bastion-*,argocd-server(one of 2 replicas),loki/loki-write-2,nats/nats-0,postgres/main-15, Authentik server/worker, hcp-terraform agents, karakeep, GHA runner listener. - Reboot 36 s — notably faster than other nodes’ ~2 min. May
be tpi-alpha-4 boots faster (warmer caches? different storage?
haven’t pinned down). One side effect: cilium agent restart
count = 2 (not 1 like other nodes). Root cause: faster
reboot caught the agent mid-shutdown while it was actively
importing
cf-ssh-bastionCiliumNetworkPolicies, leading to an additional SIGTERM-recovery cycle on top of the normal reboot restart. Agent is currently Running and healthy; not treated as a regression. cf-ssh-bastionrescheduled cleanly to tpi-beta-1 with new Cilium IP10.245.5.30. SSH access preserved via Cloudflare tunnel.- nats-0 saga (similar to vault-1 but worse):
- During drain, nats-0 was evicted and bounced through
tpi-alpha-4 → tpi-alpha-3 → tpi-beta-4. Its data PVC (pvc-30882cc6-...) entered a wedged state:state=detached robustness=unknown currentNodeID="" specNodeID=tpi-alpha-3. Longhorn could not attach the volume — repeatedFailedAttachVolume: rpc error: code = DeadlineExceededfor >7 min. - Resolution: delete the PVC + pod entirely. The NATS
StatefulSet recreated both with a fresh PV; nats-0 reached
Running 3/3~1 min later. JetStream replicated the stream data from nats-1/nats-2 (running peers on Calico-side nodes), making this safe. - Important pattern: when a Longhorn volume reaches
robustness=unknownAND attach errors areDeadlineExceeded, the simplekubectl delete podfix won’t work — the volume itself is wedged. For HA-clustered services (NATS JetStream, CNPG, Vault Raft cluster, etc.), deleting the PVC and letting cluster replication restore data is faster than coaxing the wedged volume back to life.
- During drain, nats-0 was evicted and bounced through
- Smoke test: pod IPs
10.245.4.39/32(v4),fd00:10:245:4::e515/128(v6). Externalping 1.1.1.1: 6-7 ms. Cross-node a4 → {b1, b2}: v4 0.9-2.1 ms, v6 0.7-1.5 ms. - Pods on node post-migration: 7 Cilium-managed (DS pods that rescheduled here automatically). 0 stragglers.
All-workers stability gate — 2026-05-12 ~01:43 UTC
Section titled “All-workers stability gate — 2026-05-12 ~01:43 UTC”- All 5 workers migrated: tpi-beta-1, tpi-beta-2, tpi-beta-3, tpi-beta-4, tpi-alpha-4.
- Cluster Cilium pod count: 94/221 (~43%). Remaining 127 pods are still on Calico CIDR (10.42.0.0/16), pinned to the 3 still-Calico control-plane nodes (tpi-alpha-1, tpi-alpha-2, tpi-alpha-3).
- Agent restart counts: 4 nodes at
1, tpi-alpha-4 at2(explained above). All agents currently Running and healthy. - Stability soak required: 24 hours minimum before
migrating any control-plane node. The CP migrations are
higher-risk than worker migrations because:
- kube-apiserver lives on CP nodes (loss of one → 2/3 etcd quorum survives, but rolling reboots must be sequential).
cilium-operatorreplicas are pinned to control-plane nodes (currently on tpi-alpha-1 and tpi-alpha-2). Migration of these two nodes requires the operator to reschedule to a peer; the third CP (tpi-alpha-3) must be migrated FIRST so it’s eligible to host an operator before tpi-alpha-1 and tpi-alpha-2 migrate.
- Notes captured during workers phase, applied to remaining
3 CP nodes:
- Run
kubectl drain --disable-eviction --timeout=3mdirectly. - Reboot via
ansible.builtin.reboot reboot_timeout=600 post_reboot_delay=30. - Watch for StatefulSet pods stuck
ContainerCreatingwithFailedAttachVolume>5 min —kubectl delete podfirst; if volume isrobustness=unknownwithDeadlineExceeded, delete the PVC and let HA service replicate data back. - Run
kubectl uncordon ${NODE}explicitly after the node is Ready post-reboot — cordons DO persist through reboot (confirmed during tpi-alpha-4 migration; Sean caught my earlier omission). DaemonSets schedule on cordoned nodes so the node will look healthy even when no workload pods can reschedule there.
- Run
- Pending soak: wait minimum 24 h before migrating
tpi-alpha-3 (next:
hl-51o.6).
Cross-CNI NetworkPolicy intervention — 2026-05-12 02:55-03:21 UTC
Section titled “Cross-CNI NetworkPolicy intervention — 2026-05-12 02:55-03:21 UTC”Between the all-workers gate and the first CP migration, a real
problem surfaced and was resolved via two hotfix PRs to main:
- Symptom: vault-1 (migrated to Cilium tpi-alpha-4) entered a
restart loop returning HTTP 474 from
/v1/sys/health. Cluster showed ~10ExternalSecret UpdateFailedevents in 10 minutes. - Root cause: the vault Helm chart’s auto-generated
NetworkPolicyhadfrom: [namespaceSelector: {}]ingress rules. Calico’s enforcer on still-Calico vault-0/vault-2 evaluated this by looking the source pod up in its WorkloadEndpoints cache; Cilium-managed pods (10.245.0.0/16) are NOT in Calico’s cache, so the rule didn’t match and Calico defaulted to deny. Cross-CNI raft heartbeats from vault-1 timed out. - Misinterpretation worth recording: HTTP 474 in Vault 1.20.4
is “standby + HA connection unhealthy” (per the full JSON body’s
ha_connection_healthy: false), NOT “removed from cluster” as earlier documentation suggested. Verify root cause by reading the full response body, not just the status code. An earlier speculative raft-state wipe + PVC delete were detours that didn’t help (and were ultimately reverted by deleting the vault-1 PVCs anyway for unrelated reasons). - Fixes (in order):
- PR #1015 (
a8818079) — disable vault chart’s NP via Helm values. ArgoCD synced; vault-1 reachedRunning 1/1 restartCount=0within 90 seconds. - Cascade mitigation (commit
63c61e7don PR3 branch) — re-point ESO ClusterSecretStore fromvault-internal(round-robin all 3 vault pods) tovault-active(leader-only). This is independent of the vault-1 fix and prevents future similar cascades. Live cluster patched directly; TF source updated for next manual apply. - PR #1016 (
fe13f7ce) — same fix for NATS NP. NATS pods were healthy at the time, but the same pattern would have broken NATS clustering during the last CP migration (tpi-alpha-1, hosts nats-2). Removed pre-emptively.
- PR #1015 (
- Systemic risk audit: other namespaces with
namespaceSelector: {}/podSelectorNPs are at risk during cross-CNI transitions. After audit: only vault + NATS were migration-critical. The 3calico-systemNPs (allow-apiserver, goldmane, whisker) and 6 vestigialcf-ssh-bastionCalico NPs don’t intersect migration traffic — they go away with Calico in PR4 / are swept in PR5.
tpi-alpha-3 — migrated 2026-05-12 12:08 UTC
Section titled “tpi-alpha-3 — migrated 2026-05-12 12:08 UTC”- First CP node migration. Audit pre-drain: 59 pods total
including 4 StatefulSets (
vault/vault-2,loki/loki-backend-0,dolt/dolt-0— singleton, no HA peer — andtailscale/ts-home-network-tz7kn-0), one of two cilium-operator replicas, and 6 Longhorn volumes attached. - Drain 45 s (
--disable-evictiondirect). 59 pods evicted, 0 stuck. Reboot 2 m 3 s. - Cilium operator handoff worked cleanly: the operator pod on
tpi-alpha-3 (
...ls5ff) rescheduled to tpi-alpha-2 (...n9jdp) within 70 s of eviction. Both operators stayed Running through the migration; thepodAntiAffinity: requiredconstraint forced the move to the only valid CP slot. - All 4 StatefulSets recovered cleanly via SS controller
reschedule — NO volume-bounce intervention needed:
vault-2→ tpi-beta-4 (Cilium IP10.245.0.60),Running 1/1in <2 min.loki-backend-0→ tpi-alpha-4,Running 1/1.dolt-0→ tpi-alpha-4,Running 1/1(singleton; brief downtime acceptable).tailscale/ts-home-network-tz7kn-0→ tpi-beta-4,Running 1/1.
- This is the validation that the NP-removal hotfixes worked.
Previously vault-1 needed PVC delete + raft wipe attempts. Now
vault-2 reattached its volume and joined raft cleanly — same
cross-CNI path, but without the Calico NP blocking the
heartbeat. Confirmed via
/v1/sys/seal-status:sealed:false, removed_from_cluster:falsefor both vault-1 and vault-2. - Vault HA preserved. All 3 pods 1/1 Running throughout: vault-0 Calico (tpi-alpha-1), vault-1 Cilium (tpi-alpha-4), vault-2 fresh Cilium (tpi-beta-4).
- Smoke test: pod IPs
10.245.3.135/32(v4) andfd00:10:245:3::7d60/128(v6). Externalping 1.1.1.1: 5-7 ms RTT. - 0 stragglers on tpi-alpha-3 post-migration.
- No ESO cascade (PR #1015 + the
vault-activerouting prevented it). - All prior Cilium agents stable through this migration: tpi-beta-1..4 restart=1 each (unchanged), tpi-alpha-4 restart=2 (unchanged from prior anomaly), tpi-alpha-3 restart=1 (single reboot-induced restart, clean).
- Cluster Cilium pod count: 94 → 146 / 221 (~66%). Biggest single-migration jump so far.
- Cilium operator placement now: tpi-alpha-1 + tpi-alpha-2. Next migration (tpi-alpha-2) will move its operator to either tpi-alpha-3 (now Cilium-eligible) or tpi-alpha-1.
tpi-alpha-2 — migrated 2026-05-12 12:58 UTC
Section titled “tpi-alpha-2 — migrated 2026-05-12 12:58 UTC”- Second CP node migration. Audit pre-drain: 33 pods total
(much lighter than alpha-3’s 59). Drain targets included one of
two cilium-operator replicas (
...n9jdp, freshly placed there during alpha-3’s migration 50 min earlier),vault/vault-agent-injector,loki/loki-read, and 3 StatefulSets (loki-chunks-cache-0,loki-results-cache-0,prometheus/alertmanager-0). Only 1 Longhorn volume attached. - Drain 39 s (
--disable-evictiondirect). Reboot 2 m 3 s. - Cilium operator danced again: the operator on tpi-alpha-2
(
...n9jdp) rescheduled to tpi-alpha-3 (...jxqjp) within 65 s of eviction. ThepodAntiAffinity: requiredconstraint forced it; tpi-alpha-3 was the only valid CP slot (alpha-1 still has the original operator). Both operators stayed Running. - All 3 StatefulSets recovered cleanly via SS controller
reschedule (no volume-bounce intervention needed):
loki-chunks-cache-0→ tpi-alpha-3, Running 1/1.loki-results-cache-0→ tpi-alpha-4, Running 1/1.prometheus/alertmanager-0→ tpi-alpha-4, Running 1/1.
- Smoke test: pod IPs
10.245.2.99/32(v4) andfd00:10:245:2::e2d6/128(v6). Externalping 1.1.1.1: 5.8-6.3 ms RTT. - 0 stragglers on tpi-alpha-2 post-migration.
- All prior Cilium agents stable through this migration: beta-1..4 = 1 each, alpha-3 = 1, alpha-4 = 2 (prior anomaly), alpha-2 = 1 (single reboot-induced restart, clean).
- Cluster Cilium pod count: 146 → 171 / 221 (~77%).
- Cilium operator placement now: tpi-alpha-1 + tpi-alpha-3. Migrating tpi-alpha-1 next will move that operator to tpi-alpha-2 (now Cilium).
tpi-alpha-1 — migrated 2026-05-12 13:10 UTC (FINAL CP)
Section titled “tpi-alpha-1 — migrated 2026-05-12 13:10 UTC (FINAL CP)”- The big one. 58 pods total including:
vault-0(active Raft leader at drain time)nats-2(last Calico NATS replica)external-secrets-operator-*(single replica)cilium-operator-7744fd4c4b-hl829(16h-old, the original-placement operator)kube-vip-tpi-alpha-1(DaemonSet — owns the cluster API VIP)- 5 StatefulSets (
vault-0,nats-2,loki-write-0,argocd/argocd-application-controller-0,tailscale ts-home-network-tz7kn-1) - 8 Longhorn volumes attached
postgres/main-15(one CNPG instance)replicator-kubernetes-replicator(cross-namespace secret replicator used for vault CA chain etc.)
- Drain 52 s (
--disable-evictiondirect). Reboot 2 m 3 s. - Vault leader election succeeded: vault-0’s eviction
triggered an election; vault-1 became the new leader within
seconds (verified via
/v1/sys/leaderon vault-2 reportingleader_cluster_address: vault-1.vault-internal:8201). vault-0 came back fresh on tpi-alpha-2 with Cilium IP10.245.2.211,restartCount=0. ESO routing viavault-activeService made the failover transparent — no cascade events, ExternalSecrets continued reconciling. - kube-vip VIP failover transparent: the API VIP
192.168.20.140was reachable throughout (verified post-reboot:ping 192.168.20.140shows 0% loss, 0.8 ms RTT). VRRP-based leader election handled the failover automatically. - Cilium operator dance (final iteration):
hl829on alpha-1 →hrzr5on tpi-alpha-2 (78 s after eviction). Both operators stayed Running. Placement post-migration: tpi-alpha-2 + tpi-alpha-3 (both Cilium). - StatefulSets that moved:
vault-0→ tpi-alpha-2 (Cilium IP10.245.2.211)argocd-application-controller-0→ tpi-alpha-2 (Cilium)loki-write-0→ tpi-alpha-2 (Cilium)tailscale ts-home-network-tz7kn-1→ tpi-alpha-3 (Cilium)nats-2→ tpi-alpha-1 (rescheduled BACK after reboot; needed onekubectl delete podto clear a transient volume-attach bounce — same proven pattern as nats-0 earlier in the migration. Recovered in 40 s after delete.)
- etcd quorum preserved through reboot:
kubectl get --raw='/readyz' → [+]etcd ok. tpi-alpha-1’s etcd member rejoined post-reboot; 2/3 quorum maintained throughout. - 0 stragglers on tpi-alpha-1 post-migration.
- No ESO cascade (the
vault-activerouting did its job). - Cluster Cilium pod count: 171 → 221 / 221 (100% Cilium-managed).
- All 8 Cilium agent restart counts: tpi-alpha-1..3 = 1 each (clean single reboot-induced restart per node), tpi-alpha-4 = 2 (prior anomaly, unchanged), tpi-beta-1..4 = 1 each. No flapping at any point during the 8-node migration.
PR3 — complete
Section titled “PR3 — complete”- All 8 nodes Cilium-managed. 221/221 cluster pods on Cilium
CIDR
10.245.0.0/16(dual-stack withfd00:10:245::/48). - Total wall-clock for PR3: ~18 hours (workers ~3 h, all- workers gate ~10 h, 3 CP nodes ~30 min total).
- Two hotfix PRs merged to
mainduring the migration:- PR #1015 (
a8818079) — disabled vault chart’s NetworkPolicy. Root cause: Calico’s enforcer couldn’t evaluatenamespaceSelector: {}against Cilium-side source pods, blocking cross-CNI vault raft heartbeats. - PR #1016 (
fe13f7ce) — disabled NATS NetworkPolicy pre-emptively (same pattern; NATS clustering would have degraded during the last CP migration). - Plus a commit on this PR3 branch (
63c61e7d) repointing ESO ClusterSecretStore fromvault-internal(round-robin) tovault-active(leader-only), preventing future cascades of the form seen with vault-1’s initial restart loop.
- PR #1015 (
- Cluster state after PR3:
- Calico runs in parallel (its DaemonSets, calico-apiserver, etc. are still up — they don’t manage CNI but consume cluster resources)
policyEnforcementMode: neveron Cilium (no CiliumNetworkPolicy enforcement yet)- Bastion CiliumNetworkPolicies are stored but not enforced
- Next: PR4 decommissions Calico (
calicoandcalico-systemnamespaces removed) and flips Cilium’spolicyEnforcementModefromnevertodefault. After PR4, the bastion’s CiliumNetworkPolicies and any other CNPs go live. PR5 sweeps the legacycalico-network-policy.yamlfile fromargocd/app-configs/cf-ssh-bastion/.
PR4 — Decommission Calico
Section titled “PR4 — Decommission Calico”- Date: 2026-05-12
- Operator: Sean
- Pre-removal Velero backup:
pre-calico-removal-20260512-092425(status: PartiallyFailed — only failure wasnats-js-nats-2PVC hitting Longhorn’ssnapshotMaxCount: 10limit after PR3 churn. Acceptable for rollback since NATS is HA-replicated. Follow-up: clean up old snapshots on that PVC.)
Phase A — Stop and remove Calico
Section titled “Phase A — Stop and remove Calico”- Scaled tigera-operator Deployment to 0 (Task 2).
- Deleted Tigera-managed CRs in dependency order: Whisker → Goldmane → APIServer → Installation. Installation CR’s finalizer required manual patch because the operator was scaled to 0 (no finalizer worker). (Task 3)
- Deleted three Calico namespaces:
calico-system,calico-apiserver,tigera-operator.calico-systemrequired manualtigera.io/cni-protectorfinalizer removal from two ServiceAccounts (calico-cni-plugin,calico-node) for the namespace to terminate. (Task 4) - Deleted all 31 Calico/Tigera CRDs cleanly. (Task 5)
- Deleted 4 ClusterRoles + 3 ClusterRoleBindings. The two CRs owned by
the Installation (
calico-cni-plugin,calico-nodeclusterroles and clusterrolebindings) carried the sametigera.io/cni-protectorfinalizer and required manual removal. (Task 6)
Phase B — Flip Cilium to production mode
Section titled “Phase B — Flip Cilium to production mode”- Updated
ansible/roles/cilium/defaults/main.yml(Task 7):cilium_cni_custom_conf: true → falsecilium_cni_uninstall: false → truecilium_policy_enforcement_mode: "never" → "default"cilium_operator_unmanaged_pod_watcher_restart: false → true
- Rendered values via
ansible localhost -m templateand ranhelm upgrade cilium ...directly (the ansible play headerhosts: tp_cluster_controlplane[0]couldn’t connect because the local SSH agent had issues; all role tasksdelegate_to: localhostso manual helm upgrade had identical effect). Revision 4 deployed. (Task 8) - Deleted
CiliumNodeConfig cilium-defaultand removed theio.cilium.migration/cilium-default=truelabel from all 8 nodes. (Task 9) - Scaled
system-upgrade-controllerback to 1 replica. (Task 10)
Verification
Section titled “Verification”cilium status --wait: OK across the board (8/8 agents, 8/8 envoy, 2/2 operators, Hubble Relay OK).kubectl -n kube-system get configmap cilium-config -o jsonpath='{.data.enable-policy}'returnsdefault.- Zero new unhealthy pods after enabling enforcement (only the 2
preexisting issues remain:
karakeep/meilisearchCrashLoop,temporal-worker-controller-managerImagePullBackOff — both predate the migration). - Bastion smoke test: kube-DNS query from bastion pod succeeded — CNP rules are enforcing as designed.
Repo cleanup
Section titled “Repo cleanup”- Deleted
ansible/roles/calico/(entire directory). - Removed Phase 5 (Calico) block from
ansible/k3s-playbook.yml; renumbered Phase 5b → Phase 5. - Updated
ansible/CLAUDE.md,argocd/CLAUDE.md,README.md,docs/index.md,docs/architecture/overview.md,docs/architecture/index.md,docs/reference/network.md,docs/reference/technologies.md,docs/reference/services.mdto reflect Cilium as the sole CNI. - Removed Calico/Tigera namespaces from
argocd/app-configs/velero/backup-schedule.yamlexcludedNamespaces. - Deleted
docs/operations/merlin.md(stale; merlin removed in PR #908). - Created
docs/operations/cilium.mdevergreen operations runbook.
Outstanding
Section titled “Outstanding”- PR5 sweeps
argocd/app-configs/cf-ssh-bastion/calico-network-policy.yaml(now a silent no-op since Calico CRDs are deleted). - Follow-up Velero hygiene:
pvc-2b4682ff-...(nats-2 data) has 10 Longhorn snapshots — at the limit. Periodically clean up old snapshots or raise thesnapshotMaxCountsetting on that volume.
Migration complete
Section titled “Migration complete”All 5 PRs of the Calico→Cilium migration project landed between
2026-05-09 and 2026-05-12. Cluster runs Cilium 1.19.3 in production
mode with policyEnforcementMode: default.