# 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 '