Skip to content

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

Open
Saewon Kwak (saewoni) wants to merge 20 commits into
mainfrom
fix/localdns-pod-service-recovery
Open

Saewon Kwak (saewoni) wants to merge 20 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

Problem: systemd's default restart budget for localdns.service is unsuitable in two opposite directions. Fast crash storms exhaust it in seconds and wedge the unit almost immediately; meanwhile several slower failure modes (PID file never appears, watchdog kill, ready-check timeout, hung start) restart on a cycle slower than the default threshold and would restart forever, never reaching failed.

Fix: replace the default budget with StartLimitIntervalSec=720 / StartLimitBurst=5 and RestartSec=2. The threshold (720/5 = 144s) sits just above the slowest restart cycle (~125s), so every unrecoverable failure mode deterministically terminates in failed. RestartSec=2 is the load-bearing change: it lets each restart sample real system state instead of re-entering the transient it is retrying against.

Why this matters: failed is the prerequisite for handoff. OnFailure= only fires on entry to failed, so a pod-DNS .11 fallback (stacked follow-up, see #9486) can only take over if failures reliably reach that state. This PR does not itself add the fallback — it puts a floor under un-enumerated failures so they land in failed, which is what a fallback and NPD can act on. See What failed actually triggers — it is not a passive state.

Design intent

We want the opposite of "retry forever to keep .11 alive." We want every unrecoverable path to land in failed, so that:

  1. OnFailure= can hand off to a .11 fallback responder (stacked follow-up feat: LocalDNS pod-DNS (.11) fallback with OnFailure + probe triggers #9486), and
  2. NPD gets a stable terminal state to act on — which today means cordon, drain and reboot, then redeploy. See below.

The default budget lets slow failures restart forever and never reach failed — precisely the modes a fallback needs to fire on. The table below (threshold = interval/burst) shows why 720/5 is chosen: it is above the slowest restart cycle, so all modes terminate.

failure mode approx cycle default (5/10s) 720/5
pre-flight / orphan cgroup ~2.5s failed fast failed ~13s
resolv.conf drain ~8s failed failed ~40s
dies right after READY ~5–8s failed 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 (SIGTERM at TimeoutStartSec) ~95–125s forever failed ~10.5min

Slower flapping (>144s cycle) or serves-for-hours-then-crashes intentionally still restarts — those served real traffic and shouldn't escalate a node that's up 98% of the time. That class is NPD's, and is why the NPD check should probe .11 rather than read unit state.

Change

[Unit]
StartLimitIntervalSec=720
StartLimitBurst=5

[Service]
RestartSec=2
TimeoutStartSec=90
TimeoutStopFailureMode=terminate
  • RestartSec=2 (load-bearing). 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 sub-second retries each attempt re-enters the transient it is retrying against (five 100 ms retries are effectively one). At 2s, networkd settles and an orphaned CoreDNS releases its sockets before the next ExecStart.
  • StartLimitIntervalSec=720 / StartLimitBurst=5. Threshold 144s, just above the slowest restart cycle (~125s = inherited TimeoutStartSec 90 + TimeoutStopSec 30 + RestartSec 2), so every unrecoverable mode reaches failed. Burst returns to the systemd default; only the window changes.
  • TimeoutStartSec=90 and TimeoutStopFailureMode=terminate (both pinned so the threshold math is self-contained). The worst cycle is TimeoutStartSec + TimeoutStopSec + RestartSec, and two of those three were previously whatever the image happened to default to. Neither default is the same across the images we ship: Ubuntu builds systemd with -Ddefault-timeout-sec=90 and Azure Linux 3.0 with 45, and Azure Linux additionally ships a global service.d drop-in setting TimeoutStopFailureMode=abort, which makes a stop timeout send SIGABRT and then wait a second TimeoutStopSec before SIGKILL. Measured on AzureLinuxV3: 90 + 30 + 30 + 2 = 153s, above the 144s threshold — the slow modes would have restarted forever there. Pinning both makes every image 90 + 30 + 2 = 122s. The abort step's core dump is already discarded on AKS nodes (configureCoreDump() in cis.sh sets Storage=none), so it cost 30s of restart cycle and produced nothing.

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

What failed actually triggers

Reaching failed is not passive, and the description above previously implied it was. Verified in CloudNativeCompute:

  1. aks-vm-extensionconfig/node-problem-detector/plugin/check_dns_to_localdns.sh runs every 1 minute and returns NOTOK when systemctl is-active localdns.service fails.
  2. config/node-problem-detector/custom-plugin-monitor/localdns-problem-monitor.json is "type": "temporary" with "conditions": [] — so no node condition, but it increments problem_counter{reason="LocalDNSError"}.
  3. aks-operatorconfig/metrics/profiles/default/alerting_rules.yml:
alert expression for action
LocalDNSErrorRestartNode increase(problem_counter{reason="LocalDNSError"}[10m]) >= 5 5m RestartNodeRemediationEarly, specify: Reboot, cordonanddrain: "true", dryRun: "false"
LocalDNSErrorRedeployNode same 15m RedeployNodeRemediationEarly, specify: Redeploy

So a node that reaches failed and stays there is cordoned, drained and rebooted at roughly 10 minutes, and redeployed at roughly 15. Reboot clears systemd's failed state, so recovery does exist — but it is node replacement, not service recovery, and reset-failed runs only in enableLocalDNS() at provisioning time. Nothing on the node itself clears failed afterwards.

Two consequences worth stating plainly:

  • "That class is NPD's" is not the gentle option. The section above argues that slow flapping shouldn't escalate a node that is up 98% of the time, and hands that class to NPD. NPD's handling of it is also reboot, then reimage — just on a different trigger. The distinction this PR draws is about which mechanism escalates, not about whether escalation happens.
  • The .11 fallback (feat: LocalDNS pod-DNS (.11) fallback with OnFailure + probe triggers #9486) does not prevent it. The probe checks systemctl is-active first and exits before reaching its own .11 and .10 queries, so a node whose pods are resolving perfectly through the fallback still reports a problem every minute and still gets rebooted, then redeployed. Ye Wang [msft] (@yewmsft) called this out on 2026-09-11 ("the NPD check should probe .11 rather than systemctl is-failed"); the code confirms it. A companion change in aks-vm-extension is tracked as a TODO on feat: LocalDNS pod-DNS (.11) fallback with OnFailure + probe triggers #9486 and should land before or with it.

Scope / what this is not

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

Stacked on #9360; base branch is fix/localdns-cgroup-teardown.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Windows Unit Test Results

  3 files   17 suites   1m 3s ⏱️
484 tests 484 ✅ 0 💤 0 ❌
487 runs  487 ✅ 0 💤 0 ❌

Results for commit be8e8ff.

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

Two fixes from Copilot review:

- Teardown reused the validator context, so a cancelled scenario -- the path
  where cleanup matters most -- would return immediately and leave the fault
  file, the patched localdns.sh and the systemd drop-in on the node for whatever
  ran next. Derive a bounded context with context.WithoutCancel so teardown
  still runs, without hanging forever.

- The provisioning-restart check bounded its restart with timeout 60 while the
  production path uses timeout 30 (cse_config_localdns.sh). A restart taking
  30-60s would have passed the test while failing node provisioning, so the
  check was weaker than the thing it guards. Match production.

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 new VHD restart limits can cause provisioning failures when paired with an older CSE payload.

Get a fresh assessment by requesting another Copilot review.

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

Comment on lines +29 to +30
StartLimitIntervalSec=720
StartLimitBurst=5
Copilot AI review requested due to automatic review settings September 17, 2026 21:02
@saewoni

Copy link
Copy Markdown
Contributor Author

Pre-existing e2e bug: validateLocalDNSLifecycle leaks a Restart=no drop-in onto every node

Found while adding restart-budget coverage on this branch. This is not caused by this PR — it predates it and affects every LocalDNS e2e run today. Fixed here (4954e95609) because this branch is the first thing to run after the lifecycle test, which is what made it visible.

The bug

validateLocalDNSLifecycle writes a temporary override to reach the terminal dead-service case:

/run/systemd/system/localdns.service.d/99-e2e-no-restart.conf
  [Service]
  Restart=no

and removes it in cleanup:

if [ -f "$NORESTART" ]; then                 # <-- unprivileged
    sudo rm -f "$NORESTART" || ...
    sudo systemctl daemon-reload || ...
fi

The drop-in is created with sudo mkdir + sudo tee, so under root's umask on these nodes the directory is 0750 root:root. The e2e scripts run unprivileged (hence sudo on every line). An unprivileged [ -f ... ] on a file inside a root-only directory cannot stat it — so it returns false, the guard fails closed, and the removal never runs.

The override survives the test. localdns.service is left with Restart=no.

Minimal repro (no cluster needed)

$ sudo sh -c "umask 0027; mkdir -p /tmp/r/localdns.service.d"
$ printf '[Service]\nRestart=no\n' | sudo tee /tmp/r/localdns.service.d/99-e2e-no-restart.conf >/dev/null
$ ls -ld /tmp/r/localdns.service.d
drwxr-x--- 2 root root 4096 /tmp/r/localdns.service.d

$ sudo test -f /tmp/r/localdns.service.d/99-e2e-no-restart.conf && echo TRUE
TRUE                                  # the file is definitely there

$ [ -f /tmp/r/localdns.service.d/99-e2e-no-restart.conf ] && echo TRUE || echo FALSE
FALSE                                 # ...but the cleanup's check says it is not

The trap is that the two lines run at different privilege levels: the removal was written with sudo, the guard was not. The guard doesn't report "permission denied" — it reports "absent".

Evidence from a live node

2026-09-17-gaau-localdnshostspluginubuntu2204, after a scenario whose assertions all passed:

DropInPaths=/run/systemd/system/localdns.service.d/99-e2e-no-restart.conf \
            /etc/systemd/system/localdns.service.d/delegate.conf

### /run/systemd/system/localdns.service.d/99-e2e-no-restart.conf
[Service]
Restart=no

drwxr-x--- 2 root root  /run/systemd/system/localdns.service.d/
-rw-r----- 1 root root  99-e2e-no-restart.conf

Removing it flips the unit back:

before: Restart=no
after removing the leaked drop-in: Restart=on-failure

Why nobody noticed

Nothing ran after validateLocalDNSLifecycle, and the VM is deleted at the end of the scenario. The leak had no observable consequence, and the cleanup's own follow-up (systemctl start localdns) still succeeds, so the scenario passes.

The restart-budget validation added on this branch does run afterwards. It inherited Restart=no, and every fault reported:

fault=resolvdrain elapsed=6s state=failed sub=failed result=exit-code execstarts=1 burst=5
FAIL: reached 'failed' without the start limiter refusing a start.

With Restart=no the unit goes terminal after a single start, so the budget is never reached. After removing the drop-in, the same fault on the same node behaves as designed:

fault=resolvdrain elapsed=35s state=failed sub=failed result=exit-code execstarts=5 burst=5
OK: resolv.conf never drains terminated in 'failed' after 5 ExecStarts in 35s

35s against the 37s measured by hand for this mode in #issuecomment-5707475375.

Fix

Remove unconditionally — rm -f is already a no-op when the file is absent, so there was nothing for the guard to buy — and verify with sudo test -f, which can see into the directory:

sudo rm -f "$NORESTART" || { echo "ERROR: failed to remove $NORESTART"; cleanup_status=1; }
sudo systemctl daemon-reload || { ... }
if sudo test -f "$NORESTART"; then
    echo "ERROR: $NORESTART still present after cleanup"
    cleanup_status=1
fi

The budget validation also now asserts Restart=on-failure up front, so a future leak of this kind reports itself in one line instead of as a confusing per-fault failure.

Worth noting

The same root/unprivileged mismatch bit twice in this branch. The fault harness copied localdns.sh with sudo cp, producing a root-owned 0750 copy that the unprivileged grep could not read — grep printed nothing, the anchor count came back empty, and it surfaced as a misleading ANCHOR-FAIL: ... matched lines. Fixed by sudo cat > file so the copy belongs to the caller.

Both are the same shape: a file created by sudo, then inspected without it. Probably worth a grep across the e2e suite for other [ -f ... ] / ls / grep checks against paths that are written with sudo.

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 new VHD unit can break provisioning when paired with an older independently delivered CSE script.

Review details

Suppressed comments (3)

parts/linux/cloud-init/artifacts/localdns.sh:944

  • 🟡 Medium Risk — 🧪 Test Coverage: The new background-sleep/wait path is not exercised by ShellSpec: the only start_localdns_watchdog example in localdns_spec.sh:1340-1347 leaves NOTIFY_SOCKET and WATCHDOG_USEC empty, so it takes the other branch. Because this change relies on Bash signal, set -e, and EXIT-trap behavior to shorten shutdown, add a ShellSpec that enters the watchdog branch, interrupts the wait, and verifies prompt cleanup/exit.
            # Wait for the next watchdog interval. Run sleep in a child so
            # SIGTERM can interrupt the wait and let the service's signal/exit
            # cleanup run promptly.
            sleep "${HEALTH_CHECK_INTERVAL}" &
            wait $!

parts/linux/cloud-init/artifacts/localdns.service:30

  • 🔴 High Risk — 🔄 Backward Compatibility: These limits make the new VHD depend on the matching new enableLocalDNS. An older CSE still uses systemctlEnableAndStart, which retries via daemon-reload but never calls reset-failed (cse_helpers.sh:585-625). On systemd 255, the first failed start consumes this five-start budget and all remaining provisioning retries are refused for 720 seconds, potentially failing node provisioning. Since provision_configs_localdns.sh is supplied through CustomData while this unit is baked into the image, please make the unit safe with the previous CSE behavior or stage/gate the rollout with a compatibility mechanism.
StartLimitIntervalSec=720
StartLimitBurst=5

spec/parts/linux/cloud-init/artifacts/cse_config_localdns_spec.sh:115

  • 🟡 Medium Risk — 🧪 Test Coverage: Despite the test name, the mock makes the first restart succeed, so this executes only one loop iteration and merely checks that each command appeared once. It would still pass if reset-failed were moved outside the loop, which is the exact regression this change is intended to prevent. Make the first restart fail and a later one succeed, then assert the ordered call sequence contains reset-failed before both restart attempts.
        It 'should clear the StartLimit budget before each start attempt'
            When run enableLocalDNS
            The status should be success
            The output should include "systemctl reset-failed localdns"
            The output should include "systemctl restart localdns"
            The output should include "Enable localdns succeeded."
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

This assertion concludes "the drop-in is gone" from a failed glob, so a bare ls
would read a permission failure as success and let the regression under test
pass silently. /run/systemd/network/*.d is 0755 today -- verified on a node, the
unprivileged ls does find the file -- so this is not a live bug, but the check
should not depend on a permission it does not control. The same test's own
service drop-in directory is 0750 precisely because sudo mkdir applied root's
umask, which is how this class of mistake arises.

Use sudo so the assertion sees what is actually there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 17, 2026 21:09

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 load-bearing watchdog signal behavior lacks focused ShellSpec regression coverage.

Review details

Suppressed comments (1)

parts/linux/cloud-init/artifacts/localdns.sh:944

  • 🟡 Medium Risk — 🧪 Test Coverage: This load-bearing signal-handling change is not exercised by the existing start_localdns_watchdog ShellSpec (it only covers the no-watchdog branch), and the lifecycle E2E only requires systemctl stop to finish eventually, so the previous foreground sleep would still pass. Please add a signal-focused ShellSpec that enters the configured watchdog branch, sends SIGTERM while waiting, and verifies prompt EXIT cleanup without waiting for the health-check interval; this also follows AGENTS.md:62-65.
            # Wait for the next watchdog interval. Run sleep in a child so
            # SIGTERM can interrupt the wait and let the service's signal/exit
            # cleanup run promptly.
            sleep "${HEALTH_CHECK_INTERVAL}" &
            wait $!
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

… fits

The full matrix could not complete. validateVM runs inside vmssCtx, bounded by
TestTimeoutVMSS (17m default) and shared with VM creation, so the validation
phase has roughly 11 minutes. The three slow modes need ~19.6min between them at
their shipped clocks -- readytimeout 318s, watchdog 370s, hungstart 487s -- and
the run died mid-watchdog at 1050s with "context deadline exceeded", which
surfaces through the SSH call but is the VMSS deadline, not a per-exec one.

Shorten the clocks for the fault run only: WatchdogSec 60 -> 10 and
TimeoutStartSec 90 -> 15 via the existing fault drop-in, wait_for_localdns_ready
60/60 -> 8/8 and START_LOCALDNS_TIMEOUT 10 -> 3 via the patched script. The same
code paths still run -- a real watchdog timeout, a real start timeout, the real
readiness poll, the real pid-file wait -- just on a faster clock. Worst-case
deadline sum drops from 1950s to 695s.

StartLimitIntervalSec, StartLimitBurst and RestartSec are untouched; they are
what is under test. The trade is explicit and noted in the file: this validates
the mechanism, not the shipped wall-clock durations. Those were measured by hand
on a live node and are recorded on the PR.

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 CSE retry loop can overrun its reporting reserve, and validation gaps could allow restart-budget regressions.

Review details

Suppressed comments (3)

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

e2e/scenario/scenario_localdns_restart_budget.go:505

  • 🟡 Medium Risk — 🔧 Script Logic: The comment and invariant require exactly StartLimitBurst executions, but this one-sided check accepts any larger count. If the limiter permits extra starts and the unit later fails for another reason, the E2E can still pass despite the restart budget being broken. Require equality so both early termination and budget overshoot fail validation.
    parts/linux/cloud-init/artifacts/cse_config_localdns.sh:139
  • 🟡 Medium Risk — 🔧 Script Logic: This single budget check guards up to three sequential 30-second D-Bus calls. If the loop begins near the 780-second CSE threshold, those calls plus the final 30-second status collection can exceed the 900-second outer CSE timeout, so the process is still SIGKILLed before it records the intended LocalDNS status and exit code. Recheck the CSE budget between potentially blocking calls so at most one call can consume the 120-second reporting reserve.

parts/linux/cloud-init/artifacts/localdns.sh:944

  • 🟡 Medium Risk — 🧪 Test Coverage: The configured-watchdog branch now relies on background-job and signal semantics, but localdns_spec.sh only exercises the branch where NOTIFY_SOCKET and WATCHDOG_USEC are empty. Add a ShellSpec case that enters this branch, terminates the waiting shell, and verifies cleanup begins without waiting for the full health-check interval; otherwise the behavior this change is intended to guarantee can regress unnoticed.
            # Wait for the next watchdog interval. Run sleep in a child so
            # SIGTERM can interrupt the wait and let the service's signal/exit
            # cleanup run promptly.
            sleep "${HEALTH_CHECK_INTERVAL}" &
            wait $!
  • Files reviewed: 6/6 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Shortening the fault clocks so the matrix fits the VMSS budget removed the one
thing the PR is actually about. The design claim is a margin:

    threshold   = StartLimitIntervalSec / StartLimitBurst = 144s
    worst cycle = TimeoutStartSec + TimeoutStopSec + RestartSec = 122s

720/5 was chosen because 144s clears 122s. With TimeoutStartSec shortened to 15s
for the matrix, the hung-start cycle drops to ~20s -- far under the threshold --
so every mode would pass even if the threshold were mis-sized. The test would go
green while the design was broken.

Two checks restore it, both before any clock is shortened:

- Arithmetic on the live values. Verified to reject every realistic mis-sizing:
  the shipped 10s/5 budget (threshold 2s), an interval of 100s (20s), burst
  raised to 10 without raising the interval (72s), and TimeoutStartSec raised to
  5min (worst cycle 332s). Costs nothing.

- A measured worst cycle. The arithmetic checks a model of the unit; this checks
  the machine. Induce the hung start at shipped clocks, time two consecutive
  ExecStarts, assert the cycle is under the threshold, then abort -- the
  remaining starts only repeat the same cycle. ~100s rather than the ~487s a
  full burst would cost.

The fault drop-in is split so the clock shortening now lands after the
measurement rather than with the ExecStart override.

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 watchdog child can delay restarts until the stop timeout, and the full E2E matrix exceeds the scenario timeout.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

parts/linux/cloud-init/artifacts/localdns.sh:944

  • 🔴 High Risk — Script Logic: With KillMode=mixed, systemd sends the initial SIGTERM only to the main Bash process. This new background sleep therefore survives when Bash exits, keeps the service cgroup non-empty, and makes systemd wait until TimeoutStopSec=30 before killing it. A normal restart can consequently consume the full 30-second stop timeout and collide with the new timeout 30 systemctl restart localdns provisioning path. Track this child and explicitly terminate/reap it from a TERM trap (or use a kill mode that signals the whole cgroup) so the intended prompt shutdown actually occurs.
            sleep "${HEALTH_CHECK_INTERVAL}" &
            wait $!
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread e2e/scenario/scenario_localdns_hosts.go Outdated
Comment on lines +61 to +65
// The full failure-mode matrix runs on Ubuntu2404 only: it is
// systemd 255, where daemon-reload does not clear the start
// limiter and where the provisioning regression was found. It
// costs ~23min, so the other distros run the single
// discriminating mode instead (~40s) -- enough to catch the
…erly

Three review items.

1. The backgrounded watchdog sleep was never reaped. SIGTERM was untrapped, so
   bash died on the default action: the EXIT cleanup never ran, and the sleep
   survived as an orphan in the unit's cgroup. With KillMode=mixed systemd
   signals only the main process, so it then held the unit in stop-sigterm until
   the sleep finished on its own (up to HEALTH_CHECK_INTERVAL) or TimeoutStopSec
   expired and it was SIGKILLed -- delaying the stop, and with it the next start,
   which is the opposite of what RestartSec=2 is for.

   Trap SIGTERM, kill and wait on the child via a named stop_watchdog_sleep, and
   exit 0 because a requested stop is not a failure and Restart=on-failure must
   not fire for it.

2. That path had no coverage. Added tests using real processes and real signals
   rather than mocks: a mocked kill would prove nothing about whether the child
   actually goes away. Verified by deleting the kill and watching the test fail
   with "child N survived stop_watchdog_sleep".

   The first version of that test was itself broken -- it checked
   WATCHDOG_SLEEP_PID after stop_watchdog_sleep had cleared it, so it was
   evaluating 'kill -0 ""' and passed even with the kill removed. It now saves
   the pid first.

3. The reset-failed spec only proved both calls appeared somewhere, so hoisting
   reset-failed out of the retry loop would still have passed. It now records an
   ordered trace and asserts RSRSRS across two failed restarts and a success,
   and asserts the give-up path ends on a start attempt rather than a reset, so
   the unit is left in 'failed' for NPD. Verified by hoisting the call and
   watching it fail.

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 new budget can break old-CSE/new-VHD provisioning, and the E2E validation has timeout and cleanup correctness issues.

Review details

Suppressed comments (4)

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

e2e/scenario/scenario_localdns_restart_budget.go:131

  • 🟡 Medium Risk — 🏗️ E2E cleanup: Teardown is registered only after installation succeeds. The installer writes the replacement script and service drop-in before its final daemon-reload; if that or another late command fails, this function returns here without removing the partial harness, leaving the node's LocalDNS configuration mutated. Register the idempotent teardown before calling the installer so partial-install failures are restored too.
    e2e/scenario/scenario_localdns_restart_budget.go:642
  • 🟡 Medium Risk — 🧪 E2E logic: The comment says both fewer and more than the burst are failures, but this condition only rejects fewer starts. If an unexpected reset or limiter regression permits more than five ExecStarts before the eventual refusal, the test passes even though the configured budget was not enforced as intended. Compare for exact equality here.

parts/linux/cloud-init/artifacts/localdns.service:30

  • 🔴 High Risk — 🔄 Backward Compatibility: This 12-minute window only remains provisioning-safe when the matching new CSE calls reset-failed before every attempt. The repository explicitly supports old CSE + new VHD (cse_config_localdns.sh:21-23), but the old CSE path is the removed systemctlEnableAndStart call, whose helper only does daemon-reload; as this PR itself notes, that does not clear the limiter on Ubuntu 24.04/systemd 255. A transient startup failure can therefore spend all five starts and make every old-CSE retry fail with start-limit-hit until its provisioning timeout. Please gate/install the extended budget through a compatibility handshake or otherwise preserve the old retry path on new VHDs.
StartLimitIntervalSec=720
StartLimitBurst=5

e2e/scenario/scenario_localdns_hosts.go:69

  • 🟡 Medium Risk — 🧪 E2E reliability: This selects a matrix documented just above as taking about 23 minutes, but the scenario runs under the default 17-minute TestTimeoutVMSS context (e2e/config/config.go:125), which also includes VM creation (e2e/scenario/provision.go:248-261). The context can therefore expire before this validator completes, turning the Ubuntu 24.04 lane into a deterministic/likely timeout rather than useful coverage. Please reduce the runtime further or move this destructive matrix to a separately budgeted lifecycle.
					if tt.name == "Ubuntu2404" {
						faults = localdnsFaultMatrix
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@yewmsft

Copy link
Copy Markdown
Member

reset-failed in the loop addresses my earlier point, thanks. re-reviewed the rest:

1. 720/5 = 144s rests on an implicit TimeoutStartSec. localdns.service pins TimeoutStopSec=30 explicitly but leaves the start timeout to whatever the image's DefaultTimeoutStartSec is. start is the bigger term in your ~125s. pin it:

TimeoutStartSec=90

otherwise a distro default change, or someone adding TimeoutStartSec= later, silently pushes the slowest cycle above 144s and the slow modes go back to restarting forever — the exact bug this PR fixes, with nothing to catch it.

2. what clears failed after provisioning? reset-failed only runs inside enableLocalDNS. a node that trips 5-in-720s at hour 3 has localdns dead until reboot. is NPD wired to remediate, or only to observe? if only observe, please say so in the description — "terminates in failed" reads like recovery.

3. cse_config_localdns.sh:143 — two problems with the status log:

  • systemctlEnableAndStart wrote it on both paths; now it's give-up only. we lose happy-path unit state on every node. please write it on success too.
  • it's taken immediately after the last failed restart, so the unit is still activating (auto-restart) — the log will never show the terminal state you're capturing it for.

4. localdns.sh:1111 — the TERM trap means cleanup_localdns_configs now runs in-process on every stop and again via ExecStopPost. the in-process one sleeps LOCALDNS_SHUTDOWN_DELAY=5 (:768), SIGINTs coredns, then waits. that's new time inside TimeoutStopSec=30 that your ~125s cycle math doesn't account for.

worse, wait "${COREDNS_PID}" at :783COREDNS_PID comes from the pidfile and coredns runs under systemd-cat, so it is not a child of this shell. that wait fails, cleanup_localdns_configs returns 1, and the EXIT trap prints "Cleanup failed". pre-existing, but before this PR bash died on the default TERM action and never got here; now it runs on every normal stop.

5. cse_config_localdns.sh:146systemctl enable moved after the start loop and has no check_cse_timeout. worst case 120×(25+5) = 60min on top of the loop's 10. the numbers are inherited from the old helper so not new, but the loop above it now guards against CSE's 15m kill and this doesn't. same guard here?

nit: description lists KillMode=mixed under "Change" — it's already in the unit, the diff doesn't touch it.

…endent

The 720/5 threshold (144s) is sized to clear the slowest restart cycle,
TimeoutStartSec + TimeoutStopSec + RestartSec. Two of those three were
pinned by the unit and the third was inherited from the manager default
-- which is a systemd build-time constant (-Ddefault-timeout-sec), not a
constant across the images we ship. Ubuntu builds it at 90s; Azure Linux
3.0 builds it at 45s.

So the worst cycle, and therefore the margin under the threshold, was
silently different per distro: 122s on Ubuntu, 77s on Azure Linux. Both
clear 144s today, so this is not a live defect, but the margin the unit
documents was never actually the unit's to guarantee, and a change to
DefaultTimeoutStartSec on any image would move it with nothing to catch
that.

Pin it at 90 so the arithmetic in the comment above is self-contained.

This also makes the e2e assertion honest. It checks
TimeoutStartUSec="1min 30s", which passed on Ubuntu and failed on
AzureLinuxV3 with "got 45s" -- correctly reporting a real difference,
but one no image was ever going to satisfy uniformly while the value
was inherited.

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

Two independent failures from the check-in gate on fb79777 (build
181710671).

1. hungstart timed out at its deadline with execstarts=3, sub=stop-sigterm.

   installLocalDNSFastClocks shortened TimeoutStartSec 90->15 but left
   TimeoutStopSec at 30, so each cycle is 15+30+2 = 47s rather than the
   ~22s the deadline was sized for. Five of those do not fit 120s; the
   run gave up mid-cycle three.

   The stop phase is consumed in full because of the TERM trap added in
   fb79777. The fault holds a foreground 'sleep infinity', and bash
   defers a trapped signal until the foreground child returns, so nothing
   answers the SIGTERM and systemd waits out TimeoutStopSec before the
   SIGKILL. Untrapped -- which is what the by-hand matrix on the PR
   measured -- bash died immediately and the stop phase cost ~6s, which is
   why that run recorded 487s for a mode that now costs ~625s shipped.
   Nothing in localdns.sh blocks that way in production: the watchdog uses
   'sleep & wait', which the trap does interrupt.

   Shorten TimeoutStopSec to 5 alongside the other two clocks. Cycle
   returns to 22s, the mode terminates in ~110s, and the deadline goes to
   180s for real slack. Nothing that reads TimeoutStopSec runs after this
   point -- the margin assertion and measureLocalDNSWorstCycle both run
   before the fast clocks install -- so no coverage is lost.

2. The directive assertion cannot pass on the non-gate e2e lane.

   localdns.service is baked into the VHD, so the budget only exists on an
   image built from a branch carrying it. The check-in gate builds this
   PR's images, but .pipelines/e2e.yaml also triggers on any PR touching
   e2e/** and resolves images by branch=refs/heads/main, which still ship
   the systemd default. Build 181710682 failed all three distros with
   "expected StartLimitIntervalUSec=12min, got 10s" -- an accurate reading
   of an image that was never meant to carry it.

   Probe StartLimitIntervalUSec up front and skip when it is absent, the
   way validateLocalDNSLifecycle already skips its dead-service block on
   images predating the ExecStopPost hook. The interval is the right probe
   because it is unambiguous provenance; TimeoutStartUSec now reads
   1min 30s on new images and on older Ubuntu ones alike.

Also carries the comment update matching the TimeoutStartSec pin.

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 baked restart budget can break new-VHD/old-CSE provisioning compatibility on transient failures.

Review details

Suppressed comments (1)

parts/linux/cloud-init/artifacts/localdns.service:31

  • 🔴 High Risk — This budget depends on the new CSE's reset-failed loop, but the unit is baked into a new VHD while an older CSE can still be supplied through CustomData. The repository explicitly supports new-VHD/old-CSE skew (localdns.sh:1025-1027), and the old path uses a 100×5s systemctlEnableAndStart loop without resetting the limiter (cse_helpers.sh:623-625). On systemd 255, one transient burst can therefore lock every remaining old-CSE attempt out for 720s; those retries finish in roughly 500s and provisioning exits 216 even if the transient has cleared. Please make the unit's rollout compatible with the old retry path rather than relying solely on the new CSE behavior.
StartLimitIntervalSec=720
StartLimitBurst=5
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

return false, fmt.Errorf("read the LocalDNS start-limit interval: %w", err)
}
interval := strings.TrimSpace(result.stdout)
if interval != localdnsExpectedStartLimitInterval {

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.

The provenance probe is keyed on a value that is itself under test, so the assertion it guards can no longer fail. check StartLimitIntervalUSec "12min" (:329) only ever runs on an image where :299 already matched the same constant — sharing localdnsExpectedStartLimitInterval between the two makes that structural, not incidental.

The consequence is bigger than one dead assertion: retune the budget later (720 → 600, or a burst change that moves the interval) and every lane skips, all three distros go green, and nothing anywhere says the restart budget stopped being tested. That is the exact failure mode this scenario exists to catch.

Same for the e2e.yaml lane you're working around — a PR that only touches e2e/** now has all three distros skip, i.e. a fully green localdns lane that validated none of this.

Gate on the lane rather than on the SUT. The test already knows which image it asked for — VHD_BUILD_ID vs SIG_VERSION_TAG_VALUE=refs/heads/main, per the message you removed at :389. Skip when the lane resolved a main-built image; assert unconditionally otherwise. Then the directives assertion is falsifiable again and a retune fails loudly on the PR's own VHD.

On the precedent you cite: scenario_localdns_hosts.go:217 probes ExecStopPost to decide whether to test ExecStopPost, so it has the same property. That argues for fixing both, not for keeping the shape.

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.

Gate landed the way I hoped — vhdCarriesLocalDNSRestartBudget is gone and laneResolvedMainBuiltImage() reads only config, nothing from the node:

return config.Config.SIGVersionTagName == "branch" &&
    config.Config.SIGVersionTagValue == "refs/heads/main"

Checked both sides resolve as intended: e2e/config/config.go:117-118 defaults to branch / refs/heads/main, so a local run against a main-built image skips; .pipelines/scripts/e2e_run.sh:85-86 exports SIG_VERSION_TAG_NAME=buildId and SIG_VERSION_TAG_VALUE=$VHD_BUILD_ID, so every pipeline lane fails the condition and asserts unconditionally. Retune the budget now and the PR's own VHD goes red instead of silently skipping — which was the whole ask.

Also glad you took the same gate to validateLocalDNSLifecycle (scenario_localdns_hosts.go:87) rather than leaving the precedent as-is.

localdnsFastClockDropIn = "/run/systemd/system/localdns.service.d/99-e2e-fastclock.conf"
// StartLimitIntervalSec=720 as systemd formats it. Shared by the provenance probe and
// the directive assertion so the two cannot drift apart.
localdnsExpectedStartLimitInterval = "12min"

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, but load-bearing now that this decides run-vs-skip rather than just pass-vs-fail: "12min" is systemd's rendered timespan, and this file already declines to trust that rendering — localdnsWorstCycleScript (:215) pipes the same property through systemd-analyze timespan before doing arithmetic on it.

Do the same here and compare 720000000 numerically. Otherwise a formatting difference on some image turns the whole scenario off silently instead of failing it, which is the one outcome this skip must not produce.

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.

nvm on this one — the numeric comparison mattered only because a rendering difference could flip the scenario off. Now that the skip is keyed on the lane instead of on the unit, check StartLimitIntervalUSec "12min" can only fail loudly. Leave it as the literal.

Comment thread e2e/scenario/scenario_localdns_hosts.go Outdated
// The full failure-mode matrix runs on Ubuntu2404 only: it is
// systemd 255, where daemon-reload does not clear the start
// limiter and where the provisioning regression was found. It
// costs ~23min, so the other distros run the single

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.

~23min doesn't fit the budget it's being justified against: TestTimeoutVMSS is 17min (e2e/config/config.go:125), and validation shares it with VM creation. I assume the figure is stale — a shipped-clock measurement carried forward past the shortening — but as written it says the Ubuntu2404 lane cannot pass, and whoever sizes the next fault against it will get it wrong.

The real worst case is tight anyway. Deadlines now sum to 755s (45+80+120+90+120+120+180, after this commit's hungstart 120 → 180), and measureLocalDNSWorstCycle can spend its full seq 1 240 poll before failing — ~16.8min before a single second of VM creation. When it goes over, the VMSS timeout pre-empts the per-fault deadline and you lose the one diagnostic all this careful sizing exists to produce ("hung start did not terminate within 180s"); you get a generic scenario timeout instead.

Two cheap fixes: drop the measurement poll ceiling from 240s to ~180s (the cycle it waits for is 122s, and on the success path it breaks early anyway), and add a unit test asserting sum(deadlineSeconds) + measurement ceiling < TestTimeoutVMSS - <vm-creation allowance> so the next deadline bump fails at build time instead of as a flaky lane.

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.

The sizing model is right — "one healthy run plus one regression" is the correct worst case to pin, and putting it in a test so the next deadline bump fails at build time is exactly the shape I asked for.

What I'd still change: the margin is implicit, and it's thin. Adding up what TestLocalDNSFaultMatrixFitsVMSSBudget computes at head:

  • localdnsNonMatrixAllowance 300s
  • localdnsWorstCycleCeilingSeconds 180s
  • sum(measuredSeconds) = 11+37+64+30+75+90+110 = 417s
  • max(deadline - measured) = 70s (hungstart, 180-110)

= 967s against TestTimeoutVMSS = 17min = 1020s. 53 seconds of headroom, and the one input you flagged as uncontrolled — VM creation — is inside the 300s allowance, not on top of it. One slow VMSS create and the lane goes over, and the failure you get is the generic scenario timeout, not "hung start did not terminate within 180s".

Make the margin a named constant the assertion has to clear, e.g.

const localdnsBudgetMargin = 120 * time.Second
...
require.Less(t, worst+localdnsBudgetMargin, budget,
    "fault matrix leaves less than %s of slack under TestTimeoutVMSS", localdnsBudgetMargin)

Then a future deadline bump (or a re-measure that comes in higher) fails on the number that actually matters, and anyone tightening TestTimeoutVMSS finds out here rather than on a flaky lane. Sizing localdnsNonMatrixAllowance would also benefit from a one-line comment saying where 300s came from — measured, or budgeted?

The 720/5 threshold (144s) is sized against TimeoutStartSec + TimeoutStopSec
+ RestartSec. Pinning the start timeout at 90 (previous commit, per review)
pushed AzureLinuxV3 to a measured 153s -- above the threshold, meaning the
slow failure modes would restart forever there, which is the bug this budget
exists to prevent.

The stop side is doubled on that distro. Azure Linux ships a global drop-in,
/usr/lib/systemd/system/service.d/10-timeout-abort.conf, setting
TimeoutStopFailureMode=abort (Fedora's "Shorter Shutdown Timer"). On a stop
timeout systemd sends SIGABRT to capture a core dump, then waits a SECOND
TimeoutStopSec in 'stop-watchdog' before SIGKILL. From the node's journal:

  18:51:49  start operation timed out. Terminating.      <- SIGTERM
  18:52:19  State 'stop-sigterm' timed out. Aborting.    <- +30s, SIGABRT
  18:52:49  State 'stop-watchdog' timed out. Killing.    <- +30s, SIGKILL
  18:52:50  ExecStopPost done (1s), Failed with result 'timeout'
  18:52:52  restart

90 + 30 + 30 + 1 + 2 = 153s, against the measured 153s.

Before the start timeout was pinned this did not show, because Azure Linux
also builds systemd with -Ddefault-timeout-sec=45: 45 + 60 + 2 = 108s stayed
under 144. The margin was holding on a coincidence -- a shorter start timeout
cancelling a doubled stop timeout -- on one distro, undocumented either way.

Pin 'terminate' (the systemd default) so the distro's drop-in is out of the
cycle and every image is 90 + 30 + 2 = 122s. The core dump the abort step
exists to produce is already discarded on AKS nodes: configureCoreDump() in
cis.sh sets Storage=none and ProcessSizeMax=0 for every distro, asserted by
testCoreDumpSettings in linux-vhd-content-test.sh. Today that step costs 30s
of restart cycle and produces nothing. The unit comment records that
dependency and says to revisit this if the hardening is ever relaxed.

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

Three review fixes on the restart-budget validation.

1. The provenance guard was self-defeating. It skipped unless
   StartLimitIntervalUSec was 12min -- the same value the directive assertion
   then checks -- so that assertion could only run where it was already
   guaranteed to pass, and sharing a constant between the two made it
   structural rather than incidental. Worse, a later retune (720 -> 600) would
   have skipped every lane, gone green on all distros, and said nothing about
   the budget no longer being tested.

   Gate on the lane instead. The check-in gate sets SIG_VERSION_TAG_NAME=buildId
   from VHD_BUILD_ID (.pipelines/scripts/e2e_run.sh) and tests this PR's VHDs;
   everything else falls back to branch=refs/heads/main, which resolves a
   main-built image that legitimately predates the budget. Skip only that case,
   and assert unconditionally otherwise, so the assertion is falsifiable again.

   validateLocalDNSLifecycle had the identical shape -- probing ExecStopPost to
   decide whether to test ExecStopPost -- so it now takes the same lane gate and
   asserts the hook is present rather than using its presence as permission to
   look.

2. Assert TimeoutStopFailureMode. The margin arithmetic models the stop side as
   one TimeoutStopSec and cannot see the abort state Azure Linux's global
   drop-in introduces, so without this it keeps reporting a comfortable 122s
   while the machine sits at 153s. That is exactly what happened.

3. Size the matrix against TestTimeoutVMSS at build time. Going over budget
   does not fail like a sizing error: the VMSS context deadline pre-empts the
   per-fault deadline, so instead of "hung start did not terminate within 180s"
   -- the diagnostic all this sizing exists to produce -- you get a generic
   scenario timeout. TestLocalDNSFaultMatrixFitsVMSSBudget now fails the build
   when it stops fitting.

   The model is a healthy run plus one regression (allowance + worst-cycle
   ceiling + sum(measured) + max(deadline - measured)), not every deadline
   firing at once: a mode only costs its deadline when broken, and the first
   broken mode aborts the run. Measured times move from comments into the
   matrix as data so the test can use them. The worst-cycle poll ceiling drops
   240s -> 180s, just above the 122s cycle it waits for, and the stale "~23min"
   note on the Ubuntu2404 selection is corrected -- it described shipped clocks
   and was never true after the shortening.

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
staging_cse_windows Package 1 58%
debug 0%
provisioningscripts 2%
parts_windows Package 1 76%
test 0%
windows 21%
Summary 36% (1434 / 6706)

@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage

Package Line Rate Health
shellspec spec 21%
Summary 21% (2883 / 13956)

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 VHD-baked restart limit can break transient-failure recovery when paired with an older CSE that lacks the new reset loop.

Review details

Suppressed comments (1)

parts/linux/cloud-init/artifacts/localdns.service:32

  • 🔴 High Risk — Backward Compatibility: This 12-minute window is safe only when provisioning has the new reset-failed loop. The unit is baked into the VHD, while cse_config_localdns.sh is CSE-delivered (vhdbuilder/packer/packer_source.sh:422-429, pkg/agent/const.go:62-64), so an older provisioner can still use the previous systemctlEnableAndStart loop. On systemd 255, once a transient startup issue consumes these five starts, every remaining old-CSE retry is refused even after the issue clears, causing node provisioning to fail. Please provide a VHD-side compatibility mechanism or defer activating the long budget until provisioning no longer depends on the old retry loop.
StartLimitIntervalSec=720
StartLimitBurst=5
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

# raised -- revisit this directive. The abort step would start producing a usable dump, and
# the 30s it costs would have to be bought back elsewhere in the cycle or the threshold
# widened, or Azure Linux goes back over 144s.
TimeoutStopFailureMode=terminate

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.

This pin likely doesn't take on the distro it's aimed at, and your own new assertion should be the thing that catches it.

man 5 systemd.unit on drop-ins: "Drop-in files under any of these directories take precedence over unit files wherever located." So /usr/lib/systemd/system/service.d/10-timeout-abort.conf beats TimeoutStopFailureMode= set in localdns.service itself — including the copy we install at /etc/systemd/system/localdns.service. The effective value on AzureLinuxV3 stays abort, the stop side still doubles, and the cycle stays at the 153s this comment is written to avoid.

Worth confirming before merge, one command on an AzureLinuxV3 node:

systemctl show -p TimeoutStopFailureMode localdns.service

If it prints abort, then check TimeoutStopFailureMode "terminate" in scenario_localdns_restart_budget.go should be red on that lane right now — which would make this a good catch by the new assertion rather than a bug that ships.

The fix is cheap because we already ship a unit-specific drop-in for this unit: packer_source.sh:431-432 installs localdns-delegate.conf to /etc/systemd/system/localdns.service.d/delegate.conf. Drop-ins apply in lexicographic order by filename regardless of which directory they live in, so a unit-specific file whose name sorts after 10- wins — either move the directive into delegate.conf's [Service] section (delegate > 10-, so it already sorts last) or add 99-timeout-terminate.conf beside it. Keep the comment block where it is either way; it's the most useful part of this change.

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

3 participants