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:

  1. CloudFront für Caching - Warum deine Lambda für dieselbe Weiterleitung 10.000 Mal aufrufen?
  2. DynamoDB statt RDS - Vorhersagbare Performance im großen Maßstab, keine Connection-Pooling-Kopfschmerzen
  3. Separate Lambda-Funktionen - Einfacher zu skalieren und zu debuggen, wenn Dinge schiefgehen
  4. 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.

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

Text
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:

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

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

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

TypeScript
// 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 &lt;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:

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

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

Deploy-Script, das deinen Tag nicht ruiniert#

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"
  }
}

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#

  1. Starte mit On-Demand DynamoDB - Du kennst deine Access Patterns noch nicht. Wir wechselten nach 3 Monaten zu provisioned und sparten 60%.

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

  3. Cache aggressiv - Dieser virale Link mit 2 Millionen Klicks in einer Stunde? CloudFront hat uns vor einer $3.000 Lambda-Rechnung bewahrt.

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

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

Fortschritt1/5 Beiträge abgeschlossen
Loading...

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!

Related Posts