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.