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