<? phpukraine СПІВБЕСІДИ
Пошук по платформі
DEVOPS · SENIOR

Що треба змінити в PHP-застосунку, щоб він працював на кількох серверах?

Треба винести назовні весь стан, який зараз лежить на локальному диску чи в памʼяті процесу: сесії — у Redis/БД, завантажені файли — в обʼєктне сховище, кеш і локи — у спільний Redis, черги й планувальник — в окремі процеси з дедуплікацією; липкі сесії це не рішення, а спосіб не помітити проблему.

Додали другий вузол за балансувальником — і користувачів почало кожен другий запит викидати з логіну. Чому?
Ми ввімкнули липкі сесії на балансувальнику, і все запрацювало. Що з цим рішенням не так?
Куди дівати файли, які завантажують користувачі, коли серверів більше ніж один?
Що саме у вашому застосунку заважає прямо зараз запустити три копії контейнера замість однієї?
масштабування stateless сесії Redis S3 балансувальник черги

Сам PHP-процес між запитами стану не тримає — після кожного запиту він усе забуває, і в цьому сенсі мова масштабується краще за більшість інших. Проблема в тому, що застосунок роками писався в припущенні «диск і памʼять у нас одні на всіх», і стан осідає в кількох конкретних місцях: у каталозі сесій, у завантажених файлах, у кеші й локах, у чергах і cron, у логах. Питання, яке треба поставити до кожного з них, одне: що станеться, якщо наступний запит того самого користувача прийде на інший вузол, а цей вузол просто зникне. Все, що на це питання відповідає «зламається», і є список робіт.

Найпершим стріляє сесія. Дефолтний session.save_handler=files кладе серіалізований масив у session.save_path, тобто в локальну ФС конкретного вузла; балансувальник розкидає запити, і користувач через раз бачить форму логіну. Спільним сховищем робить або сам PHP (session.save_handler=redis з session.save_path="tcp://host:6379?database=2" від phpredis, або memcached), або фреймворк: у Laravel SESSION_DRIVER=redis|database (з 11-ї версії дефолт скелета — database), у Symfony — framework.session.handler_id з RedisSessionHandler. Тут є дві деталі, на яких валяться. Перша: нативний files тримає flock на файлі до кінця запиту, тому паралельні AJAX-запити одного користувача виконуються послідовно; у phpredis це поведінка вимикається за замовчуванням і вмикається redis.session.locking_enabled=1, а власна сесія Laravel не блокує нічого взагалі — там останній запис перемагає. Друга: сховище спільне, а ключі мають бути однакові, бо cookie з ідентифікатором сесії зашифрована APP_KEY (у Symfony APP_SECRET підписує signed URI та remember-me); згенерований під час деплою ключ дає вузол, який не може прочитати чужу cookie.

Далі файли й кеш. Завантаження користувачів переїжджають у обʼєктне сховище — S3 чи сумісне (MinIO, DigitalOcean Spaces) через диск s3 і league/flysystem-aws-s3-v3; storage:link і public/storage при цьому втрачають сенс, приватні файли віддаються через Storage::temporaryUrl(). З кешем тонше: opcache і зібрані артефакти (config:cache, route:cache, view:cache) — це похідні від коду, вони мають бути на кожному вузлі й будуються під час збірки образу, це нормально. А от кеш даних локальним бути не може: APCu живе в межах процесу FPM, файловий — у межах вузла, і скидання ключа на вузлі A залишає вузол B зі старими даними. Разом із кешем переїжджає все, що на ньому побудоване: Cache::lock(), RateLimiter, мітка queue:restart, локи withoutOverlapping() і onOneServer(). Найнеприємніше, що вони не падають — вони просто перестають бути глобальними гарантіями й стають локальними.

Черги й планувальник із «ще одного процесу на вебсервері» стають окремою одиницею розгортання. Воркер не потребує nginx і масштабується власним темпом, зате потребує тієї самої черги (redis, SQS, database) і коректного завершення: на SIGTERM queue:work дороблює поточну задачу й виходить, тому terminationGracePeriodSeconds має бути більшим за найдовший job, інакше задача обірветься посередині. schedule:run навпаки має спрацьовувати рівно один раз: або окремий CronJob в оркестраторі, або ->onOneServer() на кожній задачі з обовʼязково спільним лок-стором. У контейнері до цього додається загальне правило: писати можна лише в змонтовані томи й у зовнішні сервіси, бо шар запису викидається разом із контейнером — тому логи йдуть у stderr (LOG_CHANNEL=stderr) і збираються збирачем, а не ротуються всередині. І окремо: за балансувальником треба оголосити довірені проксі (trustProxies у bootstrap/app.php, framework.trusted_proxies у Symfony), інакше X-Forwarded-For і X-Forwarded-Proto ігноруються, у логах усі запити приходять з однієї адреси, а згенеровані посилання стають http://.

Липкі сесії (ip_hash у nginx, cookie ... insert у HAProxy, стікі target group в ALB) варто вміти назвати запахом і пояснити чому. Вони не усувають локальний стан, а лише роблять його непомітним: навантаження розподіляється нерівномірно (один NAT — один вузол), автоскейлінг перестає допомагати, бо нові вузли не забирають наявні сесії, виведення вузла на деплой означає розлогінених користувачів, а падіння — втрачені кошики. Плюс тестування деградує: помилка «стан не спільний» проявиться не одразу, а через півроку під час першого ж інциденту. Легітимні винятки є — WebSocket і SSE справді привʼязані до процесу, і липкість тут не милиця, а вимога; так само вона нормальна як тимчасовий місток на час міграції сесій.

Межа підходу в тому, що абсолютно stateless застосунків не буває — стан не зникає, а переїжджає, і те, що переїхало, стає новою точкою відмови. Redis із сесіями, кешем і чергами покладе весь кластер швидше, ніж це зробив би один вебвузол, тож за ним іде реплікація чи Sentinel, і окреме питання — чи можна пережити його недоступність (сесії — ні, кеш — так, якщо код готовий до промаху). Другий компроміс — латентність: те, що було читанням з локального диска чи з APCu за мікросекунди, стає мережевим викликом, тому гарячі незмінні дані іноді свідомо лишають у локальному APCu з коротким TTL, приймаючи розузгодженість у кілька секунд. Третій — вартість переходу: якщо застосунок писався десять років з session_start() і move_uploaded_file() у public/, чесна відповідь на співбесіді включає етапність — спершу сесії й ключі, потім файли, потім кеш і локи, і лише в кінці вимкнення липкості як фінальна перевірка, що стану на вузлах більше немає.

# ── /etc/php/8.4/fpm/conf.d/90-session.ini ────────────────────────────
# Було (типовий одиничний сервер): сесія — файл у локальній ФС вузла.
#   session.save_handler = files
#   session.save_path    = "/var/lib/php/sessions"
# Стало: спільне сховище через розширення redis (phpredis 6.x).
session.save_handler = redis
session.save_path    = "tcp://redis.internal:6379?database=2"
redis.session.locking_enabled = 1   # відтворює блокування, яке давав files
redis.session.lock_expire     = 30  # щоб зависла вимога не тримала сесію вічно
session.gc_maxlifetime        = 7200 # для redis це TTL ключа, GC не потрібен

# ── .env (Laravel 12) — увесь локальний стан переїжджає у сервіси ─────
SESSION_DRIVER=redis        # було file → storage/framework/sessions
CACHE_STORE=redis           # було file; APCu теж живе в межах процесу
QUEUE_CONNECTION=redis      # було sync або database «бо один сервер»
FILESYSTEM_DISK=s3          # було local → storage/app/public
LOG_CHANNEL=stderr          # логи в stdout/stderr, не у файл контейнера
APP_KEY=base64:...          # ОДИН на всі вузли, інакше cookie не розшифрувати

# ── Перевірка, що стан справді спільний, а не «здається спільним» ────
# на вузлі A:
php artisan tinker --execute 'Cache::put("probe", gethostname(), 60);'
# на вузлі B — має вивести імʼя вузла A, а не null:
php artisan tinker --execute 'echo Cache::get("probe") ?? "null";'

# ── Планувальник: cron на всіх вузлах, дедуплікація в коді ───────────
# * * * * * php /app/artisan schedule:run >> /dev/null 2>&1
# Schedule::command('reports:send')->daily()->onOneServer();
#   ↑ потрібен redis/memcached/database/dynamodb: файлові локи локальні,
#     винятку не буде, а звіт піде стільки разів, скільки вузлів.

# ── Запах, а не рішення: липкі сесії на балансувальнику ──────────────
# nginx:   upstream app { ip_hash; server web1; server web2; }
# haproxy: cookie SRVID insert indirect nocache
# Якщо після їх вимкнення застосунок ламається — стан досі локальний.
Перелік місць, де ховається стан: `session.save_path`, завантажені файли, кеш і локи, черги й cron, логи — і що кожне з них має окреме рішення.
Що сесія за замовчуванням це `session.save_handler=files` у локальній ФС вузла, а спільною вона стає через `session.save_handler=redis` з `session.save_path="tcp://..."` або через драйвер застосунку (`SESSION_DRIVER=redis|database`).
Що спільним має бути не лише сховище, а й ключі: один `APP_KEY` (Laravel) чи `APP_SECRET` (Symfony) на всі вузли, інакше вузол B не розшифрує cookie, видану вузлом A.
Розуміння, що APCu і файловий кеш живуть у межах процесу чи вузла, тому `Cache::lock()`, rate limiter, `queue:restart` і `->onOneServer()` без спільного стора мовчки перестають гарантувати те, що обіцяють.
Що липкі сесії — тимчасовий милиць: вони ламають рівномірність навантаження, автоскейлінг і виведення вузла з ротації, а падіння вузла все одно втрачає сесії.
Що планувальник і воркери — це окремі процеси, а не щось, що запускається на кожному вебвузлі «за компанію».
Змонтувати NFS у `session.save_path` і вважати задачу закритою: додається мережева затримка на кожен запит і блокування файлу сесії через `flock` по мережі.
Вирішити проблему липкими сесіями (`ip_hash` у nginx, `cookie SRVID insert` у HAProxy) і залишити стан локальним — після перезапуску вузла всі його користувачі розлогінені.
Генерувати `php artisan key:generate` під час деплою або в `Dockerfile`: у кожного вузла (чи в кожній збірці) свій `APP_KEY`, і сесії з cookie перестають розшифровуватись.
Лишити `FILESYSTEM_DISK=local` і `storage:link`: аватарка, завантажена на вузол A, дає 404 при відкритті з вузла B.
Запускати `schedule:run` з cron на кожному вузлі без `->onOneServer()` — щоденний звіт розсилається стільки разів, скільки вузлів.
Забути про довірені проксі: без `trustProxies` (Laravel) чи `framework.trusted_proxies` (Symfony) `$request->ip()` повертає адресу балансувальника, а `url()` генерує `http://` за HTTPS-балансувальником.
ПОРАДА

Скажіть так: «Горизонтальне масштабування — це не про сервери, а про пошук стану. Я проходжу по списку: сесія, файли, кеш і локи, черги, cron, логи — і для кожного питаю, що станеться, якщо наступний запит прийде на інший вузол, а цей вузол зараз зникне». Далі окремо додайте, що липкі сесії — це індикатор незакритого пункту зі списку, а не рішення.

оновлено 5 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Липкі сесії лише ховають локальний стан і втрачають його разом із вузлом, а періодичний rsync не дає консистентності в межах запиту. Робоче рішення — прибрати привʼязку до вузла: спільне сховище сесій, обʼєктне сховище для файлів, спільний Redis для кешу й локів, окремі воркери й один екземпляр планувальника.