Repository navigation
Conversation
jaimeph
requested review from
a team,
Steve-Glass,
mumoshu,
nikola-jokic,
rentziass and
toast-gear
as code owners
September 16, 2026 17:16
jaimeph
force-pushed
the
upstream/safe-to-evict-annotation
branch
4 times, most recently
from
September 22, 2026 12:13
8300ede to
35ab620
Compare
Idle runners kept by minRunners sit on a node doing nothing, but the cluster autoscaler cannot drain that node while the runner pod carries safe-to-evict: false. The result is an empty node that never scales down. The controller now owns that annotation on the runner pod and keeps it in sync with job assignment: "true" while the runner is idle, "false" once the listener writes Status.JobID. It is opt-in behind --manage-safe-to-evict-annotation (flags.manageSafeToEvictAnnotation in the chart), and a runner template that sets the annotation itself is left untouched. The annotation is applied after the pod is built so it stays out of the pod template hash, and the reconcile patches only when the value actually changes, so an idle runner costs a single write at pod creation.
The comment said the annotation is kept "in sync" with job assignment, which reads as a value that tracks back and forth. Status.JobID is written once and never cleared, so it is a single true -> false transition and the pod dies with the job it ran.
The safe-to-evict reconcile patches the runner pod, but the Role created in the runner namespace only granted create, delete and get. The patch is the one write the controller had never needed before, so the verb was never there. The failure is invisible until runtime and only half the feature breaks: pods created after the rollout carry the annotation, because that value goes into the pod spec before Create, which is allowed. Everything that depends on the later patch fails: pods that already existed never get the annotation, and the true -> false flip on job assignment never lands. Helm reports the release as deployed and the controller logs a forbidden error on every reconcile. Seen on a live cluster: 153 reconcile errors in two minutes with pods "..." is forbidden: User "system:serviceaccount:..." cannot patch resource "pods" in API group "" in the namespace "arc-runners-default" The chart test now pins the pod verbs, so dropping one fails the render instead of the cluster. Unit tests could not have caught this: the fake client does not evaluate RBAC.
The unit tests call reconcileSafeToEvictAnnotation directly with a fake client, so they never run the reconcile that triggers it and never talk to an API server. Both gaps hid real problems: a Role in the runner namespace without the patch verb, found only once the controller ran on a cluster. These specs drive the controller through envtest instead. One follows an idle runner to "true" and then to "false" on job assignment. The other strips the annotation off a live pod and checks that the next reconcile puts it back, which is the pod that already existed when the flag was turned on. Writing the second one surfaced behaviour worth stating: stripping the annotation does not wake the controller by itself, because the reconcile is driven by the EphemeralRunner and ephemeralRunnerOwnedPodPredicate drops pod updates that only touch metadata. An existing pod is repaired by the next reconcile of its runner, which a controller restart produces for every runner it owns. The spec touches the runner to stand in for that. Verified to fail when the call to reconcileSafeToEvictAnnotation is removed from the reconcile.
jaimeph
force-pushed
the
upstream/safe-to-evict-annotation
branch
from
September 23, 2026 16:23
3e1b7c3 to
f4dc3e1
Compare
This branch has not been deployed
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.
Problem
Idle runners kept around by
minRunnerssit on a node doing nothing, and that node never scales down.The cluster autoscaler will not drain a node that hosts a pod with local storage unless the pod declares itself evictable through
cluster-autoscaler.kubernetes.io/safe-to-evict: "true". Runner pods mountemptyDirvolumes (work,dind-sock,dind-externals, see_helpers.tpl) and carry no such annotation, so the autoscaler treats every runner as non-evictable, busy or not.Hardcoding
safe-to-evict: "true"in the runner template is not a fix: the autoscaler would then be free to evict a runner in the middle of a job.Change
The controller manages the annotation on the runner pod, following job assignment:
Status.JobID)"true""false"This is opt-in. Nothing changes on upgrade until it is turned on:
Implementation notes
safeToEvictFor()derives the value and decides whether the controller owns the annotation at all. If theEphemeralRunneror itsPodTemplateSpecalready carries it, the function reports "not managed" and the controller keeps its hands off — an explicit value is a deliberate choice by whoever wrote the template.pod-template-hash. Its value changes during the pod's life and must not register as template drift.Status.JobIDis written once and never cleared, so the value goes from"true"to"false"and stays there; the pod dies with the job it ran. The reconcile is kept idempotent regardless, so it converges on its own after a controller restart.reconcileSafeToEvictAnnotation()patches only when the value differs from what is on the pod. A runner that never picks up a job costs a single write, the one at pod creation.EphemeralRunnerReconcileralready reconciles on everyEphemeralRunnerchange, and the listener's write ofStatus.JobIDis one of them. The pod patch does not feed back into the loop becauseephemeralRunnerOwnedPodPredicatedrops updates that only touch metadata.deletionTimestampare left alone: the annotation only matters while the pod is a real eviction candidate.EphemeralRunner, not off the pod. Removing it from a running pod does not wake the controller by itself, sinceephemeralRunnerOwnedPodPredicatedrops pod updates that only touch metadata. A pod that already existed when the flag was turned on is repaired by the next reconcile of its runner, which a controller restart produces for every runner it owns.Scope: what this does not cover
This addresses cluster autoscaler scale-down only. It does not protect a runner with a job in flight from:
kubectl drainkarpenter.sh/do-not-disrupthas the same limitation)Those paths go through the Eviction API, which honors
PodDisruptionBudgetand nothing else.safe-to-evictis read only by the cluster autoscaler when it decides to remove a node.PDBs do not currently work on ARC v2 either: the runner pod's direct owner is the
EphemeralRunnerCR, which declares only thestatussubresource (apis/actions.github.com/v1alpha1/ephemeralrunner_types.go:29). Without ascalesubresource the disruption controller cannot resolve the budget and the PDB ends upSyncFailed. Closing that gap means givingEphemeralRunnerascalesubresource or reparenting runner pods to a set-level owner, which is an architectural change and deliberately out of scope here. See #4618 for the full gap and #4493 for the PDB discussion.Tests
ephemeralrunner_safe_to_evict_test.go: 5 cases on value derivation (flag off, idle, with a job, explicit annotation on the runner, explicit annotation on the template) and 5 on the reconcile (annotates an idle runner, flips tofalsewhen the job arrives, does not patch when already at the desired value, does nothing with the flag off, skips a terminating pod).TestTemplate_ControllerDeployment_ManageSafeToEvictAnnotation: the flag is rendered only withmanageSafeToEvictAnnotation: true.ephemeralrunner_controller_test.go: two envtest specs driving the real reconcile against an API server. One follows an idle runner to"true"and then to"false"on job assignment; the other strips the annotation off a live pod and checks the next reconcile restores it. Both verified to fail when the call toreconcileSafeToEvictAnnotationis removed.TestTemplate_CreateManagerRole: pins the pod verbs on the Role created in the runner namespace, includingpatch.go build ./...andgo vet ./...clean.Operational note
There is a narrow window between the listener writing
JobIDand the controller setting the annotation tofalse. The autoscaler'sscale-down-unneeded-time(10 minutes by default) covers it comfortably in practice, but it is not zero.RBAC
The Role created in the runner namespace gains
patchon pods. It previously grantedcreate,deleteandget, which was enough because nothing patched a runner pod until now.This is worth calling out because of how it fails: the chart installs, Helm reports the release as deployed, the flag shows up in the controller args, and pods created after the rollout do carry the annotation, since that value goes into the pod spec before
Create. Only the later patch is forbidden, so pods that already existed never get annotated and thetrue->falseflip never lands. The failure is visible only in the controller logs.Field results
Deployed on a production CI cluster running ~100 runners across three scale sets.
The annotation converged on all 106 pre-existing runner pods within 3 minutes of the rollout, and followed job assignment from then on: on one node, six runners flipped to
"false"within 30 seconds as they picked up jobs, and back to"true"as they finished.A node that had been up for 3h15m holding a single idle runner was then retired by the autoscaler. Its last runners went idle at 16:46, the autoscaler marked it
DeletionCandidateOfClusterAutoscalerimmediately andToBeDeletedByClusterAutoscalertwo minutes later. Both runners still on the node at that point were idle with an emptyJobID; no in-flight job was disrupted. Runner pods were not recycled by the upgrade, since the change does not touch the pod template.Related to #2562.