---
title: 'Zero-downtime deployment для Laravel: атомарные релизы, миграции и очереди | DevSense'
description: 'Как выкатывать Laravel без простоя: релизы через symlink, OPcache и realpath, сборка ассетов в CI, безопасные миграции PostgreSQL по схеме expand/contract и мягкий перезапуск очередей.'
faq:
    - { question: 'Почему php artisan down не подходит для деплоя без простоя?', answer: 'Команда переводит приложение в режим обслуживания и отдаёт 503 всем пользователям, пока идёт установка зависимостей, миграции и прогрев кэшей. Это честный, но запланированный простой. Кроме того, если скрипт упадёт после down и до up, сайт останется недоступным до ручного вмешательства.' }
    - { question: 'Что такое атомарный релиз через symlink?', answer: 'Каждая версия кода собирается в отдельной папке releases/<id>, а веб-сервер смотрит на символическую ссылку current. Когда новая версия полностью готова (зависимости, кэши, ассеты), ссылка переключается одной атомарной операцией mv -T. Запросы идут либо в старую, либо в новую версию, но никогда в наполовину обновлённую.' }
    - { question: 'Как выполнять миграции, чтобы старый и новый код работали одновременно?', answer: 'По схеме expand/contract: сначала только добавляйте (новые колонки, таблицы, индексы CONCURRENTLY), затем выкатывайте код, который пишет в оба места, и лишь в следующем релизе удаляйте старое. Переименование или удаление колонки в том же релизе, где меняется код, ломает запросы старых воркеров.' }
    - { question: 'Как перезапустить воркеры очередей без потери задач?', answer: 'Командой php artisan queue:restart (или horizon:terminate для Horizon). Воркеры дорабатывают текущую задачу, завершаются, а Supervisor поднимает их уже с новым кодом. Важно, чтобы сериализованные задачи, поставленные старым кодом, оставались совместимыми с новым.' }
published: '2026-09-28'
---
# Zero-downtime deployment для Laravel: атомарные релизы, миграции и очереди

Деплой, который «обычно проходит», опаснее деплоя, который падает сразу. Недавно наш собственный деплой упал на `git fetch`: протухли учётные данные для приватного репозитория. Сайт выжил только потому, что `git fetch` стоял **до** `php artisan down`. Будь он на пару строк ниже — сайт застрял бы в режиме обслуживания на всю ночь. Большинство Laravel-проектов деплоятся именно так: `down`, `git pull`, `composer install`, `migrate`, `up`. Это работает до первого сбоя посередине и каждый раз показывает пользователям 503.

**Связанные материалы:** [Окружение, CI и деплой с Sail](../tools/sail-env-deploy) · [Сравнение очередей сообщений](message-queues-compared) · [Наблюдаемость и мониторинг](observability-monitoring-laravel)

## Содержание

* [Откуда берётся простой при деплое](#where-downtime-comes-from)
* [Главная идея: готовим рядом, переключаем атомарно](#core-idea)
* [Атомарные релизы: releases, shared и current](#atomic-releases)
* [PHP-FPM, OPcache и realpath: почему symlink «не переключается»](#opcache)
* [Ассеты: сборка в CI и старые вкладки](#assets)
* [Миграции без блокировок: expand/contract](#migrations)
* [Очереди и планировщик: мягкий перезапуск](#queues)
* [Проверка здоровья и откат](#health-and-rollback)
* [Где подход перестаёт работать](#limitations)
* [Частые ошибки](#common-mistakes)
* [Чеклист](#checklist)
* [Квиз для самопроверки](#self-test-quiz)

---

<a id="where-downtime-comes-from"></a>
## Откуда берётся простой при деплое

Простой редко выглядит как «сервер выключен». Чаще это несколько секунд или минут, когда приложение находится в промежуточном состоянии:

* **`composer install` поверх работающего кода.** Пока пакеты в `vendor/` перезаписываются, параллельные запросы подгружают смесь старых и новых классов. Результат — `Class not found` или фатальные ошибки несовместимых сигнатур.
* **Кэш конфигурации от другой версии.** `bootstrap/cache/config.php` собран старым кодом, а роуты или сервис-провайдеры уже новые. Пока не выполнен `php artisan optimize`, приложение живёт с рассинхроном.
* **Миграции с блокировками.** `ALTER TABLE` на большой таблице берёт эксклюзивную блокировку, и все запросы к ней встают в очередь. Для пользователя это выглядит как зависший сайт.
* **Воркеры на старом коде.** Supervisor держит процессы `queue:work`, загруженные до деплоя. Они продолжают выполнять задачи старыми классами против новой схемы БД.
* **Ассеты не той версии.** `public/build/manifest.json` обновился раньше или позже кода — и страница ссылается на CSS и JS, которых ещё (или уже) нет.

Режим обслуживания маскирует все эти проблемы, показывая 503. Но это не решение, а честное признание простоя.

---

<a id="core-idea"></a>
## Главная идея: готовим рядом, переключаем атомарно

**Деплой без простоя — это когда новая версия полностью собирается рядом со старой, переключение занимает одну атомарную операцию, а схема БД в каждый момент совместима и со старым, и с новым кодом.**

Из этого утверждения следуют три правила:

1. Ничего не менять «на месте» в папке, из которой сейчас отдаются запросы.
2. Переключение — одна операция, у которой нет промежуточного состояния.
3. Миграции и задачи в очередях проектируются так, чтобы две версии кода могли жить одновременно.

---

<a id="atomic-releases"></a>
## Атомарные релизы: releases, shared и current

Структура на сервере:

```
/var/www/app/
├── releases/
│   ├── 20260927-2101-1d94729/
│   └── 20260928-1015-286fc47/   ← новый релиз
├── shared/
│   ├── .env
│   └── storage/                  ← логи, загрузки, сессии
└── current -> releases/20260927-2101-1d94729
```

Каждый релиз — отдельная папка с полным кодом. Всё, что должно переживать релизы (`.env`, `storage/`), лежит в `shared/` и подключается симлинками. Веб-сервер смотрит в `current/public`.

```bash
#!/usr/bin/env bash
# deploy/release.sh
set -euo pipefail

APP=/var/www/app

# 1. Код нового релиза — в отдельную папку, текущий сайт не трогаем
git -C "$APP/repo" fetch origin
RELEASE="$APP/releases/$(date +%Y%m%d-%H%M)-$(git -C "$APP/repo" rev-parse --short origin/prod)"
mkdir -p "$RELEASE"
git -C "$APP/repo" archive origin/prod | tar -x -C "$RELEASE"

# 2. Общие файлы
ln -s "$APP/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage" && ln -s "$APP/shared/storage" "$RELEASE/storage"

# 3. Зависимости и кэши собираются до переключения
cd "$RELEASE"
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
php artisan optimize

# 4. Миграции — только совместимые с текущим кодом (см. expand/contract)
php artisan migrate --force

# 5. Атомарное переключение: rename(2) не имеет промежуточного состояния
ln -s "$RELEASE" "$APP/current_tmp"
mv -Tf "$APP/current_tmp" "$APP/current"

# 6. Сброс OPcache и мягкий перезапуск воркеров
sudo systemctl reload php8.5-fpm
php artisan queue:restart

# 7. Храним последние 5 релизов для отката
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
```

> [!IMPORTANT]
> **`ln -sfn` не атомарен.** Под капотом это два системных вызова: удалить старую ссылку и создать новую. Между ними есть момент, когда `current` не существует, и nginx отдаёт 404. Атомарно работает только `rename(2)`, то есть `mv -T` поверх существующей ссылки.

Если настраивать это руками не хочется, тот же подход реализуют Deployer (`deployer/deployer`) и Laravel Envoyer.

---

<a id="opcache"></a>
## PHP-FPM, OPcache и realpath: почему symlink «не переключается»

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

* **realpath cache** в PHP запоминает, во что разрешается путь `/var/www/app/current/...`;
* **OPcache** хранит скомпилированные файлы по разрешённому пути. В продакшене обычно стоит `opcache.validate_timestamps=0`, и PHP вообще не проверяет, изменились ли файлы.

Решение состоит из двух частей. Сначала nginx должен передавать в PHP уже разрешённый путь, а не путь через симлинк:

```nginx
# /etc/nginx/sites-available/app.conf
root /var/www/app/current/public;

location ~ \.php$ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param DOCUMENT_ROOT   $realpath_root;
    fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
```

`$realpath_root` разрешает симлинк на каждый запрос. Новые запросы приходят с путём `releases/20260928-...`, и OPcache компилирует их как новые файлы. Затем после переключения нужен `reload` PHP-FPM (он мягкий: текущие запросы дорабатывают) или `opcache_reset()` через `cachetool`, чтобы освободить память от старого релиза.

---

<a id="assets"></a>
## Ассеты: сборка в CI и старые вкладки

Собирать фронтенд на продакшен-сервере — лишняя нагрузка и лишняя зависимость (Node.js). Лучше собрать в CI и загрузить в папку **нового релиза** до переключения:

```yaml
# .github/workflows/deploy-prod.yml (фрагмент)
- name: Build assets
  run: npm ci && npm run build

- name: Upload assets into the new release
  run: rsync -az --delete public/build/ "$SERVER:$RELEASE/public/build/"
```

Vite добавляет хэш в имена файлов (`app-TDO2OnyO.js`), а `manifest.json` лежит внутри релиза. Поэтому старый код ссылается на старые файлы, новый — на новые, и переключение симлинка меняет их вместе с PHP-кодом.

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

---

<a id="migrations"></a>
## Миграции без блокировок: expand/contract

Миграции выполняются **до** переключения, значит какое-то время старый код работает с новой схемой. Отсюда правило: каждая миграция должна быть совместима с кодом предыдущего релиза. Для этого изменения схемы разбиваются на фазы.

**Пример: переименовать `users.name` в `users.full_name`.**

| Релиз | Схема | Код |
|---|---|---|
| 1. Expand | Добавить `full_name` (nullable) | Пишет в обе колонки, читает `name` |
| 2. Migrate | Фоновая задача копирует данные | Читает `full_name`, пишет в обе |
| 3. Contract | Удалить `name` | Работает только с `full_name` |

Одним релизом с `renameColumn` это не сделать: старые воркеры упадут на несуществующей колонке.

Для PostgreSQL важно ещё и то, **какие блокировки берёт миграция**:

```php
// database/migrations/2026_09_28_000000_add_status_index_to_orders.php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    // CREATE INDEX CONCURRENTLY нельзя выполнять внутри транзакции
    public $withinTransaction = false;

    public function up(): void
    {
        DB::statement('SET lock_timeout = \'5s\'');
        DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_status_idx ON orders (status)');
    }

    public function down(): void
    {
        DB::statement('DROP INDEX CONCURRENTLY IF EXISTS orders_status_idx');
    }
};
```

Что здесь важно:

* **`CONCURRENTLY`** строит индекс без блокировки записи. Обычный `$table->index()` блокирует таблицу на всё время построения.
* **`lock_timeout`** не даёт миграции бесконечно ждать блокировку. Иначе за ней встанет очередь из всех остальных запросов к таблице. Лучше упасть через 5 секунд и повторить, чем повесить сайт.
* **Колонка с `DEFAULT`** в PostgreSQL 11+ добавляется мгновенно, но `NOT NULL` на существующей колонке требует полного сканирования. Безопасный путь: `ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID`, затем `VALIDATE CONSTRAINT`, затем `SET NOT NULL` (с PostgreSQL 12 он использует уже проверенное ограничение и не сканирует таблицу).

> [!WARNING]
> **Откат кода не откатывает схему.** `migrate:rollback` на продакшене с данными почти всегда хуже, чем новая миграция «вперёд». Именно expand/contract делает откат кода безопасным: предыдущая версия продолжает работать с расширенной схемой.

---

<a id="queues"></a>
## Очереди и планировщик: мягкий перезапуск

Воркер `queue:work` — это долгоживущий процесс, который загрузил код один раз. После переключения релиза он продолжит выполнять задачи старым кодом, пока его не перезапустят.

```bash
php artisan queue:restart      # для queue:work под Supervisor
php artisan horizon:terminate  # для Horizon
```

Обе команды мягкие: воркер дорабатывает текущую задачу и завершается, а Supervisor поднимает новый процесс уже из `current`. Задачи не теряются.

Вторая проблема — **совместимость задач**. Задача, поставленная в очередь старым кодом, сериализована с его набором свойств. Если новый код переименовал свойство или добавил обязательный аргумент конструктора, десериализация сломается:

```php
// app/Jobs/SendInvoice.php
final class SendInvoice implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly int $orderId,
        // Новое поле — со значением по умолчанию, чтобы задачи
        // из старого релиза успешно десериализовались
        public readonly string $locale = 'en',
    ) {}
}
```

Планировщику (`schedule:run` из cron) перезапуск не нужен: cron каждую минуту запускает новый процесс, который уже видит обновлённый `current`.

---

<a id="health-and-rollback"></a>
## Проверка здоровья и откат

Переключение не заканчивает деплой. Нужно убедиться, что новая версия живая:

```bash
# После переключения: health-роут Laravel 11+ (bootstrap/app.php → health: '/up')
for i in {1..10}; do
  curl -fsS --max-time 5 https://example.com/up > /dev/null && exit 0
  sleep 3
done

# Не поднялась — возвращаем предыдущий релиз тем же атомарным переключением
PREV=$(ls -1dt /var/www/app/releases/* | sed -n 2p)
ln -s "$PREV" /var/www/app/current_tmp && mv -Tf /var/www/app/current_tmp /var/www/app/current
sudo systemctl reload php8.5-fpm
exit 1
```

Откат занимает секунды, потому что предыдущий релиз лежит на диске целиком, с `vendor/` и собранными ассетами. Схема БД при этом остаётся новой — и именно поэтому она должна быть обратно совместимой.

---

<a id="limitations"></a>
## Где подход перестаёт работать

* **Один сервер — это не отказоустойчивость.** Симлинк убирает простой при деплое, но не при падении сервера. Для этого нужны несколько нод за балансировщиком и rolling или blue-green деплой.
* **Некоторые миграции нельзя сделать онлайн.** Смена типа колонки с перезаписью таблицы, разделение большой таблицы, перенос данных между базами. Иногда честное окно обслуживания ночью дешевле, чем недельная схема expand/contract.
* **Сессии и кэш.** Если новая версия меняет формат данных в сессии или ключи кэша, пользователи со старыми сессиями получат ошибки. Версионируйте префиксы кэша и не меняйте формат сессии без обратной совместимости.
* **Сложность.** Для pet-проекта с десятью посетителями в час `down`/`up` с ERR-trap может быть разумным компромиссом. Главное — осознанно, и так, чтобы сбой скрипта не оставлял сайт в maintenance.

---

<a id="common-mistakes"></a>
## Частые ошибки

**1. `git pull` и `composer install` в папке, из которой отдаются запросы.**
Параллельные запросы видят смесь версий. Собирайте релиз в отдельной папке.

**2. `ln -sfn` для переключения.**
Это не атомарно. Используйте `ln -s` во временное имя и `mv -T`.

**3. `SCRIPT_FILENAME $document_root...` в nginx.**
PHP кэширует путь через симлинк и продолжает отдавать старый код. Используйте `$realpath_root`.

**4. Переименование колонки в одном релизе с кодом.**
Старые воркеры и запросы в момент переключения упадут. Разбивайте на expand/contract.

**5. `$table->index()` на большой таблице.**
Блокирует запись на всё время построения. Используйте `CREATE INDEX CONCURRENTLY` и `$withinTransaction = false`.

**6. Забыть `queue:restart`.**
Воркеры неделями выполняют задачи старым кодом против новой схемы.

**7. `php artisan down` без гарантии `up`.**
Если скрипт упадёт посередине, сайт останется в режиме обслуживания. Минимум — `trap 'php artisan up' ERR`.

---

<a id="checklist"></a>
## Чеклист

1. Код, зависимости, кэши и ассеты нового релиза готовятся в отдельной папке.
2. `.env` и `storage/` вынесены в `shared/`.
3. Переключение — `mv -T` симлинка, nginx использует `$realpath_root`.
4. После переключения — `reload` PHP-FPM и `queue:restart` (или `horizon:terminate`).
5. Каждая миграция совместима с кодом предыдущего релиза; индексы — `CONCURRENTLY`; стоит `lock_timeout`.
6. Новые аргументы задач имеют значения по умолчанию.
7. После переключения проверяется `/up`, при неудаче — автоматический откат на предыдущий релиз.
8. На диске хранятся несколько последних релизов.

---

## Итог

Деплой без простоя — это не инструмент, а свойство системы: код умеет жить в двух версиях одновременно. Симлинки, `$realpath_root` и `queue:restart` решают механику переключения. Но настоящая работа — в дисциплине миграций и совместимости задач, которая делает откат скучной операцией на пару секунд, а не ночным инцидентом.

---

<a id="self-test-quiz"></a>
## Квиз для самопроверки

### Вопрос 1: Почему `ln -sfn new current` не подходит для атомарного переключения релиза?
- А) Команда не работает с абсолютными путями.
- Б) Она выполняет два системных вызова (unlink и symlink), и между ними ссылки `current` не существует.
- В) Она копирует файлы релиза вместо создания ссылки.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
`ln -sfn` удаляет старую ссылку и создаёт новую. В промежутке веб-сервер может получить запрос и не найти `current`. Атомарен только `rename(2)`, поэтому новую ссылку создают под временным именем и переименовывают через `mv -T`.
</details>

### Вопрос 2: Нужно переименовать колонку, с которой работают воркеры очереди. Какой подход безопасен при деплое без простоя?
- А) Одна миграция с `renameColumn` и одновременное обновление кода.
- Б) Добавить новую колонку, выкатить код, пишущий в обе, перенести данные и удалить старую колонку отдельным релизом.
- В) Перевести сайт в режим обслуживания только на время миграции.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Схема expand/contract гарантирует, что в любой момент и старый, и новый код находят нужные колонки. Вариант В — это уже не zero-downtime, а вариант А ломает воркеры и запросы, работающие на старом релизе.
</details>

### Вопрос 3: Зачем в миграции с `CREATE INDEX CONCURRENTLY` указывают `public $withinTransaction = false;`?
- А) Чтобы миграция выполнялась быстрее.
- Б) PostgreSQL запрещает `CREATE INDEX CONCURRENTLY` внутри транзакционного блока, а Laravel по умолчанию оборачивает миграцию в транзакцию.
- В) Чтобы индекс создался на всех репликах одновременно.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Конкурентное построение индекса состоит из нескольких фаз с отдельными транзакциями, поэтому внутри `BEGIN ... COMMIT` оно невозможно. Свойство `$withinTransaction = false` отключает обёртку Laravel для конкретной миграции.
</details>