Skip to content

fix: bound LocalDNS restart budget so failures terminate deterministically - #9439

Merged
lilypan26 merged 36 commits into
mainfrom
fix/localdns-pod-service-recovery
Sep 23, 2026
Merged

lilypan26 merged 36 commits into
mainfrom
fix/localdns-pod-service-recovery

Conversation

@saewoni

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

Copy link
Copy Markdown
Contributor

TL;DR

localdns.service gets a bounded restart budget, so unrecoverable failures terminate in failed instead of restarting forever:

StartLimitIntervalSec=720
StartLimitBurst=5
RestartSec=2

failed is the prerequisite for handoff. OnFailure= only fires on entry to failed, and NPD needs a stable terminal state to observe. Under systemd's default budget most failure modes never get there.

Why these numbers

Threshold = StartLimitIntervalSec / StartLimitBurst. A failure repeating more slowly than the threshold never accumulates StartLimitBurst starts inside the window, so it restarts forever and never reaches failed.

systemd's default is 10s/5 — a 2s threshold. Every enumerated failure mode cycles slower than that except the fastest pre-flight exit, so today they restart indefinitely:

failure mode approx cycle today 720/5
pre-flight / orphan cgroup ~2.5s failed failed ~13s
resolv.conf drain ~8s forever failed ~40s
dies right after READY ~5–8s forever failed ~25–40s
PID file never appears ~13s forever failed ~65s
watchdog kill ~67s forever failed ~5.5min
ready-check timeout ~63–72s forever failed ~6min
hung start ~95–125s forever failed ~10.5min

720/5 gives a 144s threshold, above the slowest restart cycle — TimeoutStartSec + TimeoutStopSec + RestartSec, which is 122s on Ubuntu and 107s on Azure Linux. So every unrecoverable mode terminates. Burst stays at the systemd default; only the window changes.

Slower flapping (>144s cycle) and serves-for-hours-then-crashes still restart, deliberately. Those served real traffic, and escalating a node that is up 98% of the time is NPD's call, not systemd's.

RestartSec=2 is the load-bearing change. cleanup_iptables_and_dns runs networkctl reload on every start, and the dummy interface carrying 169.254.10.10/169.254.10.11 is deleted and recreated on every start. At the 100ms default each retry re-enters the transient it is retrying against — five retries are effectively one. At 2s, networkd settles and an orphaned CoreDNS releases its sockets before the next ExecStart.

Why the budget ships in the VHD unit

An earlier revision of this PR wrote the budget from CSE into a drop-in, to keep it from wedging an older CSE's 100-iteration retry loop. That loop turned out not to be worth defending.

localdns.sh already absorbs ~80s of transient inside a single start attempt — wait_for_localdns_removed_from_resolv_conf 5, START_LOCALDNS_TIMEOUT=10, wait_for_localdns_ready 60 60 — and iptables -w blocks on the xtables lock rather than failing, so the classic provisioning transient never surfaces as a start failure at all. Every remaining fatal exit is deterministic: attempt 300 fails for exactly the reason attempt 1 did. There is no failure shape in that script that repetition converts into a success.

So what a VHD-baked budget costs an older CSE is failing at ~10s with a readable journal instead of ~584s and 95 Start request repeated too quickly lines. Same node outcome, 574 seconds cheaper, better diagnosis.

Keeping the budget in the unit also means the guarantee is unconditional — it does not depend on which CSE provisioned the node — and RestartSec=2 ships alongside it rather than landing alone on nodes where it would push failures further from failed.

TimeoutStartSec and TimeoutStopFailureMode are deliberately not pinned. Unpinned, the worst cycle is 122s on Ubuntu and 107s on Azure Linux, both under the threshold. The 153s that motivated pinning TimeoutStopFailureMode=terminate was itself created by pinning TimeoutStartSec=90 on a distro that defaults to 45.

Restart=on-failure, KillMode=mixed and TimeoutStopSec=30 are already in the shipped unit and are unchanged; they appear above only because the threshold math depends on them.

Stop-path change

SIGTERM was previously untrapped, so bash died on the default action: the EXIT cleanup never ran, and any in-flight watchdog sleep was left orphaned in the cgroup, holding the unit in stop-sigterm until it finished or TimeoutStopSec expired. This PR traps it, reaps the child, and exits 0 — a requested stop is not a failure, and Restart=on-failure must not fire for it.

That changes what a stop does, deliberately. exit 0 fires the EXIT trap, so cleanup_localdns_configs now runs in-process on every stop, where previously only ExecStopPost ran. Two consequences:

  • A stop takes 5–6s instead of being immediate, essentially all of it LOCALDNS_SHUTDOWN_DELAY draining connections before CoreDNS is SIGINTed. That is against the timeout 30 bound _systemctl_retry_svc_operation puts on a restart, leaving ~22–24s of headroom. It does not affect the budget arithmetic: the worst cycle is a hung start, where the trap cannot run at all, because bash defers a trapped signal until the foreground command returns.
  • The dummy interface carrying .10/.11 is now torn down on a clean stop. The ExecStopPost path deliberately leaves it alone, in case an orphaned CoreDNS is still answering on .11. Worth noting for the pod-DNS fallback (feat: LocalDNS pod-DNS (.11) fallback with OnFailure + probe triggers #9486): idempotent interface creation becomes the common case on a clean stop, not the exception.

localdns_spec.sh asserts the SIGTERM/watchdog behaviour.

Scope / what this is not

One note for later: once the fallback lands, burst × C_max becomes the pod-DNS outage ceiling (~10.5min worst case here), so these numbers gain a second consumer and should not be treated as settled forever.

Testing

e2e asserts two things, in scenario_localdns_restart_budget.go:

  1. The budget ships in localdns.service itself rather than a drop-in. systemctl show reads effective values and cannot tell those apart, so the unit file is checked directly — the budget living in the VHD unit is what makes the guarantee unconditional.
  2. The unit actually reaches terminal failed with Result=start-limit-hit once the burst is spent. A directive can be present and still bound nothing.

Relationship to the stack

#9360  restore node-level DNS (.10) on unexpected exit (ExecStopPost)
  └─ #9439  (this PR) bound restart budget so failures reach 'failed'
       └─ #9486  pod-DNS .11 fallback: OnFailure= + probe take over .11

@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Windows Unit Test Results

  3 files   17 suites   1m 7s ⏱️
533 tests 533 ✅ 0 💤 0 ❌
536 runs  536 ✅ 0 💤 0 ❌

Results for commit 6ffca34.

♻️ This comment has been updated with latest results.

@saewoni
Saewon Kwak (saewoni) added this pull request to stack #9440 September 9, 2026 21:05
@saewoni

Saewon Kwak (saewoni) commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Node SIG review requested

Could Node SIG please review AgentBaker PR #9439?

This PR addresses a LocalDNS pod-DNS outage after repeated LocalDNS crashes. When localdns.service exhausts systemd's default restart limit, it enters failed and stops serving the pod-facing DNS listener at 169.254.10.11. Existing pods continue using that address in /etc/resolv.conf, so pod DNS remains unavailable until LocalDNS is manually restored.

The PR adds a controlled systemd recovery policy:

StartLimitIntervalSec=300
StartLimitBurst=30
RestartSec=2

When LocalDNS restarts, CoreDNS rebinds 169.254.10.11 and existing pods can recover DNS.

Live validation

We reproduced the failure and the recovery on the same live LocalDNS-enabled AKS node:

  • Shipped behavior: a crash storm exhausted the default restart limit, left localdns.service in failed, stopped the .11 listener, and left pod DNS broken until manual restoration.
  • With this PR: the same type of crash storm recovered the service to active/running, brought .11 back, and pod DNS recovered after the restart/backoff window.

The pod continued using the same nameserver 169.254.10.11; no pod recreation or resolver-file rewrite was needed.

PR #9439 is stacked on AgentBaker PR #9360, which handles node-level DNS restoration through ExecStopPost. #9439 handles service recovery so the pod-facing listener comes back.

Questions / current findings

  1. Should systemd be first-line recovery?

    We believe yes. Systemd sees the service failure immediately and can retry without depending on NPD polling, Kubernetes API availability, node-condition propagation, or another remediation service.

  2. Are the restart values appropriate?

    The values successfully recover the reproduced crash storm. They are a bounded recovery policy, not an infinite guarantee: a persistently broken service can still exhaust 30 attempts in five minutes. We would appreciate Node SIG guidance on whether these values are appropriate or whether persistent failure should hand off to another recovery/fallback path.

  3. Is KillMode=mixed appropriate?

    It appears appropriate for the localdns.sh supervisor plus its CoreDNS child: systemd signals the main process first and cleans up remaining service processes so the next start can bind the listeners cleanly. The explicit KillSignal=SIGTERM documents the normal graceful-stop signal; it is not itself the recovery or child-reaping mechanism.

  4. Does LocalDNS NPD already recover the failed service?

    We inspected the aks-vm-extension NPD implementation. check_dns_to_localdns.sh detects the service/listener failure and emits LocalDNSError / LocalDNSProblem. The inspected remediate_dns.sh only repairs generic network state and can restart systemd-networkd; it does not reset or restart localdns.service. We found no LocalDNS-specific service restart path in that repository. If another internal component consumes LocalDNSProblem and performs remediation, please point us to it so the ownership can be coordinated with systemd.

This PR is intended to improve recovery from transient crash storms. It does not add an infinite retry policy or a zero-downtime DNS standby fallback. Persistent-failure handling and a zero-gap fallback remain separate design questions.

Saewon Kwak (saewoni) and others added 12 commits September 22, 2026 23:37
… 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>
…he burst check

Two fixes, both from reviewing the e2e after build 181818040 lost three
LocalDNS lanes.

1. The lifecycle script outgrew the Bastion tunnel.

   Scripts are SCP'd to the node, and tunnelSession.Write (bastionssh.go)
   forwards each SSH packet as one websocket message with no chunking
   against Bastion's 8,192-byte cap. go-scp sends a 4,096-byte first chunk
   then the remainder in a single write, so

       max_write = script_size - 4096 + 45      (45 = framing + MAC)

   which first exceeds the cap at a script size of 12,243. The lifecycle
   script had been sitting 380 bytes under that and grew 512, producing the
   8,324-byte write that appears verbatim in the StatusMessageTooBig close
   frame. The scenario dies as a lost SSH session and loses its node logs,
   so nothing points at script length as the cause.

   53% of that script was comments. Comments cost the same on the wire as
   code and do nothing at runtime, so the rationale moves into the Go doc
   comment -- none of it is lost, and the script goes 12,375 -> 6,821 bytes.
   The same treatment on localdnsFaultRunScript takes it 5,765 -> ~4,170,
   which matters more than it looks: that one is sent seven times per run.

   TestLocalDNSScriptsFitBastionLimit now measures every script this package
   sends, not just the one that broke, and prints the headroom.

   The real fix is chunking in tunnelSession.Write. That is shared e2e
   infrastructure and out of scope here; this guard holds the line until it
   lands, and every other caller in the suite still has the same cliff.

2. The ExecStart count is now bounded on both sides.

   The comment claimed "exactly the burst should have run" while the check
   only rejected fewer, so a limiter that allowed twenty starts before
   eventually tripping would pass -- the journal refusal line appears either
   way. It is now [burst, burst+1].

   The upper bound has deliberate slack. Copilot twice recommended equality;
   the two measurements disagree on whether that is safe:

       by hand, live node (2026-09-17):        postready = 6, others 5
       CI, gate build 181777373 (Ubuntu2404):  postready = 5, every mode 5

   The 6 is unreproduced and unexplained. Tolerating it costs little -- a
   limiter allowing exactly one extra start slips through -- against a lane
   that flakes only when it recurs. The doc comment records both numbers so
   the next reader sees the conflict rather than re-tightening it, and notes
   that the hungstart journal count is not usable evidence here because
   measureLocalDNSWorstCycle runs that same fault before the matrix.

   Also recorded: $burst is read from the live unit, so this compares the
   SUT against itself, and is only sound because
   assertLocalDNSBudgetDirectives pins StartLimitBurst to 5 earlier in the
   same validation. Removing or reordering that assertion silently guts this
   check.

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

fbe622a pinned TimeoutStopFailureMode=terminate in localdns.service. It has no
effect there. systemd.unit(5): "Drop-in files under any of these directories take
precedence over unit files wherever located." Azure Linux ships a type-wide
drop-in at /usr/lib/systemd/system/service.d/10-timeout-abort.conf (systemd.spec
line 893) setting TimeoutStopFailureMode=abort, so the unit file loses and the
effective value on AzureLinuxV3 stayed 'abort'.

Which means the cycle stayed at the 153s that pin was written to avoid -- above
the 144s threshold, so the slow failure modes would restart forever on that
distro. Exactly the bug this PR exists to remove, reintroduced by the fix for it.

Reproduced and fixed with the real files rather than a synthetic unit:

  unit file only                              -> terminate
  + /usr/lib/.../service.d/10-abort drop-in   -> abort        <- the bug
  + localdns.service.d/delegate.conf          -> terminate    <- the fix

Effective budget afterwards: Restart=on-failure, RestartUSec=2s,
TimeoutStartUSec=1min 30s, TimeoutStopUSec=30s,
TimeoutStopFailureMode=terminate, StartLimitIntervalUSec=12min,
StartLimitBurst=5, Delegate=yes/cpu. Worst cycle 90+30+2 = 122s against the 144s
threshold, on every image.

localdns-delegate.conf is the right home because it is already a unit-specific
drop-in for this unit, already installed to
/etc/systemd/system/localdns.service.d/delegate.conf by packer_source.sh, and
already shipped by all nine packer definitions plus azlosguard.yml. A new
drop-in file would mean editing each of those, where missing one ships an image
without it and fails silently months later on one distro. The cost is that a
file named for Delegate=cpu now also carries a stop-timeout directive; mitigated
by the comment there and a pointer from localdns.service, and a rename is a
standalone change if it is wanted.

The e2e assertion is unchanged and was right all along: it reads the effective
value via systemctl show, so it does not care which file sets it. Credit to
@yewmsft for catching that the pin could not work where it was.

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

Five review items from @yewmsft.

1. The Bastion size constant was justified two ways that land a byte apart. The
   sweep is the measurement and the formula is a model: 12,242 is safe and 12,243
   produced an 8,196-byte write, where the formula predicts 8,192. It ignores SSH
   block padding, so writes step rather than increment. Comment now says to trust
   the sweep and not to "correct" the constant upward from the arithmetic.

2. "Covers every script" was only true of the ones someone remembered to add to
   the map. TestLocalDNSScriptInventoryIsRegistered now derives the inventory from
   the source and fails the build when a declared localdns*Script is not
   size-checked. Mutation-tested: declaring an unregistered script fails it.

3. 'if ! retrycmd_if_failure ...' discarded the return code. '!' inverts before $?
   is read, so a CSE budget timeout (2) and a genuine failure (1) both reported as
   "could not be enabled by systemctl", sending the on-call after systemd when the
   cause was provisioning running out of time. Captured and distinguished, matching
   what localdns_giveup_reason already does for the start loop. ShellSpec covers
   the new path; mutation-tested against the old form.

4. The 100-attempt count named a ceiling the loop cannot reach -- check_cse_timeout
   reaches its limit first in any realistic run. Dropped the ceiling language from
   the give-up reason and the per-attempt message rather than substituting another
   invented number, and said so where the constant is declared.

5. Hoisted daemon-reload above the loop. Nothing rewrites a unit between
   iterations, and it re-parses every unit on the box. Fair catch that the same
   cost argument was applied to the helper's per-attempt dumps but not its reload.

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

Four review items from @yewmsft.

1. The Bastion size comment named a mechanism I had not isolated, and got it wrong
   twice. Ye pointed out SSH pads each binary packet to a multiple of the cipher
   block size, so a 1-byte script change moves the wire write by 0 or 16 -- never
   by the 5 the comment claimed. Swept the range again and pasted the rows:

     12236..12242 -> 8180
     12243..12248 -> 8196   <- first over 8,192

   A clean 16-byte step, and no script size lands on 8,192 at all. That also
   retires max_write = size - 4096 + 45, which is off by 11 at the plateau and
   implies a granularity the transport does not have. The constant 12242 was
   right; only the explanation beside it was wrong. Data now, not a story --
   measured rows survive the next person doing arithmetic at 2am.

2. The inventory guard closed the gap one step short. 'registered' and the size
   test's 'scripts' were two hand-maintained lists with nothing tying them
   together, so declaring a script and adding it to one but not the other left
   both tests green and the script unchecked -- the same failure the guard exists
   to prevent, moved up a level. My own error message asked for two edits and
   verified one. localdnsScriptsUnderTest is now the single inventory both read,
   so registering and size-checking are one edit. Mutation-tested with exactly
   that scenario.

3. The scanned file list was the inventory restated a third time, and missed
   validate_localdns_exporter_metrics.go. Replaced with filepath.Glob("*.go") --
   go test runs in the package dir, so new scenario files are covered on arrival.
   Recorded the gap it still cannot close: the regex keys on the localdns*Script
   naming convention, so a script sent under another name stays unguarded.

4. Rewrapped the spliced comment in cse_config_localdns.sh and split the
   check_cse_timeout argument, which was being made twice in four lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yewmsft asked how many of the 100 attempts the loop actually gets. Measured
against a Type=notify unit with this loop's exact shape (reset-failed, timeout 30
systemctl restart, sleep 5):

  failure shape                         per iteration   iterations in 780s
  fails at once (pre-flight)                       5s          ~156
  fails after ~8s (resolv.conf drain)             13s           ~60
  hangs, capped by 'timeout 30'                   35s           ~22

Cost per iteration is dominated by how long the start takes to fail, because
'systemctl restart' on a Type=notify unit blocks until ready-or-failed. Only the
fastest shape reaches 100; every slower one hits the CSE deadline first, and that
is before anything else in CSE consumes the budget.

His ~60 estimate was right for the middle case. The point stands either way: the
count is a backstop and check_cse_timeout is what ends the loop, so the comment
now carries the measured range instead of asserting that without a number.

Method note for anyone repeating this: a Type=simple stub reports ~5s/iteration
for every shape, because systemd considers such a unit started as soon as exec
succeeds and 'systemctl restart' returns before the failure happens. The shapes
only separate under Type=notify, which is what localdns.service uses.

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

Per @yewmsft. The directive has to live in a unit-specific drop-in -- Azure Linux's
type-wide /usr/lib/systemd/system/service.d/10-timeout-abort.conf overrides
localdns.service, so setting it in the unit is silently ignored. Previously it
rode along in localdns-delegate.conf because that file already had the packer
plumbing.

That file is named for Delegate=cpu, and whoever comes back asking why the stop
timeout behaves this way will not think to open a file about cgroup delegation.
Worse, the PR text left a rename of delegate.conf open as a possible follow-up;
a rename that quietly dropped the directive would hand Azure Linux back to a 153s
cycle, above the 144s threshold, with nothing but the e2e assertion to catch it.

So: localdns-timeout-terminate.conf, installed as
/etc/systemd/system/localdns.service.d/99-timeout-terminate.conf. delegate.conf
returns to its single purpose.

The 99- prefix is for human readers, not for correctness. Unit-specific drop-ins
beat type-wide ones because they are separate hierarchies and systemd applies the
unit-specific one last, regardless of filename -- verified by giving the type-wide
file a 99- name so it sorted after delegate.conf, which still lost. Recorded in
the artifact so nobody depends on ordering that is not load-bearing.

Verified with the real artifacts against a type-wide abort drop-in:

  TimeoutStopFailureMode=terminate
  Delegate=yes / DelegateControllers=cpu
  TimeoutStartUSec=1min 30s  TimeoutStopUSec=30s  RestartUSec=2s
  StartLimitIntervalUSec=12min  StartLimitBurst=5

Two separate .conf files in one drop-in directory both apply, and systemctl cat
lists both with their paths, so the directive is discoverable on the node as well
as in the tree.

Plumbing: nine packer JSONs, packer_source.sh, azlosguard.yml, the VHD content
test and CODEOWNERS. Checked parity mechanically -- every file that ships
localdns-delegate.conf now also ships localdns-timeout-terminate.conf, and all
nine JSONs still parse. That check matters because a missed JSON would not fail
the build; it would ship one image without the drop-in and surface months later
as one distro back at 153s.

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

Two from @yewmsft.

1. azlosguard.yml lost delegate.conf's mode. The new localdns-timeout-terminate
   entry went in between the existing entry's 'destination:' and 'permissions:'
   lines, so it took the 644 with it. Parsed, delegate.conf became the only
   localdns entry in the file with no permissions key -- every sibling has one --
   so on the OS Guard image its mode would be whatever imagecustomizer defaults
   to, while linux-vhd-content-test.sh still asserts 644 for that exact path.

   My parity check could not see this. "Every file shipping localdns-delegate.conf
   also ships localdns-timeout-terminate.conf" is a presence check, and what moved
   was an attribute between two entries. Re-checked by parsing the YAML and
   asserting no entry anywhere in the file lacks a permissions key; none does now.

2. assertLocalDNSBudgetDirectives was gated behind validateLocalDNSLifecycle.
   scenario_localdns_hosts.go returns on any lifecycle error, and the directive
   check did not run until validateLocalDNSRestartBudget, so a broken lifecycle
   step hid whether the unit was pinned at all. That is not hypothetical: gate
   build 181818040's AzureLinuxV3 lane died in the lifecycle validation on the
   Bastion tunnel limit, and the directive assertion -- the one thing standing
   between a dropped pin and a 153s cycle on AzureLinux -- never ran on the one
   distro where it mattered.

   It is also the cheapest check in the package: one 'systemctl show', no fault
   injection, no harness, gated behind a step that SCPs a script and drives
   kill/recovery cycles. Hoisted ahead of the lifecycle validation, with the
   laneResolvedMainBuiltImage() skip moved with it so main-built images are still
   skipped rather than failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Baking StartLimitIntervalSec=720/StartLimitBurst=5 into localdns.service makes a
new image incompatible with an older CSE -- a combination this repo explicitly
supports (cse_config_localdns.sh's LOCALDNS_COREFILE_BASE fallback). Raised by
Copilot on #discussion_r4041413471; reproduced rather than reasoned about.

The budget is only survivable alongside the 'systemctl reset-failed' in
enableLocalDNS()'s retry loop, because provisioning restarts draw on the same
budget as systemd's automatic ones. An older CSE goes through
systemctlEnableAndStart -> _systemctl_retry_svc_operation, which has no reset. So
one failed burst wedges the unit for 720s while that loop runs only ~500-600s:
every remaining attempt refused, node provisioning failed, from a transient the
old image would have recovered from in seconds.

The rule lived in the image and the means to survive it lived in the script, and
those ship separately. Moving the rule into the script means whoever brings the
rule also brings the reset, so they cannot arrive apart. An older CSE creates no
drop-in and gets systemd's 10s default -- today's shipping behaviour.

Measured on a clean Ubuntu 24.04.4 / systemd 255.4 VM (the build AKS ships), 14
provisioning attempts against a service that always fails, R = refused:

  budget in the VHD unit + old CSE   12min/5   ..RRRRRRRRRRRR   wedged, never recovers
  budget in the VHD unit + new CSE   12min/5   ..............   reset clears it
  budget from CSE        + old CSE   10s/5     ..R.R.R.R.....   default, recovers
  budget from CSE        + new CSE   12min/5   ..............   protected

Two costs I asserted and then measured, both of which turned out not to be costs:
the budget's terminal behaviour is identical from either location (ActiveState=failed,
NRestarts=5, effective 12min/5), and the drop-in survives a real VM reboot with the
budget still in force.

The cost that remains is not measurable: the 144s threshold is
StartLimitIntervalSec/StartLimitBurst while the worst cycle is TimeoutStartSec +
TimeoutStopSec + RestartSec, so after this the numerator lives in CSE and the
denominator in the image. Two values that must agree, edited in different places --
the same shape as the bug that produced 153s on AzureLinux. Both files carry the
matrix and a pointer to the other so the pair is discoverable from either side.

RestartSec, TimeoutStartSec and TimeoutStopFailureMode stay in the unit: none of
them can wedge provisioning on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-on to b9f1887, and a correction to 08ef097.

TimeoutStartSec and TimeoutStopFailureMode exist only to serve the budget: the
threshold is StartLimitIntervalSec/StartLimitBurst = 144s and it has to clear the
worst restart cycle, TimeoutStartSec + TimeoutStopSec + RestartSec. Leaving them
in the VHD while the budget moved to CSE split that arithmetic across two
components, and left old-image/new-CSE getting the budget with UNPINNED timeouts
-- 45 + 60 + 2 = 107s on AzureLinux, under 144 only because two distro quirks
cancel, which is the coincidence this PR exists to remove. Both now travel with
the budget in one CSE-written drop-in.

That also deletes localdns-timeout-terminate.conf and its 16 files of packer
plumbing: TimeoutStopFailureMode needs a unit-specific drop-in, and a CSE-written
one is unit-specific, so the packer artifact was solving a delivery problem that
no longer exists.

The 99- prefix is load-bearing, and I previously recorded the opposite. systemd
applies drop-ins in lexicographic order by FILENAME across both the unit-specific
(localdns.service.d/) and type-wide (service.d/) directories -- being
unit-specific does not by itself win. Measured on Ubuntu 24.04.4 / systemd 255.4
against Azure Linux's type-wide 10-timeout-abort.conf, same file and directory,
only the name varying:

  10-localdns-budget.conf -> abort       (sorts before 10-timeout-abort, loses)
  99-localdns-budget.conf -> terminate   (sorts after, wins)
  zz-localdns-budget.conf -> terminate

08ef097 claimed the prefix was "for human readers, not for correctness" on the
strength of two tests that were both confounded: delegate.conf vs a type-wide
10-, then a type-wide 99- vs delegate.conf. In each the unit-specific file
happened to sort last (d > 1, d > 9), so they showed "later filename wins" and I
read "unit-specific wins". @yewmsft had it right in his original comment; his
later retraction, which I argued him into, was wrong.

Four-cell matrix on that VM with the type-wide abort drop-in present, 12
provisioning attempts, R = refused:

  old image + old CSE   10s/5     abort       .R.R.R.R.R.R   systemd default, recovers
  old image + new CSE   12min/5   terminate   ............
  new image + old CSE   10s/5     abort       ............
  new image + new CSE   12min/5   terminate   ............

No cell wedges, and both skew cells are correct by construction rather than by
two distro quirks cancelling.

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

The Ubuntu2404 lane failed on 264c9b2's VHD:

  FAIL: hung start did not terminate in 'failed' within 180s (state=activating)

Not a regression in the budget -- the shortened clocks stopped applying. CSE now
writes the budget to /etc/systemd/system/localdns.service.d/99-localdns-budget.conf,
which pins TimeoutStartSec=90. The harness drop-in was 99-e2e-fastclock.conf, and
systemd applies drop-ins in lexicographic filename order across every drop-in
directory, so "99-e2e" sorts before "99-localdns" and the budget's 90s won. Hung
start then ran a ~97s cycle instead of ~22s, needing ~390s to exhaust the burst
against a 180s deadline.

Measured on Ubuntu 24.04.4 / systemd 255.4 with both drop-ins present, varying
only the harness filename:

  99-e2e-fastclock.conf -> TimeoutStartUSec=1min 30s   (budget wins, clocks inert)
  zz-e2e-fastclock.conf -> TimeoutStartUSec=15s        (clocks win)

load order confirms it:
  /run/systemd/system/<unit>.d/99-e2e-fastclock.conf
  /etc/systemd/system/<unit>.d/99-localdns-budget.conf   <- last

Renamed to zz-e2e-fastclock.conf with the reason recorded at the declaration.
This is the same ordering rule that makes the budget's own 99- prefix
load-bearing; moving the budget into a drop-in put the harness on the wrong side
of it.

AzureLinuxV3 passed on the same VHD (8m53s), which is the lane that proves the
CSE-written drop-in beats Azure Linux's type-wide 10-timeout-abort.conf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts b9f1887's CSE delivery mechanism. The budget was moved into a
CSE-written drop-in to keep it from wedging an older CSE's 100-iteration
retry loop -- but that loop adds nothing to defend. localdns.sh already
absorbs ~80s of transient per start attempt (wait_for_localdns_removed_
from_resolv_conf 5, START_LOCALDNS_TIMEOUT=10, wait_for_localdns_ready
60 60), iptables -w blocks on the xtables lock rather than failing, and
every remaining fatal exit is deterministic. Attempt 6 re-runs expired
timers against a failure that cannot change.

So a VHD-baked budget costs an old CSE a fail at ~10s with a readable
journal instead of ~584s and 95 "Start request repeated too quickly"
lines. Same node outcome, cheaper, better diagnosis.

With the budget back in the unit the guarantee is unconditional -- it no
longer depends on which CSE provisioned the node -- and RestartSec=2
ships alongside it rather than landing alone on nodes where it would
push failures further from 'failed'.

Also drops the TimeoutStartSec/TimeoutStopFailureMode pins: unpinned,
the worst cycle is 122s on Ubuntu and 107s on Azure Linux, both under
the 144s threshold. The 153s that motivated the stop-mode pin was
created by pinning TimeoutStartSec to 90 on a distro that defaults to 45.

e2e keeps two assertions: that the budget ships in localdns.service
rather than a drop-in, and that the unit reaches terminal failed with
Result=start-limit-hit. The fault matrix, drop-in ordering cases and
CSE-interaction cases tested code that no longer exists.

The localdns.sh SIGTERM/watchdog fix and the hosts cleanup hardening are
unchanged. The Bastion script-size guard moves to its own PR with the
tunnelSession.Write chunking fix.

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.

Comment thread e2e/scenario/scenario_localdns_hosts.go
Comment thread e2e/scenario/scenario_localdns_restart_budget.go Outdated
Comment-only change to a file this PR has no business touching. The
Delegate=cpu rationale and the missing trailing newline are both worth
fixing, just not 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.

Comment thread e2e/scenario/scenario_localdns_restart_budget.go
Comment thread e2e/scenario/scenario_localdns_restart_budget.go Outdated
Measured on a live AzureLinuxV3 node: systemd does not report
Result=start-limit-hit on this path. It reports the underlying cause --
exit-code for all four fast fault modes -- while the journal carries
"Start request repeated too quickly."

The assertion as written would have failed every run. Assert ActiveState
plus the journal refusal line plus NRestarts >= burst instead, and print
Result as diagnostic only.

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

Copy link
Copy Markdown
Contributor

AgentBaker Linux gate detective

Run: https://msazure.visualstudio.com/CloudNativeCompute/_build/results?buildId=182353627
Failed job/stage/task: e2e / Run AgentBaker E2E / LocalDNSHostsPlugin AzureLinuxV3, Ubuntu2204, and Ubuntu2404; build build2204fipsgen2containerd Test/Scan also repeated the repair-linked CIS baseline drift.

Detective summary: E2E reported 184 scenarios with 4 failed and 0 flaky. LocalDNS restart-budget validation injected a failing ExecStart=/bin/false, but localdns stayed active after 120s instead of reaching terminal failed/start-limit-hit, so unrecoverable faults would not be bounded. The same run also repeated CIS 5.1.18/5.1.6 scan drift under repair #39802117.

Likely cause / signature: e2e-localdns-restart-budget-hungstart-not-bounded; secondary recurring signature vhd-ubuntu2204fips-cis-cron-rules-regression.

Confidence: High for deterministic LocalDNS restart-budget validation regression; high for recurring CIS drift.

Recommended owner/action: PR #9439 owner should inspect LocalDNS restart-budget behavior across AzureLinuxV3/Ubuntu2204/Ubuntu2404; VHD validation owners should continue repair #39802117 for the separate CIS drift.

Strongest alternative: shared systemd timing flake; less likely because three LocalDNSHostsPlugin variants fail the same injected-fault assertion and PR #9439 directly changes LocalDNS restart-budget/service behavior.

Evidence: E2E log reports localdns is active after 120s ... expected failed and LocalDNS did not reach terminal failed after exhausting its restart budget; final summary shows 0 flaky tests; timeline marks Run AgentBaker E2E failed; Test/Scan log separately reports 5.1.18|pass->fail and 5.1.6|pass->fail.

Wiki signatures: $sigLocal, $sigCis

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.

Comment on lines +1233 to +1237
# It also means the dummy interface carrying 169.254.10.10/.11 is now torn down on a stop.
# It was not before: localdns_cleanup_mode (the ExecStopPost path) deliberately leaves the link
# alone in case an orphaned CoreDNS is still answering on .11. Note this for the pod-DNS
# fallback (#9486): its idempotent-interface-creation requirement is now the common case on a
# clean stop, not the exception.

@yewmsft Ye Wang (yewmsft) Sep 23, 2026 •

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.

[nit] comment change

two things in this paragraph, both comment-only.

:1234-1235 says ExecStopPost leaves the link alone, but not why that doesn't apply here — so it reads like the two paths are accidentally inconsistent, and the next person "fixes" it by making ExecStopPost tear down too. the hazard it guards is an orphaned coredns still answering on .11 after the supervisor died unexpectedly. that can't happen on this path: by the time exit 0 reaches the EXIT handler, the script has already drained for LOCALDNS_SHUTDOWN_DELAY and SIGINTed coredns.

:1235-1237 isn't right for #9486. the ABRT ERR INT PIPE trap at :1216 already calls cleanup_localdns_configs, so the interface was already torn down on every failing start — which is the path the fallback actually inherits. idempotent interface creation was always required; this only adds the clean-stop case. scoping #9486 off "now the common case, not the exception" would be scoping it off a false premise.

Suggested change
# It also means the dummy interface carrying 169.254.10.10/.11 is now torn down on a stop.
# It was not before: localdns_cleanup_mode (the ExecStopPost path) deliberately leaves the link
# alone in case an orphaned CoreDNS is still answering on .11. Note this for the pod-DNS
# fallback (#9486): its idempotent-interface-creation requirement is now the common case on a
# clean stop, not the exception.
# It also means the dummy interface carrying 169.254.10.10/.11 is now torn down on a stop.
# ExecStopPost (localdns_cleanup_mode, :847) deliberately does not delete it: that path runs
# when the supervisor died unexpectedly and an orphaned CoreDNS may still be answering on .11.
# That hazard cannot exist here -- by the time 'exit 0' reaches the EXIT handler the script has
# already drained for LOCALDNS_SHUTDOWN_DELAY and SIGINTed CoreDNS. The two paths differ because
# their hazards differ; do not align them.
#
# Not new for #9486: the ABRT/ERR/INT/PIPE trap above already calls cleanup_localdns_configs, so
# the interface was already torn down on every failing start, which is the path the fallback
# inherits. Idempotent interface creation was always required; this only adds the clean stop.

same edit in the description, the .10/.11 bullet.

Comment thread e2e/scenario/scenario_localdns_restart_budget.go Outdated
Comment thread e2e/scenario/scenario_localdns_restart_budget.go Outdated
Two defects, both caught by yewmsft against build 182353627 where all
three gated lanes failed deterministically with "localdns is 'active'
after 120s of failing starts".

validateLocalDNSLifecycle runs immediately before this validator and can
only exit with the unit active -- its EXIT trap retries 'systemctl start'
until is-active. reset-failed clears failure state but stops nothing, and
daemon-reload does not restart anything, so 'systemctl start' on an active
unit was a no-op: /bin/false never executed and the loop watched a healthy
service for 120s. 'restart' is the fix.

The journal grep used a '-5 min' wall-clock window, which reaches back over
that same lifecycle validator's three kill/recovery cycles and can pass on
a refusal that is not this test's. Anchor it to fault_start instead, the
way validateLocalDNSLifecycle already anchors its own.

Validated by yewmsft on the same VHD (182353627), one scenario, no rebuild:
PR head failed in 7m18s; head plus these two lines passed in 5m34s.

NRestarts=5 against burst=5, so the existing '-ge burst' bound holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@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 67% ➖
github.com/Azure/agentbaker/aks-node-controller/utils 0% ❌
github.com/Azure/agentbaker/apiserver 25% ❌
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 88% ✔
Summary 74% (6316 / 8578) ➖

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Health
test 0% ❌
windows 21% ❌
staging_cse_windows Package 1 65% ➖
debug 0% ❌
provisioningscripts 2% ❌
parts_windows Package 1 76% ➖
Summary 37% (1598 / 6790) ❌

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Health
shellspec spec 22% ❌
Summary 22% (3036 / 14146) ❌

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.

Comment on lines +174 to +176
burst=$(systemctl show localdns.service -p StartLimitBurst --value)
[ "$nrestarts" -ge "$burst" ] ||
fail "localdns restarted $nrestarts times against a burst of $burst; the limiter did not bound this failure."
@yewmsft

Copy link
Copy Markdown
Member

both fixes land. restart at :147 is the one that mattered — start could never have run the faulted ExecStart, so nothing below it had ever executed.

and i was wrong about -ge "$burst". ran your exact sequence on systemd 255 (= the 2404 and AzureLinuxV3 lanes), unit active first, then the fault drop-in, reset-failed, restart --no-block:

pre-state=active  NRestarts=0
elapsed=12s  state=failed  Result=exit-code  NRestarts=5  burst=5
journal grep: MATCH

the refused sixth start does bump the counter — systemd increments when it schedules the restart job, then the limiter refuses that start. so it lands exactly on 5, not the 4 i predicted. the bound is right, leave it. Result=exit-code also confirms 48c82585 — asserting the limiter via the journal rather than Result=start-limit-hit is the only thing that works.

two things that follow from it being exactly 5:

  • zero margin, and 2204 is systemd 249, which i haven't measured. if 249 increments only on a start that actually ran, :175 is 4 and goes red. :158 is the first line this script has ever gotten past, so :175 has never run on any lane — 2204 is the one to watch on this run.
  • 12s end to end, so the 120s poll at :153 is not close to tight.

one for a work item, not this PR: the threshold arithmetic in the unit comment — 144s against the 122s slowest cycle — is the load-bearing claim for the budget, and /bin/false at 2s cycles never approaches it. what :175 proves is that the limiter bounds the fast shape, which is the shape where main and this PR behave identically. the slow shape is the one that buys you exit 216 instead of an anonymous CSE timeout, and nothing here covers it. not worth growing this PR for.

@yewmsft Ye Wang (yewmsft) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

approving. 6ffca34f fixes the gate failure and the gate proves it — 182475673, 184 scenarios, 114 passed, 0 failed, all five LocalDNSHostsPlugin lanes green including the three that were red on 182353627. no resolved a main-built image in the log, and the durations are the passing shape (2404 5m25s, not the 7m18s failure).

that also settles the two things i left open:

  • 2204 is systemd 249 and :175 had never executed on any lane before this run. it passed, so NRestarts -ge burst holds on 249 as well as the 255 i measured.
  • Copilot's NRestarts=4 is wrong — the refused start bumps the counter, systemd increments when it schedules the restart job. three lanes agree with the bench.

the localdns.sh:1237 nit is still open and i'm not holding the PR for it. it's comment-only, but :1233-1235 scopes #9486 off a premise that isn't true — the ABRT ERR INT PIPE trap at :1216 already tore the interface down on every failing start, so idempotent interface creation was always required and this only adds the clean-stop case. worth fixing before merge or folding into #9486, your call.

Agentbaker GPU E2E red is unrelated — GPU pod readiness over the 1m warning threshold across nine lanes, no localdns involvement.

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.

[BUG] LocalDNS unit left dead after unclean restart (orphan coredns in cgroup) -> node-local DNS blackhole

4 participants