Skip to content

Combine LocalDNS fixes from PRs 9360, 9439, and 9471 - #9503

Closed
Saewon Kwak (saewoni) wants to merge 30 commits into
mainfrom
combine-agentbaker-9360-9439-9471
Closed

Saewon Kwak (saewoni) wants to merge 30 commits into
mainfrom
combine-agentbaker-9360-9439-9471

Conversation

@saewoni

Copy link
Copy Markdown
Contributor

Summary

Combined changes from AgentBaker PRs #9360, #9439, and #9471 into one review branch.

The branch includes:

  • LocalDNS node DNS restoration after unexpected service exits.
  • Bounded LocalDNS restart behavior.
  • LocalDNS listener and upstream validation.
  • ShellSpec coverage for the combined behavior.
  • The LocalDNS lifecycle E2E scenario.

Validation

  • Go tests passed using the Entra bearer-token GoProxy configuration.
  • LocalDNS ShellSpec: 124 examples, 0 failures.
  • Shell syntax checks passed.
  • git diff --check passed.

E2E

The Azure E2E result is posted as a PR discussion comment. The targeted LocalDNSHostsPlugin/Ubuntu2204 scenario reached the VM and passed the metrics, hosts-plugin, IPv4, IPv6, and cleanup validations, but failed the cold-start upstream fallthrough check when mcr.microsoft.com returned SERVFAIL after LocalDNS restarted with an empty hosts file.

…affic

disable_dhcp_use_clusterlistener() runs 'networkctl reload' and returns
immediately, but the reload is asynchronous. networkd then re-configures the
default route interface, dropping and re-acquiring its DHCP lease, which
briefly removes the route to the upstream DNS servers. localdns is already
listening and resolv.conf already points at it by then, so any query landing in
that gap is forwarded onto an unreachable network.

localdns.service is Type=notify and systemd-notify --ready is sent after
disable_dhcp_use_clusterlistener returns, so 'systemctl start localdns'
unblocks while networkd is still mid re-configure - which is how the very first
DNS query on a freshly provisioned node can come back SERVFAIL.

The setup path already had this asymmetry with the cleanup path, which does
wait (wait_for_localdns_removed_from_resolv_conf). Add the missing wait:
poll until the upstream servers are routable again, and only accept that as
convergence once we have positively observed the re-configure - either the
route disappearing, or resolv.conf dropping the DHCP-provided servers in favor
of the localdns listener. Where neither signal is observable it falls back to
the upstreams staying routable across consecutive samples. The wait is best
effort and bounded at 10s; on timeout localdns logs and continues rather than
failing the unit.

Note that reordering (pointing DNS at localdns before the ready gate) does not
work: it aims node DNS at a listener that is not up yet, and leaves the route
outage unguarded either way.

Also dump egress state (networkd journal, ip route, ip -br addr) in the e2e
cold-start diagnostics, so a future failure captures the routing state instead
of only the localdns unit.
@github-actions

Copy link
Copy Markdown
Contributor

PR Title Lint Failed ❌

Current Title: Combine LocalDNS fixes from PRs 9360, 9439, and 9471

Your PR title doesn't follow the expected format. Please update your PR title to follow one of these patterns:

Conventional Commits Format:

  • feat: add new feature - for new features
  • fix: resolve bug in component - for bug fixes
  • docs: update README - for documentation changes
  • refactor: improve code structure - for refactoring
  • test: add unit tests - for test additions
  • chore: remove dead code - for maintenance tasks
  • chore(deps): update dependencies - for updating dependencies
  • ci: update build pipeline - for CI/CD changes

Guidelines:

  • Use lowercase for the type and description
  • Keep the description concise but descriptive
  • Use imperative mood (e.g., "add" not "adds" or "added")
  • Don't end with a period

Examples:

  • ✅ feat(windows): add secure TLS bootstrapping for Windows nodes
  • ✅ fix: resolve kubelet certificate rotation issue
  • ✅ docs: update installation guide
  • ❌ Added new feature
  • ❌ Fix bug.
  • ❌ Update docs

Please update your PR title and the lint check will run again automatically.

@saewoni

Copy link
Copy Markdown
Contributor Author

Combined PR E2E Result

Scope

This draft PR combines AgentBaker PRs #9360, #9439, and #9471.

Local Validation

  • Go tests: passed using the Entra bearer-token GoProxy configuration.
  • LocalDNS ShellSpec: 124 examples, 0 failures.
  • Shell syntax checks: passed.
  • git diff --check: passed.

Azure E2E

Executed with DISABLE_SCRIPTLESS=true so the combined LocalDNS artifacts were exercised directly.

Scenario:

LocalDNSHostsPlugin/Ubuntu2204

Result:

FAILED
DONE 1 scenarios: 0 passed, 0 flaky, 0 skipped, 1 failed

Failure:

check localdns cold start behaviour: expected exit code 0, got 1

After LocalDNS was stopped and restarted with an empty hosts file, the test queried mcr.microsoft.com through 169.254.10.10. The query returned SERVFAIL with no address:

Critical FQDN (mcr.microsoft.com): ''
ERROR: Critical FQDN did not return a valid IP after cold start

The same scenario passed:

  • LocalDNS metrics exporter validation.
  • Forward DNS metrics validation.
  • LocalDNS hosts-plugin node annotation.
  • Hosts-file IPv4 resolution.
  • Hosts-file IPv6 resolution.
  • LocalDNS service restart and cleanup mechanics.

The VM logs showed the LocalDNS service active and healthy, with the DNS drop-in removed and recreated correctly. The failure is isolated to upstream DNS fallthrough immediately after a cold restart with an empty hosts file.

Logs:

/home/sakwa/worktrees/agentbaker-combined/e2e/scenario-logs/LocalDNSHostsPlugin/Ubuntu2204

@github-actions

github-actions Bot commented Sep 15, 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 6cec1d5.

♻️ This comment has been updated with latest results.

Saewon Kwak (saewoni) and others added 25 commits September 15, 2026 15:54
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.
ExecStopPost can run after network state has already been torn down, so
re-deriving the default route and network file can fail before the known
70-localdns.conf drop-in is removed. Sweep the known runtime drop-in path
without depending on ip route or networkctl status, continue network reload,
and retain aggregate failure reporting.

Keep the dummy localdns interface and .10/.11 addresses during this
node-level cleanup: an orphaned CoreDNS child may still be serving pods,
and deleting the interface would turn a fast failure into default-route DNS
blackholing. Service recovery handles the next-start interface lifecycle.

Update ShellSpec coverage for cleanup without network-variable discovery.
Co-authored-by: saewoni <23280628+saewoni@users.noreply.github.com>
- Avoid route/interface discovery during ExecStopPost; sweep the known
  network drop-in directly so cleanup still restores node DNS after network
  state has been torn down.
- Make SIGTERM cleanup interruptible by waiting on sleep in a child process.
- Document that cleanup mode intentionally leaves the dummy interface and
  listener addresses for service recovery/orphaned-process safety, and make
  cleanup failure logs actionable.
- Skip lifecycle assertions on VHDs without the ExecStopPost hook, add a
  positive node DNS resolution check, retry resolver reads under set -e, and
  install the E2E restoration trap before the first service mutation.
- Clarify that the normal restart loop tests service recovery while the
  terminal block tests ExecStopPost.
The cleanup discovery-failure ShellSpec case intentionally exercises
successful iptables cleanup while network-variable discovery would fail.
Assert its successful rule-removal and network-reload output so ShellSpec
reports no unexpected output warnings in CI.
…cally

Follow-up to the node-level DNS restoration in the LocalDNS teardown PR
(#9360). 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), so it does not recover pod DNS.

This change tunes the systemd restart policy so LocalDNS failures reach a
deterministic terminal state instead of either wedging immediately or
retrying forever. It puts a floor under failure modes we have not
enumerated; it does not itself add the pod-facing recovery path.

- RestartSec=2 is the load-bearing change. cleanup_iptables_and_dns runs
  'networkctl reload' and the dummy interface carrying .10/.11 is deleted
  and recreated on every start; at sub-second retries each attempt
  re-enters the transient it is retrying against. 2s lets networkd settle
  and an orphaned coredns release its sockets before the next ExecStart.
- StartLimitIntervalSec=720 / StartLimitBurst=5 set the threshold to 144s,
  just above the slowest restart cycle (~125s), so every unrecoverable
  failure mode lands in 'failed' rather than restarting forever. That
  terminal state is the prerequisite for an OnFailure= .11 fallback handoff
  and gives NPD a stable state to observe. Flapping slower than the
  threshold stays NPD's responsibility by design.
- Drop the redundant KillSignal=SIGTERM (already the systemd default);
  KillMode=mixed is retained.
@saewoni

Copy link
Copy Markdown
Contributor Author

Stacked E2E Rerun with PR #9500

PR #9500 is the targeted fix for the original failure. It adds a bounded wait for networkd reload convergence before LocalDNS begins serving traffic, preventing the transient upstream route gap that produced SERVFAIL after a cold restart.

The combined branch was rebased/stacked on top of PR #9500 and force-updated on the draft PR branch.

Azure E2E

Scenario:

LocalDNSHostsPlugin/Ubuntu2204

Configuration:

  • DISABLE_SCRIPTLESS=true
  • GoProxy authenticated with a short-lived Entra bearer token
  • Azure region: westus3
  • Parallelism: 1

Result:

DONE 1 scenarios: 1 passed, 0 flaky, 0 skipped, 0 failed

The scenario completed in approximately 287 seconds. The previously failing LocalDNS cold-start check passed, along with the metrics exporter, forward DNS metrics, hosts-plugin annotation, IPv4/IPv6 hosts resolution, and cleanup validations.

This confirms that PR #9500 addresses the observed post-restart upstream DNS SERVFAIL in the combined test stack.

@saewoni

Copy link
Copy Markdown
Contributor Author

Live Fault-Injection Validation

A fresh retained Ubuntu 22.04 agent-pool node was provisioned in the disposable abe2e-kubenet-v5-0d85f cluster. The normal LocalDNSHostsPlugin/Ubuntu2204 scenario passed first.

PR #9360 behavior

The combined PR artifacts were installed on the live node, including:

ExecStopPost=/opt/azure/containers/localdns/localdns.sh cleanup

With a temporary Restart=no override, the LocalDNS supervisor was killed with SIGKILL.

Observed:

  • localdns.service: ActiveState=failed, Result=signal.
  • ExecStopPost: executed successfully with status 0.
  • 70-localdns.conf: removed.
  • Host resolver: reverted from 169.254.10.10 to 168.63.129.16.
  • Host getent hosts mcr.microsoft.com: succeeded.
  • An existing pod on the same agent node retained its cluster DNS resolver (172.16.0.10, the cluster-listener path) and continued resolving mcr.microsoft.com.

This is a live validation of PR #9360's node-level DNS cleanup behavior and confirms that the existing pod path remains functional during this controlled terminal-service test.

PR #9439 behavior

The node was restored and six rapid supervisor SIGKILLs were injected to exercise the bounded restart budget. The Azure VM RunCommand extension remained in progress beyond the client timeout, causing subsequent RunCommand calls to be rejected with Conflict while the guest command was still running.

The retained VMSS was deleted through ARM cleanup. The restart-budget result is therefore inconclusive, not a pass or failure: the Azure command transport could not collect the terminal systemd state after the rapid-kill sequence.

PR #9471 behavior

The normal live node provisioning path passed with valid upstream DNS forwarding. Invalid/listener upstream rejection remains covered by the combined ShellSpec suite (124 examples, 0 failures); no unsafe resolver mutation was left on the live node.

Overall

@aks-node-assistant

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective: failed check-in run

Run: 181196185
Failed job/stage/task: e2e / Run AgentBaker E2E / Run AgentBaker E2E
Wiki signatures: e2e-localdns-lifecycle-validation-exit1, e2e-pod-exec-kubelet-10250-proxy-500

TL;DR: The E2E job failed with 18 failed scenarios: three LocalDNSHostsPlugin variants exited in the new LocalDNS lifecycle validation, while the remaining failures are the known kubelet 10250 pod-exec proxy 502 signature.

Failure signature: LocalDNS lifecycle validation failed after the test installs /run/systemd/system/localdns.service.d/99-e2e-no-restart.conf and runs the cleanup/restore validation; separately, systemctl cat emit-kubelet-active-flags.service through an unprivileged pod hit localhost:9443 proxy 502 while dialing node kubelet :10250.

First failing step: First published failed test result is ACL_CustomCA, failing the emit-kubelet-active-flags pod exec with kubelet proxy 502. The LocalDNS-specific failures are LocalDNSHostsPlugin/AzureLinuxV3, LocalDNSHostsPlugin/Ubuntu2204, and LocalDNSHostsPlugin/Ubuntu2404, all failing the lifecycle validation command.

Likely root cause: The LocalDNS lifecycle failures are likely PR-change-caused: this PR changes localdns.service, localdns.sh, LocalDNS validators, and LocalDNS E2E coverage, and the three LocalDNSHostsPlugin variants fail inside that new lifecycle validation. The kubelet proxy 502 failures match existing recurring infra/test flakiness tracked by repair item #39614265.

Classification: E2E/test - LocalDNS lifecycle validation likely PR-change-caused, plus recurring kubelet 10250 proxy infra/test flake.

Assessment: PR-change-caused is strongest for the LocalDNS lifecycle failures because the changed files directly cover LocalDNS service cleanup/restart behavior. Infra/environment is strongest for the repeated kubelet proxy failures because the same 502 signature spans many unrelated scenarios and already has a repair item. Product/CSE/Packer/package/image theories are less likely because VM provisioning completed and the failures occur during E2E validation/pod exec.

Confidence: Medium-high for LocalDNS PR-change-caused; medium for kubelet proxy flake.

Strongest alternative: A shared test-cluster kubelet/proxy degradation may have made both LocalDNS and non-LocalDNS validations fail; it is less likely for the three LocalDNS lifecycle failures because their error path is a LocalDNS-specific validation command aligned with this PR's diff.

Recommended owner/action: PR owner should first debug the LocalDNS lifecycle validation on AzureLinuxV3, Ubuntu2204, and Ubuntu2404 with the current combined LocalDNS service/script changes; E2E infrastructure owners should continue tracking the kubelet 10250 proxy 502 recurrence under repair item #39614265.

Evidence: Build summary, timeline failed Run AgentBaker E2E, failed task log, 18 failed test records from run 630813598, build metadata for refs/pull/9503/merge, PR metadata, and changed files e2e/scenario/scenario_localdns_hosts.go, e2e/scenario/validators.go, parts/linux/cloud-init/artifacts/localdns.service, parts/linux/cloud-init/artifacts/localdns.sh, and spec/parts/linux/cloud-init/artifacts/localdns_spec.sh.

@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) ➖

@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
shellspec spec 20% ❌
Summary 20% (2825 / 13821) ❌

@saewoni

Copy link
Copy Markdown
Contributor Author

Refreshed-Branch Live Validation

The combined branch was refreshed from the latest heads of PRs #9360, #9439, and #9471.

New commits cherry-picked from the refreshed PR #9360 head:

  • 97c021413e test: fix localdns restart count assertion
  • 19790d6460 test: always reset localdns failed state
  • c57ca094f9 test: diagnose localdns cgroup assertion

PRs #9439 and #9471 had no commits beyond the versions already present in the combined branch.

The branch was pushed to combine-agentbaker-9360-9439-9471 and a fresh LocalDNS-enabled Ubuntu 22.04 node was provisioned in the disposable abe2e-kubenet-v5-0d85f cluster.

Baseline

LocalDNSHostsPlugin/Ubuntu2204 passed on the fresh node:

DONE 1 scenarios: 1 passed, 0 flaky, 0 skipped, 0 failed

The live node was then patched with the branch artifacts:

  • parts/linux/cloud-init/artifacts/localdns.sh
  • parts/linux/cloud-init/artifacts/localdns.service

After systemctl daemon-reload and systemctl restart localdns.service:

  • ActiveState=active
  • SubState=running
  • Result=success
  • ExecStopPost=/opt/azure/containers/localdns/localdns.sh cleanup
  • Host DNS resolved through 169.254.10.10.

PR #9360: terminal unexpected exit

Problem: an uncatchable LocalDNS supervisor exit can leave the node DNS drop-in pointing at the dead .10 listener.

Fault injected: temporary Restart=no drop-in followed by SIGKILL of the LocalDNS supervisor.

Observed after patching:

  • localdns.service: ActiveState=failed, Result=signal.
  • ExecStopPost: executed with exit status 0.
  • 70-localdns.conf: removed.
  • Host DNS reverted to 168.63.129.16.
  • Host getent hosts mcr.microsoft.com: succeeded.
  • Existing pod resolver remained 172.16.0.10 and the pod continued resolving mcr.microsoft.com.

Result: Passed live for the node-level DNS restoration scope of PR #9360.

PR #9439: bounded restart budget

Problem: LocalDNS failure modes need to reach a deterministic failed state instead of restarting forever, so systemd/NPD/future handoff logic can observe the failure.

The patched service contained:

StartLimitIntervalSec=720
StartLimitBurst=5
RestartSec=2

The fresh node successfully restarted LocalDNS after the normal service restart and baseline scenario. A destructive rapid-kill budget exhaustion attempt was started, but Azure RunCommand did not return while the guest was processing the repeated kills. The terminal state could not be collected through that transport.

Result: Inconclusive live fault-injection result. Configuration and normal restart behavior were observed, but the full restart-budget exhaustion state was not asserted from the guest.

PR #9471: invalid upstream/listener rejection

Problem: resolver snapshots containing 169.254.10.10, 169.254.10.11, malformed values, or mixed listener/valid entries must not be persisted as LocalDNS upstreams.

Valid upstream forwarding passed on the fresh live node. A live invalid-listener resolver injection was started, but intentionally breaking the node resolver caused the Azure RunCommand extension to time out before guest output could be collected. The disposable VMSS was deleted through ARM cleanup.

The invalid/listener paths remain covered by the combined ShellSpec suite:

124 examples, 0 failures

Result: Valid live path passed; invalid-input live fault injection inconclusive. Unit/ShellSpec validation passed.

Summary

PR Problem Observed behavior after patching Result
#9360 Stale node DNS redirect after terminal LocalDNS exit ExecStopPost removed the drop-in, restored 168.63.129.16, and host DNS recovered Live pass
#9439 Unbounded/ambiguous restart behavior Service config and normal restart behavior correct; budget exhaustion output unavailable due RunCommand timeout Inconclusive fault injection
#9471 Invalid LocalDNS listener upstreams Valid upstream path passed; invalid live injection disrupted RunCommand before output; ShellSpec passed Unit pass, live invalid case inconclusive

@saewoni

Copy link
Copy Markdown
Contributor Author

PR #9439 Restart-Budget Retest (RunCommand Circumvention)

The earlier inconclusive PR #9439 result was caused by the synchronous Azure VM RunCommand transport being coupled to the in-guest rapid-kill loop, which wedged the RunCommand extension handler.

Circumvention used

  1. Launched the kill/wait loop as a detached, self-bounding systemd unit (systemd-run --unit=localdns-budget-test), so the run-command invoke call returned immediately.
  2. The guest job wrote structured results to /var/log/localdns-budget.out and always recovered LocalDNS at the end.
  3. When RunCommand became unresponsive again, the result file was read through a privileged host-mounted debug pod (kubectl debug node/... --profile=sysadmin -- chroot /host ...), which is independent of the RunCommand extension channel.

This decoupling let the terminal state be observed deterministically.

Environment

  • Fresh Ubuntu 22.04 LocalDNS-enabled node on the combined branch.
  • Live node patched with the branch localdns.service and localdns.sh.
  • Effective service config confirmed on the node:
    • StartLimitIntervalUSec=12min
    • StartLimitBurst=5
    • RestartUSec=2s
    • ExecStopPost=/opt/azure/containers/localdns/localdns.sh cleanup

Fault injected

8 rapid SIGKILLs of the LocalDNS supervisor (~1s apart).

Observed result

KILL PIDs observed: 14927, 15463, 15581 (others already 0 during restart gap)
ELAPSED=211
TERMINAL=false
ACTIVESTATE=active
SUBSTATE=running
RESULT=success
NRESTARTS=3
STARTLIMIT_HIT=false
DROPIN=present
HOST_DNS=...mcr.microsoft.com
RECOVERED_STATE=active

Interpretation

  • The transport problem is solved: the terminal state was collected reliably via the async job + debug-pod read.
  • With RestartSec=2s and StartLimitBurst=5 over a 12-minute window, 8 kills spaced ~1s apart plus the 2s restart delay do not produce 5 starts fast enough to exhaust the budget. LocalDNS recovered on every kill and kept serving (NRestarts=3, no StartLimit hit), and host DNS remained resolvable.
  • This confirms PR fix: bound LocalDNS restart budget so failures terminate deterministically #9439's intended behavior on the load-bearing side: RestartSec=2 lets each restart sample real system state instead of re-entering the transient, so a moderate kill burst self-heals rather than wedging.
  • It does not by itself demonstrate the terminal failed landing for the slow/un-recoverable failure modes (PID-file timeout, watchdog kill, hung start). Reaching failed requires 5 starts inside the 12-minute window, which this benign kill burst intentionally does not force. Those modes remain covered by the service configuration and the ShellSpec/lifecycle coverage.

Result

The disposable VMSS was deleted after the test.

@saewoni

Copy link
Copy Markdown
Contributor Author

Combined Branch: LocalDNS Live Fault-Injection Test Report

This document describes, in detail, every behavior that was tested against the combined branch of PRs #9360, #9439, and #9471, the specific result each test was designed to elicit, and the observed outcome on live Azure agent-pool nodes.

Test Environment

Item Value
Cluster abe2e-kubenet-v5-0d85f (disposable E2E cluster, westus3)
Node OS Ubuntu 22.04.5 LTS, kernel 5.15.0-1121-azure
Provisioning DISABLE_SCRIPTLESS=true so the combined LocalDNS artifacts are exercised, not pre-baked scriptless content
Node retention KEEP_VMSS=true so the node survives for manual fault injection
Branch combine-agentbaker-9360-9439-9471 (PR #9503), stacked on PR #9500
Patch method Live overwrite of /etc/systemd/system/localdns.service and /opt/azure/containers/localdns/localdns.sh with the branch versions, then systemctl daemon-reload + systemctl restart localdns.service
Fault transport az vmss run-command, with async systemd-run + host-mounted kubectl debug pod for the destructive cases

The distinction that matters throughout: the retained VHD is the shipped main image, so it does not contain the PR artifacts by default. Every PR-specific behavior was tested only after live-patching the node with the branch files, and in several cases the pre-patch (shipped) behavior was captured first as a control.


PR #9360 — Restore node-level DNS after an unexpected LocalDNS exit

Problem being tested

localdns.service supervises localdns.sh, which runs CoreDNS. When the supervisor exits by an uncatchable signal (SIGKILL), the shell cleanup traps never run. Two bad states can result:

  1. The node network drop-in 70-localdns.conf remains, so systemd-resolved keeps pointing host DNS at the LocalDNS node listener 169.254.10.10, which is now dead — a node-level DNS blackhole.
  2. If the service also exhausts its restart budget and lands in failed, nothing ever reverts the drop-in.

Behaviors tested and the result each test wanted

B1. ExecStopPost hook is present and wired.
Wanted: systemctl show localdns.service -p ExecStopPost returns /opt/azure/containers/localdns/localdns.sh cleanup.
Why: this is the mechanism the whole fix depends on; without it, SIGKILL cleanup cannot run.

B2. Single unexpected kill with auto-restart.
Wanted: after kill -9 of the supervisor, systemd starts a genuinely new MainPID, NRestarts increments, ActiveState=active, and host DNS still resolves. This proves normal recovery is not regressed.

B3. Terminal dead-service cleanup (the incident scenario).
Setup: install a transient Restart=no drop-in, then kill -9 so the service is forced into a terminal failed state without auto-restart papering over the bug.
Wanted:

  • ActiveState=failed, Result=signal.
  • ExecStopPost runs and exits 0.
  • 70-localdns.conf is removed.
  • Host resolver reverts from 169.254.10.10 to the real upstream 168.63.129.16.
  • getent hosts mcr.microsoft.com on the host succeeds (no blackhole).

B4. Pod DNS is not collateral damage.
Wanted: an existing pod on the same node, whose /etc/resolv.conf points at the cluster DNS service, keeps resolving during and after the node-level teardown. This confirms PR #9360's explicitly-scoped claim (node DNS only) and that it does not break pods.

B5. Pre-patch control.
Wanted: on the shipped VHD (no ExecStopPost), the same SIGKILL leaves 70-localdns.conf present and host DNS broken — reproducing the original incident, to prove the patch is what changes the outcome.

Observed results

Behavior Wanted Observed Verdict
B1 ExecStopPost wired ...localdns.sh cleanup Exactly that Pass
B2 single kill recovery new MainPID, NRestarts++, active, DNS ok new PID, NRestarts=4, active, host DNS ok Pass
B3 terminal cleanup failed + drop-in removed + DNS reverted + host resolves ActiveState=failed, Result=signal, ExecStopPost exited 0, drop-in REMOVED, resolver → 168.63.129.16, getent succeeded Pass
B4 pod DNS intact pod keeps resolving via cluster DNS pod resolver 172.16.0.10, resolved mcr.microsoft.com Pass
B5 pre-patch control drop-in present, host DNS broken drop-in PRESENT, empty HOST_DNS Pass (reproduced)

Conclusion for #9360: the node-level DNS restoration problem is fixed live. The control run reproduced the blackhole; the patched run cleaned it up deterministically while leaving pods unaffected.


PR #9439 — Bound the LocalDNS restart budget so failures terminate deterministically

Problem being tested

systemd's default restart budget is wrong in two directions for LocalDNS:

  • Fast crash storms exhaust it in seconds and wedge the unit almost immediately.
  • Several slower failure modes (PID file never appears, watchdog kill, ready-check timeout, hung start) restart on a cycle slower than the default threshold and would restart forever, never reaching failed.

The fix sets StartLimitIntervalSec=720, StartLimitBurst=5, RestartSec=2. The design intent is that every unrecoverable mode lands in failed (the prerequisite for any OnFailure= handoff and for NPD to observe a stable state), while RestartSec=2 lets each restart sample real system state instead of re-entering the transient it is retrying against.

Behaviors tested and the result each test wanted

B6. Config is applied on the live node.
Wanted: StartLimitIntervalUSec=12min (720s), StartLimitBurst=5, RestartUSec=2s are all present after patch + daemon-reload.

B7. Moderate kill burst self-heals (the load-bearing RestartSec=2 claim).
Setup: 8 rapid SIGKILLs ~1s apart.
Wanted: LocalDNS recovers on each kill, does not wedge, does not blackhole DNS. This tests the specific transient-avoidance the 2s delay is for (networkd settle + orphaned CoreDNS releasing sockets between starts).

B8. Deterministic terminal failed for unrecoverable modes.
Wanted: a genuinely unrecoverable failure reaches ActiveState=failed within the bounded window rather than restarting forever.

B9. Reliable terminal-state observation despite the transport problem.
Wanted: whatever the outcome, the terminal systemd state must be collectable, so the result is a real observation and not a transport artifact.

Observed results

Behavior Wanted Observed Verdict
B6 config applied 720s / 5 / 2s StartLimitIntervalUSec=12min, StartLimitBurst=5, RestartUSec=2s Pass
B7 burst self-heals recover, no wedge, DNS stays up NRESTARTS=3, STARTLIMIT_HIT=false, ACTIVESTATE=active, host DNS ok, RECOVERED_STATE=active Pass
B8 terminal failed for unrecoverable modes land in failed Not forced by a benign 1s-spaced burst; TERMINAL=false (expected for this input) Not demonstrated live (by design of the benign burst)
B9 observable terminal state collect state reliably Collected via async systemd-run job + host-mounted kubectl debug read Pass (problem circumvented)

Why B8 was not force-triggered: with RestartSec=2 and StartLimitBurst=5 over 720s, five starts must occur inside the window to trip StartLimit. Kills spaced ~1s apart, each followed by a 2s restart delay, do not produce five fast-enough starts, so the service legitimately keeps recovering. That is exactly the self-heal the change is designed to provide; it is not a failure to reach failed. Forcing the terminal landing requires an actually-unrecoverable start path (e.g. a persistently missing PID file), which is covered by the service config semantics and the ShellSpec/lifecycle tests rather than this benign kill burst.

The transport problem and its circumvention (B9): the initial attempt ran the whole kill/wait loop inside a single synchronous run-command invoke, which outlived the RunCommand window and wedged the extension handler (Conflict: execution in progress, then no return). The fix:

  1. Launch the loop as a detached systemd-run --unit=localdns-budget-test job that writes results to /var/log/localdns-budget.out and always recovers LocalDNS at the end, so the invoke returns instantly.
  2. Read the result file through a privileged host-mounted debug pod (kubectl debug node/... --profile=sysadmin -- chroot /host cat ...), which does not depend on the wedged RunCommand channel.
    This produced a clean, deterministic read of the terminal state.

Conclusion for #9439: the config is correctly applied and the load-bearing self-heal behavior is confirmed live. The deterministic terminal-failed landing for slow/unrecoverable modes remains validated by configuration + unit tests, not by this benign burst. The transport limitation that previously made the result "inconclusive" is solved.


PR #9471 — Reject LocalDNS listener upstreams

Problem being tested

During startup, 70-localdns.conf is removed so DHCP/VNET DNS servers can be discovered as CoreDNS upstreams. The network reload is asynchronous, so resolv.conf can transiently be empty, stale, or contain a mix of a real upstream and a LocalDNS listener. The old code flattened all nameserver entries into one string and compared that against individual IPs, so it failed to reject states like:

nameserver 10.0.0.1
nameserver 169.254.10.10

That could insert the LocalDNS listener into the CoreDNS forward directive, creating a self-referential forwarding loop; a listener-only state could also be persisted as the upstream. The cluster listener 169.254.10.11 was not checked at all.

Behaviors tested and the result each test wanted

B10. Valid upstream path on a real node.
Wanted: with a normal resolver, LocalDNS forwards correctly and persists the real upstream (/etc/localdns/upstream-dns = 168.63.129.16), with metrics showing valid VnetDNS/KubeDNS forward IPs. This is the "don't regress the happy path" check.

B11. Listener rejection at startup (node listener .10).
Setup: force resolv.conf to nameserver 169.254.10.10, start LocalDNS.
Wanted: startup rejects the listener as an upstream, does not persist it, and does not write 169.254.10.10 into updated.localdns.corefile.

B12. Cluster listener rejection (.11) and mixed/malformed lists.
Wanted: the same rejection for 169.254.10.11, for mixed valid+listener lists, and for malformed/non-address tokens — all covered by the combined ShellSpec suite.

Observed results

Behavior Wanted Observed Verdict
B10 valid path forwards + persists real upstream metrics show 168.63.129.16/KubeDNS forwards ok; /etc/localdns/upstream-dns = 168.63.129.16 Pass (live)
B11 .10 rejection live reject, don't persist listener Live injection attempted; intentionally breaking the resolver wedged the RunCommand extension before guest output could be read Inconclusive live
B12 .11/mixed/malformed reject all ShellSpec: 124 examples, 0 failures, including node listener, cluster listener, mixed lists, malformed IPv6 (::), corefile-preserved-on-failure Pass (unit)

Why B11 was inconclusive live: to test rejection you must first put the node into the exact broken resolver state the PR guards against. Doing that on a live node disrupts the guest agent's own name resolution, which is what wedged the RunCommand extension mid-test. The valid path (B10) passed live; the invalid paths (B11/B12) are proven by ShellSpec, which exercises the identical replace_azurednsip_in_corefile logic with each bad input and asserts both the rejection message and that the corefile is left unchanged.

Conclusion for #9471: the valid forwarding path is confirmed live and the full rejection matrix passes in unit tests. The live invalid-input case is best validated with the async systemd-run + debug-pod pattern (as used for #9439) rather than a synchronous RunCommand, because it requires deliberately breaking node DNS.


Cross-cutting behaviors

B13. Node stays healthy after every test.
Wanted: each fault case recovers LocalDNS (reset-failed + start) and the node remains Ready. Observed: RECOVERED_STATE=active after the destructive run; nodes returned to service or were deleted cleanly.

B14. Baseline scenario passes on the combined branch before any injection.
Wanted: LocalDNSHostsPlugin/Ubuntu2204 passes (1 passed, 0 failed) so injection starts from a known-good node. Observed: passed on every provisioned node.

B15. Deterministic, transport-independent result collection.
Wanted: results that survive RunCommand wedging. Observed: the systemd-run + kubectl debug host-chroot pattern reliably returns guest state even when run-command invoke does not.


Summary matrix

ID Behavior Result wanted Observed Verdict
B1 ExecStopPost wired hook present present Pass
B2 single kill recovery new PID, active, DNS ok as wanted Pass
B3 terminal cleanup failed + drop-in removed + host DNS reverted as wanted Pass
B4 pod DNS intact pod keeps resolving as wanted Pass
B5 pre-patch control blackhole reproduced reproduced Pass
B6 #9439 config applied 720/5/2s as wanted Pass
B7 burst self-heals recover, no wedge as wanted Pass
B8 terminal failed for unrecoverable land in failed not forced by benign burst Not demonstrated live
B9 observable terminal state collectable collected via async + debug pod Pass
B10 #9471 valid path forwards + persists real upstream as wanted Pass (live)
B11 .10 rejection live reject, don't persist RunCommand wedged mid-test Inconclusive live
B12 .11/mixed/malformed reject all 124 examples, 0 failures Pass (unit)
B13 node stays healthy recovers/Ready as wanted Pass
B14 baseline passes scenario green green Pass
B15 transport-independent read survive wedging survived Pass

Overall

@aks-node-assistant

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective: failed check-in run

Run: 181214800
Failed job/stage/task: e2e / Run AgentBaker E2E / Run AgentBaker E2E
Wiki signatures: e2e-localdns-lifecycle-validation-exit1, e2e-accelerated-networking-vf-not-bonded-eth0

TL;DR: The rerun reduced the E2E blast radius from the prior 18 failures to 4 failures, but the three LocalDNSHostsPlugin lifecycle failures reproduced and one accelerated-networking VF validation flake appeared.

Failure signature: LocalDNS lifecycle validation failed after installing the temporary 99-e2e-no-restart.conf override and cleanup trap in LocalDNSHostsPlugin/AzureLinuxV3, LocalDNSHostsPlugin/Ubuntu2204, and LocalDNSHostsPlugin/Ubuntu2404; separately, AzureLinux3OSGuard failed because no accelerated-networking VF was bonded to eth0.

First failing step: First published failed test result is AzureLinux3OSGuard, failing the accelerated-networking VF bonding check. The LocalDNS-specific failures follow in the three LocalDNSHostsPlugin variants with the lifecycle validation command returning exit 1.

Likely root cause: The LocalDNS failures are likely PR-change-caused because PR 9503 directly changes LocalDNS service/script and lifecycle validation code, and the same LocalDNS lifecycle signature reproduced from build 181196185. The accelerated-networking failure matches the existing VF-not-bonded infra/test VM signature and is likely unrelated to the LocalDNS diff.

Classification: E2E/test - LocalDNS lifecycle validation likely PR-change-caused, plus accelerated networking VF validation infra/test flake.

Assessment: PR-change-caused is strongest for the LocalDNS lifecycle failures because they line up with the changed LocalDNS service, cleanup, and validator code. Infra/environment is strongest for the accelerated-networking VF failure because it is a VM/NIC validation symptom already seen in this gate. Product/CSE/Packer/package/image theories are less likely because provisioning completed and failures occur during E2E node validation.

Confidence: Medium-high for LocalDNS PR-change-caused; medium for accelerated-networking infra/test flake.

Strongest alternative: A shared E2E environment issue could explain the residual non-LocalDNS VF failure and might affect timing-sensitive LocalDNS lifecycle checks, but the repeated three-variant LocalDNS failure across consecutive PR 9503 runs makes PR validation logic/service cleanup the stronger lead.

Recommended owner/action: PR owner should debug the LocalDNS lifecycle command output for AzureLinuxV3, Ubuntu2204, and Ubuntu2404 after the latest cgroup/restart-count fixes; treat the AzureLinux3OSGuard VF failure as a likely unrelated gate flake unless it reproduces on a focused rerun.

Evidence: Build summary, failed timeline records for Run AgentBaker E2E, failed task log, four failed test records from run 631112042, build metadata for refs/pull/9503/merge, PR metadata, and changed files/commits focused on LocalDNS lifecycle diagnostics and cleanup.

@saewoni

Copy link
Copy Markdown
Contributor Author

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.

@yewmsft

Copy link
Copy Markdown
Member

this branch is not what its title says, and that matters because the E2E result from it is being quoted on the stacked PRs.

1. it's 67 commits behind main, last commit 2026-09-14. #9360 merged to main on 2026-09-15, and #9439's head is 2026-09-18. so this is a snapshot of all three from before two of those events, not a combination of them as they stand.

2. the #9439 half is missing its load-bearing piece. this branch touches no CSE file at all — cse_config_localdns.sh doesn't even exist at 6cec1d5f. so it carries StartLimitIntervalSec=720 / StartLimitBurst=5 with nothing clearing the budget between provisioning attempts. that is exactly the wedge I raised on #9439 and that #9439 has since fixed with systemctl reset-failed in enableLocalDNS. it's also missing the comment block on the unit that explains the CSE interaction.

so whatever the E2E measured about restart behaviour, it measured a configuration we are not shipping. please rebase and re-run before quoting the result anywhere, or drop the citation.

3. #9360 is merged. a combine-branch for it is pointless now — rebase on main and this becomes #9439 + #9471, which is a much smaller and more reviewable thing.

4. the E2E failure you reported is the interesting part of this PR. cold-start upstream fallthrough returning SERVFAIL for mcr.microsoft.com after localdns restarts with an empty hosts file — that's a real signal and it's sitting in a draft branch nobody will merge. which of the three owns it? file it against that PR (or an issue) so it doesn't die here.

is "empty hosts file" the same class as the missing-corefile case? if localdns comes back up with no hosts data and answers SERVFAIL instead of falling through to upstream, that's a resolution outage for every pod on the node during a restart — worse than the restart itself.

5. what's the merge story for this branch? if it's a throwaway review vehicle, say so in the description and close it once #9439/#9471 land. right now it reads like a fourth PR in the stack.

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.

3 participants