Dockerfiles
Convertir una receta en una imagen reproducible.
Creación de Dockerfiles
Hasta ahora has consumido imágenes escritas por otros. Ha llegado el momento de producirlas, y ahí es donde Docker deja de ser una utilidad para convertirse en un método de trabajo: el Dockerfile es el documento en el que el entorno de ejecución de tu aplicación pasa de ser conocimiento tribal a ser código fuente, revisado por el equipo y versionado junto con el resto.
La tentación, al principio, es tratarlo como un script de instalación que simplemente se ha movido a un archivo. Se apilan los comandos que funcionaban en el equipo local, se construye, funciona y se pasa a otra cosa. El resultado funciona. Pero también pesa 1,4 GB para una aplicación Node de tres archivos, se reconstruye en cuatro minutos cada vez que se cambia una coma, se ejecuta como root y lleva en una de sus capas el token de acceso privado que creías haber eliminado en la línea siguiente.
Ninguno de estos cuatro defectos es una fatalidad y ninguno requiere conocimientos avanzados. Todos provienen de un mismo desconocimiento: el Dockerfile no es un script que se ejecuta de arriba abajo en un entorno único. Es una sucesión de transformaciones, cada una de las cuales produce una capa inmutable, almacenada en caché y apilada sobre la anterior. Entender esta mecánica supone obtener casi gratis imágenes diez veces más ligeras y builds diez veces más rápidos.
Esta lección parte de un Dockerfile ingenuo pero funcional y lo corrige defecto a defecto, explicando en cada paso lo que el motor de construcción hace realmente.
Un primer Dockerfile, línea por línea
Tomemos una pequeña aplicación Node. En la raíz del proyecto, un archivo llamado exactamente Dockerfile, sin extensión:
# Imagen de partida: todo Dockerfile empieza por FROM.
# La versión está fijada: ni "latest", ni "node" a secas.
FROM node:22-slim
# Directorio de trabajo para las instrucciones siguientes.
# WORKDIR crea la carpeta si no existe.
WORKDIR /app
# Copiar SOLO los manifiestos de dependencias, antes del código.
# El motivo es la caché: se detalla más abajo.
COPY package.json package-lock.json ./
# "npm ci" respeta estrictamente el lock file, a diferencia de "npm install".
RUN npm ci --omit=dev
# El resto del código de la aplicación
COPY . .
# EXPOSE NO publica nada: es un metadato meramente documental.
# La publicación se hace con -p al arrancar.
EXPOSE 3000
# Comando que se ejecuta al arrancar el contenedor
CMD ["node", "server.js"]
# -t etiqueta la imagen (nombre:tag). El "." final es el CONTEXTO
# de build: la carpeta que se envía al daemon, no la ubicación del Dockerfile.
docker build -t mon-api:1.0 .
docker images mon-api
docker run --rm -p 3000:3000 mon-api:1.0
Este Dockerfile funciona y ya es correcto en un punto importante: el orden de los COPY. Antes de seguir, hay que aclarar la ambigüedad de ese punto final.
El contexto de build es el conjunto de archivos que el cliente envía al daemon antes de que empiece la construcción. El daemon no tiene ningún acceso a tu disco: solo ve ese paquete. De ahí dos consecuencias con las que todo el mundo se topa alguna vez. Un COPY ../secrets/config.json falla con path outside of build context, porque la ruta sale del paquete. Y si tu node_modules local pesa 800 MB, esos 800 MB se transfieren en cada build aunque nunca se copien; de ahí el .dockerignore, más adelante.
Además, el contexto y la ubicación del Dockerfile son independientes: docker build -f docker/Dockerfile.prod -t mon-api:prod . lee la receta en una subcarpeta manteniendo la carpeta actual como contexto.
Las instrucciones esenciales
FROM define la imagen base y puede aparecer varias veces (véase multi-stage). FROM scratch designa una imagen completamente vacía.
RUN ejecuta un comando durante la construcción y fija su resultado en una capa. Cada RUN crea una capa, por lo que conviene encadenarlos.
# Mal: tres capas, y la caché de apt se queda en la imagen
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
# Bien: una sola capa, con la limpieza en la MISMA instrucción
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
El rm -rf debe estar obligatoriamente en el mismo RUN. Si se coloca en un RUN posterior, ocultaría los archivos sin reducir el tamaño de la imagen, ya que las capas son aditivas. El --no-install-recommends evita decenas de paquetes sugeridos que no necesitas.
COPY copia desde el contexto a la imagen. ADD hace lo mismo añadiendo dos comportamientos implícitos —descomprimir un archivo local y descargar una URL— que a menudo sorprenden. La regla comúnmente aceptada es COPY por defecto, ADD solo para extraer un archivo comprimido.
COPY ./src /app/src
COPY --chown=node:node ./src /app/src # copia fijando el propietario
ADD app-v2.tar.gz /opt/ # extrae automáticamente
ENV define una variable persistente en la imagen y en los contenedores; ARG define una variable disponible únicamente durante el build, que se puede sobrescribir desde la línea de comandos.
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim
ARG APP_ENV=production
ENV NODE_ENV=${APP_ENV}
docker build --build-arg NODE_VERSION=20 --build-arg APP_ENV=staging -t mon-api:test .
# Un ARG NO es un secreto: su valor sigue siendo legible aquí
docker history --no-trunc mon-api:test
WORKDIR fija el directorio actual: prefiérelo siempre a RUN cd /app, que no tiene efecto duradero porque cada RUN inicia un proceso nuevo. EXPOSE documenta un puerto y permite docker run -P, que publica automáticamente los puertos declarados. Por último, LABEL org.opencontainers.image.source="..." añade metadatos de trazabilidad que las herramientas saben aprovechar.
La caché de capas: el orden decide el tiempo de build
Este es el punto que produce las mejoras más espectaculares, y se resume en una regla.
Para cada instrucción, el motor calcula una clave de caché. Si la instrucción y sus entradas no han cambiado, la capa se reutiliza tal cual. Pero en cuanto una capa se invalida, todas las siguientes también lo hacen. La caché es una cadena: una ruptura en el eslabón 3 rompe los eslabones 4 a 12. Para RUN, la clave es el texto del comando; para COPY, es el contenido de los archivos copiados.
De ahí el contraejemplo clásico:
FROM node:22-slim
WORKDIR /app
COPY . . # se invalida en cuanto cambia UN SOLO archivo fuente
RUN npm ci --omit=dev # ...y por tanto reinstala todo, con cada coma modificada
CMD ["node", "server.js"]
Y la versión correcta, que aprovecha la estabilidad relativa de las dependencias:
FROM node:22-slim
WORKDIR /app
# Solo los manifiestos: cambian raramente
COPY package.json package-lock.json ./
# Capa costosa, pero se conserva mientras los manifiestos sean idénticos
RUN npm ci --omit=dev
# El código al final: lo que más cambia y lo que menos cuesta
COPY . .
CMD ["node", "server.js"]
La regla general, válida para todos los lenguajes: de lo más estable a lo más volátil. Instalación del sistema, luego dependencias, luego código. Solo cambian los nombres de los archivos: requirements.txt para Python (pip install --no-cache-dir -r), go.mod y go.sum para Go, pom.xml para Maven, Cargo.toml para Rust.
Durante un build, las líneas CACHED indican las capas reutilizadas. Hay dos opciones para saltarse la caché: --no-cache fuerza una reconstrucción completa, lo que comprueba que el Dockerfile sigue funcionando en frío, y --pull descarga una imagen base actualizada en lugar de la de la caché local.
BuildKit, el motor de construcción por defecto desde Docker 23, ofrece además cachés montadas que sobreviven a las invalidaciones. Requieren la directiva de sintaxis en la primera línea:
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
# La caché de npm se monta durante el RUN y NO se
# incluye en la capa: rápido, sin engordar la imagen.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
.dockerignore: imprescindible, nunca opcional
Junto al Dockerfile, este archivo excluye rutas del contexto de build. Su sintaxis es la de .gitignore.
# Dependencias: se reinstalan en la imagen, y las del host
# quizá estén compiladas para otro SO
node_modules
__pycache__
vendor
.git # a menudo cientos de MB, nunca es útil
# Secretos y configuración local: el riesgo más grave
.env
.env.*
*.pem
*.key
# Artefactos, cobertura, archivos de editor
dist
build
coverage
.vscode
.idea
Dockerfile*
docker-compose*.yml
Actúa en tres planos a la vez. Acelera: el contexto transferido puede pasar de varios cientos de megabytes a unos cientos de kilobytes. Protege: sin él, un COPY . . incluye tu .env y tus claves privadas en una capa de la imagen, de la que podrán extraerse siempre. Y estabiliza la caché: un archivo de log que cambia en cada ejecución ya no invalidará tus capas.
Merece la pena subrayar el caso de node_modules: copiarlo desde el host no solo es inútil, sino arriesgado, porque los módulos nativos se compilan para la arquitectura y el SO de la máquina donde se instalaron.
CMD frente a ENTRYPOINT
CMD proporciona un comando por defecto, totalmente sustituible por los argumentos de docker run. ENTRYPOINT fija el ejecutable, y los argumentos de docker run se le añaden.
Con solo CMD ["echo", "bonjour"], docker run mon-image muestra «bonjour» y docker run mon-image ls / lista la raíz: el comando por defecto ha sido simplemente reemplazado. El dúo ENTRYPOINT + CMD se comporta de forma muy distinta, y es el patrón recomendado para una aplicación.
FROM alpine:3.20
ENTRYPOINT ["ping"]
CMD ["-c", "3", "localhost"]
docker run --rm mon-image # ping -c 3 localhost
docker run --rm mon-image -c 5 8.8.8.8 # ping -c 5 8.8.8.8
# ENTRYPOINT solo se sustituye de forma explícita
docker run --rm --entrypoint sh -it mon-image
Queda una distinción más sutil, con consecuencias muy reales: la forma exec frente a la forma shell.
# Forma exec (array JSON): el proceso es directamente el PID 1
CMD ["node", "server.js"]
# Forma shell (cadena): Docker ejecuta /bin/sh -c "node server.js"
CMD node server.js
En la forma shell, el PID 1 es sh, y sh no transmite SIGTERM a su proceso hijo. Tu aplicación nunca recibe la señal de parada: docker stop espera diez segundos y luego la mata de forma brusca. Usa siempre la forma exec para un proceso de larga duración. En ella no se interpretan las variables de entorno; si las necesitas, indícalo explícitamente:
CMD ["sh", "-c", "node server.js --port=$PORT"]
Multi-stage: construir en una imagen, entregar en otra
Es la mayor palanca disponible para reducir el tamaño de las imágenes. La idea: las herramientas necesarias para construir una aplicación —compilador, cabeceras, dependencias de desarrollo— no tienen nada que hacer en la imagen que la ejecuta.
Un Dockerfile puede contener varios FROM. Cada uno abre una etapa, y la etapa final copia archivos de una etapa anterior con COPY --from. Todo lo que no se copia se descarta.
# syntax=docker/dockerfile:1
# ---------- Etapa 1: construcción ----------
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# Aquí se instala TODO: TypeScript, bundlers, herramientas de test
RUN npm ci
COPY . .
RUN npm run build # genera /app/dist
# ---------- Etapa 2: ejecución ----------
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
# SOLO el artefacto compilado. El compilador, las fuentes
# y las devDependencies se quedan atrás.
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
El efecto es aún más claro con un lenguaje compilado, donde la imagen final puede prescindir de cualquier entorno de ejecución:
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO desactivado: binario estático, sin dependencia de libc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/api ./cmd/api
# scratch está literalmente vacía: ni shell, ni libc, ni gestor de paquetes
FROM scratch
# Los certificados raíz son necesarios para cualquier llamada HTTPS saliente
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]
Se pasa de aproximadamente 1 GB a unos pocos megabytes, y la superficie de ataque se reduce en la misma proporción: sin shell ni utilidades, un atacante que consiguiera ejecutar código tendría casi nada a su disposición. La contrapartida es que ya no se puede diagnosticar con docker exec ... sh. Las imágenes distroless de Google son un compromiso frecuente: sin shell, pero con un entorno de ejecución completo.
Las etapas también se pueden usar por separado, lo que hace que el mismo archivo sea aprovechable en integración continua:
docker build --target builder -t mon-api:build .
docker run --rm mon-api:build npm test
No ejecutes nunca como root
Por defecto, el proceso de un contenedor se ejecuta como root. No es el root del host, pero combinar esa elevación con un fallo del kernel o con un volumen montado desde el host basta para convertir una vulnerabilidad de la aplicación en un compromiso de la máquina. La solución se resume en dos instrucciones.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Usuario de sistema, sin contraseña ni shell de inicio de sesión
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup --no-create-home appuser \
&& chown -R appuser:appgroup /app
# Todo lo que sigue se ejecuta con este usuario
USER appuser
EXPOSE 8000
CMD ["python", "-m", "app"]
En una base Alpine, las herramientas son distintas: addgroup -S -g 1001 appgroup && adduser -S -u 1001 -G appgroup -H appuser. Y algunas imágenes oficiales ya proporcionan una cuenta sin privilegios: las imágenes node tienen un usuario node, así que basta con escribir USER node.
Dos puntos de atención. El orden importa: el USER va después de las instalaciones que requieren privilegios, y el chown precede al cambio de usuario; de lo contrario, la aplicación no podrá escribir en su propio directorio. Y un usuario sin privilegios no puede enlazarse a un puerto inferior a 1024: haz que tu aplicación escuche en el 8080 y publícala donde quieras con -p 80:8080.
La comprobación se resume en un comando: docker run --rm mon-api:1.0 id debe responder uid=1001(appuser), y no uid=0(root).
Las capas son aditivas: los secretos no se borran
Un error que parece inofensivo y que no lo es en absoluto:
# NO HAGA ESTO
COPY .npmrc /root/.npmrc
RUN npm ci
RUN rm /root/.npmrc # ilusorio: el archivo permanece en la capa del COPY
El rm crea una capa que oculta el archivo; no lo elimina de la imagen. Cualquiera que disponga de la imagen puede extraer la capa anterior y recuperar el token. El mismo razonamiento vale para un ARG TOKEN, visible en docker history.
BuildKit ofrece la solución limpia: un secreto montado durante un RUN, que no pertenece a ninguna capa.
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
# El archivo existe durante el RUN y después desaparece sin dejar rastro
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
docker build --secret id=npmrc,src=$HOME/.npmrc -t mon-api:1.0 .
Construir, etiquetar, verificar
El nombre que se da a una imagen permitirá saber, seis meses después, qué commit se está ejecutando en producción. Por tanto, etiquete en el momento del build y compruebe tres cosas antes de dar la imagen por terminada.
# Varios tags para una misma construcción, uno de ellos derivado del commit de Git
docker build -t mon-api:1.2.3 -t mon-api:"$(git rev-parse --short HEAD)" .
# Etiquetar para un registro privado, previo al push
docker tag mon-api:1.2.3 registre.exemple.com/equipe/mon-api:1.2.3
docker images mon-api # ¿es razonable el tamaño?
docker run --rm mon-api:1.2.3 id # ¿se ejecuta sin privilegios?
docker history mon-api:1.2.3 # ¿dónde ha ido a parar el peso?
Conclusiones
- Un
Dockerfileproduce una serie de capas inmutables, no un script lineal. Ordene las instrucciones de la más estable a la más volátil: dependencias antes que código; de lo contrario, pagará una reinstalación completa por cada carácter modificado. - Una capa invalidada invalida todas las siguientes. La caché es una cadena, y un
COPY . .colocado demasiado arriba la rompe sistemáticamente. - El
.dockerignoreno es opcional: acelera el build, estabiliza la caché e impide que sus.envy sus claves privadas acaben en una capa publicable. ENTRYPOINTfija el ejecutable,CMDproporciona argumentos por defecto que se pueden sobrescribir. Utilice la forma exec para que su proceso sea el PID 1 y recibaSIGTERM.- El multi-stage es la mayor palanca disponible: las herramientas de construcción permanecen en la etapa
buildery solo se copia el artefacto. Ganancias típicas de un factor 10 tanto en tamaño como en superficie de ataque. - Añada siempre un
USERsin privilegios, después de las instalaciones y delchown, y haga que la aplicación escuche por encima del puerto 1024. - Un archivo eliminado en una capa posterior sigue pudiéndose extraer. Para los tokens de build, utilice
RUN --mount=type=secret, nuncaCOPYseguido derm, nuncaARG.
Consejo: tras cada modificación, ejecute
docker historysobre su imagen. Una capa anormalmente grande casi siempre apunta a una caché de paquetes olvidada o a unCOPYdemasiado amplio.
Comentarios
Los comentarios funcionan con GitHub Discussions
Inicia sesión con GitHub para participar en la discusión