perf(docker): split external deps into a cacheable layer, swap pip for uv

Stage 3's `pip install /tmp/wheels/*.whl` re-resolved and re-downloaded
~50 transitive deps on every build, because the wheel filenames (and
thus the layer hash) changed on every version bump.

Two changes:
  1. Emit /wheels/deps.txt in the build stage — external deps only,
     notify-bridge-* siblings filtered out. The runtime stage installs
     from this file first, so the cache key is the pyproject.toml deps
     (stable across releases) instead of the local wheel filenames
     (changes every release). Registry buildcache now serves the whole
     install layer on version-only bumps.

  2. Swap pip for uv (ghcr.io/astral-sh/uv:0.11.7). uv resolves and
     downloads ~10x faster than pip; combined with a BuildKit cache
     mount on /root/.cache/uv and UV_COMPILE_BYTECODE=1, a cold install
     drops from ~60-90s to a few seconds.

Local wheels are now installed with --no-deps since externals are
already satisfied.
This commit is contained in:
2026-04-23 20:52:36 +03:00
parent bbcdf1c5d1
commit 592e1b6114
+48 -4
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
# =============================================================================
# Stage 1: Build frontend (SvelteKit static output)
# =============================================================================
@@ -14,7 +15,7 @@ COPY frontend/ ./
RUN npm run build
# =============================================================================
# Stage 2: Build Python wheels
# Stage 2: Build Python wheels + extract external dependency list
# =============================================================================
FROM python:3.12-slim AS python-build
@@ -30,16 +31,59 @@ RUN python -m build packages/core/ --wheel --outdir /wheels
COPY packages/server/ packages/server/
RUN python -m build packages/server/ --wheel --outdir /wheels
# Emit /wheels/deps.txt with ONLY external (PyPI) deps — filter out
# notify-bridge-* siblings, which are installed from local wheels below.
# This file is the cache key for the external-deps install layer: as long as
# pyproject.toml dependency lines don't change, the runtime install layer is
# served from registry buildcache and no wheels are re-downloaded.
RUN python <<'PY'
import tomllib
deps: list[str] = []
for p in ("packages/core/pyproject.toml", "packages/server/pyproject.toml"):
with open(p, "rb") as f:
data = tomllib.load(f)
for d in data["project"].get("dependencies", []):
if not d.lstrip().lower().startswith("notify-bridge-"):
deps.append(d)
seen: set[str] = set()
with open("/wheels/deps.txt", "w") as f:
for d in deps:
if d not in seen:
seen.add(d)
f.write(d + "\n")
PY
# =============================================================================
# Stage 3: Runtime
# =============================================================================
FROM ghcr.io/astral-sh/uv:0.11.7 AS uv
FROM python:3.12-slim
# uv — fast pip replacement; pinned version, not :latest
COPY --from=uv /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
# Install wheels
COPY --from=python-build /wheels/ /tmp/wheels/
RUN pip install --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels
# Install external deps first — layer cache key is deps.txt content, which
# only changes when pyproject.toml dependency lines change (not on version
# bumps). The cache mount persists downloaded wheels across local rebuilds;
# in CI, the registry buildcache serves the whole layer when unchanged.
COPY --from=python-build /wheels/deps.txt /tmp/deps.txt
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system -r /tmp/deps.txt \
&& rm /tmp/deps.txt
# Install local wheels without re-resolving — all external deps are present.
COPY --from=python-build /wheels/*.whl /tmp/wheels/
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system --no-deps /tmp/wheels/*.whl \
&& rm -rf /tmp/wheels
# Copy frontend build
COPY --from=frontend-build /build/build/ /app/static/