Выкат на VM — реестр, релизы, откат

Перед началом: нагрузка и метрики (Модуль 11). Инструменты: Docker, реестр образов, nginx, ssh, Linux-VM. Трудоёмкость: ~8 ч.

🔥 Инцидент: «Задеплоил — а ничего не изменилось»

Релизный день. На VM выполнили docker compose build && up -d — вывод пустой, код 0, контейнеры подняты. Только через час выяснилось: compose на сервере собрал старые образы — legacy builder молча не пересобрал слои, и exit code был честный ноль. Параллельно выяснилась вторая подлость: сам процесс сборки на VM съел CPU, и сервис, который «должен был обновиться», всё это время тормозил под нагрузкой сборки. Вывод, которым живёт рабочий стенд, на который мы равнялись: на сервер ничего не собирается — сервер только тянет готовые образы. В этом модуле строим такой конвейер: сборка → реестр → pull → health-гейт → откат.

📖 Теория

12.1 Три принципа релиза

  1. Сборка — на машине разработчика (или в CI), на VM только pull. Никакой конкуренции сборки за CPU с сервисом и никаких «молчаливых» пересборок.
  2. Версия — в теге образа, тег — в .env. BACKEND_TAG (api+migrate — одна версия) и UI_TAG (статика отдельной жизнью: релиз UI не рвёт WS-соединения). Откат = поменять тег и up -d.
  3. Релиз проходит гейт. После up -d проверяем, что поднялось работающее: /health API и корень UI (ожидаем HTML-страницу, а не 200-заглушку прокси). Красный гейт → авто-откат на предыдущий тег.
Выкат на VM

12.2 Состав стека на VM

Наш стадия-1 мессенджер в одном Go-процессе (REST + WS), поэтому compose небольшой:

СервисРоль
postgresданные (volume на bind-mount)
redispresence, typing, rate limit (уже умеет деградировать)
migrateone-shot: up при выкате, до старта api
apiREST + WebSocket, одна реплика
uinginx: статика SPA + реверс-прокси /api и /ws
backupежедневный pg_dump + ротация

Наружу — только 80/443. Postgres и Redis — во внутренней сети compose, без портов наружу (в dev-схеме они открыты на 5433/6380 — это только для локальной разработки).

12.3 nginx: два неочевидных требования к WS

Конфиг проксирования WebSocket — место, где ломаются 90% первых деплоев:

nginx
location /api/v1/ws {
    proxy_pass http://api:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;      # без этого — не апгрейд
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 3600s;                     # иначе обрыв каждые 60с
    proxy_buffering off;                          # события — не буферизовать
}

proxy_read_timeout по умолчанию — 60 секунд: WS «умирает» раз в минуту и клиенты переподключаются. Симптом выглядит как «сервер нестабилен», а лечится одной строкой.

12.4 Выпуск образов

deploy/build-push.sh (на машине разработчика):

bash
#!/usr/bin/env bash
set -euo pipefail
TAG="${1:-$(git describe --tags --always)}"
REGISTRY="${REGISTRY:-ghcr.io/<you>/messenger}"

docker build -t "$REGISTRY-backend:$TAG" backend/
docker build -t "$REGISTRY-ui:$TAG"      frontend/
docker push "$REGISTRY-backend:$TAG" && docker push "$REGISTRY-ui:$TAG"

echo "Выкат: ./deploy/release.sh --component all \
  --backend-tag $TAG --ui-tag $TAG"

Печатать готовую команду выката — мелочь, которая убирает класс ошибок «выкатил не тот тег». Реестр — GHCR (просто) или Yandex Container Registry (как на взрослых стендах, но с настройкой сервисного аккаунта): выбор фиксируйте в README, механизм одинаков.

12.5 release.sh: только механика

Скрипт на VM умеет ровно одно: pull + up для выбранных компонентов. Решение «гейт зелёный / откатывать» принимает вызывающий (человек, CI или маленький оркестратор):

bash
#!/usr/bin/env bash
# release.sh --component all|api|ui --backend-tag T --ui-tag T [--dry-run]
set -euo pipefail
# ... парсинг аргументов ...
[ "$COMPONENT" != "ui" ] && sed -i "s/^BACKEND_TAG=.*/BACKEND_TAG=$BACKEND_TAG/" .env
[ "$COMPONENT" != "api" ] && sed -i "s/^UI_TAG=.*/UI_TAG=$UI_TAG/" .env
docker compose --env-file .env pull
[ "$COMPONENT" != "ui" ] && docker compose --env-file .env run --rm migrate
docker compose --env-file .env up -d --remove-orphans
echo '{"component":"'"$COMPONENT"'","backend_tag":"'"$BACKEND_TAG"'"}"

Разделение «механика/решение» важно: механика не знает про здоровье — она просто идемпотентно приводит систему к тегам из .env.

12.6 Гейт и откат

bash
# после release.sh:
curl -fsS http://127.0.0.1/health          # API жив (через nginx)
curl -fsS http://127.0.0.1/ | grep -q '<div id="root"'  # UI — это SPA, не заглушка

# красный гейт → откат:
./release.sh --component all --backend-tag "$PREV_BACKEND" --ui-tag "$PREV_UI"

Скрипт-оркестратор хранит предыдущие теги (файл deploy/.env.prev), пишет результат гейта в лог и завершает релиз статусом released | rolled_back. По этим строкам потом видно, что происходило.

12.7 Миграции expand→contract — теперь это ваша схема

Помните, почему rooms → conversations делали в три шага (Модуль 2)? Вот здесь это окупается: откат на предыдущий образ после прогона миграций не падает о схему — старый код просто не замечает новых колонок и таблиц. Порядок релиза: migrate (expand) → api → проверка → в следующем релизе contract (удаление старого).

12.8 stg, TLS, бэкапы, CI

  • stg: локальный docker compose — учебный stg-контур; на проде второй копией стека на той же VM (другие порты и БД) проверяют релиз до прода.
  • TLS: certbot (--nginx) или Caddy (авто-TLS «из коробки») — оба пути валидны; главное — HTTPS и wss://, без них push не работает.
  • Бэкапы: контейнер backup с pg_dump раз в сутки в bind-mount; проверка бэкапа обязательна: раз в неделю restore в тестовую БД.
  • CI: GitHub Actions — по git-тегу собирает и пушит оба образа, затем вызывает выкат по ssh; health-гейт решает судьбу релиза.

💻 Практика

  1. Соберите deploy/: backend.Dockerfile (multi-stage: build → distroless или alpine), frontend.Dockerfile (build → nginx со статикой), nginx.conf (§12.3), docker-compose.prod.yml, .env.example (BACKEND_TAG, UI_TAG, секреты), build-push.sh, release.sh.

  2. Поднимите VM (любая Linux с Docker; Yandex Cloud/любой провайдер). Поток безопасности: только 22/80/443, пользователь деплоя, bind-mount /var/lib/messenger/{pg,backups}. DNS-имя → статический IP.

  3. Первый деплой: выпустите v0.1.0 через build-push.sh, на VM — cp .env.example .env, заполните, release.sh --component all. Настройте TLS, откройте https://ваш-домен с телефона — это ваш прод.

  4. Релиз с проверкой отката:

    • выпустите v0.1.1 со сломанным /health (нарочно: пусть отвечает 500 при старте);
    • прогоните release.sh → гейт красный → откат на v0.1.0 → гейт зелёный. Зафиксируйте released | rolled_back в логе.
    • почините и выпустите v0.1.2 штатно.
  5. Бэкап: дождитесь pg_dump (или запустите вручную), восстановите дамп в тестовую БД, сравните число сообщений — бэкап без проверки не бэкап.

  6. CI (по желанию, но рекомендовано): workflow «тег → build-push → deploy по ssh», секреты — в настройках репозитория.

Чек-лист модуля: https://ваш-домен работает снаружи; WS живёт через nginx (wss://) без обрывов; релиз v0.1.x прошёл гейт; смоделированный сбой откатился автоматически; бэкап восстановлен в тестовую БД; release.sh идемпотентен (повторный запуск с теми же тегами ничего не ломает).

🧩 Quiz

1. Почему на VM не собирают образы?
2. Зачем proxy_read_timeout 3600s для WS в nginx?
3. Гейт «UI: 200 с HTML» — почему не просто «200»?
4. Релиз UI_TAG не трогает api. Почему это важно?
5. Бэкап настроен и лежит в /var/lib/messenger/backups. Что ещё обязательно?
6. Как expand→contract спасает откат?

✅ Итоги модуля

  • Релизный конвейер стоит на трёх принципах: сборка на dev-машине, версии в тегах .env, гейт с откатом.
  • Стек на VM — шесть контейнеров; наружу только 80/443; nginx настроен под WS.
  • release.sh — чистая механика, решения принимает вызывающий.
  • Expand→contract из Модуля 2 оказался не теорией, а страховкой откатов.
  • Бэкап проверен восстановлением; CI замыкает контур «тег → прод».

🔗 Мостик к Модулю 13

Продукт живёт по адресу, релизы откатываются, бэкапы проверены. Осталось главное — пройти защиту: показать систему с двух устройств, обосновать решения перед «комиссией» и закрыть чек-лист приёмки. Финальный модуль — ваш регламент сдачи.