Docker en Producción: Imágenes Mínimas y Despliegues Reproducibles
Cómo construir imágenes ligeras, deterministas y auditables con compilación multi-etapa, usuarios sin privilegios y límites explícitos de recursos.
En la mayoría de proyectos, la imagen de contenedor es un detalle de infraestructura que se define una vez y se olvida. Cuando llega el primer incidente en producción, ese detalle deja de ser cosmético: el tamaño de la imagen, el usuario con el que se ejecuta y la forma en que se versionan las dependencias determinan cuánto tarda un despliegue, cuánta superficie de ataque expone el servicio y si dos entornos se comportan realmente igual.
Este artículo recoge decisiones concretas que aplicamos al empaquetar servicios en Docker, priorizando reproducibilidad y auditabilidad por encima de la comodidad de un Dockerfile de tres líneas.
1. Por qué el tamaño de la imagen es un problema operativo
Una imagen de varios gigabytes no es solo un número incómodo en el registro. Tiene consecuencias directas:
- Despliegues más lentos: cada nodo debe descargar la capa completa antes de arrancar el contenedor.
- Mayor superficie de ataque: cada binario y cada librería del sistema operativo base es una dependencia que hay que parchear.
- Arranques fríos más costosos en plataformas con autoescalado, donde el contenedor se crea a demanda.
El objetivo no es “la imagen más pequeña posible” como fin en sí mismo, sino una imagen que contenga únicamente lo necesario para ejecutar el servicio y nada más.
2. Compilación multi-etapa
La técnica más efectiva es separar el entorno de construcción del entorno de ejecución. La etapa de build puede incluir compiladores, cabeceras y herramientas; la etapa final solo copia los artefactos ya construidos.
# syntax=docker/dockerfile:1
FROM node:22-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
CMD ["node", "dist/server.js"]
La etapa build puede permitirse dependencias de desarrollo. La etapa runtime parte de una base más reducida y recibe solo node_modules de producción y el código compilado. Nada de código fuente, tests ni cachés de compilación viajan a producción.
3. Ejecutar sin privilegios
Por defecto, un contenedor se ejecuta como root. Si un proceso comprometido escapa del aislamiento, ese privilegio amplía el impacto. La corrección es sencilla y no tiene coste de rendimiento: crear un usuario sin privilegios y usarlo.
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN useradd --system --uid 10001 --create-home appuser
COPY --from=build --chown=appuser:appuser /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appuser /app/dist ./dist
USER appuser
CMD ["node", "dist/server.js"]
Conviene complementarlo con un sistema de archivos de solo lectura cuando el servicio no necesita escribir en disco. Esto se declara en el momento de la ejecución, no en la imagen:
docker run \
--read-only \
--tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges \
-p 3000:3000 \
klyntex/api:1.4.0
Cada una de estas banderas reduce el conjunto de acciones que un proceso comprometido puede realizar dentro del contenedor.
4. Determinismo: fijar versiones y ordenar capas
Una imagen “reproducible” es aquella en la que la misma revisión de código produce el mismo resultado hoy y dentro de seis meses. Tres hábitos ayudan a lograrlo:
- Fijar la imagen base por digest, no por etiqueta. La etiqueta
bookworm-slimpuede apuntar a contenidos distintos con el tiempo; el digest es inmutable. - Usar instalaciones deterministas.
npm cirespeta el archivo de bloqueo y falla sipackage.jsony el bloqueo no coinciden, algo quenpm installno hace. - Ordenar las capas de lo estable a lo cambiante. Copiar primero los manifiestos y después el código hace que la instalación de dependencias se cachee y no se repita en cada cambio de una sola línea.
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
Un .dockerignore cuidadoso completa el cuadro: excluir node_modules, .git, artefactos de build locales y archivos de entorno evita invalidar la caché sin motivo y reduce el contexto enviado al daemon.
5. Límites de recursos y verificación de salud
Un contenedor sin límites puede consumir toda la memoria del nodo y arrastrar consigo a servicios vecinos. Definir límites explícitos y una sonda de salud hace que el orquestador pueda tomar decisiones informadas.
docker run \
--memory 512m \
--cpus 1.0 \
--health-cmd "node -e \"require('http').get('http://localhost:3000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\" " \
--health-interval 30s \
--health-retries 3 \
klyntex/api:1.4.0
La sonda debe comprobar que el servicio responde, no solo que el proceso sigue vivo. Un proceso que responde con errores a todas las peticiones está, a efectos prácticos, caído.
6. Conclusión
Empaquetar bien un servicio no requiere herramientas exóticas. Compilación multi-etapa, ejecución sin privilegios, versiones fijadas, límites de recursos y una sonda de salud honesta cubren la mayor parte del riesgo operativo. La recompensa es un despliegue predecible: cuando algo falla, el problema está en el código o en los datos, no en las diferencias silenciosas entre entornos.