Архитектура проекта
Мы создадим полную production-ready инфраструктуру для типичного веб-приложения: PHP API (Symfony) + Go API Gateway + Nuxt Frontend. Всё в Docker-контейнерах на ECS Fargate.
ASCII-диаграмма архитектуры
┌──────────────┐
│ Route 53 │
│ DNS Zone │
│ example.com │
└──────┬───────┘
│
┌──────▼───────┐
│ ACM Cert │
│ *.example │
│ .com │
└──────┬───────┘
│
┌─────────────▼─────────────┐
│ Application Load │
│ Balancer (ALB) │
│ :443 (HTTPS) │
└─────┬──────────┬───────────┘
│ │
┌─────────────────┼──────────┼─────────────────┐
│ VPC: 10.0.0.0/16 │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ PUBLIC SUBNETS │ │
│ │ 10.0.1.0/24 (AZ-a) 10.0.2.0/24 (AZ-b) │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │ NAT GW │ │ NAT GW │ │ │
│ │ └──────────┘ └──────────┘ │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ PRIVATE SUBNETS (App) │ │
│ │ 10.0.10.0/24 (AZ-a) 10.0.11.0/24 (AZ-b)│ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ ECS Fargate │ │ ECS Fargate │ │ │
│ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ │
│ │ │ │ PHP API │ │ │ │ PHP API │ │ │ │
│ │ │ │ (Symfony) │ │ │ │ (Symfony) │ │ │ │
│ │ │ └──────────┘ │ │ └──────────┘ │ │ │
│ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ │
│ │ │ │ Go GW │ │ │ │ Go GW │ │ │ │
│ │ │ └──────────┘ │ │ └──────────┘ │ │ │
│ │ └────────────────┘ └────────────────┘ │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ PRIVATE SUBNETS (Data) │ │
│ │ 10.0.20.0/24 (AZ-a) 10.0.21.0/24 (AZ-b)│ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ │ │
│ │ │ RDS PostgreSQL │ │ ElastiCache │ │ │
│ │ │ (Multi-AZ) │ │ Redis Cluster │ │ │
│ │ │ Primary + RR │ │ │ │ │
│ │ └────────────────┘ └────────────────┘ │ │
│ └──────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────┘
│
┌──────────┼──────────┐
│ │ │
┌──────▼──┐ ┌─────▼────┐ ┌──▼──────────┐
│ S3 │ │CloudWatch│ │ ECR │
│ Bucket │ │ Logs + │ │ Docker │
│ Assets │ │ Alarms │ │ Registry │
└─────────┘ └──────────┘ └─────────────┘
Структура Terraform-проекта
infrastructure/
├── modules/
│ ├── networking/ # VPC, subnets, NAT, security groups
│ ├── ecs/ # ECS cluster, services, task definitions
│ ├── database/ # RDS PostgreSQL
│ ├── cache/ # ElastiCache Redis
│ ├── storage/ # S3 buckets
│ ├── dns/ # Route53, ACM
│ └── monitoring/ # CloudWatch, SNS
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── backend.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── backend.tf
│ └── terraform.tfvars
└── global/
└── ecr/ # ECR repositories (shared)
└── main.tf
1. Provider и Backend
# environments/prod/main.tf
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.80"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
}
# Provider for ACM certificate (must be in us-east-1 for CloudFront)
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "myapp-terraform-state"
key = "prod/terraform.tfstate"
region = "eu-central-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
# environments/prod/variables.tf
variable "project" {
type = string
default = "myapp"
}
variable "environment" {
type = string
default = "prod"
}
variable "aws_region" {
type = string
default = "eu-central-1"
}
variable "domain_name" {
type = string
default = "example.com"
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "php_image" {
description = "PHP API Docker image URI"
type = string
}
variable "go_image" {
description = "Go Gateway Docker image URI"
type = string
}
variable "php_cpu" {
type = number
default = 512
}
variable "php_memory" {
type = number
default = 1024
}
variable "go_cpu" {
type = number
default = 256
}
variable "go_memory" {
type = number
default = 512
}
variable "php_desired_count" {
type = number
default = 2
}
variable "go_desired_count" {
type = number
default = 2
}
variable "db_instance_class" {
type = string
default = "db.r6g.large"
}
variable "db_allocated_storage" {
type = number
default = 100
}
variable "redis_node_type" {
type = string
default = "cache.r6g.large"
}
variable "alert_email" {
type = string
default = "[email protected]"
}
2. Networking (VPC)
# modules/networking/main.tf
locals {
name_prefix = "${var.project}-${var.environment}"
azs = [
"${var.aws_region}a",
"${var.aws_region}b",
]
# Subnet CIDR allocation
public_subnets = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 8, i + 1)]
app_subnets = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 8, i + 10)]
data_subnets = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 8, i + 20)]
}
# ========== 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 = local.public_subnets[count.index]
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${local.name_prefix}-public-${local.azs[count.index]}"
Tier = "public"
}
}
# ========== App Subnets (private) ==========
resource "aws_subnet" "app" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = local.app_subnets[count.index]
availability_zone = local.azs[count.index]
tags = {
Name = "${local.name_prefix}-app-${local.azs[count.index]}"
Tier = "app"
}
}
# ========== Data Subnets (private) ==========
resource "aws_subnet" "data" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = local.data_subnets[count.index]
availability_zone = local.azs[count.index]
tags = {
Name = "${local.name_prefix}-data-${local.azs[count.index]}"
Tier = "data"
}
}
# ========== NAT Gateways ==========
resource "aws_eip" "nat" {
count = length(local.azs)
domain = "vpc"
tags = { Name = "${local.name_prefix}-nat-eip-${count.index}" }
}
resource "aws_nat_gateway" "main" {
count = length(local.azs)
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "${local.name_prefix}-nat-${local.azs[count.index]}" }
depends_on = [aws_internet_gateway.main]
}
# ========== Route Tables ==========
# Public route table
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
}
# Private route tables (one per AZ for HA NAT)
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[count.index].id
}
tags = { Name = "${local.name_prefix}-private-rt-${local.azs[count.index]}" }
}
resource "aws_route_table_association" "app" {
count = length(local.azs)
subnet_id = aws_subnet.app[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
resource "aws_route_table_association" "data" {
count = length(local.azs)
subnet_id = aws_subnet.data[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# ========== Security Groups ==========
# ALB Security Group
resource "aws_security_group" "alb" {
name = "${local.name_prefix}-alb-sg"
description = "ALB security group"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP (redirect to HTTPS)"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.name_prefix}-alb-sg" }
}
# ECS Tasks Security Group
resource "aws_security_group" "ecs" {
name = "${local.name_prefix}-ecs-sg"
description = "ECS tasks security group"
vpc_id = aws_vpc.main.id
ingress {
description = "From ALB"
from_port = 0
to_port = 65535
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${local.name_prefix}-ecs-sg" }
}
# Database Security Group
resource "aws_security_group" "database" {
name = "${local.name_prefix}-db-sg"
description = "Database security group"
vpc_id = aws_vpc.main.id
ingress {
description = "PostgreSQL from ECS"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.ecs.id]
}
tags = { Name = "${local.name_prefix}-db-sg" }
}
# Redis Security Group
resource "aws_security_group" "redis" {
name = "${local.name_prefix}-redis-sg"
description = "Redis security group"
vpc_id = aws_vpc.main.id
ingress {
description = "Redis from ECS"
from_port = 6379
to_port = 6379
protocol = "tcp"
security_groups = [aws_security_group.ecs.id]
}
tags = { Name = "${local.name_prefix}-redis-sg" }
}
# modules/networking/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "app_subnet_ids" {
value = aws_subnet.app[*].id
}
output "data_subnet_ids" {
value = aws_subnet.data[*].id
}
output "alb_security_group_id" {
value = aws_security_group.alb.id
}
output "ecs_security_group_id" {
value = aws_security_group.ecs.id
}
output "database_security_group_id" {
value = aws_security_group.database.id
}
output "redis_security_group_id" {
value = aws_security_group.redis.id
}
3. Application Load Balancer
# modules/ecs/alb.tf
resource "aws_lb" "main" {
name = "${var.name_prefix}-alb"
internal = false
load_balancer_type = "application"
security_groups = [var.alb_security_group_id]
subnets = var.public_subnet_ids
enable_deletion_protection = var.environment == "prod"
access_logs {
bucket = var.logs_bucket_id
prefix = "alb-logs"
enabled = true
}
tags = { Name = "${var.name_prefix}-alb" }
}
# HTTPS Listener
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.go_gateway.arn
}
}
# HTTP → HTTPS redirect
resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
# Listener rule: /api/* → PHP API
resource "aws_lb_listener_rule" "php_api" {
listener_arn = aws_lb_listener.https.arn
priority = 100
action {
type = "forward"
target_group_arn = aws_lb_target_group.php_api.arn
}
condition {
path_pattern {
values = ["/api/*"]
}
}
}
# Target Group: Go Gateway
resource "aws_lb_target_group" "go_gateway" {
name = "${var.name_prefix}-go-tg"
port = 8080
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip"
health_check {
enabled = true
healthy_threshold = 3
unhealthy_threshold = 3
timeout = 5
interval = 30
path = "/health"
matcher = "200"
}
deregistration_delay = 30
tags = { Name = "${var.name_prefix}-go-tg" }
}
# Target Group: PHP API
resource "aws_lb_target_group" "php_api" {
name = "${var.name_prefix}-php-tg"
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip"
health_check {
enabled = true
healthy_threshold = 3
unhealthy_threshold = 3
timeout = 10
interval = 30
path = "/api/health"
matcher = "200"
}
deregistration_delay = 60
tags = { Name = "${var.name_prefix}-php-tg" }
}
4. ECS Fargate (Compute)
# modules/ecs/cluster.tf
resource "aws_ecs_cluster" "main" {
name = "${var.name_prefix}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
configuration {
execute_command_configuration {
logging = "OVERRIDE"
log_configuration {
cloud_watch_log_group_name = aws_cloudwatch_log_group.ecs.name
}
}
}
tags = { Name = "${var.name_prefix}-cluster" }
}
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/${var.name_prefix}"
retention_in_days = var.environment == "prod" ? 90 : 14
tags = { Name = "${var.name_prefix}-ecs-logs" }
}
# ========== IAM Roles ==========
# Task Execution Role (used by ECS agent)
data "aws_iam_policy_document" "ecs_task_execution_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ecs_task_execution" {
name = "${var.name_prefix}-ecs-execution"
assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_assume.json
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# Allow pulling secrets
resource "aws_iam_role_policy" "ecs_task_execution_secrets" {
name = "${var.name_prefix}-secrets-access"
role = aws_iam_role.ecs_task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"ssm:GetParameters",
]
Resource = [
var.db_secret_arn,
"arn:aws:ssm:${var.aws_region}:*:parameter/${var.project}/${var.environment}/*",
]
}
]
})
}
# Task Role (used by the application)
resource "aws_iam_role" "ecs_task" {
name = "${var.name_prefix}-ecs-task"
assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_assume.json
}
resource "aws_iam_role_policy" "ecs_task_app" {
name = "${var.name_prefix}-app-permissions"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
]
Resource = "${var.assets_bucket_arn}/*"
},
{
Effect = "Allow"
Action = [
"s3:ListBucket",
]
Resource = var.assets_bucket_arn
}
]
})
}
PHP API Task Definition и Service
# modules/ecs/php_service.tf
resource "aws_ecs_task_definition" "php_api" {
family = "${var.name_prefix}-php-api"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.php_cpu
memory = var.php_memory
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([
{
name = "php-api"
image = var.php_image
portMappings = [
{
containerPort = 80
protocol = "tcp"
}
]
environment = [
{ name = "APP_ENV", value = var.environment == "prod" ? "prod" : "dev" },
{ name = "APP_DEBUG", value = var.environment == "prod" ? "0" : "1" },
{ name = "REDIS_HOST", value = var.redis_endpoint },
{ name = "REDIS_PORT", value = "6379" },
{ name = "S3_BUCKET", value = var.assets_bucket_name },
{ name = "AWS_REGION", value = var.aws_region },
]
secrets = [
{
name = "DATABASE_URL"
valueFrom = "${var.db_secret_arn}:connection_string::"
},
{
name = "APP_SECRET"
valueFrom = "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${var.project}/${var.environment}/app-secret"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.ecs.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "php-api"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost/api/health || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
}
])
tags = { Name = "${var.name_prefix}-php-api" }
}
data "aws_caller_identity" "current" {}
resource "aws_ecs_service" "php_api" {
name = "${var.name_prefix}-php-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.php_api.arn
desired_count = var.php_desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.app_subnet_ids
security_groups = [var.ecs_security_group_id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.php_api.arn
container_name = "php-api"
container_port = 80
}
deployment_configuration {
maximum_percent = 200
minimum_healthy_percent = 100
deployment_circuit_breaker {
enable = true
rollback = true # Auto-rollback on deployment failure
}
}
lifecycle {
ignore_changes = [desired_count] # Managed by auto-scaling
}
tags = { Name = "${var.name_prefix}-php-api" }
}
# Auto-scaling for PHP API
resource "aws_appautoscaling_target" "php_api" {
max_capacity = var.php_max_count
min_capacity = var.php_desired_count
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.php_api.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "php_api_cpu" {
name = "${var.name_prefix}-php-cpu-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.php_api.resource_id
scalable_dimension = aws_appautoscaling_target.php_api.scalable_dimension
service_namespace = aws_appautoscaling_target.php_api.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 70.0
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
resource "aws_appautoscaling_policy" "php_api_memory" {
name = "${var.name_prefix}-php-memory-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.php_api.resource_id
scalable_dimension = aws_appautoscaling_target.php_api.scalable_dimension
service_namespace = aws_appautoscaling_target.php_api.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
}
target_value = 80.0
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
Go Gateway Task Definition и Service
# modules/ecs/go_service.tf
resource "aws_ecs_task_definition" "go_gateway" {
family = "${var.name_prefix}-go-gateway"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.go_cpu
memory = var.go_memory
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([
{
name = "go-gateway"
image = var.go_image
portMappings = [
{
containerPort = 8080
protocol = "tcp"
}
]
environment = [
{ name = "ENV", value = var.environment },
{ name = "PHP_API_URL", value = "http://${var.name_prefix}-php-api.${var.name_prefix}-cluster:80" },
{ name = "REDIS_ADDR", value = "${var.redis_endpoint}:6379" },
{ name = "PORT", value = "8080" },
{ name = "LOG_LEVEL", value = var.environment == "prod" ? "info" : "debug" },
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.ecs.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "go-gateway"
}
}
healthCheck = {
command = ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
interval = 15
timeout = 5
retries = 3
startPeriod = 10
}
}
])
tags = { Name = "${var.name_prefix}-go-gateway" }
}
resource "aws_ecs_service" "go_gateway" {
name = "${var.name_prefix}-go-gateway"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.go_gateway.arn
desired_count = var.go_desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.app_subnet_ids
security_groups = [var.ecs_security_group_id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.go_gateway.arn
container_name = "go-gateway"
container_port = 8080
}
deployment_configuration {
maximum_percent = 200
minimum_healthy_percent = 100
deployment_circuit_breaker {
enable = true
rollback = true
}
}
lifecycle {
ignore_changes = [desired_count]
}
tags = { Name = "${var.name_prefix}-go-gateway" }
}
5. Docker Images и ECR
# global/ecr/main.tf
resource "aws_ecr_repository" "php_api" {
name = "myapp/php-api"
image_tag_mutability = "IMMUTABLE" # Tags can't be overwritten
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "AES256"
}
tags = {
Project = "myapp"
ManagedBy = "terraform"
}
}
resource "aws_ecr_repository" "go_gateway" {
name = "myapp/go-gateway"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Project = "myapp"
ManagedBy = "terraform"
}
}
# Lifecycle policy: keep last 30 images
resource "aws_ecr_lifecycle_policy" "php_api" {
repository = aws_ecr_repository.php_api.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "Keep last 30 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 30
}
action = {
type = "expire"
}
}]
})
}
Dockerfile: PHP API (Multi-stage)
# infrastructure/docker/php/Dockerfile
FROM php:8.4-fpm-alpine AS base
# Install system dependencies
RUN apk add --no-cache \
postgresql-dev \
icu-dev \
libzip-dev \
&& docker-php-ext-install \
pdo_pgsql \
intl \
opcache \
zip
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# ========== Dependencies stage ==========
FROM base AS deps
WORKDIR /app
COPY api/composer.json api/composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# ========== Build stage ==========
FROM base AS build
WORKDIR /app
COPY --from=deps /app/vendor ./vendor
COPY api/ .
RUN composer dump-autoload --optimize --classmap-authoritative
# ========== Production stage ==========
FROM base AS production
# Security: non-root user
RUN addgroup -g 1000 app && adduser -u 1000 -G app -D app
WORKDIR /app
COPY --from=build --chown=app:app /app .
# PHP configuration for production
COPY infrastructure/docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
USER app
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s CMD curl -f http://localhost/api/health || exit 1
Dockerfile: Go Gateway (Multi-stage)
# infrastructure/docker/go/Dockerfile
FROM golang:1.25-alpine AS builder
WORKDIR /build
# Dependencies first (cache layer)
COPY gateway/go.mod gateway/go.sum ./
RUN go mod download
# Build
COPY gateway/ .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-w -s" -o /app/gateway ./cmd/gateway
# ========== Production stage ==========
FROM alpine:3.21
# Security: non-root user
RUN addgroup -g 1000 app && adduser -u 1000 -G app -D app
# Certificates for HTTPS calls
RUN apk add --no-cache ca-certificates wget
WORKDIR /app
COPY --from=builder /app/gateway .
USER app
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s CMD wget --spider -q http://localhost:8080/health || exit 1
ENTRYPOINT ["./gateway"]
6. Database (RDS PostgreSQL)
# modules/database/main.tf
resource "random_password" "master" {
length = 32
special = false
}
resource "aws_secretsmanager_secret" "db" {
name = "${var.name_prefix}/database/master"
}
resource "aws_secretsmanager_secret_version" "db" {
secret_id = aws_secretsmanager_secret.db.id
secret_string = jsonencode({
username = "app_master"
password = random_password.master.result
engine = "postgres"
host = aws_db_instance.main.address
port = aws_db_instance.main.port
dbname = "${var.project}_db"
connection_string = "postgresql://app_master:${random_password.master.result}@${aws_db_instance.main.address}:5432/${var.project}_db"
})
}
resource "aws_db_subnet_group" "main" {
name = "${var.name_prefix}-db"
subnet_ids = var.data_subnet_ids
tags = { Name = "${var.name_prefix}-db-subnet-group" }
}
resource "aws_db_parameter_group" "postgres18" {
name = "${var.name_prefix}-pg18"
family = "postgres18"
parameter {
name = "shared_preload_libraries"
value = "pg_stat_statements"
apply_method = "pending-reboot"
}
parameter {
name = "log_min_duration_statement"
value = "500"
}
parameter {
name = "log_connections"
value = "1"
}
parameter {
name = "idle_in_transaction_session_timeout"
value = "60000" # 60 seconds
}
}
resource "aws_db_instance" "main" {
identifier = "${var.name_prefix}-postgres"
engine = "postgres"
engine_version = "18.1"
instance_class = var.db_instance_class
allocated_storage = var.db_allocated_storage
max_allocated_storage = var.db_allocated_storage * 2
storage_type = "gp3"
storage_encrypted = true
db_name = "${var.project}_db"
username = "app_master"
password = random_password.master.result
multi_az = var.environment == "prod"
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [var.database_security_group_id]
parameter_group_name = aws_db_parameter_group.postgres18.name
backup_retention_period = var.environment == "prod" ? 30 : 7
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
deletion_protection = var.environment == "prod"
skip_final_snapshot = var.environment != "prod"
final_snapshot_identifier = "${var.name_prefix}-final"
performance_insights_enabled = true
performance_insights_retention_period = var.environment == "prod" ? 731 : 7 # Free tier: 7 days
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
tags = { Name = "${var.name_prefix}-postgres" }
lifecycle {
prevent_destroy = false # Set to true in real production!
}
}
# Read Replica (production only)
resource "aws_db_instance" "read_replica" {
count = var.environment == "prod" ? 1 : 0
identifier = "${var.name_prefix}-postgres-rr"
replicate_source_db = aws_db_instance.main.identifier
instance_class = var.db_instance_class
storage_encrypted = true
performance_insights_enabled = true
tags = { Name = "${var.name_prefix}-postgres-read-replica" }
}
7. Cache (ElastiCache Redis)
# modules/cache/main.tf
resource "aws_elasticache_subnet_group" "main" {
name = "${var.name_prefix}-redis"
subnet_ids = var.data_subnet_ids
}
resource "aws_elasticache_parameter_group" "main" {
name = "${var.name_prefix}-redis7"
family = "redis7"
parameter {
name = "maxmemory-policy"
value = "allkeys-lru"
}
}
resource "aws_elasticache_replication_group" "main" {
replication_group_id = "${var.name_prefix}-redis"
description = "Redis cluster for ${var.name_prefix}"
node_type = var.redis_node_type
num_cache_clusters = var.environment == "prod" ? 2 : 1 # Primary + replica
port = 6379
engine = "redis"
engine_version = "7.1"
parameter_group_name = aws_elasticache_parameter_group.main.name
subnet_group_name = aws_elasticache_subnet_group.main.name
security_group_ids = [var.redis_security_group_id]
at_rest_encryption_enabled = true
transit_encryption_enabled = false # Enable if clients support TLS
automatic_failover_enabled = var.environment == "prod"
snapshot_retention_limit = var.environment == "prod" ? 7 : 0
snapshot_window = "04:00-05:00"
maintenance_window = "Mon:05:00-Mon:06:00"
tags = { Name = "${var.name_prefix}-redis" }
}
8. Storage (S3)
# modules/storage/main.tf
resource "aws_s3_bucket" "assets" {
bucket = "${var.name_prefix}-assets"
tags = { Name = "${var.name_prefix}-assets" }
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_lifecycle_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
id = "transition-to-ia"
status = "Enabled"
transition {
days = 90
storage_class = "STANDARD_IA"
}
}
}
# ALB Logs bucket
resource "aws_s3_bucket" "logs" {
bucket = "${var.name_prefix}-logs"
tags = { Name = "${var.name_prefix}-logs" }
}
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
id = "expire-old-logs"
status = "Enabled"
expiration {
days = var.environment == "prod" ? 365 : 30
}
}
}
9. DNS и SSL (Route 53 + ACM)
# modules/dns/main.tf
data "aws_route53_zone" "main" {
name = var.domain_name
}
# ACM Certificate
resource "aws_acm_certificate" "main" {
domain_name = var.domain_name
subject_alternative_names = ["*.${var.domain_name}"]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = { Name = "${var.name_prefix}-cert" }
}
# DNS validation records
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
record = dvo.resource_record_value
}
}
zone_id = data.aws_route53_zone.main.zone_id
name = each.value.name
type = each.value.type
records = [each.value.record]
ttl = 60
allow_overwrite = true
}
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate.main.arn
validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}
# A record pointing to ALB
resource "aws_route53_record" "app" {
zone_id = data.aws_route53_zone.main.zone_id
name = var.environment == "prod" ? var.domain_name : "${var.environment}.${var.domain_name}"
type = "A"
alias {
name = var.alb_dns_name
zone_id = var.alb_zone_id
evaluate_target_health = true
}
}
# API subdomain
resource "aws_route53_record" "api" {
zone_id = data.aws_route53_zone.main.zone_id
name = var.environment == "prod" ? "api.${var.domain_name}" : "api.${var.environment}.${var.domain_name}"
type = "A"
alias {
name = var.alb_dns_name
zone_id = var.alb_zone_id
evaluate_target_health = true
}
}
10. Monitoring (CloudWatch + SNS)
# modules/monitoring/main.tf
# SNS Topic for alerts
resource "aws_sns_topic" "alerts" {
name = "${var.name_prefix}-alerts"
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = var.alert_email
}
# ========== ECS Alarms ==========
resource "aws_cloudwatch_metric_alarm" "ecs_cpu_high" {
alarm_name = "${var.name_prefix}-ecs-cpu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "CPUUtilization"
namespace = "AWS/ECS"
period = 300
statistic = "Average"
threshold = 85
alarm_description = "ECS CPU utilization above 85%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
ClusterName = var.ecs_cluster_name
ServiceName = var.php_service_name
}
}
# ========== RDS Alarms ==========
resource "aws_cloudwatch_metric_alarm" "rds_cpu_high" {
alarm_name = "${var.name_prefix}-rds-cpu-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "CPUUtilization"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "RDS CPU utilization above 80%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
DBInstanceIdentifier = var.rds_instance_id
}
}
resource "aws_cloudwatch_metric_alarm" "rds_free_storage" {
alarm_name = "${var.name_prefix}-rds-storage-low"
comparison_operator = "LessThanThreshold"
evaluation_periods = 1
metric_name = "FreeStorageSpace"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 5368709120 # 5 GB in bytes
alarm_description = "RDS free storage below 5GB"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
DBInstanceIdentifier = var.rds_instance_id
}
}
resource "aws_cloudwatch_metric_alarm" "rds_connections_high" {
alarm_name = "${var.name_prefix}-rds-connections-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "DatabaseConnections"
namespace = "AWS/RDS"
period = 300
statistic = "Average"
threshold = 100
alarm_description = "RDS connections above 100"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
DBInstanceIdentifier = var.rds_instance_id
}
}
# ========== Redis Alarms ==========
resource "aws_cloudwatch_metric_alarm" "redis_memory_high" {
alarm_name = "${var.name_prefix}-redis-memory-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "DatabaseMemoryUsagePercentage"
namespace = "AWS/ElastiCache"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "Redis memory usage above 80%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
CacheClusterId = var.redis_cluster_id
}
}
# ========== ALB Alarms ==========
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
alarm_name = "${var.name_prefix}-alb-5xx-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "HTTPCode_Target_5XX_Count"
namespace = "AWS/ApplicationELB"
period = 300
statistic = "Sum"
threshold = 50
alarm_description = "ALB 5xx errors above 50 in 5 minutes"
alarm_actions = [aws_sns_topic.alerts.arn]
treat_missing_data = "notBreaching"
dimensions = {
LoadBalancer = var.alb_arn_suffix
}
}
resource "aws_cloudwatch_metric_alarm" "alb_response_time" {
alarm_name = "${var.name_prefix}-alb-latency-high"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 3
metric_name = "TargetResponseTime"
namespace = "AWS/ApplicationELB"
period = 300
statistic = "p95"
threshold = 2.0 # 2 seconds p95
alarm_description = "ALB p95 latency above 2 seconds"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = {
LoadBalancer = var.alb_arn_suffix
}
}
# CloudWatch Dashboard
resource "aws_cloudwatch_dashboard" "main" {
dashboard_name = "${var.name_prefix}-dashboard"
dashboard_body = jsonencode({
widgets = [
{
type = "metric"
x = 0
y = 0
width = 12
height = 6
properties = {
title = "ECS CPU & Memory"
metrics = [
["AWS/ECS", "CPUUtilization", "ClusterName", var.ecs_cluster_name],
["AWS/ECS", "MemoryUtilization", "ClusterName", var.ecs_cluster_name],
]
period = 300
stat = "Average"
region = var.aws_region
}
},
{
type = "metric"
x = 12
y = 0
width = 12
height = 6
properties = {
title = "RDS Performance"
metrics = [
["AWS/RDS", "CPUUtilization", "DBInstanceIdentifier", var.rds_instance_id],
["AWS/RDS", "DatabaseConnections", "DBInstanceIdentifier", var.rds_instance_id],
]
period = 300
stat = "Average"
region = var.aws_region
}
},
{
type = "metric"
x = 0
y = 6
width = 24
height = 6
properties = {
title = "ALB Requests & Errors"
metrics = [
["AWS/ApplicationELB", "RequestCount", "LoadBalancer", var.alb_arn_suffix],
["AWS/ApplicationELB", "HTTPCode_Target_5XX_Count", "LoadBalancer", var.alb_arn_suffix],
["AWS/ApplicationELB", "HTTPCode_Target_4XX_Count", "LoadBalancer", var.alb_arn_suffix],
]
period = 60
stat = "Sum"
region = var.aws_region
}
}
]
})
}
11. Root Module -- собираем всё вместе
# environments/prod/main.tf
locals {
name_prefix = "${var.project}-${var.environment}"
}
module "networking" {
source = "../../modules/networking"
project = var.project
environment = var.environment
aws_region = var.aws_region
vpc_cidr = var.vpc_cidr
}
module "storage" {
source = "../../modules/storage"
name_prefix = local.name_prefix
environment = var.environment
}
module "database" {
source = "../../modules/database"
name_prefix = local.name_prefix
project = var.project
environment = var.environment
data_subnet_ids = module.networking.data_subnet_ids
database_security_group_id = module.networking.database_security_group_id
db_instance_class = var.db_instance_class
db_allocated_storage = var.db_allocated_storage
}
module "cache" {
source = "../../modules/cache"
name_prefix = local.name_prefix
environment = var.environment
data_subnet_ids = module.networking.data_subnet_ids
redis_security_group_id = module.networking.redis_security_group_id
redis_node_type = var.redis_node_type
}
module "dns" {
source = "../../modules/dns"
name_prefix = local.name_prefix
environment = var.environment
domain_name = var.domain_name
alb_dns_name = module.ecs.alb_dns_name
alb_zone_id = module.ecs.alb_zone_id
}
module "ecs" {
source = "../../modules/ecs"
name_prefix = local.name_prefix
project = var.project
environment = var.environment
aws_region = var.aws_region
vpc_id = module.networking.vpc_id
public_subnet_ids = module.networking.public_subnet_ids
app_subnet_ids = module.networking.app_subnet_ids
alb_security_group_id = module.networking.alb_security_group_id
ecs_security_group_id = module.networking.ecs_security_group_id
certificate_arn = module.dns.certificate_arn
logs_bucket_id = module.storage.logs_bucket_id
php_image = var.php_image
php_cpu = var.php_cpu
php_memory = var.php_memory
php_desired_count = var.php_desired_count
php_max_count = var.php_desired_count * 3
go_image = var.go_image
go_cpu = var.go_cpu
go_memory = var.go_memory
go_desired_count = var.go_desired_count
redis_endpoint = module.cache.primary_endpoint
db_secret_arn = module.database.secret_arn
assets_bucket_arn = module.storage.assets_bucket_arn
assets_bucket_name = module.storage.assets_bucket_name
}
module "monitoring" {
source = "../../modules/monitoring"
name_prefix = local.name_prefix
environment = var.environment
aws_region = var.aws_region
alert_email = var.alert_email
ecs_cluster_name = module.ecs.cluster_name
php_service_name = module.ecs.php_service_name
rds_instance_id = module.database.instance_id
redis_cluster_id = module.cache.cluster_id
alb_arn_suffix = module.ecs.alb_arn_suffix
}
Cost Breakdown
Сравнение стоимости для dev и prod окружений:
┌───────────────────────┬──────────────┬──────────────────┐
│ Component │ DEV │ PRODUCTION │
├───────────────────────┼──────────────┼──────────────────┤
│ ECS Fargate │ │ │
│ PHP (0.5vCPU/1GB)x1 │ ~$25/mo │ │
│ PHP (0.5vCPU/1GB)x2 │ │ ~$50/mo │
│ Go (0.25vCPU/0.5GB)x1│ ~$13/mo │ │
│ Go (0.25vCPU/0.5GB)x2│ │ ~$26/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ ALB │ ~$22/mo │ ~$22/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ RDS PostgreSQL │ │ │
│ db.t3.micro │ ~$15/mo │ │
│ db.r6g.large Multi-AZ│ │ ~$380/mo │
│ + Read Replica │ │ ~$190/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ ElastiCache Redis │ │ │
│ cache.t3.micro │ ~$13/mo │ │
│ cache.r6g.large (x2) │ │ ~$260/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ NAT Gateway (x1 / x2) │ ~$45/mo │ ~$90/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ S3 (10GB / 100GB) │ ~$1/mo │ ~$3/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ CloudWatch │ ~$5/mo │ ~$15/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ Route53 │ ~$1/mo │ ~$1/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ ECR (shared) │ ~$1/mo │ ~$1/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ Secrets Manager │ ~$1/mo │ ~$1/mo │
├───────────────────────┼──────────────┼──────────────────┤
│ │ │ │
│ TOTAL (estimated) │ ~$140/mo │ ~$1040/mo │
│ │ │ │
└───────────────────────┴──────────────┴──────────────────┘
Notes:
- Prices are approximate for eu-central-1 (Frankfurt)
- NAT Gateway is often the biggest surprise in AWS bills
- Data transfer costs not included (can add 10-20%)
- Reserved Instances can save 30-60% on RDS and ElastiCache
Советы по оптимизации стоимости
| Компонент | Оптимизация | Экономия |
|---|---|---|
| NAT Gateway | Один NAT GW для dev вместо двух | ~$45/mo |
| RDS | Reserved Instances (1 year) | ~30% |
| ElastiCache | Reserved Nodes (1 year) | ~30% |
| ECS | Fargate Spot для не-критичных задач | ~70% |
| S3 | Lifecycle policies (IA → Glacier) | ~50% на хранении |
| Dev окружение | Выключать ночью и в выходные | ~65% |