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.

Bölüm 1: Gerçekten Çalışan Temel#

Geçen ay, çeyreklik planlama toplantısı sırasında pazarlama ekibi bomba patlattı: "Tüm kampanyalarımız için markalı kısa linkler gerekiyor. Gelecek haftaya kadar yapabilir misin?" Kolay cevap bir SaaS çözümü almak olurdu, ama ayda 50 milyon yönlendirme yapıyorsanız ve özel analitik istiyorsanız, kendi çözümünüzü yapmak mantıklı olmaya başlıyor.

Link kısaltıcıların işi şu - production'a çıkana kadar basit görünüyorlar. Sonra tüm eğlenceli edge case'leri keşfediyorsun: yönlendirme döngüleri, kötü niyetli URL'ler, büyük ölçekte analitik ve benim kişisel favorim - birisi yanlışlıkla başka bir kısa linke, o da ilkine geri dönen bir kısa link oluşturduğunda. Güzel zamanlar.

AWS CDK ile tatilinizdeyken sizi uyandırmayacak production-grade bir link kısaltıcı yapmayı göstereyim.

Kara Cuma'yı Atlatan Mimari#

Kod yazmadan önce, bir hafta boyunca peçetelere mimari çizdim (gerçekten - kahve dükkanı peçeteleri sistem tasarımı için harika). İşte sonuç:

Loading diagram...

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 Projenizi Kurma (Doğru Şekilde)#

İ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.

Bash
# 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:

Text
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 Tasarımı: 50 Milyon Kayıttan Dersler#

İşte çoğu tutorial'ın yanlış yaptığı yer - size id ve url içeren basit bir tablo gösteriyorlar. Şirin, ama production'da yaşamaz. Üç veritabanı migration'ından sonra (her biri bir öncekinden daha acı verici), işte gerçekten çalışan şema:

TypeScript
// 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 göstereyim:

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

Her Şeyi Halleten Lambda#

İşte milyonlarca link işleyen create handler:

TypeScript
// 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 (bunu zor yoldan öğrendim)
    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,
      }),
    };
  }
};

Sizi Yarı Yolda Bırakmayacak ID Üretici#

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

TypeScript
// 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 - bu kuralları kızgın kullanıcılardan öğrendim
export function validateCustomSlug(slug: string): { valid: boolean; reason?: string } {
  if (slug.length &lt;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 };
}

Can Sıkmayan Lokal Geliştirme#

İlk günden lokal geliştirmeyi düzgün kurun. İnanın, her console.log değişikliğinde AWS'ye deploy etmek istemezsiniz:

TypeScript
// 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:

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

Gününüzü Mahvetmeyecek Deploy Script'i#

JSON
// 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: 50M yönlendirme için $48/ay
  • Lambda maliyeti: $12/ay (çoğu yönlendirme CloudFront'tan)
  • CloudFront maliyeti: $85/ay (her kuruşuna değer)

Zor Yoldan Öğrenilenler#

  1. On-demand DynamoDB ile başla - Henüz erişim pattern'lerinizi bilmiyorsunuz. 3 ay sonra provisioned'a geçtik ve %60 tasarruf ettik.

  2. Her şeyi logla, hiçbir şeyi saklama - Başta her tıklamayı logladık. CloudWatch faturası... öğretici oldu. Şimdi %1 örnekleme yapıyor, gerisi için metrik kullanıyoruz.

  3. Agresif önbellekle - Bir saatte 2 milyon tıklama alan o viral link? CloudFront bizi $3,000'lık Lambda faturasından kurtardı.

  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 servisinizi phishing için kullanacak. Bunları planlayın.

  5. İlk günden rate limiting - Başta eklemedik. Sonra birisinin script'i 10 dakikada 100,000 link oluşturdu. Eğlenceli zamanlar.

Sırada Ne Var?#

Bölüm 2'de akıllı önbellekleme ile redirect handler ekleyeceğiz, cebinizi yakmayacak analitik uygulayacağız ve işler bozulduğunda (3 saat sonra değil) gerçekten haber veren monitoring kuracağız.

Bu serinin kodu GitHub'da, şemanızı kaçınılmaz olarak değiştirmeniz gerektiğinde migration script'leri dahil.

Unutmayın: link kısaltıcılar ta ki olmayıncaya kadar basittir. Baştan ölçek için inşa edin, ama bugün çalışanı deploy edin. Ve her zaman, her zaman o URL'leri doğrulayın.

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.

İlerleme1/5 yazı tamamlandı
Loading...

Yorumlar (0)

Sohbete katıl

Düşüncelerini paylaşmak ve toplulukla etkileşim kurmak için giriş yap

Henüz yorum yok

Bu yazı hakkında ilk düşüncelerini paylaşan sen ol!

Related Posts