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:
- CloudFront ile önbellekleme - Aynı yönlendirme için Lambda'yı neden 10.000 kez çalıştırasın?
- RDS yerine DynamoDB - Büyük ölçekte tahmin edilebilir performans, connection pooling baş ağrısı yok
- Ayrı Lambda fonksiyonları - İşler ters gittiğinde ölçekleme ve debug etmesi daha kolay
- 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.
# 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 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:
// 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:
// 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:
// 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:
// 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 <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:
// 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
Gününüzü Mahvetmeyecek Deploy Script'i#
// 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#
-
On-demand DynamoDB ile başla - Henüz erişim pattern'lerinizi bilmiyorsunuz. 3 ay sonra provisioned'a geçtik ve %60 tasarruf ettik.
-
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.
-
Agresif önbellekle - Bir saatte 2 milyon tıklama alan o viral link? CloudFront bizi $3,000'lık Lambda faturasından kurtardı.
-
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. -
İ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.
Bu Serideki Tüm Yazılar
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!
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!