De 354 MB a 16 MB: Docker por dentro, multi-stage y un deploy a Vercel desde la terminal

Qué es de verdad una imagen, qué es un contenedor, por qué el orden de tu Dockerfile decide cuánto esperas en cada build, y cómo terminé publicando un servidor en Go en Vercel con un solo comando.


Escribí un servidor HTTP en Go de veinte líneas. La primera imagen que lo empaquetaba pesaba 354 MB. La versión final, que hace exactamente lo mismo, pesa 16 MB. No cambié una sola línea de Go. Solo cambié cómo estaba escrito el Dockerfile.

Este artículo explica por qué, empezando por lo básico: qué es una imagen y qué es un contenedor. Si ya los tienes claros, salta a la sección de caché.

Imagen y contenedor: la receta y el plato

Una imagen es un paquete de solo lectura con todo lo que una aplicación necesita para arrancar: un sistema de archivos mínimo, librerías, el binario y la instrucción de qué ejecutar. No corre, no cambia, no tiene estado. Es una plantilla.

Un contenedor es una imagen en ejecución. Docker toma la imagen, le pone encima una capa fina en la que sí se puede escribir, le da su propio espacio de procesos y de red, y arranca el comando. Lo que el programa escriba va a esa capa, nunca a la imagen.

Si la imagen es la receta, el contenedor es el plato servido. Puedes servir diez platos de la misma receta, y si a uno le echas sal, la receta no cambia ni los otros nueve tampoco.

Cómo un Dockerfile se convierte en capas

Una imagen no es un bloque único. Es una pila de capas, y casi cada instrucción del Dockerfile produce una. Cada capa guarda solo lo que cambió respecto a la anterior.

Cada instrucción del Dockerfile se convierte en una capa de la imagen

Este es el Dockerfile final de mi proyecto:

FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY . .
RUN go build -o /server main.go

FROM alpine:3.20 AS runner
COPY --from=builder /server /server
CMD [ "/server" ]

Y esto es lo que Docker reporta de la imagen final con docker history:

CMD ["/server"]                         0B
COPY /server /server                    8.15MB
CMD ["/bin/sh"]                         0B
ADD alpine-minirootfs-3.20.10 ...       7.81MB

Dos capas con peso: el sistema base de Alpine (7,8 MB) y mi binario (8,1 MB). Las instrucciones como CMD o WORKDIR solo guardan metadatos y pesan 0 bytes. Ahí están los 16 MB, ni uno más.

La caché: el orden de los pasos importa

Como cada capa depende de la anterior, Docker puede reutilizarlas. Al reconstruir, recorre el Dockerfile de arriba abajo y, mientras la instrucción y sus archivos de entrada no hayan cambiado, usa la capa que ya tiene guardada. En cuanto una capa cambia, todas las que vienen después se reconstruyen.

Eso convierte el orden en una decisión de diseño. La regla: lo que cambia poco va arriba, lo que cambia mucho va abajo.

En un proyecto Go con dependencias, la diferencia se ve así:

# Mal: cualquier cambio en el código invalida la descarga de dependencias
COPY . .
RUN go mod download
RUN go build -o /server .
# Bien: las dependencias solo se descargan si cambian go.mod o go.sum
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /server .

En la versión mala, editar un comentario en main.go cambia el contexto de COPY . ., y eso obliga a descargar otra vez todas las dependencias. En la buena, go.mod y go.sum no cambiaron, así que la capa de dependencias sale de caché y solo se recompila el código.

Las ventajas son concretas:

  1. Builds más rápidos. Lo caro (descargar dependencias, instalar paquetes del sistema) se hace una vez y se reutiliza.
  2. Menos red y menos disco. Las capas compartidas se guardan una sola vez, aunque las usen muchas imágenes.
  3. Despliegues más ligeros. Al subir o bajar una imagen, solo viajan las capas que el destino no tiene.

Un detalle que completa la regla: un archivo .dockerignore que excluya .git, node_modules o binarios locales evita que cambios irrelevantes invaliden el COPY . ..

Multi-stage: compilar en una imagen, ejecutar en otra

Aquí está la diferencia entre 354 MB y 16 MB.

Para compilar Go necesitas el compilador, la librería estándar y las herramientas de build. Para ejecutar el binario resultante no necesitas nada de eso. Un Dockerfile de una sola etapa mete todo en la imagen final:

# Lo malo: una sola etapa. La imagen final arrastra el compilador.
FROM golang:1.24-alpine
WORKDIR /src
COPY . .
RUN go build -o /server main.go
CMD [ "/server" ]

Un build multi-stage separa responsabilidades. Cada FROM empieza una etapa nueva, con su propia base:

  1. builder parte de golang:1.24-alpine, compila y produce el binario. Es una etapa desechable.
  2. runner parte de alpine:3.20, que viene limpio, y con COPY --from=builder toma solo el binario.

Todo lo que había en builder (el compilador, el código fuente, la caché de compilación) se queda fuera de la imagen final. Medido en mi máquina:

Imagen Tamaño ¿Contiene el compilador? ¿Contiene el código fuente?
Etapa builder (equivale a una sola etapa) 354 MB Sí Sí
Etapa runner (imagen final) 16 MB No No

Un 95 % menos. Y no es solo tamaño: menos cosas dentro significa menos paquetes con vulnerabilidades, nada útil para un atacante que entre al contenedor, y arranques más rápidos porque hay menos que descargar.

Nombrar las etapas también ayuda a depurar. Si algo falla al compilar, puedes construir solo esa etapa:

docker build -f Dockerfile.vercel --target builder -t go-vercel:builder .

Muchos contenedores, una imagen, cero interferencias

Como la imagen es de solo lectura, nadie puede modificarla mientras corre. Cada contenedor recibe su propia capa escribible encima de las mismas capas compartidas. Eso es lo que permite levantar diez contenedores de la misma imagen sin que se pisen.

Varios contenedores comparten las capas de la imagen y cada uno tiene su propia capa escribible

Lo comprobé con dos contenedores de la misma imagen de Alpine:

docker run -d --name a alpine:3.20 sleep 300
docker run -d --name b alpine:3.20 sleep 300

docker exec a sh -c 'echo hola > /nota.txt'

docker exec a cat /nota.txt   # hola
docker exec b cat /nota.txt   # No such file or directory

El archivo existe en a y no en b. Escribieron sobre la misma imagen, pero cada uno en su capa.

Por qué se pierden los datos si no hay volumen

La capa escribible vive y muere con el contenedor. Si borras el contenedor, su capa desaparece con él. Siguiendo con el ejemplo:

docker rm -f a
docker run -d --name a alpine:3.20 sleep 300
docker exec a cat /nota.txt   # No such file or directory

Mismo nombre, misma imagen, pero es un contenedor nuevo con una capa nueva y vacía. La nota se fue.

Y esto no es un error, es el diseño: los contenedores son efímeros a propósito, para poder destruirlos, reemplazarlos y escalarlos sin miedo. Una actualización de versión, por ejemplo, es exactamente eso: borrar el contenedor viejo y crear uno nuevo desde la imagen nueva.

Lo que tiene que sobrevivir (una base de datos, archivos subidos por usuarios) va en un volumen: un almacenamiento que Docker gestiona fuera del contenedor y que se monta en una ruta dentro de él.

docker volume create notas
docker run --rm -v notas:/data alpine:3.20 sh -c 'echo hola > /data/nota.txt'
docker run --rm -v notas:/data alpine:3.20 cat /data/nota.txt   # hola

El primer contenedor escribió y fue destruido (--rm). El segundo, completamente nuevo, encontró el archivo. El dato vivía en el volumen, no en el contenedor.

La regla práctica: la imagen es el código, el contenedor es el proceso, el volumen es el estado.

El remate: de la terminal a Vercel

Con la imagen ya optimizada, quedaba publicarla. Vercel puede ejecutar contenedores: si el proyecto tiene un Dockerfile.vercel, lo usa para construir la imagen, arranca la etapa final e inyecta la variable PORT. Por eso el servidor la lee del entorno en lugar de tenerla fija en el código:

port := os.Getenv("PORT")
if port == "" {
    port = "80"
}

Todo el despliegue se hizo desde la terminal, sin conectar un repositorio y sin pipeline:

vercel link    # vincula la carpeta a un proyecto
vercel --prod  # construye la imagen y la publica en producción

Vercel detectó el proyecto con el preset Container, construyó las dos etapas y en 34 segundos el servidor estaba respondiendo:

curl https://05-vercel-bice.vercel.app
# Hello from a container on Vercel 👋

Lo que me llevo

  1. Una imagen es una plantilla de solo lectura hecha de capas; un contenedor es esa plantilla en ejecución, con una capa propia para escribir.
  2. El orden del Dockerfile decide qué se reutiliza de caché: lo estable arriba, lo que cambia abajo.
  3. Multi-stage separa el taller de la vitrina: se compila con todo, se entrega solo lo necesario. En este caso, de 354 MB a 16 MB.
  4. Los contenedores de una misma imagen no se afectan porque cada uno escribe en su propia capa.
  5. Lo que no está en un volumen se pierde al borrar el contenedor, y eso es una ventaja si lo diseñas así.

El código está en github.com/rubenerangel/go-docker-vercel y la demo en vivo en 05-vercel-bice.vercel.app. Herramientas: Go 1.24, Docker con BuildKit, Alpine 3.20 y Vercel CLI.