DevOps, CI/CD y contenedores

Fundamentos de Docker: imágenes, contenedores y Dockerfiles

Principiante7 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Docker empaqueta una aplicación con todo lo que necesita en una imagen y ejecuta esa imagen como un proceso aislado llamado contenedor. Esta guía explica las piezas —imágenes, capas, registros, contenedores, volúmenes— y después muestra cómo escribir un Dockerfile que compile rápido y genere una imagen pequeña y segura, con ejemplos prácticos para un servicio Spring Boot.

Lo que aprenderás

  • Explicar la diferencia entre una imagen, una capa, un contenedor y un registro
  • Ejecutar, inspeccionar, detener y eliminar contenedores con los comandos básicos de la CLI
  • Escribir un Dockerfile multi-stage que genere una imagen pequeña y reproducible
  • Usar .dockerignore, el orden de las capas y los cache mounts para mantener builds rápidos
  • Evitar los errores más habituales: ejecutar como root, el tag latest y los secretos en las imágenes

«En mi máquina funciona» era un chiste; Docker lo convirtió en una estrategia de despliegue al hacer que la máquina forme parte del artefacto. Una imagen lleva la aplicación, su runtime, sus librerías y su configuración. Ejecuta la misma imagen en un portátil, en un runner de CI y en un clúster de producción y obtendrás el mismo comportamiento. Esta guía explica el modelo que hay detrás de esa promesa y luego pasa a la práctica: los comandos que usarás a diario y cómo escribir un Dockerfile del que no te avergüences más adelante.

Imágenes, capas, contenedores y registros

Cuatro palabras concentran casi todo el vocabulario de Docker.

Una imagen es una plantilla de solo lectura: una instantánea de un sistema de archivos más metadatos como el comando por defecto, las variables de entorno y los puertos expuestos. Las imágenes se construyen a partir de un Dockerfile y se identifican por un nombre y un tag, por ejemplo eclipse-temurin:21-jre.

Una imagen está formada por capas (layers). Cada instrucción de un Dockerfile que modifica el sistema de archivos —COPY, RUN, ADD— genera una capa nueva apilada sobre las anteriores. Las capas se direccionan por contenido y se comparten: si diez imágenes empiezan todas con FROM eclipse-temurin:21-jre, las capas base se almacenan y se descargan una sola vez. Esto es también lo que hace funcionar la caché de build, como veremos.

Un contenedor es una instancia de una imagen, en ejecución o detenida. Docker añade una fina capa escribible sobre las capas de solo lectura de la imagen, crea una vista aislada de la tabla de procesos, la red y el sistema de archivos mediante namespaces de Linux y aplica límites de recursos con cgroups. El contenedor no es más que un proceso en el kernel del host; no arranca ningún sistema operativo invitado.

Un registro almacena y distribuye imágenes. Docker Hub es el registro público por defecto; las organizaciones mantienen registros privados (Harbor, GitLab, Amazon ECR, GitHub Container Registry). docker pull y docker push mueven imágenes entre un host y un registro.

$ docker pull eclipse-temurin:21-jre
$ docker image ls
REPOSITORY         TAG      IMAGE ID       CREATED       SIZE
eclipse-temurin    21-jre   3a1f0b2c9d7e   2 weeks ago   268MB

Los comandos del día a día

Con una docena de comandos llegas muy lejos.

# Ejecutar un contenedor a partir de una imagen, en modo interactivo, y eliminarlo al salir
docker run --rm -it ubuntu:24.04 bash

# Ejecutar un servidor web en segundo plano, mapeando el puerto 8080 del host al puerto 80 del contenedor
docker run -d --name web -p 8080:80 nginx:1.27

# Ver qué está en ejecución (añade -a para incluir también los contenedores detenidos)
docker ps

# Logs, una shell dentro, consumo de recursos
docker logs -f web
docker exec -it web sh
docker stats web

# Detener, arrancar, eliminar
docker stop web
docker start web
docker rm -f web

# Inspeccionar lo que sea —imágenes, contenedores, redes— en JSON
docker inspect web | jq '.[0].NetworkSettings.Ports'

# Recuperar disco: contenedores detenidos, imágenes huérfanas, redes sin usar
docker system prune

Conviene interiorizar pronto dos opciones. --rm elimina el contenedor cuando termina, lo que evita que tu máquina se llene de contenedores muertos de tus pruebas. -v (o --mount) monta un volumen o un directorio del host para que los datos sobrevivan al contenedor:

docker run -d --name db -v pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=dev postgres:16

Sin el volumen, los archivos de la base de datos viven en la capa escribible del contenedor y desaparecen con docker rm.

Cómo escribir un Dockerfile

Un Dockerfile es un script de instrucciones que producen una imagen. Este es un ejemplo mínimo para una aplicación Spring Boot cuyo build ya ha generado target/app.jar:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Constrúyelo y ejecútalo:

docker build -t myorg/app:1.0.0 .
docker run --rm -p 8080:8080 myorg/app:1.0.0

El . del final es el contexto de build: el directorio que se envía al daemon de Docker. Todo lo que referencie COPY tiene que estar dentro, y todo lo que haya dentro se transfiere; por eso importa tanto .dockerignore.

Las instrucciones que vas a usar:

InstrucciónPara qué sirve
FROMImagen base. Todo Dockerfile empieza aquí (o con FROM scratch para partir de una base vacía).
WORKDIRFija el directorio de trabajo para las instrucciones siguientes; lo crea si no existe.
COPYCopia archivos del contexto de build a la imagen. Prefiérela a ADD, que además descarga URLs y descomprime archivos comprimidos.
RUNEjecuta un comando en tiempo de build y guarda el resultado como una capa.
ENVDefine una variable de entorno para el build y la ejecución.
ARGVariable de build, que se pasa con --build-arg; no existe en tiempo de ejecución.
EXPOSEDocumenta en qué puerto escucha el proceso. No publica nada.
USERCambia el usuario para las instrucciones siguientes y para el contenedor en ejecución.
ENTRYPOINT / CMDEl comando que se ejecuta. Usa la forma de array JSON para que no intervenga ninguna shell y las señales lleguen al proceso.
HEALTHCHECKUn comando que Docker ejecuta periódicamente para informar del estado de salud del contenedor.

Multi-stage builds

El Dockerfile anterior da por hecho que el jar se ha compilado fuera de Docker, con lo que el build depende del Maven y del JDK del host. Un multi-stage build compila dentro de una primera etapa y copia solo el artefacto a una etapa de ejecución ligera:

# ---- etapa de build ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
# Copiar primero los archivos que menos cambian, para que la descarga de dependencias quede en caché
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -q dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -q package -DskipTests

# ---- etapa de ejecución ----
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 app
WORKDIR /app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

La etapa de build, con Maven y el JDK completo, se descarta; la imagen final solo contiene el JRE y el jar. El tamaño baja de unos 700 MB a menos de 300 MB, y ni el código fuente ni las herramientas de build llegan a producción. El mismo patrón sirve para Node (build con node:22, ejecución con node:22-slim o archivos estáticos servidos desde nginx), para Go (build y luego FROM scratch) y para todo lo demás.

Builds rápidos: caché y orden

Docker guarda en caché cada capa y la reutiliza mientras la instrucción y sus entradas no cambien. La caché se invalida en la primera instrucción cuyas entradas hayan cambiado, y todas las instrucciones posteriores se vuelven a ejecutar. Dos consecuencias:

  • Ordena las instrucciones de menos a más volátiles. Copia pom.xml o package.json e instala las dependencias antes de copiar el código fuente. Así, editar un archivo fuente solo vuelve a ejecutar la compilación, no la descarga de dependencias.
  • Usa .dockerignore. Un COPY . . se invalida en cuanto cambia cualquier archivo del contexto, incluidos .git, la salida del build y los archivos temporales del editor. Exclúyelos:
.git
target
node_modules
*.log
.idea
.vscode
Dockerfile
docker-compose*.yml

El --mount=type=cache de BuildKit (visto arriba) conserva las cachés del gestor de paquetes entre builds sin meterlas en una capa. Encadena con && los comandos RUN que van juntos para que sus archivos intermedios nunca acaben en una capa, y haz la limpieza en la misma instrucción:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

Seguridad e higiene: lo imprescindible

No ejecutes como root. Por defecto, el proceso dentro de un contenedor se ejecuta con UID 0. Un escape del contenedor o un bug de la aplicación le entregan entonces a un atacante privilegios de root sobre la parte del sistema de archivos del host que el contenedor ve. Crea un usuario y cambia a él con USER, como en el ejemplo multi-stage.

Fija las imágenes base. FROM node o FROM node:latest significa una imagen distinta cada semana. Usa un tag concreto (node:22.9-slim) y, para builds reproducibles, un digest: FROM node:22.9-slim@sha256:…. Reconstruye de forma deliberada para incorporar los parches de seguridad.

Nunca metas secretos en una imagen. Un COPY .env . o un ENV API_KEY=... queda guardado para siempre en una capa, visible con docker history, y viaja a todos los registros a los que llegue la imagen. Pasa los secretos en tiempo de ejecución mediante variables de entorno o archivos montados, y usa el --mount=type=secret de BuildKit si un paso del build de verdad necesita uno.

Mantén la imagen pequeña. Menos paquetes significan menos vulnerabilidades que parchear y pulls más rápidos. Prefiere bases -slim, -alpine o distroless, y analiza las imágenes con docker scout o Trivy en la CI.

Gestiona las señales. Usa la forma de array JSON de ENTRYPOINT para que tu proceso sea el PID 1 y reciba SIGTERM cuando se detenga el contenedor. Con la forma shell, ENTRYPOINT java -jar app.jar pone sh delante de tu proceso, se traga la señal y tu aplicación muere sin un apagado ordenado cuando vencen los diez segundos de gracia.

Errores habituales

  • Tratar los contenedores como mascotas. Entrar en un contenedor y editar archivos dentro funciona hasta que el contenedor se recrea. Cambia el Dockerfile y reconstruye.
  • Publicar puertos solo con EXPOSE. EXPOSE es documentación. Lo que publica de verdad es -p host:container (o una entrada ports en Compose).
  • Esperar que localhost sea el host. Dentro de un contenedor, localhost es el propio contenedor. Llega a otros contenedores a través de una red definida por el usuario y sus nombres de servicio, y al host a través de host.docker.internal en Docker Desktop.
  • Escribir datos en el sistema de archivos del contenedor. Desaparecen con docker rm. Usa volúmenes.
  • Saltarse la regla de un proceso por contenedor. Meter una app, una base de datos y un daemon de cron en una sola imagen arruina la orquestación, los logs y el escalado. Para aplicaciones multicontenedor existen Compose y Kubernetes.
  • Ignorar el código de salida. docker ps -a muestra los contenedores terminados con su código; docker logs explica por qué. 137 significa que lo mató el OOM killer o un SIGKILL: sube el límite de memoria o arregla la fuga.

Ideas clave

  • Una imagen es una plantilla de solo lectura organizada en capas; un contenedor es un proceso que se ejecuta a partir de ella con una capa escribible encima; un registro almacena imágenes.
  • Aprende run, ps, logs, exec, stop, rm, build, push y system prune. Añade --rm a tus pruebas y -v a todo lo que guarde datos.
  • Escribe Dockerfiles multi-stage: compila en una imagen completa, ejecuta en una ligera y copia solo el artefacto de una a otra.
  • Ordena las instrucciones de lo estable a lo volátil, mantén .dockerignore al día y usa cache mounts para que un cambio en el código fuente solo vuelva a ejecutar la compilación.
  • Ejecuta con un usuario que no sea root, fija las imágenes base, deja los secretos fuera de las capas, usa la forma JSON de ENTRYPOINT y mantén la imagen pequeña.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Un contenedor es una máquina virtual ligera?
No. Una máquina virtual ejecuta un sistema operativo invitado completo sobre un hipervisor. Un contenedor es un proceso normal del kernel del host, aislado con namespaces de Linux y limitado con cgroups, que ve su propio sistema de archivos tomado de la imagen. Por eso los contenedores arrancan en milisegundos y comparten el kernel del host, y por eso un contenedor no puede ejecutar un kernel distinto al de su host.
¿Qué diferencia hay entre CMD y ENTRYPOINT?
ENTRYPOINT fija el ejecutable que siempre se lanza; CMD aporta argumentos por defecto que el usuario puede sustituir en la línea de comandos de docker run. Usa ENTRYPOINT para la parte fija (el comando java y sus opciones) y CMD para los argumentos que esperas que la gente cambie. Si solo defines CMD, se puede reemplazar el comando entero.
¿Por qué mi imagen ocupa tanto?
Normalmente porque las herramientas de build, el código fuente y las cachés del gestor de paquetes han acabado en la imagen final. Un multi-stage build compila en una etapa y copia solo el artefacto resultante a una imagen de ejecución ligera. Comprueba también que .dockerignore excluya .git, node_modules, target y las carpetas del IDE, y que limpies las cachés del gestor de paquetes en la misma instrucción RUN que instala los paquetes.
¿Debo usar el tag latest?
No para nada que despliegues. latest no es más que un tag mutable que apunta a lo último que se subió, así que dos builds en días distintos pueden producir imágenes distintas. Fija las imágenes base a una versión concreta (e idealmente a un digest) y etiqueta tus propias imágenes con la versión del build o el hash del commit.