Представьте, что вы 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 находится в репозитории. В итоге у нас получилась следующая схема автоматизации:

  1. В GitOps-репозитории находятся terraform-файлы с описанием перечисленных выше сущностей Vault.

  2. Все изменения вносятся через Pull Request, а их корректность проверяется автоматическими запусками тестов в CI и код-ревью со стороны человека.

  3. После слияния изменений 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) для расшифровки секретов.

Работа с пайплайном выглядит так.

  1. Пользователь вводит параметры:

    • место расположения секрета,

    • версию key-value secrets engine,

    • сам секрет — пару «имя – значение» для добавления секрета, «имя» для удаления.

  2. Пользователь запускает пайплайн.

  3. В ходе исполнения пайплайна редактируются конфигурационные файлы и в GitOps-репозитории создается Pull Request с предложенными изменениями.

  4. После завершения работы пайплайна в артефакты сборки добавляется ссылка на 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-инженеров нашего департамента.

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


  1. dersoverflow
    30.09.2025 09:05

    в нем будут секреты: логин или пароль от базы данных, ключи для S3-бакета и так далее. Эти секреты желательно спрятать.

    а где вы храните секреты от Vault?

    Мы в команде используем HashiCorp Vault. Храним там секреты

    молодцы! моссад спасибо скажет


    1. Insane_myRR Автор
      30.09.2025 09:05

      Токен от Vault хранится в нем же, в отдельном маунте. Права на чтение для этого маунта определены через RBAC для ряда сотрудников.