CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
extension / lint (push) Successful in 19s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m15s
Build images / build-web (push) Successful in 2m52s
Build images / smoke-web (push) Successful in 1m16s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"it is out of the norm to require this init call we need to fix this."* Correct, and it is the same mistake as declaring the healthcheck per service — the image needing a deployment to remember a flag before it behaves correctly. PID 1 carries a duty no other process has: every orphaned process in the container reparents to it and must be reaped or it stays a zombie holding a PID slot. This app makes orphans in normal operation — six service modules shell out (gallery_dl, thumbnailer, backup_service, external_fetch, download_service, download_backends) and celery's prefork pool forks children that spawn them. Whatever the role, something that is not an init ends up as PID 1: supervisord for `all`, hypercorn for `web`, celery for a worker. `init: true` covered that, and cost correctness the moment it was forgotten or silently dropped — an older Swarm, a plain `docker run`, a compose file someone copied. No signal either way. So tini goes in the image and is the ENTRYPOINT. `docker run <image>` is correct on its own now, `init: true` comes out of docker-compose.single.yml, and nothing downstream has to know. The smoke asserts /proc/1/comm is tini, read from /proc because the runtime stage installs no `ps`. ## A correction to what I told the operator I justified `init: true` by saying supervisord "has no idea about orphans it never started". That is very likely wrong: supervisord's reaper calls waitpid(-1) and logs "reaped unknown pid" for children it did not spawn, so it does reap orphans. I asserted the mechanism without checking it, and could not check it here — `supervisor` is not installed in this environment. It does not change this commit. tini is correct whichever way that lands, and it covers the single-role containers too, where celery or hypercorn is PID 1 and the subprocess-spawning is heaviest. But the reason I gave was not a verified one and should not have been stated as fact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
181 lines
8.1 KiB
Docker
181 lines
8.1 KiB
Docker
# syntax=docker/dockerfile:1.25
|
|
|
|
FROM node:24-alpine AS frontend-builder
|
|
WORKDIR /build
|
|
COPY frontend/package.json frontend/package-lock.json* ./
|
|
# No package-lock.json is tracked yet (we don't run npm locally per
|
|
# feedback-no-local-runs), so `npm install` instead of `npm ci`. Flip to
|
|
# `npm ci` once a lockfile is committed.
|
|
RUN npm install --no-audit --no-fund
|
|
COPY frontend/ ./
|
|
RUN npm run build
|
|
|
|
FROM python:3.14-slim AS runtime
|
|
ENV PYTHONUNBUFFERED=1 \
|
|
PYTHONDONTWRITEBYTECODE=1 \
|
|
PIP_NO_CACHE_DIR=1 \
|
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
|
|
# System deps: ffmpeg (transcode + thumbnails, FC-2), unar (archives, FC-2),
|
|
# libpq for psycopg, postgresql-client + zstd for FC-5 backup/restore
|
|
# (pg_dump + tar --zstd), image libs, megatools (mega.nz public-link downloads
|
|
# for off-platform file-host links, #830 — `megatools dl`; Debian-native, no
|
|
# external MEGA apt repo needed).
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
ffmpeg \
|
|
unar \
|
|
libpq5 \
|
|
postgresql-client \
|
|
zstd \
|
|
megatools \
|
|
# PID 1 for every role. See the ENTRYPOINT note at the foot of this file:
|
|
# without it the image needs `init: true` in whatever runs it, which is a
|
|
# deployment remembering a flag for the image to behave correctly.
|
|
tini \
|
|
libjpeg62-turbo \
|
|
libwebp7 \
|
|
libpng16-16 \
|
|
ca-certificates \
|
|
# opencv-python-headless (via requirements-ml.txt) links these even in its
|
|
# headless build. Came from Dockerfile.ml when the images merged
|
|
# (milestone 422 step 6).
|
|
libgl1 \
|
|
libglib2.0-0 \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
WORKDIR /app
|
|
|
|
COPY requirements.txt requirements-ml.txt ./
|
|
RUN pip install -r requirements.txt
|
|
|
|
# --- ML, merged from Dockerfile.ml (milestone 422 step 6) --------------------
|
|
#
|
|
# ONE image now serves every lane. It was two because the ML lane ran in its
|
|
# own container; with the single-container layout (step 5) running every lane
|
|
# in one process tree, a second image would mean the `ml` lane could never be
|
|
# enabled from the UI — there would be no worker in this container to enable.
|
|
#
|
|
# THE COST, MEASURED from run 7273 rather than guessed — and it is far
|
|
# smaller than the estimate this comment first carried, which said "everyone
|
|
# pulls ~4GB":
|
|
#
|
|
# torch 2.12.1+cpu wheel 192.3 MB
|
|
# torchvision 0.27.1+cpu 1.8 MB
|
|
# transformers / onnxruntime / opencv / sklearn and friends
|
|
# 62.0, 35.3, 23.6, 16.7, 12.3, 9.2, 6.9 MB
|
|
# largest newly-pushed layer 222.07 MB
|
|
#
|
|
# So the ML code adds a few hundred MB to the pull, not gigabytes. The CPU
|
|
# index is what makes that true: the default PyPI torch wheel bundles the
|
|
# NVIDIA CUDA runtime and is ~2GB on its own.
|
|
#
|
|
# The GIGABYTES are in the MODEL — ~3.5GB of SigLIP weights — and those are
|
|
# NOT in this image. They arrive only when the operator enables the lane,
|
|
# which is what lets rule 164 permit a runtime fetch at all ("optional and
|
|
# clearly off"). That also settles the trade this step was asked to weigh:
|
|
# baking the weights in would add ~3.5GB to every pull for a feature many
|
|
# adopters never enable, against ~350MB for the code that makes the switch
|
|
# available. Off-by-default wins by an order of magnitude, which was NOT
|
|
# obvious before measuring — the estimate had the two costs within 15% of
|
|
# each other.
|
|
#
|
|
# `--index-url`, not `--extra-index-url`: the latter would let pip resolve a
|
|
# +cu wheel anyway, and the whole saving above depends on it not doing that.
|
|
#
|
|
# CPU-only torch from the PyTorch CPU index. Nothing here uses a GPU — the
|
|
# GPU agent is a separate service with its own image.
|
|
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
|
|
"torch>=2.12,<3.0" "torchvision>=0.27,<0.28"
|
|
RUN pip install -r requirements-ml.txt
|
|
|
|
# Where the model lands. Deliberately NOT a VOLUME instruction: that mints an
|
|
# anonymous volume when nobody mounts one, which survives `docker rm` and
|
|
# accumulates 3.5GB copies nobody can find. The compose files mount it
|
|
# explicitly instead, so an unmounted run simply re-downloads — visible, and
|
|
# recoverable.
|
|
ENV HF_HOME=/models/.huggingface \
|
|
TRANSFORMERS_CACHE=/models/.huggingface \
|
|
ML_MODEL_DIR=/models
|
|
|
|
COPY backend/ ./backend/
|
|
COPY alembic/ ./alembic/
|
|
COPY alembic.ini ./
|
|
COPY entrypoint.sh ./
|
|
RUN chmod +x entrypoint.sh
|
|
|
|
COPY --from=frontend-builder /build/dist ./frontend/dist
|
|
|
|
# Which channel this image belongs to — `dev` or `main` (milestone 271 step 7).
|
|
# build.yml passes it; /api/extension/manifest reports it beside the version so
|
|
# an operator can tell which channel an install came from without the channel
|
|
# ever touching the version string.
|
|
#
|
|
# Empty by default, deliberately: a locally-built image then reports NO channel
|
|
# rather than claiming to be one, and the manifest omits the field entirely —
|
|
# indistinguishable from an image built before the field existed, which is
|
|
# exactly the shape every reader already has to handle.
|
|
#
|
|
# Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and
|
|
# these are the values that differ between builds of otherwise identical
|
|
# source — put them any earlier and the two channels could never share a
|
|
# cached pip install.
|
|
#
|
|
# FC_VERSION is what the instance reports about itself in the UI. Since
|
|
# milestone 318 stopped publishing version image tags, that self-report is
|
|
# the only answer to "which build is this?" — nothing else names it.
|
|
ARG FC_CHANNEL=""
|
|
ENV FC_CHANNEL=${FC_CHANNEL}
|
|
ARG FC_VERSION=""
|
|
ENV FC_VERSION=${FC_VERSION}
|
|
|
|
EXPOSE 8080
|
|
|
|
# ONE healthcheck for every role, because the image knows which role it is
|
|
# running and a deployment should not have to repeat it. `healthcheck` reads
|
|
# the role entrypoint.sh recorded and asks the right question: HTTP for web,
|
|
# a self-addressed celery ping for a worker lane, both-for-every-lane for the
|
|
# consolidated `all`.
|
|
#
|
|
# start-period covers the SLOWEST role, which is `all`: alembic, then
|
|
# hypercorn, then four celery workers registering with the broker. A web-only
|
|
# container is ready long before this; the cost of the shared number is that
|
|
# a broken one takes a little longer to be called broken.
|
|
#
|
|
# A service may still declare its own healthcheck and docker will prefer it —
|
|
# the escape hatch for a deployment that wants something different.
|
|
HEALTHCHECK --interval=30s --timeout=15s --start-period=90s --retries=3 \
|
|
CMD ["python", "-m", "backend.app.scripts.healthcheck"]
|
|
|
|
# tini is PID 1, and the image brings its own rather than asking the
|
|
# deployment for one.
|
|
#
|
|
# PID 1 carries a duty no other process has: every orphaned process in the
|
|
# container reparents to it and must be reaped, or it stays a zombie holding
|
|
# a PID slot. This app makes orphans in normal operation — six service
|
|
# modules shell out (gallery-dl, ffmpeg, pg_dump, the external fetchers) and
|
|
# celery's prefork pool forks children that spawn them.
|
|
#
|
|
# Whatever the role, something that is not an init ends up as PID 1:
|
|
# supervisord for `all`, hypercorn for `web`, celery for a worker. The fix
|
|
# was `init: true` in the compose/stack file, which is out of the norm and
|
|
# put correct process handling in the hands of whoever deploys the image —
|
|
# the same mistake as declaring the healthcheck per service. A flag that is
|
|
# silently dropped (an older Swarm, a `docker run` without it) costs reaping
|
|
# with no signal at all.
|
|
#
|
|
# So the image owns it. `docker run <image>` is correct on its own, and
|
|
# nothing downstream has to know. The smoke asserts /proc/1/comm is tini.
|
|
ENTRYPOINT ["/usr/bin/tini", "--", "./entrypoint.sh"]
|
|
# The DEFAULT is the whole application, not one lane of it.
|
|
#
|
|
# `docker run fabledcurator` with no command starts hypercorn plus every
|
|
# worker lane under supervisord — the shape an adopter wants and the shape the
|
|
# consolidated stack runs. It was `web`, which meant the single-container
|
|
# layout only worked if you knew to ask for it by name, and a compose file
|
|
# that forgot `command:` got a web server with nothing processing its queues:
|
|
# a gallery that loads, accepts an import, and never finishes one.
|
|
#
|
|
# The multi-service stack is unaffected — every service there names its role
|
|
# explicitly, which is exactly what makes it the multi-service stack.
|
|
CMD ["all"]
|