From 52c170241989cd461b45f77f9ebea66e5bf7d590 Mon Sep 17 00:00:00 2001 From: Sujeito Operator Date: Sat, 15 Aug 2026 17:32:37 +0200 Subject: [PATCH] docker: mount uv at build time instead of copying it into every image (#1200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What this PR does `uv` stops being copied into the image and starts being mounted into the three `RUN`s that actually use it. The digest pin stays in exactly one place — it moves from the `COPY` to a stage declaration: ```dockerfile FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c... AS uv ``` ```dockerfile RUN --mount=type=cache,target=/root/.cache/uv \ --mount=from=uv,source=/uv,target=/usr/local/bin/uv \ uv sync --locked --no-default-groups ``` A stage consumed only through `--mount=from=` contributes no layer to anything published, so `uv` never lands in `base`. The two `RUN rm -f /usr/bin/uv /usr/bin/uvx` lines then have nothing left to delete and go with it. ### Why The `base` stage copies uv in, and both final stages try to take it back out: ```dockerfile # uv is only needed while building the image. RUN rm -f /usr/bin/uv /usr/bin/uvx ``` That intent is exactly right. **The mechanism can't carry it out**: a `RUN` adds a layer, it does not rewrite the layer underneath. The `COPY` layer is still pushed and still pulled by everyone. What the `rm` produces is a whiteout on top of it. ### Measured, not assumed Read off the published images over the registry API — `linux/amd64`, both built `2026-08-13T17:53Z`, pinned by digest so these numbers stay reproducible after tonight's scheduled rebuild: ``` ghcr.io/calibrain/shelfmark@sha256:9b6041f797cbcc1e5c50ab42bd010a8f747dfaac926080cb6269ddfae99cf820 layer COPY /uv /uvx /bin/ 24.3 MB of 585 MB total 4.1% of the pull layer RUN rm -f /usr/bin/uv /usr/bin/uvx 159 B ghcr.io/calibrain/shelfmark-lite@sha256:2eae503d791cef685e10135aaaff77077cfb4a31d6911e31216704988ce28b02 layer COPY /uv /uvx /bin/ 24.3 MB of 221 MB total 11.0% of the pull ``` The `rm` layer unpacks to exactly four tar entries: ``` usr/ usr/bin/ usr/bin/.wh.uv 0 bytes usr/bin/.wh.uvx 0 bytes ``` Two zero-length overlayfs whiteouts. That is the deletion behaving exactly as specified — and removing nothing at all from what anyone downloads. Same thing without the registry API: ``` $ docker manifest inspect ghcr.io/calibrain/shelfmark-lite:latest ``` and look for the ~24 MB layer; or `docker history` on a local build. ### To be clear about what the `rm` does and doesn't do **It is not useless and I'm not claiming it is.** It removes `uv` from the flattened filesystem, which is what the container sees at runtime and what Trivy/Grype scan by default — so the "don't ship a stale installer" half of the intent is already working today, the same way the `pip` removal above it does. This PR is about the other half: the bytes. After it, `uv` is absent from the filesystem *and* absent from the layers, so nothing regresses. This is image size, not a vulnerability, and I would not have opened it as anything else. ### Why this is safe - **Nothing at runtime can depend on `uv` or `uvx` today**, and that is read off your own artifact rather than argued: both are already whiteouted out of both published images. `uvx` is never invoked anywhere in the repo — `entrypoint.sh` has no `uv` in it, and the Makefile's `uv run` lines are the host-side dev workflow, outside the image. - `/usr/local/bin` is already on `PATH` in `python:3.14.7-slim`, and your `ENV PATH=/app/.venv/bin:$PATH` prepends rather than replaces, so `uv` resolves the same way it does now. - The pin does not move. Same image, same `sha256`, same resolution per target platform as `COPY --from=` does today, so the `linux/amd64` and `linux/arm64` builds each keep getting their own `uv`. - `RUN --mount=` is already used three times in this file, so the frontend in use supports mounts; `from=` is part of the same feature. - Your `docker-build-check` job builds `shelfmark-lite` on every PR, so a build is the cheapest possible review of this change. As a first-time contributor my workflow runs sit at `action_required` until someone approves them — approving is enough to check the whole claim. ### Notes for reviewers - I have **not** built these images locally. There is no Docker daemon on the machine I run on. Every figure above is read from the published images over the registry API, and my own selftest re-reads them live on each run rather than trusting a note. - I left the `pip` removal in `base` alone. It has the same shape, but its stated goal — keeping a stale installer out of what scanners see — is genuinely achieved by the flattened filesystem, and `pip` arrives in the `python:slim` base layer where a Dockerfile change can't reach it anyway. - Written by an automated agent; saying so plainly seemed better than not. Signed-off-by: Sujeito Operator Co-authored-by: CaliBrain --- Dockerfile | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5b051ffd..dd399130 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,11 +24,15 @@ COPY src/frontend/ ./ # Build the frontend RUN npm run build +# uv is a build-time tool only, so it is mounted into the RUNs that need it rather +# than copied into the image. A COPY here would land ~24 MB in a `base` layer that +# every published image inherits, and a later `rm` cannot take it back out again -- +# a RUN adds a layer, it does not rewrite the one underneath. +FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 AS uv + # Use python-slim as the base image FROM python:3.14.7-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 AS base -COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/ - # Add build argument for version ARG BUILD_VERSION ENV BUILD_VERSION=${BUILD_VERSION} @@ -111,6 +115,7 @@ WORKDIR /app # Install core Python dependencies first for better layer caching COPY pyproject.toml uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=from=uv,source=/uv,target=/usr/local/bin/uv \ uv sync --locked --no-default-groups # Runtime dependencies are installed into /app/.venv during the build. Remove the @@ -199,6 +204,7 @@ RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian- # Install the browser automation stack used by the full image RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=from=uv,source=/uv,target=/usr/local/bin/uv \ uv sync --locked --no-default-groups --extra browser # Deterministically resolve the Xlib namespace collision. @@ -212,13 +218,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # and force python-xlib 0.33 to own the namespace. pyautogui runs fine against # 0.33 (superset API). RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=from=uv,source=/uv,target=/usr/local/bin/uv \ uv pip uninstall --python /app/.venv/bin/python python3-xlib && \ uv pip install --python /app/.venv/bin/python --reinstall python-xlib==0.33 && \ /app/.venv/bin/python -c "import Xlib.X; assert hasattr(Xlib.X, 'FamilyServerInterpreted'), 'Xlib.X.FamilyServerInterpreted missing after fix'; print('Xlib namespace OK:', Xlib.__version__)" -# uv is only needed while building the image. -RUN rm -f /usr/bin/uv /usr/bin/uvx - # Keep SeleniumBase's bundled driver cache writable for the fixed non-root user. RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')") && \ chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \ @@ -235,7 +239,4 @@ FROM base AS shelfmark-lite ENV USING_EXTERNAL_BYPASSER=true -# uv is only needed while building the image. -RUN rm -f /usr/bin/uv /usr/bin/uvx - CMD ["/app/entrypoint.sh"]