Масштабирование Laravel: разбор инцидента с сессиями и очередями
После добавления второго сервера приложение начало выкидывать пользователей из сессий и дублировать письма. Разбираем, что сломалось при переходе с одной ноды на несколько и как это чинить.

Приложение на Laravel 11 держало один сервер: 2 vCPU, 4 ГБ RAM, Nginx + PHP-FPM, база и Redis на той же машине. При 400–500 одновременных запросах отдача страниц уходила за 3–4 секунды, PHP-FPM упирался в лимит воркеров. Решение выглядело очевидным: поставить второй сервер приложения и раскинуть трафик через балансировщик. После деплоя посыпались жалобы — пользователей случайно разлогинивало, форма оплаты теряла CSRF-токен с ошибкой 419, а часть покупателей получала по два письма-подтверждения. Разбираем, что именно ломается, когда одна нода превращается в несколько, и почему код при этом менять почти не пришлось.
Что происходило под балансировщиком
Балансировщик раздавал запросы по round-robin: первый запрос уходил на ноду A, следующий — на ноду B. Пока приложение жило на одной машине, всё состояние — сессии, кеш, файлы, очередь — лежало локально и было единым по определению. Как только нод стало две, каждая из них видела только свою половину состояния.
Три симптома — три разных места, где состояние оказалось локальным:
- Разлогинивание и ошибки 419. Драйвер сессий по умолчанию в свежем проекте —
file: сессии пишутся вstorage/framework/sessions. Пользователь логинился на ноде A, следующий запрос попадал на ноду B, где файла его сессии нет. Отсюда и потеря CSRF-токена (он тоже живёт в сессии) с кодом 419. - Двойные письма. На обеих нодах был запущен
php artisan queue:work, а драйвер очереди стоялdatabaseс таблицейjobsв общей базе. Сами по себе две ноды приdatabase-драйвере дублировать задачи не должны — блокировка строки этому мешает. Но воркеры на второй ноде запустили со старым значением--triesи укороченным таймаутом, задачи по отправке письма падали по timeout и переставлялись в очередь, а транзакционное письмо успевало уйти дважды. - Пропавшие загруженные файлы. Аватарки, загруженные через ноду A в
storage/app/public, не открывались, если отдающий запрос уходил на ноду B. Локальный диск на второй машине про эти файлы не знал.
Горизонтальное масштабирование Laravel — это на 90% вынос состояния наружу и на 10% про сам код. Пока сессия, кеш, файлы и очередь лежат на диске конкретной ноды, любое приложение остаётся одноузловым, сколько машин к нему ни приставь.
Где именно спряталось локальное состояние
В Laravel по умолчанию четыре подсистемы норовят писать на локальный диск. Проверять надо все четыре, а не только сессии, на которых обычно спотыкаются первыми.
| Подсистема | Дефолт в новом проекте | Где живёт состояние | Что ломается на нескольких нодах |
|---|---|---|---|
| Сессии | file | Локальный диск ноды | Разлогин, 419 при переключении ноды |
| Кеш | database или file | Своя таблица/диск | Разные ноды видят разный кеш, rate limiting течёт |
| Файлы (Storage) | local | Локальный диск ноды | Загрузка на одной ноде — 404 на другой |
| Очередь | database | Общая БД (уже разделяемая) | При кривых воркерах — дубли и гонки |
Значения по умолчанию зависят от версии стартового шаблона: в Laravel 11 базовый .env ставит SESSION_DRIVER=database, но реально работающие проекты часто мигрировали со старых версий, где стоял file. Всегда сверяйте фактические значения в config/session.php, config/cache.php, config/filesystems.php и config/queue.php на проде, а не в чужой инструкции.
Почему сессии на database — не бесплатное решение
Соблазн просто переключить SESSION_DRIVER=database велик: таблица уже разделяемая, и разлогин прекратится. Цена — запись в основную БД на каждый запрос аутентифицированного пользователя. Под нагрузкой это добавляет базе поток мелких UPDATE и превращает её в узкое место. Redis для сессий обычно лучше: держит их в памяти, не трогает основную БД и переживает рестарт при включённой персистентности.
Как чинили: выносим состояние в общие сервисы
Целились в минимальный набор изменений: поднять один Redis, доступный обеим нодам, и общее объектное хранилище для файлов. Ниже — фактические правки конфигурации.
Шаг 1. Сессии и кеш в Redis
Ставим отдельный Redis на выделенной машине (не на одной из нод приложения — иначе падение ноды унесёт состояние обеих). Значения .env:
SESSION_DRIVER=redis
SESSION_CONNECTION=default
CACHE_STORE=redis
REDIS_HOST=10.0.0.10
REDIS_PORT=6379
REDIS_PASSWORD=YOUR_REDIS_PASSWORD
В config/database.php проверьте секцию redis: для Laravel лучше использовать клиент phpredis (расширение PHP), он быстрее чистого PHP-клиента predis под нагрузкой. Клиент задаётся переменной REDIS_CLIENT. После правки — php artisan config:clear. Проверка: залогиньтесь и обновите страницу десять раз подряд — при round-robin вы должны остаться авторизованы на любой ноде.
Шаг 2. Файлы в объектное хранилище
Локальный диск заменяем на S3-совместимое хранилище (AWS S3, либо совместимый селф-хостед вариант). В config/filesystems.php диск s3 уже описан в стартовом шаблоне; нужно заполнить .env и поставить пакет league/flysystem-aws-s3-v3:
composer require league/flysystem-aws-s3-v3
# .env
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY
AWS_SECRET_ACCESS_KEY=YOUR_SECRET_KEY
AWS_DEFAULT_REGION=ru-central1
AWS_BUCKET=my-app-uploads
AWS_ENDPOINT=https://storage.yandexcloud.net
AWS_USE_PATH_STYLE_ENDPOINT=false
Имена переменных для S3 сверяйте с актуальным config/filesystems.php вашей версии — набор ключей менялся между релизами. Код, который вызывает Storage::disk('s3')->put(...) или просто Storage::put(...) при FILESYSTEM_DISK=s3, менять не нужно: фасад уходит на нужный драйвер сам. Проверка: загрузите файл через одну ноду, откройте ссылку — она должна работать независимо от того, какая нода отдаёт ответ.
Шаг 3. Очередь и воркеры
Очередь тоже переносим в Redis — это отвязывает воркеры от основной БД и убирает конкуренцию за строки таблицы jobs:
QUEUE_CONNECTION=redis
Воркеры на обеих нодах запускайте с одинаковыми параметрами и под супервизором. Дубли писем в инциденте лечились приведением флагов к единому виду и разумным таймаутом:
php artisan queue:work redis --tries=3 --timeout=90 --backoff=10
Здесь --timeout обязательно должен быть меньше, чем retry_after в config/queue.php для соответствующего соединения — иначе задача считается зависшей и берётся в работу повторно ещё до того, как первый воркер её закончил. Это и есть механизм двойной отправки. Проверка: поставьте одну задачу и убедитесь, что она выполнилась ровно один раз (счётчик в логе задания).
Отдельная ловушка: планировщик задач
После переезда всплыл четвёртый симптом, которого в первом списке не было: ежедневная рассылка ушла дважды. Причина — schedule:run висел в cron на обеих нодах, и обе честно запускали одну и ту же команду в одну минуту. Laravel умеет это предотвращать методом ->onOneServer() у планируемой задачи — но он требует общего блокирующего кеша (тот же Redis). Без общего кеша onOneServer не работает, и до Redis-переезда именно поэтому не срабатывал бы. Уточните синтаксис регистрации команд для вашей версии в разделе Task Scheduling официальной документации: способ описания расписания менялся между Laravel 10 и 11.
Если после переезда всё ещё ломается
- Ошибка 419 не ушла. Проверьте, что
APP_KEYодинаков на всех нодах: разные ключи — разное шифрование сессионной cookie, и ноды не поймут cookie друг друга. Ключ в.envдолжен быть побайтно идентичным. - Сессии в Redis есть, но пользователя всё равно выкидывает. Смотрите домен cookie и настройку
SESSION_DOMAIN— при работе через балансировщик и общий домен она должна быть выставлена явно. - Redis
Connection refused. Порт 6379 закрыт для нод приложения или Redis слушает только127.0.0.1. Откройте доступ по внутренней сети и защитите паролем, наружу порт не выставляйте. - Файлы льются в S3, но отдаются медленно. Для публичных файлов используйте прямые ссылки на хранилище или CDN перед ним, а не проксирование через приложение — иначе каждая картинка проходит через PHP.
- Дубли задач не исчезли. Сверьте
timeoutворкера иretry_after: первое должно быть строго меньше второго.
Что мерить и настраивать дальше
После выноса состояния приложение стало действительно stateless: ноды можно добавлять и убирать без потери сессий и файлов. Дальше смотрите на три вещи. Первое — health-check балансировщика: он должен проверять реальный endpoint приложения, а не просто TCP-порт, иначе трафик пойдёт на ноду с упавшим PHP-FPM. Второе — тип балансировки: sticky sessions по IP снимают часть проблем костылём, но с состоянием в Redis они уже не нужны и только мешают равномерности. Третье — Redis как новую единую точку отказа: настройте персистентность и продумайте реплику, потому что теперь падение Redis роняет сессии и очередь сразу всех нод. Прогон нагрузочным тестом с числом одновременных запросов выше боевого пика покажет, стал ли узким местом Redis, база или сеть между нодами.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Laravel — Sessions (официальная документация), Laravel — Queues (официальная документация)
Частые вопросы
У меня Laravel 10, а не 11 — инструкция подойдёт?
Обязательно выносить всё в Redis или можно обойтись базой?
Двойные письма ушли, но иногда всё равно приходит дубль. В чём дело?
Нужно ли переписывать код приложения при переходе на несколько нод?
Не сломается ли что-то в проде при переключении драйвера сессий?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.