Saltar al contenido principal

Entendiendo el Dockerfile

En la unidad anterior construimos tastematch:1.0 y tastematch:1.1 con este Dockerfile:

FROM node:22

WORKDIR /app

COPY . .

RUN npm install

CMD ["npm", "start"]

Funcionaba, pero ahora necesitamos algo más que repetirlo. Intenta explicar cada línea sin mirar la unidad anterior:

  • ¿de dónde parte la imagen?;
  • ¿dónde quedan los archivos?;
  • ¿qué se copia?;
  • ¿qué sucede durante la construcción?;
  • ¿qué sucederá al iniciar un contenedor?

Un Dockerfile describe paso a paso cómo queremos construir una imagen y qué comportamiento tendrá por defecto el contenedor creado a partir de ella. Vamos a leer el nuestro con más precisión y a evolucionarlo de forma razonada.

Elegir el punto de partida con FROM​

TasteMatch está escrita para Node.js. Esta primera línea selecciona una imagen que ya proporciona ese runtime y herramientas como npm:

FROM node:22

node es el nombre de la imagen y 22 es su tag. El tag forma parte de la referencia: cambiarlo puede seleccionar otra variante o versión de la base.

node:22
├── filesystem de la imagen base
├── Node.js
└── npm
↓
añadimos TasteMatch
↓
nuestra imagen

No empezamos desde un sistema vacío. La imagen base puede aportar el runtime, herramientas y archivos que nuestra aplicación necesita. Elegirla es una decisión real del Dockerfile; en este recorrido conservaremos node:22 sin comparar tamaños ni variantes avanzadas.

Pregunta: si sustituyéramos node:22 por una imagen que no contiene Node.js, ¿qué ocurriría al intentar ejecutar npm install? El resto del Dockerfile seguiría escrito, pero faltaría una herramienta de la que depende.

Establecer el lugar de trabajo con WORKDIR​

La siguiente instrucción establece un directorio de trabajo dentro de la imagen:

WORKDIR /app

A partir de ahí, las rutas relativas de instrucciones posteriores se interpretan respecto a /app cuando corresponda. También será el directorio de trabajo inicial del proceso del contenedor.

Por eso podemos escribir:

WORKDIR /app
COPY package.json .

El destino . representa aquí /app. El resultado que buscamos es:

/
└── app
└── package.json

Por qué RUN cd /app no lo sustituye​

Podríamos encontrar algo así:

RUN cd /app
RUN npm install

El primer RUN ejecuta un comando durante un paso de construcción; no establece el directorio de trabajo para las instrucciones siguientes. Además, no expresa con claridad dónde queremos trabajar a partir de ese punto.

En cambio:

WORKDIR /app
RUN npm install

declara esa intención directamente. No necesitamos estudiar ahora el filesystem completo: basta con distinguir entre cambiar de directorio dentro de un comando y establecer el directorio de trabajo del Dockerfile.

Elegir qué archivos introducir con COPY​

En nuestra primera imagen copiamos todo lo disponible de una vez:

COPY . .

COPY recibe un origen y un destino:

COPY <origen> <destino>

En el ejemplo:

  • el primer . se refiere al origen disponible para la construcción;
  • el segundo . se refiere al destino relativo al WORKDIR, /app.

También podemos ser más específicos:

COPY package.json .
COPY app.js .

O, en un proyecto con una carpeta src:

COPY src/ ./src/

La barra final ayuda a leer que copiamos el contenido de un directorio a otro directorio. Las rutas concretas deben corresponder a la estructura real del proyecto.

No siempre interesa copiar todo. Una imagen necesita los archivos que permiten construir y ejecutar la aplicación, no cualquier nota o material que casualmente esté junto al código. En la siguiente unidad estudiaremos con detalle qué archivos están disponibles durante el build y cómo excluir los que no correspondan; aquí solo queremos tomar la decisión de copia de forma consciente.

COPY y ADD: reconocer la intención​

También puedes encontrar esta instrucción en un Dockerfile:

ADD app.js /app/app.js

ADD puede incorporar archivos y dispone de comportamientos adicionales. No necesitamos recorrerlos ahora. Si nuestra única necesidad es copiar un archivo o directorio local, normalmente COPY expresa mejor lo que queremos hacer:

COPY app.js /app/app.js

La regla útil en este nivel es sencilla:

Si solo necesitas copiar archivos, utiliza COPY.

Esto no significa que ADD sea inválido; significa que no debemos elegir una instrucción más amplia sin necesitar sus otros comportamientos.

Preparar la imagen con RUN​

TasteMatch declara Express en package.json, pero copiar ese archivo no instala la dependencia. Esta instrucción realiza la preparación:

RUN npm install

RUN ejecuta un comando durante docker build. El resultado pasa a formar parte de la imagen que estamos construyendo.

docker build
↓
encuentra RUN npm install
↓
ejecuta npm install durante el build
↓
imagen con las dependencias preparadas

Otro proyecto podría necesitar otro comando de preparación, por ejemplo generar archivos antes de ejecutarse. No añadimos comandos porque sí: cada RUN debe responder a una necesidad de la aplicación.

Comprobación mental: cuando termina RUN npm install, ¿TasteMatch queda atendiendo peticiones en el puerto 3000? No. Hemos preparado la imagen, pero todavía no hemos iniciado un contenedor.

Definir el comportamiento por defecto con CMD​

Al iniciar un contenedor de TasteMatch queremos ejecutar el script start:

CMD ["npm", "start"]

CMD define el comando o los argumentos que se utilizarán por defecto al iniciar un contenedor. Actúa en la fase de ejecución, no durante el build.

docker build                 docker run
↓ ↓
construye la imagen crea el contenedor
↓
utiliza CMD por defecto

Decimos por defecto porque quien ejecuta la imagen puede proporcionar otro comando. Por ejemplo, esta ejecución sustituye el CMD de TasteMatch por un comando que existe en la imagen base:

docker run --rm tastematch:1.1 npm --version

Ese contenedor muestra la versión de npm y termina; no inicia el servidor. No hemos modificado la imagen, solo hemos elegido otro comando para esa ejecución.

Un Dockerfile puede tener varias instrucciones RUN, porque puede necesitar varios pasos de preparación. En cambio, debe expresar con claridad cuál será su comportamiento por defecto al iniciar el contenedor.

Fijar un ejecutable principal con ENTRYPOINT​

ENTRYPOINT también interviene al iniciar el contenedor, pero no es simplemente otro nombre para CMD. Permite declarar cuál es el ejecutable principal de una imagen.

Imagina una herramienta pequeña, tool.js, que recibe una categoría de recetas:

const category = process.argv[2] || "sin categoría";
console.log(`Buscando recetas de: ${category}`);

Podríamos diseñar su Dockerfile así:

FROM node:22

WORKDIR /app

COPY tool.js .

ENTRYPOINT ["node", "tool.js"]

CMD ["rápidas"]

En este diseño:

ENTRYPOINT ["node", "tool.js"]
→ ejecutable principal

CMD ["rápidas"]
→ argumento por defecto

Si la imagen se llama buscador-recetas, al ejecutar:

docker run --rm buscador-recetas

el diseño combina el ejecutable con el argumento predeterminado:

node tool.js rápidas

Y si proporcionamos otro argumento:

docker run --rm buscador-recetas postres

se utiliza postres en lugar del valor predeterminado:

node tool.js postres

La interacción exacta entre ENTRYPOINT y CMD depende de cómo se declaren. Para este primer modelo quédate con la intención: ENTRYPOINT fija el programa principal y CMD puede aportar valores por defecto. No todas las imágenes necesitan utilizar ambos.

RUN, CMD y ENTRYPOINT no actúan igual​

Estas tres instrucciones pueden parecer relacionadas porque contienen algo ejecutable, pero pertenecen a momentos e intenciones diferentes:

InstrucciónMomento principalIdea
RUNDurante docker buildPreparar o modificar la imagen.
CMDAl iniciar el contenedorProporcionar comando o argumentos por defecto.
ENTRYPOINTAl iniciar el contenedorDefinir el ejecutable principal.

Compara dos aplicaciones:

# Aplicación web
RUN npm install
CMD ["npm", "start"]
# Herramienta con argumentos
ENTRYPOINT ["node", "tool.js"]
CMD ["rápidas"]

En la aplicación web, RUN instala dependencias al construir y CMD inicia el servidor por defecto. En la herramienta, ENTRYPOINT fija el programa y CMD proporciona un argumento que el usuario puede cambiar.

RUN ≠ CMD
CMD ≠ ENTRYPOINT

Antes de añadir cualquiera de ellas, pregunta: ¿estoy preparando la imagen o describiendo qué debe pasar al iniciar el contenedor?

Dos formas de escribir instrucciones ejecutables​

Puedes encontrar una forma con una lista JSON:

CMD ["npm", "start"]

Esta es la forma exec. Cada elemento está separado explícitamente: el ejecutable es npm y su argumento es start.

También existe la forma shell:

CMD npm start

No se ejecutan exactamente igual. La forma shell utiliza una shell implícita, mientras que la forma exec inicia directamente el ejecutable indicado cuando la instrucción lo permite.

En nuestro recorrido preferiremos la forma exec para CMD y ENTRYPOINT cuando sea adecuada porque hace explícitos el ejecutable y sus argumentos:

ENTRYPOINT ["node", "tool.js"]
CMD ["rápidas"]

No necesitamos entrar todavía en señales del sistema, PID 1 ni gestión avanzada de procesos. En esta unidad basta con reconocer ambas formas y saber que no son dos escrituras idénticas.

Añadir configuración base no sensible con ENV​

Podemos definir una variable de entorno en la imagen:

ENV APP_ENV=production

La variable estará disponible para instrucciones posteriores y para los contenedores creados desde la imagen. Para que cambie el comportamiento de TasteMatch, su código tendría que consultar APP_ENV; definirla no obliga a la aplicación a utilizarla.

En esta unidad solo la emplearemos como configuración base no sensible. Más adelante aprenderemos a proporcionar configuración al ejecutar un contenedor y a reutilizar una imagen en entornos diferentes.

Nunca debemos incrustar credenciales en una imagen de esta manera:

# No hagas esto
ENV PASSWORD=mi_password
ENV API_TOKEN=token_secreto

Una imagen puede distribuirse, inspeccionarse o conservarse en distintos equipos. ENV no es un almacén de secretos y cambiar el nombre del valor no lo protege.

Documentar el puerto con EXPOSE​

TasteMatch escucha en el puerto 3000 del contenedor. Podemos expresar esa intención en el Dockerfile:

EXPOSE 3000

EXPOSE documenta el puerto previsto por la imagen. No publica automáticamente ese puerto en el host y no hace que una aplicación empiece a escuchar.

Compara las dos piezas:

EXPOSE 3000
→ la imagen documenta que la aplicación utiliza el puerto 3000

docker run -p 8080:3000 ...
→ al crear el contenedor, publica su puerto 3000 en el 8080 del host

Por tanto:

EXPOSE 3000 ≠ -p 8080:3000

EXPOSE tampoco es obligatorio para que -p funcione. La aplicación debe escuchar realmente en el puerto interno correcto y la publicación se configura al ejecutar el contenedor, como ya comprobaste en Docker 0.

Evolucionar el Dockerfile de TasteMatch​

Ya podemos reorganizar la lectura del Dockerfile alrededor de las necesidades de la aplicación. Partimos de la base y del directorio de trabajo:

FROM node:22

WORKDIR /app

Copiamos el archivo que declara la dependencia y la instalamos:

COPY package.json .
RUN npm install

Después introducimos el resto de los archivos de TasteMatch:

COPY . .

Finalmente añadimos configuración base no sensible, documentamos el puerto y declaramos el comando de inicio:

ENV APP_ENV=production

EXPOSE 3000

CMD ["npm", "start"]

El resultado completo es:

FROM node:22

WORKDIR /app

COPY package.json .
RUN npm install

COPY . .

ENV APP_ENV=production

EXPOSE 3000

CMD ["npm", "start"]

Ahora puedes leerlo de arriba abajo:

  1. parte de una imagen con Node.js;
  2. trabaja dentro de /app;
  3. introduce el manifiesto e instala la dependencia;
  4. copia los demás archivos de la aplicación;
  5. define una variable no sensible;
  6. documenta el puerto interno 3000;
  7. inicia TasteMatch mediante npm start por defecto.

Este Dockerfile es apropiado para estudiar estas instrucciones. No afirmamos que sea una plantilla universal, que esté optimizado o que esté listo para cualquier entorno de producción. El orden también interviene en cómo se construye una imagen; estudiaremos ese proceso en la siguiente unidad.

Construir y comprobar TasteMatch 2.0​

Sustituye el Dockerfile anterior de la carpeta tastematch por el evolucionado y construye una imagen nueva:

docker build -t tastematch:2.0 .

Comprueba que existe:

docker image ls tastematch

Usaremos un nombre y un puerto del host nuevos para no confundir esta práctica con los contenedores anteriores:

docker run -d --name tastematch-2-0 -p 8080:3000 tastematch:2.0

Comprueba estado y logs:

docker ps
docker logs tastematch-2-0

Abre http://localhost:8080. Debe aparecer el último mensaje que dejaste en app.js:

TasteMatch: mi primera aplicación contenerizada

Observa dos hechos:

  • la aplicación funciona porque CMD inicia el proceso previsto;
  • la URL del host funciona porque usamos -p, no porque exista EXPOSE.

Cuando termines, retira únicamente el contenedor de esta práctica y conserva la imagen para poder inspeccionar tus decisiones:

docker stop tastematch-2-0
docker rm tastematch-2-0
docker ps -a
docker image ls tastematch

Practicar la lectura de Dockerfiles​

No siempre escribirás el Dockerfile desde cero. Lee los siguientes ejemplos sin ejecutarlos y responde para cada uno:

  1. ¿Qué imagen base utiliza?
  2. ¿Qué directorio de trabajo establece?
  3. ¿Qué archivos copia y dónde?
  4. ¿Qué ocurre durante el build?
  5. ¿Qué ocurrirá por defecto al iniciar un contenedor?
  6. ¿Documenta algún puerto?
  7. ¿Aparece alguna instrucción que todavía no conocemos?

Dockerfile A: servicio Node.js​

FROM node:22
WORKDIR /service
COPY package.json .
RUN npm install
COPY server.js .
EXPOSE 4000
CMD ["node", "server.js"]

Después de leerlo, explica por qué RUN npm install no inicia el servicio y por qué EXPOSE 4000 no permite por sí solo entrar desde localhost:4000.

Dockerfile B: herramienta de texto​

FROM node:22
WORKDIR /tool
COPY formatter.js .
ENTRYPOINT ["node", "formatter.js"]
CMD ["resumen"]

Predice el programa y argumento utilizados al ejecutar la imagen sin añadir nada. Después predice qué cambiaría al proporcionar detalle al final de docker run.

Dockerfile C: una instrucción desconocida​

FROM node:22
WORKDIR /app
COPY app.js .
USER node
CMD ["node", "app.js"]

Puedes explicar casi todo el archivo aunque USER todavía no forme parte de este recorrido. Señálala como desconocida y busca su documentación cuando realmente necesites comprenderla; no inventes su significado a partir del nombre.

La capacidad de lectura consiste en separar lo que sabes, lo que puedes deducir con evidencia y lo que necesitas investigar.

Errores habituales al escribir un Dockerfile​

Utilizar RUN para iniciar la aplicación​

Este Dockerfile intenta iniciar el servidor durante la construcción:

RUN npm start

RUN pertenece al build. Para expresar el comportamiento por defecto del contenedor usamos CMD en el diseño de TasteMatch:

CMD ["npm", "start"]

Pensar que CMD ocurre durante el build​

docker build registra el comportamiento indicado por CMD, pero no inicia el servidor para dejarlo atendiendo peticiones. El comando se utiliza al crear e iniciar un contenedor.

Pensar que EXPOSE publica el puerto​

Aunque el Dockerfile contenga:

EXPOSE 3000

seguimos necesitando una publicación como -p 8080:3000 para acceder desde ese puerto del host. EXPOSE documenta; -p publica.

Utilizar ADD para cualquier copia​

Si solo queremos introducir app.js, esta instrucción expresa la intención con precisión:

COPY app.js .

No necesitamos elegir ADD por costumbre cuando no utilizamos sus otros comportamientos.

Copiar sin pensar qué necesita la imagen​

COPY . . resulta cómodo, pero puede introducir elementos que la aplicación no necesita. Antes de copiar, identifica los archivos de ejecución y preparación. En la siguiente unidad resolveremos de forma práctica cómo controlar qué participa en la construcción.

Guardar secretos con ENV​

No conviertas una credencial en parte de la imagen:

# Incorrecto: no incrustes secretos
ENV DATABASE_PASSWORD=supersecreto

Eliminar después la línea o cambiar el valor no convierte ese diseño en una gestión segura. En otra unidad separaremos la imagen de su configuración de ejecución; por ahora, utiliza ENV únicamente con valores no sensibles.

Mini reto: completa y explica antes de construir​

Recibes una aplicación web pequeña con esta estructura:

panel-web/
├── package.json
└── server.js

package.json declara Express y contiene:

{
"name": "panel-web",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.1.0"
}
}

server.js escucha en 0.0.0.0:4000, escribe Panel web escucha en el puerto 4000 al iniciar y responde Panel web preparado en /.

Crea server.js con este contenido:

const express = require("express");
const app = express();

app.get("/", (_request, response) => {
response.send("Panel web preparado");
});

app.listen(4000, "0.0.0.0", () => {
console.log("Panel web escucha en el puerto 4000");
});

Completa este Dockerfile sin buscar una plantilla terminada:

FROM ______

WORKDIR ______

COPY ______

RUN ______

COPY ______

EXPOSE ______

CMD ______

Antes de construir, entrega una explicación línea por línea:

  • qué aporta la imagen base;
  • dónde trabajará la aplicación;
  • qué archivos necesita y dónde se copian;
  • qué ocurre durante el build;
  • qué comando se utiliza al iniciar;
  • qué puerto documenta la imagen.

Después:

  1. construye una imagen llamada panel-web:1.0;
  2. comprueba que existe;
  3. crea panel-web-1-0 y publica el puerto interno 4000 en un puerto libre del host;
  4. comprueba estado, logs y respuesta HTTP;
  5. explica por qué RUN y CMD no son intercambiables;
  6. demuestra, mediante el comando utilizado, qué pieza publicó realmente el puerto;
  7. retira únicamente el contenedor del reto.

Como ampliación, crea una herramienta independiente que reciba una palabra como argumento. Diseña su Dockerfile con un ENTRYPOINT apropiado y un argumento predeterminado mediante CMD. Explica qué cambia cuando proporcionas otra palabra al ejecutar la imagen.

Ya puedes leer el recorrido completo​

Un Dockerfile ha dejado de ser una receta opaca. Ahora puedes preguntarte, ante cada instrucción:

¿De dónde parto?                 → FROM
¿Dónde trabajo? → WORKDIR
¿Qué archivos introduzco? → COPY
¿Qué preparo durante el build? → RUN
¿Qué configuración base añado? → ENV
¿Qué puerto documento? → EXPOSE
¿Qué se ejecuta por defecto? → CMD
¿Cuál es el programa principal? → ENTRYPOINT

También puedes separar los dos momentos principales:

Construcción de la imagen              Inicio del contenedor
FROM · WORKDIR · COPY · RUN CMD · ENTRYPOINT

En la siguiente unidad observaremos con más detalle cómo Docker procesa estas instrucciones durante la construcción. Estudiaremos qué resultados puede reutilizar, qué archivos intervienen y por qué el orden del Dockerfile puede cambiar el trabajo necesario.