Введение
В Kubernetes-кластере рано или поздно встаёт вопрос: где собирать Docker image приложений? Вариант «на своей машине разработчика» не масштабируется на команду. Вынос сборок на отдельную виртуальную машину решает эту проблему, но создаёт накладные расходы на обслуживание инфраструктуры и лишает ключевых преимуществ k8s: отдельная ВМ не масштабируется горизонтально под нагрузку, параллельные джобы конкурируют за общие CPU, RAM и диск, а накапливающийся кэш требует регулярной очистки.
Kubernetes executor с Kaniko, BuildKit или Buildah лишён этих недостатков: сборка происходит в изолированных подах прямо на нодах кластера, ресурсы динамически масштабируются, а виртуальные машины для Docker-демона больше не требуются.
Kaniko (GoogleContainerTools/kaniko) — инструмент от Google для сборки без privileged-контейнера. С июня 2025 года репозиторий архивирован и проект больше не развивается.
BuildKit (moby/buildkit) — стандартный движок
docker build, работающий в k8s в daemonless и rootless-режиме без привилегий ноды.Buildah (containers/buildah) — daemonless-сборка OCI-образов (
buildah bud) без Docker-демона. В этом стенде — образquay.io/buildah/stable:v1.43.2, rootlessbudс--layersи registry-кэшем (--cache-from/--cache-to).
В этой статье будет протестировано 5 проектов разных языков и фреймворков, которые собираются тремя инструментами в одних и тех же условиях, с замером времени, потребления CPU/RAM и поведения кэша. В конце — итоговая сводная таблица и разбор преимуществ и недостатков каждого подхода для продакшна.
DinD не используется. Docker-in-Docker требует privileged = true. В GitLab Runner (kubernetes executor) флаг privileged задаётся на уровне раннера, а не джоба: для DinD пришлось бы заводить отдельный GitLab Runner с privileged = true и другим тегом. Текущий раннер k8s-benchmark держит privileged = false — иначе условия замеров Kaniko / BuildKit / Buildah ломаются. Стенд выбран как раз ради сборки без привилегий.
Кэш. Все три инструмента используют только registry-кэш. Локальный кэш на ноде намеренно не используется — он копится на диске и требует очистки. Registry-кэш чистить не нужно: манифест кэша перезаписывается на каждом прогоне, а мусор подчищает garbage collection реестра. Хранясь вне пода, он переживает пересоздание и смену ноды.
Android. APK не закатывается в image и не загружается в registry: после assembleRelease в Dockerfile выполняется RUN rm APK, финальный image не пушится (--no-push / push=false / без --push). Registry-кэш слоёв при этом остаётся. Замеряем только build, не скорость загрузки APK в registry.
Сравниваемые проекты
Бенчмарк собирает 5 проектов — по одному на характерный «профиль сборки»:
№ | Проект | Язык/Framework | Профиль сборки | Репозиторий |
|---|---|---|---|---|
1 | Next.js | Node/React SSR |
| |
2 | Nuxt 3 | Node/Vue SSR |
| |
3 | Go HTTP-сервис | Go |
| |
4 | Android APK | Java/Kotlin, Gradle |
| |
5 | ML: PyTorch inference | Python |
|
Слои без privileged. Kaniko не монтирует overlay — распаковывает base-образ и делает userspace-снапшот после каждой инструкции. BuildKit rootless складывает overlay в user namespace, /dev/fuse не нужен. Buildah собирает образ как контейнер и обязан монтировать слои в одно дерево: без CAP_SYS_ADMIN kernel overlay недоступен, fallback — fuse-overlayfs, которому нужен /dev/fuse. В поде раннера его нет, поэтому rootless Buildah падает с device /dev/fuse not found. Обход без privileged — смонтировать /dev/fuse или STORAGE_DRIVER=vfs (в стенде задан vfs).
Архитектура стенда

Развёртывание
Перед развертыванием gitlab runner требуется чтобы у вас был создан Kubernetes кластер, S3 бакет и Container Registry.
В S3 бакет заливаем файл весов, например pytorch_model для job ml-pytorch.
Для мониторинга устанавливаем VictoriaMetrics k8s-stack.
1a. Установка GitLab Runner
Устанавливаем GitLab Runner с такой конфигурацией values.yaml:
gitlabUrl: https://gitlab.com/ # Количество параллельно выполняемых джобов. Тройка kaniko+buildkit+buildah # одного проекта запускается одновременно (3 джоба), поэтому 3 достаточно. concurrent: 3 # RBAC для создания/управления подами джобов. rbac: create: true rules: [] serviceAccount: create: true runners: executor: kubernetes # Тег, по которому джобы в .gitlab-ci.yml выбирают этот раннер. tags: "k8s-benchmark" # Раннер принимает только джобы с указанным тегом. runUntagged: false # Глобальный конфиг executor'а. Задаём лимиты build-контейнера # (те же 4 CPU / 4 GiB, что были у старых K8s-джобов бенчмарка). config: | [[runners]] request_concurrency = 3 [runners.kubernetes] namespace = "{{ .Release.Namespace }}" image = "alpine:3.20" cpu_request = "1" cpu_limit = "4" memory_request = "1Gi" memory_limit = "12Gi" helper_cpu_request = "100m" helper_cpu_limit = "500m" helper_memory_request = "128Mi" helper_memory_limit = "512Mi" # Build-контейнер работает без privileged (условия замеров уравнены # с прежним стендом). BuildKit в rootless-режиме требует ослабленный # securityContext: seccomp/apparmor Unconfined (нужен unshare mount ns). privileged = false allow_privilege_escalation = false [runners.kubernetes.build_container_security_context] [runners.kubernetes.build_container_security_context.seccomp_profile] type = "Unconfined" [runners.kubernetes.build_container_security_context.app_armor_profile] type = "Unconfined" # Имена подов джобов важны для Grafana: GitLab Runner включает в них # GitLab project ID (runner-…-project-<ID>-concurrent-…), по которому # дашборд различает проекты, а инструменты различаются по label `image` # метрик cAdvisor (…/kaniko… vs …/buildkit… vs …/buildah…). pull_policy = "if-not-present" resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi
2. Настройка переменных GitLab CI
В группе gitlab.com/buildkit-vs-kaniko-benchmark → Settings → CI/CD → Variables задать необходимо задать YCR_REGISTRY_ID.
3. Настройка GitLab Runner для push в Yandex Container Registry
Для авторизации и пуша собранных образов в YCR не используются статические токены, пароли или секреты, сохранённые в репозитории:
Сервисный аккаунт нод кластера (
node_service_account): При развёртывании инфраструктуры через Terraform сервисному аккаунту нод кластера (sa_k8s_node) назначаются ролиcontainer-registry.images.pusherиcontainer-registry.images.pullerна созданный реестр (см.registry.tf). Поды GitLab Runner запускаются на этих нодах и имеют сетевой доступ к сервису метаданных инстанса.Получение короткоживущего IAM-токена из метаданных ноды: В секции
before_scriptкаждого CI-джоба выполняется запрос к сервису метаданных ноды по адресу169.254.169.254(интерфейс метаданных Google Compute Engine):TOKEN=$(wget -q -O - --header="Metadata-Flavor: Google" \ "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token" \ | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')Токен генерируется платформой Yandex Cloud на базе привязанного к ноде сервисного аккаунта, действует ~12 часов и обновляется платформой автоматически.
Формирование конфигурации Docker (
config.json): Для аутентификации в реестреcr.yandexформируется заголовок с пользователемiamи полученным токеном в качестве пароля:AUTH=$(printf "iam:%s" "$TOKEN" | base64 | tr -d '\n') # Для Kaniko (/kaniko/.docker/config.json): mkdir -p /kaniko/.docker echo "{\"auths\":{\"$YCR_REGISTRY\":{\"auth\":\"$AUTH\"}}}" > /kaniko/.docker/config.json # Для BuildKit (~/.docker/config.json): mkdir -p ~/.docker echo "{\"auths\":{\"$YCR_REGISTRY\":{\"auth\":\"$AUTH\"}}}" > ~/.docker/config.json # Для Buildah (/home/build/.docker/config.json): mkdir -p /home/build/.docker echo "{\"auths\":{\"$YCR_REGISTRY\":{\"auth\":\"$AUTH\"}}}" > /home/build/.docker/config.jsonУтилиты сборки (Kaniko, BuildKit и Buildah) прозрачно считывают этот конфигурационный файл и аутентифицируются в реестре без необходимости хранить постоянные учетные данные. В образе Buildah нет
wget— IAM-токен там берётся черезcurl.
4. Перенос проектов в репозитории
Эталонный .gitlab-ci.yml (одинаков для nextjs / nuxtjs / golang / ml-pytorch; $CI_PROJECT_NAME автоматически подставляет имя репозитория). У android тот же набор джобов, но финальный image не пушится (см. ниже).
variables: # cr.yandex — верный хост Yandex Container Registry # (registry.yandex.cloud не существует в DNS). YCR_REGISTRY: cr.yandex stages: - build before_script: &docker-auth # Короткоживущий IAM-токен из метаданных ноды -> docker config для push/pull. - mkdir -p "$DOCKER_CONFIG" - TOKEN=$(wget -q -O - --header="Metadata-Flavor: Google" "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') - AUTH_B64=$(printf 'iam:%s' "$TOKEN" | base64 | tr -d '\n') - printf '{"auths":{"%s":{"auth":"%s"}}}' "$YCR_REGISTRY" "$AUTH_B64" > "$DOCKER_CONFIG/config.json" kaniko-build: stage: build image: gcr.io/kaniko-project/executor:v1.23.2-debug variables: DOCKER_CONFIG: /kaniko/.docker script: - /kaniko/executor --dockerfile=Dockerfile --context=dir://$CI_PROJECT_DIR --destination="$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-kaniko:latest" --cache=true --cache-repo="$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-kaniko" buildkit-build: stage: build image: moby/buildkit:v0.32.2-rootless variables: DOCKER_CONFIG: /home/user/.docker XDG_RUNTIME_DIR: /tmp/buildkit BUILDKITD_FLAGS: --oci-worker-no-process-sandbox script: - buildctl-daemonless.sh build --frontend dockerfile.v0 --local "context=$CI_PROJECT_DIR" --local "dockerfile=$CI_PROJECT_DIR" --output "type=image,name=$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-buildkit:latest,push=true" --import-cache "type=registry,ref=$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-buildkit" --export-cache "type=registry,ref=$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-buildkit,mode=max" buildah-build: stage: build image: quay.io/buildah/stable:v1.43.2 variables: DOCKER_CONFIG: /home/build/.docker REGISTRY_AUTH_FILE: /home/build/.docker/config.json BUILDAH_ISOLATION: chroot STORAGE_DRIVER: vfs before_script: - export HOME=/home/build - mkdir -p "$DOCKER_CONFIG" - TOKEN=$(curl -sS -H "Metadata-Flavor: Google" "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') - AUTH_B64=$(printf 'iam:%s' "$TOKEN" | base64 | tr -d '\n') - printf '{"auths":{"%s":{"auth":"%s"}}}' "$YCR_REGISTRY" "$AUTH_B64" > "$DOCKER_CONFIG/config.json" script: - buildah bud --layers --cache-from "$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-buildah" --cache-to "$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-buildah" -t "$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-buildah:latest" . - buildah push "$YCR_REGISTRY/$YCR_REGISTRY_ID/$CI_PROJECT_NAME-buildah:latest"
--layers у Buildah обязателен: без него --cache-from / --cache-to игнорируются.
Buildah разделяет сборку и доставку на две команды (buildah bud → buildah push) из-за своей архитектуры, отличной от Kaniko. Kaniko — специализированный сборщик: у него нет локального хранилища образов, и он стримит слои напрямую в registry через --destination. Buildah — daemonless-инструмент общего назначения: buildah bud только собирает образ в локальное контейнерное хранилище (в этом стенде — vfs, STORAGE_DRIVER=vfs) и никуда его не отправляет; перенос готового образа в registry — отдельный шаг buildah push.
У android destination/push финального image отключается, registry-кэш слоёв остаётся:
Kaniko:
--no-pushBuildKit:
push=falseBuildah: без
buildah push
Ослабленный securityContext для rootless BuildKit (seccompProfile: Unconfined, appArmorProfile: Unconfined) задаётся на уровне раннера в gitlab-runner/values.yaml (build_container_security_context) — в .gitlab-ci.yml его прописывать не нужно.
5. Дашборд в Grafana
Дашборд: kaniko-vs-buildkit-per-project.json — импортируйте в Grafana вручную (Dashboards → Import → Upload JSON).
Скриншоты дашборда
Скриншоты сняты на тёплом прогоне (с прогретым registry-кэшем) — сравнение Kaniko, BuildKit и Buildah в одинаковых условиях кэш-хита.
Next.js — потребление CPU (Kaniko, BuildKit, Buildah), тёплый кэш.

Next.js — потребление памяти, тёплый кэш.

Nuxt 3 — потребление CPU (Kaniko, BuildKit, Buildah), тёплый кэш.

Nuxt 3 — потребление памяти, тёплый кэш.

Go HTTP-сервис — потребление CPU (Kaniko, BuildKit, Buildah), тёплый кэш.

Go HTTP-сервис — потребление памяти, тёплый кэш.

Android APK — потребление CPU (Kaniko, BuildKit, Buildah), тёплый кэш.

Android APK — потребление памяти, тёплый кэш.

ML: PyTorch inference — потребление CPU (Kaniko, BuildKit, Buildah), тёплый кэш.

ML: PyTorch inference — потребление памяти, тёплый кэш.

Итоговые сводные таблицы
Цифры ниже — тёплый прогон (с прогретым registry-кэшем) тройки Kaniko / BuildKit / Buildah по всем пяти проектам. Время сборки = длительность job в GitLab; CPU/RAM сняты cAdvisor'ом (VictoriaMetrics) с build-контейнеров в namespace gitlab-runner.
Время сборки (тёплый кэш)
Проект | Kaniko (с) | BuildKit (с) | Buildah (с) | Выигрыш BuildKit vs Kaniko % | Выигрыш BuildKit vs Buildah % |
|---|---|---|---|---|---|
nextjs | 74 | 14 | 287 | 81 | 95 |
nuxtjs | 52 | 14 | 192 | 73 | 93 |
golang | 31 | 13 | 237 | 58 | 95 |
android | 90 | 10 | 21 | 89 | 53 |
ml-pytorch | 295 | 15 | 536 | 95 | 97 |
CPU и RAM (тёплый кэш)
Проект | CPU kaniko (cores) | CPU buildkit (cores) | CPU buildah (cores) | RAM kaniko | RAM buildkit | RAM buildah |
|---|---|---|---|---|---|---|
nextjs | 0.47 | 0.01 | 0.26 | 2.03 GiB | 3.2 MiB | 5.30 GiB |
nuxtjs | 0.36 | 0.02 | 0.28 | 912 MiB | 3.5 MiB | 4.00 GiB |
golang | 0.23 | 0.01 | 0.25 | 124 MiB | 3.1 MiB | 4.19 GiB |
android | 0.64 | 0.01 | 0.25 | 1.18 GiB | 3.5 MiB | 108 MiB |
ml-pytorch | 0.64 | 0.01 | 0.26 | 10.88 GiB | 3.2 MiB | 5.56 GiB |
Вывод
На тёплом кэше BuildKit почти обнуляет работу: ~0.01 CPU и ~3 MiB RAM (уровень простоя контейнера) на всех пяти проектах — слои берутся из registry, локальной сборки нет. Отсюда и время: 10–15 с независимо от профиля.
Kaniko на том же кэше продолжает разворачивать слои и жечь ресурсы: CPU 0.23–0.64 cores, working set от 124 MiB (golang) до 10.88 GiB (ml-pytorch), время — 31–295 с. Выигрыш BuildKit по времени — 58–95 %.
Buildah (rootless bud --layers c --cache-from/--cache-to, storage-драйвер vfs) попадает в зависимость от профиля: на android — 21 с (кэш слоёв Gradle-сборки), на Node/ML — 192–536 с, CPU 0.25–0.28 cores. Большой working set (4–5.6 GiB) — следствие vfs: каждый слой копируется в отдельное дерево вместо overlay-mount. На «тяжёлых» Node/ML-профилях Buildah заметно проигрывает BuildKit и по времени (разрыв до 97 %), и по памяти.
Итог: для сборки в кластере без privileged и с registry-кэшем BuildKit выигрывает по всем осям — минимальные CPU/RAM и стабильно малое время на тёплом кэше. Kaniko держит больший working set и дольше «додумывает» слои на кэш-хите. Buildah с vfs — самый тяжёлый по памяти и сильно зависит от профиля сборки.