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

Middle: питання на співбесіді з теми «BuildKit і збирання образів»

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

5 питань

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

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