perf(docker): keep the heavy layers cacheable (70s -> 7s rebuilds)
GIT_SHA/BUILD_DATE were stamped into an ENV at the top of the runtime stage, and BUILD_DATE is a per-second timestamp — so that layer differed on every build and invalidated everything below it. The ffmpeg/deno apt install and the full pip install silently re-ran on EVERY build, and every layer got a fresh digest, so docker push re-uploaded the whole image each publish. Moving the version stamps to the end of each stage lets those layers cache and keep their digests. Also: npm ci off the (previously uncopied) lockfile, BuildKit cache mounts for pip/npm so a dep bump only fetches the delta, and COPY --chown instead of a trailing recursive chown. Measured on a one-line backend edit: 70s -> 7s, with ffmpeg/deno/pip/npm all CACHED. Image contents verified identical (appuser uid 1000, /app + /downloads ownership, ffmpeg + deno on PATH).
This commit is contained in:
+33
-23
@@ -1,20 +1,27 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build/version info. APP_VERSION comes from the committed VERSION file (always correct,
|
||||
# even for a plain `docker compose build`); GIT_SHA/BUILD_DATE are best-effort build-args
|
||||
# the deploy scripts pass. An explicit APP_VERSION build-arg still overrides the file.
|
||||
#
|
||||
# GIT_SHA/BUILD_DATE differ on EVERY build: declare them as late as possible in each stage —
|
||||
# anything below them can never cache.
|
||||
ARG APP_VERSION=
|
||||
ARG GIT_SHA=unknown
|
||||
ARG BUILD_DATE=
|
||||
|
||||
# Stage 1: build the frontend SPA
|
||||
FROM node:20-alpine AS frontend
|
||||
WORKDIR /fe
|
||||
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
COPY frontend/ ./
|
||||
COPY VERSION ./.version
|
||||
# Version args land here — immediately before the only step that reads them — so they cannot
|
||||
# invalidate the dependency layers above.
|
||||
ARG APP_VERSION
|
||||
ARG GIT_SHA
|
||||
ARG BUILD_DATE
|
||||
WORKDIR /fe
|
||||
COPY frontend/package.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
COPY VERSION ./.version
|
||||
# Vite inlines VITE_*-prefixed env at build time; version falls back to the VERSION file.
|
||||
RUN VITE_APP_VERSION="${APP_VERSION:-$(cat ./.version)}" \
|
||||
VITE_GIT_SHA="$GIT_SHA" \
|
||||
@@ -23,17 +30,21 @@ RUN VITE_APP_VERSION="${APP_VERSION:-$(cat ./.version)}" \
|
||||
|
||||
# Stage 2: backend runtime, serving the built SPA
|
||||
FROM python:3.12-slim
|
||||
ARG GIT_SHA
|
||||
ARG BUILD_DATE
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
GIT_SHA=$GIT_SHA \
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create the app user BEFORE the copies so files land already-owned via COPY --chown (a trailing
|
||||
# `chown -R /app` would rewrite every file into a duplicate layer). /downloads must be appuser-owned
|
||||
# so a fresh named volume mounted there inherits that ownership (Docker copies the image dir's
|
||||
# ownership into a new empty volume) — the worker/API can then write without a manual chown. A bind
|
||||
# mount to a host path instead needs that path writable by this uid (see docs/self-hosting.md).
|
||||
RUN adduser --disabled-password --gecos "" appuser \
|
||||
&& mkdir -p /downloads \
|
||||
&& chown appuser /app /downloads
|
||||
|
||||
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
|
||||
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
|
||||
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||
@@ -47,21 +58,20 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --upgrade pip && pip install -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
COPY --chown=appuser backend/ .
|
||||
# Read at runtime by config.py as the app_version (a committed single source of truth).
|
||||
COPY VERSION ./VERSION
|
||||
COPY --from=frontend /fe/dist ./app/static_spa
|
||||
COPY --chown=appuser VERSION ./VERSION
|
||||
COPY --chown=appuser --from=frontend /fe/dist ./app/static_spa
|
||||
|
||||
# Version stamps LAST — keep them below everything expensive (see the header note).
|
||||
ARG GIT_SHA
|
||||
ARG BUILD_DATE
|
||||
ENV GIT_SHA=$GIT_SHA \
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
|
||||
# Create the download-center mount point owned by the app user, so a fresh named volume mounted
|
||||
# at /downloads inherits appuser ownership (Docker copies the image dir's ownership into a new
|
||||
# empty volume) — the worker/API can then write there without a manual chown. A bind mount to a
|
||||
# host path instead needs that path writable by this uid (see docs/self-hosting.md).
|
||||
RUN adduser --disabled-password --gecos "" appuser \
|
||||
&& mkdir -p /downloads \
|
||||
&& chown -R appuser /app /downloads
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["sh", "entrypoint.sh"]
|
||||
|
||||
Reference in New Issue
Block a user