← все статьи
8 мин

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 с хорошей. Начните с любого, но начните — инфраструктура в коде лучше инфраструктуры в памяти.

← ПредыдущаяError handling в распределённых системах Следующая →Node.js, Deno, Bun: выбор рантайма в 2025