Git, CI/CD & GitOpsXL · RunnersRunners
Runner autoscaling and Actions Runner Controller — scaling on Kubernetes
What you'll learn
- Explain the autoscaling problem for CI runners and why fixed-size pools waste money
- Describe the Actions Runner Controller architecture: controller, listener, runner pods
- Configure a RunnerDeployment with minRunners and maxRunners
- Recognise the role of the listener webhook in scaling runners to zero
- Decide when ARC is the right runner platform and when it is overengineered
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A fixed-size runner pool wastes money at night and queues jobs at peak. The pool that is “always warm” spends all day sitting idle; the pool that is “right-sized” starves when demand spikes. The answer is autoscaling, and on Kubernetes the canonical answer is Actions Runner Controller.
ARC is an upstream project (originally community-built, now part of the GitHub Actions ecosystem) that runs self-hosted runner pods inside a Kubernetes cluster, scaling the pod count based on the forge’s job queue depth.
The problem: fixed pools are wrong-sized
flowchart LR
subgraph FIXED["Fixed pool of 20 runners"]
R1["Runner 1"] --- R20["Runner 20"]
end
Q["Forge queue"] -->|"jobs"| FIXED
FIXED -. "20 idle at 3am,\n20 busy at 10am" .-> COST["Cost / SLA\ntrade-off"]
A fixed pool of 20 runners is over-provisioned at night and under-provisioned at peak. Either the team pays for capacity they do not need, or the team waits for capacity they do not have, or both.
ARC architecture
flowchart TB
F["Forge\n(GitHub Actions)"] -->|"workflow_job\nwebhook"| L["ARC listener\n(long-lived pod)"]
L -->|"scale to N"| K["Kubernetes API"]
K --> P["Runner pods\n(ephemeral, one per job)"]
P -->|"status, logs"| F
L -->|"listens for"| F
ARC has three moving parts:
- Controller. A Kubernetes controller that reconciles
RunnerDeploymentandRunnerSetresources. It watches the listener’s desired replica count and creates or deletes runner pods. - Listener. A long-lived pod that subscribes to the
forge’s
workflow_jobwebhook (or polls, in older versions). It translates job-queue depth into a desired replica count. - Runner pods. Short-lived pods, one per job, that register against the forge, take the job, run it, and terminate.
The listener is the bridge between the forge’s queue and Kubernetes’ scaling primitives. Without it, ARC would not know how many runner pods to create.
A minimal RunnerDeployment
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: prod-runners
namespace: actions-runner-system
spec:
replicas: 0
template:
spec:
repository: my-org/my-repo
labels:
- self-hosted
- linux
- x64
- prod
flowchart LR
A["RunnerDeployment\n(desired state)"] --> B["Controller reconciles"]
B --> C["RunnerSet\n(manages replicas)"]
C --> D["Runner pod 1"]
C --> E["Runner pod 2"]
C --> F["Runner pod N"]
The controller creates the RunnerSet, the RunnerSet creates runner pods, and the listener watches the forge’s queue and asks the controller to scale.
Scale sets and min/max
ARC’s modern API uses EphemeralRunnerSet with
RunnerScaleSet. The configuration expresses minimum and
maximum replicas:
apiVersion: actions.github.com/v1alpha1
kind: EphemeralRunnerSet
metadata:
name: prod-runners
spec:
githubConfigUrl: https://github.com/my-org/my-repo
maxRunners: 30
minRunners: 0
runnerGroup: prod
template:
spec:
containers:
- name: runner
image: my-registry.example.com/runner:prod-v12
env:
- name: ACTIONS_RUNNER_INPUT_EPHEMERAL
value: "true"
minRunners: 0scales the pool to zero when idle. No cost at night.maxRunners: 30caps the burst. The listener will not request more than 30 runner pods.ephemeral: true(set via the env var above or the template) ensures each runner pod is destroyed at the end of its job.
The listener-to-pod flow
sequenceDiagram
participant F as Forge
participant L as ARC listener
participant K as Kubernetes
participant P as Runner pod
F->>L: workflow_job queued
L->>K: scale to 5
K->>P: create runner pod
P->>F: register, take job
P->>F: run steps
F->>P: job complete
P->>K: terminate pod
L->>K: scale down to 4
The flow that matters:
- The listener is the only long-lived component. A single ARC listener per cluster, watching all runner pools. It is the bridge.
- Runner pods are short-lived. Created when a job arrives and destroyed when the job ends. The Kubernetes pod lifecycle replaces the VM-rebuild loop of the cloud-controller pattern.
- Scale-down is graceful. When the queue empties, the
listener asks for fewer pods; the controller removes idle
ones.
minRunnerssets the floor.
When ARC is the right answer
ARC is the right runner platform when:
- The team already operates a Kubernetes cluster.
- The runner pool needs to scale to zero to control cost.
- The jobs are container-friendly (most CI is; some are not, e.g. macOS or GPU jobs that need host devices).
- The team can manage custom runner images in a registry.
ARC is overengineered when:
- The team has fewer than 50 jobs per day (the operational cost of running Kubernetes outweighs the savings).
- The jobs need non-container hosts (bare metal, specialised hardware that cannot be pod-attached).
- The team is already running a cloud-vendor autoscaler
(e.g. EC2 fleet with
runners.tf) and does not need Kubernetes.
Production discipline
- Run the listener as a singleton per cluster. Multiple listeners do not scale better; they conflict.
- Pin the runner image. A
runner:latestimage that breaks at 2am breaks every job in the pool. - Set
minRunnersdeliberately. Zero is cheapest; one is fastest; more is rarely needed. - Monitor pod-creation latency. The listener-to-pod round-trip is the cold-start budget.
- Separate pools by trust level. A
prodpool and anuntrustedpool with different IAM, different egress, different secrets.
Cross-course references
- Kubernetes for Production Sysadmins - Parts IX-XII (Workloads) cover the pod-lifecycle primitives ARC uses.
- Linux for Production Sysadmins - Part XXIV (ImmutInf) covers the immutable-image pattern that ARC’s runner template embodies.
- Terraform for Production Sysadmins - Part XXIV (IaCRepos) covers the IaC patterns for managing ARC manifests.
Quiz
Knowledge check · 4 questions
Q1. What role does the ARC listener play in scaling runner pods?
Q2. Setting `minRunners: 0` in an ARC RunnerScaleSet causes the pool to scale down to zero when the job queue is empty.
Q3. Name the three architectural components of ARC and the role of each.
Q4. Design an ARC-based runner platform for a team that runs both production CI and fork-PR CI inside the same Kubernetes cluster.
Team T operates a Kubernetes cluster. They want to run CI on ARC. Production jobs need IAM access to AWS via IRSA and a private network path to internal services. Fork PR jobs run untrusted code and must not have IAM, must not reach internal services, and must be separated from production state.
Passing score: 75%. Answers are checked in this browser.