Docker Basics: Image, Container, and Layer

1. Overview

Many newcomers to Docker confuse images and containers, or don’t understand why docker build is so much faster on the second build. This article explains three fundamental concepts: image, container, and layer — with examples of building an image for a real Spring Boot application. After reading, you’ll be able to write reasonable Dockerfiles yourself, know how to leverage layer caching for faster builds, and avoid common mistakes when containerizing Java applications.

2. What is an Image

An image is a read-only snapshot containing everything needed to run an application: a base operating system, a runtime (such as a JVM), libraries, environment variables, and your code. Images don’t run on their own — they’re like a “template” from which you create containers.

You define an image using a Dockerfile:

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

Explanation of each line:

  • FROM: select the base image (JRE 21 on Temurin). Everything you build on top sits on this foundation.
  • WORKDIR: set the working directory in the container; subsequent commands will run relative to this directory.
  • COPY: copy the already-built jar file (from mvn package) into the image.
  • ENTRYPOINT: the command to run when the container starts.

Build the image with:

docker build -t order-service:1.0 .

Result:

[+] 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

The image is now saved locally; you can view the list with docker images.

Note: An image is just a static file. Running docker build multiple times with the same Dockerfile and unchanged code will not create a running application — to run it, you need to create a container from the image.

3. What is a Container

A container is a running (or stopped) instance created from an image. If an image is a “class,” a container is an “object” — you can create many containers from the same image, each independent in terms of process, network, and writable filesystem layer.

Create and run a container:

docker run -d --name order-svc -p 8080:8080 order-service:1.0
  • -d: run in detached mode (background).
  • --name: give the container a name for easier management instead of using a random ID.
  • -p 8080:8080: map port 8080 on the host to port 8080 in the container.

Check running containers:

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

You can run a second container from the same image; just change the name and port:

docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0

These two containers are completely independent: if one crashes or you write files to its filesystem, the other is unaffected. The original image (order-service:1.0) remains unchanged — that’s the read-only nature of images.

Stop and remove the container when no longer needed:

docker stop order-svc && docker rm order-svc

3.1. How Containers Differ from Virtual Machines (VMs)

Containers share the kernel with the host and don’t virtualize the entire operating system like VMs do. As a result, containers start in seconds (like the build log above) rather than tens of seconds like VMs, and use far fewer resources.

4. What are Layers and Why They Matter

Each instruction in a Dockerfile (FROM, COPY, RUN, ADD…) creates a separate layer. These layers stack on top of each other (union filesystem), and Docker caches each layer — if a layer hasn’t changed from the previous build, Docker reuses the cache instead of rebuilding it.

Consider a suboptimal Dockerfile:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]

The issue: COPY . . copies the entire source code, even if you only modify a single comment line. Because the COPY layer changes, Docker must rebuild the RUN mvn package layer that follows — taking several minutes each time, even though the logic hasn’t changed.

The optimal approach is to separate the dependencies (which rarely change) first, and place the code (which changes frequently) later:

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"]

Explanation:

  • COPY pom.xml . followed by RUN mvn dependency:go-offline: this layer only changes when you add/remove dependencies in pom.xml. Most of the time, this layer is cached — skipping the library download step (usually the most time-consuming).
  • COPY src ./src placed later: only this layer and those after it are rebuilt when you modify code; the dependency layer keeps its cache.
  • Multi-stage build (AS build then FROM ... jre): the final stage only takes the built jar file, leaving behind the JDK and Maven cache — the production image is noticeably smaller.

View the layers of an image with:

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
...

Each line corresponds to a layer, along with its size contribution. The jar COPY layer takes up the most space — this makes sense since it contains the actual application code.

4.1. How Layer Cache Works During Rebuilds

Suppose you modify just one Java class and rebuild:

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

The two CACHED lines show that Docker skipped downloading dependencies — the build is noticeably faster compared to the initial COPY . . approach.

Note: Docker compares cache based on file content and instruction order, not timestamps. If you change the order of COPY or RUN commands, all layers from that point onward will be invalidated, even if the actual content is identical to the previous build.

5. Verify with Automated Tests

When containerizing a service, you typically want to ensure that the built image runs correctly and passes health checks. Use the Testcontainers library to write tests confirming that the container starts up and responds correctly.

Add the dependency:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>

Write the test:

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 creates a temporary container from the order-service:1.0 image and automatically cleans up when the try block exits.
  • waitingFor(Wait.forHttp(...)): the test waits until the health endpoint returns 2xx before considering the container ready, avoiding race conditions.
  • getMappedPort(8080): Docker maps a random port on the host; the test retrieves the actual port for further calls if needed.

Running this test confirms that the image you built in Section 2 not only builds successfully but also actually runs correctly — a verification step many skip.

6. Common Mistakes

  • Confusing images with containers during cleanup: running docker rm deletes containers, but images remain and still consume space. To delete an image, use docker rmi.
  • Copying the entire source before installing dependencies: as discussed in Section 4, this wastes layer caching benefits and causes unnecessary slow builds.
  • Using overly heavy base images (e.g., full ubuntu) for JRE runtime: the production image balloons, increasing pull and deployment times. Use minimal JRE images like eclipse-temurin:21-jre or -alpine variants if compatible.
  • Building a new image without changing the tag: reusing the latest tag makes it hard to rollback if production breaks, since you won’t know exactly which image is running.

When you don’t need multi-stage builds: if you build the jar beforehand in CI (e.g., GitHub Actions) and then run docker build, you don’t need a build stage in your Dockerfile — just COPY the pre-built jar as shown in Section 2. Multi-stage is only needed when you want Docker to compile code from source.

7. Conclusion

An image is a static template, a container is a running instance of that image, and layers are stacked components that enable Docker’s intelligent caching for faster builds. Understanding the layer order in your Dockerfile — placing rarely-changing parts (dependencies) first and frequently-changing parts (source code) later — is the simplest way to optimize build time. From this foundation, you can move on to topics like Docker Compose for orchestrating multiple services, or optimizing image size using distroless bases.

dockerdevopscontainerimagespring-boot