Files
godot-ci/docs/research/build-caching.md
T
haylan 08b0fedf6f docs(research): investigate Docker build caching options
Ranked recommendation: fix the docker system prune -af --volumes step
first (it deletes BuildKit's build cache before every build, so no
Dockerfile change can help until that stops), then split the
apt-get install layer from the Godot/Blender download layer so the
~470s apt layer becomes cache-eligible across nightly runs. Registry
cache and Gitea Actions' actions/cache don't fit this repo's single
persistent runner / multi-GB nightly-changing layers.

Research only, no code changes.
2026-08-07 00:52:49 +02:00

283 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<N>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 1962), 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 8689, 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 1962): 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=<duration>`
to only remove cache older than a given age, `--keep-storage=<size>` 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