İçeriğe atla

Serverless Framework'ten AWS CDK'ya Geçiş: Bölüm 4 - Veritabanı ve Ortam Yönetimi

Stateful infrastructure'ı CDK'ya taşıma: DynamoDB migration, secrets management, parameter store ve VPC konfigürasyonu.

Ayhan Sipahi Ayhan Sipahi

Bir CDK migration’ında en yüksek risk database ve environment tarafındadır. Stateless Lambda fonksiyonlarının aksine bu bileşenler uygulamanın kalıcı durumunu ve konfigürasyonunu tutar. Yanlış bir adım tabloyu siler, veri kaybettirir veya staging’i hiçbir hata mesajı vermeden production key’ine bağlar.

Güvenli yol dar. Bir tabloyu import etmeden önce var olduğunu doğrulayın, environment değerlerini eksik key durumunda synth’i başarısız kılan type-safe bir builder’dan üretin ve her secret’ı tek bir stage’e kilitleyin. Aşağıdaki bölümler bu varsayılanı DynamoDB import’larına, Parameter Store’a, Secrets Manager’a ve VPC’ye bağlı fonksiyonlara uygular.

Seri Navigasyonu:

DynamoDB Import Riskleri#

Mevcut DynamoDB tablolarını Serverless Framework’ten CDK’ya taşırken en kritik konu kaynak yönetimidir. CDK, mevcut CloudFormation template’lerini çakışan kaynak olarak yorumlayabilir; bu da istenmeyen silmeler yoluyla veri kaybına yol açar.

Yaygın Import Tuzakları#

Tabloyu CDK yönetimine alırken cdk deploy başarıyla tamamlanabilir ve monitoring yeşil kalabilir. Ama aynı tabloyu Serverless Framework template’i de yönetiyorsa CDK, benzer adlı veya konfigürasyonlu kaynakları “çakışma” olarak görür. Farkı normal bir stack güncellemesi sayıp kaynağı silip yeniden oluşturmayı deneyebilir.

Temel risk: CDK mevcut Serverless Framework kaynaklarını çakışma olarak yorumlar ve yenisini oluşturmadan önce tabloyu silebilir.

Önleme: Explicit retention policy’leri her zaman tanımlayın ve import’ları production’a benzer veri yapılarıyla staging’de baştan sona deneyin.

DynamoDB Migration Stratejileri#

Güvenli Table Import Pattern’i#

Mevcut tabloları CDK’ya almak için güvenli yaklaşım:

# serverless.yml - Orijinal tablo tanımı
resources:
  Resources:
    UsersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:service}-${opt:stage}-users
        AttributeDefinitions:
          - AttributeName: userId
            AttributeType: S
          - AttributeName: email
            AttributeType: S
        KeySchema:
          - AttributeName: userId
            KeyType: HASH
        GlobalSecondaryIndexes:
          - IndexName: email-index
            KeySchema:
              - AttributeName: email
                KeyType: HASH
            Projection:
              ProjectionType: ALL
        BillingMode: PAY_PER_REQUEST

Mevcut tablolar için CDK yaklaşımı:

// lib/constructs/production-table-import.ts
import { Table, ITable } from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';
import { CustomResource, Duration } from 'aws-cdk-lib';
import { Provider } from 'aws-cdk-lib/custom-resources';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';

export interface ProductionTableImportProps {
  tableName: string;
  // Sadece başka hesap veya Region'daki tablolar için; tam ARN verilir
  tableArn?: string;
  // ARN başka bir hesaba aitse zorunlu: doğrulayıcının o hesapta üstlendiği rol
  verificationRoleArn?: string;
  // Kritik: Import etmeden önce tablonun var olduğunu doğrula
  requireExistingTable: boolean;
}

export class ProductionTableImport extends Construct {
  public readonly table: ITable;
  
  constructor(scope: Construct, id: string, props: ProductionTableImportProps) {
    super(scope, id);
    
    if (props.requireExistingTable) {
      // İlk olarak, tablonun gerçekten var olduğunu doğrula
      const verifyFn = new NodejsFunction(this, 'VerifyTableExists', {
        entry: 'src/migrations/verify-table.ts',
        handler: 'handler',
        timeout: Duration.seconds(30),
        environment: {
          TABLE_NAME: props.tableName,
        },
      });

      // DescribeTable izni varsayılan olarak verilmez
      verifyFn.addToRolePolicy(new PolicyStatement({
        actions: ['dynamodb:DescribeTable'],
        resources: ['*'],
      }));

      // Başka hesaptaki tablo, o hesabın kimlik bilgileriyle sorgulanır
      if (props.verificationRoleArn) {
        verifyFn.addToRolePolicy(new PolicyStatement({
          actions: ['sts:AssumeRole'],
          resources: [props.verificationRoleArn],
        }));
      }

      // Provider, handler'ı CloudFormation response protokolüne sarar
      const provider = new Provider(this, 'VerifyProvider', {
        onEventHandler: verifyFn,
      });

      // Tablo yoksa deployment'ı başarısız kılan custom resource
      new CustomResource(this, 'TableVerification', {
        serviceToken: provider.serviceToken,
        properties: {
          TableName: props.tableName,
          // Handler Region'ı ve hesabı kendi ortamından değil ARN'den alır
          TableArn: props.tableArn,
          VerificationRoleArn: props.verificationRoleArn,
        },
      });
    }
    
    // Sadece verification başarılı olduktan sonra import et
    this.table = Table.fromTableAttributes(this, 'ImportedTable', {
      // TableAttributes ya tableName ya tableArn alır, ikisi birden olmaz
      // Cross-account ve cross-Region referanslar ARN üzerinden gider
      ...(props.tableArn
        ? { tableArn: props.tableArn }
        : { tableName: props.tableName }),
      // fromTableAttributes sadece referans verir; CDK tablo lifecycle'ını sahiplenmez
      // tableStreamArn'ı yalnızca bir handler stream'i tüketecekse doldurun
      tableStreamArn: undefined,
    });
  }
}

// src/migrations/verify-table.ts - Kazara tablo silme işlemini önler
import { DynamoDBClient, DescribeTableCommand } from '@aws-sdk/client-dynamodb';
import { fromTemporaryCredentials } from '@aws-sdk/credential-providers';

export const handler = async (event: any) => {
  const { TableName: tableName, TableArn: tableArn, VerificationRoleArn: roleArn } =
    event.ResourceProperties;
  
  // Varsayılan client her zaman stack'in kendi Region ve hesabına bakar
  const region = tableArn?.split(':')[3];
  const client = new DynamoDBClient({
    ...(region && { region }),
    ...(roleArn && {
      credentials: fromTemporaryCredentials({
        params: { RoleArn: roleArn, RoleSessionName: 'verify-table' },
        clientConfig: { region },
      }),
    }),
  });
  
  try {
    // Tablonun var olduğunu ve ACTIVE olduğunu doğrula
    const result = await client.send(new DescribeTableCommand({
      TableName: tableName,
    }));
    
    if (result.Table?.TableStatus !== 'ACTIVE') {
      throw new Error(`Table ${tableName} is not ACTIVE (status: ${result.Table?.TableStatus})`);
    }
    
    // Yanlış hesaptaki aynı adlı tablo, import edilen tablo yerine geçmemeli
    if (tableArn && result.Table.TableArn !== tableArn) {
      throw new Error(`Verified ${result.Table.TableArn}, not the requested ${tableArn}`);
    }
    
    // Audit trail için kritik tablo bilgilerini logla
    console.log('Production table verified:', {
      tableName,
      itemCount: result.Table.ItemCount || 'unknown',
      sizeBytes: result.Table.TableSizeBytes || 'unknown',
      status: result.Table.TableStatus,
    });
    
    return { PhysicalResourceId: `verified-${tableName}` };
  } catch (error) {
    console.error('Table verification failed:', error);
    throw error; // CloudFormation deployment'ını başarısız kıl
  }
};

// Production güvenlik kontrolleri ile kullanım
const usersTable = new ProductionTableImport(this, 'UsersTable', {
  tableName: `my-service-${config.stage}-users`,
  requireExistingTable: config.stage === 'prod', // Sadece production'da doğrula
}).table;

// Normal şekilde izin ver
usersTable.grantReadWriteData(createUserFn);

Production-Grade Table Pattern’i#

Yeni tablolar için veya mevcut bir tabloyu yeniden oluştururken bu pattern gerekli production ayarlarını bir arada verir:

// lib/constructs/production-user-table.ts
import { 
  Table, 
  AttributeType, 
  BillingMode,
  TableEncryption,
  StreamViewType,
  ProjectionType
} from 'aws-cdk-lib/aws-dynamodb';
import { RemovalPolicy, Tags, Duration } from 'aws-cdk-lib';
import { Alarm, Metric, TreatMissingData } from 'aws-cdk-lib/aws-cloudwatch';

export class ProductionUserTable extends Table {
  constructor(scope: Construct, id: string, props: {
    stage: string;
    enableStreams?: boolean;
    enableBackup?: boolean;
  }) {
    super(scope, id, {
      // Blue-green deployment'lar için versiyonlu tablo adları
      tableName: `my-service-${props.stage}-users-v3`,
      partitionKey: {
        name: 'userId',
        type: AttributeType.STRING,
      },
      sortKey: {
        name: 'recordType',  // Single-table design pattern'lerini etkinleştirir
        type: AttributeType.STRING,
      },
      billingMode: BillingMode.PAY_PER_REQUEST,  // Provisioning tahmini yok
      encryption: TableEncryption.AWS_MANAGED,
      // Production'da HER ZAMAN point-in-time recovery etkinleştir
      pointInTimeRecovery: props.stage === 'prod' ? true : false,
      // Production verisini ASLA kazara silme
      removalPolicy: props.stage === 'prod' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
      // Stream'ler real-time processing ve audit trail'leri etkinleştirir
      stream: props.enableStreams ? StreamViewType.NEW_AND_OLD_IMAGES : undefined,
    });
    
    // Email-based lookup'lar için GSI (auth için kritik)
    this.addGlobalSecondaryIndex({
      indexName: 'EmailLookupIndex',
      partitionKey: {
        name: 'email',
        type: AttributeType.STRING,
      },
      sortKey: {
        name: 'recordType',
        type: AttributeType.STRING,
      },
      projectionType: ProjectionType.KEYS_ONLY,  // Maliyetleri minimize et
    });
    
    // Time-based sorgular için GSI (user activity, reporting)
    this.addGlobalSecondaryIndex({
      indexName: 'TimeSeriesIndex',
      partitionKey: {
        name: 'entityType',
        type: AttributeType.STRING,
      },
      sortKey: {
        name: 'timestamp',
        type: AttributeType.STRING,
      },
      projectionType: ProjectionType.KEYS_ONLY,
    });
    
    // Production monitoring
    this.createProductionAlarms(props.stage);
    
    // Cost tracking tag'leri
    Tags.of(this).add('Service', 'my-service');
    Tags.of(this).add('Stage', props.stage);
    Tags.of(this).add('CostCenter', 'platform');
    Tags.of(this).add('DataClassification', 'sensitive');
  }
  
  private createProductionAlarms(stage: string) {
    if (stage !== 'prod') return;
    
    // Throttle alarm - herhangi bir throttling kötü
    new Alarm(this, 'ThrottleAlarm', {
      metric: new Metric({
        namespace: 'AWS/DynamoDB',
        metricName: 'UserErrorEvents',
        dimensionsMap: {
          TableName: this.tableName,
        },
        statistic: 'Sum',
        period: Duration.minutes(5),
      }),
      threshold: 1,
      evaluationPeriods: 1,
      treatMissingData: TreatMissingData.NOT_BREACHING,
      alarmDescription: 'DynamoDB table is experiencing throttling',
    });
    
    // Error rate alarm
    new Alarm(this, 'ErrorRateAlarm', {
      metric: new Metric({
        namespace: 'AWS/DynamoDB',
        metricName: 'SystemErrorEvents',
        dimensionsMap: {
          TableName: this.tableName,
        },
        statistic: 'Sum',
        period: Duration.minutes(5),
      }),
      threshold: 5,
      evaluationPeriods: 2,
      alarmDescription: 'DynamoDB table experiencing system errors',
    });
  }
}

Zero-Downtime Data Migration#

Büyük hacimli kayıtları servis kesintisi olmadan taşımak dikkatli planlama ve tekrar edilebilir bir migration pattern’i gerektirir. Aşağıdaki yaklaşım büyük veri setlerini güvenli şekilde işler:

// lib/constructs/production-table-migrator.ts
import { CustomResource, Duration, Stack } from 'aws-cdk-lib';
import { Provider } from 'aws-cdk-lib/custom-resources';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';

export class ProductionTableMigrator extends Construct {
  constructor(scope: Construct, id: string, props: {
    sourceTable: ITable;
    targetTable: ITable;
    batchSize?: number;
    enableDualWrite?: boolean;
  }) {
    super(scope, id);
    
    // Production ayarları ile migration fonksiyonu
    const migrationFn = new NodejsFunction(this, 'MigrationFunction', {
      entry: 'src/migrations/production-table-migrator.ts',
      handler: 'handler',
      timeout: Duration.minutes(15),
      memorySize: 3008,  // En hızlı processing için max memory
      reservedConcurrentExecutions: 5,  // Diğer fonksiyonlara etkiyi sınırla
      logRetention: RetentionDays.ONE_MONTH,  // Migration log'larını sakla
      environment: {
        SOURCE_TABLE: props.sourceTable.tableName,
        TARGET_TABLE: props.targetTable.tableName,
        BATCH_SIZE: String(props.batchSize || 25),  // DynamoDB batch limiti
        ENABLE_DUAL_WRITE: String(props.enableDualWrite || false),
        // Migration tracking
        MIGRATION_ID: `migration-${Date.now()}`,
      },
    });
    
    // Migration için kapsamlı izinler
    props.sourceTable.grantFullAccess(migrationFn);  // Scan/read gerekli
    props.targetTable.grantFullAccess(migrationFn);  // Write/verify gerekli

    // Scan imlecinin tek bir invocation'dan uzun yaşaması gerekir
    migrationFn.addToRolePolicy(new PolicyStatement({
      actions: ['ssm:GetParameter', 'ssm:PutParameter', 'ssm:DeleteParameter'],
      resources: [Stack.of(this).formatArn({
        service: 'ssm',
        resource: 'parameter',
        resourceName: 'migrations/*',
      })],
    }));
    
    // Düzgün error handling ile custom resource oluştur
    const provider = new Provider(this, 'Provider', {
      onEventHandler: migrationFn,
      logRetention: RetentionDays.ONE_MONTH,
    });
    
    new CustomResource(this, 'DataMigration', {
      serviceToken: provider.serviceToken,
      properties: {
        SourceTable: props.sourceTable.tableName,
        TargetTable: props.targetTable.tableName,
        MigrationId: `migration-${Date.now()}`,
        // Sadece tablolar değiştiğinde force update
        TableFingerprint: this.generateTableFingerprint(props),
      },
    });
  }
  
  private generateTableFingerprint(props: {
    sourceTable: ITable;
    targetTable: ITable;
  }): string {
    // Tablo özelliklerine dayalı unique fingerprint oluştur
    return Buffer.from(
      `${props.sourceTable.tableName}-${props.targetTable.tableName}`
    ).toString('base64');
  }
}

// src/migrations/production-table-migrator.ts
import { 
  DynamoDBClient, 
  ScanCommand, 
  BatchWriteItemCommand,
  DescribeTableCommand,
} from '@aws-sdk/client-dynamodb';
import {
  SSMClient,
  GetParameterCommand,
  PutParameterCommand,
  DeleteParameterCommand,
} from '@aws-sdk/client-ssm';

const client = new DynamoDBClient({
  maxAttempts: 5,  // Başarısız request'leri retry et
  requestHandler: {
    connectionTimeout: 2000,
    requestTimeout: 30000,
  },
});
const ssm = new SSMClient({});

export const handler = async (event: any) => {
  const startedAt = Date.now();
  const { RequestType, ResourceProperties } = event;
  const { SourceTable, TargetTable, MigrationId } = ResourceProperties;
  
  console.log('Migration event:', { RequestType, SourceTable, TargetTable, MigrationId });
  
  try {
    if (RequestType === 'Create' || RequestType === 'Update') {
      await migrateTableData(SourceTable, TargetTable, MigrationId, startedAt);
    }
    
    return {
      PhysicalResourceId: `migration-${SourceTable}-to-${TargetTable}`,
      Data: {
        Status: 'Success',
        MigrationId,
      },
    };
  } catch (error) {
    console.error('Migration failed:', error);
    throw error;  // CloudFormation deployment'ını başarısız kıl
  }
};

async function migrateTableData(
  sourceTable: string,
  targetTable: string,
  migrationId: string,
  startedAt: number,
) {
  console.log(`Starting migration: ${sourceTable} -> ${targetTable}`);
  
  // İlk olarak, her iki tablonun da var olduğunu ve aktif olduğunu doğrula
  await verifyTableState(sourceTable);
  await verifyTableState(targetTable);
  
  // Önceki geçişten kalan imleç varsa oradan devam et
  let lastEvaluatedKey: any = await loadCheckpoint(sourceTable, targetTable);
  let totalItems = 0;
  let batchCount = 0;
  const batchSize = parseInt(process.env.BATCH_SIZE || '25');
  
  do {
    // Kaynak tabloyu scan et
    const scanResult = await client.send(new ScanCommand({
      TableName: sourceTable,
      Limit: batchSize,
      ExclusiveStartKey: lastEvaluatedKey,
    }));
    
    if (scanResult.Items && scanResult.Items.length > 0) {
      // Hedef tabloya batch write hazırla
      const writeRequests = scanResult.Items.map(item => ({
        PutRequest: { Item: item },
      }));
      
      // BatchWriteItem throttling'de UnprocessedItems döner; yok sayarsanız kayıt kaybedersiniz
      let pending: Record<string, any[]> = { [targetTable]: writeRequests };
      for (let attempt = 0; Object.keys(pending).length > 0; attempt++) {
        if (attempt === 5) {
          throw new Error('BatchWriteItem still had unprocessed items after 5 attempts');
        }
        const written = await client.send(new BatchWriteItemCommand({
          RequestItems: pending,
        }));
        pending = written.UnprocessedItems ?? {};
        if (Object.keys(pending).length > 0) {
          await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 100));
        }
      }
      
      totalItems += scanResult.Items.length;
      batchCount++;
      
      console.log(`Migrated batch ${batchCount}: ${scanResult.Items.length} items (total: ${totalItems})`);
    }
    
    lastEvaluatedKey = scanResult.LastEvaluatedKey;
    
    // 15 dakikalık Lambda timeout'una girmeden dur; yarım kalan scan asla başarılı sayılmaz
    if (lastEvaluatedKey && Date.now() - startedAt > 840000) {
      await saveCheckpoint(sourceTable, targetTable, lastEvaluatedKey);
      throw new Error(
        `Migration ${migrationId} stopped after ${totalItems} items with the scan unfinished; ` +
        'the cursor is saved and the next deployment resumes from it',
      );
    }
    
  } while (lastEvaluatedKey);
  
  // Scan bitti; sonraki çalışma temiz başlasın diye imleci sil
  await clearCheckpoint(sourceTable, targetTable);
  
  console.log(`Migration completed: ${totalItems} items migrated in ${batchCount} batches on this pass`);
}

async function verifyTableState(tableName: string) {
  const result = await client.send(new DescribeTableCommand({
    TableName: tableName,
  }));
  
  if (result.Table?.TableStatus !== 'ACTIVE') {
    throw new Error(`Table ${tableName} is not ACTIVE (status: ${result.Table?.TableStatus})`);
  }
}

// İmleç migration id'sine değil tablo çiftine bağlıdır; sonraki deployment onu bulur
function checkpointName(sourceTable: string, targetTable: string) {
  return `/migrations/${sourceTable}-to-${targetTable}/cursor`;
}

async function loadCheckpoint(sourceTable: string, targetTable: string) {
  try {
    const result = await ssm.send(new GetParameterCommand({
      Name: checkpointName(sourceTable, targetTable),
    }));
    return result.Parameter?.Value ? JSON.parse(result.Parameter.Value) : undefined;
  } catch (error: any) {
    // Parameter yoksa bu ilk geçiştir
    if (error.name === 'ParameterNotFound') return undefined;
    throw error;
  }
}

async function saveCheckpoint(sourceTable: string, targetTable: string, key: any) {
  await ssm.send(new PutParameterCommand({
    Name: checkpointName(sourceTable, targetTable),
    Value: JSON.stringify(key),
    Type: 'String',
    Overwrite: true,
  }));
}

async function clearCheckpoint(sourceTable: string, targetTable: string) {
  try {
    await ssm.send(new DeleteParameterCommand({
      Name: checkpointName(sourceTable, targetTable),
    }));
  } catch (error: any) {
    if (error.name !== 'ParameterNotFound') throw error;
  }
}

Her deployment tek bir geçişi kapsar. Scan, Lambda timeout’u içinde bitmezse imleç Parameter Store’a yazılır ve deployment başarısız olur; yarı taşınmış bir tablo asla tamamlanmış sayılmaz. Sonraki deploy taramayı o imleçten devam ettirir. Birkaç geçişten fazlasını gerektiren tablolar custom resource yerine Step Functions akışına aittir.

Environment Variable Tuzakları#

CDK migration’ı sırasında yaygın bir tuzak: Serverless Framework’ün ${env:SECRET_KEY} referansları CDK’da literal string’lere dönüşebilir. Bu sessiz başarısızlıklara yol açar; JWT validation’da “undefined” değerleri görülür ama kaynak hemen belli olmaz.

Kök neden: Serverless Framework’ün string interpolation’ı CDK’nın environment handling’inden aldatıcı şekilde farklı davranır. Migration sırasında bu fark gözden kaçarsa production’da aralıklı authentication başarısızlıkları ortaya çıkar.

Production-Grade Environment Management#

Sessiz başarısızlıkları önlemek için type-safe environment builder kullanımı önerilir:

// lib/config/production-environment.ts
export interface ProductionEnvironmentVariables {
  // Core application config - Production'da ASLA undefined olmamalı
  SERVICE_NAME: string;
  STAGE: string;
  REGION: string;
  VERSION: string;
  ENVIRONMENT: 'development' | 'staging' | 'production';
  
  // Default'ları olan feature flag'ler
  ENABLE_CACHE: 'true' | 'false';
  ENABLE_DEBUG_LOGGING: 'true' | 'false';
  ENABLE_METRICS: 'true' | 'false';
  
  // Performance tuning
  CACHE_TTL_SECONDS: string;
  MAX_RETRY_ATTEMPTS: string;
  REQUEST_TIMEOUT_MS: string;
  
  // Database referansları (tablo adları, env var'larda ARN asla)
  USERS_TABLE: string;
  ORDERS_TABLE: string;
  AUDIT_LOG_TABLE: string;
  
  // Secret ARN'leri (gerçek secret'lar runtime'da alınır)
  JWT_SECRET_ARN: string;
  DATABASE_CREDENTIALS_ARN: string;
  THIRD_PARTY_API_KEYS_ARN: string;
  
  // External servis konfigürasyonu
  STRIPE_WEBHOOK_ENDPOINT: string;
  SENDGRID_FROM_EMAIL: string;
  
  // Monitoring ve observability
  SENTRY_DSN?: string;
  DATADOG_API_KEY_ARN?: string;
  LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
  
  // Business logic konfigürasyonu
  MAX_FILE_UPLOAD_SIZE_MB: string;
  SESSION_TIMEOUT_MINUTES: string;
  RATE_LIMIT_PER_MINUTE: string;
}

export class ProductionEnvironmentBuilder {
  private vars: Partial<ProductionEnvironmentVariables> = {};
  private requiredVars: Set<keyof ProductionEnvironmentVariables> = new Set();
  
  constructor(private stage: string, private region: string, private version: string) {
    // Her zaman gerekli olan core variable'ları set et
    this.vars.STAGE = stage;
    this.vars.REGION = region;
    this.vars.VERSION = version;
    this.vars.ENVIRONMENT = this.mapStageToEnvironment(stage);
    
    // Core variable'ları required olarak işaretle
    this.requiredVars.add('SERVICE_NAME');
    this.requiredVars.add('STAGE');
    this.requiredVars.add('REGION');
    this.requiredVars.add('VERSION');
  }
  
  private mapStageToEnvironment(stage: string): 'development' | 'staging' | 'production' {
    switch (stage) {
      case 'prod':
      case 'production':
        return 'production';
      case 'staging':
      case 'stage':
        return 'staging';
      default:
        return 'development';
    }
  }
  
  addServiceName(serviceName: string): this {
    this.vars.SERVICE_NAME = serviceName;
    return this;
  }
  
  addTable(key: keyof ProductionEnvironmentVariables, table: ITable): this {
    this.vars[key] = table.tableName;
    this.requiredVars.add(key);
    return this;
  }
  
  addSecret(key: keyof ProductionEnvironmentVariables, secret: ISecret): this {
    this.vars[key] = secret.secretArn;
    this.requiredVars.add(key);
    return this;
  }
  
  addFeatureFlag(key: keyof ProductionEnvironmentVariables, enabled: boolean): this {
    this.vars[key] = enabled ? 'true' : 'false' as any;
    return this;
  }
  
  addConfig(config: Partial<ProductionEnvironmentVariables>): this {
    Object.assign(this.vars, config);
    return this;
  }
  
  markRequired(key: keyof ProductionEnvironmentVariables): this {
    this.requiredVars.add(key);
    return this;
  }
  
  build(): Record<string, string> {
    // Tüm gerekli variable'ların mevcut olduğunu doğrula
    const missing = Array.from(this.requiredVars).filter(key => 
      this.vars[key] === undefined || this.vars[key] === ''
    );
    
    if (missing.length > 0) {
      throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
    }
    
    // Stage-specific default'ları set et
    const defaults = this.getStageDefaults();
    const merged = { ...defaults, ...this.vars };
    
    // String record'a dönüştür, undefined değerleri filtrele
    return Object.entries(merged)
      .filter(([_, value]) => value !== undefined && value !== '')
      .reduce((acc, [key, value]) => ({
        ...acc,
        [key]: String(value),
      }), {});
  }
  
  private getStageDefaults(): Partial<ProductionEnvironmentVariables> {
    const isProd = this.vars.ENVIRONMENT === 'production';
    
    return {
      // Production için conservative, dev için aggressive default'lar
      ENABLE_CACHE: isProd ? 'true' : 'false',
      ENABLE_DEBUG_LOGGING: isProd ? 'false' : 'true',
      ENABLE_METRICS: isProd ? 'true' : 'false',
      CACHE_TTL_SECONDS: isProd ? '300' : '60',
      MAX_RETRY_ATTEMPTS: isProd ? '3' : '1',
      REQUEST_TIMEOUT_MS: isProd ? '30000' : '10000',
      LOG_LEVEL: isProd ? 'info' : 'debug',
      MAX_FILE_UPLOAD_SIZE_MB: '10',
      SESSION_TIMEOUT_MINUTES: '60',
      RATE_LIMIT_PER_MINUTE: isProd ? '100' : '1000',
    };
  }
}

Environment Builder Kullanımı#

// lib/stacks/api-stack.ts
const envBuilder = new ProductionEnvironmentBuilder(
  config.stage,
  config.region,
  config.version,
)
  .addServiceName('my-service')
  .addTable('USERS_TABLE', usersTable)
  .addTable('ORDERS_TABLE', ordersTable)
  .addConfig({
    ENABLE_CACHE: config.stage === 'prod' ? 'true' : 'false',
    CACHE_TTL_SECONDS: '300',
  });

// Lambda fonksiyonuna ekle
const createUserFn = new ServerlessFunction(this, 'CreateUserFunction', {
  entry: 'src/handlers/users.ts',
  handler: 'create',
  config,
  environment: envBuilder.build(),
});

Secrets Management: Cross-Environment Riski#

Staging environment’larının yanlış konfigürasyon nedeniyle production key’lerine yönlendirilmesi yaygın bir hata kaynağıdır. Environment-specific validation olmadan plaintext environment variable’lar olarak depolanan secret’lar bu riski taşır.

Kök neden: Secret’lar environment izolasyonu olmadan saklandığında staging ve production arasında cross-contamination oluşabilir.

Sağlam Secrets Management#

Production-grade secrets yönetimi için önerilen yaklaşım:

// lib/constructs/secure-function.ts
import { Secret, ISecret } from 'aws-cdk-lib/aws-secretsmanager';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';

export interface SecureFunctionProps extends ServerlessFunctionProps {
  secrets?: Record<string, ISecret>;
}

export class SecureFunction extends ServerlessFunction {
  constructor(scope: Construct, id: string, props: SecureFunctionProps) {
    const { secrets = {}, ...functionProps } = props;
    
    // Secret ARN'lerini environment variable olarak geç
    const secretEnvVars = Object.entries(secrets).reduce(
      (acc, [key, secret]) => ({
        ...acc,
        [`${key}_SECRET_ARN`]: secret.secretArn,
      }),
      {}
    );
    
    super(scope, id, {
      ...functionProps,
      environment: {
        ...functionProps.environment,
        ...secretEnvVars,
      },
    });
    
    // Tüm secret'lar için read izinleri ver
    Object.values(secrets).forEach(secret => {
      secret.grantRead(this);
    });
  }
}

// Kullanım
const apiKeySecret = new Secret(this, 'ApiKeySecret', {
  secretName: `/${config.stage}/my-service/api-keys`,
  generateSecretString: {
    secretStringTemplate: JSON.stringify({}),
    generateStringKey: 'sendgrid',
    excludeCharacters: ' %+~`#$&*()|[]{}:;<>?!\'/@"\\',
  },
});

const emailFunction = new SecureFunction(this, 'EmailFunction', {
  entry: 'src/handlers/email.ts',
  handler: 'send',
  config,
  secrets: {
    API_KEYS: apiKeySecret,
  },
});

Runtime Secret Access#

// src/libs/secrets.ts
import { 
  SecretsManagerClient, 
  GetSecretValueCommand 
} from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({});
const cache = new Map<string, any>();

export async function getSecret<T = any>(
  secretArn: string,
  jsonKey?: string
): Promise<T> {
  const cacheKey = `${secretArn}:${jsonKey || 'full'}`;
  
  if (cache.has(cacheKey)) {
    return cache.get(cacheKey);
  }
  
  try {
    const response = await client.send(
      new GetSecretValueCommand({ SecretId: secretArn })
    );
    
    const secret = JSON.parse(response.SecretString || '{}');
    const value = jsonKey ? secret[jsonKey] : secret;
    
    cache.set(cacheKey, value);
    return value;
  } catch (error) {
    console.error('Failed to retrieve secret:', error);
    throw new Error('Secret retrieval failed');
  }
}

// Handler'da kullanım
export const handler = async (event: APIGatewayProxyEventV2) => {
  const secretArn = process.env.API_KEYS_SECRET_ARN;
  const sendgridKey = await getSecret<string>(secretArn!, 'sendgrid');
  
  // Secret'ı kullan
  await sendEmail(sendgridKey, event.body);
};

Parameter Store Entegrasyonu#

Hassas olmayan konfigürasyon için:

// lib/constructs/parameter-store.ts
import { StringParameter, IParameter } from 'aws-cdk-lib/aws-ssm';

export class ServiceParameters extends Construct {
  public readonly configs: Map<string, IParameter> = new Map();
  
  constructor(scope: Construct, id: string, props: {
    service: string;
    stage: string;
    parameters: Record<string, string>;
  }) {
    super(scope, id);
    
    // Parameter'ları oluştur
    Object.entries(props.parameters).forEach(([key, value]) => {
      const param = new StringParameter(this, key, {
        parameterName: `/${props.service}/${props.stage}/${key}`,
        stringValue: value,
        description: `${key} for ${props.service} ${props.stage}`,
      });
      
      this.configs.set(key, param);
    });
  }
  
  grantRead(grantable: IGrantable) {
    this.configs.forEach(param => {
      param.grantRead(grantable);
    });
  }
  
  toEnvironment(): Record<string, string> {
    const env: Record<string, string> = {};
    this.configs.forEach((param, key) => {
      env[`${key}_PARAM`] = param.parameterName;
    });
    return env;
  }
}

RDS/ElastiCache için VPC Konfigürasyonu#

VPC ve Security Group Kurulumu#

// lib/constructs/vpc-config.ts
import { Vpc, SubnetType, SecurityGroup, Port } from 'aws-cdk-lib/aws-ec2';
import { DatabaseInstance, DatabaseInstanceEngine } from 'aws-cdk-lib/aws-rds';

export class VpcResources extends Construct {
  public readonly vpc: Vpc;
  public readonly lambdaSecurityGroup: SecurityGroup;
  public readonly databaseSecurityGroup: SecurityGroup;
  public readonly database?: DatabaseInstance;
  
  constructor(scope: Construct, id: string, props: {
    stage: string;
    enableDatabase?: boolean;
  }) {
    super(scope, id);
    
    // VPC oluştur
    this.vpc = new Vpc(this, 'Vpc', {
      vpcName: `my-service-${props.stage}`,
      maxAzs: 2,
      natGateways: props.stage === 'prod' ? 2 : 1,
      subnetConfiguration: [
        {
          name: 'Public',
          subnetType: SubnetType.PUBLIC,
          cidrMask: 24,
        },
        {
          name: 'Private',
          subnetType: SubnetType.PRIVATE_WITH_EGRESS,
          cidrMask: 24,
        },
        {
          name: 'Isolated',
          subnetType: SubnetType.PRIVATE_ISOLATED,
          cidrMask: 24,
        },
      ],
    });
    
    // Security group'lar
    this.lambdaSecurityGroup = new SecurityGroup(this, 'LambdaSG', {
      vpc: this.vpc,
      description: 'Security group for Lambda functions',
      allowAllOutbound: true,
    });
    
    this.databaseSecurityGroup = new SecurityGroup(this, 'DatabaseSG', {
      vpc: this.vpc,
      description: 'Security group for RDS database',
      allowAllOutbound: false,
    });
    
    // Lambda'nın database'e bağlanmasına izin ver
    this.databaseSecurityGroup.addIngressRule(
      this.lambdaSecurityGroup,
      Port.tcp(5432),
      'Allow Lambda functions'
    );
    
    if (props.enableDatabase) {
      this.createDatabase(props.stage);
    }
  }
  
  private createDatabase(stage: string) {
    this.database = new DatabaseInstance(this, 'Database', {
      databaseName: 'myservice',
      engine: DatabaseInstanceEngine.postgres({
        version: PostgresEngineVersion.VER_16_4,
      }),
      vpc: this.vpc,
      vpcSubnets: {
        subnetType: SubnetType.PRIVATE_ISOLATED,
      },
      securityGroups: [this.databaseSecurityGroup],
      allocatedStorage: stage === 'prod' ? 100 : 20,
      instanceType: InstanceType.of(
        InstanceClass.T3,
        stage === 'prod' ? InstanceSize.MEDIUM : InstanceSize.MICRO
      ),
      multiAz: stage === 'prod',
      deletionProtection: stage === 'prod',
      backupRetention: Duration.days(stage === 'prod' ? 30 : 7),
    });
  }
}

VPC-Enabled Lambda Fonksiyonu#

// lib/constructs/vpc-lambda.ts
export class VpcLambdaFunction extends ServerlessFunction {
  constructor(scope: Construct, id: string, props: ServerlessFunctionProps & {
    vpcResources: VpcResources;
    databaseSecret?: ISecret;
  }) {
    const { vpcResources, databaseSecret, ...functionProps } = props;
    
    super(scope, id, {
      ...functionProps,
      vpc: vpcResources.vpc,
      vpcSubnets: {
        subnetType: SubnetType.PRIVATE_WITH_EGRESS,
      },
      securityGroups: [vpcResources.lambdaSecurityGroup],
      // esbuild sadece JavaScript üretir; bu hook olmadan RDS CA'sı asset'e
      // hiç girmez ve ilk bağlantı ENOENT ile düşer
      bundling: {
        ...functionProps.bundling,
        commandHooks: {
          beforeBundling: () => [],
          beforeInstall: () => [],
          afterBundling: (inputDir: string, outputDir: string) => [
            `mkdir -p ${outputDir}/certs`,
            `cp ${inputDir}/certs/rds-ca-bundle.pem ${outputDir}/certs/`,
          ],
        },
      },
      environment: {
        ...functionProps.environment,
        ...(databaseSecret && {
          DB_SECRET_ARN: databaseSecret.secretArn,
        }),
      },
    });
    
    // Database erişimi ver
    if (databaseSecret) {
      databaseSecret.grantRead(this);
    }
  }
}

Database Connection Management#

// src/libs/database.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Client } from 'pg';
import { getSecret } from './secrets';

let client: Client | null = null;

export async function getDbClient(): Promise<Client> {
  // node-postgres'te public "ended" alanı yok; end/error event'leriyle takip edin
  if (client) {
    return client;
  }
  
  const secretArn = process.env.DB_SECRET_ARN;
  if (!secretArn) {
    throw new Error('Database secret not configured');
  }
  
  const credentials = await getSecret<{
    username: string;
    password: string;
    host: string;
    port: number;
    dbname: string;
  }>(secretArn);
  
  const pgClient = new Client({
    user: credentials.username,
    password: credentials.password,
    host: credentials.host,
    port: credentials.port,
    database: credentials.dbname,
    ssl: {
      // Bundling hook'unun asset'e kopyaladığı RDS CA'sı; doğrulamayı asla kapatmayın
      ca: readFileSync(
        join(process.env.LAMBDA_TASK_ROOT ?? process.cwd(), 'certs/rds-ca-bundle.pem'),
      ).toString(),
      rejectUnauthorized: true,
    },
    connectionTimeoutMillis: 10000,
  });
  
  // Cache'lenmiş client'ı düşür ki sonraki invocation yeniden bağlansın
  pgClient.on('end', () => { client = null; });
  pgClient.on('error', () => { client = null; });
  
  await pgClient.connect();
  client = pgClient;
  return pgClient;
}

// Lambda container shutdown'da temizlik
process.on('SIGTERM', async () => {
  if (client) {
    await client.end();
    client = null;
  }
});

Backup ve Disaster Recovery#

Otomatik DynamoDB Backup’ları#

// lib/constructs/backup-plan.ts
import { BackupPlan, BackupPlanRule, BackupResource } from 'aws-cdk-lib/aws-backup';
import { Schedule } from 'aws-cdk-lib/aws-events';

export class TableBackupPlan extends Construct {
  constructor(scope: Construct, id: string, props: {
    tables: ITable[];
    stage: string;
  }) {
    super(scope, id);
    
    const plan = new BackupPlan(this, 'BackupPlan', {
      backupPlanName: `my-service-${props.stage}-backup`,
      backupPlanRules: [
        // BackupPlanRule bir class'tır, struct değil; object literal type-check'ten geçmez
        new BackupPlanRule({
          ruleName: 'DailyBackups',
          scheduleExpression: Schedule.cron({
            hour: '3',
            minute: '0',
          }),
          startWindow: Duration.hours(1),
          completionWindow: Duration.hours(2),
          deleteAfter: Duration.days(
            props.stage === 'prod' ? 30 : 7
          ),
        }),
      ],
    });
    
    plan.addSelection('TableSelection', {
      resources: props.tables.map(table => 
        BackupResource.fromDynamoDbTable(table)
      ),
    });
  }
}

Migration Best Practice’leri#

1. Stateful Resource Stratejisi#

// lib/stacks/stateful-stack.ts
export class StatefulStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, {
      ...props,
      // Kazara silmeyi önle
      terminationProtection: true,
    });
    
    // Tüm stateful kaynaklar bir stack'te
    const tables = this.createTables();
    const secrets = this.createSecrets();
    const parameters = this.createParameters();
    
    // Diğer stack'lerde kullanım için export et
    tables.forEach((table, name) => {
      new CfnOutput(this, `${name}TableName`, {
        value: table.tableName,
        exportName: `${this.stackName}-${name}TableName`,
      });
    });
  }
}

2. Zero-Downtime Migration Checklist#

  • fromTableAttributes kullanarak mevcut tabloları import et
  • Import edilmiş kaynaklarla izinleri test et
  • Tablo schema’sı değişiyorsa dual-write pattern implement et
  • Gradual rollout için Lambda environment variable’ları kullan
  • Switch etmeden önce CloudWatch alarm’ları kur
  • External servisler için circuit breaker’lar implement et
  • Rollback prosedürlerini test et

Stateful Kaynaklar İçin Beş Kural#

CDK’da stateful infrastructure yönetimi beş kurala indirgenebilir:

1. Import’ları Her Zaman İlk Olarak Staging’de Test Et#

Risk: Staging rehearsal atlandığında production tablolarında beklenmedik silme gerçekleşebilir. Önleme: Aynı data ile staging’de rehearsal yapmadan production tablolarına karşı asla cdk deploy çalıştırmayın.

2. Environment Variable’lar Konfigürasyon Değildir#

Risk: Secret interpolation farklılıkları aralıklı authentication başarısızlıklarına yol açar. Önleme: Validation ve required field kontrolleri olan type-safe environment builder’lar kullanın.

3. Secret’ların Environment-Specific Validation’a İhtiyacı Var#

Risk: Environment izolasyonu olmadan staging production key’lerine bağlanabilir. Önleme: Cross-environment contamination’ı önleyen environment-aware secret validation uygulayın.

4. Data Migration’ın Monitoring’e İhtiyacı Var#

Risk: İzleme olmadan büyük batch migration’larda sessiz hatalar oluşabilir. Önleme: Migration fonksiyonlarında kapsamlı logging, progress tracking ve timeout handling kullanın.

5. VPC Lambda Fonksiyonları Farklıdır#

Risk: Eksik connection management cold start artışlarına ve connection pool tükenmesine yol açar. Önleme: Düzgün connection management, security group konfigürasyonu ve subnet planlama yapın.

Sonraki Adımlar#

Yukarıdaki varsayılan, hâlihazırda var olan ve taşınma sırasında ayakta kalması gereken tablolar için geçerlidir. Tablolarını CDK içinde sıfırdan oluşturan bir serviste import verification’a gerek yoktur; retention policy’yi oluşturma anında set etmek yeterlidir. Verification custom resource’u fazladan deployment adımını yalnızca silmenin geri alınamayacağı yerlerde hak eder: production ve müşteri verisi tutan diğer stage’ler.

5. Bölüm’de, authentication ve authorization implement edeceğiz:

  • Production kısıtlamaları olan Cognito user pool’ları
  • Düzgün validation yapan API Gateway authorizer’ları
  • Least privilege’i takip eden IAM roller
  • Hata yönetimi doğru kurulmuş JWT token validation
  • Complexity explosion olmadan fine-grained izinler

Kaynaklar#

Serverless Framework'ten AWS CDK'ya Geçiş Rehberi

Serverless Framework'ten AWS CDK'ya tam geçiş sürecini kapsayan 6 bölümlük kapsamlı rehber. Kurulum, uygulama pattern'leri ve best practice'ler dahil.

İlerleme 4/6 yazı tamamlandı

İlgili yazılar