Optimización y seguridad en Dockerfiles
Imágenes ligeras, builds rápidos, seguridad real. Las prácticas que reducen costos de almacenamiento, aceleran Kubernetes y eliminan vulnerabilidades
En este post te cuento mis recomendaciones para optimizar el tamaño y añadir la seguridad básica a tus Dockerfiles y en consecuencia a las imagenes que generas para distribuir y ejecutar tus aplicaciones.
Los tiempos y tamaños son ilustrativos pero no universales. Otras dependencias externas pueden afectar al tiempo de build.
¡Gracias por pasarte por aquí! 🙌
Si te interesa seguir recibiendo guías prácticas sobre Kubernetes, Azure, DevOps y contenedores directamente en tu email…
Suscríbete gratis y no te pierdas ninguna novedad. 🚀
Invalidación de caché en capas por dependencias silenciosas
El mayor error es el orden de las instrucciones en Dockerfiles. Veamos el problema:
FROM python:3.11-slim
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
RUN python build.py¿Ves el problema?
Cuando copias todo el código primero y luego instalas dependencias, cualquier cambio en tu código invalida la capa completa de pip install.
En un equipo que ejecuta más de 20 builds diarias, esto es brutal.
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN python build.pyAhora un cambio en el código equivale a reconstruir solo la última capa.
El impacto: Cambios de código que tardaban 90 segundos ahora tardan 3. Si ejecutas 20 builds diarios, son 29 minutos recuperados. Semanalmente, casi 3 horas de CI/CD sin hacer nada.
Utiliza imagenes base Distroless o Slim (Bloat binario)
Hay tres tipos de imágenes base. Solo dos son justificables en producción.
node:20 ~950MB(incluye npm, compiladores, herramientas de sistema)
node:20-slim ~220MB (Debian slim, solo runtime)
node:20-distroless ~150MB (solo aplicación, sin shell ni herramientas)~950M
¿Cuál elegir?
node:20-slim tiene Debian con glibc, package manager básico, herramientas de debugging. Es el balance perfecto: pequeño pero no roto.
node:20-distroless es extremo: solo la aplicación, sin shell, sin package manager, sin nada. No puedes debuggear. Pero es más pequeño y 100% más seguro. Zero ataque surface.
# Builder pesado, sin restricciones
FROM node:20 AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
# Runtime minimal
FROM node:20-distroless
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]Distroless no tiene bash, no tiene sh, no tiene apt. Esto es una ventaja en seguridad, pero una desventaja en debugging. Tu elección depende de tu política de acceso a contenedores.
Hay varias técnicas para depurar contenedores. Si te interesa saber cómo depurar aplicaciones con contenedores efímeros, échale un vistazo a mi artículo:
Evita COPY . . y utiliza.dockerignore
Este es el error silencioso que infla tus builds sin que lo notes.
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["npm", "start"]¿Qué está en ese .? Todo. node_modules locales (50-500MB), .git (100MB+), coverage reports, logs, archivos de CI, temporales. Todo entra en la capa de construcción.
La mayoría de eso invalida tu caché innecesariamente. Cambias un archivo .md en el repo? Reconstruyes. Actualizas .env? Reconstruyes. Ejecutas tests locales? Reconstruyes.
Dos soluciones: no utilices el COPY . . y utiliza .dockerignore
# .dockerignore
node_modules/
.git/
.gitignore
.dockerignore
.env
.env.local
coverage/
dist/
build/
*.log
.DS_Store
.vscode/
.idea/
__pycache__/
.pytest_cache/
venv/
.tox/El impacto: Tu contexto de build puede pasar de 800MB a 120MB. Las capas se reconstruyen solo por cambios de tu código. En equipos con repositorios grandes (monorepos, proyectos legacy), esto es la diferencia entre 30 segundos y 5 minutos de build.
Casos comunes que olvidas:
.git/— Puede ser 100MB+ en repos antiguosnode_modules/— Tu máquina local tiene dependencias que no necesita la imagencoverage/— Tests locales generan artifacts que invalidan el caché.envy archivos de configuración local — No pertenecen a la imagen de producción
.dockerignore es tan importante como .gitignore. La mayoría de equipos lo ignora (literalmente).
Multi-stage builds: Separa construcción de ejecución
Construye tu aplicación en un contenedor con todas las herramientas, luego copia solo los binarios al contenedor final. Los ficheros inecesarios desaparecen.
FROM node:20
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "start"]
# Imagen final: incluye npm, node-gyp, compiladores, todo# Fase de construcción
FROM node:20 AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY ./src ./src
RUN npm run build
# Imagen Runtime (solo lo necesario)
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]Desglose del tamaño:
node:20: ~950MBnode:20-slim: ~220MBMulti-stage: ~220MB (solo runtime + código compilado)
Por qué funciona:
La etapa builder construye todo, genera artifacts (dist/, node_modules/)
La etapa runtime copia solo lo que necesita
Docker descarta la etapa builder automáticamente
Tu imagen final no contiene: compiladores C++, headers, archivos temporales, dependencias de desarrollo
Ejemplo con Phyton
FROM python:3.11
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
RUN python setup.py build_ext --inplace
CMD ["python", "app.py"]Imagen optimizada:
FROM python:3.11 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN python setup.py build_ext --inplace
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /app /app
RUN useradd -m -u 1001 appuser && chown -R appuser:appuser /app
USER appuser
CMD ["python", "app.py"]Cuándo es crítico:
Lenguajes compilados (Go, Rust, C++): El builder es 10x más grande que el binario final
Proyectos con compilación lenta: Cambios de código no invalidan la etapa builder si las dependencias no cambian
Imágenes con múltiples pasos de build: Tests, linting, optimizaciones ocurren en etapas descartables
Combinado con .dockerignore: Tu contexto es pequeño, tu builder es rápido, tu imagen final es mínima.
Uso de versiones fijas
La solución necesita ubuntu y la aplicación curl, instalas la última versión disponible:
FROM ubuntu:latest
RUN apt-get update && apt-get install -y curlDos problemas: ubuntu:latest es dinamica, no sabes que versión de curl estás utilizando. Tu build funciona hoy, falla en 3 semans cuando una nueva version de Ubuntu o curl son lanzadas y alquien genera una nueva versión de la aplición.
Versiona todo explícitamente:
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl=8.5.0Pruebas contra versión fija localmente. Upgrades son deliberados, no accidentales. Tu pipeline es reproducible.
Minimiza el número de capas (cada RUN es una capa)
Una capa en Docker es cada instrucción que modifica el filesystem. Cada RUN, COPY, ADD, ENV crea una nueva capa. Más capas = imágenes más grandes porque cada capa guarda todos los cambios, incluso si los eliminas después.
apt-get clean borra, pero la capa anterior con 400MB sigue existiendo (7 capas en total):
FROM debian:bookworm-slim
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get install -y build-essential
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*Docker file agrupando comandos, total 2 capas:
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y curl git build-essential && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*¿Por qué importa?
Cuando haces apt-get install -y curl en una capa, puede generar 45MB de archivos. Si luego en otra capa haces rm -rf /var/lib/apt/lists/*, borra los archivos pero la capa anterior sigue ocupando 45MB en la imagen final.
Al combinar con && y limpiar en el mismo RUN, Docker descarta los temporales dentro de esa capa.
Filtración de secretos en el historial de capas
Esto es crítico. No copies claves, tokens o credenciales a tu imagen.
RUN AWS_ACCESS_KEY_ID=xxx docker build .Esa credencial está grabada en la imagen. Posiblemente este en el registro. Accesible a cualquiera con acceso a docker history o a tu container registry.
Puedes utilizar build secrets:
docker buildx build --secret id=aws,src=$HOME/.aws/credentials .En el Dockerfile:
RUN --mount=type=secret,id=aws \
AWS_SHARED_CREDENTIALS_FILE=/run/secrets/aws \
aws s3 cp ...Seguridad en tiempo de ejecución: Tu app corriendo como root
La mayoría de Dockerfiles hace esto:
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y myapp
CMD ["myapp"]¿Notas que no hay USER? Tu aplicación corre como root. Si hay una vulnerabilidad remota (y habrá), el atacante tiene acceso root.
Tres líneas arreglan esto:
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y myapp && \
useradd -m -u 1001 appuser && \
chown -R appuser:appuser /app
USER appuser
CMD ["myapp"]El impacto de seguridad: Una vulnerabilidad ya no significa compromiso total del host. Significa un usuario sandbox limitado. Tu threat model cambia fundamentalmente.
Si este post te ha ayudado o aclarado algo, me harías muy feliz con un ❤️
Y si crees que le puede servir a alguien más… ¡compártelo! 🙌
Conclusión
Con una pequeña inversión de tiempo para reordenar capas, usar .dockerignore, multi-stage y non-root puedes pasar de tener una imagen muy grande a una muy ligera. No solo verás una disminución en el tiempo de tus builds, también verás que la imagen tarda menos en cargarse en Kubernetes y, sumado a todo ello, reduces la superficie de ataque y vulnerabilidades de tus contenedores.

