Zero-downtime деплой: как обновлять сервис без простоя
Стратегии выкатки, рабочая схема blue-green на одном сервере, корректное завершение процессов и миграции базы, которые не останавливают сервис.

Коротко: деплой без простоя (zero-downtime deploy) — это выкатка, при которой новая версия поднимается рядом со старой, принимает трафик только после проверки готовности, а старая дорабатывает начатые запросы и только потом гасится. Для этого нужны три вещи: балансировщик перед приложением, health check и корректная обработка SIGTERM. Самая частая причина простоя — не код, а миграция базы данных.
Классический деплой на небольшом проекте выглядит так: git pull, сборка, systemctl restart app. Пока сервис перезапускается, nginx отвечает 502, а у пользователей падает оплата, теряется черновик формы или зависает запрос. Если рестарт занимает пять секунд и деплоев пара в неделю, это терпимо. Если деплоите каждый день, сервис прогревает кэш минуту, а клиенты — мобильные приложения, это уже заметная доля ошибок.
Мобильные клиенты здесь особенно чувствительны: старую версию приложения на телефоне пользователя нельзя обновить принудительно, она продолжает ходить в API ещё месяцами. Поэтому, если вы заказываете поддержку мобильного приложения у подрядчика, спросите заранее, как он выкатывает бэкенд: обновляется ли API без простоя и остаётся ли он совместимым со старыми сборками на время перехода. Ниже — какие стратегии существуют, как настроить деплой без простоя на обычном Linux-сервере с nginx и systemd, и где почти все ошибаются в первый раз.
Что такое zero-downtime деплой и почему обычный рестарт даёт простой?
Простой при деплое возникает в трёх местах:
- Окно без процесса. Старый процесс уже остановлен, новый ещё не слушает порт. Балансировщик отдаёт 502 Bad Gateway.
- Оборванные запросы. Процесс убит посреди обработки: загрузка файла, длинный SQL-запрос или вебхук платёжной системы обрываются, клиент получает ошибку соединения.
- Холодный старт. Процесс слушает порт, но ещё не готов: не открыт пул соединений к базе, не прогрет кэш, не загружена модель. Первые запросы идут по 10–30 секунд или падают по таймауту.
Деплой без простоя закрывает все три окна. Новая версия стартует параллельно со старой, балансировщик пускает на неё трафик только после ответа health check, а старая версия перестаёт принимать новые соединения, дожидается завершения текущих и только потом выходит. Важно понимать, что «без простоя» — это не «без риска»: если в новой версии баг, вы просто выкатите его без даунтайма. Поэтому вторая половина темы — быстрый откат.
Какие стратегии деплоя без простоя существуют?
Стратегий пять, и они различаются ценой, скоростью отката и сложностью.
| Стратегия | Как работает | Простой | Откат | Цена |
|---|---|---|---|---|
| Recreate | Остановить старое, запустить новое | Есть | Новый деплой | Минимальная |
| Rolling update | Экземпляры заменяются по одному | Нет | Минуты | +1 экземпляр на время выкатки |
| Blue-green | Две полные копии, трафик переключается целиком | Нет | Секунды | x2 ресурсов |
| Canary | Новая версия получает 1–10 % трафика, затем больше | Нет | Секунды | Нужны метрики и маршрутизация по весам |
| Shadow (mirroring) | Копия трафика идёт в новую версию, ответы выбрасываются | Нет | Не нужен | Высокая, сложно с записью в БД |
Для одного-двух серверов практичнее всего blue-green на одной машине: две копии приложения на разных портах за nginx. Ресурсов это требует немного (вторая копия живёт только во время деплоя), а откат сводится к переключению upstream обратно. Rolling update — стандарт в Kubernetes и для групп из трёх и более серверов. Canary имеет смысл, когда есть метрики ошибок по версиям и трафика достаточно, чтобы 5 % что-то показали. Подробнее о выборе между балансировщиками — в статье Traefik, Nginx или HAProxy.
Как сделать blue-green деплой на одном сервере с nginx и systemd?
Схема: приложение запускается как шаблонный юнит systemd app@.service, экземпляр app@8001 — «синий», app@8002 — «зелёный». Nginx смотрит на активный порт через подключаемый файл.
Юнит /etc/systemd/system/app@.service:
[Unit]
Description=App on port %i
After=network.target
[Service]
User=app
WorkingDirectory=/srv/app/current
Environment=PORT=%i
ExecStart=/srv/app/current/bin/server
KillSignal=SIGTERM
TimeoutStopSec=30
Restart=on-failure
[Install]
WantedBy=multi-user.target
TimeoutStopSec=30 — время, которое systemd даёт процессу на корректное завершение после SIGTERM, прежде чем послать SIGKILL. Оно должно быть больше самого длинного нормального запроса. Как устроены юниты и зависимости, разобрано в статье systemd без боли.
Конфигурация nginx:
# /etc/nginx/conf.d/app-upstream.conf — переписывается скриптом деплоя
upstream app { server 127.0.0.1:8001; keepalive 32; }
server {
listen 443 ssl;
server_name example.ru;
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_next_upstream error timeout http_502;
}
}
Ключевой момент: nginx -s reload (или systemctl reload nginx) не рвёт соединения. Мастер-процесс nginx запускает новые воркеры с новым конфигом, а старые воркеры дообслуживают уже открытые запросы и завершаются. Поэтому переключение upstream проходит для клиентов незаметно. Директивы upstream описаны в документации nginx.
Скрипт деплоя deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
ACTIVE=$(grep -oP '127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/app-upstream.conf)
if [ "$ACTIVE" = "8001" ]; then NEW=8002; else NEW=8001; fi
# 1. Выложить релиз в отдельный каталог и переключить симлинк
REL=/srv/app/releases/$(date +%Y%m%d%H%M%S)
mkdir -p "$REL" && tar -xzf /tmp/build.tgz -C "$REL"
ln -sfn "$REL" /srv/app/current
# 2. Поднять новую копию рядом со старой
systemctl restart "app@$NEW"
# 3. Дождаться готовности (до 60 секунд)
for i in $(seq 1 30); do
curl -fsS "http://127.0.0.1:$NEW/healthz/ready" >/dev/null && break
sleep 2
[ "$i" = 30 ] && { echo "new version not ready"; systemctl stop "app@$NEW"; exit 1; }
done
# 4. Переключить трафик
echo "upstream app { server 127.0.0.1:$NEW; keepalive 32; }" \
> /etc/nginx/conf.d/app-upstream.conf
nginx -t && systemctl reload nginx
# 5. Дать старой версии доработать запросы и остановить её
sleep 10
systemctl stop "app@$ACTIVE"
echo "switched $ACTIVE -> $NEW"
Откат — тот же шаг 4 в обратную сторону: вернуть старый порт в upstream и сделать reload. Пока старая копия не удалена, это занимает секунду. Каталоги релизов храните хотя бы последние пять — тогда можно откатиться и на версию назад, переключив симлинк. Скрипт удобно запускать из конвейера: как его собрать, описано в статье GitLab CI с нуля за час.
Зачем нужен graceful shutdown и как его проверить?
Без корректного завершения всё описанное выше не работает: systemctl stop пошлёт SIGTERM, и если приложение на него не реагирует правильно, оно либо умрёт мгновенно посреди запросов, либо провисит 30 секунд и будет убито SIGKILL. Правильное поведение по SIGTERM:
- Перестать принимать новые соединения (закрыть listening-сокет) и начать отвечать 503 на readiness-проверку.
- Дождаться завершения запросов, которые уже в работе.
- Закрыть соединения с базой, брокером очередей и кэшем.
- Выйти с кодом 0.
В большинстве фреймворков это уже есть, но часто выключено. В Go — http.Server.Shutdown(ctx), в Node.js — server.close() в обработчике process.on('SIGTERM'), у Gunicorn — параметр graceful_timeout, у Spring Boot — server.shutdown=graceful. Отдельная ловушка — Docker: если в образе процесс запускается через shell-обёртку (CMD npm start), SIGTERM получает shell, а не приложение. Используйте exec-форму CMD ["node", "server.js"] или init-процесс tini. Как устроены контейнеры, разобрано в материале Docker-контейнеры простыми словами.
Проверить graceful shutdown просто: запустите нагрузку, например hey -z 30s -c 20 https://example.ru/api/ping, и посреди теста выполните деплой. В отчёте не должно быть ни одного ответа с кодом 502 или ошибки соединения. Если есть — смотрите логи приложения в момент остановки (как их собирать, описано в статье логирование в продакшене).
Чем отличаются liveness и readiness проверки?
Их часто путают, и это ломает деплой.
- Liveness (
/healthz/live) отвечает на вопрос «процесс не завис?». Возвращает 200, если цикл событий жив. Если проверка падает, процесс перезапускают. - Readiness (
/healthz/ready) отвечает на вопрос «можно ли слать трафик?». Проверяет подключение к базе, прогретый кэш, загруженную конфигурацию. Если падает, процесс не убивают, а просто убирают из балансировки.
Типичная ошибка — проверять доступность базы в liveness. Когда база на минуту ляжет, все экземпляры приложения начнут перезапускаться по кругу, и короткий сбой превратится в долгий. Вторая ошибка — readiness, который всегда отвечает 200: балансировщик отправит трафик на процесс, который ещё открывает пул соединений. В Kubernetes те же понятия задаются параметрами livenessProbe и readinessProbe, а для rolling update важно выставить maxUnavailable: 0, чтобы старый под не гасился раньше, чем новый станет ready.
Как мигрировать базу данных без простоя?
Это главный источник даунтайма, даже когда с выкаткой кода всё в порядке. Во время деплоя без простоя старая и новая версии кода работают с одной базой одновременно — пусть и десять секунд. Значит, схема в каждый момент должна подходить обеим версиям. Решение — паттерн expand/contract (расширить, затем сжать). Пример: переименовать колонку phone в phone_number.
- Expand. Миграция добавляет новую колонку
phone_number, старую не трогает. Выкатываете код, который пишет в обе колонки, а читает из старой. - Backfill. Фоновая задача переносит данные пачками по 1–5 тысяч строк, чтобы не держать долгую блокировку.
- Переключение. Следующий релиз читает из новой колонки и пишет в обе.
- Contract. Ещё через релиз код перестаёт использовать
phone, и отдельная миграция её удаляет.
Четыре деплоя вместо одного — это цена отсутствия простоя и возможности отката на каждом шаге. Несколько правил для PostgreSQL, которые экономят нервы:
- Индексы на живой таблице создавайте через
CREATE INDEX CONCURRENTLY— обычныйCREATE INDEXблокирует запись. ALTER TABLE ... ADD COLUMNсDEFAULTв PostgreSQL 11+ выполняется мгновенно, аSET NOT NULLна большой таблице сканирует её целиком под блокировкой: сначала добавьтеCHECK ... NOT VALID, затемVALIDATE CONSTRAINT.- Выставляйте
SET lock_timeout = '5s'в миграциях: если таблица занята, миграция упадёт, а не встанет в очередь и не заблокирует все запросы за собой. - Миграции запускайте отдельным шагом до переключения трафика, а не при старте каждого экземпляра приложения.
Тот же принцип совместимости касается API. Если удалить поле из ответа или сделать необязательный параметр обязательным, старые клиенты сломаются — и для мобильных приложений, которые обновляются у пользователей неделями, это не 10 секунд, а месяц ошибок. Новые поля добавляйте, старые помечайте устаревшими и удаляйте только тогда, когда версии клиентов, которые их используют, практически исчезли из логов.
Что ещё ломает деплой без простоя?
- Сессии в памяти процесса. После переключения пользователь попадает на новый экземпляр и оказывается разлогинен. Храните сессии в Redis или базе, либо используйте подписанные токены.
- Файлы, загруженные в каталог релиза. После переключения симлинка они «пропадают». Выносите загрузки в общий каталог (
/srv/app/shared/uploads) или в объектное хранилище. - Фоновые воркеры и cron. Две версии воркера одновременно могут обработать одну задачу дважды. Нужны идемпотентные задачи или блокировка на уровне очереди.
- WebSocket и долгие соединения. Они не завершаются сами за 10 секунд. Клиент должен уметь переподключаться, а сервер — закрывать соединение с кодом «уходим на рестарт».
- Кэш статики. Новая HTML-страница ссылается на
app.4f2a.js, которого нет на старом экземпляре. Решение — хэш в именах файлов и хранение статики предыдущего релиза.
Чеклист перед первым деплоем без простоя
- Перед приложением стоит балансировщик (nginx, HAProxy, Traefik), который умеет переключать upstream через reload.
- Приложение корректно обрабатывает SIGTERM, а
TimeoutStopSecбольше самого долгого запроса. - Есть отдельные эндпоинты liveness и readiness, и readiness проверяет зависимости.
- Миграции БД обратно совместимы и запускаются до переключения трафика.
- Сессии, загрузки и статика не привязаны к конкретному экземпляру.
- Откат — одна команда, и вы его хотя бы раз проверили.
- Деплой под нагрузочным тестом проходит без ответов 502.
Частые вопросы
Нужен ли Kubernetes для деплоя без простоя?
Нет. На одном сервере достаточно двух копий приложения на разных портах, nginx и скрипта переключения upstream. Kubernetes даёт rolling update «из коробки», но для небольшого проекта это лишняя сложность. Подробнее — в статье о Kubernetes для небольших команд.
Что выбрать: blue-green или rolling update?
Blue-green проще для одного-двух серверов и даёт мгновенный откат, но временно требует двойных ресурсов. Rolling update экономнее и стандартен для кластеров из трёх и более экземпляров, но откат идёт так же постепенно, как выкатка.
Обрывает ли nginx reload активные соединения?
Нет. При reload мастер-процесс запускает новые воркеры с новым конфигом, а старые дообслуживают открытые соединения и завершаются. Обрывает соединения только полный restart.
Сколько ждать перед остановкой старой версии?
Не меньше, чем длится самый долгий нормальный запрос, плюс запас. Для обычного веб-API хватает 10–30 секунд. Если есть загрузка больших файлов или долгие отчёты, время ожидания и TimeoutStopSec нужно увеличить.
Как быть с миграцией, которая удаляет колонку?
Удалять колонку можно только тогда, когда ни одна работающая версия кода её не использует. Поэтому удаление — отдельный релиз после того, как код перестал к ней обращаться. Это и есть шаг contract в паттерне expand/contract.
Подходит ли zero-downtime для монолита на PHP?
Да. PHP-FPM поддерживает плавную перезагрузку пула, а схема с каталогами релизов и симлинком current — стандарт для PHP-деплоя. Нужно только сбрасывать OPcache после переключения симлинка или использовать realpath в конфигурации, чтобы FPM не продолжал выполнять старый код.
Как понять, что деплой действительно прошёл без простоя?
Запустите нагрузочный тест (hey, wrk, k6) на время деплоя и посчитайте ответы с кодами 5xx и ошибки соединения. Дополнительно посмотрите в access-логе nginx записи со статусом 502 и 504 за минуту деплоя: их должно быть ноль.