DEV Community

Gabriel Harnagea
Gabriel Harnagea

Posted on

Does Actions Runner Controller on AKS save money?

Actions Runner Controller (ARC) takes your GitHub Actions runner pods to zero between builds. The AKS node pool under those pods does not go with them. Add a warm baseline plus the cold start that justifies it, and ARC on AKS carries three cost floors the scale-to-zero pitch never mentions.

What ARC takes to zero, and what it does not

ARC is a Kubernetes operator. In runner scale set mode a listener watches a GitHub runner scale set and, per queued job, raises the desired replica count on the EphemeralRunnerSet; ARC then creates one ephemeral runner pod and deletes it when the job finishes. Set the minimum to zero and there are no idle runner pods between builds.

The current chart calls that floor minRunners; the legacy summerwind chart called it minReplicas. Same idea:

# gha-runner-scale-set chart values
githubConfigUrl: https://github.com/my-org
githubConfigSecret: arc-runner-secret
runnerScaleSetName: arc-linux-x64
minRunners: 0
maxRunners: 50
Enter fullscreen mode Exit fullscreen mode

Both charts accept a zero floor. Runner scale set mode holds an HTTPS long-poll to the GitHub Actions service and creates runners the moment a Job Available message arrives, so it scales straight from zero. The legacy summerwind chart instead scales from PercentageRunnersBusy, a metric it polls on the controller's sync interval, with an optional webhook server for faster reaction to workflow_job events.

On the GitHub bill, self-hosted runners including ARC are not metered per minute. GitHub announced a $0.002 per minute charge on self-hosted runners in private repos on 16 December 2025, to start 1 March 2026, then let that date pass with no charge and no new date. As of September 2026, ARC costs nothing from GitHub's side, running or idle. Everything below this line is Azure infrastructure you own.

Floor 1: the node pool bills after the pods are gone

The runner pods drop to zero the moment their jobs finish. The AKS node pool does not. Node removal is gated by two autoscaler timers that keep re-arming under bursty CI: any scale-up pushes the scale-down window out again, and only then must a node sit idle for a fixed stretch before it drains. So the empty VMs bill through the gaps between bursts, not just the tail after the last job.

The two knobs that size the tail are autoscaler profile settings, set cluster-wide on AKS:

az aks update \
  --resource-group my-rg \
  --name my-aks \
  --cluster-autoscaler-profile \
    scale-down-delay-after-add=5m \
    scale-down-unneeded-time=5m
Enter fullscreen mode Exit fullscreen mode

scale-down-delay-after-add pauses all scale-down evaluation for that long after any scale-up. scale-down-unneeded-time is how long a node must stay underutilized before it is eligible for removal. Both default to 10 minutes, so a burst can hold nodes 10 to 20 minutes after the last pod exits. The values above halve that; lower makes the pool flap under bursty CI.

You cannot dodge this tail by forcing the pool to a hard zero and walking away. A documented ARC issue reports that it does not reliably scale back up from a true zero-node state, so the next job waits or fails. The fix is a split: the ARC controller stays on the AKS system node pool every cluster already has, and only the runner pool goes to zero; the listener stays on the system pool, so a Job Available message still lands and raises the desired replica count; ARC then creates the pending pod that triggers the runner pool to scale back up.

# runner pool: user node pool, scales to zero
az aks nodepool update \
  --resource-group my-rg --cluster-name my-aks --name runners \
  --update-cluster-autoscaler --min-count 0 --max-count 10
Enter fullscreen mode Exit fullscreen mode

--update-cluster-autoscaler changes min and max on a pool that already has the autoscaler on; --enable-cluster-autoscaler does not update a pool that already has it on; the CLI points you at --update-cluster-autoscaler. The system pool cannot scale to zero at all, so it sits on capacity you already pay for, and --min-count 0 on the runner pool adds no new floor.

Floor 2: the warm baseline you set on purpose

Plenty of teams do not run the runner pool at zero. They set the minimum to 1 or 2:

minRunners: 1
maxRunners: 50
Enter fullscreen mode Exit fullscreen mode

One always-present runner pod pins at least one node up all day. Nobody waits on the autoscaler for that first job.

Price it as one VM SKU: the runner node SKU's hourly rate times 730 hours a month. That is Floor 2, flat, whether or not a single build runs. A reservation or savings plan brings the rate down.

minRunners: 1 versus 2 is about the first burst, not the daily average. If two jobs usually land together at 9am, minRunners: 1 cold-starts the second while the first takes the warm slot. Set the minimum to the concurrency you see at the start of the busy window, not the daily mean, which is near zero overnight.

If the warm runner is idle every time you look and the listener logs show long gaps with no Job Available message, you are paying Floor 2 for latency insurance you may not need.

Floor 3: cold start is why Floor 2 exists

Community reports put it around 30 to 60 seconds from job queued to running. If the autoscaler has to add a node, you also wait on VM provisioning and the node image pull. GitHub publishes no SLA, so treat the range as a planning number.

Keeping one node warm buys that gap away for a fixed cost; drop to zero and every first build after an idle period pays the cold start instead. The trade is money against latency, and it turns on whether your developers feel that gap on the first push of the morning.

Check your own setup

These commands tell you which floors you are actually paying:

# runner pods right now
kubectl get pods -n arc-runners

# nodes in the runner pool
kubectl get nodes -l agentpool=runners

# runner node count, refreshed every 30s
watch -n 30 'kubectl get nodes -l agentpool=runners --no-headers | wc -l'

# what the listener is doing (adjust the selector to your install)
kubectl -n arc-systems logs \
  -l app.kubernetes.io/component=runner-scale-set-listener --tail=50

# effective autoscaler profile
az aks show -g my-rg -n my-aks --query autoScalerProfile
Enter fullscreen mode Exit fullscreen mode

Read the pod and node lists together. A runner pod with a node under it is Floor 2, the warm baseline. No pods but nodes listed a few minutes longer is Floor 1's tail draining normally. Nodes still there well past scale-down-delay-after-add plus scale-down-unneeded-time is a scale-down that is not completing, a separate problem for the autoscaler logs. The listener logs tell you whether jobs are arriving at all. The watch line measures the tail. The profile read shows the timer values behind those minutes.

The comparison verdict changes per axis

I ran GitHub-hosted, self-hosted on a VM Scale Set, and ARC on AKS side by side in a real enterprise migration. The verdict is not the same on every axis.

Cost

GitHub-hosted runners got about 39% cheaper on 1 January 2026. Self-hosted and ARC never had a GitHub meter, so that cut does nothing for them; you pay the AKS cluster plus the three floors. In that migration the ARC idle cost matched a modest hosted bill, so the saving only appeared past a clear builds-per-day threshold with concurrency high enough to keep nodes busy. Below that, hosted is cheaper and simpler.

Network isolation

ARC runners sit in the VNet the AKS cluster already lives in, so the same network controls you apply to the cluster apply to them. Self-hosted on a VM Scale Set is the same. GitHub-hosted standard runners egress from GitHub's network. The hosted "VNet injection" feature is separate: it applies only to GitHub-hosted larger runners, where GitHub injects a NIC into your VNet. For strict isolation, self-hosted or ARC win outright.

Data residency

Self-hosted and ARC run only in the Azure region you choose. GitHub-hosted standard runners run where GitHub places them; larger runners with VNet injection let you pick from the supported regions. If a compliance rule names a region, ARC or a VM Scale Set is the clean answer.

How to decide

ARC on AKS is the right call when you already run a cluster, need runners inside your VNet or pinned to one region, and have the build volume to keep nodes busy. It is the wrong call if you picked it only for the scale-to-zero line, because the node pool tail and the warm baseline put a floor under the bill that hosted runners, at the January 2026 price, often beat.

Before you commit, get three numbers from your own cluster. From a week of the Actions API: builds per day at the busy-hour peak, not the daily average. From your runner SKU: the hourly price times 730, what one always-on node costs a month. From the node-count watch above after a quiet period: the minutes from last pod gone to last node removed. Those, not the pricing page, decide it.

One question for anyone running ARC in production: how long is your measured Floor 1 tail, and at what builds-per-day figure did ARC finally beat your GitHub-hosted bill?

Top comments (0)