Senior: питання на співбесіді з теми «Laravel у контейнерах»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Octane запускає Laravel у довгоживучих процесах (FrankenPHP, Swoole, RoadRunner): застосунок завантажується один раз, а потім обробляє тисячі запитів. Бутстрап фреймворку зникає з кожного запиту - час відповіді падає в рази.
FROM dunglas/frankenphp:1-php8.5
# ...
CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8000", "--workers=4", "--max-requests=500"]
Що змінюється для контейнера:
1. Пам'ять:
- кожен воркер тримає завантажений застосунок - сотні МБ на кілька воркерів;
--workers(за замовчуванням - за кількістю ядер) треба узгоджувати з лімітом пам'яті контейнера, інакше OOM-killer з кодом 137;- кількість ядер, яку бачить PHP, - це ядра хоста, а не ліміт
--cpusконтейнера. Явно задавайте--workers.
2. Витоки пам'яті й стан:
- пам'ять, що «протікає» за запит, накопичується;
--max-requests- воркер перезапускається після N запитів і звільняє пам'ять;- стан між запитами: статичні властивості, синглтони, що зберігають запит чи користувача, масиви-кеші в сервісах - переживають запит. Дані одного користувача можуть потрапити до іншого;
- в Octane застосунок клонує контейнер на кожен запит, але синглтони, створені при старті, спільні - не інжектуйте
Request,Authчи конфігурацію в конструктор синглтона.
3. Деплой:
- новий код - новий образ і новий контейнер, тож
octane:reloadдля продакшену в Docker зазвичай не потрібен; --watch- лише для розробки (потребує Node і chokidar), у продакшен-образ не потрапляє;- коректна зупинка: SIGTERM до Octane,
stop_grace_periodбільший за найдовший запит.
4. Конфігурація:
config:cacheпри старті до запуску Octane - конфігурація завантажується один раз і далі не перечитується;- зміна змінних оточення вимагає перезапуску контейнера.
5. З'єднання:
- з'єднання з базою й Redis живуть між запитами - після перезапуску бази перший запит може отримати «server has gone away»; Laravel перепідключається, але варто перевірити поведінку;
- кількість з'єднань з базою = кількість воркерів × реплік - узгоджуйте з
max_connectionsчи пулером.
Що перевірити перед переходом: тести під Octane, пакети на сумісність, навантажувальний тест з контролем пам'яті в docker stats протягом тривалого часу.
У контейнері з PHP кілька незалежних годинників і налаштувань часового поясу:
| Рівень | Де задається | На що впливає |
|---|---|---|
| ОС контейнера | TZ, /etc/localtime, пакет tzdata |
date у shell, cron, логи системних утиліт |
| PHP | date.timezone у php.ini |
date(), new DateTime() без явного поясу |
| Laravel | config/app.php → timezone |
now(), Carbon, Eloquent-дати, планувальник |
| база даних | налаштування сервера, сесії | NOW(), CURRENT_TIMESTAMP, timestamptz |
Типові пастки:
- офіційні PHP-образи за замовчуванням - UTC, і
date.timezoneне задано; - Alpine без
tzdataвзагалі не знає назв поясів -TZ=Europe/Kyivмовчки ігнорується; TZне впливає на PHP напряму: PHP бере пояс зdate.timezone, а якщо його немає - з власного значення за замовчуванням (UTC). Деякі сервери (зокрема вбудований у FrankenPHP) не підхоплюютьTZдля PHP-дат;- Laravel перевизначає PHP: при старті він викликає
date_default_timezone_set(config('app.timezone')), тож у застосунку діє саме конфігурація Laravel, а cron-задачі ОС чи сторонні скрипти - живуть за іншими правилами.
Узгоджене налаштування:
RUN apk add --no-cache tzdata # для Alpine
ENV TZ=Europe/Kyiv
RUN echo "date.timezone=\${TZ}" > "$PHP_INI_DIR/conf.d/timezone.ini"
// config/app.php
'timezone' => env('APP_TIMEZONE', 'UTC'),
Яку політику обрати:
- зберігати в базі UTC (або
timestamptzу PostgreSQL) - класична рекомендація: без двозначностей при переході на літній час, без проблем з користувачами з різних поясів; - показувати в локальному поясі користувача чи сайту на рівні відображення;
- якщо застосунок свідомо працює в одному місцевому поясі (сайт для однієї країни), - той самий пояс на всіх рівнях: ОС, PHP, Laravel, планувальник. Розбіжність між рівнями - головне джерело зсуву на 2-3 години.
Планувальник: ->dailyAt('09:00') виконується за поясом app.timezone (чи schedule_timezone), а не ОС. Переходи на літній час можуть пропустити чи подвоїти задачі, заплановані на 3:00 ночі.
Перевірка в контейнері:
docker exec app date
docker exec app php -r 'echo date_default_timezone_get(), PHP_EOL;'
docker exec app php artisan tinker --execute 'echo now();'
Деплой без простою в контейнерах - це rolling update: нові контейнери стартують, проходять healthcheck, отримують трафік, а старі зупиняються. Певний час стара й нова версії працюють одночасно з однією базою.
Звідси головне правило: схема бази має бути сумісною з обома версіями коду.
Небезпечні зміни, якщо робити їх за один крок:
- перейменування колонки: нова версія пише в
full_name, стара ще читаєname- помилки до завершення деплою; - видалення колонки: стара версія, що ще працює, звертається до неї;
NOT NULLдля нової колонки без значення за замовчуванням: стара версія не заповнює її при вставці;- зміна типу колонки з довгою перебудовою таблиці - блокування й таймаути.
Розширення й звуження (expand/contract, parallel change):
Перейменування name → full_name в три деплої:
- розширення: міграція додає
full_name(nullable); код пише в обидві колонки й читає зі старої; - перенесення: задача в черзі заповнює
full_nameдля старих рядків порціями; код читає з нової; - звуження: коли жодна версія не використовує
name- міграція видаляє її.
Порядок кроків деплою:
- зібрати образ, прогнати тести;
- виконати міграції разовим контейнером (
migrate --force --isolated) - схема стає «ширшою», стара версія продовжує працювати; - оновити контейнери поступово, з healthcheck;
- воркери черги - теж нова версія; задачі, поставлені старою версією, мають розбиратися новою (не змінювати сигнатуру конструктора job без сумісності);
- наступним деплоєм - очищувальна міграція.
Деталі, про які забувають:
- серіалізовані job у черзі: Laravel серіалізує об'єкт задачі; перейменований клас чи властивість - і задачі зі старого деплою падають у новому воркері;
- кеш: нова версія може читати з кешу дані у форматі старої - версіонуйте ключі чи очищуйте кеш;
- сесії й CSRF мають переживати деплой - Redis/база, однаковий
APP_KEY; - assets: користувач зі сторінкою старої версії підвантажує старі JS/CSS - вони мають бути доступні (CDN, хешовані імена);
- довгі міграції на великих таблицях - окремо від деплою, з
lock_timeout,CREATE INDEX CONCURRENTLYу PostgreSQL; - відкат: з розширювальними міграціями відкотити код можна без відкату схеми - це й робить їх безпечними.
Коли застосунок масштабується горизонтально (кілька реплік на одному чи кількох серверах), з'являється проблема дублювання: те, що має відбутися один раз, відбувається в кожній репліці.
Планувальник:
1. Окремий сервіс з однією реплікою - найпростіше:
services:
scheduler:
image: myapp:1.4.0
command: php artisan schedule:work
deploy:
replicas: 1
Але «рівно одна репліка» не гарантована: під час rolling update старий і новий контейнери можуть жити одночасно, а на кількох вузлах - збій мережі може дати два активні планувальники.
2. onOneServer() - Laravel бере атомарне блокування в спільному кеші перед запуском задачі:
Schedule::command('reports:generate')
->dailyAt('02:00')
->onOneServer();
Schedule::command('feeds:sync')
->everyFiveMinutes()
->withoutOverlapping(10)
->onOneServer();
- драйвер кешу має бути спільним і з атомарними блокуваннями: Redis, Memcached, database, DynamoDB. З
fileчиarrayу кожного контейнера своє «блокування» - захисту немає; onOneServerзахищає від дублювання в межах однієї хвилини,withoutOverlapping- від паралельного запуску, поки попередній ще працює (з терміном життя блокування на випадок падіння).
Разові artisan-команди:
- не через
docker execу довільну репліку - результат залежить від того, куди потрапили, і команда помре разом з контейнером при деплої; - разовий контейнер з тим самим образом і оточенням:
docker compose run --rm app php artisan users:reindex, у Kubernetes - Job; - довгі операції - розбити на job у черзі: команда лише ставить задачі, а воркери обробляють їх порціями з повторами й видно прогрес;
- ідемпотентність: команду, яку можуть запустити двічі, робити безпечною для повтору.
Що ще потребує координації:
- міграції -
migrate --isolated; queue:restart,horizon:terminate- сигнал через спільний кеш досягає всіх воркерів, незалежно від того, з якого контейнера його надіслано;- кеш-очищення (
cache:clear) очищує спільне сховище - один раз для всіх, а не в кожній репліці.
Діагностика дублювання: логувати ім'я хоста (gethostname() дає ID контейнера) у задачах планувальника - видно, скільки разів і де вони виконались.
Докладніше в документації: Laravel: задачі на одному сервері