gendesign/docker-compose.prod.yml
bot-backend 7d98a674c9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(ops): лёгкий Postgres на Beget под forgejo и glitchtip
Переезд ломает сам Forgejo. Базы forgejo (265 МБ) и glitchtip (729 МБ)
физически лежат внутри gendesign-postgres-1, который уезжает целиком вместе
с 15 ГБ Site Finder. В момент переезда легли бы веб, git по HTTP и Actions —
то есть CI, из которого мы деплоим, ровно тогда, когда он нужнее всего.
Отдельно обидно, что Forgejo заявлен компенсацией риска «всё в одном
аккаунте Selectel»: падая вместе с переездом, он эту роль не выполняет.

Владелец выбрал вариант А — отдельный лёгкий кластер на Beget. PostGIS тут
не нужен, обе схемы обычные реляционные: postgres:16-alpine против
postgis/postgis:16-3.4 ради одного гигабайта данных.

Мерж и деплой ничего не меняют. Сервис под профилем "infra", которого нет
ни в одном окружении на Beget, поэтому docker compose его не видит;
GLITCHTIP_DB_HOST имеет дефолт postgres, то есть строка подключения
GlitchTip рендерится байт-в-байт как сегодня. Переключение — правка одной
переменной на VM после переноса данных, откат — правка обратно.

Первая версия этого не держала: сервис стоял в профиле glitchtip, который
на Beget уже активен, и мерж поднял бы кластер с пустым INFRA_PG_PASSWORD
в вечный restart-loop при зелёном деплое. Поймано ревью.

Healthcheck проверяет не приём соединений, а наличие ОБЕИХ баз. Иначе
отравленный том — когда bootstrap-скрипт упал уже после инициализации
PGDATA и больше никогда не запустится — выглядел бы здоровым, а
обнаружился бы в ночь переезда как «role forgejo does not exist».

backup-forgejo.sh больше не хардкодит контейнер: ищет тот, где база forgejo
реально есть, и при двух кандидатах останавливается с exit 1. Это ровно
окно миграции, когда база лежит в обоих: молча выбрать один означает с
вероятностью 1/2 бэкапить труп, а порог MIN_DB_DUMP_BYTES протухший дамп
не поймает — он нормального размера.

ops/split-infra-postgres.sh — перенос: dry-run по умолчанию, снимок числа
строк после останова писателей, восстановление целевой ролью (суперюзер
забрал бы таблицы себе и выдал permission denied уже после переключения),
сверка построчных карт из pg_class. Ревью нашло два сценария бесшумной
потери, оба закрыты: писатель с несовпавшим именем больше не пропускается
молча (имя контейнера Forgejo — догадка, его compose вне репозитория), а
--force-restore отказывается работать, если переключение уже сделано.
Дампы под umask 077 и chmod 600: в дампе forgejo хеши паролей и токены.

Проверено на живом Docker, не по YAML: матрица профилей через compose
config, healthcheck исполнен в postgres:16-alpine (обе базы → 0, после DROP
одной → 1), bootstrap реально завёл роли и базы, пароль со спецсимволами
логинится, в docker logs его нет.

Refs #3061, #3057, #3075, #2989, #2203
2026-08-24 19:28:59 +03:00

760 lines
55 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Production compose. Pulls pre-built images from GHCR
# (built and pushed by .github/workflows/deploy.yml).
#
# Place at /opt/gendesign/docker-compose.prod.yml on the VM.
# Required env in /opt/gendesign/.env:
# POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD
# IMAGE_TAG (defaults to "latest")
#
# Optional (LLM chat #960/#957 — dormant until BOTH set; written to
# backend/.env.runtime by deploy.yml from Forgejo secret/var only when non-empty):
# OPENAI_API_KEY — Forgejo Actions secret (sensitive); unset → openai_api_key=None
# LLM_ENABLED — Forgejo Actions variable (bool, "true"); unset → llm_enabled=False
#
# In production the browser talks to Caddy on :80/:443.
# Caddy proxies /api/* and /health straight to backend, the rest to frontend.
# So the frontend uses same-origin relative URLs — no NEXT_PUBLIC_API_BASE_URL needed.
#
# Postgres + Redis run alongside the app on the same VM (Discovery mode).
# Volumes are shared with docker-compose.yml so switching between files preserves data.
#
# ── logging: journald (#2761) ────────────────────────────────────────────────
# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ
# и живёт в /var/lib/docker/containers/<id>/ (умирает вместе с контейнером).
# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН
# способ на обе половины, расхождение двух стеков дороже в поддержке.
#
# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера):
# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека
# postgres 2.2 ← 162 МБ за 74 дня
# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5
# ИТОГО ~39 МБ/сутки
# Бюджет journald (замер там же, сообщение самого systemd-journald):
# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён
# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G).
# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки
# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины.
# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с
# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс
# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ.
#
# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть):
# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер
# # История через пересоздания: журнал принадлежит root:systemd-journal, а
# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и
# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт
# # "No entries". Рабочий однострочник — через docker-группу:
# docker run --rm -v /:/host:ro alpine chroot /host sh -c \
# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"'
# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста
# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760).
# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID=<id>
# # — так читается лог УЖЕ УДАЛЁННОГО контейнера).
# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl
# заработает напрямую (host-config, не этот файл). До этого правка не регрессия.
#
# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так
# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так
# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки:
# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка
# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без
# ночных прогонов.
# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при
# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker
# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это
# host drop-in journald.conf.d, не этот файл.
# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями.
#
# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной
# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры
# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size.
x-logging: &default-logging
driver: journald
options:
tag: "{{.Name}}"
services:
postgres:
image: postgis/postgis:16-3.4
logging: *default-logging
restart: unless-stopped
# #2812: /dev/shm под dynamic_shared_memory_type=posix. Умолчание Docker — 64 МБ,
# и параллельные планы кладут туда свои DSM-сегменты. Прод-замер 2026-08-10:
# база постоянно держит ~9.8 МиБ (DSA кумулятивной статистики pgstat), один
# параллельный запрос Объектива берёт ~15.4 МиБ → 4-й одновременный не влезает
# в 64 МиБ и падает `DiskFull: could not resize shared memory segment`. Ровно это
# и случилось: 6 отказов за 1.2 с (market_metrics / sales_series / special_indices).
# 1 ГиБ = ~65 таких запросов; потолок celery (--concurrency=8) + request-path ≈ 12.
# tmpfs выделяется ПО ФАКТУ: значение — потолок, не резерв (0 Б до первого запроса).
# Rollback = убрать строку (снова 64 МиБ) + пересоздать контейнер.
shm_size: 1gb
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Bind to 127.0.0.1 only — accessible from host (for SSH tunnel) but not from public internet.
# UFW additionally blocks 5432 from outside.
ports:
- "127.0.0.1:5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
# GENDESIGN_PG_INITDB_DIR (#2989): та же мина initdb, что описана у trade-in
# (см. tradein-mvp/docker-compose.prod.yml) — на СВЕЖЕМ томе postgres
# прогоняет всё из этого каталога как initdb-миграции ДО восстановления
# дампа Site Finder. На первом старте нового хоста задать переменную на
# ПУСТОЙ каталог — схема тогда приезжает восстановлением дампа, а не
# initdb-цепочкой; после restore переменную снять.
# Дефолт = текущий прод-путь, поведение Beget не меняется.
#
# ⚠️ Postgres этого стека (Site Finder) НЕ параметризован по mem_limit/
# shared_buffers, в отличие от trade-in выше — сейчас у него этих лимитов
# вовсе нет (стоковый shared_buffers=128MB на базе ~15 ГБ, wal_compression
# off). Осознанно НЕ трогается в этой правке (#2989 — только initdb-мина,
# добавление лимитов сервису без них — отдельная задача с отдельным риском
# для текущего прода). См. описание PR.
- ${GENDESIGN_PG_INITDB_DIR:-./backend/db/init}:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 10
networks:
default: {}
shared:
aliases:
- gendesign-postgres
redis:
image: redis:7-alpine
logging: *default-logging
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ
# его достать. До этого redis жил только в gendesign_default, а tradein — в
# gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не
# резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а
# отсутствующая связность (см. #2709).
#
# Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml
# поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES —
# ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый
# сервис в tradein-compose в этот список не попадает и `--no-deps` его не
# подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал
# бы в пустоту. Правка того списка = правка deploy-tradein.yml, который
# сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком.
#
# aliases: тот же приём, что уже применён к postgres выше — стабильное имя
# gendesign-redis вместо compose-зависимого gendesign-redis-1.
# ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется
# блок networks:, неявная привязка к default пропадает, и backend/worker/
# beat/glitchtip потеряли бы брокер (та же грабля описана у postgres).
#
# Разделение ключей — по НОМЕРУ БД, инстанс общий:
# db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей
# db1 — trade-in (SearchCache) ← вводится здесь
# db2 — glitchtip (см. REDIS_URL ниже)
# Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на
# брокере celery вытеснял бы поставленные в очередь таски. Значит tradein
# обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если
# tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая
# celery. Тогда — отдельный инстанс, а не смена политики вытеснения.
networks:
default: {}
shared:
aliases:
- gendesign-redis
# OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI
# вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze —
# отдельный follow-up (A2/A3). Backend ходит к нему по http://osrm:5000 через
# default-сеть. НЕ публикуется наружу (нет ports:) — internal-only, Caddy его
# не проксирует.
#
# Region: Свердловская обл (oblast-clip из Ural-FO extract). Префикс .osrm-файлов
# задаётся OSRM_REGION (default `sverdlovsk`). Файлы /data/<region>.osrm* кладёт
# scripts/build_osrm.sh в ./data/osrm/ (host) → /data (container). Артефакты
# .gitignored (не коммитим ~1.5GB build output) — на VPS строятся offline.
#
# --algorithm mld → pipeline extract→partition→customize (НЕ contract). max-table-size
# поднят для будущих many-to-many table-запросов (изохроны A2).
#
# ВАЖНО: пока scripts/build_osrm.sh не отработал, /data/<region>.osrm нет →
# osrm-routed падает и контейнер crash-loop'ит (restart: unless-stopped). Это
# БЕЗВРЕДНО для деплоя: backend НЕ depends_on osrm, поэтому `compose up -d` в
# deploy.yml не блокируется. Оркестратор строит граф offline и поднимает osrm
# отдельно (docs/osrm-routing.md).
osrm:
image: osrm/osrm-backend:latest
logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm
volumes:
- ./data/osrm:/data
mem_limit: 1.5g
# TCP-probe порта 5000 через bash /dev/tcp. osrm/osrm-backend (Debian-slim)
# НЕ содержит curl/wget — HTTP-healthcheck падал бы на missing binary (ср.
# glitchtip #644). bash в образе есть → /dev/tcp надёжно проверяет, что
# osrm-routed слушает (= граф загружен в RAM, движок принимает запросы).
# start_period щедрый: загрузка MLD-графа Свердл в RAM занимает время.
# Полный route-probe (driving) — в scripts/build_osrm.sh и в PR-инструкции
# для оркестратора (curl с хоста VPS, см. docs/osrm-routing.md).
healthcheck:
test: ["CMD-SHELL", "timeout 3 bash -c '</dev/tcp/127.0.0.1/5000' || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
networks: [default]
# Пеший OSRM (#39 A3) — per-category routing: пешеходные POI (школа/магазин/парк/
# остановки) считаются по пешему графу, авто-POI (ТЦ/больница) — по `osrm` (car).
# Граф sverdlovsk-foot.osrm* собирается `CAR=0 WALK=1 bash scripts/build_osrm.sh`
# (или foot-only build_foot.sh). До сборки графа контейнер crash-loop'ит — безвредно
# (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000.
osrm-walk:
image: osrm/osrm-backend:latest
logging: *default-logging
restart: unless-stopped
command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm
volumes:
- ./data/osrm:/data
mem_limit: 1.5g
healthcheck:
test: ["CMD-SHELL", "timeout 3 bash -c '</dev/tcp/127.0.0.1/5000' || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
networks: [default]
backend:
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
# .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG).
# required: false — compose не падает если файла нет (первый деплой).
env_file:
- path: ./backend/.env
- path: ./backend/.env.runtime
required: false
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "127.0.0.1:8000:8000"
volumes:
# RBAC roles config — single source of truth (auth/roles.yaml в репо).
# FastAPI middleware и /api/v1/me читают этот файл при старте (см.
# app/core/auth.py). Read-only — мутация только через PR.
- ./auth/roles.yaml:/app/auth/roles.yaml:ro
# Полные PDF-отчёты ПТИЦА (#2259 PR-D): worker ПИШЕТ файл сюда, backend его
# ЧИТАЕТ для /report/download → общий writable bind-mount на обоих сервисах.
- ./reports:/app/reports
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
# #976 cross-DB ETL tradein→gendesign: backend подключается к tradein-postgres
# через gendesign_shared network (tradein-postgres уже в этой сети).
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
networks: [default, shared]
frontend:
image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
depends_on:
- backend
# TCP-only probe через node (есть в alpine image, запускает сам server.js).
# НЕ использует HTTP semantics — проверяет только что порт 3000 слушает.
# Если Next.js процесс жив и порт открыт → healthy, независимо от HTTP status.
# start_period 60s даёт время Next.js standalone bundle bootstrap.
healthcheck:
test: ["CMD", "node", "-e", "require('net').createConnection({port:3000,host:'127.0.0.1'}).once('connect',function(){process.exit(0)}).once('error',function(){process.exit(1)})"]
interval: 15s
timeout: 5s
retries: 6
start_period: 60s
worker:
# Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium.
image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
- path: ./backend/.env.runtime
required: false
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./data:/app/data # playwright_state.json + photo binaries
# Полные PDF-отчёты ПТИЦА (#2259 PR-D): worker (build_full_report_task) ПИШЕТ
# PDF сюда, backend ЧИТАЕТ для /report/download → общий writable bind-mount.
- ./reports:/app/reports
# Read-only bind-mount Антоновского /sf/ SQLite — для objective_etl task.
# На VPS файл лежит в /opt/gendesign/site-finder/analysis.db; путь внутри
# контейнера задан settings.objective_anton_sqlite_path (default
# /data/anton-sqlite/analysis.db).
- /opt/gendesign/site-finder:/data/anton-sqlite:ro
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=8", "--queues=celery,scrape_kn,geo"]
# #976 cross-DB ETL tradein→gendesign: worker запускает etl_newbuilding_crossload task,
# которому нужен прямой TCP-доступ к tradein-postgres через gendesign_shared.
# default — обязательно явно, иначе сервис выпадет из дефолтной сети.
networks: [default, shared]
beat:
# Lean backend-образ (без Chromium) — beat только триггерит таски в Redis.
image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest}
logging: *default-logging
restart: unless-stopped
env_file:
- path: ./backend/.env
- path: ./backend/.env.runtime
required: false
depends_on:
redis:
condition: service_healthy
# --schedule=/tmp/...: default location `/app/celerybeat-schedule` падает
# с Permission denied — WORKDIR `/app` принадлежит root, `app` (uid 1000)
# не может создавать там файлы. `/tmp` всегда writable; schedule-файл
# хранит только last_run_at для periodic tasks — потеря на restart OK,
# beat перестроит из `celery_app.conf.beat_schedule` на старте.
command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"]
# ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ────────
# Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.246.224.93),
# окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает целиком вместе с
# томом postgres_data (~15 ГБ) — а внутри него сегодня живут ЧЕТЫРЕ базы:
# gendesign 15 ГБ — Site Finder, уезжает
# auth 7.9 МБ — доступы «Меры»/«Птицы», уезжает с продуктом
# glitchtip 729 МБ — errors.gendsgn.ru, ОСТАЁТСЯ на Beget
# forgejo 265 МБ — git.gendsgn.ru + Actions CI, ОСТАЁТСЯ на Beget
# То есть в момент, когда postgres_data уедет, без базы останутся GlitchTip и
# Forgejo — а из Forgejo идёт сам деплой. Этот сервис — их новый дом.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ ЛЁГКИЙ КЛАСТЕР, А НЕ «оставить postgis на Beget». Держать
# на инфра-хосте postgis/postgis:16-3.4 с 15-гигабайтным томом ради ~1 ГБ
# реальных данных — это лишний диск, лишний RAM (у того сервиса ещё и
# shm_size: 1gb, #2812) и обновления PostGIS ради двух схем, которым геометрия
# не нужна вообще: forgejo (Go) и glitchtip (Django) — обычные реляционные
# схемы, ни geometry/geography, ни GIST по geom. Отсюда postgres:16-alpine —
# МАЖОРНАЯ ВЕРСИЯ ТА ЖЕ (16), значит дампы переносятся один-в-один, без
# апгрейда формата хранения.
#
# ПОРЯДОК ПЕРЕЕЗДА (все умолчания этого файла = сегодняшнее поведение, мерж и
# деплой сами по себе НИЧЕГО не переключают — сервис сидит в профиле `infra`,
# которого нет ни в одном .env):
# 1. дописать INFRA_PG_PASSWORD и FORGEJO_DB_PASS в /opt/gendesign/.env на
# Beget — сегодня их не читает никто, добавление безопасно. ОБА, а не
# один: initdb-скрипт падает на пустом любом из них, и падает уже ПОСЛЕ
# создания PGDATA, то есть повторно не выполнится никогда (см. мину
# initdb ниже и healthcheck, который такой полупустой кластер не пустит
# в healthy);
# 2. поднять пустой кластер: COMPOSE_PROFILES=glitchtip,infra в том же .env
# и `docker compose -p gendesign -f docker-compose.prod.yml up -d
# infra-postgres`. initdb заводит роли и ПУСТЫЕ базы. На прод это не
# влияет — в кластер ещё никто не ходит. Дождаться `healthy` в
# `docker ps`: до появления обеих баз healthcheck красный намеренно;
# 3. руками, в окно: pg_dump forgejo/glitchtip из gendesign-postgres-1 →
# psql ЦЕЛЕВОЙ РОЛЬЮ в gendesign-infra-postgres (почему именно ролью, а
# не суперюзером — в шапке ops/db-bootstrap/infra-postgres/01-*.sh).
# Этот шаг закрывает ops/split-infra-postgres.sh: гасит писателей, снимает
# дампы, заливает, сверяет. По умолчанию — СУХОЙ ПРОГОН, перенос только по
# --apply; прогнать сухой прогон стоит ЗАРАНЕЕ, засветло: он ловит забытый
# пароль в конфиге и расхождение версий до окна, а не в окне;
# 3a. СРАЗУ ЖЕ, тем же движением — прописать в конфиге бэкапа Forgejo
# (/etc/default/gendesign-backup-forgejo) `PG_CONTAINER=gendesign-postgres-1`.
# Не косметика: с момента заливки дампа непустая база forgejo лежит в
# ДВУХ запущенных контейнерах, а автоопределение в ops/backup-forgejo.sh
# на такую неоднозначность намеренно останавливается с exit 1 — то есть
# ночной бэкап git-хоста перестанет сниматься вовсе, и заметить это
# некому (канал оповещений check-backup-staleness.sh на этом хосте не
# настроен, см. ops/crontab-beget.cron). Пока боевой кластер старый —
# указываем старый; на шаге 4 меняем значение на gendesign-infra-postgres;
# после гашения старого кластера строку убираем совсем;
# 4. cutover: GLITCHTIP_DB_HOST=infra-postgres в /opt/gendesign/.env и
# `HOST = infra-postgres:5432` в app.ini Forgejo
# (/home/gendesign/forgejo/, его compose-файла в этом репозитории нет),
# затем перезапуск этих двух приложений и правка PG_CONTAINER из 3a.
# Откат = вернуть обе строки как было. Старый кластер к этому моменту не
# тронут: снятие дампов ничего в нём не меняет.
infra-postgres:
image: postgres:16-alpine
# Стабильное имя вместо compose-генерируемого `gendesign-infra-postgres-1`:
# ровно это имя перечислено в PG_CONTAINER_CANDIDATES у ops/backup-forgejo.sh
# (он ходит в БД через `docker exec "$PG_CONTAINER"` и выбирает контейнер по
# факту наличия данных, а не по зашитой константе — разбор в шапке того
# скрипта). Тот же приём уже применён у glitchtip-web, glitchtip-worker и
# gendesign-auth-forwarder.
container_name: gendesign-infra-postgres
logging: *default-logging
restart: unless-stopped
# РОВНО ОДИН профиль, и он НОВЫЙ — `infra`. Ни в одном /opt/gendesign/.env
# такого значения сегодня нет, поэтому мерж этой правки и любой следующий
# деплой для сервиса — честный no-op: compose его не видит, контейнер не
# создаётся, том не создаётся.
#
# ПОЧЕМУ НЕ `glitchtip`, хотя технически он подошёл бы. На Beget в
# /opt/gendesign/.env уже стоит COMPOSE_PROFILES=glitchtip (его выставляет
# scripts/bootstrap_glitchtip.sh), а docker-compose.prod.yml перечислен в
# paths: у .forgejo/workflows/deploy.yml, и шаг деплоя делает
# `set -a; source .env` и затем `docker compose -p gendesign up -d`. Общий
# профиль с glitchtip-web/worker означал бы, что кластер стартует на БОЕВОМ
# хосте в тот же час, когда PR влит, — то есть переключателем оказался бы
# сам мерж, что прямо противоречит требованию «все умолчания сохраняют
# сегодняшнее поведение». Хуже того, старт был бы СЛОМАННЫМ и МОЛЧАЛИВЫМ:
# INFRA_PG_PASSWORD в .env к тому моменту ещё нет, официальный энтрипойнт
# отказывается инициализировать кластер без пароля суперюзера, а
# `restart: unless-stopped` превращает это в бесконечный restart-loop. Деплой
# при этом зелёный — `up -d` не ждёт сервис, на который никто не depends_on,
# а health-check шага деплоя смотрит только на backend.
# Отдельный профиль переносит старт кластера туда, где ему и место: в шаг 2
# порядка переезда, то есть в правку одной переменной на VM
# (COMPOSE_PROFILES=glitchtip,infra), выполняемую осознанно и с уже
# заполненными паролями. Откат — убрать `,infra`.
#
# ГАРАНТИЯ «GlitchTip не поднимется без своей БД» от этого не теряется: до
# cutover он ходит в общий кластер `postgres` и к infra-postgres отношения
# не имеет, а после cutover COMPOSE_PROFILES на остающемся хосте уже
# содержит infra — иначе шага 4 просто не было бы.
#
# Профиль назван по ХОСТУ, а не по приложению, потому что главный
# потребитель этого кластера — Forgejo, которого в данном compose нет вовсе
# (живёт отдельным стеком в /home/gendesign/forgejo, см. шапку
# ops/backup-forgejo.sh): привязать «БД для Forgejo» через depends_on не к
# чему. Если GlitchTip когда-нибудь выключат, COMPOSE_PROFILES=infra всё
# равно поднимет кластер — git.gendsgn.ru не должен зависеть от судьбы
# трекера ошибок.
# На Selectel профиль `infra` не активируется никогда (и GlitchTip, и Forgejo
# остаются на Beget) → сервис там не стартует и тома не создаёт.
profiles: ["infra"]
environment:
# Суперюзерская пара САМОГО кластера — это НЕ роли forgejo/glitchtip.
# Ею initdb-скрипт заводит прикладные роли и базы, ею же удобно ходить
# `docker exec`-ом в окно миграции и при разборе инцидентов.
# У пароля намеренно НЕТ дефолта-заглушки: с пустым INFRA_PG_PASSWORD
# официальный образ откажется инициализировать кластер с внятным
# "Database is uninitialized and superuser password is not specified",
# тогда как тихий `changeme` на хосте с публичным Forgejo — это дыра,
# которую никто не заметит.
POSTGRES_DB: ${INFRA_PG_DB:-infra}
POSTGRES_USER: ${INFRA_PG_USER:-infra}
POSTGRES_PASSWORD: ${INFRA_PG_PASSWORD}
# Читаются ТОЛЬКО initdb-скриптом (ops/db-bootstrap/infra-postgres/
# 01-roles-and-databases.sh) — он создаёт ими роли-владельцы баз.
# GLITCHTIP_DB_PASS переиспользуется как есть, новая переменная не
# заводится: это тот же пароль, что уже стоит в DATABASE_URL у
# glitchtip-web/worker, и после cutover он ОБЯЗАН совпадать — иначе
# GlitchTip не залогинится в перенесённую базу.
FORGEJO_DB_PASS: ${FORGEJO_DB_PASS}
GLITCHTIP_DB_PASS: ${GLITCHTIP_DB_PASS}
volumes:
- infra_postgres_data:/var/lib/postgresql/data
# ⚠️ ТА ЖЕ МИНА initdb, что описана у сервиса postgres выше (#2989) и в
# tradein-mvp/docker-compose.prod.yml: каталог /docker-entrypoint-initdb.d
# исполняется ТОЛЬКО на ПУСТОМ томе и ТОЛЬКО один раз — на самом первом
# старте, до того как в кластер попадут любые данные.
# Здесь это ровно то, что нужно: сначала initdb создаёт роли и пустые базы,
# ПОТОМ руками заливаются дампы (роли обязаны существовать до
# восстановления — дампы сняты с --no-owner --clean --if-exists).
# Обратная сторона та же: если том infra_postgres_data уже не пуст, скрипты
# МОЛЧА не выполнятся — ни ошибки, ни строчки в логе, и правка скрипта
# после первого старта сама не применится. Прогнать заново =
# `docker volume rm gendesign_infra_postgres_data` (кластер пересоздастся
# с нуля) либо выполнить те же команды psql руками.
- ./ops/db-bootstrap/infra-postgres:/docker-entrypoint-initdb.d:ro
# Healthcheck проверяет НЕ ТОЛЬКО «кластер принимает соединения», но и что
# обе базы на месте — и это главное здесь, а не перестраховка.
#
# ЗАЧЕМ. Мина initdb выше имеет злую разновидность: ОТРАВЛЕННЫЙ ТОМ. Порядок
# шагов официального энтрипойнта — сначала initdb создаёт PGDATA (пишет
# PG_VERSION), и ТОЛЬКО ПОТОМ исполняется /docker-entrypoint-initdb.d.
# Значит любое падение bootstrap-скрипта (проще всего — забыть один из двух
# паролей в .env: FORGEJO_DB_PASS новый, а GLITCHTIP_DB_PASS там уже есть,
# так что «половина» выглядит совершенно правдоподобно) роняет контейнер
# УЖЕ ПОСЛЕ инициализации тома. На следующем рестарте энтрипойнт видит
# непустой PGDATA, объявляет DATABASE_ALREADY_EXISTS и пропускает
# initdb.d НАВСЕГДА — кластер поднимается, но ролей forgejo/glitchtip и их
# баз в нём нет и больше не появится.
# Один `pg_isready` такой кластер от готового не отличает: он зелёный,
# `docker ps` показывает healthy, и обнаружилось бы это в ночь переезда, на
# заливке дампа, репликой `role "forgejo" does not exist` — в окно, когда
# разбираться уже некогда. Проверка наличия обеих баз делает отравленный том
# видимым сразу и в самом обычном месте — в колонке STATUS у `docker ps`.
# Лечение то же: `docker volume rm gendesign_infra_postgres_data` и поднять
# заново с заполненными паролями.
#
# Идём в служебную базу `postgres`, а не в ${POSTGRES_DB}: pg_database
# виден из любой базы, а лишняя привязка к имени тут ни к чему. `-tA` даёт
# голое `t`/`f` без рамок и пробелов, `grep -qx t` не даст `f` пролезть как
# непустой вывод. Проверка остаётся зелёной и после переезда: базы никуда не
# деваются, а восстановление дампа с `--clean --if-exists` дропает объекты
# ВНУТРИ базы, но не саму базу.
healthcheck:
test:
- CMD-SHELL
- >-
pg_isready -q -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" &&
psql -U "$${POSTGRES_USER}" -d postgres -tAc
"SELECT count(*) = 2 FROM pg_database WHERE datname IN ('forgejo', 'glitchtip')"
| grep -qx t
interval: 10s
timeout: 5s
retries: 10
# 20s хватало на «кластер поднялся»; теперь в старт входит ещё и прогон
# initdb.d (создание двух баз из template0) — это доли секунды, но
# запас до 30s снимает мигание unhealthy на холодном старте.
start_period: 30s
# ~1 ГБ данных против 15 ГБ у основного кластера (glitchtip 729 МБ + forgejo
# 265 МБ). Считаем по-крупному: стоковый shared_buffers 128 МБ + ~25
# соединений (пул Forgejo + web/celery GlitchTip) по ~8 МБ + запас на
# autovacuum и на восстановление дампа в окно миграции
# (maintenance_work_mem 64 МБ) ≈ 400 МБ пика. 512 МБ даёт над этим воздух и
# при этом на порядок меньше, чем съел бы оставленный на хосте postgis.
# Ниже (256 МБ) опускать не стоит — OOM-kill пришёлся бы ровно на заливку
# дампов, то есть на самый неудобный момент.
mem_limit: 512m
# shm_size НЕ поднимаем. У основного postgres 1 ГБ понадобился под DSM
# параллельных планов по таблицам Site Finder (#2812); ни Forgejo, ни
# GlitchTip на своих объёмах параллельных планов не строят — дефолтных
# 64 МБ хватает с запасом.
#
# Портов НЕТ и на хост ничего не пробрасывается — сознательно. У основного
# postgres есть `127.0.0.1:5432` ради SSH-туннеля к рабочей БД
# (`ssh -N gendesign` → localhost:15432), здесь такой нужды нет: обе базы
# обслуживаются своими приложениями по докер-сети, а разовая миграция и
# разбор инцидентов идут через `docker exec gendesign-infra-postgres psql`.
# Лишний слушающий сокет на хосте, где стоит ПУБЛИЧНЫЙ Forgejo, — чистый
# прирост поверхности атаки без единого выигрыша.
#
# Сеть ровно одна и именно default (`gendesign_default`): в ней сидит
# контейнер `forgejo` из отдельного стека /home/gendesign/forgejo, поэтому он
# достучится сюда по имени `infra-postgres`. `default` перечислен ЯВНО — как
# только у сервиса появляется блок networks:, неявная привязка к дефолтной
# сети пропадает (та же грабля описана у postgres и redis выше). `shared` не
# нужна: с trade-in этот кластер не разговаривает.
networks: [default]
glitchtip-web:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-web
logging: *default-logging
# profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`.
# Bootstrap script activates the profile after DB + secrets are ready.
# On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env.
profiles: ["glitchtip"]
# depends_on: БД здесь больше НЕТ, остался только redis (#3061). Зависимость
# обязана быть верной в ОБЕИХ фазах переезда, а она статична — имя сервиса
# нельзя вывести из GLITCHTIP_DB_HOST. Разобраны оба варианта:
# * оставить `postgres: service_healthy` — после cutover этот сервис на
# Beget не запускается вовсе (его том с Site Finder уехал), а depends_on
# на незапущенный сервис НЕ игнорируется: compose поднимет postgres
# вместе с glitchtip-web, то есть воскресит 15-гигабайтный postgis ровно
# там, откуда его убирали;
# * поставить `infra-postgres: service_healthy` — сделало бы доступность
# работающего сегодня GlitchTip заложником кластера, который до cutover
# ПУСТ и никем не используется: любая заминка с его паролями или
# healthcheck'ом роняла бы трекер ошибок. Это прямо нарушает требование
# «мерж и деплой ничего не меняют до переноса данных».
# Терять при этом нечего: `service_healthy` у postgres доказывал лишь то, что
# кластер принимает соединения, а не что база glitchtip и её миграции на
# месте. Холодный старт с ещё не готовой БД GlitchTip переживает — контейнер
# падает и поднимается заново по `restart: always` (ниже) с экспоненциальным
# backoff'ом и сходится за десятки секунд. redis оставлен: он в дефолтном
# профиле, присутствует в обеих фазах и нужен и брокеру celery, и веб-морде.
depends_on:
redis:
condition: service_started
environment:
# GLITCHTIP_DB_HOST (#3061) — переключатель хоста БД на время переезда.
# Дефолт `postgres` = СЕГОДНЯШНЕЕ поведение байт-в-байт, поэтому мерж этой
# правки сам по себе ничего не меняет. Переключение на лёгкий кластер —
# одна строка `GLITCHTIP_DB_HOST=infra-postgres` в /opt/gendesign/.env на
# остающемся хосте, и только ПОСЛЕ того, как база glitchtip перенесена туда
# дампом. Откат — убрать строку и перезапустить оба glitchtip-контейнера.
# ⚠️ ПУСТОЕ значение переменной — НЕ то же самое, что «не задана»:
# `GLITCHTIP_DB_HOST=` даст DSN вида postgres://glitchtip:pass@:5432/...,
# то есть хост исчезнет из строки подключения и Django пойдёт в локальный
# сокет. Подстановка `:-` срабатывает только для НЕЗАДАННОЙ переменной. Та
# же грабля описана у CADDY_SITES в блоке caddy ниже: либо не задавать
# вовсе, либо задавать имя сервиса.
DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@${GLITCHTIP_DB_HOST:-postgres}:5432/glitchtip
REDIS_URL: redis://redis:6379/2
SECRET_KEY: ${GLITCHTIP_SECRET}
PORT: "8080"
# Почта отключена по умолчанию: consolemail:// печатает письмо в stdout и
# никуда его не отправляет. Реальный адрес приходит из /opt/gendesign/.env
# (GLITCHTIP_EMAIL_URL) — в репозитории пароля почтового ящика быть не должно.
#
# ВАЖНО про схему DSN (django-environ, парсер GlitchTip): для порта 465 с
# implicit SSL нужна схема smtp+ssl://, а НЕ smtps:// — вторая помечена
# deprecated и включает STARTTLS (EMAIL_USE_TLS), то есть 465 с ней рвёт
# соединение. Для 587/STARTTLS схема — smtp+tls://.
# smtp+ssl://alerts%40meraocenka.ru:ПАРОЛЬ@smtp.beget.com:465
# Логин — почтовый адрес целиком, @ в нём кодируется как %40.
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
GLITCHTIP_DOMAIN: https://errors.gendsgn.ru
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru}
ENABLE_USER_REGISTRATION: "true"
ENABLE_ORGANIZATION_CREATION: "false"
restart: always
mem_limit: 512m
# Образ glitchtip:6.1.6 НЕ содержит wget/curl (только python3) → старый
# wget-healthcheck падал на missing-binary → контейнер вечно unhealthy (#644).
# Пробуем канонический /_health/ (GET → 200) через встроенный python3.
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/_health/', timeout=8).status == 200 else 1)"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
networks: [default]
glitchtip-worker:
image: glitchtip/glitchtip:6.1.6
container_name: glitchtip-worker
logging: *default-logging
profiles: ["glitchtip"]
# depends_on без БД — по тем же причинам, что у glitchtip-web выше (там же
# разбор обоих отвергнутых вариантов). Для celery это ещё безопаснее: он и
# так обязан переживать обрыв соединения с базой в рантайме, а не только на
# старте.
depends_on:
redis:
condition: service_started
command: ./bin/run-celery-with-beat.sh
environment:
# Тот же переключатель GLITCHTIP_DB_HOST, что у glitchtip-web (полное
# обоснование и грабля с ПУСТЫМ значением — в комментарии там). Значение
# обязано совпадать с web: это один и тот же экземпляр базы, и разъехавшись,
# они дадут веб-морду и воркер, читающих разные кластеры.
DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@${GLITCHTIP_DB_HOST:-postgres}:5432/glitchtip
REDIS_URL: redis://redis:6379/2
SECRET_KEY: ${GLITCHTIP_SECRET}
CELERY_WORKER_AUTOSCALE: "1,3"
# Письма и веб-хуки шлёт celery, то есть ИМЕННО этот контейнер, а не web.
# До этой правки почтовых переменных здесь не было вовсе: настройка одного
# glitchtip-web не дала бы ни одного отправленного письма — worker брал
# умолчания образа. Значения обязаны совпадать с web (см. комментарий там).
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru}
# Нужен для абсолютных ссылок внутри писем и веб-хуков: без него
# уведомление приходит со ссылкой в никуда.
GLITCHTIP_DOMAIN: https://errors.gendsgn.ru
restart: always
mem_limit: 384m
# GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15,
# см. tradein-mvp/backend/app/api/v1/glitchtip.py): вебхуки шлёт РЕАЛЬНО
# этот celery-воркер (apps/alerts/webhooks.py send_webhook — не glitchtip-web),
# получателю `webhook` нужен доступ к http://tradein-backend:8000/... —
# tradein-backend сидит на gendesign_shared, у glitchtip-* её раньше не было
# вообще (та же грабля, что #2709 у redis: сеть должна быть общей ДО того,
# как переменная окружения с URL вообще имеет смысл). default — обязательно
# явно, иначе воркер потеряет Postgres/Redis-брокер (см. комментарий у redis
# выше про неявную привязку к default).
networks: [default, shared]
caddy:
image: caddy:2
logging: *default-logging
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. Caddyfile
# подставляет его в header_up X-Internal-Auth-Secret на /trade-in/* роутах.
# Пустой дефолт = fail-open (backend не проверяет) → ничего не ломается до
# провижининга. Значение задаётся руками в /opt/gendesign/.env на VPS.
TRADEIN_INTERNAL_AUTH_SECRET: ${TRADEIN_INTERNAL_AUTH_SECRET:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro
# Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог
# держится в git через caddy/local/.gitignore — иначе docker создал бы
# отсутствующий bind-source сам, root-owned пустышкой.
- ./caddy/local:/etc/caddy/caddy/local:ro
# Site-блоки, разложенные по хостам (#3059). Каддифайл импортирует их как
# `import caddy/sites/{$CADDY_SITES:*}.caddy` — без переменной берутся оба
# файла (текущее поведение Beget, все восемь доменов), CADDY_SITES=apps на
# Selectel даёт пять уезжающих, =infra на Beget после переезда — три
# остающихся (git / errors / obsidian).
# ⚠️ ПУСТОЕ значение переменной — не то же самое, что «не задана»: Caddy
# подставит пустую строку, получится путь caddy/sites/.caddy и конфиг не
# соберётся. Либо не задавать вовсе, либо задавать apps/infra.
- ./caddy/sites:/etc/caddy/caddy/sites:ro
- ./preview:/srv/preview:ro
- caddy_data:/data
- caddy_config:/config
- caddy_logs:/var/log/caddy
# backend: service_healthy — backend имеет /health endpoint, готов до Caddy.
# frontend: service_started — НЕ service_healthy: даже если frontend healthcheck
# дребезжит, Caddy всё равно стартует (можно отдавать 502 для /, но
# obsidian.gendsgn.ru, api.gendsgn.ru и т.д. остаются доступны).
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
networks:
- default
# shared — для маршрута obsidian.gendsgn.ru → couchdb (отдельный stack).
# Если obsidian-стек не задеплоен, Caddy просто получит 502 на этом маршруте,
# main-приложение не страдает.
- shared
glitchtip-auth-forwarder:
# Собирается локально на VPS при деплое (не тянется из GHCR).
# deploy.yml запускает: docker compose build glitchtip-auth-forwarder
build: ./ops/glitchtip-auth-forwarder
container_name: gendesign-auth-forwarder
logging: *default-logging
restart: unless-stopped
environment:
GLITCHTIP_DSN: ${GLITCHTIP_DSN}
APP_ENV: production
APP_RELEASE: auth-forwarder-1
CADDY_LOG_FILE: /var/log/caddy/auth_audit.log
STATE_FILE: /state/offset.json
volumes:
# Read-only доступ к Caddy access log
- caddy_logs:/var/log/caddy:ro
# Persistent offset — выживает при перезапуске контейнера
- auth_forwarder_state:/state
depends_on:
- caddy
volumes:
postgres_data:
# Данные лёгкого кластера остающейся инфраструктуры (#3061). Отдельный том, а
# не соседство в postgres_data: тот уезжает на Selectel целиком, а этот обязан
# остаться на Beget — 30.08.2026 их жизненные циклы расходятся навсегда.
infra_postgres_data:
redis_data:
caddy_data:
caddy_config:
caddy_logs:
auth_forwarder_state:
networks:
# Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md).
# Связывает main-stack (Caddy) и obsidian-stack (CouchDB).
shared:
external: true
name: gendesign_shared