Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb27f3909a | ||
|
|
6d4c4e3a99 | ||
|
|
af7004e85c |
@@ -0,0 +1,304 @@
|
||||
# Proposal — a node roll without the ingress hiccup
|
||||
|
||||
Status: draft, grounded in one measured roll. On 2026-09-07 all three MS-01
|
||||
were drained, patched and rebooted back to back (06:52–07:08) for the SAN
|
||||
rollout. Everything below is read off that window: Traefik access logs and
|
||||
Cilium BGP logs in Loki, the drain logs, and the live cluster afterwards.
|
||||
|
||||
The short version: there are two ingresses and they failed differently.
|
||||
**The LAN ingress (Traefik behind the BGP VIP) did not hiccup** — it
|
||||
answered every request; the 5xx it returned were for backends mid-move.
|
||||
**The public edge (proxies → NetBird mesh → Traefik) went dark for 40–80 s
|
||||
on two of the three rolls, and would have for 3½ minutes on the third had
|
||||
its routes not recovered by luck** — a known client bug the repo already
|
||||
documents and works around with a watchdog, whose 600 s restart cooldown is
|
||||
what turns a 3-second blip into minutes when rolls follow each other. So
|
||||
this is two projects: make evictions cheap, and make a peer loss on the
|
||||
mesh cost seconds, deterministically.
|
||||
|
||||
## What was measured
|
||||
|
||||
Traefik, 06:48–07:12, 24 minutes, 3 391 requests, of which 5xx:
|
||||
|
||||
| router | status | count |
|
||||
|---|---|---|
|
||||
| gitea | 503 | 56 |
|
||||
| home-assistant | 503 | 36 |
|
||||
| shelly-manager | 502 | 25 |
|
||||
| immich | 503 | 19 |
|
||||
| openbao | 503 | 13 |
|
||||
| grafana | 503 | 10 |
|
||||
| music-assistant | 503 | 9 |
|
||||
| crowdsec-lapi | 503 | 7 |
|
||||
| wordpress | 503 | 6 |
|
||||
| woodpecker | 503 | 4 |
|
||||
|
||||
The quiet 24 minutes before the roll had zero 5xx. Every entry above is a
|
||||
single-replica workload (or its single database) that was evicted and had to
|
||||
come up on another node — pod reschedule, RBD volume detach and re-attach,
|
||||
process start. That is 30–90 s of 503 per app per drain.
|
||||
|
||||
Two things made it worse than one move per app:
|
||||
|
||||
- **Double moves.** The drain of k8s-1 pushed pods onto k8s-2, which was
|
||||
drained next: `ha-pg-1`, `ak-pg-1`, `im-pg-1`, `pprl-pg-2`, `tr-pg-1`,
|
||||
`wp-pg-1`, `influxdb`, `protonmail-bridge`, `prometheus`, `wordpress-mariadb`
|
||||
each appear in two drain logs. Half the downtime was paid twice.
|
||||
- **`--disable-eviction`.** Ten PodDisruptionBudgets allow zero disruptions
|
||||
(every CNPG primary, gitea, influxdb, syncthing, protonmail-bridge,
|
||||
woodpecker-server). A plain `kubectl drain` hangs on them, so the roll used
|
||||
`--disable-eviction`, which deletes pods outright. For the two-instance CNPG
|
||||
clusters (authentik, immich, paperless) that skipped the switchover CNPG
|
||||
would have done: the operator's documented behaviour is "if a node is to be
|
||||
drained and contains a cluster's primary instance, a switchover happens
|
||||
ahead of the drain" — but only when the drain goes through the eviction API.
|
||||
We turned a 2-second switchover into a 30–60 s primary restart.
|
||||
|
||||
Beside the HTTP path, one reboot side effect took a dependency down for
|
||||
half an hour: openbao-0 came back **sealed**, and because the StatefulSet is
|
||||
`OrderedReady`, openbao-1/2 were never recreated after their eviction until
|
||||
openbao-0 was unsealed by hand. ESO could not refresh a secret for 35 minutes.
|
||||
|
||||
### The public edge
|
||||
|
||||
Public traffic reaches Traefik with client address `127.0.0.1` (the rootless
|
||||
netbird-ingress peer forwards from inside its pod through socket-LB), LAN
|
||||
traffic with `10.2.x`. That splits the access log by path. At that hour the
|
||||
only public traffic was the proxies' own watchdog — two hosts probed once a
|
||||
minute from each of two proxies, a flat 4 requests/min — which makes it a
|
||||
clean synthetic probe of the whole external chain:
|
||||
|
||||
| minute (UTC) | LAN req | public req | what was happening |
|
||||
|---|---|---|---|
|
||||
| 04:55 | 128 | 4 | k8s-1 rebooting (04:55:10) |
|
||||
| 04:56 | 109 | 2 | k8s-1 back, its peer re-enrolled 04:56:24 |
|
||||
| 04:57 | 127 | **0** | — |
|
||||
| 04:58 | 126 | 4 | proxies restarted by their watchdog |
|
||||
| 05:00–05:03 | 186–233 | 4 | k8s-2 roll: **no dip** |
|
||||
| 05:06–05:07 | 97–135 | 2 | k8s-3 roll: half |
|
||||
|
||||
The external chain was down for roughly two minutes at the k8s-1 roll, one
|
||||
at k8s-3, and not at all at k8s-2. The mechanism is in
|
||||
`ansible/roles/netbird-proxy-watchdog/defaults/main.yml`: the proxy's
|
||||
embedded NetBird client pins each domain route to one routing-peer
|
||||
*identity*; the netbird-ingress DaemonSet enrols with a setup key, so every
|
||||
pod start is a **new identity** (the k8s-1 pod registered fresh at
|
||||
04:56:24); and upstream's dns-interceptor never rebinds a route whose
|
||||
owning peer is gone ("already routed by peer … HA routing disabled"). The
|
||||
proxy then serves 502 for every domain until its container restarts —
|
||||
which the watchdog does after three failed one-minute probes, or at once
|
||||
when the flood watchdog sees the wedge in the proxy log. Which roll hurts
|
||||
depends on which of the three peers the proxies happened to pin. k8s-2's
|
||||
peer was pinned by neither.
|
||||
|
||||
Note what did *not* help: the DaemonSet's preStop (`netbird down`) took the
|
||||
peer out of the mesh cleanly at 04:55:11, and the peer was back 73 s later.
|
||||
Neither matters to a client that will not rebind and will not recognise
|
||||
the returning pod as the same peer.
|
||||
|
||||
## What was not the cause
|
||||
|
||||
**BGP.** Per node the UDM sessions were down for 47–51 s (Peer Down at the
|
||||
Cilium shutdown, Peer Up ~50 s later). That gap did not carry traffic: every
|
||||
LoadBalancer here is `externalTrafficPolicy: Local`, so a VIP is only
|
||||
advertised from nodes with a local endpoint, and the drain had already
|
||||
evicted those endpoints — Cilium withdrew the routes while the session was
|
||||
still healthy (the "soft reset out" bursts during each drain). The 90 s hold
|
||||
time and 120 s graceful-restart window never came into play. Leave them.
|
||||
|
||||
**Traefik.** Two replicas on k8s-1/k8s-2, one evicted per roll, the other
|
||||
carried the VIP alone. No PDB, no spread constraint, no preStop — which held
|
||||
by luck (the two pods happened to sit on different nodes), and is fixed below
|
||||
because luck is not a design.
|
||||
|
||||
## Target state, in order of measured impact
|
||||
|
||||
### 1. Drain in a way that lets operators do their job (procedure only)
|
||||
|
||||
- **Cordon the next node before draining the current one.** Evicted pods
|
||||
then land on the node that stays up, and nothing moves twice. Order:
|
||||
cordon B → drain A → reboot A → uncordon A → cordon C → drain B → …
|
||||
- **Never `--disable-eviction`.** It exists in the roll only because ten
|
||||
PDBs say "never". Stage 2 makes those PDBs honest, after which a plain
|
||||
`kubectl drain --ignore-daemonsets --delete-emptydir-data` completes and
|
||||
CNPG switches primaries over before its replicas are evicted.
|
||||
- **After every reboot, three gates before uncordon:** `00-multus.conf` on
|
||||
the host and Multus + whereabouts Running (k8s-1's OSD came up
|
||||
Cilium-only today because it beat the Multus DaemonSet by 30 s); OpenBao
|
||||
unsealed (`bao status` on every pod that exists, then the ones the
|
||||
StatefulSet creates after it); Ceph back at baseline.
|
||||
- This is `rollout-node.sh` from today's window, minus `--disable-eviction`,
|
||||
plus the OpenBao gate, checked into `scripts/`. A reboot runbook that lives
|
||||
in a scratchpad is a runbook nobody else can run.
|
||||
|
||||
### 2. PodDisruptionBudgets that mean what they say
|
||||
|
||||
A `minAvailable: 1` PDB on a single-replica workload does not protect
|
||||
anything; it only forces the operator to bypass all PDBs. Two changes:
|
||||
|
||||
- **CNPG single-instance clusters** (grafana-db, home-assistant, trmnl,
|
||||
woodpecker): `spec.enablePDB: false`. CNPG documents exactly this — "for
|
||||
single-instance clusters the operator will prevent draining the node" and
|
||||
recommends moving to direct control of disruption budgets. The instance
|
||||
restarts on drain (it does today too, just through the back door), and the
|
||||
multi-instance clusters keep their switchover-before-eviction behaviour.
|
||||
- **gitea, influxdb, syncthing, protonmail-bridge, woodpecker-server**: drop
|
||||
the `minAvailable: 1` PDBs. They are single replicas with RWO volumes; the
|
||||
PDB cannot buy availability, only stalls.
|
||||
|
||||
### 3. The ingress itself: three replicas, one per node, drained gracefully
|
||||
|
||||
Traefik goes to `replicas: 3` with a required `podAntiAffinity` on
|
||||
hostname, `PodDisruptionBudget maxUnavailable: 1`, and a preStop that lets
|
||||
in-flight requests finish before the endpoint is withdrawn:
|
||||
|
||||
```yaml
|
||||
# chart keys as in traefik/values.yaml
|
||||
deployment:
|
||||
replicas: 3
|
||||
lifecycle:
|
||||
preStop:
|
||||
sleep:
|
||||
seconds: 5
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- topologyKey: kubernetes.io/hostname
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: traefik
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
maxUnavailable: 1
|
||||
ports:
|
||||
websecure:
|
||||
transport:
|
||||
lifeCycle:
|
||||
graceTimeOut: 15s
|
||||
```
|
||||
|
||||
With `Local` traffic policy every node then advertises the VIP; a roll takes
|
||||
one of three ECMP paths away instead of one of two, and the UDM sees the
|
||||
withdrawal before the pod stops accepting. Also gives the UDM a route from
|
||||
k8s-3, which today advertises nothing for the ingress.
|
||||
|
||||
### 4. Databases the apps actually wait on
|
||||
|
||||
The 503 table is a priority list. Each of these is a single Postgres today:
|
||||
|
||||
| app | 5xx | cluster | today |
|
||||
|---|---|---|---|
|
||||
| home-assistant | 36 | ha-pg | `instances: 1` |
|
||||
| grafana | 10 | gf-pg | `instances: 1` |
|
||||
| woodpecker | 4 | wp-pg | `instances: 1` |
|
||||
| trmnl | — | tr-pg | `instances: 1` |
|
||||
|
||||
`instances: 2` for ha-pg and gf-pg turns a drain into a CNPG switchover
|
||||
(seconds). The cost is one more Postgres pod and a second RBD volume each.
|
||||
trmnl and woodpecker can stay at one: they were barely visible.
|
||||
|
||||
gitea (56) is the largest number and has no cheap answer: one StatefulSet,
|
||||
one RWO volume, an application that does not run two instances against the
|
||||
same data directory without shared storage. It moves once per roll (with
|
||||
stage 1) and that is its floor unless its repositories move to CephFS.
|
||||
|
||||
### 5. The public edge: what the proxies' own journals say
|
||||
|
||||
This is the only *complete* outage in the window, so despite the numbering
|
||||
it ships right after stage 1. It was measured twice: from the proxies'
|
||||
watchdog journals for the morning roll, and in a controlled afternoon
|
||||
experiment (three routing-peer kills, probed from proxy-2 itself at 2 s —
|
||||
the LAN cannot probe this path, the UDM hairpin-DNATs the proxy addresses
|
||||
straight to the VIP). Proxy clocks run ~3 min behind the cluster.
|
||||
|
||||
**What a peer loss does.** When the routing peer the proxy has pinned
|
||||
disappears, the proxy's route manager wedges (`couldn't fetch peer state`
|
||||
burst) and the *whole* process stops answering — every domain, and in the
|
||||
experiment also services that never used a domain route. The flood
|
||||
watchdog restarts the container within 2 s of the burst; the probe saw
|
||||
one failed round (≈3 s) at the first two kills. The third kill fell inside
|
||||
the flood watchdog's **600 s restart cooldown**: restart suppressed, wedge
|
||||
left standing, and the URL watchdog needed its three one-minute rounds —
|
||||
**3½ minutes of 000 on every public domain**. The morning roll had the
|
||||
same shape: k8s-1 (both proxies restarted, 40–80 s), k8s-2 (wedge at
|
||||
06:58, restart suppressed on both proxies, 278 s after the previous one —
|
||||
the routes happened to recover on their own), k8s-3 (restart, ~10 s).
|
||||
Three rolls in sixteen minutes guarantee the suppressed case.
|
||||
|
||||
- **5a. Cooldown 600 s → 60 s** in
|
||||
`ansible/roles/netbird-proxy-watchdog/defaults/main.yml`. The cooldown
|
||||
exists so a genuinely broken backend cannot restart-loop the proxy; a
|
||||
minute still does that (the URL watchdog needs three minutes to fire
|
||||
anyway). With it, every peer loss — rolls back to back included — is a
|
||||
~3 s blip instead of a lottery between 3 s and 3½ min. Cheapest change
|
||||
in this document, ships first.
|
||||
- **5b. Persist the peer identity.** Replace the DaemonSet with a
|
||||
StatefulSet of three, required anti-affinity on hostname,
|
||||
`PodDisruptionBudget maxUnavailable: 1`, and a small RBD volume per
|
||||
replica holding NetBird's state directory. A drain then evicts the pod
|
||||
through the PDB (the DaemonSet pod is not evicted at all today, it dies
|
||||
with the reboot) and it returns within ~30 s **as the same peer**. Whether
|
||||
the route manager then un-wedges without a restart is the hypothesis to
|
||||
measure; the setup-key constraint (ephemeral peers reaped after >10 min
|
||||
offline, NeedsLogin liveness restart) holds unchanged.
|
||||
- **5c. The structural exit: no domain routes at all.** The subnet-target
|
||||
experiment settled what works: an HTTP-mode service with an IP target
|
||||
forwards `Host: <ip>` and Traefik answers 404 (NetBird rejects a Host
|
||||
override); an HTTP-mode service with a domain target needs mesh DNS on
|
||||
the proxy, which answers SERVFAIL there; a **TLS-passthrough service
|
||||
(`mode: tls`, `listen_port: 443`) with a subnet target works** — SNI and
|
||||
Host reach Traefik untouched, it coexists with the HTTP listener on 443,
|
||||
no `skip_tls_verify`. If every public service became such a passthrough
|
||||
over a subnet resource covering the Cilium LB pool (`10.2.99.0/24`,
|
||||
`fd99::/64`), there would be no domain resource left to wedge on, and
|
||||
peer loss would be NetBird's documented HA failover (seconds). Costs: the
|
||||
target's `host` is the VIP, so a reconciler (the external-dns-unifi
|
||||
pattern, `PUT /api/reverse-proxies/services/{id}` from the Traefik
|
||||
Service's `status.loadBalancer.ingress`) has to own it; the proxy's L7
|
||||
features (auth, path rules) are lost — none are in use; and the pure
|
||||
failover time is unmeasured, because today every kill wedged the domain
|
||||
routes first. Decide after 5a and 5b have been measured on a real roll.
|
||||
- **Track the upstream bug.** The repo's note dates the dns-interceptor
|
||||
behaviour to 2026-07; a search of NetBird's releases and issues turned up
|
||||
no fix for the "already routed by peer / HA routing disabled" path.
|
||||
Re-check on each NetBird bump.
|
||||
- **Side findings, each its own small change:** both proxy clocks are
|
||||
~3 min slow (the proxy logs SCT timestamps "in the future"; TLS
|
||||
validity windows care) — NTP on proxy-1/proxy-2; a new service's
|
||||
certificate reaches the IPv6-only member only after ~20 min of ACME
|
||||
retries (Let's Encrypt validates over IPv4 and lands on the other member,
|
||||
which has no token for it); proxy-1 and proxy-2 do not ship syslog to
|
||||
Loki (edge-1 does), so every public-edge timeline is reconstructed over
|
||||
SSH — point them at the alloy-syslog VIP.
|
||||
|
||||
## Expected result
|
||||
|
||||
| stage | removes |
|
||||
|---|---|
|
||||
| 1 | every double move (half of today's 5xx), the primary restarts, the 35-minute OpenBao/ESO outage, the Multus race |
|
||||
| 2 | the need for `--disable-eviction` at all |
|
||||
| 3 | the single-node ingress exposure; ECMP loses 1/3 instead of 1/2 |
|
||||
| 4 | home-assistant and grafana 503s entirely |
|
||||
| 5a | the 3½-minute public-edge blackout of a second peer loss within 10 min; every peer loss becomes a ~3 s restart |
|
||||
| 5b/5c | the restart itself, if the returning identity (5b) or the absence of domain routes (5c) proves to un-wedge — to be measured |
|
||||
|
||||
What remains after all of it: one move per single-replica app per roll
|
||||
(gitea, immich, shelly-manager, music-assistant, wordpress), 30–60 s each,
|
||||
once, and a few seconds of public edge per roll. That is the honest floor
|
||||
for single-replica services on RWO storage and a mesh client that pins.
|
||||
|
||||
## Transition
|
||||
|
||||
1. Stage 5a is one Ansible default and a proxy deploy — ships first.
|
||||
2. Stage 1 is a script and a runbook change — no cluster change.
|
||||
3. Stage 5b's StatefulSet changes how the public edge enrols, so it gets
|
||||
its own roll to measure, probed from a proxy.
|
||||
4. Stage 2 is one field on four CNPG clusters and five deleted PDBs; each
|
||||
Kustomization applies without disruption.
|
||||
5. Stage 3 is a Traefik HelmRelease change; the rollout itself is a rolling
|
||||
update under the new PDB.
|
||||
6. Stage 4 is `instances: 2` on two clusters; CNPG adds the replica online.
|
||||
7. Stage 5c only if 5a+5b leave a measurable public gap.
|
||||
|
||||
Measure the next roll the same way: 5xx by router, the proxies' watchdog
|
||||
journals (or their syslog in Loki), BGP Down→Up gaps, pods per drain log.
|
||||
Reference in New Issue
Block a user