Dockerfiles Multi-Stage y Distroless
Si tus imágenes de contenedor pesan 1Gb o 2Gb, seguramente no solo tengas problemas de rendimiento, también estas arrastrando muchos paquetes del sistema operativo o librerías de terceros innecesarias.
Esto se traduce en:
- Más librerías = a + superficie de ataque.
- Más superficie de ataque = más vulnerabilidades
- Más vulnerabilidades = más riesgo.
Además, por supuesto, que una imagen ligera se despliega más rápido, ocupa menos y, por supuesto, ganamos en eficiencia.
En esta capítulo vamos a ver como utilizar técnicas multi-stage y distroless para construir imágenes más ligeras y seguras.
Dentro vídeo: https://youtu.be/6p-z8ozXlqM
¿Qué son los Dockerfiles Multi-Stage?
TODO: Preparar ejemplos feo, bueno y malo
Los Dockerfiles multi-stage son una característica de los contenedores OCI que permite utilizar múltiples instrucciones FROM en un mismo Dockerfile. Cada instruccción FROM inicia una nueva etapa de construcción, y puedes copiar selectivamente artefactos de una etapa a otra.
Es decir, podríamos utilizar una imagen con todos los herramientas necesarias para la construcción y otra imagen más ligera para la ejecución. Ya que no necesitamos incluir herramientas como compiladores, gestores de paquetes, etc... en la imagen final.
En el caso de java, podríamos utilizar una imagen con el JDK para la construcción y otra con el JRE para la ejecución. En go, rust o c, podríamos construir los binarios y utilizar una imagen base mínima para la ejecución. Todos los lenguajes pueden aprovechar esta técnica.
Aunque los lenguajes compilados son los que más mejoría obtienen ya que no necesitan nada una vez generado el binario (tras la compilación), los lenguajes interpretados como python o lenguajes transpilados como typescript también pueden beneficiarse al reducir el tamaño de las imágenes al eliminar dependencias de desarrollo y herramientas innecesarias.
Tabla de ejemplo de las dependencias que nos quitamos en un Dockerfile por lenguaje de programación:
| Lenguaje | Dependencias en construcción | Dependencias en ejecución |
|---|---|---|
| Java | JDK | JRE |
| Go | Compilador | X |
| Rust | Compilador, Cargo | X |
| C | Gcc, Herramientas de Dev | X |
| Python | Pip, herramientas de Dev | Python |
| TypeScript | Node.js, herramientas de Dev | Node.js |
Ventajas de los Multi-Stage Builds
El uso de Dockerfiles multi-stage ofrece varias ventajas:
- Imágenes más pequeñas: Solo incluyen los archivos necesarios para ejecutar la aplicación. Esto a su vez repercute en las dos siguientes.
- Mayor seguridad: Eliminan herramientas de desarrollo y dependencias no necesarias.
- Mejor rendimiento: Menos datos que transferir y almacenar, mejores tiempos en la puesta en marcha.
Ejemplo Básico de Multi-Stage
Veamos un ejemplo simple con una aplicación Java:
# Etapa de construcción
FROM maven:3.9.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src/ ./src/
RUN mvn package -DskipTests
# Etapa de ejecución
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
CMD ["java", "-jar", "app.jar"]
Tengo múltiples ejemplos (tanto malos como sus versiones óptimas) en el siguiente repositorio Container best practices con una tabla comparativa y unos github actions que actualizan los pesos de las imágenes cuando alguien contribuye.
Distroless
Ahora que entendemos cómo funciona la construcción multistage, el enfoque distroless, es lo mismo pero llevado a su máxima expresión, sin "distribución", lo esencial para ejecutar linux. El mayor impulsor de estas imágenes es Google, que tiene un surtido para los principales lenguajes en el repositorio GoogleContainerTools/Distroless.
Estas imágenes están pensadas para ejecutar con lo mínimo necesario, están basadas en debian y, por no tener, no tienen ni intérprete de comandos. Esto último a veces hace más complicado de depurar las imágenes en producción. Para esto recordad el artículo de Docker Debug, que si hay interés, también podría extenderlo a kubernetes.
Ejemplo de Java con distroless:
FROM maven:3.9.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src/ .
RUN mvn package
# Etapa de ejecución en distroless
FROM gcr.io/distroless/java21-debian13
WORKDIR /app
COPY --from=build /app/target/App.jar .
CMD ["main.jar"]
En el caso de go, que no necesitamos absolutamente nada (en contraposición al JRE de Java o al runtimes de .net), podemos incluso usar las imágenes scratch. Estas son imágenes base que utilizan el resto de distribuciones para construir sus propias imágenes base. Es decir, lo que usaría un debian de base.
Directamente podemos hacer multi-stage con la imagen scratch:
# Etapa de construcción
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
# Etapa de ejecución
FROM scratch
COPY --from=builder /app/main .
CMD ["./main"]
Esto debería funcionar como un tiro y ocupará apenas unos cuantos MB. Ahora, como decía antes, no siempre podemos usar scratch. Os recomiendo probar las imágenes distroless y me contáis que tal vuestra experiencia.
Resumen
Los Dockerfiles multi-stage y las imágenes distroless son técnicas esenciales para crear contenedores optimizados. Permiten:
- Reducir significativamente el tamaño de las imágenes
- Mejorar la seguridad eliminando componentes innecesarios
- Acelerar los despliegues y reducir los costos de almacenamiento
- Mantener un entorno de producción limpio y controlado
En el siguiente capítulo exploraremos técnicas avanzadas de optimización del caché de Docker para acelerar aún más nuestros builds.
- Lista de vídeos en Youtube: Curso de Docker
