Imágenes de Docker pequeñas

Aprende Docker → Lección 5

Una imagen grande se paga en cada descarga, en cada despliegue y cada vez que el servicio escala. Lo bueno es que reducirla no suele costar trabajo: casi todo el peso sobra, y se quita con dos técnicas que caben en una lección.

El problema: el compilador viaja con la aplicación

Para construir una aplicación hacen falta herramientas que para ejecutarla no sirven de nada: compiladores, cabeceras, gestores de paquetes. Si todo eso se instala en la imagen final, se despliega con ella para siempre.

Este Dockerfile compila un programa en C y deja el compilador dentro:

FROM alpine:3.22
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY saluda.c .
RUN gcc -static -O2 -o saluda saluda.c
CMD ["/src/saluda"]

La solución: construir en varias etapas

Una construcción en varias etapas usa varios FROM en el mismo archivo. La primera etapa compila; la última parte de cero y copia solo el resultado. Todo lo demás se descarta.

FROM alpine:3.22 AS compilacion
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY saluda.c .
RUN gcc -static -O2 -o saluda saluda.c

FROM scratch
COPY --from=compilacion /src/saluda /saluda
CMD ["/saluda"]

La diferencia medida sobre el mismo programa:

docker imagesmi-demo:multi    112kB
mi-demo:gordo    253MB
las dos hacen exactamente lo mismohola desde una imagen mínima
hola desde una imagen mínima

De 253 MB a 112 kB: unas dos mil veces menos, con el mismo comportamiento. La clave es COPY --from=compilacion, que trae un archivo de una etapa anterior sin arrastrar nada más de ella.

Qué es FROM scratch. Es la imagen vacía: ni sistema de archivos, ni intérprete de órdenes, ni nada. Solo funciona con binarios estáticos, que no dependen de bibliotecas del sistema, de ahí el -static al compilar. Para lenguajes como Go o Rust es habitual. Si tu programa necesita bibliotecas dinámicas, certificados o zona horaria, la última etapa debe ser alpine o distroless, no scratch.

La segunda técnica: no enviar lo que no hace falta

Al construir, Docker empaqueta la carpeta entera y se la manda al demonio. Eso incluye el historial de git, las dependencias ya instaladas y cualquier archivo grande que ande por ahí, aunque el Dockerfile no los copie.

Un .dockerignore funciona como un .gitignore:

node_modules
.git
*.log
.env
target
dist

Medido sobre una carpeta con 20 MB de dependencias y 5 MB de historial:

sin .dockerignoretransferring context: 25.01MB
con .dockerignoretransferring context: 118B

De 25 megabytes a 118 bytes. Eso es tiempo en cada construcción, y además evita el accidente de que un COPY . . meta en la imagen un archivo de credenciales que estaba en la carpeta.

Borrar en una capa posterior no reduce el tamaño. Es el error más común al intentar adelgazar una imagen. Si una capa instala 200 MB y la siguiente los borra, la imagen sigue pesando esos 200 MB: las capas se apilan y la de borrado solo marca los archivos como ausentes, pero los datos siguen ahí. Por eso la instalación y la limpieza tienen que ir en el mismo RUN, encadenadas con &&, o resolverse con varias etapas como arriba.

Por dónde empezar a recortar

Medida Cuánto suele ahorrar
Imagen base -alpine o -slim Cientos de megabytes
Construcción en varias etapas Casi todo lo que no es la aplicación
.dockerignore Tiempo de construcción, y evita filtrar archivos
Instalar y limpiar en el mismo RUN Lo que ocupe la caché del gestor de paquetes
--no-cache en apk, --no-install-recommends en apt Decenas de megabytes
Ver qué pesa con docker history Te dice dónde mirar