Собственно, мне нужен CI/CD.
И задуманный конвейер выглядел обычно:
git push
|
v
Forgejo Actions
|
v
Сборка OCI-образа
|
v
Forgejo Container Registry
|
v
Обновление GitOps-репозитория
|
v
Argo CD
|
v
KubernetesНо я постоянно возвращался к вопросу — чему я доверяю, когда разрешаю выполнять ci-джобы?
Так что, исходя из мыслей, что разработчик дебил (я), и того, что workflow может запускать произвольные команды, выполнять сторонние инструменты, ставить зависимости, работать с учётными данными и обрабатывать недоверенный input.
Собственно, я не хочу, чтобы задание наследовало права машины, на которой работает CI. Не хочу монтировать Docker socket в контейнер и делать вид, что это то, что мне нужно. И не хочу, чтобы авторы workflow могли произвольно менять параметры безопасности Kubernetes (да, я планировал не только сам пользоваться хостингом).
Переписывать весь ci — дело глупое, возьмем более узкое направление: Мне дают команду, которую нужно выполнять, а система дает тебе одноразовую среду, в которой уже выставлены все квоты и права.
Собственно это и есть SpawnPod.
Разделение оркестрации workflow и выполнения
У Forgejo уже есть Actions. Официальный Forgejo Runner уже умеет выполнять workflow. А еще там недавно появилась фича плагинов, благодаря чему вообще это стало возможным. Сейчас поддерживается Forgejo Runner v13.1.0 с его экспериментальным протоколом плагинов. Runner и SpawnPod взаимодействуют через приватный Unix-сокет.
Итак, SpawnPod стал бэкендом выполнения для существующего Runner.
Forgejo
|
| Задание Actions
v
Официальный Forgejo Runner
|
| Запросы на выполнение
v
SpawnPod
|
| Kubernetes API
v
Пространство имён Kubernetes
|
v
Временный Pod задания
|
| Выполнение команд
v
Результат заданияПолучаем, как мне кажется, вполне себе пригодную схему:
- Forgejo определяет, какую работу нужно выполнить.
- Runner интерпретирует workflow.
- SpawnPod управляет средой, в которой выполняются команды.
- Kubernetes предоставляет механизмы изоляции и управления ресурсами.
SpawnPod не знает, как описан workflow. Он делает ровно одну вещь — подготавливает разрешённую среду выполнения, запускает в ней команды, переносит данные рабочего каталога и затем удаляет ресурсы.
Жизненный цикл выполнения
В общих чертах джоба проходит несколько этапов.
Runner запрашивает выполнение
|
v
Проверка профиля задания
|
v
Создание ресурсов
|
v
Запуск Pod выполнения
|
v
Передача рабочего каталога
|
v
Выполнение команд
|
v
Получение кода завершения
|
v
Передача результатов
|
v
Удаление PodЭфимерные (рабочие) поды не является постоянной рабочей машиной, это обычные кубовые поды, которые создаются только для выполнения джобы, после удаляются.
У нас есть разрешенный образ, выставленные квоты и время жизни, все это создается и ставится заранее.
Между заданиями не нужно поддерживать постоянно работающую shell-среду. Выполнение начинается с разрешённого образа. Ограничения CPU, памяти, хранилища и времени жизни задаются заранее.
Одна среда обслуживает последовательные команды и передачи файлов в рамках задания; рабочий каталог сохраняется между шагами. Когда среда больше не нужна, Runner запрашивает её удаление. SpawnPod также обрабатывает прерванные операции и среды с истёкшим сроком жизни через reconciliation. Недоступность Kubernetes API может задержать очистку, поэтому она повторяется на основе сохранённого состояния. Команды с неопределённым результатом автоматически не запускаются повторно.
Это не делает произвольный код безопасным. Контейнеры и Pod Kubernetes не эквивалентны виртуальным машинам и используют общее ядро хоста.
Но это хотя бы создаёт явную границу с существенно меньшим набором возможностей, чем у неограниченного Runner на хосте.
Профили вместо произвольных спецификаций Pod
Одно из свойств безопасности среды выполнения должны определять операторы инфраструктуры, а не файлы workflow.
Для этого SpawnPod использует профили выполнения.
Вот неполный пример профиля:
profiles:
default:
network: isolated
image: localhost/spawnpod-ci-base@sha256:<approved-digest>
architecture: amd64
uid: 10001
gid: 10001
cpuRequest: 250m
cpuLimit: "1"
memoryRequest: 256Mi
memoryLimit: 768Mi
storageRequest: 512Mi
storageLimit: 2Gi
workspaceSize: 1Gi
maxLifetime: 30mОбраз зафиксирован по digest. Пользователь и группа выполнения заданы явно. Ресурсы ограничены. А максимальное время жизни не позволяет заданию занимать среду бесконечно.
Оператор также задаёт квоту ресурсов пространства имён: максимальное число Pod и суммарные бюджеты CPU, памяти и временного хранилища. Эти значения должны соответствовать реальной ёмкости кластера.
Такие настройки позволяют ограничивать ущерб, который вышедшая из-под контроля или вредоносная нагрузка может причинить остальному кластеру.
Workflow не должен иметь возможности превратить себя в привилегированный Pod, подключить произвольные тома хоста или выбрать Kubernetes service account с повышенными правами.
Этим управляет оператор. Создаваемые Pod заданий работают без root, с файловой системой корня только для чтения, без Linux capabilities и с запрещённым повышением привилегий. Они не получают токен Kubernetes API, монтирования хоста или тома Kubernetes Secret.
Admission — вторая граница
Проверка конфигурации полезна, но я не хотел, чтобы контроллер оставался единственным местом, где обеспечиваются ограничения выполнения (не кладем все яйца в одну корзину).
SpawnPod генерирует Kubernetes ValidatingAdmissionPolicy и bindings, отклоняющие Pod с неразрешёнными образами или параметрами. Эти политики необходимо установить и обеспечить их применение в кластере.
Принцип прост:
Нагрузку нужно отклонять, если она не соответствует контракту безопасности, принятому для установки. В том числе это касается разрешённых образов и параметров выполнения.
Проверки безопасности не должны целиком зависеть от корректности единственного пути исполнения кода. Ошибка при создании ресурсов, будущий рефакторинг или неверная конфигурация не должны незаметно ослаблять среду выполнения.
Admission-слой даёт ещё одну возможность отклонить ресурсы, нарушающие принятую модель. RBAC, PSA это, конечно, не отменяет, но как дополнительный слой от кривых рук — welcome.
Неудобная часть: сетевая изоляция
Самая интересная и сложная часть проекта.
Мой начальный профиль выполнения изолирован от сети.
В Kubernetes это выражено через NetworkPolicy с запретом по умолчанию:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: spawnpod-default-deny
namespace: ci
spec:
podSelector: {}
policyTypes:
- Ingress
- EgressПолитика выбирает все Pod в пространстве имён и не содержит разрешающих правил для входящего или исходящего трафика.
Если сетевая реализация применяет NetworkPolicy и нет дополнительных разрешающих политик, обычные исходящие соединения из этих Pod блокируются. NetworkPolicy действуют совместно: другая политика может разрешить доступ. При включённых разрешённых сервисах связи с ними становятся дополнительными явными исключениями.
Это полезное поведение по умолчанию для выполнения недоверенных команд.
И одновременно очень неудобное для CI. Первый реальный тест Actions сразу это и показал. Workflow выполнялся в среде под управлением SpawnPod. Runner успешно запустил задание, Pod стартовал, команда выполнилась.
Но проверка сети вернула:
=== Network ===
search ci.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.43.0.10
=== Forgejo connectivity ===
wget: bad address 'git.dmedovich.com'Задание было отмечено как успешное, потому что в диагностической команде я намеренно использовал || true.
Ошибка соответствовала настроенной сетевой изоляции. И теперь нужно было решить более сложную задачу: Как дать заданию CI доступ к исходному репозиторию, не предоставляя ему неограниченный выход в сеть?
Почему просто не разрешить HTTPS?
Можно, конечно, просто разрешить https, что, конечно, упростило бы загрузку зависимостей и операции Git.
Но в зависимости от политики это также позволило бы подключаться к произвольным HTTPS-узлам.
Скомпрометированная зависимость или вредоносный workflow могли бы использовать такой доступ для связи с внешним адресом.
Сетевая политика, разрешающая весь HTTPS-трафик, не является полноценным списком разрешённых адресатов.
Нужна была модель, в которой оператор явно определяет, с какими внешними сервисами может связываться задание.
Так появилась фича Source Proxy.
Контролируемый доступ к сети через Source Proxy
Отдельный сетевой профиль source даёт доступ к источникам через HTTP-прокси под управлением оператора. В начальной конфигурации приёмки RC7 необязательные сервисы отключены.
При отключённых сервисах схема выглядит так:
Forgejo Runner
|
v
SpawnPod
|
v
Pod задания CI (source)
|
| HTTP_PROXY / HTTPS_PROXY
|
| NetworkPolicy:
| разрешён только адрес прокси
v
Source Proxy
|
| Список разрешённых адресатов
| Фиксированный IP назначения
| Проверка TLS SNI
v
Forgejo HTTPSПрокси настраивается с помощью явно заданных маршрутов.
Например:
listen: 0.0.0.0:3128
routes:
- host: git.dmedovich.com
port: 443
address: 138.124.231.231Здесь разрешённое имя хоста и порт связываются с фиксированным IP назначения. Перед развёртыванием нужно проверить адресата и принадлежность IP, а при изменениях обновить привязку.
Прокси не нужно динамически разрешать имя адресата.
Это полезно: решение о доступе основано на маршруте, одобренном оператором, а не на доверии к любому IP, который DNS вернёт в момент запроса.
Реализация также проверяет адрес назначения и отклоняет, в частности, частные и link-local адреса. Исключение для loopback управляется отдельно.
Это снижает риск превращения прокси в неограниченный путь к внутренней инфраструктуре.
HTTPS CONNECT и SNI
Проксирование HTTPS добавляет ещё одну сложность.
В запросе HTTP CONNECT клиент просит прокси установить TCP-туннель.
После его установки прокси обычно не может анализировать зашифрованные HTTP-запросы без терминации TLS.
Source Proxy в SpawnPod проверяет полный начальный TLS ClientHello и соответствие SNI разрешённому адресату до открытия соединения с ним. Отсутствующие, некорректные, фрагментированные или зашифрованные варианты ClientHello отклоняются. Прокси не терминирует TLS до сервера; проверка сертификата остаётся обязанностью клиента.
Это позволяет обнаружить ситуацию, когда клиент запрашивает один разрешённый хост, но начинает TLS-соединение с именем другого.
Прокси не анализирует зашифрованный прикладной трафик. И не гарантирует, что весь трафик внутри туннеля безвреден.
Например, разрешённый адрес Forgejo может одновременно предоставлять загрузку репозиториев и загрузку образов в registry с аутентификацией.
Прокси может ограничить адресата, но не может решить, должна ли быть разрешена конкретная отправка образа в registry. За это по-прежнему отвечают Forgejo и учётные данные, переданные заданию.
Сетевой профиль
Конфигурация SpawnPod поддерживает сетевой режим source.
Для него адаптер добавляет в выполняемые команды переменные окружения прокси:
HTTP_PROXY
HTTPS_PROXY
http_proxy
https_proxyДля этих команд он также очищает NO_PROXY и no_proxy.
Адаптер принудительно задаёт эти начальные значения, но код задания может изменить собственное окружение процесса. Прямой обход сети блокирует NetworkPolicy. Режим политики по умолчанию разрешает только точный IP и порт прокси; явный режим pod-selector разрешает выбранные Pod прокси на этом порту.
У прокси и NetworkPolicy разные обязанности:
- NetworkPolicy ограничивает, куда Pod может устанавливать соединения.
- Source Proxy ограничивает адресатов, к которым он перенаправляет соединения.
- Учётные данные приложения определяют, какие операции разрешены на этих адресатах.
Текущее состояние
Текущая последняя версия — v0.1.0-rc.7. Source Proxy включён в общий scratch runtime-образ вместе с контроллером и агентом. Отдельный Deployment под управлением оператора запускает /spawnpod-source-proxy; пример Kubernetes включает стабильный Service, неизменяемую конфигурацию маршрутов, работу без root и токена API, а также ограничения входящего и исходящего трафика прокси.
Профиль по умолчанию остаётся изолированным. Проверенная конфигурация Runner с двумя плагинами сопоставляет spawnpod с профилем default, а spawnpod-source — с профилем source; оба используют один приватный сокет. Workflow выбирают их через runs-on: spawnpod или runs-on: spawnpod-source. Сам Runner скачивает actions по отдельному сетевому пути.
Локальные тесты также разрешили практический сетевой вопрос. На проверенном K3s правило IPBlock с точным ClusterIP не давало доступа к прокси после Service DNAT. Поэтому в RC7 добавлен явный режим sourceProxyPolicy: pod-selector. Он требует обе фиксированные метки Pod прокси в пространстве имён инфраструктуры под контролем оператора и разрешает заданный TCP-порт. IP Service сохраняется в переменных окружения прокси, но прямой доступ к выбранным backend Pod также разрешён. Режим по точному IP остаётся вариантом по умолчанию.
Runner для CI не должен нуждаться в Docker socket
После локальной проверки контролируемого сетевого доступа следующая задача — сборка OCI-образов.
В SpawnPod я хочу сохранить совместимость сборки образов с существующими ограничениями выполнения:
Процесс без root
Без привилегированного контейнера
Без Docker socket хоста
Без произвольных монтирований хоста
Ограниченный набор capabilities
Ограниченные ресурсы
Контролируемый доступ к сетиПоэтому выбор инструментов сборки становится важнее.
Следующий шаг — оценить подход к сборке OCI-образов без демона, который работает в этих условиях. SpawnPod также применяет наследуемый командами seccomp-фильтр, ограничивающий изменение namespaces, сеансов и групп процессов. Поэтому отсутствие демона или работа без root сами по себе ещё не доказывают совместимость инструмента.
Впереди еще много работы, так что буду рад любым комментариям!