Docker-in-Docker и kaniko: сборка образов

Разбираем, как собрать Docker-образ внутри джобы, которая сама выполняется в контейнере.

Docker-in-Docker (dind) — запуск Docker-демона внутри джобы как сервиса, чтобы собирать образы изнутри контейнера.

Парадокс сборки образа в контейнере

С docker-executor джоба уже выполняется в контейнере. А нам нужно внутри неё выполнить docker build — то есть нужен доступ к Docker-демону. Есть два честных пути: дать джобе доступ к Docker-демону через сервис dind или собирать образ вообще без демона утилитой kaniko.

Чтобы понять, почему это вообще проблема, стоит вспомнить, что такое Docker-демон. docker build и docker run — это команды клиента, тонкой программы, которая ничего сама не делает. Вся реальная работа (распаковка слоёв, создание namespace и cgroups, монтирование файловых систем) лежит на демоне dockerd, фоновом процессе с привилегиями root на хосте. Когда вы пишете docker build на своём ноутбуке, клиент шлёт запрос демону через сокет /var/run/docker.sock, а демон уже строит образ. Внутри же CI-джобы, которая сама является контейнером, никакого dockerd по умолчанию нет: контейнер — это изолированное пространство процессов, и демона хоста там не видно. Поэтому «просто запустить docker build» не получится — клиенту некому отправить команду.

Исторически инженеры решали это, пробрасывая в джобу хостовый сокет /var/run/docker.sock. Это работает, но фактически означает, что любая джоба получает полный контроль над демоном хоста и, значит, над всем сервером: можно запустить контейнер с монтированием корня хоста и прочитать чужие секреты. На общих раннерах это недопустимо, поэтому индустрия пришла к двум более аккуратным подходам, которые мы и разбираем.

Способ 1: docker-in-docker

build-image:
  image: docker:27
  services:
    - docker:27-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

Сервис docker:27-dind поднимает рядом с джобой отдельный Docker-демон, а клиент docker из образа джобы к нему обращается. Минус: dind требует privileged-режима у раннера, что снижает изоляцию и считается рискованным.

Разберём строки внимательнее. image: docker:27 — это образ с CLI-клиентом docker, но без запущенного демона. services: docker:27-dind — это уже образ с демоном внутри; GitLab поднимает его как отдельный контейнер-спутник. Версии клиента и сервиса лучше держать совпадающими, иначе можно поймать рассинхронизацию API. Переменная DOCKER_TLS_CERTDIR: "/certs" включает шифрованный канал между клиентом и демоном: начиная с Docker 19.03 dind по умолчанию ожидает TLS, и если каталог сертификатов не задан, клиент будет ломиться на нешифрованный порт 2375 и не достучится. Это одна из самых частых причин загадочного Cannot connect to the Docker daemon у новичков.

Полезно понимать, зачем здесь нужен privileged. Демон создаёт вложенные контейнеры, а для этого ему требуются операции, которые ядро по умолчанию запрещает обычному контейнеру: монтирование файловых систем, работа с устройствами, манипуляции с cgroups. Privileged-режим снимает почти все ограничения с контейнера сервиса — отсюда и риск. Если злоумышленник захватит такую джобу, он фактически выходит на уровень хоста. Поэтому на больших инсталляциях privileged-раннеры либо изолируют отдельным пулом, либо запрещают вовсе.

Способ 2: kaniko (без демона)

kaniko собирает образ из Dockerfile без Docker-демона и без privileged — безопаснее для общих раннеров. Он читает Dockerfile, выполняет инструкции в пользовательском пространстве и пушит результат.

build-image:
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - /kaniko/executor
      --context "$CI_PROJECT_DIR"
      --dockerfile "$CI_PROJECT_DIR/Dockerfile"
      --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

kaniko сам авторизуется в реестре через предопределённые переменные, поэтому ручной docker login часто не нужен. Для shared-раннеров GitLab.com kaniko — рекомендованный путь.

Обратите внимание на entrypoint: [""]. Образ kaniko по умолчанию запускает /kaniko/executor как точку входа, но GitLab сам управляет тем, что выполняется в джобе, и подставляет свой shell-скрипт. Сброс entrypoint в пустую строку убирает конфликт и отдаёт управление раннеру, который затем сам вызывает /kaniko/executor из секции script. Без этого приёма джоба может молча завершаться или вести себя непредсказуемо — частая ловушка.

Аутентификацию kaniko берёт из файла /kaniko/.docker/config.json. На GitLab.com предопределённые переменные CI_REGISTRY, CI_REGISTRY_USER и CI_REGISTRY_PASSWORD позволяют kaniko авторизоваться в Container Registry проекта автоматически. Если же вы пушите в сторонний реестр (Docker Hub, ECR), config.json нужно сформировать самостоятельно — это типичная причина ошибок 401 Unauthorized при первом подключении внешнего реестра.

Сравним два подхода в таблице, чтобы выбор был осознанным:

Критерий            docker-in-docker        kaniko
------------------  ----------------------  ----------------------
Нужен демон         Да (сервис dind)        Нет
privileged          Требуется               Не требуется
Безопасность        Ниже (доступ к хосту)   Выше (user space)
Кеш слоёв           Локальный, быстрый      Через флаг --cache
Совместимость       Любой Dockerfile        Почти любой
Где уместно         Свои изолир. раннеры    Общие/shared раннеры

Сам Dockerfile

Образ собирается из обычного Dockerfile в репозитории:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Порядок инструкций здесь не случаен и напрямую влияет на скорость сборки в CI. Сначала копируются только package*.json и ставятся зависимости, и лишь потом копируется весь код. Это даёт кеширование слоёв: пока манифест зависимостей не меняется, тяжёлый слой npm ci переиспользуется, и пересобирается только лёгкий верхний слой с кодом. Если бы мы скопировали весь проект одной командой перед установкой, любая правка в коде инвалидировала бы кеш зависимостей, и сборка в пайплайне каждый раз качала бы npm-пакеты заново — лишние минуты на каждом коммите.

Как работает под капотом

В случае dind GitLab поднимает сервис-контейнер с Docker-демоном и связывает его с контейнером джобы по сети; клиент docker шлёт демону команды сборки. kaniko же не нуждается в демоне: он сам разбирает слои образа, выполняет инструкции Dockerfile в своём процессе и формирует образ, который пушит в реестр по протоколу registry API. Отсюда отсутствие требования privileged.

Чуть глубже про kaniko. Он берёт базовый образ из FROM, разворачивает его файловую систему в своём контейнере, а затем для каждой инструкции Dockerfile выполняет её прямо в этой файловой системе и делает снимок изменившихся файлов — это и есть новый слой. То есть kaniko не запускает вложенные контейнеры, а имитирует процесс сборки на уровне файлов и метаданных в собственном пространстве пользователя. Готовые слои он упаковывает в манифест и отправляет в реестр обычными HTTP-запросами по Registry API v2. Именно поэтому ему не нужны привилегии ядра — он нигде не создаёт изолированных сред исполнения.

Частые ошибки

  • Использовать dind без privileged-раннера — сборка падает с ошибкой подключения к демону.
  • Забыть DOCKER_TLS_CERTDIR и ловить проблемы с TLS между клиентом и dind.
  • Пытаться запускать docker build напрямую без dind и без kaniko — демона в джобе нет.
  • Не сбросить entrypoint: [""] у образа kaniko — джоба ведёт себя непредсказуемо или молча падает.
  • Пушить в сторонний реестр и удивляться 401 — kaniko авторизуется автоматически только в Container Registry самого проекта.

Итоги

  • Чтобы собрать образ в джобе, нужен либо dind-сервис, либо kaniko.
  • dind требует privileged и менее безопасен; kaniko работает без демона и без privileged.
  • Push в Container Registry авторизуется предопределёнными переменными.
  • Порядок инструкций в Dockerfile определяет кеширование слоёв и скорость сборки в CI.
Проверьте себя
1. В чём преимущество kaniko перед docker-in-docker?
Akaniko быстрее всегда
Bkaniko собирает образ без Docker-демона и без privileged-режима, что безопаснее
Ckaniko не требует Dockerfile
Dkaniko пушит только в Docker Hub
2. Что нужно раннеру, чтобы работал docker-in-docker?
AТег gpu
BPrivileged-режим
CExecutor shell
DОтключённый кеш