The in-cluster runner ran at the default capacity 1 with no config file, so gate / images / renovate runs on different refs queued behind each other (29-759s waits; run 472 waited 759s behind a renovate burst). - ConfigMap act-runner-config with ONLY runner.capacity: 2, selected via CONFIG_FILE (honoured by the v0.6.1 image's run.sh). v0.6.1 LoadDefault() applies the same defaults to a partial file as to no file; the generate-config example is deliberately not used (its labels would override the registered ones, force_pull differs from the default). - Pod-template config-revision annotation so ConfigMap-only edits can roll it. - dind: cpu request 100m -> 2 (no limit), memory 256Mi/2Gi -> 1Gi/4Gi. Measured at capacity 1: peak 0.69 core, 447Mi working set (30d), 0 OOMs. - runner: memory 128Mi/512Mi -> 256Mi/1Gi (peak 314Mi at capacity 1). apps/wave-1/gitea-actions.yaml is a hand-owned EXTRA_UNITS file (the renderer only places it in a wave), so it is the source, not generated output. Co-Authored-By: Claude Opus 5.5 <[email protected]>
8.9 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-11-08 | feat/runner-capacity-2 | act_runner runs two jobs at once, and dind is sized for two |
What changed
apps/wave-1/gitea-actions.yaml only. That file is a hand-owned non-catalog unit
(EXTRA_UNITS["gitea-actions"] in catalog/render.py): the renderer only decides which wave
it lands in, it does not generate its bytes, and ci/render-golden.sh compares only files the
catalog renders. So it is edited in place, and no re-render was needed or run.
- New ConfigMap
act-runner-configholding a config file with ONE key,runner.capacity: 2, mounted read-only at/etc/act_runner/and selected withCONFIG_FILE=/etc/act_runner/config.yaml. The image'srun.sh(act_runner v0.6.1) turns that into--configfor bothregisteranddaemon.- Deltas only, on evidence: v0.6.1
LoadDefault()unmarshals the file onto a zero Config and then applies the same defaults as the no-file path, so the other settings are unchanged. - The
generate-configexample was deliberately NOT used. Itsrunner.labelswould override the registered labels in/data/.runnerand repointubuntu-latestatdocker.gitea.com/runner-images. Itsforce_pull: trueis not the default. Its sampleenvswould be injected into every job. - With a file present, act_runner ignores the legacy
GITEA_RUNNER_CAPACITY/_FILE/_ENVIRON/_ENV_FILE/GITEA_DEBUG/GITEA_TRACEenv vars. None of them is set on this Deployment.
- Deltas only, on evidence: v0.6.1
- Pod-template annotation
homelab.keithjackson.dev/act-runner-config-revision: "1". act_runner reads its config once, at start, so a change to the ConfigMap alone would never take effect. Bump this annotation whenever you change only the ConfigMap. - dind resources: requests
cpu 100m -> 2andmemory 256Mi -> 1Gi; limitmemory 2Gi -> 4Gi. There is still no CPU limit, on purpose. - runner resources: request
memory 128Mi -> 256Mi, limitmemory 512Mi -> 1Gi. CPU stays at100m.
Why these sizes. Measured 2026-11-08 through Prometheus, at capacity 1:
| container | peak CPU (1m rate, 30d) | peak working set (30d) | OOM events (7d) |
|---|---|---|---|
| dind | 0.69 core | 447 MiB | 0 |
| runner | 0.24 core | 314 MiB | 0 |
At the moment of measurement the pod was idle (top: dind 1m/138Mi, runner 1m/83Mi), on
k3s-prod-2.
- CPU request 2. Two jobs at today's peak is about 1.4 cores. The gate is being parallelised in other work, so each job will use more than that. The request sets dind's CFS weight under contention; at 100m it got a 1/20 share. With no limit, dind can still use spare cores on an idle node.
- Memory 1Gi / 4Gi. Two jobs at today's peak is about 0.9 GiB. If dind is OOM-killed, dockerd goes down and takes both running jobs with it, so the limit is about 4x that.
- Runner limit 512Mi -> 1Gi. Its 30-day peak was already 61% of the old limit, and with capacity 2 it holds state for two tasks.
Scheduling headroom: CPU requests per node were measured with describe node. The pod's total
CPU request goes from 200m to 2100m (+1.9 cores).
| node | CPU requested now | after the change | allocatable |
|---|---|---|---|
| prod-2 (current) | 2945m | ~4845m (61%) | 8 |
| prod-1 | 3175m | ~5075m | 8 |
| prod-3 | 3840m | ~5740m (72%) | 8 |
The pod fits on any of the three if the RWO PVC moves it.
Concurrency semantics. Workflow concurrency: groups are enforced by Gitea on the server, not by
the runner, so capacity does not change them:
gate.ymlgate-${{ github.ref }}with cancel-in-progress still cancels a superseded run on the same ref.renovate(grouprenovate, no cancel) andimages-${{ github.ref }}still serialise themselves.
What capacity 2 does change: runs on different refs (a PR gate next to a renovate or images
run, or two PRs) now run side by side instead of queueing. The required check
gate / gate (pull_request) is still one job per PR head, and gate.yml's other job
(conformance) is if: false, so it never runs.
Both jobs share one dockerd. act_runner gives each task its own container prefix
(GITEA-ACTIONS-TASK-<task id>) and its own network, and no workflow uses fixed container names
or host ports.
What I verified
- Source of truth.
catalog/render.pyEXTRA_UNITSplacesgitea-actions.yamlby anchor/offset only.ci/render-golden.shiterates over rendered files only, so this file is outside render==golden. No--emit-waveswas run. - act_runner v0.6.1 behaviour, read in its source (tag v0.6.1):
config.LoadDefault()treats a partial file as described above.daemon.go: config labels override.runnerlabels only when non-empty.poller.go: a semaphore ofcfg.Runner.Capacitybounds concurrent tasks.runner.go: per-task container name prefix.- The image's
run.sh, read fromgitea/act_runner:0.6.1, honoursCONFIG_FILE.
- The live pod has no config file today.
/dataholds only.runnerandlost+found.act_runner --versionreports v0.6.1. No LimitRange or ResourceQuota exists ingitea-actions. - Resource numbers. All figures above come from read-only
kubectl top,describe nodeand Prometheus queries against k3s-prod. - dind disk. The emptyDir sits on the node's 11.8G ephemeral filesystem: 4.0G used, 7.2G
free. Image cache is 2.18GB, which is the one
catthehacker/ubuntu:act-22.04image. Two concurrent jobs share that image. - Embedded config. It parses as YAML to
{'runner': {'capacity': 2}}. The only$in the manifest is the existing${BASE_DOMAIN}, so nothing new is exposed to Flux postBuild envsubst. - Approval policy.
apps/wave-1/matches nosensitive_pathsglob ininfra/gitea/approval-policy.json, so this PR merges on a green gate alone. - Local gate. Pinned toolbox, run from the worktree on the committed tree. The rc and skipped stages are given in the PR description.
What I did NOT verify
- UNVERIFIED: that the runner actually starts with the config and runs two jobs at once.
Nothing is applied until Flux reconciles
main. - Capacity cannot be seen through Gitea's API. act_runner v0.6.1 does not report capacity to
Gitea:
Declaresends only version and labels.GET /api/v1/admin/actions/runnerstherefore cannot show "capacity 2". Concurrency has to be observed instead. - UNVERIFIED: whether the parallelised gate fits in these sizes. That gate has not landed.
Re-measure after it does, with
max_over_time(sum by (container)(rate(container_cpu_usage_seconds_total{namespace="gitea-actions"}[1m]))[7d:30s]). - Rollout cancels in-flight jobs. The rollout restarts the runner pod (strategy Recreate,
shutdown_timeout: 0s), and any job running at that moment is cancelled, possibly including thepush-to-main gate run triggered by this very merge. Re-run it if that happens. - dind disk is not bounded by a limit. With two jobs it holds two jobs' workspaces and layers
on the same 11.8G node filesystem. I did not add an
ephemeral-storagelimit, because an eviction would kill both jobs. Watch kubelet DiskPressure on the runner's node. - Stale comments in sensitive paths.
.gitea/workflows/gate.yml(line ~52) and.gitea/workflows/README.md(line ~86) still say "act_runnerruns at capacity 1". I left them alone because.gitea/**is a sensitive path and editing them would put this PR behind human approval. Fix them in a separate PR.
Operator post-merge checklist
- Flux applied it:
kubectl --context k3s-prod -n flux-system get kustomization apps-wave-1 -o jsonpath='{.status.lastAppliedRevision}'equals the merge commit. - The pod restarted with the config:
kubectl --context k3s-prod -n gitea-actions get pod -l app.kubernetes.io/name=act-runnershows a new pod, 2/2 Running.kubectl ... exec deploy/act-runner -c runner -- cat /etc/act_runner/config.yamlshowscapacity: 2.kubectl ... exec deploy/act-runner -c runner -- psshowsact_runner daemon --config /etc/act_runner/config.yaml.- The logs show "declare successfully" with the two expected labels (
ubuntu-latest,homelab->catthehacker/ubuntu:act-22.04). That confirms the labels did not change.
- The runner is registered and online:
GET /api/v1/admin/actions/runnerslistsincluster-dind, online, with the same ID as before (no duplicate row).- Capacity is NOT shown there; see above.
- Concurrency is real: trigger two runs on different refs (e.g. two PR gates, or a PR gate plus a
workflow_dispatchof renovate). Then either:- in
GET /api/v1/repos/Home-Network/home-network-k3s-impl/actions/tasks, confirm theirrun_started_at..updated_atwindows overlap; or - check
docker --host=tcp://127.0.0.1:2376 --tlsverify ... psin the dind container lists twoGITEA-ACTIONS-TASK-*container sets at once.
- in
- Resources:
kubectl --context k3s-prod -n gitea-actions describe podshows dind requests cpu 2 / memory 1Gi, limit memory 4Gi, and runner limit memory 1Gi.