fix: recover LocalDNS after repeated unexpected exits - #9439
fix: recover LocalDNS after repeated unexpected exits#9439Saewon Kwak (saewoni) wants to merge 4 commits into
Conversation
Rebased onto latest main; the e2e scenario file was renamed from scenario_localdns_hosts_test.go to scenario_localdns_hosts.go by the standalone-CLI e2e refactor (#9321), so the lifecycle validator is re-attached to the new Register-based scenario. When the localdns supervisor exits unexpectedly (SIGKILL), the shell cleanup traps do not run, so the node can retain the network drop-in that points DNS at the dead localdns listener (169.254.10.10), causing a node-level DNS outage. - localdns.service: add ExecStopPost=/opt/azure/containers/localdns/localdns.sh cleanup so DNS is reverted after both graceful and unexpected exits. - localdns.sh: add cleanup mode (localdns_cleanup_mode) that restores node DNS and always exits 0 so a cleanup error cannot wedge systemd recovery; make cleanup_iptables_and_dns aggregate failures instead of returning early so DNS drop-in removal and network reload always run even when iptables rule deletion fails. - localdns_spec.sh: ShellSpec coverage for cleanup_iptables_and_dns and cleanup mode (success, successful rule removal, iptables-failure still restores DNS, reload failure reported, cleanup mode exits 0 on success and failure). - e2e: lifecycle validator covering normal stop/start, kill+recovery with a genuinely-new-MainPID check, and the terminal dead-service case (disable auto-restart via a transient Restart=no drop-in, kill, then assert the 70-localdns.conf drop-in was removed and DNS no longer points at 169.254.10.10, polling for a terminal ActiveState and DNS revert).
The DNS-revert settle loop suppressed resolver-read errors (|| true), so an errored or empty resolvectl/resolv.conf read produced an empty current_dns, which the absence check then treated as 'listener gone' -> success. A failed read would therefore mask the terminal-outage regression the check exists to catch. Drop the error suppression and only accept a successful, non-empty snapshot that omits 169.254.10.10; empty/failed reads keep polling and fail the test if the resolver state never becomes readable.
Follow-up to the node-level DNS restoration in the LocalDNS teardown PR.
That fix reverts the node host resolver (169.254.10.10) on an unexpected
exit, but pods use the cluster listener 169.254.10.11 (kubelet
--cluster-dns, baked into each pod's /etc/resolv.conf and not repointable
for the pod's lifetime). If LocalDNS is left dead, 169.254.10.11 stops
answering and every pod on the node black-holes DNS.
The terminal dead state is reached when a crash burst exhausts systemd's
default start limit (5 starts / 10s), after which the unit is left 'failed'
permanently ('Start request repeated too quickly') and the cluster listener
never comes back.
Keep the unit recovering so 169.254.10.11 is rebound:
- RestartSec=2 backs off between restarts so a crash loop cannot exhaust the
start-limit burst in a couple of seconds, and each restart has time to
re-bind the node and cluster listeners and re-apply config.
- StartLimitIntervalSec=300 / StartLimitBurst=30 keep systemd retrying rather
than giving up.
- KillSignal=SIGTERM (with KillMode=mixed) reaps orphaned coredns children so
the next ExecStart can re-bind the listeners cleanly.
Validated live: the crash storm that previously wedged the unit in 'failed'
now recovers to active/running, and in-pod DNS goes from a permanent
blackhole to a ~30s self-healing interruption.
Node SIG review requestedCould Node SIG please review AgentBaker PR #9439? This PR addresses a LocalDNS pod-DNS outage after repeated LocalDNS crashes. When The PR adds a controlled systemd recovery policy: StartLimitIntervalSec=300
StartLimitBurst=30
RestartSec=2When LocalDNS restarts, CoreDNS rebinds Live validationWe reproduced the failure and the recovery on the same live LocalDNS-enabled AKS node:
The pod continued using the same PR #9439 is stacked on AgentBaker PR #9360, which handles node-level DNS restoration through Questions / current findings
This PR is intended to improve recovery from transient crash storms. It does not add an infinite retry policy or a zero-downtime DNS standby fallback. Persistent-failure handling and a zero-gap fallback remain separate design questions. |
# Conflicts: # e2e/scenario_localdns_hosts.go # parts/linux/cloud-init/artifacts/localdns.service
There was a problem hiding this comment.
🟡 Changes recommended
The crash-storm recovery behavior lacks automated coverage, and the new comments inaccurately describe finite retries and KillMode=mixed.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Improves LocalDNS recovery after crash storms to restore pod DNS without manual intervention.
Changes:
- Expands systemd’s restart budget and adds a two-second backoff.
- Documents service termination and listener cleanup behavior.
File summaries
| File | Description |
|---|---|
parts/linux/cloud-init/artifacts/localdns.service |
Configures bounded LocalDNS restart recovery. |
Review details
Suppressed comments (2)
parts/linux/cloud-init/artifacts/localdns.service:32
- This describes
KillMode=mixedincorrectly:KillSignal=SIGTERMis sent only to the main process, while the remaining control-group processes receive the subsequent SIGKILL. Please document the actual cleanup sequence so future changes do not assume CoreDNS receives SIGTERM here.
# On stop, SIGTERM the control group; on an unexpected exit / SIGKILL of the
# supervisor, systemd still reaps orphaned coredns children so the next
# ExecStart can re-bind the listeners cleanly.
parts/linux/cloud-init/artifacts/localdns.service:18
- 🟡 Medium Risk — The existing
validateLocalDNSLifecycletest kills the supervisor only three times and waits for each recovery, so it passes under the old default five-start limit and never validates this new recovery budget or pod-facing listener. Add an E2E case that asserts these systemd properties, drives more than five unexpected exits, and verifies DNS through169.254.10.11recovers; otherwise a typo or ineffective directive can reintroduce the outage undetected.
StartLimitIntervalSec=300
StartLimitBurst=30
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # burst, combined with the RestartSec backoff below, means a crash storm slows | ||
| # restarts but never gives up. |
TL;DR
Problem: If LocalDNS crashes repeatedly, systemd can exhaust its default restart limit and leave
localdns.servicepermanentlyfailed. Pods still havenameserver 169.254.10.11in their existing/etc/resolv.conf, but nothing is listening on that address, so pod DNS remains broken until LocalDNS is manually restored.Fix: Give systemd a controlled recovery budget and restart backoff (
StartLimitIntervalSec=300,StartLimitBurst=30,RestartSec=2) so transient LocalDNS crash storms do not immediately wedge the service. When LocalDNS restarts, CoreDNS rebinds the pod-facing169.254.10.11listener and existing pods recover DNS.Scope: This is bounded recovery, not an infinite-retry or zero-downtime fallback. A persistently broken LocalDNS process can still exhaust the larger budget; a standby DNS fallback would be a separate follow-up.
Problem
When LocalDNS crashes repeatedly, systemd can exhaust its restart limit and leave
localdns.servicepermanently infailed. LocalDNS owns the DNS listener used by pods (169.254.10.11). Existing pods keep that address in/etc/resolv.conf, so once the service is failed and nothing listens on.11:53, pod DNS remains broken until LocalDNS is manually restored.The failure sequence is:
PR #9360 separately restores node-level DNS by removing the
169.254.10.10node-resolver drop-in after an unexpected exit. This PR addresses the pod-level recovery gap by keeping systemd retrying LocalDNS long enough for the pod-facing.11listener to come back.LocalDNS has two listeners:
169.254.10.1070-localdns.confnetworkd drop-in169.254.10.11--cluster-dns, baked into pod/etc/resolv.confA running pod cannot be repointed from the node, so pod recovery requires the
.11listener to become available again.Fix
Increase the systemd restart budget and add a restart backoff:
This changes the behavior from “a short crash storm can permanently wedge the unit” to “systemd retries with a controlled backoff and the service can recover.” When LocalDNS restarts, CoreDNS rebinds
169.254.10.10and169.254.10.11, allowing existing pods to resume DNS.This is a bounded recovery improvement, not an infinite-retry or zero-downtime guarantee. A persistent failure can still exhaust the larger start budget, and a zero-gap standby responder would require a separate design.
PR #9439 is stacked on PR #9360, so
ExecStopPostfrom #9360 and the recovery settings here work together.Reproduction — pod DNS blackhole (shipped VHD, before this fix)
Live LocalDNS-enabled AKS node (Ubuntu 24.04), a
busyboxpod scheduled on it.Baseline — LocalDNS healthy. The pod resolves through the cluster listener:
Fault injection — supervisor driven into the terminal dead state (shipped VHD):
The node was first restored to
active/running, then a transientRestart=nodrop-in was installed so that the test could force the terminal state without
waiting for systemd's restart policy. The supervisor PID was read from
systemd and killed with
SIGKILL:The shipped unit then settled in
failed; itsExecStopPostwas absent, sothe node listener and the pod listener were both left unusable. The pod remained
unable to resolve until LocalDNS was manually restored:
The same failure was also reproduced with a rapid-kill harness matching the
production crash-storm shape. The harness made up to eight attempts, reading
MainPIDbefore each attempt, sendingSIGKILLwhen a live PID existed, andwaiting 300 ms between attempts. On the shipped unit, six live PIDs were
observed and killed before systemd reached
Start request repeated too quickly.Pod DNS after the kill — remained broken until LocalDNS was restored:
The node-level fix (#9360) does nothing here — the pod never uses
169.254.10.10.Change
Keep the unit recovering so
169.254.10.11is rebound instead of wedging infailed:RestartSec=2backs off between restarts so a crash loop cannot exhaust the start-limit burst in a couple of seconds, and each restart has time to re-bind the node/cluster listeners and re-apply config.StartLimitIntervalSec=300/StartLimitBurst=30increase the restart budget so transient crash storms are less likely to exhaust the start limit immediately. A persistent failure can still exhaust this finite budget.KillMode=mixedkeeps service-process cleanup under systemd control so the nextExecStartcan re-bind the listeners cleanly.KillSignal=SIGTERMdocuments the existing/default graceful termination signal; it is not itself the child-reaping or recovery mechanism.Stacked on #9360, so on each recovery
ExecStopPost+ startup restore both listeners together.Fixed behavior — pod DNS recovers (same live node, after this fix)
The same live node was tested with the repeated-kill harness, which attempts up to
eight
SIGKILLs when a liveMainPIDis available. The observed run recoveredthrough the restart/backoff cycle and the pod DNS subsequently recovered:
Start request repeated too quickly→ terminalfailedactive/running(Result=success)169.254.10.11:53listenerdig @169.254.10.11from nodeconnection refusedIn-pod DNS timeline under this fix (polled from inside the pod during the
restart/backoff window; the recovery run observed the service return to
active/runningand the.11listener return):So this converts the reproduced permanent pod-DNS outage into a brief, self-healing (~30 s) interruption for a transient crash storm. The residual gap is the crash-loop-through-backoff window, and the finite restart budget means a persistently broken process can still eventually enter
failed. A zero-gap guarantee would require an always-on standby responder on169.254.10.11; a persistent-failure fallback is a separate follow-up. This PR addresses the reproduced transient crash-storm failure, not every possible permanent LocalDNS failure.Validation
az vmss run-command+ in-podkubectl exec. The repro cluster's discoveredkube-dnsService IP (10.0.0.10) was confirmed functional throughout; this address is cluster-specific and is not a universal constant.169.254.10.11, drive LocalDNS through the crash/recovery cycle, assert in-podnslookuprecovers, and restore the node. This complements the node-level assertions in fix: restore node-level DNS after unexpected LocalDNS exit #9360.Relationship to #9360
169.254.10.10on unexpected exit).169.254.10.11answering by guaranteeing LocalDNS recovery).Together, #9360 and this PR address the reproduced node- and pod-DNS failure paths for transient LocalDNS crash storms. Persistent LocalDNS failure and zero-gap fallback remain out of scope and require additional work.