MAATRIX / Блог / Антипаттерн: ручной деплой по SSH раз в месяц

Антипаттерн: ручной деплой по SSH раз в месяц

MAATRIX

Сервер работает, деплой — редкое событие: заходите по SSH раз в несколько недель, когда накопилось достаточно правок и наконец нашёлся свободный час, тянете git pull, перезапускаете сервис и, если сразу ничего не упало, считаете задачу закрытой. Это работает — пока не выясняется, что среди двух десятков коммитов один сломал миграцию базы, другой забыл про перезапуск воркера, а откатываться некуда, потому что предыдущей рабочей версии на диске уже нет. Разберём, что именно не так с этим подходом, и покажем минимальный скрипт, который закрывает большинство дыр без перехода на полноценный CI/CD.

Как это обычно выглядит на практике

Процедура почти всегда одна и та же и умещается в голове у одного человека:

ssh deploy@203.0.113.10
cd /var/www/myapp
git pull origin main
sudo systemctl restart myapp
sudo systemctl status myapp

Если в выводе systemctl status зелёная надпись active (running) и сервис отвечает на первый же запрос в браузере — деплой считается успешным, SSH-сессия закрывается, все расходятся. Никакого автоматического запуска тестов, никакого сохранения предыдущей версии на случай отката, никакого протокола на случай, если что-то пошло не так. Делается это не потому что кто-то ленив, а потому что по-другому — некогда: сервер один, релизы редкие, и каждый раз кажется, что «в этот раз всё пройдёт так же гладко, как в прошлый».

Частота здесь ключевая деталь: деплой происходит не по расписанию и не по событию (коммит в main), а «когда накопится достаточно изменений и появится время» — то есть раз в месяц, а иногда и реже. Именно эта нерегулярность и порождает все следующие проблемы.

Проблема №1 — большой пакет изменений невозможно быстро диагностировать

Если деплоить каждый день, в один релиз попадает 2-5 коммитов. Что-то сломалось — подозреваемых мало, и обычно достаточно посмотреть последний диф, чтобы понять причину. Если деплоить раз в месяц, в один релиз попадает 40-80 коммитов от нескольких человек: правки моделей, миграции, изменения в конфигурации, апдейты зависимостей. Когда после такого релиза что-то падает, вопрос «что из этого виновато» превращается в отдельное расследование.

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

Частые небольшие деплои превращают эту задачу в тривиальную по построению: причина почти всегда либо в последнем коммите, либо в паре последних. Это не абстрактная best practice, а прямое следствие математики — чем меньше переменных изменилось между «работало» и «не работало», тем быстрее находится виновник.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Проблема №2 — процесс живёт в голове одного человека

В реальных проектах деплой редко сводится к git pull + restart. Обычно есть побочные шаги: применить миграцию базы данных, перезапустить не только само приложение, но и зависимый воркер очередей, сбросить кеш Redis или CDN, прогреть какой-нибудь кеш заново после рестарта. Когда это делает один и тот же человек раз в месяц, часть шагов держится в памяти, а не в документе или скрипте — «а, точно, я же ещё Redis чищу после таких изменений, чуть не забыл».

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

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

Проблема №3 — код попадает на прод без единой автоматической проверки

В ручном процессе нет момента, где кто-то или что-то могло бы сказать «стоп, эта миграция не применяется» или «этот тест падает» до того, как код окажется на живом сервере. Первая проверка происходит фактически на продакшене: git pull, рестарт, и если приложение не поднялось — вы узнаёте об этом по факту сбоя, а не заранее.

Это касается не только грубых синтаксических ошибок (хотя и они случаются — опечатка в конфиге, забытая запятая в JSON), но и логических регрессий, которые ловятся тестами: сломанный эндпоинт, неправильная миграция, которая накатывается без ошибок, но портит данные. Без автоматического прогона тестов перед выкладкой единственный «тест» — это реальные пользователи в проде.

Минимальный шаг вперёд — прогонять тестовый набор проекта прямо перед тем, как трогать боевой сервис, и останавливать процесс, если тесты красные. Это не требует CI/CD-сервера — достаточно одной строчки в скрипте деплоя, которая упадёт при ненулевом коде возврата.

Проблема №4 — простой сервиса на время рестарта

systemctl restart myapp останавливает старый процесс и запускает новый — между этими двумя событиями сервис не отвечает на запросы. Для типичной конфигурации «один сервер, один процесс приложения, без балансировщика» это означает реальный, пусть и короткий, простой на каждом деплое. Ситуация особенно неприятна в сочетании с проблемой №1: чем реже деплой, тем на бОльшую пачку изменений приходится один такой простой, а если после рестарта сервис не поднимается (например, из-за той самой непроверенной миграции), простой растягивается на всё время, пока человек разбирается вручную.

Автоматизированный деплой решает это не магией, а конкретными паттернами, которые без пайплайна применять вручную неудобно и рискованно:

СтратегияКак работаетПростой
Ручной systemctl restartСтарый процесс останавливается, новый стартуетЕсть, от секунд до минут при сбое
Rolling restartНесколько инстансов за балансировщиком, перезапуск по одномуПрактически нет для клиента
Blue-greenНовая версия поднимается рядом со старой, трафик переключается после health-checkНет, только на момент переключения
CanaryНовая версия получает часть трафика, остальное остаётся на старойНет, риск ограничен долей трафика

Подробнее про canary-подход и постепенную выкладку разобрано в статье про canary-деплой — для проекта на одном сервере без балансировщика полноценный blue-green может быть избыточен, но сама идея «новая версия сначала должна доказать, что она живая» переносится и в простой скрипт ниже через health-check.

Простой скрипт деплоя с проверками

Не обязательно сразу строить CI/CD-пайплайн, чтобы закрыть три главные дыры — отсутствие тестов перед выкладкой, отсутствие бэкапа перед миграцией и отсутствие проверки после рестарта. Ниже — рабочий каркас скрипта deploy.sh, который выполняет тот же сценарий, что и ручной SSH-заход, но с проверками на каждом шаге и остановкой при первой же ошибке:

#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/var/www/myapp"
SERVICE="myapp"
HEALTH_URL="http://127.0.0.1:8000/health"
DB_NAME="myapp_prod"
BACKUP_DIR="/var/backups/myapp"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)

cd "$APP_DIR"

echo "==> Текущий коммит перед деплоем: $(git rev-parse --short HEAD)"
PREV_COMMIT=$(git rev-parse HEAD)

echo "==> Забираем изменения"
git fetch origin main
git checkout origin/main -- .

echo "==> Устанавливаем зависимости"
pip install -r requirements.txt --quiet

echo "==> Прогоняем тесты — если упадут, дальше не идём"
if ! pytest -q; then
  echo "Тесты не прошли, откатываем рабочую копию и останавливаем деплой"
  git checkout "$PREV_COMMIT" -- .
  exit 1
fi

echo "==> Бэкап базы перед миграцией"
mkdir -p "$BACKUP_DIR"
pg_dump "$DB_NAME" > "$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.sql"

echo "==> Применяем миграции"
python manage.py migrate --noinput

echo "==> Перезапускаем сервис"
sudo systemctl restart "$SERVICE"

echo "==> Ждём и проверяем health-check"
for i in $(seq 1 10); do
  if curl -fsS "$HEALTH_URL" > /dev/null; then
    echo "Сервис поднялся и отвечает, деплой успешен: $(git rev-parse --short HEAD)"
    exit 0
  fi
  sleep 2
done

echo "Health-check не прошёл за 20 секунд, откатываемся"
git checkout "$PREV_COMMIT" -- .
python manage.py migrate --noinput || true
sudo systemctl restart "$SERVICE"
exit 1

Логика простая: тесты идут до того, как скрипт трогает базу и сервис; бэкап делается прямо перед миграцией, а не когда-то заранее (чтобы восстановить именно то состояние, с которого начали); health-check после рестарта — это не «глазами посмотрел на статус», а конкретный HTTP-запрос к эндпоинту /health, который приложение должно реализовать само (обычно это простая проверка соединения с базой и Redis). Если что-то из этого не проходит, скрипт откатывает код к предыдущему коммиту и завершает выполнение с ненулевым кодом — что удобно потом обвязать алертом в Telegram или другую систему уведомлений.

Для проекта с миграциями через Flyway или Liquibase логика бэкапа и отката получается немного другой — она разобрана отдельно в статье про миграции базы данных, если у вас именно такой стек. Сам эндпоинт /health для проверки состояния можно строить по тем же принципам, что и Docker healthcheck — там же разбираются нюансы тайм-аутов и повторных попыток, см. настройку healthcheck.

От скрипта к CI/CD-пайплайну

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

Следующий зрелый шаг — вынести этот же набор шагов в CI/CD-пайплайн, который срабатывает автоматически при пуше в main: тесты прогоняются на каждый коммит, а не только перед деплоем раз в месяц, и деплой на сервер происходит сразу после зелёного пайплайна, пока изменений накопилось мало и причину возможной поломки видно с первого взгляда. Настройка такого пайплайна на собственном VPS с GitLab CI/CD и runner'ом разобрана в статье про настройку GitLab CI/CD на VPS — по сути это тот же bash-скрипт, что и выше, только вызывается не руками, а по событию, и результат каждого шага виден в истории пайплайна, а не только в терминале того, кто был за клавиатурой в момент деплоя.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли просто чаще заходить по SSH и деплоить вручную, без скрипта?

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

Нужен ли отдельный сервер под CI/CD раннер?

Необязательно на старте: раннер GitLab CI можно поставить на тот же VPS, где крутится приложение, если ресурсов хватает — важно только не конкурировать за память и CPU с продовым процессом в момент сборки.

Что делать, если базу переносить pg_dump перед каждым деплоем слишком долго из-за размера?

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

Обязательно ли делать blue-green или rolling restart, если сервер один?

Нет, для одного сервера без балансировщика это избыточно. Здесь важнее health-check с откатом при неудаче — он не убирает секундный простой при рестарте, но не даёт сломанной версии остаться в проде надолго.

С чего начать, если сейчас деплой — это буквально git pull в терминале?

С малого: обернуть те же команды в bash-скрипт с set -euo pipefail, добавить прогон тестов и один health-check после рестарта. Это уже закрывает большую часть рисков и не требует новой инфраструктуры.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →