CI / extension-version (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
extension / lint (push) Successful in 18s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m42s
CI / integration (push) Successful in 2m11s
Build images / smoke-web (push) Successful in 57s
Build images / promote (push) Skipped
Operator, 2026-09-23: *"why isn't the healthcheck built into the image or
base on what command runs if one is passed in. why is it manually declared in
the stack here."*
No good reason. The container is the only thing that knows what it was asked
to run, and every compose file, stack file and README had to restate it:
web -> urllib /api/health
worker -> celery inspect ping -d celery@$HOSTNAME
all -> both, for every lane
Three checks written by hand, once per service, in every file anyone ever
wrote — none of them wrong until a role changed, and all of them silently
wrong after. The same duplication the lane table exists to remove one level
down, and I built it without noticing.
`entrypoint.sh` now records the role it started. The Dockerfile declares ONE
`HEALTHCHECK` that reads it and asks the right question: HTTP for web, a
self-addressed celery ping for a worker lane, both-for-every-lane for `all`,
and nothing for shell/alembic, which are one-shot and have no liveness to
probe. `docker-compose.single.yml` and the consolidated stack declare none.
A service that wants something else can still declare its own; docker prefers
it, so the escape hatch is the default docker behaviour rather than a flag.
Two details that are load-bearing:
* The role is written ONCE, by the outermost invocation. `all` starts the
other roles through this same script under supervisord, and a child
overwriting the container's role would turn the composite check into a
web-only one — silently, and only on the consolidated path. FC_ROLE is
exported so a child sees it set and skips.
* The celery ping is addressed to THIS node, not a bare ping. A bare one is
answered by any worker on the broker, so in a stack with replicas a dead
container would report healthy for as long as a sibling lived — the check
would be measuring the cluster rather than the container it is inside.
`healthcheck_all.py` is deleted; its two probes moved into the dispatcher
rather than being a second copy beside it.
An unrecorded role PASSES. The entrypoint always writes the file, so the only
way to miss it is bypassing the entrypoint — a debugging shape, where a check
that cannot tell what it is looking at must not assert the thing is broken
(snippet #3969). Said on stdout rather than assumed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
159 lines
6.8 KiB
Bash
Executable File
159 lines
6.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Defaults to the whole application (see the Dockerfile's CMD). Kept in step
|
|
# with that CMD deliberately: they are two doors to the same decision, and a
|
|
# disagreement between them would only show up as `docker run --entrypoint`
|
|
# behaving differently from `docker run`.
|
|
ROLE="${1:-all}"
|
|
|
|
# CELERY NODE NAME. Every celery role below starts with `-n $CELERY_NODENAME@%h`.
|
|
#
|
|
# Celery's default node name is `celery@<hostname>`, and in the single-
|
|
# container layout all four lanes share one hostname — so all four registered
|
|
# as the SAME node. celery's own words for it, observed on run 7319:
|
|
#
|
|
# DuplicateNodenameWarning: Received multiple replies from node name:
|
|
# celery@72adc5b706a7
|
|
#
|
|
# `inspect` then collapses four replies into one dict key and the last one
|
|
# wins, so three lanes read as absent and WHICH three varies per call:
|
|
#
|
|
# lanes not answering: maintenance_long, ml, worker
|
|
# lanes not answering: maintenance_long, scheduler, worker
|
|
#
|
|
# That is fatal twice over. The composite healthcheck can never pass, so the
|
|
# container is permanently unhealthy; and `pool_grow(destination=[hostname])`
|
|
# addresses a lane BY that name, so the UI dial and the autoscaler would have
|
|
# resized whichever lane happened to answer rather than the one asked for.
|
|
#
|
|
# The generated supervisord config sets this per lane. Unset — which is every
|
|
# service in the multi-service stack — it falls back to `celery`, exactly
|
|
# celery's own default, so `celery@$HOSTNAME` healthchecks there still work.
|
|
: "${CELERY_NODENAME:=celery}"
|
|
|
|
# RECORD THE ROLE, for the image's own HEALTHCHECK to read.
|
|
#
|
|
# The container is the only thing that knows what it was asked to run, and
|
|
# before this every compose and stack file had to restate it as a healthcheck
|
|
# of its own. The Dockerfile now declares one check that dispatches on this.
|
|
#
|
|
# Written ONCE, by the outermost invocation. The `all` role starts the other
|
|
# roles through this same script under supervisord, and those children must
|
|
# not overwrite the container's role with their own — a lane starting would
|
|
# turn the composite check into a web-only one, silently. FC_ROLE is exported,
|
|
# so a child sees it set and skips.
|
|
#
|
|
# Best effort: a read-only /tmp is not a reason to refuse to boot. The
|
|
# healthcheck treats a missing file as "nothing to check" rather than as a
|
|
# failure, for the same reason.
|
|
if [ -z "${FC_ROLE:-}" ]; then
|
|
export FC_ROLE="$ROLE"
|
|
printf '%s\n' "$ROLE" > "${FC_ROLE_FILE:-/tmp/fc-role}" 2>/dev/null || true
|
|
fi
|
|
shift || true
|
|
|
|
case "$ROLE" in
|
|
web)
|
|
echo "[entrypoint] Running alembic upgrade head"
|
|
alembic upgrade head
|
|
echo "[entrypoint] Starting hypercorn on :8080"
|
|
# create_app is a factory — the `()` tells hypercorn to call it once
|
|
# and serve the returned Quart (ASGI) app, rather than treating the
|
|
# function itself as the application (which it then mis-invokes as WSGI).
|
|
# Default 4 workers (was 2): each worker is one asyncio loop, and a large
|
|
# file download occupies its worker for the transfer — 2 was too few once the
|
|
# GPU agent + the browser's thumbnail grid hit /images concurrently (they
|
|
# queued behind each other). Env-tunable via HYPERCORN_WORKERS.
|
|
exec hypercorn \
|
|
--bind 0.0.0.0:8080 \
|
|
--workers "${HYPERCORN_WORKERS:-4}" \
|
|
--access-logfile - \
|
|
"backend.app:create_app()"
|
|
;;
|
|
|
|
worker)
|
|
QUEUES="${CELERY_QUEUES:-default,import,thumbnail}"
|
|
CONCURRENCY="${CELERY_CONCURRENCY:-2}"
|
|
echo "[entrypoint] Starting Celery worker queues=$QUEUES concurrency=$CONCURRENCY"
|
|
exec celery -A backend.app.celery_app:celery worker \
|
|
-n "${CELERY_NODENAME:-celery}@%h" \
|
|
--loglevel=info \
|
|
-Q "$QUEUES" \
|
|
--concurrency="$CONCURRENCY"
|
|
;;
|
|
|
|
scheduler)
|
|
QUEUES="${CELERY_QUEUES:-maintenance,scan}"
|
|
# Honours CELERY_CONCURRENCY like the `worker` role does. It was hardcoded
|
|
# to 1, which was harmless while only compose started this lane and set no
|
|
# concurrency for it — but the generated supervisord config (milestone 422
|
|
# step 5) passes one, and a value silently ignored at boot would leave the
|
|
# lane at 1 until the reconcile sweep noticed, with nothing saying why.
|
|
CONCURRENCY="${CELERY_CONCURRENCY:-1}"
|
|
echo "[entrypoint] Starting Celery beat+worker queues=$QUEUES concurrency=$CONCURRENCY"
|
|
exec celery -A backend.app.celery_app:celery worker \
|
|
-n "${CELERY_NODENAME:-celery}@%h" \
|
|
--beat \
|
|
--loglevel=info \
|
|
-Q "$QUEUES" \
|
|
--concurrency="$CONCURRENCY"
|
|
;;
|
|
|
|
ml-worker)
|
|
# NO MODEL DOWNLOAD HERE (milestone 422 step 6). This used to run
|
|
# download_models before celery started, which made every boot of this
|
|
# role reach HuggingFace for ~3.5GB. Rule 164 permits a runtime fetch only
|
|
# for a feature that is "optional and clearly off" — so the fetch moved to
|
|
# the moment the operator ENABLES the lane, where it is visible, retryable
|
|
# and attributable, instead of being a silent precondition of starting.
|
|
#
|
|
# The worker therefore starts with no model present, which is correct: it
|
|
# is not consuming the ml queue until the lane is enabled, and enabling it
|
|
# is what enqueues ensure_models.
|
|
QUEUES="${CELERY_QUEUES:-ml}"
|
|
CONCURRENCY="${CELERY_CONCURRENCY:-1}"
|
|
echo "[entrypoint] Starting ML Celery worker queues=$QUEUES concurrency=$CONCURRENCY"
|
|
exec celery -A backend.app.celery_app:celery worker \
|
|
-n "${CELERY_NODENAME:-celery}@%h" \
|
|
--loglevel=info \
|
|
-Q "$QUEUES" \
|
|
--concurrency="$CONCURRENCY"
|
|
;;
|
|
|
|
all)
|
|
# The single-container layout (milestone 422 step 5): hypercorn plus one
|
|
# celery process per lane, under supervisord, in one container beside
|
|
# Postgres and Redis.
|
|
#
|
|
# The config is GENERATED from services/worker_lanes.LANES rather than
|
|
# checked in, so the processes this container runs and the lanes the
|
|
# application believes in cannot disagree — see the generator's docstring
|
|
# for why a static .conf would have been a fifth copy of the queue names.
|
|
#
|
|
# supervisord is PID 1 here and never reads the database. Every lane boots
|
|
# at its LANES default; the reconcile sweep raises it to whatever the
|
|
# operator stored, within one tick. That ordering is deliberate: settings
|
|
# adjust a baseline that already works, and can never prevent a boot.
|
|
CONF="${SUPERVISOR_CONF:-/tmp/supervisord.conf}"
|
|
echo "[entrypoint] Generating $CONF from the lane table"
|
|
python -m backend.app.scripts.gen_supervisord > "$CONF"
|
|
echo "[entrypoint] Starting supervisord (web + worker lanes)"
|
|
exec supervisord -c "$CONF"
|
|
;;
|
|
|
|
shell|bash)
|
|
exec /bin/bash "$@"
|
|
;;
|
|
|
|
alembic)
|
|
exec alembic "$@"
|
|
;;
|
|
|
|
*)
|
|
echo "[entrypoint] Unknown role: $ROLE" >&2
|
|
echo "[entrypoint] Valid roles: all | web | worker | scheduler | maintenance_long | ml | ml-worker | shell | alembic" >&2
|
|
exit 1
|
|
;;
|
|
esac
|