Секреты — это то, без чего не живёт ни одно приложение: токены, пароли, ключи. Хранить их опасно, доставлять — ещё опаснее. Мы во «Фланте» тоже когда-то верили в HashiCorp Vault, но быстро поняли, что с ним не всё так гладко. Рассказываем, как мы переосмыслили подход к secret management в своём продукте Deckhouse Stronghold, что придумали, чтобы не потерять безопасность на delivery-этапе, и какие риски всё ещё остаются, даже если сделать всё «правильно».

Это статья по мотивам доклада

Эта статья — текстовая версия доклада руководителя разработки Stronghold Максима Киселева с Deckhouse Conf 2025. Если вы предпочитаете смотреть видео, а не читать, выбирайте любую удобную площадку:

Кто такие «секреты» и где они обитают

Если вы деплоили что-то сложнее «Hello, world», то наверняка сталкивались с секретами. Под этим обычно понимают:

  • токены доступа к API;

  • логины и пароли к базам данных;

  • ключи для обращения к сторонним сервисам.

По нашему опыту, около 80 % секретов, особенно в Kubernetes,  — это данные для аутентификации между сервисами. 
Ещё 10 % — криптография: ключи шифрования/дешифрования или подписи сертификатов.
И ещё 10 % — давайте честно, это никакие не секреты. Просто кто-то решил, что весь .env надо положить в секреты. На всякий случай: вдруг пригодится.

Чтобы понять, как инженеры в реальности работают с секретами, мы провели небольшой опрос о том, где они хранят пароль от базы данных. БД — это универсальный пример, который понятен всем. 

Ответили 3560 человек, и вот что получилось:

На первый взгляд, Git с шифрованием — вроде бы рабочее решение. Но на практике вопросов становится даже больше, чем ответов. Где вы тогда храните ключи расшифровки? Это один ключ на весь проект или под каждый секрет есть свой? А если два проекта используют один и тот же секрет — как вы это организуете?

А теперь представьте, что у вас security-инцидент и нужно срочно сменить секрет. Что вы делаете? Генерируете новый пароль, ищете человека с доступом в Git, просите внести изменения, запускаете pipeline и ждёте, когда обновление дойдёт до инфраструктуры. Это не то чтобы «медленно», но и не похоже на оперативную реакцию.

Есть и философский момент. GitOps — штука удобная: всё, что связано с конфигурацией, живёт в Git. Но с секретами это не так однозначно. Пароли и токены — не часть бизнес-логики приложения. Это одноразовые ключи, а не артефакты, которые стоит версионировать. Хранить их в Git — как складывать случайные пароли от Wi-Fi в семейный фотоальбом: можно, но зачем?

Тем, кто предпочитает специализированные решения, знаком HashiCorp Vault — и это вполне закономерно. Он изначально задуман как хранилище секретов: данные внутри шифруются, доступ возможен как от имени человека, так и от имени сервиса. И самое важное — он позволяет разделить роли. Одна команда может управлять политиками доступа, другая — использовать секреты, не имея прямого доступа к самим данным.

Это особенно полезно в больших компаниях. В маленькой команде разработчик сам пишет конфиги и деплоит приложение — и он же управляет паролями. В компании покрупнее этим занимаются DevOps-инженеры. А в крупной организации появляется отдел ИБ, который хочет контролировать, кто, когда и к каким данным получает доступ. Vault позволяет выстроить такую модель и при этом не мешать разработке.

Чего нам не хватало в Vault

HashiCorp Vault — мощная штука. Он умеет многое, и в целом это отличное решение для хранения секретов. Но когда мы начали внедрять его в прод, довольно быстро поняли, что у этой мощности есть цена. И не только в плане ресурсов.

Во-первых, эксплуатировать Vault ощутимо сложнее, чем просто держать секреты в Git. Данные в Vault по умолчанию зашифрованы — и это здорово. Но для того, чтобы получить доступ к ним, вы должны сначала… получить другой секрет. То есть, чтобы прочитать пароль от базы, вам нужно аутентифицироваться в Vault, а для этого — иметь какой-то токен или ключ. И вот вы уже не просто получаете секрет, а идёте в хранилище за секретом, чтобы получить другой секрет. Ха-ха, классика.

Во-вторых, большинство приложений, особенно в Kubernetes, привыкли работать с переменными окружения или с конфигурационными файлами. И когда им вместо этого предлагают API — пусть даже удобный, — это уже повод для боли. Хочется, чтобы всё было как раньше: приложение запустилось, увидело ENV, подключилось к БД — и поехали. А тут: сходи в Vault, получи токен, провалидируй, а потом уже, может быть, получишь нужный секрет. Если хранилка доступна.

Но главная головная боль — это процесс unseal. Vault устроен так, что при запуске он не может просто взять и заработать. Он должен быть «распечатан». Его encryption key зашифрован и хранится отдельно. Как его расшифровать — зависит от вашей архитектуры. HashiCorp предложили три опции:

  • Cloud KMS. Храни ключи в облаке и обращайся к ним при старте. Удобно, если ты в облаке. Но если у тебя закрытый контур или ты хочешь использовать сам Vault в роли KMS — всё, мимо.

  • Transit Vault. Поднимаешь второй Vault, который будет распечатывать первый. Классная идея, пока не потеряешь «второй». Тогда данные в основном Vault — просто зашифрованный мусор.

  • Shamir’s Secret Sharing. Ключ делится на куски, каждый кусок — у отдельного человека. При unseal несколько доверенных сотрудников собираются в комнате, достают бумажки и вводят ключ вручную. Без шуток.

Да, это безопасно. Но медленно и рискованно. Люди могут быть недоступны, потерять листок, ввести ключ не туда. Нужно как минимум убедиться, что Vault, который вы распечатываете, — настоящий, а не honeypot. То есть вместо того, чтобы быстро перевести хранилище секретов в работоспособное состояние, вам нужно собрать кворум администраторов, а потом провести целое расследование на тему «А можно ли вообще запускать хранилище секретов?». В проде с этим жить неудобно.

Мы решили пойти другим путём — форкнули и значительно доработали HashiCorp Vault для себя и наших клиентов. Сделали Deckhouse Stronghold, в котором реализовали auto unseal прямо внутри нашей K8s-платформы. 

Как это работает:

  • Stronghold запускается как под внутри Deckhouse Kubernetes Platform.

  • Мы точно знаем, что образ правильный, кластер не скомпрометирован, всё стартует из проверенного состояния.

  • Первый узел инициализирует хранилище, получает ключ, делит его по Шамиру и распечатывает остальные.

  • Остальные узлы сохраняют ключ в памяти (не на диск) и могут распечатывать друг друга.

  • Если всё упало, доступ можно восстановить вручную, расшифровав ключи PGP'шкой из Kubernetes Secret.

Схема auto unseal в Deckhouse Stronghold
Схема auto unseal в Deckhouse Stronghold

Получается отказоустойчиво: любой живой узел может распечатать другие, и всё работает без участия человека. А если понадобится — можно вмешаться вручную.

Ещё пара вопросов, которые мы часто слышим от противников архитектуры с выделенным хранилищем секретов: что делать, если хранилище секретов недоступно? Как приложение тогда стартанёт?

Вопросы логичные. Но если задуматься, они больше про инфраструктуру, чем про хранилище секретов. Мы, например, запускаем Stronghold прямо на control plane-узлах. То есть на тех же, где крутятся Kubernetes API и etcd. Если они недоступны — у вас и поды не стартуют, и деплой не работает, и вообще всё лежит. А если control plane жив — значит, и Stronghold доступен. Получается, что доступность секретов становится эквивалентной доступности самого кластера. 

В результате у нас получилась хранилка, которая:

  • надёжно хранит секреты;

  • автоматически инициализируется;

  • умеет распечатываться без участия человека;

  • не создаёт точку отказа в момент рестарта подов.

Как безопасно доставить секрет в приложение

В Kubernetes всё устроено просто: запускается контейнер, а секреты попадают в него как env или файлы — через обычные Kubernetes Secrets или ConfigMap'ы. Приложению не нужно никуда ходить, чтобы получить нужный токен или пароль, — всё уже на месте.

Но если мы используем HashiCorp Vault или Deckhouse Stronghold, то всё усложняется. Чтобы получить секрет, нужно сначала аутентифицироваться, а для этого потребуется токен. Возникает вполне логичный вопрос: а где его взять? Мы ведь изначально хотели сходить в базу, а теперь оказывается, что сначала нужно сходить в Vault… с паролем, чтобы получить пароль.

Выручает то, что Kubernetes может автоматически выдавать приложению JSON Web Token (JWT). При запуске пода Kubernetes выписывает токен, который:

  • имеет срок жизни (TTL);

  • однозначно связан с конкретным подом;

  • может быть проверен и верифицирован снаружи.

Чаще всего это токен ServiceAccount’а, но не обязательно — можно создать сколько угодно токенов, привязанных к разным идентичностям. Главное, что у нас появляется механизм: приложение использует JWT, чтобы аутентифицироваться в хранилище, получает временный access token и с ним уже обращается за нужными секретами.

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

К счастью, есть обходные пути — механизмы, которые доставляют секреты в контейнер по старинке, но с использованием современных хранилищ. Вот несколько популярных решений.

1 способ: Vault Secrets Operator или External Secrets Operator

Это операторы, которые сами ходят в хранилище с использованием токена (например, JWT от ServiceAccount), получают оттуда секреты и сохраняют их в стандартные Kubernetes Secrets. В приложении при этом ничего менять не нужно: оно получает доступ к секретам, как и раньше, потому что они всё так же находятся в секретах Kubernetes и могут быть доставлены в виде env или файла. Это удобно, быстро, просто — особенно при переходе от Kubernetes Secrets к более безопасному хранилищу. Но есть нюанс: как только секрет оказался секрете Kubernetes — а значит в etcd, — он уже не такой уж и секрет.

  • Любой, у кого есть доступ к Kubernetes API в нужном пространстве имён, может прочитать этот секрет.

  • Любой, кто может смонтировать volume с секретами, — тоже.

  • Делаете бэкапы etcd? Поздравляем, теперь секреты лежат и в бэкапах.

То есть безопасное хранилище как бы есть, но на этапе доставки вы его обошли — и потенциально открыли уязвимость. Это решение скорее временное, «переходное». Оно снижает порог входа, но платой за это становится снижение контроля. Вы как бы перекладываете секреты из одного безопасного хранилища в другое, менее безопасное, после чего весь навороченный функционал хранилки секретов по аудиту доступа становится бесполезным. Приложения получат доступ к секрету — а в аудит-логах об этом не будет никакой информации. 

2 способ: Vault Agent Injector 

Тут работает вебхук, который подменяет манифест пода и добавляет в него сайдкар-контейнер с агентом. Этот агент сам запрашивает нужные секреты у хранилища, складывает их в volume, и ваше приложение просто читает файл. Преимущество в том, что теперь аудит доступа сохраняется: каждый запрос идёт от конкретного пода, и мы можем точно отследить, кто, когда и зачем просил секрет.

Но есть и минусы. Файлы с секретами хранятся в контейнере, пусть и временно. Если туда попал злоумышленник — он может их прочитать. Кроме того, каждый под тянет за собой инжектор, а это нагрузка. На сотне подов всё ок, на пяти тысячах — уже не очень. Агентов становится слишком много, они потребляют память, и это уже операционный overhead.

3 способ: Vault CSI provider 

Работает по тому же принципу, что и предыдущий вариант (секреты доставляются в виде файлов), но реализован как node-level CSI-драйвер. То есть не сайдкар в каждом поде, а один общий компонент на узле. Это экономит ресурсы, снижает избыточность и проще масштабируется. По факту — те же файлы, но с меньшими затратами.

4 способ: Vault Secrets Webhook

Это если хочется вообще без файлов. Vault Secrets Webhook не монтирует тома, а работает через подмену стартового процесса. Сначала запускается инжектор, который от имени ServiceAccount’а пода достаёт секреты, записывает их в переменные окружения и вызывает execve, передавая управление основному приложению. В итоге в поде остаётся только нужный процесс — без лишних контейнеров, без файлов, с env, заполненным нужными значениями.

Этот способ даёт максимальную безопасность: переменные окружения доступны только самому процессу, не видны при exec в под, не лежат в файловой системе. Но и тут есть нюансы: если приложение не очищает environ, кто-то может прочитать содержимое через /proc/self/environ. Это, наверное, единственная проблема или, если хотите, — уязвимость, которая есть у этого метода. Но дальше я расскажу, что можно с этим сделать.

Резюмируя все способы:

  • External Secrets Operator — просто, но компромисс по безопасности;

  • Agent Injector — хорош при умеренном масштабе;

  • CSI Provider — оптимален по балансу ресурсоёмкости и надёжности;

  • Webhook (env) — максимум безопасности, но требует аккуратности.

Наше решение 

Чтобы упростить работу с доставкой секретов и не заставлять команды разбираться с CSI-драйверами, sidecar'ами и execve-хитростями, мы сделали модуль secrets-store-integration в Deckhouse Kubernetes Platform. Его задача — абстрагировать всю магию. «Под капотом» он использует CSI и Env Injector, но для пользователя всё максимально прозрачно: включаете модуль, прописываете аннотацию в манифесте — и секреты появляются в нужном месте.

kind: Pod
apiVersion: v1
metadata:
  name: myapp
  namespace: myapp-namespace
  annotations:
    secrets-store.deckhouse.io/env-from-path: secret/data/myapp-secrets
    secrets-store.deckhouse.io/role: myapp-role
spec:
  serviceAccountName: myapp
  containers:
  - image: myapp:v1.0
    name: myapp
    command: ["/run/me"]

Например, хотите получать секреты в env — пишете аннотацию с путём к секрету в хранилище. Указываете роль, с которой контейнер должен аутентифицироваться. Всё — больше ничего делать не нужно. Само приложение не меняется, не переписывается с поддержкой Vault API и не требует дополнительных библиотек.

При этом права на доступ к секрету можно описать политиками прямо в Vault или Stronghold — независимо от RBAC в Kubernetes. Специалисты по ИБ могут сами определить, какой под, с каким сервис-аккаунтом и в каком пространстве имён имеет право получить определённый секрет. Разработчики и DevOps-инженеры к этому даже не прикасаются.

Доставка происходит только на этапе запуска пода. Никакой передачи секретов в CI/CD, никакого постоянного хранения. Только когда приложение запускается — в этот момент хранилище проверяет запрос и отдаёт актуальный секрет. Можно настроить доступ с детальностью вплоть до одного конкретного пода.

А теперь самое интересное — динамические секреты. В отличие от статических Kubernetes Secrets, которые лежат в etcd в виде YAML, такие секреты вообще не существуют до запроса. Приложение отправляет запрос: «Дай мне доступ к базе». Хранилище — будь то HashiCorp Vault или Deckhouse Stronghold — генерирует логин и пароль, создаёт пользователя в базе, возвращает данные приложению, и только тогда они «начинают жить». 

TTL у таких секретов можно задать очень короткий — например, одну минуту. Приложение подключается к базе, а спустя минуту созданный пользователь удаляется. Если кто-то и успеет вытащить эти креды из памяти — подключиться с ними уже не получится: сессия жива, а пользователь уже нет.

Это сильно снижает последствия потенциальной компрометации. Даже если кто-то проникнет в контейнер и вытащит секрет из памяти (а это, надо отметить, уже очень нестандартная ситуация), воспользоваться им уже не получится. Он истёк или пользователь удалён. Единственный способ снова получить секрет — это заново пройти аутентификацию. А мы можем дополнительно защититься и от этого.

Если вы не хотите, чтобы в контейнере можно было повторно использовать токен, можно создать его как projected volume с параметром expirationSeconds и отключить автоматическое обновление. Тогда при запуске под получает JWT, но через несколько минут он истекает. Повторно аутентифицироваться с помощью JWT уже не получится, потому что старый JWT истёк, а новый в контейнере не появится. При перезапуске контейнера токен обновится — и всё заработает снова. Это даёт баланс между доступностью и безопасностью.

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  automountServiceAccountToken: false
  containers:
    - name: nginx
      image: nginx
# В контейнере будет создан файл /vault-token, с TTL 10m.
# В случае использования subPath токен будет обновлён только в случае перезапуска контейнера.
      volumeMounts:
      - name: custom-token
        mountPath: /vault-token 
        subPath: token
  volumes:
  - name: custom-token
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          path: token
          expirationSeconds: 600 # Минимальный TTL 10 минут
          audience: stronghold   # Должен совпадать с параметром `bound_audiences` вашей роли.

Остаётся ещё один канал утечки — переменные окружения. Даже если мы не сохраняем секреты в файлы, env внутри процесса остаются. И если кто-то получит доступ в контейнер и выполнит cat /proc/1/environ — он сможет увидеть содержимое. То же самое касается форков: если приложение не делает unset, дочерние процессы получают переменные в наследство. Чтобы избежать этого, лучше всего сразу после старта делать clearenv или обнулять содержимое envp.

А чтобы контролировать доступ к чувствительным файлам, можно включить в Deckhouse Kubernetes Platform модуль runtime-audit-engine. Он отслеживает обращения к environ и другим подозрительным системным вызовам, а при срабатывании — шлёт алерты. Это не защита от утечки, но отличная сигнализация, чтобы успеть среагировать и поротейтить секреты.

Всё вместе это выстраивает схему, в которой:

  • секреты живут не в Kubernetes;

  • хранятся только в памяти;

  • выдаются только при запуске;

  • действуют ограниченное время;

  • не могут быть получены повторно.

Сколько проблем нас ждёт впереди

Что у нас получилось в итоге? Благодаря Deckhouse Stronghold и модулю secrets-store-integration мы закрыли сразу две важные задачи: надёжно храним секреты и управляем доступами к ним. При этом приложения, которые запускаются в Kubernetes, вообще не замечают этих изменений — как работали с env и файлами, так и продолжают работать. А если всё настроено правильно, то даже внутри контейнера секреты становятся недоступны для постороннего чтения.

Но важно понимать: доставка секретов — это только половина дела. После того как секрет попал в приложение, платформа теряет контроль. Что будет дальше — зависит от самого приложения.

А делает оно, увы, порой неожиданное. Например, пишет логин и пароль в логи. Да, это звучит дико, но если вы когда-нибудь открывали лог node-connect или трейсы приложений на Python, то знаете, что бывает. Или оно берёт и шлёт конфиг в Telegram. Или в crash dump. Или просто выводит в консоль. И это не редкость.

Эту часть мы пока не решаем — но думаем, как к ней подступиться. Потому что здесь всё зависит уже не от Kubernetes и не от Stronghold, а от поведения приложения.

Есть и вещи, которые вообще не зависят от нас. Например, если у кого-то есть root-доступ к виртуалке или узлу, где запущено приложение, — всё. Можно сделать дамп памяти, вытащить оттуда что угодно: токены, пароли, ключи. Да, это уже не про Kubernetes и не про секреты, а про базовую модель доверия. Но помнить про это стоит всегда.

Если у вас есть опыт доставки секретов, особенно в нестандартных сценариях, буду рад обсудить. Задавайте вопросы в комментариях, делитесь своими подходами. Всегда интересно посмотреть, как это решают в других командах.

P. S.

Читайте также в нашем блоге: 

Комментарии (2)


  1. OkGoLove
    06.08.2025 06:47

    А что насчёт аудита «кто-когда-зачем поменял секрет Х»?


  1. anzay911
    06.08.2025 06:47

    #!/bin/sh
    input_file="secret.txt"
    encrypted_file="encrypted.jwe"
    encrypted_content=$(clevis encrypt '{"tang":{"url":"http://tang.example.com"}}' < "$input_file")
    echo "$encrypted_content" > "$encrypted_file"
    

    .

    #!/bin/sh
    # В encrypted.jwe уже записан url
    encrypted_file="encrypted.jwe"
    output_file="decrypted.txt"
    decrypted_content=$(clevis decrypt < "$encrypted_file")
    echo "$decrypted_content" > "$output_file"