Skip to content

feat(localdns): keep node and pod DNS working via NAT when localdns fails - #9486

Merged
Saewon Kwak (saewoni) merged 36 commits into
mainfrom
feat/localdns-persist-clusterip
Oct 14, 2026
Merged

Saewon Kwak (saewoni) merged 36 commits into
mainfrom
feat/localdns-persist-clusterip

Conversation

@saewoni

@saewoni Saewon Kwak (saewoni) commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does / why we need it:

Pods get 169.254.10.11 or 169.254.10.10 written into /etc/resolv.conf by kubelet at sandbox creation, and it is never revisited. LocalDNS does not DNAT — build_localdns_iptable_rules() installs raw-table NOTRACK rules only — so when nothing is listening, queries are not rerouted anywhere. They fail.

That makes a dead localdns.service a DNS blackhole nothing recovers from. AKS#5930 was ~16 minutes of it on a customer node, ended by manual intervention.

This PR keeps both listeners working while localdns is in terminal failed, using kernel NAT: one nat chain that rewrites the destination of DNS packets so they never arrive at the dead addresses in the first place.

listener redirected to
169.254.10.10:53 udp/tcp the VNet DNS from the restored systemd-resolved upstream file
169.254.10.11:53 udp/tcp kube-dns — kube-proxy's own service chain, or DNAT across the Cilium agent's backends

What landed before this

Do not attribute the #5930 fix to this PR — #9361 is the fix; this is the floor underneath failure causes nobody has enumerated.

Why NAT and not a second CoreDNS

An earlier revision ran a second CoreDNS against a corefile derived from localdns's own. That shares a failure mode with the thing it is backing up. If localdns died because the baked binary cannot parse a directive the RP sent, the fallback inherits the same binary and the same directive and dies with it.

This is reachable, not theoretical. failfast_all_unhealthy_upstreams requires CoreDNS ≥ 1.12.1; measured across the five builds cached in components.json:

VERSION      baseline  health_ck  no_rec   domain   failfast
v1.11.3-32   ok        ok         ok       ok       FAIL(1)    plugin/forward: unknown property
v1.12.1-25   ok        ok         ok       ok       ok
v1.13.1-20 … v1.14.7-2   all ok

A hard startup failure, so the fallback would have crash-looped every 5s forever on such a node. A fallback must not fail for the same reason its principal did. iptables rules cannot crash-loop or misparse a config.

Why both listeners, not just .11

Which address a pod gets depends on its dnsPolicy: ClusterFirst → .11, Default and hostNetwork → .10. The second group includes CoreDNS itself, konnectivity-agent, the CSI node drivers, kube-proxy and azure-cns. Leaving .10 dark black-holes all of them — and also breaks external resolution for .11 clients, because that query goes .11 → ClusterIP → a CoreDNS replica on this same node → its own dead .10.

Why .11 is not DNAT'd at the kube-dns ClusterIP

The nat table is traversed once per connection and DNAT is a terminal verdict for it. A rule rewriting .11 to the ClusterIP consumes the traversal KUBE-SERVICES needed to translate that ClusterIP into a pod IP, so the packet leaves addressed to a virtual IP nothing owns. On Cilium the service load balancer runs before netfilter, so a ClusterIP target is never translated at all.

Going one hop further — straight into KUBE-SVC-<kube-dns>, or at the backends the Cilium agent reports — lands on a real pod IP. It also means the fallback never needs the ClusterIP, which is why the COREDNS_SERVICE_IP plumbing comes back out of parser.go, baker.go, cse_cmd.sh and cse_config_localdns.sh, along with its repro test.

The gate

apply refuses — exit 1, logged — unless localdns is ActiveState=failed, an upstream other than .10 exists in the restored resolv.conf, and either kube-proxy's kube-dns chains or a Cilium agent reporting active backends and EnableHostLegacyRouting=true is found. With eBPF host routing, pod egress skips netfilter entirely and a nat rule would never see the packet.

OnFailure= fires on every failed start attempt, not only the terminal one — measured on systemd 255.4, six firings across a five-restart budget. Five of those are starts where localdns is mid-restart and the only correct action is to do nothing. StartLimitIntervalSec=0 stops that burst rate-limiting the one start that matters.

Those no-ops are skipped, not failed. ExecStart= has no exit code meaning "nothing to do" — exit 0 latches the unit active under RemainAfterExit= and turns the one start that matters into a no-op — so the unit gates on ExecCondition=/usr/bin/systemctl is-failed localdns.service, whose non-zero exit skips the unit without failing it. Without it, a node whose localdns flaked once and recovered was left with localdns-fallback.service in failed permanently, because ExecStartPre's stop is not reset-failed: a false alarm in the field, and a red scenario on the repo-wide failed-unit validator. apply re-checks the same state, so running the script by hand stays guarded.

Recovery is unchanged: localdns.service's ExecStartPre stops this unit, whose ExecStop flushes the chain. The rules are runtime-only, so a reboot leaves nothing behind.

The systemd version split

ActiveState=failed means terminally failed on every version: through the whole restart budget the unit sits in activating/auto-restart, measured on 249 and 255. What differs is how often OnFailure= fires.

VHD systemd firings per crash storm
Ubuntu2204 249 1 — terminal only
Ubuntu2404, AzureLinuxV3, ACL 255 6 — one per attempt
Ubuntu2604 259 untested

The fallback behaves identically on both; only how often it is asked to do nothing changes. That is why the gated no-ops are rare on 249 and routine on 255.

One deviation from the prototype, flagged for review

apply polls for the resolv.conf upstream rather than reading once, bounded at 10s. ExecStopPost removes the drop-in and runs networkctl reload, which is asynchronous, and OnFailure= fires as soon as ExecStopPost returns — so the file can still hold the drop-in state (.10 and nothing else) at that instant. The oneshot has no Restart=, and OnFailure= will not fire again once localdns is terminally failed, so a single early read would leave no fallback at all. Same race wait_for_localdns_removed_from_resolv_conf exists for on the start path.

Two things deliberately not done

  • No cache, serve_stale, hosts plugin or overrides, and .10 clients lose cluster.local. That is the same position they are in on a node with localdns disabled. This is a bridge that keeps DNS moving until the node is remediated, not a replacement for localdns.
  • No attempt to recover localdns. The failed unit is left alone and a node on the fallback keeps reporting LocalDNSError, which is correct: once .10 is served again, systemctl is-active is the only remaining signal that anything is wrong. Note the division of labour — NPD only increments problem_counter{reason="LocalDNSError"} (type: temporary, no NodeCondition); aks-operator's local-dns.rules turn 5 hits in 10m into Reboot after 5m and Redeploy after 15m; the remediator does the cordon, drain and reboot. localdns is dead and needs remediation; the fallback only keeps DNS working until that happens.

Verification

Live clusters, against this PR's own VHD build (VHD_BUILD_ID=184891026, so the lane resolves branch-built images rather than main's), kubenet / kube-proxy, scriptless and CSE paths.

scenario result
LocalDNS/Ubuntu2204 29 assertions, 0 failures
LocalDNS/Ubuntu2404 38 assertions, 0 failures
LocalDNSNoHostsPlugin/Ubuntu2204 passed

Ubuntu2404 is the systemd 255 case, and it is the one that proves the gate works:

OnFailure= fires:          11
ExecCondition skips:       10
fallback applied:           2
localdns-fallback failed:   0

Ten of eleven fires were skipped, and the unit never landed in failed on a healthy node. On 2204 (systemd 249) OnFailure= fires once, so both halves of the version split are now covered by a real run rather than by argument.

LocalDNSNoHostsPlugin is the only scenario that drives a real failing startup of localdns.sh — a malformed LOCALDNS_COREFILE_BASE with Restart=no. The node's journal confirms it reached the path under test:

localdns.sh[13478]: Timed out waiting for CoreDNS to create PID file at /run/localdns.pid.
systemd[1]: localdns.service: Main process exited, code=exited, status=216/GROUP

Exactly one occurrence, which is what Restart=no should produce.

From a node's journal during the outage:

localdns-fallback: applied (kube-proxy): 169.254.10.10 -> 168.63.129.16,
                   169.254.10.11 -> KUBE-SVC-TCOU7JCQXEZGVUNU/KUBE-SVC-ERIFXISQEP7F7OF4

Also: ShellSpec 32 examples / 0 failures, make validate-shell exit 0, go build + go vet + gofmt clean, 9 packer JSONs valid.

How the e2e is organised

The fallback no longer has scenarios of its own. LocalDNS/{Ubuntu2204,Ubuntu2404,AzureLinuxV3} already drove localdns to terminal failed and recovered it, which is exactly the state the fallback needs — so the assertions run against that fault instead of manufacturing a second one on three more VMs. Net −2 VMs per lane and one fault per node.

That required splitting the restart-budget validation: localdnsTerminalFailedScript carried a trap teardown EXIT that restarted localdns the moment the limiter assertion passed, so the terminal state never survived. It is now driveLocalDNSToTerminalFailed, which deliberately exits with the unit failed, plus validateLocalDNSRestartBudgetDirectives for the static half. Restore is the caller's job and runs on every path.

Ordering is deliberate: budget directives before the fault, so a budget regression reads as a budget regression rather than as a fallback that never activated; probe pods before it too, since their resolv.conf is written at sandbox creation and never revisited.

LocalDNSHostsPlugin was renamed LocalDNS. The old name described the config, not the test — the hosts-plugin assertions live in ValidateCommonLocalDNS, while the scenario's own validator ran lifecycle and budget. It now covers four things. Note this resets ADO test-result history for those scenarios.

Static checks live in ShellSpec, not e2e: unit directives (OnFailure=, ExecCondition=, Type=oneshot, RemainAfterExit=, ExecStop=, StartLimitIntervalSec=0, the absence of Conflicts= and of [Install], the ExecStartPre handoff) and rule generation (udp/tcp DNAT, the KUBE-SVC jump, Cilium spreading, never DNAT'ing at a ClusterIP, insertion order). Each was mutation-checked — every one of ten mutations reddens exactly one example. What stays on a node is what needs one: the chain installed in the kernel, both hooks, NOTRACK removal, real DNS from pods, the systemd handoff, recovery, and that ExecCondition= actually gates rather than merely being present.

The artifacts are no longer staged onto the node. Staging let the scenario run before they shipped, but once they do it is a mask: it repaired the exact gap the scenario exists to detect and then reported a pass — which is why the OSGuard mode split was invisible here. Absence now fails and names the VHD.

Still outstanding

  • kube-proxy iptables mode only. kube_dns_chain() reads iptables -S KUBE-SERVICES. In ipvs mode the chain shape differs; in nftables mode the chains are not in iptables at all. In both, apply finds neither a kube-dns chain nor a Cilium agent and refuses — the node is left as it was. Neither mode is tested.
  • No Cilium scenario in e2e, by agreement: e2e covers the kube-proxy cases and Cilium is verified by hand (matrix posted in-thread, both dataplanes green on all 8 rows). ValidateIPTablesCompatibleWithCiliumEBPF additionally fails on a pre-existing, unrelated allowlist gap — a mangle-table -A FORWARD -d 168.63.129.16/32 -p tcp -j DROP with no --dport, which no AgentBaker script writes. Being raised with acndp separately.
  • AzureLinuxV3 and the ANC lane were not in the hand runs above — both are covered by the PR check-in gate.
  • The outage window is not measured. The scenario bounds recovery but never times the gap.
  • Conntrack: cleanup_iptables_and_dns deletes the NOTRACK rules on exit and DNAT requires conntrack, so they cannot return while the fallback is active. Every pod DNS query creates a conntrack entry for the length of the outage — bounded by the remediation window.
  • Cilium backends are snapshotted at apply. The kube-proxy path follows endpoint changes because it jumps into a live chain; the Cilium path does not.
  • localdns-fallback.service mode differs by build path. packer_source.sh installs it 644, azlosguard.yml 600, and checkLocaldnsScriptsAndConfigs asserts 644. This is the same split localdns.service already has on main, not something this PR introduces — flagged rather than resolved here, since fixing it means either an OS-SKU-aware assertion or moving azlosguard's units off 600.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Windows Unit Test Results

  3 files   18 suites   1m 9s ⏱️
556 tests 556 ✅ 0 💤 0 ❌
559 runs  559 ✅ 0 💤 0 ❌

Results for commit 14c76ed.

♻️ This comment has been updated with latest results.

@saewoni

Copy link
Copy Markdown
Contributor Author

Companion aks-rp fix (separate repo) — populate CoreDnsServiceIp on the scriptless/ANC path

The ANC bug documented here (docs/bugs/scriptless-coredns-serviceip-not-populated.md, pinned by the regression tests in aks-node-controller/parser/coredns_service_ip_repro_test.go) is in aks-rp, not AgentBaker: aks-rp never populates ClusterNetworkConfig.core_dns_service_ip, so getCoreDnsServiceIp falls back to the hardcoded 10.0.0.10 regardless of a custom service CIDR. The .11 fallback (and the LocalDNS corefile) on the scriptless path therefore forward to the wrong ClusterIP for custom-CIDR clusters.

The value already flows correctly on the CSE path (typeconversion.go → KubernetesConfig.DNSServiceIP); only the two scriptless builders need wiring. There's even a dead helper (getCoreDNSServiceIP in scriptless_variables.go, zero callers) that produces the value.

Proposed change (msazure/CloudNativeCompute, resourceprovider/sharedlib/agentbaker/)

1. scriptless.go — wire the existing (currently dead) helper:

nodeConfig.ClusterConfig = &aksnodeconfigv1.ClusterConfig{
    ClusterNetworkConfig: &aksnodeconfigv1.ClusterNetworkConfig{
        VnetName:          getVNetName(config),
        VnetResourceGroup: getVNetResourceGroup(config),
        Subnet:            getSubnet(config),
        SecurityGroupName: getSecurityGroupName(config),
        RouteTable:        getRouteTable(config),
        CoreDnsServiceIp:  getCoreDNSServiceIP(config), // <-- add
    },

getCoreDNSServiceIP(config) already returns config.AgentPoolProfile.KubernetesConfig.DNSServiceIP (empty string when unset, which AgentBaker safely defaults). No new code needed beyond the field assignment; this also removes the "declared and not used" dead-code smell.

2. scriptless_direct.go (proto-sourced path) — pull from the network profile, mirroring the sibling mc.Get… fields:

ClusterConfig: &aksnodeconfigv1.ClusterConfig{
    ClusterNetworkConfig: &aksnodeconfigv1.ClusterNetworkConfig{
        VnetName:          mc.GetCloudProviderProfile().GetVnetName(),
        VnetResourceGroup: mc.GetCloudProviderProfile().GetVnetResourceGroup(),
        Subnet:            mc.GetCloudProviderProfile().GetSubnetName(),
        SecurityGroupName: mc.GetCloudProviderProfile().GetSecurityGroupName(),
        RouteTable:        mc.GetCloudProviderProfile().GetRouteTableName(),
        CoreDnsServiceIp:  mc.GetProperties().GetNetworkProfile().GetKubeDnsServiceIP(), // <-- add
    },

GetKubeDnsServiceIP() is the proto accessor on NetworkProfile (field 5). It is empty when the customer used the default CIDR, which AgentBaker defaults to 10.0.0.10.

Verification after the aks-rp change

  • Unit-test both builders assert CoreDnsServiceIp == the configured DNSServiceIP / KubeDnsServiceIP (not empty / not silently defaulted).
  • The AgentBaker-side regression tests here already prove the ANC template honors the value once supplied (TestReproCustomCoreDnsServiceIpHonoredWhenSet) and defaults when unset (TestReproDefaultUsedWhenCoreDnsServiceIpUnset), so once aks-rp populates the field, custom-CIDR scriptless nodes get the correct ClusterIP end to end.

Ordering / impact

  • Independent, pre-existing bug — not introduced by this PR; the fallback work merely surfaced it.
  • Default-CIDR clusters are unaffected either way. The aks-rp fix is required for custom-CIDR correctness on the scriptless path (both for the existing LocalDNS corefile and this PR's .11 fallback).

@saewoni

Copy link
Copy Markdown
Contributor Author

Finding (root cause + blast radius): CoreDnsServiceIp never populated on the scriptless / ANC bootstrap path

This is the standalone write-up of the bug that the .11 fallback work surfaced (companion to the fix I posted above, and mirrored in docs/bugs/scriptless-coredns-serviceip-not-populated.md in this PR).


Status: Latent correctness bug (pre-existing; surfaced while designing the LocalDNS .11 fallback)
Affected path: Scriptless / aks-node-controller (ANC) node bootstrapping
Not affected: CSE (script-based) bootstrapping
Blast radius: Clusters that are (scriptless/ANC) × (LocalDNS enabled) × (custom, non-default DNSServiceIP)

Summary

On the scriptless (ANC) bootstrap path, aks-rp never sets the ClusterNetworkConfig.core_dns_service_ip proto field it sends to AgentBaker. AgentBaker therefore falls back to a hardcoded default of 10.0.0.10. For any cluster whose real kube-dns Service ClusterIP is not 10.0.0.10 (i.e. a custom service CIDR / custom DNSServiceIP), the generated LocalDNS corefile forwards cluster.local queries to the wrong address.

The CSE path is unaffected because it carries DNSServiceIP correctly through a different mechanism.

Root cause (verified in code)

Ultimate origin of the value — NetworkProfile.DNSServiceIP (customer ARM input), defaulted to 10.0.0.10 when unset:

  • resourceprovider/.../datamodel/v2024*/convertto.go:373 — kubernetesConfig.DNSServiceIP = networkProfile.DNSServiceIP
  • resourceprovider/.../datamodel/v2024*/defaults.go:64 — config.DNSServiceIP = DefaultKubernetesDNSServiceIP ("10.0.0.10")

CSE path — correct

aks-rp kubernetesConfig.DNSServiceIP
  └─ resourceprovider/sharedlib/agentbaker/typeconversion.go:358  DNSServiceIP: kubernetesConfig.DNSServiceIP
       └─ AgentBaker datamodel KubernetesConfig.DNSServiceIP
            └─ GetCoreDNSServiceIP()  (pkg/agent/datamodel/types.go:2722)
                 └─ corefile {{$.CoreDNSServiceIP}} (pkg/agent/baker.go:2407)

Scriptless / ANC path — BROKEN — aks-rp builds the ANC ClusterNetworkConfig but omits CoreDnsServiceIp:

  • resourceprovider/sharedlib/agentbaker/scriptless.go:213
  • resourceprovider/sharedlib/agentbaker/scriptless_direct.go:155

Both set VnetName, Subnet, SecurityGroupName, RouteTable, ... but never CoreDnsServiceIp. There is even a helper that would produce the value, but it has zero callers (dead code — evidence the wiring was intended but never completed): resourceprovider/sharedlib/agentbaker/scriptless_variables.go:142. A repo-wide search for CoreDnsServiceIp (excluding generated *.pb.go) returns no assignments, setters, or proto merges anywhere in aks-rp.

AgentBaker silently defaults — because the field arrives empty, AgentBaker substitutes the hardcoded default: aks-node-controller/parser/helper.go:924-931 returns defaultCoreDnsServiceIp ("10.0.0.10", consts.go:54) when the proto field is empty. The scriptless path does otherwise support LocalDNS (so the two features coexist and the bug is reachable): scriptless.go:322 and scriptless_direct.go:247 both wire LocalDnsProfile.

Impact

Condition Result
Default service CIDR (kube-dns at 10.0.0.10) No visible impact — the wrong-by-omission default coincides with reality
Custom DNSServiceIP (non-10.0.0.10) on scriptless + LocalDNS LocalDNS corefile forwards cluster.local to 10.0.0.10 instead of the real ClusterIP → in-cluster DNS resolution via LocalDNS is misdirected

Likely gone unnoticed because the overwhelming majority of clusters use the default service CIDR, where the hardcoded default is accidentally correct.

Why it matters here

This PR's .11 fallback persists the real kube-dns ClusterIP to /etc/localdns/environment so the fallback can forward 169.254.10.11 → real ClusterIP. On the scriptless path that persisted value is currently the wrong default for custom-CIDR clusters, so the fallback foundation depends on fixing this gap. Pre-existing and independent of this PR / #9439 — the fallback design merely surfaced it.

The two regression tests in this PR pin the behavior: TestReproCustomCoreDnsServiceIpHonoredWhenSet (ANC honors a custom value when supplied) and TestReproDefaultUsedWhenCoreDnsServiceIpUnset (silently defaults when unset). The concrete aks-rp fix is in the companion comment above.

Open questions to confirm before filing in aks-rp

  1. Is scriptless/ANC + LocalDNS GA to customers, or still pre-GA? (Determines whether this is currently reachable in production vs. latent-only.)
  2. Is there any RP-side defaulting webhook or proto-default that fills core_dns_service_ip outside the two builders inspected? (Static search found none.)

@yewmsft

Copy link
Copy Markdown
Member

the design write-up is good, but it doesn't describe what the PR actually does.

1. this PR restarts kubelet, and the description never says so. localdns-kubelet-dns.sh:69-77 runs systemctl restart kubelet.service on repoint and again on restore. so one localdns failure episode = two kubelet restarts on a production node. that is a much bigger hammer than the DNS gap it's covering, and it isn't in the TL;DR, the design section, or the behaviour matrix — a reviewer reading the description would approve this without knowing kubelet gets bounced.

put it in the description, and then justify it: what's the node's NotReady window during a kubelet restart, and how does that compare to node-monitor-grace-period? on a node where localdns flaps slowly, do we get repeated kubelet restarts? (:25-26 says "only restart when they actually changed the file", which handles fast flapping — but a localdns that fails, recovers, fails again an hour later restarts kubelet 4 times.)

honestly I'd land the fallback without the kubelet repoint first. .11 answering is the fix for the reported problem; repointing new pods is a separate, riskier optimisation that deserves its own PR and its own e2e.

2. the ANC "known issue" is not a known issue, it's a live regression. resolve_coredns_service_ip:43-50 falls back to the hardcoded 10.0.0.10 when COREDNS_SERVICE_IP is empty. you've established that aks-rp never populates it on the scriptless path. so on a custom-service-CIDR ANC cluster this PR points kubelet's --cluster-dns at an IP that does not exist, and every pod created from then on has no DNS at all. that's worse than the outage.

fail closed instead of guessing:

resolve_coredns_service_ip() {
    local ip="${COREDNS_SERVICE_IP:-}"
    [ -n "${ip}" ] || return 1     # caller no-ops; never guess the ClusterIP
    printf '%s' "${ip}"
}

and land the aks-rp change first. this is a release-ordering problem, not a follow-up.

related: restore:117-129 needs COREDNS_SERVICE_IP for its fallback branch. empty value + lost ORIGINAL_STATE_FILE = kubelet pinned at 10.0.0.10 permanently with nothing to undo it.

3. set_cluster_dns:58-66 is a non-atomic write to a file kubelet reads at start. mktemp lands in /tmp, then cat tmp > /etc/default/kubelet. a reboot or power loss mid-cat leaves KUBELET_FLAGS= truncated and kubelet comes up with no flags. stage on the same filesystem and rename:

tmp="$(mktemp -p /etc/default .kubelet.XXXXXX)"
...
mv -f "${tmp}" "${KUBELET_DEFAULT_FILE}"

also chmod 0600 at :66 — is /etc/default/kubelet 0600 today? if it's 0644 you're silently tightening it as a side effect of a DNS repoint. preserve the original mode rather than asserting one.

4. ExecStartPre=-/usr/bin/systemctl stop localdns-fallback.service is unbounded. you argue in cse_config_localdns.sh:148-150 that provisioning systemctl calls need timeout because an unresponsive PID 1 / D-Bus must not eat the deadline — same argument applies here, more so, because this one runs systemctl stop from inside another unit's Exec*, which is the documented way to deadlock systemd. the fallback also declares Before=localdns.service, so the stop job can be ordered against the start job you're inside. at minimum:

ExecStartPre=-/usr/bin/timeout 10 /usr/bin/systemctl stop localdns-fallback.service

you say "this synchronous stop is the ONLY handoff" — so what happens to .11 when it times out or deadlocks? that path needs an answer, it's the one that yanks the socket mid-flight.

5. probe timer is every 5s forever, with AccuracySec=1s. that's a process spawn every 5s on every node for the life of the node, and AccuracySec=1s opts out of systemd's timer coalescing so it wakes the CPU precisely each time. why 5s? the debounce is 3 failures, so 10s/3 gets you ~30s switch-over instead of ~15s at half the wakeups. and drop AccuracySec unless you can show the coalescing jitter actually breaks the debounce.

6. 9 packer JSONs bake 4 new artifacts and there's no linux-vhd-content-test.sh change. same ask as #6082 — assert the new units and scripts are present and executable in the VHD, otherwise a packer JSON typo ships a VHD where the fallback silently doesn't exist and we find out during an incident. cse_config_localdns.sh:147's systemctl cat ... || skip means a missing unit is silent by design.

7. the description's "Follow-ups (not in this PR)" is stale. it lists the E2E scenario and the localdns-fallback.sh ShellSpec as not-in-this-PR, but the diff adds e2e/scenario/scenario_localdns_fallback.go, validators_localdns_fallback.go (+1040), and localdns-fallback_spec.sh (+370).

8. 2855 lines / 28 files. split it: (a) env-plumbing of COREDNS_SERVICE_IP, (b) the .11 fallback unit + OnFailure=, (c) the kubelet repoint, (d) the probe. (a) and (b) are the change that fixes the reported problem and could land this week; (c) is the one that needs the aks-rp fix ahead of it.

Saewon Kwak (saewoni) added a commit that referenced this pull request Sep 21, 2026
… with a spec

The TERM trap added to reap the watchdog sleep also made 'exit 0' fire the
EXIT trap, so cleanup_localdns_configs now runs in-process on every systemd
stop where previously bash died first and only ExecStopPost ran. That was a
side effect, not a decision, and nothing recorded it.

Keeping the behaviour. Measured on a node rather than argued (journal from
gate build 181777373, AzureLinuxV3, n=11 stops / n=18 starts):

  stop   5-6s   (essentially all of it LOCALDNS_SHUTDOWN_DELAY draining)
  start  1-2s
  restart 6-8s against the 'timeout 30 systemctl restart localdns' in
          enableLocalDNS() and its e2e mirror -- ~22-24s of headroom

So the cost lands at about a quarter of the only bound that constrains it,
and the behaviour it buys is better than the alternative: without the drain
CoreDNS is SIGKILLed by the cgroup on every stop, dropping in-flight queries.
Trimming it would spend real shutdown quality to reclaim time nothing needs.

It does not move the restart-budget math. The worst cycle is a hung start,
where this trap cannot run at all -- bash defers a trapped signal until the
foreground command returns, and that fault never returns -- so systemd spends
the full TimeoutStopSec there regardless.

Second consequence, previously unrecorded: cleanup_localdns_configs now runs
to completion, so the dummy interface carrying .10/.11 is torn down on a stop.
localdns_cleanup_mode (ExecStopPost) deliberately leaves the link alone in case
an orphaned CoreDNS is still answering on .11, so the two paths used to differ;
for a clean stop they now agree. The teardown/recreate cycle was clean in the
same run: 12 teardowns, 18 setups, zero address-in-use or RTNETLINK errors.
Worth knowing for #9486 -- its "create the dummy interface idempotently"
constraint is now the common case on a clean stop rather than the exception.

The spec covers the trap wiring, which nothing did before. The traps sit below
"${__SOURCED__:+return}", so Include cannot reach them; the spec greps the real
trap lines out of the shipped script and executes them against stubs instead of
restating them, so deleting or altering a trap fails the test. Mutation-checked
both ways: removing the TERM trap fails all three examples, and changing its
'exit 0' to 'exit 1' fails all three.

Comment and test only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@saewoni
Saewon Kwak (saewoni) force-pushed the fix/localdns-pod-service-recovery branch from ff1ddb5 to 5ace504 Compare September 22, 2026 23:40
Saewon Kwak (saewoni) added a commit that referenced this pull request Sep 22, 2026
… with a spec

The TERM trap added to reap the watchdog sleep also made 'exit 0' fire the
EXIT trap, so cleanup_localdns_configs now runs in-process on every systemd
stop where previously bash died first and only ExecStopPost ran. That was a
side effect, not a decision, and nothing recorded it.

Keeping the behaviour. Measured on a node rather than argued (journal from
gate build 181777373, AzureLinuxV3, n=11 stops / n=18 starts):

  stop   5-6s   (essentially all of it LOCALDNS_SHUTDOWN_DELAY draining)
  start  1-2s
  restart 6-8s against the 'timeout 30 systemctl restart localdns' in
          enableLocalDNS() and its e2e mirror -- ~22-24s of headroom

So the cost lands at about a quarter of the only bound that constrains it,
and the behaviour it buys is better than the alternative: without the drain
CoreDNS is SIGKILLed by the cgroup on every stop, dropping in-flight queries.
Trimming it would spend real shutdown quality to reclaim time nothing needs.

It does not move the restart-budget math. The worst cycle is a hung start,
where this trap cannot run at all -- bash defers a trapped signal until the
foreground command returns, and that fault never returns -- so systemd spends
the full TimeoutStopSec there regardless.

Second consequence, previously unrecorded: cleanup_localdns_configs now runs
to completion, so the dummy interface carrying .10/.11 is torn down on a stop.
localdns_cleanup_mode (ExecStopPost) deliberately leaves the link alone in case
an orphaned CoreDNS is still answering on .11, so the two paths used to differ;
for a clean stop they now agree. The teardown/recreate cycle was clean in the
same run: 12 teardowns, 18 setups, zero address-in-use or RTNETLINK errors.
Worth knowing for #9486 -- its "create the dummy interface idempotently"
constraint is now the common case on a clean stop rather than the exception.

The spec covers the trap wiring, which nothing did before. The traps sit below
"${__SOURCED__:+return}", so Include cannot reach them; the spec greps the real
trap lines out of the shipped script and executes them against stubs instead of
restating them, so deleting or altering a trap fails the test. Mutation-checked
both ways: removing the TERM trap fails all three examples, and changing its
'exit 0' to 'exit 1' fails all three.

Comment and test only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from fix/localdns-pod-service-recovery to main September 23, 2026 23:31
Copilot AI balanced review requested due to automatic review settings October 5, 2026 20:57
@saewoni
Saewon Kwak (saewoni) force-pushed the feat/localdns-persist-clusterip branch from 3966acd to df230a1 Compare October 5, 2026 20:57

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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: 1 High severity · 3 Medium severity · 1 Low severity

Open (5)
What changed in this PR

Adds a systemd-driven LocalDNS pod-DNS fallback that keeps 169.254.10.11 answering (forwarding to the kube-dns ClusterIP) when localdns.service is down or not serving .11, using both OnFailure= and a debounce probe timer.

Changes:

  • Bake new fallback/probe scripts and systemd units into VHDs, and install them via packer.
  • Persist COREDNS_SERVICE_IP into /etc/localdns/environment (CSE + ANC plumbing) and wire OnFailure= + ExecStartPre stop-handoff.
  • Add ShellSpec coverage and a new e2e scenario/validator for crash-storm + clean-stop fallback behavior.
File Description
vhdbuilder/​packer/​vhd-image-builder-mariner.json Adds fallback/probe artifacts to Mariner VHD build inputs.
vhdbuilder/​packer/​vhd-image-builder-mariner-cvm.json Adds fallback/probe artifacts to Mariner CVM VHD build inputs.
vhdbuilder/​packer/​vhd-image-builder-mariner-arm64.json Adds fallback/probe artifacts to Mariner arm64 VHD build inputs.
vhdbuilder/​packer/​vhd-image-builder-cvm.json Adds fallback/probe artifacts to CVM VHD build inputs.
vhdbuilder/​packer/​vhd-image-builder-base.json Adds fallback/probe artifacts to base VHD build inputs.
vhdbuilder/​packer/​vhd-image-builder-arm64-gen2.json Adds fallback/probe artifacts to arm64 gen2 VHD build inputs.
vhdbuilder/​packer/​vhd-image-builder-arm64-gb.json Adds fallback/probe artifacts to arm64 GB VHD build inputs.
vhdbuilder/​packer/​vhd-image-builder-acl.json Adds fallback/probe artifacts to ACL VHD build inputs.
vhdbuilder/​packer/​vhd-image-builder-acl-arm64.json Adds fallback/probe artifacts to ACL arm64 VHD build inputs.
vhdbuilder/​packer/​packer_source.sh Installs fallback scripts/units into their on-node locations with correct modes.
spec/​parts/​linux/​cloud-init/​artifacts/​localdns-fallback_spec.sh Adds ShellSpec tests for fallback helper functions and corefile derivation.
spec/​parts/​linux/​cloud-init/​artifacts/​localdns-fallback-probe_spec.sh Adds ShellSpec tests for probe debounce/trigger state machine.
spec/​parts/​linux/​cloud-init/​artifacts/​cse_config_localdns_spec.sh Extends ShellSpec to cover probe-timer enablement + env-file COREDNS_SERVICE_IP persistence.
pkg/​agent/​baker.go Exposes GetCoreDNSServiceIP into template function map for CSE env-plumbing.
parts/​linux/​cloud-init/​artifacts/​localdns.service Wires OnFailure=localdns-fallback.service and synchronous fallback stop in ExecStartPre.
parts/​linux/​cloud-init/​artifacts/​localdns-fallback.sh Implements .11 fallback (interface ensure, corefile derive/min floor, guarded takeover).
parts/​linux/​cloud-init/​artifacts/​localdns-fallback.service Adds the fallback systemd unit (env file, restart policy, slice placement).
parts/​linux/​cloud-init/​artifacts/​localdns-fallback-probe.timer Adds a 5s periodic probe timer with debounce-driven effective latency.
parts/​linux/​cloud-init/​artifacts/​localdns-fallback-probe.sh Implements periodic .11 liveness probe and fallback trigger with fail-open behavior.
parts/​linux/​cloud-init/​artifacts/​localdns-fallback-probe.service Adds oneshot service invoked by timer to run the probe script.
parts/​linux/​cloud-init/​artifacts/​cse_config_localdns.sh Persists COREDNS_SERVICE_IP to env file and enables the probe timer with backward-compat guard.
parts/​linux/​cloud-init/​artifacts/​cse_cmd.sh Plumbs COREDNS_SERVICE_IP into CSE command environment.
e2e/​scenario/​validators_localdns_fallback.go Adds an end-to-end validator that stages artifacts, injects failures, and asserts handoff behavior.
e2e/​scenario/​scenario_localdns_fallback.go Registers the new LocalDNS fallback e2e scenario across distros and provisioning paths.
aks-node-controller/​parser/​parser.go Plumbs COREDNS_SERVICE_IP into the ANC-generated CSE environment.
aks-node-controller/​parser/​coredns_service_ip_repro_test.go Adds unit tests reproducing default-vs-configured CoreDnsServiceIp behavior in ANC.

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

@aks-node-assistant

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective

Run: 184262078
Failed job/stage/task: e2e / Run AgentBaker E2E / Run AgentBaker E2E

Detective summary: LocalDNSFallback failed phase0 wiring in AzureLinuxV3, Ubuntu2204, and Ubuntu2404: pods using 169.254.10.11 resolved kube-dns to the VNet DNS forwarder IP (10.0.0.10) instead of this cluster's kube-dns ClusterIP (172.16.0.10). The E2E summary shows 117 passed, 0 flaky, 76 skipped, and 4 failed; the additional LocalDNSHostsPlugin failure matches the existing restart-budget repair item.

Likely cause / signature: e2e-localdns-fallback-clusterip-mismatch - PR-specific LocalDNS fallback wiring/cluster DNS resolution regression.

Confidence: High for PR-change-caused LocalDNS fallback validation failure because the failing scenarios and changed files are all LocalDNS fallback-specific.

Recommended owner/action: PR owner should fix fallback DNS wiring so .11 fallback resolves cluster names through the cluster kube-dns ClusterIP; keep the separate restart-budget failure under repair #39909321.

Strongest alternative: existing LocalDNS restart-budget flake also occurred in this run, but three failed LocalDNSFallback scenarios share a new feature-specific phase0 kube-dns IP mismatch.

Evidence: build run, failed timeline record Run AgentBaker E2E, focused E2E log, scenario summary, PR LocalDNS fallback changed files, and wiki tracker.

Wiki: e2e-localdns-fallback-clusterip-mismatch

@saewoni

Copy link
Copy Markdown
Contributor Author

Live-cluster test plan for the pod-DNS fallback

Posting this before running it, because the first step is a blocker that the CI e2e already hit and it changes what the rest of the run can prove.

Blocker: COREDNS_SERVICE_IP resolves to 10.0.0.10 on a custom-CIDR cluster

The e2e run on df230a17 failed phase0-wiring on all three distros with:

... [10.0.0.10], not this cluster's kube-dns ClusterIP 172.16.0.10
-- pods using 169.254.10.11 cannot resolve cluster names

This is not an aks-rp problem. AgentBaker's e2e never calls aks-rp — it builds the bootstrap config itself — so ADO PR 17204945 (merged 2026-09-21, which populates ClusterNetworkConfig.CoreDnsServiceIp on the ANC builders) cannot affect this lane.

Traced it properly. Two separate defects:

1. e2e harness. e2e/scenario/aks_model.go:134 creates the cluster with DNSServiceIP: "172.16.0.10", but baseTemplateLinux() sets DNSServiceIP: "" at node_config.go:450 and :526. baseTemplateWindows() at :923 sets it correctly. So the Linux lanes hand AgentBaker an empty value.

Worth noting the scenario's own header says it runs "on a default service CIDR. A custom-CIDR variant is a follow-up" — but the e2e cluster is custom-CIDR. The scenario has been running the case it thought it was deferring, which is why it caught this.

2. Product gap on the CSE path — the same one 17204945 fixed for ANC.

// pkg/agent/datamodel/types.go:2748
func (a *AgentPoolProfile) GetCoreDNSServiceIP() string {
    if a.KubernetesConfig != nil && a.KubernetesConfig.DNSServiceIP != "" {
        return a.KubernetesConfig.DNSServiceIP
    }
    return DefaultCoreDNSServiceIP   // "10.0.0.10"
}

It reads only the agent-pool-level KubernetesConfig and then hardcodes. There is no cluster-level fallback — and aks-rp's own getCoreDNSServiceIP has one, with a comment explaining exactly why it is load-bearing:

typeconversion.go only populates the pool-level KubernetesConfig when the HCP agent pool proto carries one, and the frontend inheritance that fills it (managedcluster/helper.go) runs on PUT only. The async provisioning path reads persisted goal state and never re-runs it, so the pool value can be unset.

So an empty pool-level DNSServiceIP is a production-reachable state, not a harness artifact — the e2e is reproducing a real condition. GetCoreDNSServiceIP is wired in getContainerServiceFuncMap(config *NodeBootstrappingConfiguration) (baker.go:1657), so the cluster-level value is in scope at that point; the method just can't see it because it hangs off *AgentPoolProfile.

Fixes needed before this run proves anything:

  • harness: set DNSServiceIP in baseTemplateLinux (mirror the Windows template) and CoreDnsServiceIp on the ANC config — CoreDnsServiceIp appears nowhere in e2e/ today except a comment
  • product: add the cluster-level fallback, and fail closed rather than guessing, per Ye Wang (@yewmsft)'s review item 2. A fallback that binds .11 and forwards to an address that does not exist is worse than not binding at all.

These interact: ship fail-closed without the harness fix and the e2e goes red differently (fallback never starts, phase 1 fails). Harness first, then product.


The plan, once the above is fixed

Instrument first — a canary pod created before the fault. This is what the current e2e does not do, and it is what measures the headline claim. The PR says the outage drops from ~16 min to ~43 s; nothing measures it, and the e2e only asserts recovery inside a wait_for 30.

On the node, probe continuously from inside the canary's netns (same nsenter technique as validators_localdns_fallback.go, so it sees exactly what the pod sees):

SANDBOX=$(sudo crictl pods --name canary --state Ready -q | head -1)
NS=$(sudo crictl inspectp "$SANDBOX" | grep -oE '/var/run/netns/[A-Za-z0-9._-]+' | head -1)
while :; do
  if sudo nsenter --net="$NS" dig +short +timeout=1 +tries=1 \
       kubernetes.default.svc.cluster.local @169.254.10.11 | grep -qE '^[0-9]+\.'; then
    echo "$(date +%s.%N) OK"; else echo "$(date +%s.%N) FAIL"; fi
  sleep 0.5
done | tee /tmp/canary.log

The gap between the first FAIL and the next OK is the pod-DNS outage. That number is the deliverable.

Three faults, because they exercise three different triggers:

fault how trigger expected
unrecoverable corrupt LOCALDNS_COREFILE_BASE in /etc/localdns/environment, restart OnFailure= immediate
clean stop systemctl stop localdns probe + debounce ~15 s
crash pkill -9 -f 'coredns -conf .*updated.localdns.corefile', repeated OnFailure= via orphan-cgroup path immediate

The crash path matters separately because the dummy interface survives with .11 still assigned, which is why ensure_cluster_listener_interface() has to be idempotent. A clean stop takes the other cleanup path (cleanup_localdns_configs deletes the link), so testing only that would miss the one that actually occurs in production.

Assertions at each step:

sudo ss -lunpH | awk '$4=="169.254.10.11:53"'     # owner flips to the fallback
systemctl is-failed localdns.service               # failed
systemctl is-active localdns-fallback.service      # active
dig +short @169.254.10.11 kubernetes.default.svc.cluster.local   # answers
dig +short @169.254.10.10 kubernetes.default.svc.cluster.local   # SILENT, by design
getent hosts mcr.microsoft.com                     # node DNS fine via DHCP

Negative controls, without which the result means nothing:

  • a pod created during the outage must also be born on .11 and served by the fallback (the kubelet repoint is removed, so anything repointing kubelet is a bug)
  • the identical fault on a node without the fallback artifacts must show an unbounded gap — otherwise "the canary kept resolving" could just mean the fault never bit
  • restart localdns normally a few times afterwards and confirm the fallback does not engage; the 3x5s debounce should absorb a RestartSec=2 cycle

Recovery: restore the corefile, reset-failed + start, then assert ExecStartPre stopped the fallback, ss shows localdns.service again, .10 answers again, and the canary log shows at most a brief blip across the handoff. That window is the one thing the missing timeout on ExecStartPre could stretch (review item 4, still unfixed).

I will run this once the ClusterIP issue is resolved and report the measured numbers.

@saewoni

Copy link
Copy Markdown
Contributor Author

Ran it. Reproduced the failure locally, and traced it to the end.

Ran LocalDNSFallback/Ubuntu2204 against this PR's own VHD (buildId=184262078, SIG image 1.1791234323.13735) on subscription 8ecadfc9…. Identical failure to CI, at phase0-wiring, before any fault injection.

Full assertion output (CI truncated this)

PASS: localdns declares OnFailure=localdns-fallback.service
PASS: fallback declares no Conflicts= on localdns.service
PASS: fallback StartLimitIntervalUSec=0 (key is in [Unit])
PASS: dig is present, so the fallback probe can actually evaluate 169.254.10.11
FAIL: COREDNS_SERVICE_IP is 10.0.0.10 but this cluster's kube-dns ClusterIP is 172.16.0.10
FAIL: localdns's corefile forwards cluster DNS to [10.0.0.10], not this cluster's
      kube-dns ClusterIP 172.16.0.10 -- pods using 169.254.10.11 cannot resolve cluster names

The wiring is all correct. Every structural assertion about the fallback passes. The failure is entirely about the upstream address.

This is bigger than the fallback

Note the second FAIL. It is not about COREDNS_SERVICE_IP or the fallback — it is about localdns's own corefile. On this cluster LocalDNS itself forwards cluster.local to an address that does not exist. Pod DNS is broken before any fallback is involved. The fallback inherits the wrong upstream because it derives from that corefile, which is the derivation working as designed on bad input.

Root cause

The scenario ran the scriptless/ANC path (DisableScriptless=false; aks-node-controller.output confirms it). On that path:

// aks-node-controller/parser/helper.go:943
func getCoreDnsServiceIp(aksnodeconfig *aksnodeconfigv1.Configuration) string {
    if ... GetCoreDnsServiceIp(); coreDnsServiceIP != "" {
        return coreDnsServiceIP
    }
    return defaultCoreDnsServiceIp   // "10.0.0.10"
}

and the e2e never sets that field. grep -rn "CoreDnsServiceIp" e2e/ --include=*.go returns exactly one hit: the comment in scenario_localdns_fallback.go:18 saying a custom-CIDR variant is deferred. The cluster is created at 172.16.0.10 (aks_model.go:134), so the node silently gets the hardcoded default.

The CSE path has the same hole for a different reason: baseTemplateLinux() sets DNSServiceIP: "" at node_config.go:450 and :526, while baseTemplateWindows() at :923 sets it correctly. And GetCoreDNSServiceIP() (pkg/agent/datamodel/types.go:2748) reads only the agent-pool-level KubernetesConfig before hardcoding 10.0.0.10 — no cluster-level fallback, unlike aks-rp's helper, whose comment explains why that fallback is load-bearing:

typeconversion.go only populates the pool-level KubernetesConfig when the HCP agent pool proto carries one, and the frontend inheritance that fills it runs on PUT only. The async provisioning path reads persisted goal state and never re-runs it, so the pool value can be unset.

So an empty pool-level DNSServiceIP is production-reachable, not merely a harness artifact. Both lanes of this scenario are affected; ANC is simply the one that ran.

What this means for the PR

The scenario's header says it runs "on a default service CIDR. A custom-CIDR variant is a follow-up gated on the aks-rp CoreDnsServiceIp fix." The e2e cluster is not default-CIDR. The scenario has been exercising the custom-CIDR case it believed it was deferring — which is the only reason this surfaced.

Three things, in order:

  1. Harness — set CoreDnsServiceIp on the ANC config and DNSServiceIP in baseTemplateLinux (mirror the Windows template). Needed before this scenario can test anything.
  2. Product — GetCoreDNSServiceIP() should fall back to the cluster-level DNSServiceIP before hardcoding. The value is in scope: the template func is wired in getContainerServiceFuncMap(config *NodeBootstrappingConfiguration) (baker.go:1657); the method just cannot see it because it hangs off *AgentPoolProfile.
  3. Fail closed, per Ye Wang (@yewmsft)'s review item 2. A fallback that binds .11 and forwards to an address that does not exist is worse than not binding. Given FAIL Add vhdbuilder and publisher #2, the same argument arguably applies to LocalDNS itself — silently serving a corefile that points at a non-existent ClusterIP is a worse failure than refusing to start.

Note these interact: shipping fail-closed without the harness fix turns this red a different way (the fallback never starts, phase 1 fails). Harness first.

The canary measurement in the plan above is still unrun — it is blocked behind step 1. I will post the measured outage window once the scenario can get past phase0.

Fixes the LocalDNS/Ubuntu2404 failure in the required VHD gate (build 185408461).

The fallback worked -- the node journal shows "applied (kube-proxy)" -- and
lookup-0 (cluster name via .11) succeeded. Only lookup-1 failed:
nslookup mcr.microsoft.com 169.254.10.11, which is the longest path in the
suite: pod -> .11 -> KUBE-SVC -> a CoreDNS replica -> its own .10 -> VNet DNS.

The cause was a regression af39081 introduced. The shell that version replaced
polled this exact check with 'expect_resolves 5'; the one-shot pod design has no
retry at all, so a single timeout anywhere along that path fails the scenario.
That is why it passed three local runs before failing in CI.

The container now retries ten times at 3s. The final attempt runs with output
attached, so nslookup's own message reaches the container log, which is what
TerminationMessageFallbackToLogsOnError and logPodDebugInfo surface on failure.

Also splits the probes into one name per pod. The build log shows why:

  "Logs": "{\"status\":\"Failure\",\"message\":\"a container name must be
            specified for pod ..., choose one of: [lookup-0 lookup-1]\"}"

logPodDebugInfo calls GetLogs without a container name and discards the error
(kube.go:259), so on a multi-container pod the 400 was stored as if it were the
logs -- leaving the failure with no nslookup output at all. Three
single-container pods (node-ext, cluster-svc, cluster-ext) fix that without
touching the shared helper, and the failing probe now names itself.

The unit test asserts both properties, so a regression to one-shot or to
multi-container fails without needing a cluster.

Verified live against this PR's VHD build, all four scenarios:

  LocalDNS/Ubuntu2204               9 DNS pods, VM validation succeeded
  LocalDNS/Ubuntu2404               9 DNS pods  (the scenario CI failed on)
  LocalDNS/AzureLinuxV3             9 DNS pods  (first local run)
  LocalDNSNoHostsPlugin/Ubuntu2204  4 assertions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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 fallback is not retried when boot ordering makes kube-proxy and Cilium unavailable during its one activation attempt.

2 open findings

🧠 Review effort: Balanced

azure-iptables-monitor runs on Cilium nodes, scans every iptables table against
an allowlist, and labels the CiliumNode kubernetes.azure.com/user-iptables-rules
for anything it cannot match -- which makes the RP reject enabling ACNS
Performance on that cluster. Today all nine of the fallback's rules are
unmatched, so a localdns outage on a Cilium node with host legacy routing would
trip it for the duration.

The allowlist already carries a global pattern for AgentBaker-managed rules,
and has since last year:

    ^.*--comment.*AKS managed: added by AgentBaker

so tagging these the way ensure_imds_restriction.sh tags its own costs nothing
and needs no aks-rp change. That matters: the cilium synth rolls out more slowly
than the VHD, so a new allowlist pattern would have had to land well ahead of
this to avoid a window where nodes carry rules the monitor does not know.

Verified by driving the real apply_rules in an isolated netns -- not a
reproduction of it -- and matching the output with Go's regexp, the same package
the monitor uses:

    kube-proxy and cilium branches, 12 rules  ->  0 unexpected
    without the comment                       ->  9 unexpected

The hook jumps need the comment too, since the monitor checks PREROUTING and
OUTPUT, and that is the part with a trap: iptables -D matches on the full rule
spec, so clear_rules has to delete with the same comment it inserts with or the
jumps survive and keep redirecting past a recovered localdns. The netns run
confirms the chain and both jumps are gone after clear_rules, and phase 4
asserts the same thing on a real node.

Three shellspec examples pin the literal, the insert/delete symmetry and the
count, because getting the comment wrong fails silently: the rules install,
work, and are simply reported as user iptables rules. Eight existing
expectations needed updating for the new rule shape -- they caught the change,
which is what they are for.

Shellspec 35 examples, 0 failures. Verified live against this PR's VHD build on
LocalDNS/{Ubuntu2204,Ubuntu2404,AzureLinuxV3} and LocalDNSNoHostsPlugin: 9 DNS
pods each, fallback unit never entered 'failed'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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 rule matcher and main-image execution path currently fail valid runs, while Cilium dual-stack backends can break fallback activation.

3 open findings

🧠 Review effort: Balanced

@aks-node-assistant

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective

Run: 185460351
Failed job/stage/task: e2e / Run AgentBaker E2E / LocalDNS fallback + NOTRACK validation

Detective summary: LocalDNS fallback scenarios on AzureLinuxV3, Ubuntu2204, and Ubuntu2404 failed because the validator could not find the expected top-level -A PREROUTING -j LOCALDNS-FALLBACK jump, while AgentBaker-managed fallback DNAT rules to 169.254.10.11 were present. A fourth scenario also repeated the LocalDNS NOTRACK exact-rule regex miss.

Likely cause / signature: e2e-localdns-fallback-prerouting-chain-missing plus e2e-localdns-notrack-rule-regex-missing - LocalDNS fallback NAT chain validation and recurring NOTRACK validator sensitivity.

Confidence: High for surface signatures: failed test records, E2E log summary, timeline, and PR LocalDNS fallback changes all point to fallback rule-generation/validation; medium-high on PR causality because PR 9486 directly changes LocalDNS fallback behavior.

Recommended owner/action: LocalDNS fallback owner should verify the LOCALDNS-FALLBACK chain/jump installation and adjust validator expectations for the AgentBaker-managed fallback rules.

Strongest alternative: Shared cluster networking flake, but multiple OS variants fail with the identical missing fallback-chain assertion in LocalDNS-specific validation.

Evidence: build run, failed test run 687633464, failed E2E timeline, PR LocalDNS fallback changes, prior LocalDNS rows, and wiki tracker.

Wiki:
e2e-localdns-fallback-prerouting-chain-missing
and
e2e-localdns-notrack-rule-regex-missing

Fixes LocalDNS/{Ubuntu2204,Ubuntu2404,AzureLinuxV3} in the required VHD gate
(build 185460351), broken by 11ae357.

validateLocalDNSNATRules asserted the hook jumps as exact lines:

    -A PREROUTING -j LOCALDNS-FALLBACK

Once the rules carry -m comment --comment "AKS managed: added by AgentBaker ...",
that text sits between the hook and the -j, so the exact match fails. The chain's
own rules were fine -- only the two jumps are asserted this way.

Matched as patterns now, and both forms have to pass. A lane resolving a
main-built image runs a localdns-fallback.sh that predates the comment; the VHD
gate builds from the branch and gets one that writes it. The validator has to
work against either.

Why my local runs missed it: they pin VHD_BUILD_ID=184891026, an image built
from c12de4a, so the node ran the fallback script as it was before the comment
and the old assertion passed. With staging removed the node always uses the
VHD's copy, which means a local run can verify changes under e2e/ but CANNOT
verify changes under parts/ -- only a gate that builds the VHD from the branch
can. That is working as intended, and is worth knowing before trusting a local
pass on a shipped-code change.

The unit test now covers both forms, so this cannot regress without a cluster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

IPv6 resolver or backend entries can prevent fallback activation, and fault setup errors can leave mutated node state.

2 open findings
1 resolved since last review
Previously missed (3)

In code that hasn't changed since last review

Medium severity Enable errexit before mutating LocalDNS configuration

e2e/​scenario/​scenario_localdns_fallback.go:75

🟡 Medium Risk — 🔧 Script Logic: This fault test mutates /etc/localdns/environment without errexit. If the backup command or another setup command fails, execution can continue into the mutation and teardown may have no valid configuration to restore, leaving the scenario node broken for later validators. Enable -e; the intentionally tolerated commands already use conditions or || true.

Medium severity Filter nameservers to IPv4 before iptables DNAT

parts/​linux/​cloud-init/​artifacts/​localdns-fallback.sh:105

🔴 High Risk — 🔧 Script Logic: This accepts an IPv6 nameserver, but the result is later passed to IPv4 iptables ... DNAT --to-destination. If an IPv6 resolver appears before a usable IPv4 resolver, activation fails even though fallback could use the later IPv4 entry. Filter this selection to IPv4 addresses and cover an IPv6-first resolver list in ShellSpec.

Low severity Rename scenarios to the documented OS-first format

e2e/​scenario/​scenario_localdns_hosts.go:34

The renamed scenarios still put the configuration before the OS (LocalDNS/Ubuntu2204), contrary to the required <OS/image>_<distinguishing configuration> format in e2e/README.md:33-47. Since exact names are selectors and have no aliases, use the documented OS-first form while performing this rename.

🧠 Review effort: 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

Cilium dual-stack backends are mixed into IPv4 iptables rules, preventing fallback activation.

2 open findings

🧠 Review effort: Balanced

@aks-node-assistant

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective

Run: 185535615
Failed job/stage/task: e2e / Run AgentBaker E2E / AzureLinuxV3_PreinstalledBinaries and Ubuntu2204Gen2_ImagePullIdentityBinding_Disabled

Detective summary: This run had two known signatures: AzureLinuxV3_PreinstalledBinaries breached the AKS.CSE.ensureContainerd 2s threshold at 6.954s, and Ubuntu2204Gen2_ImagePullIdentityBinding_Disabled repeated the LocalDNS NOTRACK exact-rule validation miss. The first is generic CSE timing sensitivity; the second is LocalDNS validation/rule matching and is plausible PR-related for PR 9486.

Likely cause / signature: cse-ensurecontainerd-duration-threshold and e2e-localdns-notrack-rule-regex-missing.

Confidence: High for both surface signatures from failed test records, build report warning, E2E task/timeline data, and prior matching wiki rows; medium for PR causality only on the LocalDNS signature.

Recommended owner/action: LocalDNS owner should inspect NOTRACK validator/rule expectations in the fallback PR; CSE timing threshold is likely recurring gate noise unless it persists.

Strongest alternative: Shared infra latency caused both failures, but the NOTRACK failure is deterministic validator output while ensureContainerd is a known timing threshold flake.

Evidence: build run, failed test run 688783423, E2E task log 624, failed timeline, PR LocalDNS fallback changes, and wiki tracker.

Wiki:
cse-ensurecontainerd-duration-threshold
and
e2e-localdns-notrack-rule-regex-missing

…ssing

The check ran `echo "$rules" | grep -Eq "$rule"` under `set -o pipefail`.
grep -q exits on its first match, which closes the pipe and kills the
still-writing echo with SIGPIPE; pipefail then reports the whole pipeline
as failed. Once `iptables -t raw -S` exceeds the 64KB pipe buffer the
writer is forced to block mid-write, so a rule that is present -- and
found early -- gets reported missing.

Measured false failures on a healthy table: 0/100 at 50KB, 2/100 at 81KB,
100/100 at 204KB. Build 185535615 hit it on
Ubuntu2204Gen2_ImagePullIdentityBinding_Disabled with all 12 rules present
and the counts exactly right.

Replace the three counts and the 12 per-rule greps with one sorted set
comparison. awk and sort read to EOF, so no reader can orphan the writer.
Comparing the full set also rejects duplicate or unexpected extra rules,
which per-rule matching could not. On failure it now prints a diff and the
raw table size, since an oversized raw table is the one thing the old
output never revealed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

IPv6 resolver and Cilium backend entries can currently make the IPv4 NAT fallback fail to activate.

2 open findings
Previously missed (1)

In code that hasn't changed since last review

Medium severity Select an IPv4 nameserver for the IPv4 iptables fallback

parts/​linux/​cloud-init/​artifacts/​localdns-fallback.sh:105

🟡 Medium Risk — 🔧 Script Logic: This accepts an IPv6 nameserver even though the fallback programs the IPv4 iptables table. If the restored resolver file lists IPv6 first (mixed resolver files are already supported by localdns.sh), --to-destination "${vnet_dns}:53" fails and the fallback never installs despite a usable IPv4 resolver later in the file. Select an IPv4 upstream here (or implement an explicit cross-family path) and add an IPv6-first fixture.

🧠 Review effort: 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.

LGTM

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 default E2E lane targets an older main VHD, and dual-stack Cilium backends are not restricted to IPv4.

2 open findings

🧠 Review effort: Balanced

@aks-node-assistant

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective

Run: 185588062
Failed job/stage/task: e2e / Run AgentBaker E2E / Ubuntu2604Minimal_DisableKubeletServingCertificateRotationWithTags_AlreadyDisabled_CustomKubeletConfig

Detective summary: One E2E scenario failed during LocalDNS conntrack validation, repeating the repair-linked NOTRACK exact-rule regex miss in a LocalDNS fallback PR run.
Likely cause / signature: e2e-localdns-notrack-rule-regex-missing - recurring LocalDNS NOTRACK exact-rule validator sensitivity.

Confidence: High: failed test record, E2E task log, timeline, and prior repair-linked wiki row all isolate the same LocalDNS validation family.

Recommended owner/action: Repair #40133693 tracks LocalDNS/E2E owner follow-up on raw-table NOTRACK rule generation and exact regex validator assumptions.

Strongest alternative: Generic infra flake is weaker because the failed assertion is deterministic LocalDNS raw-table validation and PR 9486 changes LocalDNS behavior.
Evidence: build run, failed test run 689547422, failed E2E timeline, PR LocalDNS fallback metadata, repair #40133693, and wiki tracker.

Wiki: e2e-localdns-notrack-rule-regex-missing

Saewon Kwak (saewoni) and others added 2 commits October 14, 2026 00:10
The host-side probe was the only un-retried check in the fallback validator:
one `dig +timeout=3 +tries=1` piped into grep, with +tries=1 disabling dig's
own retry. The gate runs 60 scenarios in parallel with 72 of them sharing one
cluster and its two CoreDNS replicas, so a single attempt on that load
occasionally loses -- which failed LocalDNS/AzureLinuxV3 in build 185588062
while the pod probes, which retry ten times, passed seconds earlier.

Piping into grep also discarded dig's output, so the failure reported a bare
exit 1: no timeout, no SERVFAIL, nothing to diagnose from.

Retry in Go with the PollUntilContextTimeout already used twice in this file,
and send the node a bare dig with no shell logic at all. Using the helper that
does not assert the exit code keeps a timed-out attempt a retry rather than a
fatal error, so there is no errexit guard to get wrong, and the last answer is
captured in Go and reported in the error.

dnsAnswerHasAddress replaces the regex and is unit-tested off-node: 13 cases
covering CNAME-only answers, dig error text, IPv6, and CRLF. Mutation-tested --
dropping .To4(), dropping TrimSpace, or returning true each redden a case.

Measured on a live AzureLinuxV3 node with the fallback active: the path this
probes succeeded 60/60, so the structural concern that host traffic entering
KUBE-SVC skips KUBE-MARK-MASQ does not break resolution here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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 production Cilium API parsers lack direct automated fixture coverage, leaving that fallback path vulnerable to silent schema mismatches.

4 open findings

🧠 Review effort: Balanced

@yewmsft

Ye Wang (yewmsft) commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Following up on the iptables allowlist thread now that we have the full picture:

  1. Please revert the allowlist change: 11ae357 (tag the fallback NAT rules as AKS managed: added by AgentBaker), plus the e2e hook-pattern loosening in 0d47b43 that exists only to accept both forms. No test exercises it. Nothing runs azure-iptables-monitor while the fallback is active, and the shellspec only pins the comment literal. So it adds surface without buying anything we can verify.
  2. Adding the fallback rules to the Cilium allowlist (aks-rp allowed-iptables-patterns configmap) can come later, as explicit patterns. Nice to have, not required for this PR. The monitor's kubernetes.azure.com/user-iptables-rules label only matters for one thing: the RP rejects a PUT that turns on ACNS Performance for a cluster while any node carries it. The fallback rules exist only while localdns is down, and they're removed once it recovers. So a missing allowlist entry can only fail that one PUT during an outage, and the customer can retry once the node recovers. DNS itself is never affected.
  3. The real bug is on ACNS Performance (eBPF host routing) nodes. The azure-block-iptables LSM that Cilium attaches rejects the raw-table NOTRACK adds in localdns.sh. The first boot works only because localdns adds its rules before the blocker attaches. Any later restart (manual, watchdog, re-apply) gets iptables: Permission denied and localdns ends up terminally failed, which kills pod DNS on the node. I reproduced this on 1.35 / AzureLinux / Cilium overlay / ACNS BpfVeth. It needs its own fix in a separate PR, not this one.
  4. Meanwhile, we're adding protection on the RP side: LocalDNS Preferred mode won't turn on for ACNS Performance clusters: https://dev.azure.com/msazure/CloudNativeCompute/_git/aks-rp/pullrequest/17517514

Reverts 11ae357 and 0d47b43, per review.

The --comment tag was added so azure-iptables-monitor's existing
"AKS managed: added by AgentBaker" allowlist pattern would match the
fallback's nat rules. Nothing in the suite exercises that: the monitor never
runs while the fallback is active, and the shellspec only pinned the comment
literal back to itself. It was surface we could not verify.

0d47b43 only existed to let the e2e accept hooks both with and without that
comment, so it goes with it. validateLocalDNSNATRules returns to exact-line
matching, which is the stricter check.

The allowlist entry can be added later as explicit patterns. A missing one
cannot affect DNS: the monitor's user-iptables-rules label only gates turning
on ACNS Performance, the fallback rules exist only while localdns is down, and
they are removed when it recovers -- so the worst case is one PUT failing
during an outage, retryable once the node is back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

IPv6 upstream selection can prevent fallback installation, and the default-main E2E lane lacks rollout-aware gating.

3 open findings
2 resolved since last review

🧠 Review effort: Balanced

@aks-node-assistant

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective

Run: 185670984
Failed job/stage/task: e2e / Run AgentBaker E2E / LocalDNS/AzureLinuxV3

Detective summary: LocalDNS/AzureLinuxV3 failed lifecycle validation after localdns was killed with Restart=no; the check reached DNS recovery and reported that the node could not resolve after localdns died.

Likely cause / signature: e2e-localdns-lifecycle-execstoppost-validation-failed - LocalDNS lifecycle cleanup/DNS recovery validation failure.

Confidence: High: the failed test record, E2E log, timeline, adjacent LocalDNS scenario output, and PR 9486 LocalDNS fallback change scope all point at LocalDNS lifecycle behavior rather than generic infrastructure.

Recommended owner/action: LocalDNS fallback/lifecycle owner should inspect terminal-death DNS restoration and ExecStopPost cleanup behavior on AzureLinuxV3.

Strongest alternative: Shared E2E environment flake, but the failure is a deterministic LocalDNS lifecycle assertion while node provisioning and adjacent scenarios reached ready.

Evidence: build run, failed test run 690532741, E2E task log 624, failed timeline, PR metadata, and wiki tracker.

Wiki: e2e-localdns-lifecycle-execstoppost-validation-failed

Build 185670984 failed LocalDNS/AzureLinuxV3 on a bare `systemctl restart`
at scenario_localdns_hosts.go:218 with one line of output:

    Job for localdns.service failed because a timeout was exceeded.

Stdout was empty -- the script runs under `set -eu` and aborted mid-call
before printing anything -- so a start-limit refusal, a bind conflict and a
genuinely slow start are indistinguishable. The two journalctl calls already
in the script sit after line 218 and never ran.

Capture Result, the unit state and the last 50 journal lines on any failure
of this validator. Result alone separates the candidates: `timeout` means the
start budget was breached, `start-limit-hit` means StartLimitBurst was spent
and retries would not help, `exit-code` means localdns ran and failed.
TimeoutStartUSec is included because it is not set in the unit and the OS
default differs by distro -- 90s on Ubuntu, 45s on Azure Linux, measured on a
live node -- while the script's own readiness wait is 60s. On Azure Linux
systemd therefore wins that race and the script can never report its own
error, which fits the empty stdout and the distro-specific failure. That
remains a hypothesis; this is what would confirm it.

Evidence only: no shipped artifact changes, and nothing here alters node
behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Health
test 0% ❌
windows 27% ❌
staging_cse_windows Package 1 65% ➖
debug 0% ❌
provisioningscripts 2% ❌
parts_windows Package 1 74% ➖
Summary 38% (1649 / 6831) ❌

@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 68% ➖
github.com/Azure/agentbaker/aks-node-controller/parser 88% ✔
github.com/Azure/agentbaker/aks-node-controller/pkg/gpu 100% ✔
github.com/Azure/agentbaker/aks-node-controller/pkg/nodeconfigutils 88% ✔
github.com/Azure/agentbaker/aks-node-controller/utils 0% ❌
github.com/Azure/agentbaker/apiserver 24% ❌
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 76% ➖
github.com/Azure/agentbaker/pkg/agent/datamodel 76% ➖
github.com/Azure/agentbaker/pkg/agent/toggles 0% ❌
github.com/Azure/agentbaker/pkg/vhdbuilder/datamodel 87% ✔
Summary 74% (5610 / 7570) ➖

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Health
shellspec spec 25% ❌
Summary 25% (3831 / 15435) ❌

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 standalone E2E PR lane selects a main-built VHD without the newly baked fallback artifacts, causing guaranteed validation failures.

3 open findings
1 resolved since last review

🧠 Review effort: Balanced

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.

LGTM. Rechecked since my last approval: the allowlist-tagging revert brings localdns-fallback.sh and its spec back to their pre-11ae357d5b state with exact-line NAT matching, and the node dig retry and lifecycle diagnostics are test-only.

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