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 в путь включать не нужно.