From 4c9114a43a79738df19fa8d828152859cd853ffe Mon Sep 17 00:00:00 2001 From: Christian Tabedzki <35670232+tabedzki@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:38:10 -0400 Subject: [PATCH 1/2] fix(docker): fail build (not hang) when atlas download stalls The build-time atlas pre-download hit the GIN server directly and would hang indefinitely when the server stalled mid-transfer, since check_latest=False only skips the version check, not the data download. Bound the download with `timeout` (ATLAS_FETCH_TIMEOUT, default 1200s) so a stalled GIN server fails the build instead of hanging it, and let download failures propagate so we never ship an image without atlases. Extract the fetch logic into fetch_atlases.py, shared by the build and by a startup re-verification in entrypoint.sh (a no-op in the normal case, but re-fetches if a runtime volume mount shadows ~/.brainglobe/). Assisted-by: ClaudeCode:claude-opus-4.8 --- Dockerfile | 17 ++++++++------ entrypoint.sh | 14 +++++++++++ fetch_atlases.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 fetch_atlases.py diff --git a/Dockerfile b/Dockerfile index 610c80f..537c5f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:latest +FROM python:3.14 RUN apt-get update && apt-get install -y \ git \ @@ -30,13 +30,16 @@ RUN --mount=type=cache,target=/root/.cache/uv uv sync --no-dev --frozen # multi-hundred-MB downloads on first use. Stored in ~/.brainglobe/ inside # the image layer; mount a Docker volume there in production to persist any # additional atlases users request across container restarts. -# check_latest=False avoids hanging the build if the GIN server is down. +# +# The GIN server that hosts the atlases is frequently slow or unresponsive. We +# require the atlases to be baked in: `timeout` bounds the download so a stalled +# GIN server fails the build instead of hanging it forever, and any failure +# propagates (the build fails) rather than shipping an image without atlases. +# Bump ATLAS_FETCH_TIMEOUT if downloads legitimately need longer. ENV PATH="/app/.venv/bin:$PATH" -RUN python -c "\ -from brainglobe_atlasapi import BrainGlobeAtlas; \ -BrainGlobeAtlas('allen_mouse_25um', check_latest=False); \ -BrainGlobeAtlas('whs_sd_rat_39um', check_latest=False); \ -" +ENV ATLAS_FETCH_TIMEOUT=1200 +COPY fetch_atlases.py /fetch_atlases.py +RUN timeout "${ATLAS_FETCH_TIMEOUT}" python /fetch_atlases.py # Expose the port ENV INTERNAL_PORT=5008 diff --git a/entrypoint.sh b/entrypoint.sh index 360ef83..dbe6dcb 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,6 +11,20 @@ echo "$(date '+%Y-%m-%d %H:%M:%S') ADDRESS $ADDRESS" echo "$(date '+%Y-%m-%d %H:%M:%S') ALLOW_WEBSOCKET_ORIGIN $ALLOW_WEBSOCKET_ORIGIN" echo "$(date '+%Y-%m-%d %H:%M:%S') NUM_PROCS $NUM_PROCS" +# Verify the required atlases are present before serving. They are normally +# baked into the image at build time, so this is a fast no-op; it only does work +# if a runtime volume mount shadowed ~/.brainglobe/. REQUIRE_ATLASES=1 +# (default) makes a still-missing atlas a hard startup failure; set +# REQUIRE_ATLASES=0 to warn and start anyway (atlases download lazily on use). +REQUIRE_ATLASES=${REQUIRE_ATLASES:-1} +echo "$(date '+%Y-%m-%d %H:%M:%S') verifying atlases ..." +if ! uv run python /fetch_atlases.py; then + if [ "$REQUIRE_ATLASES" = "1" ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: required atlases unavailable; aborting startup" >&2 + exit 1 + fi + echo "$(date '+%Y-%m-%d %H:%M:%S') WARNING: atlases unavailable; continuing (will download lazily)" >&2 +fi # Start the Panel application exec uv run panel serve ./app.py \ diff --git a/fetch_atlases.py b/fetch_atlases.py new file mode 100644 index 0000000..3e591c4 --- /dev/null +++ b/fetch_atlases.py @@ -0,0 +1,61 @@ +"""Ensure the atlases PixelMap needs are present in the local brainglobe cache. + +Used in two places: + +* At Docker build time (under `timeout`), to bake the atlases into the image + layer. The build *requires* this to succeed: a non-zero exit fails the build + so we never ship an image without atlases, and `timeout` turns a stalled GIN + server into a bounded failure instead of an infinite hang. +* At container startup (via entrypoint.sh), as a cheap safety net: it re-verifies + the atlases are present (e.g. in case a runtime volume mount shadowed + ~/.brainglobe/) and re-fetches anything missing before the app boots. + +Because both callers run the same code, the startup check is a no-op in the +normal case — brainglobe stores atlases on disk and we skip any that are already +downloaded. + +Exit codes: + 0 all required atlases are present (downloaded now or already cached) + 1 at least one required atlas is still missing after attempting a download +""" + +from __future__ import annotations + +import sys + +from brainglobe_atlasapi import BrainGlobeAtlas +from brainglobe_atlasapi.list_atlases import get_downloaded_atlases + +# Atlases baked into the image / required for the app to be useful offline. +REQUIRED_ATLASES = ( + "allen_mouse_25um", + "whs_sd_rat_39um", +) + + +def ensure_atlas(name: str) -> bool: + """Make sure `name` is downloaded. Return True if present afterwards.""" + if name in get_downloaded_atlases(): + print(f"atlas already cached: {name}") + return True + try: + # Constructing the atlas triggers the download into ~/.brainglobe/. + # check_latest=False avoids the remote version check hanging the boot. + print(f"downloading atlas: {name} ...") + BrainGlobeAtlas(name, check_latest=False) + except Exception as exc: # network/GIN failure — report, don't crash caller + print(f"failed to download {name}: {exc}", file=sys.stderr) + return name in get_downloaded_atlases() + + +def main() -> int: + missing = [name for name in REQUIRED_ATLASES if not ensure_atlas(name)] + if missing: + print(f"missing atlases after fetch attempt: {', '.join(missing)}", + file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3f8f075735a75665573015f17fd95f56e6082f2b Mon Sep 17 00:00:00 2001 From: Christian Tabedzki <35670232+tabedzki@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:51:22 -0400 Subject: [PATCH 2/2] fix(docker): pass --no-dev to runtime uv run so dev deps aren't re-synced `uv run` syncs the environment with the default dependency groups (including dev) before executing. Since the image is built with `uv sync --no-dev`, the entrypoint's `uv run` calls were re-installing dev-only packages (sphinx, babel, ...) into the container at every startup. Pass --no-dev to match the build so startup neither re-syncs nor pulls dev dependencies. Assisted-by: ClaudeCode:claude-opus-4.8 --- entrypoint.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index dbe6dcb..da9e8ec 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -18,7 +18,7 @@ echo "$(date '+%Y-%m-%d %H:%M:%S') NUM_PROCS $NUM_PROCS" # REQUIRE_ATLASES=0 to warn and start anyway (atlases download lazily on use). REQUIRE_ATLASES=${REQUIRE_ATLASES:-1} echo "$(date '+%Y-%m-%d %H:%M:%S') verifying atlases ..." -if ! uv run python /fetch_atlases.py; then +if ! uv run --no-dev python /fetch_atlases.py; then if [ "$REQUIRE_ATLASES" = "1" ]; then echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: required atlases unavailable; aborting startup" >&2 exit 1 @@ -26,8 +26,10 @@ if ! uv run python /fetch_atlases.py; then echo "$(date '+%Y-%m-%d %H:%M:%S') WARNING: atlases unavailable; continuing (will download lazily)" >&2 fi -# Start the Panel application -exec uv run panel serve ./app.py \ +# Start the Panel application. --no-dev matches the build-time `uv sync +# --no-dev` so `uv run` doesn't re-sync dev dependencies (sphinx, etc.) into the +# environment at container startup. +exec uv run --no-dev panel serve ./app.py \ --address "$ADDRESS" \ --port "$INTERNAL_PORT" \ --allow-websocket-origin "$ALLOW_WEBSOCKET_ORIGIN" \