Skip to content

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

Merged
Saewon Kwak (saewoni) merged 19 commits into
mainfrom
fix/localdns-cgroup-teardown
Sep 15, 2026
Merged

Saewon Kwak (saewoni) merged 19 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.
  • Keep cleanup mode separate from normal process cleanup; genuine startup/error paths retain their existing failure status. Systemd manages process termination, while ExecStopPost handles node-DNS restoration after unexpected exits.
  • 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 original failure was historically reproduced twice on the disposable cluster sakwa-localdns-repro-0710 using the affected Ubuntu 24.04 image AKSUbuntu-2404gen2containerd-202608.06.1. That cluster is no longer retained. The historical 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.

Validation summary

Test Environment Result
Historical reproduction sakwa-localdns-repro-0710, shipped Ubuntu 24.04 image Reproduced the node-DNS blackhole after LocalDNS SIGKILL and restart-limit exhaustion
Live A/B validation Fresh LocalDNS-enabled Standard_D4s_v3 node in southcentralus Shipped artifacts reproduced the failure; PR artifacts removed 70-localdns.conf and restored node DNS
ShellSpec cleanup tests localdns.sh cleanup suite 12 cleanup examples passed
E2E package compilation go test ./... -run '^$' -count=1 in e2e Passed
Branch-VHD cloud E2E Branch-built VHDs from the earlier branch state LocalDNS lifecycle scenarios passed for that run; a fresh run is required after subsequent test/code hardening

The live A/B test manually replaced the LocalDNS artifacts on a running node. The earlier branch-VHD E2E validated artifacts baked from the branch at that time; it is a separate validation layer and must be rerun after subsequent code/test hardening. The lifecycle validator is attached to the existing LocalDNS hosts-plugin scenarios for Ubuntu 22.04, Ubuntu 24.04, and Azure Linux V3.

Live validation

The core ExecStopPost behavior was tested on a fresh LocalDNS-enabled AKS node in southcentralus (Standard_D4s_v3, Ubuntu 24.04) through az vmss run-command. The live A/B used the cleanup implementation available at that time. Subsequent cleanup and E2E hardening changes are covered by ShellSpec, syntax checks, and E2E compilation; a fresh branch-VHD cloud E2E is still required for the current head. The test used an A/B on the same node: shipped artifacts first, then the PR artifacts. Each phase installed a temporary runtime Restart=no drop-in, read the service MainPID, sent SIGKILL, waited for a terminal ActiveState, and checked the node resolver state. The test always restored the original files, removed the temporary drop-in, reloaded systemd, reset the failed state, and restarted LocalDNS before exiting.

SIGKILL is intentional: it cannot be caught by Bash, so the normal shell traps do not run. The test therefore exercises the systemd ExecStopPost path directly. The cleanup mode restores node DNS only; it intentionally does not delete the dummy interface or claim to recover the LocalDNS process/cgroup.

Phase A — shipped artifacts

ExecStopPost: absent
localdns.service after SIGKILL: failed
70-localdns.conf: still present
node DNS: still points at 169.254.10.10

This reproduced the node-level DNS blackhole from the reported failure.

Phase B — PR #9360 artifacts

ExecStopPost: present
localdns.service after SIGKILL: failed
70-localdns.conf: removed
169.254.10.10: removed from active node DNS
getent hosts mcr.microsoft.com: succeeded

The journal confirmed the cleanup path ran on the live test node:

Removing network drop-in file .../70-localdns.conf.
Successfully removed network drop-in file.
Reloading network configuration succeeded.

Normal stop/start was also verified:

stop_state=inactive
result=success
restart_state=active

Result

Phase ExecStopPost State after SIGKILL 70-localdns.conf Node DNS
Shipped artifacts absent failed still present still points to .10; DNS blackhole
PR #9360 artifacts present failed removed .10 removed; getent succeeds

Meaning for AKS#5930

This live test proves the node-level part of the incident: after an untrappable LocalDNS SIGKILL, PR #9360 removes the stale node DNS configuration and restores host-level DNS even when the service remains failed.

It does not prove pod-DNS recovery. Pods use the separate cluster listener 169.254.10.11, which is baked into existing pods' /etc/resolv.conf. Recovery of that listener is handled by the stacked follow-up work in PR #9439. Therefore, PR #9360 should claim node-level DNS restoration, not complete closure of every pod-DNS failure mode in AKS#5930.

The node was restored to the shipped artifacts and left healthy after the test. The current branch passes the cleanup-focused ShellSpec coverage and E2E compilation checks. A fresh branch-VHD cloud E2E remains required to validate the latest baked artifacts.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Windows Unit Test Results

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

Results for commit c57ca09.

♻️ 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.

@saewoni

Copy link
Copy Markdown
Contributor Author

Ye comment follow-up: the lifecycle validator now distinguishes empty or unreadable resolver state from resolver state that still contains 169.254.10.10. After successful restoration, it also positively verifies getent hosts mcr.microsoft.com so an empty or broken resolver cannot pass. This diagnostic/test update is in signed commit fdbafed.

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 cleanup can leave LocalDNS start-limited and failed when an earlier assertion fails.

Review details

Suppressed comments (1)

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

e2e/scenario/scenario_localdns_hosts.go:80

  • 🟡 Medium Risk — 🔧 Script Logic: reset-failed only runs when the temporary override already exists. If an earlier assertion or one of the restart-loop attempts fails after LocalDNS reaches StartLimit, the EXIT trap skips the reset and the later systemctl start remains rate-limited, leaving LocalDNS failed on the test node. Reset the unit unconditionally before checking/restarting it.
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

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

Unbounded iptables locking can prevent the post-stop hook from reaching the DNS restoration steps.

Get a fresh assessment by requesting another Copilot review.

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

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

It changes production DNS lifecycle behavior, and current-head branch-VHD cloud validation remains pending.

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

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

It changes production node DNS recovery, and current-head branch-VHD cloud validation remains outstanding.

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

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 lifecycle validator can exhaust systemd’s start limit and fail to restore LocalDNS on early failures.

Review details

Suppressed comments (2)

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

e2e/scenario/scenario_localdns_hosts.go:80

  • 🟡 Medium Risk — 🔧 Script Logic: reset-failed only runs after the temporary override exists. If the earlier SIGKILL/restart checks hit StartLimit or otherwise leave LocalDNS failed, the EXIT trap skips the reset, systemctl start remains rate-limited, and this validator leaves the scenario node without LocalDNS. Reset the unit unconditionally before attempting restoration.
    e2e/scenario/scenario_localdns_hosts.go:119
  • 🟡 Medium Risk — 🧪 Test Coverage: Reset the systemd start-rate counter immediately before this crash sequence. The hosts-plugin validator cold-starts LocalDNS, and this validator then performs a restart plus a stop/start before requesting three automatic recoveries; with the repository-documented default StartLimitBurst of 5 starts per 10 seconds (parts/linux/cloud-init/artifacts/cse_config.sh:1520-1522), a fast run can deny the third recovery and fail for test-generated rate limiting rather than the behavior under test.
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@aks-node-assistant

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective

  • Run: https://msazure.visualstudio.com/CloudNativeCompute/_build/results?buildId=181025322
  • Failed job/stage/task: e2e stage / Run AgentBaker E2E / failed test run 628765256.
  • TL;DR: LocalDNSHostsPlugin/ACL failed the cold-start empty-hosts validation: after localdns restarted and reported ready, dig mcr.microsoft.com @169.254.10.10 did not return a valid A record and CoreDNS logged SERVFAIL.
  • Likely cause/signature: e2e-localdns-cold-start-empty-hosts-servfail; recurring LocalDNS/CoreDNS cold-start/fallthrough regression, likely related to this PR's LocalDNS lifecycle changes.
  • Classification: E2E/test - LocalDNS/CoreDNS regression / likely PR-change-caused.
  • Confidence: High.
  • Recommended owner/action: PR owner should validate LocalDNS hosts-plugin cold-start and DNS restoration behavior for ACL and restore reliable fallthrough before merge.
  • Strongest alternative: transient DNS/network failure, but the same signature has now repeated across independent builds/scenarios with localdns ready and CoreDNS SERVFAIL evidence, so one-off infra is less likely.
  • Evidence: failed test run 628765256, Run AgentBaker E2E log 662, scenario logs for LocalDNSHostsPlugin/ACL, build metadata/PR changes, and existing wiki recurrence.
  • Wiki signature: e2e-localdns-cold-start-empty-hosts-servfail

@yewmsft Ye Wang (yewmsft) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

went through all 20 of my threads against dd3a634f. everything is addressed except the one inline note below on NRestarts; two smaller things after that.

what I actually verified rather than read:

  • ran localdns.sh cleanup as a fresh root process with DEFAULT_ROUTE_INTERFACE, NETWORK_DROPIN_FILE and NETWORK_DROPIN_DIR all unset — it removes /run/systemd/network/*.d/70-localdns.conf by glob and exits 0. and NETWORK_DROPIN_DIR is always derived under /run/systemd/network/ (localdns.sh:399), so the glob is a complete sweep, not a best-effort one. this was my main concern and it's properly fixed.
  • with iptables -D forced to fail, the drop-in is still removed, the reload still runs, and the function returns 1. moving DNS restore ahead of iptables is a good call — I didn't ask for that and it's the right ordering.
  • the gate move is confirmed by CI, not just by inspection: on the standalone run (main's published VHD) LocalDNSHostsPlugin/AzureLinuxV3 and /Ubuntu2404 pass, and on the branch-VHD run all three lifecycle scenarios pass. that's exactly the split we wanted.

on the red checks — not yours. both E2E failures are check localdns cold start behaviour, with mcr.microsoft.com coming back SERVFAIL from CoreDNS right after cold start. that validator lives in ValidateCommonLinux, which runs before Config.Validator, so the lifecycle code can't be the cause — and it hit Ubuntu2204 on one run and ACL on the other, where ACL doesn't run the lifecycle validator at all. looks like an existing upstream-resolution flake.

nit, not blocking: eval "$NETWORKCTL_RELOAD_CMD" survived into the rewritten block at localdns.sh:689. it's a constant so there's no injection, but everything around it is new code now — worth making it an array while you're in there.

Comment thread e2e/scenario/scenario_localdns_hosts.go Resolved

@yewmsft Ye Wang (yewmsft) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

took another pass over the whole change rather than thread-by-thread. the fix is correct and the mechanism holds — a few notes on what I checked, plus one new finding in the test harness and a correction to my own suggestion from earlier.

the core fix is sound, and one detail makes it work that's worth naming so nobody "tidies" it later: the cleanup dispatch at localdns.sh:1020 sits above the trap installation at :1081/:1084. that's why cleanup mode never installs the EXIT trap, never reaches cleanup_localdns_configs, and so genuinely leaves the dummy interface up the way the comment promises. move that dispatch below the traps and the documented behaviour silently inverts. I ran localdns.sh cleanup as root with no env at all and confirmed the output is drop-in removal + reload + iptables only, no interface teardown.

also confirmed the glob is a complete sweep rather than a best-effort one: NETWORK_DROPIN_DIR is only ever derived as /run/systemd/network/<file>.d (:399), and /etc/localdns/environment — the unit's only EnvironmentFile — carries just the corefile/hosts/FQDN keys, so nothing can relocate the drop-in out from under the pattern. and cleanup_iptables_and_dns || echo suspends set -e for the whole function body, so a mid-cleanup failure can't abort it; every variable it touches is either a top-level constant or :--guarded, so set -u is satisfied too.

on the restart-cycle guarantee: each crash's ExecStopPost reverts node DNS, each ExecStart re-adds it, and when StartLimit finally trips the last ExecStopPost has already run — so the terminal state is upstream DNS, not a blackhole. that's the thing the PR set out to do and it does it.

one inconsistency in the rationale, not a bug: cleanup mode keeps the dummy interface so an orphaned CoreDNS can keep serving pods, but it still removes the localdns: skip conntrack rules that orphan depends on. DNS keeps working, it just goes through conntrack — which is the thing localdns exists to avoid. worth a clause in the comment so the reasoning doesn't read as stronger than it is.

Comment thread e2e/scenario/scenario_localdns_hosts.go Resolved

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 trap can leave LocalDNS failed when an early restart assertion reaches StartLimit.

Review details

Suppressed comments (1)

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

e2e/scenario/scenario_localdns_hosts.go:80

  • 🟡 Medium Risk — E2E cleanup: reset-failed only runs after the temporary override exists. If one of the earlier restart/kill assertions trips systemd's StartLimit, the EXIT trap skips this branch and the subsequent systemctl start can be rejected, leaving LocalDNS failed on the shared scenario node. Reset the unit's failure state unconditionally before attempting restoration.
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@github-actions

Copy link
Copy Markdown
Contributor

Changes cached containers or packages on windows VHDs

Please get a Windows SIG member to approve.

The following dif file shows any additions or deletions from what will be cached on windows VHDs organised by VHD type.

  • Additions are new things cached.
  • Deletions are things no longer cached.
diff --git a/vhd_files/2025-gen2-tl.txt b/vhd_files/2025-gen2-tl.txt
index 9e68675..d494597 100644
--- a/vhd_files/2025-gen2-tl.txt
+++ b/vhd_files/2025-gen2-tl.txt
@@ -15 +15 @@ c:\akse-cache\csi-proxy\: https://packages.aks.azure.com/csi-proxy/v1.1.2-hotfix
-c:\akse-cache\wcn\: mcr.microsoft.com/wcn/package:1.7.1-cpu-arch
+c:\akse-cache\wcn\: mcr.microsoft.com/wcn/package:1.8.0-cpu-arch
diff --git a/vhd_files/2025-gen2.txt b/vhd_files/2025-gen2.txt
index bfacb1a..00f0445 100644
--- a/vhd_files/2025-gen2.txt
+++ b/vhd_files/2025-gen2.txt
@@ -15 +15 @@ c:\akse-cache\csi-proxy\: https://packages.aks.azure.com/csi-proxy/v1.1.2-hotfix
-c:\akse-cache\wcn\: mcr.microsoft.com/wcn/package:1.7.1-cpu-arch
+c:\akse-cache\wcn\: mcr.microsoft.com/wcn/package:1.8.0-cpu-arch
diff --git a/vhd_files/2025.txt b/vhd_files/2025.txt
index dab08f5..29ac686 100644
--- a/vhd_files/2025.txt
+++ b/vhd_files/2025.txt
@@ -15 +15 @@ c:\akse-cache\csi-proxy\: https://packages.aks.azure.com/csi-proxy/v1.1.2-hotfix
-c:\akse-cache\wcn\: mcr.microsoft.com/wcn/package:1.7.1-cpu-arch
+c:\akse-cache\wcn\: mcr.microsoft.com/wcn/package:1.8.0-cpu-arch

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Health
staging_cse_windows Package 1 53% ❌
debug 0% ❌
provisioningscripts 2% ❌
parts_windows Package 1 76% ➖
vhdbuilder_packer_windows Package 1 20% ❌
Summary 38% (1294 / 6021) ❌

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Health
github.com/Azure/agentbaker/aks-node-controller 72% ➖
github.com/Azure/agentbaker/aks-node-controller/common 100% ✔
github.com/Azure/agentbaker/aks-node-controller/helpers 71% ➖
github.com/Azure/agentbaker/aks-node-controller/parser 89% ✔
github.com/Azure/agentbaker/aks-node-controller/pkg/gpu 100% ✔
github.com/Azure/agentbaker/aks-node-controller/pkg/nodeconfigutils 66% ➖
github.com/Azure/agentbaker/aks-node-controller/utils 0% ❌
github.com/Azure/agentbaker/apiserver 0% ❌
github.com/Azure/agentbaker/cmd 0% ❌
github.com/Azure/agentbaker/cmd/starter 0% ❌
github.com/Azure/agentbaker/fuzz/api 0% ❌
github.com/Azure/agentbaker/hotfix/render-nodecustomdata 0% ❌
github.com/Azure/agentbaker/pkg/agent 72% ➖
github.com/Azure/agentbaker/pkg/agent/datamodel 76% ➖
github.com/Azure/agentbaker/pkg/agent/toggles 0% ❌
github.com/Azure/agentbaker/pkg/vhdbuilder/datamodel 88% ✔
Summary 72% (6050 / 8419) ➖

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 lifecycle validator silently passes when the VHD lacks the cleanup hook it is intended to verify.

Review details

Suppressed comments (1)

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

e2e/scenario/scenario_localdns_hosts.go:253

  • 🟡 Medium Risk — 🧪 Test Coverage: This fallback makes the lifecycle validator pass when the branch VHD is missing the ExecStopPost hook, so a packaging regression can silently skip the behavior this PR claims to validate. The three gated branch-VHD scenarios should fail when the hook is absent; compatibility testing for older VHDs should be separated from this regression check.
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Health
shellspec spec 20% ❌
Summary 20% (2760 / 13758) ❌

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

Production-critical DNS behavior in the latest baked artifacts still requires the pending fresh branch-VHD cloud E2E run.

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

@yewmsft Ye Wang (yewmsft) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

approving.

verified yesterday's three follow-ups actually landed in c57ca094, not just in the commit messages:

  • restarts_before dropped entirely; test "$restarts_after" -ge 3 with a diagnostic || block. absolute count, no baseline that can't mean anything.
  • reset-failed hoisted out of the $NORESTART guard, ahead of the is-active/start check.
  • control_group assert now prints on failure instead of dying silently under set -e.

on the change itself: ExecStopPost= is the right hook — it fires on SIGKILL, which is the incident path and where ExecStop= would not run at all. restoring DNS before iptables with a cleanup_failed accumulator is the right priority ordering: an xtables lock can no longer delay removal of the drop-in that points the node at a dead listener. dropping the initialize_network_variables fallback in favour of the /run/systemd/network/*.d/70-localdns.conf glob is correct for the post-exit case where route discovery can fail — i ran localdns.sh cleanup as a fresh root process with DEFAULT_ROUTE_INTERFACE / NETWORK_DROPIN_FILE / NETWORK_DROPIN_DIR all unset and it removed the drop-in, reloaded, and exited 0. and the cleanup dispatch sitting above trap installation is what keeps the dummy interface up, which is right given an orphaned coredns may still be serving pods.

scope: pod DNS on .11 is still dead while localdns is, and that's correct for this PR — #9486 covers it, stacked behind #9439. merge order is 9360 -> 9439 -> 9486. all three touch localdns.service, so expect a rebase between each merge rather than just a retarget.

e2e: 6 of 7 localdns scenarios green. LocalDNSHostsPlugin/AzureLinuxV3 is the cold-start SERVFAIL, which is #9500, not this change. worth noting the new lifecycle validator therefore hasn't run on AzureLinuxV3 yet — it's green on Ubuntu2204 and Ubuntu2404, the other two distros it's gated to.

@saewoni

Copy link
Copy Markdown
Contributor Author

comprehensive test result here: #9503 (comment)

Conclusive Live Validation — All Three Problems Resolved

All three PR problems were reproduced and confirmed fixed on a single fresh, LocalDNS-enabled Ubuntu 22.04 agent node, patched with the latest cherry-picked artifacts from the combined branch.

Setup

PR #9439 — Deterministic terminal failure (previously inconclusive) — NOW CONFIRMED

Fault: sabotaged the coredns binary path and drove repeated restarts to exhaust the budget.

RESTART_ATTEMPT_1..5: ActiveState=active   (recovered each time)
after_6:              ActiveState=failed  Result=start-limit-hit
STARTLIMIT_HIT=true (attempt 6)
FINAL_ACTIVESTATE=failed
FINAL_RESULT=start-limit-hit
STARTLIMIT_OBSERVED=true
DROPIN=removed
HOST_DNS=...mcr.microsoft.com      (host still resolves — no blackhole)
RECOVERED_ACTIVESTATE=active

Result: the bounded budget lands LocalDNS deterministically in failed with Result=start-limit-hit after 5 starts in the window, instead of restarting forever. On that terminal path, #9360's ExecStopPost still removed the drop-in and host DNS kept resolving. Confirmed live.

PR #9471 — Listener/invalid upstream rejection (previously inconclusive live) — NOW CONFIRMED

The on-node branch replace_azurednsip_in_corefile was exercised directly against crafted resolver states:

CASE=node_listener    rc=1 leaked=no  reject -> PASS   (rejects 169.254.10.10)
CASE=cluster_listener rc=1 leaked=no  reject -> PASS   (rejects 169.254.10.11)
CASE=mixed            rc=1 leaked=no  reject -> PASS   (valid + listener rejected)
CASE=listener_first   rc=1 leaked=no  reject -> PASS
CASE=malformed_ipv6   rc=1 leaked=no  reject -> PASS   (rejects '::')
CASE=malformed_cidr   rc=1 leaked=no  reject -> PASS   (rejects '10.0.0.1/24')
CASE=valid_single     rc=0            accept -> PASS   (forwards 10.0.0.1)
CASE=valid_azure      rc=0            accept -> PASS   (skips, Azure DNS)

No LocalDNS listener IP was ever written into the generated corefile in any reject case. Valid upstreams forward correctly. Confirmed live (8/8 cases).

PR #9360 — Node DNS restoration after terminal exit — RE-CONFIRMED

Terminal dead-service test on the same patched node:

PRE_ACTIVE=active,  PRE_HOST_DNS resolves
KILLING=<pid>
DEAD_ACTIVESTATE=failed
DEAD_RESULT=signal
ESP_STATUS=status=0            (ExecStopPost ran successfully)
DROPIN=removed                 (70-localdns.conf removed)
RESOLVER=168.63.129.16         (reverted from 169.254.10.10)
HOST_DNS_AFTER_DEATH resolves  (no blackhole)
RECOVERED_ACTIVE=active

Confirmed live.

Final verdict

PR Problem Live result
#9360 Stale node DNS redirect after terminal LocalDNS exit Fixed — drop-in removed, host DNS reverted, no blackhole
#9439 Restart-forever / non-deterministic failure state Fixed — deterministic failed / start-limit-hit after bounded budget; self-heals moderate bursts
#9471 Invalid/listener upstreams accepted Fixed — all listener/malformed inputs rejected, no leak; valid upstreams forwarded

Based on the latest cherry-picked commits from PRs #9360, #9439, and #9471, all three problems are no longer present on the live cluster's LocalDNS-enabled agent node. The disposable node was deleted after testing.

@saewoni

Copy link
Copy Markdown
Contributor Author

Ye Wang (@yewmsft) — this is implemented in #9486, and all three constraints held up. Point by point, with what was verified on a live node:

The proposal itself. localdns-fallback.service binds 169.254.10.11:53 and forwards to the real kube-dns ClusterIP, wired to OnFailure=localdns-fallback.service. A probe timer was added alongside it for the cases OnFailure= structurally cannot cover — a clean stop that stays inactive, and active-but-not-serving.

Constraint 1 — idempotent interface creation. ensure_cluster_listener_interface() creates the link if absent and adds .11 only if not already present. Both exit paths are exercised in e2e and pass: TEARDOWN: localdns's EXIT trap deleted the dummy interface (.11 off the node entirely) → fallback rebuilt the dummy interface from nothing, and on the crash path CRASH: fallback reused the surviving interface instead of recreating it.

Constraint 2 — stop before ExecStart, not merely Conflicts=. Took the ExecStartPre option. Worth reporting back on the other one you offered: Before= + Conflicts= is a trap. Measured on a live node (systemd 255), starting the fallback resolved that Conflicts= by stopping localdns.service, and a stop resets the restart counter — Scheduled restart job, restart counter is at 1 on every cycle. localdns therefore never spent its 720/5 budget and never reached terminal failed, which would have defeated both the OnFailure= handoff and the stable state NPD is meant to observe. The fallback now carries Before=localdns.service and deliberately no Conflicts=.

Constraint 3 — depends on #9439's numbers. Merged. Live node: localdns reached terminal 'failed' in 56s.

You were right to reject the kubelet repoint. It got built anyway as a complement rather than a replacement, you objected again on #9486, and it has been removed. Of your three original objections, reframing it as a complement answers only the first; restarting kubelet on an already-degraded node and drifting from RP goal state apply either way. It can return as its own PR once someone measures the NotReady window across a kubelet restart against --node-monitor-grace-period (50s on AKS). That number does not exist today.

Two of your assumptions were corrected by measurement rather than quietly worked around, since both changed the design:

  • OnFailure= does not fire only on entry to failed. On systemd 255 it fires on every failed start attempt — Triggering OnFailure= dependencies once per restart cycle while systemctl is-failed still reports activating. Binding .11 on each of those flaps the fallback, since localdns's ExecStartPre tears it down ~RestartSec later. localdns-fallback.sh now re-checks localdns's SubState and no-ops during auto-restart, so only a settled localdns hands over. Verified: fallback never active while localdns was mid restart-cycle.
  • "No new RP plumbing needed" held only for the derived corefile, not the minimal floor. COREDNS_SERVICE_IP is now persisted to /etc/localdns/environment on both the CSE and ANC paths; aks-rp needed its own fix for the scriptless builders, and the AgentBaker e2e harness needed fix(e2e): plumb the cluster's CoreDNS service IP to both provisioning paths #9706 for the same omission. The fallback now takes its upstream from that env value even on the derived path — the corefile's forward target is three transformations downstream of the RP config and gets sed-rewritten at runtime, and localdns may have died because that corefile is bad, which is exactly what the e2e corrupts to induce failure.

Outcome. Live cluster, 6/6 phases, 55 assertions, 0 failures — including the round trip you described: pre-failure pod stays pinned to .11 and keeps resolving, localdns recovers and reclaims the listener, the fallback stands down.

The one claim of yours still unverified is the headline number: "the reported 16 minute blackhole becomes ~43s." The scenario bounds recovery at ≤30s via a wait_for but never times the gap, so that figure remains unmeasured.

And your last line turned into its own PR — the flapping classes that never reach failed are invisible to a unit-state check. NPD now judges by who owns .11:53 rather than by systemctl is-active. On a live node, same instant, same state: master's plugin returns NOTOK (localdns service is not running. State: failed) while the new one returns OK (localdns-fallback serving 169.254.10.11; localdns down). Tracked in ADO PR 17407590, which should land before or with #9486.

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.

5 participants