Junior: питання на співбесіді з теми «Laravel у контейнерах»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Laravel-застосунок - це не лише вебсервер. У продакшені працює кілька процесів, і в Docker кожен зазвичай отримує свій контейнер:
services:
app: # вебзапити: PHP-FPM (+ Nginx) або FrankenPHP
image: myapp:1.4.0
queue: # воркер черги
image: myapp:1.4.0
command: php artisan queue:work --tries=3 --max-jobs=1000
scheduler: # планувальник задач
image: myapp:1.4.0
command: php artisan schedule:work
db:
image: postgres:18
redis:
image: redis:8-alpine
Ролі контейнерів:
| Контейнер | Що робить |
|---|---|
| вебсервер + PHP | обробляє HTTP-запити. Класична пара - Nginx і PHP-FPM (два контейнери чи один), сучасна - FrankenPHP чи Octane |
| воркер черги | виконує задачі з черги: листи, обробку файлів, сповіщення |
| планувальник | щохвилини запускає schedule:run (чи працює постійно через schedule:work) |
| база даних | PostgreSQL чи MySQL з томом для даних |
| Redis | кеш, сесії, черги, блокування |
| опційно | Horizon замість простого воркера, Reverb для вебсокетів, Meilisearch для пошуку |
Ключова ідея - один образ, різні команди. Веб, воркер і планувальник використовують той самий образ застосунку з тим самим кодом і залежностями - відрізняється лише команда запуску. Це гарантує, що воркер виконує задачі тим самим кодом, що й веб, який їх поставив.
Що зазвичай не в контейнерах у продакшені:
- база даних часто винесена в керований сервіс (RDS, Cloud SQL) - бекапи, реплікація й оновлення стають чужою турботою;
- файли користувачів - в об'єктному сховищі (S3, R2), а не на диску контейнера.
Локально все це збирає Laravel Sail чи власний compose.yaml, а в продакшені - Compose на одному сервері, Docker Swarm, Kubernetes чи платформи на кшталт Laravel Cloud, Dokploy, Coolify.
Laravel пише у два каталоги:
storage/- логи, скомпільовані шаблони Blade, файловий кеш, сесії, завантаження на дискlocal;bootstrap/cache/- кеш конфігурації, маршрутів, подій,packages.php,services.php.
Типова помилка:
The stream or file "/var/www/html/storage/logs/laravel.log" could not be opened in append mode: Failed to open stream: Permission denied
Причина: процес PHP працює від одного користувача (www-data, UID 33 в офіційних образах Debian чи 82 в Alpine), а файли належать іншому - найчастіше root, бо COPY у Dockerfile за замовчуванням створює файли власника root.
Виправлення в Dockerfile:
COPY --chown=www-data:www-data . /var/www/html
# або для вже скопійованих файлів - лише каталоги для запису
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R ug+rwX storage bootstrap/cache
USER www-data
--chown при COPY кращий за окремий RUN chown -R: окремий chown створює ще один шар з копією всіх файлів і збільшує образ.
Чого не робити:
chmod -R 777 storage- «працює», але дає запис усім і маскує справжню проблему;- запускати PHP від
root, щоб «не було проблем з правами» - якщо зловмисник виконає код, він отримає root у контейнері.
Типові пастки:
- bind mount у розробці (
./:/var/www/html) - файли мають власника з хоста (UID 501 на macOS, 1000 на Linux). На Linux процес у контейнері з іншим UID не може писати. Рішення - запускати PHP з UID розробника (Sail робить це черезWWWUSER); - том для storage створюється при першому запуску з правами
root- потрібно задати власника в образі до оголошення тому чи при старті; - файли, створені
php artisanвід root черезdocker exec(наприклад, кеш конфігурації чи лог) потім недоступні дляwww-data. Виконувати команди від того самого користувача:docker exec -u www-data app php artisan ....
Laravel читає значення через env() у файлах config/*.php. Джерела:
- змінні оточення процесу - те, що передав Docker (
-e,environment,env_fileу Compose, секрети оркестратора); - файл
.env- його завантажує бібліотека phpdotenv при старті.
Пріоритет: справжні змінні оточення важливіші за .env - phpdotenv не перезаписує вже встановлені змінні. Тому в контейнері можна взагалі не мати .env: усі значення приходять від Docker.
Чому .env не варто класти в образ:
- образ стає прив'язаним до одного середовища - для staging і продакшену потрібні різні образи;
- секрети (
APP_KEY, паролі бази, ключі API) потрапляють у шари образу й у реєстр - їх видно будь-кому з доступом до образу; .envмає бути в.dockerignore.
Пастка config:cache:
php artisan config:cache зберігає всю конфігурацію з уже підставленими значеннями в bootstrap/cache/config.php. Після цього .env і змінні оточення більше не читаються.
- якщо зробити
config:cacheпід час збирання образу, туди потраплять значення з середовища збирання (чи порожні) - а змінні, передані при запуску, буде проігноровано; - тому кешувати конфігурацію треба при старті контейнера, коли оточення вже відоме:
#!/bin/sh
# entrypoint.sh
php artisan config:cache
php artisan route:cache
php artisan view:cache
exec "$@"
Ще один наслідок: після config:cache функція env() поза файлами config/ повертає null. У коді застосунку - лише config('services.stripe.key'), а не env('STRIPE_KEY').
Перевірка в контейнері:
docker exec app php artisan about # середовище, драйвери, чи закешовано конфігурацію
docker exec app php artisan config:show database.default
У розробці npm run dev запускає dev-сервер Vite (порт 5173) і створює файл public/hot з його адресою. Директива @vite бачить цей файл і підключає скрипти не зі зібраних файлів, а з dev-сервера, - звідси гаряче перезавантаження (HMR).
Чому в Docker це часто не працює з коробки:
- Vite за замовчуванням слухає лише
localhostусередині контейнера - з браузера на хості до нього не достукатися; - порт 5173 не опубліковано;
- у
public/hotзаписується адреса, яку бачить контейнер, а браузер має звертатися до хоста.
Налаштування для контейнера:
// vite.config.js
export default defineConfig({
plugins: [laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true })],
server: {
host: '0.0.0.0', // слухати всі інтерфейси контейнера
port: 5173,
strictPort: true,
hmr: { host: 'localhost' }, // адреса, яку бачить браузер
},
});
# compose.yaml
services:
app:
ports:
- "127.0.0.1:5173:5173"
Laravel Sail робить саме це: публікує VITE_PORT і запускає sail npm run dev всередині контейнера.
Типові симптоми:
- сторінка без стилів і помилки
ERR_CONNECTION_REFUSEDдо:5173- порт не опубліковано чи Vite слухає лишеlocalhost; - стилі є, але зміни не підхоплюються - файлова система через bind mount не надсилає подій про зміни (буває на Windows і macOS), допомагає
server.watch.usePolling: true; - у продакшені запити до
:5173- у образ потрапив залишений файлpublic/hot. Його треба додати в.dockerignore.
Продакшен - зібрані файли, без Node в образі:
FROM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM php:8.5-fpm-alpine
COPY --from=assets /app/public/build /var/www/html/public/build
npm run build створює public/build з маніфестом і хешованими іменами файлів, а @vite читає маніфест. Окрема стадія збирання означає, що Node, node_modules і вихідні JS-файли не потрапляють у фінальний образ.
Помилка «Unable to locate file in Vite manifest» у контейнері - файли фронтенду не зібрано або не скопійовано в образ (чи public/build перекрито томом).
Воркер - окремий контейнер з тим самим образом, але іншою командою:
services:
queue:
image: myapp:1.4.0
command: php artisan queue:work redis --queue=high,default --tries=3 --max-jobs=1000 --max-time=3600
restart: unless-stopped
stop_grace_period: 60s
Чому окремий контейнер:
- масштабується незалежно:
docker compose up -d --scale queue=4; - важка задача не забирає ресурси у вебзапитів;
- падіння воркера не впливає на сайт, а Docker перезапускає його сам (
restart), тож Supervisor усередині не потрібен.
Чому queue:work треба перезапускати після деплою:
queue:work - довгоживучий процес: він завантажує застосунок один раз і тримає код у пам'яті. Після оновлення файлів старий воркер продовжує виконувати старий код.
- на звичайному сервері
php artisan queue:restartставить мітку в кеш, і воркери завершуються після поточної задачі (а Supervisor запускає їх заново); - у Docker код у контейнері не змінюється - деплой нової версії означає новий образ і нові контейнери. Старі контейнери зупиняються, нові стартують з новим кодом.
queue:restartу такому разі не обов'язковий, але корисний, якщо воркери живуть довше за деплой.
Коректна зупинка:
docker stopнадсилає SIGTERM - воркер Laravel завершує поточну задачу і виходить;- через
stop_grace_period(за замовчуванням 10 секунд) Docker надсилає SIGKILL - задача обривається посередині. Цей час має бути більшим за найдовшу задачу; - щоб сигнал дійшов, PHP має бути PID 1 (
execу скрипті запуску чи exec-формаcommand).
Від витоків пам'яті: --max-jobs і --max-time змушують воркер періодично завершуватися, а Docker його перезапускає з чистою пам'яттю. Для --memory враховуйте ліміт пам'яті контейнера.
Horizon - замість кількох queue:work один контейнер з php artisan horizon, який сам керує процесами за конфігурацією й балансує їх між чергами. Після деплою - horizon:terminate чи заміна контейнера.