173 lines
7.3 KiB
YAML
173 lines
7.3 KiB
YAML
# Docker Swarm stack for the hop-1 interim cutover — see
|
|
# thermograph-docs/runbooks/hop1-forgejo-registry-cutover.md (which this file implements) and
|
|
# thermograph-docs/architecture/repo-topology-and-infrastructure.md §6/§9.
|
|
#
|
|
# Distinct from docker-compose.yml (today's plain-compose deploy, unaffected by
|
|
# this file): Swarm pulls a pre-built image from the registry (IMAGE_TAG) rather
|
|
# than building in place, and needs Swarm-specific keys (deploy:, networks:,
|
|
# secrets:, no host-published ports on the overlay-fronted services).
|
|
#
|
|
# docker stack deploy -c docker-stack.yml thermograph
|
|
#
|
|
# `docker stack deploy` only interpolates from the invoking shell's environment,
|
|
# not a .env file — export the required vars first (or `set -a; . ./stack.env;
|
|
# set +a; docker stack deploy ...`). Required shell vars: IMAGE_TAG (the tag CI
|
|
# pushed to the registry), TIMESCALEDB_TAG (an EXACT pinned minor — see the db
|
|
# service below, hazard #7). POSTGRES_PASSWORD is NOT a shell var here: unlike
|
|
# docker-compose.yml (which interpolates it into THERMOGRAPH_DATABASE_URL at
|
|
# compose time), this file reads it as the `postgres_password` Swarm secret —
|
|
# POSTGRES_PASSWORD_FILE for db (native support in the postgres/timescaledb
|
|
# image), and the chunk-3 entrypoint shim builds THERMOGRAPH_DATABASE_URL for
|
|
# app/worker from the same secret file at container start. All `postgres_password`
|
|
# / `thermograph_*` secrets below must already exist in the Swarm (`docker secret
|
|
# create <name> -`) before the first deploy — Track B step 7 in
|
|
# thermograph-docs/runbooks/implementation-handoff.md.
|
|
#
|
|
# Migrations are NOT run inline by app/worker here (RUN_MIGRATIONS=0) — the
|
|
# runbook's Stage F brings the schema to head via a one-shot task BEFORE scaling
|
|
# app up, so multiple replicas never race Alembic and nothing runs DDL against a
|
|
# still-read-only standby mid-cutover (hazard #9):
|
|
#
|
|
# docker run --rm --network thermograph_internal \
|
|
# -e THERMOGRAPH_DATABASE_URL=... <image> migrate
|
|
|
|
services:
|
|
db:
|
|
# Pin the EXACT TimescaleDB minor — never latest-pg18 here. A floating tag can
|
|
# give two hosts different extension minors, which blocks a physical replica
|
|
# (a newer .so over an older catalog won't start) and risks compressed-chunk
|
|
# corruption on timescaledb_post_restore() (hazard #7). Get the exact X.Y.Z
|
|
# from the source database: SELECT extversion FROM pg_extension WHERE
|
|
# extname='timescaledb' — use the SAME tag docker-compose.yml's TIMESCALEDB_TAG
|
|
# is pinned to, on every host that could ever replicate with this one.
|
|
image: timescale/timescaledb:${TIMESCALEDB_TAG:?set TIMESCALEDB_TAG to an exact pinned minor, e.g. 2.17.2-pg18 -- not latest-pg18}
|
|
environment:
|
|
POSTGRES_USER: thermograph
|
|
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
|
POSTGRES_DB: thermograph
|
|
DB_MEMORY: ${DB_MEMORY:-8g}
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql
|
|
- ./deploy/db/init:/docker-entrypoint-initdb.d
|
|
networks:
|
|
- internal
|
|
secrets:
|
|
- postgres_password
|
|
deploy:
|
|
# Pin to the labelled DB node so replication/IO never crosses the slow WG
|
|
# uplink (hazard #14): `docker node update --label-add db=true <node>` once,
|
|
# on whichever node holds pgdata (Track B).
|
|
placement:
|
|
constraints: ["node.labels.db == true"]
|
|
resources:
|
|
limits:
|
|
cpus: "${DB_CPUS:-2}"
|
|
memory: ${DB_MEMORY:-8g}
|
|
restart_policy:
|
|
condition: on-failure
|
|
# No published port: reachable only as db:5432 on the `internal` overlay —
|
|
# Postgres must never be public (design doc §6).
|
|
|
|
# Stateless, freely-replicable web tier. ROLE=web means _should_run_notifier()
|
|
# is False unconditionally (web/app.py) — it never starts the notifier or the
|
|
# worker scheduler even if it would otherwise win leader election.
|
|
app:
|
|
image: ${IMAGE_TAG:?set IMAGE_TAG to the image CI pushed, e.g. forge.example/thermograph/app:sha-abc123}
|
|
environment:
|
|
THERMOGRAPH_BASE: /
|
|
PORT: 8137
|
|
WORKERS: ${WORKERS:-4}
|
|
THERMOGRAPH_ROLE: web
|
|
RUN_MIGRATIONS: "0"
|
|
volumes:
|
|
- appdata:/app/data
|
|
- applogs:/app/logs
|
|
networks:
|
|
- internal
|
|
secrets:
|
|
- postgres_password
|
|
- thermograph_auth_secret
|
|
- thermograph_vapid_private_key
|
|
- thermograph_vapid_public_key
|
|
deploy:
|
|
replicas: 1 # single replica this hop -- homepage.json now lives in
|
|
# Postgres (chunk 4), but the notifier/scheduler split (chunk
|
|
# 2/5) is what actually lets this go multi-replica in Phase 2
|
|
resources:
|
|
limits:
|
|
cpus: "${APP_CPUS:-4}"
|
|
restart_policy:
|
|
condition: on-failure
|
|
update_config:
|
|
order: start-first # new task must pass the image's HEALTHCHECK (now
|
|
# GET /healthz) before the old one is stopped
|
|
# No `ports:` — 127.0.0.1:8137:8137 (docker-compose.yml) has no Swarm
|
|
# equivalent: Swarm's routing mesh publishes on 0.0.0.0, which would expose
|
|
# the plaintext app un-fronted (hazard #6). Only the host's Caddy reaches
|
|
# `app`, over the `internal` overlay network — see the Caddyfile templates'
|
|
# health-gated reverse_proxy.
|
|
|
|
# Owns the notifier + worker scheduler (chunks 1/2/5). Exactly one replica —
|
|
# the leader-election guard is belt-and-suspenders, not a substitute for it.
|
|
worker:
|
|
image: ${IMAGE_TAG:?set IMAGE_TAG to the image CI pushed, e.g. forge.example/thermograph/app:sha-abc123}
|
|
environment:
|
|
THERMOGRAPH_BASE: /
|
|
WORKERS: "1"
|
|
THERMOGRAPH_ROLE: worker
|
|
# Cluster-wide advisory lock, not the flock: the flock only arbitrates
|
|
# workers on ONE host, so under Swarm it guards nothing across replicas.
|
|
THERMOGRAPH_SINGLETON_PG: "1"
|
|
RUN_MIGRATIONS: "0"
|
|
volumes:
|
|
- appdata:/app/data
|
|
- applogs:/app/logs
|
|
networks:
|
|
- internal
|
|
secrets:
|
|
- postgres_password
|
|
- thermograph_auth_secret
|
|
- thermograph_vapid_private_key
|
|
- thermograph_vapid_public_key
|
|
- thermograph_discord_webhook
|
|
deploy:
|
|
replicas: 1
|
|
resources:
|
|
limits:
|
|
cpus: "${WORKER_CPUS:-1}"
|
|
restart_policy:
|
|
condition: on-failure
|
|
# No `ports:` — the worker serves no public traffic; GET /healthz on its own
|
|
# container port is for the image's own HEALTHCHECK (Swarm task health) only.
|
|
|
|
networks:
|
|
internal:
|
|
driver: overlay
|
|
driver_opts:
|
|
# VXLAN-over-WireGuard double encapsulation needs a lower MTU than the
|
|
# ~1450 overlay default, or large payloads (a /cell bundle, the homepage
|
|
# feed) silently stall while small packets (health checks) keep passing
|
|
# (hazard #13). Validate with a real large-payload transfer, not just a
|
|
# ping, once the mesh is up (Track B).
|
|
com.docker.network.driver.mtu: "1370"
|
|
|
|
volumes:
|
|
pgdata: {}
|
|
appdata: {}
|
|
applogs: {}
|
|
|
|
# Declared here, provisioned externally (docker secret create <name> - < file) —
|
|
# never by this stack, and never committed. See Stage 0 of the cutover runbook
|
|
# for which values are continuity-critical (AUTH_SECRET, VAPID, POSTGRES_PASSWORD
|
|
# must be the EXISTING live values, not freshly generated ones).
|
|
secrets:
|
|
postgres_password:
|
|
external: true
|
|
thermograph_auth_secret:
|
|
external: true
|
|
thermograph_vapid_private_key:
|
|
external: true
|
|
thermograph_vapid_public_key:
|
|
external: true
|
|
thermograph_discord_webhook:
|
|
external: true
|