Docker: питання на співбесіді рівня Middle
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
35 питань
Multi-stage build - кілька етапів FROM в одному Dockerfile. Проміжні етапи містять інструменти для збирання, а фінальний образ копіює з них лише результат.
# Етап 1: фронтенд
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Етап 2: залежності PHP
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist
# Етап 3: фінальний образ
FROM php:8.4-fpm-alpine
WORKDIR /var/www
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
Що це дає:
- Менший образ. У фінальному немає Node.js,
node_modules, Composer, компіляторів - лише те, що потрібно для роботи. Різниця буває в сотні мегабайтів. - Менша поверхня атаки. Менше програм - менше вразливостей і менше інструментів для нападника, який потрапив у контейнер.
- Паралельне збирання. BuildKit збирає незалежні етапи одночасно, а невикористані пропускає.
- Один Dockerfile для всього: окремий етап
devз Xdebug і інструментами,testдля CI,production- збирати потрібний через--target.
docker build --target production -t myapp .
Пастка: копіюючи з етапу збирання, легко забути щось потрібне під час роботи (розширення PHP, системну бібліотеку). Перевіряйте фінальний образ запуском тестів чи smoke-тестом саме в ньому.
Обидві інструкції задають, що запускається в контейнері, але по-різному поводяться з аргументами docker run:
CMD- команда за замовчуванням. Якщо при запуску передати команду, вона повністю замінитьCMD.ENTRYPOINT- основний виконуваний файл. Аргументиdocker run(абоCMD) дописуються до нього як параметри.
ENTRYPOINT ["php", "artisan"]
CMD ["serve", "--host=0.0.0.0"]
docker run myapp # php artisan serve --host=0.0.0.0
docker run myapp queue:work # php artisan queue:work
docker run myapp migrate --force # php artisan migrate --force
Типовий сценарій - скрипт-обгортка як ENTRYPOINT, що готує середовище й передає керування команді:
#!/bin/sh
set -e
php artisan config:cache
exec "$@" # запустити CMD - і зробити його PID 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["php-fpm"]
Exec-форма проти shell-форми:
CMD ["php-fpm"](JSON-масив, exec-форма) - процес запускається напряму й отримує сигнали.CMD php-fpm(shell-форма) - запускається через/bin/sh -c, і PID 1 - це оболонка. СигналSIGTERMприdocker stopотримує shell, а не застосунок, тож контейнер не завершується коректно і через 10 секунд його вбиваютьSIGKILL.
Тому в обох інструкціях - exec-форма, а в скрипті-обгортці - exec "$@".
ENTRYPOINT можна перевизначити при запуску: docker run --entrypoint sh myapp.
Поширене непорозуміння: EXPOSE 80 «відкриває порт». Насправді EXPOSE нічого не відкриває - це документація в метаданих образу: «застосунок у контейнері слухає цей порт».
EXPOSE 8080
Що EXPOSE робить:
- записує порт у метадані образу (
docker image inspect→ExposedPorts); - слугує підказкою для людей і інструментів;
- використовується прапорцем
-P(--publish-all): Docker опублікує всі задекларовані порти на випадкові порти хоста.
Що EXPOSE не робить:
- не публікує порт на хості - без
-pконтейнер недоступний ззовні, зEXPOSEчи без; - не обмежує доступ: інші контейнери в тій самій мережі можуть звертатися до будь-якого порту, на якому слухає процес, - навіть незадекларованого;
- не змушує застосунок слухати цей порт - це має налаштувати сам застосунок.
Публікація порту - при запуску:
docker run -p 8080:80 nginx # хост:8080 → контейнер:80, на всіх інтерфейсах хоста
docker run -p 127.0.0.1:8080:80 nginx # лише локально
# compose
services:
web:
ports:
- "8080:80"
Пастка безпеки з -p 3306:3306: опублікований порт доступний на всіх інтерфейсах хоста (0.0.0.0), тобто з інтернету, якщо сервер має публічну адресу. Docker додає правила в iptables так, що вони можуть обійти налаштування фаєрвола хоста (ufw). Базу й Redis не публікують зовсім - сервіси звертаються до них через мережу Docker - або публікують лише на 127.0.0.1.
Протокол: за замовчуванням TCP; для UDP - EXPOSE 53/udp і -p 53:53/udp.
Чи писати EXPOSE взагалі: так - як документацію: той, хто запускає образ, одразу бачить, який порт слухає застосунок. Але покладатися на нього як на механізм безпеки чи мережі не можна.
Docker за замовчуванням знає лише, чи працює процес. Процес може бути живим, але застосунок - завислим: зайняті всі воркери, втрачено з'єднання з базою, вичерпано пам'ять. HEALTHCHECK дає змогу перевіряти справжню готовність.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost/up || exit 1
Параметри:
--interval- як часто перевіряти (за замовчуванням 30 с);--timeout- скільки чекати на відповідь;--start-period- час на запуск, протягом якого невдачі не рахуються (прогрів, міграції);--start-interval- частіші перевірки протягом стартового періоду, щоб швидше стати «здоровим»;--retries- скільки невдач поспіль до статусуunhealthy.
Команда повертає 0 - здоровий, 1 - нездоровий. Статус видно в docker ps (healthy, unhealthy, starting).
Хто використовує статус:
- Compose:
depends_onзcondition: service_healthy- сервіс стартує, коли залежність справді готова; - Swarm замінює нездорові контейнери і не перемикає трафік на новий контейнер під час оновлення, доки той не здоровий;
- платформи деплою (Dokploy, Coolify, Kamal) чекають на здоровий контейнер перед перемиканням трафіку.
Сам Docker Engine не перезапускає нездоровий контейнер - статус лише позначається. Перезапуск робить оркестратор чи власна логіка.
Яка перевірка добра:
- легка й швидка: запит на
/up(у Laravel 11+ такий маршрут є за замовчуванням), а не тяжка сторінка з запитами до бази; - перевіряє застосунок, а не лише порт: відповідь саме від PHP, а не лише від Nginx;
- глибина - свідомий вибір: перевірка бази в healthcheck означає, що короткочасна недоступність бази позначить нездоровими всі контейнери застосунку - і оркестратор почне їх перезапускати, погіршуючи ситуацію. Часто краще перевіряти лише сам застосунок;
- наявні утиліти: у мінімальних образах немає
curl- тодіwget -q --spider, вбудована команда застосунку чи невеликий бінарник.
Для воркерів без HTTP (черги, планувальник) - перевірка процесу чи файлу-маркера, який воркер оновлює («серцебиття»).
Перевизначити чи вимкнути - у Compose: healthcheck: у сервісі чи disable: true.
Шари образу незмінні. Кожна інструкція додає новий шар поверх попередніх, і видалення файлу в пізнішому шарі не прибирає його з попереднього - лише позначає як видалений (так званий whiteout-файл).
RUN curl -o /tmp/sdk.tar.gz https://example.com/sdk.tar.gz # шар +500 МБ
RUN tar -xzf /tmp/sdk.tar.gz -C /opt && rm /tmp/sdk.tar.gz # архів «видалено»
У контейнері /tmp/sdk.tar.gz не видно, але 500 МБ залишилися в першому шарі. Образ завантажується з усіма шарами - і з видаленими файлами теж.
Як працює файлова система контейнера (OverlayFS):
- шари образу - лише для читання, складені один на одного;
- контейнер отримує тонкий записуваний шар зверху;
- зміна файлу з нижнього шару - копіювання при записі (copy-on-write): файл копіюється у верхній шар і змінюється там;
- видалення - позначка у верхньому шарі, що ховає файл нижче.
Як правильно - створення і видалення в одному шарі:
RUN curl -o /tmp/sdk.tar.gz https://example.com/sdk.tar.gz \
&& tar -xzf /tmp/sdk.tar.gz -C /opt \
&& rm /tmp/sdk.tar.gz
Або, краще, multi-stage build: усе тимчасове - у стадії збирання, у фінальний образ копіюється лише результат:
FROM debian:bookworm-slim AS sdk
RUN ... завантажити й розпакувати
FROM php:8.5-fpm
COPY --from=sdk /opt/sdk /opt/sdk
Типові «невидимі» витрати:
apt-get updateв одномуRUN, а очищення/var/lib/apt/lists- в іншому;COPY . ., а потімRUN rm -rf tests node_modules- краще не копіювати їх через.dockerignore;RUN chown -R www-data /appпісляCOPY- повна копія всіх файлів у новому шарі (замість цього -COPY --chown);- секрети: файл з ключем, скопійований і потім видалений, лишається в шарі - його можна дістати з образу. Для секретів -
RUN --mount=type=secret.
Записуваний шар контейнера теж варто тримати малим: логи, кеш, завантажені файли - у томах чи зовнішніх сховищах. Запис у шар контейнера повільніший (copy-on-write) і зникає разом з контейнером.
depends_on: [db] у звичайній формі гарантує лише порядок запуску контейнерів: db стартує раніше за app. Але «контейнер запущено» - не «база приймає з'єднання». PostgreSQL ще кілька секунд ініціалізується, а застосунок уже пробує підключитися й падає.
Рішення - healthcheck + умова:
services:
db:
image: postgres:17
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 3s
retries: 10
app:
build: .
depends_on:
db:
condition: service_healthy
Тепер app стартує лише тоді, коли healthcheck бази почав проходити.
Інші умови:
service_started- поведінка за замовчуванням.service_completed_successfully- дочекатися, поки разовий контейнер завершиться з кодом 0. Зручно для міграцій: окремий сервісmigrateзапускається, виконуєphp artisan migrate --forceі завершується, аappстартує після нього.
Але на цьому не зупиняються: depends_on працює лише при старті через Compose. База може перезапуститися пізніше, а на проді застосунок часто запускають оркестратором без Compose. Тому застосунок сам має бути стійким до тимчасової недоступності залежностей: повторні спроби підключення, коректні помилки, а не падіння при першому ж збої.
Healthcheck для самого застосунку (наприклад, на маршрут /up у Laravel) потрібен і проксі, і оркестратору - щоб не відправляти трафік у контейнер, що ще не готовий.
За замовчуванням контейнер може використати всю пам'ять і CPU хоста. Один процес з витоком пам'яті здатен покласти весь сервер разом з базою й іншими сервісами.
docker run --memory=512m --cpus=1.5 myapp
services:
worker:
deploy:
resources:
limits:
memory: 512M
cpus: "1.5"
Що відбувається при перевищенні пам'яті: ядро Linux (OOM killer) вбиває процес у контейнері. Контейнер завершується з кодом 137 (128 + 9, сигнал SIGKILL), а в docker inspect видно "OOMKilled": true. З restart: unless-stopped він підніметься знову - і, якщо причина не усунена, впаде знову.
CPU-ліміт працює інакше: процес не вбивають, а сповільнюють (throttling) - він отримує не більше вказаної частки процесорного часу.
Нюанси для PHP:
memory_limitу PHP і ліміт контейнера - різні речі. Ліміт контейнера рахує всі процеси: майстер PHP-FPM і всі воркери разом.pm.max_children = 20з піком 100 МБ на воркер - це до 2 ГБ, і ліміт 512 МБ гарантовано закінчиться OOM.- Кількість воркерів FPM, Octane чи черг підбирають під ліміт пам'яті контейнера, а не під пам'ять хоста.
- Воркерам черг ставлять
--memoryі--max-jobs, щоб вони перезапускалися раніше, ніж їх уб'є ядро.
Моніторинг: docker stats показує поточне споживання. Код виходу 137 у логах і рестарти без явних помилок у застосунку - перша ознака, що контейнеру не вистачає пам'яті.
tmpfs - файлова система в оперативній пам'яті. Дані ніколи не потрапляють на диск хоста і зникають при зупинці контейнера.
docker run --tmpfs /tmp:rw,size=64m,mode=1777 myapp
services:
app:
tmpfs:
- /tmp:size=64m
read_only: true
Три види сховища в Docker:
| Том | Bind mount | tmpfs | |
|---|---|---|---|
| де дані | область Docker на диску | каталог хоста | пам'ять |
| переживає контейнер | так | так | ні |
| швидкість | диск | диск (на macOS - повільно) | пам'ять |
| для чого | дані, що мають зберігатися | код у розробці, конфіги | тимчасові й чутливі дані |
Коли tmpfs доречний:
- файлова система лише для читання:
read_only: true- сильний захист (зловмисник не запише бекдор у код), але застосунку потрібні тимчасові файли. tmpfs для/tmp,/runі каталогів кешу дає записуване місце без ослаблення захисту; - чутливі тимчасові дані, які не повинні потрапити на диск: розшифровані файли, тимчасові ключі;
- швидкий тимчасовий кеш: скомпільовані шаблони, тимчасові файли обробки - коли їх втрата при перезапуску не страшна;
- тести: база в tmpfs (
/var/lib/postgresql/dataу тестовому контейнері) значно прискорює тести з багатьма записами.
Що враховувати:
- пам'ять: tmpfs займає оперативну пам'ять і рахується в ліміт пам'яті контейнера. Без
sizeможна непомітно з'їсти половину пам'яті хоста; заповнений tmpfs при ліміті - OOM; - дані зникають при перезапуску - нічого, що має зберігатися;
- лише Linux-контейнери і не спільний між контейнерами (кожен має свій);
- права:
mode=1777для/tmp, інакше непривілейований користувач не зможе писати.
Для Laravel з файловою системою лише для читання: storage/framework/cache, storage/framework/views - у tmpfs чи томі, сесії й кеш - у Redis чи базі, логи - в stderr, завантажені файли - в S3/R2. Тоді коду застосунку взагалі не потрібен запис на диск.
Контейнер у циклі перезапусків (Restarting (1) 5 seconds ago) - головний процес падає одразу після старту, а політика --restart запускає його знову.
Крок 1 - логи, включно з попередніми спробами:
docker logs --tail 100 app
docker logs --since 10m app
Найчастіші причини: помилка в конфігурації чи змінних оточення, недоступна база при старті, неіснуючий файл у CMD, помилка синтаксису після деплою.
Крок 2 - стан і причина завершення:
docker inspect app --format '{{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}'
OOMKilled=true - перевищено ліміт пам'яті; код 127 - команду не знайдено; 1 - помилка застосунку.
Крок 3 - запустити образ вручну з оболонкою, обійшовши CMD:
docker run --rm -it --entrypoint sh myapp:latest
docker compose run --rm app sh
Усередині - перевірити файли, права, змінні, запустити команду вручну й побачити помилку.
Споживання ресурсів:
docker stats # CPU, пам'ять (з лімітом), мережа, диск - у реальному часі
docker stats --no-stream # одноразовий знімок
docker top app # процеси всередині контейнера
Що шукати:
- пам'ять росте й не падає - витік у довгоживучому процесі (воркер черги без
--max-jobs, Octane без--max-requests); - пам'ять близька до ліміту - скоро буде OOM; врахуйте, що в пам'ять контейнера входить і сторінковий кеш файлів;
- CPU 100% постійно - нескінченний цикл, активне очікування, надто часте опитування;
- багато процесів у
docker top- PHP-FPM з завеликимmax_childrenчи процеси-«зомбі» (немає init у PID 1).
Події Docker:
docker events --filter container=app --since 1h
Показує die, oom, kill, restart, health_status з часом - видно хронологію.
Для продакшену ручних команд замало: збір метрик (cAdvisor + Prometheus, Beszel, Netdata) і логів у централізоване сховище, сповіщення про перезапуски й OOM.
Мінімальні образи без оболонки (distroless) - для налагодження docker debug, який підключає набір інструментів до запущеного контейнера.
Спокуса знайома: зайти в контейнер (docker exec -it app bash), встановити пакет, виправити конфіг - «і все запрацювало». А потім зберегти результат як образ:
docker commit app myapp:fixed
Чому це погана практика:
- зміни зникнуть: записуваний шар контейнера знищується разом з контейнером. Наступний деплой, перезапуск оркестратором, масштабування - і ручні виправлення втрачено;
- невідтворюваність: образ з
docker commit- «чорна скринька». Ніхто не знає, які саме команди виконано, в якому порядку, з якими версіями. Повторити збирання неможливо; - немає історії в Git: зміни не проходять рев'ю, не прив'язані до коміту, їх не відкотити;
- сміття в образі: кеш менеджерів пакетів, логи, тимчасові файли, історія команд - усе, що було в контейнері, потрапляє в образ;
- секрети: якщо в контейнері були токени чи ключі - вони тепер в образі.
Принцип незмінної інфраструктури: контейнери не «лагодять», а замінюють. Виправлення - у Dockerfile чи конфігурації, новий образ, новий деплой.
Як правильно розслідувати й виправляти:
- зайти в контейнер, щоб з'ясувати проблему (це нормально);
- перенести виправлення в Dockerfile, конфіг у репозиторії чи змінні оточення;
- зібрати новий образ і задеплоїти.
Що бачити, що змінено в контейнері:
docker diff app
# A /var/www/html/storage/logs/laravel.log (додано)
# C /usr/local/etc/php/conf.d (змінено)
# D /tmp/cache (видалено)
Корисно для розслідування: що саме записує застосунок у файлову систему контейнера (і чи не варто це винести в том чи tmpfs).
Коли docker commit доречний:
- зберегти стан для розслідування інциденту чи криміналістики - заморозити контейнер, щоб вивчити пізніше;
- разові експерименти локально, які не підуть у продакшен.
Захист від спокуси в продакшені: файлова система лише для читання (read_only: true) - ручні зміни просто неможливі, а помилки конфігурації виявляються одразу.
На звичайному сервері планувальник запускається рядком у 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 воно не потрібне, а з томом - має існувати в образі чи створюватися при старті.
Питання рівня Middle з реальних технічних співбесід - 35 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії