Облачные раннеры GitHub Actions удобны ровно до того момента, когда счёт за минуты сборки начинает кусаться, а сборкам вдруг понадобился доступ к закрытым ресурсам внутри своей сети. Тогда команды переносят раннеры в собственный кластер Kubernetes через специальный оператор. И почти сразу упираются в проблему, которая кажется надуманной, пока не столкнёшься с ней лично: сборки хотят запускать Docker, а Docker внутри контейнера в Kubernetes это отдельная история с привилегиями, безопасностью и неприятными компромиссами. Разберём, как оператор управляет раннерами, какие есть режимы запуска Docker и почему у каждого своя цена.
Зачем вообще тащить раннеры в свой кластер
Причин обычно несколько и они весомые. Свои раннеры дешевле: платишь только за серверы, на которых идут задачи, а не за минуты по тарифу облака. Они дают доступ к закрытым ресурсам: например, прогнать миграции базы, спрятанной в частной подсети, с облачного раннера невозможно, а со своего внутри той же сети легко. И они снимают ограничения по мощности: можно взять любой тип машины и любой диск, не упираясь в фиксированные лимиты облачных раннеров.
Управляет этим оператор раннеров в Kubernetes. Это оператор, который оркестрирует и масштабирует самостоятельные раннеры. Он создаёт наборы раннеров, автоматически растущие и сжимающиеся по числу задач в очереди репозитория или организации. Раннеры эфемерны и основаны на контейнерах, поэтому новые экземпляры поднимаются и чисто убираются быстро. Стоит сразу отметить, что прежние режимы автомасштабирования теперь считаются устаревшими, а актуальный путь это наборы масштабируемых раннеров, и именно на них стоит строиться в новых развёртываниях.
Откуда берётся сама проблема Docker внутри Docker
Корень в том, что многие задачи рабочих процессов хотят работать с Docker. Задача может собирать образ контейнера, запускать шаги в контейнере или поднимать вспомогательные сервисы-контейнеры. На обычной машине с установленным Docker это не вопрос. Но раннер в Kubernetes сам живёт внутри контейнера, и чтобы он мог запускать контейнеры, нужен Docker внутри Docker.
Вот тут и начинается боль. Запуск Docker внутри контейнера требует привилегированного режима, потому что демону Docker нужны права на создание и управление вложенными контейнерами, монтирование служебных файловых систем и прочие операции, обычно доступные только привилегированному процессу. А привилегированный контейнер в Kubernetes это серьёзная брешь в безопасности: он фактически получает доступ к узлу, на котором работает. В среде, где на одном кластере соседствуют проекты разных команд, это особенно опасно, ведь случайная или злонамеренная задача из привилегированного контейнера способна дотянуться до узла и до соседей.
Какие режимы запуска предлагает оператор
Оператор предлагает два основных режима работы с контейнерами, и у каждого свой набор компромиссов. Понимание разницы между ними и есть ключ ко всей теме.
Первый режим это Docker внутри Docker. В нём для каждого пода раннера оператор добавляет отдельный контейнер с демоном Docker. Задачи рабочего процесса, использующие контейнеры, выполняются через этот демон. Цена в том, что контейнер с демоном требует привилегированного режима. Это самый совместимый вариант: работает и сборка образов, и контейнерные задачи, и контейнерные действия, всё как на обычной машине. Но привилегии никуда не деваются.
Второй режим это режим Kubernetes. В нём раннер не поднимает свой демон Docker, а создаёт дополнительные поды в том же пространстве имён для запуска контейнерных задач, используя механизм перехватчиков. Огромный плюс: этот режим не требует ни одного привилегированного контейнера. Но у него есть жёсткое ограничение, которое отсекает часть сценариев: из задачи рабочего процесса нельзя выполнять команды Docker, в частности нельзя собрать образ контейнера привычной командой сборки. То есть режим Kubernetes отлично подходит для задач, которым нужно лишь запуститься в контейнере, но не для тех, кто собирает образы.
# Режим Docker внутри Docker: совместимо, но привилегированно
containerMode:
type: "dind"
---
# Режим Kubernetes: без привилегий, но без сборки образов
containerMode:
type: "kubernetes"
kubernetesModeWorkVolumeClaim:
accessModes: ["ReadWriteOnce"]
storageClassName: "standard"
resources:
requests:
storage: 5Gi
Чем помогает запуск Docker без прав суперпользователя
Раз привилегированный режим опасен, появилось промежуточное решение: образ, запускающий Docker без прав суперпользователя внутри контейнера. Обычный режим Docker внутри Docker предполагает, что главный раннер работает с полными правами, что проблематично в зарегулированной или просто более ответственной к безопасности среде. Образ без прав суперпользователя запускает демон Docker от имени обычного пользователя раннера.
Это снижает риск, но честно стоит сказать о двух важных оговорках. Первая: даже образ без прав суперпользователя всё равно требует привилегированного режима. Природа вложенной контейнеризации такова, что без привилегий демону не обойтись, нужно монтировать служебные файловые системы и прочее. То есть запуск без прав суперпользователя убирает часть путей для атаки, делая случайную задачу неспособной выполнять привилегированные действия от своего имени, но сам контейнер остаётся привилегированным. Вторая оговорка: пользователь без прав суперпользователя не имеет доступа к административным командам, поэтому всё, что требует таких прав, например установку дополнительного софта, надо встраивать в базовый образ раннера заранее.
Иными словами, запуск без прав суперпользователя это разумное снижение вреда, а не полное устранение проблемы. Он закрывает самые частые лёгкие пути обхода, убирая права суперпользователя и работая от непривилегированного пользователя, но фундаментальная привилегированность вложенного Docker остаётся.
Какие инструменты позволяют собирать образы без Docker вовсе
Самый чистый выход из ловушки это вообще не запускать демон Docker для сборки образов. Если задача нужна лишь для того, чтобы собрать и опубликовать образ контейнера, есть инструменты сборки, работающие без прав суперпользователя и без привилегированного режима. Они собирают образ из описания сборки прямо в пространстве пользователя, без обращения к демону Docker.
# Раннер для сборки образов без демона Docker и привилегий
template:
spec:
securityContext:
runAsUser: 1000
fsGroup: 1000
containers:
- name: runner
image: my-registry/kaniko-runner:latest
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
volumes:
- name: docker-config
secret:
secretName: docker-credentials
Это серьёзно меняет картину безопасности: для самого частого сценария, сборки и публикации образов, привилегированный контейнер становится не нужен. Расплата в том, что такой инструмент покрывает именно сборку, а не запуск произвольных контейнерных задач, и иногда требует адаптации привычных шагов рабочего процесса. Но для команд, которым от Docker нужна только сборка образов, это достойная работа, окупающая себя снятием привилегий.
Почему контейнерные действия отдельная заноза
Есть тонкость, на которую натыкаются неожиданно. Контейнерные действия, то есть действия рабочего процесса, упакованные как образ Docker, не работают как есть в режимах без полноценного Docker. Обходной путь состоит в том, чтобы собрать нужный контейнер самостоятельно, опубликовать его во внутреннем реестре и переоформить входы и выходы как составное действие.
Сложность тут не только техническая, но и организационная. Пользователи обычно не знают, каким из способов упаковано используемое ими действие, поэтому запрет на контейнерные действия выглядит для них произвольным. Отказ без внятного объяснения причины кажется капризом и вдобавок не решает ту задачу, ради которой действие и бралось. Поэтому, ограничивая контейнерные действия ради безопасности, важно сопровождать это понятной альтернативой, а не голым запретом.
Какие практики держат развёртывание в форме
Несколько рекомендаций повторяются у тех, кто эксплуатирует это всерьёз. Привилегированным должен быть только контейнер с демоном Docker, а сам контейнер раннера следует держать непривилегированным, и это правило действует даже при использовании образа без прав суперпользователя. Запросы и лимиты ресурсов задают для всех контейнеров в наборах раннеров, делая запрос равным лимиту, потому что оператор не поддерживает вытеснение, и попытка переподписать память приводит к зависанию или падению раннеров. Для контроллера разумно держать несколько реплик, чтобы он быстрее реагировал на отказ узла. Критичные узлы помечают ограничениями, чтобы поды раннеров не садились на них и не конкурировали за ресурсы.
Радикальная по духу, но здравая рекомендация звучит так: предпочтительно запускать оператор вовсе без дополнительных контейнеров, поскольку это сокращает поверхность атаки и повышает производительность. А если поддержка Docker внутри Docker или режима Kubernetes для задач всё же нужна, стоит подумать о том, чтобы вынести такие задачи в отдельный кластер. Идея проста: изолировать рискованную вложенную контейнеризацию от остальной инфраструктуры, чтобы возможный прорыв из привилегированного контейнера не дотянулся до важного.
Какой стратегии придерживаться
Перенос раннеров GitHub Actions в свой кластер через оператор решает реальные задачи экономии, доступа к закрытым ресурсам и гибкости мощностей, но втягивает в историю с Docker внутри Docker, где простых ответов нет. Разумная логика выбора такова. Если задачам нужно лишь запускаться в контейнерах, а не собирать образы, брать режим Kubernetes и обходиться вовсе без привилегий. Если задачам нужно собирать образы, в первую очередь смотреть на инструменты сборки без демона Docker, дающие сборку без привилегированного контейнера. Если же без полноценного Docker внутри Docker не обойтись, использовать образ без прав суперпользователя как снижение вреда, ясно понимая, что привилегированность при этом остаётся, и держать контейнер раннера непривилегированным. И для особо рискованных нагрузок изолировать их в отдельном кластере.
Главная мысль в том, что удобство полноценного Docker внутри контейнера всегда оплачивается привилегиями, а привилегии в общем кластере это риск для всех соседей. Тот, кто бездумно включает привилегированный Docker внутри Docker ради совместимости, открывает дорогу к узлу для любой задачи. А тот, кто сначала спрашивает, нужна ли сборка образов или достаточно запуска контейнеров, и выбирает наименее привилегированный подходящий режим, получает и работающие сборки, и кластер, который не рушится из-за одной неосторожной задачи в рабочем процессе.