Skip to content
s1ns3nz0 | Known Unknowns
Go back

Lineth L2 Incident Management: Postmortem (4) - Alive but Dead: 12 Hours Without Grafana

5 min read

Part 4 of a series of postmortems from my Lineth L2 lab: one incident per failure type. Part 3 was startup order; this one is an observability failure: the system was fine, the view of it was not.

The lab’s Grafana runs inside the kind cluster. On the Mac it is reached at 127.0.0.1:23000 through a kubectl port-forward that launchd keeps running (KeepAlive), so it survives logouts and crashes. Format: the Google SRE postmortem template, blameless.

Summary

IncidentINC-2026-10-11-09
StatusResolved
SeveritySEV-3: the operator’s dashboards were gone for ~12 h; the system and its alert evaluation were fine
ImpactGrafana unreachable from 2026-10-11 12:06 to 2026-10-12 00:00 UTC; 1,929 failed requests in the forward’s log
TriggerA node memory stall (a prover peak took the kind node near zero free memory) broke the forward’s connection to the API server
Root causeThe supervisor checked “is the process alive”, not “does the forward work”. A wedged kubectl port-forward never exits, so KeepAlive never fired
ResolutionA wrapper under launchd that health-checks Grafana through the forward and exits after 3 failures, so launchd starts a fresh one. Tested by freezing the forward on purpose

Impact

whathow much
Grafana at 127.0.0.1:23000unreachable ~12 h
Not affectedthe Grafana pod (0 restarts, Ready), Prometheus, Loki, Tempo, alert evaluation

Error budget. None: no SLO covers the operator’s tooling. That is the point of this postmortem. During those 12 hours, anyone opening a dashboard to check on the system would have had nothing to look at, and nothing said so.

Root causes and trigger

Trigger. At 12:00 and 12:05 UTC the prover hit two large-mode memory peaks (the trace-limit problem from Part 2, not yet found then). The kind node’s available memory fell to 0.45 and 0.68 GiB; kubelet lease renewals timed out and containerd streams failed. The port-forward lost its stream one minute later. Its log (klog timestamps are local time, UTC+9):

E1011 21:06:05.252894 24120 portforward.go:455] "Unhandled Error" err="error creating error stream for port 23000 -> 3000: Timeout occurred"
E1011 21:06:37.172643 24120 portforward.go:455] "Unhandled Error" err="error creating error stream for port 23000 -> 3000: Timeout occurred"
E1011 21:07:10.155332 24120 portforward.go:455] "Unhandled Error" err="error creating error stream for port 23000 -> 3000: Timeout occurred"
E1011 21:08:46.148072 24120 portforward.go:478] "Unhandled Error" err="error creating forwarding stream for port 23000 -> 3000: Timeout occurred"

and then the same error on every request: 1,929 lines over 12 hours.

Root cause: liveness instead of function. kubectl port-forward does not exit when its API server connection breaks this way. It keeps the local port open, accepts each connection, fails to open a stream, logs an error, and waits for the next one. launchd’s KeepAlive restarts a job only when its process exits. The process never exited, so from launchd’s view everything was fine.

This is the same shape as two other incidents in the lab: an RPC node that was up with zero peers (Part 3), and a consensus client that was up with a full Java heap. In each case, “the process is running” was true and useless.

Resolution

launchd now runs a small wrapper instead of kubectl directly. It starts the forward, checks Grafana’s health endpoint through the forward every 30 s, and exits after three failures in a row so launchd starts a fresh one:

"$HOME/.local/bin/kubectl" --kubeconfig "$HOME/.kube/lineth-local.yaml" --context kind-lineth-local \
  -n monitoring port-forward service/monitoring-grafana "$PORT:80" --address=127.0.0.1 &
pf=$!
trap 'kill -9 $pf 2>/dev/null' EXIT
sleep 10
fails=0
while kill -0 "$pf" 2>/dev/null; do
  if curl -sf -o /dev/null -m 5 "http://127.0.0.1:$PORT/api/health"; then fails=0; else fails=$((fails + 1)); fi
  if [ "$fails" -ge 3 ]; then
    echo "$(date -u +%FT%TZ) grafana-forward: health check failed 3x, exiting so launchd restarts it" >&2
    exit 1
  fi
  sleep 30
done

Tested by injecting the failure, not by restarting. A restart only proves the happy path. Instead, the forward was frozen with kill -STOP on the kubectl child at 00:01:39, which reproduces “alive but not forwarding”. The wrapper gave up after three failed checks and launchd brought up a new forward:

2026-10-12T00:03:17Z grafana-forward: health check failed 3x, exiting so launchd restarts it

127.0.0.1:23000 answered 200 again right after.

Detection

time (UTC)
Forward breaks10-11 12:06:05
Noticed (a new helper script got HTTP 000 from 23000)~10-11 23:50
Root-caused10-11 23:59
Fixed and verified10-12 00:03:17

Nothing watched the local port, so detection depended on someone opening Grafana. Detection took ~12 h; once someone looked, the fix took ~13 minutes.

Lessons learned

What went well

What went wrong

Where we got lucky

Action items

#actiontypestatus
1Health-checked forward wrapper under launchdmitigatedone
2Remove the trigger: node memory headroom for the proverpreventdone (Part 2: normal-mode batches, VM sized for the host)
3Ad-hoc port-forwards can wedge the same way: kill and re-create rather than trust an existing oneprocessdocumented
4Monitor the operator path itself: alert when the local Grafana endpoint stops answering, not only when the process exitsdetectopen

Timeline (UTC)

timeevent
10-11 12:00, 12:05Prover large-mode memory peaks; node MemAvailable 0.45 / 0.68 GiB
10-11 12:06:05First error creating error stream for port 23000 -> 3000: Timeout occurred
10-11 12:06 – 10-12 00:001,929 identical errors; the process stays alive, launchd sees nothing
10-11 ~23:50A new query helper gets HTTP 000 from 23000
10-11 23:59Root cause: launchd job alive, forward wedged
10-12 00:01:39Wrapper deployed; failure injected with kill -STOP on kubectl
10-12 00:03:17Wrapper exits after 3 failed checks; launchd starts a new forward; 23000 → 200

Takeaways


Share this post:

Previous Post
Lineth L2 Incident Management: Postmortem (5) - An L1 Deadlock the Engine API Made Permanent
Next Post
Lineth L2 Incident Management: Postmortem (3) - Started Before DNS: An RPC Node With No Peer for 49 Minutes