|
| 1 | +# How I Blew Up My K3s Cluster and Didn't Panic (Thanks to GitOps) |
| 2 | + |
| 3 | +**Date**: January 24, 2026 |
| 4 | +**Time to full recovery**: ~2 hours |
| 5 | +**Panic level**: Surprisingly low |
| 6 | + |
| 7 | +## The Disaster |
| 8 | + |
| 9 | +It started with a simple disk failure on `still-fawn`, one of my Proxmox nodes. The 512GB SSD that held the ZFS root pool decided it had enough. The node wouldn't boot - just dropped into initramfs with "cannot import rpool". |
| 10 | + |
| 11 | +No big deal, I thought. I have PBS backups. I'll just restore VM 108 (my K3s control plane node on still-fawn). |
| 12 | + |
| 13 | +**Plot twist**: The PBS restore was painfully slow (2 MB/s due to chunk-based deduplication + ZFS random I/O). After 23 minutes at 5% progress, I made a decision: create a fresh VM instead. |
| 14 | + |
| 15 | +That's when things got interesting. |
| 16 | + |
| 17 | +## The Cascade |
| 18 | + |
| 19 | +My K3s cluster was a 2-node HA setup with embedded etcd: |
| 20 | +- `pumped-piglet` (VM 105) - 32GB RAM, RTX 3070 |
| 21 | +- `still-fawn` (VM 108) - 32GB RAM, AMD GPU, Coral TPU |
| 22 | + |
| 23 | +With still-fawn down, etcd lost quorum. The cluster was effectively dead. |
| 24 | + |
| 25 | +**What I tried (and failed at):** |
| 26 | + |
| 27 | +1. Reset pumped-piglet to single-node with `k3s server --cluster-reset` ✓ |
| 28 | +2. Create fresh VM 108 via Crossplane ✓ |
| 29 | +3. Join still-fawn to the cluster... ✗ |
| 30 | + |
| 31 | +The join kept failing with TLS certificate errors: |
| 32 | +``` |
| 33 | +"rejected connection on peer endpoint","error":"remote error: tls: bad certificate" |
| 34 | +``` |
| 35 | + |
| 36 | +Every attempt would: |
| 37 | +1. Add a learner member to pumped-piglet's etcd |
| 38 | +2. Fail TLS handshake |
| 39 | +3. Leave stale learner in etcd |
| 40 | +4. Crash pumped-piglet's etcd (stuck trying to reach unreachable member) |
| 41 | + |
| 42 | +I was going in circles. Reset pumped-piglet, try to join, fail, reset again. Classic insanity. |
| 43 | + |
| 44 | +## The Root Cause |
| 45 | + |
| 46 | +After much frustration (and some colorful language), I finally diagnosed it: |
| 47 | + |
| 48 | +**Mixed CA certificates**. still-fawn had OLD CA files from previous failed attempts mixed with NEW client certs: |
| 49 | + |
| 50 | +``` |
| 51 | +/var/lib/rancher/k3s/server/tls/etcd/peer-ca.crt 06:30 (OLD!) |
| 52 | +/var/lib/rancher/k3s/server/tls/etcd/client.crt 06:59 (new) |
| 53 | +``` |
| 54 | + |
| 55 | +K3s downloads CA certs from the existing cluster during join. But if OLD CA files exist on disk, they're used instead, causing TLS mismatch. |
| 56 | + |
| 57 | +**Why did the old files persist?** Because I was using `rm -rf /var/lib/rancher/k3s` instead of the proper uninstall script. The systemd service would restart and recreate partial state before the join completed. |
| 58 | + |
| 59 | +## The Fix |
| 60 | + |
| 61 | +One command: `/usr/local/bin/k3s-uninstall.sh` |
| 62 | + |
| 63 | +This script removes EVERYTHING: |
| 64 | +- All `/var/lib/rancher/k3s/*` |
| 65 | +- All `/etc/rancher/k3s/*` |
| 66 | +- systemd service files |
| 67 | +- symlinks (`kubectl`, `crictl`, `ctr`) |
| 68 | +- CNI state |
| 69 | +- iptables rules |
| 70 | + |
| 71 | +After a proper uninstall and fresh join: |
| 72 | + |
| 73 | +``` |
| 74 | +$ kubectl get nodes |
| 75 | +NAME STATUS ROLES AGE |
| 76 | +k3s-vm-pumped-piglet-gpu Ready control-plane,etcd,master 40m |
| 77 | +k3s-vm-still-fawn Ready control-plane,etcd,master 55s |
| 78 | +``` |
| 79 | + |
| 80 | +## Why I Didn't Panic: GitOps |
| 81 | + |
| 82 | +Here's the thing - my entire cluster state is in Git. When the K3s cluster came back online, I just needed to: |
| 83 | + |
| 84 | +1. Install Flux |
| 85 | +2. Create two secrets (SOPS age key + GitHub deploy key) |
| 86 | +3. Apply the GitOps sync config |
| 87 | + |
| 88 | +```bash |
| 89 | +# Install Flux |
| 90 | +flux install |
| 91 | + |
| 92 | +# Create SOPS decryption secret |
| 93 | +kubectl create secret generic sops-age \ |
| 94 | + --namespace=flux-system \ |
| 95 | + --from-file=age.agekey=~/.config/sops/age/keys.txt |
| 96 | + |
| 97 | +# Create GitHub deploy key secret |
| 98 | +kubectl create secret generic flux-system \ |
| 99 | + --namespace=flux-system \ |
| 100 | + --from-file=identity=~/.ssh/flux-homeiac-home \ |
| 101 | + --from-file=identity.pub=~/.ssh/flux-homeiac-home.pub \ |
| 102 | + --from-literal=known_hosts="$(ssh-keyscan github.com 2>/dev/null)" |
| 103 | + |
| 104 | +# Apply sync config |
| 105 | +kubectl apply -f gitops/clusters/homelab/flux-system/gotk-sync.yaml |
| 106 | +``` |
| 107 | + |
| 108 | +Within minutes, Flux was reconciling: |
| 109 | + |
| 110 | +``` |
| 111 | +$ flux get kustomizations |
| 112 | +NAME READY MESSAGE |
| 113 | +cert-manager-config True Applied revision: master@sha1:98aa8fff |
| 114 | +flux-system True Applied revision: master@sha1:98aa8fff |
| 115 | +metallb-config True Applied revision: master@sha1:98aa8fff |
| 116 | +traefik-config True Applied revision: master@sha1:98aa8fff |
| 117 | +``` |
| 118 | + |
| 119 | +HelmReleases started deploying automatically: |
| 120 | +- cert-manager ✓ |
| 121 | +- MetalLB ✓ |
| 122 | +- kube-prometheus-stack ✓ |
| 123 | +- Crossplane ✓ |
| 124 | +- external-dns ✓ |
| 125 | +- Traefik ✓ |
| 126 | + |
| 127 | +All my apps, all my configs, all my secrets (SOPS-encrypted in Git) - everything came back. |
| 128 | + |
| 129 | +## What GitOps Saved Me From |
| 130 | + |
| 131 | +Without GitOps, I would have needed to: |
| 132 | + |
| 133 | +1. Remember which Helm charts were installed and their versions |
| 134 | +2. Recreate all the Helm values files |
| 135 | +3. Remember the order of installation (CRDs before resources) |
| 136 | +4. Manually create all secrets |
| 137 | +5. Reconfigure all ingresses |
| 138 | +6. Hope I didn't forget anything |
| 139 | + |
| 140 | +Instead, I just pointed Flux at my Git repo and went to make coffee. |
| 141 | + |
| 142 | +## Lessons Learned |
| 143 | + |
| 144 | +### 1. Use the Uninstall Script |
| 145 | +Never `rm -rf /var/lib/rancher/k3s`. Always use `/usr/local/bin/k3s-uninstall.sh`. It exists for a reason. |
| 146 | + |
| 147 | +### 2. Dependency Ordering Matters |
| 148 | +Flux's server-side dry-run will fail if CRDs don't exist yet. Solution: separate Flux Kustomizations with `dependsOn`: |
| 149 | + |
| 150 | +```yaml |
| 151 | +apiVersion: kustomize.toolkit.fluxcd.io/v1 |
| 152 | +kind: Kustomization |
| 153 | +metadata: |
| 154 | + name: metallb-config |
| 155 | +spec: |
| 156 | + dependsOn: |
| 157 | + - name: flux-system # Wait for MetalLB HelmRelease |
| 158 | + path: ./infrastructure-config/metallb-config/resources |
| 159 | +``` |
| 160 | +
|
| 161 | +### 3. Keep Your Age Key Safe |
| 162 | +The SOPS age private key (`~/.config/sops/age/keys.txt`) is the master key to all your encrypted secrets. Back it up. Seriously. |
| 163 | + |
| 164 | +### 4. Document the Recovery Process |
| 165 | +I now have a complete runbook at `docs/runbooks/still-fawn-recovery-2026-01.md` with every command needed to rebuild from scratch. |
| 166 | + |
| 167 | +### 5. GitOps is Not Just Hype |
| 168 | +When your cluster dies at 2 AM, you don't want to be piecing together configs from memory. Git is your source of truth. Flux makes it real. |
| 169 | + |
| 170 | +## The Numbers |
| 171 | + |
| 172 | +| Metric | Value | |
| 173 | +|--------|-------| |
| 174 | +| Time from disk failure to cluster rebuild | ~90 minutes | |
| 175 | +| Time from Flux install to apps running | ~5 minutes | |
| 176 | +| Manual kubectl commands after Flux | 0 | |
| 177 | +| Secrets recreated manually | 2 (age key + deploy key) | |
| 178 | +| Configs recreated manually | 0 | |
| 179 | +| Sleep lost | Some, but not as much as expected | |
| 180 | + |
| 181 | +## Final Thoughts |
| 182 | + |
| 183 | +Disasters happen. Disks fail. Clusters die. The question isn't *if* but *when*. |
| 184 | + |
| 185 | +GitOps doesn't prevent disasters - it makes recovery boring. And boring is exactly what you want at 2 AM. |
| 186 | + |
| 187 | +My cluster is now running again with all services restored. The only evidence of the disaster is this blog post and a slightly updated runbook. |
| 188 | + |
| 189 | +If you're still doing `kubectl apply -f` by hand, consider this your sign to embrace GitOps. Future-you will thank present-you. |
| 190 | + |
| 191 | +--- |
| 192 | + |
| 193 | +**Stack**: K3s, Flux, SOPS, Proxmox, PBS |
| 194 | +**Repo**: [homeiac/home](https://github.com/homeiac/home) |
| 195 | + |
| 196 | +**Tags**: k3s, kubernetes, gitops, flux, disaster-recovery, etcd, homelab, infrastructure-as-code, sops |
0 commit comments