Files
home-network-k3s-impl/runbooks/vpn-egress-stale.md
T
jakman85andClaude Opus 5 a3f4ddb971 merge: feat/egress-gateway-staleness-alert — finished so it can actually pass the gate
Brings the two alerts for the silent egress-gateway failure: VPNEgressSteeringStale
(critical) and VPNEgressUplinkLabelMissing (warning), plus the kube-state-metrics
allowlist entry that makes them non-inert.

WHY THE BRANCH SAT IN THE BACKLOG: it could not pass the merge gate. Three
whole-repo checks failed on it, and each one was a real gap rather than a nuisance:

  * alert-runbooks-resolvable — both alerts link to /runbooks/vpn-egress-stale,
    which did not exist. An alert firing now would hand the operator a 404.
  * alert-rules-unit-tested — alerts.yaml is already a covered file, so new rules
    in it must be tested or a "tested" file quietly grows untested rules.
  * alert-metrics-have-scrape-targets — see the guard bug below.

WHAT THE ALERTS CATCH. egress-class=vpn pods leave through one gateway node's tun0;
Cilium resolves it to an ifindex and caches it. An OpenVPN reconnect destroys and
re-creates tun0 with a NEW ifindex, and the node label
homelab.keithjackson.dev/egress-uplink=<addr>-<ifindex> is what forces Cilium to
re-resolve. If the label does not follow the tunnel, the cache points at a destroyed
interface and every vpn-class pod is silently dropped — while tun0 stays up and
VPNTunnelDown says nothing. That ran ~80 minutes undetected on the dev gateway.

Verified this is live on prod, not inert: k3s-prod-3 carries egress-gateway=true and
egress-uplink=10.150.192.86-139, and vpn-egress-steering has been in place 14 days.
The label was exported on 0 series before this change, so the allowlist entry is
what switches the alerts on.

runbooks/vpn-egress-stale.md — written and wired into the configMapGenerator, with
the four required operator sections. It leads with proving egress from a pod the
policy selects, because the symptom is "outbound traffic silently disappears", and
it says plainly not to "fix" this by deleting the policy: that does not restore
egress, it sends vpn-class traffic out of the real ISP connection, which is the leak
the class exists to prevent.

platform/monitoring/tests/vpn-egress.test.yaml — four promtool fixtures. 1 and 2 are
the SAME tunnel reconnect with and without the label following it, which isolates the
mechanism the rule actually keys on: a label VALUE change is not a changing sample,
it retires one series and starts another. 3 is the inert case (gateway node, no uplink
label) where the critical rule CANNOT fire, which is why the warning exists.

Two fixes the branch needed that were defects in the checks themselves:

1. alert-metrics-have-scrape-targets asserted the KSM allowlist matched
   `nodes=[...egress-gateway]` with a literal closing bracket — i.e. that the list
   contained EXACTLY that one label. Adding a second label correctly (egress-uplink,
   which these alerts need) therefore FAILED the check while the property it guards
   was perfectly satisfied. Loosened to `[,\]]`: in the list, not the whole list. A
   guard that breaks when you correctly extend the thing it guards teaches people to
   edit the guard, which is how it stops guarding.

2. UNTESTED_ALERTS listed VPNTunnelDown, and the check flagged it as stale because
   "asserted on" is a bare word match over the test file — my header COMMENT
   mentioning VPNTunnelDown was enough to count as coverage. Rather than delete the
   register entry and claim coverage that did not exist, the fixtures now assert
   VPNTunnelDown QUIET in all four cases. That required making them faithful: they
   had no node_network_receive_bytes_total{device="tun0"} at all, so VPNTunnelDown
   would have been firing throughout unnoticed, because promtool only checks the
   alertnames you name. A healthy tun0 passes traffic, and that silence IS the claim
   these rules exist for.

NOTE FOR LATER, not fixed here: that word-match means any alert named in prose counts
as tested. Tightening it to `alertname:` positions could reveal other prose-only
"coverage", and the register is shrink-only, so the cascade wants doing deliberately
rather than at 01:00 unattended.

Conflicts resolved: kept HEAD's Velero custom-resource-state block and took only the
branch's one-line allowlist change (14 lines down to 1); inserted the branch's
2026-09-12 DECISIONS-LOG entry in date order rather than appending it out of sequence.

Gated: offline l0,l1 --include-scope whole-repo — 24 cases, 0 failures, 0 skips,
0 unwired. The new unit tests pass under the pinned promtool 3.14.0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 00:52:06 -05:00

124 lines
7.0 KiB
Markdown

# vpn-egress-stale — VPN egress steering did not follow the tunnel
**Alerts:** `VPNEgressSteeringStale` (critical, 15m) and `VPNEgressUplinkLabelMissing` (warning, 30m)
**File:** `platform/monitoring/alerts.yaml`
**Related:** `/runbooks/vpn-down` is for the tunnel being DOWN. This page is for the tunnel being **up**
while the pods that must use it have no egress at all.
## What this means here
`egress-class=vpn` pods leave the cluster through one gateway node's `tun0`, selected by the
`vpn-egress-steering` CiliumEgressGatewayPolicy. Cilium resolves that interface to an **ifindex** and
caches it. When OpenVPN reconnects it destroys and re-creates `tun0`, which gets a **new ifindex** — and
Cilium does not re-resolve on its own. The gateway node publishes
`homelab.keithjackson.dev/egress-uplink=<tun0 address>-<ifindex>` from the OpenVPN up-script on every
connect, and that node write is what makes Cilium re-resolve.
If the label write does not happen, the cache keeps pointing at a destroyed interface. **`tun0` looks
perfectly healthy the whole time** — it is up, it has an address, `VPNTunnelDown` stays quiet — while
every vpn-class pod is silently dropped. That exact shape ran for roughly **80 minutes** undetected on
the dev gateway, which is why these two alerts exist.
`VPNEgressSteeringStale` compares how many times `tun0`'s ifindex changed in the last hour against how
many distinct `egress-uplink` label values appeared in the same hour. More tunnel changes than label
changes means a reconnect happened that the label did not follow.
**`VPNEgressUplinkLabelMissing` is the watchdog for that comparison.** If the label is absent entirely,
the staleness rule has nothing to compare against and is **inert** — present, evaluating, and incapable
of firing. So the warning matters more than its severity suggests: it means your critical alert is
switched off.
Today's gateway is **`k3s-prod-3`** (`homelab.keithjackson.dev/egress-gateway=true`), and the policy has
been in place since early September. It is a **singleton** — there is no second gateway to fail over to.
## First commands
Run cluster commands through the pinned toolbox, which supplies the kube context:
```
./infra/devcontainer/dev.sh run kubectl --context k3s-prod get nodes \
-L homelab.keithjackson.dev/egress-gateway -L homelab.keithjackson.dev/egress-uplink
```
You want exactly one node carrying `egress-gateway=true`, and that same node carrying a non-empty
`egress-uplink` of the form `<address>-<ifindex>`.
```
# 2. what Cilium currently believes, on the gateway node itself
ssh k3s-prod-3 'sudo cilium-dbg bpf egress list'
ssh k3s-prod-3 'ip -o link show tun0' # the REAL ifindex, first field
```
Compare the ifindex in the label and in `bpf egress list` against `ip link`. **A mismatch is the whole
bug.**
```
# 3. is the re-steer mechanism itself alive?
ssh k3s-prod-3 'systemctl status vpn-egress-resteer.service vpn-egress-resteer.timer --no-pager'
ssh k3s-prod-3 'journalctl -u vpn-egress-resteer --since -2h --no-pager | tail -30'
```
```
# 4. are vpn-class pods actually broken, or only the metric?
./infra/devcontainer/dev.sh run kubectl --context k3s-prod get ciliumegressgatewaypolicies.cilium.io \
vpn-egress-steering -o yaml | head -40
```
Prove egress from a pod the policy selects rather than inferring it — the observable symptom is
"outbound traffic silently disappears", so test outbound traffic:
```
./infra/devcontainer/dev.sh run kubectl --context k3s-prod -n qbittorrent exec deploy/qbittorrent -- \
sh -c 'curl -s --max-time 10 https://ifconfig.me || echo EGRESS-FAILED'
```
That should return the **VPN provider's** address. The gateway VIP or a timeout both mean egress is
broken; your own ISP address means the kill-switch is not holding and traffic is leaking, which is worse
than a drop — see `/runbooks/vpn-down`.
## Likely causes, most likely first
1. **A tunnel reconnect that the label did not follow.** The common case, and what the alert is built to
catch. The OpenVPN up-script either did not run or could not write the node label (RBAC, expired
kubeconfig on the node, API unreachable at that moment). Check `journalctl -u vpn-egress-resteer` and
the up-script's own log around the reconnect time.
2. **Cilium cached a dead ifindex despite the label changing.** The label write happened but the
re-resolution did not take. Forcing any node-label write makes Cilium re-resolve — that is the same
lever the up-script pulls, so it is a safe nudge, not a workaround:
./infra/devcontainer/dev.sh run kubectl --context k3s-prod annotate node k3s-prod-3 \
egress-resteer-nudge="$(date -u +%s)" --overwrite
Re-applying the CiliumEgressGatewayPolicy has the same effect. Re-check `bpf egress list` after.
3. **`metricLabelsAllowlist` lost the label** (this fires `VPNEgressUplinkLabelMissing`, not the stale
alert). kube-state-metrics exposes **no** custom node labels by default; the allowlist entry in
`platform/monitoring/kube-prometheus-stack.yaml` is what publishes them. If that entry is edited or
dropped, `kube_node_labels` carries no `label_homelab_keithjackson_dev_egress_uplink` and the critical
alert goes quiet without anything looking broken. Confirm what is actually exported:
./infra/devcontainer/dev.sh run kubectl --context k3s-prod -n monitoring exec \
prometheus-kube-prometheus-stack-prometheus-0 -c prometheus -- \
/bin/promtool query instant http://localhost:9090 'kube_node_labels'
Note that KSM does **not** restart on a ConfigMap-only change; the deployment carries no checksum
annotation over it, so a `rollout restart -n monitoring deploy/kube-prometheus-stack-kube-state-metrics`
is required after editing the allowlist.
4. **The gateway node was replaced or relabelled.** If `egress-gateway=true` moved, the up-script must be
installed on the new node (`infra/ansible/roles/vpn_egress_gateway`). A gateway label with no
up-script behind it produces exactly this alert on the next reconnect.
## When to escalate or stop
- **Stop and do not "fix" it by deleting the policy.** Removing `vpn-egress-steering` does not restore
egress; it sends vpn-class traffic out of your **own** ISP connection, which is the leak the class
exists to prevent. A broken tunnel that drops traffic is the intended failure mode.
- **Escalate to a maintenance window** if the ifindex mismatch returns after every reconnect. That is the
up-script or its RBAC, not a transient, and chasing it live means repeatedly breaking egress for the
vpn-class workloads.
- **This is a singleton gateway.** There is no failover. If `k3s-prod-3` needs to be drained while this is
unresolved, accept that vpn-class egress is down for the duration rather than improvising a second
gateway under time pressure.
- If `VPNEgressUplinkLabelMissing` is firing and you cannot restore the label quickly, say so explicitly
in the incident notes: for as long as it is missing, **`VPNEgressSteeringStale` is not watching
anything**, so the absence of that critical alert proves nothing.