Pipeline serverless de imágenes

pipeline serverless de imágenes con aws lambda+node+wasm: de 11.5mb a 91.2kb

ed

Hace un tiempo me encontré con la necesidad de procesar miles de imágenes para un e-commerce: varias versiones por producto, carrusel, thumbnail, miniatura para facturas. El proceso manual no era opción. Los scripts en bash que ejecutaba desde mi máquina funcionaban, pero dependían de mí para correr. La solución era clara: un pipeline en la nube que se disparara solo cada vez que llegara una imagen nueva.

Este post es sobre cómo construí ese pipeline en AWS Lambda usando beautiful-image, y por qué WASM fue la decisión clave que hizo que todo encajara sin fricción.

La arquitectura

El flujo es simple y completamente serverless:

Sin servidores que mantener, sin workers que escalar, sin colas que configurar para el caso básico. Una imagen entra, dos variantes salen, listas para servir desde CloudFront.

Por qué WASM cambia todo

beautiful-image tiene un core en Rust compilado a WASM. Eso significa:

  • Un solo artefacto: el binario WASM de 469KB va dentro del mismo ZIP de la Lambda. Sin Layers, sin Docker images.
  • Portabilidad real: el mismo paquete corre en nodejs22.x con arquitectura x86_64 o arm64 sin recompilar nada.
  • Zero dependencias nativas: npm install es suficiente. No hay nada que compilar.

El deploy queda en tres comandos:

npm install
sam build
sam deploy

La implementación

El handler es directo. Descarga la imagen de S3, genera las variantes con beautiful-image y las sube al bucket de destino:

import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { image } from "beautiful-image/node"
import path from "node:path"

const SOURCE_BUCKET = process.env.SOURCE_BUCKET!
const DEST_BUCKET = process.env.DEST_BUCKET!

const s3 = new S3Client({})

const VARIANTS = [
  { folder: "optimized",  width: 800, quality: 80 },
  { folder: "thumbnails", width: 200, quality: 80 },
] as const

const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"])

export const handler = async (event: AWSLambda.S3Event): Promise<void> => {
  for (const record of event.Records) {
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "))

    const ext = path.extname(key).toLowerCase()
    if (!ALLOWED_EXTENSIONS.has(ext)) {
      console.log(`Skipping unsupported file type: ${key}`)
      continue
    }

    const t0 = performance.now()
    console.log(`Processing: ${key}`)

    let input: Buffer
    try {
      const t1 = performance.now()
      const { Body } = await s3.send(
        new GetObjectCommand({ Bucket: SOURCE_BUCKET, Key: key })
      )
      if (!Body) {
        console.error(`Empty body for key: ${key}`)
        continue
      }
      input = Buffer.from(await Body.transformToByteArray())
      console.log(`[timer] download: ${(performance.now() - t1).toFixed(0)}ms ${input.length}B`)
    } catch (err) {
      console.error(`Failed to download ${key}:`, err)
      continue
    }

    const filename = path.parse(key).name

    for (const { folder, width, quality } of VARIANTS) {
      const tp = performance.now()
      let result: Awaited<ReturnType<ReturnType<typeof image>["toJpeg"]>>
      try {
        result = await image(input).resize(width).toJpeg(quality)
      } catch (err) {
        console.error(`Failed to process variant ${folder} for ${key}:`, err)
        continue
      }
      console.log(`[timer] wasm ${folder} (${width}px): ${(performance.now() - tp).toFixed(0)}ms`)

      const destKey = `${folder}/${filename}.jpg`
      const tu = performance.now()
      try {
        await s3.send(
          new PutObjectCommand({
            Bucket: DEST_BUCKET,
            Key: destKey,
            Body: result.data,
            ContentType: "image/jpeg",
          })
        )
      } catch (err) {
        console.error(`Failed to upload ${destKey}:`, err)
        continue
      }
      console.log(`[timer] upload ${destKey}: ${(performance.now() - tu).toFixed(0)}ms`)

      console.log(
        `Saved ${destKey} - ${result.originalSize}B → ${result.optimizedSize}B (${Math.round(result.compressionRatio * 100)}% smaller)`
      )
    }

    console.log(`[timer] total: ${(performance.now() - t0).toFixed(0)}ms`)
  }
}

examples/lambda-demo

La API de beautiful-image es encadenable y lee como lo que hace: descarga, redimensiona, comprime, etc.

const result = await image(input).resize(800).toJpeg(80)
// result.data            Buffer listo para subir
// result.originalSize    tamaño original en bytes
// result.optimizedSize   nuevo tamaño en bytes
// result.compressionRatio  0.99 = 99% más pequeño

El template SAM

Todo el stack en ~64 líneas de YAML. La Lambda se dispara con cualquier s3:ObjectCreated en el bucket de origen:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Beautiful Image processor Lambda triggered by S3 uploads using beautiful-image

Parameters:
  SourceBucketName:
    Type: String
    Default: "my-raw-images"
    Description: S3 bucket where raw images are uploaded (trigger source)
  DestBucketName:
    Type: String
    Default: "my-processed-images"
    Description: S3 bucket where processed variants are saved
  Environment:
    Type: String
    Default: sandbox
    Description: Deployment environment name

Resources:
  ImageProcessorFunction:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: es2020
        Sourcemap: true
        EntryPoints:
          - handler.ts
        External:
          - '@aws-sdk/*'
    Properties:
      FunctionName: !Sub "ImageProcessorFunction-${Environment}"
      CodeUri: ./
      Handler: handler.handler
      Runtime: nodejs22.x
      Timeout: 60
      MemorySize: 1769
      Environment:
        Variables:
          SOURCE_BUCKET: !Ref SourceBucketName
          DEST_BUCKET: !Ref DestBucketName
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - s3:GetObject
              Resource: !Sub "arn:aws:s3:::${SourceBucketName}/*"
            - Effect: Allow
              Action:
                - s3:PutObject
              Resource: !Sub "arn:aws:s3:::${DestBucketName}/*"

  ImageProcessorFunctionS3Permission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt ImageProcessorFunction.Arn
      Action: lambda:InvokeFunction
      Principal: s3.amazonaws.com
      SourceArn: !Sub "arn:aws:s3:::${SourceBucketName}"
      SourceAccount: !Ref AWS::AccountId

examples/lambda-demo

El MemorySize de 1769MB no es arbitrario: es exactamente 1 vCPU en Lambda. WASM es single-threaded, así que poner más memoria no acelera el procesamiento, pero bajar de ese umbral sí lo frena.

El SDK de S3 se marca como External porque el runtime nodejs22.x ya lo incluye, sin necesidad de empaquetarlo.

El resultado

De 11.5MB a 91.2KB (-99%)

Una imagen entra al bucket. El pipeline genera:

  • optimized/: 800px, lista para la página de detalle
  • thumbnails/: 200px, lista para el carrusel y las facturas
  • ...otros/: variantes adicionales (e.g. 400px, 600px)

Sin intervención manual, sin servidores que mantener.

¿Cuándo usar beautiful-image vs Sharp?

La librería de facto para esto en Node.js es Sharp. Excelente herramienta, pero en Lambda introduce fricción: usa binarios nativos compilados contra libvips (~20MB), esos binarios deben coincidir con la arquitectura exacta de Lambda, y si se despliega el binario equivocado no funcionará en runtime. La documentación oficial de Sharp para Lambda dedica secciones enteras a flags de plataforma, cross-compilación y problemas con symlinks de pnpm. La solución que terminan sugiriendo los docs: usar un Lambda Layer mantenido por la comunidad.

beautiful-image no compite con Sharp en velocidad bruta. Sharp delega a libvips, una librería C con décadas de optimizaciones específicas por arquitectura que WASM no puede igualar.

Si se requiere compositing, SVG rendering, formatos RAW o conversión de espacio de color: Sharp es la herramienta correcta y nada se le acerca. Si se requiere resize y optimización para web con un handful de filtros si que el deploy sea un problema, beautiful-image llega ahí con mucho menos peso y cero fricción operacional.

El código completo con SAM template, evento de prueba y configuración está en examples/lambda-demo