Entrega eficiente de imagens: como criar sua própria CDN
Um único servidor Express é uma origem de imagens, não uma CDN global. Este guia cria uma origem pública de imagens com limites definidos usando Sharp e Redis. Coloque uma CDN na frente dela para entrega na borda e TLS, e aplique uma política adequada de controle de abuso.
Pré-requisitos
Use Node.js 24 ou mais recente e Redis 7 ou mais recente. Publique apenas arquivos JPEG ou PNG revisados e imutáveis em um diretório que os usuários da aplicação não possam modificar. Este exemplo não tem rota de upload nem de autenticação.
Benefícios de usar uma CDN para imagens
Uma CDN pode reduzir transferências repetidas a partir da sua origem e aproximar as respostas em cache dos leitores. Redimensionar antes da entrega evita enviar uma imagem em resolução total para uma tela pequena. Meça os dois efeitos com seus arquivos e locais: um hit no Redis do lado da origem não é um hit no cache de borda.
Como uma CDN funciona (em 60 segundos)
Em um miss, a CDN solicita uma variante à sua origem. A origem valida a solicitação, lê um arquivo-fonte aprovado, processa esse arquivo e retorna uma resposta que pode ser armazenada em cache. As solicitações seguintes podem ter hit no cache de borda ou no Redis. Todo cache precisa distinguir versões do arquivo-fonte, dimensões e formatos.
Componentes essenciais para uma CDN de imagens personalizada
Separe o armazenamento privado dos arquivos-fonte, uma lista explícita de publicação, um decodificador com limites, um cache descartável de resultados e uma camada de entrega. O Redis não é a fonte da verdade. Não use cache público para arquivos privados.
Configurar o projeto
Crie 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" }
}
Execute npm install e faça commit do package-lock.json gerado; as instalações seguintes podem usar
npm ci. Crie um diretório images contendo seu próprio arquivo revisado chamado photo-v1.jpg.
Inicie um cache Redis local descartável com limite de memória:
redis-server --bind 127.0.0.1 --maxmemory 128mb --maxmemory-policy allkeys-lru
Criar um otimizador de imagens mínimo
Coloque este módulo completo em optimizer.js. Quem faz a chamada fornece um nome de arquivo do seu mapa de
publicação, não uma URL arbitrária. A verificação de realpath também rejeita arquivos-fonte fora do
diretório de publicação.
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()
}
O resultado cabe no quadrado solicitado sem ampliação nem alteração da proporção. O JPEG compõe a transparência sobre fundo branco; o WebP pode preservar a transparência.
Conectar o Express com cache e segurança
Coloque esta aplicação em server.js. Quando f é omitido, o formato é negociado entre WebP e JPEG.
O formato resolvido, e não apenas a URL da solicitação, faz parte da chave do Redis.
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
})
Inicie com npm start. O Redis é uma infraestrutura privada e confiável, não um cache gravável por
usuários. Em uma falha de cache, a origem retorna 503 em vez de aceitar um pico ilimitado de trabalho
sem cache. Cada comando de cache tem um prazo de dois segundos. Uma conexão com falha permanece
fechada; reinicie este exemplo depois que o Redis se recuperar. Implantações em produção precisam de
monitoramento de prontidão e de uma política de recuperação supervisionada. O limite de duas
solicitações inclui o acesso ao cache e vale por processo. A remoção por memória limita as entradas
retidas no cache; a decodificação ainda precisa de limites de memória e CPU no sistema operacional.
Esses limites não isolam as bibliotecas nativas de imagem em um sandbox.
Integrar qualquer provedor de armazenamento de objetos
Um publicador pode preparar objetos-fonte aprovados no diretório de imagens somente leitura. Como alternativa, substitua o carregador por uma implementação com SDK de armazenamento que aplique o mesmo limite de bytes durante o streaming, fixe uma versão imutável e cancele transferências com falha. Não busque URLs arbitrárias fornecidas por usuários: isso introduz riscos de SSRF e de downloads ilimitados.
Uma URL de armazenamento pré-assinada autoriza um objeto de armazenamento. Ela não assina automaticamente sua rota de origem de imagens nem uma URL separada da CDN. Mantenha essas fronteiras de autorização explícitas.
Conteinerizar para implantações reproduzíveis
Use um contexto de build explícito:
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"]
Faça o build com docker build -t image-origin .. Forneça REDIS_URL para um Redis privado na rede do contêiner; o
loopback do contêiner não é o Redis do seu host. Publique a porta 3000 apenas para seu proxy
confiável ou para o loopback em testes. As cópias explícitas excluem arquivos .env e arquivos-fonte
não relacionados.
Escalar horizontalmente com um balanceador de carga
As instâncias podem compartilhar arquivos-fonte imutáveis e o Redis, mas cada uma tem seu próprio
limite de processamento. Defina limites para toda a frota no gateway e configure prazos. Não ative
trust proxy cegamente: uma política incorreta de cabeçalhos encaminhados permite que clientes falsifiquem
seu endereço.
Configure a CDN para incluir a largura e o formato explícito na chave. Se ela não respeitar
Vary: Accept, exija um f explícito nessa camada em vez de armazenar em cache respostas negociadas
sob uma chave compartilhada. Não armazene respostas de erro em cache.
Monitorar o desempenho
Acompanhe hits e misses de cache, latência, transformações ativas e solicitações rejeitadas sem registrar em log URLs assinadas nem conteúdo de imagens. Use novos nomes de arquivo para alterações publicadas; alterar apenas o objeto subjacente não consegue invalidar caches de navegador. Incremente a chave da política do encoder quando as configurações de saída mudarem.
Fazer teste de carga com autocannon
Primeiro, verifique a mesma URL com diferentes formatos negociados:
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
Decodifique os arquivos e confira formato e dimensões; cabeçalhos MIME sozinhos não detectam um corpo errado em cache. Execute um teste de carga apenas contra infraestrutura que seja sua:
npx autocannon -c 4 -d 10 'http://127.0.0.1:3000/images/photo.jpg?w=320&f=webp'
Meça separadamente os casos com cache frio e quente. Respostas de sobrecarga não são transformações bem-sucedidas, e uma alta vazão de solicitações, por si só, não comprova um uso aceitável de recursos.
Conclusão
Agora você tem uma origem de imagens e um cache que reconhece variantes. Entrega global na borda, publicação segura, limites operacionais e monitoramento continuam sendo responsabilidades separadas. O processamento de imagens e o Smart CDN da Transloadit oferecem opções gerenciadas de processamento e entrega.
