Секреты в Kubernetes: Sealed Secrets и External Secrets

Обычный Secret в Kubernetes — это base64, а не шифрование, и класть его в git нельзя. Разбираем два рабочих подхода для GitOps: Sealed Secrets (шифрование прямо в репозитории) и External Secrets Operator (подтягивание из Vault и облачных хранилищ), плюс ротацию и RBAC.

Первое, что удивляет новичка в Kubernetes: значение Secret кодируется в base64 — а это представление в API/YAML (как его отдаёт kubectl -o yaml), а не шифрование: от открытого текста его отделяет ровно одна команда base64 -d, доступная любому, кто может прочитать объект. И отдельно — в etcd объект по умолчанию лежит без шифрования (encryption-at-rest — отдельная настройка кластера), так что дамп хранилища раскрывает содержимое независимо от формата сериализации (в etcd это не base64, а — при стандартной конфигурации apiserver — бинарный protobuf; media-type сериализации задаётся флагом --storage-media-type). Положить такой манифест в git — значит слить секрет в историю навсегда, откуда его уже не вычистить. Для GitOps-потока нужен другой механизм. Разберём два рабочих: Sealed Secrets, который шифрует секрет прямо для коммита в репозиторий, и External Secrets Operator, который вообще не хранит секрет в git, а подтягивает его из внешнего хранилища. И, отдельно, третий вопрос, который ни один из первых двух не закрывает: кто внутри namespace вообще имеет право прочитать готовый Secret.

Это седьмая статья серии «Kubernetes на практике». Оба подхода — про GitOps, поэтому смыкаются с «GitOps с ArgoCD: progressive delivery, canary и blue-green»готовится, с 29 октября: секрет должен ехать по тому же декларативному конвейеру, что и остальные манифесты. Все выводы ниже сняты на живом стенде kubernetes/secrets в публичном репозитории digital-cookbook: Kubernetes 1.36.2 на кластере k8s-volga, контроллер sealed-secrets 0.28.0, kubeseal 0.37.0 (запускался в кластере из тулбокс-образа alpine/k8s:1.36.2), External Secrets Operator v0.14.3, HashiCorp Vault 1.21.4 (dev-режим, поднят внутри стенда), busybox:1.37. Все значения в примерах — фиктивные, придуманные для демонстрации: cookbook-not-a-real-secret, first-value, cookbook-root-token. Настоящих паролей и токенов здесь нет ни одного.

Ретрофутуристская схема двух путей секрета в кластер: слева перфокарточный шкаф-репозиторий, из которого выходит запечатанный сургучом конверт с шифротекстом, по трубе он попадает в контроллер-сейф с латунным ключом, разворачивающим его в карточку-Secret; справа отдельный бак-хранилище Vault снаружи ограды кластера, от него насос-оператор по тонкой трубке качает значение во вторую карточку-Secret, а в репозитории лежит лишь ярлык-ссылка без содержимого; понизу обе карточки накрыты одной кованой решёткой с массивным навесным замком RBAC, сквозь прутья которого виден смотритель с правом get secrets

В статье

Почему обычный Secret — не секрет

Слово «секрет» создаёт ложное ощущение защиты. Криптографической конфиденциальности по умолчанию у Secret нет: значения в поле data всего лишь закодированы в base64, и это кодирование, а не шифрование. Но сводить всё к «соглашению» тоже неверно — к Secret Kubernetes применяет ряд мер, которых нет у ConfigMap: значение kubelet доставляет только на узлы, где есть под, который его использует (а не раскладывает по всем); при монтировании как volume оно держится в tmpfs/памяти, а не на диске узла; и у Secret есть типизированные виды (kubernetes.io/tls, dockerconfigjson и другие) с проверками формата. Шифрование покоя (encryption-at-rest) к отличиям не относится — включить его можно для любых сохраняемых ресурсов, в том числе ConfigMap. То есть криптозащиты по умолчанию нет, но обращение с Secret строже, чем с ConfigMap. Разница видна в три шага и одну команду.

Обычный Secret demo-plain со stringData — Kubernetes сам кодирует значение в data. Запрашиваем поле напрямую:

kubectl get secret demo-plain -n cookbook-k8s -o jsonpath='{.data.demo-password}'
$ kubectl get secret demo-plain -n cookbook-k8s -o jsonpath='{.data.demo-password}'
Y29va2Jvb2stbm90LWEtcmVhbC1zZWNyZXQ=

Выглядит непроницаемо ровно до первой команды декодирования — той самой, которую знает наизусть любой, у кого есть доступ на чтение объекта:

$ kubectl get secret demo-plain -n cookbook-k8s -o jsonpath='{.data.demo-password}' | base64 -d
cookbook-not-a-real-secret

То же значение видно и изнутри пода, который читает секрет через переменную окружения:

$ kubectl -n cookbook-k8s exec secret-reader -- sh -c 'echo "$DEMO_PASSWORD"'
cookbook-not-a-real-secret

Три источника — вывод API, декодированная строка и переменная окружения работающего пода — дали одно и то же открытое значение. Метаданные самого объекта при этом ничего не скрывают:

$ kubectl get secret demo-plain -n cookbook-k8s
NAME         TYPE     DATA   AGE
demo-plain   Opaque   1      12s

Отсюда точная формулировка того, от чего Secret защищает, а от чего нет. В обычном табличном выводе kubectl get secret значения не видны — там показаны тип и число ключей, но не сами значения; к тому же секрет не логируется по умолчанию некоторыми инструментами. То есть Secret закрывает от случайного взгляда через плечо и от попадания в общий дамп конфигурации. Но защита на этом заканчивается: kubectl get secret -o yaml (равно как -o json) отдаёт поле data в base64 открыто, без всякого «явного запроса», а base64 -d снимает это кодирование одной командой. У того, кто может прочитать объект в namespace, на руках открытый текст, а внутри пода значение и вовсе лежит развёрнутым. Два практических следствия. Первое: шифрование значений в etcd (encryption-at-rest) — это отдельная настройка кластера, а не поведение по умолчанию; без неё дамп etcd отдаёт секреты открытым текстом. Второе, важное для GitOps: сырой Secret в git не коммитим никогда — ни в приватный репозиторий, ни «временно». Именно этот разрыв и закрывают два следующих механизма.

Sealed Secrets: шифрование для git

Идея Sealed Secrets — асимметричное шифрование, устроенное так, чтобы зашифрованный результат можно было безопасно положить в репозиторий. У контроллера в кластере есть пара ключей RSA. Приватный ключ хранится внутри кластера как обычный Secret в kube-system и наружу не выходит. Публичный сертификат контроллер сам отдаёт всем желающим по HTTP (GET /v1/cert.pem на своём сервисе, без аутентификации — это не секрет). Кто угодно с публичным сертификатом может зашифровать значение в объект SealedSecret утилитой kubeseal, но расшифровать обратно способен только контроллер, у которого есть приватный ключ.

Из этой асимметрии и следует главное свойство: зашифрованный SealedSecret безопасно класть в git. Утечка репозитория не даёт открытого текста, потому что приватного ключа в репозитории нет, а обычный Secret внутри кластера контроллер создаёт сам, применив ключ, который его не покидает.

Шифрование в стенде выполнялось прямо в кластере, без установки бинарника на машину: одноразовый под с образом alpine/k8s:1.36.2 обращался внутри к /v1/cert.pem контроллера и шифровал фиктивное значение. Здесь стоит сверять версии. Клиент kubeseal в стенде — 0.37.0, а контроллер sealed-secrets — 0.28.0: клиент новее контроллера на несколько минорных версий. Для операций стенда (шифрование значения, вывод в --format yaml) расхождение проблем не создало, но это не гарантия на будущее — новый клиент может поддерживать флаги или поведение (например, режимы scope), которых у более старого контроллера ещё нет. И отдельно: тег образа версию kubeseal не пинит — alpine/k8s:1.36.2 каталогизирован по версии Kubernetes, а не по версии утилиты внутри, поэтому фактическую версию стоит проверять явно (kubeseal --version), а не полагаться на тег:

kubectl -n cookbook-k8s exec kubeseal-runner -- kubeseal --version
kubectl -n cookbook-k8s exec kubeseal-runner -- sh -c \
  'curl -sS -o /tmp/cert.pem http://sealed-secrets.kube-system.svc:8080/v1/cert.pem'
kubectl -n cookbook-k8s exec kubeseal-runner -- sh -c \
  'kubeseal --cert /tmp/cert.pem --format yaml < /tmp/secret.yaml' > 20-sealedsecret.yaml

В репозиторий попадает только результат — манифест SealedSecret, где значение лежит в поле encryptedData как base64 от результата гибридного шифрования: само значение шифруется AES-GCM случайным одноразовым ключом, а этот ключ — RSA-OAEP публичным ключом контроллера. Публичный сертификат и исходный плейнтекст существовали лишь внутри одноразового пода и удалены вместе с ним.

Дальше — сам цикл разворачивания. После kubectl apply зашифрованного манифеста контроллер подхватил SealedSecret, расшифровал его приватным ключом и создал обычный Secret:

$ kubectl get sealedsecret,secret -n cookbook-k8s -l stand=cookbook-secrets
NAME                                   STATUS   SYNCED   AGE
sealedsecret.bitnami.com/demo-sealed            True     15s

NAME                 TYPE     DATA   AGE
secret/demo-plain    Opaque   1      3h46m
secret/demo-sealed   Opaque   1      15s

SealedSecret demo-sealed в состоянии SYNCED: True, рядом появился Secret demo-sealed того же возраста. Внутри него — исходное фиктивное значение, развёрнутое из шифротекста:

$ kubectl get secret demo-sealed -n cookbook-k8s -o jsonpath='{.data.demo-password}' | base64 -d
cookbook-sealed-not-a-real-secret

Ключевая деталь связи двух объектов — ownerReferences у созданного Secret:

$ kubectl get secret demo-sealed -n cookbook-k8s -o jsonpath='{.metadata.ownerReferences}'
[{"apiVersion":"bitnami.com/v1alpha1","controller":true,"kind":"SealedSecret","name":"demo-sealed","uid":"2ae1a538-f929-41a1-b6e4-330d3bb8ab5b"}]

Secret принадлежит SealedSecret как владельцу: удаление SealedSecret каскадно удаляет и развёрнутый из него Secret. Управляемый объект в git — это SealedSecret; обычный Secret — производное, которое живёт и умирает вместе с ним.

Привязка к namespace и имени

У Sealed Secrets есть свойство, которое легко принять за проверку прав, хотя это криптография. По умолчанию шифротекст привязан к паре namespace/name, для которой шифровался, — режим scope: strict. Тот же самый зашифрованный blob, применённый в другом namespace, не разворачивается вообще.

Проверяется прямо: тот же манифест demo-sealed с подменённым namespace: на cookbook-k8s-alt. Secret не появляется, а у SealedSecret — статус False с точным текстом причины:

$ kubectl get sealedsecret demo-sealed -n cookbook-k8s-alt --show-kind
NAME                                   STATUS                                        SYNCED   AGE
sealedsecret.bitnami.com/demo-sealed   no key could decrypt secret (demo-password)   False    20s

Тот же текст — в логах контроллера, где видно, что он честно пытался и сдался после серии повторов:

level=INFO msg="Event(...Namespace:\"cookbook-k8s-alt\", Name:\"demo-sealed\"...): type: 'Warning' reason: 'ErrUnsealFailed' Failed to unseal: no key could decrypt secret (demo-password)"
level=ERROR msg="Error updating, giving up" key=cookbook-k8s-alt/demo-sealed error="no key could decrypt secret (demo-password)"

Формулировка no key could decrypt secret (demo-password) здесь буквальна: ни один из ключей контроллера не подошёл — не потому, что не хватило прав, а потому, что шифротекст, сделанный для cookbook-k8s/demo-sealed, математически не расшифровывается для cookbook-k8s-alt/demo-sealed. Скопировать SealedSecret в другой namespace или переименовать его бесполезно: расшифровка просто не пройдёт. Практический смысл двоякий. С одной стороны, это защита от бездумного переноса: нельзя случайно «переиспользовать» чужой запечатанный секрет в другом окружении. С другой — это то, о чём спотыкаются при реорганизации репозитория: переезд манифеста в другой namespace требует пере-шифровать секрет заново, старый blob после переезда мёртв. У kubeseal есть и более широкие режимы (namespace-wide, cluster-wide), но по умолчанию действует самый строгий, и это разумный дефолт.

Ротация ключа и восстановление

Асимметрия, которая делает Sealed Secrets удобным, оборачивается одной жёсткой зависимостью: всё держится на приватном ключе контроллера. Это отдельная эксплуатационная тема, и на боевом ключе в стенде ничего не проверялось — опишем механизм словами, потому что цена ошибки здесь высока, а экспериментировать с рабочим ключом нельзя.

Первое и главное: приватный ключ контроллера надо беречь и бэкапить. Он хранится как Secret в kube-system, и его потеря означает ровно то, что показала предыдущая секция в миниатюре, но уже необратимо: ни один существующий SealedSecret больше не расшифровать. Шифротекст в git при этом цел, но бесполезен — расшифровать его нечем, а исходных значений может уже ни у кого не быть. Поэтому резервная копия ключа (снятая безопасно и хранимая отдельно от кластера) — не опциональная предосторожность, а условие восстановимости всего, что запечатано этим контроллером.

Второе — плановая ротация. Контроллер умеет держать несколько активных ключей одновременно: новым он шифрует, всеми имеющимися — пытается расшифровать. Это позволяет вводить свежий ключ, не ломая старые SealedSecret. Полное же выведение старого ключа из обращения требует пере-шифровать существующие секреты новым ключом и закоммитить обновлённые манифесты — то есть это управляемая процедура с прогоном по репозиторию, а не одномоментное переключение. Вывод для проектирования простой: Sealed Secrets переносит проблему хранения секрета из git в проблему хранения одного ключа в кластере — и этот ключ становится самым ценным объектом всей схемы. Кто отвечает за его бэкап и ротацию, должно быть решено до того, как в репозитории появится первый запечатанный секрет.

External Secrets: источник снаружи

External Secrets Operator (ESO) подходит к вопросу с обратной стороны. Secret и SealedSecret хранят значение — открытым или зашифрованным — внутри кластера или git. ExternalSecret не хранит его нигде из этого: исходное значение живёт во внешнем хранилище, а в git и в кластер попадает только ссылка на путь и ключ. Оператор периодически ходит в хранилище и синхронизирует актуальное значение во внутренний Secret.

Схема из двух объектов. SecretStore (или его кластерный аналог ClusterSecretStore) описывает, куда и как ходить: адрес хранилища, тип провайдера, способ аутентификации. ExternalSecret описывает, что именно взять: путь, ключ и имя целевого Secret. Namespaced SecretStore виден только в своём namespace, ClusterSecretStore — во всём кластере; для изолированного демо естественнее первое.

Здесь важна оговорка про источник. В стенде поднят собственный Vault в dev-режиме прямо внутри кластера — и это осознанный выбор по двум причинам. Во-первых, нельзя трогать боевые секреты ради демонстрации на фиктивных значениях: это риск без всякой необходимости. Во-вторых, воспроизводимость — у читателя, повторяющего пример на своём кластере, боевого хранилища автора нет и быть не может, а dev-Vault поднимается одним манифестом. В проде источником был бы полноценный Vault или облачный Secret Manager (в инфраструктуре РФ это, например, менеджеры секретов облачных провайдеров); механика ExternalSecret при этом та же, меняются только параметры SecretStore. Провайдер Vault и его разворачивание в HA разбираются отдельно в «Управление секретами: Vault на практике»готовится, с 20 ноября.

Само разворачивание. В свой Vault кладём фиктивное значение, затем применяем SecretStore и ExternalSecret:

kubectl -n cookbook-k8s exec deploy/vault -- sh -c \
  'VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=cookbook-root-token \
   vault kv put secret/demo password=first-value'

Результат синхронизации — оба объекта готовы, целевой Secret заполнен:

$ kubectl get secretstore,externalsecret -n cookbook-k8s
NAME                                          AGE   STATUS   CAPABILITIES   READY
secretstore.external-secrets.io/vault-stand   45s   Valid    ReadWrite      True

NAME                                               STORETYPE     STORE         REFRESH INTERVAL   STATUS         READY
externalsecret.external-secrets.io/demo-external   SecretStore   vault-stand   15s                SecretSynced   True

SecretStore vault-stand — Valid/Ready: True, ExternalSecret demo-external — SecretSynced/Ready: True. В целевом Secret demo-external — то же значение, что лежит в Vault:

$ kubectl get secret demo-external -n cookbook-k8s -o jsonpath='{.data.password}' | base64 -d
first-value

Существенное отличие от предыдущих подходов: этого значения нет ни в git, ни в манифесте ExternalSecret — там только remoteRef на путь secret/demo и ключ password. В кластер значение зашло исключительно через синхронизацию, и репозиторий о нём ничего не знает.

Ротация значения

Централизованная ротация — главный практический аргумент за External Secrets: значение меняют один раз в хранилище, а ESO разносит его сам. Замерим, за сколько.

Значение в Vault сменили на second-value и дальше опрашивали целевой Secret каждые 5 секунд по фактическому UTC-времени:

$ date -u +%Y-%m-%dT%H:%M:%SZ; kubectl -n cookbook-k8s exec deploy/vault -- sh -c \
    'VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=cookbook-root-token \
     vault kv put secret/demo password=second-value' > /dev/null
$ for i in 1 2 3 4; do
    echo "$(date -u +%H:%M:%S)  $(kubectl -n cookbook-k8s get secret demo-external \
        -o jsonpath='{.data.password}' | base64 -d)"; sleep 5
  done
2026-07-23T16:38:11Z   ← vault kv put secret/demo password=second-value
16:38:12  first-value
16:38:17  first-value
16:38:22  first-value
16:38:27  second-value

Новое значение обнаружилось в Secret примерно через 16 секунд после записи в Vault. Здесь нужна аккуратность в интерпретации. refreshInterval: 15s в ExternalSecret — это периодичность, с которой ESO сам обращается к Vault за актуальным значением, а не гарантия верхней границы задержки. Если изменение в хранилище происходит сразу после очередного опроса, до следующего пройдёт почти весь интервал. Плюс дискретность собственного замера — 5 секунд: сама смена случилась где-то в окне между 16:38:22 (ещё first-value) и 16:38:27 (уже second-value), точнее по этим данным не сказать. Так что измеренные ~16 секунд при заявленных 15 — ожидаемая величина, а не нарушение и не аномалия.

Дальше — практический факт, который важнее самого времени синхронизации. Под external-secret-reader читает password из Secret demo-external переменной окружения DEMO_PASSWORD — то есть замер снимался именно на env-переменных. После ротации Secret уже новый, а под — нет:

$ kubectl -n cookbook-k8s exec external-secret-reader -- sh -c 'echo "$DEMO_PASSWORD"'
first-value

Это значение $DEMO_PASSWORD изнутри работающего пода уже после того, как Secret обновился. Для сравнения — что лежит в Secret demo-external прямо сейчас:

$ kubectl get secret demo-external -n cookbook-k8s -o jsonpath='{.data.password}' | base64 -d
second-value

И при этом под ни разу не перезапускался:

$ kubectl get pod external-secret-reader -n cookbook-k8s
NAME                     READY   STATUS    RESTARTS   AGE
external-secret-reader   1/1     Running   0          2m24s

RESTARTS 0 — процесс всё это время жил один и тот же. Причина расхождения не в ESO: переменные окружения контейнера задаются один раз при старте процесса, и оператор синхронизирует именно объект Secret, а не память уже запущенных процессов, которые этот Secret когда-то прочитали. Отсюда вывод, но с важной оговоркой про способ доставки. Для env-переменных всё однозначно: значение фиксируется один раз при старте процесса и не обновляется никогда — как и показал замер. Для Secret, смонтированного как volume, поведение другое: kubelet сам обновляет спроецированные в под файлы с eventual consistency (с оговоркой — кроме монтирования через subPath и кроме immutable-Secret). Но даже когда файл в проекции обновился, работающий процесс должен ещё сам перечитать этот файл — обновление файла в памяти уже читавшего его процесса само по себе не появляется. Практический вывод от этого не меняется: обновление Secret не подхватывается работающей нагрузкой само по себе — ни через env (никогда), ни через volume для приложения, не умеющего перечитывать файл. Чтобы под гарантированно увидел новое значение, его перекатывают, и это отдельная задача — обычно её решают автоматической перекаткой по хешу содержимого Secret (аннотация с чек-суммой в шаблоне пода, которую меняет CI/CD или контроллер вроде Reloader), а не полагаются на то, что «секрет обновился — значит, приложение подхватило».

RBAC на секреты

Три предыдущих подхода отвечали на вопрос «где хранить и как доставить значение»: открытым текстом, зашифрованным в git, синхронизацией из внешнего хранилища. Есть четвёртый, ортогональный вопрос — «кто внутри namespace может прочитать итоговый Secret» — и на него ни SealedSecret, ни ExternalSecret не отвечают. Оба в конце концов материализуют обычный Secret, а его чтение регулируют права RBAC — и не только явное get secrets.

Для контраста — два ServiceAccount. limited привязан к роли с правами get/list/watch только на pods и без единого права на secrets. Проверка kubectl auth can-i от его имени:

$ kubectl auth can-i get secrets -n cookbook-k8s --as=system:serviceaccount:cookbook-k8s:limited
no
$ kubectl auth can-i get pods -n cookbook-k8s --as=system:serviceaccount:cookbook-k8s:limited
yes

Роль работает как задумано: к подам доступ есть, к секретам нет. Для полноты — тот же вопрос дефолтному ServiceAccount namespace:

$ kubectl auth can-i get secrets -n cookbook-k8s --as=system:serviceaccount:cookbook-k8s:default
no

В cookbook-k8s нет унаследованных байндингов, дающих дефолтному SA доступ к секретам, — это стоит проверять в своём кластере, а не предполагать. Реальная попытка чтения от имени limited (не can-i, а живой get) упирается в отказ API с точной формулировкой:

$ kubectl get secret demo-plain -n cookbook-k8s --as=system:serviceaccount:cookbook-k8s:limited
Error from server (Forbidden): secrets "demo-plain" is forbidden: User "system:serviceaccount:cookbook-k8s:limited" cannot get resource "secrets" in API group "" in the namespace "cookbook-k8s"

Запрет специфичен для ресурса secrets: тот же SA нормально отдаёт список подов, отказ приходит именно на секреты, а не на весь SA целиком.

Обратная сторона — и есть ключевой урок. ServiceAccount с правом на secrets видит все секреты namespace независимо от способа доставки:

$ kubectl -n cookbook-k8s get secrets --as=system:serviceaccount:cookbook-k8s:broad \
    -l stand=cookbook-secrets
NAME                TYPE     DATA   AGE
demo-external       Opaque   1      10m
demo-plain          Opaque   1      4h8m
demo-sealed         Opaque   1      22m
vault-stand-token   Opaque   1      10m

В одном списке — demo-plain (обычный Secret), demo-sealed (развёрнутый контроллером из SealedSecret) и demo-external (материализованный ESO из ExternalSecret). Ни по происхождению, ни по способу хранения различий нет: все три — обычные Secret, и кто может их прочитать, решают права RBAC — и не только явное get secrets. Ни шифрование блоба в git, ни вынос значения во внешнее хранилище этого не меняют.

Отсюда — принцип наименьших привилегий и то, на что реально смотреть. Отдельная узкая роль вроде «читать только эти секреты» редко бывает источником инцидента. Течёт обычно через широкие конструкции, которые дают доступ к секретам попутно: ClusterRole со wildcard-правами (resources: ["*"] или verbs: ["*"]); встроенные admin/edit (у edit по умолчанию есть get/list/watch на secrets — это часто забывают, выдавая роль «для CI» или «разработчику»); RoleBinding, ссылающийся на такую ClusterRole в чувствительном namespace; сервис-аккаунты, которым RoleBinding достался по инерции от шаблона; и, что легко упустить, право создавать поды или деплойменты в namespace — без всякого get secrets такой субъект может смонтировать любой Secret этого namespace (как volume или через envFrom) в свой под и прочитать значение изнутри контейнера, вернув его наружу. Это документированный путь эскалации, а не теоретический: право на создание подов фактически равносильно чтению всех секретов их namespace. Проверять стоит не только явные resources: ["secrets"], но и wildcard, биндинги на встроенные роли и права на создание подов/деплойментов. Модели прав — RBAC, ABAC, ReBAC — и их границы разбираются отдельно в «Модели прав: RBAC, ABAC, ReBAC — и где стоит enforcement»готовится, с 20 ноября.

Sealed Secrets: шифротекст в gitExternal Secrets: значение снаружиkubeseal + публичный ключшифрует значение, blob не расшифровать без приватногоgit · SealedSecretencryptedData — безопасно коммититьконтроллер sealed-secretsприватный ключ в kube-system, наружу не выходитвнешнее хранилище · Vaultисходное значение живёт здесьgit · ExternalSecretтолько ссылка remoteRef, значения нетоператор ESO · SecretStoreсинхронизирует значение по refreshIntervalSecret demo-sealedобычный Opaque в кластереSecret demo-externalобычный Opaque в кластереразные пути хранения и доставки — один и тот же объект Secret на выходеRBACчтение Secret регулируют права RBACи не только get / list secrets: право создать Pod/Deployment монтирует Secret и открывает значениетак субъект читает demo-sealed, demo-external, demo-plain — независимо от способа доставки

Что выбрать

Оба механизма решают одну задачу — убрать секрет из git открытым текстом, — но по-разному и с разными зависимостями.

Sealed Secrets. В git попадает шифротекст (encryptedData), исходное значение расшифровывается внутри кластера контроллером в обычный Secret. Шифротекст криптографически привязан к паре namespace/name — тот же blob в другом namespace не разворачивается. Расшифровка и восстановимость целиком зависят от сохранности приватного ключа контроллера: его потеря делает все существующие SealedSecret нерасшифровываемыми. Ротация значения — заново зашифровать и закоммитить новый SealedSecret. Подходит там, где нет и не нужно внешнее хранилище: небольшой self-hosted GitOps, где весь конвейер — это git и кластер.

External Secrets. В git — только ссылка (remoteRef), значения нет вовсе. Исходное значение живёт во внешнем хранилище, в кластер синхронизируется копия. Криптографической привязки к namespace нет — значение просто читается по ключу. Ротация централизованная: меняем один раз в хранилище, ESO подтягивает сам (в стенде — ~16 с при refreshInterval: 15s). Зависимость обратная — от доступности внешнего хранилища в момент синхронизации; если оно недоступно, ExternalSecret не обновится, но уже материализованный Secret остаётся. Подходит там, где Vault или облачный Secret Manager уже есть и нужна централизованная ротация на много кластеров.

Их можно и сочетать: например, bootstrap-секрет доступа к Vault запечатать через Sealed Secrets, а прикладные секреты тянуть через External Secrets. Но поверх обоих остаётся один и тот же вывод, который стенд показал прямее всего: и SealedSecret, и ExternalSecret заканчиваются обычным Secret в кластере, а кто внутри namespace может его прочитать — решают права RBAC, и не только явное get secrets (право создать под, монтирующий Secret, тоже открывает значение). Хранение и доставку эти механизмы закрывают; доступ на чтение — нет, и его нужно ограничивать отдельно, принципом наименьших привилегий.

Дальше по серии секреты смыкаются с соседними темами: тот же декларативный конвейер, по которому едут запечатанные и внешние секреты, разбирается в «GitOps с ArgoCD: progressive delivery, canary и blue-green»готовится, с 29 октября; контроллеры вроде sealed-secrets и ESO — это операторы, и их механика вскрывается в «Операторы и CRD: расширяем Kubernetes под себя»готовится, с 27 октября; пароль БД, который в стенде «StatefulSets и хранилище: stateful-нагрузки в Kubernetes» намеренно лежит открытым текстом, в проде выносится ровно этими двумя способами; а более широкий разговор про изоляцию и защиту нагрузок — в «Hardening контейнеров и подов: Pod Security, seccomp, capabilities»готовится, с 3 ноября. Если же вопрос стоит шире — нужен ли вообще кластер под вашу задачу, — на него отвечает «Kubernetes для домашней лаборатории», а про сам кластер, собранный без SSH на узлы, — «Четыре отказа при bootstrap на Talos» и статья про API-шлюз.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии