Infrastructure as Code: Terraform vs Pulumi
Инфраструктура, созданная через UI облачной консоли, — это технический долг, который нельзя погасить. Вы не знаете точно, что было нажато, не можете воспроизвести окружение, не можете проревьюить изменение до применения. В 3 утра при инциденте вы пытаетесь воссоздать конфигурацию балансировщика по памяти.
Infrastructure as Code решает это. Но сегодня выбор инструмента — не очевидный: Terraform де-факто стандарт, но Pulumi предлагает настоящие языки программирования вместо декларативного HCL. Разберём оба подхода честно.
Terraform: провайдеры, модули, state
Terraform — зрелый инструмент с огромной экосистемой провайдеров. HashiCorp Configuration Language (HCL) декларативен: вы описываете желаемое состояние, Terraform вычисляет diff и применяет его.
# main.tf — базовый пример: VPC + ECS кластер на AWS
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks" # locking
}
}
provider "aws" {
region = var.aws_region
}
# Переменные
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be dev, staging, or prod."
}
}
# VPC через готовый модуль из Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-vpc-${var.environment}"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = var.environment != "prod" # в prod — HA NAT
tags = local.common_tags
}
# ECS кластер
resource "aws_ecs_cluster" "main" {
name = "cluster-${var.environment}"
setting {
name = "containerInsights"
value = "enabled"
}
tags = local.common_tags
}
# Outputs
output "vpc_id" {
value = module.vpc.vpc_id
}
output "cluster_arn" {
value = aws_ecs_cluster.main.arn
}
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = "my-project"
}
}
Terraform workspaces позволяют держать несколько state-файлов для одного конфига. Удобно для dev/staging/prod с одинаковой структурой, но разными значениями переменных:
# terraform workspace new staging
# terraform workspace select prod
# terraform workspace list
# Использование в конфиге:
resource "aws_instance" "app" {
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
# ...
}
# Однако workspace — не рекомендован для prod/non-prod разделения.
# Предпочтительнее: отдельные директории с отдельными state-файлами
# environments/
# dev/
# main.tf → вызывает модули
# staging/
# main.tf
# prod/
# main.tf
Pulumi: инфраструктура на TypeScript, Python, Go
Pulumi — другой подход: вместо декларативного DSL — полноценный язык программирования. Это открывает возможности, недоступные в HCL: циклы, условия, абстракции, переиспользование библиотек, тесты.
// index.ts — аналогичный стек на Pulumi (TypeScript)
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
const config = new pulumi.Config();
const environment = config.require("environment");
const isProd = environment === "prod";
// awsx — высокоуровневые компоненты поверх aws
// Автоматически создаёт VPC, subnets, NAT gateways, route tables
const vpc = new awsx.ec2.Vpc(`vpc-${environment}`, {
cidrBlock: "10.0.0.0/16",
numberOfAvailabilityZones: isProd ? 3 : 2,
natGateways: {
strategy: isProd
? awsx.ec2.NatGatewayStrategy.OnePerAz
: awsx.ec2.NatGatewayStrategy.Single,
},
tags: {
Environment: environment,
ManagedBy: "pulumi",
},
});
// ECS кластер
const cluster = new aws.ecs.Cluster(`cluster-${environment}`, {
settings: [{ name: "containerInsights", value: "enabled" }],
tags: { Environment: environment },
});
// Реальная сила Pulumi: динамическая логика
// Создаём N микросервисов из конфига — в Terraform это неудобно
const services = ["api", "worker", "scheduler"];
const ecsServices = services.map((name) => {
const taskDef = new aws.ecs.TaskDefinition(`task-${name}`, {
family: `${name}-${environment}`,
cpu: isProd ? "512" : "256",
memory: isProd ? "1024" : "512",
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
containerDefinitions: pulumi.jsonStringify([
{
name,
image: `${config.require("ecr_repo")}/${name}:latest`,
portMappings: [{ containerPort: 8000 }],
environment: [{ name: "ENV", value: environment }],
},
]),
});
return new aws.ecs.Service(`svc-${name}`, {
cluster: cluster.arn,
taskDefinition: taskDef.arn,
desiredCount: isProd ? 3 : 1,
launchType: "FARGATE",
networkConfiguration: {
subnets: vpc.privateSubnetIds,
securityGroups: [/* sg */],
},
});
});
// Exports
export const vpcId = vpc.vpcId;
export const clusterArn = cluster.arn;
Где Pulumi выигрывает у Terraform:
- Циклы и условия — не костыли
countиfor_each, а нативный JavaScript/Python. - Тестирование — unit-тесты инфраструктуры через стандартные тест-фреймворки (Jest, pytest).
- Компонентная модель — ComponentResource создаёт переиспользуемые абстракции с правильной инкапсуляцией.
- Типизация — TypeScript-типы для всех ресурсов, IDE-автодополнение, compile-time ошибки.
State management: remote state, locking, миграция
State — центральная концепция как Terraform, так и Pulumi. State хранит отображение конфига на реальные ресурсы в облаке. Неправильное управление state = потерянные ресурсы или конфликты при параллельной работе.
# Terraform: remote state в S3 + DynamoDB locking
# Создаём S3 bucket и DynamoDB таблицу для state
# (это bootstrap — делается один раз вручную или отдельным Terraform)
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-terraform-state-${data.aws_caller_identity.current.account_id}"
lifecycle {
prevent_destroy = true # защита от случайного удаления
}
}
resource "aws_s3_bucket_versioning" "state_versioning" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled" # версионирование = история state
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
# Импорт ресурса в state (если ресурс создан вне Terraform)
# terraform import aws_instance.app i-1234567890abcdef0
# Удаление ресурса из state без удаления реального ресурса
# terraform state rm aws_instance.app
# Перемещение ресурса в state (рефакторинг)
# terraform state mv aws_instance.app module.compute.aws_instance.app
// Pulumi: state backends
// По умолчанию — Pulumi Cloud (бесплатно для небольших команд)
// Self-hosted: S3, Azure Blob, GCS, local
// pulumi login s3://my-pulumi-state-bucket
// pulumi login gs://my-pulumi-state-bucket
// pulumi login azblob://my-pulumi-state-container
// State stack operations:
// pulumi stack ls — список стеков
// pulumi stack select prod — выбор стека
// pulumi stack export > state.json — экспорт state
// pulumi stack import < state.json — импорт state
// Pulumi: resource import
// pulumi import aws:ec2/instance:Instance app i-1234567890abcdef0
Drift detection: terraform plan и reconciliation
Drift — расхождение между состоянием в state-файле и реальными ресурсами в облаке. Происходит, когда кто-то изменяет ресурс вручную через консоль, CLI или другой инструмент.
# terraform plan показывает drift:
# ~ resource "aws_security_group" "app" {
# ingress {
# ~ from_port = 8080 -> 80 # кто-то изменил вручную
# }
# }
# Автоматизированный drift detection через CI/CD:
# Запускаем terraform plan в cron, алертим если есть изменения
// GitHub Actions: scheduled drift detection
// .github/workflows/drift.yml
/*
name: Drift Detection
on:
schedule:
- cron: '0 8 * * 1-5' # каждый будний день в 8 утра
workflow_dispatch:
jobs:
detect-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.x
- name: Terraform Init
run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Detect Drift
id: plan
run: |
terraform plan -detailed-exitcode -no-color 2>&1 | tee plan.txt
echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
- name: Alert on Drift
if: steps.plan.outputs.exitcode == '2'
uses: 8398a7/action-slack@v3
with:
status: custom
custom_payload: |
{"text": "⚠️ Terraform drift detected in prod! Review plan output."}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
*/
// Terraform exitcodes:
// 0 — нет изменений (нет drift)
// 1 — ошибка
// 2 — есть изменения (drift обнаружен)
Модули и переиспользование
Terraform Registry — публичный каталог с тысячами проверенных модулей. Pulumi Packages — аналог, плюс возможность использовать любую npm/PyPI/Go библиотеку.
# Terraform: создание собственного модуля
# modules/rds/main.tf
variable "identifier" { type = string }
variable "engine_version" { type = string; default = "15.4" }
variable "instance_class" { type = string; default = "db.t3.micro" }
variable "storage_gb" { type = number; default = 20 }
variable "vpc_id" { type = string }
variable "subnet_ids" { type = list(string) }
variable "tags" { type = map(string); default = {} }
resource "aws_db_subnet_group" "this" {
name = "${var.identifier}-subnet-group"
subnet_ids = var.subnet_ids
tags = var.tags
}
resource "aws_security_group" "rds" {
name = "${var.identifier}-rds-sg"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
tags = var.tags
}
resource "aws_db_instance" "this" {
identifier = var.identifier
engine = "postgres"
engine_version = var.engine_version
instance_class = var.instance_class
allocated_storage = var.storage_gb
storage_encrypted = true
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "${var.identifier}-final"
tags = var.tags
}
output "endpoint" { value = aws_db_instance.this.endpoint }
output "db_name" { value = aws_db_instance.this.db_name }
output "instance_id" { value = aws_db_instance.this.id }
# Использование модуля:
# module "db" {
# source = "./modules/rds"
# identifier = "myapp-prod"
# instance_class = "db.t3.medium"
# vpc_id = module.vpc.vpc_id
# subnet_ids = module.vpc.private_subnets
# tags = local.common_tags
# }
Cost estimation: Infracost и политики стоимости
Infracost интегрируется в CI и показывает стоимость изменений инфраструктуры прямо в Pull Request. До применения.
# .github/workflows/infracost.yml (упрощённо)
# Infracost показывает разницу стоимости до/после PR:
#
# ┌─────────────────────────────────────────────────────────┐
# │ 💰 Infracost estimate │
# ├──────────────────────────────┬─────────┬───────────────┤
# │ Resource │ Monthly │ Change │
# ├──────────────────────────────┼─────────┼───────────────┤
# │ aws_instance.app │ $16.94 │ +$16.94 │
# │ aws_db_instance.main │ $27.38 │ +$27.38 │
# │ aws_nat_gateway.this[0] │ $32.85 │ +$32.85 │
# ├──────────────────────────────┼─────────┼───────────────┤
# │ Total monthly cost │ $77.17 │ +$77.17 │
# └──────────────────────────────┴─────────┴───────────────┘
# Установка и запуск:
# brew install infracost
# infracost auth login
# infracost breakdown --path .
# infracost diff --path . --compare-to previous-plan.json
// Pulumi: cost policies через Pulumi CrossGuard (Policy as Code)
// policy/index.ts
import { PolicyPack, validateResourceOfType } from "@pulumi/policy";
import * as aws from "@pulumi/aws";
new PolicyPack("cost-policies", {
policies: [
{
name: "no-large-instances-in-dev",
description: "Prevent expensive instances in dev environment",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(
aws.ec2.Instance,
(instance, args, reportViolation) => {
const expensiveTypes = [
"m5.xlarge",
"m5.2xlarge",
"c5.4xlarge",
];
const stack = args.getConfig("environment");
if (
stack !== "prod" &&
expensiveTypes.includes(instance.instanceType as string)
) {
reportViolation(
`Instance type ${instance.instanceType} is too expensive for non-prod.` +
` Use t3.medium or smaller.`,
);
}
},
),
},
{
name: "require-storage-encryption",
description: "All RDS instances must be encrypted",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(
aws.rds.Instance,
(rds, _, reportViolation) => {
if (!rds.storageEncrypted) {
reportViolation("RDS instance must have storage_encrypted = true");
}
},
),
},
],
});
Security: секреты в state, Vault, Policy as Code
Проблема номер один с Terraform: secrets в state-файле хранятся в plaintext. Пароль к RDS, который вы передали как переменную — в state как "password": "mysecret". Если state в S3 без шифрования, это катастрофа.
# Terraform: безопасная работа с секретами
# Вариант 1: Vault provider — секреты из HashiCorp Vault
terraform {
required_providers {
vault = {
source = "hashicorp/vault"
version = "~> 3.0"
}
}
}
data "vault_generic_secret" "db_password" {
path = "secret/prod/database"
}
resource "aws_db_instance" "main" {
password = data.vault_generic_secret.db_password.data["password"]
# Теперь в state: "password": (sensitive value)
# С Terraform 0.15+: sensitive = true скрывает из plan/apply вывода
}
# Вариант 2: AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db_creds" {
secret_id = "prod/myapp/db"
}
locals {
db_creds = jsondecode(data.aws_secretsmanager_secret_version.db_creds.secret_string)
}
resource "aws_db_instance" "main" {
username = local.db_creds["username"]
password = local.db_creds["password"]
}
// Pulumi: нативное шифрование секретов в state
import * as pulumi from "@pulumi/pulumi";
const config = new pulumi.Config();
// Секрет зашифрован в state с помощью passphrase или KMS
// pulumi config set --secret db_password "mysupersecretpassword"
const dbPassword = config.requireSecret("db_password");
// Pulumi автоматически помечает Output как secret,
// если он создан из secret — шифрование транзитивное
const db = new aws.rds.Instance("main", {
password: dbPassword, // зашифровано в state
});
// db.password — тип pulumi.Output (secret=true)
// Не попадёт в логи при pulumi preview/up
OPA (Open Policy Agent) с Terraform — политики безопасности как код. Конфигурации проверяются до terraform apply:
# Conftest + OPA: проверка Terraform plan перед apply
# policy/terraform.rego
package terraform
# Запрещаем публичные S3 buckets
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_s3_bucket_acl"
resource.values.acl == "public-read"
msg := sprintf("S3 bucket '%v' cannot be public-read", [resource.name])
}
# Требуем шифрование RDS
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_db_instance"
not resource.values.storage_encrypted
msg := sprintf("RDS instance '%v' must have storage_encrypted = true", [resource.name])
}
# Требуем теги на все ресурсы
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type != "aws_iam_policy_document"
not resource.values.tags.Environment
msg := sprintf("Resource '%v' is missing required tag 'Environment'", [resource.name])
}
# Использование:
# terraform plan -out=tfplan
# terraform show -json tfplan > tfplan.json
# conftest test tfplan.json --policy policy/
Decision matrix: что выбрать
Честное сравнение без маркетинговых заявлений:
| Критерий | Terraform | Pulumi | CDK (AWS) | Crossplane |
|---|---|---|---|---|
| Зрелость | ★★★★★ | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
| Мультиоблако | ★★★★★ | ★★★★★ | ★☆☆☆☆ (AWS only) | ★★★★☆ |
| Язык | HCL (DSL) | TS/Python/Go | TS/Python/Java | YAML/CRD |
| Тестирование | Terratest (Go) | Jest/pytest (нативно) | Jest/pytest | Сложно |
| Экосистема | Огромная (Registry) | Хорошая, растёт | Хорошая (AWS) | Kubernetes-ориентирована |
| Порог входа | Низкий | Средний | Средний | Высокий |
| Лицензия | BSL 1.1 (2023) | Apache 2.0 | Apache 2.0 | Apache 2.0 |
Примечание: HashiCorp в 2023 сменила лицензию Terraform с MPL на BSL 1.1, что ограничивает коммерческое использование. Это породило форк OpenTofu под Apache 2.0, который полностью совместим с Terraform синтаксически.
Когда выбирать Terraform / OpenTofu: у вас большая команда с разным опытом, нужен широкий мультиоблачный охват, экосистема модулей важна, менять привычный toolchain нет смысла.
Когда выбирать Pulumi: команда сильная в TypeScript/Python, нужна сложная динамическая логика при создании ресурсов, важны unit-тесты инфраструктуры, новый проект без legacy.
CDK: если вы AWS-only и команда уже пишет на TypeScript/Python — логичный выбор. CloudFormation под капотом с нормальным языком поверх.
Crossplane: если вся ваша инфраструктура — Kubernetes и вы хотите управлять облачными ресурсами через kubectl и CRD. Нишевый, но мощный для K8s-нативных команд.
Лучший IaC-инструмент — тот, который команда будет реально использовать и поддерживать. Terraform с плохой дисциплиной хуже, чем Pulumi с хорошей. Начните с любого, но начните — инфраструктура в коде лучше инфраструктуры в памяти.