Skip to content

feat(controller): detect slow restart loops via termination reason - #74

Merged
slauger merged 1 commit into
developfrom
feat/slow-restart-loops
Sep 6, 2026
Merged

slauger merged 1 commit into
developfrom
feat/slow-restart-loops

Conversation

@slauger

@slauger slauger commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Summary

watchReasons only sees containers that are waiting. Kubelet resets its restart backoff once a container has stayed up longer than roughly twice the maximum backoff, so a container that survives beyond that between deaths restarts immediately every time and never enters CrashLoopBackOff. A memory leak has exactly that shape: run, grow, get OOM-killed, restart at once, repeat. Such a workload can reach hundreds of restarts while staying completely invisible to this operator.

Adds watchTerminationReasons, matching on why the container last exited rather than on what it is waiting for. Empty by default, so nothing changes for existing policies.

A match requires all three of: the restart threshold reached, a listed termination reason, and that exit falling inside the new restartWindow (default 1h). The window is not optional. RestartCount is cumulative for a pod's whole life and never decays, so without it a workload that misbehaved last month would still be scaled down today.

Why this is not a one-line predicate change

A pod in this state is running when observed, so it never reaches the existing candidate set. Adding the check to podHasFailureReason alone would have been dead code. The change therefore also needed:

  • a second field index on the last termination reason, and a widened watch predicate, since the waiting-only predicate drops these pods' events entirely
  • a shared predicate between the reconcile loop, allReplicasFailing and policyWouldAct. This one is subtle: allReplicasFailing defaults to true and checks siblings with its own predicate, so a narrower one there would have made the whole feature inert under default settings while appearing to work in isolation. allReplicasFailing now takes the policy rather than a reason list, which makes disagreement impossible rather than merely unlikely.

Guards

Not counted: pods being deleted, which would otherwise sweep every replica of a rolling update into the candidate set at once; completed pods; containers still inside their startup probe; and classic init containers, which run once and cannot loop. Init containers declared with restartPolicy: Always are sidecars and do count.

Documentation

Also records something that would otherwise keep biting people: termination reasons never appear as waiting reasons, so putting OOMKilled into watchReasons matches nothing and fails silently. The README previously invited exactly that by saying watchReasons "matches the container's waiting reason exactly".

Closes #67

Test plan

  • A newLoopingPod fixture builds the state this loop actually produces: running now, high restart count, recent exit carrying the reason, never in a watched waiting state.
  • Table test over the matching rules: off by default, matches a watched reason, ignores an unwatched one, respects the restart threshold, and rejects a high lifetime count whose last death was weeks ago.
  • One subtest per guard.
  • Two end-to-end reconciles on the same pod: scaled down with the opt-in, untouched without it.
  • make ci passes; coverage rose from 61.2 to 64.3 percent.

watchReasons only sees containers that are waiting. Kubelet resets its
restart backoff once a container has stayed up longer than roughly twice
the maximum backoff, so a container that survives beyond that between
deaths restarts immediately every time and never enters
CrashLoopBackOff. A memory leak has exactly that shape, and such a
workload can reach hundreds of restarts while remaining invisible.

Adds watchTerminationReasons, empty by default so nothing changes for
existing policies, matching on why the container last exited rather than
on what it is waiting for. A match needs the restart threshold reached,
a listed reason, and the exit to fall inside the new restartWindow. That
window is not optional: the restart count is cumulative for a pod's
whole life and never decays, so without it a workload that misbehaved
last month would still be scaled down.

A pod in this state is running when observed, so the change is not
confined to the failure predicate. It needs a second field index, a
widened watch predicate, and a shared predicate between the reconcile
loop, allReplicasFailing and policyWouldAct, since the sibling check
runs under allReplicasFailing, which defaults to true and would
otherwise leave the feature inert.

Guards against acting on pods being deleted, which would sweep every
replica of a rolling update in at once, on completed pods, on containers
still inside their startup probe, and on classic init containers, which
run once and cannot loop. Sidecars declared with restartPolicy Always
do count.

Also documents that termination reasons never appear as waiting reasons,
so putting OOMKilled in watchReasons silently matches nothing.

Closes #67

Signed-off-by: Simon Lauger <simon@lauger.de>
@slauger
slauger merged commit 2355970 into develop Sep 6, 2026
14 checks passed
@slauger
slauger deleted the feat/slow-restart-loops branch September 6, 2026 09:28
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.

feat(controller): detect slow restart loops via termination reason

1 participant