# 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// (умирает вместе с контейнером). # Тот же 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= # # — так читается лог УЖЕ УДАЛЁННОГО контейнера). # Владельцу стоит разово выдать `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/.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/.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 '&1` там была бы пустота вместо причины. # retries 5 (не 3): при interval 60s тройка промахов = 3 минуты, столько # длится обычный флап Redis, а из unhealthy контейнер сам не выходит по # restart-политике — следующий деплой краснел бы за исправный воркер. healthcheck: test: ["CMD-SHELL", "celery -A app.workers.celery_app inspect ping -d celery@$$(hostname) -t 10 >/dev/null"] interval: 60s timeout: 30s retries: 5 start_period: 90s # #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"] # #3324: beat тоже жил без healthcheck'а. На `inspect ping` beat НЕ отвечает # (remote control — свойство воркера, не планировщика), поэтому проба другая: # СВЕЖЕСТЬ shelve-файла расписания. Celery beat синкует его на диск не реже # чем раз в `Scheduler.sync_every` = 180 с — но только когда в этом окне была # отправлена задача. У нас в расписании есть поминутная (nspd-geo-zombie- # cleanup, `* * * * *`) и двухминутная задачи, так что живой beat обновляет # mtime примерно каждые 3 минуты, а вставший — не обновляет вовсе. Порог 10 # минут = 3× запас к этой каденции. # Почему glob `celerybeat-schedule*`: имя на диске зависит от того, какой # backend выберет shelve/dbm в образе (gnu → тот же файл, dumb → .dat/.dir). # Почему не `pgrep`/`true`: PID-1 процесс жив ровно пока жив контейнер — # такая проба повторяет `State.Running` и не ловит подвисший планировщик. # `grep -q .`: сам find возвращает 0 и когда не нашёл ничего. healthcheck: test: ["CMD-SHELL", "find /tmp -maxdepth 1 -name 'celerybeat-schedule*' -mmin -10 | grep -q ."] interval: 60s timeout: 10s retries: 3 start_period: 120s # ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ──────── # Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.124.37.140), # окно 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" # Встроенный MCP-сервер GlitchTip (появился в 6.1, у нас образ 6.1.6). # settings.py:291 — `GLITCHTIP_ENABLE_MCP = env.bool(..., False)`, то есть по # умолчанию ВЫКЛЮЧЕН, и https://errors.gendsgn.ru/mcp сейчас отдаёт 404 # (проверено curl'ом 29.08). Флаг читается в двух местах: asgi.py:181 # оборачивает ASGI-приложение в MCPDjangoDispatcher, api/api.py:189 цепляет # роуты. Отсюда — переменная нужна ТОЛЬКО web-контейнеру: worker крутит # celery и HTTP не отдаёт вовсе. # # ЗАЧЕМ. Заменяет неофициальный npm-пакет mcp-glitchtip (coffebar, 0.1.2 от # 25.08.2025, всего 2 инструмента) на вендорский с 17 инструментами, среди # которых detect_n_plus_one и get_transaction_trend. Четыре span-инструмента # требуют DuckDB и без него просто не отработают — остальные 13 не зависят. # # АУТЕНТИФИКАЦИЯ. Клиент может ходить статическим APIToken в заголовке # `Authorization: Bearer`, полный OAuth-флоу не обязателен: провайдер имеет # фолбэк (apps/oauth/provider.py:281-291 — «check regular API tokens ... for # MCP clients using Bearer auth instead of the full OAuth flow»), который # валидирует токен через apps/mcp/auth.py. Проверено чтением ИМЕННО образа # 6.1.6: апстрим-issue #473 про 401 на Bearer описывает более раннее # состояние кода и здесь уже неактуален. Минимальные scope токена — # org:read + project:read + event:read (apps/mcp/server.py, _check_scopes). # # BLAST RADIUS. Пересоздаётся только glitchtip-web (env — свойство создания # контейнера): единицы секунд без приёма событий, клиентские SDK буферизуют. # Схема БД и миграции не затрагиваются — это чистый флаг роутинга. # ОТКАТ: убрать строку, передеплоить; /mcp снова станет 404, а старый # npm-клиент продолжит работать через /api/0/ — он этим флагом не задет. GLITCHTIP_ENABLE_MCP: "true" 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:-} # #3059: какие site-блоки импортирует Caddyfile (`import # caddy/sites/{$CADDY_SITES:*}.caddy`). Плейсхолдер раскрывает САМ Caddy # из окружения СВОЕГО контейнера, а не compose из файла окружения хоста — # поэтому переменную обязательно пробрасывать сюда. Без этой строки # значение с хоста до Caddy не доходит и всегда работает дефолт `*`, # то есть оба файла сразу, а разделение хостов молча не срабатывает: # после переезда (#3057) Caddy на продовом хосте начал бы выпускать # сертификаты для obsidian/errors/git, чей DNS остаётся на # инфраструктурном хосте — HTTP-01 падает, а Let's Encrypt считает # неудачи (5 на домен в час). # Дефолт `*` = сегодняшнее поведение бит в бит: пока переменная не задана # ни на одном хосте, импортируются оба файла, как и до этой строки. # В окне: на продовом хосте CADDY_SITES=apps, на инфраструктурном=infra. CADDY_SITES: ${CADDY_SITES:-*} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro # #3102 postmortem: сниппеты метрик импортируются из caddy/sites/infra.caddy # (`import ../metrics-*.caddy.snippet`). Смонтированы ПОФАЙЛОВО, как # users.caddy.snippet — сам каталог caddy/ внутрь не пробрасывается. # Забыть монт = Caddy не адаптирует конфиг и уходит в restart-loop, # роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru. - ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro - ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.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