MAX-olymp

Развёртывание на my-olymp.ru

Постоянный домен заменяет туннель. Всё решение живёт на одном адресе:

Адрес Что отдаёт
https://my-olymp.ru/ мини-приложение, статика из frontend/
https://my-olymp.ru/api/* API, авторизация по подписи данных запуска
https://my-olymp.ru/webhook события бота от платформы MAX
https://my-olymp.ru/health проверка живости

Один origin на фронтенд и API — это не только удобство: браузер не делает preflight, CORS в бою не участвует вовсе, а заголовок-костыль ngrok-skip-browser-warning больше не нужен и удалён.


Что должно быть на сервере


0. Домен: что делает его владелец

Домен и сервер могут быть у разных людей — у нас так и есть. Тогда владельцу домена нужно сделать ровно одно: направить домен на IP сервера. Больше от него ничего не требуется.

Сначала — чего делать не надо:

Что настроить в панели регистратора

В разделе DNS (у регистраторов он называется «DNS-записи», «Управление зоной», «DNS-серверы и управление зоной»):

Тип Имя (хост) Значение TTL
A @ или пусто — это сам my-olymp.ru IP сервера 300
A www тот же IP 300

@ — обязательна, www — по желанию; если её не делать, удалите блок www из deploy/nginx/my-olymp.ru.conf и не указывайте -d www.my-olymp.ru при выпуске сертификата.

TTL на время переезда лучше поставить минимальный: если IP придётся поменять, новый разъедется за минуты, а не за сутки.

Удалить всё остальное, что регистратор завёл сам:

Самая частая ошибка

Записи правятся в панели, которая не обслуживает домен. Если NS-серверы домена указывают на одного провайдера (скажем, на Cloudflare или на старый хостинг), а зону вы правите у регистратора, изменения ни на что не влияют.

Проверить, кто отвечает за домен:

dig +short NS my-olymp.ru

Что вернулось — та панель и нужна.

Если домен ведут через Cloudflare

Оставьте режим DNS only (серая тучка), а не «Proxied». Проксирование подменяет TLS на свой и добавляет ещё один слой, который придётся отлаживать вместе с вебхуком MAX; выигрыша для нас в нём нет.

Проверка

Домен разъезжается от нескольких минут до нескольких часов. Готово, когда обе команды показывают IP сервера:

dig +short my-olymp.ru
dig +short www.my-olymp.ru

Пока dig пуст или показывает чужой адрес — к шагу 2 переходить нельзя: Let’s Encrypt не подтвердит владение доменом, а у него лимит 5 неудачных проверок в час. Проще подождать, чем упереться в лимит.

Когда dig показывает нужный IP, владелец домена свободен: всё остальное делается на сервере.


1. Код и настройки

git clone https://github.com/A-blessed/MAX-olymp.git
cd MAX-olymp
cp .env.example .env

В .env заполнить:

Переменная Значение
BOT_TOKEN токен бота
BOT_USERNAME ник бота без @
WEBHOOK_SECRET произвольная строка 5–256 символов [A-Za-z0-9_-]
POSTGRES_PASSWORD обязателен, только буквы и цифры. Без него compose не стартует. Задаётся до первого up: база запоминает пароль при создании
CERTBOT_EMAIL почта для писем об истечении сертификата

PUBLIC_BASE_URL=https://my-olymp.ru и CORS_ORIGINS=https://my-olymp.ru уже стоят в шаблоне — менять их не нужно.

APP_ENV из .env в проде не читается: docker-compose.prod.yml жёстко ставит production, иначе схема API открылась бы наружу молча.

Сертификаты Минцифры положить в certs/ — без них бэкенд не установит TLS-соединение с platform-api2.max.ru. Это отдельная история от сертификата домена, они не взаимозаменяемы:

Сертификат Зачем Откуда
Минцифры, certs/ бэкенд → platform-api2.max.ru Госуслуги
Let’s Encrypt браузер и MAX → my-olymp.ru certbot, шаг 2

2. Сертификат домена

Курица и яйцо: nginx не поднимется без файла сертификата, а certbot не сможет подтвердить домен через nginx, пока тот не поднялся. Поэтому первый выпуск делается в режиме --standalone, когда порт 80 ещё свободен, — то есть до первого up. .env к этому моменту уже должен быть заполнен: compose читает его целиком даже ради одного certbot.

docker compose -f docker-compose.prod.yml run --rm -p 80:80 \
  --entrypoint certbot certbot \
  certonly --standalone --non-interactive --agree-tos \
  --email ВАША@ПОЧТА --no-eff-email \
  -d my-olymp.ru -d www.my-olymp.ru

Если A-записи для www нет — уберите -d www.my-olymp.ru, иначе выпуск упадёт на проверке домена. В этом случае удалите и серверный блок www из deploy/nginx/my-olymp.ru.conf: без сертификата на www он отдаёт браузеру ошибку.

Сертификат ложится в том letsencrypt — не в репозиторий: закрытый ключ не должен попасть в git даже случайно.

Продление автоматическое. Сервис certbot дважды в сутки вызывает renew (до истечения срока больше 30 дней он ничего не делает), а nginx раз в сутки перечитывает конфигурацию и подхватывает новый файл без перезапуска и без обрыва соединений.


3. Запуск

docker compose -f docker-compose.prod.yml up -d --build

Поднимутся четыре сервиса: db, backend, nginx, certbot. Образы backend и nginx собираются из исходников тут же, меньше чем за минуту. Схему базы Alembic применяет сам при старте бэкенда, а пустой каталог наполняется выгрузками из backend/data/ — руками ничего импортировать не нужно. Что импортировалось, видно в логе:

docker compose -f docker-compose.prod.yml logs backend | grep data/

Проверить снаружи:

curl https://my-olymp.ru/health

Ожидается {"status":"ok","env":"production","bot_configured":true,"webhook_secret_set":true}. Оба флага должны быть true — если нет, .env не доехал до контейнера.

И что отдаётся само приложение:

curl -I https://my-olymp.ru/

4. Подписка на события бота

Адрес вебхука сменился с туннельного на постоянный, поэтому подписку нужно оформить заново — старая продолжает указывать на ngrok:

docker compose -f docker-compose.prod.yml run --rm backend python -m scripts.setup_webhook

Проверить, что осталась только одна подписка и она на новый адрес:

docker compose -f docker-compose.prod.yml run --rm backend python -m scripts.setup_webhook --list

Если в списке остался туннельный адрес, удалить его:

docker compose -f docker-compose.prod.yml run --rm backend \
  python -m scripts.setup_webhook --delete --url https://СТАРЫЙ-АДРЕС/webhook

Это важнее, чем кажется: пока подписка висит на мёртвом адресе, MAX считает события недоставленными, а через 8 часов неудач отписывает бота сам.


5. Адрес мини-приложения в кабинете MAX

Через код этого сделать нельзя — в Bot API нет такого метода. Только в кабинете партнёров: Чат-боты → нужный бот → ⋮ → Настройки → поле URL мини-приложения:

https://my-olymp.ru

Пока там стоит адрес GitHub Pages, MAX будет открывать старую копию приложения, и никакие изменения на сервере на это не повлияют. Если доступа к кабинету нет — адрес нужно передать организаторам.


Если сервер на Windows

Все образы проекта — Linux-контейнеры, поэтому Docker на Windows должен запускать именно их. Первым делом после установки:

docker compose version
docker info --format ''

Нужны v2.24.0 или новее и linux. Если ответ windows — это Docker для Windows-контейнеров (его ставит, например, скрипт Microsoft для Windows Server), и сборка упадёт на no matching manifest for windows/amd64. В Docker Desktop это переключается пунктом «Switch to Linux containers» в меню значка.

Если вместо ответа failed to connect to the docker API at npipe:////./pipe/docker_engine — команда docker есть, а самого движка нет или он не запущен. docker compose version при этом отвечает нормально: версия — свойство программы, движок ей не нужен. Что стоит на самом деле:

Get-Service *docker* | Format-Table Name, Status, DisplayName
docker context ls
Test-Path "C:\Program Files\Docker\Docker\Docker Desktop.exe"
wsl --status

Что ещё отличается от Linux:

Если Linux-контейнеры поднять так и не удалось — проще взять Linux-VPS; ручной вариант без Docker описан в deploy-without-docker.md.

Переезд с запуска без Docker

Если на машине уже работал вариант с Caddy и uvicorn:

  1. Освободить 80 и 443: остановить Caddy — и службу, и задание планировщика, если его так запускали, — и процесс бэкенда. Проверить, что порты свободны:

    Get-NetTCPConnection -LocalPort 80,443 -State Listen
    

    Ответ должен быть пустым. Родную PostgreSQL останавливать не обязательно: в Docker у бэкенда своя база, и её порт на хост не публикуется.

  2. .env — в корень репозитория, рядом с docker-compose.prod.yml, а не в backend\ и не в C:\Server. Взять .env.example и перенести из старого файла BOT_TOKEN, BOT_USERNAME и WEBHOOK_SECRET — тот же самый секрет: подписка в MAX оформлена с ним, и с новым каждое событие получит 403, пока подписку не оформить заново (шаг 4). DATABASE_URL и EXTRA_CA_CERTS_DIR переносить не нужно: compose задаёт их сам.
  3. Сертификаты Минцифры — в certs\ в корне репозитория.
  4. Дальше — шаги 2 и 3. Сертификат домена certbot выпустит заново: выпущенный Caddy лежит в его собственном хранилище и nginx не нужен. В шаге 4 достаточно --list: адрес вебхука не изменился.

Данные пользователей — «Мои олимпиады», прогресс, планы — сами не переедут: у контейнера своя база. Каталог наполнится заново из backend/data/, а сохранённое пользователями, если оно нужно, переносится дампом старой базы до первого запуска бэкенда.


Обновление кода

cd MAX-olymp
git pull
docker compose -f docker-compose.prod.yml up -d --build

Пересоберутся и перезапустятся только те сервисы, у которых что-то поменялось: backend — при правке кода бэкенда, nginx — при правке frontend/ (статика запечена в его образ). Миграции схемы бэкенд применяет сам при старте. Статика отдаётся с Cache-Control: no-cache, иначе встроенный браузер MAX держал бы старую версию и после деплоя.

Данные каталога при обновлении не трогаются: автоимпорт наполняет только пустую базу. Если в backend/data/ приехали новые выгрузки, загрузите их явно — импорт обновляет записи на месте и прогресс пользователей не теряет:

docker compose -f docker-compose.prod.yml exec backend python -m scripts.import_subjects
docker compose -f docker-compose.prod.yml exec backend python -m scripts.import_schedule

Проверить после обновления, не дожидаясь утра, кому и что уйдёт в утренней сводке:

docker compose -f docker-compose.prod.yml exec backend python -m scripts.send_reminders --users
docker compose -f docker-compose.prod.yml exec backend python -m scripts.send_reminders --dry-run

При старте бэкенд сам спрашивает у MAX, оформлена ли подписка на события, и пишет об этом строку в лог (docker compose -f docker-compose.prod.yml logs backend | grep Подписка). «MAX не присылает события на …» значит, что бот глух: не узнает о запуске, не ответит на сообщения, сводки уходить будет некому. Лечится шагом 4.


Если что-то не работает

Сначала — один снимок состояния

Половина поломок деплоя видна не в той строке, на которую смотришь, а в соседней: в дате сертификата, в чужом IP у домена, в том, что контейнер не поднялся вовсе. Пересказывать такое по кускам — долго и ненадёжно, поэтому состояние снимается целиком одной командой, на самой машине:

bash deploy/diagnose.sh

На Windows, где Docker не ставится, — та же команда в PowerShell:

powershell -ExecutionPolicy Bypass -File deploy\diagnose.ps1

-ExecutionPolicy Bypass нужен почти всегда: на Windows Server запуск скриптов закрыт по умолчанию, и без ключа команда молча откажет. Политику он меняет только для этого запуска.

Скрипт ничего не меняет и не перезапускает — только читает. Он собирает в один файл diagnostics-<дата>.txt: версию кода, заданные переменные окружения, состояние сервисов и их логи, срок и домены сертификата, кто слушает 80 и 443, куда ведёт DNS и что отвечает сайт — снаружи и в обход DNS.

Версия для Windows проверяет вдобавок то, что бывает только там: кодировку .env (UTF-16 от Out-File — та самая причина UnicodeDecodeError на старте), службу PostgreSQL, занят ли порт 80 службой IIS и есть ли в брандмауэре разрешающие правила на 80 и 443.

Файл сделан пригодным для пересылки: BOT_TOKEN, WEBHOOK_SECRET, POSTGRES_PASSWORD и CERTBOT_EMAIL в нём заменены на <скрыто> — и в самом .env, и в логах, куда могли просочиться. Его можно показывать целиком, не вычитывая.

Таблица ниже — что искать в этом файле.

Симптом Где смотреть
nginx: host not found in upstream "backend" бэкенд не стал healthy; docker compose -f docker-compose.prod.yml logs backend
502 на /api/* бэкенд упал или ещё применяет миграции; те же логи
404 на /docs так и задумано: APP_ENV=production закрывает схему
Приложение открылось, но данные не грузятся адрес в кабинете MAX ведёт не на my-olymp.ru — например, на старую копию на GitHub Pages: она ходит в API с чужого origin, и без него в CORS_ORIGINS запросы отклоняются; шаг 5
Бот не отвечает подписка указывает на старый адрес или её нет вовсе; строка о подписке в логе старта, шаг 4
Ошибка сертификата на www сертификат выписан без www; либо добавьте его в certbot, либо удалите блок www из конфига nginx
Address family not supported by protocol в логах nginx на хосте отключён IPv6, а в конфиге появились строки listen [::]
Certbot: DNS problem или Invalid response домен ещё не указывает на сервер или порт 80 закрыт; шаг 0
Домен открывает страницу регистратора не снята парковка или включено перенаправление домена; шаг 0
Сайт открывается через раз лишняя AAAA-запись: браузер идёт по IPv6, а nginx слушает IPv4; шаг 0
Календарь пуст, в ответе /api/me/calendar нет поля olympiads после git pull работает старый бэкенд: docker compose -f docker-compose.prod.yml up -d --build backend
Календарь пуст, olympiads есть, но пустые при сохранённых олимпиадах в базу не загружено расписание: docker compose -f docker-compose.prod.yml exec backend python -m scripts.import_schedule. Если уже загружено — у выбранных олимпиад на olimpiada.ru нет дат (так у 85 из 146)
Бот не прислал утреннюю сводку docker compose -f docker-compose.prod.yml exec backend python -m scripts.send_reminders --user <id> скажет причину: от человека не было событий бота, уведомления выключены, нет сохранённых олимпиад или сказать нечего
В --users у всех «нет событий» MAX не присылает события серверу: нет подписки на https://my-olymp.ru/webhook. Шаг 4, затем каждому написать боту любое сообщение
«В каталоге … нет сертификатов», хотя они там смотрите путь в самом сообщении: он абсолютный и говорит, куда приложение смотрело на самом деле