317 lines
27 KiB
Markdown
317 lines
27 KiB
Markdown
# 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](https://mise.jdx.dev) | запуск всех команд и сборка окружения |
|
||
| `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, ни сервисы секретов не получат:
|
||
|
||
```bash
|
||
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` отдельно описано, что существует, и отдельно — кто имеет к этому доступ:
|
||
|
||
```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`:
|
||
|
||
```bash
|
||
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 дней, после чего сервису нужен новый. Выпускается отдельно:
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
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, так что от потери диска они не спасают. Восстановление из снапшота не проверялось.
|
||
|
||
## Новый проект из шаблона
|
||
|
||
Шаги проверены на чистой копии репозитория с пустым каталогом данных.
|
||
|
||
0. Скопировать репозиторий без `.env`, `data/`, `vault_keys.txt` и каталогов `.terraform/` (в git их и так нет). В `config.yaml` заменить под свой проект: логины администраторов, порты, если стандартные заняты, имя бакета для state, разделы `buckets`, `databases` и `services`. Бакет из `infra.silo.backup_bucket` должен быть описан в `buckets`. Больше ничего править не нужно.
|
||
1. Скопировать `.env.example` в `.env` и заполнить три пароля; `VAULT_TOKEN` оставить пустым, его запишет шаг 6. Спецсимволы в паролях ломают адреса подключения, поэтому удобно генерировать их командой `openssl rand -hex 32`. Затем `mise trust` и `mise run up`. Поднимать именно этой задачей: она выдаёт Vault и pgAdmin права на их каталоги данных, без чего на чистой машине они не запускаются.
|
||
2. Инициализировать Vault, сохранить unseal-ключ и root-токен, распечатать:
|
||
|
||
```bash
|
||
docker compose exec vault bao operator init -key-shares=1 -key-threshold=1
|
||
docker compose exec vault bao operator unseal
|
||
```
|
||
|
||
3. Создать бакет для state вручную. Terraform хранит в нём свой state, поэтому создать его сам до `init` не может:
|
||
|
||
```bash
|
||
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>
|
||
```
|
||
|
||
4. Временно записать root-токен в `.env` как `VAULT_TOKEN`: первый `apply` создаёт mount и метод входа, а у токена Terraform на это нет прав.
|
||
5. Инициализировать и применить по порядку. В `storage` бакет из шага 3 сначала принимается под управление:
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
6. Заменить root-токен на токен Terraform и отозвать root:
|
||
|
||
```bash
|
||
ROOT_TOKEN=<root-токен> mise run vault:bootstrap --revoke-root
|
||
```
|
||
|
||
7. Убедиться, что всё на месте: `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.
|
||
|
||
```bash
|
||
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`.
|