Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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 протягом тривалого часу.

Докладніше в документації: Laravel Octane

У контейнері з 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();'

Докладніше в документації: PHP: налаштування date.timezone

Деплой без простою в контейнерах - це rolling update: нові контейнери стартують, проходять healthcheck, отримують трафік, а старі зупиняються. Певний час стара й нова версії працюють одночасно з однією базою.

Звідси головне правило: схема бази має бути сумісною з обома версіями коду.

Небезпечні зміни, якщо робити їх за один крок:

  • перейменування колонки: нова версія пише в full_name, стара ще читає name - помилки до завершення деплою;
  • видалення колонки: стара версія, що ще працює, звертається до неї;
  • NOT NULL для нової колонки без значення за замовчуванням: стара версія не заповнює її при вставці;
  • зміна типу колонки з довгою перебудовою таблиці - блокування й таймаути.

Розширення й звуження (expand/contract, parallel change):

Перейменування name → full_name в три деплої:

  1. розширення: міграція додає full_name (nullable); код пише в обидві колонки й читає зі старої;
  2. перенесення: задача в черзі заповнює full_name для старих рядків порціями; код читає з нової;
  3. звуження: коли жодна версія не використовує name - міграція видаляє її.

Порядок кроків деплою:

  1. зібрати образ, прогнати тести;
  2. виконати міграції разовим контейнером (migrate --force --isolated) - схема стає «ширшою», стара версія продовжує працювати;
  3. оновити контейнери поступово, з healthcheck;
  4. воркери черги - теж нова версія; задачі, поставлені старою версією, мають розбиратися новою (не змінювати сигнатуру конструктора job без сумісності);
  5. наступним деплоєм - очищувальна міграція.

Деталі, про які забувають:

  • серіалізовані job у черзі: Laravel серіалізує об'єкт задачі; перейменований клас чи властивість - і задачі зі старого деплою падають у новому воркері;
  • кеш: нова версія може читати з кешу дані у форматі старої - версіонуйте ключі чи очищуйте кеш;
  • сесії й CSRF мають переживати деплой - Redis/база, однаковий APP_KEY;
  • assets: користувач зі сторінкою старої версії підвантажує старі JS/CSS - вони мають бути доступні (CDN, хешовані імена);
  • довгі міграції на великих таблицях - окремо від деплою, з lock_timeout, CREATE INDEX CONCURRENTLY у PostgreSQL;
  • відкат: з розширювальними міграціями відкотити код можна без відкату схеми - це й робить їх безпечними.

Докладніше в документації: Martin Fowler: Parallel Change

Коли застосунок масштабується горизонтально (кілька реплік на одному чи кількох серверах), з'являється проблема дублювання: те, що має відбутися один раз, відбувається в кожній репліці.

Планувальник:

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: задачі на одному сервері