Lo que te cuesta un Dockerfile mal escrito

1
2
3
4
5
6
FROM node:latest

COPY . .
RUN npm install

CMD ["node", "server.js"]

Cuatro líneas, y cada una te cuesta algo. docker build vuelve a ejecutar npm install en cada cambio de código, porque COPY . . rompe la cache de capas antes de que el paso de instalación tenga oportunidad de reutilizarse. La imagen final arrastra la base Debian completa, todo el árbol de node_modules incluidas las dependencias de desarrollo, y cualquier herramienta de build que npm install haya bajado para compilar módulos nativos. No se descarta nada. El contenedor corre como root, porque nada le dice lo contrario, y node:latest significa que la imagen base puede cambiar bajo tus pies entre una build y la siguiente, sin dejar rastro de qué acabaste desplegando.

Nada de esto aparece como error. La imagen se construye, el contenedor arranca, la app responde. El coste llega después: una build de CI de cinco minutos porque cada paso repite desde cero, y una imagen de 1,1 GB para una app cuyo código pesa unos cientos de kilobytes. Cada arreglo de abajo es pequeño. Juntos cambian lo que te cuesta un Dockerfile cada día.

Cómo decide la cache de capas de Docker qué reconstruir

Docker construye una imagen instrucción a instrucción y guarda en cache el resultado de cada una como una capa. En la siguiente build recorre otra vez el Dockerfile y reutiliza una capa en cache mientras la instrucción sea idéntica y la capa anterior en la cadena no haya cambiado. En cuanto una instrucción falla la cache, todas las de después también se vuelven a ejecutar. Los cache hits solo extienden una cadena desde arriba. Si imágenes, capas y contenedores todavía son conceptos difusos, qué es Docker y cómo funcionan los contenedores cubre la base que este artículo da por sabida.

COPY y ADD invalidan por contenido, no solo por el texto de la instrucción: si algún archivo copiado cambió, la cache de esa capa desaparece, y con ella todo lo que viene después. COPY . . copia todo el contexto de build, así que invalida ante cualquier cambio en cualquier parte del proyecto, hasta un comentario reescrito en un archivo que no tiene nada que ver con las dependencias. Pon RUN npm install justo después de ese COPY y se repite en cada build.

Cómo ordenar las instrucciones del Dockerfile para acertar la cache

Copia solo lo que una instrucción necesita, en el orden en que las cosas cambian de verdad: los manifiestos de dependencias rara vez, el código fuente todo el tiempo.

1
2
3
4
5
6
7
8
9
FROM node:24-slim
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

CMD ["node", "server.js"]

package.json y package-lock.json cambian cuando añades o actualizas una dependencia. RUN npm ci ahora va en cache solo contra esos dos archivos. Edita un archivo fuente y reconstruye: Docker reutiliza la capa de instalación en cache y solo repite el COPY final y lo que viene después. El paso más lento de la build corre únicamente cuando toca.

npm ci en vez de npm install es deliberado. Instala exactamente lo que hay en el lockfile y falla si el lockfile y package.json no coinciden, en vez de resolver en silencio versiones nuevas dentro de una build que esperabas reproducible.

El mismo orden aplica fuera de Node: un proyecto Python copia requirements.txt y ejecuta pip install antes que el resto del código, un proyecto Go copia go.mod/go.sum y ejecuta go mod download primero.

Cómo reducen los multi-stage builds la imagen final

Reordenar arregla la cache. No hace nada por una imagen que sigue cargando dependencias de desarrollo, herramientas de build y todo lo que npm ci bajó para compilar módulos nativos, nada de lo cual necesita la app para correr. Un multi-stage build separa lo que hace falta para construir la app de lo que hace falta para ejecutarla.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# syntax=docker/dockerfile:1
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist

CMD ["node", "dist/server.js"]

Dos líneas FROM, dos stages. La primera, llamada build, instala todas las dependencias (incluidas las de solo desarrollo, como un bundler o TypeScript) y genera dist/. La segunda arranca de cero desde la misma imagen base, instala solo las dependencias de producción, y trae un directorio del primer stage con COPY --from=build. El compilador de TypeScript, los .ts fuente, la cache de npm: nada de eso llega a la imagen final, porque el stage de build se descarta en cuanto termina.

La diferencia de tamaño es la clave. node:latest, la imagen completa basada en Debian, ronda 1,1 GB antes de añadir una sola dependencia. node:24-slim está por debajo de 300 MB, y node:24-alpine por debajo de 200 MB si tus dependencias no necesitan glibc. Multiplica eso por cada servicio que corres y cada runner de CI que baja la imagen, y la diferencia se traduce en almacenamiento y minutos reales.

El efecto es mayor en un lenguaje compilado, donde el binario no necesita nada más que a sí mismo.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Sin shell, sin gestor de paquetes, sin toolchain de Go en la imagen final: solo el binario estático más los certificados CA y los datos de zona horaria que aporta distroless/static. Un atacante que consiga ejecución en ese contenedor no tiene sh que lanzar ni apt para instalar uno.

Qué poner en .dockerignore

COPY . . manda todo el contexto de build al daemon de Docker antes de que empiece la build, y ese contexto incluye archivos que nunca pensabas enviar: .git, un node_modules local, archivos .env con credenciales reales, configuración del editor. Un .dockerignore junto al Dockerfile los excluye, con la misma sintaxis de patrones que .gitignore.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.git
.gitignore
node_modules
npm-debug.log
.env
.env.*
Dockerfile
.dockerignore
dist
*.md

Excluir node_modules no es solo cuestión de tamaño. Si una instalación local en tu máquina tiene módulos nativos compilados para macOS y arm64, COPY . . mete esos binarios en un contenedor Linux donde no van a cargar. Deja que el contenedor instale sus propias dependencias.

Cómo hacer que un contenedor corra como usuario no-root

Nada en un Dockerfile básico impide que un contenedor corra como root, así que por defecto lo hace. Si un atacante consigue ejecución de código dentro, por una vulnerabilidad en una dependencia o un bug de deserialización, ser root en el contenedor está a un bug del kernel o un mount mal hecho de ser root en el host. La mayoría de las imágenes oficiales ya trae un usuario al que puedes cambiar.

1
2
3
4
5
6
7
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

La imagen node crea un usuario node, así que USER node es la única línea que añades. Cuando una imagen base no trae ninguno, crea uno antes de cambiar:

1
2
RUN groupadd -r app && useradd -r -g app app
USER app

El --chown importa tanto como el USER. Sin él, los archivos quedan en propiedad de root mientras el proceso que los lee corre como app, y cualquier cosa que la app escriba en runtime falla con un error de permisos que descubres en producción. Lo mismo aplica a un volumen Docker montado en el contenedor: su contenido tiene que ser escribible por el UID al que cambiaste, o la primera escritura muere al arrancar.

Cómo fijar el tag o el digest de la imagen base

FROM node:latest se resuelve a lo que apunte latest el día que construyes. Reconstruye el mismo Dockerfile el mes que viene y puedes acabar en una major distinta, una release de Debian distinta, paquetes de sistema distintos, sin ningún diff en tu repositorio que muestre qué cambió. Fija la versión:

1
FROM node:24.9.0-slim

Para una build que tiene que ser reproducible byte a byte, fija también el digest. Eso ata la build a una imagen inmutable pase lo que pase con el tag:

1
FROM node:24.9.0-slim@sha256:9c1f9c1a...

Consigue el digest con docker pull node:24.9.0-slim seguido de docker inspect --format='{{index .RepoDigests 0}}' node:24.9.0-slim. Fijar el tag cubre la mayoría de los proyectos. Fijar el digest es para pipelines donde “qué desplegamos exactamente” tiene que tener respuesta meses después.

Cuándo combinar varios RUN en una sola capa

Cada RUN produce una capa, y una capa solo crece. Borrar un archivo en una capa posterior no reduce la imagen, esconde el archivo detrás de un whiteout marker mientras la capa anterior sigue cargando con los bytes. Por eso separar instalación y limpieza en dos RUN distintos no ahorra nada:

1
2
3
4
# Mal: la cache de apt del primer RUN queda horneada en una capa
# que el segundo RUN no puede eliminar de la imagen
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

Pon la limpieza en la misma capa que la instalación:

1
2
3
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

Esto aplica a instalaciones que dejan archivos atrás: caches de gestores de paquetes, archivos descargados, artefactos de build que ya copiaste a otro sitio. No es un argumento para fusionar cada RUN del archivo. Un puñado de capas legibles se depura mejor que una línea de 400 caracteres, y el número de capas por sí solo no es lo que te cuesta tamaño.

Cuándo estas prácticas no valen la pena

Un script puntual que corres en local con docker build -t scratch . && docker run --rm scratch no necesita ni un multi-stage build ni un digest fijado. Montar todo eso cuesta más que el riesgo que evita.

El tamaño reducido de Alpine viene de usar musl libc en vez de glibc, lo que rompe módulos nativos de Node y Python compilados contra glibc. Los segfaults o errores de símbolo ausente después de cambiar a -alpine suelen ser eso. Usa -slim cuando no estés seguro.

Y fijar versiones es una decisión con un coste: una imagen base sin fijar recoge parches de seguridad en el siguiente docker build --pull sin que hagas nada. Si quieres eso, tómalo de forma deliberada, sabiendo que una build puede empezar a comportarse distinto por motivos que no están en tu diff. Dejar el tag suelto por descuido no es la misma decisión.

Cómo medir tamaño de imagen y tiempo de build

Construye las dos versiones de la misma app y compara:

1
2
3
4
docker build -t app:before -f Dockerfile.before .
docker build -t app:after -f Dockerfile.after .
docker images app --format "table {{.Tag}}\t{{.Size}}"
docker history app:after

docker images muestra la diferencia de tamaño. docker history muestra qué instrucción produjo qué capa y cuánto pesa, la forma más rápida de encontrar el RUN que todavía arrastra algo que no debería. Nada de esto cambia si tu app se construye con Docker Compose: un servicio con build: . usa el mismo Dockerfile, la misma cache y el mismo contexto de build, así que docker compose build consigue las mismas ventajas.

¿Heredaste un Dockerfile de antes de todo esto? Constrúyelo tal cual una vez, corre docker history, y arregla la capa que más sorprenda. Esa única capa suele ser la mayor parte de la diferencia.

Artículos relacionados