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.
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 - безпечний вибір для застарілого коду чи пакетів, не готових до довгоживучих процесів.
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 мають вбудований маршрут перевірки стану, оголошений у 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чи метрики черги.
Файлова система контейнера тимчасова. Усе, що записано в контейнер (наприклад, у 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 воно не потрібне, а з томом - має існувати в образі чи створюватися при старті.