VPS даёт полный контроль над сервером, но обновления, HTTPS, мониторинг и выкатка без простоя — на вас; PaaS вроде Vercel, Render или Fly.io берёт это на себя ценой рамок платформы; Kubernetes нужен, когда сервисов и экземпляров много и нужна автоматическая оркестрация. Выкатка без простоя в любой модели строится одинаково: новая версия поднимается рядом со старой, проходит health check, получает трафик, а старая мягко завершает текущие запросы. Миграции БД при этом делают обратно совместимыми, чтобы откат кода оставался возможным.
После материала вы сможете
- Сравнить VPS, PaaS и контейнерную оркестрацию по контролю, ответственности и трудозатратам и выбрать модель под проект
- Описать выкатку rolling и blue-green с health check и мягким завершением старой версии
- Спланировать миграцию БД в стиле expand/contract, чтобы откат кода не ломал данные
- Составить чек-лист релиза с переменными окружения и планом отката
Что знать заранее
Проверено на
Nginx 1.30.5 (stable)Node.js 24 LTSDocker Engine 29.6
Сверено с документацией: 11 октября 2026 г.
Содержание
Что значит «задеплоить»
Деплой — это не «скопировать файлы на сервер», а цепочка из трёх стадий, которую методология The Twelve-Factor App называет build, release, run. Сборка превращает код в запускаемый артефакт: бандл, Docker-образ. Релиз соединяет сборку с конфигурацией конкретного окружения. Запуск поднимает процессы релиза и направляет на них трафик.
Конфигурация, которая различается между окружениями — адреса баз данных, ключи внешних сервисов, домен, — по той же методологии хранится в переменных окружения, а не в коде. Один и тот же образ уходит в staging и production, меняются только переменные.
Три модели: VPS, PaaS и оркестрация контейнеров
VPS
- Что даёт платформа
- Виртуальный сервер с root-доступом
- Что остаётся на команде
- ОС и обновления, firewall, Nginx и HTTPS, бэкапы, мониторинг, сценарий выкатки
- Когда уместна
- Нужен полный контроль, нестандартный софт, долгоживущие процессы
PaaS: Vercel, Render, Fly.io
- Что даёт платформа
- Сборку из Git, HTTPS, выкатку, откат, логи
- Что остаётся на команде
- Код, переменные окружения, миграции, укладывание в ограничения платформы
- Когда уместна
- Небольшая команда, стандартный стек, важна скорость релизов
Контейнеры и Kubernetes
- Что даёт платформа
- Декларативные развёртывания, rolling update, проверки здоровья, откат ревизий
- Что остаётся на команде
- Кластер или управляемый сервис, манифесты, сеть, наблюдаемость
- Когда уместна
- Много сервисов и экземпляров, есть инженер, который отвечает за платформу
VPS — полный контроль и полная ответственность. Сервер ваш: можно поставить любой софт и держать WebSocket-соединения или фоновые процессы сколько угодно. Но и без простоя выкатывать, продлевать сертификаты, ставить патчи безопасности и восстанавливать сервер после сбоя тоже вам. Типичный стек — Docker и Nginx перед приложением, как в руководстве по reverse proxy.
PaaS (Platform as a Service) берёт эксплуатацию на себя, но задаёт свою модель. У Vercel каждый деплой — отдельная сборка со своим URL, а Instant Rollback возвращает production на прежний деплой. Render при каждом деплое поднимает новый экземпляр, переключает трафик и шлёт старому SIGTERM. Fly.io позволяет выбрать стратегию выкатки в fly.toml. Ограничения — время выполнения, файловая система, фоновые задачи — у каждой платформы свои, и их стоит прочитать до выбора, а не после.
Kubernetes решает задачу оркестрации: держит нужное число экземпляров, перезапускает упавшие, выкатывает новую версию постепенно и хранит историю ревизий для отката. Это мощный, но тяжёлый инструмент. По нашему опыту, одному-двум сервисам с одной базой чаще хватает VPS с Docker Compose или PaaS, а к Kubernetes приходят, когда сервисов много и появляется человек, отвечающий за платформу.
Берите PaaS, если
- Команда маленькая и не хочет администрировать серверы
- Стек поддерживается платформой из коробки
- Нужны превью-окружения для каждой ветки
- Ограничения платформы не мешают задаче
Берите VPS или контейнеры, если
- Нужен нестандартный софт или доступ к ОС
- Есть долгие процессы, постоянные соединения или локальный диск
- Данные по требованиям должны жить в конкретной стране или у конкретного провайдера
- Есть компетенция и время на эксплуатацию
Выкатка без простоя: rolling, blue-green, canary
Recreate
- Как работает
- Остановить все старые экземпляры, затем запустить новые
- Плюсы
- Просто, две версии никогда не работают одновременно
- Минусы
- Простой на время запуска
Rolling
- Как работает
- Заменять экземпляры по одному или группами
- Плюсы
- Не нужен двойной запас ресурсов
- Минусы
- Какое-то время работают обе версии
Blue-green
- Как работает
- Поднять полную копию новой версии и переключить весь трафик разом
- Плюсы
- Мгновенное переключение и такой же быстрый откат
- Минусы
- На время выкатки нужны ресурсы на две версии
Canary
- Как работает
- Сначала один экземпляр новой версии, затем остальные
- Плюсы
- Ошибка затрагивает малую часть запросов
- Минусы
- Нужны метрики, чтобы заметить проблему
Kubernetes Deployment по умолчанию использует стратегию RollingUpdate: параметры maxUnavailable и maxSurge по умолчанию равны 25%, то есть во время выкатки доступно не меньше 75% нужных подов и запущено не больше 125%. Альтернатива — Recreate. Fly.io по умолчанию выкатывает rolling, а также умеет immediate, canary и bluegreen; последние две не работают с машинами, к которым подключены тома, а для bluegreen нужен хотя бы один health check. Render для сервиса из нескольких экземпляров меняет их по одному и отменяет весь деплой, если новый экземпляр не стал здоровым.
Health check
Новая версия получает трафик только после проверки. В Kubernetes это readiness probe: под без готовности убирается из балансировки. Liveness probe решает другое — когда контейнер перезапустить, и документация предупреждает, что неаккуратная liveness-проверка может вызвать каскадные сбои.
Переключение
Балансировщик или прокси начинает отправлять новые запросы на новую версию. В Nginx это правка
upstreamи мягкая перезагрузка: старые рабочие процессы дообслуживают свои соединения.Мягкое завершение
Старая версия получает
SIGTERM, перестаёт принимать соединения, завершает текущие запросы и выходит.docker stopждёт по умолчанию 10 секунд и затем шлётSIGKILL; Render — 30 секунд, этот срок можно увеличить.
Демонстрация: blue-green за Nginx на одном сервере
Проверим разницу на практике. Стенд — Docker-сеть, Nginx 1.30.5 и Node.js-приложение из руководства по reverse proxy, которое дополнительно 3 секунды «прогревается» перед открытием порта, как реальный сервис при подключении к базе. Скрипт watch.sh изображает пользователей: шлёт запрос примерно каждые 50 мс и в конце считает ответы по коду и версии. Скрипты кладутся в одну папку с app/ и nginx/ и делаются исполняемыми: chmod +x *.sh. Все выводы ниже — реальные.
// simulated warm-up (DB connections, caches) before the port opens
const STARTUP_DELAY_MS = Number(process.env.STARTUP_DELAY_MS ?? 0);
setTimeout(() => {
server.listen(PORT, () => console.log(`app ${VERSION} on :${PORT}`));
}, STARTUP_DELAY_MS);listen — остальной код приложения как в руководстве по Nginxinclude /etc/nginx/conf.d/upstream.inc;
server {
listen 80;
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}upstream.inc — этот файл и переписывает деплой#!/usr/bin/env bash
# Local stand: one Docker network, the app v1 ("blue") and Nginx in front of it
set -euo pipefail
P="${PREFIX:-demo}"
docker network create "$P-net" >/dev/null
echo "upstream app_backend { server $P-app-blue:3000; }" > nginx/conf.d/upstream.inc
docker run -d --rm --name "$P-app-blue" --network "$P-net" \
-e STARTUP_DELAY_MS=3000 -e APP_VERSION=v1 -v "$PWD/app:/app:ro" node:24-alpine node /app/server.mjs >/dev/null
docker run -d --rm --name "$P-proxy" --network "$P-net" -p 8081:80 \
-v "$PWD/nginx/conf.d:/etc/nginx/conf.d:ro" nginx:stable-alpine >/dev/null
echo blue > .active
sleep 5 && curl -s http://localhost:8081/api/whoami && echo#!/usr/bin/env bash
P="${PREFIX:-demo}"
docker stop "$P-proxy" "$P-app-blue" "$P-app-green" >/dev/null 2>&1
docker network rm "$P-net" >/dev/null 2>&1
rm -f .active nginx/upstream.prev
echo "stand removed"--rm удалит их) и удалить сеть#!/bin/sh
# Simulated users: a request every ~50 ms for N seconds, then a summary
# of status codes and versions ("000" = no response within 2 s).
# Usage: ./watch.sh 20 http://localhost:8081/api/whoami
DURATION="${1:-20}"; URL="${2:-http://localhost:8081/api/whoami}"
END=$(( $(date +%s) + DURATION ))
while [ "$(date +%s)" -lt "$END" ]; do
out=$(curl -s -m 2 -w ' %{http_code}' "$URL")
code="${out##* }"
version=$(printf '%s' "$out" | grep -o '"version":"[^"]*"' | cut -d'"' -f4)
echo "$code ${version:--}"
sleep 0.05
done | sort | uniq -c000 — ответа нет за 2 секундыСначала наивный вариант: остановить работающую версию и запустить новую на её месте.
#!/usr/bin/env bash
# Anti-pattern: stop the running version, then start the new one in its place
set -euo pipefail
P="${PREFIX:-demo}"
docker stop "$P-app-blue" >/dev/null
docker run -d --rm --name "$P-app-blue" --network "$P-net" \
-e STARTUP_DELAY_MS=3000 -e APP_VERSION="$1" -v "$PWD/app:/app:ro" node:24-alpine node /app/server.mjs >/dev/null
echo "restarted in place with $1"$ ./setup.sh
{"version":"v1","host":"localhost","forwardedFor":"192.168.16.1","peer":"::ffff:192.168.16.3"}
$ docker run --rm --network demo-net -v "$PWD/watch.sh:/watch.sh:ro" nginx:stable-alpine sh /watch.sh 15 http://demo-proxy/api/whoami > naive.txt &
$ sleep 3; ./naive.sh v2; wait
restarted in place with v2
$ cat naive.txt
49 200 v1
119 200 v2
53 502 -Теперь blue-green: новая версия поднимается рядом, проходит проверку /healthz, Nginx переключается мягкой перезагрузкой, и только потом старая версия получает SIGTERM. Если проверка или nginx -t не прошли, трафик остаётся на старой версии.
#!/usr/bin/env bash
# Blue-green release behind Nginx: start the new version next to the old one,
# wait for its health check, switch Nginx with a graceful reload, stop the old one.
# Usage: ./deploy.sh v2
set -euo pipefail
VERSION="$1"
P="${PREFIX:-demo}" # container name prefix
ACTIVE=$(cat .active 2>/dev/null || echo blue)
NEXT=$([ "$ACTIVE" = blue ] && echo green || echo blue)
echo "deploy $VERSION: $ACTIVE -> $NEXT"
docker run -d --rm --name "$P-app-$NEXT" --network "$P-net" \
-e STARTUP_DELAY_MS=3000 -e APP_VERSION="$VERSION" -v "$PWD/app:/app:ro" \
node:24-alpine node /app/server.mjs >/dev/null
# 1. Health check: the new version must answer before it gets traffic
for i in $(seq 1 20); do
if docker exec "$P-proxy" curl -fsS "http://$P-app-$NEXT:3000/healthz" >/dev/null 2>&1; then
echo "health check passed after $i attempt(s)"
break
fi
if [ "$i" -eq 20 ]; then
echo "health check failed: $NEXT stays out of traffic"
docker stop "$P-app-$NEXT" >/dev/null
exit 1
fi
sleep 0.5
done
# 2. Switch: rewrite the upstream, validate the config, reload gracefully
cp nginx/conf.d/upstream.inc nginx/upstream.prev
echo "upstream app_backend { server $P-app-$NEXT:3000; }" > nginx/conf.d/upstream.inc
if ! docker exec "$P-proxy" nginx -t >/dev/null 2>&1; then
cp nginx/upstream.prev nginx/conf.d/upstream.inc
docker stop "$P-app-$NEXT" >/dev/null
echo "nginx -t failed: config restored"
exit 1
fi
docker exec "$P-proxy" nginx -s reload
echo "$NEXT" > .active
# 3. Old version: SIGTERM, it finishes in-flight requests and exits
sleep 2
docker stop "$P-app-$ACTIVE" >/dev/null
echo "done: $VERSION is live on $NEXT, $ACTIVE stopped"$ ./teardown.sh && ./setup.sh
stand removed
{"version":"v1","host":"localhost","forwardedFor":"192.168.16.1","peer":"::ffff:192.168.16.3"}
$ docker run --rm --network demo-net -v "$PWD/watch.sh:/watch.sh:ro" nginx:stable-alpine sh /watch.sh 20 http://demo-proxy/api/whoami > bg.txt &
$ sleep 3
$ docker run --rm --network demo-net nginx:stable-alpine curl -s -w ' %{http_code}\n' "http://demo-proxy/api/slow?ms=8000" > slow.txt &
$ sleep 0.5; ./deploy.sh v2; wait
deploy v2: blue -> green
health check passed after 4 attempt(s)
2026/10/12 01:36:08 [notice] 77#77: signal process started
done: v2 is live on green, blue stopped
$ cat bg.txt slow.txt
126 200 v1
134 200 v2
{"waitedMs":8000} 200$ docker run --rm --network demo-net -v "$PWD/watch.sh:/watch.sh:ro" nginx:stable-alpine sh /watch.sh 15 http://demo-proxy/api/whoami > rollback.txt &
$ sleep 3; ./deploy.sh v1; wait
deploy v1: green -> blue
health check passed after 5 attempt(s)
2026/10/12 01:36:29 [notice] 149#149: signal process started
done: v1 is live on blue, green stopped
$ cat rollback.txt
108 200 v1
126 200 v2Миграции БД: сначала расширить, потом сузить
При rolling и blue-green старая и новая версии кода какое-то время работают с одной базой. Если миграция переименует колонку, старая версия упадёт, а откат кода не поможет: схема уже другая. Мартин Фаулер описывает выход как parallel change, или expand and contract: изменение делится на фазы, и на каждой схема совместима с обеими версиями кода.
Expand
Добавить новое, ничего не удаляя: новую колонку с допустимым
NULLили значением по умолчанию, новую таблицу.Двойная запись
Код пишет и в старое, и в новое поле, читает пока из старого. Существующие строки заполняются фоновой миграцией данных.
Переключение чтения
Код читает из нового поля. Откат на релиз 2 безопасен: старое поле всё ещё заполняется.
Contract
Когда старое поле никто не использует, удалить его отдельной миграцией.
Где запускать миграции, тоже зависит от платформы. У Render для этого есть pre-deploy command: она выполняется после сборки на отдельном экземпляре до выкатки, и при её ошибке деплой не происходит, а сервис продолжает работать на прежней версии; функция доступна на платных сервисах. У Fly.io — release_command во временной машине с новым образом: ненулевой код выхода отменяет деплой, таймаут по умолчанию 5 минут. На VPS миграцию запускают отдельным шагом CI перед переключением трафика.
Переменные окружения и секреты
По Twelve-Factor проверка простая: можно ли прямо сейчас открыть код репозитория, не раскрыв ни одного пароля? Если нет, конфигурация смешана с кодом. Ключи и адреса подставляются при запуске через переменные окружения или хранилище секретов платформы и не попадают ни в Git, ни в Docker-образ.
- У Vercel изменение переменных окружения применяется только к новым деплоям, а Instant Rollback возвращает прежнюю сборку без учёта новых значений — после смены ключа нужен новый деплой
- Значения переменных на Vercel шифруются при хранении, а секретные значения после сохранения доступны только на запись
- На VPS переменные передают через файл окружения с ограниченными правами или менеджер секретов; файл
.envдобавляют в.gitignore - Для каждого окружения — свой набор ключей: production-ключи не используются в staging и на ноутбуках разработчиков
Откат и чек-лист релиза
Откат — это заранее отрепетированное действие, а не импровизация во время инцидента. В Kubernetes это kubectl rollout undo; по умолчанию Deployment хранит 10 старых ревизий, и при revisionHistoryLimit: 0 откатываться будет некуда. На Vercel — Instant Rollback, причём на тарифе Hobby только к непосредственно предыдущему деплою. На VPS — повторный деплой предыдущего тега образа, как в демонстрации.
- Сборка неизменяема и имеет идентификатор: тег образа или ID деплоя, а не «последняя версия из main»
- Миграции БД обратно совместимы и запускаются до переключения трафика
- Новые переменные окружения и секреты добавлены во все нужные окружения
- Health check проверяет реальную готовность, включая подключение к базе
- Приложение обрабатывает
SIGTERM: перестаёт принимать запросы и дообслуживает текущие - Известно, какой командой и за сколько шагов делается откат, и он хотя бы раз проверен
- После переключения смотрим на ошибки и время ответа, а не только на зелёный статус деплоя
- Старая версия живёт до конца проверки, чтобы откат был мгновенным
Выкатка без простоя — это не свойство хостинга, а порядок действий: поднять рядом, проверить, переключить, мягко остановить.
Как проверено2 · пройденосверено 11 октября 2026 г.
- запущено, работает
setup.sh, deploy.sh (blue-green), naive.sh, watch.sh, teardown.sh, nginx/conf.d/site.conf, app/server.mjs (прогрев)
Docker 29.6.2, nginx:stable-alpine (1.30.5), node:24-alpine (v24.20.0), PREFIX=kb-deploy; watch.sh запускался в контейнере nginx:stable-alpine в той же сети против http://kb-deploy-proxy (в статье — префикс по умолчанию demo). Результаты последнего прогона: наивный перезапуск — 49×200 v1, 119×200 v2, 53×502; blue-green — 126×200 v1, 134×200 v2, 0 ошибок, запрос /api/slow?ms=8000, начатый до переключения, завершился 200; откат deploy.sh v1 — 108×200 v1, 126×200 v2, 0 ошибок. Health check проходил с 4–5 попытки (прогрев 3 с). Стенд удалён teardown.sh
- запущено, работает
Выводы shellsession (3 блока)
реальный вывод scratchpad/kb/verify/deploj-prilozheniya/results.txt; вывод watch.sh и долгого запроса в прогоне перенаправлялся в файлы и печатался после деплоя — так и показано в статье
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.