Загрузка образов в Kubernetes без паролей с Artifact Keeper
С каждым новым кластером начинается одна и та же рутина: пароль от реестра нужно скопировать в каждый 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: trueserviceAccountTokenAudience задаёт, для кого выпущен токен. Здесь нужно отдельное значение, которое не совпадает с audience API‑сервера. Если взять audience API‑сервера, реестр начнёт принимать токен любого пода, а токен, который плагин отправляет реестру, окажется валидным и для API‑сервера. Artifact Keeper такого провайдера не создаёт и отвечает 400.
Весь обмен показан на схеме:

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.

Флаги 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. Результаты:
Проверка | Результат |
Под из | Образ загружен с первой попытки, без |
Второй | Загружен, обмен вернул 200 |
Под из namespace |
|
|
|
Создать этим токеном API‑токен | 403 |
Создать провайдера с audience API‑сервера | 400 |
Токен, который получает плагин, умеет только читать. Реестр ограничивает его типом провайдера и не сверяется с правами группы: даже если у группы есть право на запись, этот токен ничего не запишет.
Образ на ноде скрывает отказ
Проверка с outsider сначала меня обманула. Я запустил под в чужом namespace, и он стартовал. Причиной оказалось то, что образ уже лежал на ноде после первого пода. Kubelet не загружает образ, который уже есть, и до плагина дело не доходит.

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на час плагин вернулcacheDuration1740 с: это 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, что получилось и что нет: отчёты с настоящих облаков мне нужнее всего. Если что‑то найдётся, с радостью разберусь и поправлю.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.