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

DevOps для Senior: питання на співбесіду

2 питання рівня Senior з теми DevOps з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
2 питання
OPS
DevOps·Senior ·масштабування ·stateless ·сесії

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

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

Сам 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, логи — і для кожного питаю, що станеться, якщо наступний запит прийде на інший вузол, а цей вузол зараз зникне». Далі окремо додайте, що липкі сесії — це індикатор незакритого пункту зі списку, а не рішення.

Сторінка питання →
OPS
DevOps·Senior ·міграції ·zero-downtime ·деплой

Схема змінюється у фази expand і contract: спершу зміни, сумісні зі старим кодом, потім деплой коду, який працює з обома варіантами, і лише після повного викочування прибирання старого.

Як перейменувати колонку без простою?
Чому не можна додавати NOT NULL без default на великій таблиці?
Що таке expand and contract?

Під час zero-downtime деплою стара й нова версії коду певний час працюють одночасно з однією базою. Тому кожна міграція має бути сумісна з попереднім кодом, а кожна версія коду з попередньою і наступною схемою. Цей принцип називають expand and contract: спершу розширення схеми, сумісне з усім, потім переключення коду, і лише після повного викочування прибирання старого.

Безпечні операції: додати nullable-колонку, нову таблицю, індекс через CONCURRENTLY. Небезпечні: видалення, перейменування, зміна типу, NOT NULL на існуючій колонці. Небезпечні операції розкладаються на кілька релізів. Перейменування колонки займає чотири: додати нову, писати в обидві й читати стару, читати нову й писати в обидві, писати лише в нову й видалити стару. Між другим і третім кроком іде backfill батчами по первинному ключу, щоб не тримати довге блокування й не роздувати WAL.

Окрема категорія — DDL, який блокує таблицю. У PostgreSQL додавання NOT NULL сканує таблицю під блокуванням; обхід через CHECK ... NOT VALID, VALIDATE CONSTRAINT без блокування запису і потім SET NOT NULL. Індекс без CONCURRENTLY зупиняє запис на час побудови. Будь-який DDL запускається з lock_timeout, щоб не чекати блокування вічно й не ставити в чергу всі запити за собою.

Міграції виконуються один раз, окремим кроком до перемикання трафіку, а не в entrypoint кожної репліки, і мають план відкату. Успіх міграції є умовою розкатки нового образу.

-- Задача: перейменувати users.name у users.full_name без простою

-- Реліз 1 (expand): нова nullable-колонка, миттєво
ALTER TABLE users ADD COLUMN full_name text;

-- Реліз 2: код пише в обидві колонки, читає стару. Backfill батчами у фоні
UPDATE users SET full_name = name
WHERE id BETWEEN 1 AND 10000 AND full_name IS NULL;
-- ... наступні діапазони з паузою

-- Реліз 3: код читає full_name. NOT NULL без довгого блокування (PostgreSQL)
ALTER TABLE users ADD CONSTRAINT users_full_name_nn CHECK (full_name IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_full_name_nn;   -- сканує без блокування запису
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;      -- PG 12+ використає CHECK, без скану
ALTER TABLE users DROP CONSTRAINT users_full_name_nn;

-- Індекс без блокування запису
CREATE INDEX CONCURRENTLY users_full_name_idx ON users (full_name);

-- Реліз 4 (contract): код більше не торкається name
ALTER TABLE users DROP COLUMN name;

-- Запобіжник для будь-якого DDL: не чекати блокування вічно
SET lock_timeout = '3s';
SET statement_timeout = '30s';
Назву патерну expand and contract і розуміння, що під час деплою одночасно працюють стара й нова версія коду з однією базою.
Що кожна міграція має бути сумісна з попередньою версією коду: додавання nullable-колонки або нової таблиці безпечне, видалення й перейменування ні.
Що перейменування робиться в кілька релізів: нова колонка, подвійний запис, backfill батчами, перемикання читання, видалення старої.
Що деякі DDL блокують таблицю або переписують її: ALTER з NOT NULL без default, зміна типу, індекс без CONCURRENTLY у PostgreSQL, і як це обійти.
Що міграції запускаються один раз до перемикання трафіку, окремим кроком із lock_timeout, а не всередині кожного контейнера на старті.
Видаляти або перейменовувати колонку в тому ж релізі, що й код: під час перекриття версій одна з них падає.
Додавати NOT NULL колонку без default на таблиці з мільйонами рядків і ловити повне переписування або блокування.
Створювати індекс без CONCURRENTLY у PostgreSQL і блокувати запис на хвилини.
Робити backfill одним UPDATE на всю таблицю замість батчів і тримати блокування й WAL.
Запускати migrate у entrypoint кожного контейнера й отримувати гонку між репліками.
ПОРАДА

Ключове слово — expand and contract. Назвіть його, опишіть обидві фази і наведіть приклад з перейменуванням колонки на чотири релізи.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти