Overview
Once a Kubernetes cluster is running, the next layer of decisions defines how it actually behaves in production: how traffic flows, how workloads get deployed, and where persistent data lives. This post covers three of those decisions on a 5-node bare metal cluster:
- Cilium: eBPF-based CNI replacing Flannel, with L2 LoadBalancer replacing MetalLB
- Flux CD v2: GitOps from a GitHub repo, with SOPS+age encrypted secrets
- Democratic CSI: dynamic iSCSI persistent volume provisioning against TrueNAS
The cluster itself (kubeadm bootstrap, HA control plane, physical worker nodes) is covered in the previous post. This picks up from a running cluster.
Cilium: eBPF Networking + L2 LoadBalancer
The cluster started on Flannel: straightforward to get running, but limited. Cilium brought eBPF-based networking, native network policy enforcement, and built-in L2 LoadBalancer support that replaced MetalLB entirely.
Migration from Flannel
The migration runs node-by-node. Taking multiple nodes simultaneously risks a split-brain scenario: do it serially.
| |
For each node the playbook:
- Drains the node
- Removes Flannel CNI config from
/etc/cni/net.d/ - Deletes the
flannel.1VXLAN interface andcni0bridge - Uncordons: Cilium picks up the node
Flannel and Cilium cannot coexist. The bridge and VXLAN interface must be gone before Cilium takes over or you get routing loops.
Cilium HelmRelease (via Flux)
| |
kubeProxyReplacement: false keeps kube-proxy running: a safe migration path. Full kube-proxy replacement is possible but unnecessary for this setup.
L2 LoadBalancer Pool (Replacing MetalLB)
| |
Cilium announces LoadBalancer IPs via ARP on all enp* interfaces. Services get a stable LAN-routable IP without a dedicated load balancer appliance. Current assignments:
| Service | IP |
|---|---|
| Technitium DNS | 192.168.1.200 |
| nginx-ingress | 192.168.1.201 |
| Grafana (direct) | 192.168.1.202 |
| Pi-hole | 192.168.1.203 |
Post-Migration: Restart RWO-Backed Deployments
After a CNI migration, pods don’t automatically get new network interfaces assigned by Cilium. Anything backed by a ReadWriteOnce PVC needs a careful restart: the old pod must fully terminate before the new one starts, or the volume attach hangs:
| |
For deployments with RWO PVCs, also ensure strategy: Recreate is set. Rolling updates deadlock: the new pod can’t attach the volume while the old one holds it.
Flux CD: GitOps from GitHub
All cluster state lives in a GitHub repo (homelab-gitops). Flux watches the repo and reconciles the cluster to match. Nothing is applied with kubectl apply by hand.
Repository Layout
homelab-gitops/
├── clusters/prod/flux-system/ # Flux bootstrap manifests
│ ├── gotk-components.yaml
│ ├── gotk-sync.yaml
│ ├── infrastructure.yaml # Kustomization ref
│ ├── monitoring.yaml # Kustomization ref
│ └── apps.yaml # Kustomization ref
├── infrastructure/prod/ # CNI, ingress, DNS, storage, security
│ ├── cilium/
│ ├── cert-manager/
│ ├── nginx-ingress/
│ ├── technitium/
│ ├── pihole/
│ ├── velero/
│ └── falco/
├── monitoring/prod/ # Prometheus stack, Loki, ntfy
└── apps/prod/ # AI platform, applications
├── ai-platform/ # Ollama, LiteLLM, Open-WebUI, Qdrant
├── nextcloud/ # Nextcloud + PostgreSQL + Redis
├── n8n/ # n8n workflow automation
├── vault-ingest/ # RAG ingestion pipeline
└── vaultwarden/
Three top-level Flux Kustomizations (infrastructure, monitoring, apps) with explicit dependency ordering: apps waits on infrastructure, monitoring waits on infrastructure. This prevents race conditions on first bootstrap.
SOPS + age Secret Encryption
Secrets are encrypted in git with SOPS using an age key. Flux decrypts at reconcile time using an age private key stored in a cluster secret.
| |
.sops.yaml: only encrypt data/stringData:
| |
Targeting only data/stringData fields is important. Encrypting the whole file makes git diffs unreadable and breaks Flux reconciliation in subtle ways: the rest of the manifest (metadata, kind, namespace) stays in plaintext and is diffable.
Flux Kustomization decryption config:
| |
Forcing a Reconcile
Flux polls the GitHub repo on its configured interval. To trigger an immediate reconcile without waiting:
| |
Useful after pushing a change that needs to apply immediately.
Democratic CSI: TrueNAS iSCSI Storage
Persistent storage comes from a TrueNAS server at 192.168.1.108 via iSCSI, provisioned dynamically by Democratic CSI.
How It Works
Democratic CSI runs as a DaemonSet on all nodes plus a controller pod. When a PVC is created:
- The controller calls the TrueNAS API to create a new ZFS dataset + iSCSI extent
- The node daemon connects the iSCSI target and formats the volume
- The PVC binds and the pod starts
No manual TrueNAS config per volume: it’s fully automated.
StorageClass
| |
reclaimPolicy: Retain means data survives PVC deletion: requires manual cleanup on TrueNAS but prevents accidental data loss. allowVolumeExpansion: true lets you resize PVCs without pod restarts.
RWO Constraint
iSCSI volumes are ReadWriteOnce: one node at a time. This has two practical implications:
Deployments must use strategy: Recreate:
| |
Rolling updates deadlock: the new pod can’t attach the volume while the old one holds the iSCSI session. Recreate terminates the old pod first.
During CNI migrations or node drains, pods with iSCSI PVCs may take longer to reschedule. The iSCSI session must fully close before another node can attach. If a pod gets stuck terminating, the volume appears attached even after the pod is gone:
| |
Talos-Specific Note
If you’re running Talos Linux rather than Ubuntu, Democratic CSI requires the iscsi-tools system extension and a specific machine config patch to enable the iSCSI kernel modules. On Ubuntu with kubeadm, the open-iscsi package handles this:
| |
Lessons Learned
Don’t migrate CNI on multiple nodes simultaneously. Serial node-by-node migration with drain/uncordon is slow but safe. Parallel migration caused routing issues between nodes mid-migration on the first attempt.
encrypted_regex in SOPS saves headaches. Encrypting only the secret values (not the whole file) makes diffs reviewable, keeps namespace/metadata visible, and avoids Flux failing to parse the resource kind.
reclaimPolicy: Retain by default for stateful data. The extra cleanup step on TrueNAS is worth it. Delete removes the ZFS dataset immediately when the PVC goes away: there’s no recovery.
Flux’s GitLab PAT is a silent failure. When it expires, reconciliation just stops. No alert fires by default. Add a Prometheus alert on got_reconcile_error or just set a calendar reminder.
L2 announcements need a stable interface pattern. The ^enp.* regex covers all the physical interfaces. If a node has a different NIC naming scheme, it won’t announce on that interface: check with cilium l2announce list after any node changes.
References
Running a similar stack? We take on short-term infrastructure engagements and platform work.