Effiziente Bildauslieferung: ein eigenes CDN erstellen
Ein einzelner Express-Server ist ein Bild-Origin, kein globales CDN. Diese Anleitung baut einen begrenzten öffentlichen Bild-Origin mit Sharp und Redis. Stellen Sie ein CDN davor, um Edge-Auslieferung und TLS zu erhalten, und setzen Sie eine geeignete Richtlinie zur Missbrauchskontrolle durch.
Voraussetzungen
Verwenden Sie Node.js 24 oder neuer und Redis 7 oder neuer. Veröffentlichen Sie ausschließlich geprüfte, unveränderliche JPEG- oder PNG-Dateien in einem Verzeichnis, das Anwendungsnutzer nicht verändern können. Dieses Beispiel hat keine Route zum Upload oder zur Authentifizierung.
Vorteile eines CDN für Bilder
Ein CDN kann wiederholte Übertragungen von Ihrem Origin reduzieren und zwischengespeicherte Antworten näher an die Leser bringen. Eine Größenänderung vor der Auslieferung verhindert, dass ein Bild in voller Auflösung an ein kleines Display geht. Messen Sie beide Effekte mit Ihren eigenen Dateien und Standorten: Ein Redis-Treffer am Origin ist kein Treffer im Edge-Cache.
So funktioniert ein CDN (in 60 Sekunden)
Bei einem Miss fordert das CDN eine Variante von Ihrem Origin an. Der Origin validiert die Anfrage, liest eine freigegebene Quelle, verarbeitet sie und gibt eine cachefähige Antwort zurück. Nachfolgende Anfragen können entweder den Edge-Cache oder Redis treffen. Jeder Cache muss Quellversionen, Abmessungen und Formate unterscheiden.
Wesentliche Komponenten für ein eigenes Bild-CDN
Trennen Sie privaten Quellspeicher, eine explizite Veröffentlichungsliste, einen begrenzten Decoder, einen verwerfbaren Ergebnis-Cache und eine Auslieferungsschicht. Redis ist nicht die maßgebliche Datenquelle. Verwenden Sie kein öffentliches Caching für private Dateien.
Das Projekt einrichten
Erstellen Sie package.json:
{
"name": "image-origin",
"private": true,
"type": "module",
"scripts": { "start": "node server.js" },
"dependencies": { "express": "5.2.1", "redis": "6.2.1", "sharp": "0.35.4" }
}
Führen Sie npm install aus und committen Sie die erzeugte Datei package-lock.json; nachfolgende
Installationen können npm ci verwenden. Erstellen Sie ein Verzeichnis images mit Ihrer eigenen
geprüften Datei namens photo-v1.jpg.
Starten Sie einen verwerfbaren lokalen Redis-Cache mit einem Speicherlimit:
redis-server --bind 127.0.0.1 --maxmemory 128mb --maxmemory-policy allkeys-lru
Einen minimalen Bildoptimierer bauen
Legen Sie dieses vollständige Modul in optimizer.js ab. Der Aufrufer liefert einen Dateinamen aus seiner
Veröffentlichungs-Map, nicht eine beliebige URL. Die realpath-Prüfung weist außerdem Quellen
außerhalb des Veröffentlichungsverzeichnisses zurück.
import { open, realpath } from 'node:fs/promises'
import { resolve, sep } from 'node:path'
import sharp from 'sharp'
const MAX_BYTES = 8 * 1024 * 1024
const MAX_PIXELS = 12_000_000
sharp.concurrency(1)
sharp.cache(false)
export async function optimize(filename, size, format) {
const root = await realpath(resolve('images'))
const path = await realpath(resolve(root, filename))
if (!path.startsWith(root + sep)) throw new Error('Source is outside the publishing directory.')
const handle = await open(path, 'r')
let data
try {
const stat = await handle.stat()
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_BYTES) {
throw new Error('Source size is unsupported.')
}
data = Buffer.alloc(MAX_BYTES + 1)
let length = 0
while (length < data.length) {
const { bytesRead } = await handle.read(data, length, data.length - length, null)
if (bytesRead === 0) break
length += bytesRead
}
if (length === 0 || length > MAX_BYTES) throw new Error('Source size is unsupported.')
data = data.subarray(0, length)
} finally {
await handle.close()
}
const pipeline = sharp(data, { limitInputPixels: MAX_PIXELS, failOn: 'warning' })
const metadata = await pipeline.metadata()
if (!['jpeg', 'png'].includes(metadata.format) || (metadata.pages ?? 1) !== 1) {
throw new Error('Only single-frame JPEG and PNG sources are supported.')
}
pipeline.rotate().resize({ width: size, height: size, fit: 'inside', withoutEnlargement: true })
// Sharp strips source metadata by default, including EXIF/GPS.
return format === 'jpeg'
? pipeline.flatten({ background: 'white' }).jpeg({ quality: 80 }).toBuffer()
: pipeline.webp({ quality: 80 }).toBuffer()
}
Das Ergebnis passt in das angeforderte Quadrat, ohne vergrößert zu werden oder das Seitenverhältnis zu ändern. JPEG setzt transparente Pixel auf einem weißen Hintergrund zusammen; WebP kann Transparenz erhalten.
Express mit Caching und Sicherheit einrichten
Legen Sie diese Anwendung in server.js ab. Fehlt f, wird WebP oder JPEG ausgehandelt. Das
aufgelöste Format, nicht nur die Anfrage-URL, ist Teil des Redis-Keys.
import express from 'express'
import { createClient } from 'redis'
import { optimize } from './optimizer.js'
const published = new Map([['photo.jpg', 'photo-v1.jpg']])
const sizes = new Set(['320', '640', '1280'])
const types = new Map([['webp', 'image/webp'], ['jpeg', 'image/jpeg']])
const CACHE_SECONDS = 3600
const app = express()
app.disable('x-powered-by')
const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
disableOfflineQueue: true,
socket: { connectTimeout: 2000, reconnectStrategy: false },
})
redis.on('error', () => console.error('Image cache connection failed.'))
let active = 0
async function cacheCommand(command) {
// Redis command timeouts only cover queued work; close a stalled in-flight connection too.
const timer = setTimeout(() => {
if (redis.isOpen) redis.destroy()
}, 2000)
try {
return await command()
} finally {
clearTimeout(timer)
}
}
function fail(res, status, message) {
return res.status(status).set('Cache-Control', 'no-store').type('text').send(message)
}
function sendImage(res, format, bytes) {
return res.type(types.get(format)).set('Cache-Control', `public, max-age=${CACHE_SECONDS}`)
.set('X-Content-Type-Options', 'nosniff').send(bytes)
}
app.get('/images/:name', async (req, res) => {
const filename = published.get(req.params.name)
if (!filename) return fail(res, 404, 'Image not found.')
const params = new URL(req.originalUrl, 'http://localhost').searchParams
if ([...params.keys()].some((key) => key !== 'w' && key !== 'f') ||
params.getAll('w').length > 1 || params.getAll('f').length > 1) {
return fail(res, 400, 'Unsupported image parameters.')
}
const size = params.get('w') ?? '640'
const requested = params.get('f')
const accepted = requested === null ? req.accepts(['image/webp', 'image/jpeg']) : null
const format = requested ?? (accepted === 'image/webp' ? 'webp' : 'jpeg')
if (requested === null && !accepted) return fail(res, 406, 'No supported image format.')
if (!sizes.has(size) || !types.has(format)) return fail(res, 400, 'Unsupported image variant.')
if (requested === null) res.vary('Accept')
const key = JSON.stringify(['image-v1', filename, size, format])
if (active >= 2) return fail(res, 503, 'Image processor is busy.')
active += 1
try {
const cached = await cacheCommand(() => redis.get(key))
if (cached !== null) {
return sendImage(res, format, Buffer.from(cached, 'base64'))
}
const bytes = await optimize(filename, Number(size), format)
await cacheCommand(() => redis.set(key, bytes.toString('base64'), { EX: CACHE_SECONDS }))
return sendImage(res, format, bytes)
} catch {
console.error('Image request failed.')
return fail(res, 503, 'Image is temporarily unavailable.')
} finally {
active -= 1
}
})
app.use((_req, res) => fail(res, 404, 'Route not found.'))
app.use((_error, _req, res, _next) => fail(res, 400, 'Request could not be processed.'))
async function main() {
await redis.connect()
const server = app.listen(3000, process.env.HOST ?? '127.0.0.1')
server.requestTimeout = 10_000
server.headersTimeout = 10_000
server.on('error', () => {
console.error('Image server could not start.')
if (redis.isOpen) redis.destroy()
process.exitCode = 1
})
let stopping = false
const stop = () => {
if (stopping) return
stopping = true
const deadline = setTimeout(() => {
server.closeAllConnections()
if (redis.isOpen) redis.destroy()
process.exit(1)
}, 15_000)
deadline.unref()
server.close(() => {
if (redis.isOpen) redis.destroy()
clearTimeout(deadline)
})
}
process.once('SIGTERM', stop)
process.once('SIGINT', stop)
}
main().catch(() => {
console.error('Image origin startup failed.')
if (redis.isOpen) redis.destroy()
process.exitCode = 1
})
Starten Sie sie mit npm start. Redis ist private, vertrauenswürdige Infrastruktur, kein für Nutzer
beschreibbarer Cache. Bei einem Cache-Fehler gibt der Origin 503 zurück, statt einen unbegrenzten
Schwall an nicht zwischengespeicherter Arbeit anzunehmen. Jeder Cache-Befehl hat eine Frist von zwei
Sekunden. Eine fehlgeschlagene Verbindung bleibt geschlossen; starten Sie dieses Beispiel neu,
nachdem sich Redis erholt hat. Produktionsdeployments benötigen ein Readiness-Monitoring und eine
überwachte Wiederherstellungsrichtlinie. Die Obergrenze von zwei Anfragen schließt Cache-Zugriffe ein
und gilt pro Prozess. Die Memory-Eviction begrenzt die vorgehaltenen Cache-Einträge; das Decodieren
benötigt weiterhin Speicher- und CPU-Limits des Betriebssystems. Diese Limits kapseln native
Bildbibliotheken nicht in einer Sandbox.
Einen beliebigen Object-Storage-Anbieter integrieren
Ein Publisher kann freigegebene Quellobjekte im schreibgeschützten Bildverzeichnis bereitstellen. Alternativ ersetzen Sie den Loader durch eine Implementierung mit einem Storage-SDK, die beim Streaming dasselbe Byte-Limit durchsetzt, eine unveränderliche Version festschreibt und fehlgeschlagene Übertragungen abbricht. Rufen Sie keine beliebigen, von Nutzern bereitgestellten URLs ab: Das führt zu Risiken durch SSRF und unbegrenzte Downloads.
Eine vorsignierte Storage-URL autorisiert ein Storage-Objekt. Sie signiert nicht automatisch Ihre Bild-Origin-Route oder eine separate CDN-URL. Halten Sie diese Autorisierungsgrenzen explizit.
Für wiederholbare Deployments containerisieren
Verwenden Sie einen expliziten Build-Kontext:
FROM node:24-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY optimizer.js server.js ./
COPY images ./images
USER node
ENV HOST=0.0.0.0
EXPOSE 3000
CMD ["node", "server.js"]
Bauen Sie mit docker build -t image-origin .. Geben Sie REDIS_URL an, um Redis privat im Container-Netzwerk zu
nutzen; das Loopback des Containers ist nicht das Redis Ihres Hosts. Veröffentlichen Sie Port 3000
nur gegenüber Ihrem vertrauenswürdigen Proxy oder für Tests gegenüber dem Loopback. Die expliziten
Kopien schließen Dateien vom Typ .env sowie nicht zugehörige Quelldateien aus.
Horizontal mit einem Load Balancer skalieren
Instanzen können unveränderliche Quellen und Redis gemeinsam nutzen, aber jede hat ihre eigene
Verarbeitungsobergrenze. Setzen Sie flottenweite Limits am Gateway und konfigurieren Sie Fristen.
Aktivieren Sie trust proxy nicht blind: Eine falsche Richtlinie für Forwarded-Header erlaubt Clients,
ihre Adresse zu fälschen.
Konfigurieren Sie das CDN so, dass Breite und explizites Format Teil seines Keys sind. Berücksichtigt
es Vary: Accept nicht, verlangen Sie auf dieser Ebene den Parameter f explizit, statt ausgehandelte
Antworten unter einem gemeinsamen Key zu cachen. Cachen Sie keine Fehlerantworten.
Performance überwachen
Verfolgen Sie Cache-Hits, Cache-Misses, Latenz, aktive Transformationen und abgelehnte Anfragen, ohne signierte URLs oder Bildinhalte zu protokollieren. Verwenden Sie für veröffentlichte Änderungen neue Dateinamen; das bloße Ändern eines zugrunde liegenden Objekts kann Browser-Caches nicht invalidieren. Erhöhen Sie den Schlüssel der Encoder-Richtlinie, wenn sich die Ausgabeeinstellungen ändern.
Mit autocannon einen Lasttest durchführen
Prüfen Sie zunächst dieselbe URL mit unterschiedlichen ausgehandelten Formaten:
curl --fail-with-body -H 'Accept: image/webp' 'http://127.0.0.1:3000/images/photo.jpg?w=320' -o photo.webp
curl --fail-with-body -H 'Accept: image/jpeg' 'http://127.0.0.1:3000/images/photo.jpg?w=320' -o photo.jpg
curl --fail-with-body -H 'Accept: image/webp' 'http://127.0.0.1:3000/images/photo.jpg?w=320' -o cached.webp
Decodieren Sie die Dateien und prüfen Sie Format und Abmessungen; MIME-Header allein erkennen einen falschen zwischengespeicherten Body nicht. Führen Sie einen Lasttest nur gegen Infrastruktur aus, die Ihnen gehört:
npx autocannon -c 4 -d 10 'http://127.0.0.1:3000/images/photo.jpg?w=320&f=webp'
Messen Sie kalte und warme Fälle getrennt. Überlastantworten sind keine erfolgreichen Transformationen, und ein hoher Anfragedurchsatz allein belegt noch keine akzeptable Ressourcennutzung.
Fazit
Sie haben jetzt einen Bild-Origin und einen variantenbewussten Cache. Globale Edge-Auslieferung, sicheres Veröffentlichen, betriebliche Limits und Monitoring bleiben eigenständige Aufgaben. Die Bildverarbeitung und das Smart CDN von Transloadit bieten verwaltete Optionen für Verarbeitung und Auslieferung.
