Первое, что удивляет новичка в 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 — не секрет
- Sealed Secrets: шифрование для git
- Привязка к namespace и имени
- Ротация ключа и восстановление
- External Secrets: источник снаружи
- Ротация значения
- RBAC на секреты
- Что выбрать
Почему обычный 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 ноября.
Что выбрать
Оба механизма решают одну задачу — убрать секрет из 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-шлюз.
Источники
- Sealed Secrets — bitnami-labs/sealed-secrets, scopes и ротация ключей
- External Secrets Operator — документация, SecretStore, ExternalSecret, провайдер HashiCorp Vault
- HashiCorp Vault — KV Secrets Engine v2, dev server
- Kubernetes Documentation — Secrets, Encrypting Confidential Data at Rest, RBAC Authorization
- Стенд:
kubernetes/secrets(Kubernetes 1.36.2, k8s-volga; sealed-secrets 0.28.0, kubeseal 0.37.0, External Secrets Operator v0.14.3, Vault 1.21.4 dev,busybox:1.37)
Комментарии