Skip to content
s1ns3nz0 | Known Unknowns
Go back

Lineth L2 Incident Management: Postmortem (3) - Started Before DNS: An RPC Node With No Peer for 49 Minutes

7 min read

Part 3 of a series of postmortems from my Lineth L2 lab: one incident per failure type. Part 2 was capacity; this one is startup order and dependencies.

The lab’s L2 has two execution nodes. The sequencer builds blocks. The RPC node is what users and services talk to: it accepts transactions and gossips them to the sequencer over P2P. Its only peer is the sequencer, configured as a static peer by DNS name. Format: the Google SRE postmortem template, blameless.

Summary

IncidentINC-2026-10-13-13
StatusResolved
SeveritySEV-1: users could not get transactions included through the public RPC
Impact~49 min with no user transaction included; 67 transactions stuck in the RPC node’s pool; the bridge relayer’s claims lost (the cause of Part 1)
TriggerA planned Docker VM restart (the second fix of Part 2)
Root causes(1) Besu resolves a static peer’s hostname once at start and silently drops it on failure. (2) The startup probe checked only that the RPC port was open
ResolutionPod restarted; then a startup probe on Besu’s own /readiness?minPeers=1, so a node without a peer is restarted within 2 minutes

Impact

whathow much
User transactions via the RPC node (transfers, ERC-20, withdrawals)not included for ~49 min; 67 in the RPC node’s pool; synthetic users gave up after 3 attempts
Bridge relayer (postman)its 41 claim transactions went to the RPC node and were lost → Part 1
L2 head+205 blocks in 30 min instead of ~430: only the sequencer’s own liveness transactions
Not affectedblock production, finalization

Error budget. tx-inclusion-60s (99 % of user transactions included within 60 s) went from 96 % to 90 % of its 28-day budget remaining, and tx-inclusion-5s from 91 % to 90 %. Both understate it: the SLI is built from user-transaction traces that finished. A transaction the generator gave up on never finishes and is never counted, so the worst part of the outage was invisible to the SLO (the same blind spot as in Parts 1 and 2).

Root causes and trigger

Trigger. The Docker VM was restarted at 04:17 to give the Mac memory back (Part 2). On a full node restart every pod starts at once, and cluster DNS answers only some seconds later. Synthetic clients showed the window:

l2-load | urllib3.exceptions.NameResolutionError: HTTPConnection(host='l2-node-besu', port=8545):
          Failed to resolve 'l2-node-besu' ([Errno -3] Temporary failure in name resolution)

They retry per request and recovered on their own. The RPC node did not:

2026-10-13 04:17:48.766+0000 | main | INFO  | Besu | Connecting to 1 static nodes.

That is the last peer-related line until the pod was deleted 38 minutes later. The static peer is enode://…@sequencer.lineth-l2.svc.cluster.local:30303; the name did not resolve yet.

Root cause 1: resolve once, drop silently. Besu resolves a static peer’s hostname when it starts. When that fails it drops the peer without a warning and never retries. Discovery could not help either:

WARN | Besu | --discovery-mode=BOTH but no ENR (enr:) bootnodes provided; DiscV5 will start with no bootstrap peers.

Root cause 2: the probe checked the wrong thing. The startup probe was a TCP check on the RPC port. A node with zero peers has an open RPC port, accepts transactions, answers eth_blockNumber, and is “Ready”, so Kubernetes saw nothing wrong. Transactions sent to it went into its own pool and nowhere else. Senders saw failures like:

04:25:20 FAILED: l2-send: attempt 1/3 nonce=955 failed: Error: server returned an error response:
  error code -32000: Replacement transaction underpriced

(the previous attempt with the same nonce was still sitting in the peer-less node’s pool).

Contributing. The manifest comment said the static peer was “at the sequencer Service ClusterIP”, but the config held the DNS name. The risk looked handled.

Why only this pod. Every other component checked: the coordinator, the postman and the synthetic users resolve names per request and retried; the sequencer has no static peers; the remote signer already waits for its dependency (an init container added after an earlier incident with the same “started before its dependency” shape).

Resolution

  1. Mitigation (04:56): confirmed the name now resolves, deleted the pod. It peered on start; the pool drained from 67 to 5 and the synthetic users logged ok again by ~05:06.

  2. Fix (05:26): a startup probe on Besu’s built-in readiness endpoint, which answers 200 only with at least the given number of peers:

    # Started = has a peer (Besu /readiness, minPeers=1), not just an open port: a node that came up without
    # its static peer is restarted after 2 min instead of running with 0 peers.
    startupProbe:
      httpGet: {path: "/readiness?minPeers=1", port: rpc}
      periodSeconds: 5
      failureThreshold: 24

    Checked before relying on it: /readiness?minPeers=1 → 200, /readiness?minPeers=2 → 503 on a node with one peer. The new pod was Ready, with its peer, 6 seconds after start.

A wait-for-DNS init container was considered and dropped. It covers only DNS; the probe covers any reason for “no peer” with one mechanism, and costs at most 2 minutes after a restart.

Detection

time (UTC)after the restart
RPC node starts without a peer04:17:480
LinethL2NoPeers (critical) fires04:20:383 min
LinethL2RpcTxStuck fires04:23:536 min
A human notices (L2 head growing at half speed, generators failing)04:5537 min
Mitigated04:56 → ~05:06~49 min

The alerts were right and early and reached no person. The human found it while measuring something else.

Lessons learned

What went well

What went wrong

Where we got lucky

Action items

#actiontypestatus
1Startup probe /readiness?minPeers=1 within 2 minmitigatedone
2Check other components for once-at-start name resolutionpreventdone (only this one)
3Fix the misleading manifest commentprocessdone
4After any full restart, run a check of every workflow before calling it done (runbook + script)processdone
5Route alerts to a persondetectdone (Telegram)
6Count user transactions that never complete in the inclusion SLIdetectopen

Timeline (UTC, 2026-10-13)

timeevent
04:17:13 / 04:17:37Docker Desktop stop / start (planned, Part 2)
04:17:48RPC node: Connecting to 1 static nodes. Never peers
04:17:5xSynthetic clients: Temporary failure in name resolution (retry, recover)
04:20:38LinethL2NoPeers fires (unseen)
04:23:53LinethL2RpcTxStuck fires (unseen)
04:20 onwardThe bridge relayer’s claims go to the peer-less node and are dropped at its restarts (→ Part 1)
04:55Noticed: head growth half the usual, net_peerCount 0
04:56DNS checked, pod deleted
~05:06Peered; pool 67 → 5; generators ok
05:26Startup probe on /readiness?minPeers=1 deployed; new pod Ready with a peer in 6 s

Takeaways


Share this post:

Previous Post
Lineth L2 Incident Management: Postmortem (4) - Alive but Dead: 12 Hours Without Grafana
Next Post
Lineth L2 Incident Management: Postmortem (2) - Two Counters, One Limit: Every Batch Proven Twice