İçeriğe atla
Ayhan Sipahi Ayhan Sipahi

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. Gerçek mimari kararlar, ilk kurulum ve büyük ölçekte URL kısaltıcıları inşa etmenin dersleri.

Seri Navigasyonu

Bu, production-grade link kısaltıcı oluşturma üzerine 5 bölümlük bir serinin 1. Bölümü:

  1. Bölüm 1: Proje Kurulumu & Temel Altyapı (Buradasınız)
  2. Bölüm 2: Temel İşlevsellik & API Geliştirme
  3. Bölüm 3: Gelişmiş Özellikler & Güvenlik
  4. Bölüm 4: Production Deployment & Optimizasyon
  5. Bölüm 5: Ölçeklendirme & Bakım

Giriş: Gerçek Dünya Ölçeği

AWS üzerinde link kısaltıcı yapmak, yönlendirme gecikmesi, URL doğrulama ve link bazlı analitik ölçekte zorunlu hale gelene kadar kolay görünür; ancak bu noktada SaaS çözümleri maliyet açısından sürdürülemez hale gelir. Aylık milyonlarca yönlendirmede her mimari karar (depolama motoru, caching katmanı, CDN konumlandırması) hem maliyeti hem de kuyruk gecikmesini doğrudan etkiler. Bu yazı, production-grade bir shortener’ın temelini oluşturan CDK proje kurulumunu, DynamoDB şemasını ve Lambda bağlantısını ele alıyor.

Link kısaltıcılar production’a çıkana kadar basit görünür. Sonra eğlenceli edge case’ler başlar: yönlendirme döngüleri, kötü niyetli URL’ler, büyük ölçekte analitik ve klasik hata modu; birinin büyük bir kampanya lansmanı sırasında yanlışlıkla ilk kısa linke dönen bir kısa link oluşturması. Bu tür senaryolar rate limiting ve URL doğrulama olmadan hızla sorun yaratır.

Bu yazı, AWS CDK ile tatilinizdeyken sizi uyandırmayacak production-grade bir link kısaltıcı yapmayı gösteriyor. DynamoDB single-table design, Lambda optimizasyonu ve CloudFront caching dahil gerçek mimari kararları kapsıyor; tümü yüksek trafik ortamlarında test edilmiş. Bölüm 1 altyapı kurulumuna, Bölüm 2 redirect motoruna odaklanıyor.

Yük Altında Tutarlı Kalan Mimari

Kod yazmadan önce bir hafta boyunca mimari çizmek yatırıma değer. Production yükü altında dayanıklılığını kanıtlamış bir mimari aşağıda:

İ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

Bu mimari saniyede 2.000 isteği terlemeden hallediyor. Ö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

İlk ders: sadece cdk init çalıştırmayın. Beş dakika ayırıp proje yapınızı düzgün kurun. 2x ölçekte her şeyi refactor etmediğinizde kendinize teşekkür edeceksiniz. Ayrı stack’ler ve construct’lar ile modüler yapı, ileride environment-specific konfigürasyonlar için hayat kurtarır.

# 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
npm install @aws-cdk/aws-lambda-nodejs @aws-cdk/aws-dynamodb \
  @aws-cdk/aws-apigatewayv2 @aws-cdk/aws-apigatewayv2-integrations \
  @aws-cdk/aws-cloudfront @aws-cdk/aws-cloudfront-origins

# Akıl sağlığı için dev bağımlılıkları
npm install -D @types/aws-lambda esbuild prettier eslint \
  @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 production’da yaşamaz. Birkaç veritabanı migration’ından sonra gerçekten çalışan şema şudur:

// 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? Gerçek veriyle açıklanalım:

// 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 Implementasyonu

İşte milyonlarca link işleyen create handler:

// src/handlers/create.ts
import { APIGatewayProxyHandlerV2 } 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!;

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const startTime = Date.now();
  
  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: event.requestContext?.authorizer?.userId || 'ANONYMOUS',
            CreatedAt: Date.now(),
            OriginalUrl: url,
            ClickCount: 0,
            ExpiresAt: Date.now() + (expiresInDays * 24 * 60 * 60 * 1000),
            Tags: tags,
            CreatedBy: event.requestContext?.authorizer?.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;
        }
      }
    }

    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ı

nanoid, shortid ve bir sürü başka kütüphaneyi denedikten sonra, production’da gerçekten çalışan:

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

// Destek ekibi karıştırdıktan sonra belirsiz karakterleri kaldırdım (0, O, l, I)
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ID_LENGTH = 7; // 3.5 trilyon kombinasyon verir

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 - production deneyiminden gelen 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' };
  }
  
  // Sorun yaratan 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"
  }
}

Production’dan Performans Rakamları

6 ay çalıştırdıktan sonra, işte gerçek rakamlar:

  • Create endpoint: p50: 45ms, p99: 120ms
  • Redirect endpoint (cold start): p50: 15ms, p99: 80ms
  • Redirect endpoint (warm): p50: 8ms, p99: 25ms
  • DynamoDB maliyeti: 5-10M yönlendirme için ~$6.25/ay (25M okuma birimi @ milyon başına $0.25)
  • Lambda maliyeti: $12/ay (çoğu yönlendirme CloudFront’tan)
  • CloudFront maliyeti: $85/ay (her kuruşuna değer)

Üretim Deneyiminden Çıkarımlar

  1. On-demand DynamoDB ile başla: Erişim pattern’leri başta bilinmez. 3 ay sonra provisioned’a geçmek genellikle %60 tasarruf sağlar.

  2. Her şeyi logla, hiçbir şeyi saklama: Başta her tıklamayı loglamak öğretici bir CloudWatch faturası üretir. %1 örnekleme yapıp gerisi için metrik kullanmak daha iyi bir yaklaşımdır.

  3. Agresif önbellekle: Bir saatte 500,000 tıklama alan viral bir link, CloudFront’un büyük bir Lambda faturasını önlediği yerdir.

  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.

Serinin Devamı

Temel işlevselliği uygulamaya hazır mısınız? Bölüm 2: Temel İşlevsellik & API Geliştirme’de:

  • Akıllı önbellekleme stratejileriyle redirect handler oluşturacağız
  • Cebinizi yakmayacak analitik uygulayacağız
  • Rate limiting ve kötüye kullanım önleme ekleyeceğiz
  • İşler bozulduğunda gerçekten haber veren monitoring kuracağız

Seriye Genel Bakış

  • Bölüm 3: Özel domainler, QR kodları ve toplu işlemler dahil gelişmiş özellikler
  • Bölüm 4: Blue-green deployment’lar ve sıfır kesinti migration’ları ile production deployment
  • Bölüm 5: Ölçeklendirme stratejileri ve uzun vadeli bakım pattern’leri

Bu serinin tüm kodu GitHub’da, migration script’leri ve performans testleri dahil.

Link kısaltıcılar trafik ve kötüye kullanım yüzeyi ortaya çıkana kadar basit görünür. Baştan ölçek için tasarlayın, bugün çalışanı deploy edin ve her URL’yi doğrulayın.

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