From ca25448529c51d035aaea2fe25464cf066296597 Mon Sep 17 00:00:00 2001 From: splitsec2 <35583321+splitsec2@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:07:05 -0600 Subject: [PATCH] perf(docker): keep the heavy build layers cacheable across builds - save 11minutes per build (#1379) With the amount of changes and testing I've been doing lately I noticed how long the builds were taking and how much each one pulled, so I went and looked at the Dockerfile. I think this balances cache and efficiency. Three changes needed to be stacked to make it happen. **The version stamp sits above everything expensive.** `ARG BUILD_VERSION` and `ENV BUILD_VERSION` are at the top of the `base` stage and the value carries the commit sha, so it changes on every commit. This invalidates the layer and everything below it, which means the apt install, the dependency sync and the Chromium install. Nothing in the build reads either variable. They're only used at runtime by entrypoint.sh, tor.sh, wireguard.sh and genDebug.sh, so they can move to the end of the final stages. **`COPY . .` sits above the Chromium install.** It's in `base`, and the `shelfmark` stage installs Chromium and the seleniumbase drivers after it, so any source change rebuilds those too. Moving the source copy and the runtime-paths block to the end of each final stage solves that. **There's no cross-run cache.** Runners are ephemeral, so without `cache-from` and `cache-to` every layer is rebuilt on every run whatever the ordering, and a rebuilt layer gets a new digest even when the content is identical. That's why reordering on its own doesn't change the load. Measured with a source-only change between two builds: | | Build | Pull | |---|---|---| | before | 13m 20s | 526.5 MB | | after | 2m 25s | 3.7 MB | 15 of 18 layers get reused where it was 5. The cache sits at 0.86 GB, which leaves room under the 10 GB repo budget for the uv caches in ci.yml and e2e-platform.yml. I tried `mode=max` first and it built a bit quicker at 1m 43s, but it used 5.15 GB of cache and the potential to impact other workflows so it didn't seem worth 40 seconds, but that is a single line fix if you want to. This means the runtime-paths block is now duplicated, once per final stage, and that's most of the diff. It has to sit below each stage's heavy layers to do its job and I couldn't find a way around that short of another shared stage, which looked like more complexity for complexity's sake. Happy to take another run at it if you'd rather have it 'DRY'. I checked the built image against the current one. Same size, it boots, /api/health returns 200, and BUILD_VERSION and RELEASE_VERSION are still stamped correctly. Also, I'll slow down on the PRs. Promise. --- .../build-and-publish-docker-image.yml | 5 + Dockerfile | 94 ++++++++++++++----- 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build-and-publish-docker-image.yml b/.github/workflows/build-and-publish-docker-image.yml index 3d82d8c4..3a8672c1 100644 --- a/.github/workflows/build-and-publish-docker-image.yml +++ b/.github/workflows/build-and-publish-docker-image.yml @@ -76,6 +76,11 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} + # Runners are ephemeral, so without this every layer is rebuilt on every + # run: the pinned Chromium install, the dependency syncs, the driver + # setup. Scoped per target so the two images do not evict each other. + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,scope=${{ matrix.target }} - name: Generate artifact attestation for ${{ matrix.target }} image if: github.event_name != 'pull_request' diff --git a/Dockerfile b/Dockerfile index c606e95e..799e5b1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,12 +33,6 @@ FROM ghcr.io/astral-sh/uv:0.12.16@sha256:adc68cd785ca65ea25c0611043b0a00b4ea3a22 # Use python-slim as the base image FROM python:3.14.7-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 AS base -# Add build argument for version -ARG BUILD_VERSION -ENV BUILD_VERSION=${BUILD_VERSION} -ARG RELEASE_VERSION -ENV RELEASE_VERSION=${RELEASE_VERSION} - # Set shell to bash with pipefail option SHELL ["/bin/bash", "-o", "pipefail", "-c"] @@ -127,26 +121,13 @@ RUN rm -rf \ /usr/local/lib/python*/site-packages/pip \ /usr/local/lib/python*/site-packages/pip-*.dist-info -# Copy application code *after* dependencies are installed -COPY . . - -# Copy built frontend from frontend-builder stage -COPY --from=frontend-builder /frontend/dist /app/frontend-dist - -# Final setup: create image-owned runtime paths for the fixed non-root user. -# Root/PUID mode still re-homes ownership at startup when needed. -RUN mkdir -p \ - /config \ - /books \ - /var/log/shelfmark \ - /tmp/shelfmark/seleniumbase/downloaded_files \ - /tmp/shelfmark/seleniumbase/archived_files && \ - rm -rf /app/downloaded_files /app/archived_files && \ - ln -s /tmp/shelfmark/seleniumbase/downloaded_files /app/downloaded_files && \ - ln -s /tmp/shelfmark/seleniumbase/archived_files /app/archived_files && \ - chown -R 1000:1000 /config /books /home/shelfmark /tmp/shelfmark /var/log/shelfmark && \ - chmod -R a+rX /app && \ - chmod +x /app/entrypoint.sh /app/tor.sh /app/wireguard.sh /app/genDebug.sh +# The application code is deliberately NOT copied here. `base` is shared by the +# final stages, so a COPY of the source at this point invalidates every layer +# built on top of it -- the Chromium install, the browser dependency sync and +# the SeleniumBase driver download -- on any source change. Each stage copies +# the source as its last step instead, so a code-only rebuild rewrites one small +# layer and every expensive layer is reused. `[tool.uv] package = false` is what +# makes this safe: no `uv sync` needs the project source. # Expose the application port EXPOSE ${FLASK_PORT} @@ -237,11 +218,72 @@ RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniu # Grant read/execute permissions to others RUN chmod -R o+rx /usr/bin/chromium +# --- Application code: last, so every expensive layer above stays cached --- + +COPY . . + +COPY --from=frontend-builder /frontend/dist /app/frontend-dist + +# Image-owned runtime paths for the fixed non-root user. Root/PUID mode still +# re-homes ownership at startup when needed. +RUN mkdir -p \ + /config \ + /books \ + /var/log/shelfmark \ + /tmp/shelfmark/seleniumbase/downloaded_files \ + /tmp/shelfmark/seleniumbase/archived_files && \ + rm -rf /app/downloaded_files /app/archived_files && \ + ln -s /tmp/shelfmark/seleniumbase/downloaded_files /app/downloaded_files && \ + ln -s /tmp/shelfmark/seleniumbase/archived_files /app/archived_files && \ + chown -R 1000:1000 /config /books /home/shelfmark /tmp/shelfmark /var/log/shelfmark && \ + chmod -R a+rX /app && \ + chmod +x /app/entrypoint.sh /app/tor.sh /app/wireguard.sh /app/genDebug.sh + # Default command to run the application entrypoint script + +# Version stamp last. These carry the commit sha, so they change on every +# build and everything below them rebuilds. Kept here, the dependency and +# browser layers above stay valid and a pull only fetches what changed. +ARG BUILD_VERSION +ENV BUILD_VERSION=${BUILD_VERSION} +ARG RELEASE_VERSION +ENV RELEASE_VERSION=${RELEASE_VERSION} + CMD ["/app/entrypoint.sh"] + FROM base AS shelfmark-lite ENV USING_EXTERNAL_BYPASSER=true +# --- Application code: last, so every expensive layer above stays cached --- + +COPY . . + +COPY --from=frontend-builder /frontend/dist /app/frontend-dist + +# Image-owned runtime paths for the fixed non-root user. Root/PUID mode still +# re-homes ownership at startup when needed. +RUN mkdir -p \ + /config \ + /books \ + /var/log/shelfmark \ + /tmp/shelfmark/seleniumbase/downloaded_files \ + /tmp/shelfmark/seleniumbase/archived_files && \ + rm -rf /app/downloaded_files /app/archived_files && \ + ln -s /tmp/shelfmark/seleniumbase/downloaded_files /app/downloaded_files && \ + ln -s /tmp/shelfmark/seleniumbase/archived_files /app/archived_files && \ + chown -R 1000:1000 /config /books /home/shelfmark /tmp/shelfmark /var/log/shelfmark && \ + chmod -R a+rX /app && \ + chmod +x /app/entrypoint.sh /app/tor.sh /app/wireguard.sh /app/genDebug.sh + + +# Version stamp last. These carry the commit sha, so they change on every +# build and everything below them rebuilds. Kept here, the dependency and +# browser layers above stay valid and a pull only fetches what changed. +ARG BUILD_VERSION +ENV BUILD_VERSION=${BUILD_VERSION} +ARG RELEASE_VERSION +ENV RELEASE_VERSION=${RELEASE_VERSION} + CMD ["/app/entrypoint.sh"]