Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ jobs:
tags: triangle-cms-frontend:${{ github.sha }}
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
# Cheap, unlike the embeddings image, and its build fails if the bundled
# libvips cannot write WebP, which is worth knowing before a deploy.
- name: Build imaging sidecar image
uses: docker/build-push-action@v6
with:
context: ./imaging
file: ./imaging/Dockerfile
push: false
tags: triangle-cms-imaging:${{ github.sha }}
cache-from: type=gha,scope=imaging
cache-to: type=gha,mode=max,scope=imaging

# The deployment scripts are the least reversible code in the repo, so their
# test suite runs on every PR. It stubs docker/curl/nginx on PATH and needs no
Expand Down Expand Up @@ -200,4 +211,5 @@ jobs:
# checks that the derivation deploy.sh uses still resolves.
run: |
CMS_EMBEDDINGS_TAG="$(git rev-parse HEAD:embeddings)" \
CMS_IMAGING_TAG="$(git rev-parse HEAD:imaging)" \
docker compose -f deploy/compose.cms.yml config >/dev/null
30 changes: 30 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ jobs:
echo "backend=ghcr.io/${repo}-backend" >> "$GITHUB_OUTPUT"
echo "frontend=ghcr.io/${repo}-frontend" >> "$GITHUB_OUTPUT"
echo "embeddings=ghcr.io/${repo}-embeddings" >> "$GITHUB_OUTPUT"
echo "imaging=ghcr.io/${repo}-imaging" >> "$GITHUB_OUTPUT"

- uses: docker/setup-buildx-action@v3

Expand Down Expand Up @@ -122,6 +123,21 @@ jobs:
echo "value=false" >> "$GITHUB_OUTPUT"
fi

# Same content tagging for the imaging sidecar, from imaging/.
- name: Compute the imaging tag
id: imaging_tag
run: echo "value=$(git rev-parse HEAD:imaging)" >> "$GITHUB_OUTPUT"

- name: Check whether the imaging image already exists
id: imaging_exists
run: |
if docker manifest inspect "${{ steps.image.outputs.imaging }}:${{ steps.imaging_tag.outputs.value }}" >/dev/null 2>&1; then
echo "skipping the imaging build: ${{ steps.imaging_tag.outputs.value }} is already published"
echo "value=true" >> "$GITHUB_OUTPUT"
else
echo "value=false" >> "$GITHUB_OUTPUT"
fi

- name: Build and publish backend
uses: docker/build-push-action@v6
with:
Expand Down Expand Up @@ -161,3 +177,17 @@ jobs:
org.opencontainers.image.revision=${{ steps.sha.outputs.value }}
cache-from: type=gha,scope=embeddings
cache-to: type=gha,mode=max,scope=embeddings

- name: Build and publish imaging sidecar
if: steps.imaging_exists.outputs.value != 'true'
uses: docker/build-push-action@v6
with:
context: ./imaging
file: ./imaging/Dockerfile
push: true
tags: ${{ steps.image.outputs.imaging }}:${{ steps.imaging_tag.outputs.value }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ steps.sha.outputs.value }}
cache-from: type=gha,scope=imaging
cache-to: type=gha,mode=max,scope=imaging
36 changes: 36 additions & 0 deletions deploy/compose.cms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ x-backend-base: &backend-base
# is no reason to run one per slot. Leave empty to run lexical-only search:
# the backend then skips query embedding and its reconciler exits at start.
EMBEDDINGS_URL: ${EMBEDDINGS_URL-http://embeddings:8000}
# Resized image renditions. Shared by both slots like the embeddings
# sidecar; the reconciler that drives it takes a database lock so only one
# slot renders at a time. Leave empty to stop rendering; images already
# rendered keep being served.
IMAGING_URL: ${IMAGING_URL-http://imaging:8000}
volumes:
# CephFS media tree (host). rw so the upload endpoint can store new files;
# host Nginx serves the same tree read-only (that site is in triangle-infrastructure).
Expand Down Expand Up @@ -118,6 +123,37 @@ services:
networks:
- triangle_net

# Renders resized WebP copies of library images. Stateless: the backend reads
# the original from the media mount, sends the bytes, and writes what comes
# back, so this container needs no volume and never touches CephFS itself.
imaging:
# Tagged by content, like embeddings: CMS_IMAGING_TAG is the git tree hash
# of imaging/. deploy.sh derives the value.
image: ${CMS_IMAGING_IMAGE:-ghcr.io/drexeltriangle/triangle-cms-imaging}:${CMS_IMAGING_TAG:?CMS_IMAGING_TAG is required}
restart: unless-stopped
stop_grace_period: 10s
# The backfill of the existing library (~13k originals, about 1.5s each)
# runs for hours, so it gets less of Delta's 6 cores than the embeddings
# sidecar does. libvips sizes its thread pool from VIPS_CONCURRENCY, not
# from the cgroup, so the two must match for the same reason
# OMP_NUM_THREADS does above.
cpus: ${IMAGE_CPUS:-2}
# Decoding is the memory peak: a 120MP original (the sidecar's ceiling)
# is ~480MB of pixels. A ceiling here turns a pathological file into one
# OOM-killed render, which the reconciler gives up on after three tries,
# rather than pressure on the backends.
mem_limit: ${IMAGE_MEM_LIMIT:-1536m}
environment:
VIPS_CONCURRENCY: ${IMAGE_CPUS:-2}
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')\""]
interval: 15s
timeout: 5s
retries: 4
start_period: 10s
networks:
- triangle_net

backend-blue:
<<: *backend-base
ports:
Expand Down
29 changes: 21 additions & 8 deletions deploy/scripts/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ PUBLIC_HEALTH_TIMEOUT="${PUBLIC_HEALTH_TIMEOUT:-30}"
# Generous: this covers pulling the image and loading the ONNX model on a host
# with no GPU. Exceeding it only costs semantic search, never the deployment.
EMBEDDINGS_HEALTH_TIMEOUT="${EMBEDDINGS_HEALTH_TIMEOUT:-240}"
# No model to load: the imaging sidecar is healthy as soon as uvicorn is up.
IMAGING_HEALTH_TIMEOUT="${IMAGING_HEALTH_TIMEOUT:-60}"

compose() {
docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" "$@"
Expand Down Expand Up @@ -245,30 +247,41 @@ wait_for_url() {
done
}

# wait_for_embeddings polls the container's health state rather than an HTTP
# endpoint, because the sidecar is deliberately not published to the host: only
# the backends reach it, over the compose network. Its healthcheck 503s until the
# model has finished loading, so "healthy" here means it can actually answer.
wait_for_embeddings() {
local deadline=$((SECONDS + EMBEDDINGS_HEALTH_TIMEOUT))
# wait_for_sidecar polls a shared sidecar's container health rather than an
# HTTP endpoint, because the sidecars are deliberately not published to the
# host: only the backends reach them, over the compose network. The embeddings
# healthcheck 503s until the model has finished loading, so "healthy" here means
# it can actually answer.
wait_for_sidecar() {
local service="$1"
local timeout="$2"
local deadline=$((SECONDS + timeout))
local container status=""

while true; do
container="$(compose ps -q embeddings 2>/dev/null || true)"
container="$(compose ps -q "${service}" 2>/dev/null || true)"
if [[ -n "${container}" ]]; then
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "${container}" 2>/dev/null || true)"
if [[ "${status}" == "healthy" ]]; then
return 0
fi
fi
if (( SECONDS >= deadline )); then
echo "timed out waiting for the embeddings sidecar (last status: ${status:-unknown})" >&2
echo "timed out waiting for the ${service} sidecar (last status: ${status:-unknown})" >&2
return 1
fi
sleep 3
done
}

wait_for_embeddings() {
wait_for_sidecar embeddings "${EMBEDDINGS_HEALTH_TIMEOUT}"
}

wait_for_imaging() {
wait_for_sidecar imaging "${IMAGING_HEALTH_TIMEOUT}"
}

wait_for_slot() {
local slot="$1"
validate_slot "${slot}"
Expand Down
25 changes: 25 additions & 0 deletions deploy/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ fi
export CMS_EMBEDDINGS_TAG
echo "embeddings image tag: ${CMS_EMBEDDINGS_TAG}"

# The imaging sidecar is tagged the same way, from imaging/, and for the same
# reason: it should only be rebuilt and recreated when it changes.
if imaging_tag="$(git -C "${REPO_DIR}" rev-parse HEAD:imaging 2>/dev/null)"; then
CMS_IMAGING_TAG="${imaging_tag}"
else
echo "warning: could not derive the imaging tag from git; falling back to the commit tag" >&2
CMS_IMAGING_TAG="${CMS_IMAGE_TAG}"
fi
export CMS_IMAGING_TAG
echo "imaging image tag: ${CMS_IMAGING_TAG}"

require_file "${COMPOSE_FILE}"
acquire_deploy_lock
deployment_preflight
Expand Down Expand Up @@ -74,6 +85,20 @@ else
echo "warning: could not start the embeddings sidecar; search will serve lexical results" >&2
fi

# Same arrangement for the imaging sidecar, and just as non-fatal: without it
# the backends stop producing resized images, and the site serves the originals
# it served before this sidecar existed.
if ! compose pull imaging; then
echo "warning: could not pull the imaging sidecar; new uploads will not be resized" >&2
fi
if compose up -d --no-deps imaging; then
if ! wait_for_imaging; then
echo "warning: the imaging sidecar did not become healthy; new uploads will not be resized until it does" >&2
fi
else
echo "warning: could not start the imaging sidecar; new uploads will not be resized" >&2
fi

compose pull "backend-${next_slot}" "frontend-${next_slot}"
compose up -d --no-deps "backend-${next_slot}" "frontend-${next_slot}"

Expand Down
3 changes: 2 additions & 1 deletion deploy/scripts/deploy_scripts_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ make_case() {
# the fake compose in these tests. Left at its 240s production default it made
# every deploy case sit out the full timeout; the suite took 8 minutes.
EMBEDDINGS_HEALTH_TIMEOUT=0
IMAGING_HEALTH_TIMEOUT=0
DEPLOY_TEST_MODE=1
NGINX_TEST_CMD='exit "${FAKE_NGINX_TEST_STATUS:-0}"'
NGINX_RELOAD_CMD='exit "${FAKE_NGINX_RELOAD_STATUS:-0}"'
Expand All @@ -69,7 +70,7 @@ make_case() {
FAIL_PUBLIC=0
export NGINX_ACTIVE_INCLUDE ENV_FILE COMPOSE_FILE DEPLOY_LOCK_FILE PUBLIC_BASE_URL
export BACKEND_HEALTH_TIMEOUT FRONTEND_HEALTH_TIMEOUT PUBLIC_HEALTH_TIMEOUT
export EMBEDDINGS_HEALTH_TIMEOUT
export EMBEDDINGS_HEALTH_TIMEOUT IMAGING_HEALTH_TIMEOUT
export DEPLOY_TEST_MODE NGINX_TEST_CMD NGINX_RELOAD_CMD NGINX_RELOAD_CHECK_CMD
export FAKE_NGINX_TEST_STATUS FAKE_NGINX_RELOAD_STATUS FAKE_NGINX_RELOAD_CHECK_STATUS
export FAIL_READINESS FAIL_PUBLIC
Expand Down
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,27 @@ services:
networks:
- triangle_net

imaging:
build:
context: ./imaging
dockerfile: Dockerfile
restart: unless-stopped
cpus: ${IMAGE_CPUS:-2}
environment:
VIPS_CONCURRENCY: ${IMAGE_CPUS:-2}
# Stateless and internal-only, like embeddings. Locally the CMS has no
# MEDIA_ROOT, so its reconciler stays idle unless you mount one.
networks:
- triangle_net

cms:
build:
context: ./server
dockerfile: Dockerfile
restart: unless-stopped
environment:
EMBEDDINGS_URL: ${EMBEDDINGS_URL:-http://embeddings:8000}
IMAGING_URL: ${IMAGING_URL:-http://imaging:8000}
DB_NAME: ${MARIADB_DATABASE:-triangle}
DB_USER: ${MARIADB_USER:-triangle_user}
DB_PASSWORD: ${MARIADB_PASSWORD:?MARIADB_PASSWORD is required}
Expand Down
21 changes: 21 additions & 0 deletions imaging/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

# Fail the build, not the first render, if the bundled libvips cannot write
# WebP.
RUN python -c "import pyvips; pyvips.Image.black(8, 8).webpsave_buffer()"

RUN useradd --create-home --uid 10001 imaging
USER imaging

EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
Loading
Loading