Зачем нужны модули
По мере роста инфраструктуры Terraform-код становится неуправляемым. Модули решают несколько проблем:
Без модулей: С модулями:
┌───────────────────┐ ┌───────────────────┐
│ main.tf │ │ root/ │
│ 1500 строк... │ │ ├── main.tf │ ~50 строк
│ │ │ ├── variables.tf │
│ VPC + Subnets │ │ └── outputs.tf │
│ + Security Grps │ │ │
│ + EC2 instances │ │ modules/ │
│ + RDS │ │ ├── networking/ │ Reusable
│ + ElastiCache │ │ │ ├── main.tf │
│ + S3 buckets │ │ │ ├── vars.tf │
│ + IAM roles │ │ │ └── outputs.tf│
│ + CloudWatch │ │ ├── compute/ │ Reusable
│ ... │ │ ├── database/ │ Reusable
│ │ │ └── monitoring/ │ Reusable
└───────────────────┘ └───────────────────┘
Преимущества модулей:
| Преимущество | Описание |
|---|---|
| Переиспользование | Один модуль -- много окружений (dev, staging, prod) |
| Инкапсуляция | Скрытие внутренней сложности |
| Организация | Логическое разделение инфраструктуры |
| Тестируемость | Модуль можно тестировать отдельно |
| Командная работа | Разные команды владеют разными модулями |
| Версионирование | Модуль можно версионировать и обновлять контролируемо |
Структура модуля
Минимальный модуль
modules/database/
├── main.tf # Resources
├── variables.tf # Input variables
├── outputs.tf # Output values
├── versions.tf # Provider requirements (optional)
└── README.md # Documentation
Полный модуль
modules/database/
├── main.tf # Main resource definitions
├── variables.tf # Input variable declarations
├── outputs.tf # Output value declarations
├── versions.tf # Terraform and provider version constraints
├── locals.tf # Local values
├── data.tf # Data sources
├── iam.tf # IAM resources (if needed)
├── README.md # Module documentation
├── examples/ # Usage examples
│ ├── simple/
│ │ └── main.tf
│ └── complete/
│ └── main.tf
└── tests/ # Module tests (Terraform 1.6+)
└── main.tftest.hcl
Пример: модуль PostgreSQL базы данных
modules/database/variables.tf:
variable "name" {
description = "Database identifier"
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]*$", var.name))
error_message = "Name must start with a letter and contain only lowercase letters, numbers, and hyphens."
}
}
variable "environment" {
description = "Environment name"
type = string
}
variable "engine_version" {
description = "PostgreSQL engine version"
type = string
default = "18.1"
}
variable "instance_class" {
description = "RDS instance class"
type = string
default = "db.t3.medium"
}
variable "allocated_storage" {
description = "Storage size in GB"
type = number
default = 50
validation {
condition = var.allocated_storage >= 20 && var.allocated_storage <= 65536
error_message = "Storage must be between 20 and 65536 GB."
}
}
variable "max_allocated_storage" {
description = "Maximum storage for autoscaling (0 to disable)"
type = number
default = 100
}
variable "multi_az" {
description = "Enable Multi-AZ deployment"
type = bool
default = false
}
variable "backup_retention_period" {
description = "Number of days to retain backups"
type = number
default = 7
}
variable "vpc_id" {
description = "VPC ID where database will be created"
type = string
}
variable "subnet_ids" {
description = "List of subnet IDs for the DB subnet group"
type = list(string)
}
variable "allowed_security_group_ids" {
description = "Security group IDs allowed to connect to database"
type = list(string)
default = []
}
variable "database_name" {
description = "Name of the default database"
type = string
default = "app"
}
variable "master_username" {
description = "Master username"
type = string
default = "postgres"
}
variable "deletion_protection" {
description = "Enable deletion protection"
type = bool
default = true
}
variable "tags" {
description = "Additional tags"
type = map(string)
default = {}
}
modules/database/main.tf:
locals {
name_prefix = "${var.name}-${var.environment}"
default_tags = {
Module = "database"
Environment = var.environment
}
tags = merge(local.default_tags, var.tags)
}
# Random password for master user
resource "random_password" "master" {
length = 32
special = false
}
# Store password in AWS Secrets Manager
resource "aws_secretsmanager_secret" "db_password" {
name = "${local.name_prefix}-db-master-password"
tags = local.tags
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = jsonencode({
username = var.master_username
password = random_password.master.result
host = aws_db_instance.main.address
port = aws_db_instance.main.port
dbname = var.database_name
})
}
# DB Subnet Group
resource "aws_db_subnet_group" "main" {
name = "${local.name_prefix}-db-subnet"
subnet_ids = var.subnet_ids
tags = merge(local.tags, {
Name = "${local.name_prefix}-db-subnet"
})
}
# Security Group for database
resource "aws_security_group" "db" {
name = "${local.name_prefix}-db-sg"
description = "Security group for ${local.name_prefix} database"
vpc_id = var.vpc_id
ingress {
description = "PostgreSQL from allowed security groups"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = var.allowed_security_group_ids
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(local.tags, {
Name = "${local.name_prefix}-db-sg"
})
}
# Parameter Group
resource "aws_db_parameter_group" "main" {
name = "${local.name_prefix}-pg18"
family = "postgres18"
parameter {
name = "log_connections"
value = "1"
}
parameter {
name = "log_disconnections"
value = "1"
}
parameter {
name = "log_min_duration_statement"
value = "1000" # Log queries slower than 1 second
}
parameter {
name = "shared_preload_libraries"
value = "pg_stat_statements"
apply_method = "pending-reboot"
}
tags = local.tags
}
# RDS Instance
resource "aws_db_instance" "main" {
identifier = local.name_prefix
engine = "postgres"
engine_version = var.engine_version
instance_class = var.instance_class
allocated_storage = var.allocated_storage
max_allocated_storage = var.max_allocated_storage
storage_type = "gp3"
storage_encrypted = true
db_name = var.database_name
username = var.master_username
password = random_password.master.result
multi_az = var.multi_az
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
parameter_group_name = aws_db_parameter_group.main.name
backup_retention_period = var.backup_retention_period
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
deletion_protection = var.deletion_protection
skip_final_snapshot = var.environment != "prod"
final_snapshot_identifier = var.environment == "prod" ? "${local.name_prefix}-final" : null
performance_insights_enabled = true
tags = merge(local.tags, {
Name = local.name_prefix
})
lifecycle {
prevent_destroy = false
precondition {
condition = var.environment != "prod" || var.multi_az == true
error_message = "Production database must have multi_az enabled."
}
precondition {
condition = var.environment != "prod" || var.backup_retention_period >= 14
error_message = "Production database must have at least 14 days backup retention."
}
}
}
modules/database/outputs.tf:
output "instance_id" {
description = "RDS instance identifier"
value = aws_db_instance.main.id
}
output "endpoint" {
description = "Database endpoint (host:port)"
value = aws_db_instance.main.endpoint
}
output "address" {
description = "Database hostname"
value = aws_db_instance.main.address
}
output "port" {
description = "Database port"
value = aws_db_instance.main.port
}
output "database_name" {
description = "Name of the default database"
value = aws_db_instance.main.db_name
}
output "master_username" {
description = "Master username"
value = aws_db_instance.main.username
}
output "security_group_id" {
description = "ID of the database security group"
value = aws_security_group.db.id
}
output "secret_arn" {
description = "ARN of the Secrets Manager secret with credentials"
value = aws_secretsmanager_secret.db_password.arn
}
output "connection_string" {
description = "PostgreSQL connection string"
value = "postgresql://${aws_db_instance.main.username}@${aws_db_instance.main.endpoint}/${aws_db_instance.main.db_name}"
sensitive = true
}
Root Module vs Child Modules
┌─────────────────────────────────────────────────────────┐
│ Root Module │
│ (environments/prod/) │
│ │
│ module "networking" { │
│ source = "../../modules/networking" │
│ ... │
│ } │
│ ┌──────────────────────────┐ │
│ module "database" { ───► │ Child Module │ │
│ source = "../../mod.." │ modules/database/ │ │
│ vpc_id = module.net.. │ │ │
│ subnet_ids = module.. │ Has its own: │ │
│ } │ - resources │ │
│ │ - variables │ │
│ module "app" { │ - outputs │ │
│ source = "../../mod.." │ - data sources │ │
│ db_host = module.db.. │ │ │
│ } │ Does NOT have: │ │
│ │ - provider config │ │
│ │ - backend config │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Использование модуля
# environments/prod/main.tf (Root Module)
module "networking" {
source = "../../modules/networking"
vpc_cidr = "10.0.0.0/16"
environment = "prod"
project = "myapp"
}
module "database" {
source = "../../modules/database"
name = "myapp"
environment = "prod"
# Pass outputs from one module to another
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
# Module-specific configuration
instance_class = "db.r6g.large"
allocated_storage = 200
multi_az = true
backup_retention_period = 30
deletion_protection = true
allowed_security_group_ids = [module.app.security_group_id]
}
module "app" {
source = "../../modules/compute"
name = "myapp"
environment = "prod"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
instance_count = 3
instance_type = "t3.large"
db_host = module.database.address
db_name = module.database.database_name
db_secret = module.database.secret_arn
}
# Outputs from root module
output "database_endpoint" {
value = module.database.endpoint
}
output "app_load_balancer" {
value = module.app.lb_dns_name
}
Module Sources
Terraform поддерживает разные источники для модулей:
# 1. Local path (most common for project-specific modules)
module "database" {
source = "../../modules/database"
}
# 2. Terraform Registry (public modules)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["eu-central-1a", "eu-central-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
}
# 3. GitHub
module "database" {
source = "github.com/myorg/terraform-modules//database?ref=v2.1.0"
}
# 4. Git (generic)
module "database" {
source = "git::https://gitlab.com/myorg/tf-modules.git//database?ref=v2.1.0"
}
# 5. S3 bucket
module "database" {
source = "s3::https://s3-eu-central-1.amazonaws.com/my-modules/database.zip"
}
# 6. Terraform Cloud / Enterprise private registry
module "database" {
source = "app.terraform.io/myorg/database/aws"
version = "~> 2.0"
}
Версионирование модулей
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# Version constraints (same as providers)
version = "5.1.0" # Exact version
version = ">= 5.0" # Minimum
version = "~> 5.0" # >= 5.0.0, < 6.0.0
version = ">= 5.0, < 6.0" # Range
}
Module Composition Patterns
Pattern 1: Facade Module (объединяющий модуль)
# modules/app-stack/main.tf
# Combines multiple modules into a single "app stack"
module "networking" {
source = "../networking"
vpc_cidr = var.vpc_cidr
environment = var.environment
}
module "database" {
source = "../database"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
instance_class = var.db_instance_class
multi_az = var.environment == "prod"
}
module "compute" {
source = "../compute"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
db_endpoint = module.database.endpoint
db_secret = module.database.secret_arn
}
# Usage from root:
module "app" {
source = "./modules/app-stack"
environment = "prod"
vpc_cidr = "10.0.0.0/16"
db_instance_class = "db.r6g.large"
}
Pattern 2: Conditional Resources
# Module that creates resources conditionally
variable "create_read_replica" {
type = bool
default = false
}
resource "aws_db_instance" "replica" {
count = var.create_read_replica ? 1 : 0
replicate_source_db = aws_db_instance.main.identifier
instance_class = var.replica_instance_class
}
output "replica_endpoint" {
value = var.create_read_replica ? aws_db_instance.replica[0].endpoint : null
}
Terraform State
Что такое State
State -- файл terraform.tfstate, который хранит соответствие между ресурсами в коде и реальными объектами в облаке.
┌────────────────┐ ┌────────────────┐
│ Terraform │ │ terraform │
│ Config │ │ .tfstate │
│ (.tf files) │ │ │
│ │ │ { │
│ resource "aws_ │ ┌───►│ "aws_inst..": │
│ instance" "web"│ │ │ { │
│ { │ │ │ "id": "i-abc"│
│ ami = "ami-."│ │ │ "ami": "ami."│
│ type = "t3.m"│ │ │ "type": "t3."│
│ } │ │ │ } │
└───────┬────────┘ │ │ } │
│ │ └───────┬────────┘
│ terraform │ │
│ plan/apply │ │
│ │ │
▼ │ ▼
┌────────────────────────────────────────┐
│ Terraform Core Engine │
│ │
│ Compares config vs state vs real │
│ Determines: create, update, delete │
└───────────────────┬────────────────────┘
│
▼
┌──────────────────┐
│ Cloud Provider │
│ (AWS, GCP...) │
│ │
│ Real Resources │
│ i-abc123... │
└──────────────────┘
Зачем нужен State
- Маппинг -- связь
resource "aws_instance" "web"с реальнымi-abc123 - Метаданные -- зависимости между ресурсами, порядок удаления
- Кэширование -- не нужно опрашивать API для каждого ресурса
- Производительность -- план строится быстро, без API-вызовов
Чувствительные данные в State
ВАЖНО: State содержит ВСЕ атрибуты ресурсов в открытом виде, включая пароли, секреты, приватные ключи!
{
"resources": [
{
"type": "aws_db_instance",
"name": "main",
"instances": [
{
"attributes": {
"password": "my-super-secret-password",
"endpoint": "mydb.abc123.eu-central-1.rds.amazonaws.com"
}
}
]
}
]
}
Правила работы с State:
| Правило | Описание |
|---|---|
| НИКОГДА в Git | terraform.tfstate НЕ ДОЛЖЕН быть в репозитории |
| Шифрование | Хранить в зашифрованном бэкенде (S3 + encrypt) |
| Ограничение доступа | Только CI/CD и senior-инженеры |
| Бэкапы | Версионирование S3 bucket |
Remote Backend
Проблема локального State
Developer A Developer B
┌──────────────┐ ┌──────────────┐
│ terraform │ │ terraform │
│ .tfstate │ │ .tfstate │
│ │ │ │
│ (local copy) │ │ (local copy) │
└──────┬───────┘ └──────┬───────┘
│ apply │ apply
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ AWS │
│ CONFLICT! Both modify same resources │
│ State files are out of sync! │
└─────────────────────────────────────────┘
S3 + DynamoDB Backend (самый популярный)
# backend.tf
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "projects/myapp/prod/terraform.tfstate"
region = "eu-central-1"
encrypt = true # Server-side encryption
dynamodb_table = "terraform-locks" # State locking
kms_key_id = "arn:aws:kms:eu-central-1:123:key/abc" # KMS encryption
}
}
Создание backend-инфраструктуры (bootstrap):
# bootstrap/main.tf - run this ONCE with local state
provider "aws" {
region = "eu-central-1"
}
# S3 bucket for state
resource "aws_s3_bucket" "terraform_state" {
bucket = "mycompany-terraform-state"
lifecycle {
prevent_destroy = true
}
tags = {
Name = "Terraform State"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# DynamoDB table for state locking
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = {
Name = "Terraform Lock Table"
ManagedBy = "terraform"
}
}
State Locking
State Locking (DynamoDB):
Developer A DynamoDB Developer B
│ │ │
│── terraform apply ──► │ │
│ │ │
│ Lock acquired ◄──── │ │
│ │ │
│ ... applying ... │ ◄── terraform apply ──│
│ │ │
│ │── Lock denied! ──────►│
│ │ "Error: state locked │
│ │ by Developer A" │
│ │ │
│── apply complete ───► │ │
│ Lock released │ │
│ │ │
│ │ ◄── retry apply ──────│
│ │ Lock acquired │
Другие Remote Backends
# Terraform Cloud
terraform {
cloud {
organization = "myorg"
workspaces {
name = "myapp-prod"
}
}
}
# PostgreSQL backend
terraform {
backend "pg" {
conn_str = "postgres://user:pass@host/terraform_state"
}
}
# Consul backend
terraform {
backend "consul" {
address = "consul.example.com:8500"
scheme = "https"
path = "terraform/myapp"
}
}
# Google Cloud Storage
terraform {
backend "gcs" {
bucket = "myproject-terraform-state"
prefix = "terraform/state"
}
}
State Operations
terraform state list и show
# List all resources in state
terraform state list
# aws_vpc.main
# aws_subnet.private[0]
# aws_subnet.private[1]
# aws_db_instance.main
# module.compute.aws_instance.web[0]
# module.compute.aws_instance.web[1]
# Show details of specific resource
terraform state show aws_db_instance.main
# resource "aws_db_instance" "main" {
# allocated_storage = 100
# engine = "postgres"
# engine_version = "18.1"
# id = "myapp-prod"
# instance_class = "db.r6g.large"
# ...
# }
terraform import
Импортирует существующий ресурс в Terraform State. Полезно при миграции с ручного управления на IaC.
# Syntax: terraform import <resource_address> <cloud_id>
# Import existing EC2 instance
terraform import aws_instance.web i-1234567890abcdef0
# Import existing RDS instance
terraform import aws_db_instance.main myapp-production
# Import into module
terraform import module.database.aws_db_instance.main mydb-prod
# Import with for_each
terraform import 'aws_instance.web["api"]' i-abc123
Terraform 1.5+: import block (декларативный импорт):
# Import block - declare in code, apply to import
import {
to = aws_instance.web
id = "i-1234567890abcdef0"
}
# Generate config automatically (Terraform 1.5+)
# terraform plan -generate-config-out=generated.tf
terraform state mv
Перемещает ресурс в state без пересоздания:
# Rename resource
terraform state mv aws_instance.web aws_instance.app
# Move to module
terraform state mv aws_instance.web module.compute.aws_instance.web
# Move between modules
terraform state mv module.old.aws_instance.web module.new.aws_instance.web
# Move to different state file
terraform state mv -state-out=other.tfstate aws_instance.web aws_instance.web
terraform state rm
Удаляет ресурс из state БЕЗ удаления реального ресурса:
# Remove from state (resource continues to exist in cloud)
terraform state rm aws_instance.legacy
# Remove entire module
terraform state rm module.old_database
# Useful when:
# - Moving resource management to another Terraform config
# - Resource was imported by mistake
# - Splitting monolithic state into smaller ones
terraform state replace-provider
# Replace provider (useful during provider migration)
terraform state replace-provider hashicorp/aws registry.terraform.io/hashicorp/aws
Workspaces vs Directory Structure
Два подхода к управлению окружениями:
Workspaces
# Create and switch workspace
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# List workspaces
terraform workspace list
# default
# dev
# * staging
# prod
# Switch workspace
terraform workspace select prod
# Current workspace
terraform workspace show
# prod
# Use workspace name in configuration
locals {
environment = terraform.workspace
config = {
dev = {
instance_type = "t3.micro"
instance_count = 1
multi_az = false
}
staging = {
instance_type = "t3.small"
instance_count = 2
multi_az = false
}
prod = {
instance_type = "t3.large"
instance_count = 3
multi_az = true
}
}
env_config = local.config[local.environment]
}
resource "aws_instance" "web" {
count = local.env_config.instance_count
instance_type = local.env_config.instance_type
}
Directory Structure (рекомендуется)
infrastructure/
├── modules/
│ ├── networking/
│ ├── database/
│ └── compute/
├── environments/
│ ├── dev/
│ │ ├── main.tf # Module calls
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── backend.tf # Separate state per env
│ │ └── terraform.tfvars
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── backend.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── backend.tf
│ └── terraform.tfvars
└── global/
└── iam/ # Shared IAM roles
└── main.tf
Сравнение подходов
| Критерий | Workspaces | Directory Structure |
|---|---|---|
| Разделение state | Автоматическое | Явное (разные backend keys) |
| Сложность | Простая | Средняя |
| Blast radius | Один конфиг -- одна ошибка на все env | Отдельный конфиг на каждый env |
| Различия между env | Через переменные и условия | Разные значения tfvars |
| Code review | Одно изменение для всех env | Отдельные PR для каждого env |
| Рекомендация | Для простых проектов | Для production |
Защита чувствительных данных в State
Пароли и секреты
# ❌ BAD: password in plain text
variable "db_password" {
type = string
default = "my-super-secret" # NEVER do this!
}
# ✅ GOOD: generate random password
resource "random_password" "db" {
length = 32
special = false
}
# ✅ GOOD: store in Secrets Manager
resource "aws_secretsmanager_secret" "db_password" {
name = "myapp/db-password"
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = random_password.db.result
}
# ✅ GOOD: reference in RDS
resource "aws_db_instance" "main" {
password = random_password.db.result
# Password will still be in state, but not in code!
}
Минимизация чувствительных данных
# Mark outputs as sensitive
output "db_password" {
value = random_password.db.result
sensitive = true # Hidden in plan/apply output
}
# Mark variables as sensitive
variable "api_key" {
type = string
sensitive = true # Hidden in plan/apply output
}
Рекомендации по безопасности State
┌──────────────────────────────────────────────────┐
│ State Security Checklist │
├──────────────────────────────────────────────────┤
│ │
│ [x] Remote backend (S3, Terraform Cloud) │
│ [x] Encryption at rest (S3 SSE, KMS) │
│ [x] Encryption in transit (HTTPS) │
│ [x] State locking (DynamoDB) │
│ [x] Versioning (S3 bucket versioning) │
│ [x] Access control (IAM policies) │
│ [x] No state in Git (.gitignore) │
│ [x] Sensitive outputs marked │
│ [x] Passwords generated, not hardcoded │
│ [x] Secrets stored in Secrets Manager / Vault │
│ │
└──────────────────────────────────────────────────┘
.gitignore для Terraform-проекта:
# Terraform
*.tfstate
*.tfstate.*
*.tfvars # May contain secrets
.terraform/
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Keep lock file
!.terraform.lock.hcl