Skip to content

Commit f733720

Browse files
kunxian-xiaclaude
andcommitted
ci: add Dockerized GPU self-hosted runner with cron auto-restart
Adds ci/gpu-runner/: a GPU-capable, ephemeral GitHub Actions runner packaged as a Docker image and kept alive by a cron watchdog. - Dockerfile: nvidia/cuda 12.8 devel (ubuntu24.04) + actions runner + Rust toolchain + git-lfs/m4; carries no secrets. Rewrites only the private ceno-gpu-mock dep URL https->ssh (public deps stay on HTTPS). - entrypoint.sh: mints a fresh registration token from a PAT on every start (survives restarts), runs an ephemeral runner. - start-runner.sh: builds/(re)launches with --gpus all and persistent cargo-registry + CARGO_TARGET_DIR volumes for warm incremental builds. - watchdog.sh: per-minute cron check; restarts on container stop or GPU-unreachable. - README.md: host setup, secrets (host PAT + repo deploy key via ssh-agent), and cron registration. The runner-registration PAT lives in ci/gpu-runner/runner.env, which is gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 13c5abf commit f733720

7 files changed

Lines changed: 378 additions & 1 deletion

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,7 @@ docs/book
1515
# ceno serialized files
1616
*.bin
1717
*.json
18-
*.srs
18+
*.srs
19+
20+
# GPU CI runner secrets (never commit the PAT)
21+
ci/gpu-runner/runner.env

ci/gpu-runner/Dockerfile

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# GPU-capable self-hosted GitHub Actions runner for the Ceno zkVM.
2+
#
3+
# Modeled on scroll-tech/ceno-reth-benchmark:ci/Dockerfile. This image is purely
4+
# the runner + build prerequisites — it carries NO secrets. The real GPU backend
5+
# is the PRIVATE scroll-tech/ceno-gpu repo, which Cargo.toml's active [patch]
6+
# pulls in as the local path ../ceno-gpu/cuda_hal (the public ceno-gpu-mock dep
7+
# is just the registry placeholder that the patch overrides). The workflow clones
8+
# ceno-gpu to ../ceno-gpu using a deploy key loaded into ssh-agent from GitHub
9+
# secrets; see ci/gpu-runner/README.md for the snippet.
10+
#
11+
# Build:
12+
# docker build -t ceno-gpu-runner:latest -f ci/gpu-runner/Dockerfile .
13+
#
14+
# Match CUDA to your host driver (`nvidia-smi` top-right = max supported CUDA).
15+
FROM nvidia/cuda:12.8.0-devel-ubuntu24.04
16+
17+
ARG RUNNER_VERSION="2.321.0"
18+
ARG RUNNER_SHA256="ba46ba7ce3a4d7236b16fbe44419fb453bc08f866b24f04d549ec89f1722a29e"
19+
ARG RUST_TOOLCHAIN="nightly-2025-11-20"
20+
ARG DEBIAN_FRONTEND=noninteractive
21+
22+
# OS deps: build toolchain + Ceno CI needs (m4, git-lfs), runner needs (curl/jq),
23+
# and SSH/git so the workflow can clone the private ceno-gpu repo.
24+
# No nvidia-utils here: with `--gpus` the NVIDIA Container Toolkit injects the
25+
# host's nvidia-smi + driver libs at runtime, so pinning a driver userspace in
26+
# the image is redundant and risks mismatching the host driver.
27+
RUN apt-get update \
28+
&& apt-get install -y --no-install-recommends \
29+
curl jq ca-certificates \
30+
build-essential cmake pkg-config libssl-dev libffi-dev \
31+
m4 git git-lfs openssh-client \
32+
sudo tar gzip \
33+
&& git lfs install --system \
34+
&& rm -rf /var/lib/apt/lists/* \
35+
&& useradd -m -s /bin/bash docker \
36+
&& echo "docker ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
37+
38+
# Install the actions runner (checksum-verified, like the reference).
39+
RUN mkdir -p /home/docker/actions-runner \
40+
&& cd /home/docker/actions-runner \
41+
&& curl -fsSL -o runner.tar.gz \
42+
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" \
43+
&& echo "${RUNNER_SHA256} runner.tar.gz" | shasum -a 256 -c \
44+
&& tar xzf runner.tar.gz \
45+
&& rm runner.tar.gz \
46+
&& ./bin/installdependencies.sh \
47+
&& chown -R docker:docker /home/docker \
48+
# Persistent build cache mount point. Creating it owned by `docker` here
49+
# means the (initially empty) named volume mounted at /cache inherits that
50+
# ownership on first use, so cargo can write CARGO_TARGET_DIR=/cache/target.
51+
&& mkdir -p /cache/target && chown -R docker:docker /cache
52+
53+
# All cargo git deps are public (fetched over HTTPS, no auth). The only private
54+
# repo is scroll-tech/ceno-gpu, which the workflow clones to ../ceno-gpu over SSH
55+
# using a deploy key loaded into ssh-agent from GitHub secrets — no key or URL
56+
# rewrite is baked into the image. Pre-seed github.com in known_hosts so that
57+
# clone isn't blocked on host-key prompting.
58+
USER docker
59+
RUN git config --global --add safe.directory '*' \
60+
&& mkdir -p /home/docker/.ssh && chmod 700 /home/docker/.ssh \
61+
&& ssh-keyscan github.com >> /home/docker/.ssh/known_hosts
62+
63+
# Rust toolchain pinned to rust-toolchain.toml + riscv guest target + cargo-make.
64+
ENV PATH="/home/docker/.cargo/bin:${PATH}"
65+
RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
66+
| sh -s -- -y --default-toolchain "${RUST_TOOLCHAIN}" --profile minimal \
67+
&& rustup component add rust-src clippy rustfmt \
68+
&& rustup target add riscv32im-unknown-none-elf \
69+
&& cargo install cargo-make --locked
70+
71+
WORKDIR /home/docker/actions-runner
72+
73+
COPY --chown=docker:docker ci/gpu-runner/entrypoint.sh /usr/local/bin/runner-entrypoint.sh
74+
USER root
75+
RUN chmod +x /usr/local/bin/runner-entrypoint.sh
76+
USER docker
77+
78+
ENTRYPOINT ["/usr/local/bin/runner-entrypoint.sh"]

ci/gpu-runner/README.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Ceno GPU self-hosted CI runner (Docker + cron auto-restart)
2+
3+
A GPU-capable, **ephemeral** GitHub Actions runner for this repo, packaged as a
4+
Docker image and kept alive by a cron watchdog.
5+
6+
- **Ephemeral**: the runner exits cleanly after each job, so no state leaks
7+
between CI runs. The watchdog immediately brings up a fresh one.
8+
- **Self-healing tokens**: the container mints a fresh registration token from
9+
the GitHub API on every start, so restarts never fail on an expired token.
10+
- **Cron watchdog**: restarts the container if it stops *or* if the GPU becomes
11+
unreachable inside it (your stated failure mode).
12+
13+
## One-time host setup
14+
15+
The host must have an NVIDIA driver, Docker, and the **NVIDIA Container Toolkit**
16+
(this is what makes `--gpus all` work):
17+
18+
```sh
19+
# NVIDIA Container Toolkit (Ubuntu) — see NVIDIA docs for the current repo lines
20+
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
21+
| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
22+
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
23+
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
24+
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
25+
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
26+
sudo nvidia-ctk runtime configure --runtime=docker
27+
sudo systemctl restart docker
28+
29+
# sanity check: should print your GPU(s)
30+
docker run --rm --gpus all nvidia/cuda:12.6.2-base-ubuntu24.04 nvidia-smi
31+
```
32+
33+
Check the CUDA version your driver supports (top-right of `nvidia-smi`) and set
34+
`CUDA_VERSION` in the Dockerfile / build arg to match (must be ≤ that).
35+
36+
## Private GPU backend: deploy key comes from GitHub secrets (not the image)
37+
38+
All cargo *git* deps pinned in `Cargo.toml` are **public** (`ceno-gpu-mock`,
39+
`gkr-backend`, `ceno-patch`, `openvm`) and fetch over HTTPS with no auth. The
40+
real GPU backend, however, is the **private `scroll-tech/ceno-gpu`** repo:
41+
Cargo.toml's active `[patch]` redirects `ceno_gpu` away from the public
42+
`ceno-gpu-mock` placeholder to the **local path `../ceno-gpu/cuda_hal`**. So a
43+
`--features gpu` build needs `scroll-tech/ceno-gpu` cloned to `../ceno-gpu`
44+
(a sibling of the checkout).
45+
46+
The runner **image carries no secrets**. The deploy key for `ceno-gpu` is stored
47+
as a repo secret and loaded into an ssh-agent *inside each job*, which then
48+
clones the repo to the path the patch expects.
49+
50+
Setup:
51+
1. Add a **read-only deploy key** to the `scroll-tech/ceno-gpu` repo, and store
52+
the private half as a repo secret, e.g. `CENO_GPU_DEPLOY_KEY`.
53+
2. In any GPU workflow, load it and clone `ceno-gpu` before the build:
54+
55+
```yaml
56+
jobs:
57+
gpu-job:
58+
runs-on: [ self-hosted, Linux, X64, gpu ]
59+
steps:
60+
- uses: webfactory/ssh-agent@v0.9.0
61+
with:
62+
ssh-private-key: ${{ secrets.CENO_GPU_DEPLOY_KEY }}
63+
- uses: actions/checkout@v4
64+
with:
65+
lfs: true
66+
# the active [patch] expects ../ceno-gpu/cuda_hal to exist
67+
- run: git clone git@github.com:scroll-tech/ceno-gpu.git ../ceno-gpu
68+
- run: cargo build --release --features gpu
69+
```
70+
71+
(`webfactory/ssh-agent` also adds github.com to known_hosts for the job.)
72+
73+
## Configure & first run
74+
75+
```sh
76+
cp ci/gpu-runner/runner.env.example ci/gpu-runner/runner.env
77+
$EDITOR ci/gpu-runner/runner.env # paste PAT + repo URL
78+
79+
ci/gpu-runner/start-runner.sh
80+
docker logs -f ceno-gpu-runner # confirm it registered
81+
```
82+
83+
To build manually:
84+
85+
```sh
86+
docker build -t ceno-gpu-runner:latest -f ci/gpu-runner/Dockerfile .
87+
```
88+
89+
In repo **Settings → Actions → Runners** you should now see a runner with the
90+
`gpu` label. Point GPU jobs at it:
91+
92+
```yaml
93+
runs-on: [ self-hosted, Linux, X64, gpu ]
94+
```
95+
96+
(Non-GPU jobs keep using `[ self-hosted, Linux, X64 ]` and won't be scheduled
97+
here because they don't request the `gpu` label.)
98+
99+
## Enable the cron watchdog
100+
101+
```sh
102+
crontab -e
103+
# add (use the ABSOLUTE path):
104+
* * * * * /home/zbx/blockchain/ceno/ci/gpu-runner/watchdog.sh >> /var/log/ceno-gpu-runner.log 2>&1
105+
```
106+
107+
The watchdog checks every minute and restarts on stop or GPU-unreachable.
108+
109+
## Files
110+
111+
| file | purpose |
112+
|-----------------------|----------------------------------------------------------------|
113+
| `Dockerfile` | CUDA-devel image + actions runner + Rust toolchain + git-lfs |
114+
| `entrypoint.sh` | mint token → configure ephemeral runner → run → de-register |
115+
| `start-runner.sh` | (re)launch the container with `--gpus all`; idempotent |
116+
| `watchdog.sh` | cron health check + restart |
117+
| `runner.env.example` | template for `runner.env` (PAT + repo URL; gitignored) |
118+
119+
## Notes & gotchas
120+
121+
- **PAT scope**: repo-level runner needs `repo` (classic) or fine-grained
122+
"Administration: Read and write" on this repo.
123+
- **Warm builds across ephemeral restarts**: two named volumes persist between
124+
containers — `ceno-gpu-runner-cargo` (the cargo registry, so deps aren't
125+
re-downloaded) and `ceno-gpu-runner-target` (mounted at `/cache/target`, with
126+
`CARGO_TARGET_DIR` pointed at it so incremental compile artifacts survive).
127+
This keeps recompiles fast even though each job runs in a fresh container.
128+
Because of this, **GPU jobs can drop the `actions/cache` step for `target/`** —
129+
the host volume is bigger (no ~10 GB cache limit) and faster (no network
130+
restore) than GitHub's cache backend for a workspace this size. The single
131+
ephemeral runner serves one job at a time, so there's no concurrent writer on
132+
the shared target dir. (To reset: `docker volume rm ceno-gpu-runner-target`.)
133+
- **Alternative to cron**: `--restart unless-stopped` in `start-runner.sh`
134+
restarts on crash instantly, but won't catch a hung container or a GPU that
135+
silently went away — that's why the watchdog also probes `nvidia-smi`.
136+
- **Concurrency**: this is a single ephemeral runner = one job at a time. For N
137+
concurrent GPU jobs, run N containers (`CONTAINER_NAME=ceno-gpu-runner-2 …`)
138+
and one watchdog line each.

ci/gpu-runner/entrypoint.sh

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
#!/usr/bin/env bash
2+
# Entrypoint for the Ceno GPU self-hosted runner.
3+
#
4+
# Differs from the ceno-reth-benchmark reference entrypoint in one way: instead
5+
# of taking a hard-coded RUNNER_TOKEN (which GitHub expires after ~1h and would
6+
# make an auto-restart fail), it mints a FRESH registration token from the
7+
# GitHub API on every start using a stored PAT. That is what lets the cron
8+
# watchdog restart this container indefinitely. It also runs an EPHEMERAL runner
9+
# (exits cleanly after one job) so no state leaks between CI runs.
10+
#
11+
# Required env:
12+
# GITHUB_PAT - classic PAT with `repo` scope, or fine-grained token with
13+
# "Administration: read/write" on the repo.
14+
# REPO_URL - e.g. https://github.com/scroll-tech/ceno
15+
# Optional env:
16+
# RUNNER_NAME - defaults to gpu-<short-hostname>
17+
# RUNNER_LABELS- defaults to "self-hosted,Linux,X64,gpu"
18+
set -euo pipefail
19+
20+
: "${GITHUB_PAT:?set GITHUB_PAT}"
21+
: "${REPO_URL:?set REPO_URL, e.g. https://github.com/scroll-tech/ceno}"
22+
23+
RUNNER_NAME="${RUNNER_NAME:-gpu-$(hostname | cut -c1-12)}"
24+
RUNNER_LABELS="${RUNNER_LABELS:-self-hosted,Linux,X64,gpu}"
25+
RUNNER_DIR="/home/docker/actions-runner"
26+
cd "${RUNNER_DIR}"
27+
28+
# REPO_URL -> owner/repo
29+
REPO_PATH="$(echo "${REPO_URL}" | sed -E 's#https?://[^/]+/##; s#\.git$##')"
30+
API="https://api.github.com/repos/${REPO_PATH}/actions/runners"
31+
32+
echo "[entrypoint] requesting registration token for ${REPO_PATH} ..."
33+
REG_TOKEN="$(curl -fsSL -X POST \
34+
-H "Authorization: Bearer ${GITHUB_PAT}" \
35+
-H "Accept: application/vnd.github+json" \
36+
-H "X-GitHub-Api-Version: 2022-11-28" \
37+
"${API}/registration-token" | jq -r .token)"
38+
39+
if [[ -z "${REG_TOKEN}" || "${REG_TOKEN}" == "null" ]]; then
40+
echo "[entrypoint] ERROR: could not obtain registration token (check PAT scope/REPO_URL)" >&2
41+
exit 1
42+
fi
43+
44+
cleanup() {
45+
echo "[entrypoint] de-registering runner ..."
46+
./config.sh remove --token "${REG_TOKEN}" || true
47+
}
48+
trap 'cleanup' EXIT INT TERM
49+
50+
# Fail fast if the GPU isn't actually reachable inside the container — otherwise
51+
# an ephemeral runner would pick up a GPU job and fail it.
52+
if command -v nvidia-smi >/dev/null 2>&1; then
53+
echo "[entrypoint] GPU check:"
54+
nvidia-smi -L || { echo "[entrypoint] ERROR: nvidia-smi failed; is --gpus set + toolkit installed?" >&2; exit 1; }
55+
else
56+
echo "[entrypoint] WARNING: nvidia-smi not found in container" >&2
57+
fi
58+
59+
echo "[entrypoint] configuring ephemeral runner '${RUNNER_NAME}' (labels: ${RUNNER_LABELS}) ..."
60+
./config.sh \
61+
--url "${REPO_URL}" \
62+
--token "${REG_TOKEN}" \
63+
--name "${RUNNER_NAME}" \
64+
--labels "${RUNNER_LABELS}" \
65+
--work _work \
66+
--ephemeral \
67+
--unattended \
68+
--replace
69+
70+
echo "[entrypoint] starting runner ..."
71+
exec ./run.sh

ci/gpu-runner/runner.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copy to `runner.env` and fill in. runner.env is gitignored — never commit the PAT.
2+
#
3+
# PAT needs (repo-level runner):
4+
# - classic PAT: `repo` scope, OR
5+
# - fine-grained PAT: repo selected + "Administration: Read and write"
6+
GITHUB_PAT=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
7+
8+
# The repo this runner serves.
9+
REPO_URL=https://github.com/scroll-tech/ceno
10+
11+
# Optional overrides:
12+
# RUNNER_NAME=gpu-box-1
13+
# RUNNER_LABELS=self-hosted,Linux,X64,gpu

ci/gpu-runner/start-runner.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env bash
2+
# Build (if needed) and (re)start the GPU runner container.
3+
# Idempotent: safe to call from the cron watchdog — it removes any stale
4+
# container of the same name first.
5+
set -euo pipefail
6+
7+
cd "$(dirname "$0")/../.." # repo root = docker build context
8+
9+
RUNNER_DIR="ci/gpu-runner"
10+
CONTAINER_NAME="${CONTAINER_NAME:-ceno-gpu-runner}"
11+
IMAGE_NAME="${IMAGE_NAME:-ceno-gpu-runner:latest}"
12+
ENV_FILE="${ENV_FILE:-$(pwd)/${RUNNER_DIR}/runner.env}"
13+
14+
if [[ ! -f "${ENV_FILE}" ]]; then
15+
echo "ERROR: ${ENV_FILE} not found. Copy runner.env.example -> runner.env and fill it in." >&2
16+
exit 1
17+
fi
18+
19+
# Build the image if it's missing (first run / after a host reboot+prune).
20+
# No secrets at build time — the SSH deploy key is injected per-job from GitHub
21+
# secrets via ssh-agent in the workflow.
22+
if ! docker image inspect "${IMAGE_NAME}" >/dev/null 2>&1; then
23+
echo "[start] building ${IMAGE_NAME} ..."
24+
docker build -t "${IMAGE_NAME}" -f "${RUNNER_DIR}/Dockerfile" .
25+
fi
26+
27+
# Drop any previous instance (exited ephemeral runner, crashed container, etc.).
28+
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
29+
30+
echo "[start] launching ${CONTAINER_NAME} ..."
31+
docker run -d \
32+
--name "${CONTAINER_NAME}" \
33+
--gpus all \
34+
--env-file "${ENV_FILE}" \
35+
--restart no \
36+
-e CARGO_TARGET_DIR=/cache/target \
37+
-v ceno-gpu-runner-cargo:/home/docker/.cargo/registry \
38+
-v ceno-gpu-runner-target:/cache/target \
39+
"${IMAGE_NAME}"
40+
41+
echo "[start] done. logs: docker logs -f ${CONTAINER_NAME}"

ci/gpu-runner/watchdog.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env bash
2+
# Cron watchdog for the Ceno GPU runner.
3+
#
4+
# Run it every minute from cron. It restarts the container if:
5+
# - the container is not in `running` state (crashed, or an ephemeral runner
6+
# finished a job and exited), OR
7+
# - nvidia-smi inside the container fails (GPU became unreachable).
8+
#
9+
# Crontab line (run `crontab -e` as the host user that owns docker access):
10+
# * * * * * /ABS/PATH/ceno/ci/gpu-runner/watchdog.sh >> /var/log/ceno-gpu-runner.log 2>&1
11+
set -uo pipefail
12+
13+
cd "$(dirname "$0")"
14+
CONTAINER_NAME="${CONTAINER_NAME:-ceno-gpu-runner}"
15+
TS() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
16+
17+
state="$(docker inspect -f '{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo "absent")"
18+
19+
if [[ "${state}" != "running" ]]; then
20+
echo "$(TS) watchdog: container state='${state}', restarting"
21+
./start-runner.sh
22+
exit 0
23+
fi
24+
25+
# Container is running — verify the GPU is still actually usable.
26+
if ! docker exec "${CONTAINER_NAME}" nvidia-smi -L >/dev/null 2>&1; then
27+
echo "$(TS) watchdog: GPU unreachable inside container, restarting"
28+
./start-runner.sh
29+
exit 0
30+
fi
31+
32+
# Healthy — stay quiet (no log spam every minute).
33+
exit 0

0 commit comments

Comments
 (0)