Despliegue de Lightdash en ctunlinux — cierre técnico

Fecha: 2026-08-09 Host: ctunlinux (2 vCPU / 5.2GB RAM+4GB swap / 48GB disco) Resultado: Lightdash 1.107.0 en producción, https://dash.frento.com.mx, proyecto 82df3b0a-4b56-4dee-837b-20706bae801b, 3 dashboards / 17 gráficas.

Por qué el pull de la imagen fue el cuello de botella real

lightdash/lightdash:latest pesa 14.1GB (docker images — SIZE 14.1GB, UNIQUE SIZE 14.06GB). docker history desglosado:

CapaTamaño
COPY /usr/app /usr/app (código de la app)2.79GB
dbt1.121.12GB
dbt1.11966MB
dbt1.10963MB
dbt1.9958MB
dbt1.5731MB
dbt1.8701MB
dbt1.7688MB
dbt1.4687MB
dbt1.6683MB
apt-get (python3, git, build-essential, fontconfig, etc.)494MB
Node.js runtime155MB
resto (duckdb extensions, pnpm, debian base)~170MB

El Dockerfile oficial instala las 9 versiones de dbt en venvs separados (/usr/local/dbt1.X) porque cuando un proyecto se conecta vía integración GitHub/GitLab, el propio servidor de Lightdash ejecuta dbt compile — y cada proyecto de cliente puede pinear una versión de dbt distinta. No hay build-arg ni stage para excluir versiones; la única forma de recortarlo es modificar el Dockerfile y compilar la imagen desde el código fuente (repo ~490MB sin node_modules, que en un monorepo pnpm de este tamaño fácilmente suma varios GB más) — descartado por el mismo motivo que el resto de este documento: este host no tiene margen de RAM/disco para un build de ese peso, y ya costó tres intentos solo extraer la imagen prearmada.

Como el proyecto usa el flujo CLI (lightdash deploy desde este mismo host, subiendo el manifest ya compilado — ver api-referencia.md), ninguna de las 9 versiones de dbt embebidas se usa nunca en este deployment. Es peso muerto en disco, cero impacto en RAM/CPU en operación normal.

Intento 1 — falla real de containerd/overlayfs

docker compose up -d (vía el docker-compose.yml inicial, con MinIO + Postgres + Lightdash). Tras ~35 minutos de descarga/extracción muy lenta (confirmado con ps aux que no estaba colgado: unpigz -d -c con CPU activo, solo lento por la RAM limitada del host generando swapping pesado), la extracción de la capa f6889744f9f2 (una de las de node_modules) falló:

failed to extract layer (application/vnd.oci.image.layer.v1.tar+gzip
sha256:f6889744f9f2...) to overlayfs as "extract-617546470-LLXG
sha256:357ae134...": failed call to UtimesNanoAt for
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/323/fs/usr/app/node_modules/.pnpm/@75lb+deep-merge@1.1.2:
no such file or directory

Error conocido de containerd/overlayfs al extraer árboles node_modules de pnpm (muchísimos archivos pequeños con hardlinks) bajo presión de memoria/inodos. docker compose down + docker rmi no dejó residuos (la capa parcial se descarta automáticamente al fallar), pero sí dejó el disco en ~6.9GB libres desde los ~18GB iniciales — la mayoría se recuperó al abortar, no se perdió permanentemente.

Intento 2 — el tag :latest cambió a mitad de la descarga

Segundo docker pull lightdash/lightdash:latest mostró un manifest distinto al del intento 1 (varios hashes de capa nuevos: bd9ddc54bea9, 04dfc350cea9, 7c7752f95ec8, 4b874653a391, junto con algunos reutilizados del intento anterior). Docker Hub publicó una build nueva de latest mientras el intento 1 todavía estaba en curso, invalidando buena parte del caché ya descargado. Abortado manualmente (kill al proceso docker pull) al ver que el disco volvía a bajar de forma sostenida sin garantía de terminar con el mismo manifest que ya se tenía parcialmente cacheado.

Intento 3 — éxito, después de liberar RAM

Antes de reintentar, se liberó RAM del host decomisionando servicios sin uso (no relacionado técnicamente con el pull, pero coincide con el diagnóstico de que la lentitud/fragilidad venía de swapping pesado en una VM de 5.2GB):

  • docker compose down en ~/authentik/ — 3 contenedores (authentik-server-1, authentik-worker-1, authentik-postgresql-1), sin evidencia de que protegiera nada activamente.
  • docker rm minio + docker rmi quay.io/minio/minio:latest — contenedor standalone ya exited hacía 4 semanas, bind mount a /usr/local/share/minio, sin relación con el lightdash-minio de este despliegue (nombre distinto, imagen distinta — minio/minio vs quay.io/minio/minio, mismo contenido pero repos Docker Hub separados).
  • docker stop dvt-checks soda-perses soda-loki — pausados temporalmente, reactivados al terminar el despliegue (docker start, ver nota abajo).

RAM libre pasó de 221MB a 731MB (swap de 875MB a 284MB) solo con Authentik + minio fuera. El tercer docker pull completó tras otro tramo largo (~15-20 min adicionales, con el disco tocando mínimos de ~2.6GB libres en el peor momento — nunca cruzó el umbral de aborto de seguridad de 2.5GB fijado a mano durante el proceso). docker compose up -d levantó los 4 contenedores (lightdash, lightdash-db, lightdash-minio, lightdash-minio-init) sin incidentes.

Nota operativa post-mortem: dvt-checks volvió a estar Up por su cuenta horas después (política restart: unless-stopped + algo lo reinició, no investigado a fondo). soda-perses/soda-loki, en cambio, quedaron detenidos varias horas hasta que se les hizo docker start manual al redactar esta documentación — data.frento.com.mx estuvo caído (502) todo ese tiempo sin que nadie lo notara. Lección: pausar contenedores para liberar recursos temporalmente necesita un recordatorio explícito de reactivarlos, no asumir que se reactivan solos.

Limpieza de disco adicional

Durante el incidente se aprovechó para borrar imágenes huérfanas/duplicadas sin contenedor activo, liberando ~2.1GB adicionales:

  • ghcr.io/goauthentik/server:2026.5.4 (1.84GB) y postgres:16-alpine (420MB) — huérfanas tras decomisionar Authentik.
  • apache/superset:latest (1.28GB nominal, casi todo compartido con superset-superset:latest — poco espacio único real) — duplicado sin contenedor.
  • Contenedor nifty_robinson (nombre autogenerado, comando apt-get..., exited hacía 4 semanas) + su imagen <none> asociada (6635f3d24af8, 1.28GB) — experimento abandonado, sin relación con ningún servicio documentado.

docker system df después de la limpieza: Images 15 total / 11 activas / 8.9GB, 696.8MB reclaimable en capas dangling.

Migración temporal de SECURE_COOKIES para setup por API

El registro del primer usuario/organización, la creación del proyecto, el token de acceso personal y los 3 dashboards se hicieron por curl contra la API REST (ver api-referencia.md), no desde la UI. Con SECURE_COOKIES=true (el valor correcto en producción, ya detrás de HTTPS), el Set-Cookie de login lleva el atributo Secure — curl no la persiste ni la reenvía sobre http://localhost:8090 en plano, así que cualquier llamada autenticada posterior fallaba con 401 Unauthorized en silencio (el login en sí devolvía 200 OK, la sesión simplemente no quedaba guardada).

Se bajó SECURE_COOKIES=false en el docker-compose.yml, se hizo docker compose up -d lightdash (recrea el contenedor), se completó todo el setup por API, y se volvió a subir SECURE_COOKIES=true con otro docker compose up -d lightdash antes de exponer el hostname en el túnel de Cloudflare — evitar una ventana donde cualquiera que adivinara la URL pública pudiera registrar la primera organización (el endpoint de registro está abierto hasta que existe al menos un usuario).

Esa segunda recreación del contenedor es la que causó el bug de red documentado en Lightdash § Red — la conexión a postgres-warehouse_default se había hecho a mano (docker network connect) después del primer docker compose up -d, sin declararla en el compose; la segunda recreación la perdió sin aviso hasta que el dashboard en producción tiró getaddrinfo EAI_AGAIN postgres-dw. Fix aplicado: declarar ambas redes en el docker-compose.yml (ver el archivo completo más abajo), que sobrevive cualquier recreación futura.

El bug de fct_movimientos — por qué deploy no es lo mismo que run

fct_movimientos se cambió de materialized: table a materialized: view en models/marts/fct_movimientos.sql durante el despliegue original, porque materializarlo como tabla completa (join sobre 4.5M filas de movimiento) agotó el disco del host a mitad de un dbt run (mismo tipo de límite que documenta Warehouse Postgres § movimiento para el pipeline de ingesta, aplicado ahora al lado de transformación).

Después de ese cambio se corrió lightdash deploy --ignore-errors -y, que completó sin error y dejó el dashboard “Inventario y Movimientos” funcionando en apariencia. lightdash deploy internamente corre dbt compile (o dbt list), no dbt run — regenera el manifest/SQL compilado que Lightdash usa para construir sus “explores”, pero nunca ejecuta ese SQL contra Postgres. La vista física analytics_marts.fct_movimientos nunca se creó. El error solo apareció días después, en el dashboard ya público, al intentar consultar una relación que Lightdash “sabía” que debía existir (estaba en el manifest) pero que Postgres nunca vio:

Chart 'Movimientos por mes': Error
relation "analytics_marts.fct_movimientos" does not exist

Fix: dbt run --select fct_movimientos (0.54s, CREATE VIEW) + dbt run completo sin selector para confirmar que los 20 modelos seguían consistentes (PASS=20, 5 tablas + 15 vistas, 8.25s totales — rápido porque casi todo ya estaba materializado, solo la vista faltante se creó de cero). Regla para cualquier cambio de modelo dbt futuro en este proyecto: dbt run primero, lightdash deploy después — nunca asumir que el segundo cubre al primero.

docker-compose.yml final (trivasa-bi-dev/lightdash/)

services:
  lightdash-minio:
    image: minio/minio:latest
    container_name: lightdash-minio
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: ${MINIO_PASS}
    volumes:
      - lightdash-minio-data:/data
    restart: unless-stopped
 
  lightdash-minio-init:
    image: minio/mc:latest
    container_name: lightdash-minio-init
    depends_on:
      - lightdash-minio
    entrypoint: >
      /bin/sh -c "
      until mc alias set local http://lightdash-minio:9000 minioadmin ${MINIO_PASS}; do sleep 1; done;
      mc mb -p local/lightdash;
      exit 0;
      "
    restart: "no"
 
  lightdash-db:
    image: pgvector/pgvector:pg15
    container_name: lightdash-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: lightdash
      POSTGRES_PASSWORD: ${PG_PASS}
      POSTGRES_DB: lightdash
    volumes:
      - lightdash-db-data:/var/lib/postgresql/data
 
  lightdash:
    platform: linux/amd64
    image: lightdash/lightdash:latest
    container_name: lightdash
    depends_on:
      - lightdash-db
      - lightdash-minio
    environment:
      - PGHOST=lightdash-db
      - PGPORT=5432
      - PGUSER=lightdash
      - PGPASSWORD=${PG_PASS}
      - PGDATABASE=lightdash
      - SECURE_COOKIES=true
      - TRUST_PROXY=true
      - LIGHTDASH_SECRET=${LIGHTDASH_SECRET}
      - PORT=8080
      - SITE_URL=https://dash.frento.com.mx
      - LIGHTDASH_INSTALL_TYPE=docker_image
      - ALLOW_MULTIPLE_ORGS=false
      - SCHEDULER_ENABLED=true
      - S3_ENDPOINT=http://lightdash-minio:9000
      - S3_REGION=us-east-1
      - S3_BUCKET=lightdash
      - S3_ACCESS_KEY=minioadmin
      - S3_SECRET_KEY=${MINIO_PASS}
      - S3_FORCE_PATH_STYLE=true
    ports:
      - "127.0.0.1:8090:8080"
    restart: unless-stopped
    networks:
      - default
      - postgres-warehouse_default
 
volumes:
  lightdash-db-data:
  lightdash-minio-data:
 
networks:
  default: {}
  postgres-warehouse_default:
    external: true

.env correspondiente (trivasa-bi-dev/lightdash/.env, permisos 600): LIGHTDASH_SECRET, PG_PASS, MINIO_PASS (los tres generados con openssl rand), ADMIN_PASS (contraseña del usuario admin, no reutilizada por ningún otro servicio de este host).

Véase también

  • api-referencia.md — todos los endpoints REST usados para el setup inicial y la creación de dashboards, reversados desde el propio binario del contenedor (sin documentación pública a este nivel de detalle).
  • Lightdash — página de wiki que resume esto en prosa consultable.