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