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

Middle: питання на співбесіді з теми «Laravel у контейнерах»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

5 питань

На звичайному сервері планувальник запускається рядком у crontab:

* * * * * cd /var/www/html && php artisan schedule:run >> /dev/null 2>&1

schedule:run щохвилини перевіряє, які задачі настав час виконати, і завершується.

У Docker є два підходи.

1. schedule:work - окремий контейнер (рекомендовано):

services:
  scheduler:
    image: myapp:1.4.0
    command: php artisan schedule:work
    restart: unless-stopped

schedule:work - процес на передньому плані, який сам щохвилини викликає schedule:run. Не потрібен cron-демон, вивід іде в stdout (видно в docker logs), сигнали зупинки обробляються коректно.

2. cron усередині контейнера:

RUN apt-get install -y cron && echo "* * * * * www-data php /var/www/html/artisan schedule:run" > /etc/cron.d/laravel
CMD ["cron", "-f"]

Недоліки: cron не передає дочірнім процесам змінні оточення контейнера (треба їх окремо зберігати й підставляти), вивід задач не потрапляє в логи Docker, потрібен root для cron-демона.

3. Зовнішній планувальник - cron на хості, Kubernetes CronJob чи планувальник платформи запускає разовий контейнер з php artisan schedule:run. Корисно, якщо платформа не любить постійно запущених процесів без роботи.

Обов'язкові правила:

  • рівно один планувальник. Якщо масштабувати контейнер scheduler до двох реплік чи запускати schedule:run у кожному вебконтейнері, кожна задача виконається кілька разів - листи підуть двічі, звіти згенеруються двічі;
  • для задач, які взагалі не можна дублювати, - ->onOneServer() (атомарне блокування в спільному кеші) і ->withoutOverlapping(), щоб довга задача не стартувала вдруге, поки працює перша;
  • довгі задачі - в черзу (->runInBackground() чи задача, що ставить job), щоб не блокувати інші задачі цієї хвилини;
  • таймзона: ->timezone('Europe/Kyiv') чи schedule_timezone у конфігурації - інакше «щодня о 9:00» означатиме 9:00 UTC.

Докладніше в документації: Laravel: запуск планувальника

PHP-FPM + Nginx - класична схема:

клієнт → Nginx (статика, TLS, проксі) → FastCGI → PHP-FPM (пул процесів) → Laravel
  • Nginx віддає статичні файли й передає PHP-запити в PHP-FPM;
  • PHP-FPM тримає пул процесів; кожен запит - чисте завантаження застосунку з нуля;
  • у Docker - два контейнери (Nginx і PHP-FPM, зі спільним доступом до public/) або один з менеджером процесів;
  • плюси: перевірена роками схема, ізоляція запитів (витоки пам'яті й глобальний стан не переживають запит), будь-який код Laravel працює без змін;
  • мінуси: два процеси й конфігурації, бутстрап фреймворку на кожен запит.

FrankenPHP - сучасний сервер застосунків:

  • вебсервер Caddy з вбудованим PHP - один бінарний файл, один процес у контейнері;
  • автоматичний HTTPS, HTTP/2 і HTTP/3, стиснення, Early Hints;
  • два режими:
    • класичний - як PHP-FPM: кожен запит завантажує застосунок заново;
    • worker mode (через Laravel Octane) - застосунок завантажується один раз, а запити обробляються в довгоживучих процесах. Відповіді значно швидші, бо бутстрап фреймворку зникає.
FROM dunglas/frankenphp:1-php8.5
COPY . /app
CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8000"]

Порівняння:

PHP-FPM + Nginx FrankenPHP
процеси в контейнері два (або два контейнери) один
конфігурація nginx.conf + пул FPM Caddyfile або параметри Octane
продуктивність бутстрап на кожен запит з Octane - бутстрап один раз
сумісність коду повна worker mode вимагає уважності до стану
HTTPS, HTTP/3 налаштовувати вбудовано

Що враховувати з worker mode: стан між запитами зберігається - статичні властивості, синглтони з даними користувача, кешування в пам'яті можуть «протікати» між запитами різних користувачів. Код і пакети мають бути готові до Octane.

Практичний вибір: для нового проєкту FrankenPHP спрощує образ і дає запас продуктивності; PHP-FPM - безпечний вибір для застарілого коду чи пакетів, не готових до довгоживучих процесів.

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

php artisan optimize у продакшені кешує кілька речей, і в Docker важливо розуміти, від чого залежить кожен кеш.

Команда Що кешує Залежить від оточення?
config:cache усю конфігурацію з підставленими env() так
route:cache зареєстровані маршрути зазвичай ні
view:cache скомпільовані шаблони Blade ні
event:cache знайдені слухачі подій ні

При збиранні образу можна формувати те, що залежить лише від коду:

RUN composer install --no-dev --optimize-autoloader --no-interaction \
 && php artisan view:cache \
 && php artisan event:cache

Переваги: кеші вже в образі, контейнер стартує швидше, а помилка (наприклад, неправильний синтаксис Blade) з'являється на етапі збирання, а не на продакшені.

При старті контейнера - те, що залежить від змінних оточення:

#!/bin/sh
set -e
php artisan config:cache
php artisan route:cache
exec "$@"

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

route:cache - з нюансом: маршрути зазвичай не залежать від оточення, але якщо в routes/*.php є умови на config() чи env() (домен, увімкнені модулі), кеш при збиранні їх «заморозить». Безпечніше - при старті.

Ще кілька деталей:

  • Composer: --optimize-autoloader (чи --classmap-authoritative) - класична мапа класів замість пошуку файлів на кожен запит;
  • OPcache з validate_timestamps=0 - код у контейнері не змінюється, перевіряти час зміни файлів не потрібно;
  • права: кеші при старті пишуться в bootstrap/cache і storage від імені користувача застосунку - ці каталоги мають бути йому доступні для запису;
  • кілька реплік - кожна формує свій кеш при старті; це кілька сотень мілісекунд і не проблема.

Помилка, яку часто допускають: php artisan optimize у Dockerfile «щоб було швидше» - після чого продакшен-контейнер підключається до бази з порожнім паролем.

Докладніше в документації: Laravel: оптимізація

Нові застосунки Laravel мають вбудований маршрут перевірки стану, оголошений у bootstrap/app.php:

->withRouting(
    web: __DIR__.'/../routes/web.php',
    health: '/up',
)

GET /up повертає 200, якщо застосунок завантажився без винятків, і 500 - якщо під час обробки сталася помилка. Під час запиту Laravel генерує подію DiagnosingHealth, на яку можна підписатися й додати власні перевірки (якщо слухач кидає виняток - відповідь 500).

Healthcheck у Compose:

services:
  app:
    image: myapp:1.4.0
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8000/up"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s

Docker позначає контейнер healthy чи unhealthy. На це спираються:

  • depends_on: { condition: service_healthy } у Compose;
  • балансувальники й платформи деплою (Dokploy, Coolify, Swarm), що не перемикають трафік на новий контейнер, поки він не здоровий;
  • моніторинг і сповіщення.

Що варто перевіряти, а що ні:

  • liveness («процес живий і відповідає») - /up без зовнішніх залежностей. Якщо в перевірку додати базу, то при короткому збої бази всі контейнери застосунку позначаться нездоровими й можуть бути перезапущені - збій бази перетвориться на збій сайту;
  • readiness («готовий приймати трафік») - тут доречно перевірити підключення до бази й Redis, бо без них застосунок не може обробити запит;
  • у Kubernetes це окремі проби; у Compose - зазвичай одна, і краще тримати її легкою.

Практичні деталі:

  • curl має бути в образі - для мінімальних образів замість нього php -r з file_get_contents чи маленький скрипт;
  • start_period - час на старт і config:cache, протягом якого невдалі перевірки не рахуються;
  • маршрут має працювати без сесії й автентифікації і не потрапляти під обмеження частоти запитів чи режим обслуговування, якщо його використовує балансувальник;
  • для воркерів черги HTTP-перевірки немає - стан видно з того, що процес живий, а глибше - через horizon:status чи метрики черги.

Докладніше в документації: Laravel: маршрут перевірки стану

Файлова система контейнера тимчасова. Усе, що записано в контейнер (наприклад, у storage/app/public на диску local), зникає при його перестворенні - тобто при кожному деплої.

Варіант 1 - том Docker:

services:
  app:
    volumes:
      - uploads:/var/www/html/storage/app
volumes:
  uploads:
  • дані переживають перестворення контейнера;
  • працює лише на одному сервері: другий сервер чи друга репліка на іншому хості цих файлів не бачать;
  • бекапи томів - окрема турбота;
  • віддавати файли користувачам доводиться через застосунок або Nginx з доступом до тому.

Варіант 2 - об'єктне сховище (рекомендовано): S3, Cloudflare R2, DigitalOcean Spaces, MinIO.

// config/filesystems.php
'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'endpoint' => env('AWS_ENDPOINT'),
    ],
],
$path = $request->file('avatar')->store('avatars', 's3');
$url = Storage::disk('s3')->url($path);
  • контейнери без стану: будь-яку кількість реплік на будь-яких серверах можна знищити й перестворити;
  • файли віддаються з CDN, а не через застосунок;
  • надійність і бекапи - на боці провайдера;
  • приватні файли - через тимчасові підписані посилання (temporaryUrl).

Що ще не повинно жити в контейнері:

  • сесії - в Redis чи базі, а не в файлах, інакше користувача розлогінить після деплою чи при переході на іншу репліку;
  • кеш - Redis чи база; файловий кеш у кожній репліці свій і не очищується разом;
  • логи - в stderr, звідки їх збирає Docker.

Локальна розробка: MinIO в Compose імітує S3, і застосунок працює з тим самим драйвером, що й у продакшені.

Важлива деталь Laravel: php artisan storage:link створює символьне посилання public/storage → storage/app/public. З S3 воно не потрібне, а з томом - має існувати в образі чи створюватися при старті.

Докладніше в документації: Laravel: файлове сховище