
Есть такие ребята — SRE (с англ. Site Reliability Engineering), которые выросли из старых добрых и бородатых системных администраторов. Но они устали заниматься ежедневной рутиной и решили всё автоматизировать. Именно поэтому 50% времени SRE пишут код.

Не спешите применять это правило ко всем знакомым SRE, потому что в основном они поддерживают инфраструктуру проекта: запускают серверы, мониторят Kubernetes и перекладывают JSON из одного сервиса в другой. Решение всех этих задач они стремятся оформить в виде кода: скриптов, утилит, пайплайнов и манифестов.
А ведь со временем кода станет очень много, и его придётся читать, тестировать, рефакторить. Можно переписать внутреннее устройство функции, но для пользователя функции её поведение не изменится. Зато читабельность и производительность кода возрастут!
Рефакторить — перерабатывать архитектуру кода так, чтобы это было незаметно внешним пользователям.
У ребят из команды SRE основной объём кода будет инфраструктурным, то есть манифесты описания инфраструктуры. И стандарт де-факто для такого описания сегодня — это Terraform. Жизненный цикл сопровождения такого Tеrraform-кода зависит от проекта, и тут нет «волшебной пилюли» и строгих правил.
Разберём один из примеров организации IaaC-репозитория (Infrastructure as a Code) для Terraform и начнём с вредных советов.
Ничто не предвещало беды
На старте проекта вы создаёте свою первую виртуальную машину в Terraform-манифесте с помощью соответствующего ресурса.
Ресурсом в Terraform называется блок, описывающий инфраструктурный объект в облаке: виртуальная машина, сеть или DNS-запись.
Здесь и далее в качестве примера будем использовать Terraform-провайдер для Yandex Cloud.
Сделайте так, чтобы в файле-манифесте compute.tf у вас появилось описание:
compute.tf
resource "yandex_compute_instance" "default" { name = "test" platform_id = "standard-v1" zone = "ru-central1-a" resources { cores = 2 memory = 4 } boot_disk { initialize_params { image_id = "image_id" } } network_interface { subnet_id = "${yandex_vpc_subnet.foo.id}" } metadata = { foo = "bar" ssh-keys = "ubuntu:${file("~/.ssh/id_rsa.pub")}" } }
Хотя нет. Сначала нужно создать VPC и подсеть: разместим эти ресурсы в файле network.tf.
network.tf
resource "yandex_vpc_network" "foo" {} resource "yandex_vpc_subnet" "foo" { zone = "ru-central1-a" network_id = "${yandex_vpc_network.foo.id}" v4_cidr_blocks = ["10.5.0.0/24"] }
Теперь нам нужно запустить Kubernetes-кластер в облаке, поэтому создадим файл k8s.tf:
k8s.tf
resource "yandex_kubernetes_cluster" "my_cluster" { name = "name" description = "description" network_id = "${yandex_vpc_network.network_resource_name.id}" master { version = "1.17" zonal { zone = "${yandex_vpc_subnet.foo.zone}" subnet_id = "${yandex_vpc_subnet.foo.id}" } public_ip = true security_group_ids = ["${yandex_vpc_security_group.security_group_name.id}"] maintenance_policy { auto_upgrade = true maintenance_window { start_time = "15:00" duration = "3h" } } master_logging { enabled = true log_group_id = "${yandex_logging_group.log_group_resoruce_name.id}" kube_apiserver_enabled = true cluster_autoscaler_enabled = true events_enabled = true } } service_account_id = "${yandex_iam_service_account.service_account_resource_name.id}" node_service_account_id = "${yandex_iam_service_account.node_service_account_resource_name.id}" labels = { my_key = "my_value" my_other_key = "my_other_value" } release_channel = "RAPID" network_policy_provider = "CALICO" kms_provider { key_id = "${yandex_kms_symmetric_key.kms_key_resource_name.id}" } }
И сюда же добавим описание нод-кластера. Или давайте добавим ноды в отдельный файл k8s-nodes.tf, ведь бывает много отдельных групп, которые нужно быстро найти:
k8s-nodes.tf
resource "yandex_kubernetes_node_group" "my_node_group" { cluster_id = "${yandex_kubernetes_cluster.my_cluster.id}" name = "name" description = "description" version = "1.22" labels = { "key" = "value" } instance_template { platform_id = "standard-v2" network_interface { nat = true subnet_ids = ["${yandex_vpc_subnet.foo.id}"] } resources { memory = 2 cores = 2 } boot_disk { type = "network-hdd" size = 64 } scheduling_policy { preemptible = false } container_runtime { type = "containerd" } } scale_policy { fixed_scale { size = 1 } } allocation_policy { location { zone = "ru-central1-a" } } maintenance_policy { auto_upgrade = true auto_repair = true maintenance_window { day = "monday" start_time = "15:00" duration = "3h" } maintenance_window { day = "friday" start_time = "10:00" duration = "4h30m" } } }
Но виртуальных машин у нас может быть много, да и кластеров K8s тоже. Поэтому разделим их не по типу ресурса, а по проектам. Так проще сопровождать конкретный проект. Создадим файл project1.tf.
Зависимости
Ресурсы в манифестах обязательно зависят друг от друга — идентификаторы одних ресурсов будут использоваться в описании других.
Например, чтобы быстро находить серверы при настройке сетевых доступов между ними, создадим файл для групп безопасности (security groups) — sg.tf.
Виртуальным машинам нужен доступ в Kubernetes, а сервисам в Kubernetes — к виртуальным машинам. А ещё некоторые виртуальные машины «ровнее других», и им нужен доступ к сервисам в другом проекте (но не наоборот).
В одном проекте такие зависимости легко реализуются. Можно сослаться на идентификатор ресурса в соответствующих правилах:
resource "yandex_vpc_security_group" "group1" { name = "My security group 1" description = "description for my security group" network_id = yandex_vpc_network.foo.id # Разрешаем исходящий трафик к хостам group2 egress { protocol = "UDP" description = "rule3 description" security_group_id = yandex_vpc_security_group.group2.id port = 8080 } } resource "yandex_vpc_security_group" "group2" { name = "My security group 2" description = "description for my security group" network_id = yandex_vpc_network.foo.id # Разрешаем входящий трафик от хостам group1 ingress { protocol = "TCP" description = "rule1 description" security_group_id = yandex_vpc_security_group.group1.id port = 8080 } }
Но подождите, у нас тут циклическая зависимость двух ресурсов. Давайте для каждого сервиса будем создавать по две группы безопасности. Одну сделаем пустой и будем указывать её в правилах других групп безопасности, а вторая поработает в правилах:
# Пустая группа для хостов group1 resource "yandex_vpc_security_group" "group1-initial" { name = "My initial security group 1" description = "description for my security group" network_id = yandex_vpc_network.foo.id } resource "yandex_vpc_security_group" "group1" { name = "My security group 1" description = "description for my security group" network_id = yandex_vpc_network.foo.id # Чтобы избежать циклических зависимостей, указываем пустую group2-initial egress { protocol = "UDP" description = "rule3 description" security_group_id = yandex_vpc_security_group.group2-initial.id port = 8080 } } resource "yandex_vpc_security_group" "group2-initial" { name = "My security group 2" description = "description for my security group" network_id = yandex_vpc_network.foo.id } resource "yandex_vpc_security_group" "group2" { name = "My security group 2" description = "description for my security group" network_id = yandex_vpc_network.foo.id ingress { protocol = "TCP" description = "rule1 description" security_group_id = yandex_vpc_security_group.group1-initial.id port = 8080 } }
Запутанно, тут у нас зарождается начало первой макаронины, но зато это решает нашу проблему с циклом. А все зависимости между сервисами можно держать в голове: когда джун придёт за помощью, вы быстро укажете строчку в манифесте, чтобы вставить нужное правило.
Некоторое время спустя
Репозиторий разрастается, к одному способу организации добавляется второй, а потом третий, и мы получаем вот такой набор файлов.
Содержимое репозитория
. ├── project1.tf ├── project1-xyz.tf ├── project1-db-master.tf ├── project1-s3-project2-copy1.tf ├── project1-s3-project3-copy1.tf ├── project1-s3-project3.tf ├── project1-s3-project4.tf ├── project1-subnets.tf ├── project1-secrets.tf ├── project-6.tf ├── project2-test.tf ├── project2-test2.tf ├── project2-integrations.tf ├── project2-from-company.tf ├── project2-dev.tf ├── project2-ci.tf ├── project2-to-company-sg.tf ├── project2-to-company.tf ├── jump-host.tf ├── monitoring.tf ├── cdn-internal.tf ├── cdn-external.tf ├── cicd.tf ├── siem-dev-compute.tf ├── cloud-init │ ├── de-keys │ ├── de-devops-keys │ ├── de-admin-keys │ ├── de-whoami-keys │ ├── devops-keys │ ├── ipv4-router.yaml │ ├── ipv6-router.yaml │ ├── scripts.yaml │ ├── k8s-keys │ ├── super-super-users.yaml │ └── super-users.yaml ├── cloud.tf ├── cloud_company_test.tf ├── compute-non-production.tf ├── compute.tf ├── control.tf ├── servers.tf ├── dev-project1-integrations-kafka-topics.tf ├── dev-project1-integrations-kafka.tf ├── dev-project1-integrations.tf ├── dev-project1-db.tf ├── my-vm.tf ├── dns-microservices.tf ├── dns-macroservices.tf ├── inokentiy.tf ├── accounting-1c.tf ├── accounting-1c-enterprise.tf ├── accounting-backups.tf ├── accounting-db.tf ├── accounting-analytics.tf ├── accounting-project8-s3.tf ├── accounting-containers.tf ├── accounting-s3.tf ├── accounting-subnets.tf ├── accounting.tf ├── express-all.tf ├── security-rules-s3.tf ├── security-rules.tf ├── ci-project1-sec.tf ├── ci-ansible.tf ├── ci-autotests.tf ├── ci-common.tf ├── ci-deploy.tf ├── ci-accounting.tf ├── ci-infra.tf ├── ci-here.tf ├── ci-project8.tf ├── ci-build.tf ├── ci-tf.tf ├── cicd2.tf ├── mons.tf ├── iam.tf ├── proxy-nonsecure.tf ├── proxy-secure.tf ├── ipsec.tf ├── k8s-sib.tf ├── k8s-common.tf ├── k8s-dev-common-nodes.tf ├── k8s-dev-ingress.tf ├── k8s-dev-nodes.tf ├── k8s-dev-sa.tf ├── k8s-dev-vm-nodes.tf ├── k8s-dev.tf ├── k8s-secure-dev-common-nodes.tf ├── k8s-secure-dev-ingress.tf ├── k8s-secure-dev-nodes.tf ├── k8s-secure-dev-vm-nodes.tf ├── k8s-secure-dev.tf ├── k8s-test-common-nodes.tf ├── k8s-test-ingress.tf ├── k8s-test-nodes.tf ├── k8s-test-secure-common-nodes.tf ├── k8s-test-secure-ingress.tf ├── k8s-test-secure-nodes.tf ├── k8s-test-secure-sql-nodes.tf ├── k8s-test-secure-vm-nodes.tf ├── k8s-test-secure.tf ├── k8s-test-sa.tf ├── k8s-test-vm-nodes.tf ├── k8s-test.tf ├── keys.tf ├── lb.tf ├── test-dev-compute.tf ├── adapter.tf ├── connector.tf ├── db-backend-baza.tf ├── db-backend.tf ├── db.tf ├── midnight-computer.tf ├── nat.tf ├── network.tf ├── cache.tf ├── outputs.tf ├── secure-dmz.tf ├── backups.tf ├── project8-stand-folder.tf ├── project8-stand-sg.tf ├── project8-stands.tf ├── project8-proxy.tf ├── project8-new-left.tf ├── project8-new.tf ├── project8-new-vars.tf ├── project8-folder.tf ├── project8-some-tests.tf ├── project8-lb.tf ├── project8-proxy-compute.tf ├── project8-proxy-sg.tf ├── project8-proxy-variables.tf ├── project8-smoke-compute.tf ├── project8-smoke-dns.tf ├── project8-smoke-db.tf ├── project8-smoke-network.tf ├── project8-smoke-s3.tf ├── project8-smoke-sg.tf ├── project8-smoke-variables.tf ├── project8-test-compute.tf ├── project8-test-dns.tf ├── project8-test-folder.tf ├── project8-test-lb.tf ├── project8-test-lockbox.tf ├── project8-test-db.tf ├── project8-test-network.tf ├── project8-test-copies.tf ├── project8-test-sg.tf ├── project8-test-variables.tf ├── project8-testlab-compute.tf ├── project8-to-internet.tf ├── project8-test-folder.tf ├── project8-test.tf ├── proxy-to-partners.tf ├── qa.tf ├── containers.tf ├── s3.tf ├── s3_project8_project1.tf ├── s3_project8_project2.tf ├── external-to-project8.tf ├── scripts │ ├── manage.sh │ └── unmanage.sh ├── project7-test.tf ├── project7.tf ├── sg-for-servers.tf ├── sg.tf ├── sg-nikolay.tf ├── work-in-progress.tf ├── ver1-work-in-progress.tf ├── sib-compute.tf ├── sib-lb.tf ├── sib-db.tf ├── sib-other.tf ├── sib-sg.tf ├── wtf.tf ├── analyze-compute.tf ├── analyze-lb.tf ├── analyze-db-proxy.tf ├── analyze-other.tf ├── analyze-sg.tf ├── templates │ ├── script.yaml │ ├── open.yaml │ ├── forwarder.yaml │ ├── logs.yaml │ └── logs-forwarder.yaml ├── test-project1-integrations-kafka-topics.tf ├── test-project1-integrations-kafka.tf ├── test-project1-integrations.tf ├── test-project1-db-int.tf ├── test-project1-db.tf ├── test-project1-s3-exchange.tf ├── test-project1-s3-project8.tf ├── test-project1-s3-wtf.tf ├── test-project1-subnets.tf ├── test-dns-microservices.tf ├── test-dns-macroservices.tf ├── test-backend-kafka.tf ├── test-backend-redis.tf ├── test-backend-db.tf ├── secrets-test-dev-compute.tf ├── secrets.tf ├── playground.tf ├── manager.tf ├── s3-share.tf └── sa.tf
Если вы хотели посмотреть на реальный проект — вот он выше.
Теперь нам нужно разделить тестовое и продовое окружения, и после долгой плодотворной дискуссии мы с командой решаем продублировать весь этот набор файлов для прода.
Если запустить terraform plan в таком проекте, то можем ждать от 10 до 40 минут, и наш state заблокируется на всё время выполнения.
planпоказывает намерения изменений, которые Terraform собирается выполнить.
terraform state— это хранилище состояния текущей инфраструктуры. Такое состояние сравнивается с кодом описания в манифестах и с реальным положением вещей в облаке. После такого сравнения Terraform принимает решение о выполнении изменений. Стейт хранится в файле, поэтому можете использовать различные бэкенды для централизованного размещения этого файла.
Как раз успеете распить цикорий всей командой.
Хотя, конечно, добавление очередной виртуальной машины или правила в группу безопасности займёт гораздо больше времени: понадобится раскрутить клубок зависимостей, вставить описание ресурсов в нужное место и проделать несколько итераций запуска terraform plan для отладки изменений.
Одна и та же задача решается по-разному в разных файлах, и кажется, чтобы проделать аналогичное изменение в этот раз, проще создать новый файл и описать ресурсы заново.
Самое главное — работой вы точно будете обеспечены! Со временем и вовсе станете матёрыми экспертами: в голове кристаллизуется граф зависимостей между ресурсами, в котором вы будете хорошо ориентироваться, а у молодого и неопытного джуна появится наставник.
Вам же придётся доверять этому коду до тех пор, пока в голове «сидят» особенности работы всех зависимостей.
А что тут плохого?
Давайте выпишем список совершенных грехов:
В репозитории отсутствует хоть сколько-нибудь логичная структура — в этом наборе файлов сам чёрт ногу сломит.
Блокирование стейта снижает эффективность команды: кто-то катит очередные изменения, а вы ждёте, пока они применятся.
Неопределённые интерфейсы в зависимостях. Тут можно провести аналогию с языками динамической типизации: несоответствия типов могут всплывать слишком поздно, когда они уже нанесли вред продовой инфраструктуре.
Много «копипасты» и повторения кода.
К такому коду мало доверия: новичку сложно ответить на вопрос «Что будет, если я запущу
terraform apply?».Как это всё запускать в CI?
А как надо?
Модули
Описание вашего проекта нужно начать с Terraform-модулей. Terraform-модуль — это набор манифестов, собранных вместе для решения определенной задачи. Модуль можно выложить в общий доступ с помощью git-репозитория, хранить локально или использовать специальный сервис Terraform Registry. Типовой модуль может иметь следующую файловую структуру:
├── main.tf ├── outputs.tf ├── variables.tf ├── README.md
Во-первых, модули создают удобные абстракции, и вы можете описать инфраструктуру с помощью архитектурных терминов вместо использования физических объектов. Сравните:
Слева | Справа |
В этом проекте нам нужно запустить три сервера в разных зонах доступности, балансировщик и кластер баз данных | В этом проекте мы запустим сервис API-прокси для бесшовной интеграции нового бэкенда |
И какой вариант вам больше нравится?

Во-вторых, модули описывают строгие контракты для входных и выходных переменных. При использовании модулей вы соединяете выходные переменные (outputs) одного модуля c входными переменными (variables) другого модуля, а строгие контракты позволяют избежать ошибок в процессе сопровождения инфраструктуры и серьёзных инцидентов.
Например, описание сложной переменной для сетевых интерфейсов определяется её типом:
variable "network_interfaces" { description = "Instance network interfaces" # optional и значения по умолчанию появились в Terraform ≥ 1.3 type = map(object({ subnet_id = string ip_address = optional(string) security_group_ids = optional(set(string)) ipv6 = optional(bool) nat = optional(bool) nat_ip = optional(string) dns_record = optional(object({ dns_zone_id = string ttl = optional(number, 300) ptr = optional(bool, true) })) nat_dns_record = optional(object({ dns_zone_id = string ttl = optional(number, 300) ptr = optional(bool, true) })) })) default = {} }
Если на вход подать некорректный объект, например, не указав обязательное поле subnet_id, то Terraform сообщит нам об ошибке на ранней стадии валидации кода.
В-третьих, для каждого отдельного модуля можно написать тесты.
Тесты
Тесты для инфраструктурной автоматизации обеспечивают надёжное доверие к этой самой инфраструктурной автоматизации: поведение модуля тестируют на разных входных параметрах, а при изменении кода модуля можно будет понять влияние этого изменения на инфраструктуру.
Тесты особенно полезны в сложных модулях, в которых используются динамические блоки, циклы и вычисляемые локальные переменные (locals): поведение таких модулей почти невозможно прогнозировать, только читая код.
Наиболее популярный фреймворк для тестирования модулей — это Terratest — библиотека для Go, с помощью которой можно проверить как простые test-кейсы (ожидаемый output), так и сложную интеграционную логику с другими модулями.
package test import ( "testing" "github.com/gruntwork-io/terratest/modules/terraform" "github.com/stretchr/testify/assert" ) func TestTerraformModule(t *testing.T) { terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ TerraformDir: "./tests", }) // Удаляем ресурсы после тестов defer terraform.Destroy(t, terraformOptions) // Запускаем apply для тестовых ресурсов terraform.InitAndApply(t, terraformOptions) output := terraform.Output(t, terraformOptions, "hello_world") // Проверяем - тестируем, что output соответствует ожиданиям assert.Equal(t, "Hello, World!", output) }
При тестировании Terraform-модулей будет создаваться реальная инфраструктура в облаке. Такие тесты не похожи на Unit-тестирование — возможности создать mock-объекты для облачных провайдеров пока нет. Хотя что-то можно проверить, анализируя только вывод
terraform plan.
Разбивка кода проекта на логичную структуру
Теперь подумаем, как нам разбить весь проект на логичную структуру. В любом проекте обязательно выделяется инфраструктурный фундамент в виде VPC, подсетей и DNS-зоны. Назовём этот фундамент infra:
. └── terraform └── infra ├── dns │ └── main.tf ├── sa └── vpc
Для каждого окружения заведём отдельную директорию:
. └── terraform ├── envs │ ├── dev │ ├── prod │ └── test └── infra ├── dns ├── sa └── vpc
Но для работы с такой структурой нам нужно зайти в каждую директорию и выполнить terraform apply, а для обмена данными можно использовать terraform_remote_state источника данных (data source).
Terragrunt
Конечно, в любой CI-системе это можно организовать с помощью скриптов. Для оркестрации инфраструктурного Terraform-кода в сложном проекте хорошо зарекомендовал себя инструмент-враппер Terragrunt. Как и в случае с чистым Terraform, вы разбиваете код на модули, но для вызова этих модулей и передачи им входных переменных используете конфигурационные файлы terragrunt.hcl:
. └── terraform ├── envs │ ├── dev │ │ └── compute │ │ └── terragrunt.hcl │ │ k8s │ │ └── terragrunt.hcl │ ├── prod │ └── test └── infra ├── dns ├── sa └── vpc
При выполнении команды terragrunt run-all apply в корневой директории проекта оркестратор пройдётся по всем вложенным директориям и применит конфигурацию модуля, описанную в terragrunt.hcl.
Для каждого расположения конфигурации Terragrunt создаст свой собственный state-файл, например, envs/dev/compute/terraform.tfstate в настроенном бэкенде, а для обмена данными между этими стейтами будут использоваться переменные и выходные переменные модулей.
Чтобы описать зависимости между двумя модулями, используют ключевое слово dependency:
dependency "vpc" { config_path = "../../../infra/vpc" } inputs = { vpc_id = dependency.vpc.outputs.vpc_id }
В этом примере мы описали зависимость модуля compute от модуля vpc и передали входную переменную vpc_id в модуль compute.
Вспомним всё и подведём итоги
Давайте теперь вспомним список проблем, который мы составляли в самом начале, и что получили:
В репозитории присутствует логичная структура: модули и их использование в Terragrunt позволили разбить код на логичные структурные блоки.
Каждая небольшая часть инфраструктуры работает со своим состоянием, не блокируя работу с другими: для каждого окружения создаётся свой state-файл.
Между модулями определены интерфейсы в виде входных переменных и выходных значений.
Организация кода в виде модулей добавляет возможность переиспользования.
К коду модуля больше доверия за счёт тестов: мы можем убедиться, что модуль работает корректно.
CI теперь можно организовать довольно гибко: для каждой части инфраструктуры и для каждого окружения.
Заключение
В этой статье мы рассмотрели, как можно организовать инфраструктурный код в Terraform-проекте. Мы разбили код на модули, описали зависимости между ними и научились запускать их с помощью Terragrunt.
Теперь можно смело переходить к следующему этапу — автоматизации с помощью CI-систем. Конечная цель организации инфраструктурного кода — создание надёжной и масштабируемой инфраструктуры, которая может поддерживаться и расширяться без лишних трудностей для SRE-инженера и команды, а таких трудностей можно избежать благодаря CI-системам.
Тестирование кода с помощью Terratest также имеет решающее значение для обеспечения качества и безопасности вашей инфраструктуры. Тестирование позволяет проверить, работает ли код так, как он должен работать, и выявлять ошибки и проблемы до того, как они станут критическими. Тестирование также помогает вам убедиться, что ваша инфраструктура работает так, как вы ожидаете, и что она не будет нежелательно изменена во время разработки.

