AWS VPC Subnet Tasarımı ve Yönetimi: Kapsamlı Rehber

Bir uygulamayı AWS’e taşırken en çok ihmal edilen ama en kritik adımlardan biri ağ tasarımıdır. “Şimdi hızlıca bir VPC kuralım, sonra düzeltiriz” diyerek yapılan tasarımlar, ilerleyen süreçte onlarca saatlik yeniden yapılandırma çalışmasına dönüşür. Bu yazıda AWS VPC subnet tasarımını sıfırdan ele alacağız, gerçek dünya senaryoları üzerinden ilerleyeceğiz ve Terraform ile AWS CLI komutlarını bolca kullanacağız.

VPC ve Subnet Kavramlarına Hızlı Bakış

VPC (Virtual Private Cloud), AWS içinde tamamen sana ait izole bir ağ ortamıdır. Kendi IP adres aralığını, routing kurallarını ve güvenlik politikalarını belirlersin. Subnet ise bu VPC’nin daha küçük parçalara bölünmüş halidir. Her subnet belirli bir Availability Zone (AZ) ile ilişkilidir ve bu ilişki değiştirilemez.

Temel ayrım şudur: Public subnet internet erişimi olan, private subnet internet erişimi olmayan alt ağlardır. Ama iş bununla bitmiyor. Production ortamlarında genellikle üç katmanlı bir yapı görürsün:

  • Public subnet: Load balancer, bastion host, NAT gateway gibi bileşenler
  • Private subnet: Uygulama sunucuları, backend servisler
  • Database subnet: RDS, ElastiCache, Redshift gibi veri katmanı bileşenleri

Bu üçlü yapı hem güvenliği hem de operasyonel netliği artırır.

CIDR Bloğu Planlaması

En sık yapılan hata, gelecekteki büyümeyi hesaba katmadan küçük bir CIDR bloğuyla başlamaktır. VPC’nin CIDR bloğunu sonradan daraltamazsın. Genişletme desteği geldi ama bu da kendi kısıtlamalarıyla geliyor.

Genel öneri: Production VPC’si için /16 bloğu kullan. Bu sana 65.534 kullanılabilir IP adresi verir. Subnet’leri ise ihtiyaca göre /24 veya /22 olarak böl.

Örnek bir planlama senaryosu düşünelim. 3 AZ, 3 katman, multi-environment yapısı:

10.0.0.0/16   - Production VPC
10.1.0.0/16   - Staging VPC
10.2.0.0/16   - Development VPC

Production VPC içindeki subnet dağılımı:

10.0.0.0/24    - Public Subnet AZ-a
10.0.1.0/24    - Public Subnet AZ-b
10.0.2.0/24    - Public Subnet AZ-c
10.0.10.0/24   - Private Subnet AZ-a
10.0.11.0/24   - Private Subnet AZ-b
10.0.12.0/24   - Private Subnet AZ-c
10.0.20.0/24   - Database Subnet AZ-a
10.0.21.0/24   - Database Subnet AZ-b
10.0.22.0/24   - Database Subnet AZ-c

Bu yapı hem anlaşılır hem de genişlemeye müsait.

Terraform ile VPC Oluşturma

Artık elle AWS Console’dan subnet oluşturma dönemi bitti. Her şeyi kod olarak yönetmek gerekiyor. İşte temel bir VPC yapısı:

# main.tf - VPC ve subnet tanımları
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

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

  tags = {
    Name        = "${var.project_name}-vpc"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

# Public Subnets
resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone = var.availability_zones[count.index]

  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-${var.availability_zones[count.index]}"
    Type = "public"
    "kubernetes.io/role/elb" = "1"
  }
}

# Private Subnets
resource "aws_subnet" "private" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name = "${var.project_name}-private-${var.availability_zones[count.index]}"
    Type = "private"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

# Database Subnets
resource "aws_subnet" "database" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 20)
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name = "${var.project_name}-database-${var.availability_zones[count.index]}"
    Type = "database"
  }
}

Kubernetes tag’lerini (kubernetes.io/role/elb) fark ettin mi? EKS kullanıyorsan bu tag’ler olmadan load balancer otomatik olarak doğru subnet’e konumlanamıyor. Bu küçük detay saatlerini kurtarabilir.

Internet Gateway ve NAT Gateway Yapılandırması

Public subnet’in gerçekten public olabilmesi için Internet Gateway (IGW) şart. Private subnet’teki kaynakların internete erişebilmesi için de NAT Gateway gerekiyor.

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

  tags = {
    Name = "${var.project_name}-igw"
  }
}

# Elastic IP for NAT Gateway (her AZ için ayrı)
resource "aws_eip" "nat" {
  count  = length(var.availability_zones)
  domain = "vpc"

  depends_on = [aws_internet_gateway.main]

  tags = {
    Name = "${var.project_name}-nat-eip-${count.index}"
  }
}

# NAT Gateway (her AZ'da bir tane - high availability için)
resource "aws_nat_gateway" "main" {
  count         = length(var.availability_zones)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  depends_on = [aws_internet_gateway.main]

  tags = {
    Name = "${var.project_name}-nat-${var.availability_zones[count.index]}"
  }
}

Burada önemli bir maliyet kararı var. Her AZ’da ayrı NAT Gateway çalıştırmak, AZ başarısızlığına karşı koruma sağlar ama maliyeti üçe katlar. Geliştirme ortamları için tek NAT Gateway yeterli olabilir, production’da ise her AZ’da ayrı NAT Gateway kullanmak doğru yaklaşım.

Route Table Yapılandırması

Subnet’leri oluşturduk, şimdi trafik akışını yönetmemiz gerekiyor:

# 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 = "${var.project_name}-public-rt"
  }
}

# Public subnet association
resource "aws_route_table_association" "public" {
  count          = length(aws_subnet.public)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# Private Route Tables (her AZ için ayrı - kendi NAT'ını kullansın)
resource "aws_route_table" "private" {
  count  = length(var.availability_zones)
  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 = "${var.project_name}-private-rt-${var.availability_zones[count.index]}"
  }
}

# Private subnet association
resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}

# Database subnet route table (internet erişimi yok)
resource "aws_route_table" "database" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-database-rt"
  }
}

resource "aws_route_table_association" "database" {
  count          = length(aws_subnet.database)
  subnet_id      = aws_subnet.database[count.index].id
  route_table_id = aws_route_table.database.id
}

Database subnet için hiçbir internet rotası tanımlamadık. Bu kasıtlı. Veritabanı sunucularının internete çıkmaması gerekiyor.

Security Group Stratejisi

Network ACL ve Security Group arasındaki farkı anlamak kritik. Network ACL subnet seviyesinde çalışır ve stateless’tır. Security Group ise instance seviyesinde çalışır ve stateful’dır. Pratikte Security Group’ları daha çok kullanırsın ama ikisini birlikte kullanmak defense-in-depth sağlar.

# Bastion Host Security Group
resource "aws_security_group" "bastion" {
  name_prefix = "${var.project_name}-bastion-"
  vpc_id      = aws_vpc.main.id
  description = "Security group for bastion host"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = var.allowed_ssh_cidrs
    description = "SSH from office IP"
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name = "${var.project_name}-bastion-sg"
  }
}

# Application Security Group
resource "aws_security_group" "app" {
  name_prefix = "${var.project_name}-app-"
  vpc_id      = aws_vpc.main.id
  description = "Security group for application servers"

  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
    description     = "Traffic from ALB only"
  }

  ingress {
    from_port       = 22
    to_port         = 22
    protocol        = "tcp"
    security_groups = [aws_security_group.bastion.id]
    description     = "SSH from bastion only"
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-app-sg"
  }
}

Dikkat et: CIDR bloğu yerine security group ID’si referans veriyoruz. Bu çok daha güvenli bir yaklaşım. IP tabanlı kurallar değişince güncellenmesi unutulabiliyor, ama security group referansları otomatik olarak güncelleniyor.

VPC Flow Logs Kurulumu

Güvenlik olaylarını araştırırken, compliance gereksinimlerini karşılarken veya sadece ağ trafiğini anlamaya çalışırken VPC Flow Logs vazgeçilmez. Bunu ilk günden kur:

# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "vpc_flow_logs" {
  name              = "/aws/vpc/flowlogs/${var.project_name}"
  retention_in_days = 30

  tags = {
    Name = "${var.project_name}-vpc-flow-logs"
  }
}

# IAM Role for Flow Logs
resource "aws_iam_role" "flow_logs" {
  name = "${var.project_name}-flow-logs-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "vpc-flow-logs.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy" "flow_logs" {
  name = "${var.project_name}-flow-logs-policy"
  role = aws_iam_role.flow_logs.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams"
      ]
      Resource = "*"
    }]
  })
}

# VPC Flow Log
resource "aws_flow_log" "main" {
  vpc_id          = aws_vpc.main.id
  traffic_type    = "ALL"
  iam_role_arn    = aws_iam_role.flow_logs.arn
  log_destination = aws_cloudwatch_log_group.vpc_flow_logs.arn

  tags = {
    Name = "${var.project_name}-flow-log"
  }
}

AWS CLI ile Mevcut Yapıyı İnceleme

Bazen Terraform olmayan bir ortamı devraldın ve mevcut yapıyı anlamaya çalışıyorsun. AWS CLI ile hızlıca bilgi toplayabilirsin:

# Tüm VPC'leri listele
aws ec2 describe-vpcs 
  --query 'Vpcs[*].{ID:VpcId,CIDR:CidrBlock,Name:Tags[?Key==`Name`].Value|[0]}' 
  --output table

# Belirli bir VPC'nin subnet'lerini görüntüle
VPC_ID="vpc-0123456789abcdef0"
aws ec2 describe-subnets 
  --filters "Name=vpc-id,Values=${VPC_ID}" 
  --query 'Subnets[*].{ID:SubnetId,CIDR:CidrBlock,AZ:AvailabilityZone,Public:MapPublicIpOnLaunch,Name:Tags[?Key==`Name`].Value|[0]}' 
  --output table

# Route table'ları incele
aws ec2 describe-route-tables 
  --filters "Name=vpc-id,Values=${VPC_ID}" 
  --query 'RouteTables[*].{ID:RouteTableId,Routes:Routes[*].{Dest:DestinationCidrBlock,Via:GatewayId}}' 
  --output json | jq .

# Kullanılmayan Elastic IP'leri bul (maliyet optimizasyonu)
aws ec2 describe-addresses 
  --query 'Addresses[?AssociationId==null].{AllocationId:AllocationId,PublicIP:PublicIp}' 
  --output table

Bu komutlar özellikle bir müşteri ortamını devraldığında veya audit yapman gerektiğinde çok işe yarıyor.

VPC Peering ve Transit Gateway

Birden fazla VPC’yi birbirine bağlamak gerektiğinde iki seçeneğin var. VPC Peering basit ve düşük maliyetli, ama her VPC çiftini ayrı ayrı bağlamak gerekiyor ve transitif routing desteklenmiyor. Transit Gateway ise merkezi bir hub gibi çalışıyor ve karmaşık multi-VPC mimarileri için doğru seçim.

Küçük bir senaryo: Development, Staging ve Production VPC’lerinin merkezi bir Shared Services VPC’ye erişmesi gerekiyor (monitoring, logging, bastion host için). Bunu Transit Gateway ile çözeriz:

# Transit Gateway oluştur
aws ec2 create-transit-gateway 
  --description "Merkezi TGW" 
  --options AmazonSideAsn=64512,AutoAcceptSharedAttachments=enable,DefaultRouteTableAssociation=enable,DefaultRouteTablePropagation=enable 
  --tag-specifications 'ResourceType=transit-gateway,Tags=[{Key=Name,Value=main-tgw}]'

# VPC'yi Transit Gateway'e bağla
TGW_ID="tgw-0123456789abcdef0"
SUBNET_IDS="subnet-xxx,subnet-yyy,subnet-zzz"

aws ec2 create-transit-gateway-vpc-attachment 
  --transit-gateway-id ${TGW_ID} 
  --vpc-id ${VPC_ID} 
  --subnet-ids ${SUBNET_IDS} 
  --tag-specifications 'ResourceType=transit-gateway-attachment,Tags=[{Key=Name,Value=prod-vpc-attachment}]'

Gerçek Dünya Senaryosu: E-ticaret Platformu Mimarisi

Bir e-ticaret platformu için subnet tasarımını düşünelim. Platforma şunları barındırıyor:

  • Frontend React uygulaması (CloudFront + S3, subnet gerektirmiyor)
  • API Gateway (Application Load Balancer ile)
  • Mikroservisler (ECS Fargate)
  • PostgreSQL (RDS Multi-AZ)
  • Redis cache (ElastiCache)
  • Celery worker’ları (arka plan görevleri)

Bu yapı için subnet stratejisi şöyle şekillenir:

  • Public subnet: Sadece ALB ve NAT Gateway burada. Başka hiçbir şey.
  • Private App subnet: ECS task’ları ve Celery worker’ları burada çalışır. ALB’den trafik alır, veritabanına private subnet üzerinden erişir.
  • Private Data subnet: RDS ve ElastiCache burada. Sadece App subnet’ten gelen bağlantılara izin verilir.

Bu yapıda RDS’e direkt erişim için bile önce bastion host’a SSH, oradan da veritabanına bağlanman gerekiyor. Bazıları bunu zahmetli buluyor ama bu tam da istediğimiz şey.

Yaygın Hatalar ve Çözümleri

Subnet CIDR’larının çakışması: Özellikle VPC peering veya Transit Gateway kullanırken farklı VPC’lerin CIDR’ları çakışmamalı. Bunu en baştan planla. Sonradan düzeltmek neredeyse imkansız.

Tek AZ kullanımı: “Şimdilik tek AZ yeterli” diyerek başlanan projeler, ilk AZ outage’da pişmanlığa dönüşüyor. Baştan multi-AZ kur.

NAT Gateway maliyetini görmezden gelmek: NAT Gateway hem saatlik hem de veri transfer ücreti alıyor. Yüksek trafikli sistemlerde bu önemli bir maliyet kalemi olabilir. Aynı AZ içinde kalan trafik için veri transfer ücreti yok, bu yüzden her AZ’da ayrı NAT Gateway kullanmak hem güvenli hem de uzun vadede daha ucuz olabiliyor.

Subnet’e yeterli IP bırakmamak: /28 ile subnet açıp 16 IP’nin 5’ini AWS’e verince 11 IP kalıyor. ECS Fargate’te her task bir IP tüketiyor. Ani ölçeklenmede subnet IP’si tükenir ve yeni task başlatılamaz.

Flow Logs’u sonradan açmaya çalışmak: Güvenlik olayı olduktan sonra “keşke flow logs açık olsaydı” demek istemezsin. İlk günden aç.

Subnet Taşıma ve Yeniden Yapılandırma

Var olan bir EC2 instance’ını farklı bir subnet’e taşıyamazsın. Bu kısıtlamayı atlamamın tek yolu instance’ı yeniden oluşturmak. Ancak bunu planlayarak yapabilirsin:

# Mevcut instance'ın detaylarını al
INSTANCE_ID="i-0123456789abcdef0"

aws ec2 describe-instances 
  --instance-ids ${INSTANCE_ID} 
  --query 'Reservations[0].Instances[0].{SubnetId:SubnetId,VpcId:VpcId,PrivateIP:PrivateIpAddress,AMI:ImageId,Type:InstanceType}' 
  --output json

# AMI oluştur
aws ec2 create-image 
  --instance-id ${INSTANCE_ID} 
  --name "migration-snapshot-$(date +%Y%m%d)" 
  --no-reboot 
  --description "Migration to new subnet"

Yeni subnet’te bu AMI’den yeni instance başlatırsın. Elastic IP varsa onu yeni instance’a taşırsın. DNS kaydını günceller ve işi bitirirsin.

Maliyet Optimizasyonu için Endpoint’ler

VPC Endpoint’leri sık unutulan ama ciddi tasarruf sağlayan bir özellik. Private subnet’teki bir EC2 instance S3’e eriştiğinde bu trafik normalde NAT Gateway üzerinden geçer ve veri transfer ücreti ödersin. Gateway Endpoint ile bu trafiği direkt AWS backbone üzerinden yönlendirebilirsin, ücretsiz:

# S3 için Gateway Endpoint
resource "aws_vpc_endpoint" "s3" {
  vpc_id       = aws_vpc.main.id
  service_name = "com.amazonaws.${var.aws_region}.s3"
  vpc_endpoint_type = "Gateway"

  route_table_ids = concat(
    aws_route_table.private[*].id,
    [aws_route_table.database.id]
  )

  tags = {
    Name = "${var.project_name}-s3-endpoint"
  }
}

# DynamoDB için Gateway Endpoint
resource "aws_vpc_endpoint" "dynamodb" {
  vpc_id       = aws_vpc.main.id
  service_name = "com.amazonaws.${var.aws_region}.dynamodb"
  vpc_endpoint_type = "Gateway"

  route_table_ids = aws_route_table.private[*].id

  tags = {
    Name = "${var.project_name}-dynamodb-endpoint"
  }
}

S3 ve DynamoDB için Gateway Endpoint ücretsiz. Interface Endpoint’ler (SSM, ECR, Secrets Manager vb.) ise saatlik ücretli ama NAT Gateway üzerinden geçen trafik maliyetiyle karşılaştırıldığında genellikle karlı çıkıyor.

Sonuç

AWS VPC subnet tasarımı bir kez yapılan ve sonra unutulan bir iş değil. Uygulamanın büyümesiyle birlikte ağ yapısı da evrilmesi gerekiyor. Ama bu evrimin acı vermemesi için başlangıçta doğru temeli atmak şart.

En kritik noktalara bakacak olursak: CIDR planlamasını gelecek büyümesini hesaba katarak yap, her şeyi Terraform ile kodu olarak yönet, multi-AZ yapısını baştan kur, VPC Flow Logs’u ihmal etme ve VPC Endpoint’lerle maliyetleri optimize et.

Güvenlik açısından ise en önemli prensip en az ayrıcalık ilkesi. Veritabanı sunucuları internete çıkmasın, uygulama sunucuları doğrudan erişilemesin, her şey kendi güvenlik grubuyla izole edilsin.

Bu temeller üzerinde inşa ettiğin her yapı, hem güvenli hem de yönetilebilir olacak. Sonradan “keşke baştan böyle yapsaydım” dedirtmeyen nadir AWS kararlarından biri, işte doğru subnet tasarımıdır.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir