Skip to content

fix: restore node-level DNS after unexpected LocalDNS exit - #9360

Open
Saewon Kwak (saewoni) wants to merge 3 commits into
mainfrom
fix/localdns-cgroup-teardown
Open

fix: restore node-level DNS after unexpected LocalDNS exit#9360
Saewon Kwak (saewoni) wants to merge 3 commits into
mainfrom
fix/localdns-cgroup-teardown

Conversation

@saewoni

@saewoni Saewon Kwak (saewoni) commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

This PR addresses the LocalDNS failure reported by Azure/AKS#5930. localdns.service supervises localdns.sh, which starts CoreDNS as a background process. During an unexpected supervisor exit, such as SIGKILL, the script cannot run its cleanup traps. The node can therefore retain the network drop-in that points DNS at LocalDNS while the resolver is no longer available. Restart attempts can also encounter leftover processes and fail, eventually leaving the service dead and causing a node-level DNS outage.

Scope: this PR restores node-level (host) DNS only. It reverts the node host resolver (the 169.254.10.10 node-listener drop-in and iptables rules) after an unexpected LocalDNS exit. It does not restore pod DNS. Pods receive nameserver 169.254.10.11 (the cluster listener) from kubelet --cluster-dns, baked into each pod's /etc/resolv.conf at creation and not repointable from the node for the pod's lifetime, so ExecStopPost cannot give already-running pods a working resolver. Restoring pod DNS requires keeping the .11 cluster listener answering -- guaranteeing LocalDNS service recovery from the terminal dead state (the cgroup / StartLimit cause) and/or a .11 cluster-DNS fallback -- which is a distinct root cause tracked as a separate follow-up PR. This PR therefore does not by itself fully close AKS#5930; it addresses the node-DNS redirect and validates that behavior.

Fix

  • Add ExecStopPost=/opt/azure/containers/localdns/localdns.sh cleanup so DNS cleanup runs after both normal and unexpected service exits.
  • Add a cleanup mode to localdns.sh that restores node DNS configuration and always exits successfully; systemd remains responsible for process cleanup.
  • Handle normal SIGTERM with graceful cleanup and exit status 0; genuine error paths retain the existing failure status.
  • Add isolated AgentBaker E2E coverage for Ubuntu 22.04, Ubuntu 24.04, and Azure Linux V3. The lifecycle validation exercises the LocalDNS systemd unit files (ExecStopPost DNS cleanup) baked into the VHD, which are identical regardless of the bootstrap path, so the scenario runs under the default (aks-node-controller / scriptless) provisioning path.

The following are intentionally out of scope and tracked separately: pod-level DNS restoration (cluster-listener .11 recovery / fallback), guaranteed LocalDNS service recovery from the terminal dead state (cgroup delegation / StartLimit), and the unrelated network reconfiguration change.

Live reproduction

The failure was reproduced twice on the disposable cluster sakwa-localdns-repro-0710 using the affected Ubuntu 24.04 image AKSUbuntu-2404gen2containerd-202608.06.1. Reproduction targeted the single system-pool node aks-sysnp-14424852-vmss000000 with LocalDNS enabled.

Healthy baseline:

ActiveState=active
SubState=running
Result=success
NRestarts=0
nameserver 169.254.10.10

Fault injection:

for i in 1 2 3 4 5 6 7 8 9 10; do
  main=$(systemctl show -p MainPID --value localdns.service)
  if [ "$main" -gt 0 ]; then
    kill -9 "$main" || true
  fi
  sleep 0.25
done

Observed failure:

Failed to kill control group /localdns.slice/localdns.service, ignoring: Invalid argument
Start request repeated too quickly
Failed to start localdns.service
ActiveState=failed
SubState=failed
Result=signal

The first run reached NRestarts=13; the second reached NRestarts=6. Each run was recovered with systemctl reset-failed localdns.service followed by systemctl restart localdns.service.

Live validation

The fix was validated on a real LocalDNS-enabled AKS node rather than only in unit tests, because the failure it addresses is a runtime systemd-ordering behavior that static checks cannot exercise. A fresh cluster was provisioned in southcentralus with a LocalDNS nodepool (Standard_D4s_v3, the >= 4 vCPU minimum LocalDNS requires), giving a node in the exact vulnerable configuration: localdns.service active, the network drop-in 70-localdns.conf present, and the link resolver pointing at the node listener 169.254.10.10. The node's shipped localdns.service has no ExecStopPost, so it is the unfixed baseline.

The test is an A/B on the same node, driven through az vmss run-command. Each phase disables auto-restart with a transient Restart=no drop-in, SIGKILLs the supervisor's MainPID, waits for the unit to reach a terminal ActiveState, and then inspects whether node DNS was reverted. SIGKILL is the important detail: it is not trappable, so the script's own cleanup traps never run — the only thing that can revert DNS on this path is ExecStopPost, which systemd invokes after a process exits unexpectedly.

Phase A — shipped unit, no ExecStopPost. After the supervisor is killed the unit goes failed, and the node is left blackholed: the drop-in is still present and the link still resolves through 169.254.10.10, which is no longer answered. This reproduces the reported incident on demand.

Phase B — fixed localdns.service + localdns.sh swapped in on the same node. After the identical kill, the drop-in is gone and the listener is no longer in the link's DNS. The journal shows ExecStopPost doing the work on the unexpected-exit path — removing the localdns iptables rules, removing the network drop-in, and reloading networkd — and the unit restarts cleanly afterward.

Result

Phase ExecStopPost State after SIGKILL 70-localdns.conf Link DNS 169.254.10.10
A (shipped) absent failed still present still configured — node blackholed
B (this PR) present failed removed removed — DNS reverted to upstream

Phase B journal, unexpected-exit path:

localdns.sh[10004]: Successfully removed existing localdns iptables rule from OUTPUT chain (rule 4).
localdns.sh[10004]: Successfully removed existing localdns iptables rule from PREROUTING chain (rule 1).
localdns.sh[10004]: Removing network drop-in file /run/systemd/network/10-netplan-eth0.network.d/70-localdns.conf.
localdns.sh[10004]: Successfully removed network drop-in file.
localdns.sh[10004]: Reloading network configuration succeeded.

The distinction the PR turns on is that the shell traps and ExecStop are irrelevant on SIGKILL — systemd skips them and goes straight to ExecStopPost — so ExecStopPost is the only hook that can undo the node DNS redirection when the supervisor dies uncooperatively. Phase A shows the node stays pointed at the dead listener without it; Phase B shows it is reverted with it. The node was restored to the shipped artifacts and left healthy after the run.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Windows Unit Test Results

  3 files   14 suites   50s ⏱️
415 tests 415 ✅ 0 💤 0 ❌
418 runs  418 ✅ 0 💤 0 ❌

Results for commit ea38772.

♻️ This comment has been updated with latest results.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Removes unnecessary LocalDNS cgroup delegation to prevent teardown failures after unclean supervisor termination.

Changes:

  • Sets Delegate=no for localdns.service.
  • Adds VHD-content validation for the setting.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
parts/linux/cloud-init/artifacts/localdns-delegate.conf Disables cgroup delegation.
vhdbuilder/packer/test/linux-vhd-content-test.sh Verifies delegation remains disabled.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Copilot AI review requested due to automatic review settings September 9, 2026 16:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The E2E validation can falsely detect recovery and does not substantiate the claimed rapid-failure or dual-bootstrap coverage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

e2e/scenario_localdns_hosts_test.go:106

  • 🟡 Medium Risk — 🧪 Test Coverage: The PR description says this validation rejects cgroup teardown errors, but this branch only prints a warning and still passes. Either fail the scenario here so that guarantee is enforced, or update the stated validation scope if this warning is intentionally allowed until the separate cgroup fix.
if sudo journalctl -u localdns.service --since "@$test_start" --no-pager | grep -q 'Failed to kill control group'; then
    echo "WARNING: LocalDNS cgroup teardown warning observed"
fi

e2e/scenario_localdns_hosts_test.go:90

  • 🟡 Medium Risk — 🔧 Script Logic: Immediately after kill, systemd may still expose the pre-kill active/running state, so this can declare recovery without observing a restart and then target the stale PID on the next iteration. Capture the old PID and only accept active/running after MainPID changes to a new nonzero value.
        state=$(sudo systemctl show localdns.service -p ActiveState -p SubState --value)
        if [ "$state" = $'active\nrunning' ]; then
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread e2e/scenario_localdns_hosts_test.go Outdated
Comment thread e2e/scenario_localdns_hosts.go
Copilot AI review requested due to automatic review settings September 9, 2026 16:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

E2E validation has asynchronous cleanup races and does not currently cover both claimed bootstrap paths.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

e2e/scenario_localdns_hosts_test.go:57

  • 🟡 Medium Risk — 🧪 Test Coverage: This validator does not provide the claimed coverage of both bootstrap paths. RunScenario enables Runtime.EnableScriptlessNBCCSECmd by default for these three scriptless-capable images (e2e/test_helpers.go:98-102), and the repository has no E2E pipeline setting DISABLE_SCRIPTLESS, so these cases exercise aks-node-controller only. Add a dedicated legacy-mode run that actually disables scriptless provisioning, or revise the stated validation scope.
					Validator: func(ctx context.Context, s *Scenario) error {
						if tt.name == "Ubuntu2204" || tt.name == "Ubuntu2404" || tt.name == "AzureLinuxV3" {
							return validateLocalDNSLifecycle(ctx, s)

e2e/scenario_localdns_hosts_test.go:165

  • 🟡 Medium Risk — 🔧 Script Logic: This is described as best-effort but exits immediately if the old DNS value is still visible. localdns.sh:1047-1052 explicitly documents that networkctl reload updates resolved asynchronously and that shutdown does not wait, so a correct cleanup can transiently retain 169.254.10.10 here and make the E2E flaky. Poll boundedly for propagation before failing.
if command -v resolvectl >/dev/null 2>&1; then
    if resolvectl status 2>/dev/null | grep -q '169\.254\.10\.10'; then
        echo "FAIL: link DNS still points at 169.254.10.10 after localdns died"
        exit 1
    fi
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread e2e/scenario_localdns_hosts_test.go Outdated
Copilot AI review requested due to automatic review settings September 9, 2026 16:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The production-critical DNS lifecycle change still requires a successful cloud E2E run for the current commit.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI review requested due to automatic review settings September 9, 2026 17:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The terminal recovery restores host DNS but leaves ClusterFirst pods dependent on the dead 169.254.10.11 listener reported in the linked incident.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread parts/linux/cloud-init/artifacts/localdns.sh
Copilot AI review requested due to automatic review settings September 9, 2026 17:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The live DNS assertion can falsely pass when its resolver probe fails.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

e2e/scenario_localdns_hosts_test.go:172

  • 🟡 Medium Risk — 🧪 Test Coverage: Both resolver probes convert command failure into an empty string, and the following negative grep then treats that empty result as proof that DNS was reverted. If resolvectl/D-Bus is temporarily unavailable during the reload (or the fallback file cannot be read), this phase passes without validating the live resolver state. Retry when the probe itself fails and only test output from a successful probe.
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI review requested due to automatic review settings September 9, 2026 17:51
@saewoni
Saewon Kwak (saewoni) force-pushed the fix/localdns-cgroup-teardown branch from c2e15d3 to 96adef0 Compare September 9, 2026 17:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The terminal-state E2E can falsely pass when resolver-state retrieval fails.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread e2e/scenario_localdns_hosts.go Outdated
@saewoni

Copy link
Copy Markdown
Contributor Author

E2E status: branch-VHD E2E passed; the red Agentbaker E2E is the expected published-VHD case

Because the LocalDNS unit files (localdns.service, localdns.sh) are baked into the VHD, the LocalDNS lifecycle validation only exercises this fix on a VHD built from this branch. There are two E2E runs, and they behave exactly as expected:

Check VHD under test Result
AKS Linux VHD Build - PR check-in gate (e2e Run AgentBaker E2E) VHD built from this branch (has ExecStopPost) pass
Agentbaker E2E currently published VHD (no ExecStopPost) ❌ fail (expected)

The branch-VHD E2E passed — the LocalDNSHostsPlugin/{Ubuntu2204,Ubuntu2404,AzureLinuxV3} lifecycle validation, including the terminal dead-service SIGKILL case, ran on nodes provisioned from branch-built VHDs and succeeded.

The red Agentbaker E2E ran against the published VHD, which does not yet have ExecStopPost baked in, so the new lifecycle assertion correctly fails there with FAIL: 70-localdns.conf still present after localdns died — i.e. it reproduces the very blackhole this PR fixes on the unpatched image. Its 4th failure, Ubuntu2204_ArtifactStreaming_TrustedLaunch (a waagent iptables ... CHAIN_ZERO failed ExtHandler error), is unrelated to this change.

This matches the live-node A/B validation in the PR description: without ExecStopPost the node stays pointed at the dead 169.254.10.10 listener; with it, ExecStopPost removes 70-localdns.conf and reverts DNS on the untrappable SIGKILL path.

Copilot AI review requested due to automatic review settings September 9, 2026 19:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The E2E validation can exit without restoring its service override and does not correctly retry transient resolver-read failures.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

e2e/scenario_localdns_hosts.go:170

  • 🟡 Medium Risk — 🔧 Script Logic: Because this script starts with set -e, a transient failure from either resolver read terminates the entire validation at the assignment instead of retrying as the comment promises. This can make the eventual-consistency check flaky and also bypass the restoration below. Convert read failures to an empty snapshot; the existing non-empty guard will then retry safely.
    if command -v resolvectl >/dev/null 2>&1; then
        current_dns=$(resolvectl status 2>/dev/null)
    else
        current_dns=$(cat /run/systemd/resolve/resolv.conf 2>/dev/null)
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread e2e/scenario_localdns_hosts.go Outdated
@aks-node-assistant

Copy link
Copy Markdown
Contributor

Failed gate run

Detective summary

Two tests failed: AzureLinuxV3_KubeletCustomConfig did not validate bootstrap token credential before kubelet startup, and Ubuntu2604Minimal_CSE_FullInstallPerformance exceeded the ubuntuSnapshotUpdate 2s threshold at 2.321836544s.

Likely cause

Signatures: e2e-azurelinuxv3-kubeletcustomconfig-bootstrap-token-not-validated and cse-ubuntusnapshot-update-duration-threshold. Classification: mixed E2E bootstrap validation plus known CSE timing-threshold flake. The CSE threshold row is already under repair #39622938; the bootstrap validation failure is tracked as a new noisy active item.

Recommended owner/action

Node Lifecycle/E2E owner: continue CSE threshold work under #39622938 and inspect whether bootstrap-token validation is a transient STLS/bootstrap timing issue before treating it as PR-caused.

Strongest alternative

PR-change-caused LocalDNS regression is the strongest alternative because PR 9360 modifies LocalDNS service/script behavior; it is less likely because neither primary failure is a LocalDNS functional assertion and the CSE timing threshold is a known recurring signature.

Evidence

  • Timeline: Run AgentBaker E2E failed, log 662.
  • Test results: AzureLinuxV3_KubeletCustomConfig expected bootstrap token credential validation true but got false; Ubuntu2604Minimal_CSE_FullInstallPerformance took 2.321836544s over threshold.
  • Log: DONE 194 scenarios: 122 passed, 0 flaky, 70 skipped, 2 failed with both failures listed.
  • Build metadata: PR 9360, source branch refs/pull/9360/merge.

Wiki signature

e2e-azurelinuxv3-kubeletcustomconfig-bootstrap-token-not-validated and cse-ubuntusnapshot-update-duration-threshold

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.
Register an EXIT cleanup handler before installing the temporary Restart=no
systemd drop-in used by the terminal dead-service validation. The handler
removes the override, reloads systemd, resets the failed unit, restarts
LocalDNS, and verifies the service is active. Preserve the original test
status and report cleanup failures instead of masking them, so a failed
assertion cannot contaminate the node or subsequent validation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The resolver polling loop exits on transient read errors instead of retrying them.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +200 to +204
if command -v resolvectl >/dev/null 2>&1; then
current_dns=$(resolvectl status 2>/dev/null)
else
current_dns=$(cat /run/systemd/resolve/resolv.conf 2>/dev/null)
fi
@saewoni

Copy link
Copy Markdown
Contributor Author

Ye Wang [msft] (@yewmsft) could you please re-review PR #9360 after the latest updates?

The PR has been rebased onto the latest main and now focuses explicitly on node-level DNS restoration after an unexpected LocalDNS exit. The pod-level recovery work is being handled separately in PR #9439.

Since your earlier review, we have:

  • Added aggregate cleanup behavior so an iptables cleanup failure no longer skips removal of the LocalDNS network drop-in and network reload.
  • Added ShellSpec coverage for successful cleanup, partial cleanup failure, reload failure, and the cleanup mode exit contract.
  • Hardened the E2E lifecycle test with a genuinely-new MainPID check, terminal ActiveState polling, DNS-revert settling, and an EXIT cleanup trap for the temporary Restart=no test drop-in.
  • Rebased the branch onto current main.
  • Reproduced the original node-DNS blackhole on a live LocalDNS-enabled node with the shipped unit, then verified that the PR changes remove 70-localdns.conf and restore node DNS after SIGKILL.
  • Confirmed the branch-VHD cloud E2E passed for the LocalDNS lifecycle scenarios.

The remaining scope clarification is intentional: #9360 restores the node resolver (169.254.10.10) and does not claim to restore the pod resolver (169.254.10.11). The pod-level service-recovery follow-up is PR #9439.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The E2E resolver poll exits on transient read errors instead of retrying as intended.

Review details

Suppressed comments (1)

e2e/scenario_localdns_hosts.go:204

  • 🟡 Medium Risk — 🔧 Script Logic: With set -e, a transient nonzero exit from resolvectl status or the fallback cat terminates the validation at this assignment, so the surrounding 12-attempt poll never retries errored reads as intended. Handle the read failure inside the loop and leave current_dns empty so the next iteration can retry.
    if command -v resolvectl >/dev/null 2>&1; then
        current_dns=$(resolvectl status 2>/dev/null)
    else
        current_dns=$(cat /run/systemd/resolve/resolv.conf 2>/dev/null)
    fi
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants