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_filedata source’unoutput_path‘ini.gitignore‘a ekleyin ve CI/CD’de deterministik build kullanın - Lambda güncellenmiyor:
source_code_hashdoğru hesaplanmıyor olabilir.output_base64sha256kullandığı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_onkullanı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.
