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 (de mvn 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 build varias 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 de RUN mvn dependency:go-offline: esta capa solo cambia cuando agregas/eliminas dependencias en pom.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 ./src viene 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 build seguida de FROM ... 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 COPY o RUN, 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();
        }
    }
}
  • GenericContainer crea un contenedor temporal a partir de la imagen order-service:1.0; se limpia automáticamente cuando el bloque try termina.
  • 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 rm elimina el contenedor, pero la imagen persiste y sigue ocupando espacio. Para eliminar la imagen, usa docker 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, ubuntu completo) 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 como eclipse-temurin:21-jre o la versión -alpine si es compatible.
  • Construir una imagen nueva sin cambiar la etiqueta: reutilizar la etiqueta latest dificulta 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 etapa build en el Dockerfile — solo necesitas COPY del 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.

dockerdevopscontainerimagespring-boot