mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
Ход AI выполнялся прямо в приёме сообщения: цикл опроса мессенджеров и HTTP-запрос виджета ждали провайдера, держа открытой транзакцию организации. Один ход — это два обращения к модели (эмбеддинг и чат) по тридцать секунд с двумя повторами, то есть до трёх минут, и всё это время ни одно входящее по всей установке не забиралось. Владелец видел это как «бот залипает»: сайт и MAX на одном агенте отвечали с задержками или молчали. Приём теперь доводит дело до записи сообщения и ставит событие `conversation.ai_turn_requested`. Ход считает роль событий воркера (`run_worker --role=events`) короткими транзакциями, между которыми остаются походы к провайдеру и в мессенджер. Туда же уехала расшифровка голосовых — последнее обращение наружу из цикла опроса. Воркер разделён на роли: `poller` опрашивает подключения и ведёт периодические работы (один экземпляр — курсоры и паузы после сбоя живут в его памяти), `events` разбирает outbox и масштабируется репликами (`CHATBALLS_EVENT_WORKERS`, по умолчанию две). Роль `all` осталась для разработки. Очередь событий научилась двум вещам: события одного диалога не выдаются параллельно (иначе два ответа приезжают клиенту вперемешку) и событие, взятое упавшим процессом, возвращается в очередь по истечении аренды. Попутно убраны мины, которые тот же залип и продлевали: - ход клиенту ограничен своим таймаутом (CHATBALLS_AI_TURN_TIMEOUT, 20 с) и сроком годности (CHATBALLS_AI_TURN_DEADLINE_SECONDS, 120 с) — просроченный ход не зовёт модель, а передаёт диалог оператору; - отказ провайдера по существу запроса (4xx, кроме 429) больше не повторяется трижды по таймауту; - предохранитель провайдера считает сбои по ключу «организация + интеграция», а не один на процесс: отозванный ключ одной организации гасил AI у всех; - потолок паузы после сбоя опроса — минута вместо четверти часа: он был компромиссом ради журнала однопоточного воркера. Виджет узнаёт, что ответ считается, по признаку `thinking` в ленте. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
383 lines
19 KiB
YAML
383 lines
19 KiB
YAML
# Канонический production-манифест Chatballs (ADR-CHATBALLS-0028).
|
||
# Один Compose для: установки клиента, staging, smoke.
|
||
# Локальная разработка — через override compose.dev.yaml; этот файл не содержит
|
||
# dev source mounts, dev-команд и host-портов (кроме public 80/443 у gateway).
|
||
#
|
||
# Этот файл — весь дистрибутив. Рядом с ним не должно лежать ничего: ни
|
||
# Caddyfile, ни init-скриптов базы, ни генератора секретов — всё это внутри
|
||
# образов (deploy/docker/*.Dockerfile). Поэтому установка на чистый хост
|
||
# выглядит так:
|
||
#
|
||
# curl -fsSL <ссылка на compose.yaml со страницы релиза> -o compose.yaml
|
||
# docker compose up -d --wait
|
||
#
|
||
# В релизной копии ссылки на образы уже закреплены по digest (их подставляет
|
||
# CI). Значения по умолчанию ниже — для сборки из исходников: с
|
||
# compose.dev.yaml они собираются локально.
|
||
#
|
||
# У продукта нет .env: секреты инстанса генерирует сервис secrets при первом
|
||
# старте, всё остальное человек настраивает в UI.
|
||
|
||
services:
|
||
# Секреты инстанса (ключ подписи, пароли ролей БД) генерирует первый старт;
|
||
# они живут в томе chatballs-secrets. Человек их не вводит и не хранит:
|
||
# установка — одна команда, всё остальное настраивается в UI. Скрипт
|
||
# идемпотентен — перезапуск не меняет пароли работающей базы.
|
||
#
|
||
# Крутится на backend-образе: он всё равно нужен стеку, а скрипт запечён в
|
||
# него (apps/backend/Dockerfile.production).
|
||
secrets:
|
||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||
entrypoint: ["/bin/sh", "/usr/local/bin/chatballs-generate-secrets.sh"]
|
||
# Скрипт создаёт каталог и выставляет права — это делает root, а
|
||
# production-образ работает под hub.
|
||
user: root
|
||
restart: "no"
|
||
volumes:
|
||
- chatballs-secrets:/run/chatballs/secrets
|
||
- chatballs-secrets-platform:/run/chatballs/secrets/platform
|
||
- chatballs-secrets-schema:/run/chatballs/secrets/schema
|
||
|
||
postgres:
|
||
image: ${CHATBALLS_POSTGRES_IMAGE:-chatballs-postgres:dev}
|
||
restart: unless-stopped
|
||
environment:
|
||
POSTGRES_DB: ${POSTGRES_DB:-chatballs}
|
||
POSTGRES_USER: ${POSTGRES_USER:-chatballs_bootstrap}
|
||
POSTGRES_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_password
|
||
POSTGRES_APP_USER: ${POSTGRES_APP_USER:-chatballs_app}
|
||
POSTGRES_APP_PASSWORD_FILE: /run/chatballs/secrets/postgres_app_password
|
||
POSTGRES_PLATFORM_USER: ${POSTGRES_PLATFORM_USER:-chatballs_platform}
|
||
POSTGRES_PLATFORM_PASSWORD_FILE: /run/chatballs/secrets/platform/postgres_platform_password
|
||
POSTGRES_MIGRATION_USER: ${POSTGRES_MIGRATION_USER:-chatballs_migration}
|
||
POSTGRES_MIGRATION_PASSWORD_FILE: /run/chatballs/secrets/schema/postgres_migration_password
|
||
depends_on:
|
||
secrets:
|
||
condition: service_completed_successfully
|
||
volumes:
|
||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
|
||
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
|
||
- chatballs-postgres:/var/lib/postgresql/data
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 10
|
||
|
||
redis:
|
||
image: ${CHATBALLS_REDIS_IMAGE:-redis:7-alpine}
|
||
restart: unless-stopped
|
||
command: ["redis-server", "--appendonly", "yes"]
|
||
volumes:
|
||
- chatballs-redis:/data
|
||
healthcheck:
|
||
test: ["CMD", "redis-cli", "ping"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 10
|
||
start_period: 20s
|
||
|
||
# One-shot init: только миграции (ADR-CHATBALLS-0028 §one-shot init). Владельца и
|
||
# демо-данные создаёт мастер первого запуска в браузере (/api/v1/setup/).
|
||
# collectstatic испечён в backend-образ, здесь не вызывается.
|
||
init:
|
||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||
volumes:
|
||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
|
||
command: ["python", "manage.py", "migrate", "--noinput"]
|
||
environment:
|
||
CHATBALLS_DB_ROLE: migration
|
||
restart: "no"
|
||
depends_on:
|
||
postgres:
|
||
condition: service_healthy
|
||
redis:
|
||
condition: service_healthy
|
||
|
||
backend-app:
|
||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||
restart: unless-stopped
|
||
command: >
|
||
sh -c "gunicorn chatballs_backend.asgi_app:application
|
||
--worker-class uvicorn.workers.UvicornWorker
|
||
--bind 0.0.0.0:8000
|
||
--workers $${CHATBALLS_GUNICORN_WORKERS:-3}
|
||
--timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
|
||
environment:
|
||
CHATBALLS_DB_ROLE: app
|
||
volumes:
|
||
# Только общий том: паролей platform и migration у публичного процесса
|
||
# нет даже в файловой системе (они в своих томах, см. сервис secrets).
|
||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||
# Локальные файлы — именованный том, а не каталог с хоста. Том наследует
|
||
# владельца из образа (hub), поэтому загрузки работают и на Linux, где
|
||
# bind-mount достался бы контейнеру как root:root и падал с EACCES.
|
||
- chatballs-media:/app/apps/backend/media
|
||
# Обмен с сервисом updater: сюда кладётся запрос на обновление, отсюда
|
||
# читается его статус. Доступа к Docker у приложения нет и не будет.
|
||
- chatballs-updates:/run/chatballs/updates
|
||
depends_on:
|
||
init:
|
||
condition: service_completed_successfully
|
||
postgres:
|
||
condition: service_healthy
|
||
redis:
|
||
condition: service_healthy
|
||
healthcheck:
|
||
test:
|
||
[
|
||
"CMD",
|
||
"python",
|
||
"-c",
|
||
"import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CHATBALLS_APP_HEALTHCHECK_HOST', 'app.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)",
|
||
]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 10
|
||
start_period: 20s
|
||
|
||
backend-platform:
|
||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||
volumes:
|
||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
|
||
restart: unless-stopped
|
||
command: >
|
||
sh -c "gunicorn chatballs_backend.asgi_platform:application
|
||
--worker-class uvicorn.workers.UvicornWorker
|
||
--bind 0.0.0.0:8000
|
||
--workers $${CHATBALLS_GUNICORN_WORKERS:-3}
|
||
--timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
|
||
environment:
|
||
CHATBALLS_DB_ROLE: platform
|
||
depends_on:
|
||
init:
|
||
condition: service_completed_successfully
|
||
postgres:
|
||
condition: service_healthy
|
||
redis:
|
||
condition: service_healthy
|
||
healthcheck:
|
||
test:
|
||
[
|
||
"CMD",
|
||
"python",
|
||
"-c",
|
||
"import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CHATBALLS_PLATFORM_HEALTHCHECK_HOST', 'platform.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)",
|
||
]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 10
|
||
start_period: 20s
|
||
|
||
# Django admin не подключён к gateway. Единственный host bind — loopback;
|
||
# удалённый доступ допускается только через SSH tunnel (ADR-CHATBALLS-0031).
|
||
backend-admin:
|
||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||
volumes:
|
||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||
- chatballs-secrets-schema:/run/chatballs/secrets/schema:ro
|
||
restart: unless-stopped
|
||
command: >
|
||
sh -c "gunicorn chatballs_backend.asgi_admin:application
|
||
--worker-class uvicorn.workers.UvicornWorker
|
||
--bind 0.0.0.0:8000
|
||
--workers $${CHATBALLS_GUNICORN_WORKERS:-2}
|
||
--timeout $${CHATBALLS_GUNICORN_TIMEOUT:-60}"
|
||
# Break-glass technical surface only: schema credentials are never used by
|
||
# public app/platform runtimes and this service remains loopback-only.
|
||
environment:
|
||
CHATBALLS_DB_ROLE: migration
|
||
ports:
|
||
- "127.0.0.1:${CHATBALLS_ADMIN_PORT:-18001}:8000"
|
||
depends_on:
|
||
init:
|
||
condition: service_completed_successfully
|
||
postgres:
|
||
condition: service_healthy
|
||
redis:
|
||
condition: service_healthy
|
||
|
||
# Опрос подключений и периодические работы. Строго один экземпляр: курсоры
|
||
# мессенджеров и паузы после сбоя живут в памяти процесса, и второй опросчик
|
||
# забирал бы те же обновления второй раз.
|
||
worker:
|
||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||
restart: unless-stopped
|
||
command: ["python", "manage.py", "run_worker", "--role=poller"]
|
||
environment:
|
||
CHATBALLS_DB_ROLE: app
|
||
# Захват outbox идёт по всем организациям сразу — воркеру нужен алиас
|
||
# platform. У backend-app его нет: он работает только ролью app.
|
||
CHATBALLS_DB_PLATFORM_ALIAS: "1"
|
||
volumes:
|
||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
|
||
- chatballs-media:/app/apps/backend/media
|
||
depends_on:
|
||
backend-app:
|
||
condition: service_healthy
|
||
|
||
# Обработка событий: ответы AI, доставка уведомлений, приглашения на звонки.
|
||
# Ход AI ждёт модель секунды и десятки секунд, поэтому эта работа вынесена из
|
||
# опроса и масштабируется репликами — события разбираются с `skip_locked`, а
|
||
# ходы одного диалога всё равно идут по очереди
|
||
# (chatballs.events.services.claim_next_outbox_event). Реплик стоит держать
|
||
# немного: они ходят к провайдеру одним ключом организации.
|
||
worker-events:
|
||
image: ${CHATBALLS_BACKEND_IMAGE:-chatballs-backend:dev}
|
||
restart: unless-stopped
|
||
command: ["python", "manage.py", "run_worker", "--role=events"]
|
||
deploy:
|
||
replicas: ${CHATBALLS_EVENT_WORKERS:-2}
|
||
environment:
|
||
CHATBALLS_DB_ROLE: app
|
||
CHATBALLS_DB_PLATFORM_ALIAS: "1"
|
||
volumes:
|
||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||
- chatballs-secrets-platform:/run/chatballs/secrets/platform:ro
|
||
- chatballs-media:/app/apps/backend/media
|
||
depends_on:
|
||
backend-app:
|
||
condition: service_healthy
|
||
|
||
# Обновление по кнопке из интерфейса (ADR-CHATBALLS-0049). Единственный
|
||
# сервис с доступом к docker.sock: забирает запрос из тома chatballs-updates,
|
||
# проверяет, что это официальный релиз (адрес со страницы релизов, образы по
|
||
# digest), и запускает `docker compose pull && up` отдельным контейнером вне
|
||
# проекта. Без сокета (rootless, podman) просто спит — кнопка в интерфейсе
|
||
# тогда сообщает, что обновление из интерфейса недоступно.
|
||
updater:
|
||
image: ${CHATBALLS_UPDATER_IMAGE:-chatballs-updater:dev}
|
||
restart: unless-stopped
|
||
environment:
|
||
CHATBALLS_UPDATE_REPO: ${CHATBALLS_UPDATE_REPO:-dartdavros/chatballs}
|
||
volumes:
|
||
- /var/run/docker.sock:/var/run/docker.sock
|
||
- chatballs-updates:/run/chatballs/updates
|
||
depends_on:
|
||
backend-app:
|
||
condition: service_healthy
|
||
|
||
# Frontend: один nginx-образ со static-сборкой internal-ui и web-chat,
|
||
# внутренняя маршрутизация (/api/, /ws/, /chat-widget.js,
|
||
# /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует
|
||
# host-порт: public boundary — gateway (Caddy).
|
||
frontend:
|
||
image: ${CHATBALLS_FRONTEND_IMAGE:-chatballs-frontend:dev}
|
||
restart: unless-stopped
|
||
depends_on:
|
||
backend-app:
|
||
condition: service_healthy
|
||
healthcheck:
|
||
# Без него `up --wait` считает контейнер готовым сразу после старта, и
|
||
# человек открывает адрес раньше, чем nginx поднял конфигурацию.
|
||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 5s
|
||
|
||
# Gateway: единственный HTTP/HTTPS public boundary (ADR-CHATBALLS-0028 §gateway).
|
||
# Caddy: TLS termination, ACME, HTTP->HTTPS redirect, WebSocket upgrade (native).
|
||
# Caddyfile запечён в образ (deploy/docker/gateway.Dockerfile) — с хоста
|
||
# ничего не монтируется. Маршрутизация публичных путей делегируется frontend
|
||
# (internal). Coturn не проксируется через Caddy — отдельная network boundary
|
||
# (profile calls).
|
||
gateway:
|
||
image: ${CHATBALLS_GATEWAY_IMAGE:-chatballs-gateway:dev}
|
||
restart: unless-stopped
|
||
environment:
|
||
CHATBALLS_PLATFORM_DOMAIN: ${CHATBALLS_PLATFORM_DOMAIN:-platform.localhost}
|
||
ports:
|
||
- "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:80:80"
|
||
- "${CHATBALLS_WEB_LISTENING_IP:-0.0.0.0}:443:443"
|
||
volumes:
|
||
- chatballs-caddy-data:/data
|
||
- chatballs-caddy-config:/config
|
||
depends_on:
|
||
frontend:
|
||
condition: service_started
|
||
backend-app:
|
||
condition: service_healthy
|
||
backend-platform:
|
||
condition: service_healthy
|
||
|
||
# Coturn — медиа-relay для звонков (SPEC-CHATBALLS-0013 §11).
|
||
#
|
||
# Поднимается вместе со всей установкой и слушает тот же адрес, что и веб:
|
||
# звонок через симметричный NAT или VPN без relay не соединяется в принципе,
|
||
# и превращать это в отдельную настройку значит выдавать клиенту заведомо
|
||
# неработающие звонки. Порт 3478 не спорит с 80 и 443, так что второй IP не
|
||
# нужен; TURN-over-TLS по умолчанию выключен — он требует сертификата и
|
||
# 443-го порта, то есть как раз выделенного адреса. Кому нужен TLS (сети,
|
||
# где наружу разрешён только 443), включает его отдельным compose-override.
|
||
#
|
||
# Host networking: relay использует широкий UDP-диапазон и реальный внешний
|
||
# IP, публикуется напрямую и не проходит через HTTP reverse proxy.
|
||
# credentials выдаёт backend по общему CHATBALLS_CALL_TURN_SECRET
|
||
# (static-auth-secret); адреса relay backend строит от адреса установки.
|
||
coturn:
|
||
image: ${CHATBALLS_COTURN_IMAGE:-coturn/coturn:4.6}
|
||
restart: unless-stopped
|
||
network_mode: host
|
||
command:
|
||
- -n
|
||
- --log-file=stdout
|
||
- --no-cli
|
||
- --fingerprint
|
||
- --use-auth-secret
|
||
# Общий секрет приходит файлом из тома секретов: его генерирует первый
|
||
# старт, человек его не вводит и не дублирует в двух местах.
|
||
- -c
|
||
- /run/chatballs/secrets/turnserver-secret.conf
|
||
# realm участвует только в digest-аутентификации; клиент узнаёт его от
|
||
# самого сервера, поэтому значение фиксированное.
|
||
- --realm=chatballs
|
||
- --listening-port=3478
|
||
# TLS и DTLS выключены: без сертификата они всё равно не поднялись бы, а
|
||
# сертификат означает 443 и выделенный адрес.
|
||
- --no-tls
|
||
- --no-dtls
|
||
- --min-port=49160
|
||
# Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок).
|
||
# 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте.
|
||
# Все порты диапазона должны быть открыты на firewall и не пересекаться
|
||
# с ephemeral-диапазоном ОС.
|
||
- --max-port=49999
|
||
# Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом).
|
||
- --no-multicast-peers
|
||
- --no-tcp-relay
|
||
- --denied-peer-ip=10.0.0.0-10.255.255.255
|
||
- --denied-peer-ip=172.16.0.0-172.31.255.255
|
||
- --denied-peer-ip=192.168.0.0-192.168.255.255
|
||
volumes:
|
||
- chatballs-secrets:/run/chatballs/secrets:ro
|
||
healthcheck:
|
||
# Allocation smoke: STUN binding к собственному listener.
|
||
test: ["CMD", "turnutils_stunclient", "-p", "3478", "127.0.0.1"]
|
||
interval: 30s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 15s
|
||
|
||
volumes:
|
||
# Секреты инстанса: генерируются при первом старте, живут только здесь.
|
||
# Три тома, чтобы каждый процесс видел лишь свои пароли: общий, роли
|
||
# platform и роли миграций (вместе с паролем владельца кластера).
|
||
chatballs-secrets:
|
||
chatballs-secrets-platform:
|
||
chatballs-secrets-schema:
|
||
# Состояние установки. Именованные тома вместо каталогов с хоста: установка
|
||
# не зависит от того, из какого каталога её запустили, и не упирается в
|
||
# владельца каталога на хосте.
|
||
chatballs-postgres:
|
||
chatballs-redis:
|
||
chatballs-media:
|
||
chatballs-caddy-data:
|
||
chatballs-caddy-config:
|
||
# Обмен приложения с сервисом updater: запрос и статус обновления.
|
||
chatballs-updates:
|