Питання на співбесіді з 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 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і сумісний формат задач; - сесії й кеш у пам'яті контейнера - губляться при заміні.
Перевірки стану відповідають на різні питання, і оркестратор реагує на них по-різному.
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 перевіряють командою.
Поширена помилка - міграції в команді старту контейнера:
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), інакше застосунок «зависне» на час міграції.
Відкат: відкат коду не відкочує базу. План має враховувати, що попередня версія коду працюватиме з новою схемою - ще одна причина для сумісних міграцій.
Резервна копія перед міграцією на продакшені - обов'язковий крок для ризикованих змін.
Горизонтальне масштабування - запустити кілька однакових контейнерів і розподіляти між ними запити. Працює, лише якщо контейнер без стану (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 і окремими контейнерами для воркерів і планувальника - класична конфігурація, що масштабується.
Проблема: коли змінюється 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 недетермінований: збирання не повинно від нього залежати для правильності - лише для швидкості.
Сервери на 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 чи системних бібліотек перевипускає образ під тим самим тегом.
Що відбувається на практиці:
Dockerfileне змінювався місяць;- CI збирає образ заново (новий коміт у застосунку чи очищений кеш);
FROM php:8.5-fpm-alpineтепер означає новий патч PHP і нові версії системних бібліотек (наприклад, ICU, OpenSSL);- застосунок, що працював, починає падати - сегментаційні помилки в розширенні, зміни поведінки форматування, несумісність бібліотеки.
Найгірше - збій не пов'язаний зі змінами в коді: «ми нічого не міняли, а продакшен упав після деплою». Відкат коду не допомагає, бо старий коміт збирається з тим самим новим базовим образом.
Рівні фіксації:
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 для каталогів запису).
«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.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії