Когда инфраструктуру меняют пятеро, apply с ноутбука — это пять разных правд
terraform test$ find . -type f -not -name lab.sh -not -path "./.home/*" -not -path "./steps/*" | sort
./app/main.tf
./.gitignore
./.gitlab-ci.yml
./modules/webapp/main.tf
./modules/webapp/outputs.tf
./modules/webapp/tests/webapp.tftest.hcl
./modules/webapp/variables.tf
./modules/webapp/versions.tf
./network/main.tf
modules/ — модули команды (модуль 06), с тестами в tests/.network/, app/ — проекты: корневые модули, у каждого своё состояние. Сеть меняется раз в год, приложение — каждый день; в одном состоянии ошибка в приложении могла бы задеть сеть, а план сети пересчитывался бы при каждой правке приложения..gitlab-ci.yml — конвейер (раздел 06).# Скачанные провайдеры и модули
.terraform/
# Состояние и его копии — в нём секреты
*.tfstate
*.tfstate.*
# Планы — двоичные, с секретами
*.tfplan
tfplan
# Локальные значения переменных и переопределения
*.auto.tfvars
override.tf
override.tf.json
*_override.tf
crash.log
.terraform.lock.hcl в списке нет — его кладут в Git (модуль 01). *.auto.tfvars исключены, потому что их Terraform читает сам и в них часто оказываются личные значения и секреты; файлы окружений называют явно (dev.tfvars) и подключают ключом -var-file.
Оба проекта хранят состояние в одной базе PostgreSQL, но в разных схемах: schema_name в блоке backend. Строку подключения backend берёт из PG_CONN_STR, как в модуле 05. Проект сети:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
}
backend "pg" {
schema_name = "tf_network" # у каждого проекта своя схема
}
}
resource "docker_network" "app" {
name = "price-net"
}
output "network_name" {
value = docker_network.app.name
}
$ cd network && terraform init -no-color | grep -E "^Successfully configured|initialized!"
Successfully configured the backend "pg"! Terraform will automatically
Terraform has been successfully initialized!
$ terraform apply -auto-approve -no-color | sed -n "/Apply complete/,\$p"
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
network_name = "price-net"
$ cd ../app && terraform init -no-color | grep -E "^Successfully configured|initialized!"
Successfully configured the backend "pg"! Terraform will automatically
Terraform has been successfully initialized!
Проект приложения берёт имя сети из выходов проекта сети источником данных terraform_remote_state:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
}
backend "pg" {
schema_name = "tf_app"
}
}
# Выходы другого проекта — из его состояния
data "terraform_remote_state" "network" {
backend = "pg"
config = {
schema_name = "tf_network"
}
}
locals {
env = terraform.workspace
ports = { default = 8080, prod = 8090 }
}
module "price" {
source = "../modules/webapp"
name = "price-${local.env}"
title = "Сервис цен (${local.env})"
port = local.ports[local.env]
network = data.terraform_remote_state.network.outputs.network_name
}
output "url" {
value = module.price.url
}
$ terraform plan -no-color | grep -E "^ <= |^ # |network |Plan:"
# module.price.docker_container.this will be created
# module.price.docker_image.this will be created
Plan: 2 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve -no-color | sed -n "/Apply complete/,\$p"
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
url = "http://127.0.0.1:8080/"
Источник данных прочитан при обновлении состояния — в плане его нет, а имя сети подставлено. Цена такого способа: terraform_remote_state читает состояние другого проекта целиком, со всеми секретами, — значит, у приложения есть доступ на чтение к состоянию сети. Поэтому, где можно, связывают проекты обычными источниками данных по имени (data "docker_network", data "yandex_vpc_network"): им нужны права только на чтение объекта, и проекты не зависят от устройства состояния друг друга.
Workspace (рабочее пространство) — отдельное состояние для того же кода. По умолчанию есть одно, default; имя текущего доступно в коде как terraform.workspace — по нему проект выбирает имя и порт:
$ terraform workspace list
* default
$ terraform workspace new prod
Created and switched to workspace "prod"!
You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.
$ terraform apply -auto-approve -no-color | sed -n "/Apply complete/,\$p"
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
url = "http://127.0.0.1:8090/"
$ terraform workspace list
default
* prod
$ docker ps --format "{{.Names}} {{.Ports}}" | sort
price-default 127.0.0.1:8080->80/tcp
price-prod 127.0.0.1:8090->80/tcp
tfstate-pg 127.0.0.1:15432->5432/tcp
$ docker exec tfstate-pg psql -U postgres -d tfstate -c "select schemaname from pg_tables where tablename = 'states'" -c "select 'tf_app' as schema, name from tf_app.states union all select 'tf_network', name from tf_network.states order by 1, 2"
schemaname
------------
tf_network
tf_app
(2 rows)
schema | name
------------+---------
tf_app | default
tf_app | prod
tf_network | default
(3 rows)
В таблице состояний проекта tf_app теперь две строки — default и prod, у каждой свой набор ресурсов.
Workspaces удобны для одинаковых временных стендов — «стенд на каждую ветку». Для dev и prod их обычно не используют:
| Workspaces | Каталоги envs/dev, envs/prod | |
|---|---|---|
| Код | Один на все окружения | В каждом каталоге — вызовы общих модулей со своими значениями |
| Какое окружение сейчас | Не видно в коде; легко сделать apply не в том | Видно по каталогу, в котором вы стоите |
| Состояние и права | В одном backend; разные права настроить трудно | Свой backend на окружение: к состоянию prod доступ только у конвейера |
| Различия окружений | Условия terraform.workspace == … по всему коду | Разные значения входов модулей |
Частая раскладка: modules/ с модулями и envs/dev, envs/prod — тонкие корневые модули из вызовов module и backend.tf. Обновление модуля выкатывается сначала в dev, потом — сменой ref — в prod (модуль 06).
Тесты модуля — файлы *.tftest.hcl в каталоге tests/. Каждый блок run выполняет plan или apply с заданными переменными и проверяет условия assert; expect_failures ожидает, что проверка переменной отвергнет значение:
variables {
name = "test-web"
title = "Тест"
port = 8080
network = "bridge"
}
run "port_on_localhost" {
command = plan
assert {
condition = docker_container.this.ports[0].ip == "127.0.0.1"
error_message = "Порт должен быть открыт только на 127.0.0.1"
}
assert {
condition = output.url == "http://127.0.0.1:8080/"
error_message = "Неверный адрес сервиса"
}
}
run "privileged_port_rejected" {
command = plan
variables {
port = 80
}
expect_failures = [var.port]
}
run "page_is_served" {
# настоящий контейнер: создаётся и удаляется самим terraform test
variables {
name = "test-web-apply"
port = 8099
}
assert {
condition = docker_container.this.name == "test-web-apply"
error_message = "Имя контейнера не совпадает"
}
}
$ cd ../modules/webapp && terraform init -backend=false -no-color | grep initialized
Terraform has been successfully initialized!
$ terraform test -no-color
tests/webapp.tftest.hcl... in progress
run "port_on_localhost"... pass
run "privileged_port_rejected"... pass
run "page_is_served"... pass
tests/webapp.tftest.hcl... tearing down
tests/webapp.tftest.hcl... pass
Success! 3 passed, 0 failed.
Блоки с command = plan ничего не создают и работают за секунды. Третий блок — apply (команда по умолчанию): terraform test создал настоящий контейнер, проверил его и в конце удалил сам (tearing down). Для облачных модулей такие тесты гоняют в отдельном тестовом каталоге облака. terraform test есть и в OpenTofu (tofu test).
Главное правило команды: в общей инфраструктуре apply делает только конвейер. Люди предлагают изменения merge request'ом, конвейер показывает план, коллега читает план и код, после слияния — apply того же файла плана по кнопке. Пример для GitLab CI (подробно конвейеры — в курсе cicd-basics):
# Конвейер Terraform для проекта app: проверки и plan в каждом merge request,
# apply — вручную и только из main, тем же файлом плана.
default:
image: debian:trixie-slim # образ с terraform собирают заранее и кладут в свой реестр
before_script:
- cd app
- |
cat > ~/.terraformrc <<'RC'
provider_installation {
network_mirror {
url = "https://terraform-mirror.yandexcloud.net/"
include = ["registry.terraform.io/*/*"]
}
direct {
exclude = ["registry.terraform.io/*/*"]
}
}
RC
- terraform init -input=false -lock-timeout=5m
stages: [check, plan, apply]
check:
stage: check
script:
- terraform fmt -check -recursive ..
- terraform validate
- (cd ../modules/webapp && terraform init -input=false -backend=false && terraform test)
plan:
stage: plan
script:
- terraform plan -input=false -lock-timeout=5m -out=tfplan
- terraform show -no-color tfplan > plan.txt
artifacts:
paths: [app/tfplan, app/plan.txt]
expire_in: 1 week
apply:
stage: apply
script:
- terraform apply -input=false -lock-timeout=5m tfplan
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
environment: production
Шаги проверки можно выполнить и локально перед пушем. Сводка плана из show -json удобна для комментария в merge request — после правки заголовка страницы:
$ cd ../.. && terraform fmt -check -recursive && echo "fmt: ok"
fmt: ok
$ cd app && terraform validate -no-color
Success! The configuration is valid.
$ sed -i "s/Сервис цен/Цены/" main.tf
$ terraform plan -out=tfplan -no-color >/dev/null && terraform show -json tfplan | jq -c "[.resource_changes[].change.actions[]] | group_by(.) | map({(.[0]): length}) | add"
{"create":1,"delete":1,"no-op":1}
create + delete — контейнер будет пересоздан, а не изменён: заголовок попадает в контейнер через upload. Такая строка в комментарии к merge request заметнее, чем сотня строк плана.
~/.terraformrc; образ с Terraform собирают заранее и кладут в свой реестр, а не скачивают в каждом задании.TF_VAR_…, PG_CONN_STR, ключи облака), доступные только защищённым веткам.plan из merge request — чтение; у задания apply из main — запись. Сервисный аккаунт облака — с ролями только на нужный каталог, не admin облака.plan по расписанию ночью; непустой план — повод разбираться (модуль 03).Есть и внешние проверки: линтер tflint (ошибки, которые пропускает validate: несуществующие типы машин, устаревший синтаксис) и сканеры безопасности конфигураций — checkov, trivy config (открытые наружу порты, бакеты без шифрования). Их добавляют в стадию check.
$ terraform destroy -auto-approve -no-color | grep "Destroy complete"
Destroy complete! Resources: 2 destroyed.
$ terraform workspace select default && terraform destroy -auto-approve -no-color | grep "Destroy complete"
Switched to workspace "default".
Destroy complete! Resources: 2 destroyed.
$ terraform workspace delete prod
Deleted workspace "prod"!
$ cd ../network && terraform destroy -auto-approve -no-color | grep "Destroy complete"
Destroy complete! Resources: 1 destroyed.
.terraform.lock.hcl; не в Git — .terraform/, состояние, планы, *.auto.tfvarsterraform_remote_state читает чужое состояние целиком; связь источниками данных по имени безопаснееterraform test: run с plan или apply, assert, expect_failuresapply в общей инфраструктуре — только конвейер: fmt, validate, test → plan в MR → apply того же плана по кнопке