*
*

Всем привет и добро пожаловать!

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

Для начала давайте определим, что такое Terraform.

Terraform - это инструмент, который позволяет безопасно и эффективно создавать, изменять и управлять облачными и локальными ресурсами, и который реализует концепцию "инфраструктура как код" (IaC) - Terraform Intro. Он предоставляет декларативную модель. Это значит, что мы описываем конечное состояние инфраструктуры или конкретного ресурса, и нам не нужно описывать пошаговые инструкции. Он имеет свой язык описания - HashiCorp Configuration Language (HCL). Далее этот код преобразуется в вызовы к API провайдера. О том, как работает Terraform, можно ознакомиться в его официальной документации.

Теперь давайте ответим на вопрос, а нужен ли вообще Terraform. Мой ответ - да. И вот собственно почему:

  • Terraform позволяет хранить инфраструктуру целиком или отдельные ресурсы в репозитории как код. Это позволяет пользоваться теми же преимуществами, которые мы используем для любого кода, а именно: прозрачность, доступность, версионирование, мы наглядно видим изменения в ресурсах и их историю, видим как эти изменения применяются, видим кто их вносил, можем контролировать изменения через ревью кода, можем откатить на предыдущую версию (commit). Мы всегда можем воспользоваться поиском по коду и найти, где настраивается тот или иной ресурс.

  • В преобладающем большинстве провайдеры не всегда предоставляют полный функционал через GUI, некоторые настройки доступны только через API, а Terraform как раз работает с API провайдера. Но, как мне подсказали, бывают и обратные случаи.

  • Когда у нас инфраструктура/ресурс/сервис/конфигурация представлены как код, то гораздо легче передавать/получать контекст, чем объяснять "на пальцах" или искать, что и где было "натыкано" руками. О передаче контекста и знаний я писал в отдельной статье.

  • Terraform позволяет автоматизировать рутинные задачи.

  • Terraform способствует снижению человеческого фактора.

  • Terraform позволяет стандартизировать подходы и процессы.

Все вышеперечисленные аргументы не относятся только к преимуществам Terraform, скорее больше в целом к преимуществам IaC. Да и у Terraform существуют аналоги, например, Pulumi, CloudFormation для AWS. Поэтому, если вы используете любой другой аналог Terraform, который поддерживает концепцию IaC, это уже хорошо, и однозначно лучше, чем "натыкивать" инфраструктуру руками.

Давайте представим, что код Terraform выглядел следующим образом:

Hidden text

main.tf

resource "aws_instance" "instance" {
  count         = var.instance_count
  ami           = "ami-xxxxxxxxxx"
  instance_type = var.instance_type
  tags = {
    Name = "instance_${count.index}"
  }
  lifecycle {
    create_before_destroy = true
  }
  iam_instance_profile = aws_iam_instance_profile.instance-profile.name
}

resource "aws_security_group" "allow-ssh" {
  name        = "allow-ssh"
  description = "Allow SSH"
  vpc_id      = "vpc-xxxxxxxxxx"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "allow-http" {
  name        = "allow-http"
  description = "Allow HTTP"
  vpc_id      = "vpc-xxxxxxxxxx"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "allow-https" {
  name        = "allow-https"
  description = "Allow HTTPS"
  vpc_id      = "vpc-xxxxxxxxxx"

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_s3_bucket" "s3" {
  bucket = "storage"

  tags = {
    Name = "storage"
  }

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_iam_role" "instance-role" {
  name = "instance-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ec2.amazonaws.com"
      }
    }]
  })
  tags = {
    Name = "instance-role"
  }
}

resource "aws_iam_role_policy" "instance-policy" {
  name = "instance-policy"
  role = aws_iam_role.instance-role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = [
        "s3:ListBucket",
        "s3:GetObject",
        "s3:PutObject"
      ]
      Effect = "Allow"
      Resource = [
        aws_s3_bucket.s3.arn,
        "${aws_s3_bucket.s3.arn}/*"
      ]
    }]
  })
}

resource "aws_iam_instance_profile" "instance-profile" {
  name = "instance-profile"
  role = aws_iam_role.instance-role.name
  tags = {
    Name = "instance-profile"
  }
}

resource "aws_lb" "nlb" {
  name               = "nlb"
  load_balancer_type = "network"
  subnets            = ["subnet-xxxxxxxxxx", "subnet-xxxxxxxxxx", "subnet-xxxxxxxxxx"]
}

resource "aws_lb_target_group" "tg" {
  name     = "tg"
  port     = 443
  protocol = "TCP"
  vpc_id   = "vpc-xxxxxxxxxx"
}

resource "aws_lb_listener" "listener" {
  load_balancer_arn = aws_lb.nlb.arn
  port              = 443
  protocol          = "TCP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tg.arn
  }
}

resource "aws_lb_target_group_attachment" "tg-attachment" {
  count            = var.instance_count
  target_group_arn = aws_lb_target_group.tg.arn
  target_id        = aws_instance.instance[count.index].id
  port             = 443
}

outputs.tf

output "s3_bucket_id" {
  value = aws_s3_bucket.s3.id
}

output "nlb_dns_name" {
  value = aws_lb.nlb.dns_name
}

providers.tf

provider "aws" {
  region = "eu-central-1"
}

terraform_backend.tf

terraform {
  backend "s3" {
    bucket = "terraform-state-bucket"
    key = "terraform/state"
    region = "eu-central-1"
  }
}

variables.tf

variable "instance_count" {
  type    = any
  default = 3
}

variable "instance_type" {
  default = "t2.micro"
  type    = any
}

versions.tf

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.24.0"
    }
  }
}

За несколько лет практического использования Terraform удалось прийти к выводам и сформировать рекомендации. Попробуем разбить их на логические блоки:

1. Code style

1.1. Переход от описания каждого ресурса отдельно к модели с массивами и циклами, когда ресурсу передаётся массив с вводными данными, а для итерации используется for_each.

Было

resource "aws_security_group" "allow-ssh" {
  name = "allow-ssh"
  …
  ingress {
    …
  }

  egress {
    …
  }
}

resource "aws_security_group" "allow-http" {
  name = "allow-http"
  …
  ingress {
    …
  }

  egress {
    …
  }
}

resource "aws_security_group" "allow-https" {
  name = "allow-https"
  …
  ingress {
    …
  }

  egress {
    …
  }
}

Стало

resource "aws_security_group" "this" {
  for_each    = local.security_groups
  vpc_id      = each.value.vpc_id
  name        = each.value.name
  description = each.value.description
  dynamic "ingress" {
    for_each = each.value.ingress
    content {
      from_port   = lookup(ingress.value, "from_port", lookup(ingress.value, "port", "0"))
      to_port     = lookup(ingress.value, "to_port", lookup(ingress.value, "port", "0"))
      protocol    = lookup(ingress.value, "proto", "tcp")
      cidr_blocks = lookup(ingress.value, "cidr", [])
      description = lookup(ingress.value, "description", "")
    }
  }
  dynamic "egress" {
    for_each = each.value.egress
    content {
      from_port   = lookup(egress.value, "from_port", lookup(egress.value, "port", "0"))
      to_port     = lookup(egress.value, "to_port", lookup(egress.value, "port", "0"))
      protocol    = lookup(egress.value, "proto", "tcp")
      cidr_blocks = lookup(egress.value, "cidr", [])
      description = lookup(egress.value, "description", "")
    }
  }
  …
}

locals {
  security_groups = {
    instance_sg = {
      name        = "instance-sg"
      description = "Security group for inctances"
      vpc_id      = data.aws_vpc.vpc.id
      ingress = [
        { port = 22, cidr = ["0.0.0.0/0"], description = "Allow SSH" },
        { port = 80, cidr = ["0.0.0.0/0"], description = "Allow HTTP" },
        { port = 443, cidr = ["0.0.0.0/0"], description = "Allow HTTPS" },
      ]
      egress = [
        { proto = "-1", cidr = ["0.0.0.0/0"], description = "Allow egress traffic" },
      ]
    }
  }
}

Это позволяет отделить код создания ресурса, который по факту меняется очень редко, и вводные данные, которые как раз часто изменяются. Здесь можно привести аналогию и представить блок создания ресурса функцией, которая на вход принимает параметры, а вводные данные - такими параметрами. При такой схеме мы можем разнести код создания ресурса и вводные данные по отдельным файлам, и всё наше управление инфраструктурой или отдельными ресурсами свести к редактированию одного (или хотя бы нескольких) файла, добавляя или удаляя вводные данные в массив. Так как при каждом изменении вводных данных нет необходимости править код создания ресурса, то мы можем значительно снизить человеческий фактор. Кроме того, если у нас возникла необходимость внести изменения в параметры ресурса, то нет необходимости вносить изменения в 100-500 ресурсов, а только в один блок, можно также установить в коде значение по умолчанию.

1.2. Использование data source вместо хардкода. Это делает код более универсальным и тиражируемым, мы избавляемся от необходимости проверять весь код при его копировании и эксплуатации в других окружениях или аккаунтах.

Было

resource "aws_lb" "nlb" {
  name               = "nlb"
  load_balancer_type = "network"
  subnets            = ["subnet-xxxxxxxxxx", "subnet-xxxxxxxxxx", "subnet-xxxxxxxxxx"]
  …
}

Стало

resource "aws_lb" "nlb" {
  name               = "nlb"
  load_balancer_type = "network"
  subnets            = data.aws_subnets.subnets.ids
  …
}

1.3. Использование нижнего подчеркивания "_" вместо тире "-" в наименовании module, resource, data source, variable, output, etc.

Было

resource "aws_iam_instance_profile" "instance-profile" {
  name = "instance-profile"
  …
}

Стало

resource "aws_iam_instance_profile" "instance_profile" {
  name = "instance-profile"
  …
}

1.4. Использование обезличенного this в имени ресурса, если модуль создаёт единственный ресурс такого типа или нет более наглядного и общего имени, которое бы подошло.

Пример

resource "aws_security_group" "this" {
  for_each = local.security_groups
  …
  dynamic "ingress" {
    for_each = each.value.ingress
    content {
      …
    }
  }
  dynamic "egress" {
    for_each = each.value.egress
    content {
      …
    }
  }
  …
}

Данную рекомендацию наглядно демонстрирует преобразованный блок кода про создание AWS Security Groups. На самом деле его вполне можно вынести во внешний модуль и использовать (вызывать) в тех частях инфраструктуры, где есть необходимость в создании AWS Security Groups.

1.5. Использование тире "-" внутри значений параметров, например, в значении параметра name. Это придаёт более человеко читаемый вид, а в некоторых ресурсах нельзя использовать нижнее подчёркивание "_" в имени.

Было

resource "aws_instance" "instance" {
  count = var.instance_count
  …
  tags = {
    Name = "instance_${count.index}"
  }
  …
}

Стало

resource "aws_instance" "instance" {
  for_each = var.instances
  …
  tags = {
    Name = each.value.instance_name,
    …
  }
}

instances = {
  instance1 = { … , instance_name = "instance-1", … }
  instance2 = { … , instance_name = "instance-2", … }
}

1.6. Добавление параметров count и for_each внутрь блока кода первой строкой.

Пример

resource "aws_lb_target_group_attachment" "tg_attachment" {
  for_each = toset(data.aws_instances.nlb.ids)
  …
}

1.7. Добавление параметров depends_on, lifecycle и tags (если ресурс их поддерживает) в конец блока кода последовательно друг за другом.

Было

resource "aws_instance" "instance" {
  count = var.instance_count
  …
  tags = {
    …
  }
  lifecycle {
    …
  }
  …
}

Стало

resource "aws_instance" "instance" {
  for_each = var.instances
  …
  lifecycle {
    …
  }
  tags = {
    …
  }
}

1.8. Использование проверок для переменных. При создании ресурсов или объектов со стороны провайдеров существуют ограничения, о которых мы можем узнать либо из документации к сервису, либо непосредственно при применении кода, когда ошибку выдаёт API провайдера, например, ограничения на специальные символы или длину для имени AWS S3. Для упрощения использования кода можно воспользоваться блоком validation и параметрами condition и error_message для variable, чтобы проверять вводные данные до применения, на этапе валидации или плана.

Пример

variable "bucket_name" {
  description = "Name of the S3 bucket"
  type        = string
  validation {
    condition     = can(regex("^[a-z0-9-]+$", var.bucket_name))
    error_message = "S3 bucket name must not contain underscores and should only contain lowercase letters, numbers, and hyphens"
  }
}

1.9. Описание параметров variable в следующей последовательности:
description
type
default
validation

Практической пользы не так много, но такой подход систематизирует и структурирует код.

Пример

variable "region" {
  description = "AWS region"
  type        = string
  default     = "eu-central-1"
}

1.10. Обязательное (всегда) описание параметра description для variable и output. Это делает код открытым и более понятным, ещё один механизм передачи контекста, позволяет в дальнейшем автоматически генерировать документацию к коду, например, с помощью terraform-docs.

Было

output "nlb_dns_name" {
  value = aws_lb.nlb.dns_name
}

Стало

output "nlb_dns_name" {
  description = "DNS name of the NLB"
  value       = aws_lb.nlb.dns_name
}

1.11. Детальное описание типа для каждой переменной, в том числе внутри map и object. Избежание типа any. Это позволяет лучше структурировать код, понимать типы передаваемых между модулей переменных (объектов), контролировать вводные данные.

Пример

variable "instances" {
  description = "Instances with parameters"
  type = map(object({
    instance_type = string
    instance_name = string
    idx           = number
  }))
}

1.12. Использование по возможности for_each вместо count. Для создания нескольких однотипных ресурсов без написания кода для каждого в отдельности можно воспользоваться парой параметров обработки циклов - это count и for_each.

У параметра count в значении указывается целое число и создаётся такое количество экземпляров ресурса, например:

resource "aws_instance" "instance" {
  count = var.instance_count
  …
}

Код выше создаёт 3 одинаковых EC2 инстанса. Если мы хотим сократить количество инстансов до 2-х (count = 2), то Terraform не даёт выбрать какой именно из 3-х инстансов будет уничтожен.

А вот параметр for_each позволяет работать с именованными списками. Кроме того, мы можем написать код так, чтобы задать для каждого инстанса разные AMI или тип, например:

resource "aws_instance" "instance" {
  for_each      = var.instances
  ami           = data.aws_ami.ubuntu.id
  instance_type = each.value.instance_type
  …
}

instances = {
  instance1 = { … }
  instance2 = { … }
}

При этом, у нас есть возможность производить манипуляции с конкретным инстансом, то есть если мы удалим из переменной строку с instance1, то удалится именно instance1.

На практике был случай, когда для внутреннего пользования был переписан публичный модуль AWS LB v8.7.0, count были заменены на for_each. Из-за использования count в данном модуле имела значение последовательность вводных данных (настроек LB), и это сильно усложняло эксплуатацию кода с большим количество настроек. Замена на for_each упростила это, сделала модуль более гибким. И судя по последним изменениям был произведен рефакторинг публичного модуля AWS LB v9.0.0, и так же count был заменён на for_each.

Но нужно понимать, что не всегда стоит заменять count на for_each. Каждый из этих параметров имеет свою специфику и сценарии, когда они более оптимальны для использования.

2. Структура (файлов/каталогов/репозиториев)

2.1. Вынесение изменяемых вводных данных в variables и locals, удаление хардкода. Если вводные данные не содержат переменных или вычисляемых значений, то практичнее выносить их в variables, если содержат и в этом есть необходимость - в locals. Это также позволяет отделить код создания ресурсов и вводные данные. В большинстве случаев весь код создания ресурсов получается идентичным от окружения к окружению и хранится в отдельных файлах, а управление инфраструктурой или отдельными ресурсами сводится к редактированию variables и locals. Уточню, что такое возможно не во всех сценариях, но практика показывает, что в 90% это реально и сильно упрощает процесс обслуживания кода. Это как values.yml для helm чарта.

2.2. Вынесение version, provider, backend в один отдельный файл, например, terraform.tf. Эти блоки относятся больше к управлению кодом, чем к созданию конкретных ресурсов. В них, как правило, нельзя использовать переменные. Это позволяет свести их контроль в одну точку (файл). Кроме того, если есть планы дальнейшего перехода на Terragrunt, то это упрощает процесс и позволяет из одной точки (файла) генерировать эти данные, контролировать версии и настройки хранения стейта.

Было

providers.tf

provider "aws" {
  region = "eu-central-1"
}

terraform_backend.tf

terraform {
  backend "s3" {
    bucket = "terraform-state-bucket"
    key    = "terraform/state"
    region = "eu-central-1"
  }
}

versions.tf

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.24.0"
    }
  }
}

Стало

terraform.tf

terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.24.0"
    }
  }

  backend "s3" {
    bucket         = "terraform-state-bucket"
    key            = "terraform/state.tfstate"
    region         = "eu-central-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

provider "aws" {
  region = var.region
}

2.3. Разделение кода на отдельные файлы. Чтобы не иметь большую "портянку" кода в одном файле, удобнее и практичнее разделить код по отдельным файлам, например, отдельные файлы под AWS S3, AWS VPC и так далее. Это хорошая возможность структурировать код, разделить его на логические части, становится удобнее ориентироваться в коде и его структуре.

Например, код в файле main.tf из примера мы можем разделить на следующие файлы, из названия которых сразу понятно, какие ресурсы (объекты) они содержат: s3.tf, aim.tf, ec2.tf, etc.

2.4. Разделение кода на слои (каталоги). Под слоями понимаются отдельные каталоги, в которых описан код, создающий логически обособленные ресурсы. Каждый слой (каталог) имеет свой стейт. То есть в рамках одного окружения и/или аккаунта выделяются слои (каталоги), первым слоем идут базовые ресурсы, от которых зависят последующие, и так далее. Например, код для какого-нибудь окружения в AWS аккаунте можно разделить на следующие слои (каталоги):
KMS
IAM (+ IRSA, Instance Profile)
VPC (+ Subnets, Security Groups)
S3
EKS
EC2 for infrastructure services
EC2 for product services
ALB, NLB

Это позволяет снизить зону аффекта, уменьшить время применения кода, сократить время блокировки. Что-то изменяется редко, например, AWS VPC, кроме того, многие правки в AWS VPC приводят к пересозданию ресурсов, что, как правило, не допустимо. Что-то изменяется часто, например, добавляются EC2 инстансы под новые сервисы или увеличивается размер существующих, потому что не справляются с текущей нагрузкой. Что-то применяется долго, например, AWS Global Accelerator, он имеет глобальную конфигурацию и включает взаимодействие с глобальной edge-сетью AWS, его применение может доходить до 15 минут, и блокировать стейт для всего кода всё это время - не самое практичное и оптимальное решение. Что-то применяется быстро, например, IAM.

Кроме того, большое количество объектов в стейте не улучшает производительность кода и очень сильно растягивает его применение и время блокировки стейта. А если возникает необходимость манипулировать стейтом и его объектами, то это сильно усложняет процесс и часто приводит к проблемам и повреждению стейта.

Например, код из примера можно разделить на 2 слоя: 1 - создание вычислительных ресурсов и хранилища, 2 - создание балансировщика. Из-за небольшого количества ресурсов (объектов) в примере более детальное деление на отдельные слои будет не столь показательным и не даст ощутимой выгоды. И когда у нас 10-20 ресурсов (объектов) это не критично.

2.5. Подключение модулей как внешние источники - Terraform Module Sources. Это позволяет избежать жесткой привязки к коду модуля, даёт больше пространства для манёвра, позволяет версионировать модули, расширяет возможности для рефакторинга модулей, добавляет гибкости. Например, после рефакторинга модуля, особенно если там есть breaking changes (лучше конечно без них), можно поэтапно внедрять новую версию, прописывая в source для каждого блока отдельно.

3. Управление кодом и его применение

3.1. Использование для хранения стейта удаленное хранилище, например, S3. Terraform поддерживает несколько типов хранения для стейта, от локального каталога до Kubernetes - Terraform Backends. Использование локального компьютера и каталога на нём для хранения источника истины инфраструктуры (стейта) - сомнительная затея. Кроме того, если мы работаем с кодом в команде, то даже не желательно, а обязательно иметь единую точку для хранения стейта, и удаленное хранилище для этого подходит отлично. Я предпочитаю использовать для хранения стейта AWS S3, не зависимо от того, что мы конфигурируем. В AWS S3 бакете создаются каталоги под различные стейты, создаётся иерархия каталогов под проекты/аккаунты/окружения/слои/сервисы/ресурсы, настраивается разделение прав по данным каталогам, включается версионирование и шифрование, настраивается реплика в другой регион AWS.

3.2. Использование блокировок для стейта - Terraform State Locking. Когда мы управляем инфраструктурой или отдельными ресурсами через Terraform не одни, то может возникнуть ситуация с одновременным запуском, что может привести к повреждению стейта. Например, вы или ваш коллега может перетереть ваши или его объекты в стейте. Для предотвращения такого поведения была разработана блокировка стейта. Если кто-то запускает terraform plan, terraform apply или проводит манипуляции со стейтом из terraform cli, то стейт блокируется для других пользователей, и выполнять манипуляции со стейтом может только этот пользователь. Другим пользователям необходимо дождаться завершения блокировки.

Было

terraform {
  backend "s3" {
    bucket = "terraform-state-bucket"
    key    = "terraform/state"
    region = "eu-central-1"
  }
}

Стало

terraform {
  …
  backend "s3" {
    bucket         = "terraform-state-bucket"
    key            = "terraform/state.tfstate"
    region         = "eu-central-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

3.3. Увеличение количества параллельных операций Terraform. По умолчанию Terraform использует 10 параллельных операций. Мы можем изменить количество через параметр запуска terraform cli или передать его как переменные окружения - Terraform Parallelism.

terraform plan -parallelism=100
terraform apply -parallelism=100
export TF_CLI_ARGS_plan="-parallelism=100"
export  TF_CLI_ARGS_apply="-parallelism=100"

Для локального запуска у меня всегда прописаны эти переменные окружения, а в CI - как переменные пайплайна.
На практике с помощью такой настройки удалось сократить время выполнения terraform plan и terraform apply с 10 минут до 30 секунд.

3.4. Сохранение плана в файл и последующее применение конфигурации из этого файла.

terraform plan -out tf.plan
terraform apply tf.plan

Это позволяет сократить время применения кода, потому что перед terraform apply не будет лишний раз выполняться terraform plan. Кроме того, мы получаем более контролируемое поведение и снижаем зону аффекта, так как точно знаем, какие изменения применяем.


По итогу, вышеперечисленные рекомендации помогли оптимизировать код и сделать его более структурированным. Теперь давайте представим, что в результате код приобрёл следующий вид:

Hidden text

Слой 1

terraform.tf

terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.24.0"
    }
  }

  backend "s3" {
    bucket         = "terraform-state-bucket"
    key            = "terraform/state.tfstate"
    region         = "eu-central-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

provider "aws" {
  region = var.region
}

variables.tf

variable "instances" {
  description = "Instances with parameters"
  type = map(object({
    instance_type = string
    instance_name = string
    idx           = number
  }))
}

variable "vpc_name" {
  description = "Name of the VPC"
  type        = string
}

variable "subnet_name" {
  description = "The subnet name or template"
  type        = string
}

variable "bucket_name" {
  description = "Name of the S3 bucket"
  type        = string
  validation {
    condition     = can(regex("^[a-z0-9-]+$", var.bucket_name))
    error_message = "S3 bucket name must not contain underscores and should only contain lowercase letters, numbers, and hyphens"
  }
}

variable "region" {
  description = "AWS region"
  type        = string
  default     = "eu-central-1"
}

outputs.tf

output "s3_bucket_id" {
  description = "ID of the S3 bucket"
  value       = aws_s3_bucket.s3.id
}

s3.tf

resource "aws_s3_bucket" "s3" {
  bucket = var.bucket_name
  lifecycle {
    prevent_destroy = true
  }
  tags = {
    Name = var.bucket_name
  }
}

iam.tf

resource "aws_iam_role" "instance_role" {
  name               = "instance-role"
  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
  tags = {
    Name = "instance-role"
  }
}

resource "aws_iam_role_policy" "instance_policy" {
  name   = "instance-policy"
  role   = aws_iam_role.instance_role.id
  policy = data.aws_iam_policy_document.s3_access_policy.json
}

resource "aws_iam_instance_profile" "instance_profile" {
  name = "instance-profile"
  role = aws_iam_role.instance_role.name
  tags = {
    Name = "instance-profile"
  }
}

data "aws_iam_policy_document" "assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
    effect = "Allow"
  }
}

data "aws_iam_policy_document" "s3_access_policy" {
  statement {
    actions = [
      "s3:ListBucket",
      "s3:GetObject",
      "s3:PutObject"
    ]
    resources = [
      "${aws_s3_bucket.s3.arn}",
      "${aws_s3_bucket.s3.arn}/*"
    ]
    effect = "Allow"
  }
}

ec2.tf

resource "aws_instance" "instance" {
  for_each             = var.instances
  ami                  = data.aws_ami.ubuntu.id
  instance_type        = each.value.instance_type
  subnet_id            = data.aws_subnets.subnets.ids[each.value.idx]
  iam_instance_profile = aws_iam_instance_profile.instance_profile.name
  lifecycle {
    create_before_destroy = true
  }
  tags = {
    Name = each.value.instance_name,
    lb   = "nlb"
  }
}

data.tf

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477", "amazon"]
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

data "aws_vpc" "vpc" {
  filter {
    name   = "tag:Name"
    values = [var.vpc_name]
  }
}

data "aws_subnets" "subnets" {
  filter {
    name   = "tag:Name"
    values = [var.subnet_name]
  }
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.vpc.id]
  }
}

sg.tf

resource "aws_security_group" "this" {
  for_each    = local.security_groups
  vpc_id      = each.value.vpc_id
  name        = each.value.name
  description = each.value.description
  dynamic "ingress" {
    for_each = each.value.ingress
    content {
      from_port   = lookup(ingress.value, "from_port", lookup(ingress.value, "port", "0"))
      to_port     = lookup(ingress.value, "to_port", lookup(ingress.value, "port", "0"))
      protocol    = lookup(ingress.value, "proto", "tcp")
      cidr_blocks = lookup(ingress.value, "cidr", [])
      description = lookup(ingress.value, "description", "")
    }
  }
  dynamic "egress" {
    for_each = each.value.egress
    content {
      from_port   = lookup(egress.value, "from_port", lookup(egress.value, "port", "0"))
      to_port     = lookup(egress.value, "to_port", lookup(egress.value, "port", "0"))
      protocol    = lookup(egress.value, "proto", "tcp")
      cidr_blocks = lookup(egress.value, "cidr", [])
      description = lookup(egress.value, "description", "")
    }
  }
  lifecycle {
    create_before_destroy = true
  }
  tags = {
    Name = each.value.name
  }
}

sg_locals.tf

locals {
  security_groups = {
    instance_sg = {
      name        = "instance-sg"
      description = "Security group for inctances"
      vpc_id      = data.aws_vpc.vpc.id
      ingress = [
        { port = 22, cidr = ["0.0.0.0/0"], description = "Allow SSH" },
        { port = 80, cidr = ["0.0.0.0/0"], description = "Allow HTTP" },
        { port = 443, cidr = ["0.0.0.0/0"], description = "Allow HTTPS" },
      ]
      egress = [
        { proto = "-1", cidr = ["0.0.0.0/0"], description = "Allow egress traffic" },
      ]
    }
  }
}

all.auto.tfvars

instances = {
  instance1 = { instance_type = "t2.micro", instance_name = "instance-1", idx = 0 }
  instance2 = { instance_type = "t2.micro", instance_name = "instance-2", idx = 1 }
}
bucket_name = "storage"
vpc_name    = "vpc"
subnet_name = "vpc-subnet-*"

Слой 2

terraform.tf

terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.24.0"
    }
  }

  backend "s3" {
    bucket         = "terraform-state-bucket"
    key            = "terraform/lb.tfstate"
    region         = "eu-central-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

provider "aws" {
  region = var.region
}

variables.tf

variable "vpc_name" {
  description = "Name of the VPC"
  type        = string
}

variable "subnet_name" {
  description = "The subnet name or template"
  type        = string
}

variable "region" {
  description = "AWS region"
  type        = string
  default     = "eu-central-1"
}

outputs.tf

output "nlb_dns_name" {
  description = "DNS name of the NLB"
  value       = aws_lb.nlb.dns_name
}

data.tf

data "aws_vpc" "vpc" {
  filter {
    name   = "tag:Name"
    values = [var.vpc_name]
  }
}

data "aws_subnets" "subnets" {
  filter {
    name   = "tag:Name"
    values = [var.subnet_name]
  }
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.vpc.id]
  }
}

data "aws_instances" "nlb" {
  instance_tags = { lb = "nlb" }
}

nlb.tf

resource "aws_lb" "nlb" {
  name               = "nlb"
  load_balancer_type = "network"
  subnets            = data.aws_subnets.subnets.ids
  tags = {
    Name = "nlb"
  }
}

resource "aws_lb_target_group" "tg" {
  name     = "tg"
  port     = 443
  protocol = "TCP"
  vpc_id   = data.aws_vpc.vpc.id
  tags = {
    Name = "tg"
  }
}

resource "aws_lb_listener" "listener" {
  load_balancer_arn = aws_lb.nlb.arn
  port              = 443
  protocol          = "TCP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tg.arn
  }
}

resource "aws_lb_target_group_attachment" "tg_attachment" {
  for_each         = toset(data.aws_instances.nlb.ids)
  target_group_arn = aws_lb_target_group.tg.arn
  target_id        = each.value
  port             = 443
  depends_on = [
    aws_lb.nlb,
    aws_lb_target_group.tg
  ]
}

nlb.auto.tfvars

vpc_name    = "vpc"
subnet_name	= "vpc-subnet-*"


Всё вышеперечисленное не ограничивается только данным списком и не претендует на последнюю истину. Практика показывает, что у каждого может быть свой путь, который приводит к формированию своих выводов и рекомендаций по написанию кода Terraform. И даже изложенное выше может не принести пользы или значительно улучшить управление кодом Terraform. Если у вас за время использования Terraform сложились свои подходы, вы можете поделиться ими в комментариях к статье.

*Picture is generated by OpenAI's ChatGPT

Комментарии (5)


  1. Year
    29.05.2024 05:21
    +1

    Спасибо за добротный разбор.

    Я видел еще пару интересных концепций:

    • Использование дефолтного объекта. Например, параметры ami, instance_type, create_before_destroy и tag можно собрать в одну переменную default_instance. Теперь мы все эти параметры можем как переопределять, так и вовсе не указывать (хотя для читаемости параметр instance_type можно и оставить). Однако это может нарушать принципы, изложенные в п.1.11 статьи.

    Hidden text
    variable "instances" {
      description = "Instances with parameters"
      type = map(object({
    #    instance_type = string
        instance_name = string
        idx           = number
      }))
    }
    
    variable "default_instance" {
      description = "Default Instance parameters"
      type = object({
        instance_type = string
        ami = string
        create_before_destroy = bool
        tags = map(string)
      })
    }
    
    # default_instance = {
    #   instance_type = "t2.micro"
    #   ami = "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"
    #   create_before_destroy = true
    #   tags = { lb = "nlb" }
    # }
    # instances = {
    #   instance1 = { instance_name = "instance-1", idx = 0 }
    #   instance2 = { instance_name = "instance-2", idx = 1 }
    # }
    
    locals {
    instances = { for k, v in var.instances: k => merge(var.default_instance, v) }
    }
    
    resource "aws_instance" "instance" {
      for_each             = local.instances
      ami                  = data.aws_ami.ubuntu.id
      instance_type        = each.value.instance_type
      subnet_id            = data.aws_subnets.subnets.ids[each.value.idx]
      iam_instance_profile = aws_iam_instance_profile.instance_profile.name
      lifecycle {
        create_before_destroy = each.value.create_before_destroy
      }
      tags = merge(each.value.tags, { Name = each.value.instance_name })
    }

    • Использование yaml файлов для задания переменных. Если требуется использовать один конфиг для нескольких инструментов (например, для terraform и ansible или helm), то вместо *.auto.tfvars может подойти более универсальный формат. Код terraform при этом не сильно усложняется

    Hidden text
    default_instance:
      instance_type: t2.micro
      ami: "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"
      create_before_destroy: true
      tags:
        lb: nlb
    
    instances:
      instance1: 
        instance_name: instance-1
        idx: 0
      instance2: 
        instance_name: instance-2 
        idx: 1
    
    # variable "config_path" {
    #   description = "Path to yaml config"
    #   path = string
    # }
    
    # locals {
    #   config = yamldecode(file(${var.config_path}"))
    #   instances = local.config.instances
    # }


  1. Chupaka
    29.05.2024 05:21
    +1

    1.3. Использование нижнего подчеркивания "_" вместо тире "-" в наименовании module, resource, data source, variable, output, etc.

    К сожалению, этот пункт никак не прокомментирован. Зачем всё же у объекта и управляющего им ресурса делать разные имена, ещё и сильно похожие? :)


  1. nicosha
    29.05.2024 05:21

    Спасибо за бестррактис. Неплохо бы дополнить это репом с пуликом на рефакторинг для наглядности


  1. APXEOLOG
    29.05.2024 05:21
    +3

    Все вышеперечисленные аргументы не относятся только к преимуществам Terraform, скорее больше в целом к преимуществам IaC. Да и у Terraform существуют аналоги, например, Pulumi, CloudFormation для AWS. Поэтому, если вы используете любой другой аналог Terraform, который поддерживает концепцию IaC, это уже хорошо, и однозначно лучше, чем "натыкивать" инфраструктуру руками.

    Между Terraform и Pulumi есть кардинальное концептуальное отличие. Terraform - это декларативная портянка, в которой придется на еще одном декларативном синтаксисе описывать километровые конфигурационные файлы. Это тоже самое, что CloudFormation Template в AWS.

    Pulumi (и родной AWS CDK) - это модель описания инфраструктуры реальным кодом (обычно с поддержкой нескольких ЯП), где портянка из п.1 авто-генерируется на основе написанного кода.

    Любой, кто работал с CFN Templates на реальной инфраструктуре, больше к декларативному подходу возвращаться не захочет.

    Мы в компании давно выбрали CDK и ни разу не пожалели. Рекомендую всем изучить этот вопрос, и четко ответить на вопрос - нужен ли вам Тераформ.

    Кстати HashiCorp осознали фатальный недостаток декларативных портянок и тоже делают https://github.com/hashicorp/terraform-cdk. Можно его тоже рассмотреть.


    1. Cib0rg
      29.05.2024 05:21

      Так-то да, нативные языки лучше. Но надо учитывать такую вещь, как "отраслевой стандарт". Если вы готовы на несколько месяцев дольше искать человека в случае необходимости замены или расширения команды - то никаких проблем. Job security разными методами достигается.