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

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

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

100 питань

Обидва - оркестратори: керують контейнерами на кластері серверів за описом бажаного стану. Різниця - у складності й можливостях.

Docker Swarm - вбудований у Docker Engine:

docker swarm init
docker stack deploy -c compose.yaml myapp
docker service scale myapp_app=4
  • сервіс - опис того, що має працювати (образ, кількість реплік, порти, ресурси);
  • задача (task) - конкретний контейнер сервісу на конкретному вузлі;
  • стек - набір сервісів з файлу у форматі Compose (з розділом deploy);
  • вбудовані: overlay-мережі між вузлами, балансування (routing mesh), секрети, поступові оновлення з відкатом.

Kubernetes - окрема платформа зі своїми поняттями:

  • Pod - найменша одиниця: один чи кілька контейнерів зі спільною мережею;
  • Deployment - бажана кількість однакових pod-ів і стратегія оновлення;
  • Service - стабільна адреса й балансування до pod-ів;
  • Ingress / Gateway - вхід HTTP-трафіку ззовні;
  • ConfigMap, Secret, томи (PersistentVolume), горизонтальне автомасштабування, оператори для баз даних і черг.

Порівняння:

Swarm Kubernetes
поріг входу низький: знайомий формат Compose високий: багато понять і YAML
встановлення одна команда керований сервіс чи складне налаштування
екосистема обмежена величезна (Helm, оператори, моніторинг)
автомасштабування немає вбудованого є (HPA, кластерні автоскейлери)
популярність, вакансії невелика стандарт індустрії

Коли достатньо Swarm: кілька серверів, кілька сервісів, невелика команда без окремого DevOps, потреба в простих поступових оновленнях і відмовостійкості. Багато PaaS для самостійного хостингу (наприклад, Dokploy) використовують Swarm під капотом.

Коли Kubernetes: десятки сервісів, кілька команд, автомасштабування, складні мережеві й безпекові політики, керований сервіс у хмарі - і готовність інвестувати в його знання.

Помилка вибору: Kubernetes для одного Laravel-застосунку з базою - складність, яка забирає більше часу, ніж дає користі.

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

Найпростіший деплой - docker compose pull && docker compose up -d - зупиняє старий контейнер і лише потім запускає новий. Між цими моментами сайт недоступний, а якщо новий контейнер не стартує - недоступний довго.

Принципи деплою без простою:

1. Спершу запустити нове, потім зупинити старе (start-first). Обидві версії якийсь час працюють паралельно.

2. Трафік на новий контейнер - лише коли він готовий. Перевірка стану (healthcheck) підтверджує, що застосунок справді відповідає, а не просто процес запущено:

healthcheck:
  test: ["CMD", "curl", "-fsS", "http://localhost/up"]
  interval: 10s
  timeout: 3s
  retries: 3
  start_period: 20s

У Laravel 11+ маршрут /up налаштовано за замовчуванням.

3. Старий контейнер завершує поточні запити перед зупинкою (graceful shutdown): отримує SIGTERM, перестає приймати нові запити, дообробляє поточні, і лише потім зупиняється. Балансувальник має встигнути прибрати його з пулу.

4. Автоматичний відкат, якщо нова версія не пройшла перевірку стану.

Як це реалізують:

  • Docker Swarm:
deploy:
  replicas: 2
  update_config:
    parallelism: 1
    order: start-first
    failure_action: rollback

Розділ update_config діє саме в режимі Swarm (docker stack deploy); звичайний docker compose up його не використовує і просто перестворює контейнери.

  • Kubernetes - стратегія RollingUpdate у Deployment з readiness-перевірками;
  • Kamal - запускає новий контейнер, перевіряє його й перемикає трафік через свій проксі (kamal-proxy);
  • власний скрипт з Compose - запустити новий контейнер під іншою назвою, дочекатися здорового стану, перемкнути зворотний проксі (Caddy, Traefik, Nginx), зупинити старий.

Пастки, через які «без простою» не виходить:

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

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

Перевірки стану відповідають на різні питання, і оркестратор реагує на них по-різному.

Liveness - «процес живий чи завис?»

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

Readiness - «чи готовий приймати трафік прямо зараз?»

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

Startup - «чи завершився запуск?»

Поки вона не пройшла, інші перевірки не виконуються. Для застосунків з довгим стартом: без неї liveness-перевірка може «вбивати» контейнер, що просто повільно запускається.

Що буде, якщо переплутати:

  • liveness перевіряє базу даних. База на хвилину недоступна - усі контейнери застосунку провалюють liveness і одночасно перезапускаються. Після відновлення бази - шторм перезапусків і холодний старт усього сервісу. Залежності - у readiness, а liveness має перевіряти лише сам процес;
  • немає readiness - трафік іде на контейнер, який ще прогріває кеш чи виконує міграції: користувачі отримують помилки після кожного деплою;
  • занадто суворі пороги (тайм-аут 1 секунда під навантаженням) - здорові контейнери перезапускаються саме тоді, коли найбільше потрібні;
  • важка перевірка (повний запит до бази, рендер сторінки) на кожні кілька секунд - зайве навантаження.

Як це виглядає в Docker: HEALTHCHECK у Dockerfile чи healthcheck у Compose дає один статус (healthy/unhealthy). Сам Docker не перезапускає нездоровий контейнер - на статус реагують Swarm, Compose з depends_on: condition: service_healthy чи зовнішні інструменти.

Для Laravel:

  • liveness - легкий ендпойнт без звернень до залежностей (маршрут /up підходить, якщо не перевантажений подіями);
  • readiness - окремий ендпойнт, що перевіряє базу, Redis, наявність закешованої конфігурації;
  • черги й планувальник - окремі перевірки: воркер без HTTP перевіряють командою.

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

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

CMD php artisan migrate --force && php-fpm

Проблеми:

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

Правильні варіанти:

1. Окремий крок деплою перед оновленням застосунку - одноразовий контейнер з тим самим образом:

docker run --rm --env-file .env ghcr.io/acme/app:sha-a1b2c3d php artisan migrate --force

У Kubernetes - Job перед оновленням Deployment; у Kamal - хук перед деплоєм; у CI - окремий крок пайплайну.

2. Якщо міграції все ж запускаються зі старту контейнера - з блокуванням:

php artisan migrate --force --isolated

--isolated бере атомарне блокування через кеш - міграції виконає лише один процес, інші пропустять. Потрібен спільний драйвер кешу з підтримкою блокувань (Redis, база даних).

Сумісність версій - найважливіше. Під час поступового деплою старий і новий код працюють одночасно з уже оновленою базою. Тому міграції мають бути сумісними з попередньою версією:

  • додати колонку - безпечно (nullable чи зі значенням за замовчуванням);
  • перейменувати чи видалити колонку - у кілька релізів: додати нову, писати в обидві, перенести дані, перевести читання, і лише потім видалити стару;
  • важкі зміни великих таблиць (індекси, зміна типу) - без довгих блокувань (онлайн-DDL, CREATE INDEX CONCURRENTLY у PostgreSQL), інакше застосунок «зависне» на час міграції.

Відкат: відкат коду не відкочує базу. План має враховувати, що попередня версія коду працюватиме з новою схемою - ще одна причина для сумісних міграцій.

Резервна копія перед міграцією на продакшені - обов'язковий крок для ризикованих змін.

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

Горизонтальне масштабування - запустити кілька однакових контейнерів і розподіляти між ними запити. Працює, лише якщо контейнер без стану (stateless): будь-який запит може обробити будь-яка копія.

Що заважає масштабуванню і куди це винести:

Стан у контейнері Куди винести
сесії у файлах Redis чи база даних (SESSION_DRIVER=redis)
кеш у файлах Redis, Memcached
завантажені файли в storage/app об'єктне сховище (S3, R2) чи спільний том
черги в синхронному режимі чи database на SQLite Redis чи база даних, окремі воркери
планувальник у кожному контейнері окремий контейнер чи onOneServer()
блокування у файлах блокування через Redis/базу

Типові наслідки, якщо цього не зробити:

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

Масштабування в різних середовищах:

docker compose up -d --scale app=4          # кілька копій на одному сервері
docker service scale myapp_app=4             # Swarm
kubectl scale deployment app --replicas=4    # Kubernetes

На одному сервері кілька копій мають сенс, лише якщо одна копія не використовує всі ядра; справжня користь - копії на різних серверах за балансувальником.

Що ще враховувати:

  • з'єднання з базою: кожна копія тримає свій пул - 10 копій по 20 процесів PHP-FPM = 200 з'єднань. База може не витримати раніше, ніж закінчаться ресурси застосунку;
  • база - не масштабується так само: вузьке місце переміщується туди - репліки для читання, кеш, оптимізація запитів;
  • довірені проксі - за балансувальником застосунок має бачити справжні IP і протокол;
  • одноразові задачі при старті (міграції, прогрів кешу) не повинні виконуватися кожною копією.

Для Laravel контейнер з php-fpm чи FrankenPHP, сесіями й кешем у Redis, файлами в S3 і окремими контейнерами для воркерів і планувальника - класична конфігурація, що масштабується.

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

Проблема: коли змінюється composer.lock чи package-lock.json, шар з composer install чи npm ci інвалідується, і усі пакети завантажуються з інтернету заново, навіть якщо змінився один.

Cache mount - каталог, що зберігається між збираннями на збирачі, але не потрапляє в шари образу:

# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    COMPOSER_CACHE_DIR=/tmp/cache \
    composer install --no-dev --no-scripts --prefer-dist --no-interaction

FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

Тепер при зміні одного пакета менеджер бере решту з кешу на диску збирача - збирання прискорюється з хвилин до секунд.

Чим це відрізняється від кешу шарів:

Кеш шарів Cache mount
що кешується результат інструкції цілком лише вміст каталогу
коли втрачається будь-яка зміна вхідних файлів інструкції лише при очищенні кешу збирача
потрапляє в образ так ні

Обидва механізми працюють разом: незмінений composer.lock - кеш шару, змінений - cache mount робить перевстановлення дешевим.

Інші типові застосування:

  • apt/apk: --mount=type=cache,target=/var/cache/apt (у Debian-образах треба ще вимкнути автоматичну чистку кешу apt);
  • кеш збирачів (Go, Rust, Gradle);
  • кеш Vite чи інших інструментів збирання фронтенду.

Параметри:

  • id= - явна назва кешу, щоб різні етапи чи проєкти ділили (або не ділили) його;
  • sharing=locked - для пакетних менеджерів, які не терплять одночасного доступу (apt);
  • uid, gid, mode - права, якщо збирання виконується не від root.

Обмеження: cache mount живе на конкретному збирачі. У CI на одноразових раннерах він порожній при кожному запуску - там допомагає експорт кешу (registry, GitHub Actions cache) чи постійний збирач. Також вміст cache mount недетермінований: збирання не повинно від нього залежати для правильності - лише для швидкості.

Докладніше в документації: Оптимізація кешу: cache mounts

Сервери на ARM (AWS Graviton, Hetzner CAX, Ampere) часто дешевші за x86 при тій самій продуктивності, а розробники працюють на Mac з Apple Silicon (arm64). Образ, зібраний лише для amd64, на arm64-сервері або не запуститься (exec format error), або працюватиме через емуляцію - у рази повільніше.

Мультиплатформний образ - один тег, що містить варіанти для кількох архітектур. Docker сам завантажує той, що відповідає машині.

docker buildx create --name multi --driver docker-container --use
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/acme/app:1.4.2 --push .

Під тегом у реєстрі зберігається індекс (manifest list) з окремими маніфестами для кожної платформи.

Перевірка:

docker buildx imagetools inspect ghcr.io/acme/app:1.4.2

Три способи зібрати для «чужої» архітектури:

1. Емуляція (QEMU) - найпростіше, працює «з коробки» в Docker Desktop. Але збирання для іншої архітектури значно повільніше: компіляція PHP-розширень чи нативних npm-модулів під емуляцією може займати десятки хвилин.

2. Нативні вузли - збирач з кількома вузлами різних архітектур (docker buildx create --append): кожна платформа збирається на своєму залізі. У CI - раннери обох архітектур (GitHub Actions має arm64-раннери) і об'єднання результатів в один індекс.

3. Крос-компіляція в Dockerfile - для мов, що вміють збирати під іншу архітектуру (Go, Rust): етап збирання виконується на рідній платформі збирача, а результат призначений для цільової.

Що враховувати для PHP-образів:

  • офіційні образи PHP, Node, PostgreSQL мають варіанти для обох архітектур - базовий образ проблемою не буде;
  • сторонні бінарники в Dockerfile (завантажені curl-ом інструменти) - треба обирати файл під TARGETARCH, а не жорстко прописаний amd64;
  • локальний образ: звичайне сховище образів Docker тримає один варіант; для роботи з мультиплатформними образами локально потрібне containerd-сховище образів (у нових версіях Docker воно використовується за замовчуванням) або завантаження одразу в реєстр (--push);
  • тестувати обидві архітектури: образ, що збирається для arm64, ще не гарантує, що всі розширення в ньому працюють коректно.

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

Локально збирання швидке завдяки кешу шарів. У CI (GitHub Actions, GitLab CI) раннер зазвичай одноразовий: кожен запуск починається з порожнього кешу, і composer install, npm ci, встановлення розширень PHP виконуються щоразу заново.

BuildKit уміє експортувати кеш у зовнішнє сховище й імпортувати його в наступному запуску:

docker buildx build \
  --cache-from type=registry,ref=ghcr.io/acme/app:buildcache \
  --cache-to type=registry,ref=ghcr.io/acme/app:buildcache,mode=max \
  -t ghcr.io/acme/app:sha-a1b2c3d --push .

Бекенди кешу:

Бекенд Де зберігається
inline у самому образі (лише кеш фінального етапу)
registry окремий образ-кеш у реєстрі
local каталог на диску
gha кеш GitHub Actions
s3, azblob об'єктне сховище

mode=min чи mode=max:

  • min (за замовчуванням) - кеш лише шарів, що потрапили у фінальний образ. Проміжні етапи multi-stage (встановлення Composer-залежностей, збирання фронтенду) не кешуються;
  • max - кеш усіх етапів. Для multi-stage збирань саме він дає найбільший виграш, ціною більшого розміру кешу.

GitHub Actions:

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    push: true
    tags: ghcr.io/acme/app:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

Кеш GitHub Actions має обмеження розміру на репозиторій - старі записи витісняються. Для великих образів надійніший кеш у реєстрі.

Що враховувати:

  • cache mounts не експортуються цими бекендами - вони живуть лише на конкретному збирачі. У CI їх роль виконує саме експортований кеш шарів;
  • кеш для гілок: окремі ключі кешу для main і гілок (ref=...:buildcache-${branch}) з імпортом кешу main як запасного варіанта - pull-request-и не затирають кеш основної гілки;
  • порядок інструкцій у Dockerfile (рідко змінюване вгорі) важливий так само, як локально: експорт кешу не врятує від інвалідації через COPY . . на початку;
  • безпека: кеш у реєстрі може містити вміст проміжних етапів - зберігати його з тими самими правами доступу, що й образи.

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

Плаваючий тег - тег, що з часом вказує на інші образи: php:8.5-fpm-alpine, node:22, postgres:18. Кожен новий патч PHP, оновлення Alpine чи системних бібліотек перевипускає образ під тим самим тегом.

Що відбувається на практиці:

  1. Dockerfile не змінювався місяць;
  2. CI збирає образ заново (новий коміт у застосунку чи очищений кеш);
  3. FROM php:8.5-fpm-alpine тепер означає новий патч PHP і нові версії системних бібліотек (наприклад, ICU, OpenSSL);
  4. застосунок, що працював, починає падати - сегментаційні помилки в розширенні, зміни поведінки форматування, несумісність бібліотеки.

Найгірше - збій не пов'язаний зі змінами в коді: «ми нічого не міняли, а продакшен упав після деплою». Відкат коду не допомагає, бо старий коміт збирається з тим самим новим базовим образом.

Рівні фіксації:

FROM php:8.5-fpm-alpine                     # плаваючий: будь-який 8.5.x і будь-який Alpine
FROM php:8.5.6-fpm-alpine3.22               # конкретний патч PHP і версія Alpine
FROM php:8.5.6-fpm-alpine3.22@sha256:...    # точний вміст образу
  • точна версія тегу - захищає від більшості несподіванок, але й цей тег інколи перевипускають (оновлення системних пакетів);
  • дайджест - гарантія, що образ байт у байт той самий. Тег поруч лишається для читабельності.

Але фіксація без оновлень - теж ризик: базові образи отримують виправлення вразливостей. Зафіксований на рік образ накопичує відомі CVE. Тому фіксацію поєднують з керованими оновленнями:

  • Renovate чи Dependabot створюють pull-request з новою версією чи дайджестом базового образу;
  • CI збирає й тестує образ;
  • оновлення потрапляє в продакшен як звичайна зміна - з можливістю відкату й зрозумілою історією.

Те саме для інших залежностей збирання: версії Composer і Node, завантажені бінарники (curl без перевірки версії й контрольної суми), пакети apt/apk без версій. Lock-файли застосунку (composer.lock, package-lock.json) - обов'язкові.

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

Докладніше в документації: Найкращі практики: фіксація версій базових образів

docker build --check - вбудований лінтер Dockerfile у BuildKit. Він аналізує Dockerfile без виконання збирання і виводить попередження з посиланнями на опис кожного правила.

docker build --check .

Приклад реального виводу на проблемному Dockerfile:

FROM php:8.5-cli-alpine as base
ARG APP_KEY=secret
ENV APP_KEY $APP_KEY
WORKDIR app
CMD php -v
WARNING: FromAsCasing - 'as' and 'FROM' keywords' casing do not match
WARNING: SecretsUsedInArgOrEnv - Do not use ARG or ENV instructions for sensitive data
WARNING: LegacyKeyValueFormat - "ENV key=value" should be used
WARNING: WorkdirRelativePath - Relative workdir "app" can have unexpected results
WARNING: JSONArgsRecommended - JSON arguments recommended for CMD

Що означають найкорисніші правила:

  • SecretsUsedInArgOrEnv - змінна з назвою, схожою на секрет (KEY, TOKEN, PASSWORD), в ARG чи ENV. Значення лишиться в історії чи метаданих образу - треба RUN --mount=type=secret;
  • JSONArgsRecommended - CMD php-fpm у «shell-формі» запускає процес через /bin/sh -c, і сигнал SIGTERM отримує оболонка, а не застосунок: контейнер не завершується коректно. Правильно - CMD ["php-fpm"];
  • WorkdirRelativePath - відносний WORKDIR залежить від попереднього значення в базовому образі, яке може змінитися;
  • LegacyKeyValueFormat - застарілий запис ENV key value;
  • FromAsCasing, StageNameCasing - стилістична узгодженість;
  • UndefinedVar, UndefinedArgInFrom - використання змінної, яку ніде не оголошено (друкарські помилки, що непомітно дають порожнє значення).

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

  • у CI - окремий крок перед збиранням;
  • перетворити попередження на помилки - директивою на початку Dockerfile:
# syntax=docker/dockerfile:1
# check=error=true
  • вимкнути окреме правило свідомо: # check=skip=JSONArgsRecommended.

Зв'язок з іншими інструментами: Hadolint - популярний сторонній лінтер з ширшим набором правил (зокрема перевіркою скриптів у RUN через ShellCheck). docker build --check зручний тим, що вже вбудований і знає семантику BuildKit.

Під час звичайного docker build ці перевірки теж виконуються, а попередження показуються в кінці виводу - їх просто легко не помітити.

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

1. Не запускати від root. За замовчуванням процес у контейнері - root. Вразливість у застосунку тоді дає нападнику root усередині контейнера, а з помилками конфігурації (змонтований Docker-сокет, привілейований режим) - і шлях на хост.

RUN addgroup -S app && adduser -S app -G app
USER app

Для PHP-FPM зазвичай достатньо, щоб воркери працювали від www-data, а записувані каталоги (storage/, bootstrap/cache/) належали цьому користувачу.

2. Мінімальна база. alpine, -slim чи distroless-образи містять менше пакетів - менше CVE і менше інструментів для нападника. Multi-stage build прибирає з фінального образу компілятори й менеджери пакетів.

3. Жодних секретів у шарах. COPY .env чи ARG API_KEY залишають секрет в історії образу, і docker history чи розпакування шарів його покаже - навіть якщо пізніше файл видалено. Секрети - лише під час запуску (змінні оточення, Docker secrets) або під час збирання через --mount=type=secret.

4. Фіксовані версії. FROM php:8.4.13-fpm-alpine замість php:latest; для максимальної відтворюваності - за дайджестом @sha256:.... Плаваючий тег може принести несподівані зміни.

5. Сканування. docker scout cves, Trivy чи Grype у CI знаходять відомі вразливості в пакетах образу. Регулярне перезбирання підтягує оновлення безпеки базового образу.

6. Обмеження під час запуску: файлова система лише для читання (--read-only з окремими томами для запису), --cap-drop=ALL, без --privileged, ніколи не монтувати /var/run/docker.sock у застосунок.

7. .dockerignore - щоб .git, .env, дампи й ключі не потрапили в контекст збирання взагалі.

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

Інколи секрет потрібен під час збирання: токен для приватного Composer- чи npm-репозиторію, ключ для завантаження приватного пакета.

Чому не ARG чи ENV:

ARG COMPOSER_AUTH
RUN composer install

Значення ARG зберігається в метаданих образу й видно через docker history. ENV - тим більше, він лишається у фінальному образі. А COPY auth.json і подальше RM не допоможе: файл лишився в попередньому шарі.

Правильно - секрет-монтування BuildKit: секрет доступний лише під час виконання однієї інструкції RUN, як тимчасовий файл, і не потрапляє в жоден шар чи кеш.

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=composer_auth,target=/root/.composer/auth.json \
    composer install --no-dev --prefer-dist
docker build --secret id=composer_auth,src=$HOME/.composer/auth.json .
# або зі змінної оточення:
docker build --secret id=composer_auth,env=COMPOSER_AUTH_JSON .

У Docker Compose і GitHub Actions (docker/build-push-action) для цього є власні параметри secrets.

Для SSH-ключів (клонування приватних Git-репозиторіїв під час збирання) - окремий механізм --mount=type=ssh з пересиланням SSH-агента, без копіювання ключа.

Перевірка: docker history --no-trunc і розпакування шарів не мають показувати ні значення, ні файлу секрету. Сканери секретів у CI (gitleaks, trufflehog) можна натравити й на сам образ.

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

Linux визначає права за числовими ідентифікаторами користувача й групи (UID/GID), а не за іменами. Контейнер і хост ділять ядро, тож файл, створений у контейнері процесом з UID 33 (www-data у Debian), на хості належатиме користувачу з UID 33 - яким би не було його ім'я.

Типові симптоми:

  • у розробці з bind mount: застосунок у контейнері (UID 33 чи root) створює файли в storage/ і vendor/ - на хості розробник (UID 1000) не може їх змінити чи видалити без sudo. Або навпаки: контейнер не може писати в каталог, створений на хості;
  • у продакшені: «Permission denied» при записі в storage/logs чи bootstrap/cache, бо файли скопійовано з власником root, а процес працює як www-data.

Рішення в образі:

FROM php:8.5-fpm

COPY --chown=www-data:www-data . /var/www/html

USER www-data
  • COPY --chown - власник одразу при копіюванні, без окремого RUN chown -R (той дублює всі файли в новому шарі);
  • USER - процес працює не від root.

Рішення для розробки - узгодити UID:

ARG UID=1000
ARG GID=1000
RUN groupmod -o -g ${GID} www-data && usermod -o -u ${UID} -g ${GID} www-data
docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g)

Тепер www-data у контейнері має той самий UID, що й розробник на хості, - файли з обох сторін належать одній людині. Так робить Laravel Sail (WWWUSER, WWWGROUP).

Альтернативи:

  • docker run --user $(id -u):$(id -g) - запуск від імені користувача хоста (але в образі може не бути такого користувача й домашнього каталогу);
  • іменовані томи замість bind mount для vendor, node_modules - вони живуть у Docker, без конфлікту з хостом;
  • rootless Docker чи userns-remap - root у контейнері відображається на непривілейованого користувача хоста.

На macOS і Windows з Docker Desktop проблема менш помітна: файлова система хоста монтується у віртуальну машину з перетворенням власників. Тому налаштування, що «працює на Mac», ламається на Linux-сервері чи в CI.

Пастка chmod -R 777 - «швидке рішення» проблем з правами, яке дає будь-якому процесу право змінювати код застосунку. Правильно - потрібний власник і мінімальні права (775 для каталогів запису).

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

«Docker» - це набір компонентів, і розуміння їхньої ролі пояснює, чому образи, зібрані Docker, працюють у Kubernetes без Docker.

Стандарти OCI (Open Container Initiative):

  • image spec - формат образу: шари, маніфест, конфігурація;
  • runtime spec - як запустити контейнер з розпакованого образу;
  • distribution spec - протокол реєстрів (push/pull).

Образ, зібраний Docker, - звичайний OCI-образ. Його запускають containerd, CRI-O, Podman, Kubernetes.

Шари виконання:

docker CLI  →  dockerd (Docker Engine)  →  containerd  →  containerd-shim  →  runc  →  процес
  • docker CLI - клієнт; надсилає команди демону через API (сокет /var/run/docker.sock);
  • dockerd - Docker Engine: збирання (BuildKit), мережі, томи, API, Compose-сумісність;
  • containerd - керує життєвим циклом контейнерів і образами (pull, зберігання, знімки файлових систем);
  • runc - низькорівневе середовище виконання OCI: створює namespaces і cgroups і запускає процес;
  • shim - тримає контейнер, коли containerd перезапускається.

Чому це важливо на практиці:

  • Kubernetes з версії 1.24 не використовує Docker Engine напряму - він працює з containerd чи CRI-O через CRI. Образи при цьому ті самі;
  • перезапуск dockerd не обов'язково зупиняє контейнери (опція live-restore);
  • альтернативні runtime: gVisor (runsc) додає ізоляцію ядра в просторі користувача, Kata Containers - легкі віртуальні машини для кожного контейнера. Підключаються до Docker як --runtime;
  • Podman - сумісний з CLI Docker, без центрального демона й з rootless за замовчуванням.

Docker Desktop на macOS і Windows: контейнерам потрібне ядро Linux, тож Docker Desktop запускає легку віртуальну машину з Linux, а docker CLI на хості говорить з демоном у ній. Звідси особливості: повільніший доступ до файлів хоста (bind mount через межу віртуальної машини), окрема пам'ять і CPU, обмежені налаштуваннями VM, host.docker.internal для доступу до хоста.

Архітектура процесора: образ зібрано під конкретну архітектуру (amd64, arm64). На Mac з Apple Silicon образ amd64 запуститься через емуляцію - повільно й інколи з помилками, тому для продакшен-серверів важливі мультиплатформні образи.

Докладніше в документації: Docker: альтернативні середовища виконання

Офіційний образ PHP постачається без php.ini - діють значення за замовчуванням, розраховані на розробку. Для продакшену конфігурацію треба задати явно.

Базова конфігурація:

FROM php:8.5-fpm

RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/app.ini $PHP_INI_DIR/conf.d/zz-app.ini
; docker/php/app.ini
expose_php = Off
memory_limit = 256M
upload_max_filesize = 20M
post_max_size = 25M
max_execution_time = 30
date.timezone = Europe/Kyiv

opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.jit = tracing
opcache.jit_buffer_size = 64M

Ключові рішення для контейнера:

  • opcache.validate_timestamps = 0 - PHP не перевіряє зміну файлів на кожен запит. У контейнері код незмінний (новий деплой = новий контейнер), тож перевірка - марна робота. Але для розробки з bind mount потрібне 1, інакше зміни коду не видно;
  • opcache.max_accelerated_files - більше за кількість PHP-файлів проєкту з vendor (у Laravel-проєкті легко десятки тисяч);
  • preload (opcache.preload) - завантажити класи фреймворку в пам'ять при старті FPM. Дає приріст, але вимагає перезапуску при зміні коду й акуратного вибору файлів;
  • JIT помітно допомагає обчислювальним задачам; для типового вебзастосунку, що чекає на базу, ефект невеликий.

PHP-FPM:

; www.conf
pm = static            ; передбачуване використання пам'яті в контейнері з лімітом
pm.max_children = 20   ; ≈ ліміт пам'яті контейнера / пам'ять одного воркера
pm.max_requests = 500  ; перезапуск воркера - захист від витоків пам'яті

У контейнері з жорстким лімітом пам'яті pm = dynamic з великим max_children може перевищити ліміт під навантаженням - і OOM-killer вб'є процеси.

Логи: FPM і PHP мають писати в stderr/stdout (error_log = /proc/self/fd/2, catch_workers_output = yes), щоб їх збирав Docker.

Розділення середовищ: однаковий образ для всіх середовищ, а відмінності (рівень логування, Xdebug) - через змінні оточення чи окрему стадію збирання для розробки, а не різні Dockerfile.

Часовий пояс: date.timezone у PHP і змінна TZ разом з пакетом tzdata в образі - інакше логи, планувальник і дати застосунку можуть «з'їжджати» на UTC.

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

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

Рівні
Junior 35 Middle 35 Senior 30

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