En la lección anterior construiste auroralibros/aurora-api:0.1.0 con un Dockerfile que se te dio hecho y se te explicó por encima. Funciona, arranca la API dentro de un contenedor y responde a curl. Pero lo escribiste casi a ciegas: sabes qué hace cada línea, no por qué está escrita exactamente así. Esta lección salda esa deuda. Vas a recorrer el lenguaje del Dockerfile instrucción por instrucción, con el nivel de detalle que en 02-02 quedó pendiente: por qué WORKDIR es mejor que RUN cd, en qué se diferencian COPY y ADD y por qué casi siempre gana COPY, qué significan exactamente las dos formas de RUN y de CMD, por qué encadenar comandos con && produce imágenes más pequeñas, por qué EXPOSE no expone nada, y qué le pasa a tu contenedor cuando lo paras según cómo hayas escrito el CMD. Al final construirás el Dockerfile definitivo de aurora-api, comentado línea a línea y con cada decisión justificada.
Contenido
- Estructura de un Dockerfile, comentarios y la directiva
# syntax - Tabla resumen de las instrucciones básicas
FROM: de dónde partesWORKDIR: dónde trabajasCOPY: qué traes del contextoCOPYfrente aADDRUN: qué ejecutas durante la construcciónENV: configuración que sobrevive a la construcciónEXPOSE: documentación, no publicaciónCMD: qué proceso arranca el contenedor- El Dockerfile definitivo de
aurora-api
- Estructura de un Dockerfile, comentarios y la directiva
# syntax
# syntaxUn Dockerfile es un fichero de texto plano, sin extensión, con una instrucción por línea:
# syntax=docker/dockerfile:1
# Comentario: BuildKit lo ignora por completo
FROM node:22-alpine
WORKDIR /app
RUN echo "una instrucción" && \
echo "puede continuar en la línea siguiente"
CMD ["node", "server.js"]Reglas de sintaxis:
- Las instrucciones se escriben en MAYÚSCULAS por convención.
from node:22-alpinefunciona igual, pero nadie lo escribe así: en mayúsculas se distingue de un vistazo la instrucción de sus argumentos. - Una instrucción por línea. Para partir una línea larga se usa la barra invertida
\al final, sin nada después (ni siquiera un espacio: un espacio tras la barra invierte su efecto y produce errores desconcertantes). - Las líneas que empiezan por
#son comentarios, salvo las directivas del apartado siguiente. Como comprobaste en el ejercicio 2 de la lección 02-02, los comentarios no afectan a la caché. - El orden importa, y no solo por la caché: cada instrucción se ejecuta sobre el estado que dejó la anterior.
- Las líneas vacías se ignoran. Úsalas para agrupar bloques lógicos; un Dockerfile bien espaciado se lee muchísimo mejor.
La directiva # syntax
La primera línea del fichero merece un apartado propio:
No es un comentario: es una directiva del intérprete. Le dice a BuildKit qué versión del frontend de Dockerfile debe usar para interpretar el resto del fichero. BuildKit descarga esa imagen del registro y la usa como analizador sintáctico.
Por qué conviene ponerla siempre:
- Te da las funcionalidades más recientes sin actualizar Docker.
docker/dockerfile:1es una etiqueta móvil que apunta a la última versión estable de la serie 1. Cosas comoCOPY --link, los montajes de caché o los secretos de build (lección 05-05) dependen de ella. - Hace el fichero portable. El mismo Dockerfile se interpreta igual en tu portátil y en un agente de CI con otra versión de Docker.
- Es compatible hacia atrás. La serie
1garantiza que no romperá los Dockerfiles existentes.
Las variantes que verás:
| Directiva | Qué obtienes |
|---|---|
# syntax=docker/dockerfile:1 |
Última estable de la serie 1. La recomendada |
# syntax=docker/dockerfile:1.12 |
Fijada a una versión menor concreta, para máxima reproducibilidad |
| (sin directiva) | El frontend que traiga tu versión de Docker Engine. Funciona, pero pierdes funcionalidades |
Debe ser la primera línea del fichero (antes de cualquier instrucción, incluso antes de otros comentarios) para que surta efecto.
- Tabla resumen de las instrucciones básicas
Estas son las instrucciones que cubre esta lección, con lo esencial de cada una:
| Instrucción | Qué hace | ¿Crea capa? | ¿Cuándo actúa? |
|---|---|---|---|
FROM |
Establece la imagen base | Hereda las de la base | Construcción |
WORKDIR |
Fija el directorio de trabajo | Sí (metadato, tamaño ~0) | Construcción y ejecución |
COPY |
Copia ficheros del contexto a la imagen | Sí | Construcción |
ADD |
Como COPY, más URLs y descompresión automática |
Sí | Construcción |
RUN |
Ejecuta un comando y congela el resultado | Sí | Construcción |
ENV |
Define variables de entorno | Sí (metadato) | Construcción y ejecución |
EXPOSE |
Documenta el puerto que usa el servicio | Sí (metadato) | Solo documentación |
CMD |
Define el proceso por defecto del contenedor | Sí (metadato) | Ejecución |
La columna "¿Crea capa?" explica una observación de la lección 01-05: no todas las instrucciones engordan la imagen. COPY, ADD y RUN modifican el sistema de archivos y generan capas con contenido real; las demás solo escriben metadatos en el manifiesto, y su capa pesa cero bytes.
La columna "¿Cuándo actúa?" es la que más confusión evita. RUN se ejecuta al construir y su resultado queda congelado; CMD no se ejecuta al construir en absoluto, solo describe qué hará el contenedor al arrancar. Confundirlas es el error conceptual número uno de quien empieza.
Hay más instrucciones —ARG, ENTRYPOINT, USER, LABEL, HEALTHCHECK, VOLUME, STOPSIGNAL, ONBUILD, SHELL—, y todas se ven en la lección siguiente, la 02-04.
FROM: de dónde partes
FROM: de dónde partesFROM es obligatoria y debe ser la primera instrucción (salvo directivas y comentarios). Establece el sistema de archivos y los metadatos de partida: todo lo que venga después se construye encima de esas capas.
Sintaxis completa:
Formas que verás, aplicadas a Aurora Libros:
FROM node # Peligroso: latest, versión impredecible
FROM node:22 # Mejor: fija la mayor, pero la imagen es de ~1,1 GB
FROM node:22-alpine # La elegida: ligera y con la mayor fijada
FROM node:22.14-alpine3.21 # Máximo control: fija menor y versión de Alpine
FROM node:22-alpine@sha256:9f2c1a... # Reproducibilidad absoluta: digest inmutableEl compromiso entre reproducibilidad y mantenimiento es real y no tiene una respuesta única:
| Forma | Reproducibilidad | Parches de seguridad | Recomendado para |
|---|---|---|---|
node (= latest) |
Nula | Automáticos, pero puede cambiar de versión mayor sin avisar | Nunca |
node:22 |
Media | Automáticos dentro de la mayor 22 | Desarrollo |
node:22-alpine |
Media | Automáticos dentro de la mayor 22 | Aurora Libros: buen equilibrio |
node:22.14-alpine3.21 |
Alta | Manuales | Entornos regulados |
@sha256:… |
Total | Manuales | Producción crítica, cadena de suministro auditada |
Aurora Libros usa node:22-alpine, decidido en la lección 01-07 por tres motivos que ahora puedes justificar del todo: cumple el engines: node >=22.0.0 del package.json, es imagen oficial (espacio library/, primer criterio de confianza de la lección 02-01) y pesa ~142 MB frente a los ~1,1 GB de node:22. La contrapartida es que Alpine usa musl en lugar de glibc como biblioteca C; las tres dependencias del proyecto (express, pg, redis) son JavaScript puro o traen binarios compatibles, así que no supone problema.
La cláusula AS <nombre> da nombre a una etapa de construcción. Sirve para las builds multi-etapa, la técnica que permite compilar en una imagen y llevarse solo el resultado a otra mucho más pequeña. Se estudia en la lección 05-04; menciónala mentalmente y sigue.
WORKDIR: dónde trabajas
WORKDIR: dónde trabajasWORKDIR fija el directorio de trabajo para todas las instrucciones posteriores que lo usen: RUN, COPY, ADD y CMD. Si no existe, lo crea, incluidos los directorios intermedios.
Por qué no RUN cd
Es la pregunta obligada. Compara:
En el primer caso, npm ci se ejecuta en /, no en /app, y falla con ENOENT: no such file or directory, open '/package.json'. La razón es fundamental: cada RUN se ejecuta en un contenedor temporal nuevo. El cd cambia el directorio del shell de ese contenedor efímero, que muere en cuanto termina la instrucción; el siguiente RUN arranca de cero, en el directorio de trabajo heredado de la imagen.
La única forma de que cd sirva es encadenarlo dentro del mismo RUN:
Aun así, WORKDIR gana por cuatro razones:
- Persiste en tiempo de ejecución. Es el directorio donde entras con
docker exec -it aurora-api shy donde se ejecuta elCMD. Uncddentro de unRUNno deja rastro. - Crea el directorio si no existe, sin necesidad de
mkdir -p. - Es un metadato visible.
docker image inspectte dice cuál es; uncdenterrado en unRUNhay que ir a buscarlo. - Se lee mejor. Declara la intención en lugar de esconderla dentro de una cadena de comandos.
Detalles adicionales:
WORKDIR /app # Absoluta: siempre preferible
WORKDIR api # Relativa a la anterior: acaba en /app/api
WORKDIR $DIRECTORIO # Admite variables definidas con ENV o ARGUsa siempre rutas absolutas. Las relativas encadenadas obligan a llevar la cuenta mental de dónde estás y son una fuente clásica de errores en Dockerfiles largos.
Para Aurora Libros, /app es la convención habitual en imágenes de Node. Cualquier ruta valdría (/srv/aurora, /usr/src/app), pero /app es corta, inequívoca y no colisiona con el sistema de archivos de Alpine.
COPY: qué traes del contexto
COPY: qué traes del contextoCOPY trae ficheros y directorios desde el contexto de construcción hasta el sistema de archivos de la imagen.
Reglas que hay que tener claras:
- El origen es siempre relativo a la raíz del contexto, jamás a tu directorio actual ni a la ubicación del Dockerfile. Y nunca puede salir del contexto: es la restricción que provocó el error
"/db/init.sql": not foundde la lección 02-02. - El destino es relativo al
WORKDIRsi no es absoluto. ConWORKDIR /app, el destino./significa/app/. - Si el destino termina en
/, se trata como directorio; si no, y hay un solo origen, se interpreta como el nombre del fichero destino. Poner siempre la barra final evita sorpresas. - Con varios orígenes, el destino debe ser un directorio y acabar en
/. COPYcopia el contenido de un directorio, no el directorio.COPY web/ /public/deja el contenido deweb/directamente en/public/, no en/public/web/. Es la fuente número uno de rutas que "no aparecen" dentro de la imagen.
Ejemplos con Aurora Libros:
# Dos ficheros concretos al WORKDIR
COPY package.json package-lock.json ./
# Comodines: todo lo que empiece por "package" y acabe en ".json"
COPY package*.json ./
# Todo el contexto (ya filtrado por .dockerignore)
COPY . .
# Renombrando en el destino
COPY server.js /app/main.js
# Un directorio completo a una ruta absoluta
COPY web/ /usr/share/nginx/html/Sobre COPY package*.json ./: el comodín cubre package.json y package-lock.json de una vez. Tiene una ventaja práctica sobre nombrarlos por separado: no falla si package-lock.json no existe, porque el patrón simplemente coincide con menos ficheros. En cambio, COPY package.json package-lock.json ./ da error si falta el lock. Ambas formas son defendibles: la explícita detecta antes el olvido del lock (que rompería el npm ci), la del comodín es más tolerante. Aurora Libros usará la del comodín, que es la convención más extendida en el ecosistema Node.
--chown y --chmod
Por defecto, todo lo copiado pertenece a root:root. --chown cambia el propietario en la misma operación:
La imagen node:22-alpine incluye ya un usuario node sin privilegios. Copiar directamente con su propiedad evita un RUN chown -R node:node /app posterior, que duplicaría el tamaño de esa capa: cambiar los permisos de un fichero lo marca como modificado y el copy-on-write de la lección 01-05 obliga a escribir una copia entera en la capa nueva. Un chown recursivo sobre node_modules puede añadir decenas de megas a la imagen.
--chmod hace lo propio con los permisos:
El uso de USER para ejecutar el contenedor sin privilegios se ve en la lección 02-04, y el porqué en profundidad, en la 05-03.
COPY frente a ADD
COPY frente a ADDADD existe desde el primer día de Docker y hace todo lo que hace COPY, más dos cosas extra. Precisamente por eso conviene evitarla.
| Aspecto | COPY |
ADD |
|---|---|---|
| Copiar ficheros locales del contexto | Sí | Sí |
| Copiar directorios | Sí | Sí |
--chown / --chmod |
Sí | Sí |
Descomprimir automáticamente un .tar, .tar.gz, .tar.bz2, .tar.xz local |
No | Sí |
| Descargar desde una URL | No | Sí (pero desaconsejado) |
Clonar un repositorio Git (--keep-git-dir) |
No | Sí, versiones recientes |
| Comportamiento predecible | Total | Depende del tipo de fichero |
| Recomendación oficial | Usar esta | Solo en casos concretos |
El problema de ADD es que su comportamiento depende de lo que le pases:
ADD datos.tar.gz /app/ # Descomprime el tar dentro de /app/
ADD datos.zip /app/ # NO descomprime: los zip no entran en la regla
ADD fichero.txt /app/ # Copia normalTres comportamientos distintos con la misma instrucción. Quien lee el Dockerfile tiene que saber de memoria qué formatos se descomprimen para predecir el resultado. COPY siempre hace exactamente lo mismo: copiar.
Y el caso de la URL es peor:
# ❌ MAL: descarga un fichero remoto en una capa
ADD https://ejemplo.com/herramienta.tar.gz /tmp/
RUN tar -xzf /tmp/herramienta.tar.gz -C /opt && rm /tmp/herramienta.tar.gzCuatro problemas acumulados: el .tar.gz queda para siempre en la capa del ADD aunque lo borres después (copy-on-write, lección 01-05), no puedes verificar la suma de comprobación antes de usarlo, ADD no descomprime lo que viene de una URL (solo lo local), y no controlas cabeceras ni autenticación. La forma correcta, todo en un único RUN:
# ✅ BIEN: descarga, verifica, extrae y borra en la MISMA capa
RUN wget -q https://ejemplo.com/herramienta.tar.gz -O /tmp/h.tar.gz && \
echo "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b /tmp/h.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/h.tar.gz -C /opt && \
rm /tmp/h.tar.gzAquí sí verificas el hash, y el borrado ocurre dentro de la misma capa, así que el fichero temporal no engorda la imagen.
La regla práctica: usa COPY siempre. El único caso donde ADD aporta algo genuino es descomprimir un tarball local del contexto, que ahorra tener tar en la imagen:
En el Dockerfile de aurora-api no aparece ningún ADD: no hay tarballs ni descargas.
RUN: qué ejecutas durante la construcción
RUN: qué ejecutas durante la construcciónRUN ejecuta un comando en el momento de construir la imagen, dentro de un contenedor temporal basado en las capas anteriores, y congela el sistema de archivos resultante en una capa nueva. Es la instrucción que instala paquetes, compila, genera ficheros… y la que más peso añade.
Forma shell y forma exec
# Forma shell: se ejecuta a través de /bin/sh -c
RUN npm ci --omit=dev
# Forma exec: se ejecuta directamente, sin shell
RUN ["npm", "ci", "--omit=dev"]| Aspecto | Forma shell | Forma exec |
|---|---|---|
| Sintaxis | RUN comando args |
RUN ["ejecutable", "arg1", "arg2"] |
| Ejecuta a través de | /bin/sh -c |
Directamente |
Variables de entorno ($VAR) |
Se expanden | No se expanden |
Tuberías, &&, >, * |
Funcionan | No funcionan |
| Requiere shell en la imagen | Sí | No |
Uso habitual en RUN |
El normal | Raro |
Para RUN, la forma shell es la habitual, porque casi siempre quieres encadenar comandos o expandir variables. La forma exec solo hace falta en imágenes sin shell (scratch, distroless) o cuando un argumento contiene caracteres que el shell interpretaría mal.
Ojo con la comilla: la forma exec es JSON, así que exige comillas dobles. RUN ['npm', 'ci'] con comillas simples no es JSON válido y BuildKit lo tratará como forma shell, con resultados desconcertantes.
Por qué encadenar con && y \
Esta es la parte que de verdad importa. Cada RUN crea una capa. Compara:
# ❌ MAL: cuatro capas, y la basura queda dentro para siempre
RUN apk update
RUN apk add --no-cache curl
RUN apk add --no-cache tzdata
RUN rm -rf /var/cache/apk/*# ✅ BIEN: una sola capa, la limpieza sí surte efecto
RUN apk update && \
apk add --no-cache curl tzdata && \
rm -rf /var/cache/apk/*Dos problemas en la versión mala, y el segundo es el grave:
- Cuatro capas donde bastaba una. Más metadatos, más entradas en el manifiesto, más lentitud al montar la imagen.
- El
rm -rfdel final no libera nada. Recuerda de la lección 01-05: las capas son diffs y solo se apilan. Borrar un fichero en la capa 4 no lo elimina de la capa 2; solo escribe un marcador de borrado que lo oculta. El fichero sigue viajando dentro de la imagen, ocupando su espacio en cada descarga.
Compruébalo tú mismo:
# Versión con la limpieza en un RUN aparte
printf 'FROM alpine:3.21\nRUN apk add --no-cache python3\nRUN rm -rf /usr/lib/python3.12\n' > /tmp/Dockerfile.mal
docker build -q -t prueba:mal -f /tmp/Dockerfile.mal /tmp
# Versión con todo en la misma capa
printf 'FROM alpine:3.21\nRUN apk add --no-cache python3 && rm -rf /usr/lib/python3.12\n' > /tmp/Dockerfile.bien
docker build -q -t prueba:bien -f /tmp/Dockerfile.bien /tmp
docker image ls prueba --format "table {{.Tag}}\t{{.Size}}"48 MB de diferencia por mover un rm de una línea a otra. El fichero borrado "existe" igualmente dentro de la imagen mal, invisible pero descargable.
De ahí las tres reglas de oro de RUN:
- Agrupa las operaciones relacionadas en un solo
RUNcon&&y\. - Limpia en la misma capa donde ensucias. Cachés de gestores de paquetes, ficheros temporales, código fuente que ya has compilado.
- Pero no lo agrupes todo. Si metes en un único
RUNla instalación de dependencias y el arranque de la aplicación, pierdes granularidad de caché. El equilibrio: unRUNpor propósito.
Limpieza según el gestor de paquetes
| Base | Comando | Nota |
|---|---|---|
| Alpine | apk add --no-cache paquete |
--no-cache evita escribir el índice: no hace falta rm posterior |
| Debian/Ubuntu | apt-get update && apt-get install -y --no-install-recommends paquete && rm -rf /var/lib/apt/lists/* |
El rm es imprescindible y debe ir en el mismo RUN |
| Node | npm ci --omit=dev && npm cache clean --force |
La caché de npm puede ocupar cientos de MB |
Un aviso sobre Debian: apt-get update y apt-get install deben ir siempre en el mismo RUN. Si los separas, la caché puede reutilizar un update de hace semanas mientras ejecuta un install nuevo, y acabarás instalando versiones que ya no están en los repositorios. Es el clásico error conocido como cache busting de apt.
RUN en Aurora Libros
Desmenuzado:
npm cien lugar denpm install.cisignifica clean install: borranode_modulessi existe e instala exactamente las versiones fijadas enpackage-lock.json, sin resolver rangos. Si el lock y elpackage.jsonno concuerdan, falla en lugar de improvisar. Eso es justo lo que quieres en una imagen: dos builds del mismo commit producen los mismos bytes.npm install, en cambio, puede resolver^4.21.2a4.21.2hoy y a4.22.0el mes que viene, produciendo imágenes distintas del mismo código.--omit=devexcluye lasdevDependencies. En Aurora Libros hoy no hay ninguna, pero en cuanto entren linters o frameworks de test, esta opción evitará que viajen a producción. Sustituye a la antigua--production, ahora desaconsejada.npm cache clean --forceborra la caché que npm deja en~/.npm, que puede rondar los 50 MB. Va en el mismoRUNpor todo lo explicado arriba: en unRUNaparte no ahorraría un solo byte.
ENV: configuración que sobrevive a la construcción
ENV: configuración que sobrevive a la construcciónENV define variables de entorno que existen durante el resto de la construcción y también dentro del contenedor en ejecución. Esa doble vida es su rasgo distintivo, y es lo que la diferencia de ARG (lección 02-04).
Sintaxis:
ENV CLAVE=valor
ENV CLAVE1=valor1 CLAVE2=valor2 # Varias en una instrucción, una sola capa
ENV CLAVE valor # Forma antigua, sin '=': desaconsejadaUsa siempre la forma con =. La antigua es ambigua con valores que contienen espacios y está en desuso.
Las variables definidas se pueden usar en instrucciones posteriores:
Y persisten en ejecución. Compruébalo con la imagen que ya tienes:
Lo importante es que son valores por defecto sobrescribibles al arrancar el contenedor:
Ese -e tiene prioridad sobre el ENV de la imagen. Es exactamente el mecanismo que hace que el server.js de la lección 01-07 —que lee toda su configuración de process.env— sirva sin cambios en tu portátil y en producción.
Qué poner y qué NO poner en un ENV
| Variable | ¿En el ENV del Dockerfile? |
Por qué |
|---|---|---|
NODE_ENV=production |
Sí | No es secreto y es el valor correcto por defecto para la imagen |
PORT=3000 |
Sí | Valor por defecto sensato, sobrescribible con -e |
DB_HOST |
No | Depende del entorno; se inyecta al ejecutar |
DB_USER |
No | Depende del entorno |
DB_PASSWORD |
JAMÁS | Es un secreto. Queda grabado en una capa y visible con docker image history |
La última fila es la regla que arrastras desde la lección 01-07 y que ahora puedes demostrar. Si alguien escribiera ENV DB_PASSWORD=superSecreta2026, cualquiera con acceso a la imagen la vería:
["PATH=/usr/local/sbin:...","NODE_VERSION=22.14.0","YARN_VERSION=1.22.22","NODE_ENV=production","PORT=3000"]Ahí está todo, en claro, sin necesidad ni de arrancar el contenedor. Y publicando la imagen en el repositorio público auroralibros/aurora-api, en Internet.
NODE_ENV=production merece un comentario porque en Node no es decorativo: Express desactiva vistas de depuración y cachea plantillas, muchas librerías reducen el registro y npm install omitiría las devDependencies. Es un cambio de comportamiento real, no una etiqueta.
EXPOSE: documentación, no publicación
EXPOSE: documentación, no publicaciónAquí está la trampa que atrapa a todo el mundo. Leyendo "expose" cualquiera entiende "abre este puerto al exterior". EXPOSE no abre nada, no publica nada y no cambia el comportamiento de la red. Es exclusivamente documentación en forma de metadato.
Lo que sí hace:
- Deja constancia en los metadatos de la imagen de qué puerto usa el servicio, para que quien la ejecute lo sepa sin leer el código.
- Aparece en
docker image inspecty en la columna PORTS dedocker ps. - Habilita la opción
docker run -P(mayúscula), que publica todos los puertos declarados conEXPOSEen puertos aleatorios altos del host. - Docker Compose y algunos orquestadores lo leen como pista.
Lo que no hace: publicar el puerto. Eso sigue siendo trabajo exclusivo de -p en docker run, tal como aprendiste en la lección 01-06.
Demuéstralo. Tu imagen 0.1.0 tiene EXPOSE 3000. Arráncala sin -p:
docker run -d --name prueba-expose auroralibros/aurora-api:0.1.0
docker ps --filter name=prueba-expose --format "table {{.Names}}\t{{.Ports}}"Fíjate en la columna PORTS: pone 3000/tcp, sin ninguna flecha ->. Eso significa "el contenedor declara este puerto", no "está publicado". Compruébalo:
Ahora con -P mayúscula, que sí publica lo declarado:
docker rm -f prueba-expose
docker run -d --name prueba-expose -P auroralibros/aurora-api:0.1.0
docker ps --filter name=prueba-expose --format "table {{.Names}}\t{{.Ports}}"Ahora sí hay flecha: el puerto 3000 del contenedor está publicado en el 32768 del host, elegido al azar. Limpia:
Entonces, ¿para qué molestarse en poner EXPOSE? Por tres razones sólidas:
- Documenta la interfaz de la imagen. Quien la reciba sabe qué puerto mapear sin leer
server.js. - Es el contrato con el orquestador. Compose (módulo 4), Swarm y Kubernetes (módulo 6) lo usan como referencia.
- Cuesta cero. Es un metadato: no añade un solo byte a la imagen.
Sintaxis completa:
EXPOSE 3000 # TCP por defecto
EXPOSE 3000/tcp # Explícito
EXPOSE 53/udp # UDP
EXPOSE 3000 9229 # Varios puertos
CMD: qué proceso arranca el contenedor
CMD: qué proceso arranca el contenedorCMD define el comando por defecto que se ejecuta al arrancar un contenedor a partir de la imagen. No se ejecuta durante la construcción: solo se guarda como metadato.
Y hay una regla que gobierna todo lo demás, ya vista en la lección 01-06: el contenedor vive mientras viva su proceso principal. Cuando el proceso del CMD termina, el contenedor se para. Por eso CMD ["node", "server.js"] mantiene el contenedor vivo (el servidor no termina) y CMD ["echo", "hola"] produce un contenedor que muere al instante.
Las dos formas, y por qué importan de verdad
# Forma exec (JSON) — LA CORRECTA
CMD ["node", "server.js"]
# Forma shell — problemática
CMD node server.jsParecen equivalentes, y en condiciones normales lo son. La diferencia aparece al parar el contenedor, y es lo bastante importante como para verla en detalle.
Con la forma exec, Docker ejecuta node server.js directamente. El proceso node es el PID 1 dentro del contenedor.
Con la forma shell, Docker ejecuta /bin/sh -c "node server.js". El PID 1 es sh, y node es un proceso hijo:
flowchart LR
subgraph EXEC["Forma exec: CMD [node, server.js]"]
E1["PID 1: node server.js"]
end
subgraph SHELL["Forma shell: CMD node server.js"]
S1["PID 1: /bin/sh -c"] --> S2["PID 7: node server.js"]
end
SIG1["docker stop<br/>SIGTERM"] --> E1
SIG2["docker stop<br/>SIGTERM"] --> S1
E1 -.->|"cierra conexiones<br/>y termina limpiamente"| OK["Parada en ~0,2 s ✅"]
S1 -.->|"sh no reenvía la señal"| KO["10 s de espera<br/>y SIGKILL ❌"]
Cuando ejecutas docker stop, Docker envía SIGTERM al PID 1 y espera 10 segundos antes de enviar SIGKILL. Con la forma exec, node recibe el SIGTERM y puede cerrar conexiones, vaciar búferes y terminar. Con la forma shell, el SIGTERM lo recibe sh, que no lo reenvía a sus hijos: node no se entera de nada, sigue trabajando, y a los 10 segundos lo mata un SIGKILL que no admite limpieza. Peticiones cortadas a la mitad y transacciones a medias.
Mídelo:
# Con forma exec (tu imagen actual)
docker run -d --name t-exec auroralibros/aurora-api:0.1.0
time docker stop t-exec# Con forma shell
printf 'FROM auroralibros/aurora-api:0.1.0\nCMD node server.js\n' > /tmp/Dockerfile.shell
docker build -q -t aurora-shell -f /tmp/Dockerfile.shell /tmp
docker run -d --name t-shell aurora-shell
time docker stop t-shell0,3 segundos frente a 10,2. Diez segundos de diferencia por unos corchetes, multiplicados por cada contenedor en cada despliegue. En un despliegue continuo con veinte réplicas (módulo 6), esa diferencia es la que separa un rollout limpio de uno con errores para los usuarios.
Limpia:
Regla: usa siempre la forma exec, con corchetes y comillas dobles. Es JSON: las comillas simples no valen.
Otras propiedades de CMD
- Solo la última cuenta. Si escribes varios
CMD, los anteriores se descartan sin aviso. - Se sobrescribe desde la línea de comandos. Todo lo que pongas tras el nombre de la imagen reemplaza al
CMD:
No ha arrancado el servidor: has sustituido el CMD por node --version. Esta flexibilidad es útil para depurar (docker run --rm -it tuimagen sh te da una shell dentro de la imagen) y es la base del patrón ENTRYPOINT + CMD de la lección 02-04.
- Si la imagen base tiene un
ENTRYPOINT, elCMDse convierte en sus argumentos. Es justo el tema de 02-04. - No uses
CMDpara varios procesos.CMD ["sh", "-c", "node server.js & nginx"]es un antipatrón: un contenedor, un proceso. Aurora Libros tiene cuatro servicios porque tendrá cuatro contenedores.
- El Dockerfile definitivo de
aurora-api
aurora-apiYa puedes justificar cada línea. Este es el Dockerfile final del módulo, que sustituye al mínimo de la lección 02-02. Guárdalo en ~/aurora-libros/api/Dockerfile:
# syntax=docker/dockerfile:1
# -----------------------------------------------------------------------------
# Imagen de aurora-api · API REST del catálogo de Aurora Libros S.L.
# Construir con: docker build -t auroralibros/aurora-api:1.0.0 .
# -----------------------------------------------------------------------------
# 1. Imagen base oficial: Node 22 (exigido por engines) sobre Alpine (~142 MB
# frente a ~1,1 GB de node:22). Etiqueta con la versión mayor fijada para
# recibir parches sin saltos de versión inesperados.
FROM node:22-alpine
# 2. Directorio de trabajo. Se crea si no existe y persiste en ejecución:
# es donde aterriza 'docker exec -it aurora-api sh'.
WORKDIR /app
# 3. Solo los manifiestos de dependencias. Al ir ANTES del código, la capa del
# npm ci se reutiliza mientras package.json y el lock no cambien.
# El comodín cubre package.json y package-lock.json en una sola instrucción.
COPY package*.json ./
# 4. Instalación reproducible:
# npm ci -> versiones EXACTAS del lock; falla si no concuerda
# --omit=dev -> fuera las dependencias de desarrollo
# npm cache clean -> libera ~50 MB de caché EN LA MISMA CAPA, que es la
# única forma de que el borrado ahorre espacio de verdad
RUN npm ci --omit=dev && npm cache clean --force
# 5. Ahora el código de la aplicación, lo más volátil. El .dockerignore ya ha
# dejado fuera node_modules/, .git/ y .env.
COPY . .
# 6. Configuración por defecto, sobrescribible con -e al ejecutar.
# NODE_ENV=production activa optimizaciones reales en Express.
# AQUÍ NO VA NINGÚN SECRETO: quedaría grabado en los metadatos de la imagen.
ENV NODE_ENV=production \
PORT=3000
# 7. Documenta que el servicio escucha en el 3000. NO publica el puerto:
# para eso sigue haciendo falta -p 3000:3000 en docker run.
EXPOSE 3000
# 8. Proceso principal, en forma exec para que node sea el PID 1 y reciba
# SIGTERM directamente: parada en 0,3 s en lugar de 10 s.
CMD ["node", "server.js"]Construye la versión 1.0.0, que es la que acompaña al version del package.json:
[+] Building 11.9s (11/11) FINISHED
=> [1/5] FROM docker.io/library/node:22-alpine@sha256:9f2c... 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 47.83kB 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package*.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 8.9s
=> [5/5] COPY . . 0.1s
=> exporting to image 0.6s
=> => naming to docker.io/auroralibros/aurora-api:1.0.0 0.0sLa demostración del acierto de caché
Cambia el código y reconstruye, que es lo que harás cincuenta veces al día:
[+] Building 1.2s (11/11) FINISHED
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] COPY package*.json ./ 0.0s
=> CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force 0.0s
=> [5/5] COPY . . 0.1s
=> exporting to image 0.5s1,2 segundos, con el npm ci intacto en la caché. El orden de las instrucciones —dependencias arriba, código abajo— hace exactamente lo que se diseñó.
Verifica la imagen terminada:
docker image ls auroralibros/aurora-api
docker image inspect auroralibros/aurora-api:1.0.0 \
--format 'WorkingDir: {{.Config.WorkingDir}}
Cmd: {{json .Config.Cmd}}
Env: {{json .Config.Env}}
Puertos: {{json .Config.ExposedPorts}}'REPOSITORY TAG IMAGE ID CREATED SIZE
auroralibros/aurora-api 1.0.0 8c1e4a7f2b9d 4 seconds ago 167MB
auroralibros/aurora-api 0.1.0 6b4d2f8e1a3c 22 minutes ago 167MB
WorkingDir: /app
Cmd: ["node","server.js"]
Env: ["PATH=...","NODE_VERSION=22.14.0","YARN_VERSION=1.22.22","NODE_ENV=production","PORT=3000"]
Puertos: {"3000/tcp":{}}Todos los metadatos que has ido declarando están ahí, legibles. Y ninguna credencial entre ellos, como marca la regla del proyecto. Última comprobación funcional:
docker run -d --name aurora-api-v1 -p 3000:3000 auroralibros/aurora-api:1.0.0
curl -s http://localhost:3000/salud
docker rm -f aurora-api-v1{"servicio":"aurora-api","version":"1.0.0","db":"ko","cache":"ko",
"errorDb":"connect ECONNREFUSED 127.0.0.1:5432","errorCache":"connect ECONNREFUSED 127.0.0.1:6379"}Igual que en 02-02: el servicio vive y responde; las dependencias siguen sin existir, y eso es el módulo 3.
Errores Comunes y Consejos
RUN cd /appesperando que persista. CadaRUNcorre en un contenedor temporal distinto. UsaWORKDIR.CMDen forma shell. Diez segundos extra en cada parada y señales que nunca llegan a tu proceso. Usa siempreCMD ["ejecutable", "arg"]con comillas dobles: es JSON, no Python.- Creer que
EXPOSEpublica el puerto. No publica nada.EXPOSEdocumenta;-ppublica. Si tu servicio "no responde" y endocker psno ves la flecha->en PORTS, ese es el problema. - Limpiar en un
RUNdistinto del que ensucia. El borrado no libera nada: la capa inferior conserva los ficheros. Encadena con&&en la misma instrucción. apt-get updateyapt-get installenRUNseparados. La caché reutiliza un índice viejo y elinstallfalla o instala versiones obsoletas. Siempre juntos.npm installen lugar denpm ci. Rompe la reproducibilidad: el mismo commit puede producir imágenes distintas. Y no olvides--omit=dev.ADDpor costumbre. Su comportamiento depende del tipo de fichero y esconde descargas remotas sin verificar. UsaCOPYsalvo que necesites descomprimir un tarball local.- Secretos en
ENV. Quedan en los metadatos, visibles condocker image inspectsin siquiera arrancar el contenedor, y viajan con la imagen a donde vaya. Nunca. COPY . .sin.dockerignore. Ya lo viste en 02-02: contexto hinchado, caché rota y riesgo de filtrar un.env.- Consejo: comenta el por qué, no el qué.
# Instala dependenciassobra: se ve.# El cache clean va aquí para que libere espacio de verdades lo que agradecerá quien lo lea dentro de seis meses. - Consejo: lee Dockerfiles ajenos. Los de las imágenes oficiales están en GitHub (lección 02-01) y son una escuela excelente.
Ejercicios
Ejercicio 1: demuestra que la limpieza tiene que ir en la misma capa
Construye dos imágenes basadas en node:22-alpine que instalen el paquete git con apk:
- Versión A:
apk add giten unRUNyrm -rf /var/cache/apk/*en otroRUNposterior. - Versión B: todo encadenado con
&&en un únicoRUN, usandoapk add --no-cache.
Compara los tamaños con docker image ls y usa docker image history para localizar la capa culpable de la diferencia. Explica el resultado en términos de capas y copy-on-write.
Ejercicio 2: mide el impacto de la forma del CMD
- Escribe dos Dockerfiles idénticos salvo el
CMD: uno en forma exec y otro en forma shell, ambos arrancandonode server.jsdesde la imagen de Aurora Libros. - Arranca un contenedor de cada uno.
- Con
docker exec <contenedor> ps -o pid,comm, comprueba qué proceso es el PID 1 en cada caso. - Cronometra
docker stopen los dos contime. - Explica por qué la diferencia es de exactamente unos 10 segundos y no de un valor arbitrario, y qué opción de
docker stoppermitiría cambiar esa espera.
Ejercicio 3: audita y corrige un Dockerfile
Este Dockerfile de la API de Aurora Libros tiene siete problemas relacionados con lo visto. Identifícalos, explica la consecuencia de cada uno y escribe la versión corregida.
FROM node:latest
ADD . /app
RUN cd /app
RUN npm install
RUN npm cache clean --force
ENV DB_PASSWORD=aurora2026
ENV NODE_ENV production
EXPOSE 3000
CMD node /app/server.jsSoluciones
Solución al ejercicio 1
mkdir -p /tmp/ej1 && cd /tmp/ej1
cat > Dockerfile.a <<'EOF'
FROM node:22-alpine
RUN apk add git
RUN rm -rf /var/cache/apk/*
EOF
cat > Dockerfile.b <<'EOF'
FROM node:22-alpine
RUN apk add --no-cache git
EOF
docker build -q -t ej1:a -f Dockerfile.a .
docker build -q -t ej1:b -f Dockerfile.b .
docker image ls ej1 --format "table {{.Tag}}\t{{.Size}}"Cuatro megas de diferencia (con paquetes más grandes, la diferencia llega a decenas o cientos). Localiza la capa culpable:
SIZE CREATED BY
0B RUN /bin/sh -c rm -rf /var/cache/apk/* # buildkit
19.2MB RUN /bin/sh -c apk add git # buildkitLa lectura es concluyente: la capa del rm -rf pesa 0 B. No ha liberado nada. Solo ha escrito marcadores de borrado (whiteouts) que ocultan los ficheros de la capa inferior, pero esa capa de 19,2 MB sigue en la imagen y se descarga entera en cada docker pull.
En la versión B, --no-cache hace que apk no escriba nunca el índice en disco, así que no hay nada que borrar. Es la aplicación directa del copy-on-write de la lección 01-05: una capa solo puede añadir contenido u ocultarlo, nunca reducir el tamaño de las capas anteriores. De ahí la regla: ensucia y limpia en la misma instrucción.
Solución al ejercicio 2
cd /tmp/ej1
printf 'FROM auroralibros/aurora-api:1.0.0\nCMD ["node", "server.js"]\n' > Dockerfile.exec
printf 'FROM auroralibros/aurora-api:1.0.0\nCMD node server.js\n' > Dockerfile.shell
docker build -q -t cmd:exec -f Dockerfile.exec .
docker build -q -t cmd:shell -f Dockerfile.shell .
docker run -d --name c-exec cmd:exec
docker run -d --name c-shell cmd:shell3. Los procesos:
Confirmado: en la forma exec node es el PID 1; en la forma shell el PID 1 es sh y node es su hijo con PID 7.
4. Los tiempos:
5. La diferencia es de ~10 segundos exactos porque ese es el valor del temporizador de gracia de docker stop: envía SIGTERM al PID 1, espera 10 segundos y, si el contenedor sigue vivo, envía SIGKILL.
- En la forma exec,
noderecibe el SIGTERM. Aunque esteserver.jsno instale un manejador explícito, el comportamiento por defecto de Node ante SIGTERM es terminar de inmediato: 0,3 s. - En la forma shell,
shrecibe el SIGTERM y no lo reenvía a sus hijos (un shell POSIX mínimo no hace reenvío de señales).nodenunca se entera. Se agotan los 10 segundos y llega el SIGKILL, que el proceso no puede capturar: cierre abrupto, conexiones cortadas, sin oportunidad de vaciar búferes ni cerrar el pool de PostgreSQL.
El plazo se puede ajustar con docker stop -t <segundos>:
docker rm -f c-shell 2>/dev/null; docker run -d --name c-shell cmd:shell
time docker stop -t 2 c-shell # ~2 s: solo acorta la agonía, no arregla la causaBajar el plazo no resuelve nada: el proceso sigue sin recibir la señal. La solución es la forma exec, o un ENTRYPOINT con un script que use exec "$@" (lección 02-04). Limpieza:
Solución al ejercicio 3
Los siete problemas:
| # | Línea | Problema | Consecuencia |
|---|---|---|---|
| 1 | FROM node:latest |
Etiqueta latest y variante completa |
Versión mayor impredecible (puede saltar a Node 24 sin avisar) e imagen de ~1,1 GB en lugar de ~142 MB |
| 2 | ADD . /app |
ADD donde basta COPY |
Comportamiento dependiente del tipo de fichero; sin justificación aquí |
| 3 | RUN cd /app |
cd en un RUN propio |
No tiene ningún efecto: el npm install siguiente se ejecuta en / y falla |
| 4 | RUN npm install |
Sin ci ni --omit=dev, y después del ADD . |
No reproducible, incluye dependencias de desarrollo y la caché se invalida con cada cambio de código |
| 5 | RUN npm cache clean en línea aparte |
Limpieza en otra capa | No libera un solo byte |
| 6 | ENV DB_PASSWORD=aurora2026 |
Secreto en la imagen | Visible con docker image inspect; catastrófico en un repositorio público |
| 7 | CMD node /app/server.js |
Forma shell | PID 1 = sh, SIGTERM no llega a node, 10 s de espera y SIGKILL en cada parada |
Un octavo detalle menor: ENV NODE_ENV production usa la sintaxis antigua sin =, desaconsejada por ambigua.
Versión corregida:
# syntax=docker/dockerfile:1
# (1) Base oficial ligera con la versión mayor fijada
FROM node:22-alpine
# (3) WORKDIR en lugar de RUN cd: persiste y crea el directorio
WORKDIR /app
# (4) Dependencias antes que código, para preservar la caché
COPY package*.json ./
# (4)(5) Instalación reproducible y limpieza EN LA MISMA CAPA
RUN npm ci --omit=dev && npm cache clean --force
# (2) COPY en lugar de ADD, y después de las dependencias
COPY . .
# (6) Sin secretos. (8) Sintaxis con '='
ENV NODE_ENV=production \
PORT=3000
EXPOSE 3000
# (7) Forma exec: node es PID 1 y recibe SIGTERM
CMD ["node", "server.js"]La contraseña se inyecta al ejecutar, nunca al construir:
docker run -d --name aurora-api \
-p 3000:3000 \
-e DB_HOST=aurora-db \
-e DB_PASSWORD=aurora2026 \
auroralibros/aurora-api:1.0.0Y en el módulo 4 ni siquiera irá en la línea de comandos, sino en un fichero .env fuera del control de versiones (lección 04-05) o en un secreto gestionado (lección 05-03).
Conclusión
Ya dominas el lenguaje del Dockerfile. Sabes que la directiva # syntax=docker/dockerfile:1 te da el intérprete más reciente sin actualizar Docker, y que de las ocho instrucciones básicas solo COPY, ADD y RUN engordan la imagen: las demás escriben metadatos que no pesan nada. FROM fija el punto de partida y el compromiso entre reproducibilidad y parches automáticos. WORKDIR sustituye a un RUN cd que nunca funcionaría, porque cada RUN vive en un contenedor temporal distinto, y además persiste en ejecución. COPY resuelve sus rutas contra el contexto y copia el contenido de los directorios; ADD añade descompresión y descargas con un comportamiento variable que la hace peor opción salvo para tarballs locales.
Con RUN has visto la regla más rentable de todas: limpia en la misma capa donde ensucias, porque una capa solo puede añadir u ocultar, nunca adelgazar a las anteriores —48 MB de diferencia en la demostración con python3—. Con ENV has distinguido lo que es un valor por defecto legítimo (NODE_ENV, PORT) de lo que jamás debe entrar en una imagen (DB_PASSWORD), y lo has comprobado leyendo los metadatos con docker image inspect. Con EXPOSE has desmontado la trampa del nombre: documenta, no publica, y la prueba es la ausencia de flecha -> en docker ps. Y con CMD has medido en carne propia que la forma exec y la forma shell no son estilos alternativos: son 0,3 segundos frente a 10,2 al parar el contenedor, porque el PID 1 es node o es sh, y sh no reenvía SIGTERM.
El resultado es auroralibros/aurora-api:1.0.0: 167 MB, comentada línea a línea, con cada decisión justificada, que se reconstruye en 1,2 segundos tras un cambio de código y que no lleva un solo secreto dentro. Es un Dockerfile que funciona.
En la siguiente lección, Instrucciones Avanzadas del Dockerfile, lo convertirás en uno profesional. Aprenderás a parametrizar la versión base con ARG y a distinguirlo de ENV, a separar el ejecutable fijo de sus argumentos con ENTRYPOINT y CMD combinados, a dejar de ejecutar la API como root con USER, a describir la imagen con etiquetas OCI mediante LABEL, y a que Docker vigile por su cuenta el endpoint /salud con HEALTHCHECK para que docker ps muestre healthy o unhealthy. Ese es el salto entre una imagen que arranca y una imagen que puedes poner en producción sin sonrojarte.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
