Speed up Docker rebuilds on Pi 5 with BuildKit cache mounts.

Authorfritjof@alokat.org
Date2026-04-15 13:50:11
Hash70c4f5cd26f8af3818354ea83e7bb742cb99ac82

Summary

M ./.dockerignore -5 +6
M ./Dockerfile -17 +64
M ./README.md +13

Diff

patch 70c4f5cd26f8af3818354ea83e7bb742cb99ac82
Author: fritjof@alokat.org
Date:   Wed Apr 15 13:50:11 UTC 2026
  * Speed up Docker rebuilds on Pi 5 with BuildKit cache mounts.
hunk ./.dockerignore 4
-*.vo
-*.vok
-*.vos
-*.glob
-.*.aux
+**/dist-newstyle/
+**/*.vo
+**/*.vok
+**/*.vos
+**/*.glob
+**/.*.aux
hunk ./Dockerfile 1
+# syntax=docker/dockerfile:1.7
hunk ./Dockerfile 8
+#
+# BuildKit cache mounts are used below so that apt packages, the opam
+# switch (OCaml + Rocq), the stack resolver, and .stack-work survive
+# between builds.  BuildKit is the default builder since Docker 23; older
+# hosts need `DOCKER_BUILDKIT=1 docker build ...`.
hunk ./Dockerfile 16
-# Install Rocq (needed for verified extraction) and build tools
-RUN apt-get update && \
+# TARGETARCH is populated by BuildKit from the target platform and is used
+# below to namespace cache mounts so arm64 and amd64 builds do not share
+# arch-specific binaries (notably the opam switch, which is native code).
+ARG TARGETARCH
+
+# Build parallelism.  Defaults to 2 because the Raspberry Pi 5 hangs under
+# -j4 (GHC linking and the opam Rocq build exhaust RAM on a 4 GB Pi).
+# Raise it on bigger hosts via `--build-arg JOBS=$(nproc)`.
+ARG JOBS=2
+
+# Install Rocq build dependencies.  /var/cache/apt and /var/lib/apt are
+# cache-mounted so repeated builds do not redownload .deb files; the
+# default docker-clean hook is removed so apt keeps its cache.
+RUN --mount=type=cache,id=darcsweb-apt-cache-${TARGETARCH},target=/var/cache/apt,sharing=locked \
+    --mount=type=cache,id=darcsweb-apt-lib-${TARGETARCH},target=/var/lib/apt,sharing=locked \
+    rm -f /etc/apt/apt.conf.d/docker-clean && \
+    apt-get update && \
hunk ./Dockerfile 35
-      libgmp-dev linux-libc-dev pkg-config && \
-    rm -rf /var/lib/apt/lists/*
-
-# Pin OCaml compiler and Rocq versions for reproducible builds
-RUN opam init --disable-sandboxing --bare -y && \
-    opam switch create default ocaml-base-compiler.4.14.2 && \
-    eval $(opam env --switch=default) && \
+      libgmp-dev linux-libc-dev pkg-config
+
+# Pin OCaml + Rocq.  /root/.opam is cache-mounted, so the multi-minute
+# OCaml and Rocq compilation runs once, not on every rebuild.  The mount
+# must be re-declared on every subsequent RUN that invokes `rocq` (the
+# stack build triggers extraction through Setup.hs).  A stale cache can
+# leave the switch half-built; verify the compiler runs with the pinned
+# version and rebuild from scratch on mismatch.
+RUN --mount=type=cache,id=darcsweb-opam-${TARGETARCH},target=/root/.opam,sharing=locked \
+    if [ ! -f /root/.opam/config ]; then \
+      opam init --disable-sandboxing --bare -y; \
+    fi && \
+    if ! opam exec --switch=default -- ocaml -version 2>/dev/null | grep -q '4\.14\.2'; then \
+      opam switch remove default -y 2>/dev/null || true; \
+      opam switch create default ocaml-base-compiler.4.14.2 -j"${JOBS}"; \
+    fi && \
+    eval "$(opam env --switch=default)" && \
hunk ./Dockerfile 54
-    opam install rocq-core.9.0.0 rocq-stdlib.9.0.0 -y
+    opam install rocq-core.9.0.0 rocq-stdlib.9.0.0 -y -j"${JOBS}"
+
+# Make rocq available to later RUN steps that mount /root/.opam.
+ENV PATH=/root/.opam/default/bin:$PATH
hunk ./Dockerfile 61
-# Copy dependency manifests first to cache the resolver/GHC download
+# Copy dependency manifests first so the snapshot/GHC layer is cached
+# independent of source changes.
hunk ./Dockerfile 64
-RUN eval $(opam env --switch=default) && \
+
+# Stack's resolver, GHC tarball, and global package DB live in ~/.stack;
+# cache-mount it so `stack setup` is a no-op on the second build.
+RUN --mount=type=cache,id=darcsweb-stack-${TARGETARCH},target=/root/.stack,sharing=locked \
hunk ./Dockerfile 70
-# Copy sources and build
+# Copy sources and build.  .stack-work is cache-mounted so Haskell
+# modules are only recompiled when their sources actually change.
hunk ./Dockerfile 78
-RUN eval $(opam env --switch=default) && \
-    stack build --install-ghc --no-terminal --copy-bins --local-bin-path /usr/local/bin
+RUN --mount=type=cache,id=darcsweb-opam-${TARGETARCH},target=/root/.opam,sharing=locked \
+    --mount=type=cache,id=darcsweb-stack-${TARGETARCH},target=/root/.stack,sharing=locked \
+    --mount=type=cache,id=darcsweb-stackwork-${TARGETARCH},target=/src/.stack-work,sharing=locked \
+    stack build --no-terminal --copy-bins \
+      --local-bin-path /usr/local/bin \
+      -j"${JOBS}"
hunk ./Dockerfile 88
-RUN apt-get update && \
+ARG TARGETARCH
+
+RUN --mount=type=cache,id=darcsweb-apt-cache-runtime-${TARGETARCH},target=/var/cache/apt,sharing=locked \
+    --mount=type=cache,id=darcsweb-apt-lib-runtime-${TARGETARCH},target=/var/lib/apt,sharing=locked \
+    rm -f /etc/apt/apt.conf.d/docker-clean && \
+    apt-get update && \
hunk ./Dockerfile 95
-    rm -rf /var/lib/apt/lists/* && \
hunk ./README.md 171
+The Dockerfile uses BuildKit cache mounts for apt, the opam switch
+(OCaml + Rocq), and the stack artifacts, so the expensive first build is
+reused on subsequent rebuilds. BuildKit is the default builder in Docker
+23+; on older hosts prefix the command with `DOCKER_BUILDKIT=1`.
+
+Build parallelism defaults to `JOBS=2`, which is what a 4 GB Raspberry
+Pi 5 can sustain without hanging during GHC linking or the Rocq compile.
+On a host with more RAM and cores, raise it:
+
+```
+docker build --build-arg JOBS=$(nproc) -t darcsweb .
+```
+