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
ARGfrente aENV: ámbito y momentoARGantes del primerFROMENTRYPOINTfrente aCMD: las cuatro combinaciones- El patrón
docker-entrypoint.shy por qué importaexec "$@" USER: dejar de ejecutar como rootLABELy las etiquetas estándar OCIHEALTHCHECK: que Docker vigile tu servicioVOLUMEy por qué declararlo suele ser mala ideaSTOPSIGNALy el manejo de señalesONBUILDySHELL- El Dockerfile profesional de
aurora-api
ARG frente a ENV: ámbito y momento
ARG frente a ENV: ámbito y momentoLas 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]\""]Durante la construcción, las dos están disponibles. Ahora ejecuta:
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"El puente entre ambas
El patrón más útil es combinarlas: un ARG que alimenta un ENV.
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:
docker build --build-arg DB_PASSWORD=superSecreta2026 -t filtrada .
docker image history filtrada --no-trunc | head -5CREATED BY
|1 DB_PASSWORD=superSecreta2026 /bin/sh -c echo "conectando con $DB_PASSWORD" > /app/config.txtAhí 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.
ARG antes del primer FROM
ARG antes del primer FROMARG 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.
ENTRYPOINT frente a CMD: las cuatro combinaciones
ENTRYPOINT frente a CMD: las cuatro combinacionesCMD, 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:
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 --versionAquí --version ha sustituido a server.js, pero node sigue estando: se ha ejecutado node --version. El ejecutable es intocable.
El patrón recomendado
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 --helpfunciona 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:
--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:
Formas shell y exec, otra vez
Todo lo aprendido en la lección 02-03 sobre CMD aplica igual a ENTRYPOINT, agravado:
Con ENTRYPOINT en forma shell pasan dos cosas malas, no una:
- El PID 1 es
shy no reenvía SIGTERM: los 10 segundos de espera y el SIGKILL de la lección anterior. CMDse ignora por completo, y los argumentos dedocker runtambién. ElENTRYPOINTen forma shell se traga todo el mecanismo de argumentos.
Con ENTRYPOINT, la forma exec no es una recomendación: es obligatoria.
- El patrón
docker-entrypoint.sh y por qué importa exec "$@"
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 elENTRYPOINTes el script y elCMDes["node","server.js"],"$@"valenode server.js. Las comillas son imprescindibles: sin ellas, un argumento con espacios se partiría en dos.execes lo esencial. Sinexec, 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—. Conexec, el shell se reemplaza a sí mismo pornode: mismo PID, mismos descriptores de fichero, el script desaparece del árbol de procesos ynodepasa a ser el PID 1.
Compruébalo con el script en marcha:
PID 1 es node, no sh. El script se ejecutó, hizo su trabajo y se apartó. Y por tanto:
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í.
USER: dejar de ejecutar como root
USER: dejar de ejecutar como rootAhora mismo tu API se ejecuta como root dentro del contenedor. Compruébalo:
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:
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:
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:
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:
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.
LABEL y las etiquetas estándar OCI
LABEL y las etiquetas estándar OCILABEL 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?
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.0El 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:
HEALTHCHECK: que Docker vigile tu servicio
HEALTHCHECK: que Docker vigile tu servicioUn 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.
| 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 1Detalles de la elección del comando:
wgety nocurl:node:22-alpinetraewget(de BusyBox) pero nocurl. Usarcurlobligaría a unapk add curlque añade unos 4 MB. Si prefierescurl, la opción escurl -f http://localhost:3000/salud || exit 1, donde-fhace 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í quelocalhostes 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:
health: starting es el periodo de gracia. Espera y vuelve a mirar:
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:
{
"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é
healthyantes de arrancar otro condepends_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:
HEALTHCHECK NONE
Si tu imagen base define un healthcheck que no te sirve, puedes desactivarlo:
Es raro, pero aparece al heredar de imágenes corporativas con comprobaciones que no aplican a tu caso.
VOLUME y por qué declararlo suele ser mala idea
VOLUME y por qué declararlo suele ser mala ideaVOLUME declara que un directorio de la imagen debe montarse como volumen:
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:
- Genera volúmenes anónimos huérfanos. Cada
docker runsin-vexplícito crea un volumen con nombre aleatorio que no se borra al eliminar el contenedor (salvodocker rm -v). En una máquina de desarrollo se acumulan decenas de gigabytes de volúmenes con nombres comof3a9c2e1b8...que nadie sabe si son importantes. Los verás endocker system dfen la lección 02-05. - 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. - Rompe el
COPYposterior. Todo lo que escribas en esa ruta después delVOLUMEen el Dockerfile se pierde silenciosamente, sin ningún aviso. Es un error desesperante de diagnosticar. - Es una decisión de ejecución, no de imagen. Quién monta qué dónde lo decide quien despliega, con
-vo con la secciónvolumes:de Compose.
Demostración del punto 3:
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.
STOPSIGNAL y el manejo de señales
STOPSIGNAL y el manejo de señalesSTOPSIGNAL 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úmeroEl 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.
ONBUILD y SHELL
ONBUILD y SHELLDos 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:
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:
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.txtSin 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.
- El Dockerfile profesional de
aurora-api
aurora-apiTodo 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.0sVerificació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())"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-proaurora-pro: Up 5 seconds (health: starting)
aurora-pro: Up 45 seconds (unhealthy)
aurora-pro
real 0m0.304sLas 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
ARGpara secretos. Quedan endocker image historyen claro. Para credenciales en la build existen los secretos de BuildKit (lección 05-05); para credenciales en ejecución,-ey ficheros de entorno (lección 04-05). - Esperar que un
ARGglobal esté disponible dentro de la etapa. LosARGanteriores al primerFROMsolo los ven losFROM. Hay que volver a declararlos, sin valor, dentro de la etapa. ENTRYPOINTen forma shell. Rompe dos cosas: el PID 1 pasa a sersh(10 s de parada) y los argumentos dedocker runy elCMDse 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 serexec "$@", con comillas. USERdemasiado arriba. Si cambias de usuario antes denpm ci, el paso falla conEACCES. Instala como root, copia con--chowny ponUSERjusto antes delCMD.RUN chown -Rdespués de copiar. Duplica el tamaño de esa capa por copy-on-write. UsaCOPY --chown.- Un usuario sin privilegios escuchando en el puerto 80.
EACCES: permission denied. Escucha en un puerto alto y publica con-p 80:3000. HEALTHCHECKsin--start-period. El contenedor se marcaunhealthydurante su arranque legítimo y el orquestador entra en un bucle de reinicios.- Un healthcheck que consulta dependencias externas y no distingue. Si
/saluddevuelve 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. VOLUMEen 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
LABELen una sola instrucción, con\para partir líneas. CadaLABELes una capa de metadatos. - Consejo: prueba el comando del healthcheck a mano con
docker execantes 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:
- Reciba un
ARG ENTORNO_BUILDcon valor por defectolocal. - Defina un
ENV ENTORNO_RUNa partir de eseARG. - Imprima ambos valores durante la construcción con un
RUN. - Imprima ambos al ejecutar el contenedor.
Después:
- Constrúyela sin
--build-argy ejecútala. ¿Qué imprime cada variable? - Constrúyela con
--build-arg ENTORNO_BUILD=producciony ejecútala. ¿Qué cambia? - Ejecuta la imagen con
-e ENTORNO_RUN=sobrescrito. ¿Se puede hacer lo mismo con elARG? - Comprueba con
docker image historysi el valor delARGes 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:
- Rellena la tabla con lo observado y compárala con la teórica.
- Explica en una frase la regla que gobierna las cuatro filas.
- Usa
--entrypointpara ejecutarls /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:
- Explica por qué eso es problemático en un orquestador que reinicia los contenedores enfermos.
- Propón dos endpoints distintos y qué debería comprobar cada uno.
- Escribe el
HEALTHCHECKque usarías en el Dockerfile y justifica la elección. - 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:defectoCon --build-arg:
docker build --no-cache --progress=plain --build-arg ENTORNO_BUILD=produccion -t ej:prod . 2>&1 | grep "\[build\]"
docker run --rm ej:prodAnálisis:
- En build, ambas existen. El
ENVtoma el valor delARG: es el patrón puente. - En ejecución, el
ARGestá siempre vacío. Nunca llegó al contenedor. - El
ENVconserva el valor congelado en la build.
Sobrescritura en ejecución:
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:
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=localEl 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 adios1. 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:
--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:
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 1Justificació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}}"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}}"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.
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 starting → unhealthy 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
- ¿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
