From 16c62346964385fadda0d6c9b837928b735d6464 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Sat, 12 Sep 2026 20:46:56 -0500 Subject: [PATCH] feat(monitoring): alert when egress steering goes stale, not just when tun0 dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VPNTunnelDown` goes green the instant a tun0 exists, which is not the same as vpn-class pods having egress: Cilium caches (egress IP, egress ifindex) at first resolution, so after a reconnect the BPF egress map points at a destroyed interface and the packets are dropped while tun0 looks perfectly healthy. Dev sat in exactly that state for ~80 minutes with nothing alerting. Two rules close it: * VPNEgressSteeringStale (critical, for: 15m) — fires when the live tun0 was re-created more times in the last hour than the gateway node's `egress-uplink` label changed, i.e. Cilium was never told about a reconnect. * VPNEgressUplinkLabelMissing (warning, for: 30m) — the watchdog for the watchdog: a gateway node whose uplink label never reaches kube-state-metrics is a cluster where the rule above is inert. Both gated on a gateway node existing, reusing `VPNTunnelDown`'s `kube_node_labels` guard — a cluster with no gateway cannot be made to fire them. The direct label-vs-map comparison is NOT available and nothing was invented to pretend otherwise: no `cilium_*`/`hubble_*` series exist on either cluster (the cilium-agent exposes no metrics port, there is no ServiceMonitor), and Cilium OSS publishes no series carrying egress-map contents at all — the map is readable only via `cilium-dbg bpf egress list`. PromQL also cannot compare a metric value (the live ifindex) against a number inside a label value, so the level-triggered equality test is not expressible; the same invariant is counted over a window instead. Both limits, and the textfile-collector/black-box-probe routes that would lift them, are written down in the rule comments and the DECISIONS-LOG. `egress-uplink` added to kube-state-metrics.metricLabelsAllowlist in the same kustomization, because without it the first rule has nothing to compare. Verified read-only against LIVE prod (port-forward + /api/v1/query), rendered expr extracted from `kubectl kustomize platform-config`: empty when healthy; empty when evaluated at a timestamp before prod had a gateway node; and FIRING when pointed at a label that genuinely failed to follow prod's real 128 -> 129 tun0 flap earlier today. Nothing on prod was disturbed to produce that. Co-Authored-By: Claude Opus 5 --- DECISIONS-LOG.md | 142 ++++++++++++++++++ platform/monitoring/alerts.yaml | 126 ++++++++++++++++ .../monitoring/kube-prometheus-stack.yaml | 14 +- platform/vpn-egress/README.md | 25 +++ 4 files changed, 306 insertions(+), 1 deletion(-) diff --git a/DECISIONS-LOG.md b/DECISIONS-LOG.md index b20b43f..684a55b 100644 --- a/DECISIONS-LOG.md +++ b/DECISIONS-LOG.md @@ -2331,3 +2331,145 @@ vpn-class egress dead until someone noticed. > gateway whose tunnel interface is dynamic requires an explicit re-resolution trigger on every > interface change. This is a standing operational requirement of the ADR-0015 design, not an > implementation detail.* + +## 2026-09-12 — The silent-gateway outage now has an alert, and the direct signal turned out not to exist (branch `feat/egress-gateway-staleness-alert`) + +Hand-off item 4 from the section above: *"A silent gateway outage still has no alert… nothing +watches the invariant `egress-uplink` label == `cilium-dbg bpf egress list`'s Egress Ifindex."* +Closed, as far as Prometheus can close it — and the part that cannot be closed is recorded here +rather than papered over with a plausible-looking metric name. + +### The direct signal is not available, verified rather than assumed + +The invariant wants the BPF egress map's contents. They are not in Prometheus, for two independent +reasons, both checked read-only against live prod: + +| check | result | +|---|---| +| `{__name__=~"cilium.*egress.*"}`, `cilium_drop.*`, `hubble_drop.*` | **empty vector** — no `cilium_*`/`hubble_*` series exist at all | +| container ports on the cilium DaemonSet | `health=9879`, `peer-service=4244` — **no metrics port**; only the operator has `operator-prometheus-serve-addr :9963` | +| `kubectl get servicemonitors -A \| grep -iE 'cilium\|hubble'` | none — Hubble/Cilium metrics export is still the open TODO in `platform/cilium/helmrelease.yaml` | + +So `HubbleDeniedFlowSpike` cannot fire today either; that is a separate, pre-existing gap, noted +and left alone. And even with agent metrics turned on, **Cilium OSS publishes no series carrying +egress-map contents** — the map is readable only through `cilium-dbg bpf egress list`, a CLI over +the BPF map. Nothing was invented to fill the hole. + +What node-exporter *does* export is the live tun0 **ifindex as a metric value**: +`node_network_iface_id{device="tun0"}` = `129` on `k3s-prod-3`. Read-only on the live cluster, all +three agree right now: + +``` +node label homelab.keithjackson.dev/egress-uplink = 10.150.192.86-129 +node-exporter node_network_iface_id{device="tun0"} = 129 +bpf map Egress IP 10.150.192.86 / Egress Ifindex 129 (all 16 rows, 4 vpn-class pods) +``` + +(The map on the two NON-gateway agents reads `Egress IP 0.0.0.0 / Ifindex 0` with +`Gateway IP 192.168.2.12` — only the gateway node resolves the interface. Worth knowing before +debugging the wrong agent.) + +### PromQL cannot express the equality test — so the invariant is counted instead + +The label's value is `-`; the live ifindex is a metric VALUE. PromQL has no +label-to-number conversion, so `label == value` is **not expressible**, and there is no +level-triggered form of this comparison. What is expressible is the same invariant counted over a +window: **the published label must change at least as often as tun0 is re-created.** + +```promql +( + changes(max(node_network_iface_id{device="tun0"})[1h:1m]) + > + (count(count_over_time(max by (label_homelab_keithjackson_dev_egress_uplink) ( + kube_node_labels{label_homelab_keithjackson_dev_egress_uplink!=""})[1h:1m])) - 1) +) +and on() +(count(kube_node_labels{label_homelab_keithjackson_dev_egress_gateway="true"}) > 0) +``` + +`count(count_over_time(max by (