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

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

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

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

Офіційний образ 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.

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

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: драйвери зберігання

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