Горизонтальное масштабирование Laravel: что ревьюить в коде
Приложение работает на одном сервере, но после запуска второго инстанса ломаются сессии, кеш и очереди. Разбираем, какие места в коде Laravel мешают запустить несколько инстансов за балансировщиком.

Приложение на Laravel 11.x спокойно живёт на одном сервере. Вы поднимаете второй инстанс за балансировщиком — и пользователи начинают вылетать из аккаунтов при каждом втором запросе, загруженные файлы видны через раз, а задачи из очереди выполняются дважды. Причина не в балансировщике: в коде зашиты предположения, которые верны только для одного процесса на одной машине. Разбираем, что искать на ревью перед тем, как ставить приложение на несколько инстансов.
Это разбор под конкретным углом: где именно код Laravel держится за локальное состояние. Настройка Nginx, autoscaling и сессии в базе против Redis — смежные темы, здесь не разворачиваю. Речь про строки в проекте, которые придётся менять.
Что вообще мешает запустить второй инстанс
Горизонтальное масштабирование работает при одном условии: любой запрос можно обслужить на любом инстансе, и результат будет одинаковым. Каждое место, где приложение полагается на то, что «предыдущий запрос обрабатывал этот же процесс», — это баг под нагрузкой.
В Laravel таких мест четыре крупных: сессии, кеш, файлы и запланированные задачи с очередями. Плюс мелочи вроде локальных блокировок и генерации URL. Пройдёмся по каждому — что искать в коде и как переписать.
Инстансы должны быть stateless. Всё состояние, которое переживает один HTTP-запрос, обязано лежать во внешнем хранилище, доступном всем инстансам одинаково.
Сессии: driver=file — приговор
Самая частая находка. В config/session.php драйвер по умолчанию — file, сессии пишутся в storage/framework/sessions на локальный диск. Пользователь авторизовался на инстансе A, следующий запрос ушёл на инстанс B — там его сессии нет, Laravel считает его гостем.
На ревью проверяете переменную окружения SESSION_DRIVER. Значения file и array для нескольких инстансов не годятся. Рабочие варианты — redis, database, memcached, cookie. Для большинства проектов это Redis:
# .env — общий для всех инстансов
SESSION_DRIVER=redis
SESSION_CONNECTION=default
CACHE_STORE=redis
QUEUE_CONNECTION=redis
REDIS_HOST=10.0.0.5
REDIS_PASSWORD=YOUR_REDIS_PASSWORD
REDIS_PORT=6379Драйвер cookie кажется соблазнительным (никакого сервера состояния), но кладёт всю сессию в куку клиента — есть лимит на размер и всё содержимое едет по сети в каждом запросе. Для флеш-сообщений хватит, для корзины на пару десятков позиций уже тесно.
Кеш: Cache::lock и теги в файловом драйвере
Если CACHE_STORE=file или array, кеш каждого инстанса свой. Прогрели тяжёлый запрос на одном — на остальных его нет, база получает лишнюю нагрузку. Это не поломка, но и смысла в кеше почти не остаётся.
Опаснее Cache::lock(). Атомарные блокировки на файловом драйвере действуют в пределах одного процесса. Код вида «взять лок, чтобы задача не выполнилась дважды» на нескольких инстансах молча пропускает дубли:
// Работает как задумано только на общем драйвере (redis/database)
$lock = Cache::lock('process-order-'.$order->id, 10);
if ($lock->get()) {
try {
$this->processOrder($order);
} finally {
$lock->release();
}
}
// На CACHE_STORE=file два инстанса возьмут лок одновременноИщите на ревью все вызовы Cache::lock и Cache::store('...')->lock и убеждайтесь, что драйвер под ними — общий для всех инстансов.
Файлы: локальный диск вместо общего хранилища
Загрузка через $request->file('avatar')->store('avatars') с диском local кладёт файл на тот инстанс, который принял запрос. Отдать его потом сможет только он же. Пользователь увидит картинку через раз — в зависимости от того, на какой инстанс попал запрос за файлом.
Решение — вынести диск во внешнее хранилище (S3-совместимое) в config/filesystems.php и переключить дефолтный диск через FILESYSTEM_DISK. Точные параметры драйвера s3 и список поддерживаемых опций смотрите в разделе File Storage документации Laravel — они менялись между мажорными версиями.
На ревью красный флаг — любой прямой путь к файлу: storage_path(), public_path() для пользовательского контента, Storage::disk('local'). Всё, что пользователь загрузил и что потом отдаётся, должно жить на общем диске.
Планировщик и очереди: задача выполнится N раз
Крон-планировщик Laravel запускается командой schedule:run. Если она стоит в кроне на каждом из трёх инстансов, каждая запланированная задача выполнится трижды. Рассылка уйдёт втройне, отчёт сгенерируется трижды.
Два способа не наступить на это:
- запускать
schedule:runтолько на одном выделенном инстансе — просто, но этот инстанс становится особенным; - оставить крон на всех, но пометить задачи методом
onOneServer()в определении расписания. Laravel возьмёт блокировку в кеше, и задачу выполнит только один инстанс. Требует общего драйвера кеша с поддержкой атомарных локов (Redis, Memcached, database) — на файловом не сработает.
// routes/console.php, Laravel 11.x
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:generate')
->dailyAt('03:00')
->onOneServer();С воркерами очередей симметрично: если задача не идемпотентна (списание денег, отправка письма), два воркера на разных инстансах могут взять её при неаккуратной настройке ретраев. Проверяйте, что тяжёлые побочные эффекты защищены либо уникальными задачами (ShouldBeUnique), либо проверкой состояния перед действием.
Мелочи, которые всплывают позже
| Место в коде | Проблема на нескольких инстансах | Что делать |
|---|---|---|
| Статические/глобальные переменные в сервисах | Состояние живёт в памяти одного процесса | Хранить во внешнем сторе, не в свойствах синглтонов |
| APP_URL не задан | Разные инстансы генерируют разные абсолютные URL | Фиксировать APP_URL, доверять заголовкам прокси через TrustProxies |
| Логи в storage/logs | Логи размазаны по инстансам, тяжело собрать | Драйвер логов в stdout/syslog, сбор централизованно |
| Rate limiting на файловом кеше | Лимит считается на каждом инстансе отдельно | Общий драйвер кеша для лимитера |
Заголовок APP_URL и работа за прокси — частая причина того, что redirect-ссылки и ссылки в письмах ведут на http вместо https или на внутренний адрес. За балансировщиком нужно настроить доверие к заголовкам X-Forwarded-* через middleware TrustProxies. Конкретный синтаксис настройки доверенных прокси менялся: в Laravel 11 это делается в bootstrap/app.php, точную сигнатуру сверьте в разделе Requests / Configuring Trusted Proxies документации.
Порядок рефакторинга
Не переписывайте всё разом. Порядок, который даёт работающий результат на каждом шаге:
- Поднять общий Redis (или использовать управляемый) и переключить
SESSION_DRIVER,CACHE_STORE,QUEUE_CONNECTION. Проверка: залогиниться, обновить страницу десять раз — не выкидывает. - Вынести пользовательские файлы на общий диск, переключить
FILESYSTEM_DISK. Проверка: загрузить файл, запросить его несколько раз подряд — отдаётся стабильно. - Найти все
Cache::lockи убедиться, что драйвер общий. Пометить запланированные задачиonOneServer()либо оставить планировщик на одном инстансе. - Проверить идемпотентность задач очереди с денежными и почтовыми эффектами.
- Настроить
TrustProxiesиAPP_URL, вывести логи в централизованный сбор. - Только теперь поднимать второй инстанс за балансировщиком.
Что мерить после запуска
После запуска второго инстанса смотрите на три вещи. Первое — количество запросов к Redis: если оно линейно растёт от нагрузки без всплесков, кеш и сессии работают правильно. Второе — дубли в побочных эффектах: логи отправленных писем, повторные записи в БД от одной задачи. Третье — распределение запросов по инстансам через логи балансировщика: перекос выдаёт залипание на конкретном инстансе (часто из-за sticky sessions, которые больше не нужны, если сессии в Redis).
Дальше — вынос воркеров очередей на отдельные инстансы от веб-серверов, чтобы тяжёлые задачи не отъедали ресурсы у обработки HTTP. Это отдельная тема про топологию инфраструктуры, здесь мы разбирали только код.
Видеомейкер: Ролик по описанию или оживление вашего фото. От пяти секунд, со звуком, вертикально или горизонтально.
Спросить за 190 ₽Источники: Laravel Deployment / Configuration — официальная документация, Laravel Cache — Atomic Locks
Частые вопросы
У меня Laravel 10, а не 11 — инструкция подойдёт?
Обязательно ли Redis, или можно обойтись базой данных?
Переключил SESSION_DRIVER на redis, но пользователей всё равно выкидывает — почему?
Что будет с уже загруженными файлами при переезде на S3?
Нужны ли sticky sessions на балансировщике, если сессии в Redis?
Как проверить, что задача из очереди не выполнится дважды на разных воркерах?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.