ОБЛАКА: AWS Terraform

Обязательно

Terraform: HCL, провайдеры, состояние и модули

HCL · Plan · State · Modules

Ресурсы описываются HCL, провайдер вызывает API, plan показывает разницу, state связывает файл и ресурс.

1. HCL описывает ресурсы и связи

1 / 5

resource, variable, output и data описывают инфраструктуру. Зависимости вычисляются автоматически.

  • Тип ресурса задан провайдером.
  • Параметры версионируются.
  • Выходы используются другими модулями.
resource "aws_instance" "api"image_id = var.imagevpc_id = data.vpc.idDependency graphVPC → API

2. Провайдер переводит HCL в API

2 / 5

AWS provider знает ресурсы и вызывает AWS API. Версия фиксирует совместимость.

  • init скачивает провайдеры.
  • Версия lock сохраняется.
  • Один HCL работает с разными облаками.
HCLAWS providerAWS APIinit → provider plugin

3. Plan показывает разницу до apply

3 / 5

Terraform сравнивает конфигурацию и state. Plan перечисляет create, update и destroy до изменения.

  • Diff проходит ревью.
  • Опасные удаления видны.
  • Apply применяет выбранный plan.
Config + stateterraform planReviewed diffapply только после осознания

4. State и lock защищают команду

4 / 5

Remote backend хранит соответствие, блокировка сериализует применения. Секреты в state не помещаются.

  • Backend общий.
  • Lock предотвращает гонку.
  • Доступ ограничен.
Remote statemapping + lockTeam pipelineserialized apply

5. Модуль повторяет инфраструктуру

5 / 5

Модуль принимает входы и создаёт согласованный набор ресурсов. Dev и prod различаются только значениями.

  • Шаблон не копируется.
  • Версия модуля фиксируется.
  • Выходы используются другими модулями.
Moduledev valuesprod valuesTwo environments

Представьте, что вы настраиваете облако руками: заходите в консоль AWS, кликаете «создать сервер», «создать хранилище», «открыть порт». Работает — пока серверов мало. Когда их десятки, а окружений несколько (тестовое, боевое), руками это уже не повторить одинаково: где-то забыли галочку, где-то регион не тот. Terraform решает эту боль: вы описываете нужную инфраструктуру текстом в файле, а Terraform сам создаёт её в облаке — одинаково, сколько угодно раз.

Terraform — это инструмент инфраструктуры как кода (infrastructure as code): вместо кликов вы пишете конфигурацию, кладёте её в git рядом с приложением и применяете командой. Его главная фишка — он работает почти с любым облаком (AWS, Google Cloud, Azure) и сотнями других систем через единый язык. Если вы только знакомитесь с тем, зачем всё это нужно, начните со статьи инфраструктура как код: основы. Здесь — про сам Terraform, с примерами на AWS.

init провайдеры, модули, backend plan разница, ничего не трогая apply применить после yes destroy убрать всё, чем управляет

Четыре команды в этом порядке: init отделён от plan, потому что план нельзя посчитать без скачанных провайдеров и подключённого состояния; apply всегда показывает план ещё раз; destroy в боевом контуре руками не запускают.

Что такое Terraform и HCL

Terraform читает файлы с расширением .tf, написанные на языке HCL (HashiCorp Configuration Language) — это собственный язык конфигурации Terraform. HCL устроен просто: вся конфигурация состоит из блоков. Блок — это тип, опциональные метки в кавычках и тело в фигурных скобках.

тип_блока "метка1" "метка2" {
  аргумент = значение
}

Главное, что нужно понять новичку: HCL — декларативный. Вы описываете не «как делать» (создай, потом измени, потом удали), а «что должно быть в итоге». Terraform сам вычисляет разницу между тем, что описано, и тем, что уже есть в облаке, и делает только нужные шаги. Это как список покупок: вы пишете, что должно лежать в холодильнике, а не маршрут по магазину.

Любой проект на Terraform обычно начинается с блока terraform {}, где указано, какие провайдеры и какая версия нужны:

terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

Версию провайдера всегда указывайте явно: без неё Terraform возьмёт свежайшую, и однажды утром сборка сломается на чужом обновлении. Какая версия сейчас актуальна, смотрят на странице провайдера в Terraform Registry — мажорные версии там меняются раз в год-два (пятая вышла в 2023-м, шестая — в 2025-м), так что цифру из любой статьи стоит перепроверить.

Провайдеры

Сам по себе Terraform не знает, что такое «сервер AWS» или «бакет S3». За это отвечают провайдеры — плагины, которые переводят ваши блоки в вызовы API конкретного облака. Провайдер AWS умеет работать с AWS, провайдер Google Cloud — с Google, и так далее. Аналогия: Terraform — переводчик, а провайдер — словарь конкретного облака: без словаря переводчик понимает ваш текст, но сказать облаку ничего не может.

Провайдер настраивается отдельным блоком provider. Для AWS чаще всего достаточно указать регион:

provider "aws" {
  region = "us-east-1"
}

Один и тот же провайдер можно подключить несколько раз — например, чтобы создавать ресурсы в двух регионах. Для этого второму экземпляру дают псевдоним через alias:

provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

Какие именно ресурсы AWS бывают и зачем они — это отдельная большая тема, начните с обзора основы AWS.

Ресурсы и зависимости

Ресурс — это конкретный объект инфраструктуры: бакет хранилища, виртуальный сервер, таблица базы данных. Описывается блоком resource с двумя метками: тип ресурса и ваше внутреннее имя (по нему вы будете ссылаться на ресурс внутри конфигурации).

resource "aws_s3_bucket" "my_bucket" {
  bucket = "my-company-app-data-2026"

  tags = {
    Environment = "production"
    Team        = "backend"
  }
}

Здесь aws_s3_bucket — тип (бакет в объектном хранилище S3), my_bucket — имя для ссылок внутри кода, bucket — реальное имя бакета в AWS.

Часто один ресурс зависит от другого: например, политику доступа нельзя создать, пока нет самого бакета. Terraform определяет такие зависимости автоматически — по ссылкам. Когда вы упоминаете один ресурс внутри другого в формате тип.имя.атрибут, Terraform понимает, что сначала надо создать первый:

resource "aws_s3_bucket" "my_bucket" {
  bucket = "my-company-app-data-2026"
}

resource "aws_s3_bucket_versioning" "versioning" {
  bucket = aws_s3_bucket.my_bucket.id

  versioning_configuration {
    status = "Enabled"
  }
}

Ссылка aws_s3_bucket.my_bucket.id означает «возьми id бакета my_bucket» — и заодно говорит Terraform: бакет создавай первым.

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

resource "aws_lambda_function" "handler" {
  function_name = "order-handler"
  role          = aws_iam_role.lambda.arn
  filename      = "handler.zip"
  handler       = "index.handler"
  runtime       = "nodejs20.x"

  depends_on = [aws_iam_role_policy_attachment.lambda_logs]
}

Важно не увлекаться: depends_on нужен только там, где ссылки действительно нет. Если один ресурс упоминает другой — порядок Terraform построит сам, и дописывать зависимость руками не надо.

Повторяющиеся ресурсы: count и for_each

Как только понадобилось три одинаковых подсети или четыре бакета, копирование блока перестаёт быть решением — именно от этого код и должен избавлять. В HCL для повторения есть два механизма, и выбор между ними важнее, чем кажется.

count — создать N экземпляров, обращение по номеру:

resource "aws_subnet" "private" {
  count             = 3
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 10}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]
}

for_each — создать по экземпляру на каждый элемент набора или отображения, обращение по ключу:

resource "aws_subnet" "private" {
  for_each = {
    "eu-central-1a" = "10.0.10.0/24"
    "eu-central-1b" = "10.0.11.0/24"
    "eu-central-1c" = "10.0.12.0/24"
  }

  vpc_id            = aws_vpc.main.id
  availability_zone = each.key
  cidr_block        = each.value
}

Почему это не одно и то же. Экземпляры count адресуются номером: aws_subnet.private[0], [1], [2]. Удалили средний элемент из списка — и всё, что было после него, сдвигается: третья подсеть становится второй, и Terraform видит это как «изменить вторую и удалить третью». То есть удаление одного элемента из середины приводит к пересозданию половины ресурсов. Для подсетей это неприятно, для баз — катастрофа.

Экземпляры for_each адресуются ключом: aws_subnet.private["eu-central-1b"]. Удалили один элемент — удаляется ровно он, остальные не шевелятся.

Отсюда практическое правило: for_each по умолчанию, count — только когда экземпляры действительно безымянные и одинаковые (например, «три идентичные машины, порядок не важен») или когда нужно условное создание:

resource "aws_nat_gateway" "main" {
  count = var.enable_nat ? 1 : 0     # ходовой приём: ресурс включается флагом
  ...
}

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

И то же работает для модулей. Блок module тоже принимает count и for_each — то есть «поднять этот же набор ресурсов для каждой среды или каждого арендатора» описывается одним вызовом, а не копией блока:

module "service" {
  for_each = toset(["orders", "payments", "notifications"])
  source   = "./modules/service"
  name     = each.key
}

Ссылки на существующее: data-источники

Половина настоящей конфигурации — это не создание, а ссылки на то, что уже есть: сеть, которую ведёт другая команда, свежий образ машины, список зон доступности, идентификатор вашего аккаунта. Для этого есть отдельный вид блока — источник данных (data). Он ничего не создаёт и ничего не меняет: он читает и отдаёт значения в вашу конфигурацию.

# кто я и где я
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# какие зоны доступности есть в этом регионе
data "aws_availability_zones" "available" {
  state = "available"
}

# свежий образ Amazon Linux, а не захардкоженный идентификатор
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

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

# сеть, созданная не нами: находим по тегу
data "aws_vpc" "shared" {
  tags = { Name = "shared-prod" }
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.al2023.id
  subnet_id     = tolist(data.aws_subnets.private.ids)[0]
  instance_type = "t3.small"
}

Три вещи, которые стоит знать про источники данных.

Они читаются при каждом планировании. То есть data "aws_ami" с признаком «самый свежий» однажды вернёт новый образ, и план покажет замену машины, хотя код вы не трогали. Это не поломка, а следствие: если такое поведение не нужно, образ закрепляют явным идентификатором или сохраняют результат в переменную среды.

Если источник ничего не нашёл, планирование падает — и это правильно: лучше ошибка, чем создание ресурса в неизвестной сети. Отсюда привычка искать по устойчивым признакам (тег, имя), а не по порядковому номеру.

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

Цикл init, plan, apply, destroy

Работа с Terraform — это четыре команды, которые вы запускаете по очереди.

  1. terraform init — подготовка папки к работе. Скачивает указанные провайдеры (тот самый AWS-плагин), подтягивает модули и настраивает хранение state. Запускается один раз в начале и повторно каждый раз, когда поменяли провайдеры, backend или подключили либо изменили модуль. Забытый init после правки модуля — самая частая причина непонятных ошибок у новичка.
terraform init
  1. terraform plan — сухой прогон. Показывает, что именно изменится, ничего не трогая. В выводе + — создать, ~ — изменить, - — удалить. Это ваша страховка: всегда смотрите план перед применением.
terraform plan
  1. terraform apply — применяет изменения в реальном облаке. Сначала снова покажет план и спросит подтверждение — нужно ввести yes.
terraform apply
  1. terraform destroy — удаляет всю инфраструктуру, которой управляет эта конфигурация. Полезно для временных тестовых окружений, чтобы не платить за неиспользуемые ресурсы. Тоже спросит подтверждение.
terraform destroy

Запомнить просто: init — подготовиться, plan — посмотреть, apply — сделать, destroy — убрать.

State и где он живёт

Чтобы понимать разницу между «что описано» и «что уже создано», Terraform ведёт state (состояние) — файл, где записано, какие реальные ресурсы соответствуют вашим блокам. По умолчанию это локальный файл terraform.tfstate в папке проекта.

Локальный файл годится, пока вы один. Как только над инфраструктурой работает команда, локальный state становится проблемой: у каждого своя копия, они расходятся, а если двое запустят apply одновременно — state может повредиться. Решение — remote backend: state хранится в общем месте, а не на чьём-то ноутбуке.

Классический вариант на AWS — backend s3: сам state лежит в бакете S3, а DynamoDB используется для блокировки (lock), чтобы два человека не меняли state одновременно.

terraform {
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "my-app/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

Здесь bucket и key — где лежит файл состояния, dynamodb_table — таблица для блокировки (у неё должен быть ключ LockID типа String), encrypt — шифровать state в покое.

Начиная с Terraform 1.10 появилась нативная блокировка прямо в S3 без DynamoDB — флаг use_lockfile = true; DynamoDB-вариант со временем уберут, но в существующих проектах он встречается повсеместно, поэтому знать его стоит.

Если берёте use_lockfile, не забудьте поднять и required_version в блоке terraform {}: с объявленным >= 1.0 конфигурация может попасть на версию, которая про этот флаг ещё не знает, и упадёт на init.

State часто содержит чувствительные данные (например, пароли), поэтому общий бакет должен быть закрыт правами — про разграничение доступа смотрите IAM в AWS.

Модули

Когда конфигурация растёт, копировать одни и те же блоки в разные окружения утомительно и опасно. Модуль — это переиспользуемый набор .tf-файлов, как функция в обычном коде: один раз описали, дальше вызываете с разными параметрами. Любая папка с .tf-файлами уже является модулем; вызывают её блоком module с обязательным аргументом source — откуда брать код.

module "app_storage" {
  source = "./modules/storage"

  bucket_name = "my-company-app-data-2026"
  environment = "production"
}

Локальный путь — только один из видов источника, и для общих модулей он как раз не годится. Модуль, который используют несколько команд, берут с версией:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"     # из публичного реестра
  version = "~> 5.13"                           # версия обязательна
  ...
}

module "service" {
  source = "git::https://git.example.com/infra/modules.git//service?ref=v2.4.1"
  ...
}

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

Для модулей из реестра версию задают аргументом version, для модулей из репозитория — ссылкой на тег в самом источнике (?ref=v2.4.1); ветка (?ref=main) в этой роли — та же беда, что и отсутствие версии. Локальный путь версии не имеет вовсе, и это нормально для модулей внутри одной конфигурации: они меняются вместе с ней и попадают в тот же запрос на изменение.

И практическое правило про структуру: модуль имеет смысл, когда его вызывают минимум дважды или когда он скрывает заметную сложность. Модуль на один ресурс, вызываемый один раз, только добавляет уровень вложенности — его лучше не делать вовсе.

Откуда модуль знает про bucket_name и environment? Он объявляет их сам — блоками variable в своих файлах, а ресурсы внутри обращаются к ним как var.bucket_name. Передать модулю аргумент, которого он не объявил, нельзя: Terraform остановится с ошибкой «An argument named "bucket_name" is not expected here».

variable "bucket_name" {
  type = string
}

variable "environment" {
  type    = string
  default = "dev"
}

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name

  tags = {
    Environment = var.environment
  }
}

Обратно наружу модуль отдаёт значения блоками output — например, идентификатор созданного бакета, чтобы на него можно было сослаться из вызывающего кода как module.app_storage.bucket_id:

output "bucket_id" {
  value = aws_s3_bucket.this.id
}

source указывает путь: локальную папку (./modules/storage), git-репозиторий или публичный реестр Terraform Registry, где лежат тысячи готовых модулей. Так из тестового и боевого окружения вы вызываете один модуль с разными значениями — и инфраструктура гарантированно одинаковой формы.

module "test_storage" {
  source      = "./modules/storage"
  bucket_name = "my-app-test"
  environment = "test"
}

module "prod_storage" {
  source      = "./modules/storage"
  bucket_name = "my-app-prod"
  environment = "production"
}

Где это применяется

Terraform встречается почти везде, где есть облако: команды держат описание инфраструктуры в git и применяют его автоматически из конвейера сборки. Если вы настраиваете CI/CD, шаги terraform plan и terraform apply обычно встроены в пайплайн — об устройстве конвейеров читайте принципы CI/CD-пайплайна, а про выкатку — стратегии релизов. Часто Terraform поднимает кластер Kubernetes, а уже внутрь кластера приложения деплоят и конфигурируют другими инструментами.

Типичные ошибки новичков:

  • Применять без plan. Всегда сначала смотрите план — apply меняет реальное облако и стоит денег.
  • Не хранить state удалённо в команде. Локальный terraform.tfstate у каждого свой — окружения разъедутся. Переходите на remote backend сразу, как только над проектом больше одного человека.
  • Править ресурсы руками в консоли облака. Тогда реальность расходится со state (это называют дрейфом), и следующий apply может всё сломать. Меняйте инфраструктуру только через Terraform.
  • Класть state в git. В нём бывают секреты, а блокировки git не даёт. State — в S3 с шифрованием, не в репозитории.

Ещё одно имя, которое стоит знать заранее, — OpenTofu. В августе 2023 года HashiCorp сменила лицензию Terraform с открытой на BUSL, и сообщество отделило форк, который остался полностью открытым и живёт теперь под крылом Linux Foundation. По языку и командам он совместим: tofu init, tofu plan, tofu apply — всё, что вы прочитали выше, работает и там. Часть компаний перешла на OpenTofu именно из-за лицензии, поэтому встретить его в чужом проекте — обычное дело, и пугаться незнакомого слова не нужно.

Дополнительно: при первом чтении можно пропустить

Глубже: ещё две команды, которые ставят в первый деньрасширенное

К четырём основным добавляют две быстрые проверки — они выполняются за секунды и ловят половину ошибок до планирования.

terraform fmt -recursive     # привести отступы и выравнивание к канону
terraform validate           # проверить синтаксис и типы, без обращения к облаку

fmt приводит форматирование к единому виду. Ценность не в красоте, а в ревью: без него половина изменений в запросе — это переставленные пробелы, и настоящие правки в них теряются.

validate проверяет, что конфигурация вообще корректна: синтаксис, обязательные аргументы, ссылки на несуществующие переменные и ресурсы, соответствие типов. Он не ходит в облако, поэтому работает мгновенно и не требует прав.

Обе команды ставят в хук перед коммитом и в конвейер как первый шаг. Рядом обычно живёт линтер (tflint), который ловит то, чего не видит validate: несуществующий тип машины, устаревшие аргументы провайдера, нарушения соглашений об именах.

Глубже: destroy в боевом контуре: чего боятсярасширенное

Команда, о которой стоит сказать отдельно, потому что её случайный запуск — самая дорогая ошибка новичка. destroy удаляет всё, чем управляет эта конфигурация, — включая базу с данными. Никакого «удалить только машины» в ней нет.

Как выглядит ошибка на практике: человек работает с двумя средами, у него открыт терминал не в той папке или экспортирована не та переменная окружения — и подтверждение yes уносит боевой контур. Второй вариант: конфигурация одна на все среды, и переключение между ними сделано переменной, значение которой забыли.

Что защищает, по порядку надёжности:

  1. Отдельное состояние на каждую среду (и лучше отдельная папка, а не одна конфигурация с переменной). Тогда нельзя случайно применить не туда: там просто другое состояние.
  2. prevent_destroy на всём, чья потеря необратима — базах, хранилищах с данными, ключах шифрования. Применение остановится с ошибкой, и это то самое «успеть подумать».
  3. Защита от удаления на стороне облака. У базы есть отдельный признак защиты от удаления, у хранилища объектов — блокировка версий; они действуют независимо от инструмента.
  4. Права конвейера. У роли, под которой выполняется применение в боевом контуре, может вообще не быть права удалять базы — тогда самый неудачный сценарий заканчивается отказом в правах.
  5. Отдельные аккаунты под среды — самая крепкая граница из возможных: перепутать конфигурацию можно, аккаунт — намного труднее.

И правило, которое стоит взять сразу: в боевом контуре destroy не выполняют руками вовсе. Если что-то надо удалить, это удаляют из кода и применяют обычный apply — тогда план покажет ровно то, что исчезнет, и это увидит ревью.

Глубже: версии и файл блокировкирасширенное

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

Версия провайдера. Провайдер AWS обновляется каждую неделю, и обновления меняют поведение. Поэтому в конфигурации задают допустимый диапазон:

terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"     # 5.60 и новее в пределах 5.x
    }
  }
}

Файл блокировки. После init рядом появляется .terraform.lock.hcl — в нём записаны точные версии провайдеров и их контрольные суммы для разных платформ. Этот файл коммитят в репозиторий: он и есть гарантия, что у вас, у коллеги и в конвейере используются одни и те же версии. Обновляют его сознательно:

terraform init -upgrade                     # обновить в пределах разрешённого диапазона
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64   # добавить суммы для других платформ

Вторая команда нужна чаще, чем кажется: если конфигурацию применяют и с ноутбука на другой архитектуре, и в конвейере на Linux, в файле блокировки должны быть суммы для обеих платформ — иначе на второй платформе init откажется работать.

Версия самого Terraform. Задаётся тем же блоком (required_version) и проверяется при запуске: это защита от «у меня работает, а в конвейере старая версия падает». Версию инструмента в конвейере закрепляют явно, а не берут «последнюю».

Коротко

  • HCL декларативен: описывают желаемый результат, а Terraform считает разницу; версию провайдера и самого Terraform фиксируют явно, файл блокировки коммитят.
  • Провайдер переводит блоки в вызовы API облака; второй регион это тот же провайдер с alias.
  • Зависимости строятся по ссылкам между ресурсами; depends_on пишут только там, где связь есть, а ссылки нет.
  • for_each по умолчанию, count только для безымянных одинаковых экземпляров: удаление из списка count сдвигает и пересоздаёт всё после него.
  • Источники данных читаются при каждом планировании: «самый свежий образ» однажды покажет замену машины, а ненайденный источник роняет план, и это правильно.
  • init готовит папку, plan показывает разницу, apply применяет, destroy удаляет всё; fmt и validate ставят в хук и первым шагом конвейера.
  • destroy в боевом контуре не выполняют руками: отдельное состояние на среду, prevent_destroy на необратимом, защита от удаления на стороне облака, права конвейера без удаления баз, отдельные аккаунты.
  • State хранят удалённо с блокировкой (S3 с DynamoDB или use_lockfile с Terraform 1.10), шифруют и не кладут в git: в нём бывают пароли.
  • Модуль оправдан, когда его вызывают минимум дважды или он прячет сложность; общий модуль берут с версией, а не с ветки. OpenTofu это открытый форк после смены лицензии.

Что почитать дальше