İçeriğe atla

AWS CDK Link Kısaltıcı Bölüm 1: Proje Kurulumu & Temel Altyapı

AWS CDK, DynamoDB ve Lambda ile production-grade link kısaltıcı kurulumu. Mimari kararlar, proje yapısı ve ölçekte ayakta kalan şema seçimleri.

Ayhan Sipahi Ayhan Sipahi

AWS üzerinde link kısaltıcı yapmak; yönlendirme gecikmesi, URL doğrulama ve link bazlı analitik zorunlu hale gelene kadar kolay görünür. Aylık milyonlarca yönlendirmede her mimari karar (depolama motoru, caching katmanı, CDN konumu) hem maliyeti hem de kuyruk gecikmesini doğrudan etkiler.

Sağlam duran varsayılan şu: tek bir DynamoDB tablosu, her rota için ayrı ve küçük birer Lambda, yönlendirme yolunun önünde CloudFront. Proje yapısını ve tablo şemasını ilk günden doğru kurarsanız serinin sonraki bölümleri migration değil ekleme olur. Yönlendirme döngüleri, kötü niyetli URL’ler ve kötüye kullanım trafiği de sonraya bırakılacak başlıklar değil: create handler’ın yazmadan önce neyi doğrulayacağını bunlar belirler.

Mimariye Genel Bakış#

CDK kodunu yazmadan önce mimariyi çizin. Geri dönüşü pahalı olan kararlar depolama ve caching tarafında. Serinin geri kalanının üzerine kurulduğu yapı şöyle:

İzleme

Depolama Katmanı

API Katmanı

Kullanıcı Akışı

Kullanıcı

CloudFront CDN

API Gateway

Create Lambda

Redirect Lambda

Analytics Lambda

DynamoDB

DynamoDB Accelerator

CloudWatch

Alarmlar

Her katman kendi başına ölçekleniyor ve cache’ten dönen bir yönlendirme Lambda’ya da DynamoDB’ye de hiç uğramıyor. Önemli kararlar:

  1. CloudFront ile önbellekleme - Aynı yönlendirme için Lambda’yı neden 10.000 kez çalıştırasın?
  2. RDS yerine DynamoDB - Büyük ölçekte tahmin edilebilir performans, connection pooling baş ağrısı yok
  3. Ayrı Lambda fonksiyonları - İşler ters gittiğinde ölçekleme ve debug etmesi daha kolay
  4. Sıcak yollar için DAX - Çünkü o viral link veritabanınızı döver

CDK Proje Kurulumu#

cdk init ile yetinmeyin. Proje yapısına ayıracağınız birkaç dakika, stack sayısı arttığında sizi bir refactor’dan kurtarır. Ayrı stack’ler ve yeniden kullanılabilir construct’lar, environment’a özel konfigürasyonu handler’ların dışında tutar.

# TypeScript ile projeyi baştan oluştur
mkdir link-shortener && cd link-shortener
npx cdk init app --language typescript

# Gerçekten ihtiyacımız olan bağımlılıkları yükle (CDK v2)
npm install aws-cdk-lib@latest constructs@latest \
  @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb zod

# Akıl sağlığı için dev bağımlılıkları
npm install -D @types/aws-lambda @types/node esbuild \
  prettier eslint tsx \
  @typescript-eslint/parser @typescript-eslint/eslint-plugin

Proje yapınız şöyle görünmeli:

link-shortener/
├── bin/
│  └── link-shortener.ts  # CDK app giriş noktası
├── lib/
│  ├── stacks/
│  │  ├── api-stack.ts  # API Gateway + Lambda
│  │  ├── database-stack.ts  # DynamoDB tabloları
│  │  └── cdn-stack.ts  # CloudFront dağıtımı
│  └── constructs/
│  ├── link-table.ts  # DynamoDB construct
│  └── lambda-function.ts  # Yeniden kullanılabilir Lambda construct
├── src/
│  ├── handlers/
│  │  ├── create.ts  # Kısa link oluştur
│  │  ├── redirect.ts  # Yönlendirmeleri yönet
│  │  └── analytics.ts  # Tıklamaları takip et
│  └── utils/
│  ├── id-generator.ts  # Kısa ID üretimi
│  └── url-validator.ts  # URL doğrulama
├── test/
└── cdk.json

DynamoDB Şema Tasarımı#

Çoğu tutorial id ve url içeren basit bir tablo gösterir. Bu düzen; tekilleştirme, link bazlı analitik ve özel slug ihtiyacı çıktığı anda yetersiz kalır. İki GSI’lı tek tablo üçünü birden karşılar:

// lib/constructs/link-table.ts
import { Table, AttributeType, BillingMode, StreamViewType } from 'aws-cdk-lib/aws-dynamodb';
import { RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class LinkTable extends Construct {
  public readonly table: Table;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.table = new Table(this, 'LinksTable', {
      partitionKey: {
        name: 'PK',
        type: AttributeType.STRING,
      },
      sortKey: {
        name: 'SK',
        type: AttributeType.STRING,
      },
      billingMode: BillingMode.PAY_PER_REQUEST, // Buradan başla, pattern'lerini öğrenince provisioned'a geç
      pointInTimeRecovery: true, // Çünkü birisi önemli bir şeyi silecek
      stream: StreamViewType.NEW_AND_OLD_IMAGES, // Analitik ve debugging için
      removalPolicy: RemovalPolicy.RETAIN, // Production verisini asla yanlışlıkla silme
    });

    // Orijinal URL ile arama için GSI (tekilleştirme)
    this.table.addGlobalSecondaryIndex({
      indexName: 'GSI1',
      partitionKey: {
        name: 'GSI1PK',
        type: AttributeType.STRING,
      },
      sortKey: {
        name: 'GSI1SK',
        type: AttributeType.STRING,
      },
    });

    // Analitik sorguları için GSI
    this.table.addGlobalSecondaryIndex({
      indexName: 'GSI2',
      partitionKey: {
        name: 'GSI2PK',
        type: AttributeType.STRING,
      },
      sortKey: {
        name: 'CreatedAt',
        type: AttributeType.NUMBER,
      },
    });
  }
}

Neden bu şema? Tabloda tutulan kayıtların şekli şöyle:

// Tablodaki örnek kayıtlar
const linkRecord = {
  PK: 'LINK#abc123',  // Kısa kod
  SK: 'METADATA',  // Gelecekteki genişlemeye izin verir
  GSI1PK: 'URL#https://example.com/very/long/url',
  GSI1SK: 'LINK#abc123',  // Tekilleştirme için
  GSI2PK: 'USER#user123',  // Kim oluşturdu
  CreatedAt: 1706544000000,  // Sıralama için timestamp
  OriginalUrl: 'https://example.com/very/long/url',
  ClickCount: 0,
  ExpiresAt: 1738080000000,  // TTL
  Tags: ['campaign-2024', 'email'],
  CustomSlug: 'summer-sale',  // Opsiyonel özel slug
};

const clickRecord = {
  PK: 'LINK#abc123',
  SK: `CLICK#${Date.now()}#${uuid}`, // Benzersiz tıklama olayı
  UserAgent: 'Mozilla/5.0...',
  IPHash: 'hashed-ip',  // Gizlilik uyumlu
  Referer: 'https://twitter.com',
  Timestamp: 1706544000000,
};

Bu tasarım şunları sağlar:

  • Bir link için tüm veriyi tek istekle sorgula
  • URL’leri verimli tekilleştir
  • Analitik için bireysel tıklamaları takip et
  • Çakışma olmadan özel slug’ları destekle
  • TTL ile linkleri otomatik expire et

Create Handler#

Create handler URL’yi doğrular, GSI1 üzerinden tekilleştirir ve üretilen kısa ID çakıştığında yeniden dener:

// src/handlers/create.ts
import type { APIGatewayProxyHandlerV2WithLambdaAuthorizer } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
import { generateShortId } from '../utils/id-generator';
import { validateUrl } from '../utils/url-validator';

const client = new DynamoDBClient({});
const ddb = DynamoDBDocumentClient.from(client, {
  marshallOptions: { removeUndefinedValues: true },
});

const TABLE_NAME = process.env.TABLE_NAME!;
const DOMAIN = process.env.SHORT_DOMAIN!;

type AuthContext = { userId?: string };

export const handler: APIGatewayProxyHandlerV2WithLambdaAuthorizer<AuthContext> = async (event) => {
  const startTime = Date.now();
  const userId = event.requestContext.authorizer?.lambda?.userId;
  
  try {
    const body = JSON.parse(event.body || '{}');
    const { url, customSlug, expiresInDays = 365, tags = [] } = body;

    // URL'yi doğrula (production'da sık karşılaşılan bir sorun kaynağı)
    const validation = await validateUrl(url);
    if (!validation.isValid) {
      return {
        statusCode: 400,
        body: JSON.stringify({ 
          error: validation.error,
          details: validation.details 
        }),
      };
    }

    // Mevcut kısa link kontrolü (tekilleştirme)
    const existing = await ddb.send(new QueryCommand({
      TableName: TABLE_NAME,
      IndexName: 'GSI1',
      KeyConditionExpression: 'GSI1PK = :pk',
      ExpressionAttributeValues: {
        ':pk': `URL#${url}`,
      },
      Limit: 1,
    }));

    if (existing.Items?.length) {
      const existingLink = existing.Items[0];
      console.log(`Tekilleştirme bulundu: ${existingLink.PK}`);
      return {
        statusCode: 200,
        body: JSON.stringify({
          shortUrl: `${DOMAIN}/${existingLink.PK.replace('LINK#', '')}`,
          isNew: false,
          processingTime: Date.now() - startTime,
        }),
      };
    }

    // Çakışma tespiti ile kısa ID üret
    let shortId = customSlug || generateShortId();
    let attempts = 0;
    const maxAttempts = 5;

    while (attempts < maxAttempts) {
      try {
        await ddb.send(new PutCommand({
          TableName: TABLE_NAME,
          Item: {
            PK: `LINK#${shortId}`,
            SK: 'METADATA',
            GSI1PK: `URL#${url}`,
            GSI1SK: `LINK#${shortId}`,
            GSI2PK: `USER#${userId ?? 'ANONYMOUS'}`,
            CreatedAt: Date.now(),
            OriginalUrl: url,
            ClickCount: 0,
            ExpiresAt: Date.now() + (expiresInDays * 24 * 60 * 60 * 1000),
            Tags: tags,
            CreatedBy: userId,
            SourceIP: event.requestContext?.http?.sourceIp,
          },
          ConditionExpression: 'attribute_not_exists(PK)',
        }));
        
        break; // Başarılı!
      } catch (error: any) {
        if (error.name === 'ConditionalCheckFailedException') {
          if (customSlug) {
            return {
              statusCode: 409,
              body: JSON.stringify({ 
                error: 'Özel slug zaten mevcut',
                suggestion: generateShortId(),
              }),
            };
          }
          shortId = generateShortId(); // Başka ID dene
          attempts++;
        } else {
          throw error;
        }
      }
    }

    // Tüm denemeler çakıştı: yazılmamış bir kayıt için asla başarı dönme
    if (attempts >= maxAttempts) {
      return {
        statusCode: 503,
        body: JSON.stringify({ error: 'Kısa ID ayrılamadı, tekrar deneyin' }),
      };
    }

    return {
      statusCode: 201,
      body: JSON.stringify({
        shortUrl: `${DOMAIN}/${shortId}`,
        shortId,
        expiresAt: new Date(Date.now() + (expiresInDays * 24 * 60 * 60 * 1000)).toISOString(),
        processingTime: Date.now() - startTime,
      }),
    };
  } catch (error) {
    console.error('Kısa link oluşturma hatası:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({ 
        error: 'Sunucu hatası',
        requestId: event.requestContext?.requestId,
      }),
    };
  }
};

ID Üreteci Tasarımı#

Kütüphane seçimi, alfabe ve uzunluk kadar belirleyici değil. Daraltılmış bir alfabe üzerinde crypto.randomBytes kullanmak; kodu kısa, sesli okunduğunda karışmayan ve tahmin edilmesi zor tutar:

// src/utils/id-generator.ts
import { randomBytes } from 'crypto';

// Belirsiz karakterler (0, O, l, I) çıkarıldı; kod sesli okunduğunda karışmasın
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ID_LENGTH = 7; // 58 karakter, 58^7 yaklaşık 2,2 trilyon kombinasyon

export function generateShortId(length: number = ID_LENGTH): string {
  const bytes = randomBytes(length);
  let id = '';
  
  for (let i = 0; i < length; i++) {
    id += ALPHABET[bytes[i] % ALPHABET.length];
  }
  
  return id;
}

// Özel slug'lar için doğrulama kuralları
export function validateCustomSlug(slug: string): { valid: boolean; reason?: string } {
  if (slug.length < 3) {
    return { valid: false, reason: 'Çok kısa (min 3 karakter)' };
  }
  
  if (slug.length > 50) {
    return { valid: false, reason: 'Çok uzun (max 50 karakter)' };
  }
  
  // Sadece alfanümerik ve tire, alfanümerik ile başlayıp bitmeli
  if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/.test(slug)) {
    return { valid: false, reason: 'Geçersiz karakter veya format' };
  }
  
  // Gerçek rotaları gölgeleyecek rezerve kelimeler
  const reserved = ['api', 'admin', 'dashboard', 'login', 'logout', 'static', 'health'];
  if (reserved.includes(slug.toLowerCase())) {
    return { valid: false, reason: 'Rezerve kelime' };
  }
  
  return { valid: true };
}

Lokal Geliştirme Ortamı#

İlk günden lokal geliştirmeyi düzgün kurun. Her console.log değişikliğinde AWS’ye deploy etmek hem yavaş hem pahalıdır:

// local-dev.ts
import express from 'express';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { handler as createHandler } from './src/handlers/create';
import { handler as redirectHandler } from './src/handlers/redirect';

const app = express();
app.use(express.json());

// AWS servislerini lokal mockla
process.env.TABLE_NAME = 'local-links';
process.env.SHORT_DOMAIN = 'http://localhost:3000';
process.env.AWS_REGION = 'us-east-1';

// Lambda handler'ları Express için sarma
const lambdaToExpress = (handler: any) => async (req: any, res: any) => {
  const event = {
    body: JSON.stringify(req.body),
    pathParameters: req.params,
    queryStringParameters: req.query,
    requestContext: {
      http: {
        sourceIp: req.ip,
      },
      requestId: Math.random().toString(36),
    },
  };
  
  const result = await handler(event);
  res.status(result.statusCode).json(JSON.parse(result.body));
};

app.post('/create', lambdaToExpress(createHandler));
app.get('/:id', lambdaToExpress(redirectHandler));

app.listen(3000, () => {
  console.log('Lokal dev sunucusu http://localhost:3000 üzerinde çalışıyor');
  console.log('DynamoDB Local port 8000 üzerinde gerekli');
});

DynamoDB’yi lokal çalıştır:

docker run -p 8000:8000 amazon/dynamodb-local \
  -jar DynamoDBLocal.jar -sharedDb -inMemory

Deploy Script Yapılandırması#

// package.json scripts
{
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk",
    "local": "tsx watch local-dev.ts",
    "deploy:dev": "cdk deploy --all --context environment=dev",
    "deploy:prod": "cdk deploy --all --context environment=prod --require-approval never",
    "destroy:dev": "cdk destroy --all --context environment=dev",
    "synth": "cdk synth --quiet",
    "diff": "cdk diff --all"
  }
}

Sık Karşılaşılan Tuzaklar#

  1. On-demand DynamoDB ile başla: Erişim pattern’leri başta bilinmez. Trafik öngörülebilir hale gelince, provisioned kapasite tahminini on-demand faturasıyla karşılaştırıp öyle geçin.

  2. Tıklama loglarını örnekle: Her tıklama için bir log satırı, CloudWatch faturanızı trafikle aynı eğriye bindirir. Yaklaşık %1 örnekleyin, gerisi için metrik kullanın.

  3. Agresif önbellekle: Viral bir link bir saatte 500.000 tıklama alabilir. Cache dostu bir yönlendirme yanıtıyla bunların neredeyse tamamını CloudFront karşılar.

  4. URL’leri düzgün doğrula: Birisi javascript:alert('xss') için kısa link oluşturmaya çalışacak. Birisi yönlendirme döngüleri oluşturacak. Birisi servisi phishing için kullanacak. Bunları planlayın.

  5. İlk günden rate limiting: Olmadan, bir script bir ürün lansmanı sırasında 10 dakikada 100.000 link oluşturabilir.

Sonraki Adımlar#

Bölüm 2 redirect handler’ı ve önbellekleme stratejisini kuruyor, hacim büyüdükçe ucuz kalan analitiği ekliyor ve create endpoint’inin önüne rate limiting koyuyor. Seri sonrasında Bölüm 3 ile özel domainler, toplu işlemler ve güvenlik katmanlarına; Bölüm 4 ile deployment, maliyet ayarı ve monitoring’e; Bölüm 5 ile çok bölgeli ölçeklendirme ve uzun vadeli bakıma geçiyor.

Bu serinin tüm kodu GitHub (yeni sekmede açılır)’da.

Tek tablo, on-demand kapasite ve önde cache düzeni; yönlendirme trafiği okuma ağırlıklı olduğu ve kısa kodlar anlamsız kaldığı sürece geçerli. Analitik sorguları linkler arasında tarama gerektirmeye başladığında, düzenli trafik provisioned kapasiteyi ucuzlattığında veya bir uyum gereksinimi tıklama verisini aynı tablodan çıkarmaya zorladığında bu düzeni yeniden gözden geçirin.

Kaynaklar#

AWS CDK Link Kısaltıcı: Sıfırdan Production'a

AWS CDK, Node.js Lambda ve DynamoDB ile production-grade bir link kısaltma servisi kurulumu hakkında 5 bölümlük kapsamlı seri. Gerçek production hikayeleri, performans optimizasyonu ve maliyet yönetimi dahil.

İlerleme 1/5 yazı tamamlandı

İlgili yazılar