skip to content

Lakehouse serverless con DuckDB sobre AWS Lambda

9 min read

Cómo pasamos de reportes corporativos de más de 30 segundos a consultas de milisegundos: una capa de serving en formato nativo DuckDB servida desde Lambda, con aceleraciones de 8× a 64× y un costo operativo de menos de 0.30 USD al mes.

De reportes de 30 segundos a respuestas en milisegundos

Hay una métrica que mata cualquier tablero corporativo: el tiempo que el usuario pasa mirando un spinner. En nuestro Portal interno, el reporte de facturación multifuente —ventas, metas, retail, e-commerce, intercompañía— tardaba más de 30 segundos en responder. Para Comercial, Finanzas y RGM, eso no es un tablero interactivo; es un proceso por lotes con interfaz bonita.

Este artículo cuenta cómo bajamos esa latencia a milisegundos con una capa de serving en formato nativo DuckDB servida desde AWS Lambda, y —más importante— los tres patrones que la hacen confiable y barata en un entorno serverless. El costo operativo incremental de toda la solución es menor a 0.30 USD al mes.

El punto de partida: ¿por qué 30 segundos?

El reporte consultaba en tiempo real las vistas de SQL Server (la capa Gold del medallón). Esas vistas hacían joins cross-server y agregaciones sobre tablas grandes, y la latencia se disparaba por encima de los 30 s.

La primera decisión ya estaba tomada antes de este trabajo: desacoplar la lectura analítica del OLTP. Un pipeline serverless materializa las vistas en archivos Parquet particionados por Hive (fuente=/anio=/mes=) en S3, y un motor DuckDB embebido en Lambda sirve las consultas leyendo esos Parquet.

Eso ya funcionaba. El percentil 50 de las consultas era de ~660 ms. Pero había una mejora de un orden de magnitud sobre la mesa, y este artículo es sobre cómo la capturamos.

Medir antes de rediseñar

Antes de tocar nada, caracterizamos el sistema en producción. Esta parte es la que más recomiendo no saltarse: el formato de archivo no era el cuello de botella.

  • El lake tenía ~556 MB en 409 archivos Parquet, comprimidos con ZSTD, con estadísticas min/max y bloom filters en todas las columnas. A nivel de bytes, era casi óptimo.
  • La Lambda de consulta DuckDB costaba 1.06 USD al mes con ~9 500 invocaciones. Una de las funciones más eficientes por invocación de toda la cuenta.

Pero el análisis también reveló dos cosas subóptimas frente a la guía oficial de DuckDB:

  1. Archivos diminutos. La mediana era de 327 KB y el máximo 8.4 MB —entre 12× y 25 000× por debajo del mínimo recomendado de 100 MB. El particionado mensual fragmentaba las fuentes chicas en archivos de kilobytes, con 1–3 row groups, insuficientes para alimentar los 6 hilos del motor.
  2. Clustering ausente. Los row groups de un mismo archivo tenían rangos min/max solapados en las columnas de filtro frecuentes. Es decir: pagábamos el almacenamiento de estadísticas y bloom filters, pero no servían para podar nada.

La conclusión fue clara: no cambiar el formato de archivo (Parquet + ZSTD ya era bueno), sino la capa lógica de servicio. Y explotar algo que Parquet no tiene: el formato nativo de DuckDB.

La idea: el formato nativo de DuckDB como capa derivada

DuckDB tiene su propio formato de archivo (.duckdb) que incluye estadísticas —como HyperLogLog para conteos distintos— que el Parquet no guarda. La guía oficial reporta que las consultas sobre Parquet corren entre 1.1× y 5× más lento que sobre una base nativa, justo para cargas con joins y consultas repetidas. Que es exactamente la forma de un tablero.

La clave del diseño: el .duckdb es una capa derivada, no un reemplazo. Parquet sigue siendo la fuente de verdad. Una Lambda dedicada consolida diariamente todos los Parquet en un único archivo nativo, con una tabla por fuente, y lo sube a S3. La Lambda de servicio lo adjunta en modo solo-lectura.

 MSSQL Gold (vistas VW_*)
   │  Step Functions + EventBridge (export diario / mensual)
   ▼  COPY TO parquet (ZSTD, ROW_GROUP_SIZE 100000)
 S3 ── facturacion/fuente=X/anio=Y/mes=M/data.parquet   ★ fuente de verdad
 S3 ── facturacion/_db/facturacion-<ts>.duckdb          ★ capa nativa, versionada
   │        ▲ build-duckdb (cron diario, rebuild completo) → puntero en SSM
   ▼        │
 Lambda DuckDB genérica
   • ATTACH '<uri-del-puntero>' READ_ONLY   (re-attach por TTL 60 s)
   • sirve lake."<fuente>" y read_parquet(...)
   • Function URL + token; warmer en horario hábil
   ▼
 Portal (Amplify) → reporte-facturacion-duckdb
   • flag USE_NATIVE_DUCKDB → lake.<fuente> | read_parquet
   • caché DynamoDB (vistas default) → UI

Esto preserva tres cosas que no quería perder: interoperabilidad (otros consumidores siguen leyendo Parquet), reproducibilidad (el .duckdb se reconstruye ante cualquier fallo) y reversibilidad (puedo apagar la ruta nueva con un flag). El costo es duplicar almacenamiento —el archivo nativo pesó 1.75 GB, ~3× el Parquet— pero a esta escala son centavos.

Lo interesante no fue la idea en sí, sino los tres problemas que hubo que resolver para que funcionara en serverless.

Patrón 1: consistencia entre lectura y regeneración

Aquí está la trampa más sutil. Si adjuntas un .duckdb remoto y sobrescribes el mismo objeto de S3 durante la regeneración diaria, corrompes las lecturas en vuelo. DuckDB lee páginas por offset; un contenedor que tiene el catálogo viejo en memoria leería bytes del archivo nuevo. Resultado: respuestas basura, intermitentes e imposibles de reproducir.

La solución es un patrón transferible a cualquier artefacto inmutable servido desde object storage (índices, modelos, bases embebidas):

  • El builder escribe un archivo versionado e inmutable: facturacion-<epochMs>.duckdb. Nunca sobrescribe.
  • Publica un puntero en AWS SSM (/diveco/data-lake/query/duckdb-db-uri) con la URI de la última versión. Conserva las dos versiones más recientes y limpia el resto.
  • La Lambda de servicio relee el puntero con un TTL de 60 s. Si cambió, hace DETACH + ATTACH de la versión nueva.

El archivo en uso nunca se sobrescribe, así que la corrupción desaparece, y la staleness queda acotada a ≤ 60 s tras cada reconstrucción. Como Lambda serializa peticiones por contenedor, la re-adjunción no tiene condiciones de carrera dentro del contenedor.

Patrón 2: cold start determinista empaquetando extensiones

INSTALL httpfs descarga la extensión desde extensions.duckdb.org en cada cold start. Eso son ~1 s de latencia con el jitter impredecible que trae cualquier descarga por red.

La solución es aburrida y efectiva: pre-empaquetamos la extensión descomprimida en el artefacto de despliegue, y el handler hace LOAD '<ruta-local>' —offline, determinista— con fallback a INSTALL remoto por si acaso. En DuckDB 1.5, el proveedor credential_chain del secret de S3 migró a una extensión aws independiente, así que empaquetamos ambas (httpfs + aws).

El efecto: el init del motor bajó ~49 % (de ~1027 ms a ~520 ms) y, sobre todo, se volvió determinista: el rango pasó de 885–1224 ms a 653–786 ms. Sin sorpresas de red.

Detalle de producción: la versión empaquetada debe coincidir exactamente con la del binario embebido en el binding @duckdb/node-api. El fallback a INSTALL existe justo para protegerte de un desajuste de versión tras un upgrade.

Patrón 3: el warmer vence a provisioned concurrency

Adjuntar el .duckdb añade ~1 s al cold start (hay que leer el catálogo de un archivo de 1.75 GB por httpfs). La respuesta “obvia” de AWS sería provisioned concurrency: mantener una instancia siempre caliente.

Hicimos las cuentas: una instancia de 10 GB con provisioned concurrency factura el tiempo ocioso, ~110 USD/mes. Para un tablero que se usa en horario de oficina, eso es pagar 24/7 por algo que se usa 12 horas de lunes a viernes.

En su lugar, un calentador (warmer): un ping de EventBridge cada 5 minutos, solo en horario hábil (7:00–19:00 GT), que invoca la Lambda con {warmup:true} para mantener un contenedor caliente y el catálogo fresco. AWS no factura el tiempo ocioso del contenedor entre pings, solo los milisegundos de cada ping. Costo estimado: ~0.15 USD/mes. Unas 700× más barato que provisioned concurrency para el mismo objetivo de latencia.

¿La primera consulta de la noche paga cold start? Sí. Y está perfectamente bien.

Este es, para mí, el punto más importante del diseño serverless: el modelo de facturación —no solo la latencia— guía las decisiones de arquitectura.

Los resultados

Todo se midió en us-east-1, sobre la misma Lambda de servicio (10 240 MB, ~6 vCPU, DuckDB 1.5.3).

Paridad. Comparamos lake.<fuente> contra read_parquet(...) para las 8 fuentes y 5 de metas. Los conteos coincidieron exactamente; las medidas, salvo ruido de punto flotante en el decimal 15 (sumas en paralelo). La única divergencia fue un beneficio colateral: tres Parquet truncados de meses futuros hacían fallar la ruta read_parquet, mientras que la ruta nativa los omite en construcción y responde.

Latencia en caliente (medianas de 5 ejecuciones):

Consulta típica de tableroread_parquetnativoAceleración
Agregación por país (GROUP BY + SUM)72 ms9 ms~8×
COUNT(*) total histórico64 ms1 ms~64×
Serie mensual (GROUP BY mes)60 ms6 ms~10×
Filtro selectivo (1 mes, 1 organización)60 ms3 ms~20×

El formato nativo gana en todas las formas de consulta. El COUNT(*) se resuelve directo del metadato del catálogo.

Costo incremental mensual: reconstrucción diaria ~0.12 USD + warmer ~0.15 USD + almacenamiento extra en centavos. Menos de 0.30 USD/mes por toda la capa nativa.

Cut-over sin riesgo

Un detalle de ingeniería que vale oro: el cambio de read_parquet(...) a lake."<fuente>" se centralizó en dos helpers (fromFuente, fromMetas) gobernados por una variable, USE_NATIVE_DUCKDB.

Con el flag apagado (por defecto), el SQL generado es idéntico al original —verificado por la suite de pruebas—, así que desplegar no altera el comportamiento hasta que activas el flag. El rollback es un solo valor: false. Pude activar la ruta nueva en pruebas sin tocar producción.

Lo que me llevo de este proyecto

  1. El formato no era el cuello de botella; la capa lógica sí. Parquet + ZSTD ya era óptimo a nivel de bytes. La ganancia vino del formato nativo para consultas repetidas y de afinar el motor (más CPU, object_cache, extensiones locales). Mide antes de rediseñar.
  2. Serverless impone disciplina de estado. La ausencia de estado persistente convirtió la consistencia lectura/regeneración en el problema central. Versionado + puntero + TTL es la respuesta, y se transfiere a cualquier artefacto inmutable adjuntado en caliente.
  3. El costo es una variable de diseño de primera clase. La diferencia entre 0.15 y 110 USD/mes para el mismo objetivo de latencia no es un detalle de optimización. Es la decisión de arquitectura.

Hacia dónde sigue

Hay evolución natural cuando el dataset crezca y la reconstrucción completa deje de ser trivial:

  • DuckLake como capa de tabla: escritura incremental con ACID y time travel nativo, manteniendo Parquet en object storage y el catálogo en una base SQL.
  • S3 Express One Zone para GET de un dígito de milisegundos.
  • Clustering por ORDER BY en el export, para activar de una vez la poda de row groups que hoy pagamos pero no aprovechamos.
  • Rebuild incremental o una capa “cerrado vs. corriente”.

Por ahora, sobre un lake Parquet ya bien construido, una capa de serving derivada en formato nativo DuckDB nos dio aceleraciones de 8–64× en las consultas reales del tablero, con paridad exacta y por menos de lo que cuesta un café al mes. A veces la mejor arquitectura no es la más sofisticada, sino la más barata de operar y la más fácil de revertir.


¿Tu reportería corporativa todavía consulta el OLTP en tiempo real? ¿Cuánto te está costando —en segundos de espera y en infraestructura— no haber desacoplado la lectura analítica?