fix(chart): pin CronJob memory requests==limits so a node OOM stops culling them (backend#2935) - #948
Conversation
…ulling them (backend#2935) The ap-workspace-auto-upgrade CronJob was exit-137 (SIGKILL) killed on every hourly tick on a live edge, and image-refresh failed in lock-step (six paired failures) — together, which points at ONE shared cause, not two independent container-limit overruns. Diagnosis (measured, not assumed): - Both CronJobs already declare memory limits: auto-upgrade 256Mi, image-refresh 128Mi. - Neither workload's own working set approaches its limit. helm's peak on this chart is ~50Mi (repo update + search + `helm template` render); kubectl's is ~40-80Mi (one `get deployment -o json` + `rollout status`; the curl HEADs return headers only; jq ~2Mi). So the limit was never the binding constraint, and there is no leak or unbounded read (both scripts read bounded JSON and stream through awk). - What they share is QoS: both are Burstable with requests BELOW real usage (128Mi<256Mi, 64Mi<128Mi). A Burstable pod running above its memory request draws a high oom_score_adj, so under NODE memory pressure the kernel OOM-killer targets it first. The telemetry reason is "Error, exit code 137" (not OOMKilled), consistent with a node-level kill rather than a container hitting its own cgroup limit. auto-upgrade at :23 and image-refresh at :22 overlap, so a single pressure event culls both. Fix — the repo's established remedy for exactly this exit-137 signature: pin requests.memory == limits.memory on both CronJobs (auto-upgrade 256Mi, image-refresh 128Mi). This reserves the memory and lowers oom_score_adj (the pods stay Burstable) so the node OOM-killer no longer prefers them — identical to jobs-manager/pods-monitor after their exit-137 mass-restart OOM (#642, backend#1144), the GPU device plugins (#919), and wait-for-mysql (backend#2913). Limits are UNCHANGED — this is not a "move the cliff" bump; the measured footprints show >3x headroom already. CPU stays burstable (a share weight, throttled not killed — backend#2418). Guard: auto_upgrade_test.yaml and image_refresh_test.yaml now assert requests.memory == limits.memory on each CronJob pod, so the gap that made them easy victims cannot silently reopen. Chart version bumped 1.9.91 -> 1.9.92. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
saadqbal
left a comment
There was a problem hiding this comment.
Good diagnosis — the paired-failure timing argument (6 image-refresh failures out of ~24 runs,
exactly the ones coinciding with the hourly tick) and the Error vs OOMKilled distinction are
both right, and I agree this is a kernel node-OOM rather than either container overrunning its
own cgroup. The peak-RSS measurements are the right instinct too.
But the remedy doesn't reach that mechanism, and the comment says it does. oom_score_adj for a
Burstable pod is 1000 - 1000*memRequest/nodeCapacity — a function of the request against node
capacity, set once at container start. It does not depend on whether the pod is running above
its request, which is what the values.yaml note asserts. So 128Mi→256Mi moves auto-upgrade
993→985 on a 16Gi node, in a 3..999 band; image-refresh 64Mi→128Mi on a 64Gi node moves 999→999,
i.e. nothing. Guaranteed is −997. "A node under pressure no longer prefers it" isn't what this
buys.
And these two are the only pods in the chart one line from Guaranteed — I checked: zero
initContainers on either CronJob, memory now equal, cpu the sole blocker
(helm:cpu(req=50m,lim=500m)). The #642 precedent doesn't transfer, because jobs-manager
renders an unresourced init container and can't reach Guaranteed however you set cpu; these can.
"Share weight, throttled not killed" is true in isolation, but cpu equality is also the QoS
precondition, and that's the part that matters here.
So: either pin cpu and take Guaranteed, stating the reservation-vs-throttle tradeoff, or keep the
memory pin and rewrite the claim as what it actually delivers — an honest scheduler reservation
that reduces node overcommit, which is a real structural fix for #2935's cause and currently
framed as a side-note. I'd take the first for auto-upgrade; it's the security-fix delivery path.
Also nothing establishes why the node was under pressure. #2935's "Do" item 1 is half-answered
— we know it wasn't these two containers, we don't know what it was — and I'd rather that line
landed on the issue before it closes.
Rest is clean and the safety work is solid. No new top-level values key, so no nil-guard needed.
Limits never moved and requests rose to meet them, so no too-low cap was introduced. Guards are
mutation-proof both ways — reverting the request, then separately raising only the limit, each
reddens the right assert. And on the coupling worry: #922's full 19-test suite and all five
goldens pass verbatim against this branch, both CronJobs still Burstable, so no golden moves and
the two can land in either order. Forbid plus startingDeadlineSeconds already bound the
Pending case from your operator note.
Nit: the tests assert two literal values rather than the relation or the class. With #922's
checker landing in the same window the class is derivable in-repo — and "asserted the values
believed to imply the class" is the exact phrase in its docstring.
…ned (backend#2935) Addresses @saadqbal's review on client#948. The memory-only pin was the wrong mechanism for the claim it carried: a Burstable pod's oom_score_adj is 1000 - 1000*memRequest/nodeCapacity, fixed at container start and independent of usage, so raising the request 128Mi->256Mi moves auto-upgrade ~992->984 on a 16Gi node and image-refresh 999->998 on a large node — it does NOT "stop the OOM-killer preferring the pod". Only Guaranteed QoS (oom_score_adj -997) does that. These two CronJobs are the only chart pods one line from Guaranteed: zero initContainers, memory already equal, cpu the sole blocker. (#642 pinned jobs-manager's memory but could not reach Guaranteed there — it renders an unresourced init container; here we can.) So pin cpu too: - auto-upgrade: cpu request 50m->500m (== limit); memory already 256Mi==256Mi. - image-refresh: cpu request 20m->200m (== limit); memory already 128Mi==128Mi. Limits never moved — only requests rose to meet them. The two levers now do two honest things: the memory RESERVATION reduces the node overcommit that let pressure build (the structural half — #2935's cause), and Guaranteed's -997 keeps the pod off the OOM-killer's shortlist under pressure (the mechanism). Reservation-vs-throttle tradeoff stated in values.yaml; helm/kubectl are I/O-bound so the cpu cap does not slow real work; Forbid + startingDeadlineSeconds bound the Pending case. Guards now assert cpu AND memory request==limit on both pods (the in-manifest proxy for Guaranteed given no initContainers; client#922's checker will assert the class directly). Comments corrected to credit each lever with what it actually delivers. Chart 1.9.92 -> 1.9.93 (develop advanced to 1.9.92 under this branch). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…emory-oom # Conflicts: # client/Chart.yaml
|
Thanks @saadqbal — this was exactly right, and I took the first option. Pushed 036ecf1 (+ a develop merge, 638b977). oom_score_adj mechanism / the claim. You're correct and I verified the arithmetic:
#642 doesn't transfer. Agreed — noted in the comment that jobs-manager renders an unresourced init container and can't reach Guaranteed however cpu is set, whereas these two have zero initContainers and can, which is why they get the stronger fix. Reservation-vs-throttle tradeoff is now stated in values.yaml: Guaranteed reserves the full envelope for the brief run and caps it there; helm/kubectl are I/O-bound so the cap doesn't slow real work; limits never moved (only requests rose to meet them); "Do" item 1 (why the node was under pressure). Landed on backend#2935: we know it wasn't these two containers, we can't identify the source from the failure telemetry (no node memory in hand), prime suspect is a spawned training pod whose envelope dwarfs the control plane — with the Tests nit. Guards now assert cpu AND memory request==limit on both pods (the in-manifest proxy for Guaranteed given no initContainers), not just memory literals. Left a note that client#922's Re-requesting your review. |
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 638b977. Configure here.
LukasWodka
left a comment
There was a problem hiding this comment.
Read this at 638b977d. The diagnosis is right and the correction Asad asked for landed properly — not approving only because his CHANGES_REQUESTED is still standing (recorded at 563037df; GitHub dismisses stale approvals on push, not change-requests, so it needs him to clear it). Everything below is so the re-read is short.
The oom_score_adj arithmetic now in the comment is correct, and it was worth correcting. Burstable is 1000 - 1000 * memRequest / nodeCapacity, fixed at pod start and independent of actual usage, so on a 16Gi node 128Mi → 256Mi moves ~992 → ~984 — noise against a Guaranteed pod's -997. The earlier framing credited the memory pin with the victim-preference change; it doesn't do that, the CPU pin does, by moving the pod into Guaranteed. Two levers, two different mechanisms, and the current comment separates them cleanly: the memory request is an honest reservation that reduces node overcommit (attacking the cause), the CPU pin is what moves it off the kernel's shortlist (attacking the symptom). Worth having both, and worth having said which does which.
I checked the tradeoff you flag rather than taking the reassurance. Reserving 500m/256Mi for a brief hourly run can make the pod unschedulable on a small edge, and with concurrencyPolicy: Forbid a single stuck-Pending Job would block every later tick — which for the security-fix delivery path would be a quieter failure than the OOM loop it replaces. It's genuinely bounded: activeDeadlineSeconds is set on both CronJobs (max(900, timeout+300) on auto-upgrade, default 3600 on image-refresh), and a Job's deadline runs from startTime regardless of whether a pod ever scheduled, so a Pending Job self-terminates and releases Forbid. The chart already says as much a few lines up. So the tradeoff is real, acknowledged, and has a working brake — no objection from me.
One gap in the guard, smaller than the one you already named. The comments say the assertion is a proxy for Guaranteed because there are no initContainers — true, I checked both templates. But the QoS class depends on every container in the pod, and the tests assert containers[0] only. Today each CronJob renders exactly one container (helm, refresh), so the proxy holds; add a sidecar tomorrow and the assertions stay green while the class silently drops to Burstable — the same shape of hole as the initContainer one, via a different door. Not worth a commit here given client#922 lands the direct pod-qos-class checker, but the comment naming only initContainers slightly overstates what the proxy covers.
Two things I verified and am happy with: Helm unit tests ran (16s, pass) so the new assertions actually executed rather than being skipped, and both new tests fail if either request is reverted — each resource has its own equal, so the mutation is per-lever rather than one line covering both.
Gate otherwise: 24 pass, 0 failing, 0 pending, MERGEABLE, zero unresolved threads. Ready from my side the moment Asad clears his review.
saadqbal
left a comment
There was a problem hiding this comment.
This is the right shape now — thanks for taking the cpu pin rather than just softening the prose.
I checked the class rather than the values: #922's pod-qos-class.py, which implements
ComputePodQOS including init containers, derives Guaranteed for both t-auto-upgrade and
t-image-refresh off this branch's render on all five profiles. Single container, zero
initContainers each, so the class is real, and the comment now credits the right lever to the
right mechanism. Clearing my review.
Two things before you merge, neither of them code.
Closes tracebloc/backend#2935 no longer matches the ticket. Lukas retitled it at 09:35 — after
your last push — and the headline is now 431x exit 1 against 46x exit 137. This PR fixes the
46. I checked whether the exit-1s might be downstream of the OOM kills and they aren't: the
pending-upgrade wedge path deliberately exit 0s (#2877), so the 431 are the LATEST/CURRENT
resolution failures under set -eu, and nothing here touches them. Make it Part of and give the
exit-1 half its own ticket. Cross-repo keywords don't auto-close here — every prior one was closed
by hand — which is exactly how someone reads "Closes" and closes it anyway, and that edge is still
missing security fixes.
Merge order against #922, and this reverses what I told you last pass. Then, the memory-only pin
moved no golden. The cpu pin does: all five pod-qos-expect.*.txt files move two rows each —
class t-auto-upgrade and class t-image-refresh, Burstable -> Guaranteed, ten rows total. On the
merged tree #922's suite goes 19/19 to 15/19 (tests 1, 2, 3, 18). The init rows don't move, and
qos-reachability-expect.txt stays 20/20 both ways because it already recorded both as reachable.
Land #922 first and let this PR carry the ten rows, or land this first and expect #922 to. Chart.yaml
collides either way — #922 is at 1.9.94 and you're at 1.9.93, so the second one re-bumps.
Nit, and it argues for #922 landing first: the comment says the proxy holds because there are no
initContainers, but the tests read containers[0] only. That's Lukas's point and it's the same hole
through a different door — add a sidecar and these stay green while the class drops. #922's checker
closes it properly.
Nit: the arithmetic is off by one, in the direction that understates your own argument. kubelet
integer-divides on bytes, so 16Gi is 993->985 rather than 992->984, and image-refresh on a 64Gi node
is 999->999 — the request rounds to zero and the 1000 is clamped. Literally no movement, which is
the stronger version of the point you're making.
Good call landing the "Do item 1" comment on #2935 bounding the node-pressure cause as not
identified rather than guessing — what we know, what we don't, the unconfirmed suspect, and what to
capture next time. That's the part that stops this being re-diagnosed from scratch.
…#2872) The four QoS failures were real and were NOT visible on the branch head. CI evaluates the pull_request MERGE of head into base, and #948 (526f4ad) landed on develop after this branch last merged it: pinning CronJob memory to requests == limits moves t-auto-upgrade and t-image-refresh from Burstable to Guaranteed. On the head alone every case passes, which is why it first read as an environment difference; reproduced by merging develop locally. Measured under CI pinned helm v3.15.4 on the merge -- both CronJobs Guaranteed on every profile -- and all five expectation files updated. Nothing in the chart prose called the CronJobs Burstable, so no comment needed correcting. Rows re-derived from measurement rather than restored from a pre-revert copy: doing the latter would have reintroduced the TEST that 2d25d1a reverted without its code. That revert and b0e4500 diagnostic both stand untouched. Separately worth recording: local helm here is v4.1.1 while CI pins v3.15.4, so chart renders verified locally were not on the CI configuration. Everything above ran under 3.15.4. drift 43/43, helm unittest 660/660, full bats 1647/1647 -- on the merge, v3.15.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ss for real (backend#2872) (#922) * docs(qos): correct the false Guaranteed-QoS claims and assert the class for real (backend#2872) Six claims in this repo said a workload had Guaranteed QoS. None did, and two files contradicted themselves internally -- install-client-helm.sh:38 said "requests == limits (Guaranteed QoS)" while :324 said "Guaranteed QoS is lost by design", and install-k8s.ps1 carried the same pair. The training-envelope claims went stale when backend#2418 made CPU a request-only share weight; nobody noticed because no test tier could assert a QoS class. Corrected: values.yaml, values.schema.json (which SHIPS to users), jobs-manager-deployment.yaml, mysql-deployment.yaml, install-client-helm.sh, install-k8s.ps1. Deleted the false history at values.yaml:964, which called jobs-manager "BestEffort QoS in older releases". It was Burstable from day one: before #66 introduced the resources block the template hardcoded requests cpu 100m / memory 256Mi and limits cpu 500m / memory 512Mi (git show 2d9d013^:client/templates/jobs-manager-deployment.yaml). Requests AND limits set, so never BestEffort. The pod was under-resourced, which is the real cause; the class was never the difference. Amended the ACCURATE comments too. They attributed Burstable to cpu req != lim alone, which is true but incomplete and would send the next fix attempt down a dead end: ComputePodQOS requires requests == limits in every container INCLUDING init containers, and jobs-manager renders an unresourced init-writable-data whenever hostPath.enabled=true while mysql renders an unresourced mysql-format-guard UNCONDITIONALLY. Measured on the rendered chart: equalising cpu buys Guaranteed for jobs-manager only on a CSI cluster, and for mysql nowhere. New scripts/tests/pod-qos-class.{py,bats} DERIVES each pod class with the kubelet rule instead of asserting the values believed to imply it -- the defect shape that let all of this survive. Asserted per hostPath mode, because the mode changes the answer. Six mutations proven to redden; two of them (an ignored memory dimension, a workload silently becoming BestEffort) exposed real gaps in the first version of the suite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(qos-check): only cpu and memory count toward the class (backend#2871) The first version of pod-qos-class.py treated ANY resource key as qos-relevant. ComputePodQOS skips everything isSupportedQoSComputeResource rejects, so a container whose only requests are nvidia.com/gpu and ephemeral-storage -- exactly what client-runtime._get_gpu_resources produces for every GPU training pod -- has EMPTY qos-relevant maps and the pod is BestEffort, not Burstable. So the checker would have reported those pods as Burstable and quietly agreed they were fine: the same defect shape it exists to catch. Found while verifying backend#2871 against the real code rather than trusting the ticket. Two tests added (gpu-only -> BestEffort; extended resources alongside equal cpu/memory -> still Guaranteed, so the rule is not "ignore unknown keys"), and the mutation back to the old behaviour reddens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(chart): bump to 1.9.88 — develop reached 1.9.87 while this PR was open * docs(qos): the class follows DERIVE_JOB_ENVELOPE, not the template (backend#2872) Review on rfcs#63 (@saadqbal) caught this sweep swapping one wrong QoS claim for another -- the one thing it cannot afford to do. Three of my own claims here had the same defect: they read as though the derive path's Burstable envelope were the DEFAULT. It is not. DERIVE_JOB_ENVELOPE is off by default (client-runtime jobs_manager.py:2664), and the fallback assigns the literal cpu=1,memory=2Gi to requests AND limits, so those pods are GUARANTEED -- client-runtime says so outright at :2412: "the literal (and a symmetric env-override) has request == limit, so those pods are Guaranteed ... The derive path is deliberately Burstable on CPU instead." There is even a test named test_derivation_disabled_by_default_uses_literal. So values.yaml, values.schema.json (which ships) and the template comment now split by the flag and state BOTH classes, rather than naming one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(chart): bump to 1.9.90 — develop reached 1.9.89 while this PR was open * test(qos): derive the expected class set from the render, not a hand-list (backend#2872) Bugbot, and it is right about the guard I built to close this exact shape. The expected table restated SIX workload names in the bats file. The chart renders TEN: t-auto-upgrade, t-image-refresh, t-egress-reachability-check and t-storage-assertions-check were classified by the checker and then ignored by the assertion, so a silent Burstable<->Guaranteed change on any of them stayed green. And the init-container check tested that two names APPEARED -- a membership test where a set comparison was needed -- so init-mysql-data could vanish with nothing reddening. A restated list inside a guard against restated claims. CLAUDE.md rule 1, in the file that cites it. Now `pod-qos-class.py --expect <file>` compares the render against a declared expectation by SET EQUALITY IN BOTH DIRECTIONS: a workload the chart starts rendering fails until someone classifies it, and a row naming a workload the chart no longer renders fails rather than being satisfied by nothing. The unresourced-init set is asserted exactly, because that set is what decides whether Guaranteed is reachable at all. Three new tests prove the check can fail: a dropped row, a stale row, and a vanished init container each redden. Five mutations proven, including a REAL chart mutation -- stripping resources from image-refresh-cronjob.yaml, one of the four workloads that was previously invisible -- which now reddens both mode assertions. Verified: bats 53/53, helm unittest 641/641, drift 37/37, version guard ✓. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(qos): render the GPU device plugins — the chart's only Guaranteed pods (backend#2872) Bugbot, and it is the sharpest gap yet: `_render` never enabled gpu.devicePlugin, so neither nvidia-device-plugin-daemonset nor amdgpu-device-plugin-daemonset was ever classified. Measured: both are GUARANTEED -- which is exactly what client#919 bought. Stripping their resources would return them to BestEffort, leave every GPU training pod Pending, and this guard would have stayed green. So the one place Guaranteed actually exists in this chart was the one place the QoS suite could not see. _render now takes extra --set pairs, and the suite asserts a class table PER VENDOR -- the chart renders one plugin keyed on gpu.devicePlugin.vendor, so asserting only nvidia would leave the amd template unclassified, the same partial-coverage mistake one level down. Side benefit worth naming: the checker's Guaranteed path is now exercised against REAL chart output rather than only synthetic manifests. A classifier that could never produce "Guaranteed" from the chart would previously have passed every assertion in this file. Proven with a real chart mutation, anchor asserted: renaming both `resources:` blocks in gpu-device-plugin.yaml reddens the two new tests. My first attempt used the wrong indentation, matched 0 lines, and reported "gap NOT closed" -- which is why the anchor assertion is there. Verified: bats 55/55, helm unittest 641/641, drift 37/37. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(qos): the "only route" claim was false, and the preflight skipped .py guards (backend#2872) Review on client#922, all verified on this head rather than taken on trust. - "Pod-level resources are the only route" was the same false QoS claim in the PR whose job is deleting false QoS claims. Resourcing every init container reaches Guaranteed too: verified by patching the render so every container including the init containers carries requests == limits, at which point pod-qos-class.py returns Guaranteed for BOTH t-jobs-manager and mysql-client. Rejected on cost (the reservation is held for the pod's whole life), not capability. - KEP-2837 is beta in 1.34, not 1.36. Checked rather than trusted, as asked; the "measured on a real 1.36 cluster" mentions are about our own pin and stand. - pyyaml-preflight enumerated .sh and .bats only, so its "the denominator is the tree" header was false and pod-qos-class.py was invisible to it. Now 23 guards, with .py treated as whole-file python -- extract_python finds only embedded blocks, so without that a python guard would fail closed while correctly wrapped. Mutation-proved: a bare `import yaml` reddens test 1 naming pod-qos-class.py. - Deleted _class_of: dead after the --expect rewrite, and its unanchored `$1 ~ w` would have matched a sibling workload and asserted a different pod's class. - Six claims, not ten, in both headers. CLAUDE.md rule numbers replaced with the rules themselves -- a reader in this repo cannot resolve a number that lives in a workspace file. - _norm's zero-quantity exception stated: requests == limits == 0 derives Guaranteed here and is BestEffort on a cluster. helm unittest 641/641, drift 38/38, check-facts, qos 17/17, preflight 3/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(qos): classify the lockdown Job, and fail closed on any unreached template (backend#2872) Bugbot Medium on client#922, verified before fixing: `egress-enforcement-check` renders only with `networkPolicy.training.enabled=true`, `allowExternalHttps=false` AND a probe host set -- three values no mode in this suite set, so the Job was never classified. Stripping its resources demoted it to BestEffort with the suite green. TWO FIXES, because one more mode closes the instance and not the class. - A lockdown render mode plus `pod-qos-expect.lockdown.txt`, compared by set equality in both directions like every other mode. Mutation: removing the Job's `resources` block reddens test 18. - A CROSS-MODE COVERAGE ASSERTION, both sides derived. The chart side greps the templates for a pod-bearing `kind`; the reached side is the union of a new `--sources` mode over all five renders. It reads helm's `# Source:` comment from the raw text, because `yaml.safe_load_all` discards it and a workload NAME cannot say which file produced it -- a template that renders nothing contributes no name to notice the absence of. Empty on either side fails closed. Mutation: a new conditionally-rendered Deployment no mode reaches reddens test 19 by name. The coverage guard earned itself immediately: it caught my own first draft passing `gpu.enabled` / `gpu.vendor` instead of `gpu.devicePlugin.*`, so the two GPU modes were rendering no device plugin at all. That is exactly the failure it exists to report, found on the way in. Merged develop; the manifest conflict is a hash, so the only correct resolution is the recomputed value, not either side. helm unittest 641/641, drift 38/38, qos 19/19, check-facts 14/14, preflight 3/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(qos): the coverage guard held its own copy of the kind list (backend#2872) Bugbot on client#922, and it is my own restatement one commit old: the coverage assertion grepped for a hand-written `Deployment|StatefulSet|DaemonSet|Job|CronJob` while `POD_KINDS` also carries `Pod`. A raw Pod template was therefore absent from the denominator, so if no mode rendered it, coverage stayed green and `--expect` never saw it -- a BestEffort demotion on that pod would not fail anything. Fixed by removing the copy, not by adding `Pod` to it: a new `--kinds` mode prints POD_KINDS and the bats side builds its grep pattern from that, so the two cannot disagree again. Empty output fails closed -- an empty alternation would make the grep match every template or none, and either way the set comparison stops meaning anything. Mutation, Bugbot's exact scenario: a `kind: Pod` template gated behind a value no mode sets. With the derived list, test 19 fails naming `zz-raw-pod.yaml`; with the old hand-written list re-pasted in its place, the same template is invisible and test 19 passes. Both measured. helm unittest 641/641, drift 38/38, qos 19/19, preflight 3/3, shellcheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(qos): the class follows the flag AND whether the pod asks for a GPU (backend#2872) Asad, review of #922. The correction overshot in the three places that ship: values.schema.json, values.yaml and the jobs-manager comment all said the class follows the flag, full stop. On a GPU cluster it does not -- client-runtime resolves a GPU envelope to nvidia.com/gpu and ephemeral-storage only, neither is QoS-relevant, so the container sets no cpu/memory at all and the pod is BestEffort under EITHER setting. That matters because the schema description ships to operators, and the BestEffort/oom_score_adj argument used to justify Guaranteed elsewhere is exactly what a GPU operator would conclude does not apply to them -- then flip DERIVE_JOB_ENVELOPE on to escape Burstable and change nothing on their cluster. All three now carry the same sentence: cpu-only + off -> Guaranteed, cpu-only + on -> Burstable, GPU -> BestEffort either way. pod-qos-class.py already encoded the carve-out (345ab1d in this PR); the prose asserted the opposite of the chart' own checker. Dropped the verbatim client-runtime quote that propped up the Guaranteed claim. A cross-repo prose quote is a dependency nothing checks -- client-runtime#457 exists to delete one in the other direction -- and it pinned the very sentence being corrected here. Cite the behaviour and where it lives, do not copy it. Declared the checker' third divergence: a limits-only container reads Burstable here and is Guaranteed on a cluster, because the API server defaults requests from limits before the class is computed and this reads the rendered manifest. Safe direction, no chart pod does it -- written down because the other two are, and an undeclared divergence is paid for by whoever meets a red CI on correct code with nothing to read. helm unittest 641/641 (the body said 631 -- fixed there too). bats pod-qos-class: 19/19. make drift: all 38 guards green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(chart): bump to 1.9.94 (backend#2872) develop reached 1.9.92 when #942 merged, so this branch no longer sat above it and `chart content => Chart.yaml version bump` failed. The gate compares against the develop tip (BASE_SHA), not the fork point, so a bump goes stale whenever develop moves under a long-lived branch. Caught by re-running the check rather than reading it: the PR still showed a green pass from before #942 landed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(qos): correct the claims #942 falsified, and assert REACHABILITY (backend#2872) Three things, all from @saadqbal review of client#922. 1. #942 FALSIFIED TWO CLAIMS THIS PR ADDS. Its `wait-for-mysql` is unconditional and unequal on BOTH dimensions (cpu 10m/100m, memory 16Mi/64Mi) with no values key, so `values.yaml` and `jobs-manager-deployment.yaml` were both wrong to say equalising cpu buys Guaranteed for jobs-manager on a CSI cluster. Measured on the merged tree with hostPath off and every resources.* knob equalised: t-jobs-manager Burstable wait-for-mysql:cpu(req=10m,lim=100m); wait-for-mysql:memory(req=16Mi,lim=64Mi) Guaranteed is now unreachable through values on EVERY cluster. Both comments corrected. 2. AND THE GUARD STAYED GREEN THROUGH IT -- this PR subject one turn deeper. The goldens pin the CLASS and the unresourced-init SET; `wait-for-mysql` is resourced, merely unequal, and jobs-manager was already Burstable, so nothing moved. `scripts/tests/qos-reachability.sh` asserts reachability: it equalises every resources.* knob derived from values.schema.json, classifies through pod-qos-class.py (no second copy of ComputePodQOS), and compares per-pod verdicts to a golden by set equality both ways. Proved rather than argued -- adding an unconditional unequal init container to an ALREADY-Burstable pod (the exact #942 shape) leaves all 19 existing QoS tests green and reddens this guard, naming the container. The golden records blocking container NAMES, not quantities, so a resource bump does not churn it. 3. BOTH INSTALLER HEADERS called the training pod BURSTABLE flat. On a GPU edge it is BestEffort: the GPU path requests only nvidia.com/gpu / amd.com/gpu plus ephemeral-storage, and client-runtime `_get_gpu_resources` never reads RESOURCE_REQUESTS / RESOURCE_LIMITS there. backend#2871 raised both GPU BestEffort workloads; client#919 fixed the device-plugin half and the issue was CLOSED with the training half unfixed, so the record is written into the two files rather than left as a reference. Chart 1.9.94. drift 43/43, helm unittest 657/657, QoS 19/19, Pester 887/887. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(qos): walk EVERY resources node, not just the top-level one (backend#2872) autoUpgrade, imageRefresh and egressProxy each expose requests and limits under their own parent, so they were never equalised and their pods read blocked while an operator could already reach Guaranteed through values. The guard was agreeing with its own incomplete domain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(qos): probe the free-form resources node instead of recording it blocked (backend#2872) Builds on 691841e rather than replacing it -- the recursive walk and the fatal/informational note split are that commit, and they are right. One verdict was still wrong: `telemetryCollector.resources` is `{"type": "object"}` with no declared keys, so it fell into #NOTAPAIR and t-telemetry-collector was recorded `blocked`. But the schema does not forbid keys either, and the DaemonSet renders the node with `toYaml $tc.resources` -- a wholesale passthrough -- so a requests/limits pair set there reaches the pod. Measured, with ONLY that key equalised: t-telemetry-collector Guaranteed every container has requests == limits otel-collector {requests: {cpu: 1000m, memory: 1Gi}, limits: {same}} So `blocked` was a verdict reached by never having looked -- the one thing this guard exists to refuse. Free-form nodes are now PROBED with the canonical pair and the run reports that the verdict came from a probe rather than a declaration. `gpu.devicePlugin.*` stays #NOTAPAIR: its flat one-pair shape is applied to both sides by construction (client#919 makes the split form unexpressible), so there is genuinely nothing to equalise. Golden: two rows corrected, with the reason recorded beside them. Mutation-proved: disabling the probe branch returns both rows to `blocked` and reddens the guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(qos): the pod-level branch needs the cpu/memory carve-out too (backend#2872) KEP-2837 pod-level resources short-circuit above the container walk, so an envelope of extended resources only returned Burstable where the kubelet says BestEffort. The container path already carried this guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Revert "fix(qos): the pod-level branch needs the cpu/memory carve-out too (backend#2872)" This reverts commit 51681eb. * test(qos): report the checker output when an expectation fails (backend#2872) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(qos): record that #948 moved both CronJobs to Guaranteed (backend#2872) The four QoS failures were real and were NOT visible on the branch head. CI evaluates the pull_request MERGE of head into base, and #948 (526f4ad) landed on develop after this branch last merged it: pinning CronJob memory to requests == limits moves t-auto-upgrade and t-image-refresh from Burstable to Guaranteed. On the head alone every case passes, which is why it first read as an environment difference; reproduced by merging develop locally. Measured under CI pinned helm v3.15.4 on the merge -- both CronJobs Guaranteed on every profile -- and all five expectation files updated. Nothing in the chart prose called the CronJobs Burstable, so no comment needed correcting. Rows re-derived from measurement rather than restored from a pre-revert copy: doing the latter would have reintroduced the TEST that 2d25d1a reverted without its code. That revert and b0e4500 diagnostic both stand untouched. Separately worth recording: local helm here is v4.1.1 while CI pins v3.15.4, so chart renders verified locally were not on the CI configuration. Everything above ran under 3.15.4. drift 43/43, helm unittest 660/660, full bats 1647/1647 -- on the merge, v3.15.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(qos): harden the coverage loop renders, and say what the finding did NOT reproduce (backend#2872) Bugbot Medium: the coverage loop called `_render` in five case arms with no `|| return 1` -- the only `_render` calls in the file without it -- so a failed render fell through and whatever it left behind was measured for coverage. THE MECHANISM AS DESCRIBED DOES NOT REPRODUCE, and that belongs on the record rather than being implied away. The finding said `helm template` streams, so a failed render can still emit earlier pod documents. Measured on the pinned v3.15.4 AND on v4.1.1, across three failure modes -- a late template `fail`, a values-schema violation, and invalid YAML in the rendered output -- helm buffers the whole manifest and writes 0 bytes every time. With an empty file the classifier already refuses ("no pod-bearing template in this render") and the `--sources` call already carried `|| return 1`. Fixed anyway, because "safe" was resting on two accidents this test asserts neither of: helm buffering, and the classifier refusal. A helm that ever did stream would reopen it in silence, and one word per arm is cheaper than that dependency. Mutation-proved: a late `fail` in requests-proxy-service.yaml now reddens "coverage: every pod-bearing template is classified by at least one mode". bats-hygiene 14/14 accepts the form. drift 43/43, helm unittest 660/660, full bats 1647/1647 under v3.15.4 on the merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(qos): re-land the pod-level cpu/memory carve-out, verified under helm 3.15.4 (backend#2962) KEP-2837 pod-level resources short-circuit above the per-container walk, so a pod-level envelope of extended resources only (nvidia.com/gpu + ephemeral-storage and nothing else) returned Burstable where ComputePodQOS says BestEffort: with pod-level resources set the kubelet derives the class from them alone, filtered to cpu/memory, and does not fall back to the containers. The per-container path already carried this guard (backend#2871); this is its missing pod-level half. The first attempt (51681eb) was reverted after CI reddened four --expect tests, blamed on a helm-3.15.4-vs-4 render difference. Re-verified under the pinned helm v3.15.4 (identical build to CI, v3.15.4+gfa9efb0): no chart workload renders pod-level resources on any hostPath mode under 3.15.4 OR 4, so the carve-out is inert on every chart render and cannot flip an --expect result. The reverted red was not reproducible under the pinned helm in isolation, in a matched Linux amd64 container, or in the full bats suite; its diagnostics were swallowed by the pre-_ok assertion, so it was reverted without evidence. The suite is now 21/21 green under helm 3.15.4, and the _ok helper (added since) means a re-land cannot go blind again. The new pod-level BestEffort case is built so it cannot pass for the wrong reason: its containers are themselves Guaranteed, so a fall-through would read Guaranteed and the reverted no-carve-out branch read Burstable -- the case rejects both and only the carve-out yields BestEffort. Closes tracebloc/backend#2962 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Revert "fix(qos): re-land the pod-level cpu/memory carve-out, verified under helm 3.15.4 (backend#2962)" This reverts commit e777910. The carve-out encoded the wrong kubelet rule. Verified against the Kubernetes source (pkg/apis/core/v1/helper/qos/qos.go + component-helpers/resource/helpers.go): ComputePodQOS gates the pod-level branch on IsPodLevelResourcesSet, whose supported set is {cpu, memory, hugepages} only -- an extended-resources-only envelope (nvidia.com/gpu + ephemeral-storage) makes it FALSE, so with the PodLevelResourcesFixKubeletQOSClass gate (Beta ~1.36) the kubelet FALLS THROUGH to the per-container walk and returns Guaranteed when the containers are Guaranteed -- NOT BestEffort. The reverted commit returned BestEffort and asserted it with a test built on Guaranteed containers, which is the pre-fix behavior and is wrong for the 1.34-1.36+ clusters this chart targets. Only latent (no chart renders pod-level resources) kept it from being caught. backend#2962's premise and acceptance criteria rest on that pre-fix behavior; the finding is written up on the issue for re-scoping. Removing the wrong claim from this PR rather than papering over it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Arturo Peroni <arturo@tracebloc.io>
Closes tracebloc/backend#2935
What was happening
ap-workspace-auto-upgrade-*was exit-137 (SIGKILL) killed on every hourly tick on a liveap-workspaceedge, andimage-refreshfailed in lock-step — six paired failures.image-refreshruns every 15m, so failing only when it coincides with the hourly upgrade tick is itself the tell: one shared cause, not two independent container-limit overruns.Diagnosis (measured)
256Mi, image-refresh128Mi./usr/bin/time -l):helm~50 MiB (repo update + search + template render),kubectl~40–80 MiB,jq~2 MiB — each a fraction of its limit. Both scripts read only small bounded JSON and stream throughawk.Error, exit code 137(notOOMKilled) — the signature of the kernel OOM-killer under node memory pressure, not either container hitting its own cgroup limit.The fix — make both CronJobs Guaranteed QoS
Two independent levers, each doing an honest, distinct thing (this is the shape after @saadqbal's review — the first revision pinned memory only and mis-credited it):
requestraised to meet the limit = an honest scheduler reservation. This attacks the cause: the node is no longer overcommitted by the gap between what these pods reserved and what they use, so it's less likely to enter the pressure that triggered the kill.oom_score_adj = -997. This is what takes the pod off the kernel OOM-killer's shortlist under pressure. A memory pin alone does not — a Burstable pod'soom_score_adj = 1000 − 1000·memRequest/nodeCapacityis fixed at start and independent of usage, so128Mi→256Mimoves auto-upgrade only ~992→984 (16Gi node) and image-refresh 999→998 (large node), i.e. nothing.These two CronJobs are the only chart pods one line from Guaranteed — zero initContainers, memory already equal, cpu the sole blocker. (#642 pinned jobs-manager's memory but couldn't reach Guaranteed there — it renders an unresourced init container; here we can.)
Limits never moved — only requests rose to meet them, so no too-low cap was introduced. Tradeoff (stated in values.yaml): Guaranteed reserves the full envelope for the pod's brief run and caps it there;
helm/kubectlare I/O-bound so the cpu cap doesn't slow real work, andForbid+startingDeadlineSecondsbound the Pending case if a small edge can't spare the reservation.Guard / test
auto_upgrade_test.yamlandimage_refresh_test.yamlnow assert cpu AND memoryrequest==limiton each CronJob pod — the in-manifest proxy for Guaranteed given no initContainers (client#922'spod-qos-classchecker will assert the class directly once it lands). Reverting either request reddens the matching assert. Also corrected the#554comment. Chartversion+appVersionbumped to1.9.93(develop advanced to 1.9.92 under this branch; merged and re-bumped one patch above).Verification
helm unittest ./client— 646 passed (incl. the strengthened guards).helm lint ./client— 0 charts failed.Notes for the reviewer
helm upgrade --atomic --wait. All evidence says node-pressure, so the limit was deliberately not bumped; if a live edge ever shows a >256Mi peak (it'd surface asOOMKilled), the ceiling is the thing to raise — orthogonal to the QoS fix.requests-proxy,resource-monitor, theegress-*/storage-assertionscheck hooks); they were not observed OOMing and are out of scope here. Worth a look only if fleet-wide pressure is confirmed.Decision gate (recorded, not folded in)
ap-workspaceinspected; recommended a fleet check on backend#2935.Note
Medium Risk
Changes scheduler reservations and OOM victim preference for the security-fix auto-upgrade path on memory-tight edges; behavior is intentional but can leave CronJobs Pending if the node cannot honor Guaranteed envelopes.
Overview
Addresses backend#2935, where auto-upgrade and image-refresh CronJob pods were exit-137 killed together on a live edge—consistent with node-level OOM, not cgroup limit breaches (measured peaks stayed well under existing limits).
Resource requests for both CronJobs are raised so CPU and memory requests equal their existing limits, moving the pods to Guaranteed QoS (
oom_score_adj -997) and reserving memory honestly on the node. Limits are unchanged (auto-upgrade stays 500m/256Mi; image-refresh 200m/128Mi). Tradeoff: tighter scheduler reservation on small edges; pods may go Pending rather than OOM-loop.Helm unittest cases in
auto_upgrade_test.yamlandimage_refresh_test.yamllock request==limit for CPU and memory so the QoS fix cannot regress. The #554 pending-upgrade recovery comment inauto-upgrade-cronjob.yamlis updated to mention node OOM and Guaranteed QoS. Chart version/appVersion bump to 1.9.93.Reviewed by Cursor Bugbot for commit 638b977. Bugbot is set up for automated code reviews on this repo. Configure here.