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 applyterraform init— загружает провайдеры и модули, инициализирует backend.terraform fmt— форматирует HCL-файлы.terraform validate— проверяет внутреннюю корректность конфигурации.terraform plan— показывает предполагаемые изменения.terraform apply— применяет изменения.terraform destroy— формирует и применяет план удаления управляемых объектов.
Terraform управляет ресурсами через state. Ручное изменение инфраструктуры вне Terraform может вызвать расхождение между конфигурацией, state и реальным состоянием.
Содержание
- HCL-синтаксис
- Providers, resources и data sources
- State-файл и backend
- Модули Terraform
- Variables и outputs
- tflint — линтинг Terraform
- Workspaces
- Типичный рабочий процесс
- Структура проекта
- Общий пример
- Практические рекомендации
HCL-синтаксис
HCL — HashiCorp Configuration Language — язык конфигурации, используемый Terraform. Он ориентирован на описание блоков, аргументов и выражений.
Блоки
Блок содержит тип, необязательные метки и тело:
resource "aws_instance" "web" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
}Здесь:
resource— тип блока;aws_instance— тип ресурса провайдера;web— локальное имя ресурса в конфигурации;amiиinstance_type— аргументы;aws_instance.web— адрес ресурса.
Строки 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[*].idcount удобен для однородных объектов, но удаление элемента из середины индексированного списка может изменить адреса следующих экземпляров.
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 относятся:
count;for_each;depends_on;provider;lifecycle.
Явная зависимость применяется, когда 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"]]
}
}create_before_destroyпытается сначала создать замену, затем удалить старый объект.prevent_destroyблокирует планы, удаляющие ресурс, но не заменяет резервное копирование и контроль доступа.ignore_changesисключает перечисленные атрибуты из обычного сравнения после создания.
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 = "..."
}Предпочтительны:
- переменные окружения;
- локальный профиль CLI;
- временные credentials;
- роль облачной виртуальной машины или CI-системы;
- механизм федерации идентификационных данных.
Пример профиля для локальной разработки:
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 содержит:
- адреса ресурсов Terraform;
- идентификаторы объектов у провайдера;
- известные значения атрибутов;
- связи и метаданные, необходимые Terraform;
- значения outputs.
State не является обычным файлом конфигурации. Его не следует редактировать вручную.
Почему state важен
Terraform использует state, чтобы:
- сопоставлять блоки
resourceс объектами API; - рассчитывать план изменений;
- отслеживать зависимости;
- хранить значения, полученные после применения;
- выполнять операции перемещения и импорта.
Если state потерян, реальные ресурсы не исчезают, но Terraform теряет привязку к ним. Для восстановления может потребоваться импорт.
Чувствительные данные
State может содержать секретные значения в открытом виде, даже если переменная или output отмечены как sensitive. Метка sensitive скрывает значение в части вывода CLI, но не шифрует state.
Поэтому state необходимо:
- хранить в защищённом backend;
- шифровать на стороне хранилища;
- ограничивать доступ;
- не публиковать в Git;
- резервировать и версионировать;
- не передавать посторонним пользователям.
Пример .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
Удалённое состояние обеспечивает:
- общий источник состояния для команды;
- централизованный контроль доступа;
- резервирование и версионирование;
- шифрование;
- блокировку конкурентных операций, если backend её поддерживает.
При одновременном apply без блокировки два процесса могут перезаписать state или принять решения на снове устаревших данных.
Миграция backend
После изменения backend выполняют повторную инициализацию:
terraform init -migrate-stateTerraform предложит перенести существующее состояние. Перед миграцией рекомендуется сделать резервную копию и запретить параллельные запуски.
Чтение 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.mdmain.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 и регион внутри себя.
Рекомендации по проектированию модулей
Хороший модуль:
- решает одну понятную задачу;
- имеет небольшой и стабильный интерфейс;
- содержит
descriptionу variables и outputs; - задаёт точные типы;
- валидирует критичные входные значения;
- предоставляет разумные значения по умолчанию;
- не скрывает опасные побочные эффекты;
- документирует пример использования;
- фиксирует совместимые версии Terraform и providers.
Не каждую пару ресурсов нужно превращать в отдельный модуль. Слишком мелкие модули усложняют навигацию и обновления.
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."
}
}Основные атрибуты:
description— назначение переменной;type— ограничение типа;default— значение по умолчанию;sensitive— скрытие значения в части интерфейса CLI;nullable— разрешён лиnull;validation— пользовательская проверка.
Переменная без 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 planTerraform автоматически загружает:
terraform.tfvars;terraform.tfvars.json;- файлы
*.auto.tfvars; - файлы
*.auto.tfvars.json.
Явный -var-file удобен для раздельных конфигураций окружений.
Приоритет значений
Значение может поступать из default, файлов, переменных окружения и аргументов CLI. При совместном использовании нужно учитывать установленный Terraform порядок приоритета. В проекте лучше выбрать один понятный способ передачи окруженческих параметров и зафиксировать его в README и CI.
Секретные переменные
variable "database_password" {
description = "Пароль базы данных"
type = string
sensitive = true
}sensitive = true уменьшает риск вывода секрета на экран, но:
- не шифрует значение;
- не исключает его из state;
- не делает безопасным хранение секрета в Git;
- не заменяет менеджер секретов.
По возможности 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 не заменяет:
terraform fmt;terraform validate;terraform plan;- тесты модулей;
- сканер безопасности инфраструктурного кода.
Эти инструменты проверяют разные аспекты.
Что проверяет 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=falseterraform 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 удобны, когда:
- конфигурация окружений почти одинакова;
- различаются значения нескольких параметров;
- окружения имеют одинаковый жизненный цикл;
- одна команда управляет всеми окружениями;
- разделения одним backend и набором credentials достаточно.
Ограничения
Workspaces не обеспечивают сами по себе:
- изоляцию облачных аккаунтов;
- отдельные права доступа;
- независимые backend-настройки;
- защиту от выбора неправильного workspace;
- заметные различия архитектуры окружений.
Для критичных окружений часто безопаснее отдельные 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 -recursive2. Инициализация
terraform initПосле обновления разрешённых версий:
terraform init -upgrade3. Валидация и линтинг
terraform validate
tflint --init
tflint --recursive4. План
terraform plan -var-file="environments/dev.tfvars" -out="dev.tfplan"Сохранённый план позволяет применить именно просмотренный набор изменений, если между операциями не возникли несовместимые изменения состояния или окружения.
Просмотр:
terraform show dev.tfplan5. Применение
terraform apply dev.tfplanАвтоматическое подтверждение:
terraform apply -auto-approve-auto-approve подходит для контролируемого CI-процесса, но не должен обходить review и проверки.
6. Удаление
terraform plan -destroy
terraform destroyПеред удалением следует проверить защиту данных, резервные копии и зависимости вне Terraform.
Plan в CI
Безопасный процесс обычно разделяет:
- форматирование и статические проверки;
- создание plan;
- review;
- применение одобренного plan;
- журналирование результата.
Нельзя применять бинарный 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
- Используйте remote backend для командной работы.
- Включайте шифрование, версионирование и блокировку, если backend поддерживает их.
- Ограничивайте доступ по принципу наименьших привилегий.
- Не храните
*.tfstateв Git. - Не редактируйте state вручную.
- Перед рискованными state-операциями создавайте резервную копию.
Секреты
- Не записывайте credentials в
.tfи.tfvars. - Используйте временные credentials, роли и менеджеры секретов.
- Помните, что
sensitiveне шифрует state. - Не публикуйте plan-файлы и логи, способные содержать секреты.
Изменения
- Просматривайте
terraform planпередapply. - Сохраняйте plan для контролируемого применения.
- Проверяйте операции
replaceиdestroyособенно внимательно. - Не выполняйте параллельные применения к одному state.
- Ручные изменения в облачной консоли либо запрещайте, либо быстро переносите в код.
Код
- Используйте
terraform fmt,terraform validateи TFLint. - Задавайте типы, описания и validation для variables.
- Публикуйте только необходимые outputs.
- Используйте
for_eachсо стабильными ключами для именованных объектов. - Не злоупотребляйте
depends_on,ignore_changesи dynamic-блоками. - Разбивайте систему на состояния по жизненному циклу и зоне ответственности, а не только по размеру файлов.
Модули
- Фиксируйте версии внешних модулей.
- Изучайте их исходный код и plan перед применением.
- Не передавайте в модуль больше прав и секретов, чем необходимо.
- Поддерживайте README с входами, outputs и минимальным примером.
- Избегайте глубоких цепочек модулей, затрудняющих диагностику.
Workspaces
- Используйте их для похожих окружений с общей моделью доступа.
- Для production рассмотрите отдельные аккаунты, backend и root modules.
- Всегда явно проверяйте workspace перед
planиapply. - В CI выбирайте workspace детерминированно.
Минимальный checklist перед merge
[ ] terraform fmt -check -recursive выполнен
[ ] terraform validate выполнен
[ ] tflint выполнен
[ ] plan создан и просмотрен
[ ] нет неожиданных destroy/replace
[ ] версии Terraform, providers и модулей ограничены
[ ] секреты отсутствуют в коде и логах
[ ] state хранится в защищённом backend
[ ] изменения инфраструктуры прошли review
[ ] для критичных ресурсов предусмотрены backup и rollback