Питання на співбесіді: Laravel у контейнерах
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
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 чи заміна контейнера.
На звичайному сервері планувальник запускається рядком у 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 воно не потрібне, а з томом - має існувати в образі чи створюватися при старті.
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: задачі на одному сервері