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 (frommvn 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 buildmultiple 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 byRUN mvn dependency:go-offline: this layer only changes when you add/remove dependencies inpom.xml. Most of the time, this layer is cached — skipping the library download step (usually the most time-consuming).COPY src ./srcplaced later: only this layer and those after it are rebuilt when you modify code; the dependency layer keeps its cache.- Multi-stage build (
AS buildthenFROM ... 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
COPYorRUNcommands, 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();
}
}
}
GenericContainercreates a temporary container from theorder-service:1.0image and automatically cleans up when thetryblock 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 rmdeletes containers, but images remain and still consume space. To delete an image, usedocker 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 likeeclipse-temurin:21-jreor-alpinevariants if compatible. - Building a new image without changing the tag: reusing the
latesttag 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 abuildstage in your Dockerfile — justCOPYthe 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.