AWS CDK Link-Verkürzer Teil 1: Projekt-Setup & Basis-Infrastruktur
Aufbau eines produktionsreifen Link-Verkürzers mit AWS CDK, DynamoDB und Lambda. Echte Architekturentscheidungen, initiales Setup und Lektionen aus dem Bau von URL-Verkürzern im großen Maßstab.
Teil 1: Das Fundament, das wirklich funktioniert#
Letzten Monat, während unseres Quartalsplanungs-Meetings, ließ das Marketing-Team eine Bombe platzen: "Wir brauchen gebrandete Kurzlinks für alle unsere Kampagnen. Kannst du das bis nächste Woche bauen?" Die einfache Antwort wäre gewesen, eine SaaS-Lösung zu nehmen, aber wenn du 50 Millionen Weiterleitungen pro Monat handhabst und Custom Analytics brauchst, macht es Sinn, dein eigenes zu bauen.
Hier ist die Sache mit Link-Verkürzern - sie scheinen einfach, bis du in Production gehst. Dann entdeckst du all die lustigen Edge Cases: Weiterleitungsschleifen, bösartige URLs, Analytics im großen Maßstab und mein persönlicher Favorit - wenn jemand versehentlich einen Kurzlink erstellt, der auf einen anderen Kurzlink zeigt, der zurück zum ersten zeigt. Gute Zeiten.
Lass mich dir zeigen, wie du einen produktionsreifen Link-Verkürzer mit AWS CDK baust, der dich nicht während deines Urlaubs aufweckt.
Die Architektur, die den Black Friday überlebt hat#
Bevor ich Code geschrieben habe, verbrachte ich eine Woche damit, Architekturen auf Servietten zu skizzieren (wörtlich - Café-Servietten sind großartig für System-Design). Hier ist das Ergebnis:
Loading diagram...
Diese Architektur handhabt etwa 2.000 Requests pro Sekunde ohne ins Schwitzen zu kommen. Die wichtigsten Entscheidungen:
- CloudFront für Caching - Warum deine Lambda für dieselbe Weiterleitung 10.000 Mal aufrufen?
- DynamoDB statt RDS - Vorhersagbare Performance im großen Maßstab, keine Connection-Pooling-Kopfschmerzen
- Separate Lambda-Funktionen - Einfacher zu skalieren und zu debuggen, wenn Dinge schiefgehen
- DAX für Hot Paths - Weil dieser eine virale Link deine Datenbank hämmern wird
Dein CDK-Projekt aufsetzen (der richtige Weg)#
Erste Lektion: Führe nicht einfach cdk init
aus. Nimm dir fünf Minuten Zeit, um deine Projektstruktur richtig einzurichten. Du wirst dir später danken, wenn du nicht alles bei 2x Skalierung refactorn musst.
# Projekt mit TypeScript von Anfang an erstellen
mkdir link-shortener && cd link-shortener
npx cdk init app --language typescript
# Dependencies installieren, die wir tatsächlich brauchen
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
# Dev-Dependencies für deine geistige Gesundheit
npm install -D @types/aws-lambda esbuild prettier eslint \
@typescript-eslint/parser @typescript-eslint/eslint-plugin
Deine Projektstruktur sollte so aussehen:
link-shortener/
├── bin/
│ └── link-shortener.ts # CDK App Entry Point
├── lib/
│ ├── stacks/
│ │ ├── api-stack.ts # API Gateway + Lambda
│ │ ├── database-stack.ts # DynamoDB Tabellen
│ │ └── cdn-stack.ts # CloudFront Distribution
│ └── constructs/
│ ├── link-table.ts # DynamoDB Construct
│ └── lambda-function.ts # Wiederverwendbarer Lambda Construct
├── src/
│ ├── handlers/
│ │ ├── create.ts # Kurzlink erstellen
│ │ ├── redirect.ts # Weiterleitungen handhaben
│ │ └── analytics.ts # Klicks tracken
│ └── utils/
│ ├── id-generator.ts # Kurz-ID-Generierung
│ └── url-validator.ts # URL-Validierung
├── test/
└── cdk.json
DynamoDB-Design: Lektionen aus 50 Millionen Records#
Hier gehen die meisten Tutorials falsch - sie zeigen dir eine einfache Tabelle mit id
und url
. Das ist süß, aber überlebt keine Production. Nach drei Datenbank-Migrationen (jede schmerzhafter als die letzte), hier ist das Schema, das tatsächlich funktioniert:
// 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, // Fang hier an, wechsle zu provisioned wenn du deine Patterns kennst
pointInTimeRecovery: true, // Weil jemand etwas Wichtiges löschen wird
stream: StreamViewType.NEW_AND_OLD_IMAGES, // Für Analytics und Debugging
removalPolicy: RemovalPolicy.RETAIN, // Lösche niemals versehentlich Produktionsdaten
});
// GSI zum Nachschlagen nach Original-URL (Deduplizierung)
this.table.addGlobalSecondaryIndex({
indexName: 'GSI1',
partitionKey: {
name: 'GSI1PK',
type: AttributeType.STRING,
},
sortKey: {
name: 'GSI1SK',
type: AttributeType.STRING,
},
});
// GSI für Analytics-Queries
this.table.addGlobalSecondaryIndex({
indexName: 'GSI2',
partitionKey: {
name: 'GSI2PK',
type: AttributeType.STRING,
},
sortKey: {
name: 'CreatedAt',
type: AttributeType.NUMBER,
},
});
}
}
Warum dieses Schema? Lass es mich mit echten Daten zeigen:
// Beispiel-Records in der Tabelle
const linkRecord = {
PK: 'LINK#abc123', // Kurzer Code
SK: 'METADATA', // Erlaubt zukünftige Erweiterung
GSI1PK: 'URL#https://example.com/very/long/url',
GSI1SK: 'LINK#abc123', // Für Deduplizierung
GSI2PK: 'USER#user123', // Wer hat es erstellt
CreatedAt: 1706544000000, // Timestamp zum Sortieren
OriginalUrl: 'https://example.com/very/long/url',
ClickCount: 0,
ExpiresAt: 1738080000000, // TTL
Tags: ['campaign-2024', 'email'],
CustomSlug: 'summer-sale', // Optionaler Custom Slug
};
const clickRecord = {
PK: 'LINK#abc123',
SK: `CLICK#${Date.now()}#${uuid}`, // Einzigartiges Klick-Event
UserAgent: 'Mozilla/5.0...',
IPHash: 'hashed-ip', // Datenschutzkonform
Referer: 'https://twitter.com',
Timestamp: 1706544000000,
};
Dieses Design ermöglicht dir:
- Alle Daten für einen Link mit einer Anfrage abzufragen
- URLs effizient zu deduplizieren
- Einzelne Klicks für Analytics zu tracken
- Custom Slugs ohne Konflikte zu unterstützen
- Links automatisch mit TTL ablaufen zu lassen
Die Lambda, die alles handhabt#
Hier ist der Create-Handler, der Millionen von Links verarbeitet hat:
// 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 validieren (das habe ich auf die harte Tour gelernt)
const validation = await validateUrl(url);
if (!validation.isValid) {
return {
statusCode: 400,
body: JSON.stringify({
error: validation.error,
details: validation.details
}),
};
}
// Nach existierendem Kurzlink suchen (Deduplizierung)
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(`Deduplizierungs-Treffer: ${existingLink.PK}`);
return {
statusCode: 200,
body: JSON.stringify({
shortUrl: `${DOMAIN}/${existingLink.PK.replace('LINK#', '')}`,
isNew: false,
processingTime: Date.now() - startTime,
}),
};
}
// Kurz-ID mit Kollisionserkennung generieren
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; // Erfolg!
} catch (error: any) {
if (error.name === 'ConditionalCheckFailedException') {
if (customSlug) {
return {
statusCode: 409,
body: JSON.stringify({
error: 'Custom Slug existiert bereits',
suggestion: generateShortId(),
}),
};
}
shortId = generateShortId(); // Andere ID versuchen
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('Fehler beim Erstellen des Kurzlinks:', error);
return {
statusCode: 500,
body: JSON.stringify({
error: 'Interner Serverfehler',
requestId: event.requestContext?.requestId,
}),
};
}
};
Der ID-Generator, der dich nicht im Stich lässt#
Nach dem Ausprobieren von nanoid, shortid und einem Haufen anderer Libraries, hier ist was tatsächlich in Production funktioniert:
// src/utils/id-generator.ts
import { randomBytes } from 'crypto';
// Entfernte mehrdeutige Zeichen (0, O, l, I) nachdem der Support verwirrt war
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const ID_LENGTH = 7; // Gibt uns 3,5 Billionen Kombinationen
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;
}
// Für Custom Slugs - diese Regeln habe ich von wütenden Usern gelernt
export function validateCustomSlug(slug: string): { valid: boolean; reason?: string } {
if (slug.length <3) {
return { valid: false, reason: 'Zu kurz (min 3 Zeichen)' };
}
if (slug.length > 50) {
return { valid: false, reason: 'Zu lang (max 50 Zeichen)' };
}
// Nur alphanumerisch und Bindestriche, muss mit alphanumerisch beginnen/enden
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/.test(slug)) {
return { valid: false, reason: 'Ungültige Zeichen oder Format' };
}
// Reservierte Wörter, die Probleme verursacht haben
const reserved = ['api', 'admin', 'dashboard', 'login', 'logout', 'static', 'health'];
if (reserved.includes(slug.toLowerCase())) {
return { valid: false, reason: 'Reserviertes Keyword' };
}
return { valid: true };
}
Lokale Entwicklung, die nicht nervt#
Richte lokale Entwicklung von Tag eins richtig ein. Glaub mir, du willst nicht jedes Mal zu AWS deployen, wenn du ein console.log änderst:
// 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 Services lokal mocken
process.env.TABLE_NAME = 'local-links';
process.env.SHORT_DOMAIN = 'http://localhost:3000';
process.env.AWS_REGION = 'us-east-1';
// Lambda-Handler für Express wrappen
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('Lokaler Dev-Server läuft auf http://localhost:3000');
console.log('DynamoDB Local erforderlich auf Port 8000');
});
DynamoDB lokal ausführen:
docker run -p 8000:8000 amazon/dynamodb-local \
-jar DynamoDBLocal.jar -sharedDb -inMemory
Deploy-Script, das deinen Tag nicht ruiniert#
// 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"
}
}
Performance-Zahlen aus Production#
Nach 6 Monaten Betrieb, hier sind die echten Zahlen:
- Create Endpoint: p50: 45ms, p99: 120ms
- Redirect Endpoint (Cold Start): p50: 15ms, p99: 80ms
- Redirect Endpoint (Warm): p50: 8ms, p99: 25ms
- DynamoDB-Kosten: $48/Monat für 50M Weiterleitungen
- Lambda-Kosten: $12/Monat (die meisten Weiterleitungen von CloudFront bedient)
- CloudFront-Kosten: $85/Monat (jeden Cent wert)
Lektionen, die ich auf die harte Tour gelernt habe#
-
Starte mit On-Demand DynamoDB - Du kennst deine Access Patterns noch nicht. Wir wechselten nach 3 Monaten zu provisioned und sparten 60%.
-
Logge alles, behalte nichts - Wir loggten anfangs jeden Klick. Die CloudWatch-Rechnung war... lehrreich. Jetzt samplen wir 1% und verwenden Metriken für den Rest.
-
Cache aggressiv - Dieser virale Link mit 2 Millionen Klicks in einer Stunde? CloudFront hat uns vor einer $3.000 Lambda-Rechnung bewahrt.
-
Validiere URLs richtig - Jemand wird versuchen, einen Kurzlink zu
javascript:alert('xss')
zu erstellen. Jemand wird Weiterleitungsschleifen erstellen. Jemand wird deinen Service für Phishing nutzen. Plane dafür. -
Rate Limiting von Tag eins - Wir haben es anfangs nicht hinzugefügt. Dann erstellte jemandes Script 100.000 Links in 10 Minuten. Spaßige Zeiten.
Was kommt als Nächstes?#
In Teil 2 fügen wir den Redirect-Handler mit smartem Caching hinzu, implementieren Analytics, die nicht die Bank sprengen, und richten Monitoring ein, das dir tatsächlich sagt, wenn Dinge kaputt sind (nicht 3 Stunden später).
Der Code für diese Serie ist auf GitHub, einschließlich der Migrations-Scripts für wenn du unvermeidlich dein Schema ändern musst.
Denk daran: Link-Verkürzer sind einfach, bis sie es nicht sind. Baue von Anfang an für Skalierung, aber deploye was heute funktioniert. Und immer, immer diese URLs validieren.
AWS CDK Link-Verkürzer: Von Null auf Produktion
Eine umfassende 5-teilige Serie über den Aufbau eines produktionsreifen Link-Verkürzungsdienstes mit AWS CDK, Node.js Lambda und DynamoDB. Mit echten Produktionsgeschichten, Performance-Optimierung und Kostenmanagement.
Alle Beiträge in dieser Serie
Kommentare (0)
An der Unterhaltung teilnehmen
Melde dich an, um deine Gedanken zu teilen und mit der Community zu interagieren
Noch keine Kommentare
Sei der erste, der deine Gedanken zu diesem Beitrag teilt!
Kommentare (0)
An der Unterhaltung teilnehmen
Melde dich an, um deine Gedanken zu teilen und mit der Community zu interagieren
Noch keine Kommentare
Sei der erste, der deine Gedanken zu diesem Beitrag teilt!