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]>
156 lines
8.9 KiB
Markdown
156 lines
8.9 KiB
Markdown
---
|
|
date: 2026-11-08
|
|
branch: feat/runner-capacity-2
|
|
title: 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.
|
|
|
|
1. **New ConfigMap `act-runner-config`** holding a config file with ONE key,
|
|
`runner.capacity: 2`, mounted read-only at `/etc/act_runner/` and selected with
|
|
`CONFIG_FILE=/etc/act_runner/config.yaml`. The image's `run.sh` (act_runner v0.6.1) turns
|
|
that into `--config` for both `register` and `daemon`.
|
|
- 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-config` example was deliberately NOT used. Its `runner.labels` would override
|
|
the registered labels in `/data/.runner` and repoint `ubuntu-latest` at
|
|
`docker.gitea.com/runner-images`. Its `force_pull: true` is not the default. Its sample `envs`
|
|
would be injected into every job.
|
|
- With a file present, act_runner ignores the legacy `GITEA_RUNNER_CAPACITY` /
|
|
`_FILE` / `_ENVIRON` / `_ENV_FILE` / `GITEA_DEBUG` / `GITEA_TRACE` env vars. None of
|
|
them is set on this Deployment.
|
|
2. **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.
|
|
3. **dind resources:** requests `cpu 100m -> 2` and `memory 256Mi -> 1Gi`; limit
|
|
`memory 2Gi -> 4Gi`. There is still no CPU limit, on purpose.
|
|
4. **runner resources:** request `memory 128Mi -> 256Mi`, limit `memory 512Mi -> 1Gi`. CPU stays
|
|
at `100m`.
|
|
|
|
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.yml` `gate-${{ github.ref }}` with cancel-in-progress still cancels a superseded run on
|
|
the same ref.
|
|
- `renovate` (group `renovate`, no cancel) and `images-${{ 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.py` `EXTRA_UNITS` places `gitea-actions.yaml` by
|
|
anchor/offset only. `ci/render-golden.sh` iterates over rendered files only, so this file is
|
|
outside render==golden. No `--emit-waves` was 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 `.runner` labels only when non-empty.
|
|
- `poller.go`: a semaphore of `cfg.Runner.Capacity` bounds concurrent tasks.
|
|
- `runner.go`: per-task container name prefix.
|
|
- The image's `run.sh`, read from `gitea/act_runner:0.6.1`, honours `CONFIG_FILE`.
|
|
- **The live pod has no config file today.** `/data` holds only `.runner` and `lost+found`.
|
|
`act_runner --version` reports v0.6.1. No LimitRange or ResourceQuota exists in
|
|
`gitea-actions`.
|
|
- **Resource numbers.** All figures above come from read-only `kubectl top`, `describe node` and
|
|
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.04` image. 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 no `sensitive_paths` glob in
|
|
`infra/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: `Declare` sends only version and labels. `GET /api/v1/admin/actions/runners` therefore
|
|
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
|
|
the `push`-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-storage` limit, 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_runner` runs 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
|
|
|
|
1. Flux applied it:
|
|
`kubectl --context k3s-prod -n flux-system get kustomization apps-wave-1 -o jsonpath='{.status.lastAppliedRevision}'`
|
|
equals the merge commit.
|
|
2. The pod restarted with the config:
|
|
- `kubectl --context k3s-prod -n gitea-actions get pod -l app.kubernetes.io/name=act-runner`
|
|
shows a new pod, 2/2 Running.
|
|
- `kubectl ... exec deploy/act-runner -c runner -- cat /etc/act_runner/config.yaml` shows
|
|
`capacity: 2`.
|
|
- `kubectl ... exec deploy/act-runner -c runner -- ps` shows `act_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.
|
|
3. The runner is registered and online:
|
|
- `GET /api/v1/admin/actions/runners` lists `incluster-dind`, online, with the same ID as
|
|
before (no duplicate row).
|
|
- Capacity is NOT shown there; see above.
|
|
4. Concurrency is real: trigger two runs on different refs (e.g. two PR gates, or a PR gate plus a
|
|
`workflow_dispatch` of renovate). Then either:
|
|
- in `GET /api/v1/repos/Home-Network/home-network-k3s-impl/actions/tasks`, confirm their
|
|
`run_started_at` .. `updated_at` windows overlap; or
|
|
- check
|
|
`docker --host=tcp://127.0.0.1:2376 --tlsverify ... ps` in the dind container lists two
|
|
`GITEA-ACTIONS-TASK-*` container sets at once.
|
|
5. Resources: `kubectl --context k3s-prod -n gitea-actions describe pod` shows dind requests
|
|
cpu 2 / memory 1Gi, limit memory 4Gi, and runner limit memory 1Gi.
|