MidПрактика17 min

Terraform: основы

HCL синтаксис, providers, resources, data sources, variables, outputs, locals, workflow и практические примеры с AWS

Что такое Terraform

Terraform -- это инструмент от HashiCorp для декларативного управления инфраструктурой. Вы описываете желаемое состояние ресурсов в файлах .tf, а Terraform определяет, какие действия выполнить, чтобы привести реальную инфраструктуру к этому состоянию.

Terraform Workflow:
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   .tf files  │     │   Terraform  │     │  Cloud API   │
│              │────►│   Engine     │────►│  (AWS, GCP,  │
│  HCL Code   │     │              │     │   Azure...)  │
│              │     │  Plan → Apply│     │              │
└──────────────┘     └──────┬───────┘     └──────────────┘
                            │
                     ┌──────▼───────┐
                     │ terraform    │
                     │   .tfstate   │
                     │              │
                     │ Current state│
                     │ of resources │
                     └──────────────┘

HCL синтаксис

HCL (HashiCorp Configuration Language) -- специализированный язык для описания инфраструктуры. Он проще, чем JSON/YAML для человека, но при этом поддерживает вычисления и логику.

Blocks (Блоки)

Основная единица HCL -- блок. Каждый блок имеет тип и (иногда) метки:

# Block structure: type "label1" "label2" { ... }

# Resource block - 2 labels (type and name)
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"
}

# Variable block - 1 label (name)
variable "region" {
  type    = string
  default = "eu-central-1"
}

# Provider block - 1 label (name)
provider "aws" {
  region = var.region
}

# Terraform block - no labels
terraform {
  required_version = ">= 1.9"
}

# Locals block - no labels
locals {
  common_tags = {
    Project   = "my-app"
    ManagedBy = "terraform"
  }
}

Arguments (Аргументы)

Аргументы -- это key = value пары внутри блоков:

resource "aws_instance" "web" {
  # Simple arguments
  ami           = "ami-0c55b159cbfafe1f0"    # string
  instance_type = "t3.medium"                 # string
  monitoring    = true                        # bool
  count         = 3                           # number

  # Nested block (not an argument!)
  root_block_device {
    volume_size = 50
    volume_type = "gp3"
    encrypted   = true
  }

  # Map argument
  tags = {
    Name = "web-server"
    Env  = "production"
  }
}

Expressions (Выражения)

HCL поддерживает мощные выражения:

locals {
  # String interpolation
  bucket_name = "app-${var.environment}-${var.region}"

  # Conditional expression
  instance_type = var.environment == "production" ? "t3.large" : "t3.micro"

  # List comprehension with for
  public_subnet_ids = [for s in aws_subnet.public : s.id]

  # Map comprehension
  instance_tags = {
    for idx, inst in aws_instance.web :
    inst.id => inst.private_ip
  }

  # Splat expression (shorthand for list comprehension)
  all_instance_ids = aws_instance.web[*].id

  # Function calls
  config = jsondecode(file("config.json"))

  # Arithmetic
  total_storage = var.base_storage * var.instance_count
}

Типы данных

variable "examples" {
  # Primitive types
  # string  = "hello"
  # number  = 42
  # bool    = true

  # Collection types
  # list(string) = ["a", "b", "c"]
  # set(string)  = ["a", "b", "c"]   # no duplicates, unordered
  # map(string)  = { key1 = "val1", key2 = "val2" }

  # Structural types
  # object({ name = string, age = number })
  # tuple([string, number, bool])

  # Special
  # any    = accepts any type
  # null   = absence of a value
}

# Object type example
variable "database_config" {
  type = object({
    engine         = string
    instance_class = string
    storage_gb     = number
    multi_az       = bool
    backup_days    = number
  })

  default = {
    engine         = "postgres"
    instance_class = "db.t3.medium"
    storage_gb     = 100
    multi_az       = false
    backup_days    = 7
  }
}

Providers

Providers -- это плагины, которые Terraform использует для взаимодействия с API облачных платформ и других сервисов.

┌──────────────────────────────────────────────────────┐
│                 Terraform Core                        │
│           (HCL parser, plan, apply)                   │
├──────┬───────┬──────┬──────────┬─────────────────────┤
│      │       │      │          │                      │
│  ┌───▼──┐ ┌──▼──┐ ┌─▼───┐ ┌───▼────┐ ┌────────────┐│
│  │ AWS  │ │ GCP │ │Azure│ │Docker  │ │Kubernetes  ││
│  │Provid│ │Provi│ │Provi│ │Provider│ │Provider    ││
│  │er    │ │der  │ │der  │ │        │ │            ││
│  └───┬──┘ └──┬──┘ └─┬───┘ └───┬────┘ └─────┬──────┘│
│      │       │      │          │             │       │
└──────┼───────┼──────┼──────────┼─────────────┼───────┘
       │       │      │          │             │
       ▼       ▼      ▼          ▼             ▼
    AWS API  GCP API Azure API Docker API  K8s API

Конфигурация провайдеров

terraform {
  required_providers {
    # AWS provider
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.80"      # >= 5.80.0, < 6.0.0
    }

    # Docker provider (for local development)
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }

    # Kubernetes provider
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.35"
    }

    # Random provider (utility)
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

# AWS provider configuration
provider "aws" {
  region = "eu-central-1"

  # Authentication (best practice: use environment variables)
  # AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
  # Or IAM role / SSO profile

  default_tags {
    tags = {
      ManagedBy = "terraform"
      Project   = var.project_name
    }
  }
}

# Multiple provider configurations (aliases)
provider "aws" {
  alias  = "us_east"
  region = "us-east-1"
}

# Use aliased provider for specific resources
resource "aws_acm_certificate" "cert" {
  provider = aws.us_east  # CloudFront requires certs in us-east-1
  domain_name = "example.com"
}

# Docker provider
provider "docker" {
  host = "unix:///var/run/docker.sock"
}

# Kubernetes provider
provider "kubernetes" {
  config_path = "~/.kube/config"
  context     = "my-cluster"
}

Версионирование провайдеров

# Version constraints
version = "5.80.0"     # Exact version
version = ">= 5.80"   # Minimum version
version = "~> 5.80"    # >= 5.80.0, < 6.0.0 (pessimistic)
version = ">= 5.0, < 6.0"  # Range

# Lock file: .terraform.lock.hcl
# Generated by terraform init, commit to git!
# Ensures all team members use same provider versions

Resources

Resources -- главные строительные блоки Terraform. Каждый ресурс соответствует объекту в облаке (сервер, сеть, база данных и т.д.).

Жизненный цикл ресурса

Resource Lifecycle:
                  ┌─────────┐
                  │  Code   │
                  │ Written │
                  └────┬────┘
                       │
              terraform plan
                       │
                  ┌────▼────┐
          ┌───── │  Plan    │ ─────┐
          │      │ Review   │      │
          │      └────┬────┘       │
     No changes       │        Changes needed
          │      terraform apply   │
          │           │            │
          │      ┌────▼────┐      │
          │      │         │      │
          │      │ CREATE  │◄─────┘ (new resource)
          │      │ UPDATE  │◄─────  (changed attribute)
          │      │ DESTROY │◄─────  (removed from code)
          │      │ REPLACE │◄─────  (force new: immutable attr change)
          │      │         │
          │      └────┬────┘
          │           │
          │      ┌────▼────┐
          └─────►│  State  │
                 │ Updated │
                 └─────────┘

Основные аргументы ресурсов

# Basic resource
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = var.instance_type
  subnet_id     = aws_subnet.private[0].id

  tags = {
    Name = "web-server"
  }
}

# depends_on - explicit dependency (use only when implicit isn't enough)
resource "aws_instance" "app" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  # Terraform usually detects dependencies automatically
  # Use depends_on only for hidden dependencies
  depends_on = [aws_iam_role_policy.app_policy]
}

# lifecycle - control resource behavior
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  lifecycle {
    # Create new before destroying old (zero-downtime)
    create_before_destroy = true

    # Prevent accidental destruction
    prevent_destroy = true

    # Ignore changes made outside Terraform
    ignore_changes = [
      tags["LastModified"],
      ami,  # Don't update AMI automatically
    ]

    # Custom condition
    precondition {
      condition     = var.instance_type != "t3.nano"
      error_message = "Instance type t3.nano is too small for production."
    }
  }
}

# timeouts - how long to wait for operations
resource "aws_db_instance" "main" {
  engine         = "postgres"
  instance_class = "db.t3.medium"

  timeouts {
    create = "60m"    # RDS can take a while
    update = "90m"
    delete = "30m"
  }
}

count и for_each

Два способа создать несколько экземпляров ресурса:

# count - for simple numeric iteration
resource "aws_instance" "web" {
  count = 3

  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"
  subnet_id     = aws_subnet.private[count.index].id

  tags = {
    Name = "web-${count.index + 1}"  # web-1, web-2, web-3
  }
}

# Reference: aws_instance.web[0], aws_instance.web[1], ...
# All IDs: aws_instance.web[*].id

# Problem with count: removing item from middle shifts indices!
# If count was 3 and you remove web-2, web-3 gets recreated as web-2

# ---

# for_each - for maps and sets (preferred for most cases)
resource "aws_instance" "web" {
  for_each = {
    api     = { type = "t3.large",  subnet = "private-1" }
    worker  = { type = "t3.medium", subnet = "private-2" }
    cron    = { type = "t3.small",  subnet = "private-1" }
  }

  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = each.value.type
  subnet_id     = aws_subnet.main[each.value.subnet].id

  tags = {
    Name = "app-${each.key}"  # app-api, app-worker, app-cron
    Role = each.key
  }
}

# Reference: aws_instance.web["api"], aws_instance.web["worker"]
# All IDs: values(aws_instance.web)[*].id

# for_each with set of strings
variable "availability_zones" {
  type    = set(string)
  default = ["eu-central-1a", "eu-central-1b", "eu-central-1c"]
}

resource "aws_subnet" "private" {
  for_each = var.availability_zones

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, index(tolist(var.availability_zones), each.value))
  availability_zone = each.value

  tags = {
    Name = "private-${each.value}"
  }
}

Когда использовать count vs for_each:

Сценарий count for_each
Простое количество (N серверов) Да Избыточно
Разные параметры для каждого Нет Да
Условное создание (0 или 1) count = var.enabled ? 1 : 0 Можно, но count проще
Удаление из середины списка Проблема (сдвиг индексов) Нет проблемы

Data Sources

Data Sources позволяют читать информацию о существующих ресурсах, которыми Terraform не управляет.

# Read existing VPC by tag
data "aws_vpc" "existing" {
  filter {
    name   = "tag:Name"
    values = ["production-vpc"]
  }
}

# Read latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }

  filter {
    name   = "state"
    values = ["available"]
  }
}

# Use data source in resource
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.medium"
  subnet_id     = data.aws_vpc.existing.id  # Use existing VPC
}

# Read current AWS account info
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

output "account_id" {
  value = data.aws_caller_identity.current.account_id
}

# Read IAM policy document (common pattern)
data "aws_iam_policy_document" "app_policy" {
  statement {
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:PutObject",
    ]
    resources = [
      "${aws_s3_bucket.app.arn}/*",
    ]
  }

  statement {
    effect = "Allow"
    actions = [
      "secretsmanager:GetSecretValue",
    ]
    resources = [
      aws_secretsmanager_secret.db_password.arn,
    ]
  }
}

resource "aws_iam_policy" "app" {
  name   = "app-policy"
  policy = data.aws_iam_policy_document.app_policy.json
}

# Read file
data "local_file" "user_data" {
  filename = "${path.module}/scripts/user_data.sh"
}

# Template rendering
data "template_file" "nginx_config" {
  template = file("${path.module}/templates/nginx.conf.tpl")

  vars = {
    server_name = var.domain_name
    upstream    = aws_lb.app.dns_name
  }
}

Variables (Переменные)

Input Variables

# variables.tf

# Simple string variable
variable "environment" {
  description = "Deployment environment (dev, staging, production)"
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be dev, staging, or production."
  }
}

# Number with validation
variable "instance_count" {
  description = "Number of web server instances"
  type        = number
  default     = 2

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 10
    error_message = "Instance count must be between 1 and 10."
  }
}

# Boolean
variable "enable_monitoring" {
  description = "Enable CloudWatch detailed monitoring"
  type        = bool
  default     = false
}

# List of strings
variable "allowed_cidrs" {
  description = "CIDR blocks allowed to access the application"
  type        = list(string)
  default     = ["10.0.0.0/8"]
}

# Map
variable "instance_types" {
  description = "Instance types per environment"
  type        = map(string)
  default = {
    dev        = "t3.micro"
    staging    = "t3.small"
    production = "t3.large"
  }
}

# Complex object
variable "database" {
  description = "Database configuration"
  type = object({
    engine            = string
    engine_version    = string
    instance_class    = string
    allocated_storage = number
    multi_az          = bool
    backup_retention  = number
  })

  default = {
    engine            = "postgres"
    engine_version    = "18.1"
    instance_class    = "db.t3.medium"
    allocated_storage = 50
    multi_az          = false
    backup_retention  = 7
  }
}

# Sensitive variable (won't show in plan output)
variable "db_password" {
  description = "Database master password"
  type        = string
  sensitive   = true

  validation {
    condition     = length(var.db_password) >= 16
    error_message = "Database password must be at least 16 characters."
  }
}

# Nullable variable
variable "custom_domain" {
  description = "Custom domain name (optional)"
  type        = string
  default     = null
  nullable    = true
}

Приоритет задания переменных

Terraform читает значения переменных из разных источников. Приоритет (от низшего к высшему):

Priority (lowest → highest):
┌──────────────────────────────────────────────────┐
│  1. default в variable block          (lowest)   │
│  2. terraform.tfvars файл                        │
│  3. *.auto.tfvars файлы                          │
│  4. -var-file=custom.tfvars                      │
│  5. TF_VAR_name environment variable             │
│  6. -var="name=value" в командной строке (highest)│
└──────────────────────────────────────────────────┘
# Ways to pass variables:

# 1. Default value in variable block
# (already in variables.tf)

# 2. terraform.tfvars (auto-loaded)
echo 'environment = "production"' > terraform.tfvars

# 3. Named .tfvars file
terraform apply -var-file="prod.tfvars"

# 4. Environment variable
export TF_VAR_db_password="super-secret-password"
terraform apply

# 5. Command line
terraform apply -var="environment=production" -var="instance_count=3"

Пример prod.tfvars:

# environments/prod.tfvars
environment       = "production"
instance_count    = 3
enable_monitoring = true
allowed_cidrs     = ["10.0.0.0/8", "172.16.0.0/12"]

database = {
  engine            = "postgres"
  engine_version    = "18.1"
  instance_class    = "db.r6g.large"
  allocated_storage = 200
  multi_az          = true
  backup_retention  = 30
}

Outputs

Outputs экспортируют значения из Terraform -- ID ресурсов, IP-адреса, DNS-имена. Полезны для передачи данных между модулями и для отображения после apply.

# outputs.tf

# Simple output
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

# Output used by other modules
output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

# Sensitive output (hidden in console)
output "db_connection_string" {
  description = "Database connection string"
  value       = "postgresql://${aws_db_instance.main.username}:${var.db_password}@${aws_db_instance.main.endpoint}/${aws_db_instance.main.db_name}"
  sensitive   = true
}

# Conditional output
output "load_balancer_dns" {
  description = "DNS name of the load balancer"
  value       = var.enable_lb ? aws_lb.main[0].dns_name : null
}

# Complex output
output "instances" {
  description = "Map of instance names to their IPs"
  value = {
    for key, inst in aws_instance.web :
    key => {
      id         = inst.id
      private_ip = inst.private_ip
      public_ip  = inst.public_ip
    }
  }
}
# View outputs after apply
terraform output
terraform output vpc_id
terraform output -json  # Machine-readable format

# Use in scripts
VPC_ID=$(terraform output -raw vpc_id)

Locals

Locals -- вычисляемые значения, которые упрощают конфигурацию и устраняют дублирование.

locals {
  # Common tags for all resources
  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
    Team        = "platform"
  }

  # Computed name prefix
  name_prefix = "${var.project_name}-${var.environment}"

  # Conditional logic
  is_production = var.environment == "production"
  instance_type = local.is_production ? "t3.large" : "t3.micro"

  # Transform data
  azs = ["${var.region}a", "${var.region}b", "${var.region}c"]

  # Merge maps
  resource_tags = merge(local.common_tags, {
    CreatedAt = timestamp()
  })

  # Flatten nested structures
  subnet_configs = flatten([
    for az in local.azs : [
      {
        name = "public-${az}"
        az   = az
        type = "public"
      },
      {
        name = "private-${az}"
        az   = az
        type = "private"
      }
    ]
  ])
}

# Using locals in resources
resource "aws_instance" "web" {
  instance_type = local.instance_type

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-web"
    Role = "web-server"
  })
}

Terraform Workflow

Основные команды

Terraform Workflow:
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   terraform │     │   terraform │     │   terraform │
│     init    │────►│    plan     │────►│    apply    │
│             │     │             │     │             │
│ Download    │     │ Show what   │     │ Execute     │
│ providers   │     │ will change │     │ changes     │
│ Init backend│     │             │     │             │
└─────────────┘     └─────────────┘     └─────────────┘
       │                                       │
       │            ┌─────────────┐            │
       │            │   terraform │            │
       │            │   destroy   │◄───────────┘
       │            │             │    (when needed)
       │            │ Remove all  │
       │            │ resources   │
       │            └─────────────┘
       │
       ▼
┌─────────────┐
│ .terraform/ │  Downloaded providers
│ .terraform  │  and modules cached
│  .lock.hcl  │  Version lock file
└─────────────┘

terraform init

# Initialize working directory
terraform init

# Output:
# Initializing the backend...
# Initializing provider plugins...
# - Finding hashicorp/aws versions matching "~> 5.80"...
# - Installing hashicorp/aws v5.82.0...
# Terraform has been successfully initialized!

# Upgrade providers to latest matching versions
terraform init -upgrade

# Reconfigure backend
terraform init -reconfigure

# Generated files:
# .terraform/          - downloaded providers and modules
# .terraform.lock.hcl  - lock file (COMMIT to git!)

terraform plan

# Show what will change
terraform plan

# Save plan to file (for later apply)
terraform plan -out=tfplan

# Plan with specific variables
terraform plan -var-file="prod.tfvars"

# Plan for destruction
terraform plan -destroy

# Target specific resource
terraform plan -target=aws_instance.web

# Output:
# Terraform will perform the following actions:
#
#   # aws_instance.web will be created
#   + resource "aws_instance" "web" {
#       + ami           = "ami-0c55b159cbfafe1f0"
#       + instance_type = "t3.medium"
#       + id            = (known after apply)
#       + public_ip     = (known after apply)
#     }
#
# Plan: 1 to add, 0 to change, 0 to destroy.

terraform apply

# Apply changes (will show plan and ask for confirmation)
terraform apply

# Apply saved plan (no confirmation needed)
terraform apply tfplan

# Apply with auto-approve (CI/CD only!)
terraform apply -auto-approve

# Apply with variables
terraform apply -var-file="prod.tfvars"

# Apply specific resource only
terraform apply -target=aws_instance.web

terraform destroy

# Destroy all resources
terraform destroy

# Destroy specific resource
terraform destroy -target=aws_instance.web

# Destroy with auto-approve (dangerous!)
terraform destroy -auto-approve

Вспомогательные команды

# Format code
terraform fmt
terraform fmt -recursive    # All subdirectories
terraform fmt -check        # Check only (for CI)

# Validate configuration
terraform validate

# Show current state
terraform show

# List resources in state
terraform state list

# Show specific resource in state
terraform state show aws_instance.web

# Refresh state (sync with real infrastructure)
terraform refresh   # Deprecated, use:
terraform apply -refresh-only

# Console for testing expressions
terraform console
> var.environment
"production"
> cidrsubnet("10.0.0.0/16", 8, 1)
"10.0.1.0/24"

Provisioners (и почему их НЕ стоит использовать)

Provisioners выполняют скрипты на созданном ресурсе. Однако HashiCorp рекомендует избегать их в пользу cloud-init, Packer, или Ansible.

# ❌ Anti-pattern: provisioner
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  # DON'T DO THIS in production
  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y nginx php8.4-fpm",
      "sudo systemctl start nginx",
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file("~/.ssh/id_rsa")
      host        = self.public_ip
    }
  }

  # File provisioner
  provisioner "file" {
    source      = "configs/nginx.conf"
    destination = "/tmp/nginx.conf"
  }

  # Local exec (runs on YOUR machine, not the server)
  provisioner "local-exec" {
    command = "echo ${self.private_ip} >> inventory.txt"
  }
}

Почему provisioners -- плохая практика:

Проблема Описание
Не декларативные Terraform не знает, что скрипт сделал
Не идемпотентные Повторный запуск может сломать
Нет drift detection Terraform не отследит изменения
Зависимость от SSH Требует открытый порт 22
Медленные Выполняются последовательно

Правильные альтернативы:

# ✅ User Data (cloud-init) - runs on first boot
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  user_data = <<-EOF
    #!/bin/bash
    apt-get update
    apt-get install -y nginx php8.4-fpm
    systemctl start nginx
  EOF

  user_data_replace_on_change = true
}

# ✅ Pre-baked AMI with Packer (best practice)
# Build AMI with all software pre-installed
# Then reference it in Terraform
data "aws_ami" "app" {
  most_recent = true
  owners      = ["self"]

  filter {
    name   = "name"
    values = ["app-server-*"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.app.id
  instance_type = "t3.medium"
  # No provisioner needed - everything is in the AMI
}

Практический пример: VPC + EC2 для PHP/Go приложения

Полный пример создания базовой инфраструктуры для веб-приложения:

Структура файлов

infrastructure/
├── main.tf           # Provider and backend
├── variables.tf      # Input variables
├── outputs.tf        # Output values
├── networking.tf     # VPC, subnets, security groups
├── compute.tf        # EC2 instances
├── dev.tfvars        # Dev environment values
└── prod.tfvars       # Production values

main.tf

# main.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.80"
    }
  }

  backend "s3" {
    bucket         = "myapp-terraform-state"
    key            = "infrastructure/terraform.tfstate"
    region         = "eu-central-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = var.project_name
      Environment = var.environment
      ManagedBy   = "terraform"
    }
  }
}

variables.tf

# variables.tf
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "eu-central-1"
}

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "myapp"
}

variable "environment" {
  description = "Environment name"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

variable "instance_count" {
  description = "Number of EC2 instances"
  type        = number
  default     = 1
}

variable "ssh_allowed_cidrs" {
  description = "CIDR blocks allowed for SSH access"
  type        = list(string)
  default     = []
}

networking.tf

# networking.tf
locals {
  azs         = ["${var.aws_region}a", "${var.aws_region}b"]
  name_prefix = "${var.project_name}-${var.environment}"
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${local.name_prefix}-vpc"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${local.name_prefix}-igw"
  }
}

# Public Subnets
resource "aws_subnet" "public" {
  count = length(local.azs)

  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = local.azs[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "${local.name_prefix}-public-${local.azs[count.index]}"
    Type = "public"
  }
}

# Private Subnets
resource "aws_subnet" "private" {
  count = length(local.azs)

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
  availability_zone = local.azs[count.index]

  tags = {
    Name = "${local.name_prefix}-private-${local.azs[count.index]}"
    Type = "private"
  }
}

# NAT Gateway (for private subnets internet access)
resource "aws_eip" "nat" {
  count  = var.environment == "prod" ? length(local.azs) : 1
  domain = "vpc"

  tags = {
    Name = "${local.name_prefix}-nat-eip-${count.index}"
  }
}

resource "aws_nat_gateway" "main" {
  count = var.environment == "prod" ? length(local.azs) : 1

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = {
    Name = "${local.name_prefix}-nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

# Route Tables
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-rt"
  }
}

resource "aws_route_table_association" "public" {
  count = length(local.azs)

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  count  = length(local.azs)
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main[var.environment == "prod" ? count.index : 0].id
  }

  tags = {
    Name = "${local.name_prefix}-private-rt-${count.index}"
  }
}

resource "aws_route_table_association" "private" {
  count = length(local.azs)

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}

# Security Groups
resource "aws_security_group" "web" {
  name        = "${local.name_prefix}-web-sg"
  description = "Security group for web servers"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTPS"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  dynamic "ingress" {
    for_each = length(var.ssh_allowed_cidrs) > 0 ? [1] : []
    content {
      description = "SSH"
      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-sg"
  }
}

compute.tf

# compute.tf
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

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

  filter {
    name   = "state"
    values = ["available"]
  }
}

resource "aws_instance" "web" {
  count = var.instance_count

  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.private[count.index % length(local.azs)].id
  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = <<-EOF
    #!/bin/bash
    # Install Docker for PHP/Go containers
    yum update -y
    yum install -y docker
    systemctl start docker
    systemctl enable docker

    # Pull and run application
    docker pull ${var.project_name}/api:latest
    docker run -d -p 80:80 ${var.project_name}/api:latest
  EOF

  root_block_device {
    volume_size = 30
    volume_type = "gp3"
    encrypted   = true
  }

  tags = {
    Name = "${local.name_prefix}-web-${count.index + 1}"
    Role = "web-server"
  }
}

outputs.tf

# outputs.tf
output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "Public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "Private subnet IDs"
  value       = aws_subnet.private[*].id
}

output "web_security_group_id" {
  description = "Web security group ID"
  value       = aws_security_group.web.id
}

output "instance_ids" {
  description = "EC2 instance IDs"
  value       = aws_instance.web[*].id
}

output "instance_private_ips" {
  description = "Private IPs of web instances"
  value       = aws_instance.web[*].private_ip
}

tfvars файлы

# dev.tfvars
environment       = "dev"
instance_type     = "t3.micro"
instance_count    = 1
ssh_allowed_cidrs = ["YOUR_IP/32"]

# prod.tfvars
environment       = "prod"
instance_type     = "t3.large"
instance_count    = 3
vpc_cidr          = "10.1.0.0/16"
ssh_allowed_cidrs = []  # No SSH in production

Выполнение

# Initialize
terraform init

# Plan for dev
terraform plan -var-file="dev.tfvars"

# Apply dev
terraform apply -var-file="dev.tfvars"

# Plan for prod
terraform plan -var-file="prod.tfvars"

# Apply prod (with care!)
terraform apply -var-file="prod.tfvars"

Встроенные функции

Terraform предоставляет множество полезных функций:

locals {
  # String functions
  upper_env   = upper(var.environment)           # "PRODUCTION"
  name_parts  = split("-", "my-app-server")      # ["my", "app", "server"]
  joined      = join(", ", var.allowed_cidrs)     # "10.0.0.0/8, 172.16.0.0/12"
  replaced    = replace("hello-world", "-", "_") # "hello_world"
  trimmed     = trimspace("  hello  ")           # "hello"

  # Numeric functions
  max_count = max(var.min_count, 3)              # Returns larger
  min_count = min(var.max_count, 10)             # Returns smaller
  ceil_val  = ceil(4.3)                          # 5

  # Collection functions
  flat_list  = flatten([["a", "b"], ["c"]])      # ["a", "b", "c"]
  unique     = distinct(["a", "b", "a"])         # ["a", "b"]
  merged     = merge({a = 1}, {b = 2})           # {a = 1, b = 2}
  has_key    = contains(keys(var.tags), "Name")  # true/false
  length_val = length(var.subnets)               # 3

  # Network functions
  subnet_1 = cidrsubnet("10.0.0.0/16", 8, 1)    # "10.0.1.0/24"
  subnet_2 = cidrsubnet("10.0.0.0/16", 8, 2)    # "10.0.2.0/24"

  # Encoding functions
  encoded = base64encode("hello")                # "aGVsbG8="
  json    = jsonencode({key = "value"})           # '{"key":"value"}'
  yaml    = yamlencode({key = "value"})           # "key: value\n"

  # Filesystem functions
  config   = file("${path.module}/config.txt")   # Read file contents
  template = templatefile("${path.module}/tpl.sh", { name = "app" })

  # Type conversion
  to_num  = tonumber("42")                       # 42
  to_str  = tostring(42)                         # "42"
  to_list = tolist(toset(["a", "b"]))           # ["a", "b"]
}

Проверь себя

5 из 8

Какой приоритет имеют способы задания переменных в Terraform (от наивысшего к наименьшему)?

Чем for_each отличается от count при создании нескольких ресурсов?

Какой файл создаётся при terraform init и должен быть закоммичен в Git?

Почему HashiCorp рекомендует избегать provisioners в Terraform?

Какой тип блока в HCL используется для описания облачных ресурсов (серверов, сетей, баз данных)?