Представьте, что вы DevOps-инженер и разработчик просит развернуть новое приложение в Kubernetes. В большинстве случаев в нем будут секреты: логин или пароль от базы данных, ключи для S3-бакета и так далее. Эти секреты желательно спрятать.
Есть несколько способов это сделать. Мы в команде используем HashiCorp Vault. Храним там секреты в формате key-value, откуда они попадают в приложения, развернутые в ArgoCD с помощью ArgoCD Vault Plugin или аналогичных решений. Звучит не очень сложно, но кое-что в такой схеме нам не нравилось: ручное добавление или изменение существующих секретов в Vault, а также необходимость периодически создавать руками новые key-value secrets engine. Еще стоит упомянуть, что Vault используется не только DevOps-инженерами, но и разработчиками — например, в их Jenkins-джобах. А у разработчиков нет доступа на запись в Vault, поэтому любой запрос на добавление/изменение секретов с их стороны выполнялся в рамках заведенного на DevOps-инженера Jira-тикета. Тикеты не всегда вовремя замечали в бэклоге, поэтому такая простая задачка, как добавление секретов, могла растянуться на пару дней.
В итоге взаимодействие с Vault мы в YADRO решили автоматизировать. В статье я расскажу, как можно управлять Vault через подход IaC (Infrastructure as a Code) с использованием OpenTofu — open source-форка Terraform.
Чего мы хотим добиться
Вначале цель была одна: внедрить возможность создавать, изменять и удалять секреты в Vault с помощью кода. Но в процессе решили перенести в код еще и управление пользователями, политиками безопасности, secrets engine и бэкендами аутентификации в Vault. Преимущества здесь в том, что все изменения проходят тестирование и ревью в рамках Pull Request (PR), а вся конфигурация Vault находится в репозитории. В итоге у нас получилась следующая схема автоматизации:
В GitOps-репозитории находятся terraform-файлы с описанием перечисленных выше сущностей Vault.
Все изменения вносятся через Pull Request, а их корректность проверяется автоматическими запусками тестов в CI и код-ревью со стороны человека.
После слияния изменений CI запускает приведение состояния Vault к тому, что описано в репозитории.
Инструментарий
OpenTofu — это инструмент, который позволяет описывать инфраструктуру в декларативном формате и автоматически применять изменения. OpenTofu использует провайдеры — плагины, которые взаимодействуют с облачными платформами, физическими серверами и другими ресурсами. Для работы с Vault разработан официальный провайдер для HashiCorp.
Структура директории, именование секрета
Прежде чем описывать создание секретов, стоит немного рассказать, как мы организовали структуру директорий с tf-файлами. Особенность OpenTofu, как и Terraform, в том, что при запуске команды применения изменений читаются только tf-файлы в рабочей директории, без рекурсивного обхода. Это приводит к некрасивым решениям: либо плодить в рабочей директории много файлов с секретами под каждое приложение, либо писать все конфигурации в один файл, получая тысячи строк кода.
Проблему можно решить с помощью модулей — самостоятельных блоков кода, которые инкапсулируют логику создания и управления инфраструктурой. Структура получается такой.
Для каждого KV (key-value) secrets engine создана отдельная директория.
Для каждой группы секретов внутри KV secrets engine создан отдельный модуль.
Конфигурации KV secrets engine описаны в корневой директории.
Конфигурации модулей описаны в корневой директории.
Эта структура позволяет быстро найти нужный файл для внесения изменений. Таким образом, в каждом модуле есть файл secrets.tf с конфигурацией секретов. Для добавления нового секрета в существующий модуль достаточно описать его конфигурацию в этом файле:
resource "vault_kv_secret_v2" "secrets" {
mount = var.my_mount_path
name = "my_mount/secrets"
data_json = jsonencode(
{
dbPassword: <секрет>
}
)
}
Параметры здесь следующие:
mount — путь до secret engine;
name — полный путь до секрета;
data_json — строка в формате json, которая будет записана по этому пути.
Если же стоит задача добавить совершенно новый секрет по новому пути, то для него нужно сделать отдельный модуль:
module "new_secrets" {
source = "./my_mount/new_secrets"
my_mount_path = vault_mount.my_mount.path
}
Здесь в качестве параметров указываются:
source — путь до директории, где находится модуль;
my_mount_path — переменная, которая передается в этот модуль из корневого и используется при создании секретов.
После этого раздела может возникнуть резонный вопрос: «Неужели вы просто указываете пароли в конфигурации секрета и кладете это в репозиторий?» Конечно же нет, и об этом следующий раздел.
SOPS
Чтобы хранить секреты в git-репозитории безопасно, мы используем SOPS (Secrets OPerationS) — инструмент для безопасного управления секретами, который шифрует конфиденциальные данные, что позволяет хранить их в системах контроля версий без риска утечки. SOPS поддерживает различные провайдеры шифрования. Как работает SOPS.
Шифрование: SOPS шифрует только значения в файле (например YAML, JSON, ENV), оставляя ключи открытыми для удобства чтения.
Дешифрование: при необходимости файл расшифровывается с использованием указанных ключей или мастер-ключей.
В качестве провайдера шифрования мы выбрали age — простой, современный и безопасный инструмент для шифрования файлов, разработанный с расчетом на минимализм и удобство использования. С помощью age генерируется пара ключей, используемая для шифрования через SOPS.
Для интеграции SOPS с OpenTofu существует отдельный провайдер. Секреты попадают в ресурсы с помощью data — подключения зашифрованного файла как источника данных. В итоге в каждом модуле лежит зашифрованный файл с секретами, который используется в коде следующим образом:
data "sops_file" "secrets" {
source_file = "my_mount/secrets/secrets.enc.json"
}
resource "vault_kv_secret_v2" "secrets" {
mount = var.my_mount_path
name = "my_mount/secrets"
data_json = jsonencode(
{
dbPassword: data.sops_file.secrets.data["dbPassword"]
}
)
}
Этот подход позволяет безопасно хранить секреты в git, но также накладывает сложности при попытке внести изменения в конфигурацию.
Вам нужно знать приватный age-ключ для расшифровки файла с секретами.
Каждое изменение секретов требует дополнительных действий: расшифровка файла с секретами, его изменение, шифрование файла.
Некоторое время мы жили так, но потом решили автоматизировать эти шаги и повысить безопасность (чем меньше людей имеет ключ — тем меньше возможных точек утечки данных). Подробно об этом расскажу в разделе «Автоматизация процесса работы с секретами с помощью пайплайна».
Создание Secrets Engine Mount
Для создания нового KV Secrets Engine Mount используется файл mounts.tf в корневой директории конфигурации.
resource "vault_mount" "argocd" {
path = "argocd"
type = "kv"
options = { version = "2" }
}
Параметры здесь следующие.
Path — путь, куда secrets engine будет смонтирован.
Type — тип secrets engine. У параметра много опций, мы же используем key-value, так как все секреты хранятся в формате ключ-значение.
Options — в нашем случае используется вторая версия KV secrets engine.
Так можно с легкостью создать новый secrets engine без необходимости в нужных доступах для Vault.
Создание политик безопасности
В Vault есть RBAC, которую было бы глупо не использовать. В нашем инстансе настроена доменная авторизация и пользователям назначаются роли напрямую или согласно их AD-группам. Политики безопасности также можно создавать через код — путем редактирования policies.tf в корне репозитория:
resource "vault_policy" "argocd-viewer" {
name = "argocd-viewer"
policy = <<-EOF
path "argocd/data/argocd-vault/*" {
capabilities = [ "list", "read" ]
}
path "argocd/metadata/*" {
capabilities = [ "list", "read" ]
}
EOF
}
Параметры здесь следующие:
name — имя политики;
policy — строка, содержащая описание политики.
В примере выше создается политика, позволяющая просматривать и читать секреты в KV secrets engine, где хранятся все секреты приложений, разворачиваемых в ArgoCD.
Создание пользователей
Для возможности входа LDAP-пользователей в Vault в самом инстансе Vault настраивается бэкенд-аутентификация LDAP. Его, кстати, тоже можно создать через код. После этого можно добавлять пользователей из AD с помощью OpenTofu путем редактирования файла users.tf в корне конфигурации:
resource "vault_ldap_auth_backend" "ldap" {
path = "ldap"
# далее идут чувствительные параметры, зависящие от желаемых настроек аутентификации, подробнее можно узнать в документации https://library.tf/providers/hashicorp/vault/latest/docs/resources/ldap_auth_backend
}
resource "vault_ldap_auth_backend_user" "viewer-1" {
username = "user"
policies = ["argocd-viewer"]
backend = vault_ldap_auth_backend.ldap.path
depends_on = [
vault_policy.argocd-viewer
]
}
Параметры здесь следующие:
username — имя пользователя;
policies — список предоставляемых политик;
backend — используемый бэкенд аутентификации.
Естественно, пользователя нельзя создать, пока не будет создана выдаваемая ему политика, поэтому очередь создания ресурсов нужно определять с помощью depends_on.
Автоматизация интеграции Vault с кластерами Kubernetes
Как было сказано ранее, все приложения в Kubernetes у нас деплоятся с использованием ArgoCD, а секреты расшифровываются с помощью vault-secrets-webhook или vault-injector.
Чтобы это работало, необходимо настраивать в Vault бэкенд аутентификации для кластера Kubernetes. Ручная настройка не была бы проблемой, если бы кластер был один. Но у нас их 12, и я уверен, что это далеко не рекорд. Для ручной настройки необходимо обладать соответствующими навыками и нужными правами в Vault, что ограничивает круг людей, которые могут это сделать. Чтобы это мог быть любой DevOps-инженер из нашей команды, мы решили это автоматизировать.
Под конфигурацию auth backend для Kubernetes выделили отдельный модуль k8s_clusters. Первым делом нужно создать сам auth backend. Для этого существует файл auth_backend.tf внутри модуля:
resource "vault_auth_backend" "cluster-1" {
type = "kubernetes"
path = "cluster-1"
}
Параметры:
type — тип бэкенда, нам нужен kubernetes;
path — путь до бэкенда, требует уникальное имя.
После можно приступать к конфигурации бэкенда в файле auth_backend_config.tf:
resource "vault_kubernetes_auth_backend_config" "k8s-role-cluster-1-config" {
backend = vault_auth_backend.cluster-1.path
kubernetes_host = "https://<ip-адрес мастера>:6443"
kubernetes_ca_cert = data.sops_file.k8s_secrets.data["kubernetes_ca_cert_cluster_1"]
token_reviewer_jwt = data.sops_file.k8s_secrets.data["token_reviewer_jwt_cluster_1"]
depends_on = [
vault_auth_backend.cluster-1
]
}
Параметры здесь таковы.
Backend — бэкенд, для которого производится конфигурация.
Kubernetes_host — адрес мастер-ноды кластера.
Kubernetes_ca_cert — CA-сертификат для кластера. Здесь мы также используем шифрование чувствительных данных через SOPS.
Token_reviewer_jwt — JWT-токен для аутентификации. Получается из создаваемого секрета в кластере при настройке vault-secrets-webhook.
Ресурс зависит от auth backend, поэтому должен создаваться после него.
Следующим шагом будет создание в файле auth_backend_role.tf внутри бэкенда роли, в которой будут указаны все сущности Kubernetes, необходимые для работы vault-secrets-webhook:
resource "vault_kubernetes_auth_backend_role" "k8s-role-cluster-1" {
backend = vault_auth_backend.cluster-1.path
role_name = "k8s-role-cluster-1"
bound_service_account_names = ["vault-k8s"]
bound_service_account_namespaces = ["namespace1", "namespace2"]
token_ttl = 172800
token_policies = ["k8s_policy"]
depends_on = [
vault_auth_backend.monitoring-cluster-dbn-1
]
Параметры следующие:
backend — бэкенд, для которого производится конфигурация;
role_name — имя роли;
bound_service_account_names — имя сервис-аккаунтов, для которых будет использована роль;
bound_service_account_namespaces — список неймспейсов, где будет использована роль;
token_ttl — TTL токена;
token_policies — политики токена.
Ресурс также зависит от auth backend, поэтому тоже должен создаваться после него.
Таким образом, для конфигурации Vault под использование vault-secrets-webhook достаточно только обладать доступом к кластеру и создать PR с изменениями.
Итоговая конфигурация
По итогам описанных выше действий в репозитории получается следующая конфигурация:
configs/
├── vault/
│ ├── modules.tf # Конфигурация модулей
│ ├── policies.tf # Конфигурация политик
│ ├── users.tf # Конфигурация пользователей
│ ├── kvv2_mounts.tf # Конфигурация Key-Value Secrets Engines 2 версии
│ ├── kvv_mounts.tf # Конфигурация Key-Value Secrets Engines 1 версии
│ ├── providers.tf # Конфигурация провайдеров
│ └── variables.tf # Переменные
│ └── k8s_clusters/ # Модуль для Kubernetes-кластеров
│ └──── auth_backend.tf # Auth Backends
│ └──── auth_backend_config.tf # Конфигурация для Auth Backends
│ └──── auth_backend_role.tf # Конфигурация ролей внутри Auth Backends
│ └──── variables.tf # Переменные
│ └──── providers.tf # Конфигурация провайдеров
│ └── my_mount/ # Модуль для Key-Value Secrets Engine Mount
│ └──── secrets/
│ └────── secrets.tf # Конфигурация секретов
│ └────── variables.tf # Переменные
│ └────── providers.tf # Конфигурация провайдеров
Безусловно, это не жесткие правила именования, но я думаю, выбранные имена достаточно подходят для выполняемых задач.
Для автоматического применения изменений реализован postcommit-триггер в Jenkins, который срабатывает при мердже изменений в репозиторий. В нем запускается инициализация tofu и команда применения изменений, с помощью которых состояние инфраструктуры приводится к тому, что описано в main-ветке репозитория.
Автоматизация процесса работы с секретами с помощью пайплайна
Как упоминалось выше, для добавления или удаления секретов инженерам нужно было изменять как минимум два файла конфигурации. А также расшифровать секреты перед тем, как с ними работать, и, наконец, отправить внесенные правки на проверку.
Чтобы облегчить задачу инженерам, мы разработали Jenkins-пайплайн, который редактирует конфигурации секретов по запросу на их изменение. Это также позволило сделать процесс более безопасным, так как теперь не было необходимости предоставлять доступ к приватному ключу (age key) для расшифровки секретов.
Работа с пайплайном выглядит так.
-
Пользователь вводит параметры:
место расположения секрета,
версию key-value secrets engine,
сам секрет — пару «имя – значение» для добавления секрета, «имя» для удаления.
Пользователь запускает пайплайн.
В ходе исполнения пайплайна редактируются конфигурационные файлы и в GitOps-репозитории создается Pull Request с предложенными изменениями.
После завершения работы пайплайна в артефакты сборки добавляется ссылка на PR, по которой пользователь может перейти и добавить описание к внесенным изменениям, а затем отправить PR на проверку.

Немного о разработке пайплайна
Изначально идея состояла в том, чтобы пользователь мог указывать количество секретов, которые он добавляет или удаляет. Однако в Jenkins не удалось найти решение, которое бы позволяло динамически рендерить параметры и безопасно подавать их значения в пайплайн, поэтому мы задали некоторый максимум полей для секретов с возможностью оставлять их значения пустыми. Если у вас есть мысли, как это сделать по изначальному плану, делитесь в комментариях.
Для добавления и удаления секретов решили сделать отдельные пайплайны, а не усложнять логику условными конструкциями. В написании пайплайнов мы использовали ООП-подход, что позволило объединить общие кодовые части и избежать дублирования.
В качестве языка программирования был выбран Python и библиотека python-hcl2 для работы с hcl-файлами. На Python у нас уже были утилиты для работы с другими конфигурациями, да и сам синтаксис был понятен большинству членов команды. В качестве альтернативы мы рассматривали библиотеку на Go от Hashicorp, но по сложности написания кода она превосходила библиотеку на Python.
Для инженеров хотелось сделать процесс работы с секретами максимально простым: и редактирование конфигурационных файлов, и ввод параметров пайплайна, чтобы автоматизация сделала свое дело. Именно поэтому от пользователя требуется задать только базовые параметры, а оставшиеся необходимые значения формируются на их основе.
Для безопасной передачи секретов в параметры джобы использовался плагин Mask Passwords. Скрыть параметры в логах удалось с помощью MaskPasswordsBuildWrapper.
Плюсы решения |
Минусы решения |
Уменьшение вероятности человеческой ошибки, так как количество ручных действий минимизировано. |
Ограничение на максимальное количество секретов, которое можно указать в джобе. |
Сокращение времени инженера на работу с секретами, поскольку нет необходимости самостоятельно изменять конфигурационные файлы. |
Постоянная поддержка: при обновлении terraform-провайдера необходимо актуализировать код, чтобы он оставался рабочим. |
Повышение безопасности: инженерам не нужно предоставлять доступ к ключу для расшифровки секретов. |
Выводы
Мы получили возможность конфигурировать все необходимые нам сущности в Vault через код. Тем самым мы избавились от ручного труда, свели к минимуму риск человеческой ошибки и сильно упростили процесс настройки Vault. На самом деле возможностей конфигурации намного больше, и я уверен, что наше решение легко можно подстроить под иные требования.
Мы реализовали автоматизацию для внесения изменений в секреты, что сильно улучшило user experience в GitOps и уменьшило время, затрачиваемое на доставку новых секретов в Vault.
Напоследок — немного цифр. Сейчас с помощью GitOps мы управляем более чем 500 секретами и 12 кластерами Kubernetes. Систему использует более 20 DevOps-инженеров нашего департамента.
Комментарии (9)

zolti
30.09.2025 09:05верно ли я понимаю, что администратор вашей среды исполнения, заглянувший в джобу выполнения пайпа, сможет отыскать, то что скрыто под Mask Passwords, потом вскрыть все что под SOPS и в итоге получить доступ ко всем секретам хранящимся в Vault, ведь фактически они все у вас хранятся в гит?

Insane_myRR Автор
30.09.2025 09:05Нет, не совсем. Ключ для sops лежит в секретах Jenkins, и действительно, люди с правами администратора Jenkins могут получить этот ключ через script console. Но по счастливому стечению обстоятельств, администраторы Jenkins - это те же DevOps, которые раньше руками добавляли секреты в Vault.

trublast
30.09.2025 09:05Все любят волт и тераформ. Но в описанной схеме не очень удачно то, что имея возможность что-то сделать в гите - получаешь возможность вскрыть волт. Или можешь внедриться на раннер - можешь вскрыть волт.
Во-первых, соглашусь, что вариант, когда политики волта хранятся в гите - абсолютно правильный. Но подгружать их волт должен самостоятельно, проверяя подписи коммитов. Несколько подписей поставить на один коммит гит не дает, но есть обходные решения.
Это можно реализовать например через плагин. И в этот момент понимаешь, что затащить в плагин opentofu с провайдером волта - это оверкилл. Мы в итоге храним в git обычные json волтовые или hcl, без всякого терраформа.Крайний случай - это когда политики в волт пушит какой-то конкретный человек, который и так имеет доступ изменять политики. Но не раннер, не джоба CI и т.д. Это сильно сужает поверхность потенциальной атаки.
Во-вторых, не совсем понятно зачем хранить сами секреты в гите, если они у вас хранятся в волте. Да еще sops свеху прикручен. Волт вообще-то для того, чтобы секреты НЕ хранить нигде кроме волта, да еще извлекать их точечно и именно в тот момент, когда это необходимо. Версионирование секретов волт поддерживает, историю изменений тоже видно. Бэкапы делать - да проще некуда, можно их потом хоть распечатывать в base64, или в паблик выкладывать, это безопасно. В вашем же случае волт - это не система хранения секретов с политиками доступа, а перевалочный пункт по доставке секретов в кубер. И если это действительно то, для чего используется волт, то тут можно было остановиться например на helm-secrets. Получилось бы примерно тоже самое, но без дополнительной машинерии.
Insane_myRR Автор
30.09.2025 09:05Фактически, всё, кроме самих секретов, в git пушат те, кто понимает, зачем это им, и когда-то обладали соответствующими правами в самом Vault. Ну и у нас все еще есть код ревью, где овнерами являются компетентные люди. Для попытки, например, запустить tofu apply руками, нужно знать токен для Vault, который известен трем людям.
На счет хранения секретов - я отчасти с Вами согласен, но мы хотели дать возможность быстро доставлять секреты в Vault всем, без необходимости идти в сам Vault и обладать нужными правами. Конкретно нас такой юзкейс полностью устраивает, так как правда ускорил процессы. Но я не претендую на то, что данная статья - истина в последней инстанции.
P.S. Спасибо большое за хороший развернутый комментарий
dersoverflow
а где вы храните секреты от Vault?
молодцы! моссад спасибо скажет
Insane_myRR Автор
Токен от Vault хранится в нем же, в отдельном маунте. Права на чтение для этого маунта определены через RBAC для ряда сотрудников.
zolti
а где храните тот секрет, который отмыкает ваши файлы под sops?
Insane_myRR Автор
В корпоративном менеджере паролей. Сразу отвечаю на логично вытекающий вопрос - хранить все пароли мы там не можем из-за ряда причин, одной из которых является отсутствие нормальной интеграции с ArgoCD. Но этот секрет аналогично можно хранить в Vault, нужно только правильно настроить RBAC.