diff --git a/docs/research/act-runner-volume-cleanup.md b/docs/research/act-runner-volume-cleanup.md new file mode 100644 index 0000000..546ffb1 --- /dev/null +++ b/docs/research/act-runner-volume-cleanup.md @@ -0,0 +1,318 @@ +# Why act_runner's Docker volumes accumulate, and how to clean them up + +> **Scope note:** this is about the **host/runner infrastructure** for +> git.arthurerlich.de's Gitea Actions (`act_runner` + the Docker daemon it +> drives, in the docker-compose setup for that Gitea instance) — **not** +> about this repo's own `Dockerfile`/published image. This repo's +> `publish.yml` already runs `docker system prune -af --volumes` before its +> build for the same underlying reason described here (unbounded volume/image +> growth on the CI host), which is what prompted this research. No access to +> the actual Gitea docker-compose file was available or used; every claim +> below is grounded in act_runner's/Gitea's/Docker's own docs and source, or +> flagged as inferred/secondary where it isn't. + +## Recommendations (ranked) + +1. **Schedule `docker system prune -af --volumes` on the runner host on a + recurring basis (cron or a scheduled Gitea Action), the same pattern this + repo's own `publish.yml` already uses before its build.** — **Proven + mechanism, standard mitigation.** No built-in act_runner/Gitea equivalent + exists (see §2); this is the documented, generic Docker-level fix and is + what community guidance for this exact runner-volume-leak class of + problem converges on. Prefer a filtered form if the host also runs + same-day rebuilds you don't want swept up: + `docker system prune -af --volumes --filter "until=24h"` (or + `docker volume prune --filter "until=24h"` for a volumes-only, less + aggressive sweep) — see §2 for the exact filter semantics. +2. **Confirm/enable act_runner's `container.valid_volumes` allowlist and + review `container.options`/`container.network`, but do not expect them to + fix the leak** — **Proven from act_runner's own config reference (§1.2): + these control what containers are *allowed* to mount, not automatic + cleanup after a job ends.** Leaving `valid_volumes` wide open + (`- '**'`) doesn't itself cause more leakage than default, but tightening + it is good hygiene independent of the disk problem. +3. **If DinD (Docker-in-Docker) mode is in use, that is very likely where + the bulk of the leaked volumes come from** — **inferred, but from a + documented, closely-analogous mechanism (§1), not confirmed against this + host's actual config** since no docker-compose file was available. DinD + needs its own persistent Docker data directory (commonly volume-backed), + and any job container that declares a `VOLUME` in its Dockerfile gets an + anonymous volume that Docker does not remove on `docker rm` unless the + container was started with `--rm` — a mechanism GitHub's own + `actions/runner` maintainers confirmed by design for the conceptually + identical GitHub Actions Docker executor (§1.3). act_runner's job/step + containers are created, started, stopped, and removed as separate Docker + API calls (not a single `docker run --rm`), which is the same shape of + gap. +4. **A dedicated Linux user for act_runner (and rootless Docker specifically) + is a security/isolation improvement, not a fix for volume accumulation.** + — **Proven boundary, directly stated in act_runner's own docs (§3).** + Do it for blast-radius/`docker.sock`-exposure reasons; don't expect it to + change disk usage. Running the Docker daemon itself in **rootless mode** + changes *where* volumes live (per-user data root under + `$XDG_RUNTIME_DIR`/`~/.local/share/docker` instead of `/var/lib/docker`) + but Docker's own rootless docs describe no change to prune/cleanup + semantics — unused volumes still only go away via an explicit prune, + run as that user against that daemon. + +**Net: there is no act_runner/Gitea-side switch that stops the leak.** The +fix is operational — a scheduled prune — same shape as the one already +present in this repo's own `publish.yml`. + +--- + +## 1. Why this happens + +### 1.1 act_runner's job execution model + +Per Gitea's own Act Runner documentation, act_runner supports two execution +modes: **Docker container mode** ("it is recommended to run jobs in a +docker container", requiring a running Docker daemon) and **host mode** +(jobs run directly on the machine act_runner is on, no Docker involved). +Docker mode is the default/recommended mode and is what a normal +docker-compose Gitea+act_runner setup uses. +([docs.gitea.com/1.23/usage/actions/act-runner](https://docs.gitea.com/1.23/usage/actions/act-runner/)) + +In Docker mode, act_runner creates a container per job (and per +service-container / step container as declared in the workflow), runs it, +and removes it — as separate Docker API calls, not a single atomic +`docker run --rm ...`. This matters because Docker's own volume-removal +rule is tied to *how* a container is removed, not just that it's gone (see +§1.3). + +### 1.2 act_runner's `container.*` config settings + +From act_runner's own `config.yaml` reference (fetched from +`gitea.com/gitea/act_runner`'s example config, +[gitea.com/gitea/act_runner — config.example.yaml](https://gitea.com/gitea/act_runner/raw/branch/main/internal/pkg/config/config.example.yaml)): + +- **`container.docker_host`** — overrides the Docker daemon act_runner talks + to. Empty (default) auto-detects; `-` auto-detects without mounting the + socket into job containers. Controls *which* daemon runs jobs, not + cleanup. +- **`container.network`** — `host`, `bridge`, or a custom network name; + empty auto-creates a per-job network. Unrelated to volumes. +- **`container.privileged`** — "Privileged mode is required for + Docker-in-Docker." This is the flag that turns on DinD-style job + execution. +- **`container.options`** — free-form extra flags passed to container + creation (e.g. `--add-host=...`, or extra `--volume` mounts). Whatever a + workflow or admin puts here can itself add more bind mounts/volumes per + job, but is opt-in, not a default source of leakage. +- **`container.valid_volumes`** — an **allowlist** (glob syntax) of + volumes/bind mounts job containers are permitted to mount; empty means + none permitted, `'**'` means any. This is an authorization control, not a + lifecycle/cleanup control — it limits what *can* be mounted, and doesn't + cause or prevent leftover anonymous volumes from images that declare + `VOLUME` internally. +- **`container.bind_workdir`** — relevant specifically to DinD: "required + for Docker-in-Docker (DinD) setups when jobs use docker compose with bind + mounts (e.g. `.:/app`), as volume-based workspaces are not accessible + from the DinD daemon's filesystem." Confirms DinD's workspace handling is + a distinct code path from plain Docker mode. +- **`workdir_cleanup_age` / `idle_cleanup_interval`** — these exist, but per + the config reference they clean up **act_runner's own workspace + directories** (stale bind-mounted task-workdir folders on the host + filesystem, and orphaned host-mode scratch dirs) — **not Docker volumes**. + There is no equivalent `container`-level setting for pruning Docker + volumes. + +None of act_runner's documented settings amount to "clean up Docker volumes +after a job." The closest things (`workdir_cleanup_age`, +`idle_cleanup_interval`) are scoped to filesystem workdirs, a different +mechanism from Docker volume objects. + +### 1.3 Is DinD specifically the source of the leak, vs plain Docker mode? + +Two independent, additive causes are documented: + +1. **Any Docker execution mode (plain or DinD): anonymous volumes from + job/service containers leak by Docker's own removal semantics.** This is + not act_runner-specific — it's how Docker containers work. GitHub's own + `actions/runner` maintainers documented the identical failure mode for + GitHub Actions' (conceptually equivalent) Docker executor: *"When using + service containers with a `VOLUME` declaration in their Dockerfile, an + anonymous volume is automatically created with the container. At the end + of the workflow, the container is stopped and removed, but the anonymous + volumes it created stay around... The runner creates, starts, stops and + removes containers with separate commands, so passing `--rm` to `docker + create` has no effect on `docker remove`."* + ([actions/runner#1885](https://github.com/actions/runner/issues/1885) — + maintainer-confirmed root cause, not community speculation). act_runner's + job execution is architecturally the same shape (separate create/start/ + stop/remove calls against the Docker API), so the same leak mechanism + applies to it by design, absent extra config — this is the strongest + available evidence, though it is evidence from GitHub's runner, applied + by analogy to act_runner rather than a direct act_runner maintainer + statement (no act_runner-specific issue making this exact claim was + found in this pass). +2. **DinD adds its own persistent data directory on top.** A Docker-in-Docker + daemon needs its own `/var/lib/docker`-equivalent to store the images/ + layers/volumes *of the jobs it runs*, and community DinD setups + routinely back that directory with a Docker volume so it survives + restarts of the DinD container itself (e.g. "`/var/lib/docker` cannot be + on AUFS, so it needs to be made a volume" — a long-standing operational + note about DinD from general Docker-in-Docker tooling, not Gitea-specific, + flagged here as secondary/community evidence). This is additive to (1): + DinD setups accumulate both the generic per-job anonymous-volume leak + *and* whatever grows inside DinD's own backing store (images, build + cache, and volumes belonging to the containers it spawns), which is a + second, larger accumulation surface than plain Docker mode. + +**Conclusion:** the generic anonymous-volume leak happens in plain Docker +mode too (it's a Docker container-removal semantics issue, not a DinD-only +bug), but DinD mode is very likely the bigger contributor on a real host, +because it adds a whole second Docker data root that grows independently. +Without the actual docker-compose file, it isn't possible to confirm from +this pass alone whether git.arthurerlich.de's act_runner is configured for +DinD (`container.privileged: true` set) or plain Docker mode. + +--- + +## 2. How to clean up + +### 2.1 `docker volume prune` / `docker system prune` semantics (Docker's own docs) + +Per Docker's official pruning reference +([docs.docker.com/engine/manage-resources/pruning](https://docs.docker.com/engine/manage-resources/pruning/), +[docs.docker.com/reference/cli/docker/volume/prune](https://docs.docker.com/reference/cli/docker/volume/prune/), +[docs.docker.com/reference/cli/docker/system/prune](https://docs.docker.com/reference/cli/docker/system/prune/)): + +- **`docker system prune`** (no flags) removes stopped containers, networks + not used by at least one container, dangling images, and unused build + cache. **Volumes are excluded by default.** +- **`docker system prune --volumes`** (or `-a --volumes` to also sweep + non-dangling unused images) additionally removes "all volumes not used by + at least one container." +- **`docker volume prune`** removes unused local volumes; by default this + means **anonymous** volumes not referenced by any container. `--all` + extends this to unused **named** volumes too. +- **`docker volume prune --filter "until="`** narrows the sweep to + volumes older than the given age (still only unused ones) — a way to keep + volumes from a job that finished minutes ago while still cleaning up + older leftovers. + +**Safety on a host mid-job:** all of the above operate only on resources +**not referenced by any container, running or stopped**. A volume mounted +into a currently-running job container is never touched. This makes it safe +to run on a schedule against a live runner host without coordinating with +in-flight jobs — the documented behavior is conservative by construction, +not merely "usually fine in practice." + +### 2.2 Does act_runner or Gitea ship any built-in cleanup for this? + +No. Searched act_runner's config reference, Gitea's Actions/runner +documentation, and the two most relevant issue threads found +([go-gitea/gitea#31457](https://github.com/go-gitea/gitea/issues/31457), +about `act_runner` manageability generally, and +[gitea.com/gitea/runner#167](https://gitea.com/gitea/runner/issues/167) / +[go-gitea/gitea#24438](https://github.com/go-gitea/gitea/issues/24438), +about `docker.sock` exposure) — none describe a built-in Docker volume +cleanup mechanism, cron-style prune config, or maintainer statement +addressing this exact problem. The only cleanup knobs act_runner documents +(`workdir_cleanup_age`, `idle_cleanup_interval`) are scoped to its own +host-filesystem workdirs, not Docker volume objects (§1.2). This absence is +itself informative: there is nothing to "turn on" here — a scheduled +external prune is the only available lever. + +### 2.3 Is a scheduled `docker system prune -af --volumes` the standard approach? + +Yes, and it is corroborated by both this repo's own precedent and general +community guidance: + +- This repo's `publish.yml` already runs `docker system prune -af --volumes` + before its build, for the same underlying class of problem (disk + exhaustion from accumulated Docker state on a CI runner) — direct + precedent already in this codebase. +- General community guidance for self-hosted CI runners converges on the + same shape of fix: a scheduled/cron `docker system prune` (optionally + filtered by age) run on the runner host. This is consistent, secondary + evidence (blog posts / community write-ups, not a Docker or Gitea + standards document) but aligns with what Docker's own docs make safe + (§2.1). + +**A more targeted alternative** — `docker volume prune --filter "until=24h"` +run on a schedule (e.g. nightly cron) — trades some aggressiveness for +narrower scope: it only ever removes volumes, leaves stopped +containers/networks/images alone, and skips anything from the last 24h in +case a long-running or recently-finished job's volume is still wanted for +debugging. This is a reasonable middle ground if a full `system prune -af` +feels too broad for the runner host (e.g. if other non-CI Docker workloads +share the machine); `docker system prune -af --volumes` is simpler and is +what this repo already does elsewhere. + +--- + +## 3. Dedicated user for the runner: security concern, not a cleanup mechanism + +### 3.1 act_runner's own guidance + +Gitea's Act Runner docs (systemd install instructions) recommend creating a +dedicated unprivileged `act_runner` user, and note that adding it to the +`docker` group (needed for Docker mode) **"effectively gives act_runner +root access to the system"** — framed entirely as a privilege/blast-radius +concern. +([docs.gitea.com/1.23/usage/actions/act-runner](https://docs.gitea.com/1.23/usage/actions/act-runner/)) + +This is echoed more sharply in Gitea's own tracker: +[gitea.com/gitea/runner#167](https://gitea.com/gitea/runner/issues/167) / +mirrored as [go-gitea/gitea#24438](https://github.com/go-gitea/gitea/issues/24438), +titled *"Gitea Actions is HIGHLY insecure due to binding of docker.sock into +all containers (= root on host)"* — the concern raised (and act_runner's own +`examples/vm/rootless-docker.md` walkthrough, built specifically in +response to this class of concern) is **container escape / arbitrary root +on the host via `docker.sock` exposure to untrusted job code**, not disk +usage. + +### 3.2 Rootless Docker / user-namespace remapping (Docker's own docs) + +Per Docker's official rootless-mode docs +([docs.docker.com/engine/security/rootless](https://docs.docker.com/engine/security/rootless/)): +rootless mode "lets you run the Docker daemon and containers as a non-root +user to mitigate potential vulnerabilities in the daemon and the container +runtime" — again, a security-isolation feature. The docs describe no +special relationship to volume/disk accumulation or cleanup behavior. +act_runner's own `rootless-docker.md` walkthrough +([gitea.com/gitea/act_runner — examples/vm/rootless-docker.md](https://gitea.com/gitea/act_runner/raw/branch/main/examples/vm/rootless-docker.md)) +is, in its own words, exclusively about security/isolation setup (dedicated +user, rootless daemon, systemd units) and makes no mention of volume +cleanup or storage management at any point. + +Practically, rootless mode does shift *where* Docker's data root (and +therefore its volumes) lives — per-user, typically under +`$XDG_RUNTIME_DIR` / `~/.local/share/docker` rather than the system-wide +`/var/lib/docker` — but this only changes the location being filled up, not +whether it fills up. The same `docker volume prune` semantics (§2.1) still +apply, run as that user against that user's daemon. + +### 3.3 Conclusion + +- **(a) Dedicated Linux user for the act_runner service itself:** purely a + blast-radius/permissions control (who can touch what if the runner or a + job container is compromised). No documented bearing on volume + accumulation. +- **(b) Rootless Docker / userns-remap:** also purely a security-isolation + mechanism per Docker's own docs. It relocates where volumes are stored + (per-user vs system-wide) but does not change prune semantics or provide + any automatic cleanup — the volume-growth problem is orthogonal to this + and is not solved by adopting either (a) or (b). + +Both are worth doing for `docker.sock`-exposure/security reasons +independent of this investigation, but neither should be expected to move +the disk-usage needle — only a scheduled prune (§1 recommendation #1, §2.3) +addresses that. + +--- + +## Sources + +- act_runner config reference — [gitea.com/gitea/act_runner: config.example.yaml](https://gitea.com/gitea/act_runner/raw/branch/main/internal/pkg/config/config.example.yaml) +- Gitea Act Runner docs — [docs.gitea.com/1.23/usage/actions/act-runner](https://docs.gitea.com/1.23/usage/actions/act-runner/) +- act_runner rootless Docker walkthrough — [gitea.com/gitea/act_runner: examples/vm/rootless-docker.md](https://gitea.com/gitea/act_runner/raw/branch/main/examples/vm/rootless-docker.md) +- Gitea Actions `docker.sock` security issue — [gitea.com/gitea/runner#167](https://gitea.com/gitea/runner/issues/167) / mirrored [go-gitea/gitea#24438](https://github.com/go-gitea/gitea/issues/24438) +- act_runner manageability issue — [go-gitea/gitea#31457](https://github.com/go-gitea/gitea/issues/31457) (no maintainer disk/volume-cleanup discussion found in this thread) +- Anonymous volume leak, maintainer-confirmed root cause (GitHub's `actions/runner`, conceptually equivalent Docker executor) — [actions/runner#1885](https://github.com/actions/runner/issues/1885) +- Docker pruning reference — [docs.docker.com/engine/manage-resources/pruning](https://docs.docker.com/engine/manage-resources/pruning/) +- `docker volume prune` reference — [docs.docker.com/reference/cli/docker/volume/prune](https://docs.docker.com/reference/cli/docker/volume/prune/) +- `docker system prune` reference — [docs.docker.com/reference/cli/docker/system/prune](https://docs.docker.com/reference/cli/docker/system/prune/) +- Docker rootless mode — [docs.docker.com/engine/security/rootless](https://docs.docker.com/engine/security/rootless/) diff --git a/docs/research/build-caching.md b/docs/research/build-caching.md new file mode 100644 index 0000000..a25d97d --- /dev/null +++ b/docs/research/build-caching.md @@ -0,0 +1,282 @@ +# Can this repo's Docker build be sped up, and does caching exist for it? + +Research triggered by the real-run timing gap: ~5 min locally vs **~16 min** on +the actual runner +(run [1493](https://git.arthurerlich.de/haylan/godot-ci/actions/runs/1493)), +dominated by the single `apt-get install` layer (~470s, `build-essential`, +`mingw-w64`, X11/audio/udev dev headers) and the Godot export +templates + Blender downloads. + +## Verdict: **yes — but the fix is "stop deleting the cache", not "add a cache"** + +The runner (`runs-on: ubuntu-latest` in +[`.gitea/workflows/publish.yml`](../../.gitea/workflows/publish.yml)) is a +**self-hosted, persistent** [act_runner](https://gitea.com/gitea/act_runner) +host, not an ephemeral GitHub-style VM — the workflow's own "Free up runner +disk" step and its comment only make sense on a persistent box ("*the +runner's disk fills up over successive runs until a push … is what finally +fails*"; an ephemeral runner starts clean every time and would never +accumulate anything to prune). That persistence is exactly what makes plain +Docker build-cache (no registry, no extra tooling) viable here — and it's +also exactly what today's `docker system prune -af --volumes` step throws +away before every single build, per Docker's own docs: `system prune`'s +default removal set explicitly includes *"Unused build cache"*, and `-a` +widens that to "all unused build cache," not just dangling entries +([docs.docker.com/reference/cli/docker/system/prune](https://docs.docker.com/reference/cli/docker/system/prune/)). +So right now, **local BuildKit cache cannot survive from one nightly run to +the next no matter how the Dockerfile is written** — the prune step deletes +it every time, before the build that could have reused it even starts. + +Ranked recommendations: + +1. **Stop nuking the build cache in the prune step.** Replace + `docker system prune -af --volumes` with targeted pruning that skips + builder cache: `docker container prune -f && docker image prune -af && + docker volume prune -f`, plus a separate, bounded + `docker builder prune -f --filter until=24h` (or `--keep-storage=GB`) + to cap build-cache growth without wiping cache that's still same-day + reusable. This alone is what unlocks everything below — no Dockerfile + change helps while the current step runs first. Near-zero risk: it still + frees the disk that motivated the step, just not by deleting reusable + build cache along with it. +2. **Split the `apt-get install` layer from the Godot/Blender download + layer in the Dockerfile**, apt first. Right now both live in one giant + `RUN` chained with `&&` (`Dockerfile` lines 19–62), so a single-byte + change anywhere in that chain — including the Godot/Blender URLs that + *do* change nightly — invalidates the apt install too. BuildKit's cache + is a linear, per-instruction, content-addressed chain: it reuses a layer + only if every instruction up to and including it is byte-identical to a + cached run (Docker's build-cache docs: cache is invalidated "from that + point" the instant one instruction's content changes, + [docs.docker.com/build/cache](https://docs.docker.com/build/cache/)). + The apt package list changes essentially never; `GODOT_VERSION`/ + `BLENDER_URL` change most nights. Splitting them means the ~470s apt + layer becomes a cache hit on every night that doesn't touch the + Dockerfile itself, while the Godot/Blender layer keeps rebuilding (it has + to — new content every time a version bumps). Combined with #1, this is + the actual fix for the dominant cost in the real run. +3. **Add `RUN --mount=type=cache` for apt's own directories** + (`/var/cache/apt`, `/var/lib/apt`, `sharing=locked`) as a smaller + supplement to #2 — it persists apt's downloaded `.deb` cache independent + of the Dockerfile-instruction cache chain, so even a Dockerfile edit that + invalidates the layer doesn't force re-downloading every `.deb` from + Debian's mirrors, only re-running `dpkg` against the still-local cache + ([docs.docker.com/reference/dockerfile/#run---mounttypecache](https://docs.docker.com/reference/dockerfile/#run---mounttypecache)). + Worth doing alongside #2 since it's a few lines, but it's a hedge against + Dockerfile churn, not the main win — #2 already gets full cache hits on + the common case (no Dockerfile change, only build-args change). +4. **Skip registry-based cache (`--cache-to/--cache-from type=registry`).** + Not worth it here. Registry cache exists to share a build cache *across + machines that don't share disk* — ephemeral GitHub-hosted runners, fleets + of self-hosted runners, etc. + ([docs.docker.com/build/cache/backends/registry](https://docs.docker.com/build/cache/backends/registry/)). + This repo has exactly one persistent runner with its own disk, so #1–#3 + already give it that same cache locally, for free, with no push/pull + round-trip. Pushing a *separate* cache image on top would add push/pull + time on every run for a benefit local cache already provides, and it's a + bad fit for this specific image's shape: the Godot/Blender layers are + multi-GB and, by design, different nearly every night (new versions), + so `mode=max` would repeatedly re-push multi-GB "cache" blobs that are + very unlikely to ever be pulled back down. There's also an open question + of whether it would even work: Gitea's container registry only gained + general OCI-artifact support in 1.24 + ([go-gitea/gitea#25846](https://github.com/go-gitea/gitea/issues/25846), + closed via PR #34666) — before that it rejected non-image manifests with + `"unsupported: Schema version is not supported"`. This repo's Gitea + instance version wasn't confirmed as part of this research, so pushing a + buildx cache manifest (which uses custom OCI media types, distinct from + a normal image manifest) is not guaranteed to work without checking that + first. +5. **Gitea Actions' `actions/cache` action is the wrong tool for this, + noted for completeness.** Gitea Actions does support a GitHub-compatible + `actions/cache@v3`/`v4` via a cache server that ships enabled by default + in `act_runner` + ([about.gitea.com tutorial](https://about.gitea.com/resources/tutorials/enable-gitea-actions-cache-to-accelerate-cicd/); + [docs.gitea.com/usage/actions/overview](https://docs.gitea.com/usage/actions/overview/)), + plus a separate `RUNNER_TOOL_CACHE` mechanism. Both are key/value or + directory caches meant for things like `node_modules`/pip wheels between + workflow steps — not a Docker layer-cache mechanism, and not something + `docker build`/`docker push` (what this workflow actually calls, see + below) has any hook into. It doesn't apply to this problem; #1–#3 do. + +## 1. What the workflow and Dockerfile actually do today + +`.gitea/workflows/publish.yml` runs plain `docker build` and `docker push` — +not `docker/build-push-action` or `docker buildx build` with any cache flags +(lines 86–89, 96/105/109). `runs-on: ubuntu-latest` is a Gitea-side runner +*label*, matched against whatever labels this instance's `act_runner` was +registered with — Gitea's own runner docs describe labels as exactly this +kind of local mapping (`ubuntu-latest:docker://...` style entries a runner +operator configures, not a GitHub-hosted VM type) +([search result summary of docs.gitea.com/usage/actions, act_runner label +docs](https://docs.gitea.com/1.23/usage/actions/act-runner/); the specific +`main_runner` name mentioned as a possible identifier for this instance's +runner doesn't appear anywhere in this repo's tracked files — it would only +show up in the Gitea Actions run UI, not the workflow YAML). The disk-fill +problem the "Free up runner disk" step's own comment describes (*"nothing +here ever pruned old ones, so the runner's disk fills up over successive +runs"*) is only possible on a runner whose disk persists between runs — +corroborated independently by act_runner's own architecture: its Docker +executor keeps a long-lived host Docker daemon and reuses the image cache +across job runs by default (act_runner documentation on the docker/dind +executor flavors and idle-cleanup behavior — runner cleans up stale +workspaces on an interval rather than starting from a fresh disk each job). + +The `Dockerfile`'s expensive work is one `RUN` chain (lines 19–62): a single +`apt-get install` covering `build-essential`, `scons`, `mingw-w64`, and the +X11/audio/udev dev headers, immediately followed — in the same layer, via +`&&` — by the `curl`+`unzip` of Godot's editor and export templates and the +`curl`+`tar` of the Blender tarball. Because it's one `RUN`, it's one cache +entry: any change anywhere in it (including the Godot/Blender URLs, which +change on essentially every nightly build since the workflow tracks +newest-stable) invalidates the whole thing, apt install included. + +## 2. Docker/BuildKit's own caching mechanisms (docs.docker.com) + +- **Layer cache (default, local, free)**: BuildKit caches each instruction's + result keyed on that instruction's content plus everything before it in + the Dockerfile; a build on the same machine reuses it automatically with + no extra flags, provided the cache wasn't evicted + ([docs.docker.com/build/cache](https://docs.docker.com/build/cache/)). + This is what #1+#2 above unlock for this repo — it needs nothing but (a) + not deleting it and (b) ordering the Dockerfile so the part that doesn't + change (apt) comes before the part that does (downloads). +- **`RUN --mount=type=cache`**: a *build cache mount*, separate from the + layer cache — a directory that survives across builder invocations + without itself being part of the cached layer, intended for package- + manager caches like apt's `/var/cache/apt`/`/var/lib/apt`. Needs + `sharing=locked` for apt specifically, since apt needs exclusive access to + its own state + ([docs.docker.com/reference/dockerfile/#run---mounttypecache](https://docs.docker.com/reference/dockerfile/#run---mounttypecache)). + Persists even when the layer cache above gets invalidated by a Dockerfile + edit — a smaller, complementary win, not a replacement for #2's layer + split. +- **`--cache-to`/`--cache-from type=registry`**: exports/imports build + cache to/from an OCI registry, as a separate artifact from the final + image, specifically to let machines that don't share local disk share a + cache (`mode=max` caches every stage, not just the final one — bigger + push, more reuse potential) + ([docs.docker.com/build/cache/backends/registry](https://docs.docker.com/build/cache/backends/registry/)). + Not needed for a single persistent runner (see verdict §4). +- **`BUILDKIT_INLINE_CACHE`/`type=inline`**: embeds cache metadata directly + in the pushed image manifest instead of a separate cache artifact — + Docker's own CI guidance is "in most cases you want to use the inline + cache exporter" for simple cases, but it only supports `mode=min` (final + stage only) versus registry cache's `min`/`max` + ([docs.docker.com/build/ci/github-actions/cache](https://docs.docker.com/build/ci/github-actions/cache/)). + Same applicability caveat as registry cache: solves a multi-machine + problem this repo doesn't have. +- Docker Engine ≥23 makes BuildKit (via buildx) the default builder for + plain `docker build` — *"Set Buildx and BuildKit as the default builder on + Linux. Alias `docker build` to `docker buildx build`"* + ([docs.docker.com/engine/release-notes/23.0](https://docs.docker.com/engine/release-notes/23.0/)). + So the workflow's existing plain `docker build` call (no `buildx` in the + command) should already be getting BuildKit's layer cache today — it's + the prune step deleting it that matters, not the build command needing to + change to buildx. + +## 3. `docker system prune -af --volumes` vs `docker builder prune` + +Confirmed directly against Docker's CLI reference: + +- `docker system prune`'s default removal set is stopped containers, unused + networks, dangling images, and **unused build cache**; `-a`/`--all` + widens image removal to *all* unused images (not just dangling), and + `--volumes` additionally removes unattached anonymous volumes + ([docs.docker.com/reference/cli/docker/system/prune](https://docs.docker.com/reference/cli/docker/system/prune/)). + There's no flag on `system prune` to keep build cache while still pruning + everything else — build cache removal isn't optional once you call it. +- `docker builder prune` is the narrower, cache-only equivalent: `--all` to + remove all unused cache (not just dangling), `--filter until=` + to only remove cache older than a given age, `--keep-storage=` to + cap total cache size instead of clearing it outright + ([docs.docker.com/reference/cli/docker/builder/prune](https://docs.docker.com/reference/cli/docker/builder/prune/)). + +This is the concrete lever for recommendation #1: keep freeing the disk the +workflow's own comment says filled up (old images, old anonymous volumes), +but do it with `container prune`/`image prune -af`/`volume prune` instead of +`system prune`, and bound the build cache separately with +`docker builder prune -f --filter until=24h` (or a `--keep-storage` cap) +rather than deleting all of it unconditionally before every build. A same- +day rerun (e.g. `workflow_dispatch` shortly after the nightly cron) would +then still get a cache hit on the apt layer; a multi-day-old apt cache entry +still gets swept on the next age-filtered prune, so disk doesn't grow +unbounded either. + +## 4. Layer-order restructuring compatibility + +Splitting `Dockerfile`'s one `RUN` into "apt install" then "Godot/Blender +downloads" is a pure reorder/split — no semantic change, no new +dependencies, and it's exactly what BuildKit's per-instruction cache keying +is designed to reward (§2). Its payoff is entirely contingent on §3: with +`docker system prune -af --volumes` still running first, splitting the +layer changes nothing, because the reusable apt-layer cache entry is deleted +moments before the build that would've hit it starts. The two +recommendations are a pair, not independent options. + +## 5. Registry cache realism for this specific image + +Weighed directly against this repo's shape: + +- **Storage cost**: `mode=max` registry cache stores every stage as its own + cache layer set in the registry, separate from the pushed image + ([docs.docker.com/build/cache/backends/registry](https://docs.docker.com/build/cache/backends/registry/)) — + for an image whose expensive layers are multi-GB Godot export templates + and a Blender tarball that both change nearly nightly, that's another + near-full copy of those multi-GB layers pushed to Gitea's package registry + on top of the image push that already happens, for cache entries with a + short effective lifetime (superseded the next time Godot/Blender bump). +- **Gitea registry support**: Gitea's container registry is OCI-compliant + for standard image/Helm-chart manifests + ([docs.gitea.com/usage/packages/container](https://docs.gitea.com/usage/packages/container/)), + but general OCI-*artifact* support (the category buildx's cache manifest + falls into, using non-image media types) only landed in Gitea 1.24 via + [go-gitea/gitea#25846](https://github.com/go-gitea/gitea/issues/25846) — + prior to that, pushes of non-standard-image manifests failed with + `501 unsupported: Schema version is not supported`. This repo's actual + Gitea server version wasn't checked as part of this research, so this is + a real "verify before use" gate, not just a style preference. +- **Time saved**: none of it addresses the local-persistent-runner case + this repo actually has — §2/§4's plain layer-cache fix gets the same or + better result (apt-layer cache hits) for free, with no push/pull latency + and no registry storage growth. + +Net: registry cache isn't wrong in general, it's solving a problem (cache +sharing across machines without shared disk) this repo doesn't have, at a +storage and reliability cost this repo's layer shapes make worse than +typical. + +## Sources + +- `Dockerfile` — this repo's build, current single `RUN` chain (apt install + + Godot download/unpack + Blender download/unpack), fetched at HEAD +- `.gitea/workflows/publish.yml` — `runs-on: ubuntu-latest`, plain + `docker build`/`docker push` (no buildx/cache flags), the "Free up runner + disk" step and its comment explaining *why* it prunes before every build +- https://docs.docker.com/build/cache/ — BuildKit layer cache mechanics, + invalidation model +- https://docs.docker.com/reference/dockerfile/#run---mounttypecache — + `RUN --mount=type=cache`, apt cache-mount example, `sharing=locked` +- https://docs.docker.com/build/cache/backends/registry/ — `--cache-to`/ + `--cache-from type=registry`, `mode=min`/`max` +- https://docs.docker.com/build/ci/github-actions/cache/ — + `BUILDKIT_INLINE_CACHE`/`type=inline` vs registry cache tradeoffs +- https://docs.docker.com/engine/release-notes/23.0/ — BuildKit/buildx as + default `docker build` builder since Engine 23.0 +- https://docs.docker.com/reference/cli/docker/system/prune/ — `docker + system prune` default removal set (incl. build cache), `-a`/`--volumes` +- https://docs.docker.com/reference/cli/docker/builder/prune/ — `docker + builder prune`, `--filter until=`, `--keep-storage` +- https://docs.gitea.com/usage/actions/act-runner and + https://docs.gitea.com/1.23/usage/actions/act-runner/ — act_runner + labels, docker/dind executor persistence and idle cleanup behavior +- https://about.gitea.com/resources/tutorials/enable-gitea-actions-cache-to-accelerate-cicd/ + and https://docs.gitea.com/usage/actions/overview/ — Gitea Actions' + `actions/cache` support and default-enabled cache server +- https://docs.gitea.com/usage/packages/container/ — Gitea's OCI-compliant + container registry +- https://github.com/go-gitea/gitea/issues/25846 (closed via PR #34666) — + Gitea container registry's OCI-artifact (non-image-manifest) support + landing in 1.24, and the `501 unsupported: Schema version is not + supported` error prior to that +- https://gitea.com/gitea/act_runner — act_runner project, docker executor