External Secrets Operator: синхронизация Vault KV v2 в Kubernetes
Published: 2026-02-13
External Secrets Operator (ESO) связывает HashiCorp Vault и Kubernetes. Вместо ручного запечатывания секретов через kubeseal или шаблонизации в CI вы описываете, что хотите получить из Vault, и ESO автоматически создаёт Kubernetes Secret, обновляя его по расписанию.
В посте — полная настройка: установка, конфигурация ClusterSecretStore, примеры ExternalSecret для типичных сценариев (один ключ, выгрузка всего пути, учётные данные registry) и порядок отладки, когда синхронизация не работает.
Установка
ESO разворачивается Helm-чартом через FluxCD:
yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: external-secrets
spec:
interval: 1h
chart:
spec:
chart: external-secrets
version: "0.14.4"
sourceRef:
kind: HelmRepository
name: external-secrets
namespace: flux-system
values:
installCRDs: true
replicaCount: 1
serviceMonitor:
enabled: true
interval: 60s
labels:
release: kube-prom-stack
installCRDs: true подходит для однокластерных установок. Для мультикластерных управляйте CRD отдельно во избежание конфликтов версий при обновлении ESO в разных кластерах в разное время.
После установки проверьте наличие CRD:
bashkubectl get crd | grep external-secrets
Должны присутствовать clustersecretstores.external-secrets.io, externalsecrets.external-secrets.io и другие.
ClusterSecretStore: подключение ESO к Vault
ClusterSecretStore — кластерный ресурс. Содержит конфигурацию подключения и аутентификации для Vault:
yamlapiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-infra
spec:
provider:
vault:
server: "http://vault.vault.svc.cluster.local:8200"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: "external-secrets"
namespace: "external-secrets"
server — адрес Vault. Внутреннее DNS-имя сервиса лучше, чем имя хоста ingress: нет TLS, нет внешнего round-trip, нет зависимости от доступности ingress.
path: "secret" — путь монтирования KV. Если вы монтировали по другому пути (например, vault secrets enable -path=kv kv-v2), обновите это поле.
version: "v2" — должно совпадать с версией KV. У KV v2 и v1 разные пути API; при неправильной версии ESO возвращает ошибку «secret not found».
role: "external-secrets" — роль Kubernetes-аутентификации в Vault, под которой работает ESO.
Создание роли в Vault
bashvault write auth/kubernetes/role/external-secrets \
bound_service_account_names=external-secrets \
bound_service_account_namespaces=external-secrets \
policies=read-secrets \
ttl=1h
И политика:
hcl# read-secrets.hcl
path "secret/data/*" {
capabilities = ["read"]
}
path "secret/metadata/*" {
capabilities = ["read", "list"]
}
bashvault policy write read-secrets read-secrets.hcl
ExternalSecret: получение конкретного ключа из Vault
yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: my-app-db-password
namespace: app
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-infra
kind: ClusterSecretStore
target:
name: my-app-db-password
creationPolicy: Owner
data:
- secretKey: password # имя ключа в K8s Secret
remoteRef:
key: secret/data/app/db # путь KV v2 в Vault
property: password # имя поля в Vault
ESO создаёт Secret с именем my-app-db-password в namespace app с data.password, полученным из Vault. Каждые refreshInterval значение перечитывается и Secret обновляется, если оно изменилось.
Формат пути KV v2
В KV v2 путь API отличается от CLI-пути:
- CLI:
vault kv get secret/app/db(без префиксаdata/) - API/ESO:
secret/data/app/db(с префиксомdata/)
На этом спотыкаются почти все в первый раз. Если ESO выдаёт «secret not found», а vault kv get из командной строки работает — скорее всего, дело в формате пути.
Синхронизация всех ключей из пути Vault
Если в KV-пути Vault много полей, используйте dataFrom вместо перечисления каждого:
yamlspec:
dataFrom:
- extract:
key: secret/data/app/config
ESO создаст Kubernetes Secret с записью на каждое поле секрета Vault. Если в секрете Vault есть поля username, password и host, в K8s Secret попадут все три.
Переименование ключей
Если имена полей в Vault не совпадают с ожидаемыми приложением в Kubernetes Secret:
yamlspec:
data:
- secretKey: DB_PASSWORD # ключ K8s Secret
remoteRef:
key: secret/data/app/db
property: password # имя поля Vault
- secretKey: DB_HOST
remoteRef:
key: secret/data/app/db
property: host
Синхронизация учётных данных Docker registry
У image pull secrets особый формат. ESO умеет формировать их напрямую:
yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: registry-creds
namespace: app
spec:
refreshInterval: 24h
secretStoreRef:
name: vault-infra
kind: ClusterSecretStore
target:
name: registry-creds
creationPolicy: Owner
template:
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: |
{"auths":{"registry.example.com":{"auth":"{{ .auth | b64enc }}"}}}
data:
- secretKey: auth
remoteRef:
key: secret/data/registry
property: auth
Или если полный dockerconfigjson хранится в Vault:
yamlspec:
data:
- secretKey: .dockerconfigjson
remoteRef:
key: secret/data/registry
property: dockerconfigjson
target:
template:
type: kubernetes.io/dockerconfigjson
creationPolicy: Owner vs Merge
Owner (рекомендуется): ESO владеет Secret. При удалении ExternalSecret Secret тоже удаляется. Secret нельзя изменить вручную — ESO перезапишет изменения при следующем refresh.
Merge: ESO добавляет ключи в существующий Secret. Полезно, когда Secret создаёт Helm-чарт, а из Vault нужно лишь дописать дополнительные ключи. Существующие ключи в Secret сохраняются.
None: ESO вообще не управляет жизненным циклом Secret — только создаёт его, если не существует, никогда не обновляет и не удаляет.
Сравнение ESO и SealedSecrets
| ESO | SealedSecrets | |
|---|---|---|
| Источник секретов | Vault, AWS SM, GCP SM и т.д. | Git (зашифрованный) |
| Ротация | Автоматически по расписанию | Ручное перезапечатывание |
| Audit trail | В Vault | В Git |
| Air-gapped | Нужен доступ к Vault | Только Git |
| Перезапечатывание при пересборке кластера | Нет | Да |
| Работает без Vault | Нет | Да |
| Первоначальный bootstrap | Нужны Vault + ESO сначала | Работает с нуля |
Используем оба: SealedSecrets для bootstrap (kubeconfig, начальные учётные данные registry, секреты инициализации Vault) и ESO для секретов приложений, которые живут в Vault и часто ротируются. Правило: всё, что нужно самому ESO для старта (адрес Vault, конфиг аутентификации), должно быть SealedSecret; всё остальное — ExternalSecret.
Отладка
bash# Проверить, успешно ли синхронизировался ESO
kubectl get externalsecret -n app
# Посмотреть статус последней синхронизации и ошибку
kubectl describe externalsecret my-app-db-password -n app
# Принудительная немедленная ресинхронизация
kubectl annotate externalsecret my-app-db-password \
force-sync=$(date +%s) -n app --overwrite
Приём с аннотацией force-sync выручает, когда значение в Vault уже обновлено, а ждать следующего интервала синхронизации не хочется.
Проверка состояния ClusterSecretStore
bashkubectl get clustersecretstore vault-infra
kubectl describe clustersecretstore vault-infra
Рабочий ClusterSecretStore показывает Valid: True. Частые причины Invalid:
- Vault запечатан — проверьте
vault status - Неправильно настроена роль аутентификации — проверьте
vault read auth/kubernetes/role/external-secrets - Неправильный адрес Vault — проверьте поле
serverв ClusterSecretStore
Типичные сообщения об ошибках
| Ошибка | Причина |
|---|---|
could not find key ... |
Поле property в remoteRef не существует в секрете Vault |
secret not found |
Неправильный формат пути (забыт префикс data/ для KV v2) |
permission denied |
У роли Vault, которую использует ESO, нет прав чтения этого пути |
connection refused |
Vault запечатан или неправильный URL в поле server |
ESO с несколькими Vault namespace
При использовании Vault Enterprise с namespaces добавьте namespace в ClusterSecretStore:
yamlspec:
provider:
vault:
server: "http://vault.vault.svc.cluster.local:8200"
namespace: "admin/dev" # Vault Enterprise namespace
path: "secret"
version: "v2"
path указывается относительно namespace — префикс namespace в путь включать не нужно.