Repository navigation
feat: container restart journal (ADR 0020) - #46
Merged
Merged
Conversation
ADR 0019 added the node architecture to node_metadata and updated §8, but left the field-minimization paragraph saying no node status fields beyond kernelVersion are read. That sentence is the narrow promise, so it is the one that has to be exact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w (ADR 0020)
The journal had one event type in it. The termination reader discarded
everything that was not OOMKilled, so a container that exited 1 forty times an
hour was invisible — while its usage rollup looked like a comfortably
over-provisioned workload, because a container that keeps dying uses nothing.
Report restarts as an hour-aligned aggregate per (namespace, pod, container),
one payload file per window rather than one per restart. Per-event files would
have handed the spool's file count to a crash loop: the spool is bounded by age
alone, and a hundred looping containers write thousands of files an hour. A
per-pod cap was rejected — a payload that looks complete while silently
truncated is the failure mode ADR 0018 exists to end.
Count and reason are separate fields because they are of different quality.
restartCount is a counter, so restarts is exact; a container status carries only
its most recent termination, so reasons is a sample of it. The difference is
reasons_unobserved, and sum(reasons) + reasons_unobserved == restarts holds for
every record. Reasons outside the known set are counted under "other": the set
belongs to the CRI, and a payload whose keys a container runtime chooses has
unbounded shape.
The record names the pod, which no other payload does. Which replica is looping
cannot be recovered from a workload total. security.md now enumerates this
rather than leaving it to be discovered — oom_kill has always named pods.
Reasons are collected, termination messages are not: a message is free text that
routinely carries node names and taints, the same distinction ADR 0019 drew for
build settings.
Verified in kind: restarts=3, reasons={Error:3}, reasons_unobserved=0 for a
container exiting 1, in 63s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice A of the object journal: non-OOM terminations, as a windowed aggregate. ADR 0020 in this PR.
Also carries a one-line fix for something #45 missed — see the first commit.
What was wrong
The journal had exactly one event type in it.
internal/collector/oom.godiscarded everything else in one line:A container killed by the kernel was reported; a container that exited 1 forty times in an hour was invisible. That matters for numbers the agent already sends: a container that keeps dying uses almost no CPU and memory, so its usage rollup looks like a comfortably over-provisioned workload. The restart count is what separates "does not need those resources" from "never lives long enough to use them".
Windowed, not per-event
One payload file per hour-aligned window holding many records — the usage-snapshot shape — keyed by (namespace, pod, container).
Per-event files would have handed the spool's file count to a crash loop. The spool is bounded by age alone (
DefaultMaxAge = 24h, no count or byte cap), and a container in CrashLoopBackOff restarts ~12×/hour: a hundred of them write ~1200 files/hour, ~28000 before the first sweep. The latent problem was always there — removing the OOM-only filter is what would have made it live.A per-pod cap was considered and rejected: it keeps the per-event shape by silently dropping data, and a payload that looks complete while truncated is the failure mode ADR 0018 exists to end.
Exact counts, sampled reasons
The two fields come from data of different quality, so they are not merged:
restarts— exact, from the restart counter's advance. Counts restarts the agent never saw individually.reasons— a sample. A container status carries only its most recent termination.reasons_unobserved— the difference.sum(reasons) + reasons_unobserved == restartsholds for every record, and the e2e asserts it.Folding them would mean either under-reporting restarts or inventing reasons. This is ADR 0013's principle applied to a journal payload.
Reasons outside the known set are counted under
otherrather than passed through: the set belongs to the CRI, and a map whose keys a container runtime picks is a payload whose shape it picks. Counted, never dropped.Pod names
The record names the pod, which no other payload does —
workload_metadataexplicitly counts replicas instead of listing them. Which replica is looping cannot be recovered from a workload total, and it is the first thing anyone asks.oom_killhas named pods since it shipped andsecurity.mdnever said so. This makes it a documented decision: new sensitivity-table row, new §8 subsection, and a pointer to the controls that already work (namespace filter, label selector, opt-out annotation — the last of which removes already-collected data since ADR 0018).Reasons yes, messages no
lastState.terminated.reasoncomes from the kubelet's own vocabulary. The adjacentmessageis free text that routinely carries node names, taints and other workloads' details. Same distinction, same answer, as the build-settings allow-list in ADR 0019.First observation is baselined, not reported
A counter already standing at 40 describes restarts at moments Kubernetes does not record. Reporting them would place a history in whichever window happens to be open at agent start. Knowingly incomplete, and bounded: once per pod.
Verification
make test(incl.go vet -tags e2e ./...) andmake lintgreen. 7 unit tests on the collector side, 9 on the accumulator, 3 on the payload plus a golden.make restarts-e2e: deploys the controller next to a container that exits 1, asserts the payload in the spool. PASS, 63.30s:One thing worth knowing about the tests
The new collector tests deadlocked at first for a reason the existing OOM tests only get away with by luck:
WaitForCacheSyncproves the initial List landed, but an update arrives over the reflector's watch, which is established afterwards. Every update raced it and the fake clientset silently dropped them (it has no replay). The helper now blocks until a pod created after the watcher started has been delivered, which is proof the watch is live. Not a sleep.Docs
security.md: RBAC row, sensitivity table, field minimization (restart counters and terminated reason/exit code, and that messages are not read), new §8 subsection on the journal and pod names.backend-requirements.md: the arithmetic rule, the fixed reason set withother, supersede-by-window (deliveries are cumulative within a window, not increments), and thatoom_killoverlaps this by design.🤖 Generated with Claude Code