Three things only a live cluster showed: - dockerd crash-looped with `chmod /home/rootless/.local/share/docker: operation not permitted`. It chmods an existing data root, and the PVC mount point is owned by host root, which fsGroup does not change and which is unmapped inside rootlesskit's user namespace. data-root now points at a subdirectory dockerd creates itself. - Pulumi reported the StatefulSet ready while that was happening: with no probe, a container is ready the moment it starts. A `docker info` readiness probe makes the await mean the daemon is up. - DNS timed out in the runner pod. GKE enables NodeLocal DNSCache on new clusters, so the kube-dns Service IP is answered by node-local-dns, which the NetworkPolicy did not allow. The DNS rule now allows both. Also: leaving privateClusterConfig.enablePrivateEndpoint unset was a diff on every run (the provider reads it back as true with IP endpoints disabled), so it is now set to true. docs/runners.md troubleshooting is updated with what the deploy actually showed: COS supports rootless dockerd, and Cloud NAT hairpins to the VM's external IP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 KiB
Gitea Actions runners on GKE
Gitea Actions jobs run on a small GKE cluster in the same project, not on
gitea-vm. The VM has no memory to spare (see Memory on a 2 GB instance in
runbook.md), and a runner on the host it deploys is exactly the
loop migrate-to-gitea-scm.md warns about.
Everything here is behind one flag, gitea:runnersEnabled, which is off by
default.
Shape
| Piece | Choice | Where |
|---|---|---|
| Cluster | GKE Standard, zonal (gitea:zone), named gitea-runners, used for nothing else |
infra/pkg/gke |
| Nodes | One autoscaled pool, e2-standard-2 spot by default, private (no external IPs), COS |
infra/pkg/gke |
| Node identity | gitea-gke-nodes@, holding only roles/container.defaultNodeServiceAccount |
infra/pkg/gke |
| Egress | Cloud NAT on a reserved static IP (runnerEgressIp stack output) |
infra/pkg/network |
| Control plane | DNS endpoint only, authorised by IAM; IP endpoints are disabled | infra/pkg/gke |
| Runner | StatefulSet of docker.io/gitea/runner:<tag>-dind-rootless, one pod per node |
infra/pkg/runner |
| Registration | Preset token in Secret Manager, shared by Gitea and the runners | scripts/bootstrap.sh |
| WAF | Runner protocol and artifact routes bypass Coraza | vm/config/Caddyfile.tmpl |
Why Standard and not Autopilot: workflows need a Docker daemon (docker build,
Docker actions), which means a privileged Docker-in-Docker pod, and Autopilot
refuses privileged pods. The runner's newer Kubernetes backend, which runs
each job as its own pod, needs no privileges, but it cannot run Docker actions
or docker build.
Why zonal: GKE's free tier credits the management fee of one zonal or Autopilot cluster per billing account. If this billing account already has a cluster, this one adds about $73/month.
Cost
Roughly $25–35/month for one replica, provided the free-tier credit covers the cluster fee:
| approx. / month | |
|---|---|
spot e2-standard-2 node |
$15 |
| static NAT IP (in use) | $3.65 |
| Cloud NAT gateway + data processing | $1 + $0.045/GB |
| boot disk 30 GB + PVCs 10 + 20 GB, pd-balanced | $6 |
Each extra replica adds a node, its boot disk, and two PVCs.
Threat model
Read this before pointing a repository you do not fully trust at these runners.
- Jobs can act as the runner. Jobs run inside the runner pod's own dockerd
and get its socket, which is what makes
docker buildwork. Through that socket a job can read anything the daemon can see, including the runner's/data/.runnercredentials and the mounted registration token. A malicious workflow could register a rogue runner, or impersonate this one and pick up other repositories' jobs and secrets. That is acceptable here only because this is a single-tenant instance withDISABLE_REGISTRATION = true: every workflow author is already trusted with every secret. - Jobs cannot reach Google Cloud. This part is enforced, not assumed:
- Job pods get no Google identity. The runner's Kubernetes service account holds exactly one permission, read access to the registration token secret.
- The metadata server is blocked. Job containers' traffic leaves from
the runner pod's network namespace, so the GKE metadata server would issue
them that KSA's token. The NetworkPolicy blocks
169.254.0.0/16for that reason. It is load-bearing: do not "simplify" it away. - Escaping to the node gains little. dockerd runs as uid 1000 (rootless), so a job that breaks out of its own container lands as an unprivileged user. Root on the node would only yield the node SA, which holds logging and monitoring writes and nothing else.
- The cluster is not a path to the VM's private side. The NetworkPolicy also blocks all RFC1918 ranges. Jobs reach Gitea the way the rest of the internet does: through its public address, Caddy, and the WAF (except the bypassed runner routes below).
The WAF bypass
vm/config/Caddyfile.tmpl routes these prefixes to Gitea without Coraza:
/api/actions/* runner protocol (Connect RPC)
/api/actions_pipeline/* artifacts v3, job summaries
/twirp/github.actions.results.api.v1.ArtifactService/* artifacts v4
This is not optional. The runner speaks Connect with binary protobuf bodies
(Content-Type: application/proto), and artifact uploads are PUT/PATCH with
application/octet-stream. CRS rejects both outright in On mode: the
content type is not in tx.allowed_request_content_type, and the methods are
not in tx.allowed_methods. Log uploads would trip SQLi/XSS rules on build
output even if they got that far. Every bypassed route authenticates with a
runner or task token. The reasoning is the same as for the git bypass in
waf.md.
The matcher uses prefixes, not a list of RPC names, so a Gitea upgrade that
adds an RPC does not silently start returning 403. /api/v1/ is a different
prefix and stays behind the WAF. Caddy cleans the path before matching, so
/api/actions/../v1/... is inspected like any other /api/v1 request.
fail2ban and the shared egress IP
Every runner and every job shares the Cloud NAT address. With the gitea jail
at maxretry = 5, a single job retrying a bad token would ban all of CI for
an hour. It would also take the runners offline, since they poll through the
same IP. So the VM adds that address to ignoreip, using instance metadata
runner-egress-ip. The trade-off is that hostile traffic from a job is never
banned, which is acceptable under the threat model above.
Turning it on
- Re-run
scripts/bootstrap.sh <project-id>. It is idempotent, and on an existing project it adds three things:- the
container.googleapis.comAPI roles/container.adminforcb-infra@- the
gitea-runner-registration-tokensecret, as 64 random hex characters
- the
- Set the flag and push:
The first apply takes 15–25 minutes: cluster, node pool, then the StatefulSet. If the StatefulSet wait times out (IAM or CSI propagation on a brand-new cluster), run
(cd infra && pulumi config set gitea:runnersEnabled true) make preview # local: the infra trigger only fires on main, so a branch gets no preview git commit infra/Pulumi.prod.yaml -m 'Enable GKE runners' && git pushpulumi upagain. Everything already created stays. - The infra pipeline's config-sync step restarts Gitea with the token.
Meanwhile the runner retries registration every 5 seconds. If the runner logs
keep saying the token is invalid after the pipeline finishes, the VM most
likely read the secret before its IAM grant had propagated. Run
make sync. - Check:
make kube && make runners # pod Running, log shows it registered and declaredmake kubeneedsgke-gcloud-auth-plugin:gcloud components install gke-gcloud-auth-plugin, orgoogle-cloud-cli-gke-gcloud-auth-pluginfrom the same package repository as a distro-packaged gcloud. Site Administration → Actions → Runners should listgitea-runner-0as Idle, with labelsubuntu-latest,ubuntu-24.04andubuntu-22.04. - Run the smoke workflow below once.
Smoke workflow
Put this in a scratch repository as .gitea/workflows/smoke.yaml:
on: [push]
jobs:
smoke:
runs-on: ubuntu-latest # required: runner 5.x fails jobs without runs-on
steps:
- uses: actions/checkout@v4
- name: docker build
run: |
printf 'FROM alpine:3\nRUN echo built\n' > Dockerfile
docker build -t smoke . && docker run --rm smoke echo ran
docker info | grep -A1 'Registry Mirrors' # expect mirror.gcr.io
- uses: actions/cache@v4
with: { path: cached, key: smoke-${{ github.run_id }}, restore-keys: smoke- }
- run: mkdir -p cached && date >> cached/stamp && cat cached/stamp
- uses: actions/upload-artifact@v4
with: { name: smoke, path: cached }
- name: metadata server must be unreachable
run: |
! curl -sf -m 5 -H 'Metadata-Flavor: Google' \
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/email
- name: egress IP
run: curl -s https://api.ipify.org # must equal `pulumi stack output runnerEgressIp`
Push twice. The second run's cached/stamp should have two lines. While it
runs, make logs should show no Coraza audit entries for /api/actions,
/api/actions_pipeline or /twirp/.
Day-to-day
| Task | How |
|---|---|
| More parallel jobs on one node | pulumi config set gitea:runnerCapacity 2 |
| More nodes | gitea:runnerReplicas (≤ gitea:runnerMaxNodes, max 10) |
| On-demand instead of spot | gitea:runnerSpot false. The pool is replaced create-first, so the runners reschedule. |
| Bigger nodes | gitea:runnerMachineType e2-standard-4 |
| Upgrade the runner | bump gitea:runnerImageTag; it must stay a -dind-rootless tag |
| Shell into the runner | kubectl --context=gke_<p>_<zone>_gitea-runners -n gitea-runner exec -it gitea-runner-0 -- sh |
Scaling follows gitea:runnerReplicas, not the job queue. Pod
anti-affinity puts one runner pod on each node. The node pool's minimum and
initial size are max(1, runnerReplicas): at least 1 because the runner must
keep polling Gitea, and sized up front so that a first apply with several
replicas does not hang waiting on node provisioning. After that, raising
runnerReplicas triggers a normal autoscaler scale-up, one node per new pod,
taking a few minutes each. The first time you go above 1, watch it with
make runners.
Spot preemption gives a pod about 15 seconds, so a job running at the time fails. Re-run it. On on-demand nodes, a drain (node upgrade or scale-down) lets running jobs finish for up to 55 minutes. Node upgrades normally happen in the Sunday 05:00–09:00 UTC window, alongside the VM's reboot window. GKE can still push urgent security patches and auto-repairs outside it, and those interrupt running jobs without warning, just as preemption does.
Runner 5.x is tested upstream against current Gitea, not 1.27. If
registration or job fetching misbehaves after a runner upgrade, roll
gitea:runnerImageTag back to the previous tag before debugging anything else.
Rotating the registration token
Registered runners authenticate with their own .runner credentials, so
rotation never affects them; it only governs new registrations.
openssl rand -hex 32 | tr -d '\n' \
| gcloud secrets versions add gitea-runner-registration-token --project <p> --data-file=-
make sync # renders the new token file and restarts Gitea
Two Gitea behaviours make this a one-way street:
- Never re-add a value the secret has held before. When Gitea starts with a new preset token, it deactivates every older instance-level token, and it never reactivates one. Rotating A→B→A leaves A dead: Gitea only logs a warning and every new registration fails.
- "Reset registration token" in the admin UI also kills the preset token, permanently, across restarts. Use it only to revoke, then add a fresh secret version as above.
To revoke without replacing, click Reset in the admin UI. Removing the secret does not revoke anything, because Gitea keeps the token active in its database.
Turning it off
Two applies, in this order:
cd infra
pulumi config set gitea:runnerReplicas 0 && pulumi up # 1. deletes the pods AND their PVC disks
pulumi config set gitea:runnersEnabled false && pulumi up # 2. deletes the cluster, NAT, subnet, IP
Step 1 matters because deleting a cluster does not delete PVC-backed disks. Skipping it leaves orphaned PDs that keep billing. To find them:
gcloud compute disks list --filter='name~^pvc- AND -users:*'
The Kubernetes objects carry DeletedWith(cluster), so step 2 does not try to
delete them one by one through an API whose token expired long ago.
Then remove stale runners from Site Administration → Actions → Runners. The token file on the VM is left in place on purpose (see Rotating above). It is harmless.
Troubleshooting
The first apply fails creating the Namespace or the SecretProviderClass. A
brand-new cluster can report RUNNING a little before its API server, or the
Secret Manager add-on's CRD, is ready. Run pulumi up again; nothing is lost.
pulumi refresh or destroy fails with 401 against the cluster. The
Kubernetes provider's token in state has expired. Add --run-program, which
re-runs the program and fetches a fresh one. As a last resort for a cluster
that is already gone, set PULUMI_K8S_DELETE_UNREACHABLE=true for one run.
Pod crashloops; the log mentions user namespaces. Rootless dockerd needs
/proc/sys/user/max_user_namespaces > 0 on the node. COS_CONTAINERD on GKE
1.36 has it; check if a node image change ever breaks this:
kubectl --context=… debug node/<node> -it --image=busybox -- cat /proc/sys/user/max_user_namespaces
If it is 0, the rootless image cannot work on that node image. The fallback is
the rootful docker:dind native-sidecar pattern from the upstream
examples/kubernetes/statefulset-dind.yaml. A job escape then means root on
the node: the node SA is minimal and the metadata block still applies, but
update the threat model above to match.
docker build in a job fails to reach the daemon. Check whether the runner
log says "jobs get the daemon socket directly". The runner probes whether the
inner dockerd can see its temp directory, and falls back to mounting the
daemon socket directly if it cannot. If neither path works, it is the same
fallback as above.
actions/cache never restores. Jobs reach the runner's cache server at the
pod IP on port 8088. If Dataplane V2 turns out to evaluate that traffic, the
NetworkPolicy already allows it between runner pods. If it still fails, set
cache.host explicitly in runnerConfig (infra/pkg/runner) from a downward-API
POD_IP environment variable; the config expands ${NAME}.
The runner cannot reach Gitea at all (cannot ping the Gitea instance server: deadline_exceeded). Check DNS first:
kubectl --context=… -n gitea-runner exec gitea-runner-0 -- nslookup gitea.jasonmross.dev
A timeout here means the NetworkPolicy is not letting queries through. GKE
enables NodeLocal DNSCache on new clusters, so the kube-dns Service IP is
answered by the node-local-dns pod on the node. The DNS egress rule must
allow k8s-app=node-local-dns as well as k8s-app=kube-dns. If DNS works,
note that Cloud NAT does hairpin to the VM's external address (verified on
the first deploy). Look at the VPC firewall and fail2ban on the VM next.
dockerd exits with chmod /home/rootless/.local/share/docker: operation not permitted. dockerd was given the PVC mount point itself as its data root.
That directory is owned by host root, which is unmapped inside rootlesskit's
user namespace. The data root must be a subdirectory that dockerd creates
itself: data-root in daemon.json, dockerDataRoot in infra/pkg/runner.