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

Питання на співбесіді: 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: розгортання

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: права на каталоги

Laravel читає значення через env() у файлах config/*.php. Джерела:

  1. змінні оточення процесу - те, що передав Docker (-e, environment, env_file у Compose, секрети оркестратора);
  2. файл .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

Докладніше в документації: Laravel: кешування конфігурації

У розробці 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 перекрито томом).

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

Воркер - окремий контейнер з тим самим образом, але іншою командою:

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 чи заміна контейнера.

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

На звичайному сервері планувальник запускається рядком у 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: файлове сховище

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