Nada arruina un despliegue limpio como una imagen que se compila bien y que luego se desploma en tiempo de ejecución porque eliminaste silenciosamente la única biblioteca compartida que tu binario necesita. La marca verde del CI se convierte en una pequeña mentira que te dices a ti mismo.
Las construcciones multi-etapa son la herramienta adecuada para reducir imágenes sin convertir producción en una excavación arqueológica. Pero también son una herramienta afilada. Bien usadas, cortan la grasa. Usadas descuidadamente, cortan arterias.
Tabla de contenidos
- Qué hace realmente la construcción multi-etapa (y qué no hace)
- Datos interesantes y breve historia (porque el contexto evita incidentes)
- Patrones centrales que funcionan en producción
- El contrato de tiempo de ejecución: lo que debes mantener
- Tareas prácticas: comandos, salidas y decisiones (12+)
- Guía rápida de diagnóstico
- Errores comunes: síntomas → causa raíz → solución
- Tres micro-historias corporativas desde el campo
- Listas de verificación / plan paso a paso
- Preguntas frecuentes
- Conclusión: próximos pasos que pagan la factura
Qué hace realmente la construcción multi-etapa (y qué no hace)
Las construcciones multi-etapa son la forma de Docker de dejarte usar una imagen para compilar y otra para ejecutar—dentro de un mismo Dockerfile. Compilas en una etapa pesada con compiladores y cabeceras, y luego copias los resultados a una etapa delgada que contiene solo lo que la aplicación necesita en tiempo de ejecución.
La parte importante: las construcciones multi-etapa no convierten mágicamente tu aplicación en “amigable con mínimos requisitos”. Simplemente facilitan separar las dependencias de tiempo de compilación de las dependencias de tiempo de ejecución. Si tu app necesita glibc en tiempo de ejecución y la despliegas en una imagen Alpine basada en musl, no “optimizaste”. Plantaste una bomba de tiempo.
Por qué a la gente de operaciones le gusta
Las imágenes más pequeñas se descargan más rápido, ocupan menos espacio, se escanean más rápido y tienen una menor superficie de ataque. No es teoría: son menos bytes cruzando la red el día del despliegue y menos paquetes que parchear a las 2 a.m.
Qué cambia en la práctica
- Reproducibilidad de la compilación: puedes fijar toolchains de compilación sin inflar el tiempo de ejecución.
- Postura de seguridad: menos paquetes en tiempo de ejecución equivalen a menos CVE que revisar.
- Modos de fallo: verás bibliotecas faltantes, certificados CA ausentes, datos de zona horaria faltantes, falta de shell, usuarios inexistentes—cosas de las que no te dabas cuenta que dependías.
Una cita para tener pegada en tu monitor:
“La esperanza no es una estrategia.” — Gordon R. Dickson
Las construcciones multi-etapa son cómo dejas de esperar que tu imagen de tiempo de ejecución “probablemente tenga lo que necesita”. Lo verificas.
Datos interesantes y breve historia (porque el contexto salva incidentes)
Algunos puntos de contexto cortos y concretos que explican por qué las construcciones multi-etapa se convirtieron en práctica estándar:
- Docker añadió las construcciones multi-etapa en 2017 (Docker 17.05). Antes de eso, la gente usaba “contenedores builder” frágiles y pasos de copia manuales.
- El cacheo de capas moldeó el estilo de Dockerfile: ordenar comandos para maximizar la reutilización de caché se volvió una habilidad porque reconstruir todo era terriblemente lento.
- Alpine se popularizó por ser pequeño, no porque fuera universalmente compatible. La incompatibilidad musl vs glibc aún pica a equipos que envían binarios precompilados.
- Las imágenes distroless (runtimes mínimos sin gestores de paquetes ni shells) ganaron tracción conforme crecieron las preocupaciones por la cadena de suministro y la superficie de ataque.
- El formato de imagen OCI estandarizó el layout de imágenes entre runtimes, lo que hizo que las herramientas de inspección y escaneo fueran más consistentes.
- BuildKit cambió las reglas: mejor paralelismo, mejor caché, mounts de secretos y patrones avanzados de copia hicieron las multi-etapas más mantenibles.
- Los SBOM se volvieron habituales cuando las organizaciones empezaron a necesitar responder “¿qué hay dentro de esta imagen?” durante auditorías e incidentes.
- Los ecosistemas de lenguajes respondieron de forma distinta: Go abrazó binarios estáticos; Node y Python se apoyaron en bases más delgadas; Java añadió jlink/jdeps para recortar runtimes.
Patrones centrales que funcionan en producción
Patrón 1: Builder + runtime con artefactos explícitos
Este es el patrón canónico multi-etapa: compilar en una etapa, copiar un binario o la app empaquetada a una etapa de runtime.
cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Opinión: si puedes enviar un binario verdaderamente estático, hazlo. Es el artefacto operacional más limpio. Pero solo si entiendes qué sacrificas (funciones de glibc, matices de DNS, herramientas a nivel OS). Estático no es “mejor”, es “diferente”.
Patrón 2: Runtime “base slim” (aún con shell)
Distroless es excelente para endurecer. También es un dolor cuando estás de guardia y necesitas inspeccionar el contenedor rápidamente. A veces la respuesta correcta es “Debian slim con shell”, especialmente cuando la madurez de tu organización dice que depurarás en vivo.
cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1
FROM node:22-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Si vas a adelgazar imágenes, hazlo sin eliminar tu capacidad de operar el servicio. Un shell no es malvado; enviar compiladores y gestores de paquetes en runtime sí lo es.
Patrón 3: “Etapa toolbox” para depuración (fuera de producción)
Mantén las imágenes de producción mínimas, pero no finjas que nunca necesitarás herramientas. Usa una etapa extra como toolbox para reproducir problemas localmente o en un despliegue de depuración controlado.
cr0x@server:~$ cat Dockerfile
# syntax=docker/dockerfile:1
FROM debian:bookworm AS toolbox
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates iproute2 dnsutils procps strace \
&& rm -rf /var/lib/apt/lists/*
# other stages ...
No envías la etapa toolbox. La mantienes para que tus ingenieros puedan anexar las mismas herramientas al mismo layout de sistema de archivos cuando depuren.
Patrón 4: Reutilizar artefactos entre múltiples imágenes finales
Un buen Dockerfile multi-etapa puede emitir múltiples objetivos: una imagen de runtime, una imagen de depuración, una imagen de test. Mismo código fuente, salidas diferentes.
cr0x@server:~$ docker buildx build --target runtime -t myapp:runtime .
[+] Building 18.7s (16/16) FINISHED
=> exporting to image
=> => naming to docker.io/library/myapp:runtime
Así es como mantienes paridad sin enviar basura. Mismo build, target diferente.
Broma #1: Si tu Dockerfile tiene una sola etapa, no es “simple”, es “optimista”.
El contrato de tiempo de ejecución: lo que debes mantener
La imagen de runtime es un contrato entre tu app y el userland del SO que envías. Las construcciones multi-etapa facilitan violar ese contrato por accidente. Esto es lo que suele faltar:
1) Bibliotecas compartidas y cargador dinámico
Si compilas con CGO habilitado (común para comportamiento de DNS, bindings de SQLite, procesamiento de imágenes, etc.), necesitarás la libc y el cargador correctos en la etapa de runtime. Si compilas en Debian y ejecutas en Alpine, puedes obtener el clásico:
exec /app: no such file or directory(aunque el archivo exista) porque la ruta del cargador dinámico no existe en el runtime.
2) Certificados CA
Tu servicio se comunica con endpoints HTTPS. Sin certificados CA, TLS falla. Muchas imágenes “mínimas” no los incluyen por defecto.
3) Datos de zona horaria y locales
UTC-only está bien hasta que no lo está—trabajos de reportes, timestamps visibles al cliente y logs de cumplimiento pueden volverse extraños rápidamente.
4) Usuarios, permisos y propiedad de archivos
Las copias multi-etapa preservan la propiedad de archivos a menos que le digas a Docker lo contrario. Si ejecutas como non-root (deberías), verifica que los archivos sean legibles/ejecutables.
5) Semántica del entrypoint
La forma shell vs exec importa. Si dependes de expansión de shell pero eliminaste el shell, lo descubrirás en tiempo de ejecución, no en build.
6) Expectativas de observabilidad
Si tu playbook de guardia asume que curl existe dentro del contenedor, distroless te decepcionará. Usa sidecars, contenedores de depuración efímeros o objetivos de depuración separados. Decide con intención.
Tareas prácticas: comandos, salidas y decisiones (12+)
Estas son tareas reales que puedes ejecutar hoy. Cada una incluye: comando, qué significa la salida y qué decisión tomar.
Tarea 1: Comparar tamaños de imagen y decidir si la optimización importa
cr0x@server:~$ docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
REPOSITORY TAG SIZE
myapp fat 1.12GB
myapp slim 142MB
myapp distroless 34.6MB
Significado: tienes una reducción de orden de magnitud disponible.
Decisión: si los despliegues son lentos, el almacenamiento del registro es costoso o los escáneres están saturados, persigue multi-etapa. Si tu imagen ya está ~60–150MB y estable, prioriza la corrección sobre rascarte otros 10MB.
Tarea 2: Inspeccionar capas para encontrar qué está inflando la imagen
cr0x@server:~$ docker history --no-trunc myapp:fat | head -n 8
IMAGE CREATED CREATED BY SIZE COMMENT
3f2c... 2 hours ago /bin/sh -c npm install 402MB
b18a... 2 hours ago /bin/sh -c apt-get update && apt-get install 311MB
9c10... 3 hours ago /bin/sh -c pip install -r requirements.txt 198MB
...
Significado: estás enviando dependencias de compilación y caches de paquetes.
Decisión: mueve herramientas de compilación/instalación a la etapa builder; asegúrate de que los caches no se copien al runtime; usa npm ci --omit=dev o equivalente.
Tarea 3: Confirmar qué etapas existen y cómo se llaman
cr0x@server:~$ docker buildx bake --print 2>/dev/null | sed -n '1,40p'
{
"target": {
"default": {
"context": ".",
"dockerfile": "Dockerfile"
}
}
}
Significado: tu configuración de build es simple; el descubrimiento de etapas está en el Dockerfile.
Decisión: nombra explícitamente las etapas (AS build, AS runtime) para que las líneas COPY no se vuelvan obsoletas.
Tarea 4: Construir un target específico para validar solo la etapa de runtime
cr0x@server:~$ docker build --target runtime -t myapp:runtime .
[+] Building 21.3s (12/12) FINISHED
=> exporting to image
=> => naming to docker.io/library/myapp:runtime
Significado: la etapa de runtime se construye y exporta. Buen comienzo.
Decisión: conecta el CI para construir explícitamente el target de runtime, no solo la etapa por defecto.
Tarea 5: Ejecutar el contenedor y vigilar la clase de fallos “compila, no corre”
cr0x@server:~$ docker run --rm myapp:runtime
standard_init_linux.go:228: exec user process caused: no such file or directory
Significado: usualmente un desajuste del cargador dinámico / libc, no un binario faltante.
Decisión: verifica si el binario está vinculado dinámicamente y si la base de runtime tiene el cargador correcto (glibc vs musl). Corrige la elección de base o los flags de compilación.
Tarea 6: Comprobar si un binario está vinculado dinámicamente (inspección en etapa builder)
cr0x@server:~$ docker run --rm --entrypoint /bin/bash myapp:build -lc "file /out/app && ldd /out/app || true"
/out/app: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
linux-vdso.so.1 (0x00007ffd6b3d9000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9d7c3b4000)
/lib64/ld-linux-x86-64.so.2 (0x00007f9d7c5b6000)
Significado: está vinculado dinámicamente y espera rutas del cargador estilo Debian.
Decisión: ejecuta sobre una base compatible con glibc (Debian/Ubuntu/distroless-glibc), o recompila estático (si es viable), o empaqueta las librerías necesarias cuidadosamente.
Tarea 7: Verificar que existan certificados CA en la imagen de runtime
cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:runtime -lc "ls -l /etc/ssl/certs/ca-certificates.crt 2>/dev/null || echo missing"
missing
Significado: las llamadas TLS fallarán a menos que tu runtime de lenguaje incluya certificados (muchos no lo hacen, o no completamente).
Decisión: instala ca-certificates en la etapa de runtime (o cópialas desde el builder), o cambia a una base que las incluya.
Tarea 8: Probar TLS saliente desde dentro del contenedor (cuando existan herramientas)
cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "curl -fsS https://example.com | head"
<!doctype html>
<html>
<head>
Significado: los certificados y DNS funcionan, el egress de red funciona, hay una sanidad básica del runtime.
Decisión: si esto falla, no adivines—revisa certificados, DNS, proxies y políticas de red antes de tocar el Dockerfile otra vez.
Tarea 9: Confirmar que la imagen de runtime contiene los archivos de configuración necesarios
cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:runtime -lc "ls -l /app/config || true"
ls: cannot access '/app/config': No such file or directory
Significado: olvidaste copiar defaults de config, plantillas, migraciones o assets estáticos.
Decisión: COPY explícitamente esos artefactos desde el contexto de build o desde la salida del builder; no dependas de “estaba en la imagen vieja”.
Tarea 10: Confirmar usuario y permisos de archivos (runtime sin root)
cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "id && ls -l /app && test -x /app/app && echo executable"
uid=1000(node) gid=1000(node) groups=1000(node)
total 18240
-rwxr-xr-x 1 root root 18673664 Jan 2 12:11 app
executable
Significado: el binario es ejecutable, pero es propiedad de root.
Decisión: decide si la propiedad importa. Para lectura/ejecución está bien; para escribir logs, archivos temporales o caches, explotará. Prefiere COPY --chown=node:node para directorios de la app que necesiten escritura.
Tarea 11: Medir tiempo de build y efectividad de caché
cr0x@server:~$ DOCKER_BUILDKIT=1 docker build --progress=plain -t myapp:test . | sed -n '1,35p'
#1 [internal] load build definition from Dockerfile
#2 [internal] load metadata for docker.io/library/node:22-bookworm
#3 [build 1/6] WORKDIR /app
#4 [build 2/6] COPY package*.json ./
#5 [build 3/6] RUN npm ci
#5 CACHED
#6 [build 4/6] COPY . .
#7 [build 5/6] RUN npm run build
...
Significado: el paso de instalación de dependencias está cacheado; el copiado del código invalida solo las capas posteriores.
Decisión: mantiene los descriptores de dependencias copiados antes del código fuente, para que cambios pequeños en el código no disparen una reinstalación completa de dependencias.
Tarea 12: Verificar qué terminó realmente en el filesystem de la imagen de runtime
cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "du -sh /app/* | sort -h | tail"
4.0K /app/package.json
16M /app/node_modules
52M /app/dist
Significado: node_modules es más pequeño que dist; estás enviando solo dependencias de producción (bien).
Decisión: si node_modules es enorme, probablemente enviaste devDependencies o un cache de build. Arregla el comando de instalación y .dockerignore.
Tarea 13: Detectar inclusión accidental de secretos o basura de compilación
cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "find /app -maxdepth 2 -name '*.pem' -o -name '.env' -o -name 'id_rsa' 2>/dev/null | head"
Significado: no se encontraron archivos secretos obvios (esto no es una auditoría completa, pero es una comprobación rápida de cordura).
Decisión: si algo aparece, detén la línea. Arregla el contexto de build y .dockerignore, luego rota secretos según sea necesario.
Tarea 14: Comparación rápida de superficie CVE mediante listado de paquetes
cr0x@server:~$ docker run --rm --entrypoint /bin/sh myapp:slim -lc "dpkg -l | wc -l"
196
Significado: 196 paquetes instalados. Es mucha superficie potencial que parchear.
Decisión: si puedes moverte a distroless o a una base más delgada manteniendo el contrato de runtime, reduces el trabajo de parches. Si no puedes, al menos fija versiones y parchea de forma predecible.
Tarea 15: Confirmar que entrypoint y cmd son lo que crees
cr0x@server:~$ docker inspect myapp:runtime --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}'
["/app"] null
Significado: el contenedor usa entrypoint en forma exec; no se requiere shell.
Decisión: mantenlo así. Si ves ["/bin/sh","-c",...] en una imagen mínima, espera fallos en tiempo de ejecución cuando el shell no esté presente.
Guía rápida de diagnóstico
Cuando una “optimización” multi-etapa rompe producción, no tienes tiempo para filosofía contenedorizada. Necesitas una secuencia que encuentre el cuello de botella rápidamente.
Primero: clasifica la falla (inicio vs servicio vs llamadas externas)
- El contenedor no arranca: entrypoint faltante, desajuste de cargador, permisos, arquitectura incorrecta.
- Arranca y luego falla: archivos de config/assets faltantes, variables de entorno ausentes, segfault por desajuste de libc, directorio de trabajo erróneo.
- Arranca y sirve pero con funcionalidades rotas: certificados CA faltantes, zona horaria, fuentes, códecs de imagen, locales, diferencias en comportamiento DNS.
Segundo: confirma lo que enviaste (no lo que pensabas enviar)
- Inspecciona entrypoint/cmd (
docker inspect). - Lista archivos esperados dentro del runtime (
ls,du). - Revisa modo de enlace del binario (
file,ldden la etapa builder).
Tercero: valida el contrato de runtime con una sola petición de sondeo
- Para clientes HTTPS: comprueba que exista el bundle de CA; prueba TLS contra un endpoint conocido (en una imagen de depuración si es necesario).
- Para apps muy dependientes de DNS: verifica la configuración del resolv y su comportamiento; confirma /etc/resolv.conf dentro del contenedor.
- Para escrituras en filesystem: chequea usuario, permisos y rutas escribibles (
/tmp, dirs de cache de la app).
Cuarto: decide la vía de remediación
- Desajuste de base: cambia la base de runtime, no parchees librerías a la mala a menos que te gusten las sorpresas.
- Artefactos faltantes: cópialos explícitamente, añade tests que fallen el build si faltan.
- Demasiado minimal para depurar: añade un target de debug; no metas shells en producción “por si acaso”.
Errores comunes: síntomas → causa raíz → solución
Esta sección existe porque la mayoría de fallos se repiten. Los equipos cambian; la física no.
1) “exec … no such file or directory” pero el archivo existe
- Síntoma: el contenedor sale inmediatamente; el error menciona “no such file or directory”.
- Causa raíz: ruta del cargador dinámico ausente (binario glibc en imagen musl), arquitectura incorrecta, o finales de línea CRLF en scripts.
- Solución: ejecuta
fileyldden la etapa builder; alinea la imagen base con la libc; asegura el correctoGOOS/GOARCH; usa entrypoint en forma exec; normaliza finales de línea.
2) Errores TLS: “x509: certificate signed by unknown authority”
- Síntoma: la app arranca pero no puede llamar a dependencias HTTPS.
- Causa raíz: bundle de certificados CA faltante en la imagen de runtime.
- Solución: instala
ca-certificatesen runtime; o copia el bundle desde el builder; verifica con una sonda TLS.
3) Funciona en CI, falla en producción: config/plantillas/migraciones faltantes
- Síntoma: errores en runtime sobre archivos faltantes; endpoints devuelven 500; arranque queja de plantillas.
- Causa raíz: la copia multi-etapa solo trajo el binario, no los archivos de soporte.
- Solución: define un directorio de artefactos explícito en la salida del builder y cópialo completo; añade una comprobación en tiempo de build de que las rutas requeridas existen.
4) Permiso denegado al escribir logs/cache
- Síntoma: la app se cae al escribir en /app, /var/log o directorios de cache; funciona como root.
- Causa raíz: el runtime corre como non-root, pero los archivos/directorios copiados son de propiedad root y no son escribibles.
- Solución: crea directorios escribibles en la etapa de runtime; usa
COPY --chown; defineUSERintencionalmente; prefiere escribir en /tmp o en volúmenes dedicados.
5) “sh: not found” o “bash: not found”
- Síntoma: el contenedor falla porque intenta ejecutar un comando de shell.
- Causa raíz: CMD/ENTRYPOINT en forma shell en una imagen mínima que no trae shell.
- Solución: usa la forma exec en arrays JSON; elimina scripts de shell o usa una base apropiada; para lógica de inicio compleja considera un binario init pequeño.
6) La imagen es pequeña pero los builds ahora son dolorosamente lentos
- Síntoma: el tiempo de CI aumentó después de la “optimización”.
- Causa raíz: orden de caché pobre, copiar el repo entero antes de instalar dependencias, o deshabilitar características de BuildKit.
- Solución: copia los manifiestos de dependencias primero; usa BuildKit; usa .dockerignore; considera mounts de caché para gestores de paquetes.
7) “Funciona localmente, falla en Kubernetes” tras adelgazar
- Síntoma: docker run local bien; en cluster falla con DNS/timeouts/permisos.
- Causa raíz: el contexto de seguridad del cluster corre con un UID diferente; filesystem de solo lectura; políticas de red más estrictas; faltan herramientas para inspección.
- Solución: alinea el usuario de runtime; escribe en rutas aprobadas; prueba con el mismo contexto de seguridad; proporciona un target de debug o método efímero de depuración.
Broma #2: Distroless es genial hasta que te das cuenta de que has contenedorizado tu capacidad de entrar en pánico en silencio.
Tres micro-historias corporativas desde el campo
Micro-historia 1: El incidente causado por una suposición
Tenían un servicio Go que “obviamente” se compilaba a un binario estático. Eso es lo que todos dicen sobre servicios Go justo antes de que habiliten CGO por una pequeña característica y se les olvide. El equipo cambió la base de runtime de Debian slim a Alpine porque el tamaño quedaba fantástico en una diapositiva.
El despliegue se lanzó en horario laboral. Algunos pods arrancaron y enseguida se cayeron. Los logs eran insultantes: “no such file or directory.” La gente comprobó la imagen; el binario estaba ahí. Alguien sugirió que el registry lo había corrompido. Otro dijo que Kubernetes “tenía uno de esos días”. Clásico.
El problema real: el binario estaba vinculado dinámicamente con glibc y esperaba /lib64/ld-linux-x86-64.so.2. Alpine no lo tenía. El binario ni siquiera pudo llegar a main(). No era un bug de aplicación. Era un problema del cargador.
La solución fue aburrida: volver la base de runtime a una imagen compatible con glibc, luego decidir si CGO era realmente necesario. Más tarde reconstruyeron con CGO_ENABLED=0 y verificaron con file y ldd en CI.
La lección quedó: “minimal” es un contrato de runtime, no un plan de dieta. Si no puedes describir las dependencias de tu binario, no puedes reducir el contenedor de forma segura.
Micro-historia 2: La optimización que salió mal
Un equipo de plataforma decidió acelerar el CI cacheando todo. Introdujeron un caching agresivo de BuildKit y copiaron el monorepo entero temprano en el Dockerfile para que los builds tuvieran “contexto”. La imagen se hizo más pequeña gracias a multi-etapa. Los tiempos de build, sin embargo, se convirtieron en un desastre en cámara lenta.
¿Por qué? Porque copiar el repo entero antes de instalar dependencias invalidaba la caché en casi cada commit. Un cambio de doc en un servicio vecino causaba la reconstrucción de la capa de dependencias de Node. Los ingenieros empezaron a evitar merges cerca de ventanas de release porque los builds eran demasiado lentos para iterar con seguridad.
Luego añadieron una segunda “optimización”: un RUN rm -rf general al final de la etapa builder. No ayudó al tamaño de runtime (multi-etapa ya descartaba el builder), pero sí incrementó el tiempo de build y redujo la reutilización de caché, porque las capas cambiaban constantemente.
Eventualmente refactorizaron: contextos específicos por servicio, .dockerignore estrictos y manifiestos de dependencias copiados temprano. También dejaron de “limpiar” capas del builder que nunca se enviaban.
La conclusión operativa: optimiza lo que realmente te cuesta. Con builds multi-etapa, el tamaño de runtime suele ser barato de arreglar; la latencia de build requiere disciplina y estructura.
Micro-historia 3: La práctica aburrida pero correcta que salvó el día
Un equipo empresarial estaba moviendo una API Python a builds multi-etapa. Todos querían distroless porque a seguridad le encantaba la frase “sin shell”. Los SREs pusieron resistencia, no porque amaran shells, sino porque amaban dormir.
Construyeron dos targets: runtime (mínimo) y debug (mismos bits de la app, más un shell y herramientas de red básicas). La imagen de debug no se desplegaba por defecto. Solo se usaba para respuesta a incidentes en un namespace controlado, con aprobaciones explícitas.
Dos meses después, una dependencia empezó a fallar TLS intermitentemente por una rotación de proxy MITM corporativo. La imagen de producción no tenía curl ni openssl, como se diseñó. El on-call desplegó el target de debug para reproducir el fallo y confirmó el problema de la cadena CA en minutos, no en horas.
La resolución fue sencilla: actualizar CAs confiables y validar la configuración del proxy. La verdadera ganancia fue el tiempo hasta el diagnóstico. El equipo no tuvo que reconstruir una “imagen debug puntual” durante el outage mientras todos miraban.
La lección: tener un target de debug es trabajo aburrido de gobernanza. También es cómo evitas convertir outages en teatro improvisado.
Listas de verificación / plan paso a paso
Plan paso a paso: migrar a multi-etapa sin romper el runtime
- Comienza con una base conocida y buena. Conserva tu Dockerfile/imagen actual como referencia. No lo borres hasta que el nuevo pruebe su valía.
- Nombra tus etapas. Usa
AS buildyAS runtime. Tu yo del futuro te lo agradecerá. - Define un directorio de artefactos. Ejemplo:
/outcontiene binarios, assets, migraciones y configs que deben enviarse. - Copia solo artefactos al runtime. No todo el repo. No
/root/.cache. No tus sentimientos. - Elige una base de runtime que coincida con tu linkage. Si es glibc dinámico: usa Debian/Ubuntu/distroless-base. Si es estático: distroless-static puede ser excelente.
- Añade comprobaciones del contrato de runtime en CI. Verifica que existan los archivos esperados; verifica el tipo de binario; verifica la presencia del bundle CA si aplica.
- Ejecuta como non-root. Define
USER. Arregla la propiedad de archivos conCOPY --chowny crea directorios escribibles explícitamente. - Crea un target de debug. Mismos bits de la app, herramientas extra. No lo despliegues a prod por defecto, pero mantenlo construible.
- Mide antes/después. Tamaño de imagen, tiempo de pull, tiempo de build, tiempo de escaneo, tiempo de arranque. Elige optimizaciones que muevan métricas reales, no solo estética.
- Despliega gradualmente. Canary deploy. Observa logs por archivos/libs faltantes. Si te sorprende algo, tus checks están incompletos.
Lista de verificación: elementos del contrato de runtime
- Entrypoint en forma exec y existente
- Arquitectura del binario coincide con los nodos del cluster
- Linkage del binario coincide con la libc de la imagen base
- Certificados CA disponibles para HTTPS
- Estrategia de zona horaria/locales definida (solo UTC o incluir tzdata)
- Usuario non-root configurado; directorios escribibles creados
- Todos los assets/config/migraciones requeridos copiados
- Healthcheck funcional (o health checks externos consideran imágenes mínimas)
Preguntas frecuentes
1) ¿Siempre valen la pena las construcciones multi-etapa?
No. Si tu imagen de runtime ya es pequeña y estable, y rara vez la reconstruyes, puede que no obtengas suficiente beneficio. Pero para la mayoría de servicios con despliegues frecuentes, vale la pena hacerlo una vez y mantenerlo correcto.
2) ¿Debería usar Alpine para runtime para ahorrar espacio?
Sólo si estás seguro de que tus dependencias de runtime son compatibles con musl, o compilaste específicamente para Alpine. De lo contrario usa Debian slim o variantes distroless que coincidan con tu linkage.
3) Distroless vs slim: ¿cuál elegir?
Distroless cuando tienes buena observabilidad y un camino de depuración claro (target debug, contenedores efímeros). Slim cuando tu organización aún necesita la energía de “entrar al contenedor” para sobrevivir incidentes.
4) ¿Por qué mi imagen se redujo pero el arranque se volvió más lento?
No suele ser por el tamaño de la imagen. Suele deberse a caches ausentes (esperado), JIT frío, cambios en comportamiento de DNS o cambios en la lógica de init. Mide el tiempo de arranque y revisa qué cambió además de los bytes.
5) ¿Cómo evito copiar secretos al stage de runtime?
Mantén limpio el contexto de build con .dockerignore, evita copiar el repo completo cuando sólo necesitas artefactos, y nunca hornees secretos de runtime en imágenes. Usa mecanismos de inyección de secretos en tiempo de ejecución.
6) ¿Puedo ejecutar sin shell y aún depurar eficazmente?
Sí. Usa un target de debug, sidecars o contenedores efímeros de depuración. La clave es planear la depuración, no fingir que no ocurrirá.
7) ¿Cuál es la forma más segura de manejar certificados en imágenes mínimas?
Prefiere una base que incluya certificados CA, o instala/copia explícitamente un bundle CA conocido. Luego prueba TLS en una comprobación de integración que falle a voz en cuello.
8) ¿Cómo mantengo los tiempos de build rápidos con multi-etapa?
Ordena tu Dockerfile para el caché: copia los manifiestos de dependencias primero, instala deps, luego copia el código. Usa BuildKit. Usa .dockerignore para mantener archivos no relacionados fuera del contexto.
9) ¿Está bien tener múltiples etapas finales?
Sí. Es un patrón sólido: runtime para producción, debug para incidentes, test para CI. Mismo código y artefactos, empaquetados diferentes.
10) ¿Y si mi app necesita paquetes del SO en tiempo de ejecución?
Entonces instálalos en runtime—intencional y mínimamente. Las construcciones multi-etapa no son una renuncia a dependencias en runtime; son una renuncia a las dependencias accidentales.
Conclusión: próximos pasos que pagan la factura
Si te llevas una cosa: las construcciones multi-etapa no son un truco para ganar un concurso de “imagen más pequeña”. Son un mecanismo para hacer explícitas y auditableas las dependencias de runtime.
Próximos pasos que puedes hacer esta semana:
- Elige un servicio con una imagen vergonzosamente grande e implementa un split builder/runtime con etapas nombradas.
- Añade checks en CI que validen el contrato de runtime: linkage/arquitectura del binario, archivos requeridos presentes y certificados CA cuando corresponda.
- Crea un target
debugy documenta cuándo puede usarse. - Mide tamaño de imagen, tiempo de build y tiempo de despliegue antes/después. Conserva los cambios que mejoren operaciones, no solo estética.