Docker básico: imagen, contenedor y capas
1. Resumen
Muchas personas nuevas en Docker a menudo confunden imagen y contenedor, o no entienden por qué docker build es tan rápido en la segunda compilación. Este artículo explica tres conceptos fundamentales: imagen, contenedor y capas, acompañados de ejemplos reales para construir una imagen de una aplicación Spring Boot. Después de leer, podrás escribir Dockerfiles razonables, sabrás cómo aprovechar el caché de capas para compilar más rápido, y evitarás algunos errores comunes al containerizar aplicaciones Java.
2. ¿Qué es una imagen?
Una imagen es una instantánea de solo lectura que contiene todo lo necesario para ejecutar la aplicación: sistema operativo base, runtime (como JVM), librerías, variables de entorno y tu código. La imagen no se ejecuta por sí sola — es como una “plantilla” a partir de la cual se crean contenedores.
Defines una imagen mediante un Dockerfile:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Explicación línea por línea:
FROM: selecciona la imagen base (JRE 21 en Temurin). Todo lo que construyas luego se basa en esta.WORKDIR: establece el directorio de trabajo dentro del contenedor; los comandos posteriores se ejecutarán relativos a este directorio.COPY: copia el archivo jar ya compilado (demvn package) en la imagen.ENTRYPOINT: comando que se ejecuta cuando el contenedor se inicia.
Construye la imagen con el comando:
docker build -t order-service:1.0 .
Resultado:
[+] Building 3.2s (8/8) FINISHED
=> [1/3] FROM eclipse-temurin:21-jre
=> [2/3] WORKDIR /app
=> [3/3] COPY target/order-service.jar app.jar
=> exporting to image
=> => naming to docker.io/library/order-service:1.0
La imagen se guarda localmente en este punto; puedes ver la lista con docker images.
Nota: Una imagen es solo un archivo estático. Ejecutar
docker buildvarias veces con el mismo Dockerfile y código sin cambios no creará una aplicación en ejecución — para ejecutar, necesitas crear un contenedor a partir de la imagen.
3. ¿Qué es un contenedor?
Un contenedor es una instancia en ejecución (o detenida) creada a partir de una imagen. Si la imagen es una “clase”, el contenedor es un “objeto” — puedes crear múltiples contenedores a partir de la misma imagen, cada contenedor es independiente en cuanto a procesos, red y sistema de archivos escribible (capa de escritura).
Crear y ejecutar un contenedor:
docker run -d --name order-svc -p 8080:8080 order-service:1.0
-d: ejecuta en modo desapegado (background).--name: asigna un nombre al contenedor para fácil administración, en lugar de usar un ID aleatorio.-p 8080:8080: mapea el puerto 8080 del host al puerto 8080 dentro del contenedor.
Verifica los contenedores en ejecución:
docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
3f2a9c1b8d4e order-service:1.0 Up 5 seconds 0.0.0.0:8080->8080/tcp order-svc
Puedes ejecutar un segundo contenedor a partir de la misma imagen, solo necesitas cambiar el nombre y el puerto:
docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0
Estos dos contenedores son completamente independientes: si un contenedor falla o escribes archivos en su sistema de archivos, el otro contenedor no se ve afectado. La imagen original (order-service:1.0) permanece sin cambios — esa es la naturaleza de solo lectura de la imagen.
Detén y elimina el contenedor cuando no lo necesites:
docker stop order-svc && docker rm order-svc
3.1. Cómo difiere un contenedor de una máquina virtual (VM)
Los contenedores comparten el kernel con el host, no virtualizan todo el sistema operativo como lo hace una VM. Por eso los contenedores se inician en cuestión de segundos (como en el registro de compilación anterior) en lugar de decenas de segundos como una VM, y consumen muchos menos recursos.
4. ¿Qué son las capas y por qué son importantes?
Cada instrucción en un Dockerfile (FROM, COPY, RUN, ADD…) crea una capa separada. Estas capas se apilan una encima de la otra (union filesystem), y Docker cachea cada capa — si una capa no ha cambiado desde la compilación anterior, Docker reutiliza el caché en lugar de recompilar.
Considera un Dockerfile escrito de manera no óptima:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]
Problema: COPY . . copia todo el código fuente, incluso cuando solo cambies una línea de comentario. Dado que la capa COPY cambia, Docker se ve obligado a recompilar la capa RUN mvn package que la sigue — consuma varios minutos cada vez, aunque la lógica del código sea la misma.
La forma óptima es separar la parte de dependencias (que rara vez cambia) primero, y poner la parte del código (que cambia frecuentemente) después:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Explicación:
COPY pom.xml .seguido deRUN mvn dependency:go-offline: esta capa solo cambia cuando agregas/eliminas dependencias enpom.xml. La mayoría del tiempo, esta capa se cachea — salta el paso de descarga de librerías (que suele llevar más tiempo).COPY src ./srcviene después: solo esta capa y las posteriores se recompilan cuando modificas el código; la capa de dependencias sigue siendo cacheada.- Compilación multi-etapa (
AS buildseguida deFROM ... jre): la etapa final solo toma el archivo jar compilado, sin llevar el JDK y el caché de Maven — la imagen de producción es significativamente más pequeña.
Ve las capas de una imagen con:
docker history order-service:1.0
IMAGE CREATED CREATED BY SIZE
a1b2c3d4e5f6 3 minutes ago ENTRYPOINT ["java" "-jar"...] 0B
7f8e9d0c1b2a 3 minutes ago COPY /app/target/order-ser… 45.2MB
...
Cada línea corresponde a una capa, con el tamaño que agrega. La capa COPY jar ocupa la mayoría del espacio — esto tiene sentido porque contiene el código real de la aplicación.
4.1. Cómo funciona el caché de capas cuando se compila de nuevo
Supón que solo modificas una clase Java y vuelves a compilar:
docker build -t order-service:1.1 .
=> [build 1/5] FROM eclipse-temurin:21-jdk
=> CACHED [build 2/5] COPY pom.xml .
=> CACHED [build 3/5] RUN mvn dependency:go-offline
=> [build 4/5] COPY src ./src
=> [build 5/5] RUN mvn package -DskipTests
=> [stage-1 2/2] COPY --from=build /app/target/order-service.jar app.jar
Las dos líneas CACHED muestran que Docker salta la descarga de dependencias — la compilación es notablemente más rápida que con el enfoque COPY . . original.
Nota: Docker compara el caché basándose en el contenido del archivo y el orden de las instrucciones, no en la marca de tiempo. Si cambias el orden de
COPYoRUN, todas las capas desde ese punto en adelante se invalidarán, incluso si el contenido es esencialmente el mismo que antes.
5. Verificación mediante pruebas automatizadas
Cuando containerizas un servicio, generalmente quieres asegurar que la imagen compilada funcione correctamente y pase la verificación de salud. Usa la librería Testcontainers para escribir pruebas que confirmen que el contenedor se inicia y devuelve la respuesta correcta.
Agrega la dependencia:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
Escribe la prueba:
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import static org.assertj.core.api.Assertions.assertThat;
class OrderServiceContainerTest {
@Test
void containerShouldRespondOnHealthEndpoint() {
try (GenericContainer<?> container =
new GenericContainer<>("order-service:1.0")
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health"))) {
container.start();
assertThat(container.isRunning()).isTrue();
assertThat(container.getMappedPort(8080)).isPositive();
}
}
}
GenericContainercrea un contenedor temporal a partir de la imagenorder-service:1.0; se limpia automáticamente cuando el bloquetrytermina.waitingFor(Wait.forHttp(...)): la prueba espera hasta que el endpoint de salud devuelva 2xx antes de considerar que el contenedor está listo, para evitar condiciones de carrera.getMappedPort(8080): Docker mapea un puerto aleatorio en el host; la prueba obtiene el puerto real para hacer llamadas posteriores si es necesario.
Ejecutar esta prueba confirma que la imagen que compilaste en la Sección 2 no solo se compila exitosamente sino que también se ejecuta correctamente — un paso de verificación que muchas personas ignoran.
6. Errores comunes
- Confundir imagen con contenedor al limpiar: ejecutar
docker rmelimina el contenedor, pero la imagen persiste y sigue ocupando espacio. Para eliminar la imagen, usadocker rmi. - Copiar todo el código fuente antes de instalar dependencias: como se mencionó en la Sección 4, pierdes el beneficio del caché de capas, lo que resulta en una compilación innecesariamente lenta.
- Usar una imagen base demasiado pesada (por ejemplo,
ubuntucompleto) para JRE en tiempo de ejecución: la imagen de producción se vuelve voluminosa, aumenta el tiempo de descarga/despliegue. Deberías usar una imagen JRE minimalista comoeclipse-temurin:21-jreo la versión-alpinesi es compatible. - Construir una imagen nueva sin cambiar la etiqueta: reutilizar la etiqueta
latestdificulta revertir cuando hay errores en producción, porque no sabes exactamente qué imagen está en ejecución.
Cuándo no necesitas compilación multi-etapa: si compilas el jar previamente en CI (por ejemplo, GitHub Actions) y luego ejecutas
docker build, no necesitas la etapabuilden el Dockerfile — solo necesitasCOPYdel archivo jar precompilado como en el ejemplo de la Sección 2. Solo necesitas compilación multi-etapa cuando quieres que Docker compile automáticamente el código desde la fuente.
7. Conclusión
Una imagen es una plantilla estática, un contenedor es una instancia en ejecución a partir de esa imagen, y las capas son capas que se apilan que ayudan a Docker a cachear inteligentemente para compilar más rápido. Dominar el orden de las capas en un Dockerfile — colocar la parte que rara vez cambia (dependencias) primero, y la parte que cambia frecuentemente (código fuente) después — es la forma más simple de optimizar el tiempo de compilación. Desde esta base, puedes avanzar a temas como Docker Compose para orquestar múltiples servicios, u optimizar el tamaño de la imagen usando imágenes base distroless.