Корень боли: почему docker in docker - это мина
Сначала разберём, откуда вообще берётся проблема, иначе решения покажутся магией. Классический docker build требует общения с демоном. В Kubernetes-раннере демона нет, и появляются два костыля - оба плохие.
Первый - docker in docker (DinD). Ты запускаешь внутри пода ещё один dockerd. Чтобы вложенный демон смог управлять namespaces, монтировать оверлей-файловую систему и создавать сетевые мосты, контейнеру нужен флаг privileged. А privileged-контейнер - это, грубо, рут на ноде. Он видит все устройства хоста через /dev, может перемонтировать /sys, выбраться к cgroup ноды и при удачном стечении - к соседним подам. Один скомпрометированный Dockerfile в одном проекте - и атакующий гуляет по кластеру. Для multi-tenant CI это приговор.
Второй костыль - пробросить хостовый /var/run/docker.sock внутрь пода (docker outside of docker). Звучит легче, но это ещё хуже. Сокет демона - это API без аутентификации с правами рута. Любой, кто дотянулся до docker.sock, может запустить контейнер с монтированием хостовой / и стать рутом на ноде за три команды. Плюс ты собираешь образы общим демоном вперемешку с чужими задачами - изоляции ноль.
Корень в том, что для сборки слоёв исторически нужны были рутовые операции. Прорыв последних лет в том, что благодаря user namespaces, fuse-overlayfs и rootless-режиму контейнерных рантаймов слои можно собирать БЕЗ рута и без привилегий. На этом и стоят инструменты ниже.Простое правило: если в CI всплывает privileged: true ради сборки или монтируется docker.sock - это не сборка, это дыра, которую кто-то когда-то проэксплуатирует.

kaniko: сборка из Dockerfile прямо в контейнере
kaniko появился в Google как ответ на вопрос docker in docker: как собрать образ из Dockerfile в контейнере без демона и без привилегий. Идея простая до изящества. Образ gcr.io/kaniko-project/executor запускается как обычный контейнер. Внутри он сам читает Dockerfile, сам разбирает инструкции и сам по очереди исполняет каждый слой - но не через демон, а напрямую в собственной файловой системе. После каждой инструкции (RUN, COPY, ADD) kaniko делает снимок дельты файловой системы, упаковывает её в tar - это и есть новый слой - и собирает корректный OCI-образ. Никакого dockerd, никаких namespaces от рута.
Код: Выделить всё
# .gitlab-ci.yml - сборка через kaniko
build:
stage: build
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
- >-
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
--cache=true
--cache-repo "${CI_REGISTRY_IMAGE}/cache"
Важная грабля по памяти. kaniko держит слои и снапшоты файловой системы в памяти, и на жирных образах (большие COPY, тяжёлые RUN apt install) ему легко не хватает лимита - под падает с OOMKilled. Лечится осознанно: ставь limits.memory с запасом, дроби тяжёлые RUN, и помни про --snapshot-mode=redo или --use-new-run для более экономного снапшотинга. Это не баг, это плата за подход со снимками ФС.
И теперь главное про 2026 год, иначе урок устареет на старте. В июне 2025 Google заархивировал оригинальный репозиторий GoogleContainerTools/kaniko - он стал read-only, активная разработка прекращена. У kaniko остались непропатченные дыры в зависимостях. Проект подхватил форк от Chainguard (Fork Yeah, мы возвращаем kaniko), но классический образ от Google больше не получает обновлений. Вывод для прода: kaniko как концепцию знать обязательно (его всё ещё полно в живых пайплайнах), но новые проекты на нём не начинают. Дефолтная рекомендация сообщества теперь - BuildKit rootless.
BuildKit rootless: buildkitd + buildctl как замена kaniko
BuildKit - это тот самый движок, что под капотом у docker buildx (мы разбирали его в уроках про buildx и кэш). Но BuildKit умеет работать standalone, без docker-демона: отдельно демон buildkitd и отдельно клиент buildctl. А в rootless-режиме buildkitd запускается от непривилегированного пользователя в user namespace - без privileged, без docker.sock. Это и делает связку buildkit ci прямой и более быстрой заменой kaniko: по бенчмаркам BuildKit за счёт параллельного DAG-исполнения стадий и продвинутого кэша обгоняет kaniko на десятки процентов.
Почему быстрее. kaniko исполняет Dockerfile линейно, инструкция за инструкцией. BuildKit строит граф зависимостей (DAG): независимые стадии многоступенчатой сборки идут параллельно, а неизменившиеся ноды берутся из кэша без пересчёта. Плюс mount-кэши для пакетных менеджеров (--mount=type=cache) живут между сборками.
Код: Выделить всё
# .gitlab-ci.yml - rootless BuildKit, без привилегий
build:
image:
name: moby/buildkit:rootless
entrypoint: [""]
variables:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
before_script:
- mkdir -p ~/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n')\"}}}" > ~/.docker/config.json
script:
- >-
buildctl-daemonless.sh build
--frontend dockerfile.v0
--local context=.
--local dockerfile=.
--output type=image,name=${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA},push=true
--export-cache type=registry,ref=${CI_REGISTRY_IMAGE}/cache,mode=max
--import-cache type=registry,ref=${CI_REGISTRY_IMAGE}/cache
RU-реалия: gcr.io (где живёт kaniko) и docker.io бывают недоступны или ходят через зеркало, а moby/buildkit и cosign разумно заранее зеркалить во внутренний Yandex Container Registry или VK Cloud Container Registry, чтобы пайплайн не зависел от внешней сети. В --export-cache тогда указывай ref на свой registry.
Buildah и фундамент OCI: nerdctl, runc, containerd
Третий игрок - Buildah от Red Hat, часть набора Podman и с января 2025 в CNCF Sandbox. Buildah тоже daemonless и умеет rootless, но философия другая: это не только Dockerfile, а ещё и набор примитивов для сборки образа по шагам из shell-скрипта (buildah from, buildah run, buildah copy, buildah commit). Удобно, когда образ собирается нестандартно - без Dockerfile вообще.
Код: Выделить всё
# Buildah: и из Dockerfile, и пошагово
# 1) привычный путь - из Dockerfile
buildah build -t registry.example.ru/app:1.0 -f Dockerfile .
# 2) пошаговая сборка скриптом, без Dockerfile
ctr=$(buildah from alpine:3.20)
buildah run "$ctr" -- apk add --no-cache ca-certificates
buildah copy "$ctr" ./app /usr/local/bin/app
buildah config --entrypoint '["/usr/local/bin/app"]' "$ctr"
buildah commit "$ctr" registry.example.ru/app:1.0
buildah push registry.example.ru/app:1.0
Supply chain в пайплайне: скан, SBOM, подпись, SLSA-provenance
Собрать образ без демона - полдела. Безопасный docker ci - это ещё и доказать, что в образе нет известных дыр и что он собран именно тобой. Четыре шага после сборки.
- Скан уязвимостей - Trivy или Docker Scout прогоняют образ по базам CVE до пуша в прод-registry. trivy image --exit-code 1 --severity CRITICAL,HIGH valит пайплайн, если нашлось критичное.
- SBOM (Software Bill of Materials) - список всех пакетов и версий внутри образа. BuildKit умеет генерить его прямо на сборке через --attest type=sbom, отдельно syft тоже подойдёт.
- Подпись - cosign sign криптографически подписывает образ; в CI это делается keyless через OIDC-токен раннера (GitHub Actions, GitLab), без хранения приватных ключей. Потом cosign verify проверяет подпись перед деплоем в admission-контроллере.
- SLSA-provenance - аттестация о том, КАК собран образ: какой коммит, какой Dockerfile, какой раннер. BuildKit генерит её через --provenance=mode=max (или --attest type=provenance,mode=max), оборачивая данные в in-toto attestation формата SLSA Provenance v1.
Код: Выделить всё
# Подпись и аттестация в CI (keyless, OIDC)
COSIGN_EXPERIMENTAL=1 cosign sign --yes \
registry.example.ru/app:${CI_COMMIT_SHORT_SHA}
# Проверка перед деплоем: подпись + provenance конкретного билдера
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity-regexp '.*' \
--certificate-oidc-issuer https://gitlab.example.ru \
registry.example.ru/app:${CI_COMMIT_SHORT_SHA}
Типичные грабли и антипаттерны
- privileged: true ради сборки. Главный антипаттерн. Если видишь его в манифесте раннера - переходи на BuildKit rootless или Buildah, проблема решается без привилегий.
- Монтирование docker.sock в под. Это раздача рута на ноде. Никогда в multi-tenant.
- kaniko на новых проектах. Образ от Google заморожен с июня 2025 и не патчится. Либо форк Chainguard, либо сразу BuildKit.
- OOMKilled у kaniko. Снимки ФС в памяти. Поднимай limits.memory, дроби тяжёлые RUN.
- Кэш без mode=max. inline-кэш не сохраняет промежуточные стадии многоступенчатой сборки - повторный билд пересчитывает половину. Для CI бери --export-cache type=registry,mode=max.
- Креды registry в открытую. Не хардкодь пароль в Dockerfile или ENV образа - используй CI-переменные и keyless-подпись через OIDC.
Локально, без CI, чтобы прочувствовать daemonless. Демон docker глушить не обязательно, но и пользоваться им не будем.
- Подними локальный registry: docker run -d -p 5000:5000 --name reg registry:2 (registry демоном не считается, это просто хранилище).
- Возьми минимальный Dockerfile (FROM alpine, RUN apk add curl, CMD ...).
- Собери через rootless BuildKit, запустив moby/buildkit:rootless и вызвав внутри buildctl-daemonless.sh build с --output type=image,name=localhost:5000/app:1,push=true.
- Повтори сборку с --export-cache type=registry,ref=localhost:5000/cache,mode=max и --import-cache - засеки время первого и второго прогона, разница на кэше будет наглядной.
- Прогони trivy image localhost:5000/app:1 и посмотри список CVE. Затем cosign sign --key ... (или keyless) и cosign verify - убедись, что подпись проверяется.
- Бонус: собери тот же образ через buildah build -t localhost:5000/app:2 . и сравни, что на выходе одинаковый OCI-образ.
- Почему docker in docker с privileged и проброс docker.sock считаются небезопасными в multi-tenant CI - что именно может сделать атакующий?
- Чем BuildKit rootless архитектурно быстрее kaniko и зачем нужен флаг --oci-worker-no-process-sandbox?
- Что хранит кэш в режиме mode=max и почему inline-кэша недостаточно для многоступенчатой сборки?
- Какие четыре артефакта supply chain (скан, SBOM, подпись, provenance) на какой вопрос отвечают и как они складываются в SLSA?
Сборка образа без docker в k8s-нативном CI - это норма 2026 года, а не хак. DinD и docker.sock закрыты как небезопасные. kaniko показал путь, но с июня 2025 его оригинал заморожен - знать как наследие, начинать на BuildKit rootless или Buildah. Все они дают один OCI-образ поверх containerd/runc. А поверх сборки навешивается supply chain - скан, SBOM, подпись cosign и SLSA-provenance - чтобы образ был не только собран без привилегий, но и доказуемо твой и чистый.