Infrastructure as a Code: ожидания и реальность

от автора

Игорь Тиунов

SRE в Yandex и автор на курсе «DevOps для эксплуатации и разработки».

Есть такие ребята — 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 для отладки изменений.

Одна и та же задача решается по-разному в разных файлах, и кажется, чтобы проделать аналогичное изменение в этот раз, проще создать новый файл и описать ресурсы заново.

Самое главное — работой вы точно будете обеспечены! Со временем и вовсе станете матёрыми экспертами: в голове кристаллизуется граф зависимостей между ресурсами, в котором вы будете хорошо ориентироваться, а у молодого и неопытного джуна появится наставник.

Вам же придётся доверять этому коду до тех пор, пока в голове «сидят» особенности работы всех зависимостей.

А что тут плохого?

Давайте выпишем список совершенных грехов:

  1. В репозитории отсутствует хоть сколько-нибудь логичная структура — в этом наборе файлов сам чёрт ногу сломит.

  2. Блокирование стейта снижает эффективность команды: кто-то катит очередные изменения, а вы ждёте, пока они применятся.

  3. Неопределённые интерфейсы в зависимостях. Тут можно провести аналогию с языками динамической типизации: несоответствия типов могут всплывать слишком поздно, когда они уже нанесли вред продовой инфраструктуре.

  4. Много «копипасты» и повторения кода.

  5. К такому коду мало доверия: новичку сложно ответить на вопрос «Что будет, если я запущу terraform apply?».

  6. Как это всё запускать в 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.

Вспомним всё и подведём итоги

Давайте теперь вспомним список проблем, который мы составляли в самом начале, и что получили:

  1. В репозитории присутствует логичная структура: модули и их использование в Terragrunt позволили разбить код на логичные структурные блоки.

  2. Каждая небольшая часть инфраструктуры работает со своим состоянием, не блокируя работу с другими: для каждого окружения создаётся свой state-файл.

  3. Между модулями определены интерфейсы в виде входных переменных и выходных значений.

  4. Организация кода в виде модулей добавляет возможность переиспользования.

  5. К коду модуля больше доверия за счёт тестов: мы можем убедиться, что модуль работает корректно.

  6. CI теперь можно организовать довольно гибко: для каждой части инфраструктуры и для каждого окружения.

Заключение

В этой статье мы рассмотрели, как можно организовать инфраструктурный код в Terraform-проекте. Мы разбили код на модули, описали зависимости между ними и научились запускать их с помощью Terragrunt.

Теперь можно смело переходить к следующему этапу — автоматизации с помощью CI-систем. Конечная цель организации инфраструктурного кода — создание надёжной и масштабируемой инфраструктуры, которая может поддерживаться и расширяться без лишних трудностей для SRE-инженера и команды, а таких трудностей можно избежать благодаря CI-системам.

Тестирование кода с помощью Terratest также имеет решающее значение для обеспечения качества и безопасности вашей инфраструктуры. Тестирование позволяет проверить, работает ли код так, как он должен работать, и выявлять ошибки и проблемы до того, как они станут критическими. Тестирование также помогает вам убедиться, что ваша инфраструктура работает так, как вы ожидаете, и что она не будет нежелательно изменена во время разработки.


ссылка на оригинал статьи https://habr.com/ru/companies/yandex_praktikum/articles/738682/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *