mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-03 22:07:04 +01:00
docker: mount uv at build time instead of copying it into every image (#1200)
### 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=<image>` 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 <operator@sujeito.org>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
This commit is contained in:
co-authored by
CaliBrain
parent
3e2a7a48d5
commit
52c1702419
+9
-8
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user