Guía DevOps para Sysadmins: Escribiendo tu Primer Dockerfile Optimizado
TL;DR — Lo que vas a tener al final
- Tiempo de setup: 1-2 horas para el primer multi-stage, 30 min para los siguientes.
- Requisitos: Docker 24+ con BuildKit habilitado.
- Resultado: Imágenes 80-95% más pequeñas, builds 50-70% más rápidos.
- Troubleshooting: DOCKER_BUILDKIT=1, dive para analizar layers, troubleshooting sin shell con stage debug.
Por qué importa optimizar un Dockerfile
Un Dockerfile de 12 líneas que termina produciendo una imagen de 1.4 GB es uno de los errores más comunes en equipos que empiezan con Docker. La imagen pesa, el pull tarda, el deploy se vuelve lento, y encima cada apt-get update deja basura en una capa intermedia que se queda ahí para siempre. La buena noticia: optimizar un Dockerfile no es magia negra ni requiere reescribir tu aplicación. Es una serie de decisiones mecánicas, repetibles, que cualquier sysadmin puede aplicar en su próximo commit.
Esta guía es para sysadmins y devs junior que ya tienen su primer docker build funcionando y quieren entender por qué la imagen les quedó grande, por qué el docker push tarda 4 minutos, y cómo pasar de una imagen “que funciona” a una imagen “lista para producción”. Vamos paso a paso, con ejemplos reales que puedes copiar y adaptar a tu stack.
Anatomía de un Dockerfile: las instrucciones que importan
Antes de optimizar, entendé qué hace cada instrucción a nivel de capa. Docker construye imágenes como una pila inmutable: cada instrucción FROM, RUN, COPY, ADD crea una capa que se queda en la imagen final, aunque la borres en una capa posterior. La regla práctica: menos capas con más trabajo cada una, salvo cuando eso rompe el caché.
Estas son las instrucciones que vas a usar el 95% del tiempo:
| Instrucción | Crea capa | Uso típico |
|---|---|---|
FROM |
Sí | Imagen base. SIEMPRE la primera línea. |
RUN |
Sí | Ejecutar comandos en build (instalar paquetes, compilar). |
COPY |
Sí | Copiar archivos del contexto al filesystem de la imagen. |
ADD |
Sí | Como COPY, pero además soporta URLs y extracción de tarballs locales. Evitalo si no necesitas esas features: el comportamiento mágico confunde. |
ENV |
No | Variable de entorno persistente. |
WORKDIR |
No | Cambia el directorio de trabajo para instrucciones siguientes. |
USER |
No | Cambia el usuario que ejecuta las siguientes instrucciones. |
EXPOSE |
No | Documenta el puerto (no abre nada por sí mismo). |
CMD / ENTRYPOINT |
No | Comando por defecto al arrancar el contenedor. |
Lo crítico: RUN, COPY y ADD son las únicas instrucciones que materializan trabajo en el filesystem. Cualquier optimización real pasa por reducir lo que hacen esas tres.
Estrategia de capas y caché: ordená para no romper builds
Docker cachea capas por hash del contenido de la instrucción y de los archivos que toca. Si cambiás una línea, todas las capas que vienen después se invalidan. Esto es una herramienta, no un bug: puedes ordená las capas de mayor a menor frecuencia de cambio para que un cambio en el código no invalide la instalación de dependencias del sistema.
Patrón canónico para una app Python:
FROM python:3.12-slim
WORKDIR /app
# 1. Dependencias del sistema: cambian raro
RUN apt-get update &&
apt-get install -y --no-install-recommends gcc libpq-dev &&
rm -rf /var/lib/apt/lists/*
# 2. Dependencias de Python: cambian cuando actualizás requirements.txt
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 3. Código de la app: cambia en cada commit
COPY . .
CMD ["python", "main.py"]
Si invertís el orden y ponés COPY . . antes que pip install, cada vez que cambies una línea de código Docker va a reinstalar las dependencias. En un proyecto mediano eso son 30-90 segundos por build. El patrón correcto mantiene la instalación cacheada mientras editas solo el código.
El rm -rf /var/lib/apt/lists/* al final del RUN apt-get es importante: las listas de paquetes pesan entre 30 y 80 MB y quedan en una capa intermedia. Si las borrás en la misma capa donde las creaste, esa basura no llega a la imagen final.
Multi-stage builds: el atajo más fuerte para imágenes chicas
El error clásico: instalar el compilador, compilar tu binario, y dejarlo todo en la imagen final aunque ya no lo necesites. Multi-stage builds resuelven esto con dos FROM consecutivos: uno para compilar (con gcc, headers, todo lo que quieras) y otro para el runtime (solo el binario compilado).
Ejemplo con Go, donde el contraste es brutal:
# Etapa 1: build (con toolchain completo)
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .
# Etapa 2: runtime (imagen mínima, solo el binario)
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /out/app /usr/local/bin/app
USER nobody
ENTRYPOINT ["/usr/local/bin/app"]
La imagen final pesa ~15 MB en lugar de ~800 MB que pesaría si copiaras el toolchain. El binario está compilado estátamente (CGO_ENABLED=0) así que corre sobre Alpine sin glibc. Misma idea aplica a Rust (cargo build --release) o C/C++.
Para Python el contraste es menor pero igual significativo: puedes compilar wheels en una etapa con python:3.12 y luego copiar el venv a una etapa con python:3.12-slim. Para Node.js compilas TypeScript en una etapa node:20 y solo copiás node_modules + dist/ a una etapa node:20-alpine.
Elegir la imagen base: slim, alpine, distroless, scratch
La imagen base es la decisión que más impacto tiene en el tamaño final. Opciones ordenadas de mayor a menor tamaño:
| Base | Tamaño típico | Cuándo usarla |
|---|---|---|
ubuntu:24.04 |
~77 MB | Cuando necesitas glibc y paquetes completos de una distro mainstream. |
python:3.12 |
~350 MB | Dev rápido. Nunca para producción. |
python:3.12-slim |
~120 MB | Buen balance para Python en producción. |
node:20-alpine |
~50 MB | Node.js con musl en lugar de glibc. |
gcr.io/distroless/* |
~20 MB | Solo runtime, sin shell, sin package manager. Para binarios estáticos. |
scratch |
0 MB | Imagen vacía. Solo para binarios 100% estáticos (Go, Rust con musl). |
Distroless (GoogleContainerTools/distroless) es especialmente interesante para sysadmins: la imagen final no tiene shell, lo que reduce la superficie de ataque de forma dramática. Si un atacante logra RCE en tu contenedor, no hay /bin/sh para spawnear. Trade-off: debugging es más difícil, necesitas kubectl debug o sidecar con utilidades.
Alpine (alpine:3.20, ~7 MB) usa musl en lugar de glibc. Funciona perfecto para Go, Rust y la mayoría de apps Node.js, pero algunas extensiones nativas de Python (numpy, pandas) están compiladas contra glibc y rompen. prueba antes de prometer.
Seguridad que sale gratis: non-root, .dockerignore, healthcheck
Tres prácticas que no cuestan tamaño y te ahorran incidentes. La primera es no correr como root dentro del contenedor:
FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "main.py"]
El --chown en el COPY evita tener que hacer un RUN chown -R después (que sumaría una capa con metadata). appuser es no-root, UID no fijo, suficiente para que el contenedor no pueda escribir fuera de su directorio.
Segunda práctica: el .dockerignore. Si no lo tienes, Docker copia todo el contexto de build al daemon, incluyendo .git, node_modules, venv, archivos de credenciales, y tus mp3 de la carpeta del proyecto. Un .dockerignore mínimo:
.git
.gitignore
node_modules
venv
__pycache__
*.pyc
.env
.env.*
README.md
.dockerignore
Dockerfile
docker-compose*.yml
Eso solo puede ahorrarte 100-500 MB en el contexto y, lo más importante, evita que archivos sensibles lleguen al daemon. Tercera práctica: el HEALTHCHECK:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3
CMD curl -f http://localhost:8000/health || exit 1
Eso le dice a Docker (y a Kubernetes) si el contenedor está vivo de verdad o solo responde al proceso. Sin HEALTHCHECK, Docker asume “vivo” mientras el proceso principal corra, lo cual es una mentira útil hasta que tu app se cuelga en un loop infinito abriendo conexiones sin responder.
Tu primer Dockerfile optimizado end-to-end
Juntemos todo en un ejemplo real, app Flask en producción:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
WORKDIR /build
RUN apt-get update &&
apt-get install -y --no-install-recommends gcc libpq-dev &&
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
&& apt-get update
&& apt-get install -y --no-install-recommends libpq5 curl
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt
&& rm -rf /wheels
COPY --chown=appuser:appuser . .
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3
CMD curl -f http://localhost:8000/health || exit 1
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:application"]
Qué hace cada parte: la primera etapa compila wheels offline para todas las dependencias (más rápido, reproducible, sin red en runtime). La segunda etapa parte de slim, instala solo las librerías de runtime (libpq5, no libpq-dev), copia los wheels precompilados y los instala. Termina con usuario no-root, healthcheck y gunicorn para servir. Tamaño esperado: ~180 MB frente a los ~450 MB de un Dockerfile naive. Y el build cache es estable: cambiás app.py y no recompilás nada.
Verificación: medí antes de presumir
Optimizar sin medir es fe ciega. Tres comandos que te dicen si tu trabajo sirvió:
# Tamaño total de la imagen
docker images mi-app:latest --format "{{.Size}}"
# Distribución de tamaño por capa
docker history mi-app:latest --format "{{.Size}} {{.CreatedBy}}"
# Buscar capas grandes sospechosas
docker history mi-app:latest --no-trunc --format "{{.Size}} {{.CreatedBy}}" | sort -h | tail -10
Si una capa pesa 300 MB y fue creada por un RUN apt-get install, tienes un rm -rf /var/lib/apt/lists/* faltante o una dependencia innecesaria. Si pesa 150 MB y fue creada por COPY, tienes archivos que no deberían estar en el contexto (revisa el .dockerignore).
Para seguridad, corre un scanner de vulnerabilidades:
# Con trivy (gratis, open source)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock
aquasec/trivy:latest image mi-app:latest
# Con docker scout (integrado en Docker Desktop)
docker scout cves mi-app:latest
Trivy te lista CVEs por severidad y te dice si hay una versión parchada. Útil antes de cada release: una imagen “optimizada” con 47 CVEs críticas no es producción-ready por más que pese 80 MB.
Resumen
Optimizar un Dockerfile se reduce a cinco decisiones: elegí bien la imagen base (slim/alpine/distroless según tu stack), ordená las capas de mayor a menor frecuencia de cambio para no romper el caché, usa multi-stage builds para separar compilación de runtime, no corras como root y medí siempre con docker history y un scanner antes de promover a producción.
Las imágenes chicas despliegan más rápido, escanean más rápido, ocupan menos espacio en el registry, y limitan la superficie de ataque. Es la optimización con mejor ratio esfuerzo/beneficio que existe en operaciones.
Preguntas frecuentes
¿Por qué Guía DevOps para Sysadmins: Escribiendo tu Primer Dockerfile Optimizado es relevante para sysadmins Linux?
Forma parte del flujo diario de administración. Los conceptos explicados se aplican directamente a servidores en producción y resuelven problemas reales que enfrentan sysadmins hispanohablantes.
¿Qué necesito para empezar con Guía DevOps para Sysadmins: Escribiendo?
Distribución Linux actualizada (Debian, Ubuntu, RHEL, Fedora o Arch), acceso root, y conexión a internet. Para producción: entorno de staging separado antes de aplicar cambios.
¿Cuáles son los errores más comunes con Guía DevOps para Sysadmins: Escribiendo?
Los tres más frecuentes: (1) saltar la lectura de logs, (2) aplicar cambios directos en producción sin probar, (3) olvidar permisos y ownership. La documentación y un plan de rollback siempre.
¿Cuándo usar Guía DevOps para Sysadmins: Escribiendo y cuándo una alternativa?
Esta solución es ideal para el caso descrito. Alternativas como Docker, Ansible o configuración manual pueden ser mejores según contexto: portabilidad, escala y reproducibilidad son los factores clave.
¿Cómo migro desde una configuración previa a Guía DevOps para Sysadmins: Escribiendo?
Documentar el estado actual, probar la nueva configuración en staging, validar funcionalmente, y aplicar en producción con rollback plan listo. Los posts de SoyLinux incluyen troubleshooting para los problemas más comunes.
Lee también
- Guía Fedora 44: Novedades, Instalación y Upgrade para Sysadmins Linux
- Configura VLANs en Linux con nmcli: Guía paso a paso para sysadmins
- Protege tu SSH: Guía esencial para configurar Fail2ban en Linux
- Guía Definitiva: Configura Kubernetes con kubeadm en Ubuntu para Sysadmins Linux
- Configura Prometheus y Grafana: Guía DevOps para SysAdmins Linux
