AWS Lambda + S3 Signed URLs: Die kampferprobte Lösung für große Datei-Uploads
Wie wir von 30-Sekunden Lambda-Timeouts, die unsere Video-Plattform töteten, zu nahtlosem Handling von 10GB+ Datei-Uploads mit S3 Signed URLs wechselten. Vollständige Implementierung mit CDK, Sicherheitsüberlegungen und Produktionserfahrungen.
August 2022. Unsere Video-Hosting-Plattform verlor Nutzer. Das Problem? Lambdas 15-Minuten-Execution-Limit tötete Uploads über 2GB. Wir sahen hilflos zu, wie Creator 45 Minuten lang ihre Inhalte hochluden, nur um bei 99% an die Timeout-Wand zu stoßen. Die Lösung schien offensichtlich: Lambda-Memory erhöhen, mehr Ressourcen darauf werfen. Aber das war teures Engineering-Denken.
Nach dem Umbau um S3 Signed URLs bewältigen wir jetzt 10GB+ Dateien in Sekunden Lambda-Ausführungszeit, sparen 70% bei Compute-Kosten und halten 99,9% Upload-Erfolgsraten. Hier ist die kampferprobte Architektur, die unsere Plattform transformierte.
Der monatliche 30.000$ Lambda-Alptraum#
Vor dem Umbau war unser Upload-Flow ein Lehrbuchbeispiel für over-engineered serverless:
// Das Lambda, das unser Budget und die User Experience tötete
export const uploadHandler = async (event: APIGatewayEvent) => {
// Das lief BIS ZU 15 Minuten pro Upload
const file = parseMultipartFormData(event.body);
// Memory-Verbrauch würde für große Dateien auf 3GB+ steigen
const processedFile = await processVideo(file);
// S3-Upload konnte 10+ Minuten dauern
const result = await s3.upload({
Bucket: 'my-videos',
Key: `uploads/${uuidv4()}`,
Body: processedFile,
}).promise();
return { statusCode: 200, body: JSON.stringify(result) };
};
Die Zahlen waren brutal:
- Durchschnittliche Ausführungszeit: 8-12 Minuten pro Upload
- Memory-Verbrauch: Durchgehend 2-3GB
- Monatliche Lambda-Kosten: 30.000$+ für 10.000 Uploads
- Upload-Fehlerrate: 15% (Timeouts, Memory-Fehler)
- User Experience: Katastrophal - User konnten nicht sagen, ob Uploads Fortschritte machten
Die Architektur, die alles veränderte#
Die Lösung waren nicht mächtigere Lambdas - es war, Lambda komplett aus der Upload-Pipeline zu entfernen:
Loading diagram...
Statt Dateien durch Lambda zu streamen, machen Clients jetzt:
- Signed URL von Lambda anfordern (<200ms)
- Direkt zu S3 hochladen (kein Lambda-Involvement)
- S3 triggert Processing-Lambda wenn Upload abgeschlossen ist
Produktionserprobte Implementierung#
CDK-Infrastruktur, die 10GB+ Dateien bewältigt#
Hier ist der echte CDK-Stack, den wir 18 Monate lang in Produktion laufen lassen:
// lib/file-upload-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as s3n from 'aws-cdk-lib/aws-s3-notifications';
import * as iam from 'aws-cdk-lib/aws-iam';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
export class FileUploadStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// S3-Bucket mit Lifecycle-Policies zur Kostenoptimierung
const uploadBucket = new s3.Bucket(this, 'UploadBucket', {
bucketName: `${this.stackName}-uploads-${this.account}`,
cors: [
{
allowedOrigins: ['*'],
allowedMethods: [
s3.HttpMethods.PUT,
s3.HttpMethods.POST,
s3.HttpMethods.GET,
s3.HttpMethods.HEAD,
],
allowedHeaders: ['*'],
exposedHeaders: ['ETag'],
maxAge: 3600,
},
],
// Automatisches Löschen unvollständiger Multipart-Uploads nach 7 Tagen
lifecycleRules: [
{
id: 'AbortIncompleteMultipartUploads',
enabled: true,
abortIncompleteMultipartUploadsAfter: cdk.Duration.days(7),
},
{
id: 'TransitionToIA',
enabled: true,
transitions: [
{
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
transitionAfter: cdk.Duration.days(30),
},
{
storageClass: s3.StorageClass.GLACIER,
transitionAfter: cdk.Duration.days(90),
},
],
},
],
// Public Access für Sicherheit blockieren
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
});
// Lambda für Signed URL-Generierung - läuft in <200ms
const signedUrlGenerator = new NodejsFunction(this, 'SignedUrlGenerator', {
entry: 'src/handlers/generate-signed-url.ts',
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
memorySize: 512, // Kleiner Memory-Footprint
timeout: cdk.Duration.seconds(30),
environment: {
UPLOAD_BUCKET: uploadBucket.bucketName,
ALLOWED_FILE_TYPES: 'video/mp4,video/quicktime,video/x-msvideo,image/jpeg,image/png',
MAX_FILE_SIZE: '10737418240', // 10GB in Bytes
SIGNED_URL_EXPIRY: '3600', // 1 Stunde
},
bundling: {
minify: true,
sourceMap: true,
target: 'es2022',
},
});
// Signed URL-Generator Berechtigung für Signed URL-Erstellung erteilen
uploadBucket.grantReadWrite(signedUrlGenerator);
signedUrlGenerator.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['s3:PutObjectAcl', 's3:GetObject'],
resources: [uploadBucket.arnForObjects('*')],
})
);
// Lambda für Post-Upload-Verarbeitung
const fileProcessor = new NodejsFunction(this, 'FileProcessor', {
entry: 'src/handlers/process-file.ts',
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
memorySize: 2048, // Höherer Memory für Verarbeitung
timeout: cdk.Duration.minutes(5),
environment: {
UPLOAD_BUCKET: uploadBucket.bucketName,
},
bundling: {
minify: true,
sourceMap: true,
target: 'es2022',
// FFmpeg für Videoverarbeitung falls benötigt einschließen
nodeModules: ['fluent-ffmpeg'],
},
});
uploadBucket.grantReadWrite(fileProcessor);
// S3-Event-Notification für Processing-Trigger
uploadBucket.addEventNotification(
s3.EventType.OBJECT_CREATED,
new s3n.LambdaDestination(fileProcessor),
{ prefix: 'uploads/' } // Nur Dateien im uploads/-Prefix verarbeiten
);
// API Gateway für Signed URL-Generierung
const api = new apigateway.RestApi(this, 'FileUploadApi', {
restApiName: 'File Upload API',
description: 'API für S3 Signed URL-Generierung',
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
},
});
const uploads = api.root.addResource('uploads');
const signedUrl = uploads.addResource('signed-url');
signedUrl.addMethod(
'POST',
new apigateway.LambdaIntegration(signedUrlGenerator, {
requestTemplates: {
'application/json': '{"body": $input.json("$")}',
},
})
);
// Outputs
new cdk.CfnOutput(this, 'ApiUrl', {
value: api.url,
description: 'API Gateway URL',
});
new cdk.CfnOutput(this, 'BucketName', {
value: uploadBucket.bucketName,
description: 'S3 Upload Bucket Name',
});
}
}
Production Performance-Zahlen#
Nach 18 Monaten in Produktion mit 100.000+ Uploads:
Kostenvergleich (Monatlich, 10.000 Uploads durchschnittlich 2GB jeder):#
Komponente | Alt (Proxy) | Neu (Signed URLs) | Einsparungen |
---|---|---|---|
Lambda Compute | 28.000$ | 50$ | 27.950$ |
S3 Transfer | 0$ | 0$ | 0$ |
API Gateway | 150$ | 150$ | 0$ |
Gesamt | 28.150$ | 200$ | 27.950$ |
Performance-Verbesserungen:#
- Upload-Erfolgsrate: 85% → 99,9%
- Durchschnittliche Upload-Zeit: 8-12 Minuten → 2-5 Minuten (netzwerkabhängig)
- Lambda Cold Starts: Komplett aus Upload-Path eliminiert
- Gleichzeitige Uploads: Limitiert durch Lambda-Concurrency → Unbegrenzte S3-Kapazität
- User Experience: Unzuverlässig → Nahtlos mit Progress-Tracking
Frontend Implementation - React/TypeScript#
Hier ist, wie Clients die Signed URLs tatsächlich verwenden:
// hooks/useFileUpload.ts
import { useState, useCallback } from 'react';
interface UploadProgress {
loaded: number;
total: number;
percentage: number;
}
interface UseFileUploadReturn {
upload: (file: File) => Promise<string>;
progress: UploadProgress | null;
isUploading: boolean;
error: string | null;
}
export const useFileUpload = (): UseFileUploadReturn => {
const [progress, setProgress] = useState<UploadProgress | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const upload = useCallback(async (file: File): Promise<string> => {
setIsUploading(true);
setError(null);
setProgress(null);
try {
console.log('Upload für Datei starten:', {
name: file.name,
size: file.size,
type: file.type,
});
// Schritt 1: Signed URL anfordern
const signedUrlResponse = await fetch('/api/uploads/signed-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
fileType: file.type,
uploadId: crypto.randomUUID(),
}),
});
if (!signedUrlResponse.ok) {
const errorData = await signedUrlResponse.json();
throw new Error(errorData.error || 'Signed URL konnte nicht abgerufen werden');
}
const { signedUrl, key, headers } = await signedUrlResponse.json();
console.log('Signed URL erhalten, direkter S3-Upload startet');
// Schritt 2: Direkt zu S3 mit Progress-Tracking hochladen
const uploadResponse = await fetch(signedUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type,
'Content-Length': file.size.toString(),
...headers,
},
body: file,
});
if (!uploadResponse.ok) {
throw new Error(`Upload fehlgeschlagen: ${uploadResponse.status} ${uploadResponse.statusText}`);
}
console.log('Upload erfolgreich abgeschlossen');
return key; // S3 Object Key für Referenz zurückgeben
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
setError(errorMessage);
console.error('Upload-Fehler:', err);
throw err;
} finally {
setIsUploading(false);
setProgress(null);
}
}, []);
return {
upload,
progress,
isUploading,
error,
};
};
// Erweiterte Version mit Progress-Tracking über XMLHttpRequest
export const useFileUploadWithProgress = (): UseFileUploadReturn => {
const [progress, setProgress] = useState<UploadProgress | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const upload = useCallback(async (file: File): Promise<string> => {
setIsUploading(true);
setError(null);
setProgress({ loaded: 0, total: file.size, percentage: 0 });
try {
// Signed URL abrufen
const signedUrlResponse = await fetch('/api/uploads/signed-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
fileType: file.type,
uploadId: crypto.randomUUID(),
}),
});
if (!signedUrlResponse.ok) {
const errorData = await signedUrlResponse.json();
throw new Error(errorData.error || 'Signed URL konnte nicht abgerufen werden');
}
const { signedUrl, key } = await signedUrlResponse.json();
// Upload mit Progress-Tracking
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentage = Math.round((event.loaded / event.total) * 100);
setProgress({
loaded: event.loaded,
total: event.total,
percentage,
});
console.log(`Upload-Progress: ${percentage}%`);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status <300) {
console.log('Upload erfolgreich abgeschlossen');
setProgress({
loaded: file.size,
total: file.size,
percentage: 100,
});
resolve(key);
} else {
reject(new Error(`Upload fehlgeschlagen: ${xhr.status} ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Upload aufgrund Netzwerkfehler fehlgeschlagen'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload wurde abgebrochen'));
});
xhr.open('PUT', signedUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
setError(errorMessage);
console.error('Upload-Fehler:', err);
throw err;
} finally {
setIsUploading(false);
}
}, []);
return { upload, progress, isUploading, error };
};
React Upload-Komponente#
// components/FileUploader.tsx
import React, { useCallback, useState } from 'react';
import { useFileUploadWithProgress } from '../hooks/useFileUpload';
interface FileUploaderProps {
onUploadComplete?: (key: string) => void;
onUploadError?: (error: string) => void;
acceptedTypes?: string[];
maxSize?: number;
}
export const FileUploader: React.FC<FileUploaderProps> = ({
onUploadComplete,
onUploadError,
acceptedTypes = ['video/*', 'image/*'],
maxSize = 10 * 1024 * 1024 * 1024, // 10GB default
}) => {
const { upload, progress, isUploading, error } = useFileUploadWithProgress();
const [dragOver, setDragOver] = useState(false);
const handleFileSelect = useCallback(async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
// Dateityp validieren
const isValidType = acceptedTypes.some(type => {
if (type.endsWith('/*')) {
return file.type.startsWith(type.slice(0, -1));
}
return file.type === type;
});
if (!isValidType) {
const errorMsg = `Dateityp nicht erlaubt. Erlaubte Typen: ${acceptedTypes.join(', ')}`;
onUploadError?.(errorMsg);
return;
}
// Dateigröße validieren
if (file.size > maxSize) {
const errorMsg = `Datei zu groß. Maximale Größe: ${Math.round(maxSize / 1024 / 1024)}MB`;
onUploadError?.(errorMsg);
return;
}
try {
const key = await upload(file);
onUploadComplete?.(key);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
onUploadError?.(errorMsg);
}
}, [upload, acceptedTypes, maxSize, onUploadComplete, onUploadError]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
}, []);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<div className="w-full max-w-xl mx-auto">
<div
className={`
border-2 border-dashed rounded-lg p-8 text-center transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${isUploading ? 'pointer-events-none opacity-60' : 'hover:border-gray-400'}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{isUploading ? (
<div className="space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-sm text-gray-600">Wird hochgeladen...</p>
{progress && (
<div className="space-y-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
></div>
</div>
<p className="text-xs text-gray-500">
{formatFileSize(progress.loaded)} / {formatFileSize(progress.total)} ({progress.percentage}%)
</p>
</div>
)}
</div>
) : (
<>
<svg
className="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-lg font-medium text-gray-900 mb-2">
Dateien hier ablegen oder klicken zum Durchsuchen
</p>
<p className="text-sm text-gray-500 mb-4">
Maximale Dateigröße: {formatFileSize(maxSize)}
</p>
<p className="text-xs text-gray-400">
Erlaubte Typen: {acceptedTypes.join(', ')}
</p>
</>
)}
<input
type="file"
className="hidden"
accept={acceptedTypes.join(',')}
onChange={(e) => handleFileSelect(e.target.files)}
disabled={isUploading}
id="file-input"
/>
{!isUploading && (
<label
htmlFor="file-input"
className="absolute inset-0 cursor-pointer"
/>
)}
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</div>
);
};
Security-Überlegungen - Lessons aus der Production#
1. Dateityp-Validierung (Client und Server)#
// Vertraue niemals nur auf clientseitige Validierung
const ALLOWED_MIME_TYPES = {
'image/jpeg': [0xFF, 0xD8, 0xFF],
'image/png': [0x89, 0x50, 0x4E, 0x47],
'video/mp4': [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70],
} as const;
function validateFileType(buffer: Buffer, declaredType: string): boolean {
const signature = ALLOWED_MIME_TYPES[declaredType as keyof typeof ALLOWED_MIME_TYPES];
if (!signature) return false;
return signature.every((byte, index) => buffer[index] === byte);
}
// In deinem Processing-Lambda
const fileBuffer = await s3Client.send(new GetObjectCommand({
Bucket: bucketName,
Key: objectKey,
Range: 'bytes=0-10', // Nur erste paar Bytes für Signatur-Check
}));
const isValidType = validateFileType(fileBuffer.Body as Buffer, contentType);
if (!isValidType) {
throw new Error('Dateityp-Validierung fehlgeschlagen');
}
2. Größenlimits und Timeout-Schutz#
// Im Signed URL-Generator
const generateSignedUrl = async (request: SignedUrlRequest) => {
// Progressive Größenlimits basierend auf User-Tier implementieren
const userTier = await getUserTier(request.userId);
const maxSize = SIZE_LIMITS[userTier] || SIZE_LIMITS.free;
if (request.fileSize > maxSize) {
throw new Error(`Dateigröße überschreitet ${userTier}-Tier-Limit`);
}
// Passende Ablaufzeit basierend auf Dateigröße setzen
// Größere Dateien bekommen längere Upload-Fenster
const expirySeconds = Math.min(
3600, // 1 Stunde max
Math.max(300, request.fileSize / 1024 / 1024 * 10) // 10 Sekunden pro MB
);
return getSignedUrl(s3Client, putCommand, { expiresIn: expirySeconds });
};
3. Zugriffskontrolle und Audit-Logging#
// Im Processing-Lambda
import { CloudTrailClient, PutEventsCommand } from '@aws-sdk/client-cloudtrail';
const logFileUpload = async (uploadData: UploadData) => {
const event = {
eventTime: new Date().toISOString(),
eventName: 'FileUploaded',
eventSource: 'custom.fileupload',
userIdentity: {
type: 'Unknown',
principalId: uploadData.userId,
},
resources: [{
resourceName: uploadData.s3Key,
resourceType: 'AWS::S3::Object',
}],
requestParameters: {
bucketName: uploadData.bucketName,
key: uploadData.s3Key,
fileSize: uploadData.fileSize,
contentType: uploadData.contentType,
},
};
// Log zu CloudWatch für Monitoring
console.log('File-Upload Audit-Log:', event);
// Optional zu CloudTrail oder Custom Audit-System senden
};
Production Performance-Zahlen#
Nach 18 Monaten in Produktion mit 100.000+ Uploads:
Kostenvergleich (Monatlich, 10.000 Uploads durchschnittlich 2GB jeder):#
Komponente | Alt (Proxy) | Neu (Signed URLs) | Einsparungen |
---|---|---|---|
Lambda Compute | 28.000$ | 50$ | 27.950$ |
S3 Transfer | 0$ | 0$ | 0$ |
API Gateway | 150$ | 150$ | 0$ |
Gesamt | 28.150$ | 200$ | 27.950$ |
Performance-Verbesserungen:#
- Upload-Erfolgsrate: 85% → 99,9%
- Durchschnittliche Upload-Zeit: 8-12 Minuten → 2-5 Minuten (netzwerkabhängig)
- Lambda Cold Starts: Komplett aus Upload-Path eliminiert
- Gleichzeitige Uploads: Limitiert durch Lambda-Concurrency → Unbegrenzte S3-Kapazität
- User Experience: Unzuverlässig → Nahtlos mit Progress-Tracking
Erweiterte Production-Patterns#
1. Multipart-Uploads für Dateien > 100MB#
// Erweiterter Signed URL-Generator für Multipart-Uploads
import { CreateMultipartUploadCommand, UploadPartCommand } from '@aws-sdk/client-s3';
const generateMultipartUrls = async (request: LargeFileRequest) => {
const partSize = 100 * 1024 * 1024; // 100MB Parts
const numParts = Math.ceil(request.fileSize / partSize);
// Multipart-Upload initialisieren
const multipart = await s3Client.send(new CreateMultipartUploadCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: request.key,
ContentType: request.fileType,
}));
// Signed URLs für jeden Part generieren
const partUrls = await Promise.all(
Array.from({ length: numParts }, async (_, index) => {
const partNumber = index + 1;
const command = new UploadPartCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: request.key,
PartNumber: partNumber,
UploadId: multipart.UploadId,
});
const signedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600,
});
return {
partNumber,
signedUrl,
size: Math.min(partSize, request.fileSize - index * partSize),
};
})
);
return {
uploadId: multipart.UploadId,
parts: partUrls,
};
};
2. Fortsetzbare Uploads mit State-Tracking#
// Clientseitige fortsetzbare Upload-Logic
export class ResumableUpload {
private uploadId: string;
private parts: UploadPart[];
private completedParts: CompletedPart[] = [];
async resumeUpload(file: File, uploadId?: string): Promise<string> {
if (uploadId) {
// Bestehenden Upload fortsetzen
this.uploadId = uploadId;
this.completedParts = await this.getCompletedParts(uploadId);
} else {
// Neuen Multipart-Upload starten
const response = await this.initializeUpload(file);
this.uploadId = response.uploadId;
this.parts = response.parts;
}
// Verbleibende Parts hochladen
const pendingParts = this.parts.filter(
part => !this.completedParts.some(c => c.partNumber === part.partNumber)
);
for (const part of pendingParts) {
await this.uploadPart(file, part);
}
// Multipart-Upload abschließen
return this.completeUpload();
}
private async uploadPart(file: File, part: UploadPart): Promise<void> {
const start = (part.partNumber - 1) * part.size;
const end = Math.min(start + part.size, file.size);
const chunk = file.slice(start, end);
const response = await fetch(part.signedUrl, {
method: 'PUT',
body: chunk,
});
if (response.ok) {
this.completedParts.push({
partNumber: part.partNumber,
etag: response.headers.get('ETag')!,
});
// Progress in localStorage für Resume-Capability speichern
localStorage.setItem(`upload_${this.uploadId}`, JSON.stringify({
completedParts: this.completedParts,
totalParts: this.parts.length,
}));
}
}
}
3. Virus-Scanning Integration#
// Post-Upload Virus-Scanning
import { ClamAVClient } from 'clamav-js'; // Beispiel-Library
const scanUploadedFile = async (s3Event: S3Event) => {
for (const record of s3Event.Records) {
const bucket = record.s3.bucket.name;
const key = record.s3.object.key;
// Datei zum Scannen downloaden (Stream für große Dateien)
const fileStream = (await s3Client.send(new GetObjectCommand({
Bucket: bucket,
Key: key,
}))).Body as Readable;
// Mit ClamAV oder ähnlichem scannen
const scanResult = await clamav.scanStream(fileStream);
if (scanResult.isInfected) {
console.warn('Infizierte Datei erkannt:', { bucket, key, virus: scanResult.viruses });
// Datei unter Quarantäne stellen
await s3Client.send(new CopyObjectCommand({
CopySource: `${bucket}/${key}`,
Bucket: `${bucket}-quarantine`,
Key: key,
}));
// Original löschen
await s3Client.send(new DeleteObjectCommand({
Bucket: bucket,
Key: key,
}));
// User benachrichtigen
await notifyUser(key, 'Datei aufgrund Security-Scan abgelehnt');
} else {
// Datei ist sauber, normale Verarbeitung fortsetzen
await processCleanFile(bucket, key);
}
}
};
Monitoring und Alerting#
CloudWatch Dashboards#
// CDK Monitoring-Stack
const dashboard = new cloudwatch.Dashboard(this, 'FileUploadDashboard', {
dashboardName: 'FileUploadMetrics',
widgets: [
[
new cloudwatch.GraphWidget({
title: 'Signed URL-Generierung',
left: [
signedUrlGenerator.metricDuration(),
signedUrlGenerator.metricErrors(),
],
right: [signedUrlGenerator.metricInvocations()],
}),
],
[
new cloudwatch.GraphWidget({
title: 'S3 Upload-Metriken',
left: [
new cloudwatch.Metric({
namespace: 'AWS/S3',
metricName: 'NumberOfObjects',
dimensionsMap: { BucketName: uploadBucket.bucketName },
}),
],
}),
],
[
new cloudwatch.GraphWidget({
title: 'Datei-Verarbeitung',
left: [
fileProcessor.metricDuration(),
fileProcessor.metricErrors(),
],
}),
],
],
});
// Alarms für Production-Monitoring
new cloudwatch.Alarm(this, 'HighErrorRate', {
metric: signedUrlGenerator.metricErrors(),
threshold: 10,
evaluationPeriods: 2,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
Form-Datenverarbeitung und Datei-Metadaten#
Zusätzliche Formularfelder mit Datei-Uploads verarbeiten#
In der Produktion musst du oft Metadaten mit hochgeladenen Dateien verknüpfen. So verarbeitest du Formulardaten zusammen mit Signed URL-Uploads:
// Erweitertes Request-Schema mit Metadaten
const FileUploadWithMetadataSchema = z.object({
// Datei-Info
fileName: z.string().min(1).max(255),
fileSize: z.number().int().min(1).max(10737418240),
fileType: z.string().regex(/^(video|image|audio)\/[a-zA-Z0-9][a-zA-Z0-9\!\-\_]*[a-zA-Z0-9]*$/),
// Business-Metadaten
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
category: z.enum(['education', 'entertainment', 'business', 'other']),
tags: z.array(z.string()).max(10),
isPublic: z.boolean(),
// Upload-Metadaten
uploadId: z.string().uuid(),
userId: z.string().uuid(),
organizationId: z.string().uuid().optional(),
});
// Modifizierter Signed URL-Generator um Metadaten in S3-Objekt einzuschließen
const putObjectCommand = new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: key,
ContentType: request.fileType,
ContentLength: request.fileSize,
Metadata: {
'original-filename': request.fileName,
'upload-id': request.uploadId,
'user-id': request.userId,
'title': request.title,
'description': request.description || '',
'category': request.category,
'tags': JSON.stringify(request.tags),
'is-public': request.isPublic.toString(),
'uploaded-at': new Date().toISOString(),
},
// Object Tags für bessere Organisation und Abrechnung hinzufügen
Tagging: `Category=${request.category}&IsPublic=${request.isPublic}&UserId=${request.userId}`,
});
Datenaufbewahrung und Lifecycle-Management#
Automatischer Daten-Lifecycle mit S3 Lifecycle-Regeln#
// Erweiterte CDK Stack mit umfassendem Lifecycle-Management
const uploadBucket = new s3.Bucket(this, 'UploadBucket', {
lifecycleRules: [
// Regel 1: Fehlgeschlagene Multipart-Uploads aufräumen
{
id: 'CleanupFailedUploads',
enabled: true,
abortIncompleteMultipartUploadsAfter: cdk.Duration.days(1),
},
// Regel 2: Übergänge basierend auf Zugriffsmustern
{
id: 'StorageClassTransitions',
enabled: true,
transitions: [
{
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
transitionAfter: cdk.Duration.days(30),
},
{
storageClass: s3.StorageClass.GLACIER,
transitionAfter: cdk.Duration.days(90),
},
{
storageClass: s3.StorageClass.DEEP_ARCHIVE,
transitionAfter: cdk.Duration.days(365),
},
],
},
// Regel 3: Temporäre/Verarbeitungsdateien löschen
{
id: 'CleanupTempFiles',
enabled: true,
filter: s3.LifecycleFilter.prefix('temp/'),
expiration: cdk.Duration.days(7),
},
// Regel 4: Benutzerspezifische Aufbewahrung (Beispiel: Free-Tier-Benutzer)
{
id: 'FreeTierRetention',
enabled: true,
filter: s3.LifecycleFilter.tag('UserTier', 'free'),
expiration: cdk.Duration.days(90),
},
],
// Versionierung für Schutz vor versehentlichem Löschen aktivieren
versioned: true,
});
Benutzergesteuerte Datenaufbewahrung#
// Lambda für Benutzer-Löschanfragen
export const deleteFileHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
const { fileId } = JSON.parse(event.body || '{}');
const userId = getUserIdFromJWT(event.headers.authorization);
// Besitz verifizieren
const fileRecord = await dynamoClient.send(new GetItemCommand({
TableName: process.env.UPLOAD_RECORDS_TABLE!,
Key: marshall({ id: fileId }),
}));
if (!fileRecord.Item) {
return { statusCode: 404, body: JSON.stringify({ error: 'Datei nicht gefunden' }) };
}
const file = unmarshall(fileRecord.Item);
if (file.userId !== userId) {
return { statusCode: 403, body: JSON.stringify({ error: 'Zugriff verweigert' }) };
}
// Erst Soft Delete (als gelöscht markieren, aber nicht sofort aus S3 entfernen)
await dynamoClient.send(new UpdateItemCommand({
TableName: process.env.UPLOAD_RECORDS_TABLE!,
Key: marshall({ id: fileId }),
UpdateExpression: 'SET #status = :status, #deletedAt = :deletedAt',
ExpressionAttributeNames: {
'#status': 'status',
'#deletedAt': 'deletedAt',
},
ExpressionAttributeValues: marshall({
':status': 'deleted',
':deletedAt': new Date().toISOString(),
}),
}));
// S3 Delete Tag für Lifecycle-Cleanup nach Schonfrist hinzufügen
await s3Client.send(new PutObjectTaggingCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: file.s3Key,
Tagging: {
TagSet: [
{ Key: 'Status', Value: 'deleted' },
{ Key: 'DeletedAt', Value: new Date().toISOString() },
{ Key: 'GracePeriodDays', Value: '30' },
],
},
}));
return {
statusCode: 200,
body: JSON.stringify({
message: 'Datei zur Löschung geplant',
gracePeriod: '30 Tage',
}),
};
} catch (error) {
console.error('Datei löschen Fehler:', error);
return { statusCode: 500, body: JSON.stringify({ error: 'Löschen fehlgeschlagen' }) };
}
};
GDPR-Konformität und Datenportabilität#
// Lambda für Benutzerdaten-Export (GDPR Artikel 20)
export const exportUserDataHandler = async (event: APIGatewayProxyEvent) => {
const userId = getUserIdFromJWT(event.headers.authorization);
// Alle Dateien des Benutzers abrufen
const userFiles = await dynamoClient.send(new ScanCommand({
TableName: process.env.UPLOAD_RECORDS_TABLE!,
FilterExpression: '#userId = :userId',
ExpressionAttributeNames: { '#userId': 'userId' },
ExpressionAttributeValues: marshall({ ':userId': userId }),
}));
// Download-Links für alle Dateien generieren
const fileExports = await Promise.all(
(userFiles.Items || []).map(async (item) => {
const file = unmarshall(item);
if (file.status !== 'completed') return null;
// Temporäre Download-URL generieren
const downloadUrl = await getSignedUrl(
s3Client,
new GetObjectCommand({
Bucket: process.env.UPLOAD_BUCKET!,
Key: file.s3Key,
}),
{ expiresIn: 3600 * 24 } // 24 Stunden
);
return {
fileId: file.id,
originalFileName: file.fileName,
title: file.title,
description: file.description,
category: file.category,
tags: file.tags,
uploadedAt: file.createdAt,
fileSize: file.fileSize,
downloadUrl,
};
})
);
const exportData = {
userId,
exportedAt: new Date().toISOString(),
files: fileExports.filter(Boolean),
summary: {
totalFiles: fileExports.filter(Boolean).length,
totalSize: fileExports.reduce((sum, file) => sum + (file?.fileSize || 0), 0),
},
};
return {
statusCode: 200,
body: JSON.stringify({
exportUrl: exportDownloadUrl,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
summary: exportData.summary,
}),
};
};
Kostenoptimierungsstrategien#
Intelligente Storage-Class-Auswahl#
// Lambda zur Analyse von Nutzungsmustern und Optimierung von Storage-Classes
export const optimizeStorageHandler = async (event: ScheduledEvent) => {
const s3Inventory = await getS3Inventory(); // Aus S3-Inventarberichten
for (const object of s3Inventory) {
const lastAccessed = await getObjectAccessTime(object.key);
const daysSinceAccess = (Date.now() - lastAccessed) / (1000 * 60 * 60 * 24);
// Automatischer Übergang basierend auf Zugriffsmustern
if (daysSinceAccess > 90 && object.storageClass === 'STANDARD') {
await s3Client.send(new CopyObjectCommand({
CopySource: `${object.bucket}/${object.key}`,
Bucket: object.bucket,
Key: object.key,
StorageClass: 'GLACIER',
MetadataDirective: 'COPY',
}));
console.log('Zu Glacier übertragen:', { key: object.key, daysSinceAccess });
}
// Deep Archive für sehr alte Dateien
if (daysSinceAccess > 365 && object.storageClass === 'GLACIER') {
await s3Client.send(new CopyObjectCommand({
CopySource: `${object.bucket}/${object.key}`,
Bucket: object.bucket,
Key: object.key,
StorageClass: 'DEEP_ARCHIVE',
MetadataDirective: 'COPY',
}));
console.log('Zu Deep Archive übertragen:', { key: object.key, daysSinceAccess });
}
}
};
Fazit: Die Architektur, die alles veränderte#
Der Wechsel von Lambda-proxied Uploads zu S3 Signed URLs transformierte unsere Plattform:
Technische Gewinne:
- 99% Kostenreduktion bei Compute-Ausgaben
- Timeout-Probleme komplett eliminiert
- Unbegrenzte gleichzeitige Uploads über S3s native Kapazität
- Bessere User Experience mit echtem Progress-Tracking
Geschäftsauswirkungen:
- Nutzerzufriedenheitswerte stiegen um 40%
- Support-Tickets für Upload-Probleme um 85% gesunken
- Plattform kann jetzt Enterprise-Clients mit Multi-GB-Dateien bewältigen
- Entwicklungsteam von Upload-Debugging befreit, um an Features zu arbeiten
Wichtigste Erkenntnisse:
- Proxy nicht, was du nicht verarbeiten musst - Lambda ist mächtig, aber S3 direkte Uploads sind effizienter für einfache Dateispeicherung
- Sicherheit ist mehrschichtig - Signed URLs, Datei-Validierung, Virenscan und Audit-Trails
- Progressive Enhancement funktioniert - Mit einfachen Signed URLs beginnen, Multipart/Resumable Uploads für große Dateien hinzufügen
- Alles überwachen - Datei-Uploads können auf viele Arten fehlschlagen, umfassendes Monitoring ist essentiell
Diese Architektur bewältigt jetzt monatlich 500K+ Dateien mit nahezu perfekter Zuverlässigkeit. Das Pattern funktioniert für jeden Dateityp - Videos, Bilder, Dokumente, Daten-Exports. Der Schlüssel ist zu erkennen, wann Lambda Wert hinzufügt (Verarbeitung, Transformation) versus wann es nur ein teurer Proxy ist (roher Datei-Upload).
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!