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

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-тестом саме в ньому.

Докладніше в документації: Multi-stage builds

Обидві інструкції задають, що запускається в контейнері, але по-різному поводяться з аргументами 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.

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

Поширене непорозуміння: 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 взагалі: так - як документацію: той, хто запускає образ, одразу бачить, який порт слухає застосунок. Але покладатися на нього як на механізм безпеки чи мережі не можна.

Докладніше в документації: Dockerfile: 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.

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

Шари образу незмінні. Кожна інструкція додає новий шар поверх попередніх, і видалення файлу в пізнішому шарі не прибирає його з попереднього - лише позначає як видалений (так званий 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) і зникає разом з контейнером.

Докладніше в документації: Docker: драйвери зберігання

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) потрібен і проксі, і оркестратору - щоб не відправляти трафік у контейнер, що ще не готовий.

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

За замовчуванням контейнер може використати всю пам'ять і 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. Тоді коду застосунку взагалі не потрібен запис на диск.

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

Контейнер у циклі перезапусків (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 container stats

Спокуса знайома: зайти в контейнер (docker exec -it app bash), встановити пакет, виправити конфіг - «і все запрацювало». А потім зберегти результат як образ:

docker commit app myapp:fixed

Чому це погана практика:

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

Принцип незмінної інфраструктури: контейнери не «лагодять», а замінюють. Виправлення - у Dockerfile чи конфігурації, новий образ, новий деплой.

Як правильно розслідувати й виправляти:

  1. зайти в контейнер, щоб з'ясувати проблему (це нормально);
  2. перенести виправлення в Dockerfile, конфіг у репозиторії чи змінні оточення;
  3. зібрати новий образ і задеплоїти.

Що бачити, що змінено в контейнері:

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) - ручні зміни просто неможливі, а помилки конфігурації виявляються одразу.

Докладніше в документації: docker container commit

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

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

Інші рівні
Junior 35 Senior 30

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