El Dockerfile con el que cerraste la lección anterior funciona: construye auroralibros/aurora-api:1.0.0, arranca Express en un contenedor y responde a curl. Pero le faltan las piezas que separan una imagen que funciona de una imagen que puedes poner en producción sin sonrojarte. Ahora mismo, la API se ejecuta como root dentro del contenedor, nadie sabe quién la construyó ni desde qué commit, Docker no tiene forma de saber si el proceso está vivo pero atascado, la versión de Node está escrita a fuego en el FROM, y no puedes ejecutar la imagen con un comando distinto sin perder por completo su comportamiento por defecto. Esta lección resuelve las cinco cosas. Vas a aprender ARG y su diferencia crítica con ENV, el binomio ENTRYPOINT + CMD con sus cuatro combinaciones, USER para dejar de ser root, LABEL con el estándar OCI, HEALTHCHECK contra el endpoint /salud que preparaste en la lección 01-07, y las tres instrucciones menores —VOLUME, STOPSIGNAL, ONBUILD y SHELL— que conviene conocer aunque uses poco. Al final tendrás el Dockerfile profesional de aurora-api.

Contenido

  1. ARG frente a ENV: ámbito y momento
  2. ARG antes del primer FROM
  3. ENTRYPOINT frente a CMD: las cuatro combinaciones
  4. El patrón docker-entrypoint.sh y por qué importa exec "$@"
  5. USER: dejar de ejecutar como root
  6. LABEL y las etiquetas estándar OCI
  7. HEALTHCHECK: que Docker vigile tu servicio
  8. VOLUME y por qué declararlo suele ser mala idea
  9. STOPSIGNAL y el manejo de señales
  10. ONBUILD y SHELL
  11. El Dockerfile profesional de aurora-api

  1. ARG frente a ENV: ámbito y momento

Las dos definen variables. La confusión entre ellas es constante, y la diferencia es sencilla de enunciar: ARG existe solo durante la construcción; ENV existe también dentro del contenedor en ejecución.

Aspecto ARG ENV
Momento en que existe Solo durante docker build Durante la build y en ejecución
Visible dentro del contenedor No Sí (printenv, process.env)
Se define desde fuera con --build-arg CLAVE=valor -e CLAVE=valor en docker run
Valor por defecto ARG CLAVE=valor ENV CLAVE=valor
Sobrescribible al ejecutar No existe en ejecución Sí, con -e
¿Aparece en docker image history? Sí, su valor Sí, su valor
Uso típico Versiones, rutas de build, metadatos de CI Configuración por defecto de la aplicación

Un ejemplo que lo demuestra todo. Crea /tmp/arg-env/Dockerfile:

# syntax=docker/dockerfile:1
FROM alpine:3.21

# Argumento de construcción, con valor por defecto
ARG SALUDO_BUILD=hola-desde-la-build

# Variable de entorno, con valor por defecto
ENV SALUDO_RUN=hola-desde-la-ejecucion

# Durante la build, AMBAS están disponibles
RUN echo "BUILD ve ARG: $SALUDO_BUILD" && echo "BUILD ve ENV: $SALUDO_RUN"

CMD ["sh", "-c", "echo \"RUN ve ARG: [$SALUDO_BUILD]\"; echo \"RUN ve ENV: [$SALUDO_RUN]\""]
cd /tmp/arg-env
docker build --progress=plain --no-cache -t argenv . 2>&1 | grep "BUILD ve"
#5 0.153 BUILD ve ARG: hola-desde-la-build
#5 0.154 BUILD ve ENV: hola-desde-la-ejecucion

Durante la construcción, las dos están disponibles. Ahora ejecuta:

docker run --rm argenv
RUN ve ARG: []
RUN ve ENV: [hola-desde-la-ejecucion]

El ARG está vacío. No es que valga otra cosa: no existe. Se evaporó cuando terminó la build. El ENV sigue ahí.

Y así se pasa un valor desde fuera:

docker build --build-arg SALUDO_BUILD=valor-inyectado --progress=plain --no-cache -t argenv . 2>&1 | grep "BUILD ve ARG"
#5 0.148 BUILD ve ARG: valor-inyectado

El puente entre ambas

El patrón más útil es combinarlas: un ARG que alimenta un ENV.

ARG VERSION_APP=1.0.0
ENV APP_VERSION=${VERSION_APP}

Así, --build-arg VERSION_APP=1.2.3 en la construcción acaba siendo una variable de entorno consultable dentro del contenedor. Es como se inyectan números de versión y hashes de commit desde un pipeline de CI (lección 06-02).

La advertencia importante: ARG no es un secreto

Esto hay que grabárselo:

# ❌ NUNCA HAGAS ESTO
ARG DB_PASSWORD
RUN echo "conectando con $DB_PASSWORD" > /app/config.txt
docker build --build-arg DB_PASSWORD=superSecreta2026 -t filtrada .
docker image history filtrada --no-trunc | head -5
CREATED BY
|1 DB_PASSWORD=superSecreta2026 /bin/sh -c echo "conectando con $DB_PASSWORD" > /app/config.txt

Ahí está la contraseña, en claro, en el historial de la imagen. Cualquiera que descargue la imagen puede leerla con un solo comando, sin arrancar nada. Y publicando en el repositorio público auroralibros/aurora-api, en Internet.

Es la misma regla que arrastras desde la lección 01-07, ahora con una vía de fuga adicional: no solo ENV filtra secretos, también ARG. Para pasar credenciales a una build de forma segura existen los montajes de secretos de BuildKit (RUN --mount=type=secret), que no dejan rastro en ninguna capa; se estudian en la lección 05-05. Para Aurora Libros, la regla se mantiene intacta: las credenciales no entran en la imagen ni al construir ni al ejecutar; se inyectan al arrancar el contenedor.

  1. ARG antes del primer FROM

ARG tiene una peculiaridad de ámbito que sorprende a todo el mundo: un ARG declarado antes del primer FROM solo es visible en las líneas FROM, no dentro de la imagen.

# syntax=docker/dockerfile:1

# ARG "global": vive fuera de cualquier etapa. Solo lo ven los FROM.
ARG NODE_VERSION=22
ARG ALPINE_VERSION=3.21

FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}

# Aquí dentro, NODE_VERSION ya no existe salvo que se vuelva a declarar
ARG NODE_VERSION
RUN echo "Construyendo sobre Node ${NODE_VERSION}"

Esa doble declaración (ARG NODE_VERSION sin valor, dentro de la etapa) es la forma de "reimportar" el argumento global. Sin ella, la variable estaría vacía dentro del RUN.

La utilidad práctica es enorme: parametrizar la versión base sin tocar el Dockerfile.

# Build por defecto: Node 22
docker build -t aurora-api:node22 .

# Probar la API con Node 23 sin editar nada
docker build --build-arg NODE_VERSION=23 -t aurora-api:node23 .

Casos de uso reales para Aurora Libros:

Escenario Comando
Probar la próxima versión mayor de Node antes de adoptarla --build-arg NODE_VERSION=23
Reproducir un bug en la versión antigua --build-arg NODE_VERSION=20
Matriz de compatibilidad en CI Un job por versión, mismo Dockerfile

Un aviso: parametrizar la base es cómodo, pero el valor por defecto debe ser el de producción. Si el ARG no tiene valor por defecto y alguien construye sin --build-arg, obtendrá node:-alpine, que no existe, y un error críptico de manifiesto.

  1. ENTRYPOINT frente a CMD: las cuatro combinaciones

CMD, que ya conoces, define qué se ejecuta al arrancar el contenedor y se descarta por completo si pasas un comando en docker run. ENTRYPOINT define un ejecutable que no se descarta: los argumentos de docker run se le añaden detrás.

La combinación de ambas produce cuatro escenarios. Esta tabla es la referencia que conviene tener a mano:

# Dockerfile docker run imagen ejecuta docker run imagen extra ejecuta
1 Solo CMD ["node","server.js"] node server.js extra (el CMD se descarta)
2 Solo ENTRYPOINT ["node","server.js"] node server.js node server.js extra
3 ENTRYPOINT ["node"] + CMD ["server.js"] node server.js node extra (el CMD se sustituye)
4 ENTRYPOINT ["node","server.js"] + CMD ["--puerto=3000"] node server.js --puerto=3000 node server.js extra

La regla que resume las cuatro filas: ENTRYPOINT es el ejecutable fijo; CMD son los argumentos por defecto, y son lo único que el usuario puede reemplazar.

Compruébalo con la imagen que ya tienes:

# Caso 1: solo CMD (tu imagen 1.0.0)
docker run --rm auroralibros/aurora-api:1.0.0 node --version
v22.14.0

El CMD ["node","server.js"] ha desaparecido: el servidor no arranca.

# Caso 3: ENTRYPOINT + CMD
printf 'FROM auroralibros/aurora-api:1.0.0\nENTRYPOINT ["node"]\nCMD ["server.js"]\n' > /tmp/Dockerfile.ep
docker build -q -t aurora-ep -f /tmp/Dockerfile.ep /tmp

docker run --rm aurora-ep --version
v22.14.0

Aquí --version ha sustituido a server.js, pero node sigue estando: se ha ejecutado node --version. El ejecutable es intocable.

El patrón recomendado

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

Ventajas frente a solo CMD:

  • La imagen tiene una identidad clara. Es "la imagen que ejecuta node", no "una imagen genérica".
  • Es autodocumentada. docker run tuimagen --help funciona sin más.
  • Evita accidentes. Nadie arranca por descuido un contenedor que no hace lo que la imagen promete.

Y su inconveniente principal: depurar cuesta más. Con solo CMD, un docker run --rm -it tuimagen sh te da una shell. Con ENTRYPOINT ["node"], ese comando intenta ejecutar node sh y falla. Para eso está --entrypoint:

docker run --rm -it --entrypoint sh aurora-ep
/app #

--entrypoint sustituye el ejecutable fijo. Ojo con un detalle que despista: --entrypoint acepta un solo valor, y todo lo que venga después del nombre de la imagen se le pasa como argumentos:

docker run --rm --entrypoint sh aurora-ep -c "ls /app && node --version"
node_modules
package-lock.json
package.json
server.js
v22.14.0

Formas shell y exec, otra vez

Todo lo aprendido en la lección 02-03 sobre CMD aplica igual a ENTRYPOINT, agravado:

ENTRYPOINT ["node", "server.js"]     # ✅ Forma exec
ENTRYPOINT node server.js            # ❌ Forma shell

Con ENTRYPOINT en forma shell pasan dos cosas malas, no una:

  1. El PID 1 es sh y no reenvía SIGTERM: los 10 segundos de espera y el SIGKILL de la lección anterior.
  2. CMD se ignora por completo, y los argumentos de docker run también. El ENTRYPOINT en forma shell se traga todo el mecanismo de argumentos.

Con ENTRYPOINT, la forma exec no es una recomendación: es obligatoria.

  1. El patrón docker-entrypoint.sh y por qué importa exec "$@"

Cuando el arranque necesita lógica —esperar a una dependencia, generar un fichero de configuración, aplicar migraciones—, el ENTRYPOINT apunta a un script.

Crea ~/aurora-libros/api/docker-entrypoint.sh:

#!/bin/sh
# Script de arranque de aurora-api
# Se ejecuta antes del proceso principal y le cede el control con exec "$@"

set -e   # Aborta ante el primer error: mejor no arrancar que arrancar mal

echo "[entrypoint] Iniciando aurora-api en modo ${NODE_ENV:-development}"

# Validación de configuración obligatoria: fallar pronto y con un mensaje claro
if [ -z "$DB_HOST" ]; then
  echo "[entrypoint] AVISO: DB_HOST no definida; se usará localhost"
fi

# Espera activa a que la base de datos acepte conexiones.
# Evita el clásico ECONNREFUSED cuando la API arranca antes que PostgreSQL.
if [ -n "$DB_HOST" ] && [ "$ESPERAR_DB" = "true" ]; then
  echo "[entrypoint] Esperando a ${DB_HOST}:${DB_PORT:-5432}..."
  intentos=0
  until nc -z "$DB_HOST" "${DB_PORT:-5432}" 2>/dev/null; do
    intentos=$((intentos + 1))
    if [ "$intentos" -ge 30 ]; then
      echo "[entrypoint] ERROR: la base de datos no responde tras 30 intentos"
      exit 1
    fi
    sleep 1
  done
  echo "[entrypoint] Base de datos disponible tras ${intentos}s"
fi

echo "[entrypoint] Cediendo el control a: $@"

# LA LÍNEA CLAVE
exec "$@"

Y el Dockerfile lo incorpora así:

COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

El flujo completo:

sequenceDiagram
    participant D as docker run
    participant E as docker-entrypoint.sh (PID 1)
    participant N as node server.js

    D->>E: Arranca ENTRYPOINT con CMD como argumentos ($@)
    E->>E: set -e, comprueba variables
    E->>E: Espera a que DB_HOST responda
    E->>N: exec "$@" · REEMPLAZA el proceso
    Note over E,N: node hereda el PID 1;<br/>el script deja de existir
    D->>N: docker stop → SIGTERM llega DIRECTAMENTE a node
    N->>N: Cierre limpio en 0,3 s

Por qué exec "$@" y no simplemente "$@"

Es la parte que hay que entender de verdad.

  • "$@" son todos los argumentos recibidos, cada uno entrecomillado por separado. Como el ENTRYPOINT es el script y el CMD es ["node","server.js"], "$@" vale node server.js. Las comillas son imprescindibles: sin ellas, un argumento con espacios se partiría en dos.
  • exec es lo esencial. Sin exec, el shell lanza un hijo y se queda esperando: el PID 1 sigue siendo el script, y estás exactamente en el escenario de la forma shell de la lección 02-03 —SIGTERM al script, que no lo reenvía, diez segundos y SIGKILL—. Con exec, el shell se reemplaza a sí mismo por node: mismo PID, mismos descriptores de fichero, el script desaparece del árbol de procesos y node pasa a ser el PID 1.

Compruébalo con el script en marcha:

docker run -d --name t-entry auroralibros/aurora-api:1.1.0
docker exec t-entry ps -o pid,comm
PID   COMMAND
    1 node

PID 1 es node, no sh. El script se ejecutó, hizo su trabajo y se apartó. Y por tanto:

time docker stop t-entry
real    0m0.291s

Si quitaras el exec, esa cifra sería 10 segundos. Un carácter de diferencia, cuatro letras, y todo el comportamiento de parada del contenedor cambia.

Nota práctica: el script usa nc (netcat), que en node:22-alpine no viene instalado. Habría que añadir RUN apk add --no-cache netcat-openbsd. En la versión final de esta lección se opta por no incluir la espera activa, porque Docker Compose resuelve las dependencias de arranque de forma más limpia con depends_on y condiciones de salud (lección 04-02). El patrón, sin embargo, conviene conocerlo: te lo encontrarás en las imágenes oficiales de PostgreSQL y MySQL, que lo usan justamente así.

  1. USER: dejar de ejecutar como root

Ahora mismo tu API se ejecuta como root dentro del contenedor. Compruébalo:

docker run --rm auroralibros/aurora-api:1.0.0 id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon)...

uid=0 es root. Un contenedor está aislado, pero ese aislamiento no es una muralla infranqueable: si aparece una vulnerabilidad de escape del runtime o montas mal un volumen, el root del contenedor puede convertirse en root del host. Ejecutar como usuario sin privilegios elimina esa clase entera de problemas por un coste de tres líneas.

La instrucción es USER, y afecta a todas las instrucciones posteriores del Dockerfile y al proceso del contenedor:

USER node
USER 1000
USER node:node
USER 1000:1000    # La forma más portable: numérica

Crear el usuario en Alpine

node:22-alpine ya trae un usuario node (uid 1000), así que bastaría USER node. Pero conviene saber crearlo, porque en otras bases no existe:

RUN addgroup -g 1001 -S aurora && \
    adduser  -u 1001 -S aurora -G aurora

Opciones de las herramientas de Alpine (BusyBox), distintas de las de Debian:

Opción Significado
-g 1001 / -u 1001 GID y UID explícitos. Fijarlos importa para que los permisos de volúmenes montados coincidan
-S System: cuenta de sistema, sin contraseña ni caducidad
-G aurora Grupo primario del usuario
-D (en adduser) Sin contraseña

En Debian/Ubuntu el equivalente sería groupadd -g 1001 aurora && useradd -u 1001 -g aurora -m -s /bin/sh aurora.

El orden correcto

Este es el punto donde falla todo el mundo:

# ❌ MAL: cambiar a USER antes de instalar
USER node
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev      # EACCES: permission denied
# ✅ BIEN: instalar como root, cambiar de usuario al final
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

La secuencia correcta es: instalar y copiar como root (que puede escribir donde quiera), asignar la propiedad en la misma operación de copia con --chown, y poner USER justo antes del CMD. Como viste en 02-03, --chown en el COPY evita un RUN chown -R posterior que duplicaría el tamaño de la capa por el copy-on-write.

Comprueba el resultado en la imagen final:

docker run --rm auroralibros/aurora-api:1.1.0 id
uid=1000(node) gid=1000(node) groups=1000(node)

La consecuencia inmediata: puertos privilegiados

Un usuario sin privilegios no puede escuchar en puertos por debajo del 1024. Si tu aplicación usara el puerto 80, dejaría de arrancar:

Error: listen EACCES: permission denied 0.0.0.0:80

Por eso aurora-api escucha en el 3000, y por eso el contenedor de Nginx del módulo 4 requerirá cuidado. La solución nunca es volver a root: es escuchar en un puerto alto y publicarlo donde haga falta con -p 80:3000, ya que el mapeo lo hace Docker en el host, no el proceso.

El endurecimiento completo de la seguridad en ejecución —capabilities, --read-only, no-new-privileges, perfiles seccomp, user namespaces— es la lección 05-03. Aquí te quedas con lo esencial: un contenedor de producción no se ejecuta como root.

  1. LABEL y las etiquetas estándar OCI

LABEL añade metadatos arbitrarios a la imagen en forma de pares clave-valor. No cambia el comportamiento, no pesa nada, y responde a preguntas que en producción son urgentes: ¿de qué commit salió esta imagen? ¿quién la mantiene? ¿de qué versión es?

LABEL clave="valor"
LABEL clave1="valor1" clave2="valor2"

Agrupa siempre varias etiquetas en una sola instrucción: cada LABEL crea una capa de metadatos.

El estándar OCI

Para que las herramientas puedan leer estos datos existe un vocabulario estándar, org.opencontainers.image.*, definido por la Open Container Initiative (la misma de la lección 01-03):

Etiqueta Contenido
org.opencontainers.image.title Nombre legible del componente
org.opencontainers.image.description Descripción breve
org.opencontainers.image.version Versión del software empaquetado
org.opencontainers.image.authors Responsables, con contacto
org.opencontainers.image.vendor Organización propietaria
org.opencontainers.image.licenses Licencia en formato SPDX
org.opencontainers.image.source URL del repositorio de código
org.opencontainers.image.documentation URL de la documentación
org.opencontainers.image.revision Hash del commit exacto
org.opencontainers.image.created Fecha de construcción (RFC 3339)
org.opencontainers.image.base.name Imagen base utilizada

Aplicadas a Aurora Libros, combinando ARG para lo que varía en cada build:

ARG VERSION=1.1.0
ARG REVISION=desconocida
ARG CREATED=desconocida

LABEL org.opencontainers.image.title="aurora-api" \
      org.opencontainers.image.description="API REST del catálogo de Aurora Libros S.L." \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.authors="[email protected]" \
      org.opencontainers.image.vendor="Aurora Libros S.L." \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
      org.opencontainers.image.documentation="https://github.com/auroralibros/aurora-libros/blob/main/README.md" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}" \
      org.opencontainers.image.base.name="docker.io/library/node:22-alpine"

Los tres primeros ARG se rellenan en la build, típicamente desde el pipeline:

docker build \
  --build-arg VERSION=1.1.0 \
  --build-arg REVISION="$(git rev-parse --short HEAD)" \
  --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t auroralibros/aurora-api:1.1.0 .

Y así se leen después:

docker image inspect auroralibros/aurora-api:1.1.0 \
  --format '{{range $k, $v := .Config.Labels}}{{$k}} = {{$v}}
{{end}}'
org.opencontainers.image.authors = [email protected]
org.opencontainers.image.base.name = docker.io/library/node:22-alpine
org.opencontainers.image.created = 2026-08-04T09:14:22Z
org.opencontainers.image.description = API REST del catálogo de Aurora Libros S.L.
org.opencontainers.image.licenses = MIT
org.opencontainers.image.revision = 7a3f912
org.opencontainers.image.source = https://github.com/auroralibros/aurora-libros
org.opencontainers.image.title = aurora-api
org.opencontainers.image.vendor = Aurora Libros S.L.
org.opencontainers.image.version = 1.1.0

El valor real de esto se ve a las tres de la mañana de un martes: producción falla, tienes un contenedor corriendo y necesitas saber exactamente qué código lleva dentro. Un docker inspect te da el commit 7a3f912, y con él el git log te cuenta todo. Sin esa etiqueta, empieza la arqueología.

Las etiquetas también sirven para filtrar, con la sintaxis de la lección 01-04:

docker image ls --filter "label=org.opencontainers.image.vendor=Aurora Libros S.L."

  1. HEALTHCHECK: que Docker vigile tu servicio

Un contenedor puede estar Up y ser completamente inútil: el proceso vive pero el bucle de eventos está bloqueado, el pool de conexiones agotado o la aplicación devolviendo 500 a todo. docker ps diría Up 3 hours tan tranquilo. HEALTHCHECK le da a Docker una forma de comprobar la salud real.

HEALTHCHECK [opciones] CMD <comando>
Opción Por defecto Qué controla
--interval 30s Cada cuánto se ejecuta la comprobación
--timeout 30s Cuánto se espera a que responda antes de darla por fallida
--start-period 0s Periodo de gracia inicial: los fallos aquí no cuentan
--start-interval 5s Intervalo durante el periodo de gracia (versiones recientes)
--retries 3 Fallos consecutivos necesarios para declarar unhealthy

El comando determina el estado por su código de salida: 0 = sano, 1 = enfermo. Cualquier otro valor se trata como error.

--start-period merece atención especial. Sin él, una aplicación que tarda 20 segundos en arrancar sería marcada unhealthy durante ese arranque legítimo, y en un orquestador la matarían y reiniciarían en bucle eterno. El periodo de gracia dice: "durante los primeros N segundos, si falla, no lo cuentes".

El healthcheck de aurora-api

Aurora Libros ya tiene el endpoint /salud desde la lección 01-07, diseñado precisamente para esto: devuelve 200 si la base de datos y la caché responden, y 503 si no.

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/salud || exit 1

Detalles de la elección del comando:

  • wget y no curl: node:22-alpine trae wget (de BusyBox) pero no curl. Usar curl obligaría a un apk add curl que añade unos 4 MB. Si prefieres curl, la opción es curl -f http://localhost:3000/salud || exit 1, donde -f hace que devuelva código de error ante un HTTP 4xx/5xx.
  • --spider: no descarga el cuerpo, solo comprueba que el recurso responde.
  • --tries=1: sin reintentos internos; ya reintenta Docker con --retries.
  • || exit 1: normaliza cualquier fallo al código 1, que es lo que Docker espera.
  • localhost: la comprobación se ejecuta dentro del contenedor, así que localhost es el propio servicio. No necesita puertos publicados.
  • Los tiempos: cada 30 s, con 3 s de paciencia, 10 s de gracia al arrancar y 3 fallos seguidos antes de declararlo enfermo. En el peor caso, un servicio caído se detecta en unos 90 segundos.

Verlo funcionar

docker run -d --name aurora-hc -p 3000:3000 auroralibros/aurora-api:1.1.0
docker ps --filter name=aurora-hc --format "table {{.Names}}\t{{.Status}}"

Recién arrancado:

NAMES        STATUS
aurora-hc    Up 3 seconds (health: starting)

health: starting es el periodo de gracia. Espera y vuelve a mirar:

sleep 40 && docker ps --filter name=aurora-hc --format "table {{.Names}}\t{{.Status}}"
NAMES        STATUS
aurora-hc    Up 43 seconds (unhealthy)

unhealthy, y con razón: /salud devuelve 503 porque no hay PostgreSQL ni Redis, exactamente como en las lecciones anteriores. El healthcheck está haciendo su trabajo con total precisión: el proceso vive, pero el servicio no está operativo. Justo la distinción que Up no sabe hacer.

El historial completo está en los metadatos:

docker inspect aurora-hc --format '{{json .State.Health}}' | python3 -m json.tool
{
    "Status": "unhealthy",
    "FailingStreak": 3,
    "Log": [
        {
            "Start": "2026-08-04T09:32:11.442Z",
            "End": "2026-08-04T09:32:11.503Z",
            "ExitCode": 1,
            "Output": ""
        }
    ]
}

FailingStreak: 3 son los tres fallos consecutivos que dispararon el cambio de estado. El campo Log guarda las últimas comprobaciones con su salida, que es donde miras cuando un healthcheck falla y no sabes por qué.

Los estados posibles:

Estado Significado
starting Dentro del --start-period; los fallos no cuentan
healthy La última comprobación salió con código 0
unhealthy Se alcanzaron --retries fallos consecutivos

Y por qué importa más allá de la columna de docker ps:

  • Docker Compose puede esperar a que un servicio esté healthy antes de arrancar otro con depends_on: condition: service_healthy (lección 04-02). Es la solución limpia al problema que intentaba resolver a mano el script del apartado 4.
  • Docker Swarm reemplaza automáticamente las réplicas unhealthy (lección 06-03).
  • Kubernetes tiene su propio mecanismo equivalente, las probes (lección 06-05).

Limpia:

docker rm -f aurora-hc

HEALTHCHECK NONE

Si tu imagen base define un healthcheck que no te sirve, puedes desactivarlo:

HEALTHCHECK NONE

Es raro, pero aparece al heredar de imágenes corporativas con comprobaciones que no aplican a tu caso.

  1. VOLUME y por qué declararlo suele ser mala idea

VOLUME declara que un directorio de la imagen debe montarse como volumen:

VOLUME /var/lib/postgresql/data
VOLUME ["/datos", "/registros"]

Cuando arranques un contenedor de esa imagen sin especificar nada, Docker creará automáticamente un volumen anónimo para esa ruta.

Suena útil, y sin embargo la recomendación general es no ponerlo. Cuatro razones:

  1. Genera volúmenes anónimos huérfanos. Cada docker run sin -v explícito crea un volumen con nombre aleatorio que no se borra al eliminar el contenedor (salvo docker rm -v). En una máquina de desarrollo se acumulan decenas de gigabytes de volúmenes con nombres como f3a9c2e1b8... que nadie sabe si son importantes. Los verás en docker system df en la lección 02-05.
  2. No se puede deshacer. Una vez que una imagen declara VOLUME /datos, quien la usa no puede quitarlo. Le impone una decisión de almacenamiento que quizá no le convenga.
  3. Rompe el COPY posterior. Todo lo que escribas en esa ruta después del VOLUME en el Dockerfile se pierde silenciosamente, sin ningún aviso. Es un error desesperante de diagnosticar.
  4. Es una decisión de ejecución, no de imagen. Quién monta qué dónde lo decide quien despliega, con -v o con la sección volumes: de Compose.

Demostración del punto 3:

FROM alpine:3.21
VOLUME /datos
RUN echo "hola" > /datos/fichero.txt    # Se pierde
docker run --rm esa-imagen ls /datos
(vacío)

El fichero no está. Se escribió en una capa que el montaje del volumen tapa.

Para Aurora Libros no se usa VOLUME en ningún Dockerfile. La API no guarda estado —lee de PostgreSQL y cachea en Redis—, y la persistencia del catálogo se resuelve montando un volumen al ejecutar el contenedor de aurora-db, que es el tema de la lección 03-06. Curiosamente, la propia imagen oficial de PostgreSQL sí declara VOLUME /var/lib/postgresql/data, y es justo el motivo de que aparezcan volúmenes anónimos huérfanos en cuanto experimentas con ella sin -v.

  1. STOPSIGNAL y el manejo de señales

STOPSIGNAL cambia la señal que Docker envía al PID 1 al ejecutar docker stop:

STOPSIGNAL SIGTERM     # El valor por defecto
STOPSIGNAL SIGQUIT     # Lo que necesita Nginx
STOPSIGNAL SIGINT
STOPSIGNAL 15          # También por número

El comportamiento por defecto ya lo conoces de la lección 02-03: docker stop envía SIGTERM, espera 10 segundos y envía SIGKILL, que no se puede capturar ni ignorar.

El problema es que no todos los programas interpretan SIGTERM igual:

Programa SIGTERM Señal para apagado ordenado
Node.js Termina de inmediato SIGTERM (con manejador propio)
Nginx Apagado rápido: corta las conexiones en curso SIGQUIT: apagado ordenado
PostgreSQL Smart shutdown: espera a los clientes SIGINT para fast shutdown
Apache Apagado inmediato SIGWINCH para ordenado

El caso de Nginx es el que afecta a Aurora Libros: el contenedor aurora-web del módulo 4 querrá STOPSIGNAL SIGQUIT para no cortar peticiones a medio servir durante un despliegue.

Para la API, SIGTERM (el valor por defecto) es correcto, pero conviene que server.js lo capture para cerrar limpiamente. El patrón, que se retomará en la lección 06-01:

// Apagado ordenado: deja de aceptar conexiones nuevas, termina las
// en curso y cierra el pool de PostgreSQL y el cliente de Redis.
const servidor = app.listen(PORT, '0.0.0.0', () => { /* ... */ });

async function apagar(senal) {
  console.log(`[aurora-api] recibida ${senal}, cerrando ordenadamente...`);
  servidor.close(async () => {
    await pool.end();
    await cache.quit();
    console.log('[aurora-api] cerrado limpiamente');
    process.exit(0);
  });
  // Red de seguridad: si algo se atasca, salir antes del SIGKILL
  setTimeout(() => process.exit(1), 8000).unref();
}

process.on('SIGTERM', () => apagar('SIGTERM'));
process.on('SIGINT', () => apagar('SIGINT'));

Fíjate en el temporizador de 8 segundos: es deliberadamente inferior a los 10 de docker stop, para forzar la salida antes de que llegue el SIGKILL. Y recuerda que nada de esto sirve si el CMD está en forma shell: la señal no llegaría nunca al proceso de Node.

  1. ONBUILD y SHELL

Dos instrucciones de uso minoritario que conviene reconocer.

ONBUILD

Registra una instrucción que no se ejecuta ahora, sino cuando alguien use esta imagen como base con un FROM.

# En la imagen "aurora-base-node"
FROM node:22-alpine
WORKDIR /app
ONBUILD COPY package*.json ./
ONBUILD RUN npm ci --omit=dev && npm cache clean --force
ONBUILD COPY . .
CMD ["node", "server.js"]

Quien la use solo escribe:

FROM auroralibros/aurora-base-node:1

Y al construir, se disparan automáticamente los tres pasos registrados. Es una plantilla para estandarizar microservicios de una organización.

Por qué se usa poco: la magia oculta. Quien lee ese Dockerfile de una línea no ve lo que va a ocurrir; tiene que ir a inspeccionar la base. Depurar un fallo en un ONBUILD es especialmente frustrante, porque el error señala a un fichero que no contiene la instrucción culpable. Las propias imágenes oficiales de Node retiraron sus variantes onbuild hace años por este motivo. Si necesitas estandarizar, hoy es preferible una plantilla de Dockerfile compartida o un fichero base bien documentado.

SHELL

Cambia el intérprete que usa la forma shell de RUN, CMD y ENTRYPOINT:

SHELL ["/bin/bash", "-c"]
SHELL ["powershell", "-Command"]    # Contenedores Windows

Por defecto es ["/bin/sh", "-c"] en Linux. El caso de uso más común y legítimo en Linux es activar el modo estricto de bash en imágenes con muchos RUN encadenados:

SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -s https://ejemplo.com/lista.txt | grep aurora > /app/lista.txt

Sin pipefail, ese RUN tiene éxito aunque el curl falle, porque el código de salida de una tubería es el del último comando, y grep sí funcionaría. Con pipefail, el fallo de cualquier eslabón hace fallar la instrucción entera. Es un fallo silencioso clásico que produce imágenes con ficheros vacíos.

Nota para Alpine: node:22-alpine no trae bash, solo ash de BusyBox. Usar SHELL ["/bin/bash", …] allí requeriría RUN apk add --no-cache bash. En el Dockerfile de aurora-api no aparece ningún SHELL: no hay tuberías en los RUN.

  1. El Dockerfile profesional de aurora-api

Todo junto. Guarda esto en ~/aurora-libros/api/Dockerfile:

# syntax=docker/dockerfile:1
# =============================================================================
# aurora-api · API REST del catálogo de Aurora Libros S.L.
#
# Construcción estándar:
#   docker build -t auroralibros/aurora-api:1.1.0 .
#
# Construcción con metadatos completos (lo que hará el pipeline en 06-02):
#   docker build \
#     --build-arg VERSION=1.1.0 \
#     --build-arg REVISION="$(git rev-parse --short HEAD)" \
#     --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
#     -t auroralibros/aurora-api:1.1.0 .
# =============================================================================

# --- Argumentos globales: solo los ven las instrucciones FROM ---------------
# Permiten probar otra versión de Node sin tocar el fichero:
#   docker build --build-arg NODE_VERSION=23 -t aurora-api:node23 .
ARG NODE_VERSION=22
ARG ALPINE_VERSION=3.21

FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}

# --- Argumentos de la etapa: metadatos de trazabilidad ----------------------
# Reimportamos NODE_VERSION porque los ARG globales no cruzan el FROM.
ARG NODE_VERSION
ARG VERSION=1.1.0
ARG REVISION=desconocida
ARG CREATED=desconocida

# --- Metadatos OCI ----------------------------------------------------------
# Cuestan cero bytes y responden a "¿qué commit lleva dentro esto?" a las 3 AM.
LABEL org.opencontainers.image.title="aurora-api" \
      org.opencontainers.image.description="API REST del catálogo de Aurora Libros S.L." \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.authors="[email protected]" \
      org.opencontainers.image.vendor="Aurora Libros S.L." \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}" \
      org.opencontainers.image.base.name="docker.io/library/node:${NODE_VERSION}-alpine"

WORKDIR /app

# --- Dependencias antes que código (caché, lección 02-02) -------------------
# --chown en el propio COPY: evita un RUN chown -R posterior que duplicaría
# el tamaño de la capa por copy-on-write.
COPY --chown=node:node package*.json ./

RUN npm ci --omit=dev && npm cache clean --force

# --- Código de la aplicación ------------------------------------------------
COPY --chown=node:node . .

# --- Configuración por defecto, sobrescribible con -e -----------------------
# NINGÚN SECRETO AQUÍ: quedaría en los metadatos y en docker image history.
ENV NODE_ENV=production \
    PORT=3000 \
    APP_VERSION=${VERSION}

# --- Usuario sin privilegios ------------------------------------------------
# Va DESPUÉS de instalar y copiar (root necesitaba escribir) y ANTES del CMD.
# node:22-alpine ya incluye el usuario "node" con uid 1000.
USER node

# --- Puerto: documentación, no publicación ----------------------------------
# 3000 y no 80 porque un usuario sin privilegios no puede usar puertos <1024.
EXPOSE 3000

# --- Comprobación de salud --------------------------------------------------
# wget viene con BusyBox en Alpine; curl no está y añadiría ~4 MB.
# start-period de 10 s para no marcar unhealthy durante un arranque legítimo.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/salud || exit 1

# --- Proceso principal ------------------------------------------------------
# ENTRYPOINT fija el ejecutable; CMD son los argumentos reemplazables.
# Ambos en forma exec: node es PID 1 y recibe SIGTERM directamente.
ENTRYPOINT ["node"]
CMD ["server.js"]

Construye la nueva versión:

cd ~/aurora-libros/api
docker build \
  --build-arg VERSION=1.1.0 \
  --build-arg REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo sin-git)" \
  --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t auroralibros/aurora-api:1.1.0 .
[+] Building 10.8s (13/13) FINISHED
 => [1/5] FROM docker.io/library/node:22-alpine3.21@sha256:9f2c...        0.0s
 => [2/5] WORKDIR /app                                                    0.1s
 => [3/5] COPY --chown=node:node package*.json ./                         0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                8.7s
 => [5/5] COPY --chown=node:node . .                                      0.1s
 => exporting to image                                                    0.7s
 => => naming to docker.io/auroralibros/aurora-api:1.1.0                  0.0s

Verificación completa de todo lo que has añadido:

# 1. El usuario ya no es root
docker run --rm auroralibros/aurora-api:1.1.0 --eval "console.log(process.getuid())"
1000

Fíjate en el detalle: con ENTRYPOINT ["node"], el argumento --eval "..." ha sustituido al CMD ["server.js"] y se ha ejecutado node --eval .... El caso 3 de la tabla del apartado 3, en vivo.

# 2. ENTRYPOINT y CMD en los metadatos
docker image inspect auroralibros/aurora-api:1.1.0 \
  --format 'Entrypoint: {{json .Config.Entrypoint}}
Cmd:        {{json .Config.Cmd}}
User:       {{.Config.User}}
Health:     {{json .Config.Healthcheck.Test}}'
Entrypoint: ["node"]
Cmd:        ["server.js"]
User:       node
Health:     ["CMD-SHELL","wget --quiet --tries=1 --spider http://localhost:3000/salud || exit 1"]
# 3. Arranque, healthcheck y parada limpia
docker run -d --name aurora-pro -p 3000:3000 auroralibros/aurora-api:1.1.0
sleep 5 && docker ps --filter name=aurora-pro --format "{{.Names}}: {{.Status}}"
sleep 40 && docker ps --filter name=aurora-pro --format "{{.Names}}: {{.Status}}"
time docker stop aurora-pro
docker rm aurora-pro
aurora-pro: Up 5 seconds (health: starting)
aurora-pro: Up 45 seconds (unhealthy)
aurora-pro
real    0m0.304s

Las tres cosas confirmadas: periodo de gracia, detección correcta de que el servicio no está operativo (falta PostgreSQL, módulo 3) y parada en 0,3 segundos gracias a la forma exec.

Compara con la versión anterior:

Aspecto 1.0.0 (lección 02-03) 1.1.0 (esta lección)
Usuario de ejecución root (uid 0) node (uid 1000)
Versión de Node Fija en el FROM Parametrizable con --build-arg
Trazabilidad Ninguna 11 etiquetas OCI, con commit y fecha
Salud del servicio Solo "el proceso vive" healthy/unhealthy real contra /salud
Ejecutable Reemplazable por completo Fijo (node), con argumentos reemplazables
Tamaño 167 MB 167 MB (los metadatos no pesan)

Toda esa mejora ha costado cero bytes.

Errores Comunes y Consejos

  • Usar ARG para secretos. Quedan en docker image history en claro. Para credenciales en la build existen los secretos de BuildKit (lección 05-05); para credenciales en ejecución, -e y ficheros de entorno (lección 04-05).
  • Esperar que un ARG global esté disponible dentro de la etapa. Los ARG anteriores al primer FROM solo los ven los FROM. Hay que volver a declararlos, sin valor, dentro de la etapa.
  • ENTRYPOINT en forma shell. Rompe dos cosas: el PID 1 pasa a ser sh (10 s de parada) y los argumentos de docker run y el CMD se ignoran por completo. Forma exec siempre.
  • Un script de entrypoint sin exec. El script se queda como PID 1, no reenvía SIGTERM y vuelves a los 10 segundos y el SIGKILL. La última línea debe ser exec "$@", con comillas.
  • USER demasiado arriba. Si cambias de usuario antes de npm ci, el paso falla con EACCES. Instala como root, copia con --chown y pon USER justo antes del CMD.
  • RUN chown -R después de copiar. Duplica el tamaño de esa capa por copy-on-write. Usa COPY --chown.
  • Un usuario sin privilegios escuchando en el puerto 80. EACCES: permission denied. Escucha en un puerto alto y publica con -p 80:3000.
  • HEALTHCHECK sin --start-period. El contenedor se marca unhealthy durante su arranque legítimo y el orquestador entra en un bucle de reinicios.
  • Un healthcheck que consulta dependencias externas y no distingue. Si /salud devuelve 503 porque la base de datos está caída, el orquestador reiniciará la API, que no tiene la culpa de nada. Para Aurora Libros es correcto en desarrollo; en producción conviene separar liveness (¿vive el proceso?) de readiness (¿puede atender?), y eso se ve en la lección 06-05.
  • VOLUME en el Dockerfile. Genera volúmenes anónimos huérfanos, no se puede deshacer y hace desaparecer silenciosamente lo que escribas después en esa ruta. Decide el almacenamiento al ejecutar.
  • Consejo: pon los LABEL en una sola instrucción, con \ para partir líneas. Cada LABEL es una capa de metadatos.
  • Consejo: prueba el comando del healthcheck a mano con docker exec antes de meterlo en el Dockerfile. Ahorra ciclos de build enteros.

Ejercicios

Ejercicio 1: demuestra la diferencia entre ARG y ENV

Construye una imagen basada en alpine:3.21 que:

  1. Reciba un ARG ENTORNO_BUILD con valor por defecto local.
  2. Defina un ENV ENTORNO_RUN a partir de ese ARG.
  3. Imprima ambos valores durante la construcción con un RUN.
  4. Imprima ambos al ejecutar el contenedor.

Después:

  • Constrúyela sin --build-arg y ejecútala. ¿Qué imprime cada variable?
  • Constrúyela con --build-arg ENTORNO_BUILD=produccion y ejecútala. ¿Qué cambia?
  • Ejecuta la imagen con -e ENTORNO_RUN=sobrescrito. ¿Se puede hacer lo mismo con el ARG?
  • Comprueba con docker image history si el valor del ARG es visible. Explica qué implica esto para las contraseñas.

Ejercicio 2: recorre las cuatro combinaciones de ENTRYPOINT y CMD

Crea cuatro imágenes basadas en alpine:3.21, una por fila de la tabla del apartado 3, usando echo como ejecutable. Para cada una, ejecuta docker run --rm <imagen> y docker run --rm <imagen> adios y anota la salida real. Después:

  1. Rellena la tabla con lo observado y compárala con la teórica.
  2. Explica en una frase la regla que gobierna las cuatro filas.
  3. Usa --entrypoint para ejecutar ls / en la imagen de la cuarta fila.

Ejercicio 3: healthcheck que sí distingue

El HEALTHCHECK actual de aurora-api marca el contenedor unhealthy cuando falla PostgreSQL, aunque la API funcione perfectamente. Diseña una alternativa:

  1. Explica por qué eso es problemático en un orquestador que reinicia los contenedores enfermos.
  2. Propón dos endpoints distintos y qué debería comprobar cada uno.
  3. Escribe el HEALTHCHECK que usarías en el Dockerfile y justifica la elección.
  4. Demuestra con un contenedor real la diferencia entre comprobar /salud (que devuelve 503 sin base de datos) y comprobar solo que el puerto responde.

Soluciones

Solución al ejercicio 1

mkdir -p /tmp/ej-argenv && cd /tmp/ej-argenv

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21

ARG ENTORNO_BUILD=local
ENV ENTORNO_RUN=${ENTORNO_BUILD}

RUN echo "[build] ARG=${ENTORNO_BUILD} ENV=${ENTORNO_RUN}"

CMD ["sh", "-c", "echo \"[run] ARG=[${ENTORNO_BUILD}] ENV=[${ENTORNO_RUN}]\""]
EOF

docker build --no-cache --progress=plain -t ej:defecto . 2>&1 | grep "\[build\]"
docker run --rm ej:defecto
#5 0.142 [build] ARG=local ENV=local
[run] ARG=[] ENV=[local]

Con --build-arg:

docker build --no-cache --progress=plain --build-arg ENTORNO_BUILD=produccion -t ej:prod . 2>&1 | grep "\[build\]"
docker run --rm ej:prod
#5 0.139 [build] ARG=produccion ENV=produccion
[run] ARG=[] ENV=[produccion]

Análisis:

  • En build, ambas existen. El ENV toma el valor del ARG: es el patrón puente.
  • En ejecución, el ARG está siempre vacío. Nunca llegó al contenedor.
  • El ENV conserva el valor congelado en la build.

Sobrescritura en ejecución:

docker run --rm -e ENTORNO_RUN=sobrescrito ej:prod
[run] ARG=[] ENV=[sobrescrito]

El ENV sí se sobrescribe con -e. El ARG no, porque no existe en ejecución: -e ENTORNO_BUILD=x crearía una variable de entorno nueva sin ninguna relación con el ARG de la build.

El historial:

docker image history ej:prod --no-trunc --format "{{.CreatedBy}}" | head -4
CMD ["sh" "-c" "echo \"[run] ARG=[${ENTORNO_BUILD}] ENV=[${ENTORNO_RUN}]\""]
|1 ENTORNO_BUILD=produccion /bin/sh -c echo "[build] ARG=${ENTORNO_BUILD} ENV=${ENTORNO_RUN}"
ENV ENTORNO_RUN=produccion
ARG ENTORNO_BUILD=local

El valor produccion aparece en claro en la línea del RUN. Si en lugar de produccion hubiera sido superSecreta2026, cualquiera que descargase la imagen la leería con un solo comando y sin arrancar nada. Esa es exactamente la razón de que ARG no sirva para secretos, por mucho que "desaparezca" en ejecución: desaparece del entorno, no del historial.

Solución al ejercicio 2

mkdir -p /tmp/ej-ep && cd /tmp/ej-ep

printf 'FROM alpine:3.21\nCMD ["echo", "hola-cmd"]\n' > D1
printf 'FROM alpine:3.21\nENTRYPOINT ["echo", "hola-entry"]\n' > D2
printf 'FROM alpine:3.21\nENTRYPOINT ["echo"]\nCMD ["hola-combi"]\n' > D3
printf 'FROM alpine:3.21\nENTRYPOINT ["echo", "prefijo"]\nCMD ["sufijo"]\n' > D4

for n in 1 2 3 4; do docker build -q -t ep:$n -f D$n . ; done

for n in 1 2 3 4; do
  echo "--- Caso $n ---"
  echo -n "sin argumentos: "; docker run --rm ep:$n
  echo -n "con 'adios':    "; docker run --rm ep:$n adios
done
--- Caso 1 ---
sin argumentos: hola-cmd
con 'adios':    /bin/sh: adios: not found
--- Caso 2 ---
sin argumentos: hola-entry
con 'adios':    hola-entry adios
--- Caso 3 ---
sin argumentos: hola-combi
con 'adios':    adios
--- Caso 4 ---
sin argumentos: prefijo sufijo
con 'adios':    prefijo adios

1. La tabla observada coincide con la teórica. El caso 1 con argumento es especialmente ilustrativo: adios reemplazó al CMD entero y Docker intentó ejecutar un programa llamado adios, que no existe. En el caso 2, adios se añadió al ENTRYPOINT. En el 3 y el 4, adios sustituyó al CMD pero el ENTRYPOINT permaneció.

2. La regla: ENTRYPOINT es el ejecutable fijo y CMD son sus argumentos por defecto; lo que escribes tras el nombre de la imagen en docker run reemplaza únicamente al CMD.

3. Con --entrypoint:

docker run --rm --entrypoint ls ep:4 /
bin
dev
etc
home
lib
...

--entrypoint ls sustituye a echo, y el / posterior al nombre de la imagen sustituye al CMD, quedando ls /. Es el comando que necesitarás cada vez que quieras inspeccionar una imagen con ENTRYPOINT propio:

docker run --rm -it --entrypoint sh auroralibros/aurora-api:1.1.0

Solución al ejercicio 3

1. El problema. El healthcheck actual comprueba /salud, que devuelve 503 si PostgreSQL o Redis no responden. En Swarm o Kubernetes, un contenedor unhealthy se reinicia o se reemplaza. Si la base de datos cae cinco minutos, todas las réplicas de la API se marcarán enfermas y entrarán en un ciclo de reinicios pese a estar perfectamente sanas. Peor aún: cuando PostgreSQL vuelva, se encontrará con una avalancha de reconexiones de réplicas recién arrancadas, con las cachés vacías. Se ha convertido un fallo de una dependencia en una caída total y en una tormenta de reintentos.

2. Dos endpoints con responsabilidades distintas:

Endpoint Pregunta que responde Comprueba Acción si falla
/salud/vivo (liveness) ¿El proceso funciona? Solo que Express responde. Sin dependencias Reiniciar: el proceso está roto
/salud/listo (readiness) ¿Puede atender peticiones? Base de datos y caché accesibles Sacar del balanceo, sin reiniciar

La distinción es la clave: reiniciar arregla un proceso atascado, pero no arregla una base de datos caída. Ante un fallo de dependencia, lo correcto es dejar de enviarle tráfico a esa réplica y esperar, no matarla.

3. El HEALTHCHECK del Dockerfile:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/salud/vivo || exit 1

Justificación: el HEALTHCHECK de Docker tiene un único estado, y su consecuencia natural es el reinicio o el reemplazo. Por tanto debe reflejar la liveness. La readiness la consulta el balanceador o el orquestador por su cuenta —en Compose, con depends_on: condition: service_healthy (lección 04-02); en Kubernetes, con una readinessProbe separada (lección 06-05)—.

El endpoint nuevo en server.js sería tan simple como:

// Liveness: no toca ninguna dependencia externa a propósito
app.get('/salud/vivo', (req, res) => res.status(200).json({ estado: 'vivo' }));

4. La demostración. Con el healthcheck actual, sin base de datos:

docker run -d --name hc-salud auroralibros/aurora-api:1.1.0
sleep 45
docker ps --filter name=hc-salud --format "{{.Names}}: {{.Status}}"
hc-salud: Up 45 seconds (unhealthy)

Ahora sobrescribiendo el healthcheck en tiempo de ejecución para comprobar solo que el puerto responde:

docker run -d --name hc-puerto \
  --health-cmd="wget -q --tries=1 --spider http://localhost:3000/libros/abc || exit 1" \
  --health-interval=10s --health-start-period=10s --health-retries=3 \
  auroralibros/aurora-api:1.1.0
sleep 45
docker ps --filter name=hc-puerto --format "{{.Names}}: {{.Status}}"
hc-puerto: Up 45 seconds (healthy)

El mismo contenedor, con la misma base de datos ausente, es unhealthy con una comprobación y healthy con la otra. La petición a /libros/abc devuelve un 400 de validación —sin tocar PostgreSQL, porque el identificador no es un entero—, lo que demuestra que Express está vivo y enrutando correctamente. Es exactamente la señal de liveness que se busca.

Fíjate de paso en las opciones --health-* de docker run: permiten sobrescribir el healthcheck de la imagen sin reconstruirla, algo utilísimo para experimentar.

docker rm -f hc-salud hc-puerto

Conclusión

Tu imagen ha pasado de "funciona" a "es profesional", y sin ganar un solo byte. Sabes distinguir ARG de ENV por su ámbito y su momento —el primero se evapora al terminar la build, el segundo viaja dentro del contenedor—, y sabes que ninguno de los dos sirve para secretos, porque un --build-arg con una contraseña queda escrito en claro en docker image history. Has usado ARG antes del FROM para parametrizar la versión de Node y poder probar Node 23 sin editar una línea.

Dominas el binomio ENTRYPOINT + CMD y sus cuatro combinaciones, con la regla que las resume: el ENTRYPOINT es el ejecutable fijo, el CMD son los argumentos reemplazables, y --entrypoint es tu puerta de entrada cuando necesitas depurar. Has visto por qué un script docker-entrypoint.sh debe terminar en exec "$@": sin ese exec, el script se queda como PID 1, no reenvía SIGTERM y vuelves a los diez segundos de espera y el SIGKILL. Con USER node has dejado de ejecutar la API como root —uid=1000 confirmado— instalando primero como root y copiando con --chown para no duplicar capas, y entiendes por qué eso obliga a escuchar en el 3000 y no en el 80.

Con once etiquetas OCI la imagen ya dice quién la hizo, de qué commit sale y cuándo se construyó, que es lo que necesitas a las tres de la mañana. Con HEALTHCHECK Docker vigila el endpoint /salud que preparaste en la lección 01-07, y has visto el ciclo completo startingunhealthy en docker ps, con el historial de fallos en docker inspect. Y sabes por qué VOLUME en el Dockerfile suele ser mala idea —volúmenes huérfanos, decisión irreversible impuesta al usuario y escrituras posteriores que desaparecen sin aviso—, para qué sirve STOPSIGNAL (Nginx necesita SIGQUIT) y qué hacen ONBUILD y SHELL en los pocos casos en que merecen la pena.

auroralibros/aurora-api:1.1.0 es una imagen que ejecuta como usuario sin privilegios, se identifica, se autodiagnostica y se para limpiamente en 0,3 segundos. Ya sabes construirla; ahora conviene saber administrarla. En la siguiente lección, Gestionando Imágenes Docker, te ocuparás del almacén local: listar y filtrar con plantillas, extraer campos concretos con inspect --format, auditar con history qué capa se comió 80 MB, borrar imágenes con contenedores que las usan, entender de dónde salen esas misteriosas imágenes <none>:<none> que se acumulan build tras build, limpiar con la familia prune sin destruir lo que no debes, diagnosticar el espacio con docker system df y llevarte una imagen a otra máquina sin registro alguno, con docker save y docker load.

Docker: De Principiante a Avanzado

Módulo 1: Introducción a Docker

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados