Представьте, что вы 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-инженеров нашего департамента.
dersoverflow
а где вы храните секреты от Vault?
молодцы! моссад спасибо скажет
Insane_myRR Автор
Токен от Vault хранится в нем же, в отдельном маунте. Права на чтение для этого маунта определены через RBAC для ряда сотрудников.