Питання на співбесіді з 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.
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 воно не потрібне, а з томом - має існувати в образі чи створюватися при старті.
Класична продакшен-пастка на 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"]
Це закриває більшість сценаріїв закріплення зловмисника в контейнері після злому застосунку.
Найпоширеніший спосіб передати пароль бази чи ключ 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.
Образ містить не лише ваш код, а й базову ОС, системні бібліотеки, 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) для базових образів і залежностей роблять процес постійним, а не авральним.
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.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 - перелік сервісів, що будуть запущені з цим профілем.
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).
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.
У розробці код зазвичай монтують з хоста: - .:/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.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії