How do you run GitHub Actions runners on Kubernetes with ARC?

Actions Runner Controller (ARC) is a Kubernetes operator that runs GitHub Actions self-hosted runners as Pods. It is developed with the GitHub Actions team, installs with two Helm charts and creates one ephemeral runner Pod per job, scaling the number of runners with the job queue and down to zero when no jobs are waiting. Most of the work in running it is not the install but choosing a container mode, isolating untrusted jobs and keeping startup latency low.

How ARC works

ARC’s current architecture is called runner scale sets. GitHub’s ARC documentation describes these components:

ComponentWhat it does
Controller managerInstalled from the gha-runner-scale-set-controller Helm chart. Watches the ARC custom resources.
AutoscalingRunnerSetOne per runner scale set, installed from the gha-runner-scale-set chart. Registers the scale set with GitHub in a runner group.
Listener PodHolds a long-poll connection to GitHub and waits for “Job Available” messages, then raises the desired runner count.
EphemeralRunner PodsEach Pod registers with a just-in-time configuration, runs exactly one job and is deleted afterwards.

A workflow targets a scale set by its installation name in runs-on. The minRunners and maxRunners values in the chart bound the number of runners. If you leave both unset, ARC scales with the assigned jobs and scales down to zero when no jobs are active.

Because every runner is ephemeral and registered just in time, a job cannot leave files, processes or credentials behind for the next job on the same runner. That follows GitHub’s own hardening advice for self-hosted runners.

Runner scale sets vs the legacy mode

ARC started as a community project with its own autoscaling resources, such as RunnerDeployment and HorizontalRunnerAutoscaler, in the actions.summerwind.net API group. The project README states that with the introduction of runner scale sets, “the existing autoscaling modes are now legacy” and are maintained by the community only. New installations should use runner scale sets. GitHub describes moving from the legacy mode as a major architectural change, so plan it as a migration rather than an upgrade.

Container modes

Many workflows run Docker commands, use container: jobs or start service containers. A runner inside a Pod has no Docker daemon by default, so ARC offers container modes, documented in the scale set deployment guide:

ModeHow containers runTrade-off
None (default)Only the runner process; no container supportSimplest and least privileged, but container: jobs and Docker steps fail
dindA Docker-in-Docker sidecar in the runner PodFull Docker compatibility, but the sidecar requires privileged mode
kubernetesRunner container hooks create a separate Pod for each job containerNo privileged containers, but needs persistent volumes and gives the runner permission to create Pods
kubernetes-novolumeSame hooks, with the job filesystem copied between Pods through lifecycle hooksNo persistent volume needed

A privileged container can reach the host kernel and devices, so Docker-in-Docker effectively gives every job root access to the node. Kubernetes mode avoids that, but GitHub warns that allowing jobs without a job container “can give the runner pod elevated privileges with the Kubernetes API server.” Steps that run docker build also need a different builder in Kubernetes mode, such as rootless BuildKit.

Startup latency: nodes and caches

Scaling to zero saves money, but the first job after a quiet period waits for capacity. If no node has room, the cluster autoscaler or node auto-provisioning has to create one, the node has to join the cluster, and the runner image has to be pulled before the job starts. A runner image with large toolchains makes that pull slower. Typical mitigations:

  • Keep a small warm pool with minRunners during working hours. ARC does not support scheduled minimum values, so changing them by time of day needs external automation.
  • Keep runner images small and pull job-specific tools at run time from a nearby cache.
  • Store dependency and build caches in a registry or object storage close to the cluster, since an ephemeral Pod starts with an empty disk.
  • Set CPU and memory requests that match real usage, so the scheduler can pack several runners per node.

Self-hosted jobs that wait in the queue for 24 hours are cancelled, so a misconfigured scale set shows up as stuck jobs rather than errors.

Isolating untrusted jobs

GitHub Actions workflows run arbitrary code, and a pull request can change that code. For runners on Kubernetes:

  • Separate trust levels. Create different scale sets in different runner groups for trusted branches and for pull requests from forks, and give them different namespaces.
  • Dedicate nodes. Taint the nodes that run CI so runner Pods never share a node with production workloads.
  • Restrict the network. Apply a default-deny egress policy and allow only the registries and endpoints builds need. Block the cloud metadata endpoint. See Kubernetes network policies.
  • Sandbox the runtime. Run untrusted jobs with a RuntimeClass such as gVisor or Kata Containers, as described in isolating untrusted workloads.
  • Avoid long-lived credentials. Give jobs short-lived OIDC tokens instead of cloud keys mounted into runner Pods.

GPU and arm64 runner pools

Each scale set has its own Pod template, so you can offer several runner types under different runs-on names. An arm64 scale set uses a nodeSelector on kubernetes.io/arch: arm64. A GPU scale set requests nvidia.com/gpu in the container limits and tolerates the taint on GPU nodes, so GPU nodes exist only while GPU jobs run. The article on GPU jobs in CI/CD covers driver and CUDA pinning for those pools.

Running ARC on Cloudfleet

Cloudfleet Kubernetes Engine (CFKE) is a CNCF-conformant Kubernetes service, so ARC installs with its standard Helm charts. Fleet taints keep a Fleet dedicated to CI runners, and every node the Fleet provisions carries the taint from the moment it joins. Runners that build pull requests from external contributors can use runtimeClassName: kata to run in Kata Containers.

On this page