-
Notifications
You must be signed in to change notification settings - Fork 277
fix: bound LocalDNS restart budget so failures terminate deterministically #9439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b07dd85
fc320f6
76c628b
fe3c99a
a071cae
ff80671
9a4e3a0
2ee5a55
c1133e3
4954e95
ec5f390
fa30d82
5017d1c
fe19732
c0f7541
fb79777
4326b8b
32e6432
fbe622a
be8e8ff
f31bb56
90c3922
e1f31d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package scenario | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/Azure/agentbaker/e2e/config" | ||
| ) | ||
|
|
||
| // localdnsNonMatrixAllowance is the time the LocalDNS scenarios must leave for everything | ||
| // that is not the fault matrix: VMSS create, node bootstrap, the scenario's default | ||
| // provisioning validation, validateLocalDNSLifecycle, and the provisioning-restart loop. | ||
| // | ||
| // Measured on the check-in gate (build 181710671, LocalDNSHostsPlugin/Ubuntu2404): VM | ||
| // creation and the default validations finished at 181s, and the lifecycle validation plus | ||
| // the provisioning-restart loop added roughly another 100s. 300s is that with margin, | ||
| // because creation time is the part we do not control. | ||
| const localdnsNonMatrixAllowance = 300 * time.Second | ||
|
|
||
| // TestLocalDNSFaultMatrixFitsVMSSBudget fails the build when the fault matrix can no longer | ||
| // finish inside TestTimeoutVMSS. | ||
| // | ||
| // This exists because going over the budget does not fail like a sizing error. The VMSS | ||
| // context deadline pre-empts the per-fault deadline, so instead of the specific diagnostic | ||
| // the matrix is built to produce -- "hung start did not terminate in 'failed' within 180s", | ||
| // which says the restart budget stopped bounding that mode -- you get a generic scenario | ||
| // timeout that says nothing. Every per-fault deadline is carefully sized, and all of that | ||
| // work is wasted the moment the sum stops fitting. | ||
| // | ||
| // The model is "a healthy run plus one regression", not "every deadline fires at once": | ||
| // | ||
| // allowance + worst-cycle ceiling + sum(measured) + max(deadline - measured) | ||
| // | ||
| // A mode only costs its deadline when it is broken, and the first broken mode aborts the | ||
| // run, so at most one overrun is ever paid. Summing all seven deadlines would instead | ||
| // assert a state that can never be reached and would force the matrix to be cut for no | ||
| // reason. What this does guarantee is the property that matters: if any single mode | ||
| // regresses, its deadline fires and reports itself before the VMSS context expires. | ||
| func TestLocalDNSFaultMatrixFitsVMSSBudget(t *testing.T) { | ||
| vmssBudget := config.DefaultConfiguration().TestTimeoutVMSS | ||
|
|
||
| var healthy time.Duration | ||
| var worstOverrun time.Duration | ||
| for _, fault := range localdnsFaultMatrix { | ||
| if fault.measuredSeconds <= 0 { | ||
| t.Errorf("fault %q has no measuredSeconds; the matrix cannot be sized against "+ | ||
| "TestTimeoutVMSS without it. Run the mode on the gate and record what it took.", | ||
| fault.name) | ||
| continue | ||
| } | ||
| if fault.deadlineSeconds <= fault.measuredSeconds { | ||
| t.Errorf("fault %q has deadlineSeconds=%d at or below measuredSeconds=%d; it will "+ | ||
| "fail on a healthy node", fault.name, fault.deadlineSeconds, fault.measuredSeconds) | ||
| continue | ||
| } | ||
| healthy += time.Duration(fault.measuredSeconds) * time.Second | ||
| if overrun := time.Duration(fault.deadlineSeconds-fault.measuredSeconds) * time.Second; overrun > worstOverrun { | ||
| worstOverrun = overrun | ||
| } | ||
| } | ||
|
|
||
| // The worst-cycle measurement runs before the matrix and can spend its full poll. | ||
| ceiling := localdnsWorstCycleCeilingSeconds * time.Second | ||
| total := localdnsNonMatrixAllowance + ceiling + healthy + worstOverrun | ||
|
|
||
| if total > vmssBudget { | ||
| t.Errorf( | ||
| "LocalDNS restart-budget validation cannot fit TestTimeoutVMSS.\n"+ | ||
| " non-matrix allowance: %v (VM create + default validation + lifecycle)\n"+ | ||
| " worst-cycle ceiling: %v\n"+ | ||
| " healthy matrix run: %v (%d modes)\n"+ | ||
| " worst single overrun: %v\n"+ | ||
| " total: %v\n"+ | ||
| " TestTimeoutVMSS: %v\n"+ | ||
| "Over by %v. Reduce a deadline, drop a fault from localdnsFaultMatrix, lower "+ | ||
| "localdnsWorstCycleCeilingSeconds, or move the matrix to its own scenario. Do not "+ | ||
| "raise the allowance without a measurement to back it.", | ||
| localdnsNonMatrixAllowance, ceiling, healthy, len(localdnsFaultMatrix), | ||
| worstOverrun, total, vmssBudget, total-vmssBudget, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // TestLocalDNSDiscriminatingFaultIsInMatrix guards localdnsDiscriminatingFault's lookup. | ||
| // | ||
| // It returns nil if the matrix no longer contains the mode it names, and a nil fault list | ||
| // makes validateLocalDNSRestartBudget fail every non-Ubuntu2404 lane with a confusing | ||
| // "fault matrix is misconfigured" at runtime. Catch a rename here instead. | ||
| func TestLocalDNSDiscriminatingFaultIsInMatrix(t *testing.T) { | ||
| if got := localdnsDiscriminatingFault(); len(got) != 1 { | ||
| t.Fatalf("localdnsDiscriminatingFault() returned %d faults, want exactly 1; "+ | ||
| "the mode it looks up is no longer in localdnsFaultMatrix", len(got)) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| package scenario | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "testing" | ||
| ) | ||
|
|
||
| // bastionMaxScriptBytes is the largest script this package can send to a node. | ||
| // | ||
| // Scripts are SCP'd to the VM over the Bastion tunnel, and tunnelSession.Write | ||
| // (bastionssh.go) forwards whatever the SSH transport hands it as a single websocket | ||
| // message with no chunking. Azure Bastion caps an inbound message at 8,192 bytes and closes | ||
| // the tunnel with StatusMessageTooBig when one exceeds it — taking the whole scenario down | ||
| // mid-run, with no indication that script length was the cause. | ||
| // | ||
| // go-scp emits the file body as a 4,096-byte first chunk then the remainder in one write, | ||
| // so the largest wire write is: | ||
| // | ||
| // max_write = script_size - 4096 + 45 (45 = SSH framing + MAC) | ||
| // | ||
| // which first exceeds 8,192 at a script size of 12,243. The limit below is therefore the | ||
| // last safe size, measured by sweeping script sizes one byte at a time against a real | ||
| // x/crypto/ssh client and server. | ||
| // | ||
| // Gate build 181818040 lost three LocalDNS lanes to exactly this: the lifecycle script had | ||
| // been sitting 380 bytes under the cliff and grew 512 bytes, producing an 8,324-byte write. | ||
| // | ||
| // The right long-term fix is chunking in tunnelSession.Write, which is shared e2e | ||
| // infrastructure and out of scope here. Until that lands, this test is the guard. | ||
| const bastionMaxScriptBytes = 12242 | ||
|
|
||
| // TestLocalDNSScriptsFitBastionLimit fails the build when any script this package sends | ||
| // would kill the Bastion tunnel. | ||
| // | ||
| // It covers every script, not just the one that broke, because the failure mode gives no | ||
| // hint about its cause: the scenario dies with a websocket close frame and loses its node | ||
| // logs, so whoever hits it next starts from "the node became unreachable" rather than | ||
| // "my script got too long". Cheaper to fail here. | ||
| // | ||
| // If a script does outgrow the limit, prefer moving its comments into Go — they cost the | ||
| // same on the wire as code and buy nothing at runtime. validateLocalDNSLifecycle's doc | ||
| // comment is the worked example. | ||
| func TestLocalDNSScriptsFitBastionLimit(t *testing.T) { | ||
| scripts := map[string]string{ | ||
| "lifecycle(true)": localdnsLifecycleScript("true"), | ||
| "lifecycle(false)": localdnsLifecycleScript("false"), | ||
| "localdnsDirectiveAssertScript": localdnsDirectiveAssertScript, | ||
| "localdnsWorstCycleScript": localdnsWorstCycleScript, | ||
| "localdnsProvisioningRestartScript": localdnsProvisioningRestartScript, | ||
| "localdnsFaultHarnessInstallScript": localdnsFaultHarnessInstallScript, | ||
| "localdnsFaultTeardownScript": localdnsFaultTeardownScript, | ||
| } | ||
| for _, fault := range localdnsFaultMatrix { | ||
| scripts["localdnsFaultRunScript/"+fault.name] = localdnsFaultRunScript(fault) | ||
| } | ||
|
|
||
| for name, script := range scripts { | ||
| if len(script) > bastionMaxScriptBytes { | ||
| t.Errorf( | ||
| "%s is %d bytes, over the %d-byte Bastion tunnel limit by %d.\n"+ | ||
| "This will not fail as a size error: the tunnel closes with "+ | ||
| "StatusMessageTooBig, the scenario reports a dead SSH session, and node log "+ | ||
| "collection fails too. Move the script's comments into Go rather than "+ | ||
| "deleting them — they cost the same on the wire and do nothing at runtime.", | ||
| name, len(script), bastionMaxScriptBytes, len(script)-bastionMaxScriptBytes, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| if t.Failed() || testing.Verbose() { | ||
| for name, script := range scripts { | ||
| fmt.Printf(" %-46s %6d bytes (%d headroom)\n", | ||
| name, len(script), bastionMaxScriptBytes-len(script)) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,16 +7,74 @@ Before=kubelet.service | |
| Before=containerd.service | ||
| # don't run on old images; we're not compatible | ||
| ConditionKernelVersion=>=5.15 | ||
| # Bound the restart budget so every unrecoverable failure mode terminates in | ||
| # 'failed' rather than restarting forever. The threshold is | ||
| # StartLimitIntervalSec/StartLimitBurst = 720/5 = 144s, just above the slowest | ||
| # restart cycle (~122s = TimeoutStartSec 90 + TimeoutStopSec 30 + RestartSec 2, all | ||
| # pinned below -- including TimeoutStopFailureMode, without which Azure Linux spends two | ||
| # stop timeouts and the cycle is 153s, above the threshold), | ||
| # so slow failures (PID file never appears, watchdog kill, ready-check timeout, | ||
| # hung start) still reach 'failed'. That terminal state is what lets an | ||
| # OnFailure= handoff fire and gives NPD a stable state to observe. | ||
| # | ||
| # This budget is shared with CSE's provisioning restarts, not just systemd's own | ||
| # auto-restart cycles: a manual 'systemctl start/restart' counts against | ||
| # StartLimitBurst exactly like an automatic restart. A single CSE start therefore | ||
| # costs one slot and lets Restart=on-failure spend the remaining four within | ||
| # ~11s, after which every further CSE attempt is refused with "Start request | ||
| # repeated too quickly" for the rest of the 720s window. enableLocalDNS() runs | ||
| # 'systemctl reset-failed localdns' before each provisioning attempt to keep that | ||
| # from wedging node provisioning, and deliberately skips it on the give-up path | ||
| # so the unit is left in 'failed'. Don't rely on 'systemctl daemon-reload' to | ||
| # clear this instead: it resets start_ratelimit on systemd 249 but not on 255 | ||
| # (Ubuntu 24.04), which is what AKS ships. | ||
| StartLimitIntervalSec=720 | ||
| StartLimitBurst=5 | ||
|
Comment on lines
+31
to
+32
Comment on lines
+31
to
+32
|
||
|
|
||
| [Service] | ||
| Type=notify | ||
| NotifyAccess=all | ||
| WatchdogSec=60 | ||
| Restart=on-failure | ||
| # Load-bearing: give each restart time to sample real system state instead of | ||
| # re-entering the transient it is retrying against. cleanup_iptables_and_dns | ||
| # runs 'networkctl reload' on every start, and the dummy interface carrying the | ||
| # node (169.254.10.10) and cluster (169.254.10.11) listeners is deleted and | ||
| # recreated on every start. At sub-second retries these overlap; 2s lets | ||
| # networkd settle and an orphaned coredns release its sockets before ExecStart. | ||
| RestartSec=2 | ||
| KillMode=mixed | ||
| # Revert node DNS configuration even when the supervisor exits unexpectedly. | ||
| ExecStopPost=/opt/azure/containers/localdns/localdns.sh cleanup | ||
| # Pinned, not inherited, so the StartLimit margin above is self-contained. The | ||
| # manager default is a systemd build-time constant (-Ddefault-timeout-sec) and it | ||
| # is not the same on every image we ship: Ubuntu builds it at 90s, Azure Linux at | ||
| # 45s. Leaving it inherited makes the slowest restart cycle -- and therefore the | ||
| # margin under the 144s threshold -- differ per distro, and a change to | ||
| # DefaultTimeoutStartSec would move it silently. | ||
| TimeoutStartSec=90 | ||
| TimeoutStopSec=30 | ||
| # Pinned for the same reason, and measured rather than assumed. Azure Linux ships a global | ||
| # drop-in at /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. That doubles the stop side of the restart cycle. Measured on AzureLinuxV3: | ||
| # 90 + 30 + 30 + 2 = 153s, above the 144s threshold -- so the slow modes would restart forever | ||
| # there, which is exactly what this budget exists to prevent. 'terminate' is the systemd | ||
| # default; pinning it makes the cycle 90 + 30 + 2 = 122s on every image we ship. | ||
| # | ||
| # The core dump that step exists to produce is already discarded on AKS nodes: | ||
| # configureCoreDump() in parts/linux/cloud-init/artifacts/cis.sh sets Storage=none and | ||
| # ProcessSizeMax=0, applied to every distro (it runs before the Mariner/AzureLinux early | ||
| # return in applyCIS), and asserted by testCoreDumpSettings in | ||
| # vhdbuilder/packer/test/linux-vhd-content-test.sh. So today the abort step costs 30s of | ||
| # restart cycle and produces nothing. | ||
| # | ||
| # If that hardening is ever relaxed -- Storage= set to anything but none, or ProcessSizeMax | ||
| # 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Worth confirming before merge, one command on an AzureLinuxV3 node: If it prints The fix is cheap because we already ship a unit-specific drop-in for this unit: |
||
| Slice=localdns.slice | ||
| EnvironmentFile=-/etc/localdns/environment | ||
| ExecStart=/opt/azure/containers/localdns/localdns.sh | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this budget is shared with cse's provisioning loop, and nothing clears it.
enableLocalDNScallssystemctlEnableAndStart localdns 30(cse_config.sh:2056) ->systemctl_restart 100 5 30->_systemctl_retry_svc_operation(cse_helpers.sh:586-601), which issuessystemctl restart localdnsup to 100 times at 5s. manual restarts count againstStartLimitBurstexactly like auto-restarts, and there is nosystemctl reset-failedanywhere incse_helpers.sh/cse_config.sh/localdns.sh.so at provisioning time:
daemon-reload+status+ an unboundedjournalctl -u+sleep 5), so 100 iterations run ~600-800s -- about the same span as the window. nearly every retry gets "start request repeated too quickly" and cse exitsERR_LOCALDNS_FAIL.burst didn't change; the recovery cadence did, 10s -> 12min. and the transients this hits are the exact ones
RestartSec=2is here to wait out -- networkd settling, orphaned coredns releasing sockets.tuning can't fix it: you need
interval/burst > 122sto terminate the slow modes, and<= ~10sfor cse to revive at loop cadence. one budget can't be both. so clear it explicitly in the provisioning path, replacingsystemctlEnableAndStart localdns 30 || exit $ERR_LOCALDNS_FAIL:~150s of effective attempts instead of ~25s, and the give-up path deliberately skips the reset so the terminal state npd probes for is preserved.
one thing to verify before changing anything:
_systemctl_retry_svc_operationrunssystemctl daemon-reloadevery iteration (cse_helpers.sh:589). if that resetsstart_limit, this is moot. cheap to confirm on a node.also please say in the comment above that the budget is shared with cse's provisioning restarts -- as written it only reasons about systemd's auto-restart cycles, which is how this got missed.
we've hit this class before:
cse_config.sh:1520-1526describes nvidia-cdi-refresh burning5/10sand leaving the unit "permanently failed and unstartable". puttingreset-failedinto_systemctl_retry_svc_operationwould fix it for every service, but that's a separate pr.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed on a node, and you're right on the substance. I had claimed the opposite earlier in this thread's direction of travel — that claim was wrong, and the reason is worth recording.
Environment:
AKSUbuntu-2404gen2containerd-202609.03.1, Ubuntu 24.04.4, systemd 255.4. Fresh AKS cluster, nodepool withlocalDnsProfile.mode=Required. (Aside for anyone reproducing: localdns is rejected below 4 cores —Standard_D2s_v3returnsBadRequest,D4s_v3works.)I patched the node with this branch's files, verified by hash rather than by assumption —
3df6353d…(localdns.sh),7d2bd1d1…(localdns.service). On-node diff againstmainis exactlyStartLimitIntervalSec=720,StartLimitBurst=5,RestartSec=2plus thesleep … & wait $!change. Afterdaemon-reload+restartthe service came up clean:active/running, both listeners bound,dig @169.254.10.10and@169.254.10.11resolving, external resolution working.On your
daemon-reloadquestionIt resets it on systemd 249 and not on systemd 255. My earlier measurement said otherwise because I was counting starts by grepping the journal, which is unsound here. Re-running with the stub itself counting every real
ExecStartinvocation, identical methodology on both:daemon-reloadfailedfailedfailedSo the
daemon-reloadatcse_helpers.sh:589does not save us on 24.04. The comment currently committed inlocaldns.serviceasserts that reset as general behaviour and cites those bad 249 numbers — that text is false for 24.04 nodes and needs to come out regardless of what else we do here.Impact
Transient that fails the first 8 starts then clears, driven by the real CSE retry loop:
10s/5active/running720s/5failed/failed720s/5, realsystemctl_restart 100 5 30failed/failedJournal shows
Start request repeated too quickly.Your ~600-800s estimate for the loop was accurate — measured 584s, which fits entirely inside the 720s window, so CSE exhausts all 100 attempts before the budget ever refreshes. A transient thatmainrecovers from in 18s becomes an unrecoverable provisioning failure.Caveat: the failure was injected with a stub
ExecStart, not real coredns, so the 8-then-clears scenario is synthetic. The rate limiter is unit-level and the clone's effective directives were identical (StartLimitIntervalUSec=12min,StartLimitBurst=5,RestartUSec=2s), so the mechanism holds, but the specific transient is constructed.Where that leaves the change
RestartSec=2is unaffected and independently justified — networkd settling and orphaned coredns releasing sockets are real, and that directive stands on its own.The
StartLimitpair as written is a regression on 24.04. Yourreset-failedapproach is the right fix and has the added property of not depending on version-specific reload semantics at all, which this exercise shows we cannot rely on. Given that, movingreset-failedinto_systemctl_retry_svc_operationlooks less like a nice-to-have and more like the actual fix — it would cover nvidia-cdi-refresh too, and any future unit that tightens its budget without realising CSE shares it.Two smaller things the node run also confirmed:
TimeoutStartUSecis inherited at1min30s, not pinned by this PR, so the 125s worst-cycle math in the comment rests onDefaultTimeoutStartSec; andOnFailureis empty, so nothing currently consumes thefailedstate this budget exists to produce.