- Added k3s host and cluster setup docs, including pinned install, module checks, and readiness commands. - Added kata-runtime setup docs with containerd integration, RuntimeClass, and guest-kernel validation steps. - Added ARC ARC/runner-and-cache deployment docs covering helm values, secrets, and pod network policy. - Added reload-runner automation and a preloaded runner image Dockerfile with pinned toolchain and rollout checks.
20 KiB
03 - Preloaded runner image
Each ephemeral CI job boots a fresh Kata microVM and runs inside a single runner container. To keep that cold microVM warm (no per-job dependency fetches), the container does not use the stock GitHub Actions runner image directly: it uses a locally built image that bakes every dependency CI installs on every job into the snapshot. This document covers building that image, importing it into k3s containerd, pointing ARC at it, and verifying it.
Navigation: previous - 02-kata-runtime.md (Kata + containerd + RuntimeClass) | next - 04-arc-and-caching.md (ARC runners, RustFS cache, egress policy).
The image built here is referenced by the ARC runner pod template (
template.spec.containers[0].image+imagePullPolicy: IfNotPresent), documented in 04-arc-and-caching.md. The pinnedruntimeClassName: kata-qemuon that same template comes from 02-kata-runtime.md.
All host commands below run on <CI_HOST> (the single k3s node) as root.
1. Why a preloaded image
The base ghcr.io/actions/actions-runner:latest is a clean Ubuntu 24.04 runner.
On a normal (GitHub-hosted-style) runner, the CI workflow installs its system
dependencies at the start of every job: the cairo/pango native stack for canvas
builds, fd/ripgrep/imagemagick, bun, and a pinned Rust nightly with the
cross targets. Inside a Kata microVM that is destroyed after a single job, paying
that apt/bun/rustup cost on every job is pure latency - the microVM starts
cold each time.
The preloaded image moves that work to build time. Every ephemeral runner then
starts with the toolchain already present: apt deps are not re-fetched, bun and
cargo/rustc are on PATH, and the pinned Rust toolchain is already the
default so target/component installs in CI become no-ops.
Stay in sync with setup-system-deps
The apt set baked into the image must match the repo's
.github/actions/setup-system-deps composite action. That action is the
self-healing counterpart: it probes for the baked tools and skips the apt
round-trip when they are present (preloaded image), but installs the exact same
set on a stock runner so CI still works anywhere. Its detection probes are:
command -v fd && command -v rg && command -v magick \
&& pkg-config --exists cairo pango
If you add a dependency that the action probes for, add it in both the
Dockerfile apt line and setup-system-deps. If they drift, either the action
re-installs deps the image already has (slow) or CI breaks on a tool the image
forgot to bake.
2. The Dockerfile
Build context lives at /root/omp-kata-runner-image/. The Dockerfile below is
reproduced verbatim (it contains no secrets or redactable host identifiers; the
ARG pins, the full apt set, and the toolchain steps are real).
The canonical copy of this Dockerfile is version-controlled at
infra/runner.Dockerfile; the/root/omp-kata-runner-image/copy is overwritten from it by the repo-driven reload script (see section 3 below).
# syntax=docker/dockerfile:1
# Preloaded omp-kata runner image.
#
# Stock GitHub Actions runner (Ubuntu 24.04) with the dependencies CI installs
# on every job baked in, so each ephemeral Kata microVM boots with them already
# present instead of re-fetching them per job:
# - APT system deps (canvas/cairo stack + fd/ripgrep/imagemagick) + fd/magick shims
# - GitHub CLI (gh) — present on GitHub-hosted runners; the coding-agent github
# tool and release workflows expect it
# - C/build toolchain the native + canvas builds need
# - bun (system-wide, on PATH)
# - rust nightly toolchain (pinned) + clippy/rustfmt + linux-arm64/windows-msvc targets
#
# Rebuild + reimport (see /root/omp-kata-runner.md) after bumping the ARGs below
# or the apt set. Keep the apt set in sync with .github/actions/setup-system-deps.
FROM ghcr.io/actions/actions-runner:latest
ARG RUST_NIGHTLY=nightly-2026-04-29
ARG BUN_VERSION=1.3.14
USER root
ENV DEBIAN_FRONTEND=noninteractive
# Mirrors the "Install system deps" block in .github/workflows/ci.yml plus the
# C/build toolchain (native + canvas builds) and the GitHub CLI. The gh apt repo
# is added first so `gh` installs in the same apt transaction.
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y \
build-essential pkg-config curl ca-certificates git unzip xz-utils zstd gh \
libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev \
fd-find ripgrep imagemagick \
&& ln -sf "$(command -v fdfind)" /usr/local/bin/fd \
&& ln -sf /usr/bin/convert /usr/local/bin/magick \
&& rm -rf /var/lib/apt/lists/*
# bun, system-wide (BUN_INSTALL/bin == /usr/local/bin, already on PATH).
ENV BUN_INSTALL=/usr/local
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
&& bun --version
# rust toolchain for the runner user; rustup default == pinned nightly so
# dtolnay/rust-toolchain@nightly and target/component adds are no-ops in CI.
USER runner
ENV RUSTUP_HOME=/home/runner/.rustup \
CARGO_HOME=/home/runner/.cargo \
PATH=/home/runner/.cargo/bin:/usr/local/bin:${PATH}
RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
| sh -s -- -y --default-toolchain "${RUST_NIGHTLY}" --profile minimal \
&& rustup component add clippy rustfmt \
&& rustup target add aarch64-unknown-linux-gnu x86_64-pc-windows-msvc \
&& cargo --version && rustc --version
Stage-by-stage annotation
# syntax=docker/dockerfile:1 + FROM ghcr.io/actions/actions-runner:latest.
BuildKit frontend pin, then the stock Actions runner base (Ubuntu 24.04). The
base already ships the runner agent and its /home/runner/run.sh entrypoint, the
non-root runner user, and sudo. Everything below layers onto that base; the
runner agent itself is never modified.
ARG RUST_NIGHTLY / ARG BUN_VERSION. The two version knobs you bump. They
are build args so you can also override them ad hoc with
docker build --build-arg RUST_NIGHTLY=... --build-arg BUN_VERSION=... without
editing the file. RUST_NIGHTLY must match what the repo's
dtolnay/rust-toolchain@nightly step expects so the toolchain install in CI is a
no-op (see step 4 below).
USER root + ENV DEBIAN_FRONTEND=noninteractive. Switch to root for the
apt and bun system installs; noninteractive suppresses debconf/tzdata prompts
during apt-get install.
The apt RUN block. This is the set that must mirror setup-system-deps.
In order:
- The first three lines add the GitHub CLI apt repository (keyring + signed
source list) before
apt-get update, soghresolves and installs in the same apt transaction as everything else.ghis present on GitHub-hosted runners and is expected by the release workflows and the coding-agentgithubtool. apt-get installpulls three groups:- build toolchain / utilities:
build-essential pkg-config curl ca-certificates git unzip xz-utils zstd gh.build-essential+pkg-configare needed by the native and canvas builds;zstdis the codec the bun and sccache cache tarballs use (see 04-arc-and-caching.md). - canvas / cairo native stack:
libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev- the-devheaders the canvas/rsvg native modules compile against. - CLI tools:
fd-find ripgrep imagemagick, used by the agent and tests.
- build toolchain / utilities:
- The two shims normalize Debian's binary names to what callers expect:
Debian ships
fdasfdfind, soln -sf "$(command -v fdfind)" /usr/local/bin/fdexposes it asfd; ImageMagick installsconvert, soln -sf /usr/bin/convert /usr/local/bin/magickexposes the v7-stylemagickname. These two shims are exactly whatsetup-system-depsrecreates on a stock runner. rm -rf /var/lib/apt/lists/*drops the apt index to keep the layer smaller.
bun (ENV BUN_INSTALL=/usr/local + install RUN). Setting
BUN_INSTALL=/usr/local makes the official installer drop the binary at
/usr/local/bin/bun, which is already on PATH for every user - so bun is
system-wide with no per-user shell init. The version is pinned via
bun-v${BUN_VERSION}, and bun --version fails the build if the install is
broken.
Rust toolchain (USER runner + rustup RUN). The toolchain is installed as
the runner user - the UID jobs execute as - so cargo/rustc are owned by and
visible to the job without sudo. RUSTUP_HOME/CARGO_HOME are pinned under
/home/runner, and ~/.cargo/bin is prepended to PATH. rustup installs the
pinned nightly as the default toolchain (--profile minimal), then adds the
clippy and rustfmt components and the aarch64-unknown-linux-gnu
(Linux arm64) and x86_64-pc-windows-msvc (Windows cross) targets. Because the
default toolchain already is the pinned nightly with these components/targets,
the corresponding rustup steps in CI become no-ops - the warm-start payoff.
cargo --version && rustc --version is the final build-time sanity check.
3. Build, import, and roll out (reload.sh)
/root/omp-kata-runner-image/reload.sh does the whole cycle: build, in-image
smoke test, import into k3s containerd, point ARC at the new tag, and verify the
rollout. It is idempotent and cache-fast on an unchanged rebuild. Reproduced
verbatim (no secrets; the /root and kubeconfig paths are the real host paths):
#!/usr/bin/env bash
# Rebuild the preloaded omp-kata runner image, import it into k3s containerd,
# point the ARC runner scale set at it, and roll it out. Idempotent: safe to
# re-run after editing ./Dockerfile. Docker layer cache makes an unchanged
# rebuild near-instant.
#
# ./reload.sh # build tag omp-kata-runner:YYYY-MM-DD-HHMMSS
# ./reload.sh 2026-06-20 # build tag omp-kata-runner:2026-06-20
# ./reload.sh foo:bar # build an explicit repo:tag
set -euo pipefail
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
cd "$(dirname "$0")"
arg="${1:-$(date +%Y-%m-%d-%H%M%S)}"
case "$arg" in *:*) IMAGE="$arg";; *) IMAGE="omp-kata-runner:$arg";; esac
echo "==> [1/5] building $IMAGE"
DOCKER_BUILDKIT=1 docker build -t "$IMAGE" -t omp-kata-runner:preloaded .
echo "==> [2/5] verifying baked tools"
docker run --rm --entrypoint bash "$IMAGE" -lc '
set -e
for b in gh fd rg magick bun cargo rustc pkg-config zstd; do
command -v "$b" >/dev/null || { echo "MISSING: $b"; exit 1; }
done
echo "tools OK | $(bun --version) | $(rustc --version) | gh $(gh --version | head -1 | cut -d" " -f3)"
'
echo "==> [3/5] importing into k3s containerd (k8s.io namespace)"
docker save "$IMAGE" | k3s ctr -n k8s.io images import --platform linux/amd64 -
echo "==> [4/5] pointing ARC runner scale set at $IMAGE"
sed -i "s#image: omp-kata-runner:.*#image: $IMAGE#" /root/arc-omp-values.yaml
helm upgrade omp-kata --namespace arc-runners --version 0.14.2 \
-f /root/arc-omp-values.yaml \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set >/dev/null
echo "==> [5/5] verifying rollout"
live="$(kubectl get autoscalingrunnerset omp-kata -n arc-runners -o jsonpath='{.spec.template.spec.containers[0].image}')"
echo "ARC runner image is now: $live"
[ "$live" = "$IMAGE" ] && echo "OK: reloaded $IMAGE" || { echo "MISMATCH: expected $IMAGE"; exit 1; }
Run it with no argument for an auto-dated tag:
cd /root/omp-kata-runner-image
./reload.sh
What each step does
Preamble. set -euo pipefail aborts on the first error; KUBECONFIG points
at the k3s admin config; cd into the build context. The tag is resolved from
$1: no arg gives a timestamped omp-kata-runner:YYYY-MM-DD-HHMMSS; an argument
containing a colon (foo:bar) is used as an explicit repo:tag; anything else
is treated as a tag suffix on omp-kata-runner:.
[1/5] build. DOCKER_BUILDKIT=1 docker build tags the result twice: the
immutable $IMAGE (dated) and the moving omp-kata-runner:preloaded alias.
BuildKit + the docker layer cache make an unchanged rebuild near-instant.
[2/5] verify baked tools. Runs the freshly built image with a bash entrypoint
and asserts every expected binary is on PATH
(gh fd rg magick bun cargo rustc pkg-config zstd), failing the whole script if
any is missing, then prints the bun / rustc / gh versions. This catches a broken
apt set, missing shim, or bad toolchain pin before anything touches the
cluster.
[3/5] import into k3s containerd.
docker save "$IMAGE" | k3s ctr -n k8s.io images import --platform linux/amd64 -
streams the image tarball straight from the docker daemon into k3s's own
containerd, in the k8s.io namespace - the namespace the kubelet pulls from.
--platform linux/amd64 matches the host architecture. Note docker save is
given only the dated $IMAGE, so only the dated tag is imported; the
:preloaded alias stays a docker-local convenience and is never imported or
referenced by ARC.
[4/5] point ARC at the new tag. sed -i rewrites the single
image: omp-kata-runner:... line in /root/arc-omp-values.yaml to the new tag,
then helm upgrade re-renders the runner scale set with the chart pinned to
0.14.2. (That values file is the runner pod template, documented in
04-arc-and-caching.md.)
[5/5] verify rollout. Reads the image back off the live
autoscalingrunnerset via kubectl ... jsonpath and asserts it equals $IMAGE,
printing OK or exiting non-zero with MISMATCH. New ephemeral runner pods
created after this point boot from the new image; in-flight jobs finish on the
old one (the scale set is scale-to-zero, so this drains quickly).
Running it from the repo (over SSH)
You do not have to keep reload.sh on the host. The repo ships the version-
controlled Dockerfile plus an SSH-driven wrapper that performs the same build,
import, and rollout remotely, so the whole image lifecycle is managed from a
checkout:
infra/runner.Dockerfile- the image definition (source of truth).infra/reload-runner.sh- copies that Dockerfile to the host, then runs the build / verify / import /helm upgrade/ verify steps over SSH.
The host is never hardcoded; point it at your node with CI_HOST:
CI_HOST=<CI_HOST> ./infra/reload-runner.sh # dated tag
CI_HOST=<CI_HOST> ./infra/reload-runner.sh 2026-06-20 # explicit tag
It honors the same defaults as the host script (remote build dir, ARC values path, release name, namespace, chart version), each overridable via the environment variables documented in the script header.
4. Why import into k3s containerd instead of using a registry
This is a single-node cluster, and the only consumer of the runner image is the kubelet/containerd on that same node. A registry would add a service to run, secure, and authenticate against, for zero benefit. Instead:
docker save | k3s ctr -n k8s.io images importplaces the image directly into the containerd instance k3s schedules from. containerd normalizes the short referenceomp-kata-runner:<tag>todocker.io/library/omp-kata-runner:<tag>in its store (verified: the imported tags appear under that prefix).- The ARC pod template sets
imagePullPolicy: IfNotPresent. Because the image is already present locally, the kubelet uses the local copy and never attempts a pull - no registry, no pull credentials, no registry egress (which the runner egress lockdown in 04-arc-and-caching.md would block anyway).
Trade-off: the image must be (re-)imported on every node that schedules runners. Here that is exactly one node, so a re-roll is simply rebuild + re-import, and the next job's microVM starts cold but with warm dependencies from the local store.
5. Tag conventions
| Tag | Mutability | Imported into containerd? | Referenced by ARC? | Purpose |
|---|---|---|---|---|
omp-kata-runner:YYYY-MM-DD-HHMMSS |
immutable | yes | yes | the build of record; what runners actually boot |
omp-kata-runner:preloaded |
moving | no | no | docker-local alias to the most recent build |
- The default
reload.shtag is timestamped (date +%Y-%m-%d-%H%M%S). You can also pass a date-only tag (./reload.sh 2026-06-20) or an explicitrepo:tag. - ARC always pins the immutable dated tag, never
:preloaded. That keeps a rollout reproducible and makes rollback trivial:sedthe image line back to the prior dated tag (it is still in the local store) andhelm upgrade. - Live example at time of writing: the scale set references
omp-kata-runner:2026-06-15-002621, held in containerd asdocker.io/library/omp-kata-runner:2026-06-15-002621.
6. Bumping bun / Rust / the apt set and re-rolling
-
bun: edit
ARG BUN_VERSION=in the Dockerfile (or pass--build-arg BUN_VERSION=...). -
Rust: edit
ARG RUST_NIGHTLY=to the new pinned nightly. Keep it equal to what the repo'sdtolnay/rust-toolchain@nightlystep resolves, so the CI toolchain install stays a no-op. -
apt set: edit the
apt-get installline. You must mirror the change in.github/actions/setup-system-deps(and, if you add a tool the action probes for, in its detection block - currentlyfd,rg,magick,pkg-config --exists cairo pango). -
Re-roll:
cd /root/omp-kata-runner-image ./reload.shThe rebuild is cache-fast for unchanged layers, the baked-tools check guards the change, and the import/helm-upgrade/verify steps roll the new tag out. The next job's microVM boots warm with the updated toolchain.
7. Verification
Baked-tools check (any tag)
reload.sh step 2 already runs this on every build. To re-check an existing tag
standalone:
docker run --rm --entrypoint bash omp-kata-runner:preloaded -lc '
set -e
for b in gh fd rg magick bun cargo rustc pkg-config zstd; do
command -v "$b" >/dev/null || { echo "MISSING: $b"; exit 1; }
done
echo "tools OK | $(bun --version) | $(rustc --version)"
'
Confirm the live ARC reference and that the tag exists in the k3s image store (both read-only):
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get autoscalingrunnerset omp-kata -n arc-runners \
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
k3s ctr -n k8s.io images ls | grep omp-kata-runner
Kata microVM boot check
The baked-tools check above runs the image under plain docker; it does not
prove the image boots inside a Kata QEMU/KVM microVM. To verify that, launch a
throwaway pod with runtimeClassName: kata-qemu (the RuntimeClass set up in
02-kata-runtime.md) and confirm both the deps and the
guest kernel:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl run preload-verify -n arc-runners --restart=Never \
--image=omp-kata-runner:2026-06-15-002621 \
--overrides='{"spec":{"runtimeClassName":"kata-qemu"}}' \
--command -- bash -lc 'uname -r; bun --version; rustc --version; magick -version | head -1'
kubectl logs preload-verify -n arc-runners
kubectl delete pod preload-verify -n arc-runners
Use the tag that is currently live (or any imported tag). uname -r should show
the Kata guest kernel (a 6.x vmlinux.container build), not the host kernel -
confirming the image really booted in its own microVM - and the bun/rustc/
magick lines confirm the baked toolchain is present inside the VM. This kubectl run is the only step here that creates a cluster object; delete the pod
afterward as shown.
Continue to 04-arc-and-caching.md for how ARC references this image in the runner pod template, wires in the RustFS shared cache, and locks down runner egress.