Repository navigation
Fix and recover the container DNS boot race - #64
Merged
Merged
Conversation
A reboot can leave every bridge-network container unable to resolve any hostname, indefinitely. Measured on jonathan-node-1 on 2026-10-05, where the ADS-B feed was dead for 67 minutes and looked exactly like a bad release: 18:43:28 NetworkManager-wait-online finishes, network-online.target is met 18:43:32 tar1090 starts; dockerd writes its resolv.conf from the host's 18:43:40 docker.service reaches active 18:43:45 NetworkManager finally writes /etc/resolv.conf <- 13s too late 18:43:46 first getaddrinfo EAI_AGAIN network-online.target is not a promise that DNS works. It is satisfied when NetworkManager reports a connection is up, 17 s before NetworkManager wrote the resolver configuration. Containers on a user-defined bridge get `nameserver 127.0.0.11`, Docker's embedded resolver, which forwards to whatever upstreams dockerd read from the host's /etc/resolv.conf at container start. Read too early and the container keeps a dead upstream for its whole lifetime: nothing re-reads it, and the container cannot see or fix the host file. Only a container restart regenerates it. Containers on host networking are unaffected, which is why this presents as one service being broken rather than as a network fault. Nothing recovers it either. The proxy catches the error and keeps serving, so `restart: always` never fires (RestartCount stayed 0 across the outage); the container healthcheck watches readsb's local aircraft.json, which is rewritten every second regardless, so it stayed healthy; Docker's restart policy acts on process exit, not on healthcheck failure; and the RSPduo watchdog monitors blah2 API staleness, which was unaffected. So: one ExecStartPre on docker.service that waits for a non-loopback nameserver, bounded at 60 s and exiting 0 even on timeout, because a failing ExecStartPre would stop the daemon from starting at all. The real race was 13 s. Explicit nameservers in daemon.json would also fix the timing, but they hardcode resolvers a given site may not route to, and nodes sit on networks we do not control. Waiting for whatever the site's DHCP provides is correct everywhere. Tested: the script against 8 resolv.conf fixtures (stale 127.0.0.53, healthy, empty, comments-only, ::1, IPv6-only, mixed loopback-plus-real, and a file rewritten mid-wait, which unblocks on the rewrite); and on jn1 against both its real resolver configuration (exits in 0 s) and the exact stale content it carried at boot (waits, then proceeds). Docs: owl-os keeps role reasoning in the role header rather than docs/, as wifi_reconnect does; nothing in docs/ describes container DNS or service ordering, so no doc change applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docker.service runs with TimeoutStartSec=infinity on these nodes, so systemd will not rescue a hung ExecStartPre: a Docker daemon that never starts is far worse than the race this guards against. Three independent guards now: 1. the script's own 60 s deadline, after which it exits 0 2. timeout(1) as a hard outer bound that does not rely on the script's logic 3. a leading '-' so systemd ignores a non-zero exit either way -k 10 on the timeout is load bearing. Plain timeout sends only SIGTERM, which a wedged process can ignore, and then timeout itself waits forever: measured at 12 s and still running until an outer guard SIGKILLed it, against 5 s for the same process under -k. Also records in the role header that this is confirmed on two nodes and latent fleet wide: /etc/resolv.conf was written 5 s after dockerd started on jn1 and 6 s after on fairforest-b, both carrying the identical stale `ExtServers: [host(127.0.0.53)]` and *.gx.internal.cloudapp.net record, which is how we know it comes from the shared image build. On fairforest-b the breakage is masked by a local ADS-B receiver: the proxy serves `X-Data-Source: local` while the remote fallback is dead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ExecStartPre guard prevents the race on future boots. It does nothing for
a container already stuck, and it ships in an OS image, so every node updated
before it reaches them stays broken until someone notices. Three of three
nodes checked were broken at the same time.
A systemd timer every 5 minutes restarts tar1090 when, and only when, the
container cannot resolve any of the hosts in its own ADSB_UPSTREAMS.
The trigger is resolution, not "the feed has no data", for two reasons:
* It catches the masked case. fairforest-b has its own ADS-B receiver, so the
proxy served X-Data-Source: local and looked healthy while its remote
fallback was dead. It sat like that for nearly two hours and nothing in the
stack would ever have reported it.
* It does not fire during a real upstream outage. adsb.retina.fm served
HTTP 200 with an empty sky for about two weeks in September. If DNS
resolves, a restart cannot help, and restarting every cycle would be churn
that hides the actual problem.
Two bugs found by testing, both the same silent-failure shape this is meant to
end:
* `exec 9>"$RESTART_LOCK" || exit 0` meant an unwritable lock path disabled
the watchdog permanently and without a word. The lock guards operations
that recreate the whole compose project; this restarts one container, so a
lock we cannot open must not veto it. It now proceeds and says so.
* A failed state-file write was swallowed, which disables the cooldown that
stops a host-level DNS outage becoming a restart every 5 minutes. It now
warns.
Tested: 8 decision paths against a stubbed docker (healthy, container stopped,
first failure, cooldown active, cooldown expired, unwritable lock, unwritable
state, empty ADSB_UPSTREAMS falling back). Then end to end on jn1 by breaking
resolution inside the live container: healthy run changed nothing, the broken
run restarted it and recovered to X-Data-Source: adsb.retina.fm, and an
immediate repeat was suppressed by the cooldown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Purple10101
added a commit
that referenced
this pull request
Oct 15, 2026
Shows the owner when the radio is overloaded, which only became meaningful once blah2 stopped dying on overload: until #79 an overload deadlocked the SDRplay API, blah2 exited, and the meter simply read "no signal". Also stops blah2 before restarting the SDRplay service, which is the config-apply path that could leave the radar down while reporting done, renames routes/mode.py to routes/sdr.py, and shows the Tracker at +/-300 Hz by default with tracks beyond it flagged. Cut as os-v0.19.0-dev, paired with retina-node v0.4.8.0-dev. A dev build for verification on Josh Test Node 1 and 2: both stable-only filters reject the -dev suffix, so no node is offered this as latest, and it is deployed by hand. This release also carries #64, the container DNS boot race fix. That needs the Gate A/B/C checklist on staged-first before any fleet rollout, because a node that resolves fine afterwards proves nothing: NetworkManager sometimes wins the race anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The failure this prevents
A reboot can leave every bridge-network container unable to resolve any hostname, indefinitely. Measured on jonathan-node-1 on 2026-10-05, where the ADS-B feed was dead for 67 minutes and looked exactly like a bad release:
Symptom in the container:
getaddrinfo EAI_AGAINon every upstream, and the proxy servingX-Data-Source: nonewith 0 aircraft. The host resolved fine throughout.Why it happens
network-online.targetis not a promise that DNS works. It is satisfied when NetworkManager reports a connection is up, which here was 17 s before NetworkManager wrote the resolver configuration.A bridge container's resolver is fixed at container start. It gets
nameserver 127.0.0.11, Docker's embedded resolver, which forwards to whatever upstreams dockerd read from the host's/etc/resolv.confat that moment. Read too early and the container keeps a dead upstream for its whole lifetime. Nothing re-reads it, and the container cannot see or fix the host file. Only a container restart regenerates it.Containers on host networking are unaffected, which is why this presents as one service being broken rather than as a network fault.
Why nothing recovered it
Four independent mechanisms all miss it, each verified on the node:
restart: alwaysRestartCountstayed 0 across the outage./healthcheck.shchecks/run/readsb/aircraft.jsonfreshness. readsb rewrites it every second regardless, so the container stayedhealthy.Downstream saw no error either: HTTP 200 with an empty aircraft list. retina-telemetry reports nothing about the feed.
The change
One
ExecStartPreondocker.servicethat waits for a non-loopback nameserver in/etc/resolv.conf. Bounded at 60 s, and exits 0 even on timeout, because a failingExecStartPrewould stop the daemon starting at all, which is worse than the race. The real race was 13 s.Deliberately not done: pinning explicit nameservers in
/etc/docker/daemon.json. It would fix the timing, but hardcodes resolvers a given site may not route to, and nodes sit on networks we do not control. Waiting for whatever the site's DHCP provides is correct everywhere.Noted but not acted on: the rootfs also carries the image build environment's own
/etc/resolv.confuntil NetworkManager replaces it. On jn1 that wasnameserver 127.0.0.53plus a*.gx.internal.cloudapp.netsearch domain, neither of which can be generated on a Pi (the node has nosystemd-resolvedunit at all). That is the file dockerd read. Waiting makes its content irrelevant, so clearing it would be hygiene rather than a fix, and not worth risking an image build without a resolver mid-run.Testing
The script against 8
resolv.conffixtures:127.0.0.53::1And on jn1 itself: against its real resolver configuration (exits in 0 s) and against the exact stale content it carried at boot (waits, then proceeds). The drop-in could not be installed there to test the boot path, as the node has no passwordless sudo; that path is only exercised by a reboot.
Docs
owl-os keeps role reasoning in the role header rather than in
docs/, aswifi_reconnectdoes, and the header here carries the full timeline and rationale. Nothing indocs/describes container DNS or service ordering, so no doc change applies.🤖 Generated with Claude Code
Update: added self-healing (3 commits now)
The first commit prevents the race on future boots. On its own that was not enough, because it ships in an OS image: every node updated before it arrives stays broken until a human notices. Three of three nodes checked were broken simultaneously today.
Second commit: hardening
docker.serviceruns withTimeoutStartSec=infinityon these nodes, so systemd will not rescue a hungExecStartPre. A Docker daemon that never starts is far worse than the race. Three independent guards now:-k 10is load bearing: plaintimeoutsends only SIGTERM, which a wedged process can ignore, after whichtimeoutitself waits forever. Measured at 12 s and still running until an outer guard SIGKILLed it, against 5 s for the same process under-k.Third commit: the watchdog
A systemd timer every 5 minutes restarts tar1090 when, and only when, the container cannot resolve any host in its own
ADSB_UPSTREAMS.Why the trigger is resolution and not "the feed has no data":
X-Data-Source: localand looked healthy while its remote fallback was dead. It sat that way for nearly two hours and nothing in the stack would ever have reported it.A probe from the host would not work at all, incidentally: the host's DNS is fine throughout. The fault is confined to the container's namespace.
Two bugs testing found, both the shape this is meant to end
exec 9>"$RESTART_LOCK" || exit 0meant an unwritable lock path disabled the watchdog permanently and silently. The lock guards operations that recreate the whole compose project; this restarts one container, so a lock we cannot open must not veto it. It now proceeds and logs.Testing
8 decision paths against a stubbed
docker:ADSB_UPSTREAMSThen end to end on jn1, by breaking resolution inside the live container:
Still only exercised by a reboot
The
ExecStartPreboot path remains untested: neither node has passwordless sudo, so the drop-in could not be installed. The watchdog is now the backstop for exactly that uncertainty. Suggested gate after the first image carrying this: reboot a node and checkdocker exec tar1090 getent hosts adsb.retina.fm.Fourth node checked: owl-debb (2026-10-06)
Different site, different resolver (
192.168.0.50,search ht.home), so this is not network specific.It is currently healthy, and that is not reassuring. All the preconditions are present:
The race happened here too. It just did not land on this container, because the container was not started at boot:
Created seven minutes before I looked, roughly 12 hours after boot. The reason is a second, unrelated fault: owl-debb has no SDR, so blah2 crash-loops (
[RspDuo] MaxDevs=1023 NumDevs=0 / Error: No devices found,RestartCount=16,status=restarting). The RSPduo watchdog therefore sees stale blah2 API data every 5 minutes and runs acompose down/up, recreating the whole stack. Every recreate regeneratesresolv.conffrom the current, correct host file.So owl-debb is immune to this bug as a side effect of being permanently broken in a different way. Fix its SDR and it becomes exposed like the rest.
It also confirms the provenance argument: when dockerd reads the host file late it gets the real local values (
ExtServers: [host(192.168.0.50)],search ht.home), where jn1, jn2 and ffb all gotExtServers: [host(127.0.0.53)]with a*.gx.internal.cloudapp.netsearch domain. Same image, so that stale content is the build environment's own file, read before NetworkManager replaced it.Fleet tally
Four of four have the preconditions. Three of four were broken simultaneously. The fourth is only spared by a fault of its own.
Fifth node: owl-ded9 (2026-10-06). Broken, and the cleanest proof yet.
owl-ded9 and owl-debb share a site, a resolver (
192.168.0.50), an OS image and an identical 2 s race margin. They ended up in opposite states, and the reason is exactly the mechanism this PR addresses.owl-ded9 had 5653
EAI_AGAINentries and zero successful fetches since boot: the feed was dead for 12.5 hours and nothing reported it.The only difference between the two is whether anything recreated the container after boot. owl-debb has no SDR, so blah2 crash-loops and the RSPduo watchdog runs a
compose down/upevery 5 minutes. owl-ded9 has a working SDR (blah2 running, RestartCount=2), so its stack stayed up and stayed broken.That is the whole argument for this PR in one comparison: the outcome is decided by a 6-second accident of ordering, and a node that is otherwise working correctly is the one that stays broken.
Fixed by hand with
docker restart tar1090: resolver nowhost(192.168.0.50),X-Data-Source: adsb.retina.fm.Fleet tally
source: localFive of five have the preconditions. Four of five were broken simultaneously. The one that was not is spared only by a fault of its own.
Unrelated observation, not chased
owl-ded9's
RECEIVER_LAT/LONis42.241528,-72.648361, which is exactly jn1's WGBY-TV transmitter coordinate. Its 0-aircraft reading after the fix is genuine (40 nm radius at 03:30 local; 120 nm gives 10 and 250 nm gives 42 at the same point), so the feed is fine. But a receiver sited on a transmitter is worth a separate look, and may bear on the long-standing "owl-ded9 maps are noise" symptom.Boot path now tested on hardware (owl-ded9, 2026-10-06)
Installed the drop-in on owl-ded9 by hand and rebooted. Watchdog deliberately NOT installed, so this tests the prevention layer in isolation rather than having the watchdog mask a failure.
The reboot alone was inconclusive, and the timeline says why
Outcome was healthy (
ExtServers: [host(192.168.0.50)], resolves ok, 0EAI_AGAIN,X-Data-Source: adsb.retina.fm, where the same node had 5653EAI_AGAINand a dead feed the previous boot). But NM wroteresolv.confthree seconds before docker.service even began, so the race never arose and the wait loop was a no-op. That boot would likely have succeeded without the drop-in too. The ordering of NM's write relative towait-onlinevaries between boots: late yesterday, early today.So the condition was forced directly
With
/etc/resolv.confreplaced bynameserver 127.0.0.53(loopback only), thensystemctl restart docker:The
ExecStartPreheld the Docker daemon inactivatingfor 12+ seconds while the resolver configuration was unusable, and released within 3 seconds of it becoming usable. That is the mechanism that prevents the race, demonstrated on the node that actually lost the race yesterday.Also confirmed on hardware
systemd-analyze verify docker.serviceclean.ignore_errors=yes, so the leading-is honoured.docker.servicetook 7.05s, against 12.6s measured on jn1 without the drop-in.Still untested
The Ansible role has not been applied by an image build (no
ansibleavailable here). Validated offline only: 7 tasks, standardcopy/filemodules, bothsrcfiles present. The drop-in and script content installed by hand are byte-identical to what the role writes.Node left in this state
owl-ded9 keeps the hand-installed drop-in and script (not the watchdog), so it is protected against its next reboot until an image supersedes it. Everything else restored: resolv.conf correct, 8 containers up, feed healthy.