PunchAtiku to campaign in all states — ADC campaign councilBollywood HungamaFardeen Khan buys his first Royal Enfield Guerrilla 450 Apex: “One of the most fun bikes I’ve ever ridden”El ComercioMaría Corina Machado regresa a Venezuela entre una multitud que exige elecciones presidencialesESPNIraola era at Liverpool finally has liftoff thanks to big-money forwardsThe Jerusalem PostSheffi Paz, prominent Tel Aviv anti-illegal immigration activist, dies at 74Daily MaverickVenezuela opposition leader Machado returns home after fraught year awayInquirerChief prosecutor warns giving false addresses in complaints is perjuryZDF heuteAktuelle Pressemitteilungen des ZDFSoompiYoo Yeon Seok And Seo Hyun Jin Praise Each Other’s Acting As They Reunite After 10 Years In “The Perfect Lie”SRF NewsKrieg im Nahen Osten – Israel dementiert Bericht zu Angriff auf Iran vor Parlamentswahlسكاي نيوز عربيةدبي المالي العالمي يستهدف 1000 شركة ذكاء اصطناعي بحلول 2030Capital FMDPP orders criminal probe into Eastleigh building collapse
The Daily Newsstand · Free, Always
Monday, October 26, 2026

Загрузка образов в Kubernetes без паролей с Artifact Keeper

Translate

С каждым новым кластером начинается одна и та же рутина: пароль от реестра нужно скопировать в каждый namespace, где будут загружать приватные образы, и повторить это при ротации. Кластеры создаются динамически, так что рутина не кончается, а в каждой копии пароль может протухнуть или утечь.

В Kubernetes 1.34+ есть выход: kubelet может передать плагину токен ServiceAccount пода, для которого загружают образ, а плагин обменяет его в реестре на короткоживущие учётные данные только на чтение. Токен ServiceAccount выпускается для конкретного пода на ограниченное время и не лежит ни в одном манифесте. Реестр сам решает, каким токенам верить. Это ближе к zero trust: у пода нет долгоживущего секрета, который можно украсть и использовать из другого места.

Схема работает, но при отказе причину трудно найти. Под переходит в ImagePullBackOff, а в events виден лишь общий ответ реестра: failed to resolve reference ... unexpected status from HEAD request. Настоящую причину пишет kubelet, и она есть только в его журнале на ноде:

ak-kubelet-provider: https://ak.test: 401 CI JWT did not match any identity mapping

Эта строка появляется не сразу: сначала нужно настроить kubelet, права нод и сам реестр. Плагин написал я для реестра Artifact Keeper, и все примеры взяты с моего тестового кластера.

Что такое Artifact Keeper

Artifact Keeper — реестр артефактов с открытым кодом (лицензия MIT), написанный на Rust. Это альтернатива JFrog Artifactory и Sonatype Nexus Repository Manager. Он хранит образы Docker/OCI и пакеты Maven, PyPI, npm, Cargo, Go, Helm, а метаданные держит в PostgreSQL. Плагин опирается на то, что реестр принимает токены ServiceAccount из Kubernetes и выдаёт по ним учётные данные. Он написан под API обмена Artifact Keeper, но сама схема подойдёт любому реестру, который проверяет OIDC‑токены кластера.

Как kubelet передаёт плагину токен ServiceAccount

Kubelet умеет получать учётные данные для образов у внешнего исполняемого файла. Такой файл называют image credential provider, и он запускается на каждой ноде. Kubelet отправляет ему в stdin JSON с именем образа, а в stdout ждёт логин, пароль и срок кеширования.

В Kubernetes 1.34 к запросу добавился токен ServiceAccount пода, для которого загружают образ (KEP-4412, поле tokenAttributes в конфиге). Раньше плагин видел только имя образа и мог опираться лишь на то, с какой ноды пришёл запрос. Теперь он знает, какой под просит образ, и может обменять токен на учётные данные для этого ServiceAccount.

Конфиг для плагина выглядит так:

apiVersion: kubelet.config.k8s.io/v1
kind:CredentialProviderConfig
providers:
  - name: ak-kubelet-provider
  matchImages: ["ak.test"]
  defaultCacheDuration: "5m"
  apiVersion: credentialprovider.kubelet.k8s.io/v1
  args:
    - --url=https://ak.test
  tokenAttributes:
    serviceAccountTokenAudience: artifact-keeper
    cacheType: ServiceAccount
    requireServiceAccount: true

serviceAccountTokenAudience задаёт, для кого выпущен токен. Здесь нужно отдельное значение, которое не совпадает с audience API‑сервера. Если взять audience API‑сервера, реестр начнёт принимать токен любого пода, а токен, который плагин отправляет реестру, окажется валидным и для API‑сервера. Artifact Keeper такого провайдера не создаёт и отвечает 400.

Весь обмен показан на схеме:

Образа на ноде ещё нет, и kubelet вызывает плагин. Под из team получает доступ, потому что для namespace team в реестре есть маппинг, то есть правило, которое пускает ServiceAccount из этого namespace.

Образа на ноде ещё нет, и kubelet вызывает плагин. Под из team получает доступ, потому что для namespace team в реестре есть маппинг, то есть правило, которое пускает ServiceAccount из этого namespace.

Плагин возвращает kubelet cacheDuration, равную сроку жизни выданного токена минус 60 секунд. С cacheType: ServiceAccount kubelet кеширует учётные данные отдельно для каждого ServiceAccount. Второй под того же ServiceAccount берёт их из кеша, а под другого ServiceAccount вызывает плагин сам.

Что нужно кластеру кроме плагина

Плагин работает только вместе с несколькими настройками кластера. Без них образ не загрузится, а причину этого найти трудно.

Ноды должны иметь право запрашивать токены с нужным audience. Начиная с Kubernetes 1.33 действует ServiceAccountNodeAudienceRestriction. Нода не получит от API‑сервера токен с audience, которого нет в проецируемых томах пода, пока ей это явно не разрешили.

Без разрешения kubelet пишет в журнал is not authorized to request tokens for this audience и загружает образ без учётных данных. Под получает общий отказ, как будто плагина нет вовсе. Разрешение выдаёт ClusterRole, привязанная к группе system:nodes:

rules:
  - apiGroups: [""]
    resources: ["artifact-keeper"]
    verbs: ["request-serviceaccounts-token-audience"]

resources здесь — сам audience.

Если у нод нет права на audience, плагин не запускается вообще: реестр не получает ни запроса, ни ошибки, а в events остаётся общий отказ

Если у нод нет права на audience, плагин не запускается вообще: реестр не получает ни запроса, ни ошибки, а в events остаётся общий отказ

Флаги kubelet. Нужны --image-credential-provider-config и --image-credential provider-bin-dir. Мой kind‑конфиг с kubeadmConfigPatches их не применил: пришлось дописать флаги в /var/lib/kubelet/kubeadm-flags.env на ноде и перезапустить kubelet.

Если плагин не вызывается совсем, в журнале kubelet нет ни одной строки от ak-kubelet provider. Тогда проверьте matchImages (хост образа должен совпасть) и разрешение на audience из предыдущего пункта.

Реестр обменивает токен на доступ только на чтение

Чтобы реестр принимал токены ServiceAccount и выдавал по ним учётные данные, в Artifact Keeper 1.11 есть провайдер CI OIDC типа kubernetes. Для этого сначала создаю провайдера. На моём стенде реестр работает внутри того же kind‑кластера: передаю ему ключи подписи статически, и ему не нужно обращаться к discovery‑эндпоинту API‑сервера:

kubectl get --raw /openid/v1/jwks > jwks.json
jq -n --slurpfile jwks jwks.json '{
      name: "prod-cluster", provider_type: "kubernetes",
      issuer_url: "https//kubernetes.default.svc.cluster.local",
      audience: "artifact-keeper",
      key_source: "static", static_jwks: $jwks[0]}' |
  curl -X POST https://ak.test/api/v1/admin/ci-oidc \
    -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -d @-

Потом создаю маппинг: он определяет, какие ServiceAccount получают доступ, и привязывает их к группе с правом чтения.

curl -X POST https://ak.test/api/v1/admin/ci-oidc/$PROVIDER_ID/mappings \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "team-ns", "priority": 10,
       "claim_filters":{"/kubernetes.io/namespace":"team"},
       "group_binding_ids":["'$GROUP_ID'"]}'

Ключ, который начинается с / , — JSON Pointer в claims токена. Один фильтр по namespace team пускает все ServiceAccount этого namespace. Совпадение точное: team не пустит team sandbox.

Все ServiceAccount, которых пускает один маппинг, работают под одной учётной записью реестра, у меня это ci-1007c2257aba. Различить их можно в логе реестра: для каждого обмена там записаны namespace, ServiceAccount, под и нода.

Чужой namespace получает отказ, свой образ загружается

Я поднял kind 1.34.3 и поставил Artifact Keeper 1.11.1 внутрь того же кластера. Плагин собрал из main под arm64 musl. Результаты:

Проверка

Результат

Под из team с ServiceAccount app

Образ загружен с первой попытки, без imagePullSecrets

Второй ServiceAccount other из team под тем же маппингом

Загружен, обмен вернул 200

Под из namespace outsider, которого нет в маппинге

ImagePullBackOff , в журнале kubelet 401 CI JWT did not match any identity mapping, как во введении

push выданным токеном 

denied: Token does not have required scope: write:artifacts, и на блобах, и на манифесте

Создать этим токеном API‑токен 

403

Создать провайдера с audience API‑сервера

400

Токен, который получает плагин, умеет только читать. Реестр ограничивает его типом провайдера и не сверяется с правами группы: даже если у группы есть право на запись, этот токен ничего не запишет.

Образ на ноде скрывает отказ

Проверка с outsider сначала меня обманула. Я запустил под в чужом namespace, и он стартовал. Причиной оказалось то, что образ уже лежал на ноде после первого пода. Kubelet не загружает образ, который уже есть, и до плагина дело не доходит.

Один и тот же под из чужого namespace запускается или падает в зависимости от imagePullPolicy и от того, есть ли образ на ноде. В первом случае реестр о нём не узнаёт.

Один и тот же под из чужого namespace запускается или падает в зависимости от imagePullPolicy и от того, есть ли образ на ноде. В первом случае реестр о нём не узнаёт.

Чтобы проверить отказ, нужен imagePullPolicy: Always или образ, которого на ноде нет. Пока этого не сделаешь, любая проверка «чужому ServiceAccount нельзя» проходит зелёной.

Плагин: один бинарь на Rust, токен не попадает в логи

Это один статический бинарь на Rust, около 1,7 МБ. HTTP‑клиент ureq с rustls, без async‑рантайма. Собирается под x86_64 и aarch64 musl, подписывается cosign.

Rust выбран ради близости к самому реестру: Artifact Keeper написан на нём же. Я хотел, чтобы команда реестра могла взять плагин на поддержку без смены стека и инструментов. Async‑рантайма в зависимостях нет сознательно: процесс живёт десятки миллисекунд, ему нужен один запрос.

Бинарь работает на каждой ноде под root, поэтому у него несколько правил, и под каждое есть тест:

  • Токен не попадает в логи ни на каком уровне, а если реестр вернёт его в тексте ошибки.

  • Плагин заменит его на [REDACTED].

  • http:// отвергается ещё до отправки запроса: токен нельзя послать по открытому каналу.

  • Редиректы не выполняются, чтобы токен не ушёл на другой адрес.

  • На весь обмен действует один таймаут, по умолчанию 10 секунд.

При ошибке плагин пишет одну строку в stderr и завершается с кодом 1. Kubelet записывает её в свой журнал и загружает образ без учётных данных. Поэтому в events виден только общий отказ, а подробности лежат на ноде: journalctl -u kubelet | grep ak-kubelet provider.

EKS, GKE и AKS: схема есть, прогона на настоящих кластерах нет

На этих платформах флаги kubelet напрямую не задать. Схемы ниже проверены по частям и на kind, так что это рабочий план без гарантий.

EKS (Amazon Linux 2023). nodeadm пишет config.json с одним провайдером, ecr credential-provider. Скрипт в user data копирует этот файл и добавляет запись плагина. Файл с добавленной записью скрипт передаёт kubelet вторым флагом --image-credential provider-config. Из двух одинаковых флагов kubelet берёт последний, поэтому читает этот файл, а запись ECR в нём остаётся: системные образы EKS так же приходят из ECR. На Bottlerocket, EKS Auto Mode и Fargate так сделать нельзя.

GKE Standard и AKS. Ни Google, ни Microsoft не дают добавить плагин через API. Для них в репозитории есть привилегированный DaemonSet: он находит конфиг провайдеров в командной строке kubelet, дописывает туда плагин и перезапускает kubelet. Если kubelet после перезапуска не поднялся, DaemonSet возвращает прежний конфиг.

Вендор такую схему не поддерживает и может сломать её обновлением образа ноды. Под DaemonSet работает как root на каждой ноде, и менять его ConfigMap должны только администраторы кластера.

19 мс на вызов, и кеш не даёт повторять обмен

Замеры на одной ноде kind (ноутбук, реестр в том же кластере): сетевая задержка почти нулевая. Цифры показывают цену самого плагина.

  • Один вызов плагина занимает около 19 мс вместе с запуском процесса и TLS‑рукопожатием: 100 последовательных вызовов заняли 1,9 с. 

  • 200 вызовов, 20 параллельно, прошли за 1,1 с без ошибок.

  • Срок жизни токена реестра в моей настройке 30 минут. Для токена ServiceAccount на час плагин вернул cacheDuration 1740 с: это 30 минут минус 60 секунд запаса.

  • 20 подов одного ServiceAccount, которого ещё не было на ноде, дали 2 обмена вместо 20. Скорее всего, второй обмен случился до того, как кеш заполнился.

cacheDuration определяет более короткий из двух токенов: при токене ServiceAccount на 10 минут плагин вернул 539 с, при токене на час — 1740 с.

Что остаётся за границей моих тестов

  • Настоящие EKS, GKE и AKS. Единственный реальный прогон — kind.

  • Версии Kubernetes новее 1.34.3. Все проверки шли на 1.34.3, минимальной версии, которую поддерживает плагин.

  • Режим реестра, в котором он сам забирает ключи подписи у кластера (discovery). У меня были статические ключи.

  • Нагрузка на сам реестр: как он держит сотни нод, которые одновременно приходят за токеном.

Что дальше: запрос на перенос в организацию Artifact Keeper

В трекере Artifact Keeper есть issue № 4520: в нём просят перенести плагин в организацию проекта, выпустить первый релиз, сослаться на него из документации и добавить тип kubernetes в форму провайдера в веб‑интерфейсе. Пока этой формы нет, провайдера и маппинги создают через API, как в примерах выше.

Плагин, схема установки и все конфиги лежат в репозитории artifact‑keeper‑kubelet‑provider, релизы подписаны cosign. Сейчас репозиторий находится в моём аккаунте: если его перенесут в организацию Artifact Keeper, адрес изменится. Буду рад, если вы попробуете плагин на EKS, GKE или AKS и расскажете в issue, что получилось и что нет: отчёты с настоящих облаков мне нужнее всего. Если что‑то найдётся, с радостью разберусь и поправлю.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.