Terraform ile Serverless Altyapı Yönetimi

Serverless altyapı yönetimi, başlangıçta “sadece birkaç Lambda fonksiyonu” gibi görünse de zamanla onlarca fonksiyon, API Gateway endpointleri, IAM rolleri, DynamoDB tabloları ve CloudWatch alarm yığınına dönüşebiliyor. Bu noktada “kimin ne deploy ettiğini bilmiyoruz” sorunuyla karşılaşmak kaçınılmaz oluyor. Terraform burada devreye giriyor ve tüm bu karmaşayı kod olarak yönetmenizi sağlıyor.

Neden Terraform, Neden Serverless?

AWS Console üzerinden Lambda fonksiyonu oluşturmak beş dakika sürer. Ama aynı fonksiyonu staging ve production ortamlarında tutarlı biçimde yönetmek, versiyon kontrolü altında tutmak ve ekip arkadaşlarınızla koordineli çalışmak başka bir hikaye. Serverless Framework veya AWS SAM gibi araçlar var elbette, ancak Terraform’un avantajı tek bir araçla hem serverless hem de klasik altyapınızı (VPC, RDS, ECS) yönetebilmeniz.

Terraform’un serverless dünyasındaki temel gücü şu noktalarda ortaya çıkıyor:

  • State yönetimi: Hangi kaynakların mevcut olduğunu takip eder, tutarsızlıkları yakalar
  • Çoklu provider desteği: AWS Lambda yanında Azure Functions ve Google Cloud Functions’ı aynı codebase’den yönetebilirsiniz
  • Modüler yapı: Tekrar kullanılabilir Lambda modülleri yazarak ekip genelinde standartları oturtabilirsiniz
  • Plan/Apply döngüsü: Deploy öncesinde ne değişeceğini görmek, serverless ortamlarda kritik önem taşır

Temel Yapı ve Provider Konfigürasyonu

Önce sağlam bir temel atalım. Gerçek dünya projelerinde genellikle şu dizin yapısını kullanıyorum:

serverless-infra/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       ├── variables.tf
│       └── terraform.tfvars
├── modules/
│   ├── lambda/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── api-gateway/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── shared/
    └── backend.tf

Provider ve backend konfigürasyonu şöyle görünmeli:

# shared/backend.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.0"
    }
  }

  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "serverless/terraform.tfstate"
    region         = "eu-west-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

provider "aws" {
  region = var.aws_region

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

default_tags bloğunu mutlaka ekleyin. Onlarca Lambda fonksiyonunuzu tek tek tag’lemek yerine tüm kaynaklar otomatik olarak etiketlenir. Maliyet takibi ve kaynak organizasyonu için hayat kurtarıcı.

Lambda Fonksiyonu Oluşturma

Temel bir Lambda fonksiyonu oluşturmak için önce deployment paketini hazırlamamız gerekiyor. Terraform’un archive_file data source’u burada çok işe yarıyor:

# modules/lambda/main.tf

# Kaynak kodu zip'le
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/../../src/${var.function_name}"
  output_path = "${path.module}/../../builds/${var.function_name}.zip"
}

# IAM rolü oluştur
resource "aws_iam_role" "lambda_role" {
  name = "${var.project}-${var.environment}-${var.function_name}-role"

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

# Temel CloudWatch loglama izni
resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Lambda fonksiyonu
resource "aws_lambda_function" "this" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "${var.project}-${var.environment}-${var.function_name}"
  role             = aws_iam_role.lambda_role.arn
  handler          = var.handler
  runtime          = var.runtime
  timeout          = var.timeout
  memory_size      = var.memory_size
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256

  environment {
    variables = var.environment_variables
  }

  dynamic "vpc_config" {
    for_each = var.vpc_config != null ? [var.vpc_config] : []
    content {
      subnet_ids         = vpc_config.value.subnet_ids
      security_group_ids = vpc_config.value.security_group_ids
    }
  }

  # Cold start optimizasyonu için
  reserved_concurrent_executions = var.reserved_concurrency
}

# CloudWatch Log Group - retention ile
resource "aws_cloudwatch_log_group" "lambda_logs" {
  name              = "/aws/lambda/${aws_lambda_function.this.function_name}"
  retention_in_days = var.log_retention_days
}

source_code_hash parametresi kritik. Zip dosyasının hash’ini tuttuğu için sadece kod değiştiğinde fonksiyon güncelleniyor, her terraform apply çalıştırıldığında gereksiz deploy yapmıyor.

API Gateway Entegrasyonu

Gerçek dünyada Lambda fonksiyonlarının büyük çoğunluğu bir API Gateway arkasında çalışır. HTTP API (v2) oluşturalım, REST API’ye göre hem daha ucuz hem daha hızlı:

# modules/api-gateway/main.tf

resource "aws_apigatewayv2_api" "this" {
  name          = "${var.project}-${var.environment}-api"
  protocol_type = "HTTP"

  cors_configuration {
    allow_headers = ["content-type", "x-amz-date", "authorization"]
    allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
    allow_origins = var.allowed_origins
    max_age       = 300
  }
}

resource "aws_apigatewayv2_stage" "default" {
  api_id      = aws_apigatewayv2_api.this.id
  name        = "$default"
  auto_deploy = true

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api_logs.arn
    format = jsonencode({
      requestId      = "$context.requestId"
      ip             = "$context.identity.sourceIp"
      requestTime    = "$context.requestTime"
      httpMethod     = "$context.httpMethod"
      routeKey       = "$context.routeKey"
      status         = "$context.status"
      responseLength = "$context.responseLength"
      integrationError = "$context.integrationErrorMessage"
    })
  }
}

resource "aws_apigatewayv2_integration" "lambda" {
  for_each = var.lambda_integrations

  api_id                 = aws_apigatewayv2_api.this.id
  integration_type       = "AWS_PROXY"
  integration_uri        = each.value.lambda_invoke_arn
  payload_format_version = "2.0"
  timeout_milliseconds   = 29000
}

resource "aws_apigatewayv2_route" "routes" {
  for_each = var.lambda_integrations

  api_id    = aws_apigatewayv2_api.this.id
  route_key = each.value.route_key
  target    = "integrations/${aws_apigatewayv2_integration.lambda[each.key].id}"
}

# Lambda invoke izni
resource "aws_lambda_permission" "api_gw" {
  for_each = var.lambda_integrations

  statement_id  = "AllowAPIGatewayInvoke-${each.key}"
  action        = "lambda:InvokeFunction"
  function_name = each.value.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.this.execution_arn}/*/*"
}

resource "aws_cloudwatch_log_group" "api_logs" {
  name              = "/aws/apigateway/${var.project}-${var.environment}"
  retention_in_days = 30
}

for_each kullanımına dikkat edin. Tek bir blokla birden fazla route ve integration oluşturabiliyoruz. Yeni endpoint eklemek istediğinizde sadece lambda_integrations map’ine yeni bir giriş eklemeniz yeterli.

DynamoDB ile Durum Yönetimi

Serverless uygulamaların büyük çoğunluğu DynamoDB kullanır. Terraform ile yönetirken on-demand kapasite modunu tercih edin, tahmin edilmesi güç trafik için çok daha ekonomik:

# Kullanıcı tablosu örneği
resource "aws_dynamodb_table" "users" {
  name         = "${var.project}-${var.environment}-users"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "userId"
  range_key    = "createdAt"

  attribute {
    name = "userId"
    type = "S"
  }

  attribute {
    name = "createdAt"
    type = "S"
  }

  attribute {
    name = "email"
    type = "S"
  }

  # Email ile sorgu için GSI
  global_secondary_index {
    name            = "email-index"
    hash_key        = "email"
    projection_type = "ALL"
  }

  # Point-in-time recovery - production'da zorunlu
  point_in_time_recovery {
    enabled = var.environment == "prod" ? true : false
  }

  # Encryption at rest
  server_side_encryption {
    enabled = true
  }

  # TTL için
  ttl {
    attribute_name = "expiresAt"
    enabled        = true
  }

  lifecycle {
    prevent_destroy = true
  }
}

# Lambda'ya DynamoDB erişimi için IAM policy
resource "aws_iam_policy" "dynamodb_access" {
  name = "${var.project}-${var.environment}-dynamodb-policy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:UpdateItem",
          "dynamodb:DeleteItem",
          "dynamodb:Query",
          "dynamodb:Scan"
        ]
        Resource = [
          aws_dynamodb_table.users.arn,
          "${aws_dynamodb_table.users.arn}/index/*"
        ]
      }
    ]
  })
}

lifecycle { prevent_destroy = true } bloğunu production DynamoDB tablolarına mutlaka ekleyin. Yanlışlıkla terraform destroy çalıştırdığınızda sizi kurtarır.

Ortam Yönetimi ve Workspace Kullanımı

Birden fazla ortamı yönetmek için Terraform workspace’leri veya ayrı dizinler kullanabilirsiniz. Ben ayrı dizin yaklaşımını tercih ediyorum, daha az sürprizle karşılaşıyorum:

# environments/dev/terraform.tfvars
project_name = "myapp"
environment  = "dev"
aws_region   = "eu-west-1"

lambda_functions = {
  "user-api" = {
    handler     = "index.handler"
    runtime     = "nodejs20.x"
    timeout     = 30
    memory_size = 256
    environment_variables = {
      LOG_LEVEL = "debug"
      DB_TABLE  = "myapp-dev-users"
    }
  }
  "notification-sender" = {
    handler     = "app.lambda_handler"
    runtime     = "python3.11"
    timeout     = 60
    memory_size = 512
    environment_variables = {
      LOG_LEVEL  = "info"
      SNS_TOPIC  = "arn:aws:sns:eu-west-1:123456789:dev-notifications"
    }
  }
}

# Production için farklı değerler
# environments/prod/terraform.tfvars
lambda_functions = {
  "user-api" = {
    handler              = "index.handler"
    runtime              = "nodejs20.x"
    timeout              = 30
    memory_size          = 1024  # Prod'da daha fazla bellek
    reserved_concurrency = 100   # Concurrency limiti
    environment_variables = {
      LOG_LEVEL = "warn"
      DB_TABLE  = "myapp-prod-users"
    }
  }
}

Dev ve prod arasındaki farkları tfvars dosyalarında tutmak, kod tabanını temiz ve yönetilebilir kılıyor. Memory size ve reserved concurrency gibi performans parametrelerini ortama göre ayarlamak hem maliyet hem de güvenilirlik açısından önemli.

EventBridge ile Event-Driven Mimari

Modern serverless mimariler event-driven yapıya dayanır. Terraform ile EventBridge kuralları oluşturmak oldukça temiz:

# Zamanlanmış görev - her gece temizlik işlemi
resource "aws_cloudwatch_event_rule" "nightly_cleanup" {
  name                = "${var.project}-${var.environment}-nightly-cleanup"
  description         = "Her gece 02:00'de eski kayitlari temizler"
  schedule_expression = "cron(0 2 * * ? *)"
  state               = var.environment == "prod" ? "ENABLED" : "DISABLED"
}

resource "aws_cloudwatch_event_target" "cleanup_lambda" {
  rule      = aws_cloudwatch_event_rule.nightly_cleanup.name
  target_id = "CleanupLambdaTarget"
  arn       = aws_lambda_function.cleanup.arn

  input = jsonencode({
    action      = "cleanup"
    days_to_keep = 90
    dry_run     = false
  })
}

resource "aws_lambda_permission" "allow_eventbridge" {
  statement_id  = "AllowEventBridgeInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.cleanup.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.nightly_cleanup.arn
}

# S3 event trigger
resource "aws_s3_bucket_notification" "upload_trigger" {
  bucket = aws_s3_bucket.uploads.id

  lambda_function {
    lambda_function_arn = aws_lambda_function.image_processor.arn
    events              = ["s3:ObjectCreated:*"]
    filter_prefix       = "uploads/"
    filter_suffix       = ".jpg"
  }

  depends_on = [aws_lambda_permission.s3_invoke]
}

state = var.environment == "prod" ? "ENABLED" : "DISABLED" gibi koşullu ifadeler, dev ortamında zamanlanmış görevlerin tetiklenmesini engellemek için pratik bir yöntem.

Monitoring ve Alerting

Deploy etmek yetmez, izlemek de gerekiyor. CloudWatch alarmları Terraform ile şöyle kurulur:

# Lambda hata oranı alarmı
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
  for_each = aws_lambda_function.functions

  alarm_name          = "${each.key}-error-rate"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "2"
  metric_name         = "Errors"
  namespace           = "AWS/Lambda"
  period              = "300"
  statistic           = "Sum"
  threshold           = "5"
  alarm_description   = "${each.key} fonksiyonu 5 dakikada 5'ten fazla hata aldi"
  treat_missing_data  = "notBreaching"

  dimensions = {
    FunctionName = each.value.function_name
  }

  alarm_actions = [aws_sns_topic.alerts.arn]
  ok_actions    = [aws_sns_topic.alerts.arn]
}

# Duration alarmı - timeout'a yaklaşma uyarısı
resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
  for_each = aws_lambda_function.functions

  alarm_name          = "${each.key}-duration-warning"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = "3"
  metric_name         = "Duration"
  namespace           = "AWS/Lambda"
  period              = "300"
  statistic           = "p95"
  # Timeout'un %80'ine ulaşınca uyar
  threshold           = each.value.timeout * 1000 * 0.8
  alarm_description   = "${each.key} fonksiyonu timeout limitine yaklasiyor"

  dimensions = {
    FunctionName = each.value.function_name
  }

  alarm_actions = [aws_sns_topic.alerts.arn]
}

# SNS topic ve email subscription
resource "aws_sns_topic" "alerts" {
  name = "${var.project}-${var.environment}-alerts"
}

resource "aws_sns_topic_subscription" "email" {
  for_each  = toset(var.alert_emails)
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "email"
  endpoint  = each.value
}

Duration alarmı için timeout değerinin %80’ini threshold olarak kullanmak güzel bir pratik. Fonksiyon timeout’a girmeden önce uyarı alırsınız ve müdahale şansınız olur.

Gerçek Dünya Senaryosu: E-ticaret Sipariş İşleme

Tüm bu parçaları bir araya getiren somut bir senaryo ele alalım. Bir e-ticaret platformunda sipariş işleme pipeline’ı:

  • order-receiver: API Gateway’den sipariş alan Lambda, SQS’e yazar
  • order-processor: SQS’i tetikleyici olarak kullanan Lambda, iş mantığını işler
  • notification-sender: DynamoDB Streams’i dinleyen Lambda, müşteriye bildirim gönderir

Bu üç fonksiyon arasındaki bağlantıyı Terraform ile kurmak şöyle görünür:

# SQS kuyruğu
resource "aws_sqs_queue" "orders" {
  name                       = "${var.project}-${var.environment}-orders"
  visibility_timeout_seconds = 300  # Lambda timeout ile eşleşmeli
  message_retention_seconds  = 86400
  receive_wait_time_seconds  = 20   # Long polling

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.orders_dlq.arn
    maxReceiveCount     = 3
  })
}

resource "aws_sqs_queue" "orders_dlq" {
  name                      = "${var.project}-${var.environment}-orders-dlq"
  message_retention_seconds = 1209600  # 14 gun
}

# SQS -> Lambda event source mapping
resource "aws_lambda_event_source_mapping" "sqs_trigger" {
  event_source_arn                   = aws_sqs_queue.orders.arn
  function_name                      = aws_lambda_function.order_processor.arn
  batch_size                         = 10
  maximum_batching_window_in_seconds = 5

  # Hata durumunda kısmi batch başarısını destekle
  function_response_types = ["ReportBatchItemFailures"]

  scaling_config {
    maximum_concurrency = 50
  }
}

# DynamoDB Streams trigger
resource "aws_lambda_event_source_mapping" "dynamodb_trigger" {
  event_source_arn  = aws_dynamodb_table.orders.stream_arn
  function_name     = aws_lambda_function.notification_sender.arn
  starting_position = "LATEST"
  batch_size        = 100
  filter_criteria {
    filter {
      pattern = jsonencode({
        eventName = ["INSERT", "MODIFY"]
        dynamodb = {
          NewImage = {
            status = { S = ["CONFIRMED", "SHIPPED", "DELIVERED"] }
          }
        }
      })
    }
  }
}

filter_criteria bloğu özellikle güçlü. DynamoDB’deki her değişiklik Lambda’yı tetiklemek yerine, sadece status alanı belirli değerlere güncellendiğinde tetikleniyor. Bu hem maliyet hem de gereksiz işlem yükü açısından kritik bir optimizasyon.

State Yönetimi ve CI/CD Entegrasyonu

Ekip olarak çalışıyorsanız state dosyası çakışmaları büyük sorun. Remote state ve locking zorunlu:

# State locking için DynamoDB tablosu (bir kere oluşturulur)
resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

GitHub Actions pipeline’ınızda şu adımları takip edin:

  • Pull Request açıldığında: terraform plan çalıştır, sonucu PR’a yorum olarak ekle
  • Main branch’e merge edildiğinde: terraform apply -auto-approve çalıştır
  • Plan ve apply arasında: State lock aktif kalır, başka kimse değişiklik yapamaz

TF_VAR_ prefix’li environment variable’lar hassas bilgileri (database şifresi, API key) Terraform’a güvenli şekilde geçirmenizi sağlar. Bu değerleri asla .tfvars dosyasına yazmayın, GitHub Secrets veya AWS Secrets Manager üzerinden yönetin.

Yaygın Hatalar ve Çözümleri

Terraform ile serverless yönetirken sık karşılaşılan problemler:

  • Zip dosyası her seferinde değişiyor: archive_file data source’un output_path‘ini .gitignore‘a ekleyin ve CI/CD’de deterministik build kullanın
  • Lambda güncellenmiyor: source_code_hash doğru hesaplanmıyor olabilir. output_base64sha256 kullandığınızdan emin olun
  • IAM izin sorunları: En az yetki prensibini uygulayın ama geliştirme sırasında CloudTrail loglarını izleyerek hangi izinlerin eksik olduğunu tespit edin
  • Circular dependency: Lambda A’nın ARN’ına Lambda B’nin IAM policy’sinde ihtiyaç duyuyorsanız depends_on kullanın ama mümkünse mimariyi yeniden değerlendirin
  • Timeout during apply: Büyük Lambda deployment paketleri için S3’ten deploy yöntemine geçin, doğrudan zip yükleme yerine

Sonuç

Terraform ile serverless altyapı yönetmek başlangıçta fazladan iş gibi görünebilir. “Birkaç Lambda için neden bu kadar kod yazayım?” sorusu akla gelebilir. Ama iki ay sonra on beş fonksiyonunuz olduğunda, üç farklı ortamı yönetmeniz gerektiğinde ve ekibinize yeni biri katıldığında, tüm bu yapının değerini anlıyorsunuz.

Benim önerim şu: Projeye başlarken modüler yapıyı kurun, default_tags‘i unutmayın, DynamoDB tablolarına prevent_destroy ekleyin ve monitoring’i altyapıyla birlikte deploy edin. Fonksiyonu yazdıktan iki hafta sonra alarm kurmayı hatırlamazsınız.

Küçük bir uyarı: Terraform state’i hassas bilgi içerebilir. S3 bucket’ınızda encryption ve versioning açık olsun, bucket policy’si sıkı tutun. State dosyası ele geçirilirse altyapınızın haritası ortaya çıkar.

Serverless ve Terraform kombinasyonu doğru kurulduğunda gerçekten güçlü. Altyapınız kod reviewden geçer, her değişiklik git history’de durur ve yeni ortam oluşturmak tek bir komuta indirgenir. Başlamak için bekleyecek bir şey yok.

Bir yanıt yazın

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