İçeriğe atla

2025-09-21

AWS CDK ve Serverless ile Geçici Preview Ortamları Oluşturma

AWS CDK, Lambda ve GitHub Actions kullanarak otomatik preview ortamları oluşturmayı öğrenin - sorunsuz PR test ve inceleme süreçleri için

Paylaşılan Staging Ortamlarının Problemi

Paylaşılan tek bir staging ortamı, bir ekip günde birden fazla pull request açmaya başladığı an darboğaza dönüşür. Eşzamanlı PR’lar birbirlerinin veritabanı durumunu üzerine yazar, aynı feature flag’lerde çakışır ve aynı DNS kaydı için yarışır; test sinyali düşer çünkü bir hata, gerçek bir regresyon kadar “başka birinin PR’ı” anlamına da gelebilir.

PR başına bir tane olan geçici (ephemeral) preview ortamları kaynak çakışmasını ortadan kaldırır ama kendi kısıtlarını getirir: ortam başına maliyet, incelemeci için cold-start sürtünmesi ve temizlik güvenilirliği. Bu yazı, AWS üzerinde CDK ile PR başına preview ortamı tasarımını ele alır. PR başına stack oluşturan CDK kompozisyonunu, preview URL yönlendirmesini, PR kapandığında otomatik yıkımı, veritabanı seeding stratejilerini ve bu deseni ekip ölçeğinde sürdürülebilir kılan maliyet kontrollerini kapsar.

Mimari Genel Bakış

Çözüm, AWS serverless servislerini GitHub Actions ile birleştirerek tamamen otomatik preview ortamları oluşturuyor. Her PR kendi subdomain’ini ve production’u taklit eden ama uygun şekilde ölçeklenen altyapı stack’ini alıyor.

GitHub PR

GitHub Actions

AWS CDK Deploy

API Gateway

Lambda Functions

Route53 DNS

CloudFront CDN

pr-123.preview.company.com

Application Logic

DNS Routing

Static Assets

CloudWatch

Cleanup Automation

CDK Destroy

Temel Implementasyon

CDK Stack Mimarisi

Temel, her PR için özdeş altyapı oluşturan parametreli bir CDK stack’i:

// lib/preview-environment-stack.ts
import { Stack, StackProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';

export interface PreviewStackProps extends StackProps {
  prNumber: string;
  commitSha: string;
  domain: string;
  certificateArn: string;
}

export class PreviewEnvironmentStack extends Stack {
  constructor(scope: Construct, id: string, props: PreviewStackProps) {
    super(scope, id, props);

    const { prNumber, commitSha, domain } = props;

    // Kaynak yönetimi için ortak tag'ler
    const commonTags = {
      Environment: 'preview',
      PRNumber: prNumber,
      CommitSha: commitSha.substring(0, 8),
      CreatedBy: 'github-actions',
      TTL: this.calculateTTL(72), // 72 saat
    };

    // Uygulama için Lambda function
    const appFunction = new lambda.Function(this, 'AppFunction', {
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('dist'),
      timeout: Duration.seconds(30),
      memorySize: 256,
      environment: {
        STAGE: 'preview',
        PR_NUMBER: prNumber,
        COMMIT_SHA: commitSha,
      },
    });

    // Özel domain ile API Gateway
    const api = new apigateway.RestApi(this, 'PreviewApi', {
      restApiName: `preview-api-pr-${prNumber}`,
      description: `PR ${prNumber} için preview ortamı`,
      binaryMediaTypes: ['*/*'],
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: apigateway.Cors.ALL_METHODS,
      },
    });

    // Lambda integration
    const lambdaIntegration = new apigateway.LambdaIntegration(appFunction, {
      requestTemplates: { 'application/json': '{ "statusCode": "200" }' },
    });

    api.root.addMethod('ANY', lambdaIntegration);
    api.root.addProxy({
      defaultIntegration: lambdaIntegration,
    });

    // Özel domain için Route53 kaydı
    const previewDomain = `pr-${prNumber}.${domain}`;
    const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
      domainName: domain,
    });

    // Caching için CloudFront distribution
    const distribution = new cloudfront.CloudFrontWebDistribution(this, 'Distribution', {
      originConfigs: [{
        customOriginSource: {
          domainName: api.restApiId + '.execute-api.' + this.region + '.amazonaws.com',
          originPath: '/prod',
        },
        behaviors: [{ isDefaultBehavior: true }],
      }],
      comment: `PR ${prNumber} için preview distribution`,
    });

    // Tüm kaynaklara ortak tag'leri uygula
    Object.entries(commonTags).forEach(([key, value]) => {
      this.node.applyAspect(new TagAspect(key, value));
    });

    // Temizlik aspect'ini uygula
    this.node.applyAspect(new AutoCleanupAspect());
  }

  private calculateTTL(hours: number): string {
    const expiryDate = new Date(Date.now() + hours * 60 * 60 * 1000);
    return expiryDate.toISOString();
  }
}

// Düzgün kaynak silme için cleanup aspect
import * as cdk from 'aws-cdk-lib';

class AutoCleanupAspect implements cdk.IAspect {
  visit(node: cdk.IConstruct): void {
    if (node instanceof cdk.CfnResource) {
      node.addPropertyOverride('DeletionPolicy', 'Delete');
    }
  }
}

// Maliyet takibi için tagging aspect
class TagAspect implements cdk.IAspect {
  constructor(private key: string, private value: string) {}

  visit(node: cdk.IConstruct): void {
    if (cdk.TagManager.isTaggable(node)) {
      node.tags.setTag(this.key, this.value);
    }
  }
}

GitHub Actions Workflow

Otomasyon, PR olaylarına yanıt veren bir GitHub Actions workflow’u ile başlıyor:

# .github/workflows/preview-environment.yml
name: Preview Environment

on:
  pull_request:
    types: [opened, synchronize, closed]

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  deploy-preview:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest

    env:
      PR_NUMBER: ${{ github.event.number }}
      COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
      PREVIEW_DOMAIN: pr-${{ github.event.number }}.preview.company.com

    steps:
      - name: Kodu checkout et
        uses: actions/checkout@v4

      - name: Node.js setup
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - name: AWS credentials yapılandır
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Dependencies'leri yükle
        run: |
          npm ci
          npm run build

      - name: CDK stack'i deploy et
        run: |
          npx cdk deploy preview-pr-${{ env.PR_NUMBER }} \
            --parameters prNumber=${{ env.PR_NUMBER }} \
            --parameters commitSha=${{ env.COMMIT_SHA }} \
            --require-approval never \
            --outputs-file cdk-outputs.json

      - name: Deployment URL'ini çıkar
        id: extract-url
        run: |
          PREVIEW_URL=$(jq -r '.["preview-pr-${{ env.PR_NUMBER }}"].PreviewURL' cdk-outputs.json)
          echo "url=$PREVIEW_URL" >> $GITHUB_OUTPUT

      - name: Deployment hazır olmasını bekle
        run: |
          for i in {1..30}; do
            if curl -f -s "${{ steps.extract-url.outputs.url }}/health" > /dev/null; then
              echo "Deployment hazır!"
              break
            fi
            echo "Deployment bekleniyor... ($i/30)"
            sleep 10
          done

      - name: E2E testleri çalıştır
        env:
          CYPRESS_BASE_URL: ${{ steps.extract-url.outputs.url }}
        run: |
          npm run test:e2e

      - name: PR yorumunu güncelle
        uses: actions/github-script@v7
        with:
          script: |
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const botComment = comments.find(comment =>
              comment.user.type === 'Bot' &&
              comment.body.includes('Preview Environment')
            );

            const body = `## Preview Environment

            **URL:** ${{ steps.extract-url.outputs.url }}
            **Durum:** Hazır
            **Commit:** \`${{ env.COMMIT_SHA }}\`

            E2E testler: Başarılı
            `;

            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: body
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: body
              });
            }

  cleanup-preview:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest

    steps:
      - name: Kodu checkout et
        uses: actions/checkout@v4

      - name: AWS credentials yapılandır
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: CDK stack'i yok et
        run: |
          npx cdk destroy preview-pr-${{ github.event.number }} --force

      - name: PR yorumunu güncelle
        uses: actions/github-script@v7
        with:
          script: |
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const botComment = comments.find(comment =>
              comment.user.type === 'Bot' &&
              comment.body.includes('Preview Environment')
            );

            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: botComment.body + '\n\n**Durum:** Temizlendi'
              });
            }

OIDC Authentication Kurulumu

Uzun süreli AWS credential’ları saklamak yerine, güvenli, geçici erişim için GitHub’ın OIDC provider’ını kullan:

// iam/github-oidc-role.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';

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

    // GitHub Actions için OIDC provider oluştur
    const provider = new iam.OpenIdConnectProvider(this, 'GitHubProvider', {
      url: 'https://token.actions.githubusercontent.com',
      clientIds: ['sts.amazonaws.com'],
      thumbprints: ['6938fd4d98bab03faadb97b34396831e3780aea1'],
    });

    // GitHub Actions için IAM role
    const role = new iam.Role(this, 'GitHubActionsRole', {
      assumedBy: new iam.WebIdentityPrincipal(
        provider.openIdConnectProviderArn,
        {
          StringEquals: {
            'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
          },
          StringLike: {
            'token.actions.githubusercontent.com:sub': 'repo:your-org/your-repo:*',
          },
        }
      ),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess'),
      ],
    });

    // CDK operasyonları için ek policy'ler
    role.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.Allow,
      actions: [
        'iam:CreateRole',
        'iam:DeleteRole',
        'iam:AttachRolePolicy',
        'iam:DetachRolePolicy',
        'iam:PassRole',
      ],
      resources: ['*'],
    }));
  }
}

Maliyet Optimizasyonu ve İzleme

Kaynak Boyutlandırma

Preview ortamlarının maliyet ile işlevsellik arasında denge kurması gerektiğini öğrendim. 72 saatlik bir preview ortamı için pratik maliyet breakdown’u:

// Maliyet optimizasyon konfigürasyonu
const previewConfig = {
  lambda: {
    memorySize: 256, // MB - çoğu workload için yeterli
    timeout: Duration.seconds(30),
    reservedConcurrency: 5, // Eşzamanlı execution'ları sınırla
  },
  apiGateway: {
    throttling: {
      rateLimit: 100,
      burstLimit: 200,
    },
  },
  cloudfront: {
    priceClass: cloudfront.PriceClass.PRICE_CLASS_100, // Sadece ABD ve Avrupa edge lokasyonları
    defaultTtl: Duration.hours(1), // Geliştirme için kısa TTL
  },
};

// 72 saatlik ortam başına tahmini maliyetler:
// - API Gateway: ~$0.12 (1000 request × $3.50/milyon)
// - Lambda: ~$0.08 (50 invocation × 1GB-sec × $0.0000166667)
// - CloudFront: ~$0.04 (1GB data transfer)
// - Route53: ~$0.02 (hosted zone sorguları)
// Toplam: Preview ortamı başına ~$0.26

Otomatik Temizlik

Temizlik otomasyonu maliyet kaçışını önlüyor ve ortamların birikmemesini sağlıyor:

// lib/cleanup-function.ts
import { CloudFormationClient, DeleteStackCommand, ListStacksCommand } from '@aws-sdk/client-cloudformation';

export const handler = async (event: any) => {
  const cfn = new CloudFormationClient({});

  try {
    // Süresi dolmuş TTL tag'leriyle stack'leri bul
    const { StackSummaries } = await cfn.send(new ListStacksCommand({
      StackStatusFilter: ['CREATE_COMPLETE', 'UPDATE_COMPLETE'],
    }));

    const expiredStacks = StackSummaries?.filter(stack => {
      if (!stack.StackName?.startsWith('preview-pr-')) return false;

      const ttlTag = stack.Tags?.find(tag => tag.Key === 'TTL');
      if (!ttlTag?.Value) return false;

      const expiryDate = new Date(ttlTag.Value);
      return expiryDate < new Date();
    }) || [];

    // Süresi dolmuş stack'leri sil
    for (const stack of expiredStacks) {
      console.log(`Süresi dolmuş stack siliniyor: ${stack.StackName}`);

      await cfn.send(new DeleteStackCommand({
        StackName: stack.StackName,
      }));
    }

    return {
      statusCode: 200,
      body: JSON.stringify({
        message: `${expiredStacks.length} süresi dolmuş stack temizlendi`,
        deletedStacks: expiredStacks.map(s => s.StackName),
      }),
    };
  } catch (error) {
    console.error('Temizlik başarısız:', error);
    throw error;
  }
};

E2E Testing Entegrasyonu

Cypress Konfigürasyonu

Preview ortamlarıyla E2E testing’i böyle entegre ettim:

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: process.env.CYPRESS_BASE_URL || 'http://localhost:3000',
    video: true,
    screenshotOnRunFailure: true,
    defaultCommandTimeout: 10000,
    requestTimeout: 15000,
    responseTimeout: 15000,

    setupNodeEvents(on, config) {
      // Başarısızlıkta screenshot al
      on('after:screenshot', (details) => {
        console.log('Screenshot alındı:', details.path);
      });

      // Preview ortam testing için özel komutlar
      on('task', {
        waitForDeployment() {
          // Deployment hazır olmasını beklemek için özel logic
          return null;
        },
      });
    },
  },
});

Güvenlik En İyi Pratikleri

Network Güvenliği

İnternete açık preview ortamlarıyla çalışırken, bu güvenlik pattern’lerinin iyi çalıştığını öğrendim:

// Lambda için VPC ve security group'lar
import * as ec2 from 'aws-cdk-lib/aws-ec2';

const vpc = new ec2.Vpc(this, 'PreviewVPC', {
  maxAzs: 2,
  natGateways: 1, // Maliyet optimizasyonu
  subnetConfiguration: [
    {
      cidrMask: 24,
      name: 'Private',
      subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
    },
    {
      cidrMask: 24,
      name: 'Public',
      subnetType: ec2.SubnetType.PUBLIC,
    },
  ],
});

const securityGroup = new ec2.SecurityGroup(this, 'LambdaSecurityGroup', {
  vpc,
  description: 'Preview Lambda function'ları için security group',
  allowAllOutbound: true,
});

// Inbound traffic'i kısıtla
securityGroup.addIngressRule(
  ec2.Peer.anyIpv4(),
  ec2.Port.tcp(443),
  'Sadece HTTPS traffic'
);

Secrets Yönetimi

Systems Manager Parameter Store ile gizli bilgiler. Lambda environment variable’ları parametre referansı. dbPassword.grantRead ile Lambda’ya okuma izni.

Gerçek Dünya Deneyimleri

Karşılaştığım Yaygın Tuzaklar

DNS Propagation Gecikmeleri: Route53 değişiklikleri yayılması 30-60 saniye sürebiliyor. Deployment’ları hazır olarak işaretlemeden önce health check’ler eklemeyi öğrendim.

Kaynak Temizlik Başarısızlıkları: Bazen CDK destroy operasyonları kaynak bağımlılıkları nedeniyle başarısız oluyor. İşe yarayan retry mekanizması:

# Gelişmiş temizlik script'i
#!/bin/bash
STACK_NAME="preview-pr-$1"
MAX_RETRIES=3

for i in $(seq 1 $MAX_RETRIES); do
  echo "$STACK_NAME stack'ini yok etme denemesi $i"

  if npx cdk destroy $STACK_NAME --force; then
    echo "Stack başarıyla yok edildi"
    exit 0
  fi

  if [ $i -lt $MAX_RETRIES ]; then
    echo "30 saniye sonra tekrar denenecek..."
    sleep 30
  fi
done

echo "$MAX_RETRIES denemeden sonra stack yok edilemedi"
exit 1

Cold Start Performance: Lambda cold start’lar ilk testlerin başarısız olmasına neden olabiliyor. Pre-warming yardımcı oluyor.

Performans Optimizasyonları

CloudFront Caching Stratejisi: Tazelik ile performansı dengele:

const distribution = new cloudfront.CloudFrontWebDistribution(this, 'Distribution', {
  originConfigs: [{
    customOriginSource: {
      domainName: api.restApiId + '.execute-api.' + this.region + '.amazonaws.com',
      originPath: '/prod',
    },
    behaviors: [
      {
        isDefaultBehavior: true,
        allowedMethods: cloudfront.CloudFrontAllowedMethods.ALL,
        cachedMethods: cloudfront.CloudFrontAllowedCachedMethods.GET_HEAD_OPTIONS,
        cachePolicyId: cloudfront.OriginRequestPolicyId.CORS_S3_ORIGIN,
        ttl: {
          default: Duration.minutes(5), // Geliştirme için kısa TTL
          max: Duration.hours(1),
          min: Duration.seconds(0),
        },
      },
    ],
  }],
});

İzleme ve Alarm

Maliyet İzleme

Bütçe sürprizlerini önlemek için PR başına harcamaları takip et. CloudWatch dashboard, Cost alarm.

// Preview ortamları için CloudWatch dashboard
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';

const dashboard = new cloudwatch.Dashboard(this, 'PreviewDashboard', {
  dashboardName: 'Preview-Environments',
  widgets: [
    [
      new cloudwatch.GraphWidget({
        title: 'Preview Ortam Maliyetleri',
        left: [
          new cloudwatch.Metric({
            namespace: 'AWS/Billing',
            metricName: 'EstimatedCharges',
            dimensionsMap: {
              Currency: 'USD',
            },
          }),
        ],
      }),
    ],
    [
      new cloudwatch.SingleValueWidget({
        title: 'Aktif Preview Ortamları',
        metrics: [
          new cloudwatch.Metric({
            namespace: 'Custom/Preview',
            metricName: 'ActiveEnvironments',
          }),
        ],
      }),
    ],
  ],
});

// Maliyet alarmı
const costAlarm = new cloudwatch.Alarm(this, 'PreviewCostAlarm', {
  metric: new cloudwatch.Metric({
    namespace: 'AWS/Billing',
    metricName: 'EstimatedCharges',
    dimensionsMap: {
      Currency: 'USD',
    },
  }),
  threshold: 50, // Aylık maliyetler $50'ı aşarsa alarm ver
  evaluationPeriods: 1,
});

Deployment Başarı Takibi

Custom/Preview namespace ile DeploymentSuccess metriği. Başarılı/başarısız deployment sayıları için CloudWatch metrik.

Ana Çıkarımlar

Bu pattern’i birden fazla projede uyguladıktan sonra öğrendiklerim:

  1. Basit Başla: Temel Lambda + API Gateway ile başla. Ekibin otomasyona alışması üzerine karmaşıklığı ekle.

  2. Maliyet Kontrolü Kritik: Düzgün tagging ve temizlik olmadan, preview ortamları hızla pahalı hale gelebilir. Otomatik temizlik tartışılmaz.

  3. Güvenlik İlk Günden: Uzun süreli credential’lar yerine OIDC kullan. Daha güvenli ve credential rotation baş ağrılarını ortadan kaldırıyor.

  4. Her Şeyi İzle: Başarısız deployment’lar ve kaçan maliyetler baştan düzgün izleme ile yakalanması çok daha kolay.

  5. Temizliği Test Et: Temizlik otomasyonun eninde sonunda başarısız olacak. Düzenli test et ve manuel fallback’leri hazır tut.

Otomasyon yatırımı hızla kendini geri ödüyor. Ekipler daha hızlı inceleme döngüleri, daha az staging ortam çakışması ve deployment’larında daha fazla güven rapor ediyor. En önemlisi, geliştirme iş akışlarını sıklıkla yavaşlatan sürtüşmeyi ortadan kaldırıyor.

Bu pattern ile çalışırken, deployment-to-ready sürelerini tutarlı şekilde 5 dakikanın altında görüyorum, maliyetler 72 saatlik ortam başına $0.30’un altında kalıyor. Sadece geliştirici deneyimi iyileştirmesi bile bu mimari pattern’i değerli kılıyor.

İlgili yazılar