Питання на співбесіді: Образи й Dockerfile
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
15 питань
- Образ (image) - незмінний шаблон: файлова система з усім потрібним (ОС-шар, PHP, розширення, код, залежності) і метадані - яку команду запускати, які порти слухати. Збирається з
Dockerfile, зберігається в реєстрі (Docker Hub, GHCR). - Контейнер - запущений екземпляр образу: ізольований процес з власною файловою системою, мережею й обмеженнями ресурсів.
Аналогія: образ - як клас, контейнер - як об'єкт. З одного образу можна запустити скільки завгодно контейнерів.
docker build -t myapp:1.4 . # зібрати образ
docker run -d --name web myapp:1.4 # запустити контейнер
docker ps # запущені контейнери
docker images # локальні образи
Шари. Образ складається з шарів тільки для читання - по одному на інструкцію Dockerfile, що змінює файли. Контейнер додає зверху тонкий записуваний шар. Тому:
- десять контейнерів з одного образу ділять його шари й не займають десятикратно більше місця;
- зміни, зроблені всередині контейнера, зникають разом із ним. Дані, що мають жити довше (база, завантажені файли), зберігають у томах (volumes).
Практичне правило: контейнер має бути «одноразовим» - його можна знищити й створити заново з того ж образу без втрати даних. Змінити застосунок - це зібрати новий образ, а не зайти в контейнер і поправити файл.
Кожна інструкція Dockerfile (RUN, COPY, ADD...) створює шар. Під час повторного збирання Docker перевіряє, чи змінилася інструкція та файли, що в неї входять. Якщо ні - бере шар з кешу. Щойно один шар змінився, усі наступні збираються заново.
Погано:
COPY . /var/www
RUN composer install --no-dev
Будь-яка зміна в коді (навіть README) інвалідує COPY ., і composer install виконується щоразу - хвилини на кожне збирання.
Добре: спершу те, що змінюється рідко, потім - часто:
COPY composer.json composer.lock /var/www/
RUN composer install --no-dev --no-scripts --no-autoloader
COPY . /var/www
RUN composer dump-autoload --optimize
Тепер залежності перевстановлюються лише при зміні composer.json/composer.lock, а зміна коду перебудовує тільки останні шари.
Ще правила:
.dockerignore- щоб у контекст збирання не потраплялиvendor/,node_modules/,.git/,.env, логи. ІнакшеCOPY .інвалідується через файли, що до застосунку не мають стосунку, а секрети можуть потрапити в образ.- Встановлення пакетів ОС - на початку, одним
RUN, разом з очищенням кешу менеджера пакетів. - Кеш-монтування BuildKit зберігає кеш Composer чи npm між збираннями навіть після інвалідації шару:
RUN --mount=type=cache,target=/root/.composer/cache composer install
- У CI кеш зникає з кожною новою машиною - його експортують у реєстр (
--cache-to/--cache-from).
Обидві інструкції додають файли в образ, але ADD робить більше, і саме це робить його менш передбачуваним.
COPY - просто копіює файли й каталоги з контексту збирання (або з іншої стадії --from=):
COPY composer.json composer.lock ./
COPY --chown=www-data:www-data . /var/www/html
ADD уміє ще й:
- завантажувати файли за URL:
ADD https://example.com/app.tar.gz /tmp/; - автоматично розпаковувати локальні архіви (
.tar,.tar.gz,.tar.xz) у каталог призначення; - клонувати Git-репозиторії:
ADD https://github.com/org/repo.git#v1.2 /src.
Чому за замовчуванням - COPY:
- передбачуваність:
COPY archive.tar.gz /app/копіює архів як файл.ADDз тим самим рядком розпакує його - поведінка залежить від типу файлу; - кеш: для URL Docker мусить завантажити файл, щоб перевірити, чи він змінився;
- безпека: завантаження з інтернету без перевірки вмісту - ризик ланцюжка постачання.
Коли ADD доречний:
- розпакувати локальний архів одним кроком (наприклад, бінарник з архіву поруч із Dockerfile);
- завантажити файл із перевіркою контрольної суми - сучасний
ADDпідтримує--checksum=sha256:..., тож вміст гарантовано той, що очікувався; - Git-репозиторій як джерело без окремого
git cloneв образі.
Корисні параметри обох інструкцій:
--chown=user:group- власник файлів одразу при копіюванні. ОкремийRUN chown -RпісляCOPYстворює ще один шар з копією всіх файлів - образ подвоюється в розмірі;--chmod=755- права доступу;--link- копіювання незалежним шаром, який не інвалідується при зміні попередніх шарів (корисно для фінальних стадій).
Що потрапляє в контекст визначає .dockerignore - без нього COPY . . скопіює .git, node_modules, .env і локальні логи.
Правило Docker Best Practices: COPY для локальних файлів; ADD - лише коли потрібні його додаткові можливості, і то свідомо.
Образ - це набір шарів, і кожна інструкція Dockerfile, що змінює файлову систему (RUN, COPY, ADD), створює новий шар. Розмір образу - сума шарів.
Історія шарів:
docker image history myapp:latest
IMAGE CREATED BY SIZE
a1b2c3d4e5f6 COPY . /var/www/html 48MB
<missing> RUN composer install --no-dev 62MB
<missing> RUN apt-get update && apt-get install ... 310MB
<missing> FROM php:8.5-fpm ...
Видно, яка інструкція скільки додала. --no-trunc показує повні команди.
Метадані образу:
docker image inspect myapp:latest
Змінні оточення, ENTRYPOINT/CMD, відкриті порти, мітки, архітектура (amd64/arm64), кількість шарів.
Що всередині файлової системи:
docker run --rm -it myapp:latest sh # зайти й подивитися
docker run --rm myapp:latest du -sh /var/www/html/* | sort -h
Інструмент dive показує вміст кожного шару і файли, які були додані в одному шарі й видалені в наступному (марнування місця).
Типові причини великих образів:
- кеш менеджерів пакетів:
apt-get installбезrm -rf /var/lib/apt/lists/*в тому самомуRUN; - інструменти збирання у фінальному образі: компілятори,
git,node_modulesдля збирання фронтенду - їх прибирає multi-stage build; - зайве в контексті:
.git, тести, локальні файли - потрібен.dockerignore; chown -Rокремою інструкцією - копія всіх файлів у новому шарі;- важкий базовий образ - повний Debian замість
slim.
Чому docker image ls і реальний розмір відрізняються: спільні шари базового образу зберігаються на диску один раз для всіх образів, що на ньому побудовані. docker system df -v показує, скільки займають шари насправді.
Розмір має значення: час завантаження при деплої й масштабуванні, місце в реєстрі, поверхня атаки (кожен зайвий пакет - потенційна вразливість).
Офіційний образ php має кілька варіантів - вони відрізняються тим, як запускається PHP:
| Тег | Що всередині | Для чого |
|---|---|---|
php:8.5-cli |
лише PHP CLI | команди, воркери черг, скрипти |
php:8.5-fpm |
PHP-FPM (FastCGI) | за Nginx/Caddy, класичний веб-стек |
php:8.5-apache |
Apache з mod_php |
простий «все в одному» веб-сервер |
php:8.5-zts |
збірка з потокобезпекою | розширення на кшталт parallel, вбудовування |
Кожен варіант має основу Debian (за замовчуванням) або Alpine (php:8.5-fpm-alpine) - менший розмір, але інша бібліотека C (musl), що інколи дає несумісності.
Альтернатива для Laravel - образи FrankenPHP (dunglas/frankenphp): сучасний сервер на основі Caddy з вбудованим PHP, HTTPS і режимом воркера для Octane - один процес замість зв'язки Nginx + PHP-FPM.
Встановлення розширень - допоміжні скрипти офіційного образу:
FROM php:8.5-fpm
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev libzip-dev libicu-dev \
&& docker-php-ext-install pdo_pgsql zip intl opcache \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& rm -rf /var/lib/apt/lists/*
docker-php-ext-install- розширення з вихідних кодів PHP (pdo_mysql,intl,zip,gd,opcache...);docker-php-ext-configure- параметри перед збиранням (наприклад,gd --with-jpeg --with-freetype);pecl install+docker-php-ext-enable- розширення з PECL (redis,xdebug,imagick);- системні бібліотеки (
libpq-dev,libzip-dev) треба встановити самостійно - їх назви різняться в Debian і Alpine.
Простіше - install-php-extensions (проєкт mlocati): сам встановлює потрібні системні залежності для обох дистрибутивів:
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_pgsql intl zip redis opcache
Конфігурація PHP: готові шаблони php.ini-production / php.ini-development лежать у $PHP_INI_DIR; власні налаштування - окремим файлом у $PHP_INI_DIR/conf.d/.
Пастка: інструменти збирання (*-dev пакети, компілятори) лишаються в образі - їх прибирають у тому самому RUN чи через multi-stage build.
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) і зникає разом з контейнером.
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.