Files
stolzor 5cba142003
terraform / check (push) Waiting to run
Resources and access
2026-10-11 21:04:27 +03:00

27 KiB
Raw Permalink Blame History

infra

Локальная инфраструктура для проекта: Postgres с pgvector, S3-хранилище Silo, хранилище секретов OpenBao (форк Vault) и pgAdmin. Репозиторий задуман как шаблон: всё, что относится к конкретному проекту, лежит в config.yaml и .env.

Роли разделены:

  • Docker Compose запускает сервисы.
  • Terraform настраивает то, что внутри них: бакеты и ключи S3, базы и роли Postgres, политики и секреты Vault.
  • Vault хранит сгенерированные секреты. Приложения берут их оттуда, а не из .env.

Требования

Инструмент Зачем
Docker с Compose 2.20+ сервисы; нужна поддержка include
Terraform 1.6+ проверено на 1.16.5
mise запуск всех команд и сборка окружения
jq, curl используются задачами

Повседневная работа

Все команды запускаются из каталога infra/. При первом запуске mise попросит доверить ему конфигурацию: mise trust.

Команда Что делает
mise run up поднять сервисы
mise run down остановить сервисы, данные в data/ остаются
mise run plan terraform plan во всех трёх конфигурациях
mise run tf <каталог> <команда> любая команда Terraform, например mise run tf storage apply
mise run fmt отформатировать все .tf
mise run check форматирование и terraform validate; то же делает CI в .github/workflows/terraform.yml
mise run vault:app-check [сервис] проверить, что сервис получает из Vault всё нужное и ничего лишнего
mise run vault:backup снапшот Vault в бакет бэкапов
mise run vault:bootstrap выпустить токен Terraform, нужен ROOT_TOKEN
mise run example:up [сервис] [static|dynamic] запустить пример Go-сервиса, example:down — остановить

mise tasks показывает полный список. Обычный docker compose (ps, logs, exec) работает как всегда.

Правило: перед каждым apply читать plan, особенно строки destroy и -/+.

После перезапуска контейнера Vault

Vault запускается запечатанным, и пока его не распечатали, ни Terraform, ни сервисы секретов не получат:

docker compose exec vault bao operator unseal   # спросит unseal-ключ
docker compose exec vault bao status            # Sealed должно быть false

Где лежат настройки

Что Где Кто правит
Всё несекретное: адреса, порты, образы, логины администраторов, имена бакетов, сервисы config.yaml вы
Пароли администраторов и токен Vault для Terraform .env (образец в .env.example) вы
Секреты сервисов: ключи S3, пароли баз Vault Terraform
.env.config, backend.hcl генерируются из config.yaml никто

Два сгенерированных файла нужны потому, что Compose и блок backend в Terraform читать YAML не умеют. mise пересоздаёт их сам, когда config.yaml изменился; в git они не попадают.

Секреты из .env превращаются в переменные, которые ждут инструменты (TF_VAR_* для Terraform, AWS_* для backend), в секции [env] файла mise.toml.

Бакеты, базы и сервисы

В config.yaml отдельно описано, что существует, и отдельно — кто имеет к этому доступ:

buckets:
  orders-files:
    versioning: true
    noncurrent_days: 30
  reports:
    versioning: false
    expire_days: 90

databases:
  orders:
    pgvector: true
  analytics: {}

services:
  orders:
    buckets:
      orders-files: rw
      reports: ro
    databases: [orders, analytics]
Раздел Поле Значение
buckets versioning хранить старые версии объектов, по умолчанию true
noncurrent_days сколько дней хранить старые версии, по умолчанию 30
expire_days удалять объекты через столько дней; по умолчанию не удалять
databases pgvector установить расширение vector, по умолчанию false
services buckets бакет и доступ к нему: rw или ro
databases базы, в которых сервис работает

Бакет или база без сервиса — это просто запись в buckets или databases. Один бакет и одну базу можно дать нескольким сервисам. Сервис — это учётная запись: для него создаются ключи S3, роли в Postgres, политика и роль AppRole в Vault.

После правки применить по очереди, читая plan перед каждым apply:

mise run tf vault apply      # политики и роли AppRole
mise run tf storage apply    # бакеты и ключи S3
mise run tf database apply   # базы, роли, пароли
mise run vault:app-check     # проверить доступы

При удалении порядок обратный: database, storage, vault. Что стоит знать:

  • Удалить можно только пустой бакет.
  • Переименование бакета или базы Terraform воспринимает как удаление старого и создание нового.
  • Ссылку сервиса на несуществующий бакет или базу и недопустимое значение доступа ловит проверка на plan.
  • Опечатка в имени необязательного поля (например, noncurent_days) ошибки не вызовет: лишнее поле молча отбрасывается, и берётся значение по умолчанию. Смотрите в plan, что изменилось именно то, что вы хотели.
  • Пока живы временные роли, выданные Vault, отобрать у сервиса базу не получится: Postgres ответит dependent privileges exist. Нужно дождаться истечения их срока (до часа) или отозвать токены сервиса.

Что получает сервис

Сервис входит в Vault через AppRole по паре role_id и secret_id и получает токен на час. С ним доступно:

Путь в Vault Содержимое
secrets/<сервис>/s3 endpoint, buckets, access_key, secret_key
secrets/<сервис>/postgres/<база> постоянные host, port, database, username, password, sslmode
database/creds/<сервис>-<база> временные username и password: Vault создаёт роль в Postgres на час и сам её удаляет

В Postgres у сервиса отдельная роль на каждую базу, <сервис>-<база>. Владеет базой роль <база>_owner без права входа, а роли сервисов работают от её имени. Поэтому таблицы, созданные одним сервисом, доступны другому сервису той же базы.

Какой пароль базы выбрать: постоянный проще и подходит по умолчанию; временный не нужно ротировать и он не лежит в KV, но сервис обязан продлевать токен Vault, иначе через час потеряет доступ к базе.

Адреса в секретах указаны для сети Compose (postgres:5432, object-storage:9000). Чужие пути отдают 403. В HTTP API движка KV версии 2 после имени mount идёт data/: GET /v1/secrets/data/<сервис>/s3.

role_id лежит в mise run tf vault output approle_role_ids. secret_id — это пароль сервиса: он действует 30 дней, после чего сервису нужен новый. Выпускается отдельно:

curl -X POST -H "X-Vault-Token: $VAULT_TOKEN" \
  http://127.0.0.1:8200/v1/auth/approle/role/<сервис>/secret-id

Готовый пример всех запросов, которые делает сервис, — задача mise-tasks/vault/app-check.

Пример сервиса на Go

В examples/go-service/ лежит небольшой сервис, который показывает всю цепочку: при запуске он знает только адрес Vault, role_id и secret_id, а ключи S3 и пароль базы получает из Vault.

mise run example:up                     # первый сервис с бакетом и базой, постоянный пароль
mise run example:up <сервис> dynamic    # временная роль в Postgres от Vault
curl http://127.0.0.1:8090/                  # состояние подключений
mise run example:down

Задача example:up действует как администратор: берёт role_id, выпускает одноразовый secret_id, собирает образ и запускает контейнер в сети основного Compose. Дальше работает только код из main.go:

  1. вход через AppRole клиентом github.com/hashicorp/vault/api;
  2. чтение secrets/<сервис>/s3 и secrets/<сервис>/postgres/<база>;
  3. подключение к Postgres (pgx) и S3 (minio-go);
  4. HTTP-обработчик, который показывает, под кем сервис работает в базе и сколько объектов видит в своих бакетах.

Это учебный пример, и в нём намеренно нет того, что нужно настоящему сервису:

  • Продления токена. Токен Vault живёт час. С постоянным паролем сервис продолжит работать и после этого, а с временной ролью потеряет доступ к базе: Vault удаляет роль вместе с токеном. Настоящий сервис продлевает токен (LifetimeWatcher в клиенте) или входит заново.
  • Повторного входа. secret_id одноразовый, поэтому после перезапуска контейнера сервис в Vault не войдёт; нужен новый mise run example:up.
  • Отзыва токена при остановке. Временная роль остаётся в Postgres до истечения срока, до часа.

Структура

infra/
├── config.yaml              # несекретные настройки
├── .env                     # секреты, не в git
├── mise.toml                # окружение и короткие задачи
├── mise-env.sh              # следит за актуальностью сгенерированных файлов
├── mise-tasks/              # длинные задачи: tf, render, vault/*, example/*
├── examples/go-service/     # пример сервиса, который берёт секреты из Vault
├── docker-compose.yml       # подключает compose.services.yml с обоими env-файлами
├── compose.services.yml     # описание сервисов
├── config/vault/vault.hcl   # конфигурация OpenBao
├── data/                    # данные сервисов, не в git
├── modules/
│   ├── config/              # типы, проверки и значения по умолчанию для config.yaml
│   ├── bucket/              # бакет, versioning и lifecycle
│   ├── bucket-access/       # пользователь, политика и ключи S3 одного сервиса
│   ├── database/            # база, её роль-владелец и права
│   └── database-access/     # роль сервиса в одной базе
├── vault/                   # Terraform: mount, политики, AppRole
├── storage/                 # Terraform: Silo и запись ключей в Vault
└── database/                # Terraform: Postgres, запись паролей и временные роли

vault/, storage/ и database/ — три отдельные конфигурации, каждая со своим state в бакете из infra.silo.state_bucket. Объединить их нельзя: провайдеру нужен уже настроенный сервис во время plan. Порядок применения всегда vault → storage → database.

Vault: что нужно знать

  • Токен Terraform лежит в .env как VAULT_TOKEN. Он живёт, пока его продлевают хотя бы раз в 30 дней: docker compose exec -e BAO_TOKEN=<токен> vault bao token renew.
  • Его политика (vault_policy.terraform в vault/main.tf) перечисляет только реально используемые пути. Новый тип ресурса в Vault сначала требует строки в политике; токен может обновить её сам. Создавать и удалять mount он не может намеренно.
  • Root-токен отозван. Новый выпускается через bao operator generate-root с unseal-ключом.
  • Unseal-ключ хранить вне машины и отдельно от снапшотов: без него снапшот не расшифровать, вместе они дают полный доступ.
  • Бэкап делает mise run vault:backup: снапшот уходит в бакет из infra.silo.backup_bucket и хранится 30 дней. Расписания нет, оно зависит от машины; задачу можно вызывать из cron или таймера systemd.
  • Снапшоты лежат на той же машине, что и Vault, так что от потери диска они не спасают. Восстановление из снапшота не проверялось.

Новый проект из шаблона

Шаги проверены на чистой копии репозитория с пустым каталогом данных.

  1. Скопировать репозиторий без .env, data/, vault_keys.txt и каталогов .terraform/ (в git их и так нет). В config.yaml заменить под свой проект: логины администраторов, порты, если стандартные заняты, имя бакета для state, разделы buckets, databases и services. Бакет из infra.silo.backup_bucket должен быть описан в buckets. Больше ничего править не нужно.

  2. Скопировать .env.example в .env и заполнить три пароля; VAULT_TOKEN оставить пустым, его запишет шаг 6. Спецсимволы в паролях ломают адреса подключения, поэтому удобно генерировать их командой openssl rand -hex 32. Затем mise trust и mise run up. Поднимать именно этой задачей: она выдаёт Vault и pgAdmin права на их каталоги данных, без чего на чистой машине они не запускаются.

  3. Инициализировать Vault, сохранить unseal-ключ и root-токен, распечатать:

    docker compose exec vault bao operator init -key-shares=1 -key-threshold=1
    docker compose exec vault bao operator unseal
    
  4. Создать бакет для state вручную. Terraform хранит в нём свой state, поэтому создать его сам до init не может:

    set -a; source .env; source .env.config; set +a
    docker compose exec -e MC_HOST_local="http://${SILO_USER}:${SILO_PASSWORD}@localhost:9000" \
      object-storage mc mb local/<state_bucket из config.yaml>
    
  5. Временно записать root-токен в .env как VAULT_TOKEN: первый apply создаёт mount и метод входа, а у токена Terraform на это нет прав.

  6. Инициализировать и применить по порядку. В storage бакет из шага 3 сначала принимается под управление:

    mise run tf vault init    && mise run tf vault apply
    mise run tf storage init
    mise run tf storage import minio_s3_bucket.state_terraform_s3 <state_bucket>
    mise run tf storage apply
    mise run tf database init && mise run tf database apply
    
  7. Заменить root-токен на токен Terraform и отозвать root:

    ROOT_TOKEN=<root-токен> mise run vault:bootstrap --revoke-root
    
  8. Убедиться, что всё на месте: mise run plan должен трижды показать No changes, mise run vault:app-check — пройти.

Два проекта из одного шаблона могут работать на одной машине одновременно, если у них разные порты и разные имена каталогов: имя каталога становится именем проекта Compose и его сети.

Неочевидные места в коде

В коде нет комментариев, поэтому причины решений, которые не видны из самого кода, собраны здесь.

  • depends_on между грантами в modules/database. Гранты public и owner меняют права одной и той же базы; без явного порядка Terraform выполнял бы их параллельно, и Postgres мог бы вернуть tuple concurrently updated.
  • Грант owner. После отзыва прав у public владелец базы не может подключиться к ней без явного CONNECT.
  • assume_role в modules/database-access. Роль сервиса при входе переключается на владельца базы, поэтому всё, что она создаёт, принадлежит владельцу, а не ей самой. Иначе второй сервис той же базы не смог бы изменить чужие таблицы.
  • Отдельная роль на пару сервис и база. assume_role задаётся для роли целиком, а владельцы у баз разные.
  • depends_on в output owner модуля database. Роль сервиса не создаётся, пока владельцу не выданы права на базу.
  • ignore_changes = [roles] у роли vault в database/main.tf. Членством управляет postgresql_grant_role; без этой строки два ресурса по очереди переписывают список ролей.
  • with_admin_option там же. Начиная с Postgres 16 роль с CREATEROLE добавляет участников только в те роли, где у неё есть ADMIN OPTION.
  • ALTER ROLE ... SET role в creation_statements. Тот же приём для временных ролей Vault: созданные ими объекты принадлежат владельцу базы, а саму роль можно удалить без ошибок.
  • delete_all_versions у секретов. Без него удалённый сервис оставлял бы в Vault восстановимые версии своих ключей.
  • prevent_destroy у бакета со state. В нём лежит state той самой конфигурации, которая им управляет. Из-за этого он описан отдельным ресурсом, а не в buckets: prevent_destroy нельзя включить по условию.
  • Политика terraform в vault/main.tf. Каждый путь взят из записи реальных запросов провайдера (TF_LOG=DEBUG) при добавлении и удалении сервиса. Создание и удаление mount, метода входа и самой политики в неё не входят намеренно.
  • host и internal_host в config.yaml. Первый — адрес с хоста, им пользуется Terraform; второй — адрес внутри сети Compose, он попадает в секреты сервисов и в настройки Vault.
  • include в docker-compose.yml. Только так Compose читает второй env-файл для подстановки переменных; сам он загружает один .env.
  • chown в задаче up. Docker создаёт каталоги данных от имени root, а Vault и pgAdmin работают под своими пользователями.
  • mise-env.sh. mise собирает окружение раньше, чем выполняет зависимости задач, поэтому сгенерированные файлы проверяются в момент загрузки окружения, а не отдельной задачей.
  • #MISE в начале задач — не комментарии, а директивы mise с описанием задачи.

Шпаргалка

Команды выполняются из infra/. Первые две строки загружают в shell те же переменные, что получают задачи mise.

set -a; source .env; source .env.config; set +a
MC="docker compose exec -e MC_HOST_local=http://${SILO_USER}:${SILO_PASSWORD}@localhost:9000 object-storage mc"
BAO="docker compose exec -e BAO_TOKEN=$VAULT_TOKEN vault bao"

# Silo
$MC ls local                      # бакеты
$MC version info local/<бакет>    # включён ли versioning
$MC ls --versions local/<бакет>   # объекты со всеми версиями
$MC ilm rule ls local/<бакет>     # lifecycle-правила
$MC admin user ls local           # пользователи
$MC admin policy ls local         # политики

# Vault
docker compose exec vault bao status          # запечатан или нет
$BAO kv get -mount=secrets <сервис>/s3        # секрет сервиса
$BAO policy read terraform                    # политика токена Terraform
$BAO token lookup                             # срок жизни токена

# Terraform
mise run tf storage state list                # чем управляет конфигурация
mise run tf storage state show <адрес>        # атрибуты одного ресурса
mise run tf storage plan -refresh-only        # только дрейф, без изменений из кода

Как обращаться со state:

  • Изменение руками (дрейф) видно в plan и откатывается следующим apply.
  • Объект, созданный руками, берётся под управление блоком import { to = ..., id = ... } рядом с описанием ресурса; после apply блок можно удалить.
  • Блок moved переименовывает ресурс в state без пересоздания.
  • State содержит секреты в открытом виде, поэтому он лежит в бакете, а не в git.

Ссылки

Ограничения

  • Всё рассчитано на одну локальную машину: TLS выключен, порты привязаны к 127.0.0.1, у Vault один unseal-ключ.
  • У state нет блокировки. Два одновременных apply в одном каталоге могут его испортить.
  • Секреты сервисов и пароль роли vault в Postgres лежат в state в открытом виде. Доступ к бакету со state равен доступу ко всем секретам.
  • После смены порта Silo в config.yaml нужен mise run tf <каталог> init -reconfigure в каждом каталоге: адрес state запоминается при init.