Lokalen Bild-Origin mit Sharp und Redis aufbauen
Liefern Sie ein skaliertes JPEG oder WebP über dieselbe Bild-URL aus, ohne ihre gecachten Bytes zu vermischen. Diese Anleitung baut einen lokalen Bild-Origin auf, erzeugt ein transparentes Beispielbild und prüft Antworten bei kaltem und warmem Cache. Der Origin ist eine Komponente eines eigenen Bild-CDN; die globale Edge-Auslieferung benötigt ein separates CDN.
Voraussetzungen
Verwenden Sie eine gepflegte Version von Node.js 24 LTS, mindestens 24.15.0, Corepack mit Yarn 4,
Docker und cURL. Das Beispiel nutzt Express 5.2.1, Sharp 0.35.4, den Redis-Client 6.2.1 und
Redis 8.10.2. Die native TypeScript-Unterstützung von Node führt
Dateien mit der Endung .mts direkt aus, ohne Compiler oder
tsx.
Veröffentlichen Sie nur geprüfte, unveränderliche JPEG- oder PNG-Quelldateien mit einem Einzelbild. Dieser Origin hat keine Upload- oder Authentifizierungsroute: Jedes aufgeführte Bild und jede abgeleitete Variante ist öffentlich. Die folgenden Byte- und Pixelgrenzen begrenzen die angenommene Arbeit; sie isolieren den nativen Decoder von Sharp nicht in einer Sandbox.
Projekt einrichten
Erstellen Sie ein neues, leeres Verzeichnis image-origin und öffnen Sie dort ein
Terminal. Speichern Sie diese Datei package.json:
{
"name": "image-origin",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": { "fixture": "node create-fixture.mts", "start": "node server.mts" },
"dependencies": { "express": "5.2.1", "redis": "6.2.1", "sharp": "0.35.4" },
"devDependencies": { "@types/express": "5.0.6" }
}
Speichern Sie auch .yarnrc.yml. Sie wählt eine lokale Installation von
node_modules und verhindert, dass die ausführbare Yarn-Datei eines übergeordneten
Projekts die Ausführung übernimmt:
nodeLinker: node-modules
enableGlobalCache: false
ignorePath: true
Legen Sie vor der Installation die Lockfile-Grenze des Projekts fest. Führen Sie alle folgenden Befehle in diesem Verzeichnis aus:
touch yarn.lock && corepack yarn install
Behalten Sie die erzeugte Datei yarn.lock; für wiederholte Installationen
können Sie corepack yarn install --immutable verwenden.
Speichern Sie create-fixture.mts, um ein Beispielbild mit 640 × 400 zu erzeugen:
eine deckend rote linke und eine transparente rechte Hälfte. Das Skript verweigert das Ersetzen
einer vorhandenen Quelldatei.
import { mkdir, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
const width = 640
const height = 400
const pixels = Buffer.alloc(width * height * 4)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width / 2; x += 1) {
const offset = (y * width + x) * 4
pixels[offset] = 255
pixels[offset + 3] = 255
}
}
const png = await sharp(pixels, { raw: { width, height, channels: 4 } }).png().toBuffer()
await mkdir('images', { recursive: true })
await writeFile('images/photo-v1.png', png, { flag: 'wx' })
Erzeugen Sie das Bild und starten Sie dann einen entbehrlichen Redis-Cache, der an Loopback
gebunden ist. Falls Port 6379 belegt ist, wählen Sie einen anderen Host-Port und übergeben Sie
dessen URL beim Start des Origins über REDIS_URL.
corepack yarn fixture &&
docker run --detach --rm --name image-origin-redis \
-p 127.0.0.1:6379:6379 redis:8.10.2 \
redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --save '' --appendonly no
Der Cache ist entbehrlich: Diese Konfiguration schreibt weder einen Redis-Snapshot noch eine
Append-only-Datei. Die Originale bleiben in images/. Die
Verdrängungsrichtlinie von Redis entfernt bei Bedarf gecachte
Einträge; bei einem Cache-Miss können sie neu erzeugt werden.
Minimalen Bildoptimierer erstellen
Speichern Sie optimizer.mts. Der Aufrufer übergibt einen Dateinamen aus der
Veröffentlichungszuordnung statt einer URL oder eines vom Nutzer gewählten Pfads. Der Optimierer
akzeptiert höchstens 8 MiB an Quelldatei-Bytes und 12 Millionen Eingabepixel.
Das Verzeichnis muss während der Bearbeitung von Anfragen unter der Kontrolle des Herausgebers
bleiben.
import { open, realpath } from 'node:fs/promises'
import { resolve, sep } from 'node:path'
import sharp from 'sharp'
export type Format = 'jpeg' | 'webp'
const MAX_BYTES = 8 * 1024 * 1024
const MAX_PIXELS = 12_000_000
sharp.concurrency(1)
sharp.cache(false)
export async function optimize(filename: string, size: number, format: Format): Promise<Buffer> {
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: Buffer
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 (!metadata.format || !['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 })
return format === 'jpeg'
? pipeline.flatten({ background: 'white' }).jpeg({ quality: 80 }).toBuffer()
: pipeline.webp({ quality: 80 }).toBuffer()
}
Das Bild passt in das angeforderte Quadrat, behält sein Seitenverhältnis und wird nicht vergrößert. JPEG hinterlegt transparente Bereiche mit Weiß; WebP erhält die Transparenz. Die Standard-Ausgaberichtlinie von Sharp konvertiert nach sRGB und entfernt die Metadaten der Quelle. Die Rotation wendet die EXIF-Ausrichtung an, bevor diese Metadaten entfernt werden. Erfolgreiches Decodieren ist keine Integritätsprüfung des Originals: Prüfen Sie die Quellpixel vor der Veröffentlichung.
Ausgehandelte Varianten ausliefern und cachen
Speichern Sie server.mts. Lassen Sie f weg, um
mit Accept ein Format auszuhandeln, oder fordern Sie
f=jpeg oder f=webp explizit an.
Die Breite w akzeptiert 320, 640 oder 1.280; der Standardwert ist 640.
Andere oder doppelte Parameter werden abgelehnt.
import type { Response } from 'express'
import express from 'express'
import { createClient } from 'redis'
import { type Format, optimize } from './optimizer.mts'
const published = new Map([['photo-v1.png', 'photo-v1.png']])
const sizes = new Set(['320', '640', '1280'])
const types = { 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<T>(command: () => Promise<T>): Promise<T> {
// Closing the connection also rejects commands already sent to a stalled Redis server.
const timer = setTimeout(() => {
if (redis.isOpen) redis.destroy()
}, 2000)
try {
return await command()
} finally {
clearTimeout(timer)
}
}
function fail(res: Response, status: number, message: string): Response {
return res.status(status).set('Cache-Control', 'no-store').type('text').send(message)
}
function sendImage(res: Response, format: Format, bytes: Buffer): Response {
return res.type(types[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) || (format !== 'jpeg' && format !== 'webp')) {
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: unknown, _req: express.Request, res: Response, _next: express.NextFunction) =>
fail(res, 400, 'Request could not be processed.'))
async function main(): Promise<void> {
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid PORT.')
await redis.connect()
const server = app.listen(port, '127.0.0.1')
server.requestTimeout = 10_000
server.headersTimeout = 10_000
server.on('listening', () => console.log(`Image origin: http://127.0.0.1:${port}`))
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 den Origin in diesem Terminal:
corepack yarn start
Warten Sie auf Image origin: http://127.0.0.1:3000. Ein belegter Port
oder ein nicht verfügbares Redis lässt den Start fehlschlagen. Wählen Sie mit
PORT=3002 corepack yarn start einen anderen Origin-Port und passen Sie die folgenden URLs an.
Halten Sie Redis privat und vertrauenswürdig: Gecachte Bytes werden nicht erneut als Bilder validiert.
Varianten bei kaltem und warmem Cache prüfen
Fordern Sie in einem zweiten Terminal im Projektverzeichnis dieselbe URL viermal an. Diese Befehle überschreiben die vier benannten Ausgabedateien; verwenden Sie ein neues Verzeichnis, wenn Sie frühere Ergebnisse behalten möchten.
curl -fsSLo photo.webp -D webp.headers -H 'Accept: image/webp' 'http://127.0.0.1:3000/images/photo-v1.png?w=320' &&
curl -fsSLo photo.jpg -D jpeg.headers -H 'Accept: image/jpeg' 'http://127.0.0.1:3000/images/photo-v1.png?w=320' &&
curl -fsSLo cached.webp -H 'Accept: image/webp' 'http://127.0.0.1:3000/images/photo-v1.png?w=320' &&
curl -fsSLo cached.jpg -H 'Accept: image/jpeg' 'http://127.0.0.1:3000/images/photo-v1.png?w=320'
Beide Header-Dateien sollten Vary: Accept, Cache-Control: public, max-age=3600 und
den entsprechenden Wert für Content-Type enthalten.
Vary teilt einem HTTP-Cache mit, dass
die Antwort auch von Accept abhängt; der Redis-Schlüssel unterscheidet
dadurch noch nicht zwischen Varianten. Der Schlüssel muss auch das ermittelte Format enthalten.
Speichern Sie check-results.mts, um die Dateien zu decodieren, ihre tatsächlichen
Formate und Abmessungen zu prüfen und die Bytes bei kaltem und warmem Cache zu vergleichen:
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import sharp from 'sharp'
for (const [cold, warm, format] of [
['photo.webp', 'cached.webp', 'webp'],
['photo.jpg', 'cached.jpg', 'jpeg'],
]) {
const bytes = await readFile(cold)
assert.deepEqual(await readFile(warm), bytes)
const decoder = sharp(bytes)
const metadata = await decoder.metadata()
assert.equal(metadata.format, format)
assert.equal(metadata.width, 320)
assert.equal(metadata.height, 200)
const { data, info } = await decoder.ensureAlpha().raw().toBuffer({ resolveWithObject: true })
const left = (100 * info.width + 80) * info.channels
assert.ok(data[left] > 245 && data[left + 1] < 15 && data[left + 2] < 15)
assert.equal(data[left + 3], 255)
const right = (100 * info.width + 240) * info.channels
assert.equal(data[right + 3], format === 'webp' ? 0 : 255)
if (format === 'jpeg') assert.ok(data[right] > 245 && data[right + 1] > 245 && data[right + 2] > 245)
console.log(`${cold}: ${format}, 320 × 200, warm bytes match`)
}
node check-results.mts
Öffnen Sie auch beide bei kaltem Cache erzeugten Dateien im Browser: Die rote Hälfte sollte links sein, die rechte Hälfte beim WebP transparent und beim JPEG weiß. Identische Bytes bei kaltem und warmem Cache allein beweisen keinen Cache-Treffer. Prüfen Sie bei laufendem Origin die beiden Redis-Schlüssel:
docker exec image-origin-redis redis-cli --scan --pattern '[[]"image-v1"*'
Sie sollten ["image-v1","photo-v1.png","320","webp"] und
["image-v1","photo-v1.png","320","jpeg"] lauten. Jeder gespeicherte Wert ist das entsprechende Bild in
Base64 und läuft nach einer Stunde ab. Eine neue Breite erzeugt eine weitere Variante; eine Anfrage
mit w=1280 ergibt weiterhin 640 × 400, weil der Optimierer das
Beispielbild nicht vergrößert.
Geänderte Quelle veröffentlichen
Fügen Sie eine geprüfte Datei photo-v2.png und einen neuen Eintrag
['photo-v2.png', 'photo-v2.png'] in der Veröffentlichungszuordnung hinzu.
Verwenden Sie /images/photo-v2.png?w=320 auf der Seite, die das Bild einbindet. Lassen Sie die
alte Datei und Route unverändert: Wenn Sie photo-v1.png überschreiben oder
das Ziel ihrer bestehenden URL ändern, bleiben bereits gecachte Browserantworten bis zum Ablauf
gültig. Der Quelldateiname im Redis-Schlüssel trennt die Versionen am Origin.
Erhöhen Sie das Präfix image-v1 der Encoder-Richtlinie, wenn Sie
Ausgabeeinstellungen ändern. Dadurch werden Redis-Schlüssel ersetzt, doch bestehende HTTP-Caches
benötigen weiterhin neue URLs oder eine gezielte Invalidierung. Objektspeicher können freigegebene
Dateien über einen Herausgeber bereitstellen; das Abrufen beliebiger Remote-URLs ist nicht Teil
dieser Anleitung.
Lokalen Origin stoppen und wiederherstellen
Drücken Sie Strg+C im Origin-Terminal, um keine neuen Verbindungen mehr anzunehmen und aktive Anfragen abschließen zu lassen. Nach 15 Sekunden erzwingt der Prozess das Schließen der Verbindungen und endet mit einem Fehlerstatus; dies ist kein sofortiger Abbruch der Transformation. Stoppen Sie anschließend den Cache:
docker stop image-origin-redis
Wenn Redis nicht mehr erreichbar ist oder ein Befehl zwei Sekunden lang blockiert, gibt der Origin
eine nicht gecachte Antwort mit Status 503 zurück, statt ohne Cache fortzufahren. Bei Ablauf dieser
Frist wird die gemeinsame Redis-Verbindung geschlossen und ihre ausstehenden Befehle werden
abgewiesen. Automatisches Wiederverbinden und die Offline-Warteschlange sind deaktiviert; starten
Sie diesen Origin nach dem Neustart von Redis neu. Führen Sie aus der Einrichtung nur den Befehl
docker run erneut aus, danach corepack yarn start;
führen Sie den Quellgenerator nicht erneut aus. Die Obergrenze von zwei Anfragen umfasst auch
Cache-Zugriffe. Jede Anfrage belegt ihren Platz, bis ihr Handler abgeschlossen ist, sodass eine
zusätzliche gleichzeitige Anfrage den Status 503 erhält. Dies ist eine Zulassungsgrenze pro Prozess,
keine Garantie für den gesamten Speicher- oder CPU-Verbrauch.
Origin an die Auslieferung anbinden
Redis verwendet eine transformierte Antwort an diesem Origin erneut. Es platziert keine Kopien in
der Nähe der Leser, terminiert kein öffentliches TLS und belegt keine Leistungsverbesserung.
Ein CDN vor einem transformierenden Origin muss Breite und explizites Format im Cache-Schlüssel
berücksichtigen oder Vary: Accept bei ausgehandelten Antworten korrekt beachten.
Schließen Sie Fehler vom Edge-Caching aus. Mehrere Origin-Prozesse vervielfachen auch die
Zulassungsgrenze.
Unsere Anleitung zu S3 und CloudFront behandelt eine separate Auslieferung auf Basis eines Speichers; sie ist keine Deployment-Anleitung für diesen Express-Server. Für verwaltete Verarbeitung und Auslieferung bietet Transloadit Bildverarbeitung und Smart CDN.
