all posts

ENGINEERING NOTES

SpawnPod: создание изолированного бэкенда выполнения для Forgejo Actions на Kubernetes

Собственно, мне нужен 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 сами по себе ещё не доказывают совместимость инструмента.

Впереди еще много работы, так что буду рад любым комментариям!

all posts