Модуль 08 — Terraform в команде

Когда инфраструктуру меняют пятеро, apply с ноутбука — это пять разных правд

Прогресс курса Модуль 8 из 9

Что вы освоите в этом модуле

01

Репозиторий

$ 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
# Скачанные провайдеры и модули
.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.

02

Проекты и состояние между ними

Оба проекта хранят состояние в одной базе 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"): им нужны права только на чтение объекта, и проекты не зависят от устройства состояния друг друга.

03

Окружения: workspaces

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, у каждой свой набор ресурсов.

04

Окружения: каталоги

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).

05

terraform test

Тесты модуля — файлы *.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).

06

Конвейер: plan в merge request, apply по кнопке

Главное правило команды: в общей инфраструктуре 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 заметнее, чем сотня строк плана.

Есть и внешние проверки: линтер 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.

Ключевые выводы модуля

Запомните главное

  • В Git — код, модули, .terraform.lock.hcl; не в Git — .terraform/, состояние, планы, *.auto.tfvars
  • Инфраструктуру делят на проекты по скорости изменений, у каждого своё состояние
  • terraform_remote_state читает чужое состояние целиком; связь источниками данных по имени безопаснее
  • Workspaces — для одинаковых временных стендов; dev и prod — каталоги со своими backend и правами
  • terraform test: run с plan или apply, assert, expect_failures
  • apply в общей инфраструктуре — только конвейер: fmt, validate, test → plan в MR → apply того же плана по кнопке
  • Секреты — в защищённых переменных CI; права plan и apply разные
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 07: count, for_each, источники данных Все модули Итоговый проект