Terraform Modülleri ile Tekrar Kullanılabilir Altyapı Tasarımı

Altyapıyı kod olarak yönetmek güzel bir fikir, ama aynı kaynak bloklarını her proje için kopyala-yapıştır yapmaya başladığınızda bu güzellik hızla kabusa dönüşebilir. Bir VPC oluşturma konfigürasyonunu beş farklı ortama kopyaladıktan sonra güvenlik grubunda bir değişiklik yapmanız gerektiğinde, beş dosyayı tek tek düzenlemek zorunda kalıyorsunuz. İşte tam bu noktada Terraform modülleri devreye giriyor ve hayatınızı kurtarıyor.

Terraform Modülü Nedir?

En basit tanımıyla bir Terraform modülü, birlikte kullanılan kaynak tanımlarının bir klasör içinde toplanmasıdır. Her Terraform konfigürasyonu zaten teknik olarak bir modüldür; kök dizinde çalıştırdığınız her şey “root module” olarak adlandırılır. Ama asıl güç, bu yapıyı tekrar kullanılabilir bileşenlere dönüştürdüğünüzde ortaya çıkıyor.

Modüller sayesinde şunları yapabilirsiniz:

  • Altyapı bileşenlerini soyutlayarak karmaşıklığı gizlemek
  • Aynı altyapıyı farklı ortamlar (dev, staging, prod) için parametrik olarak oluşturmak
  • Takım içinde standart bir altyapı kütüphanesi oluşturmak
  • Değişiklik yönetimini merkezi hale getirmek

Modül Dizin Yapısı

İyi bir modül belirli bir dizin yapısına uyar. Bu yapı hem okunabilirliği artırır hem de Terraform’un beklentileriyle uyumludur.

modules/
└── vpc/
    ├── main.tf        # Ana kaynak tanımları
    ├── variables.tf   # Giriş değişkenleri
    ├── outputs.tf     # Çıktılar
    ├── versions.tf    # Provider gereksinimleri
    └── README.md      # Modül dokümantasyonu

Projenizin genel yapısı ise şöyle görünebilir:

infrastructure/
├── modules/
│   ├── vpc/
│   ├── ec2/
│   └── rds/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   └── prod/
└── README.md

Bu yapıda modules/ altındaki her klasör bağımsız ve tekrar kullanılabilir bir bileşeni temsil ediyor. environments/ altındaki her klasör ise bu modülleri belirli parametrelerle çağırıyor.

İlk Modülünüzü Yazmak: VPC Örneği

Gerçek dünya senaryosundan gidelim. Şirketinizde her ortam için benzer bir AWS VPC yapısı kuruyorsunuz: public subnet, private subnet, internet gateway ve NAT gateway. Bu yapıyı her ortam için tekrar yazmak yerine bir modüle dönüştürelim.

modules/vpc/variables.tf:

variable "vpc_name" {
  description = "VPC'nin adı"
  type        = string
}

variable "vpc_cidr" {
  description = "VPC CIDR bloğu"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "Kullanılacak availability zone listesi"
  type        = list(string)
}

variable "private_subnet_cidrs" {
  description = "Private subnet CIDR listesi"
  type        = list(string)
}

variable "public_subnet_cidrs" {
  description = "Public subnet CIDR listesi"
  type        = list(string)
}

variable "enable_nat_gateway" {
  description = "NAT Gateway oluşturulsun mu?"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Kaynaklara eklenecek etiketler"
  type        = map(string)
  default     = {}
}

modules/vpc/main.tf:

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

  tags = merge(var.tags, {
    Name = var.vpc_name
  })
}

resource "aws_subnet" "public" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]

  map_public_ip_on_launch = true

  tags = merge(var.tags, {
    Name = "${var.vpc_name}-public-${count.index + 1}"
    Type = "public"
  })
}

resource "aws_subnet" "private" {
  count             = length(var.private_subnet_cidrs)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]

  tags = merge(var.tags, {
    Name = "${var.vpc_name}-private-${count.index + 1}"
    Type = "private"
  })
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = merge(var.tags, {
    Name = "${var.vpc_name}-igw"
  })
}

resource "aws_eip" "nat" {
  count  = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0
  domain = "vpc"

  tags = merge(var.tags, {
    Name = "${var.vpc_name}-nat-eip-${count.index + 1}"
  })
}

resource "aws_nat_gateway" "this" {
  count         = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = merge(var.tags, {
    Name = "${var.vpc_name}-nat-${count.index + 1}"
  })
}

modules/vpc/outputs.tf:

output "vpc_id" {
  description = "Oluşturulan VPC'nin ID'si"
  value       = aws_vpc.this.id
}

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

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

output "nat_gateway_ids" {
  description = "NAT Gateway ID listesi"
  value       = aws_nat_gateway.this[*].id
}

Modülü Çağırmak

Modülü yazdıktan sonra farklı ortamlarda kullanmak artık çok basit. Dev ortamı için:

# environments/dev/main.tf

module "vpc" {
  source = "../../modules/vpc"

  vpc_name             = "myapp-dev"
  vpc_cidr             = "10.1.0.0/16"
  availability_zones   = ["eu-west-1a", "eu-west-1b"]
  public_subnet_cidrs  = ["10.1.1.0/24", "10.1.2.0/24"]
  private_subnet_cidrs = ["10.1.10.0/24", "10.1.20.0/24"]
  enable_nat_gateway   = false  # Dev'de maliyet düşürme

  tags = {
    Environment = "dev"
    Project     = "myapp"
    ManagedBy   = "terraform"
  }
}

# Modül çıktısını başka bir kaynakta kullanmak
resource "aws_security_group" "app" {
  name   = "myapp-dev-app-sg"
  vpc_id = module.vpc.vpc_id

  ingress {
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = ["10.1.0.0/16"]
  }

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

Production ortamı için aynı modülü farklı parametrelerle çağırıyorsunuz:

# environments/prod/main.tf

module "vpc" {
  source = "../../modules/vpc"

  vpc_name             = "myapp-prod"
  vpc_cidr             = "10.0.0.0/16"
  availability_zones   = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24", "10.0.30.0/24"]
  enable_nat_gateway   = true  # Prod'da NAT Gateway açık

  tags = {
    Environment = "prod"
    Project     = "myapp"
    ManagedBy   = "terraform"
    CostCenter  = "engineering"
  }
}

Aynı kodu iki kez yazmadınız, ama iki tamamen farklı altyapı konfigürasyonu oluşturdunuz. Dev ortamında NAT Gateway kapalı olduğu için maliyet düşüyor, prod’da ise üç AZ’e yayılmış tam bir yüksek erişilebilirlik yapısı var.

Modül Versiyonlama

Ekip büyüdükçe modülleri bir Git repository’sinde tutmak ve versiyonlamak kritik hale gelir. Terraform, kaynak olarak Git URL’lerini destekler:

# Git tag ile versiyonlama
module "vpc" {
  source = "git::https://github.com/sirketiniz/terraform-modules.git//modules/vpc?ref=v1.2.0"

  vpc_name           = "myapp-prod"
  vpc_cidr           = "10.0.0.0/16"
  availability_zones = ["eu-west-1a", "eu-west-1b"]
  # ...
}

# Belirli bir commit hash ile sabitlemek
module "vpc" {
  source = "git::https://github.com/sirketiniz/terraform-modules.git//modules/vpc?ref=a1b2c3d"

  vpc_name = "myapp-staging"
  # ...
}

Bu yaklaşımın avantajı şu: Modülde bir güncelleme yaptığınızda tüm ortamlar otomatik olarak etkilenmiyor. Her ortam kendi versiyonunu kullanmaya devam ediyor. Staging’de yeni versiyonu test edip, onay aldıktan sonra prod’u güncelliyorsunuz.

Terraform Registry’den Hazır Modüller

Her şeyi sıfırdan yazmak zorunda değilsiniz. Terraform Registry’de topluluk tarafından yazılmış ve bakımı yapılan binlerce modül var. AWS VPC için topluluk modülünü kullanmak istediğinizde:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.2"

  name = "myapp-prod"
  cidr = "10.0.0.0/16"

  azs             = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = false

  tags = {
    Environment = "prod"
    Terraform   = "true"
  }
}

Registry modüllerini kullanırken dikkat etmeniz gereken birkaç nokta var. version parametresini her zaman belirtin, yoksa terraform init çalıştırdığınızda modül güncellenebilir ve beklenmedik değişiklikler yaşanabilir. Ayrıca production ortamlarında dışarıdan bir modüle bağımlı olmak riskli olabilir; önemli modülleri fork’layıp kendi repo’nuzda tutmayı değerlendirin.

İç İçe Modüller: Composability

Modüllerin gerçek gücü, onları birbirini çağıracak şekilde yapılandırdığınızda ortaya çıkıyor. Örneğin bir “complete application stack” modülü, VPC, ECS cluster ve RDS modüllerini bir araya getirebilir:

# modules/app-stack/main.tf

module "vpc" {
  source = "../vpc"

  vpc_name             = "${var.app_name}-${var.environment}"
  vpc_cidr             = var.vpc_cidr
  availability_zones   = var.availability_zones
  public_subnet_cidrs  = var.public_subnet_cidrs
  private_subnet_cidrs = var.private_subnet_cidrs
  enable_nat_gateway   = var.environment == "prod" ? true : false
  tags                 = local.common_tags
}

module "ecs_cluster" {
  source = "../ecs"

  cluster_name = "${var.app_name}-${var.environment}"
  vpc_id       = module.vpc.vpc_id
  subnet_ids   = module.vpc.private_subnet_ids
  tags         = local.common_tags
}

module "database" {
  source = "../rds"

  db_identifier = "${var.app_name}-${var.environment}"
  vpc_id        = module.vpc.vpc_id
  subnet_ids    = module.vpc.private_subnet_ids
  db_name       = var.db_name
  db_username   = var.db_username
  tags          = local.common_tags
}

locals {
  common_tags = {
    Application = var.app_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

Bu yaklaşımla yeni bir uygulama ortamı kurmak sadece birkaç satır oluyor:

# Yeni bir uygulama için prod ortamı

module "myapp_prod" {
  source = "../../modules/app-stack"

  app_name    = "myapp"
  environment = "prod"
  vpc_cidr    = "10.0.0.0/16"

  availability_zones   = ["eu-west-1a", "eu-west-1b"]
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24"]

  db_name     = "myappdb"
  db_username = "myapp_user"
}

Modül Tasarımında Dikkat Edilmesi Gerekenler

Tek Sorumluluk İlkesi

Her modül tek bir sorumluluğu yerine getirmeli. “VPC ve RDS ve ECS” modülü yapmak cazip gelse de, bu modülü test etmek ve bakımını yapmak çok zorlaşır. Küçük ve odaklı modüller tercih edin.

Değişkenleri Dikkatli Tasarlayın

Çok az değişken modülü esnek olmayan hale getirir, çok fazla değişken ise kullanımı karmaşıklaştırır. İyi bir kural: Sık değişen ve ortama göre farklılaşan değerleri değişken yapın, nadiren değişen konfigürasyon detaylarını modül içinde sabitleyin veya makul defaultlar belirleyin.

Validation Bloğu Kullanın

Değişken validasyonu eklemek, kullanıcıların yanlış değer girmesini önler ve hata mesajlarını anlamlı hale getirir:

variable "environment" {
  description = "Ortam adı"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment değeri 'dev', 'staging' veya 'prod' olmalıdır."
  }
}

variable "vpc_cidr" {
  description = "VPC CIDR bloğu"
  type        = string

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "Geçerli bir CIDR bloğu girilmelidir, örneğin: 10.0.0.0/16"
  }
}

Anlamlı Çıktılar Tanımlayın

Modülünüzü kullanan kişilerin ihtiyaç duyacağı tüm bilgileri output olarak sunun. Sadece ID’ler değil, ARN’lar, DNS adları ve diğer referans değerleri de çıktı olarak tanımlayın. Bu sayede modüller arasında veri akışı sorunsuz çalışır.

CI/CD Pipeline ile Modül Testi

Modüller geliştirildiğinde otomatik test süreçleri kritik. Bir GitLab CI pipeline örneği:

# .gitlab-ci.yml

stages:
  - validate
  - plan
  - apply

variables:
  TF_VERSION: "1.6.0"

validate:
  stage: validate
  image: hashicorp/terraform:${TF_VERSION}
  script:
    - cd modules/vpc
    - terraform init -backend=false
    - terraform validate
    - terraform fmt -check -recursive
  rules:
    - changes:
        - modules/vpc/**

plan_dev:
  stage: plan
  image: hashicorp/terraform:${TF_VERSION}
  script:
    - cd environments/dev
    - terraform init
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - environments/dev/tfplan
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

apply_dev:
  stage: apply
  image: hashicorp/terraform:${TF_VERSION}
  script:
    - cd environments/dev
    - terraform init
    - terraform apply -auto-approve tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Daha ileri seviye testler için terratest kütüphanesi kullanabilirsiniz. Go ile yazılan bu kütüphane modülleri gerçek bir ortamda deploy edip, sonuçları test ettikten sonra kaynakları temizliyor. Küçük ortamlarda değeri büyük, enterprise projelerde ise neredeyse zorunlu.

Modül Bakımı ve Refactoring

Bir modül yazdıktan ve birden fazla yerde kullandıktan sonra değişiklik yapmak dikkat gerektirir. Modülde bir kaynağın adını değiştirdiğinizde, tüm bu kaynaklar yeniden oluşturulabilir. Bu production’da ciddi downtime yaratabilir.

Bu tür değişiklikleri yönetmek için moved bloğunu kullanabilirsiniz:

# modules/vpc/main.tf içinde kaynak adını değiştirirken

moved {
  from = aws_vpc.main
  to   = aws_vpc.this
}

Bu blok Terraform’a kaynağın silinip yeniden oluşturulmadığını, sadece state içinde yeniden adlandırıldığını söyler. Kritik bir özellik.

Yeni değişkenler eklerken her zaman default değeri tanımlayın. Böylece mevcut kullanımlar bozulmaz ve kullanıcılar kademeli olarak geçiş yapabilir.

Sonuç

Terraform modülleri, altyapı kodunuzu gerçek anlamda “tekrar kullanılabilir” hale getiren temel araçtır. Kopyala-yapıştır döngüsünden çıkmak, merkezi güncelleme yapabilmek ve takım içinde standartları oturtmak için modüler yapı kaçınılmazdır.

İyi bir başlangıç noktası: Mevcut konfigürasyonunuza bakın ve tekrar eden bloklardan birini seçin. O bloğu bir modüle dönüştürün, değişken ve output tanımlarını ekleyin, ardından diğer ortamlarda bu modülü çağırın. İlk modülü yazıp çalıştığını gördüğünüzde, geri kalan konfigürasyonları da modüle dönüştürme isteği kendinliğinden gelecek.

Modül versiyonlamasını en baştan bir alışkanlık haline getirin. Git tag’leri kullanın, Registry’den aldığınız modüllerin versiyonlarını sabitleyin ve production ortamlarını test edilmemiş değişikliklerden koruyun. Bu basit pratiğin ilerleyen dönemde sizi kaç kez kurtaracağını şimdiden söyleyebilirim.

Son olarak, mükemmel modülü yazmaya çalışarak başlamayın. Tek bir ortam için çalışan basit bir modülle başlayın, gerçek ihtiyaçlar ortaya çıktıkça geliştirin. Over-engineering, modül dünyasında da sizi yavaşlatır.

Bir yanıt yazın

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