Представьте, что вы настраиваете облако руками: заходите в консоль AWS, кликаете «создать сервер», «создать хранилище», «открыть порт». Работает — пока серверов мало. Когда их десятки, а окружений несколько (тестовое, боевое), руками это уже не повторить одинаково: где-то забыли галочку, где-то регион не тот. Terraform решает эту боль: вы описываете нужную инфраструктуру текстом в файле, а Terraform сам создаёт её в облаке — одинаково, сколько угодно раз.
Terraform — это инструмент инфраструктуры как кода (infrastructure as code): вместо кликов вы пишете конфигурацию, кладёте её в git рядом с приложением и применяете командой. Его главная фишка — он работает почти с любым облаком (AWS, Google Cloud, Azure) и сотнями других систем через единый язык. Если вы только знакомитесь с тем, зачем всё это нужно, начните со статьи инфраструктура как код: основы. Здесь — про сам Terraform, с примерами на AWS.
Четыре команды в этом порядке: 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 — это четыре команды, которые вы запускаете по очереди.
terraform init— подготовка папки к работе. Скачивает указанные провайдеры (тот самый AWS-плагин), подтягивает модули и настраивает хранение state. Запускается один раз в начале и повторно каждый раз, когда поменяли провайдеры, backend или подключили либо изменили модуль. Забытыйinitпосле правки модуля — самая частая причина непонятных ошибок у новичка.
terraform init
terraform plan— сухой прогон. Показывает, что именно изменится, ничего не трогая. В выводе+— создать,~— изменить,-— удалить. Это ваша страховка: всегда смотрите план перед применением.
terraform plan
terraform apply— применяет изменения в реальном облаке. Сначала снова покажет план и спросит подтверждение — нужно ввестиyes.
terraform apply
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 уносит боевой контур. Второй вариант: конфигурация одна на все среды, и переключение между ними сделано переменной, значение которой забыли.
Что защищает, по порядку надёжности:
- Отдельное состояние на каждую среду (и лучше отдельная папка, а не одна конфигурация с переменной). Тогда нельзя случайно применить не туда: там просто другое состояние.
prevent_destroyна всём, чья потеря необратима — базах, хранилищах с данными, ключах шифрования. Применение остановится с ошибкой, и это то самое «успеть подумать».- Защита от удаления на стороне облака. У базы есть отдельный признак защиты от удаления, у хранилища объектов — блокировка версий; они действуют независимо от инструмента.
- Права конвейера. У роли, под которой выполняется применение в боевом контуре, может вообще не быть права удалять базы — тогда самый неудачный сценарий заканчивается отказом в правах.
- Отдельные аккаунты под среды — самая крепкая граница из возможных: перепутать конфигурацию можно, аккаунт — намного труднее.
И правило, которое стоит взять сразу: в боевом контуре 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 это открытый форк после смены лицензии.
Что почитать дальше
- Основы инфраструктуры как кода — state, идемпотентность и дрейф без привязки к инструменту.
- State, секреты и конвейер — как Terraform живёт в команде и в CI.
- CloudFormation — родной для AWS способ описывать инфраструктуру.
- AWS CDK — инфраструктура как программа на привычном языке.