Images, layers and Dockerfiles

The union filesystem, layer caching, .dockerignore, and the Dockerfile order that makes rebuilds fast.

5 min read🐳 Docker

A container is a process with a restricted view of the machine: its own filesystem, its own network namespace, its own process table, and limits on memory and CPU that the kernel enforces. An image is the filesystem that process starts from, built once and shipped everywhere. Neither is a virtual machine — there is no guest kernel, which is why a container starts in milliseconds and why "it works in Docker" means "it works on this kernel with this filesystem", nothing more.

Images are layers

A Dockerfile is a list of steps, and every step that changes the filesystem produces a layer: a read-only diff over the one before it. The image is the stack; a container is the stack plus one writable layer on top that vanishes when the container is removed.

Dockerfile, first attemptdockerfile
FROM eclipse-temurin:21-jre
COPY . /app                              # layer: the whole repository
RUN cd /app && ./mvnw package -DskipTests   # layer: the build, and Maven's whole cache
CMD ["java", "-jar", "/app/target/app.jar"]

Layers are content-addressed and shared: two images from the same FROM line share those layers on disk and over the network, and a pull fetches only what the host does not have. The consequence that shapes every Dockerfile is the build cache: a step is re-run only if its inputs changed — its command, and for COPY/ADD the files' contents — and once one step re-runs, every step after it re-runs too. The first attempt above copies the whole repository before building, so a one-line change to a lesson file invalidates the copy, which invalidates the build, which re-downloads every dependency.

Cache order: what changes least goes first

Dockerfile, ordered by rate of changedockerfile
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw -B -q dependency:go-offline        # cached until pom.xml changes
COPY src src
RUN ./mvnw -B -q package -DskipTests           # re-runs on a source change; dependencies are already here

The POM changes weekly, the sources change hourly, so the dependency download sits above the source copy and survives it. The same principle decides everything else in the file: base image, then OS packages, then dependencies, then code, then configuration. .dockerignore is the other half — target/, node_modules/, .git/, *.md — because a file that is not sent to the daemon cannot invalidate a COPY.

The base image

FROM is the decision with the longest consequences. Three questions:

  • JDK or JRE? A build needs javac; a running service needs only the runtime. eclipse-temurin:21-jre is roughly a third the size of the JDK image, and a smaller image is a smaller attack surface and a faster pull. jlink can go further — a runtime with only the modules the application uses.
  • Which OS? -jammy/-noble (Ubuntu) and -alpine are the usual tags. Alpine is small and uses musl instead of glibc, which most Java code never notices and some native libraries do; Temurin's Alpine builds are fine for a Spring service, and Distroless (gcr.io/distroless/java21) has no shell at all, which is the most secure and the hardest to debug.
  • Which tag? eclipse-temurin:21 moves with every patch release; 21.0.5_11-jre-jammy does not. Pin, and let a bot (Dependabot handles Dockerfiles) open the upgrade — the same rule as the Testcontainers lesson's postgres:16-alpine. This repository's runner image pins its base and every jar it bakes in, and says why in the file: a learner compiling against one Spring version while the harness has another is a failure that looks like the learner's mistake.

Multi-stage builds: build in one image, ship another

The JDK, Maven's cache and the sources are needed to build the jar and are dead weight around it. A multi-stage build uses one image to build and copies only the result into another:

Dockerfile, completedockerfile
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw -B -q dependency:go-offline
COPY src src
RUN ./mvnw -B -q package -DskipTests
 
FROM eclipse-temurin:21-jre-jammy
RUN useradd --system --uid 10001 app
WORKDIR /app
COPY --from=build --chown=app:app /src/target/app.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

The final image has the JRE and one jar; the build stage is discarded. The Java-in-a-container lesson improves the last COPY with Spring Boot's layered jar, so that the dependencies and the application code are separate layers and a code change ships a few hundred kilobytes rather than the whole jar.

Not root

By default a container's process runs as root inside the container, which is root on the host's kernel with a narrower view. A vulnerability that lets an attacker run a command in the container then runs it as root, and container escapes are easier from there. USER app after a useradd is the fix, and it has consequences you meet immediately: the process cannot bind port 80 (use 8080 and let the load balancer map it), cannot write outside directories you chowned, and cannot install packages at run time, which it should not be doing anyway. This repository's runner goes one step further for the code it executes on behalf of learners — every submission container runs as 65534 (nobody) with --network=none, so the image bakes in every jar a problem could need, because at run time there is no way to fetch one.

Two more lines that belong in every service image: ENTRYPOINT in the exec form (["java", ...], a JSON array) so that Java is PID 1 and receives SIGTERM directly rather than through a shell that swallows it; and no credentials in any layer — a copied .env or a build argument holding a token is in the image's history forever, readable by anyone who can pull it.

Progress is saved on this device and to your account when signed in.