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

Питання на співбесіді з Docker

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

100 питань

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

Класична продакшен-пастка на Ubuntu й Debian:

sudo ufw default deny incoming
sudo ufw allow 22
sudo ufw allow 443
# фаєрвол налаштовано, «все закрито»

А потім docker run -p 6379:6379 redis - і Redis доступний з інтернету, попри правила ufw.

Чому так: Docker сам керує правилами фаєрвола (iptables чи nftables) - без них не працювали б мережі bridge і публікація портів. Трафік на опублікований порт перенаправляється в контейнер у таблиці NAT ще до того, як потрапить у ланцюжки, якими керує ufw. Документація Docker прямо попереджає: Docker і ufw використовують правила фаєрвола несумісно.

Як захиститися:

1. Не публікувати те, що не має бути публічним - найнадійніше:

services:
  redis:
    # без ports - доступний лише іншим контейнерам у мережі
  postgres:
    ports:
      - "127.0.0.1:5432:5432"   # лише з самого хоста

2. Ланцюжок DOCKER-USER - Docker пропускає через нього трафік до контейнерів перед власними правилами. Туди додають обмеження (наприклад, дозволити порт лише з певних адрес). Правила в інших ланцюжках, створених Docker, змінювати не можна - Docker перезаписує їх.

3. Фаєрвол на рівні мережі чи хмари - security groups у AWS, фаєрвол провайдера (Hetzner Cloud Firewall) працюють поза хостом і не залежать від правил, які створює Docker.

4. Не вимикати керування фаєрволом Docker ("iptables": false) без глибокого розуміння - зламаються мережі контейнерів.

Перевірка ззовні, а не з самого сервера:

nmap -p 1-65535 ваш-сервер

і docker ps для переліку опублікованих портів.

Висновок для співбесіди: фаєрвол хоста не захищає опубліковані порти Docker; захищає правильна публікація (127.0.0.1 чи без ports) і фаєрвол на рівні мережі.

Докладніше в документації: Фільтрація пакетів і фаєрволи

У Linux права root розділені на окремі можливості (capabilities): змінювати власника файлів (CAP_CHOWN), відкривати порти нижче 1024 (CAP_NET_BIND_SERVICE), налаштовувати мережу (CAP_NET_ADMIN), завантажувати модулі ядра (CAP_SYS_MODULE) тощо.

Docker за замовчуванням дає контейнеру обмежений набір - близько півтора десятка можливостей (CHOWN, SETUID, NET_BIND_SERVICE, KILL та інші), а небезпечні (SYS_ADMIN, NET_ADMIN, SYS_MODULE) прибирає. Тому root у контейнері слабший за root на хості.

Принцип найменших привілеїв - прибрати все й додати лише потрібне:

docker run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx
services:
  app:
    cap_drop: [ALL]
    cap_add: [NET_BIND_SERVICE]
    security_opt:
      - no-new-privileges:true
docker run --rm --cap-drop ALL alpine chown nobody /tmp
# chown: /tmp: Operation not permitted - навіть від root

no-new-privileges забороняє процесу отримати нові права через setuid-бінарні файли (наприклад, sudo, su) - навіть якщо вони є в образі.

Скільки можливостей потрібно типовому вебзастосунку? Часто - жодної, якщо він працює від звичайного користувача й слухає порт вище 1024. Процеси, що стартують від root і перемикаються на іншого користувача (nginx, php-fpm master), потребують SETUID, SETGID, CHOWN. Підібрати мінімальний набір - експериментально: прибрати все й додавати те, без чого контейнер не запускається.

Небезпечні можливості, які додають «щоб працювало»:

  • SYS_ADMIN - фактично майже повний root (монтування, багато адміністративних операцій) - один із класичних шляхів виходу з контейнера;
  • NET_ADMIN - керування мережею хоста;
  • SYS_PTRACE - відстеження інших процесів.

Якщо документація сторонньої програми вимагає таких можливостей, варто зрозуміти навіщо, а не додавати бездумно.

Перевірити ефективні можливості в контейнері: grep CapEff /proc/self/status і розшифрувати capsh --decode=....

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

--read-only робить кореневу файлову систему контейнера доступною лише для читання. Запис дозволено тільки в явно змонтовані томи й tmpfs.

docker run --rm --read-only alpine touch /x
# touch: /x: Read-only file system

docker run --rm --read-only --tmpfs /tmp alpine sh -c 'touch /tmp/y && echo ok'
# ok

Навіщо:

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

Що зазвичай потрібно для запису й чим це закрити:

  • тимчасові файли (/tmp, /var/run, кеш PHP-сесій) - tmpfs (у пам'яті, зникає при зупинці);
  • дані, що мають жити - іменовані томи;
  • для Laravel: storage/framework (кеш, сесії, скомпільовані шаблони), storage/logs, bootstrap/cache - tmpfs або томи. Логи краще писати в stderr (LOG_CHANNEL=stderr), тоді каталог логів не потрібен.
services:
  app:
    read_only: true
    tmpfs:
      - /tmp
      - /var/www/html/storage/framework:uid=1000,gid=1000
    volumes:
      - uploads:/var/www/html/storage/app

Особливості tmpfs:

  • зберігається в пам'яті й зараховується до ліміту пам'яті контейнера - великі тимчасові файли (обробка відео) там не місце;
  • вміст зникає при зупинці контейнера;
  • права й розмір задаються опціями монтування.

Поєднання захисних прапорців - стандартний «посилений» запуск:

read_only: true
user: "1000:1000"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]

Це закриває більшість сценаріїв закріплення зловмисника в контейнері після злому застосунку.

Докладніше в документації: Монтування tmpfs

Найпоширеніший спосіб передати пароль бази чи ключ API в контейнер - змінна оточення. Але змінні оточення легко витікають:

  • docker inspect показує всі змінні контейнера відкритим текстом - будь-хто з доступом до Docker на хості їх бачить;
  • /proc/<pid>/environ - процеси з достатніми правами читають оточення інших процесів;
  • дочірні процеси успадковують оточення: сторонній бінарний файл, запущений застосунком, отримує всі секрети;
  • логи й звіти про помилки: сторінки налагодження, phpinfo(), дампи оточення в системах моніторингу;
  • ENV у Dockerfile зберігається в образі - будь-хто з доступом до образу (реєстр) прочитає docker history / docker inspect образу.

Секрети Compose монтують значення файлом у /run/secrets/<назва>:

services:
  app:
    image: myapp
    secrets:
      - db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt     # або environment: DB_PASSWORD з оточення хоста
  • значення не видно в docker inspect контейнера;
  • доступ лише для сервісів, яким секрет явно надано;
  • багато офіційних образів підтримують змінні з суфіксом _FILE (POSTGRES_PASSWORD_FILE, MYSQL_ROOT_PASSWORD_FILE) - читають секрет з файлу.

Застосунок має вміти читати секрет з файлу. Для Laravel - невеликий код у конфігурації чи завантаження змінних з файлу на старті контейнера (скрипт-обгортка, що читає /run/secrets/* і експортує їх лише для процесу PHP).

Що варто пам'ятати:

  • секрети Compose без Swarm - це по суті bind mount файлу: захищають від inspect і випадкового витоку, але не від root на хості;
  • у продакшені - менеджери секретів (Vault, AWS Secrets Manager, Doppler) чи механізми оркестратора (Kubernetes Secrets з шифруванням);
  • на етапі збирання образу секрети передаються через RUN --mount=type=secret, а не ARG чи ENV;
  • файл .env з секретами - у .dockerignore і .gitignore.

Докладніше в документації: Секрети в Docker Compose

Образ містить не лише ваш код, а й базову ОС, системні бібліотеки, PHP і розширення, Composer- і npm-залежності. У кожному шарі можуть бути відомі вразливості (CVE).

Інструменти сканування:

docker scout cves myapp:1.4            # Docker Scout (вбудований у Docker CLI)
docker scout quickview myapp:1.4       # коротке зведення з порадами
trivy image myapp:1.4                  # Trivy від Aqua Security - відкритий і популярний у CI
grype myapp:1.4                        # Grype від Anchore

Сканер визначає пакети в образі (за базами пакетного менеджера ОС і файлами залежностей мов) і зіставляє їх з базами вразливостей.

Як вбудувати в процес:

  • у CI на кожне збирання: падати на вразливостях рівня CRITICAL/HIGH, для яких є виправлення (trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1);
  • регулярне сканування вже зібраних образів - нові CVE з'являються щодня, а образ, чистий тиждень тому, сьогодні може бути вразливим;
  • SBOM (перелік компонентів образу) - docker scout sbom чи syft: швидко зрозуміти, де використовується вразлива бібліотека, коли виходить нове повідомлення.

Що робити з результатами - не намагатися виправити все:

  • оновити базовий образ - найчастіше прибирає більшість знахідок. Закріплюйте мінорну версію (php:8.5-fpm-alpine) і регулярно перезбирайте;
  • менший образ - менше вразливостей: Alpine, -slim, distroless, multi-stage без інструментів збирання у фінальному образі;
  • оцінювати досяжність: вразливість у бібліотеці, яку застосунок не викликає, чи в пакеті, що потрапив в образ випадково, - менш пріоритетна, ніж у вебсервері;
  • документувати свідомі винятки (VEX, файл ігнорування з причиною й датою перегляду), щоб звіт лишався корисним, а не з сотнею проігнорованих рядків.

Пастки:

  • «нуль CVE» - не мета: багато знахідок у базових ОС не мають виправлень або не стосуються вашого використання;
  • сканер бачить лише відомі пакети: бінарні файли, скопійовані вручну (curl ... | tar), часто невидимі для нього;
  • автоматичні оновлення (Dependabot, Renovate) для базових образів і залежностей роблять процес постійним, а не авральним.

Докладніше в документації: Docker Scout

Compose вміє зливати кілька файлів: базова конфігурація + доповнення для конкретного оточення.

Автоматичне злиття: якщо поруч з compose.yaml є compose.override.yaml, docker compose up бере обидва - перевизначення застосовується поверх бази.

# compose.yaml - спільне для всіх оточень
services:
  app:
    image: myapp:${TAG:-latest}
    environment:
      APP_ENV: production
# compose.override.yaml - лише для локальної розробки (часто в .gitignore)
services:
  app:
    build: .
    environment:
      APP_ENV: local
      APP_DEBUG: "true"
    volumes:
      - .:/var/www/html
    ports:
      - "127.0.0.1:8000:8000"
  mailpit:
    image: axllent/mailpit

Явний набір файлів - прапорець -f (файл override тоді не підхоплюється автоматично):

docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.ci.yaml run --rm app php artisan test

Або через змінну COMPOSE_FILE=compose.yaml:compose.ci.yaml.

Правила злиття:

  • одиночні значення (image, command, restart) - заміняються значенням з пізнішого файлу;
  • словники (environment, labels) - зливаються за ключами;
  • списки ports, volumes - об'єднуються (з урахуванням однакових цілей), а не заміняються;
  • !reset і !override - теги YAML, щоб явно скинути чи повністю замінити значення з базового файлу (наприклад, прибрати порти, оголошені в базі).
services:
  app:
    ports: !reset []

Відносні шляхи в усіх файлах обчислюються від першого файлу (базової директорії проєкту) - типова пастка при -f інша-папка/compose.yaml.

Перевірити результат злиття:

docker compose -f compose.yaml -f compose.prod.yaml config

Типові схеми:

  • compose.yaml (база) + compose.override.yaml (розробка, автоматично) + compose.prod.yaml (явно на сервері);
  • окремі файли для CI (без томів з кодом, з тестовою базою в пам'яті).

Альтернатива злиттю - include (підключення окремих частин) і профілі (сервіси, що вмикаються за потреби).

Докладніше в документації: Злиття кількох файлів Compose

Профілі дають змогу тримати в одному compose.yaml сервіси, які запускаються лише за потреби.

services:
  app:
    build: .
  postgres:
    image: postgres:18

  mailpit:
    image: axllent/mailpit
    profiles: [tools]

  phpmyadmin:
    image: phpmyadmin
    profiles: [tools]

  horizon:
    build: .
    command: php artisan horizon
    profiles: [queue]

  playwright:
    image: mcr.microsoft.com/playwright
    profiles: [e2e]

Правила:

  • сервіс без profiles запускається завжди;
  • сервіс з профілем - лише коли профіль активовано:
docker compose up -d                              # app, postgres
docker compose --profile tools up -d              # + mailpit, phpmyadmin
docker compose --profile tools --profile queue up -d
COMPOSE_PROFILES=tools,queue docker compose up -d
  • явно названий сервіс запускається навіть без активного профілю: docker compose run --rm playwright - профіль не потрібен;
  • якщо сервіс з профілем залежить (depends_on) від іншого сервісу з неактивним профілем - Compose повідомить про помилку; залежності мають бути або без профілю, або в тому самому.

Коли профілі зручніші за окремі файли:

  • допоміжні інструменти розробки (адмінки баз, перехоплювачі пошти, профайлери), які потрібні не щодня;
  • важкі сервіси (Elasticsearch, браузерні тести), що споживають багато пам'яті, - вмикати лише для відповідних задач;
  • ролі одного застосунку - вебсервер, воркер черги, планувальник - запускаються за потреби;
  • усе в одному файлі - видно повну картину оточення, без пошуку по кількох файлах.

Коли краще окремі файли (-f): коли відрізняється конфігурація тих самих сервісів між оточеннями (розробка, CI, продакшен), а не набір сервісів.

Перевірка: docker compose --profile tools config --services - перелік сервісів, що будуть запущені з цим профілем.

Докладніше в документації: Профілі Compose

Healthcheck - команда, яку Docker періодично виконує всередині контейнера, щоб визначити, чи сервіс справді працює, а не просто запущений процес. Результат - стан starting, healthy чи unhealthy.

services:
  postgres:
    image: postgres:18
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s        # як часто перевіряти
      timeout: 3s         # скільки чекати на відповідь
      retries: 10         # скільки невдач поспіль до unhealthy
      start_period: 20s   # пільговий період на старті: невдачі не рахуються
      start_interval: 1s  # частіші перевірки під час start_period

  app:
    build: .
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8000/up"]
      interval: 10s
      timeout: 3s
      retries: 3

Що робить healthcheck добрим:

  • перевіряє здатність обслуговувати запити, а не лише живий процес: pg_isready, redis-cli ping, HTTP-запит до ендпойнта здоров'я (у Laravel 11+ - маршрут /up);
  • легкий і швидкий - він виконується кожні кілька секунд; важкі запити до бази в healthcheck створюють навантаження;
  • не залежить від зовнішніх сервісів у перевірці «живучості»: якщо застосунок вважає себе unhealthy через недоступний сторонній API, оркестратор почне його перезапускати, що нічого не виправить;
  • інструмент є в образі: curl чи wget часто відсутні в мінімальних образах - перевірка падає не через сервіс, а через відсутню програму;
  • $$ у Compose - щоб змінна підставилася всередині контейнера, а не при розборі файлу.

Як використовується стан:

  • depends_on з condition: service_healthy - залежний сервіс стартує лише після готовності;
  • docker compose ps показує стан, а docker inspect - історію останніх перевірок з виводом команди;
  • docker compose up --wait - дочекатися, поки всі сервіси стануть healthy (зручно в CI);
  • оркестратори (Swarm) перезапускають unhealthy-контейнери. Звичайний Docker сам по собі не перезапускає unhealthy-контейнер - лише позначає його.

Healthcheck в образі (HEALTHCHECK у Dockerfile) задає перевірку за замовчуванням; у Compose її можна перевизначити чи вимкнути (disable: true).

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

Bind mount (volumes: - .:/var/www/html) робить каталог хоста видимим у контейнері напряму: зміна файлу на хості миттєво видна в контейнері.

docker compose watch (Compose 2.22+) - інший підхід: Compose стежить за файлами на хості й при змінах виконує дію з контейнером.

services:
  app:
    build: .
    develop:
      watch:
        - action: sync               # скопіювати змінені файли в контейнер
          path: ./app
          target: /var/www/html/app
        - action: sync+restart       # скопіювати й перезапустити сервіс
          path: ./config
          target: /var/www/html/config
        - action: rebuild            # перезібрати образ і перестворити контейнер
          path: composer.lock
        - action: sync
          path: ./resources
          target: /var/www/html/resources
          ignore:
            - node_modules/
docker compose watch          # або docker compose up --watch

Дії:

  • sync - копіює змінені файли в контейнер (для коду, що підхоплюється «на льоту»: PHP, шаблони, фронтенд з HMR);
  • sync+restart - копіює й перезапускає основний процес (змінилася конфігурація);
  • sync+exec - копіює й виконує команду в контейнері;
  • rebuild - перезбирає образ (змінилися залежності: composer.lock, package.json, Dockerfile).

Чим відрізняється від bind mount:

  • синхронізація лише потрібних каталогів, з виключеннями - vendor, node_modules і кеші не синхронізуються, у контейнері лишаються свої версії, зібрані для Linux;
  • продуктивність на macOS і Windows: bind mount через віртуальну машину Docker Desktop повільний на великих кодових базах; watch копіює лише змінені файли, а читання в контейнері йде з його власної файлової системи;
  • права й власники файлів - менше проблем, ніж зі спільними каталогами;
  • автоматичний rebuild при зміні залежностей - не треба пам'ятати про --build.

Обмеження:

  • зміни з контейнера назад на хост не синхронізуються (згенеровані файли, міграції, створені командою artisan make:*) - для таких робочих процесів bind mount зручніший;
  • потрібен build у конфігурації сервісу (watch працює з сервісами, які Compose збирає).

Для Laravel-розробки часто поєднують: bind mount для каталогів, де генеруються файли, і watch - для залежностей з rebuild.

Докладніше в документації: Compose Watch

У розробці код зазвичай монтують з хоста: - .:/var/www/html. Разом із кодом у контейнер потрапляють і каталоги залежностей хоста - vendor і node_modules. Звідси три проблеми:

1. Різні платформи. На хості macOS (ARM чи x86), у контейнері - Linux. Нативні модулі npm (esbuild, rollup, sharp) і деякі Composer-пакети з бінарними файлами, встановлені на хості, у контейнері не запрацюють - і навпаки.

2. Продуктивність. vendor і node_modules - десятки тисяч дрібних файлів. На Docker Desktop (macOS, Windows) кожне читання через bind mount проходить через межу віртуальної машини - автозавантаження Composer і збирання фронтенду помітно сповільнюються.

3. Конфлікти. Залежності, встановлені в контейнері, перезаписують хостові (і навпаки) - нескінченне «а в мене працює».

Рішення - іменований том поверх каталогу залежностей:

services:
  app:
    build: .
    volumes:
      - .:/var/www/html                  # код з хоста
      - vendor:/var/www/html/vendor      # залежності - у томі Docker
      - node_modules:/var/www/html/node_modules

volumes:
  vendor:
  node_modules:

Пізніше змонтований том «закриває» відповідний підкаталог bind mount: у контейнері vendor - з тому (Linux-версії, швидкий доступ), а на хості свій vendor (чи порожньо).

Що варто знати:

  • встановлення залежностей - у контейнері: docker compose run --rm app composer install, docker compose run --rm app npm ci;
  • IDE на хості не бачить залежностей з тому - автодоповнення й аналіз коду страждають. Варіанти: встановлювати й на хості теж, налаштувати IDE на віддалений інтерпретатор у контейнері, devcontainers;
  • новий том порожній при першому створенні, але якщо в образі за цим шляхом уже є файли, Docker копіює їх у порожній том - зручно, якщо залежності встановлені при збиранні образу;
  • оновлення залежностей: після зміни composer.lock том треба оновити (composer install знову) - сам по собі він не «знає» про зміни;
  • скинути - docker compose down -v видаляє томи проєкту (разом з даними бази, якщо вони теж у томах!) - або видалити конкретний том: docker volume rm проєкт_vendor.

Альтернативи: docker compose watch з виключенням каталогів залежностей, синхронізовані файлові спільні каталоги Docker Desktop.

Докладніше в документації: Томи Docker

Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 35 Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії