--- title: "Cómo reducir el tamaño de imágenes Docker con multi-stage" description: "Reduce el tamaño de tus imágenes Docker con builds multi-stage, imágenes base slim, .dockerignore y un buen orden de capas, y mide la mejora con docker history." category: "Docker Kubernetes" language: es source: https://cubepath.com/es/docs/docker-kubernetes/optimizacion-de-imagenes-docker updated: 2026-09-25 --- # Cómo reducir el tamaño de imágenes Docker con multi-stage Una imagen Docker grande tarda más en construirse, en subirse al registro y en descargarse en cada servidor, y además arrastra paquetes que amplían la superficie de ataque. En este tutorial partirás de un Dockerfile típico para una aplicación Node.js y lo optimizarás paso a paso: `.dockerignore`, imagen base slim, orden de capas para aprovechar la caché, build multi-stage y usuario sin privilegios. Al final verás el mismo enfoque aplicado a un binario Go. Todo se hace en Ubuntu 24.04 con Docker Engine. ## Requisitos previos - Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 2 GB de RAM. - Un usuario no root con privilegios `sudo` y miembro del grupo `docker`. - Docker Engine con el plugin Buildx instalado desde el repositorio oficial de Docker. BuildKit es el constructor por defecto en las versiones actuales, y lo necesitas para los montajes de caché del paso 5. No necesitas tener Node.js instalado en el servidor: todo se ejecuta dentro de contenedores. ## Paso 1: Crear la aplicación de ejemplo Crea un directorio para el proyecto: ```bash mkdir -p ~/app-node && cd ~/app-node ``` Genera `package.json` e instala Express usando un contenedor de Node. Las opciones `-u` y `HOME` hacen que los archivos generados sean de tu usuario y no de root: ```bash docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD":/app -w /app node:24-slim \ sh -c 'npm init -y >/dev/null && npm install express' ``` Esto crea `package.json`, `package-lock.json` y `node_modules/`. El archivo de bloqueo es importante: permite instalar exactamente las mismas versiones en cada build con `npm ci`. Crea el servidor: ```bash nano server.js ``` ```javascript const express = require('express'); const app = express(); app.get('/', (req, res) => res.send('Hola desde CubePath\n')); app.listen(3000, () => console.log('Escuchando en el puerto 3000')); ``` ## Paso 2: Medir el punto de partida Este es el Dockerfile que se ve en muchos proyectos. Créalo como `Dockerfile.inicial`: ```bash nano Dockerfile.inicial ``` ```dockerfile FROM node:24 WORKDIR /app COPY . . RUN npm install EXPOSE 3000 CMD ["node", "server.js"] ``` Tiene tres problemas: la imagen base completa incluye compiladores y cientos de paquetes de Debian, `COPY . .` mete en la imagen todo el directorio (incluido tu `node_modules` local), y como el código se copia antes de instalar dependencias, cualquier cambio en `server.js` obliga a reinstalarlas. Constrúyela y mira su tamaño: ```bash docker build -f Dockerfile.inicial -t app:inicial . docker images app ``` ```output REPOSITORY TAG IMAGE ID CREATED SIZE app inicial 5d3a1c7e9b02 10 seconds ago 1.13GB ``` Los tamaños exactos variarán según la versión de la imagen base, pero el orden de magnitud será este. Para ver qué capa aporta cada megabyte usa `docker history`: ```bash docker history app:inicial ``` Las capas con más peso son las de la imagen base y la de `RUN npm install`. ## Paso 3: Excluir archivos con .dockerignore Cuando ejecutas `docker build .`, Docker envía el directorio entero como contexto de build. El archivo `.dockerignore` excluye lo que no debe llegar a la imagen: dependencias locales, historial de Git, secretos y artefactos. Créalo: ```bash nano .dockerignore ``` ```text node_modules npm-debug.log .git .gitignore .env *.md Dockerfile* .dockerignore ``` Excluir `node_modules` es especialmente importante: las dependencias se instalan dentro de la imagen, y las locales podrían estar compiladas para otra plataforma. Excluir `.env` evita que un secreto acabe publicado en el registro. ## Paso 4: Usar una imagen base slim y ordenar las capas Docker reutiliza de la caché cada capa cuyo contenido y capas anteriores no hayan cambiado. Por eso conviene copiar primero lo que cambia poco (los manifiestos de dependencias), instalar, y copiar el código al final. Crea el `Dockerfile`: ```bash nano Dockerfile ``` ```dockerfile FROM node:24-slim WORKDIR /app ENV NODE_ENV=production COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY server.js ./ USER node EXPOSE 3000 CMD ["node", "server.js"] ``` Qué ha cambiado: - `node:24-slim` es Debian con solo lo necesario para ejecutar Node, sin compiladores ni documentación. - `npm ci --omit=dev` instala exactamente lo que dice `package-lock.json` y omite las dependencias de desarrollo. - `npm cache clean --force` se ejecuta en la misma instrucción `RUN`. Si lo hicieras en otra, la caché ya estaría guardada en la capa anterior y la imagen no bajaría de tamaño. - `USER node` ejecuta la aplicación con el usuario sin privilegios que ya incluye la imagen oficial. Construye y compara: ```bash docker build -t app:slim . docker images app ``` ```output REPOSITORY TAG IMAGE ID CREATED SIZE app slim a41f0c2d8e55 5 seconds ago 232MB app inicial 5d3a1c7e9b02 3 minutes ago 1.13GB ``` Ahora modifica el mensaje de `server.js` y vuelve a construir. En la salida verás `CACHED` en el paso de `npm ci`: solo se rehace la capa del código, y el build tarda un par de segundos. ```bash sed -i 's/Hola desde CubePath/Hola de nuevo/' server.js docker build -t app:slim . ``` ```output => CACHED [3/5] COPY package.json package-lock.json ./ => CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force => [5/5] COPY server.js ./ ``` ## Paso 5: Separar build y ejecución con multi-stage En una aplicación real el build necesita herramientas que no hacen falta para ejecutarla: dependencias de desarrollo, un compilador de TypeScript, un bundler o `python3` y `make` para módulos nativos. Un build multi-stage usa una etapa para compilar y copia a la imagen final solo el resultado. Sustituye el contenido del `Dockerfile`: ```bash nano Dockerfile ``` ```dockerfile # syntax=docker/dockerfile:1 FROM node:24-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev FROM node:24-slim AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY package.json server.js ./ USER node EXPOSE 3000 CMD ["node", "server.js"] ``` Dos detalles: - La etapa `deps` es donde irían `npm ci` con dependencias de desarrollo y `npm run build` si tu proyecto compila. La imagen final solo recibe lo que copies con `COPY --from`. - `RUN --mount=type=cache` guarda la caché de npm en el constructor, fuera de la imagen. Los builds siguientes descargan menos y no hace falta `npm cache clean`. Construye, arranca un contenedor y comprueba que responde: ```bash docker build -t app:multistage . docker run -d --name app -p 127.0.0.1:3000:3000 app:multistage curl http://127.0.0.1:3000/ ``` ```output Hola de nuevo ``` Confirma que el proceso no se ejecuta como root: ```bash docker exec app id ``` ```output uid=1000(node) gid=1000(node) groups=1000(node) ``` En esta aplicación tan sencilla el tamaño final es similar al del paso 4, porque no hay herramientas de build que descartar. En proyectos con TypeScript, Next.js o módulos nativos la diferencia suele ser de cientos de megas. Elimina el contenedor de prueba: ```bash docker rm -f app ``` ## Paso 6: Llevarlo al extremo con un binario estático (Go) Los lenguajes que generan binarios estáticos permiten una imagen final casi vacía. Como ejemplo, crea un servidor Go mínimo en otro directorio: ```bash mkdir -p ~/app-go && cd ~/app-go nano main.go ``` ```go package main import ( "fmt" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hola desde Go") }) http.ListenAndServe(":8080", nil) } ``` Crea el `Dockerfile`. La etapa de build compila con la imagen oficial de Go, y la final usa `gcr.io/distroless/static-debian12:nonroot`, que solo contiene certificados CA, datos de zona horaria y un usuario sin privilegios: ```bash nano Dockerfile ``` ```dockerfile # syntax=docker/dockerfile:1 FROM golang:1 AS build WORKDIR /src COPY main.go ./ RUN go mod init example.com/hola && \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hola . FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/hola /hola EXPOSE 8080 ENTRYPOINT ["/hola"] ``` `CGO_ENABLED=0` genera un binario sin dependencias de libc, y `-ldflags="-s -w"` elimina la información de depuración. En un proyecto real tendrías `go.mod` y `go.sum` en el repositorio y los copiarías antes que el código, igual que en el paso 4. Construye y compara con la imagen de compilación: ```bash docker build -t hola-go . docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'hola-go|golang' ``` ```output hola-go:latest 9.5MB golang:1 850MB ``` La imagen final pesa unos pocos megas. La contrapartida es que no hay shell: no podrás usar `docker exec -it hola-go sh`. Para depurar, usa un contenedor de herramientas que comparta su red (`docker run --rm -it --network container: nicolaka/netshoot`) o la variante `:debug` de distroless, que incluye busybox. ## Paso 7: Inspeccionar las capas con dive `docker history` dice cuánto pesa cada capa, pero no qué archivos contiene. La herramienta [dive](https://github.com/wagoodman/dive) muestra el contenido capa a capa y señala el espacio desperdiciado (archivos añadidos en una capa y borrados en otra). Puedes ejecutarla como contenedor sin instalar nada: ```bash docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive:latest app:inicial ``` Navega entre capas con las flechas, cambia de panel con `Tab` y sal con `Ctrl+C`. El indicador `Image efficiency score` y la lista de archivos potencialmente desperdiciados te dicen qué limpiar. > Advertencia: montar `/var/run/docker.sock` da al contenedor control total sobre Docker, equivalente a root en el host. Hazlo solo con imágenes de confianza y para usos puntuales como este. ## Resumen de técnicas | Técnica | Qué reduce | |---|---| | `.dockerignore` | Contexto de build y archivos accidentales (secretos, `.git`) | | Imagen base `slim`, `alpine` o distroless | Paquetes del sistema que la aplicación no usa | | Copiar manifiestos antes que el código | Tiempo de build, gracias a la caché | | Limpiar en la misma instrucción `RUN` | Espacio muerto en capas intermedias | | Build multi-stage | Compiladores y dependencias de desarrollo en la imagen final | | `RUN --mount=type=cache` | Descargas repetidas entre builds | | Etiquetas fijas (`node:24-slim`, no `latest`) | Cambios inesperados de tamaño o comportamiento | Sobre Alpine: sus imágenes son muy pequeñas, pero usan musl en lugar de glibc. Algunos módulos nativos de Node o ruedas de Python precompiladas no funcionan o hay que compilarlos, lo que alarga el build. Si no tienes un motivo concreto, las variantes `slim` son un buen equilibrio. Para paquetes del sistema en imágenes Debian o Ubuntu, instala sin paquetes recomendados y borra las listas de apt en la misma capa: ```dockerfile RUN apt-get update && \ apt-get install -y --no-install-recommends curl ca-certificates && \ rm -rf /var/lib/apt/lists/* ``` ## Solución de problemas **`npm ci` falla porque `package.json` y `package-lock.json` no están sincronizados o no encuentra el lockfile.** `npm ci` necesita `package-lock.json` y que esté sincronizado con `package.json`. Regenéralo con `npm install` y comprueba que no está en `.dockerignore`. **El build no reutiliza la caché aunque no has tocado las dependencias.** Algo anterior a la instrucción `RUN` cambia en cada build: un `COPY . .` antes de instalar, un `ARG` con valor variable o un archivo que se regenera. Revisa el orden de las instrucciones. **`Error: Cannot find module` en la imagen final multi-stage.** Falta algún archivo en los `COPY --from`. Copia también los directorios que genere tu build (por ejemplo `dist/`). **`exec /hola: no such file or directory` en la imagen distroless.** El binario está enlazado dinámicamente. Compila con `CGO_ENABLED=0` o usa la imagen `gcr.io/distroless/base-debian12`, que incluye glibc. ## Conclusión Has pasado de una imagen de más de 1 GB a una de unos 230 MB para Node.js y a menos de 10 MB para un binario Go, con builds que reutilizan la caché y procesos que no se ejecutan como root. Las mismas reglas sirven para cualquier lenguaje: contexto limpio, base mínima, capas ordenadas y separación entre build y ejecución. Como siguientes pasos puedes: - Escanear tus imágenes en busca de vulnerabilidades con Trivy antes de publicarlas. - Construir imágenes para `amd64` y `arm64` a la vez con `docker buildx build --platform`. - Automatizar el build en tu CI con la caché de BuildKit exportada al registro (`--cache-to` y `--cache-from`).