Senior: питання на співбесіді з теми «BuildKit і збирання образів»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Збирання 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» платформу, і чому видалення тегу не видаляє образ.