Skip to content

Commit baa9a05

Browse files
bk86aclaude
andcommitted
feat: persistent-volume support via entrypoint chown + root info route
Two related changes that both ship in the container image: 1. docker-entrypoint.sh + Dockerfile: when /app/data is a freshly mounted persistent volume the platform hands the container a root-owned directory, which broke load_data() because the container was running as appuser. The new entrypoint runs as root just long enough to chown /app/data, then exec-drops to appuser via gosu before running the original uvicorn CMD. The chown is idempotent (no-op on warm starts where the directory is already correctly owned). Verified locally with a fresh root-owned docker volume. 2. New "/" route returning service metadata and useful pointers (openapi.json, docs, redoc, health, example lookup/pattern URLs, source repo). Replaces the previous {"detail":"Not Found"} on the bare hostname. Marked include_in_schema=False so it doesn't clutter the OpenAPI document. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0829cf6 commit baa9a05

4 files changed

Lines changed: 48 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **`/` root endpoint** returns service metadata and pointers to `/openapi.json`, `/docs`, `/redoc`, `/health`, and example `/lookup` and `/pattern` URLs. Replaces the previous `{"detail":"Not Found"}` response on the bare hostname. Marked `include_in_schema=False` so it doesn't clutter the OpenAPI document.
12+
- **Persistent-volume support** via a new `docker-entrypoint.sh`: container starts as root, `chown appuser:appuser /app/data` (idempotent — no-op on warm starts), then `exec gosu appuser "$@"` to drop privileges before uvicorn starts. `Dockerfile` installs `gosu` and replaces `USER appuser` with `ENTRYPOINT`. Lets a freshly-provisioned platform persistent volume (initially root-owned) be mounted at `/app/data` without breaking the SQLite cache build. Cold-start cache survives pod recreates and redeploys; subsequent restarts skip the GISCO TERCET re-download until the configured TTL expires.
13+
914
### Documentation
1015

1116
- **Performance re-baseline under multi-worker** (#68): `docs/performance.md` updated with the post-#68 numbers and a new rate-limit shared-storage verification subsection. Realistic-corpus knee at 35-40 RPS (vs ~30 single-worker), hot-key plateau at ~50 RPS, p99 at the old knee dropped from 4.5 s to 150 ms. Recommended operating point unchanged at 27 RPS — the win is headroom, not the operating point itself. The Redis sidecar shared-storage path is verified end-to-end: 130 anonymous requests against the published `120/minute` cap produced exactly 120 × `200` + 10 × `429`, ruling out per-worker counter divergence.

Dockerfile

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ FROM python:3.14-slim
22

33
WORKDIR /app
44

5+
# gosu is used by docker-entrypoint.sh to drop privileges to appuser after
6+
# fixing ownership on the /app/data mount (no-op on warm starts; required
7+
# when the platform mounts a fresh root-owned persistent volume).
8+
RUN apt-get update \
9+
&& apt-get install -y --no-install-recommends gosu \
10+
&& rm -rf /var/lib/apt/lists/*
11+
512
COPY requirements.lock ./requirements.lock
613
RUN pip install --no-cache-dir -r requirements.lock
714

@@ -11,14 +18,15 @@ RUN useradd -r -s /bin/false appuser \
1118

1219
COPY app/ ./app/
1320
COPY tercet_missing_codes.csv ./tercet_missing_codes.csv
21+
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
22+
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
1423

1524
VOLUME ["/app/data"]
1625

1726
EXPOSE 8000
1827

19-
USER appuser
20-
2128
HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
2229
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
2330

31+
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
2432
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${PC2NUTS_WORKERS:-1}"]

app/main.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,29 @@ def get_pattern(
290290
)
291291

292292

293+
@app.get(
294+
"/",
295+
summary="Service entry point",
296+
include_in_schema=False,
297+
)
298+
def root(request: Request, response: Response):
299+
response.headers["Cache-Control"] = f"public, max-age={settings.cache_max_age}"
300+
base = str(request.base_url).rstrip("/")
301+
return {
302+
"service": "PostalCode2NUTS",
303+
"version": __version__,
304+
"links": {
305+
"openapi": f"{base}/openapi.json",
306+
"docs": f"{base}/docs" if settings.docs_enabled else None,
307+
"redoc": f"{base}/redoc" if settings.docs_enabled else None,
308+
"health": f"{base}/health",
309+
"lookup_example": f"{base}/lookup?country=DE&postal_code=10115",
310+
"pattern_example": f"{base}/pattern?country=DE",
311+
"source": "https://github.com/bk86a/PostalCode2NUTS",
312+
},
313+
}
314+
315+
293316
@app.get(
294317
"/health",
295318
response_model=HealthResponse,

docker-entrypoint.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/bin/sh
2+
# Drop privileges to appuser before running the CMD.
3+
#
4+
# We start the container as root so a freshly-mounted persistent volume at
5+
# /app/data (initially root-owned by the platform) can be chowned to appuser.
6+
# The chown is idempotent: a no-op on warm starts where the directory is
7+
# already correctly owned.
8+
set -e
9+
chown appuser:appuser /app/data
10+
exec gosu appuser "$@"

0 commit comments

Comments
 (0)