27 KiB
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:
- вход через AppRole клиентом
github.com/hashicorp/vault/api; - чтение
secrets/<сервис>/s3иsecrets/<сервис>/postgres/<база>; - подключение к Postgres (
pgx) и S3 (minio-go); - 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, так что от потери диска они не спасают. Восстановление из снапшота не проверялось.
Новый проект из шаблона
Шаги проверены на чистой копии репозитория с пустым каталогом данных.
-
Скопировать репозиторий без
.env,data/,vault_keys.txtи каталогов.terraform/(в git их и так нет). Вconfig.yamlзаменить под свой проект: логины администраторов, порты, если стандартные заняты, имя бакета для state, разделыbuckets,databasesиservices. Бакет изinfra.silo.backup_bucketдолжен быть описан вbuckets. Больше ничего править не нужно. -
Скопировать
.env.exampleв.envи заполнить три пароля;VAULT_TOKENоставить пустым, его запишет шаг 6. Спецсимволы в паролях ломают адреса подключения, поэтому удобно генерировать их командойopenssl rand -hex 32. Затемmise trustиmise run up. Поднимать именно этой задачей: она выдаёт Vault и pgAdmin права на их каталоги данных, без чего на чистой машине они не запускаются. -
Инициализировать Vault, сохранить unseal-ключ и root-токен, распечатать:
docker compose exec vault bao operator init -key-shares=1 -key-threshold=1 docker compose exec vault bao operator unseal -
Создать бакет для 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> -
Временно записать root-токен в
.envкакVAULT_TOKEN: первыйapplyсоздаёт mount и метод входа, а у токена Terraform на это нет прав. -
Инициализировать и применить по порядку. В
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 -
Заменить root-токен на токен Terraform и отозвать root:
ROOT_TOKEN=<root-токен> mise run vault:bootstrap --revoke-root -
Убедиться, что всё на месте:
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в outputownerмодуля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.
Ссылки
- Язык Terraform: https://developer.hashicorp.com/terraform/language
- Backend S3: https://developer.hashicorp.com/terraform/language/backend/s3
- Провайдер MinIO: https://registry.terraform.io/providers/aminueza/minio/latest/docs
- Провайдер PostgreSQL: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs
- Провайдер Vault: https://registry.terraform.io/providers/hashicorp/vault/latest/docs
- OpenBao: https://openbao.org/docs/
- mise: https://mise.jdx.dev/tasks/
Ограничения
- Всё рассчитано на одну локальную машину: TLS выключен, порты привязаны к
127.0.0.1, у Vault один unseal-ключ. - У state нет блокировки. Два одновременных
applyв одном каталоге могут его испортить. - Секреты сервисов и пароль роли
vaultв Postgres лежат в state в открытом виде. Доступ к бакету со state равен доступу ко всем секретам. - После смены порта Silo в
config.yamlнуженmise run tf <каталог> init -reconfigureв каждом каталоге: адрес state запоминается приinit.