Продакшен¶
Эталонная продакшен-конфигурация — несколько одинаковых серверов с общим Redis, которые развёртывает GitHub Actions с поэтапным обновлением и автоматическим откатом. Запросы попадают на серверы либо через ваш собственный балансировщик нагрузки, либо напрямую из Cloudflare (проксируемые DNS-записи, по одной на сервер), а TLS на каждом сервере завершает контейнер Caddy (см. Cloudflare без балансировщика).
flowchart LR
C[Клиенты] --> CF[Cloudflare / CDN]
CF --> LB[балансировщик nginx]
LB --> A1[сервер 1<br/>telegram-me]
LB --> A2[сервер 2<br/>telegram-me]
LB --> A3[сервер 3<br/>telegram-me]
A1 & A2 & A3 --> R[(Redis / Valkey)]
A1 & A2 & A3 --> T[t.me]
Подготовка сервера¶
Запустите
deploy/bootstrap-server.sh
один раз на каждом новом сервере Ubuntu от имени пользователя, под которым
входит GitHub Actions:
Его можно безопасно запускать повторно. Скрипт:
- устанавливает Docker Engine и плагин compose из репозитория Docker;
- добавляет пользователя в группу
docker; - добавляет 2 ГиБ swap, если его нет (
SWAP_MB=0пропускает этот шаг). Это помогает небольшим машинам вроде облачных инстансов с 1 ГБ памяти; - открывает порт 443 в файрволе хоста на образах Oracle Cloud, которые блокируют всё, кроме SSH. Кто может подключаться, по-прежнему решает список безопасности или группа безопасности облака.
За собственным балансировщиком разрешите ему доступ к порту 8000, а снаружи
не пускайте ничего больше, например с UFW:
sudo ufw default deny incoming
sudo ufw allow ssh
sudo ufw allow from 10.0.0.0/24 to any port 8000 proto tcp
sudo ufw enable
Больше ничего устанавливать не нужно: workflow при каждом развёртывании
копирует compose-файл, скрипт развёртывания и .env.
Как проходит развёртывание¶
sequenceDiagram
participant GH as GitHub Actions
participant S1 as Сервер 1 (canary)
participant SN as Серверы 2…N
GH->>GH: CI и Guards: линтер, типы, тесты, контракт, образ, интеграция, скрипты развёртывания
GH->>GH: сборка и push linux/amd64 + linux/arm64
GH->>S1: remote-deploy.sh <image:sha-…>
S1->>S1: compose up --wait (healthcheck), smoke-тест внутри контейнера
alt здоров и smoke-тест пройден
S1-->>GH: ok, запомнить образ
else иначе
S1->>S1: снова запустить предыдущий образ
S1-->>GH: ошибка → развёртывание останавливается, больше ничего не меняется
end
GH->>GH: CANARY_SOAK секунд реального трафика на сервере 1
GH->>S1: remote-deploy.sh --verify (тот же образ, здоров, без перезапусков, smoke-тест)
loop каждый следующий сервер, по одному
GH->>SN: remote-deploy.sh <image:sha-…> (те же проверки)
end
GH->>GH: smoke-тест через публичный URL
Note over GH,SN: сбой после сервера 1 откатывает все серверы, получившие релиз
- Push в
mainзапускает CI и Guards, собирает образ (тегиlatestиsha-…) и развёртываетsha-…на всех серверах изSERVERS_IPS, один за другим (deploy/rollout.sh). deploy/remote-deploy.shждёт, пока новый контейнер станет здоровым, а затем запускает smoke-тест (scripts/smoke_matrix.py: коды статуса и содержимое каждого эндпоинта, внутри контейнера на живом t.me, с одним повтором). Healthcheck сам по себе доказывает лишь, что процесс отвечает на/healthz; smoke-тест доказывает, что он отдаёт страницы с конфигурацией и Redis этого сервера. Если что-то из этого не проходит, скрипт перезапускает ранее развёрнутый образ (записанный в~/telegram-me/.deployed-image) и завершается ошибкой.- Сервер 1 — canary: получив релиз, он
CANARY_SOAKсекунд (по умолчанию 60) обслуживает реальный трафик и проверяется снова: он должен по-прежнему выполнять этот образ, быть здоровым, не перезапускаться и не быть убитым из-за памяти, а также пройти smoke-тест. Только после этого разворачиваются остальные серверы. - В конце smoke-тест проходит через публичный URL (
PUBLIC_URLилиhttps://PUBLIC_HOST), то есть через Cloudflare и Caddy, с заголовкомCF_BYPASS_KEY. - Если любая из этих проверок не проходит после того, как сервер получил
релиз, откатываются все серверы, которые его получили
(
remote-deploy.sh --rollback), так что флот никогда не остаётся разделённым между двумя релизами. - Первое развёртывание 2.0 заменяет контейнер 1.x (с именем
telegram-me, запущенный черезdocker run). Если 2.0 не становится здоровым, контейнер 1.x снова запускается из своего образа и~/env.app. - Push тега
v2.0.0дополнительно публикует теги2.0.0,2.0,2и релиз на GitHub с заметками изCHANGELOG.md.
Конфигурация¶
Секреты¶
| Секрет | Обязателен | Назначение |
|---|---|---|
SERVERS_IPS |
да | Адреса серверов через пробел, например 203.0.113.10 203.0.113.11. Workflow маскирует каждый адрес в логах и называет серверы 1, 2, … |
SSH_KEY |
да | Приватный ключ для SSH_USER |
SSH_KNOWN_HOSTS |
рекомендуется | Вывод ssh-keyscan для серверов; без него ключам хостов доверяют при первом подключении (с предупреждением) |
REDIS_URL |
да | Общий Redis для всех серверов |
CF_BYPASS_KEY |
нет | Отправляется публичными smoke-тестом и проверкой здоровья как x-bypass-key — для правила WAF Cloudflare, которое пропускает их мимо защиты от ботов |
POW_SECRET |
с ENABLE_POW |
Подписывает задачи proof of work; не короче 32 символов (POW_SECRET) |
TLS_CERT, TLS_KEY |
с PUBLIC_HOST |
Сертификат и ключ Cloudflare Origin CA (PEM) для Caddy; без них Caddy использует самоподписанный сертификат |
DOCKER_USERNAME, DOCKERHUB_TOKEN |
да | Push в Docker Hub (вместо токена принимается DOCKER_PASSWORD) |
Переменные¶
Переменные репозитория переопределяют продакшен-.env, который записывается
при каждом развёртывании:
| Переменная | По умолчанию | Примечания |
|---|---|---|
ENABLE_FEED |
true |
В продакшене оставлено включённым ради существующих клиентов; в самом приложении по умолчанию false |
ENABLE_PREVIEWS |
true |
|
ENABLE_RATE_LIMIT, RATE_LIMIT_RPM |
false, 120 |
См. Ограничение частоты и proof of work |
RATE_LIMIT_BURST, RATE_LIMIT_SHARED |
RATE_LIMIT_RPM, true |
|
ENABLE_POW, POW_DIFFICULTY |
false, 18 |
ENABLE_POW требует секрета POW_SECRET; без него развёртывание останавливается |
POW_CHALLENGE_TTL, POW_BIND_IP |
300, true |
|
CACHE_MODE, PARSER_MODE |
normal, full |
|
PUBLIC_HOST |
— | Публичное имя хоста, например api.example.com. Задайте его, чтобы запускать Caddy на порту 443 каждого сервера (Cloudflare без балансировщика) |
FORWARDED_ALLOW_IPS |
127.0.0.1 |
Укажите адрес(а) балансировщика. С PUBLIC_HOST: 172.29.53.0/24, собственная сеть стека (Caddy) |
DISABLE_DOCS |
false |
|
DEPLOY_IMAGE |
docker.io/koval01/telegram-me |
Репозиторий, из которого идёт развёртывание |
SSH_USER |
ubuntu |
|
PUBLIC_HEALTHCHECK_URL |
— | Проверяется после развёртывания, например https://api.example.com/healthz |
PUBLIC_URL |
https://PUBLIC_HOST |
Базовый URL публичного smoke-теста после развёртывания (без него и без PUBLIC_HOST тест пропускается) и PUBLIC_URL приложения для /robots.txt и /sitemap.xml |
CANARY_SOAK |
60 |
Сколько секунд сервер 1 обслуживает новый релиз, прежде чем его проверят снова и за ним последуют остальные серверы; 0 отключает проверку canary |
Используйте окружение production в настройках репозитория, чтобы добавить
обязательных ревьюеров или ограничить ветки, из которых можно развёртывать.
Cloudflare без балансировщика¶
Когда проксируемые DNS-записи указывают на сами серверы, Cloudflare
подключается к порту 443 каждого из них. Задайте переменную PUBLIC_HOST, и
каждое развёртывание будет также запускать сервис proxy из
compose.prod.yaml: Caddy
(deploy/Caddyfile).
- Caddy завершает TLS и проксирует запросы в API, который в этом случае
слушает только
127.0.0.1:8000. - Адрес клиента берётся из
CF-Connecting-IP, которому доверяют только из диапазонов Cloudflare. Caddy передаёт его дальше вX-Forwarded-For, так что ограничение частоты запросов и логи видят настоящих клиентов. - Сертификат. Создайте сертификат Origin CA в панели Cloudflare
(SSL/TLS → Origin Server) и сохраните его в секретах
TLS_CERTиTLS_KEY. Затем включите режим SSL Full (strict). Без них Caddy отдаёт самоподписанный сертификат, который работает только в режиме SSL Full. - В облачном файрволе (список или группа безопасности) пропускайте к порту 443 только диапазоны Cloudflare, а к порту 8000 — никого.
Клиенты API — программы, а не браузеры. Защита Cloudflare от ботов (Bot Fight
Mode, уровень «I'm Under Attack», правила Managed Challenge) отвечает им
страницей проверки (403) вместо JSON, и это касается и публичной проверки
здоровья при развёртывании. Выключайте её, если API предназначен для программ.
Если она остаётся включённой (например, для демо), добавьте собственное правило
WAF, которое пропускает запросы с заголовком, совпадающим с длинным случайным
ключом, например
any(http.request.headers["x-bypass-key"][*] eq "<key>"). Сохраните ключ в
секрете CF_BYPASS_KEY для публичных smoke-теста и проверки здоровья при
развёртывании, а при ручном запуске передавайте его smoke-тесту через
--header "x-bypass-key:$KEY".
Cloudflare выбирает одну из DNS-записей на каждое соединение. Пока сервер перезапускается во время поэтапного развёртывания, часть запросов к нему может несколько секунд завершаться ошибкой.
Откат¶
Запустите workflow Deploy вручную (Actions → Deploy → Run workflow) с
тегом, на который нужно вернуться, например sha-0123456789ab (все сборки
хранятся в реестре). Используется та же поэтапная процедура с теми же
проверками; снимайте отметку smoke только пока недоступен сам t.me —
тогда её не пройдёт ни один релиз.
На одном сервере можно откатиться и вручную — на образ, развёрнутый перед последним, или на любой другой:
bash ~/telegram-me/remote-deploy.sh --rollback
cd ~/telegram-me
APP_IMAGE=docker.io/koval01/telegram-me:sha-0123456789ab \
docker compose -f compose.prod.yaml up -d --wait
Отдельный воркер ленты¶
Чтобы вынести рефрешер ленты из процессов API, запустите workflow Deploy с
отмеченным with_worker: тогда каждый сервер запускает сервис worker, а
API работает с FEED_REFRESH_MODE=worker. В любом случае выбор лидера в Redis
гарантирует единственный активный рефрешер на всех серверах.
Эксплуатация¶
cd ~/telegram-me
docker compose -f compose.prod.yaml ps
docker compose -f compose.prod.yaml logs -f --since 10m api
cat .deployed-image
В продакшене логи — это строки JSON (LOG_FORMAT=json) с ротацией по
5 × 50 МБ.