İçeriğe atla

2025-09-04

AWS Serverless TypeScript Projelerini Yapılandırma: Eksiksiz Rehber

AWS Lambda, API Gateway ve TypeScript ile production-ready serverless projeleri oluşturmak için en iyi uygulamalar. Gerçek dünya örnekleri, maliyet optimizasyonu ve performans ipuçları.

EC2 instance’ları üzerinde geleneksel bir Express.js API çalıştırıyordum. Sabit maliyetler, öngörülebilir ölçeklendirme, 99.9% uptime. Hayat güzeldi. Sonra en büyük müşterimiz ayda bir kez, 10 dakika içinde 50.000 webhook işlemesi gerektiren bir özellik istedi.

Aylık 10 dakikalık bir yoğunluk için EC2 instance’larını 7/24 çalışır durumda tutmak israf gibiydi. İşte o zaman AWS Lambda’ya dalış yaptım. Production Lambda fonksiyonları oluşturmak, her serverless hatasını yapmak ve AWS faturalarında çok fazla para harcamaktan öğrendiklerimi paylaşıyorum. Bu rehber, maliyet sürprizlerinden kaçınmak ve performansı optimize etmek için somut adımlar sunuyor. CDK stack örnekleri, DynamoDB single-table design ve cold start optimizasyonları dahil—hepsi gerçek production sistemlerinden uyarlanmış. Lambda concurrency limitleri ve throttling’i baştan planlamak, webhook burst’lerinde beklenmedik hataları önler. İlk günden kapsamlı monitoring kurmak, incident sırasında gerçekten işe yarayacak tek şey.

Neden Sonunda Serverless’ı Benimsedim (Yıllarca Direniş Sonrası)

Eskiden serverless’ı “ekstra adımlarla vendor lock-in” olarak nitelendiren biriyim. Kubernetes cluster’ları yönetmek ve JVM garbage collector’larını ince ayar yapmak geçmişinden geldiğim için, Lambda kontrolü bırakmak gibi geliyordu. Ancak üç olay fikrimi değiştirdi:

Beklenmedik Trafik Artışı (Haziran 2022)

Express API’miz gece 2’de Hacker News’te yer aldı. Trafik 100 istek/dk’dan 5.000 istek/dk’ya çıktı. Auto-scaling grubumuz yeni instance’lar başlatmak için 8 dakika aldı. O zamana kadar ciddi ödeme işleme hataları yaşamıştık ve Redis cache’imiz aşırı yüklenmişti.

Lambda anında ölçeklenirdi. Bu olay otomatik ölçeklendirmenin değerini vurguladı.

Webhook İşleme Zorluğu (Ağustos 2022)

Bir müşteri 10.000+ event’in patlamalar halinde gelebileceği Stripe webhook’larını işlemeye ihtiyaç duyuyordu. EC2 ile iki kötü seçeneğimiz vardı:

  1. Tepe yük için fazla provision (pahalı)
  2. Queue kullan ve webhook timeout riski al (güvenilmez)

Lambda’nın otomatik concurrency ölçeklendirmesi bunu zarif bir şekilde çözdü. Her webhook kendi fonksiyon instance’ını aldı. Queue yok, timeout yok, fazla provisioning yok.

Compute Kullanım Analizi (Ekim 2022)

Gerçek compute kullanımımızı analiz ettiğimizde, API sunucularımızın zamanın %87’sinde boşta olduğunu, ancak %100 kapasite için ödeme yaptığımızı gördük. Kullanılmayan kaynaklar için aylık maliyetler önemli ölçüde birikiyor.

Lambda’nın milisaniye başına ödeme modeli bu verimsizliği doğrudan çözdü. Idle sürelerde ödeme yapmıyorsun; sadece gerçek execution süresince faturalandırılıyorsun. Bu özellikle düzensiz trafik pattern’leri olan API’ler için önemli.

Production’da Gerçekten İşe Yarayan Stack

Birden fazla yaklaşımı denedikten sonra, işte karar verdiğimiz:

// Production CDK stack'imiz - acıyla rafine edildi
import { Stack, StackProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RestApi, LambdaIntegration, Cors } from 'aws-cdk-lib/aws-apigateway';
import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import { Runtime } from 'aws-cdk-lib/aws-lambda';

export class ProductionServerlessStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // DynamoDB tablosu - single-table design'ı zor yoldan öğrendik
    const dataTable = new Table(this, 'DataTable', {
      partitionKey: { name: 'PK', type: AttributeType.STRING },
      sortKey: { name: 'SK', type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST,  // On-demand fiyatlama spike'larda bizi kurtardı
      // Point-in-time recovery bir junior dev'in DELETE hatasından bizi kurtardı
      pointInTimeRecovery: true,
      removalPolicy: RemovalPolicy.RETAIN,  // Prod verisini asla yanlışlıkla silme
    });

    // Farklı erişim pattern'leri için GSI ekle
    dataTable.addGlobalSecondaryIndex({
      indexName: 'GSI1',
      partitionKey: { name: 'GSI1PK', type: AttributeType.STRING },
      sortKey: { name: 'GSI1SK', type: AttributeType.STRING },
    });

    // Production'a hazır ayarlarla Lambda fonksiyonu
    const apiHandler = new NodejsFunction(this, 'ApiHandler', {
      entry: 'src/handlers/api.ts',
      runtime: Runtime.NODEJS_20_X,
      // Gerçek profiling'e dayalı memory boyutlandırma, tahmin değil
      memorySize: 1024,  // JSON işleme workload'umuz için sweet spot
      timeout: Duration.seconds(28),  // API Gateway'in 29s limitinin altında
      environment: {
        TABLE_NAME: dataTable.tableName,
        NODE_ENV: 'production',
        // DynamoDB için connection reuse'u etkinleştir
        AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
        // Özel env vars
        LOG_LEVEL: 'info',
        ENABLE_X_RAY: 'true',
      },
      bundling: {
        minify: true,
        target: 'node20',
        // Bundle'dan aws-sdk'yı hariç tut - Lambda runtime sağlıyor
        externalModules: ['@aws-sdk/*'],
        // Kullanılmayan kodu tree-shake et
        treeShaking: true,
        // Prod sorunlarını debug etmek için source maps
        sourceMap: true,
      },
    });

    // Lambda'ya DynamoDB erişimi ver
    dataTable.grantReadWriteData(apiHandler);

    // API Gateway kurulumu
    const api = new RestApi(this, 'ServerlessApi', {
      restApiName: 'production-serverless-api',
      defaultCorsPreflightOptions: {
        allowOrigins: Cors.ALL_ORIGINS,
        allowMethods: Cors.ALL_METHODS,
        allowHeaders: ['Content-Type', 'Authorization'],
      },
    });

    // Ana endpoint'i tanımla
    const apiIntegration = new LambdaIntegration(apiHandler);
    api.root.addProxy({
      defaultIntegration: apiIntegration,
    });
  }
}

Gerçeği Ele Alan Lambda Handler

İşte production incident’lerden öğrenilen tüm error handling ve optimizasyonlarla birlikte production Lambda handler’ımız:

// src/handlers/api.ts
import { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';

// Connection reuse için handler dışında DynamoDB client oluştur
const dynamoClient = new DynamoDBClient({
  region: process.env.AWS_REGION,
  // Maliyetleri %15 düşüren connection pooling ayarları
  maxAttempts: 3,
  requestHandler: {
    connectionTimeout: 1000,
    socketTimeout: 1000,
  },
});

const docClient = DynamoDBDocumentClient.from(dynamoClient, {
  marshallOptions: {
    removeUndefinedValues: true,  // DynamoDB validation hatalarını önler
    convertEmptyValues: false,
  },
});

interface Item {
  id: string;
  name: string;
  description?: string;
  createdAt: string;
  updatedAt: string;
}

// Yüksek hacimli istekleri işleyen handler
export const handler: APIGatewayProxyHandler = async (event): Promise<APIGatewayProxyResult> => {
  const { httpMethod, pathParameters, body, requestContext } = event;
  const requestId = requestContext.requestId;

  // Incident sırasında gerçekten yardımcı olan yapılandırılmış loglama
  console.log('Request received', {
    requestId,
    method: httpMethod,
    path: event.path,
    pathParams: pathParameters,
    userAgent: event.headers['User-Agent'],
    sourceIp: event.requestContext.identity.sourceIp,
  });

  try {
    switch (httpMethod) {
      case 'GET':
        return await handleGet(pathParameters?.id, requestId);
      case 'POST':
        return await handlePost(body, requestId);
      case 'PUT':
        return await handlePut(pathParameters?.id, body, requestId);
      case 'DELETE':
        return await handleDelete(pathParameters?.id, requestId);
      default:
        return createResponse(405, { error: 'Method not allowed' });
    }
  } catch (error) {
    console.error('Handler error', {
      requestId,
      error: error.message,
      stack: error.stack,
      method: httpMethod,
      path: event.path,
    });

    if (error.name === 'ValidationException') {
      return createResponse(400, { error: 'Invalid request data' });
    }
    if (error.name === 'ConditionalCheckFailedException') {
      return createResponse(409, { error: 'Resource conflict' });
    }
    if (error.name === 'ResourceNotFoundException') {
      return createResponse(404, { error: 'Resource not found' });
    }

    return createResponse(500, {
      error: 'Internal server error',
      requestId,
    });
  }
};

async function handleGet(id: string | undefined, requestId: string): Promise<APIGatewayProxyResult> {
  if (!id) {
    const result = await docClient.send(new QueryCommand({
      TableName: process.env.TABLE_NAME!,
      KeyConditionExpression: 'PK = :pk',
      ExpressionAttributeValues: { ':pk': 'ITEM' },
      Limit: 50,
    }));
    const items = result.Items?.map(item => ({
      id: item.SK.replace('ITEM#', ''),
      name: item.name,
      description: item.description,
      createdAt: item.createdAt,
      updatedAt: item.updatedAt,
    })) || [];
    return createResponse(200, { items, count: items.length, requestId });
  }

  const result = await docClient.send(new GetCommand({
    TableName: process.env.TABLE_NAME!,
    Key: { PK: 'ITEM', SK: `ITEM#${id}` },
  }));

  if (!result.Item) {
    return createResponse(404, { error: 'Item not found', requestId });
  }

  return createResponse(200, {
    item: {
      id: result.Item.SK.replace('ITEM#', ''),
      name: result.Item.name,
      description: result.Item.description,
      createdAt: result.Item.createdAt,
      updatedAt: result.Item.updatedAt,
    },
    requestId,
  });
}

async function handlePost(body: string | null, requestId: string): Promise<APIGatewayProxyResult> {
  if (!body) return createResponse(400, { error: 'Request body is required', requestId });

  let data: Partial<Item>;
  try {
    data = JSON.parse(body);
  } catch {
    return createResponse(400, { error: 'Invalid JSON', requestId });
  }

  if (!data.name || typeof data.name !== 'string' || data.name.trim().length === 0) {
    return createResponse(400, { error: 'Name is required and must be a non-empty string', requestId });
  }

  const id = generateId();
  const now = new Date().toISOString();
  const item: Item = {
    id,
    name: data.name.trim(),
    description: data.description?.trim() || undefined,
    createdAt: now,
    updatedAt: now,
  };

  await docClient.send(new PutCommand({
    TableName: process.env.TABLE_NAME!,
    Item: {
      PK: 'ITEM',
      SK: `ITEM#${id}`,
      ...item,
      GSI1PK: 'ITEMS_BY_NAME',
      GSI1SK: item.name.toLowerCase(),
    },
    ConditionExpression: 'attribute_not_exists(PK)',
  }));

  return createResponse(201, { item, requestId });
}

async function handlePut(id: string | undefined, body: string | null, requestId: string): Promise<APIGatewayProxyResult> {
  if (!id || !body) return createResponse(400, { error: 'ID and body required', requestId });
  // Update logic - benzer PutCommand ile mevcut item güncelleme
  return createResponse(200, { requestId });
}

async function handleDelete(id: string | undefined, requestId: string): Promise<APIGatewayProxyResult> {
  if (!id) return createResponse(400, { error: 'ID required', requestId });
  // DeleteCommand ile silme
  return createResponse(200, { requestId });
}

function createResponse(statusCode: number, body: any): APIGatewayProxyResult {
  return {
    statusCode,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': 'Content-Type,Authorization',
      'X-Request-ID': body.requestId || 'unknown',
    },
    body: JSON.stringify(body),
  };
}

function generateId(): string {
  return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
}

Gerçek Sorunlarda Uyarı Veren Monitoring Kurulumu

Çok fazla gereksiz alarmdan sonra production monitoring kurulumumuz:

// Ağlamayan CloudWatch alarmları - error rate, duration p95, throttle
const errorAlarm = new Alarm(this, 'HighErrorRate', {
  metric: lambdaFunction.metricErrors({ statistic: 'Sum', period: Duration.minutes(5) }),
  threshold: 0.05,  // %5 hata oranı
  evaluationPeriods: 2,
  treatMissingData: TreatMissingData.NOT_BREACHING,  // Gece invoke yoksa alarm yok
});
const throttleAlarm = new Alarm(this, 'ThrottledRequests', {
  metric: lambdaFunction.metricThrottles({ statistic: 'Sum', period: Duration.minutes(1) }),
  threshold: 1,  // Tek throttle bile alarm
  evaluationPeriods: 1,
});

TreatMissingData.NOT_BREACHING: Lambda hiç invoke edilmezse (ör. gece) alarm tetiklenmez – false positive yok. Çok fazla alarm gereksiz notification yorgunluğuna yol açar; sadece aksiyon gerektiren metrikleri izleyin.

Maliyet Optimizasyon Dersleri

1. Memory vs Duration Trade-off

// Bu pattern bizi ayda 200$ kurtardı
const optimizedHandler = new NodejsFunction(this, 'OptimizedHandler', {
  // Daha fazla memory = daha hızlı execution = daha az maliyet
  memorySize: 1024, // 512'den 1024'e çıkarmak 30% hızlandırdı
  timeout: Duration.seconds(15), // 30'dan 15'e indirebildik
});

2. Connection Reuse

// Yanlış - her çağrıda yeni connection
export const badHandler = async (event: APIGatewayProxyEvent) => {
  const dynamoClient = new DynamoDBClient({}); // Bu pahalı!
  // ...işlemler
};

// Doğru - connection'ı tekrar kullan
const dynamoClient = new DynamoDBClient({}); // Handler dışında
export const goodHandler = async (event: APIGatewayProxyEvent) => {
  // Mevcut connection'ı kullan
};

3. Bundle Size Optimizasyonu

// package.json'da sadece ihtiyacın olanları içe aktar
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; // Good: Spesifik
import AWS from 'aws-sdk'; // Bad: Tüm SDK'yı içe aktarır

Production’da Öğrenilen Dersler

1. CloudWatch Logs Maliyeti

CloudWatch Logs faturamız ayda 400a ulaştı. Çözüm:

// Log level'ları akıllıca kullan
const logger = {
  error: (message: string, meta?: any) => {
    console.error(JSON.stringify({ level: 'error', message, meta, timestamp: new Date().toISOString() }));
  },
  warn: (message: string, meta?: any) => {
    console.warn(JSON.stringify({ level: 'warn', message, meta, timestamp: new Date().toISOString() }));
  },
  info: (message: string, meta?: any) => {
    if (process.env.LOG_LEVEL !== 'error') {
      console.log(JSON.stringify({ level: 'info', message, meta, timestamp: new Date().toISOString() }));
    }
  },
};

2. Cold Start Optimizasyonu

// Provisioned concurrency sadece kritik endpoint'ler için
const criticalHandler = new NodejsFunction(this, 'CriticalHandler', {
  // Sadece payment processing için provisioned concurrency
  reservedConcurrencyLimit: 10,
});

// Diğerleri için on-demand yeterli
const regularHandler = new NodejsFunction(this, 'RegularHandler', {
  // Provisioned concurrency yok = maliyet tasarrufu
});

3. DynamoDB Maliyet Optimizasyonu

// Write-heavy workload'lar için on-demand
const writeHeavyTable = new Table(this, 'WriteHeavyTable', {
  billingMode: BillingMode.PAY_PER_REQUEST, // Spike'larda maliyet etkili
});

// Predictable workload'lar için provisioned
const predictableTable = new Table(this, 'PredictableTable', {
  billingMode: BillingMode.PROVISIONED,
  readCapacity: 5,
  writeCapacity: 5,
});

Hatalardan Öğrenenler

1. DynamoDB Scan Hatası

// Bu kod önemli maliyetlere neden oldu
const getAllUsers = async () => {
  const result = await dynamoClient.send(new ScanCommand({
    TableName: process.env.TABLE_NAME,
  }));
  return result.Items; // 2M kayıt scan'ledi!
};

// Düzeltme: Query kullan
const getUsersByStatus = async (status: string) => {
  const result = await dynamoClient.send(new QueryCommand({
    TableName: process.env.TABLE_NAME,
    IndexName: 'GSI1',
    KeyConditionExpression: 'GSI1PK = :pk',
    ExpressionAttributeValues: {
      ':pk': `STATUS#${status}`,
    },
  }));
  return result.Items;
};

2. Memory Leak in Lambda

// Yanlış - global değişkenlerde veri biriktirme
let cache: any = {}; // Bu Lambda instance'larında memory leak'e neden olur

export const handler = async (event: APIGatewayProxyEvent) => {
  cache[event.requestContext.requestId] = event; // Memory leak!
  // ...
};

// Doğru - her request için temiz state
export const handler = async (event: APIGatewayProxyEvent) => {
  const requestCache = new Map(); // Local scope
  // ...
};

3. 15 Dakikalık Timeout Keşfi

Lambda max 15 dakika. Uzun Step Functions veya batch job’lar için chunk’lama veya Fargate. Bizim job 22 dakika sürüyordu; 5’er dakikalık chunk’lara böldük.

Production Verisinden Performans Çıkarımları

18 aylık production süreci boyunca detaylı monitoring ile elde ettiğimiz veriler:

Cold Start Analizi

  • Ortalama cold start: 850ms
  • P95 cold start: 1,200ms
  • Bundle size etkisi: 10MB bundle = +400ms cold start
  • Memory etkisi: 1024MB vs 512MB = -200ms cold start

Maliyet Dağılımı (Aylık)

  • Lambda execution: $89/ay (8M invocation)
  • API Gateway: $28/ay (8M request)
  • DynamoDB: $67/ay (pay-per-request)
  • CloudWatch logs: $12/ay
  • Toplam: 196/ay(EC2es\cdeg˘eri196/ay (EC2 eşdeğeri 800/ay ile karşılaştırıldığında)

Güvenilirlik Metrikleri

  • Uptime: %99.97 (EC2’de %99.9’a karşı)
  • Error rate: %0.02 (çoğunlukla client hataları)
  • P95 response time: 180ms

Serverless Ne Zaman Kullanılmamalı

Serverless her zaman cevap değil. Uzun süreli process’ler, websocket ağırlıklı uygulamalar ve cold start hassas senaryolar için container’lar daha uygun. Serverless’e geçmeden önce workload karakteristiğini iyi anla.

Container’da kaldığım durumlar:

  1. Uzun süren süreçler – Video encoding, büyük batch joblar
  2. Websocket ağırlıklı uygulamalar – Gerçek zamanlı oyun, chat
  3. Legacy uygulamalar – Karmaşık deployment gereksinimleri
  4. Stateful workload’lar – In-memory cache, session’lar
  5. Cold start hassas – Sub-100ms yanıt gereksinimleri

Kırılmayan Deployment Pipeline’ı

CodePipeline ile zero-downtime deployment. Synth: npm ci, build, test, cdk synth. Test ve Prod stage’leri. Integration testleri post-step. Prod için ManualApproval, smoke testleri.

Sonuç

TypeScript ile AWS Lambda, ekibimizin özellik geliştirme sürecini dönüştürdü. Haftalık deployment’lardan günlük deployment’lara geçtik. AWS maliyetlerimiz önemli ölçüde düştü. Uptime’ımız %99.97’ye yükseldi.

En büyük kazanım? Azaltılmış operasyonel yük. Daha az sunucu çökmesi acil çağrısı, minimal kapasite planlaması ve işletim sistemi yaması yok.

Serverless öğrenme eğrisi diktir, ancak üretkenlik kazanımları ölçülebilir. Küçük başlayın, ilk günden kapsamlı monitoring uygulayın ve öğrenme sürecinde hatalar yapmayı bekleyin. CDK ile infrastructure as code kullanmak, manuel konsol işlemlerinden çok daha güvenli ve tekrarlanabilir.

Başlamaya hazır mısınız? Basit bir CRUD API ile başlayın, ilk günden düzgün monitoring ekleyin ve platformun özelliklerini öğrenirken kademeli olarak oluşturun.

İlgili yazılar