Skip to content

Commit bff7626

Browse files
refactor(pii): ship both engines in one image — engine is a pure env flip
Collapse the gliner build target into the single pii image: spaCy lg models, torch (CPU), gliner, and the baked GLiNER weights all ship in it, so PII_ENGINE switches engines with no image swap and no tag matrix. CI reverts to the single pii build (no -gliner tags). The GPU variant becomes the same Dockerfile built with --build-arg TORCH_INDEX_URL=.../cu128. Image grows ~6.1GB -> ~9.6GB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Up3F97mjCH9HCj1pX4J8VJ
1 parent 3309d18 commit bff7626

9 files changed

Lines changed: 65 additions & 132 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,6 @@ jobs:
9090
ecr_repo_secret: ECR_REALTIME
9191
- dockerfile: ./docker/pii.Dockerfile
9292
ecr_repo_secret: ECR_PII
93-
# Opt-in GLiNER variant of the pii image (torch + gliner + baked
94-
# model). Deployed by setting PII_ENGINE=gliner on the pii service.
95-
- dockerfile: ./docker/pii.Dockerfile
96-
ecr_repo_secret: ECR_PII
97-
target: gliner
98-
tag_suffix: -gliner
9993
steps:
10094
- name: Checkout code
10195
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -132,8 +126,7 @@ jobs:
132126
file: ${{ matrix.dockerfile }}
133127
platforms: linux/amd64
134128
push: true
135-
tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev${{ matrix.tag_suffix || '' }}
136-
target: ${{ matrix.target || '' }}
129+
tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev
137130
provenance: false
138131
sbom: false
139132

@@ -208,13 +201,6 @@ jobs:
208201
- dockerfile: ./docker/pii.Dockerfile
209202
ghcr_image: ghcr.io/simstudioai/pii
210203
ecr_repo_secret: ECR_PII
211-
# Opt-in GLiNER variant of the pii image (torch + gliner + baked
212-
# model). amd64 only — the deploy fleet is amd64; no arm64/manifest.
213-
- dockerfile: ./docker/pii.Dockerfile
214-
ghcr_image: ghcr.io/simstudioai/pii
215-
ecr_repo_secret: ECR_PII
216-
target: gliner
217-
tag_suffix: -gliner
218204
steps:
219205
- name: Checkout code
220206
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -258,35 +244,26 @@ jobs:
258244
ECR_REGISTRY="${{ steps.login-ecr.outputs.registry }}"
259245
ECR_REPO="${{ steps.ecr-repo.outputs.name }}"
260246
GHCR_IMAGE="${{ matrix.ghcr_image }}"
261-
TAG_SUFFIX="${{ matrix.tag_suffix }}"
262-
263-
# Suffixed variants (e.g. pii -gliner) are amd64-only and skip the
264-
# manifest merge, so their GHCR tags are plain — no -amd64 alias.
265-
if [ -n "$TAG_SUFFIX" ]; then
266-
ARCH_SUFFIX=""
267-
else
268-
ARCH_SUFFIX="-amd64"
269-
fi
270247
271248
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
272-
ECR_TAG="latest${TAG_SUFFIX}"
249+
ECR_TAG="latest"
273250
else
274-
ECR_TAG="staging${TAG_SUFFIX}"
251+
ECR_TAG="staging"
275252
fi
276253
ECR_IMAGE="${ECR_REGISTRY}/${ECR_REPO}:${ECR_TAG}"
277254
278255
TAGS="${ECR_IMAGE}"
279256
280257
if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then
281-
GHCR_AMD64="${GHCR_IMAGE}:latest${TAG_SUFFIX}${ARCH_SUFFIX}"
282-
GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}${TAG_SUFFIX}${ARCH_SUFFIX}"
258+
GHCR_AMD64="${GHCR_IMAGE}:latest-amd64"
259+
GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}-amd64"
283260
TAGS="${TAGS},$GHCR_AMD64,$GHCR_SHA"
284261
285262
if [ "${{ needs.detect-version.outputs.is_release }}" = "true" ]; then
286263
VERSION="${{ needs.detect-version.outputs.version }}"
287-
GHCR_VERSION="${GHCR_IMAGE}:${VERSION}${TAG_SUFFIX}${ARCH_SUFFIX}"
264+
GHCR_VERSION="${GHCR_IMAGE}:${VERSION}-amd64"
288265
TAGS="${TAGS},$GHCR_VERSION"
289-
echo "📦 Adding version tag: ${VERSION}${TAG_SUFFIX}${ARCH_SUFFIX}"
266+
echo "📦 Adding version tag: ${VERSION}-amd64"
290267
fi
291268
fi
292269
@@ -300,7 +277,6 @@ jobs:
300277
platforms: linux/amd64
301278
push: true
302279
tags: ${{ steps.meta.outputs.tags }}
303-
target: ${{ matrix.target || '' }}
304280
provenance: false
305281
sbom: false
306282

apps/pii/engines.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,15 @@ def build_gliner_analyzer(model_name: str, device: str | None) -> AnalyzerEngine
225225
:param device: torch device ("cpu", "cuda", "cuda:0"); None auto-detects
226226
via Presidio's device_detector (cuda when available, else cpu).
227227
"""
228-
# Fail fast with an actionable message on the lean image. Without these
229-
# checks Presidio would try to pip-download the missing spaCy models at
230-
# startup (a silent network fallback that dies with an unrelated pip
231-
# permission error), and the gliner ImportError would surface only later.
228+
# Fail fast with an actionable message when gliner deps are missing (e.g.
229+
# a custom-built image without them). Without these checks Presidio would
230+
# try to pip-download the missing spaCy models at startup (a silent
231+
# network fallback that dies with an unrelated pip permission error), and
232+
# the gliner ImportError would surface only later.
232233
if importlib.util.find_spec("gliner") is None:
233234
raise RuntimeError(
234-
"PII_ENGINE=gliner requires the gliner image variant "
235-
"(docker build --target gliner); the gliner package is not installed"
235+
"PII_ENGINE=gliner but the gliner package is not installed; "
236+
"use the stock pii image (docker/pii.Dockerfile ships torch + gliner)"
236237
)
237238
missing = [
238239
m["model_name"]
@@ -242,7 +243,7 @@ def build_gliner_analyzer(model_name: str, device: str | None) -> AnalyzerEngine
242243
if missing:
243244
raise RuntimeError(
244245
f"PII_ENGINE=gliner needs spaCy models {missing}; "
245-
"use the gliner image variant (docker build --target gliner)"
246+
"use the stock pii image (docker/pii.Dockerfile ships them)"
246247
)
247248
nlp_engine = NlpEngineProvider(nlp_configuration=GLINER_NLP_CONFIGURATION).create_engine()
248249
analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES)

apps/pii/scripts/bench_engines.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
register the same recognizers — so any mismatch there is a wiring bug and the
88
script exits non-zero.
99
10-
Meant to run inside the gliner image (the only one with both engines):
10+
Meant to run inside the pii image (both engines ship in it):
1111
12-
docker run --rm sim-pii:gliner python scripts/bench_engines.py
13-
docker run --rm -v $PWD/texts.json:/data.json sim-pii:gliner \\
12+
docker run --rm <pii-image> python scripts/bench_engines.py
13+
docker run --rm -v $PWD/texts.json:/data.json <pii-image> \\
1414
python scripts/bench_engines.py --payload /data.json
1515
1616
Payload format: JSON list of {"text": str, "language": str} objects.

apps/pii/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
NER engine selection (see engines.py):
88
- PII_ENGINE=spacy (default): the 5 large spaCy models, unchanged behavior.
99
- PII_ENGINE=gliner: one multilingual GLiNER model for PERSON/LOCATION/NRP/
10-
DATE_TIME; requires the `gliner` image target (torch + gliner installed).
10+
DATE_TIME. The stock image ships both engines, so this is a pure env flip.
1111
PII_DEVICE picks cpu/cuda (unset = auto-detect), PII_GLINER_MODEL overrides
1212
the model id. The same code runs on CPU and GPU. Each uvicorn worker
1313
(PII_WORKERS) loads its own GLiNER model copy — into GPU memory when on

apps/pii/tests/test_engines.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def test_invalid_pii_engine_fails_import():
7070
reason="fail-fast path only exists when gliner is not installed",
7171
)
7272
def test_build_gliner_analyzer_fails_fast_without_gliner():
73-
with pytest.raises(RuntimeError, match="gliner image variant"):
73+
with pytest.raises(RuntimeError, match="gliner package is not installed"):
7474
engines.build_gliner_analyzer(model_name="fake/model", device="cpu")
7575

7676

apps/pii/tests/test_integration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
RUN_PII_INTEGRATION to keep plain `pytest` runs model-free):
55
66
# spacy regression (default engine)
7-
docker run --rm -e RUN_PII_INTEGRATION=1 sim-pii:gliner python -m pytest tests
7+
docker run --rm -e RUN_PII_INTEGRATION=1 <pii-image> python -m pytest tests
88
99
# gliner engine
10-
docker run --rm -e RUN_PII_INTEGRATION=1 -e PII_ENGINE=gliner sim-pii:gliner \
10+
docker run --rm -e RUN_PII_INTEGRATION=1 -e PII_ENGINE=gliner <pii-image> \
1111
python -m pytest tests/test_integration.py
1212
1313
The suite adapts to PII_ENGINE: shared assertions always run, engine-specific

docker/pii.Dockerfile

Lines changed: 39 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
# ========================================
22
# Combined Presidio service (analyzer + anonymizer) on a single port (5001)
33
#
4-
# Targets (docker builds the LAST stage when no --target is given):
5-
# (default) / spacy : lean image, spaCy NER only — what self-hosters get.
6-
# gliner : superset image (torch CPU + gliner + baked GLiNER
7-
# model + small spaCy models). Both PII_ENGINE=spacy
8-
# and PII_ENGINE=gliner work in it.
9-
# gliner-gpu : scaffold for the EC2-GPU fleet — same layout, CUDA
10-
# torch wheels (bundle their own CUDA libs; host only
11-
# needs the nvidia container runtime). Not built in CI.
4+
# ONE image serves both NER engines — the engine is a pure runtime choice via
5+
# PII_ENGINE (spacy default | gliner). spaCy large models, torch (CPU), the
6+
# gliner package, and the baked GLiNER weights all ship in it, so flipping
7+
# engines never requires an image swap.
128
#
13-
# Source files are COPY'd last in each terminal stage so code edits never
14-
# re-download deps or models.
9+
# GPU variant (EC2-GPU fleet follow-up): same Dockerfile, CUDA torch wheels —
10+
# docker build --build-arg TORCH_INDEX_URL=https://download.pytorch.org/whl/cu128 ...
11+
# (torch CUDA wheels bundle their own CUDA libs; the host only needs the
12+
# nvidia container runtime.)
13+
#
14+
# Source files are COPY'd last so code edits never re-download deps or models.
1515
# ========================================
1616
FROM python:3.12-slim-bookworm AS base
1717

@@ -29,35 +29,9 @@ COPY apps/pii/requirements.txt ./requirements.txt
2929
RUN --mount=type=cache,target=/root/.cache/pip \
3030
pip install -r requirements.txt
3131

32-
RUN groupadd -g 1001 pii && \
33-
useradd -u 1001 -g pii pii && \
34-
chown -R pii:pii /app
35-
36-
# Listen on 5001. Runs as its own ECS service (separate task), reached via PII_URL;
37-
# 5001 avoids colliding with the app's 3000 in local/compose runs on one host.
38-
EXPOSE 5001
39-
40-
# start-period covers the model cold start. With PII_WORKERS>1 each worker loads
41-
# the five spaCy models independently and in parallel, so allow generous headroom
42-
# (memory-bandwidth contention stretches the wall-time beyond the single-worker case).
43-
HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \
44-
CMD curl -fsS http://localhost:5001/health || exit 1
45-
46-
# Worker count is env-driven so ONE image scales per task size: set PII_WORKERS to
47-
# the task's vCPU count (each worker loads the models independently, ~3 GB each, so
48-
# size task memory ≈ PII_WORKERS × 3 GB + overhead). Defaults to 1 for local/small.
49-
# `sh -c exec` expands the env var while keeping uvicorn as PID 1 for clean SIGTERM.
50-
# Quote the expansion so a malformed PII_WORKERS fails uvicorn arg-parsing rather
51-
# than being interpreted by the shell.
52-
# NB for the gliner engine: EACH worker loads its own GLiNER model copy (into GPU
53-
# memory when on cuda), so GPU deployments generally want PII_WORKERS=1 per GPU.
54-
CMD ["sh", "-c", "exec uvicorn server:app --host 0.0.0.0 --port 5001 --workers \"${PII_WORKERS:-1}\""]
55-
5632
# Pinned spaCy models (en + es/it/pl/fi, ~2.2GB total). Downloaded with
5733
# retries/resume — the large wheels truncate on flaky networks if pip fetches
58-
# the URLs directly. Shared by every terminal image so PII_ENGINE=spacy works
59-
# everywhere (the gliner opt-in stays reversible without an image swap).
60-
FROM base AS spacy-models
34+
# the URLs directly.
6135
ARG SPACY_MODELS="en_core_web_lg-3.8.0 es_core_news_lg-3.8.0 it_core_news_lg-3.8.0 pl_core_news_lg-3.8.0 fi_core_news_lg-3.8.0"
6236
RUN --mount=type=cache,target=/root/.cache/pip \
6337
for model in ${SPACY_MODELS}; do \
@@ -69,15 +43,14 @@ RUN --mount=type=cache,target=/root/.cache/pip \
6943
pip install /tmp/*.whl && \
7044
rm /tmp/*.whl
7145

72-
# --- GLiNER (CPU) ------------------------------------------------------------
73-
FROM spacy-models AS gliner
74-
75-
# torch pinned here (not requirements-gliner.txt) because the CPU and CUDA
76-
# targets install the same version from different wheel indexes. 2.11.0 is the
46+
# --- GLiNER engine deps -------------------------------------------------------
47+
# torch is pinned here (not requirements-gliner.txt) because the CPU and CUDA
48+
# builds install the same version from different wheel indexes. 2.11.0 is the
7749
# newest release published on both the cpu and cu128 indexes for py312.
7850
ARG TORCH_VERSION=2.11.0
51+
ARG TORCH_INDEX_URL=https://download.pytorch.org/whl/cpu
7952
RUN --mount=type=cache,target=/root/.cache/pip \
80-
pip install torch==${TORCH_VERSION} --index-url https://download.pytorch.org/whl/cpu
53+
pip install torch==${TORCH_VERSION} --index-url ${TORCH_INDEX_URL}
8154

8255
COPY apps/pii/requirements-gliner.txt ./requirements-gliner.txt
8356
RUN --mount=type=cache,target=/root/.cache/pip \
@@ -105,54 +78,38 @@ RUN python -c "from gliner import GLiNER; GLiNER.from_pretrained('${GLINER_MODEL
10578
chmod -R a+rX /opt/hf-cache
10679
ENV HF_HUB_OFFLINE=1
10780

108-
# Bench + tests ride along only in this image — it's the only one with both
109-
# engines installed (see apps/pii/scripts/bench_engines.py). pytest/httpx are
110-
# baked in so the documented `docker run ... python -m pytest tests` works
111-
# as-is (the runtime user has no writable HOME for pip install --user).
81+
# pytest/httpx for the in-image test suites (tests/) — baked in because the
82+
# runtime user has no writable HOME for pip install --user.
11283
COPY apps/pii/requirements-dev.txt ./requirements-dev.txt
11384
RUN --mount=type=cache,target=/root/.cache/pip \
11485
pip install -r requirements-dev.txt
11586

87+
RUN groupadd -g 1001 pii && \
88+
useradd -u 1001 -g pii pii && \
89+
chown -R pii:pii /app
90+
11691
COPY --chown=pii:pii apps/pii/server.py apps/pii/engines.py ./
11792
COPY --chown=pii:pii apps/pii/scripts ./scripts
11893
COPY --chown=pii:pii apps/pii/tests ./tests
11994

12095
USER pii
12196

122-
# --- GLiNER (CUDA) scaffold — wired for the GPU fleet follow-up --------------
123-
FROM spacy-models AS gliner-gpu
124-
125-
ARG TORCH_VERSION=2.11.0
126-
RUN --mount=type=cache,target=/root/.cache/pip \
127-
pip install torch==${TORCH_VERSION} --index-url https://download.pytorch.org/whl/cu128
128-
129-
COPY apps/pii/requirements-gliner.txt ./requirements-gliner.txt
130-
RUN --mount=type=cache,target=/root/.cache/pip \
131-
pip install -r requirements-gliner.txt
132-
133-
ARG SPACY_SM_MODELS="en_core_web_sm-3.8.0 es_core_news_sm-3.8.0 it_core_news_sm-3.8.0 pl_core_news_sm-3.8.0 fi_core_news_sm-3.8.0"
134-
RUN --mount=type=cache,target=/root/.cache/pip \
135-
for model in ${SPACY_SM_MODELS}; do \
136-
whl="${model}-py3-none-any.whl"; \
137-
curl -fL --retry 5 --retry-delay 5 --retry-all-errors -C - \
138-
-o "/tmp/${whl}" \
139-
"https://github.com/explosion/spacy-models/releases/download/${model}/${whl}" || exit 1; \
140-
done && \
141-
pip install /tmp/*.whl && \
142-
rm /tmp/*.whl
143-
144-
ENV HF_HOME=/opt/hf-cache
145-
ARG GLINER_MODEL=urchade/gliner_multi_pii-v1
146-
RUN python -c "from gliner import GLiNER; GLiNER.from_pretrained('${GLINER_MODEL}')" && \
147-
chmod -R a+rX /opt/hf-cache
148-
ENV HF_HUB_OFFLINE=1
149-
150-
COPY --chown=pii:pii apps/pii/server.py apps/pii/engines.py ./
151-
COPY --chown=pii:pii apps/pii/scripts ./scripts
97+
# Listen on 5001. Runs as its own ECS service (separate task), reached via PII_URL;
98+
# 5001 avoids colliding with the app's 3000 in local/compose runs on one host.
99+
EXPOSE 5001
152100

153-
USER pii
101+
# start-period covers the model cold start. With PII_WORKERS>1 each worker loads
102+
# the five spaCy models independently and in parallel, so allow generous headroom
103+
# (memory-bandwidth contention stretches the wall-time beyond the single-worker case).
104+
HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \
105+
CMD curl -fsS http://localhost:5001/health || exit 1
154106

155-
# --- DEFAULT (last stage): the lean spaCy image, content-equivalent to today -
156-
FROM spacy-models AS spacy
157-
COPY --chown=pii:pii apps/pii/server.py apps/pii/engines.py ./
158-
USER pii
107+
# Worker count is env-driven so ONE image scales per task size: set PII_WORKERS to
108+
# the task's vCPU count (each worker loads the models independently, ~3 GB each, so
109+
# size task memory ≈ PII_WORKERS × 3 GB + overhead). Defaults to 1 for local/small.
110+
# `sh -c exec` expands the env var while keeping uvicorn as PID 1 for clean SIGTERM.
111+
# Quote the expansion so a malformed PII_WORKERS fails uvicorn arg-parsing rather
112+
# than being interpreted by the shell.
113+
# NB for the gliner engine: EACH worker loads its own GLiNER model copy (into GPU
114+
# memory when on cuda), so GPU deployments generally want PII_WORKERS=1 per GPU.
115+
CMD ["sh", "-c", "exec uvicorn server:app --host 0.0.0.0 --port 5001 --workers \"${PII_WORKERS:-1}\""]

helm/sim/values.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,7 @@
714714
"engine": {
715715
"type": "string",
716716
"enum": ["spacy", "gliner"],
717-
"description": "NER engine; gliner requires the -gliner image variant"
717+
"description": "NER engine (spacy default, gliner opt-in; both ship in the image)"
718718
},
719719
"device": {
720720
"type": "string",

helm/sim/values.yaml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -824,10 +824,9 @@ pii:
824824

825825
# NER engine: "spacy" (default) or "gliner" (opt-in zero-shot transformer NER
826826
# for PERSON/LOCATION/NRP/DATE_TIME; regex/checksum recognizers are identical
827-
# on both engines). gliner requires the gliner image variant — the `-gliner`
828-
# published tag (e.g. image.tag: latest-gliner) or a local
829-
# `docker build --target gliner -f docker/pii.Dockerfile` — and fails fast at
830-
# startup on the default image.
827+
# on both engines). The image ships both engines, so this is a pure env flip
828+
# — no image change needed. Note: gliner on CPU is orders of magnitude slower
829+
# than spacy; it is intended for GPU nodes.
831830
engine: "spacy"
832831

833832
# Torch device for the gliner engine: "cpu", "cuda", or "cuda:N". Empty

0 commit comments

Comments
 (0)