Skip to content

Let a PodDisruptionBudget protect busy ephemeral runners - #4710

Open
aandreev-akamai wants to merge 1 commit into
actions:masterfrom
aandreev-akamai:ephemeralrunner-scale-subresource
Open

aandreev-akamai wants to merge 1 commit into
actions:masterfrom
aandreev-akamai:ephemeralrunner-scale-subresource

Conversation

@aandreev-akamai

Copy link
Copy Markdown

Fixes #4618

Problem

A PodDisruptionBudget selecting gha-runner-scale-set runner pods never works. The runner pod's controller owner is an EphemeralRunner, which only has a status subresource. The disruption controller resolves expected pods through the owner's scale subresource, so the PDB reports SyncFailed (expectedPods: 0, currentHealthy: 0). Voluntary evictions that only honor PDBs (kubectl drain, AKS/EKS/GKE node auto-upgrade) therefore kill in-flight jobs with The runner has received a shutdown signal. safe-to-evict / do-not-disrupt don't help, they are only honored by the Cluster Autoscaler / Karpenter. See #4493, #2562 and #4618.

Change

  1. scale subresource on EphemeralRunner. Adds spec.replicas and status.replicas, both fixed at 1 (spec.replicas is an enum of 1, so kubectl scale is rejected). Existing objects pick up the default on read, so no migration is needed. The controller also reports status.replicas = 1.

  2. Busy label on the runner pod. Once a job is assigned, the EphemeralRunner controller sets actions.github.com/runner-busy: "true" on the pod. Runner pods are single-use, so the label is never removed. A PDB can then protect busy runners only, and idle runners stay evictable so drains and upgrades are not blocked by idle capacity:

    apiVersion: policy/v1
    kind: PodDisruptionBudget
    spec:
      maxUnavailable: 0
      selector:
        matchLabels:
          actions.github.com/runner-busy: "true"
  3. Drift check ignores replicas. The EphemeralRunnerSet template embeds EphemeralRunnerSpec, so the CRD default would make the stored template differ from the desired one and bump the revision, deleting idle runners. Older CRDs prune the field, so the comparison ignores it instead of asserting a value. This keeps a controller upgrade without a CRD upgrade safe.

  4. RBAC. The gha-runner-scale-set and gha-runner-scale-set-experimental manager Roles gain patch on pods.

Upgrade notes

  • The scale-set chart must be upgraded together with the controller, otherwise labelling the pod fails with a forbidden error.
  • CRDs are not upgraded by Helm, so apply the updated EphemeralRunner / EphemeralRunnerSet CRDs for the PDB to start working. Without them the controller keeps working as before, only without PDB protection.
  • There is a short window between job assignment and the controller labelling the pod in which the runner is still evictable.

Evidence

  • kind cluster: with the new CRD, a PDB selecting runner-busy=true reports expectedPods: 1, currentHealthy: 1 instead of SyncFailed. Evicting a busy pod is rejected with TooManyRequests, while evicting an idle pod succeeds. Before the change the same PDB reports SyncFailed with expectedPods: 0.
  • envtest: a new spec checks that spec.replicas defaults to 1, status.replicas is reported, an idle pod is not labelled, and the pod is labelled once a job is assigned. The full suite passes (187/187), including TestPredicateProjectionsCoverEveryStatusField, which now lists Replicas.
  • go test ./... passes except test/e2e and test_e2e_arc, which need GITHUB_TOKEN and a live cluster.

🤖 Generated with Claude Code

A PDB selecting runner pods never works today: the pod's controller owner is
an EphemeralRunner, which has no scale subresource, so the disruption
controller reports SyncFailed (expectedPods: 0). Voluntary evictions such as
node drains and managed node auto-upgrades therefore kill in-flight jobs.

- Serve a scale subresource on EphemeralRunner (spec/status.replicas, fixed
  at 1) so the disruption controller can resolve a budget.
- Label the runner pod actions.github.com/runner-busy=true once a job is
  assigned, so a PDB can select busy runners only and idle runners stay
  evictable. Runner pods are single-use, so the label is never removed.
- Ignore replicas when deciding whether the EphemeralRunnerSet template
  drifted. The CRD defaults it to 1 and older CRDs prune it, so comparing it
  would bump the revision and delete idle runners.
- Grant the controller patch on pods in the scale set manager roles.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 9, 2026 14:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The scale endpoint and drift-normalization behavior need direct regression coverage.

2 open findings
What changed in this PR

Adds PDB support for busy ephemeral runners to protect in-flight jobs during voluntary disruptions.

Changes:

  • Adds a fixed-size scale subresource to EphemeralRunner.
  • Labels busy runner pods and grants required patch permissions.
  • Prevents replica defaulting from triggering runner-set drift.
File Description
controllers/​actions.github.com/​predicates_test.go Includes replica status in projection coverage.
controllers/​actions.github.com/​helpers.go Ignores replicas during drift detection.
controllers/​actions.github.com/​ephemeralrunner_controller.go Labels busy pods and reports replicas.
controllers/​actions.github.com/​ephemeralrunner_controller_test.go Tests replica defaults and busy labeling.
controllers/​actions.github.com/​constants.go Defines the busy-runner label.
config/​crd/​bases/​actions.github.com_ephemeralrunnersets.yaml Adds the embedded replica schema.
config/​crd/​bases/​actions.github.com_ephemeralrunners.yaml Adds replica fields and scale subresource.
charts/​gha-runner-scale-set/​templates/​manager_role.yaml Grants pod patch permission.
charts/​gha-runner-scale-set-experimental/​templates/​manager_role.yaml Grants experimental pod patch permission.
charts/​gha-runner-scale-set-controller/​crds/​actions.github.com_ephemeralrunnersets.yaml Packages the runner-set schema update.
charts/​gha-runner-scale-set-controller/​crds/​actions.github.com_ephemeralrunners.yaml Packages the scale subresource.
charts/​gha-runner-scale-set-controller-experimental/​crds/​actions.github.com_ephemeralrunnersets.yaml Updates the experimental runner-set CRD.
charts/​gha-runner-scale-set-controller-experimental/​crds/​actions.github.com_ephemeralrunners.yaml Updates the experimental runner CRD.
apis/​actions.github.com/​v1alpha1/​ephemeralrunner_types.go Defines replica fields and scale markers.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +244 to +254
Eventually(
func() (int32, error) {
current := new(v1alpha1.EphemeralRunner)
if err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, current); err != nil {
return 0, err
}
return current.Status.Replicas, nil
},
ephemeralRunnerTimeout,
ephemeralRunnerInterval,
).Should(BeEquivalentTo(1), "status.replicas should be reported through the scale subresource")
Comment on lines +34 to +35
currentSpec, desiredSpec := current.Spec.EphemeralRunnerSpec, desired.Spec.EphemeralRunnerSpec
currentSpec.Replicas, desiredSpec.Replicas = 0, 0

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants