Zero-downtime деплой на обычном VPS через rsync и симлинки

2026-08-20 10:00:00

Zero-downtime деплой ассоциируется с Kubernetes и блю-грин на десяти серверах. На практике его можно собрать и на одном обычном VPS буквально за вечер.

Схема каталогов

/var/www/app/
  releases/
    20260810-1200/
    20260815-0930/
  shared/
    .env
    storage/
  current -> releases/20260815-0930

Nginx и php-fpm смотрят не на конкретный релиз, а на symlink current. Переключение релиза — это атомарная смена симлинка, а не подмена файлов на лету.

Сам деплой

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

RELEASE=$(date +%Y%m%d-%H%M)
TARGET="/var/www/app/releases/$RELEASE"

rsync -az --delete \
  --exclude='.git' \
  --exclude='storage' \
  ./ deploy@server:"$TARGET"/

ssh deploy@server "
  ln -sfn /var/www/app/shared/.env $TARGET/.env
  ln -sfn /var/www/app/shared/storage $TARGET/storage
  cd $TARGET && composer install --no-dev -o
  ln -sfn $TARGET /var/www/app/current
  sudo systemctl reload php8.3-fpm
"

Почему это zero-downtime

Смена symlink — атомарная операция на уровне файловой системы: nginx либо видит старый путь, либо новый, промежуточного «наполовину выкачанного» состояния не существует. reload у php-fpm (не restart) поднимает новые воркеры, не убивая старые, пока они не доработают текущие запросы.

Откат

ssh deploy@server "ln -sfn /var/www/app/releases/20260810-1200 /var/www/app/current"

Откат — тоже атомарная смена symlink на предыдущий релиз. Никаких git revert под нагрузкой в 3 часа ночи.

Что не забыть

  • Хранить не больше 3-5 последних релизов — иначе диск закончится незаметно
  • .env и загруженные файлы держать в shared, а не в каждом релизе
  • Прогонять миграции БД отдельным шагом, до переключения симлинка