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

Питання на співбесіді: BuildKit і збирання образів

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

14 питань

BuildKit - рушій збирання образів, що замінив старий (legacy) збирач. У Docker Engine він використовується за замовчуванням з версії 23.0 (у Docker Desktop - ще раніше), тож у сучасному Docker docker build - це вже BuildKit.

Що він змінив:

1. Паралельне збирання. BuildKit будує граф залежностей між етапами й виконує незалежні частини одночасно. У multi-stage збиранні етапи для Composer і для npm виконуються паралельно, а не по черзі.

2. Пропуск непотрібних етапів. Етапи, від яких не залежить цільовий етап, не збираються взагалі. Старий збирач виконував усі етапи послідовно.

3. Розумніший кеш:

  • cache mounts (RUN --mount=type=cache) - кеш пакетних менеджерів між збираннями, не потрапляючи в шари образу;
  • експорт і імпорт кешу в реєстр, локальний каталог чи кеш GitHub Actions - корисно для CI, де кожне збирання на «чистій» машині.

4. Секрети й SSH під час збирання - --mount=type=secret, --mount=type=ssh: доступ до приватних репозиторіїв і токенів без потрапляння в шари.

5. Мультиплатформні образи через docker buildx - одна команда збирає образ для amd64 і arm64.

6. Сучасний синтаксис Dockerfile: heredoc-и в RUN і COPY, COPY --link, COPY --chmod, перевірки (docker build --check).

7. Атестації - SBOM і provenance прикріплюються до образу під час збирання.

docker build і docker buildx build: у сучасному Docker docker build - псевдонім для docker buildx build з налаштованим за замовчуванням збирачем. buildx дає змогу створювати окремі збирачі (наприклад, драйвер docker-container для мультиплатформних збирань і експорту кешу).

Корисний рядок на початку Dockerfile:

# syntax=docker/dockerfile:1

Він каже BuildKit завантажити актуальну стабільну версію фронтенду Dockerfile - нові можливості синтаксису доступні без оновлення самого Docker.

Вивід збирання у BuildKit компактний; для повного логу кожного кроку - --progress=plain.

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

Контекст збирання - набір файлів, які Docker передає збирачу. У команді docker build . крапка - це контекст: поточний каталог з усім вмістом. Інструкції COPY і ADD бачать лише файли з контексту.

Проблема без .dockerignore: у контекст потрапляє все - vendor, node_modules, .git, логи, локальна база SQLite, .env:

  • повільне збирання - сотні мегабайтів передаються збирачу щоразу;
  • ламається кеш: COPY . . вважає зміненими будь-які файли, включно з логами й .git, - шар і всі наступні збираються заново;
  • витік секретів: .env з паролями чи ключі можуть опинитися в образі й потрапити в реєстр;
  • неправильні залежності: локальний vendor (зібраний під macOS, з dev-пакетами) перезаписує той, що встановлено в образі.

.dockerignore для Laravel-проєкту:

.git
.github
.env
.env.*
!.env.example
node_modules
vendor
public/build
public/hot
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*.php
tests
*.log
docker-compose*.yml

Синтаксис схожий на .gitignore: шаблони, ** для будь-якої глибини, ! - виняток з виключення.

Чому краще виключати, ніж копіювати вибірково: навіть з точними COPY (COPY app/ app/) контекст усе одно передається збирачу повністю - .dockerignore зменшує саму передачу.

Як перевірити розмір контексту: BuildKit показує його у виводі збирання (transferring context: 2.3MB). Сотні мегабайтів - сигнал, що щось не виключено.

Нюанси:

  • .dockerignore шукається в корені контексту (або поруч з Dockerfile у вигляді Dockerfile.dockerignore для кількох Dockerfile в одному репозиторії);
  • vendor і node_modules встановлюються всередині збирання (composer install, npm ci) - тоді вони збираються під Linux образу й лише з потрібними залежностями;
  • виключені файли недоступні навіть для COPY - якщо файл потрібен у збиранні, його не можна виключати.

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

ARG - змінна часу збирання. Доступна лише під час docker build, у запущеному контейнері її немає:

ARG PHP_VERSION=8.5
FROM php:${PHP_VERSION}-fpm-alpine

ARG APP_VERSION=dev
RUN echo "Building version $APP_VERSION"
docker build --build-arg APP_VERSION=1.4.2 .

ENV - змінна середовища образу. Діє і під час збирання (у наступних інструкціях), і в кожному запущеному контейнері:

ENV APP_ENV=production \
    PHP_OPCACHE_VALIDATE_TIMESTAMPS=0

Значення за замовчуванням можна перевизначити при запуску: docker run -e APP_ENV=staging ....

Типові поєднання:

ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}   # перенести значення збирання в середовище контейнера

Особливості ARG:

  • область видимості: ARG, оголошений до FROM, доступний лише в рядку FROM. Щоб використати його всередині етапу, треба повторити ARG PHP_VERSION після FROM;
  • кожен етап multi-stage збирання має свої ARG;
  • вбудовані змінні BuildKit: TARGETPLATFORM, TARGETARCH, BUILDPLATFORM - для мультиплатформних збирань.

Головна пастка - секрети:

ARG GITHUB_TOKEN
RUN composer config github-oauth.github.com $GITHUB_TOKEN && composer install

Значення ARG зберігається в історії образу (docker history показує команди з підставленими значеннями), а ENV - ще й у метаданих образу й кожному контейнері. Будь-хто з доступом до образу прочитає секрет. docker build --check попереджає про це правилом SecretsUsedInArgOrEnv. Для секретів - RUN --mount=type=secret.

Що куди класти:

  • ARG - параметри збирання: версії базових образів і інструментів, прапорці («встановити розширення для розробки»), версія релізу;
  • ENV - налаштування середовища, однакові для всіх запусків образу (шляхи, параметри PHP);
  • конфігурація конкретного середовища (база, ключі, URL) - не в образ, а при запуску: змінні оточення, .env, секрети оркестратора. Один образ має працювати і на staging, і на продакшені.

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

Офіційні PHP-образи мають варіанти на Alpine Linux (php:8.5-fpm-alpine) і на Debian (php:8.5-fpm на повному Debian, а для інших технологій часто ще й -slim).

Alpine:

  • маленький розмір - базова система кілька мегабайтів, образ PHP - десятки мегабайтів замість сотень;
  • менше пакетів - менше потенційних вразливостей у сканері;
  • але інша стандартна бібліотека C - musl замість glibc.

Наслідки musl, через які обирають Debian:

  • бінарні пакети під glibc не працюють: готові збірки деяких розширень, інструментів (наприклад, частина бінарників для Node.js-модулів, браузери для генерації PDF) - доводиться збирати з вихідного коду, і збирання стає довшим;
  • відмінності в поведінці: робота з DNS (наприклад, обробка search у resolv.conf), локалі, деякі функції форматування - інколи дають несподівані помилки, які важко відтворити;
  • продуктивність: у частині навантажень (виділення пам'яті, багатопотоковість) musl помітно повільніший за glibc;
  • налагодження: менше звичних інструментів, інша пакетна система (apk замість apt).

Debian (slim):

  • стандартний glibc - сумісність з більшістю бінарних пакетів і розширень;
  • більший розмір (але з multi-stage збиранням і чисткою - прийнятний);
  • передбачувана поведінка, звична для більшості серверних систем.

Як обрати:

  • Alpine - коли важливий розмір і застосунок не залежить від нативних бінарників під glibc; добре перевірена конфігурація;
  • Debian - коли потрібні нестандартні розширення, бінарні інструменти (wkhtmltopdf, Chromium, драйвери баз даних), коли команда стикалася з «дивними» помилками на musl;
  • distroless / мінімальні образи - для скомпільованих застосунків (Go); для PHP менш практичні.

Незалежно від вибору:

  • фіксувати версію: не php:8.5-fpm-alpine, а конкретну мінорну версію чи дайджест - інакше нове збирання тихо підтягне іншу версію PHP або системних бібліотек;
  • оновлювати свідомо й регулярно - заради виправлень безпеки;
  • не порівнювати лише розмір: різниця у 100 МБ рідко важлива, а година налагодження проблеми musl - помітна.

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

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

Як знайти, що займає місце:

docker image ls myapp                  # загальний розмір
docker history myapp:1.4.2             # розмір кожного шару й команда, що його створила
dive myapp:1.4.2                       # інтерактивний перегляд шарів і файлів

dive показує вміст кожного шару, які файли додано, змінено чи «видалено» в ньому, і оцінює «втрачене» місце.

Типові причини великого образу:

1. Видалення в окремому шарі. Шар неможливо зменшити наступним шаром - видалений файл просто приховується, але лишається в образі:

# погано: 300 МБ кешу лишаться в першому шарі
RUN apt-get update && apt-get install -y build-essential
RUN apt-get purge -y build-essential && rm -rf /var/lib/apt/lists/*

# добре: встановлення й чистка в одному RUN
RUN apt-get update \
 && apt-get install -y --no-install-recommends libzip-dev \
 && rm -rf /var/lib/apt/lists/*

2. Інструменти збирання в фінальному образі - компілятори, -dev-пакети, Composer, Node.js. Ліки - multi-stage: збирати в одному етапі, копіювати лише результат у фінальний.

3. Залежності для розробки: composer install без --no-dev, node_modules після збирання фронтенду (у фінальний образ потрібен лише public/build).

4. Зайве в контексті збирання: .git, тести, локальні vendor і node_modules через COPY . . без .dockerignore.

5. Кеші пакетних менеджерів: кеш Composer, npm, apk, apt - чистити в тому самому RUN або використовувати cache mounts (RUN --mount=type=cache), що взагалі не потрапляють в образ.

6. Рекомендовані пакети: apt-get install без --no-install-recommends тягне багато непотрібного.

7. Великий базовий образ: повний Debian замість slim/Alpine, якщо застосунку не потрібна сумісність, яку дає повний образ.

Корисні звички для PHP-образу: composer install --no-dev --optimize-autoloader --no-scripts на етапі залежностей, розширення - через docker-php-ext-install з видаленням -dev-пакетів у тому самому шарі, фронтенд - окремим етапом на образі Node.

Межа оптимізації: зменшувати розмір має сенс, доки це не шкодить читабельності Dockerfile і швидкості налагодження.

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

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

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

Збирання arm64-образу на x86-машині (чи навпаки) через емуляцію QEMU працює «прозоро», але кожна інструкція RUN виконується в емульованому процесорі. Компіляція PHP-розширень (docker-php-ext-install, pecl install), нативних npm-модулів чи npm run build може сповільнитися в 5-20 разів - збирання на хвилини перетворюється на збирання на пів години.

Варіанти прискорення:

1. Нативні збирачі для кожної архітектури. Кожна платформа збирається на своєму залізі, результати об'єднуються в один мультиплатформний образ:

  • buildx з кількома вузлами: docker buildx create --name multi --platform linux/amd64 ssh://amd-builder і --append вузол linux/arm64;
  • CI з матрицею - arm64-раннери (у GitHub Actions доступні) збирають arm64, amd64-раннери - amd64; окремий крок створює індекс (docker buildx imagetools create);
  • хмарні збирачі (Docker Build Cloud та інші) з нативними вузлами обох архітектур.

2. Виконувати незалежні від архітектури кроки на рідній платформі збирача. Збирання фронтенду дає однаковий результат (JS, CSS) для будь-якої архітектури - немає сенсу емулювати його:

# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build                      # виконується нативно один раз

FROM php:8.5-fpm-alpine                # цільова платформа
COPY --from=assets /app/public/build /var/www/html/public/build

--platform=$BUILDPLATFORM змушує етап виконуватися на архітектурі збирача.

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

Вбудовані змінні BuildKit: BUILDPLATFORM, TARGETPLATFORM, TARGETOS, TARGETARCH, TARGETVARIANT - щоб, наприклад, завантажити бінарник потрібної архітектури:

ARG TARGETARCH
RUN curl -fsSL -o /usr/local/bin/tool "https://example.com/tool-linux-${TARGETARCH}"

Пастки:

  • кеш для кожної платформи окремий - експорт кешу в CI має покривати обидві;
  • залежності Composer з нативними частинами зазвичай немає (PHP-код незалежний від архітектури), тож composer install теж можна винести на $BUILDPLATFORM (з --ignore-platform-reqs чи узгодженими розширеннями);
  • тестування образу кожної архітектури - емуляція під час збирання не гарантує, що розширення поводяться однаково.

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

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

Навіщо:

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

Що робить збирання невідтворюваним:

1. Мітки часу. Кожен файл у шарі має час зміни, а метадані образу - час створення. Два збирання з різницею в хвилину - різні дайджести.

Рішення - змінна SOURCE_DATE_EPOCH, яку BuildKit використовує як фіксований час (зазвичай - час останнього коміту):

SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) \
docker buildx build --output type=image,name=ghcr.io/acme/app:1.4.2,push=true,rewrite-timestamp=true .

rewrite-timestamp=true переписує й мітки часу файлів у шарах.

2. Плаваючі залежності:

  • базові образи без зафіксованого дайджесту;
  • apt-get install / apk add без версій - пакетні репозиторії змінюються щодня;
  • composer install / npm install без lock-файлів (або npm install замість npm ci);
  • завантаження «останньої версії» інструментів через curl.

3. Недетермінованість інструментів: генерація випадкових значень під час збирання, порядок файлів в архівах, вбудовані дати збирання у фронтенд-бандлі чи версійні файли.

4. Середовище збирання: різні версії BuildKit можуть по-різному формувати шари.

Реалістичний рівень для більшості проєктів:

  • фіксувати все, що визначає вміст (базові образи за дайджестом, lock-файли, версії інструментів);
  • фіксувати час (SOURCE_DATE_EPOCH);
  • збирати лише в CI з однаковим збирачем.

Повна побітова відтворюваність образів з пакетами Debian чи Alpine складна (пакетні репозиторії не зберігають старих версій вічно), тож часто достатньо «функціональної» відтворюваності: та сама поведінка, ті самі версії компонентів, задокументовані в SBOM і provenance-атестації.

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

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

Класичний спосіб виконати кілька команд в одному шарі - довгий ланцюжок через && і \:

RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev \
 && docker-php-ext-install intl zip pdo_pgsql \
 && pecl install redis \
 && docker-php-ext-enable redis \
 && apk del .build-deps

Важко читати, легко забути \ чи &&, неможливо коментувати окремі рядки.

Heredoc у RUN (сучасний синтаксис Dockerfile, працює з BuildKit):

# syntax=docker/dockerfile:1
RUN <<EOF
set -eux
apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev
docker-php-ext-install intl zip pdo_pgsql
# Redis з PECL
pecl install redis
docker-php-ext-enable redis
apk del .build-deps
EOF

Увесь блок виконується одним RUN - тобто одним шаром, як і ланцюжок з &&.

Важливо: set -e. У ланцюжку && помилка будь-якої команди зупиняє виконання. У heredoc команди виконуються як скрипт - без set -e збирання продовжиться після помилки, і проблему помітять лише в образі. set -eux - зупинитися на помилці (e), на невизначених змінних (u) і показувати команди у виводі (x).

Інший інтерпретатор:

RUN <<EOF python3
print("Генерація конфігурації")
EOF

Heredoc у COPY - створити файл прямо в Dockerfile без окремого файлу в репозиторії:

COPY <<EOF /usr/local/etc/php/conf.d/opcache.ini
opcache.enable=1
opcache.validate_timestamps=0
opcache.memory_consumption=256
EOF

Зручно для невеликих конфігураційних файлів, що стосуються лише образу.

Підстановка змінних: у <<EOF змінні збирання (ARG) підставляються; щоб передати текст буквально (зокрема $ для оболонки всередині), - лапки навколо маркера: <<'EOF'.

Коли heredoc не потрібен: одна-дві короткі команди читаються і в звичайному RUN. Для довгих скриптів (понад десяток рядків) краще окремий файл-скрипт у репозиторії, який можна перевірити ShellCheck і викликати з RUN.

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

OCI (Open Container Initiative) - відкриті специфікації формату образів, середовища запуску й протоколу реєстрів. Завдяки ним образ, зібраний Docker, запускається в containerd, Podman, Kubernetes, а реєстри (Docker Hub, GHCR, ECR) взаємозамінні.

З чого складається образ:

  • шари - архіви змін файлової системи, кожен адресується дайджестом свого вмісту;
  • конфігурація - JSON з налаштуваннями запуску (CMD, ENTRYPOINT, ENV, користувач, порти) і історією збирання;
  • маніфест - перелік: яка конфігурація й які шари складають образ, з їхніми дайджестами;
  • індекс (image index, раніше manifest list) - для мультиплатформних образів: посилання на маніфести для кожної архітектури, а також на атестації (SBOM, provenance).

Дайджест образу - це дайджест маніфесту (чи індексу), тож він однозначно визначає весь вміст.

Мітки (labels) і анотації - метадані образу:

LABEL org.opencontainers.image.source="https://github.com/acme/app" \
      org.opencontainers.image.revision="a1b2c3d" \
      org.opencontainers.image.version="1.4.2" \
      org.opencontainers.image.licenses="MIT"
  • labels зберігаються в конфігурації образу - видно через docker inspect;
  • анотації - поля в маніфесті чи індексі; їх читають реєстри й інструменти, не завантажуючи весь образ:
docker buildx build --annotation "index:org.opencontainers.image.source=https://github.com/acme/app" ...

Навіщо стандартні ключі org.opencontainers.image.*:

  • source - GHCR автоматично пов'язує пакет образу з репозиторієм GitHub, сканери вразливостей і Renovate знаходять, звідки образ;
  • revision, version, created - з образу видно, який коміт і яка версія в ньому, без зовнішніх записів;
  • licenses, vendor, title, description - для каталогів і перевірок відповідності.

Генерувати метадані в CI зручно дією docker/metadata-action: вона створює теги (за гілкою, SHA, версією) і стандартні мітки з контексту репозиторію.

Практичне значення для співбесіди: розуміти, що тег - лише посилання на маніфест, дайджест - незмінний ідентифікатор, мультиплатформний образ - індекс з кількома маніфестами, а атестації - теж частина індексу. Це пояснює більшість «дивних» ситуацій: чому docker pull на Mac і на сервері дає різні дайджести того самого тегу, чому образ з атестаціями показує в реєстрі «unknown/unknown» платформу, і чому видалення тегу не видаляє образ.

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