Terraform и tflint

Terraform — инструмент класса Infrastructure as Code (IaC), который позволяет описывать инфраструктуру декларативным кодом, просматривать план изменений и применять его через API облачных платформ и других систем.

Terraform-конфигурация обычно состоит из файлов с расширением .tf:

infrastructure/
├── versions.tf
├── providers.tf
├── main.tf
├── variables.tf
├── outputs.tf
├── terraform.tfvars
└── .terraform.lock.hcl

Минимальный пример:

terraform {
  required_version = ">= 1.6.0"

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

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

resource "aws_s3_bucket" "assets" {
  bucket = "example-project-assets"

  tags = {
    Project     = "example"
    Environment = "dev"
  }
}

Основной цикл работы:

terraform init
terraform fmt -check
terraform validate
terraform plan
terraform apply

Terraform управляет ресурсами через state. Ручное изменение инфраструктуры вне Terraform может вызвать расхождение между конфигурацией, state и реальным состоянием.

Содержание


HCL-синтаксис

HCL — HashiCorp Configuration Language — язык конфигурации, используемый Terraform. Он ориентирован на описание блоков, аргументов и выражений.

Блоки

Блок содержит тип, необязательные метки и тело:

resource "aws_instance" "web" {
  ami           = "ami-0123456789abcdef0"
  instance_type = "t3.micro"
}

Здесь:

Строки aws_instance и web сами по себе не являются именем EC2-инстанса в AWS. Имя облачного объекта обычно задаётся отдельным аргументом или тегом.

Аргументы и выражения

Аргумент присваивает выражение имени:

instance_type = "t3.micro"
monitoring    = true
volume_size   = 20

Выражением может быть литерал, ссылка, вызов функции, условие или конструкция for.

Основные типы значений

# string
project_name = "demo"

# number
instance_count = 2

# bool
enabled = true

# list или tuple
availability_zones = ["eu-central-1a", "eu-central-1b"]

# map или object
tags = {
  Project     = "demo"
  Environment = "dev"
}

# null — отсутствие значения
optional_name = null

Конкретный тип коллекции зависит от контекста и ограничений типа. Например, list(string) требует строки одного типа, а tuple([string, number]) описывает фиксированные позиции.

Комментарии

# Однострочный комментарий
// Другой однострочный комментарий

/*
Многострочный
комментарий
*/

Строки и интерполяция

name = "${var.project}-${var.environment}"

Если строка состоит только из ссылки, интерполяция не нужна:

region = var.aws_region

Современный HCL позволяет смешивать текст и выражения:

bucket = "${var.project}-${var.environment}-assets"

Многострочная строка:

user_data = <<-EOT
  #!/bin/bash
  echo "Environment: ${var.environment}" > /tmp/environment
EOT

Функция templatefile() удобнее для больших шаблонов:

user_data = templatefile("${path.module}/templates/user-data.sh.tftpl", {
  environment = var.environment
  port        = var.application_port
})

Ссылки на объекты

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}

Ссылка aws_vpc.main.id создаёт не только передачу значения, но и неявную зависимость: подсеть будет создана после VPC.

Локальные значения

locals вычисляют значения внутри модуля и уменьшают дублирование:

locals {
  name_prefix = "${var.project}-${var.environment}"

  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

Использование:

tags = merge(local.common_tags, {
  Name = "${local.name_prefix}-web"
})

locals не являются входными параметрами. Пользователь модуля не может передать им значение напрямую.

Условные выражения

instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

Обе ветви должны иметь совместимые типы.

count

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

resource "aws_instance" "web" {
  count = var.instance_count

  ami           = var.ami_id
  instance_type = var.instance_type

  tags = {
    Name = "web-${count.index + 1}"
  }
}

Обращение к одному экземпляру:

aws_instance.web[0].id

Ко всем идентификаторам:

aws_instance.web[*].id

count удобен для однородных объектов, но удаление элемента из середины индексированного списка может изменить адреса следующих экземпляров.

for_each

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

variable "subnets" {
  type = map(object({
    cidr = string
    az   = string
  }))
}

resource "aws_subnet" "this" {
  for_each = var.subnets

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = {
    Name = each.key
  }
}

Адреса ресурсов:

aws_subnet.this["public-a"]
aws_subnet.this["public-b"]

Стабильные ключи помогают избежать ненужного пересоздания ресурсов при изменении порядка элементов.

for-выражения

locals {
  uppercase_names = [for name in var.names : upper(name)]

  instance_ips = {
    for key, instance in aws_instance.web :
    key => instance.private_ip
  }
}

Фильтрация:

locals {
  enabled_services = {
    for name, service in var.services :
    name => service
    if service.enabled
  }
}

Динамические блоки

dynamic формирует повторяемые вложенные блоки:

resource "aws_security_group" "web" {
  name   = "web"
  vpc_id = aws_vpc.main.id

  dynamic "ingress" {
    for_each = var.ingress_rules

    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

Динамические блоки полезны для повторяемой структуры, но их избыток ухудшает читаемость.

Встроенные функции

lower("PROD")
upper("dev")
length(var.subnets)
contains(var.environments, "prod")
lookup(var.instance_types, var.environment, "t3.micro")
merge(local.common_tags, var.extra_tags)
concat(var.public_subnets, var.private_subnets)
try(var.settings.timeout, 30)
coalesce(var.optional_name, "default")
jsonencode(local.policy)
yamldecode(file("${path.module}/config.yaml"))

Функции не вызываются как методы объектов: используется length(var.items), а не var.items.length().

Meta-arguments

К общим meta-arguments относятся:

Явная зависимость применяется, когда Terraform не может вывести её из ссылок:

resource "aws_instance" "app" {
  ami           = var.ami_id
  instance_type = var.instance_type

  depends_on = [aws_iam_role_policy.app]
}

Не следует добавлять depends_on повсеместно: обычные ссылки точнее описывают зависимости.

lifecycle

resource "aws_instance" "app" {
  ami           = var.ami_id
  instance_type = var.instance_type

  lifecycle {
    create_before_destroy = true
    prevent_destroy       = true
    ignore_changes        = [tags["UpdatedAt"]]
  }
}

ignore_changes следует использовать точечно: слишком широкое исключение может скрыть значимый drift.


Providers, resources и data sources

Provider

Provider — плагин, который связывает Terraform с API конкретной платформы: AWS, Azure, Google Cloud, Kubernetes, GitHub, Cloudflare и другими системами.

Требования к провайдеру фиксируются в блоке terraform:

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

Конфигурация провайдера:

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project   = var.project
      ManagedBy = "Terraform"
    }
  }
}

Версию провайдера рекомендуется ограничивать, а файл .terraform.lock.hcl — хранить в системе контроля версий. Lock-файл фиксирует выбранные версии и контрольные суммы провайдеров.

Аутентификация провайдера

Секреты не следует записывать непосредственно в .tf или terraform.tfvars:

# Плохой пример
provider "aws" {
  access_key = "..."
  secret_key = "..."
}

Предпочтительны:

Пример профиля для локальной разработки:

provider "aws" {
  region  = var.aws_region
  profile = var.aws_profile
}

Несколько конфигураций provider

Alias позволяет работать с несколькими регионами или аккаунтами:

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

provider "aws" {
  alias  = "secondary"
  region = "eu-west-1"
}

resource "aws_s3_bucket" "replica" {
  provider = aws.secondary
  bucket   = "example-replica-bucket"
}

При передаче alias-провайдера в дочерний модуль конфигурацию провайдера указывают в providers блока module.

Resource

Resource — объект, жизненным циклом которого управляет Terraform:

resource "aws_s3_bucket" "logs" {
  bucket = var.logs_bucket_name
}

Общий адрес:

<тип_ресурса>.<локальное_имя>

Примеры ссылок:

aws_s3_bucket.logs.id
aws_s3_bucket.logs.arn

После чтения конфигурации и state Terraform сравнивает желаемое и фактическое состояние. План может содержать:

+ create
~ update in-place
-/+ replace
- destroy

Замена означает, что старый объект будет удалён и создан новый; точный порядок зависит от ресурса и lifecycle.

Data source

Data source читает уже существующие данные, но обычно не управляет их жизненным циклом:

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

Использование результата:

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.instance_type
}

Различие:

Конструкция Назначение Управляет жизненным циклом
resource Создать или изменить объект Да
data Прочитать существующие данные Нет
module Сгруппировать и переиспользовать конфигурацию Косвенно, через ресурсы модуля

Импорт существующего ресурса

Существующий объект можно связать с адресом Terraform. Сначала описывается ресурс:

resource "aws_s3_bucket" "existing" {
  bucket = "existing-bucket-name"
}

Затем применяется import-блок:

import {
  to = aws_s3_bucket.existing
  id = "existing-bucket-name"
}

После импорта необходимо проверить terraform plan и привести конфигурацию в соответствие с реальными настройками. Сам импорт не гарантирует, что HCL полностью описывает объект.


State-файл и backend

Что такое state

State — данные Terraform о соответствии ресурсов в конфигурации реальным объектам. По умолчанию состояние хранится в файле terraform.tfstate.

State содержит:

State не является обычным файлом конфигурации. Его не следует редактировать вручную.

Почему state важен

Terraform использует state, чтобы:

Если state потерян, реальные ресурсы не исчезают, но Terraform теряет привязку к ним. Для восстановления может потребоваться импорт.

Чувствительные данные

State может содержать секретные значения в открытом виде, даже если переменная или output отмечены как sensitive. Метка sensitive скрывает значение в части вывода CLI, но не шифрует state.

Поэтому state необходимо:

Пример .gitignore:

.terraform/
*.tfstate
*.tfstate.*
crash.log
crash.*.log
*.tfvars
*.tfvars.json

# Если tfvars не содержит секретов и должен версионироваться,
# добавьте его обратно отдельным правилом.

Файл .terraform.lock.hcl, напротив, обычно следует коммитить.

Полезные команды state

terraform state list
terraform state show aws_instance.web
terraform state mv OLD_ADDRESS NEW_ADDRESS
terraform state rm ADDRESS
terraform show

Команды, изменяющие state, требуют осторожности и резервной копии. terraform state rm удаляет привязку из state, но сам облачный объект обычно остаётся.

Перемещение адресов

При рефакторинге можно использовать блок moved:

moved {
  from = aws_instance.web
  to   = module.compute.aws_instance.web
}

Это сообщает Terraform, что ресурс перемещён, а не удалён и создан заново.

Backend

Backend определяет, где хранится state и как Terraform выполняет связанные с ним операции.

Локальный backend используется по умолчанию:

terraform {
  backend "local" {
    path = "state/terraform.tfstate"
  }
}

Для командной работы обычно применяют remote backend. Пример S3-backend:

terraform {
  backend "s3" {
    bucket       = "company-terraform-state"
    key          = "network/prod/terraform.tfstate"
    region       = "eu-central-1"
    encrypt      = true
    use_lockfile = true
  }
}

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

Backend-блок имеет особое поведение: он инициализируется до вычисления обычных переменных. Поэтому в его аргументах нельзя рассчитывать на стандартные var.* и local.*.

Частичную конфигурацию можно передать при инициализации:

terraform {
  backend "s3" {}
}
terraform init \
  -backend-config="bucket=company-terraform-state" \
  -backend-config="key=network/prod/terraform.tfstate" \
  -backend-config="region=eu-central-1"

Не следует передавать секреты так, чтобы они сохранялись в истории shell, логах CI или служебных файлах Terraform. Лучше использовать поддерживаемые провайдером механизмы аутентификации.

Remote state

Удалённое состояние обеспечивает:

При одновременном apply без блокировки два процесса могут перезаписать state или принять решения на снове устаревших данных.

Миграция backend

После изменения backend выполняют повторную инициализацию:

terraform init -migrate-state

Terraform предложит перенести существующее состояние. Перед миграцией рекомендуется сделать резервную копию и запретить параллельные запуски.

Чтение outputs другого state

data "terraform_remote_state" "network" {
  backend = "s3"

  config = {
    bucket = "company-terraform-state"
    key    = "network/prod/terraform.tfstate"
    region = "eu-central-1"
  }
}

resource "aws_instance" "app" {
  subnet_id     = data.terraform_remote_state.network.outputs.private_subnet_id
  ami           = var.ami_id
  instance_type = var.instance_type
}

Потребителю remote state фактически требуется доступ к данным состояния, а state может включать больше сведений, чем опубликованные outputs. Для слабосвязанных систем безопаснее и прозрачнее публиковать необходимые значения через отдельное хранилище конфигурации или API.

Drift

Drift — расхождение между кодом Terraform и фактической инфраструктурой, например из-за ручного изменения в консоли.

Проверка:

terraform plan

Обновление только state на основе текущего состояния объектов:

terraform plan -refresh-only
terraform apply -refresh-only

Перед принятием drift в state нужно выяснить, было ли ручное изменение допустимым. Обычно долгосрочное решение — обновить HCL или отменить изменение через Terraform.


Модули Terraform

Модуль — каталог Terraform-конфигурации. Любой каталог с .tf-файлами, из которого запускается Terraform, является root module. Модуль, подключённый через блок module, называется child module.

Назначение модулей

Модули помогают:

Структура модуля

modules/
└── s3-bucket/
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    ├── versions.tf
    └── README.md

main.tf:

resource "aws_s3_bucket" "this" {
  bucket = var.name
  tags   = var.tags
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = var.versioning_enabled ? "Enabled" : "Suspended"
  }
}

variables.tf:

variable "name" {
  description = "Имя S3-бакета"
  type        = string
}

variable "versioning_enabled" {
  description = "Включить версионирование объектов"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Теги ресурсов"
  type        = map(string)
  default     = {}
}

outputs.tf:

output "id" {
  description = "Идентификатор бакета"
  value       = aws_s3_bucket.this.id
}

output "arn" {
  description = "ARN бакета"
  value       = aws_s3_bucket.this.arn
}

Подключение локального модуля

module "assets_bucket" {
  source = "./modules/s3-bucket"

  name               = "example-dev-assets"
  versioning_enabled = true

  tags = {
    Environment = "dev"
  }
}

Обращение к output модуля:

module.assets_bucket.arn

Внутренние ресурсы дочернего модуля нельзя считать его публичным интерфейсом. Root module должен использовать объявленные outputs.

Источники модулей

# Локальный путь
source = "./modules/vpc"

# Terraform Registry
source  = "terraform-aws-modules/vpc/aws"
version = "5.0.0"

# Git-репозиторий и тег
source = "git::https://example.com/infrastructure/modules.git//vpc?ref=v1.4.0"

Для удалённых модулей рекомендуется фиксировать версию или неизменяемый ref. Ветка main может измениться и нарушить воспроизводимость.

После изменения источника или версии модуля:

terraform init -upgrade

Передача providers в модуль

module "replica" {
  source = "./modules/s3-bucket"

  providers = {
    aws = aws.secondary
  }

  name = "example-replica"
}

Переиспользуемый модуль обычно объявляет требования к provider, но не должен без необходимости задавать credentials и регион внутри себя.

Рекомендации по проектированию модулей

Хороший модуль:

Не каждую пару ресурсов нужно превращать в отдельный модуль. Слишком мелкие модули усложняют навигацию и обновления.


Variables и outputs

Входные переменные

variable "environment" {
  description = "Имя окружения"
  type        = string
  default     = "dev"
  nullable    = false

  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "environment должен быть dev, stage или prod."
  }
}

Основные атрибуты:

Переменная без default обязательна.

Типы переменных

variable "instance_type" {
  type = string
}

variable "instance_count" {
  type = number
}

variable "monitoring_enabled" {
  type = bool
}

variable "availability_zones" {
  type = list(string)
}

variable "tags" {
  type = map(string)
}

variable "application" {
  type = object({
    name    = string
    port    = number
    enabled = optional(bool, true)
  })
}

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

Способы передачи значений

Через параметр:

terraform plan -var="environment=dev"

Через файл:

terraform plan -var-file="environments/dev.tfvars"

environments/dev.tfvars:

environment   = "dev"
aws_region    = "eu-central-1"
instance_type = "t3.micro"

Через переменную окружения:

export TF_VAR_environment="dev"
terraform plan

Terraform автоматически загружает:

Явный -var-file удобен для раздельных конфигураций окружений.

Приоритет значений

Значение может поступать из default, файлов, переменных окружения и аргументов CLI. При совместном использовании нужно учитывать установленный Terraform порядок приоритета. В проекте лучше выбрать один понятный способ передачи окруженческих параметров и зафиксировать его в README и CI.

Секретные переменные

variable "database_password" {
  description = "Пароль базы данных"
  type        = string
  sensitive   = true
}

sensitive = true уменьшает риск вывода секрета на экран, но:

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

Outputs

output "instance_public_ip" {
  description = "Публичный IP веб-сервера"
  value       = aws_instance.web.public_ip
}

Просмотр:

terraform output
terraform output instance_public_ip
terraform output -json

Чувствительный output:

output "database_password" {
  value     = var.database_password
  sensitive = true
}

Значение всё ещё может находиться в state и может быть явно получено пользователем с соответствующим доступом.

Preconditions и postconditions

Условие до операции:

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = var.environment != "prod" || var.monitoring_enabled
      error_message = "Для prod необходимо включить monitoring."
    }
  }
}

Проверка результата:

output "bucket_arn" {
  value = aws_s3_bucket.assets.arn

  precondition {
    condition     = startswith(aws_s3_bucket.assets.arn, "arn:")
    error_message = "Провайдер вернул некорректный ARN."
  }
}

Поддержка конкретных проверок зависит от версии Terraform и контекста блока.


tflint — линтинг Terraform

TFLint — статический анализатор Terraform-конфигураций. Он обнаруживает ошибки и нарушения правил до plan или apply.

TFLint не заменяет:

Эти инструменты проверяют разные аспекты.

Что проверяет TFLint

В зависимости от подключённых ruleset-плагинов TFLint может находить:

Запуск

tflint --init
tflint

Проверка всех модулей из корня проекта зависит от используемой версии и режима запуска. Часто применяют:

tflint --recursive

Доступные флаги следует проверять через:

tflint --help

Конфигурация .tflint.hcl

config {
  call_module_type = "local"
}

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

rule "terraform_required_version" {
  enabled = true
}

rule "terraform_required_providers" {
  enabled = true
}

rule "terraform_naming_convention" {
  enabled = true
  format  = "snake_case"
}

Набор доступных параметров и правил зависит от версии TFLint и плагинов.

Плагин AWS

Пример объявления провайдер-специфичного ruleset:

plugin "aws" {
  enabled = true
  version = "<зафиксированная-версия>"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

После изменения плагинов:

tflint --init

Версию плагина рекомендуется фиксировать и обновлять контролируемо. Актуальную совместимую версию следует выбирать по документации ruleset.

Отключение правила

Глобально:

rule "terraform_documented_variables" {
  enabled = false
}

Точечное подавление лучше сопровождать объяснением:

# tflint-ignore: aws_instance_invalid_type
resource "aws_instance" "example" {
  instance_type = "custom-value"
}

Синтаксис inline-игнорирования зависит от правила и версии инструмента. Подавление не должно использоваться для скрытия реальной ошибки.

Машиночитаемый вывод

tflint --format=json

Он подходит для CI и последующей обработки. Для человека удобен стандартный компактный вывод.

Типичный CI-процесс

terraform fmt -check -recursive
terraform init -backend=false
terraform validate

tflint --init
tflint --recursive

terraform plan -input=false

terraform init -backend=false полезен для локальной структурной проверки, когда backend недоступен или не нужен. Для настоящего plan обычно требуется полноценная инициализация backend и аутентификация провайдеров.

Разделение ответственности

Инструмент Основная задача
terraform fmt Единое форматирование HCL
terraform validate Синтаксис и внутренняя согласованность
TFLint Статические правила и provider-specific проверки
terraform plan Изменения относительно state и API
Security scanner Ошибочные настройки безопасности
Tests Проверка ожидаемого поведения модулей

Pre-commit

TFLint удобно запускать до коммита вместе с форматированием:

repos:
  - repo: local
    hooks:
      - id: terraform-fmt
        name: terraform fmt
        entry: terraform fmt -check -recursive
        language: system
        pass_filenames: false

      - id: tflint
        name: tflint
        entry: tflint --recursive
        language: system
        pass_filenames: false

Это пример локальной конфигурации. Воспроизводимость требует зафиксировать версии Terraform, TFLint и плагинов в окружении разработчиков и CI.


Workspaces

Workspace позволяет одному root module иметь несколько отдельных state в рамках одного backend.

Изначально существует workspace:

default

Основные команды

terraform workspace list
terraform workspace show
terraform workspace new dev
terraform workspace select dev
terraform workspace select -or-create stage
terraform workspace delete dev

Подстановка имени:

locals {
  environment = terraform.workspace
}

resource "aws_s3_bucket" "assets" {
  bucket = "example-${terraform.workspace}-assets"
}

Настройки по workspace:

locals {
  instance_types = {
    default = "t3.micro"
    dev     = "t3.micro"
    stage   = "t3.small"
    prod    = "t3.large"
  }

  instance_type = lookup(
    local.instance_types,
    terraform.workspace,
    local.instance_types.default
  )
}

Для чего подходят workspaces

Workspaces удобны, когда:

Ограничения

Workspaces не обеспечивают сами по себе:

Для критичных окружений часто безопаснее отдельные root modules и отдельные backend key, аккаунты или проекты:

live/
├── dev/
│   ├── backend.tf
│   └── main.tf
├── stage/
│   ├── backend.tf
│   └── main.tf
└── prod/
    ├── backend.tf
    └── main.tf

Повторяемую часть при этом выносят в child modules.

Риск неправильного workspace

Перед plan и apply полезно явно проверять выбранное окружение:

terraform workspace show
terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan

В CI workspace должен выбираться явно, а не зависеть от состояния рабочего каталога предыдущего запуска.

Workspace и backend key

Способ физического хранения state разных workspaces зависит от backend. Нельзя предполагать конкретный путь без проверки документации backend. Независимо от расположения каждый workspace представляет отдельное логическое состояние.


Типичный рабочий процесс

1. Форматирование

terraform fmt -recursive

Проверка без изменения файлов:

terraform fmt -check -recursive

2. Инициализация

terraform init

После обновления разрешённых версий:

terraform init -upgrade

3. Валидация и линтинг

terraform validate
tflint --init
tflint --recursive

4. План

terraform plan -var-file="environments/dev.tfvars" -out="dev.tfplan"

Сохранённый план позволяет применить именно просмотренный набор изменений, если между операциями не возникли несовместимые изменения состояния или окружения.

Просмотр:

terraform show dev.tfplan

5. Применение

terraform apply dev.tfplan

Автоматическое подтверждение:

terraform apply -auto-approve

-auto-approve подходит для контролируемого CI-процесса, но не должен обходить review и проверки.

6. Удаление

terraform plan -destroy
terraform destroy

Перед удалением следует проверить защиту данных, резервные копии и зависимости вне Terraform.

Plan в CI

Безопасный процесс обычно разделяет:

  1. форматирование и статические проверки;
  2. создание plan;
  3. review;
  4. применение одобренного plan;
  5. журналирование результата.

Нельзя применять бинарный plan, полученный из недоверенного источника. Он может включать операции и чувствительные данные.


Структура проекта

Пример проекта с окружениями и локальными модулями:

terraform-project/
├── .gitignore
├── .tflint.hcl
├── README.md
├── modules/
│   ├── network/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── versions.tf
│   └── compute/
│       ├── main.tf
│       ├── variables.tf
│       ├── outputs.tf
│       └── versions.tf
└── live/
    ├── dev/
    │   ├── backend.tf
    │   ├── main.tf
    │   ├── providers.tf
    │   ├── variables.tf
    │   ├── outputs.tf
    │   └── dev.tfvars
    └── prod/
        ├── backend.tf
        ├── main.tf
        ├── providers.tf
        ├── variables.tf
        ├── outputs.tf
        └── prod.tfvars

Альтернативная структура одного root module:

terraform-project/
├── backend.tf
├── versions.tf
├── providers.tf
├── main.tf
├── variables.tf
├── locals.tf
├── outputs.tf
├── environments/
│   ├── dev.tfvars
│   └── prod.tfvars
└── modules/
    └── application/

Terraform загружает все .tf-файлы текущего каталога как единый модуль. Имена main.tf, variables.tf и outputs.tf являются соглашением для удобства, а не обязательным требованием языка.


Общий пример

Ниже приведён упрощённый проект: VPC, публичная подсеть, Security Group, EC2 и S3. Для production понадобятся дополнительные настройки безопасности, отказоустойчивости, наблюдаемости и резервирования.

versions.tf

terraform {
  required_version = ">= 1.6.0"

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

providers.tf

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = local.common_tags
  }
}

variables.tf

variable "project" {
  description = "Короткое имя проекта"
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]+$", var.project))
    error_message = "Используйте строчные латинские буквы, цифры и дефисы."
  }
}

variable "environment" {
  description = "Окружение"
  type        = string

  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "Допустимые значения: dev, stage, prod."
  }
}

variable "aws_region" {
  description = "Регион AWS"
  type        = string
  default     = "eu-central-1"
}

variable "vpc_cidr" {
  description = "CIDR VPC"
  type        = string
  default     = "10.10.0.0/16"
}

variable "public_subnet_cidr" {
  description = "CIDR публичной подсети"
  type        = string
  default     = "10.10.1.0/24"
}

variable "instance_type" {
  description = "Тип EC2-инстанса"
  type        = string
  default     = "t3.micro"
}

variable "ssh_allowed_cidrs" {
  description = "Сети, которым разрешён SSH. Пустой список отключает SSH ingress."
  type        = list(string)
  default     = []
}

locals.tf

locals {
  name_prefix = "${var.project}-${var.environment}"

  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

main.tf

data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${local.name_prefix}-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${local.name_prefix}-igw"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name = "${local.name_prefix}-public"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "${local.name_prefix}-public"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "web" {
  name        = "${local.name_prefix}-web"
  description = "Web server traffic"
  vpc_id      = aws_vpc.main.id

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

  dynamic "ingress" {
    for_each = length(var.ssh_allowed_cidrs) > 0 ? [1] : []

    content {
      description = "SSH from approved networks"
      from_port   = 22
      to_port     = 22
      protocol    = "tcp"
      cidr_blocks = var.ssh_allowed_cidrs
    }
  }

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

  tags = {
    Name = "${local.name_prefix}-web"
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  metadata_options {
    http_endpoint = "enabled"
    http_tokens   = "required"
  }

  user_data = <<-EOT
    #!/bin/bash
    dnf install -y nginx
    systemctl enable --now nginx
    echo "${local.name_prefix}" > /usr/share/nginx/html/index.html
  EOT

  tags = {
    Name = "${local.name_prefix}-web"
  }
}

resource "aws_s3_bucket" "assets" {
  bucket = "${local.name_prefix}-assets"

  tags = {
    Name = "${local.name_prefix}-assets"
  }
}

resource "aws_s3_bucket_public_access_block" "assets" {
  bucket = aws_s3_bucket.assets.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "assets" {
  bucket = aws_s3_bucket.assets.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
  bucket = aws_s3_bucket.assets.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

outputs.tf

output "vpc_id" {
  description = "ID созданной VPC"
  value       = aws_vpc.main.id
}

output "instance_id" {
  description = "ID EC2-инстанса"
  value       = aws_instance.web.id
}

output "instance_public_ip" {
  description = "Публичный IP EC2-инстанса"
  value       = aws_instance.web.public_ip
}

output "assets_bucket_arn" {
  description = "ARN S3-бакета"
  value       = aws_s3_bucket.assets.arn
}

environments/dev.tfvars

project            = "demo"
environment        = "dev"
aws_region         = "eu-central-1"
instance_type      = "t3.micro"
ssh_allowed_cidrs  = []

.tflint.hcl

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

plugin "aws" {
  enabled = true
  version = "<зафиксированная-совместимая-версия>"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

Запуск примера

terraform fmt -recursive
terraform init
terraform validate

tflint --init
tflint

terraform plan \
  -var-file="environments/dev.tfvars" \
  -out="dev.tfplan"

terraform show dev.tfplan
terraform apply dev.tfplan

В примере имя S3-бакета должно быть глобально уникальным. Для реального проекта к имени обычно добавляют контролируемый уникальный суффикс. Публичный EC2 и открытый HTTP используются только для демонстрации; производственная архитектура часто размещает приложения в приватных подсетях за балансировщиком.


Практические рекомендации

Версии

Указывайте совместимую версию Terraform и ограничения providers:

terraform {
  required_version = ">= 1.6.0, < 2.0.0"

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

Коммитьте .terraform.lock.hcl и обновляйте зависимости отдельным review-процессом.

State

Секреты

Изменения

Код

Модули

Workspaces

Минимальный checklist перед merge

[ ] terraform fmt -check -recursive выполнен
[ ] terraform validate выполнен
[ ] tflint выполнен
[ ] plan создан и просмотрен
[ ] нет неожиданных destroy/replace
[ ] версии Terraform, providers и модулей ограничены
[ ] секреты отсутствуют в коде и логах
[ ] state хранится в защищённом backend
[ ] изменения инфраструктуры прошли review
[ ] для критичных ресурсов предусмотрены backup и rollback