Kubernetes
At a glance — a
self-hostedlauncher that runs each ephemeral runner as a Pod (one per run). Use it when you’re already on a cluster and want horizontal scale. There’s agantrycd-runner-group-kubernetesHelm chart that sets most of this for you.
Configure
On top of the shared runner-group config:
| Variable | Required | Notes |
|---|---|---|
KUBERNETES_IMAGE | Yes | ephemeral runner image |
KUBERNETES_NAMESPACE | Yes | namespace for runner Pods (the chart sets it from the release namespace) |
KUBERNETES_SERVICE_ACCOUNT | No | SA for the runner Pod; empty = namespace default |
KUBERNETES_MEMORY | No | memory request+limit (e.g. 512Mi) |
KUBERNETES_CPU | No | CPU request+limit (e.g. 500m) |
KUBERNETES_CONTAINER_BACKEND_URL | No | BACKEND_URL as seen from inside the Pod, if needed |
KUBERNETES_STARTUP_GRACE_PERIOD | No | 5m; how long a Pod may stay Pending before it’s force-deleted |
KUBERNETES_IMAGE_PULL_SECRETS | No | comma-separated Secret names for private-registry pulls |
KUBERNETES_IMAGE_PULL_POLICY | No | Always, IfNotPresent or Never for the runner Pods; empty = kubelet default (see below) |
KUBERNETES_ALLOW_STACK_IMAGE_OVERRIDE | No | false; allow a stack to override the runner image per run (see below) |
KUBECONFIG | No | path to kubeconfig; empty = in-cluster |
The KUBERNETES_* values above are the group-wide defaults applied to every
runner Pod.
Image pull policy
The published runner image uses a mutable tag: a release republishes it in
place. Nothing about that reaches the cluster — runner Pods are created by the
launcher rather than by Helm, so there is no manifest change, no event and no
restart to notice. With KUBERNETES_IMAGE_PULL_POLICY unset the kubelet
defaults to IfNotPresent for any tag but :latest, which never re-resolves
the tag, and each node goes on running whichever digest it cached first —
indefinitely, and differently per node.
So the chart ships Always, and that is the right setting whenever
ephemeralRunner.image.tag names a tag. The pull is a manifest check against
the registry, paid once per run against a run that then executes OpenTofu, and
layers are re-downloaded only when the digest actually moved.
Set IfNotPresent when ephemeralRunner.image names an immutable digest
(repository: ghcr.io/gantrycd/gantrycd-runner@sha256, tag: <hex>), where
the reference cannot go stale and re-checking buys nothing.
To confirm what a Pod actually ran, compare its resolved digest against the registry’s — the tag is no evidence either way:
kubectl -n <runner-namespace> get pod <runner-pod> \
-o jsonpath='{.status.containerStatuses[0].imageID}{"\n"}'
Per-stack overrides
A stack can override the image and resource envelope of its own runs with
reserved gantrycd:runner-spec: labels. They are snapshotted onto the run at
creation, returned in the poll response, and applied by this launcher when it
builds the Pod. Any field left unset falls back to the KUBERNETES_* default.
| Stack label | Overrides |
|---|---|
gantrycd:runner-spec:kubernetes_image | container image |
gantrycd:runner-spec:kubernetes_request_cpu | CPU request |
gantrycd:runner-spec:kubernetes_limit_cpu | CPU limit |
gantrycd:runner-spec:kubernetes_request_memory | memory request |
gantrycd:runner-spec:kubernetes_limit_memory | memory limit |
CPU/memory values must be valid Kubernetes quantities
(e.g. 500m, 2, 512Mi, 4Gi) — invalid values are rejected when the stack
is saved. Request/limit are independent: set only kubernetes_limit_memory to
raise the cap while leaving the request at the group default. Mismatched values
(request > limit) are rejected by Kubernetes when the Pod is created.
Image override is opt-in and gated. A runner Pod runs under this group’s ServiceAccount — frequently bound to IRSA / cloud credentials — so a stack editor choosing the image is a privilege-escalation vector.
kubernetes_imageis honored only when the operator setsKUBERNETES_ALLOW_STACK_IMAGE_OVERRIDE=true(ephemeralRunner.allowStackImageOverridein the chart); otherwise it is ignored and a warning is logged. CPU/memory overrides need no opt-in — they are bounded by your namespaceResourceQuota/LimitRange. If you need a different image by default rather than per-stack, prefer a separate runner group (see overview).
How it behaves
- Each runner’s JWT is stored in a per-run Secret (not a plaintext Pod env
value), so the group’s Role needs
secretscreate/deletealongside itspodsverbs. - Pending Pods that exceed
KUBERNETES_STARTUP_GRACE_PERIODare force-deleted; finished Pods are reaped onREAP_INTERVAL.