DevOps · 02

Docker Containers

Docker is a thin layer of convenience over Linux kernel features, and most of its surprises make sense once you see that. This page builds the model from the kernel up (namespaces, cgroups, layered filesystems), then works through the Dockerfile, the build cache and BuildKit, four production-grade Dockerfiles for a Spring Boot service that were built and run for this page with real sizes, how the JVM behaves under a memory limit, networking, storage, Compose with a health-gated database, tagging, security and troubleshooting.

13
Chapters
29.4
Docker Engine tested
4
Spring Boot images built
7
Diagrams
01
Chapter One - The Mental Model

What a Container Really Is

A container is not a small virtual machine. A VM emulates hardware and boots its own kernel. A container is a normal Linux process (or a few) that the kernel has been asked to treat specially in three ways: it is shown a restricted view of the system, it is given a resource budget, and it is given its own root filesystem. Everything else, the kernel, the scheduler, the network stack, is shared with the host and every other container.

A container is a process with a restricted view (namespaces), a budget (cgroups) and its own root (overlayfs)
Container A java -jar application.jar PID 1 inside, an ordinary PID on the host Container B postgres sees only its own processes, network, mounts What the process can SEE (namespaces) pid net mnt uts ipc cgroup user time What the process can USE (cgroups v2) memory.max cpu.max pids.max io.max What the process thinks its / is (overlayfs, copy-on-write) image layers (read-only) + container layer (read-write) = merged / seen by the process ONE shared Linux kernel. No hypervisor, no guest OS, no second kernel to boot.
Kernel featureWhat it doesWhat you see in practice
NamespacesGive a process a private copy of a global resource: process IDs (pid), network stack (net), mount table (mnt), hostname (uts), IPC objects (ipc), cgroup root (cgroup), user IDs (user), clock offsets (time)ps inside the container shows only your process as PID 1; ip addr shows only eth0 and lo
cgroups (v2)Limit and account for CPU, memory, process count and I/O of a group of processesdocker run -m 512m writes 536870912 into the container's memory.max; exceeding it gets the process killed by the kernel (OOM killer)
Union filesystem (overlayfs)Stack read-only image layers and one writable layer into a single merged /, with copy-on-writeEdits inside a container never change the image; deleting the container deletes the writable layer
Capabilities, seccomp, AppArmorCut down which privileged operations and system calls the process may useA default container is not allowed to load kernel modules or change the system clock (chapter 10)
Seeing it on a real machine

The commands below were run on this machine (Docker Engine 29.4.1, cgroup v2). Each namespace is an inode number in /proc/self/ns. A container gets new numbers for most namespaces; the host keeps the originals.

Shell - namespace IDs of a container versus the host (abridged to the interesting lines)
$ docker run --rm ubuntu:24.04 ls -l /proc/self/ns cgroup -> cgroup:[4026535244] ipc -> ipc:[4026535242] mnt -> mnt:[4026535240] net -> net:[4026535245] pid -> pid:[4026535243] user -> user:[4026531837] uts -> uts:[4026535241] $ ls -l /proc/self/ns # on the host cgroup -> cgroup:[4026531835] ipc -> ipc:[4026531839] mnt -> mnt:[4026531832] net -> net:[4026531833] pid -> pid:[4026531836] user -> user:[4026531837] uts -> uts:[4026531838]

Six namespaces differ, so the container has its own PIDs, network, mounts, hostname, IPC and cgroup view. **The user namespace is identical to the host's.** By default Docker does not remap user IDs, so UID 0 inside the container is UID 0 on the host, just with fewer privileges. That is why running as a non-root user (chapter 10) matters, and why rootless mode and user-namespace remapping exist.

Shell - the budget, and the same process as seen from inside and outside
$ docker compose up -d # the app of chapter 8, limited to 512 MiB and 1 CPU $ docker exec shop-app-1 ps -o pid,ppid,user,args PID PPID USER COMMAND 1 0 app java -XX:MaxRAMPercentage=75 -jar application.jar 72 0 app ps -o pid,ppid,user,args $ docker exec shop-app-1 cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max 536870912 100000 100000 $ docker inspect -f '{{.State.Pid}}' shop-app-1 # the same process, as the host numbers it 2482937

cpu.max is quota period in microseconds: 100000 100000 means "100 ms of CPU time per 100 ms window", one CPU. The Java process is PID 1 in its own namespace and PID 2482937 on the host. Same process, two numbers.

How docker run gets there
docker CLI
▶
Sends the request to the Docker daemon (dockerd) over a Unix socket (/var/run/docker.sock). Whoever can write to that socket effectively controls the host
dockerd
▶
Handles images, networks, volumes and the API; delegates running containers to containerd
containerd
▶
Manages image content and snapshots (the layers) and starts a small shim per container
runc
▶
The OCI runtime. It calls the kernel to create the namespaces and cgroups, sets up the root filesystem, drops privileges and execs your process. Then it exits; the shim stays as the container's parent

The pieces are specified by the Open Container Initiative (OCI): the image spec (what an image is), the runtime spec (how to run one) and the distribution spec (how registries serve them). That is why an image built with Docker runs on containerd, Podman or Kubernetes without change. On macOS and Windows, Docker Desktop runs a small Linux VM, because the container features above are Linux kernel features.

Key insight: A container is a process in a hotel room. The room has its own door number, its own view (namespaces), a meter limiting power and water (cgroups), and its own furniture layout (overlayfs). The building, the plumbing and the electricity grid (the kernel) are shared by every room. A VM would be a separate house with its own plumbing.
⚠ Treating a container as a security boundary equal to a VM
  • All containers share one kernel. A kernel vulnerability, a container started with --privileged, or a mounted docker.sock breaks the isolation. Containers are good isolation for packaging and resource control; for hostile multi-tenant code use the hardening in chapter 10, and consider sandboxed runtimes or VMs.
02
Chapter Two - Images

Images, Layers, Digests and Tags

An image is an immutable bundle: a manifest (the list of parts), a config (default command, environment, user, history) and an ordered list of layers. Each layer is a tar archive of filesystem changes. When a container starts, the runtime stacks the layers read-only and adds one empty writable layer on top.

An image is a manifest pointing at a config and a stack of content-addressed layers
Tag eclipse-temurin:21-jre a movable label points to Manifest sha256:0b99b3... the digest: hash of the manifest Config: env, entrypoint, user, history one JSON blob, also hashed Layer 4 COPY application/ sha256:9e1c... Layer 3 COPY dependencies/ sha256:44ad... Layer 2 RUN useradd app sha256:b07f... Layer 1 base OS + JRE sha256:7d3a... Container layer (read-write) Each layer is a tar of file changes. Its name is the hash of its content, so identical content is stored once and shared by every image that contains it. Read-only, shared Per container, discarded
ConceptWhat it isMutable?
LayerTar archive of added, changed and deleted files, identified by the SHA-256 of its contentNo: change one byte and it becomes a different layer
ManifestJSON listing the config and layer digests (OCI image spec). A multi-platform image has an index pointing at one manifest per architectureNo
Digestsha256: hash of the manifest. image@sha256:... names exactly one image foreverNo
TagA human-friendly name (21-jre, 1.4.2, latest) that a registry maps to a manifest digestYes: the owner can repoint it any time
Image IDLocal identifier (hash of the config); what docker images showsNo
Layers are content-addressed and copy-on-write

Because a layer is named by its hash, two images that start from the same base store the base once on disk, and a registry transfers only the layers you do not have yet. That is why pulling a new version of your app is quick if only the top layer changed, and why chapter 4 spends so much effort on layer order.

Deleting a file in a later layer does not shrink the image. The earlier layer still contains it, and the later layer only adds a marker (a whiteout, a file named .wh.<name>). Chapter 10 shows what that means for secrets.

Shell - looking at an image
$ docker pull eclipse-temurin:21-jre 21-jre: Pulling from library/eclipse-temurin Digest: sha256:0b99b3376fbb50dbf9c0af8535fdd5b8eb0d3fdfc8b1e4967f383b6919f6901a Status: Downloaded newer image for eclipse-temurin:21-jre $ docker history shop:2-layered --format 'table {{.Size}}\t{{.CreatedBy}}' # abridged, newest layer first SIZE CREATED BY 0B ENTRYPOINT ["java" "-XX:MaxRAMPercentage=75"... 16.4kB COPY /build/extracted/application/ ./ 4.1kB COPY /build/extracted/snapshot-dependencies/ ./ 4.1kB COPY /build/extracted/spring-boot-loader/ ./ 25.7MB COPY /build/extracted/dependencies/ ./ 41kB RUN /bin/sh -c groupadd --system --gid 10001 ...

The digest above is the one this machine received on 2026-10-31. The 21-jre tag will point at a different digest after the next Temurin release, which is exactly the point of the next section.

Tags move, digests do not
You writeYou getUse it for
eclipse-temurin:21-jreWhatever the tag points to today: new patch releases and a possibly different base OS (on this machine it resolved to Ubuntu 26.04) at the next pullLocal experiments
eclipse-temurin:21.0.11_10-jreA specific build, though a tag is still repointable in principleReproducible-enough builds, with a bot (Renovate, Dependabot) to bump it
eclipse-temurin@sha256:0b99b3...Exactly one image, byte for byteProduction deployments and base-image pinning in security-sensitive pipelines
myapp:latestlatest is just a tag name, the default when you omit one. It does not mean newestNothing in production
⚠ The latest trap
  • latest is whatever was pushed last without a tag, or whatever the publisher decided. Two nodes pulling myapp:latest at different times run different code, a rollback cannot go "back to latest", and docker pull on a restarted node silently upgrades you. Deploy immutable version tags or digests (chapter 9), never latest.
03
Chapter Three - The Dockerfile

Dockerfile Instruction Reference

A Dockerfile is a recipe. Each instruction that changes the filesystem (RUN, COPY, ADD) produces a layer; the others (ENV, CMD, USER, ...) change the image config. Defaults below were checked against the Dockerfile reference on docs.docker.com.

InstructionWhat it doesNotes and gotchas
FROM image[:tag|@digest] AS nameStarts a build stage from a base imageSeveral FROMs make a multi-stage build (chapter 4). Pin the tag; FROM scratch is an empty image
RUN cmdRuns a command at build time and commits the filesystem change as a layerChain related commands with && so cleanup happens in the same layer
COPY src destCopies files from the build context (or --from=stage)Preferred over ADD. --chown, --chmod, --link are available
ADD src destLike COPY, plus it can fetch a URL and auto-extract local tar archivesThe surprise features are why COPY is the default choice. Use ADD deliberately (for example --checksum on a URL)
WORKDIR /pathSets the working directory for later instructions and for the running containerCreates the directory if needed. Avoid RUN cd ... && across instructions
ENV KEY=valueSets an environment variable in the image and in the running containerVisible in docker inspect; not for secrets
ARG NAME=defaultBuild-time variable, set with --build-argGone at runtime, but **recorded in docker history**; never pass secrets this way
EXPOSE 8080Documents the port the app listens onDoes not publish anything. Publishing is -p at run time
USER uid:gidSets the user for later RUNs and for the container processPrefer a numeric UID so Kubernetes runAsNonRoot can verify it
HEALTHCHECKDefines a command Docker runs to decide healthy or unhealthyNeeds the tool (such as curl) inside the image; distroless images have none
ENTRYPOINT / CMDWhat runs when the container startsSee below: form and combination matter
STOPSIGNAL, VOLUME, LABEL, SHELL, ONBUILDStop signal, declared volume mount points, metadata, default shell for shell form, triggers for child imagesVOLUME in a Dockerfile makes an anonymous volume per container; most teams declare volumes at run time instead
CMD versus ENTRYPOINT

ENTRYPOINT is what the container is; CMD is the default arguments (or the default command if there is no entrypoint). Arguments after the image name in docker run replace CMD but are appended to ENTRYPOINT. --entrypoint replaces the entrypoint.

Dockerfiledocker run imgdocker run img --server.port=9090
ENTRYPOINT ["java","-jar","app.jar"]runs java -jar app.jarruns java -jar app.jar --server.port=9090 (appended)
CMD ["java","-jar","app.jar"]runs java -jar app.jartries to run --server.port=9090 as a command and fails (replaced)
ENTRYPOINT ["java","-jar","app.jar"] + CMD ["--spring.profiles.active=dev"]runs java -jar app.jar --spring.profiles.active=devthe CMD default is replaced by your argument
Shell form versus exec form, and why PID 1 matters

CMD java -jar app.jar (shell form) is run as /bin/sh -c "java -jar app.jar". CMD ["java","-jar","app.jar"] (exec form, a JSON array) runs the program directly. The difference decides who is PID 1, and PID 1 is special: the kernel gives it no default signal handlers, so a SIGTERM sent by docker stop is ignored unless the process installs a handler itself. A shell does not forward signals to its child.

We measured it with docker stop (default grace period 10 s, then SIGKILL) on this machine:

ContainerPID 1 wasdocker stop tookExit code
CMD sleep 300 (shell form)/bin/sh -c sleep 30010.5 s137 (killed)
CMD ["sleep","300"] (exec form)sleep10.5 s137: sleep has no SIGTERM handler, and PID 1 ignores signals without one
ENTRYPOINT ["/start.sh"] that runs sleep 300/bin/sh /start.sh10.5 s137
docker run --init with the exec formdocker-init, with sleep as its child0.6 s143 (SIGTERM)
Spring Boot, exec form ENTRYPOINT ["java","-jar",...]java1.1 s143, graceful shutdown logged
Spring Boot, shell form ENTRYPOINT java -jar ... && echo done/bin/sh -c ..., java was PID 710.7 s137

So the rule is not just "use exec form". It is: the process that is PID 1 must handle SIGTERM. The JVM does (it runs shutdown hooks, Spring Boot closes the web server gracefully and exits 143). A shell, sleep or a script does not. Fixes, in order of preference: exec form with a program that handles signals; exec java ... as the last line of any wrapper script so the shell replaces itself; or docker run --init / init: true in Compose to put a tiny init (tini, built into Docker) in front.

Dockerfile - a wrapper script that does not swallow signals
#!/bin/sh # entrypoint.sh: prepare things, then REPLACE the shell with the app set -eu echo "starting with profile ${SPRING_PROFILES_ACTIVE:-default}" exec java -XX:MaxRAMPercentage=75 -jar /application/application.jar "$@"
The .dockerignore file

The build context is the directory you pass to docker build (usually .). It is sent to the builder in full, so a missing .dockerignore means .git, target/, node_modules and .env travel with every build and can end up inside a COPY . . layer. A .dockerignore file in the context root lists what to leave out; later !pattern lines add exceptions.

.dockerignore used by the sample project
target/* !target/*.jar .git/ .idea/ *.md Dockerfile* compose.yaml
⚠ Ignoring the very thing you COPY
  • The first version of this sample had target/ in .dockerignore and COPY target/shop-1.0.0.jar in the Dockerfile. The build failed with "/target/shop-1.0.0.jar": not found, because the file was never sent to the builder. Hence the target/* plus !target/*.jar pair above. When a COPY says "not found" for a file that exists, check .dockerignore first.
04
Chapter Four - Building

The Build Cache, BuildKit and Multi-Stage Builds

BuildKit is the build engine behind docker build (the default since Docker Engine 23.0) and behind docker buildx build. It runs independent stages in parallel, skips stages nobody needs, and offers features the classic builder never had: cache mounts, secret mounts and multi-platform output. The first line # syntax=docker/dockerfile:1 selects the current Dockerfile frontend so newer syntax works on any recent engine.

Cache invalidation: one changed instruction re-runs everything after it
Naive order: copy everything, then build FROM maven COPY . . RUN mvn package (re-downloads every dependency) Any edit to any file changes the COPY layer, so the slow dependency download runs every time. Cache-friendly order: slow, stable things first FROM maven COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package Edit a Java file: only the red steps run. Edit pom.xml: everything below it runs. green = CACHED red = re-executed Rule: an instruction is reused only if it and every instruction before it are unchanged (for COPY, "unchanged" means the checksum of the copied files).
Layer cache rules
  • Docker compares each instruction with the cache. For RUN it compares the instruction text (not what the command would produce); for COPY and ADD it compares a checksum of the files copied.
  • As soon as one instruction misses the cache, every later instruction runs again.
  • So order instructions from least to most frequently changing: base image, OS packages, dependency manifest, dependency download, source code, build.
  • A RUN apt-get update && apt-get install -y x stays cached forever, even when packages have moved on. Rebuild with --pull --no-cache on a schedule so base images and packages get refreshed.

Measured with the multi-stage Dockerfile of chapter 5 (warm Maven cache mount, this machine): editing one Java file left COPY pom.xml, mvn dependency:go-offline and the dependency, loader and snapshot layers CACHED, and re-ran only COPY src, the Maven package step and the final application layer. A first, completely cold build (image pulls plus all Maven downloads) took 82 s here; rebuilds with a warm cache took roughly 14 to 25 s, dominated by the Maven package step. Treat these as one machine's numbers, not a benchmark.

BuildKit features you will actually use
FeatureSyntaxWhy
Cache mountRUN --mount=type=cache,target=/root/.m2 mvn packageKeeps the Maven or Gradle download cache between builds without putting it in any layer. Even when the layer is re-run, dependencies are not downloaded again
Secret mountRUN --mount=type=secret,id=api_token ... with docker build --secret id=api_token,src=token.txt .The secret appears as a file under /run/secrets/api_token for that one RUN and is not stored in any layer or in docker history
SSH mountRUN --mount=type=ssh git clone git@...Use your SSH agent to fetch private repositories without copying keys in
**Multi-stage + --target**docker build --target build -t app:build .Build only up to a named stage (for tests, or to debug a build stage)
Registry cache--cache-from type=registry,ref=... and --cache-to type=registry,ref=...,mode=maxCI runners start empty; export and import the cache through a registry
Attestations--sbom=true --provenance=mode=maxAttach an SBOM and build provenance to the pushed image (flags exist in docker buildx build --help here; not run for this page)
HeredocsRUN <<EOF ... EOFMulti-line scripts without a chain of && and backslashes
Shell - the secret mount, run for real
$ cat Dockerfile.good # syntax=docker/dockerfile:1 FROM alpine:3.20 RUN --mount=type=secret,id=api_token \ test -s /run/secrets/api_token && echo "used the token without storing it" $ docker build -q -f Dockerfile.good --secret id=api_token,src=token.txt -t leak:good . sha256:de0ba6db6d59... # builds; the history line shows only the RUN text, not the token
Multi-stage builds

A multi-stage Dockerfile has several FROM lines. Early stages carry the compiler, Maven, test tools and source; the final stage starts from a slim runtime image and copies in only the build output with COPY --from=build. Nothing from the earlier stages ships. The result is smaller, has fewer vulnerabilities to scan, and never contains your source or build credentials.

Dockerfile - the shape of every multi-stage build
FROM maven:3.9-eclipse-temurin-21 AS build # big: JDK + Maven + sources COPY . . RUN mvn -q -DskipTests package FROM eclipse-temurin:21-jre # small: runtime only COPY --from=build /target/app.jar /app.jar # only the artifact crosses over ENTRYPOINT ["java", "-jar", "/app.jar"]

Multi-platform images (for example linux/amd64 and linux/arm64, so the same tag runs on Intel servers and Apple Silicon or Graviton) are built with docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.com/shop:1.4.2 --push .. A multi-platform result is an image index with one manifest per architecture, so it has to be pushed (or exported as an OCI archive) rather than loaded into a single local image store. It needs a builder that supports multiple platforms (see the buildx docs; QEMU emulation or native builders). The flags above exist in the buildx installed here (v0.30.1); the multi-platform build itself was not run for this page.

05
Chapter Five - Java in Practice

Production Dockerfiles for a Spring Boot App

The sample is a small Spring Boot 3.5.16 service (Java 21, Maven) with spring-boot-starter-web, Actuator and a JdbcTemplate that talks to PostgreSQL. It exposes /hello, /memory and /actuator/health/readiness. Every Dockerfile below was built and run on this machine (Docker Engine 29.4.1). The Spring Boot facts were checked against the Spring Boot reference for 4.1.2 on docs.spring.io, and the commands were run against the 3.5.16 jar.

Image size on this machine: docker images, DISK USAGE (bar) and CONTENT SIZE (compressed)
1 naive fat jar 773 MB (content 248 MB) buildpack (build-image) 543 MB (content 263 MB) 2 layered, JRE, non-root 506 MB (content 139 MB) 3 distroless java21 311 MB (content 86.7 MB) 4 jlink + distroless cc 197 MB (content 74 MB) Sizes are as printed by docker images with the containerd image store. The app jar is 25 MB in every variant.
Variant 1: the naive fat jar
Dockerfile.1-naive
FROM eclipse-temurin:21-jdk COPY target/shop-1.0.0.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]

It works, and it is the version in most tutorials. Its problems: the JDK (compiler, jshell, tools) ships to production; it runs as root; the 25 MB jar is one layer, so changing one line of your code re-uploads and re-downloads all 25 MB; and running an uber jar un-unpacked adds startup overhead (Spring Boot docs call this out under "Efficient Container Images").

Variant 2: layered jar, JRE, non-root

Spring Boot jars built by the Maven or Gradle plugin carry a layer index (BOOT-INF/layers.idx) that splits the jar into dependencies, spring-boot-loader, snapshot-dependencies and application, ordered from least to most likely to change. A helper mode of the jar unpacks those directories so each becomes its own Docker layer.

The command changed

The current command is java -Djarmode=tools -jar application.jar extract --layers --destination extracted (Spring Boot reference, "Dockerfiles"; the jar also offers list-layers). The older -Djarmode=layertools form is gone: on the 3.5.16 jar built for this page java -Djarmode=layertools -jar shop-1.0.0.jar lists only help. Old tutorials that say layertools extract will fail on current Spring Boot.

Dockerfile.2-layered
# syntax=docker/dockerfile:1 FROM eclipse-temurin:21-jre AS extract WORKDIR /build COPY target/shop-1.0.0.jar application.jar RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted FROM eclipse-temurin:21-jre WORKDIR /application RUN groupadd --system --gid 10001 app && useradd --system --uid 10001 --gid app --no-create-home app COPY --from=extract /build/extracted/dependencies/ ./ COPY --from=extract /build/extracted/spring-boot-loader/ ./ COPY --from=extract /build/extracted/snapshot-dependencies/ ./ COPY --from=extract /build/extracted/application/ ./ USER 10001:10001 EXPOSE 8080 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "application.jar"]

The extraction runs in a throw-away first stage; the runtime stage copies the four directories in order of volatility. Checking the result with docker history: the dependency layer is 25.7 MB, the loader and snapshot layers are about 4 kB each, and the application layer is 16.4 kB. After a code change only that 16 kB layer is rebuilt, pushed and pulled.

Variant 3: Cloud Native Buildpacks, no Dockerfile at all
Shell - Spring Boot Maven plugin, Paketo buildpacks
$ mvn -q -B -DskipTests spring-boot:build-image -Dspring-boot.build-image.imageName=shop:buildpack $ docker images shop:buildpack IMAGE ID DISK USAGE CONTENT SIZE shop:buildpack c6c5a4b73685 543MB 263MB

spring-boot:build-image needs a running Docker daemon and builds an OCI image with Cloud Native Buildpacks; the default builder in the Spring Boot docs is paketobuildpacks/builder-noble-java-tiny:latest, and the resulting image runs as a non-root user (docs: "images build and run as non-root users"). It sets up the JVM memory configuration for you and layers the image sensibly. The trade-off is less control: you tune through plugin properties and BP_*/BPL_* environment variables, not Dockerfile lines. It is a good default for teams that do not want to own Dockerfiles.

Variant 4: minimal runtime (distroless, then jlink)

Smaller images mean faster pulls and fewer packages to patch. Distroless images from Google contain the runtime and its libraries but no shell and no package manager. The names below exist and were pulled for this page: gcr.io/distroless/java21-debian13:nonroot (also java21-debian12), gcr.io/distroless/cc-debian13:nonroot. The :nonroot tag runs as UID 65532.

Dockerfile.3-distroless
# syntax=docker/dockerfile:1 FROM eclipse-temurin:21-jre AS extract WORKDIR /build COPY target/shop-1.0.0.jar application.jar RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted FROM gcr.io/distroless/java21-debian13:nonroot WORKDIR /application COPY --from=extract /build/extracted/dependencies/ ./ COPY --from=extract /build/extracted/spring-boot-loader/ ./ COPY --from=extract /build/extracted/snapshot-dependencies/ ./ COPY --from=extract /build/extracted/application/ ./ EXPOSE 8080 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "application.jar"]

For the last step, jlink builds a custom JRE containing only the JDK modules the application needs, which can then sit on a minimal base such as distroless/cc (it provides glibc and libstdc++ but no Java).

Dockerfile.4-jlink
# syntax=docker/dockerfile:1 FROM eclipse-temurin:21-jdk AS build WORKDIR /build COPY target/shop-1.0.0.jar application.jar RUN java -Djarmode=tools -jar application.jar extract --layers --destination extracted # Ask jdeps which JDK modules the app jar and its dependency jars use, then build a trimmed runtime RUN jdeps --ignore-missing-deps -q --multi-release 21 --print-module-deps \ extracted/application/application.jar extracted/dependencies/lib/*.jar > modules.txt \ && jlink --add-modules "$(cat modules.txt),jdk.crypto.ec" \ --strip-debug --no-man-pages --no-header-files --compress=zip-6 --output /javaruntime FROM gcr.io/distroless/cc-debian13:nonroot ENV JAVA_HOME=/opt/java ENV PATH="/opt/java/bin:${PATH}" COPY --from=build /javaruntime /opt/java WORKDIR /application COPY --from=build /build/extracted/dependencies/ ./ COPY --from=build /build/extracted/spring-boot-loader/ ./ COPY --from=build /build/extracted/snapshot-dependencies/ ./ COPY --from=build /build/extracted/application/ ./ EXPOSE 8080 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "application.jar"]
⚠ jlink works until the first reflection
  • The first attempt used jdeps --class-path ... application.jar and produced a runtime with only java.base. The container failed at start-up with NoClassDefFoundError: java/beans/PropertyEditorSupport (Spring needs java.desktop), and after a partial fix with javax/management/MBeanRegistration. The working command passes the application jar and every dependency jar to jdeps, which printed java.base, java.compiler, java.desktop, java.instrument, java.net.http, java.prefs, java.rmi, java.scripting, java.security.jgss, java.sql.rowset, jdk.jfr, jdk.management, jdk.unsupported for this app, and jdk.crypto.ec is added by hand for TLS. jdeps cannot see classes loaded by name at run time, so always start the final image in CI and hit a health endpoint before trusting a jlink runtime.
The measured comparison
VariantBase imageDisk usageContent sizeRuns asShell inside?
1 naive fat jareclipse-temurin:21-jdk (Ubuntu 26.04)773 MB248 MBrootyes
2 layered, JRE, non-rooteclipse-temurin:21-jre506 MB139 MB10001yes
3 distrolessdistroless/java21-debian13:nonroot311 MB86.7 MB65532no
4 jlink + distroless/ccdistroless/cc-debian13:nonroot + custom runtime197 MB74 MB65532no
BuildpacksPaketo builder-noble-java-tiny run image543 MB263 MBnon-rootno

For reference, the bare base images on the same machine: eclipse-temurin:21-jdk 729 MB, eclipse-temurin:21-jre 462 MB, eclipse-temurin:21-jre-alpine 283 MB, distroless/java21-debian13:nonroot 262 MB (disk usage). Disk usage is the unpacked size on disk; content size is the compressed size that moves over the network. Alpine uses musl instead of glibc; test your native dependencies before choosing it. All five variants were started and served /actuator/health/liveness.

Choose by team cost, not by the smallest number
  • Variant 2 (JRE, layered, non-root) is the sensible default: you can still docker exec into it and the base is a well-known supported image. Go to distroless or jlink when your scanner results, pull times or compliance rules justify losing the shell. Docker Debug (docker debug) and Kubernetes ephemeral containers (page 04) are the answer to "but I cannot exec into it".

A newer option from the Spring Boot docs: with Java 25 or later, the layered Dockerfile can add a training run (java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar application.jar) and start with -XX:AOTCache=app.aot for faster startup; for Java 24 and earlier the docs show the CDS equivalent. This page uses Java 21 and did not run it.

06
Chapter Six - Java in Practice

The JVM Inside a Container

Modern JVMs are container-aware: on JDK 21, UseContainerSupport is true by default, and the JVM reads the cgroup limits instead of the host totals to size the heap and the default thread pools. Three things still bite people, and all were measured here.

Example: where 512 MiB of container memory goes (the heap is only part of it)
Heap 384 MiB (75%) Meta Thr Code Misc 0 512 MiB = memory.max Meta = metaspace and classes, Thr = thread stacks, Code = JIT code cache, Misc = direct buffers, GC, libc. Slice sizes other than the heap are illustrative: they depend on the application. The point is that non-heap memory is real, so heap = container limit leaves no room and the kernel kills the process. Java OutOfMemoryError the JVM ran out of HEAP (inside its limit) OOMKilled, exit 137 the KERNEL killed the process (cgroup limit)
1. The default heap is only 25% of the limit
Shell - same container, with and without the flag (memory limit 512 MiB)
$ docker run --rm -m 512m --cpus 1 --entrypoint java eclipse-temurin:21-jre -XX:+PrintFlagsFinal -version | grep -E ' MaxHeapSize | MaxRAMPercentage ' size_t MaxHeapSize = 134217728 # 128 MiB, because MaxRAMPercentage defaults to 25 double MaxRAMPercentage = 25.000000 $ docker run --rm -m 512m --cpus 1 --entrypoint java eclipse-temurin:21-jre -XX:MaxRAMPercentage=75 -XX:+PrintFlagsFinal -version | grep -E ' MaxHeapSize ' size_t MaxHeapSize = 402653184 # 384 MiB

The conservative default leaves room for non-heap memory but wastes a lot of a small container. A starting point of **-XX:MaxRAMPercentage=70 to 75** is common; measure docker stats and tune. (InitialRAMPercentage defaults to about 1.56%, MinRAMPercentage to 50% and only matters for very small limits.)

2. The percentage is of the container limit, and without a limit it is of the host

Spring Boot service with -XX:MaxRAMPercentage=75, started with -m 512m --cpus 1 in Compose: /memory answered {"cpus":1,"maxHeapMiB":371}. (371 rather than 384 because the JVM reports the usable heap, which excludes one survivor space.) The same image started with no limit on this 31 GiB machine reported a maximum heap of 23808 MiB, 75% of the host. A percentage flag without a container limit is a request to use most of the machine. Always set both.

3. Heap is not the container's memory, so OOMKilled can happen with a healthy heap
  • Non-heap memory: metaspace (loaded classes), thread stacks (default ThreadStackSize is 1024 KiB per thread, and Tomcat has 200 request threads by default), the JIT code cache (ReservedCodeCacheSize 240 MiB reserved on this JDK, usually far less committed), direct ByteBuffers used by Netty and NIO, GC data structures and the C library.
  • Rule of thumb: container limit = max heap + a few hundred MiB of overhead, then verify under load. With a 512 MiB limit, a 75% heap leaves 128 MiB for everything else, which a busy service may exceed.
  • Runtime.availableProcessors() follows --cpus: the JVM sized cpus to 1 under --cpus 1 and to 2 under --cpus 2 (shown by -XshowSettings:system: Effective CPU Count: 2, Memory Limit: 256.00M). ForkJoinPool, GC thread counts and Tomcat tuning follow it. -XX:ActiveProcessorCount=N overrides it.
Shell - provoking the kernel OOM killer (heap larger than the container allows)
$ docker run --name oomt -m 128m --entrypoint java eclipse-temurin:21-jre -Xmx512m -Xms512m -XX:+AlwaysPreTouch -version $ docker inspect oomt --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' exit=137 oom=true
SymptomCauseFix
Stack trace with java.lang.OutOfMemoryError: Java heap space; container exit 1 (or the pod keeps running, degraded)Heap too small for the live data, or a leakRaise MaxRAMPercentage (and the limit), fix the leak, take a heap dump
Container disappears, exit 137, OOMKilled=true, no Java stack traceTotal process memory exceeded memory.max; the kernel killed itLower MaxRAMPercentage, raise the limit, cap direct memory (-XX:MaxDirectMemorySize), reduce threads
Exit 137, OOMKilled=falseSomething sent SIGKILL: docker stop timed out (chapter 3), or the orchestrator killed itHandle SIGTERM; check docker events and node logs
Latency spikes only in containers with a CPU limitCFS throttling: the quota is used up early in each 100 ms windowRaise --cpus, reduce thread counts, check cpu.stat for nr_throttled
Shell - flags worth putting in the image or JAVA_TOOL_OPTIONS (JDK 21, flags exist)
-XX:MaxRAMPercentage=75 # heap as a share of the container limit -XX:+ExitOnOutOfMemoryError # die on Java OOM so the orchestrator restarts a clean process -XX:+HeapDumpOnOutOfMemoryError # off by default; needs a writable HeapDumpPath, e.g. a mounted volume -XX:HeapDumpPath=/dumps

More on heap sizing, GC choice and what to measure is on the JVM Memory and GC page; the Kubernetes meaning of requests and limits is on page 04.

07
Chapter Seven - Networking and Storage

Networks, Ports, Volumes, Bind Mounts and tmpfs

Networking
DriverWhat it isUse
bridge (default)A software switch on the host. Each container gets an IP on a private subnet and outbound traffic is NATedSingle-host containers; the docker0 default network and user-defined networks
user-defined bridgeSame driver, created with docker network create. Adds automatic DNS by container or service name and better isolationWhat Compose creates for you. Prefer it to the default bridge
hostThe container shares the host network namespace; no NAT, no isolation of portsRare: maximum network performance or many ports. Linux Engine only in the usual configuration
noneOnly a loopback interfaceBatch jobs that must not touch the network
overlay, macvlanMulti-host networks (Swarm), containers with their own MAC on the LANSpecialised; Kubernetes uses its own CNI plugins (page 03)
Bridge networking: a user-defined network gives DNS names, a published port gives access from outside
Docker host Browser / curl localhost:8080 -p NAT rule host:8080 to 172.20.0.3:8080 user-defined bridge network "shop_backend" 172.20.0.0/16 embedded DNS at 127.0.0.11 app 172.20.0.3 JDBC url ends in db:5432 db (postgres) 172.20.0.2 no published port db resolves to 172.20.0.2 Containers on the same user-defined network reach each other by service name and by any port, with no -p needed. Only what you publish with -p is reachable from outside the host.

Publishing a port maps a host port to a container port: -p 8080:8080 (all host interfaces), -p 127.0.0.1:8080:8080 (host loopback only, which is what you want for a database during development), -p 8080 (random host port, see docker port). EXPOSE in the Dockerfile only documents; it publishes nothing.

Shell - name resolution measured on a user-defined network versus the default bridge
$ docker network create demo-net $ docker run -d --name web --network demo-net nginx:alpine $ docker run --rm --network demo-net alpine:3.20 getent hosts web 172.20.0.2 web web $ docker run -d --name web2 nginx:alpine # default bridge $ docker run --rm alpine:3.20 sh -c 'getent hosts web2 || echo "name web2 not resolvable on the default bridge"' name web2 not resolvable on the default bridge
⚠ Using localhost to reach another container
  • localhost inside a container is the container itself. A Spring Boot app that has jdbc:postgresql://localhost:5432/shop in its configuration works on your laptop and fails in Compose with Connection refused. Use the service name (db) on a shared network, and take the URL from an environment variable (DB_URL), as the sample's application.properties does.
Storage: volumes, bind mounts, tmpfs
TypeFlagLivesUse it for
Volume-v dbdata:/var/lib/postgresql/data or --mount type=volume,src=dbdata,dst=...In Docker's area (/var/lib/docker/volumes/<name>/_data on Linux); managed with docker volumeDatabase files and any data that must outlive the container. Portable and the default recommendation
Bind mount-v /host/path:/container/path[:ro]Any host path you nameSource code in development, config files, reading host files read-only
tmpfs--tmpfs /scratch:rw,size=16mRAM only, gone when the container stopsScratch space with a read-only root filesystem; data you must not persist
Shell - all three at once (output abridged to the relevant lines)
$ docker volume create dv $ docker run --rm -v dv:/data -v /tmp:/hosttmp:ro --tmpfs /scratch:rw,size=16m alpine:3.20 sh -c 'mount | grep -E " /scratch | /hosttmp "' tmpfs on /scratch type tmpfs (rw,nosuid,nodev,noexec,relatime,size=16384k,inode64) /dev/mapper/ubuntu--vg-ubuntu--lv on /hosttmp type ext4 (ro,relatime) $ docker volume inspect dv --format '{{.Mountpoint}}' /var/lib/docker/volumes/dv/_data
  • The writable container layer is slow and ephemeral: heavy writes (database files, uploads) belong on a volume.
  • A bind mount to a directory owned by root is not writable by a non-root container user (UID 10001 in our image). Fix ownership on the host or use a volume.
  • docker compose down keeps named volumes; docker compose down -v deletes them. That flag is how people lose a development database.
  • In production on Kubernetes or ECS, persistent data uses the platform's volume types (EBS, EFS), not Docker volumes (page 03, ECS).
08
Chapter Eight - Compose

Docker Compose: App, PostgreSQL and a Health-Gated Start

Docker Compose describes a multi-container application in one compose.yaml file (the Compose Specification) and starts it with one command. It creates a project network, volumes and containers, injects the service names into DNS, and tears everything down again. It is the right tool for local development and CI integration tests; for production orchestration see Kubernetes (pages 03 and 04).

docker compose up: dependency order driven by a health check, not by start order
db: starting db: pg_isready retries every 5 s db: healthy app: created WAITING app: starts (Spring Boot, 5 to 10 s) depends_on: db: condition: service_healthy Without the condition, Compose only waits for the db container to START. Postgres then spends a few seconds initialising, and the app's first connection fails before the database accepts them.
compose.yaml - tested: docker compose up -d --wait
name: shop services: app: image: shop:2-layered ports: - "8080:8080" environment: DB_URL: jdbc:postgresql://db:5432/shop DB_USER: shop DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env or the shell} depends_on: db: condition: service_healthy healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health/readiness"] interval: 10s timeout: 3s retries: 5 start_period: 40s mem_limit: 512m cpus: 1.0 restart: unless-stopped networks: [backend] db: image: postgres:17-alpine environment: POSTGRES_DB: shop POSTGRES_USER: shop POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env or the shell} volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U shop -d shop"] interval: 5s timeout: 3s retries: 10 networks: [backend] volumes: pgdata: networks: backend:
.env - Compose reads this file automatically for ${VAR} substitution
DB_PASSWORD=change-me-locally
What each part does
  • name: shop fixes the project name (container names become shop-app-1, shop-db-1, and the network shop_backend). The old top-level version: field is no longer needed.
  • depends_on with condition: service_healthy makes Compose wait until the db health check passes. The other conditions are service_started (the default) and service_completed_successfully (for one-shot jobs such as migrations).
  • healthcheck on db uses pg_isready, which ships in the PostgreSQL image. The app's check uses curl against Spring Boot's readiness endpoint, which works because the eclipse-temurin image contains curl; a distroless image would need a different mechanism.
  • ${DB_PASSWORD:?message} stops Compose with an error if the variable is unset, instead of starting a database with an empty password. Never commit the real .env.
  • mem_limit and cpus are plain service attributes; the memory limit is what made the JVM size its heap at 371 MiB in chapter 6. (deploy.resources.limits expresses the same thing and is also honoured by docker compose today.)
  • restart: unless-stopped restarts the app after a crash or a daemon restart, but not after you stopped it deliberately.
Shell - the run on this machine (abridged)
$ docker compose up -d --wait Container shop-db-1 Started Container shop-db-1 Healthy Container shop-app-1 Started Container shop-app-1 Healthy $ docker compose ps NAME IMAGE COMMAND SERVICE STATUS PORTS shop-app-1 shop:2-layered "java -XX:MaxRAMPerc..." app Up 23 seconds (healthy) 0.0.0.0:8080->8080/tcp shop-db-1 postgres:17-alpine "docker-entrypoint.s..." db Up 29 seconds (healthy) 5432/tcp $ curl -s localhost:8080/hello {"dbSessions":6,"message":"hello from a container"} $ curl -s localhost:8080/actuator/health/readiness {"status":"UP"}
⚠ Assuming depends_on makes the app wait for a ready database
  • Plain depends_on orders container start, not readiness. Use the health-gated form, and still make the application tolerant of a database that disappears and returns (connection retries, and in Kubernetes a readiness probe). Health gating is a development and CI convenience, not a substitute for resilient clients.
CommandWhat it does
docker compose up -d --waitStart in the background and block until services are running/healthy
docker compose ps / logs -f app / topStatus, follow one service's logs, processes
docker compose exec db psql -U shop shopRun a command in a running service container
docker compose build / up --buildBuild images defined with build: and start
docker compose configPrint the fully resolved file (variables substituted); -q just validates
docker compose down / down -vRemove containers and networks / and named volumes (data loss)
docker compose --profile debug upStart services that carry profiles: [debug]

Spring Boot also has a spring-boot-docker-compose module that can start a compose.yaml for you when the application starts and wire connection details automatically (see the Docker Compose section of the Spring Boot reference). It is a development convenience and is excluded from the packaged image by default.

09
Chapter Nine - Distribution

Registries and a Tagging Strategy

A registry stores images and serves them over the OCI distribution API: Docker Hub, GitHub Container Registry, Amazon ECR, Google Artifact Registry, Azure Container Registry, Harbor. An image name has up to four parts: registry.example.com/team/shop:1.4.2 is registry registry.example.com, repository team/shop, tag 1.4.2. With no registry the name means Docker Hub (docker.io/library/...).

StrategyExampleVerdict
latest onlyshop:latestDo not deploy. Unrepeatable, no rollback target
Semantic versionshop:1.4.2Good for humans and release notes. Make it immutable
Git SHAshop:3f9c1abPerfect traceability from a running container to a commit. Meaningless to humans on its own
Version + SHAshop:1.4.2-3f9c1abRecommended: readable and traceable. CI pushes it once and never overwrites it
Moving convenience tagsshop:1.4, shop:1, shop:mainFine for dev environments and consumers who want auto-updates; do not use them to deploy production
Digestshop@sha256:ab12...The strongest pin. Kubernetes and Compose accept it; tooling (Renovate, Argo CD image updater) can bump it for you
Rules that keep a registry trustworthy
  • Build once, promote the same image. The artifact tested in staging is the artifact in production, referenced by the same digest. Rebuilding per environment tests a different image.
  • Make tags immutable where the registry supports it (Amazon ECR has a tag-immutability repository setting) so a second push to 1.4.2 is rejected rather than silently replacing it.
  • Add labels that link back to the source (org.opencontainers.image.source, .revision, .version), so anyone with a running image can find its commit.
  • Expire untagged and old images with a registry lifecycle policy, or storage grows without bound. Keep whatever is currently deployed plus a rollback window.
  • Scan on push and again on a schedule: a clean image today can have a known CVE next week (chapter 10).
Shell - a CI tagging step (validated with bash -n)
#!/usr/bin/env bash set -euo pipefail REGISTRY=registry.example.com/team APP=shop VERSION=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version) # 1.4.2 SHA=$(git rev-parse --short=7 HEAD) # 3f9c1ab TAG="${VERSION}-${SHA}" # 1.4.2-3f9c1ab docker build \ --label org.opencontainers.image.revision="$SHA" \ --label org.opencontainers.image.version="$VERSION" \ -t "$REGISTRY/$APP:$TAG" . docker push "$REGISTRY/$APP:$TAG" # the digest to deploy, as the registry reports it docker buildx imagetools inspect "$REGISTRY/$APP:$TAG" --format '{{json .Manifest.Digest}}'

The script uses registry.example.com, a placeholder; it was syntax-checked, not run against a registry.

⚠ Overwriting a release tag
  • Re-pushing 1.4.2 after "a small fix" means two clusters can run different code under the same version, imagePullPolicy: IfNotPresent nodes keep the old one, and your scanner report no longer describes what is deployed. A fix is 1.4.3.
10
Chapter Ten - Security

Image and Runtime Security

Secrets baked into layers: proof

Layers are tar files that anyone with the image can read. The Dockerfile below copies a token file, uses it, then deletes it, and also takes the token as a build argument. It builds fine, and the secret is still in the image twice.

Dockerfile.bad - do not do this
FROM alpine:3.20 ARG API_TOKEN COPY token.txt /tmp/token.txt RUN echo "token is $(cat /tmp/token.txt)" > /dev/null && rm /tmp/token.txt
Shell - what an attacker (or a curious colleague) sees
$ docker build -q -f Dockerfile.bad --build-arg API_TOKEN=sk-demo-abc -t leak:bad . $ docker run --rm leak:bad cat /tmp/token.txt cat: can't open '/tmp/token.txt': No such file or directory # the running container looks clean... $ docker history --no-trunc leak:bad --format '{{.CreatedBy}}' | head -3 RUN |1 API_TOKEN=sk-demo-abc /bin/sh -c echo "token is $(cat /tmp/token.txt)" > /dev/null && rm /tmp/token.txt # buildkit COPY token.txt /tmp/token.txt # buildkit ARG API_TOKEN=sk-demo-abc # ...but history prints the build arg $ docker save leak:bad -o bad.tar && mkdir x && tar -xf bad.tar -C x # then read the layer tars layer ...c8673bb12c34 tmp/token.txt b'API_TOKEN=sk-demo-123456\n' # the file is still inside an earlier layer layer ...0db75328216a tmp/.wh.token.txt b'' # the "delete" is only a whiteout marker

Three leaks from three lines: the ARG value in the image history, the COPY'd file inside its layer, and a rm that hides nothing. The fix is the BuildKit secret mount from chapter 4, which never writes the secret into a layer. At run time, inject secrets as environment variables from the orchestrator's secret store, or as mounted files (Compose secrets:, Kubernetes Secrets, AWS Secrets Manager), not as ENV in the image. Also keep .env and credentials out of the build context with .dockerignore.

Runtime hardening
Shell - a locked-down run, measured
$ docker run --rm --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \ --pids-limit 200 -u 10001:10001 alpine:3.20 sh -c 'id; touch /x; touch /tmp/ok && echo tmp-writable; grep CapEff /proc/self/status' uid=10001 gid=10001 groups=10001 touch: /x: Read-only file system tmp-writable CapEff: 0000000000000000 $ docker run --rm alpine:3.20 grep CapEff /proc/self/status # for comparison: a default container CapEff: 00000000a80425fb # 14 capabilities still granted by default
Controldocker runComposeWhy
Non-root user-u 10001:10001 (or USER in the Dockerfile)user: "10001:10001"Limits what a compromised process can do; the user namespace is not remapped by default (chapter 1)
Read-only root filesystem--read-only --tmpfs /tmpread_only: true, tmpfs: [/tmp]Malware cannot persist or modify binaries; Spring Boot needs a writable /tmp (Tomcat work directory)
Drop capabilities--cap-drop ALL then --cap-add what you needcap_drop: [ALL]Removes root-like powers; a web service needs none
No privilege escalation--security-opt no-new-privilegessecurity_opt: [no-new-privileges:true]Blocks setuid binaries from gaining privileges
Process limit--pids-limit 200pids_limit: 200Contains fork bombs
Resource limits-m, --cpusmem_limit, cpusNoisy neighbours and runaway memory (chapter 11)
  • Never run --privileged or mount /var/run/docker.sock into an application container; either one is equivalent to root on the host.
  • Do not publish database ports to 0.0.0.0 on shared machines (-p 127.0.0.1:5432:5432).
  • Rootless Docker and user-namespace remapping make container root a regular host user, at the cost of some features; consider them on shared build hosts.
Scanning and SBOMs

A scanner unpacks the image, inventories the OS packages and the language libraries (Maven jars here), and matches them against vulnerability databases. We scanned two of the images with Trivy 0.76.1 (run from its container image against docker save tarballs, on 2026-10-31, so the counts are a snapshot of that day's database).

ImageOS packages (HIGH/CRITICAL)Java jars (HIGH/CRITICAL)
shop:2-layered (Temurin JRE on Ubuntu 26.04)012 (6 HIGH, 6 CRITICAL)
shop:3-distroless (Debian 13.7)4 HIGH12 (the same jar, so the same findings)

Two lessons. The application's own dependencies often dominate: the Java findings are identical in every variant, because the jar is identical, and were reported in Jackson, embedded Tomcat, Spring MVC and the PostgreSQL driver of this particular dependency set. A smaller base image does not fix them; upgrading the dependencies does (Spring Boot patch releases bump the managed versions). And counts are not equally actionable: triage by whether a fixed version exists and whether the vulnerable code path is reachable.

Shell - scanning without installing anything
$ docker save shop:2-layered -o s.tar $ docker run --rm -v "$PWD":/in:ro aquasec/trivy:latest image --input /in/s.tar --severity HIGH,CRITICAL
  • Docker Scout (docker scout quickview, docker scout cves) is the scanner integrated into Docker Desktop and Hub. The CLI plugin is installed here, but it needs a Docker login, so it was not run for this page.
  • SBOM (software bill of materials): a machine-readable inventory of what is in the image. BuildKit can attach one with --sbom=true (flag present, not run here); Spring Boot can also expose its own dependency list through the Actuator sbom endpoint. Scanners can consume an SBOM instead of re-inspecting the image, and an SBOM lets you answer "are we affected?" the day a new CVE is published.
  • Provenance attestations (--provenance=mode=max) record how and where an image was built. Signing (cosign/Notary) lets clusters admit only images your pipeline produced.
  • Rebuild on a schedule even without code changes: base images get patched, and a pinned digest stays vulnerable until you bump it.
11
Chapter Eleven - Operations

Limits, Logging, Troubleshooting and Common Mistakes

Resource limits
FlagEffectGotcha
-m 512m / --memoryHard memory cap (memory.max); exceed it and the OOM killer actsContainers without it can use all host memory. --memory-swap equal to -m disables swap
--memory-reservationSoft limit hint under pressureNot a guarantee
--cpus 1.5CPU quota: 150 ms per 100 ms period (cpu.max)Throttling, not queueing: bursty services see latency spikes (chapter 6)
--cpu-sharesRelative weight when CPUs are contendedOnly matters under contention
--pids-limitMaximum number of processes/threadsA JVM with thousands of threads needs a generous value
--ulimit nofile=65536Per-process limits such as open filesDefault is often fine; high-connection servers need more
Shell - live usage (abridged: other containers on this machine omitted)
$ docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.PIDs}}' NAME CPU % MEM USAGE / LIMIT MEM % PIDS shop-app-1 0.14% 149.3MiB / 512MiB 29.17% 30 shop-db-1 0.02% 53.9MiB / 30.98GiB 0.17% 16

The app uses 149 MiB of its 512 MiB while idle, with a 384 MiB heap ceiling; docker stats shows the container total (heap plus everything else), which is what the kernel compares with the limit.

Logging
  • Write logs to stdout and stderr, not to files inside the container. Spring Boot logs to the console by default; do not add a file appender in a container.
  • The default driver is json-file, and by default no log rotation is performed, so a chatty container can fill the disk (Docker logging docs). Set max-size and max-file, or switch to the local driver, which rotates by default.
  • Other drivers ship logs off the host: syslog, journald, fluentd, gelf, awslogs and more. Recent Engine versions keep a local copy as well, so docker logs still works with them ("dual logging").
  • The default delivery mode is blocking: if the driver is slow, the application's writes to stdout block. mode: non-blocking uses a buffer instead, at the risk of dropping messages when it is full.
daemon.json - rotate logs for every container (restart the daemon after editing)
{ "log-driver": "local", "log-opts": { "max-size": "10m", "max-file": "3" } }
Shell - per container, in Compose or docker run
$ docker run -d --log-driver json-file --log-opt max-size=10m --log-opt max-file=3 shop:2-layered
Troubleshooting a container
Exit codeMeaningTypical cause
0Process finished normallyA one-shot command; or the main process daemonised and PID 1 exited
1Application errorUncaught exception at start-up; read docker logs
125docker run itself failedBad flag, daemon error
126 / 127Command cannot be invoked / not foundMissing executable bit, wrong ENTRYPOINT path, no shell in a distroless image
137128 + 9, SIGKILLOOMKilled (check State.OOMKilled), or docker stop timed out and killed it
139128 + 11, SIGSEGVNative crash (JNI library, musl/glibc mismatch)
143128 + 15, SIGTERMNormal graceful stop of a JVM that handled the signal. Not an error
Container exits immediately
▶
docker ps -a shows Exited (1). Run docker logs <name> first: nine times out of ten the stack trace is there. Then docker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' <name>
It cannot start at all
▶
docker run --rm -it --entrypoint sh image to explore the image (a distroless image has no shell; use a debug image or docker debug)
It runs but misbehaves
▶
docker exec -it <name> sh, docker top, docker stats, docker diff <name> to list files changed in the writable layer
Networking looks wrong
▶
docker inspect -f '{{json .NetworkSettings.Networks}}' <name>, docker network inspect <net>, test DNS with getent hosts <service> from inside
What happened and when?
▶
docker events --since 30m shows die, oom, kill, health_status and restart events with timestamps
Shell - a real stop, with the exit code (Spring Boot, exec form)
$ docker stop shop-app-1 shop-app-1 # returned in about 1 s $ docker inspect shop-app-1 --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' exit=143 oom=false $ docker logs --tail 4 shop-app-1 # abridged ... Commencing graceful shutdown. Waiting for active requests to complete ... Graceful shutdown complete ... HikariPool-1 - Shutdown initiated... ... HikariPool-1 - Shutdown completed.
Common mistakes
MistakeWhat you seeFix
COPY . . before dependency resolutionEvery build re-downloads all dependenciesCopy the manifest, resolve dependencies, then copy sources (chapter 4)
Shell-form ENTRYPOINT, or a script without execdocker stop takes 10 s, exit 137, no graceful shutdownExec form, exec java ..., or --init (chapter 3)
Running as root, full JDK, Maven in the runtime imageBig image, long scan reports, privileged processMulti-stage build, JRE, numeric non-root USER
No memory limit, or MaxRAMPercentage without oneHeap sized from the host (23808 MiB measured) and the host swapsSet the limit and the percentage (chapter 6)
Heap equal to the container limitExit 137, OOMKilled=true, no stack traceHeap 70-75% of the limit, leave room for non-heap
Secrets in ENV, ARG or COPYVisible in docker history and in layer tarsBuildKit secret mounts at build, orchestrator secrets at run time (chapter 10)
localhost for another serviceConnection refused only inside containersService name on a user-defined network
Deploying latestUnrepeatable rollouts, surprise upgradesVersion-plus-SHA tags or digests (chapter 9)
Database data in the writable layerData gone after docker rm or recreateNamed volume
No log rotationDisk full from /var/lib/docker/containers/*/*-json.loglocal driver or max-size/max-file (chapter 11)
One container, many processes (sshd, cron, app)Unclear health, signal handling, restartsOne main process per container; sidecars for the rest
Ignoring .dockerignoreHuge build context, secrets or target/ in layers; or a needed file excludedKeep it small and explicit (chapter 3)
12
Chapter Twelve - Interview

Interview Questions and Answers

  • Q: What is a container, and how is it different from a VM?
    A container is an ordinary process (or group) on the host kernel, isolated with namespaces (what it sees), limited with cgroups (what it can use) and given its own root filesystem through a layered union filesystem. A VM runs a full guest OS on virtualised hardware with its own kernel. Containers start in milliseconds and are light, but share the kernel, so isolation is weaker.
  • Q: Name the Linux features Docker is built on.
    Namespaces (pid, net, mnt, uts, ipc, user, cgroup, time), cgroups for CPU, memory, pids and I/O limits, overlayfs for layered copy-on-write filesystems, plus capabilities, seccomp and AppArmor/SELinux for privilege reduction.
  • Q: What is an image layer, and why does instruction order matter?
    A layer is a content-addressed tar of filesystem changes. Docker reuses a cached layer only if its instruction and all earlier ones are unchanged, so put rarely changing, slow steps (OS packages, dependency download) before frequently changing ones (source code). Good ordering makes rebuilds and pushes small.
  • Q: Tag versus digest? What is wrong with latest?
    A tag is a movable pointer; a digest is the hash of the manifest and names one image immutably. latest is only the default tag name, not "newest", so deployments using it are not reproducible and cannot roll back. Deploy immutable version tags or digests.
  • Q: CMD versus ENTRYPOINT; shell form versus exec form?
    ENTRYPOINT defines the executable, CMD the default arguments, and docker run arguments replace CMD but are appended to ENTRYPOINT. Shell form wraps the command in /bin/sh -c, so the shell is PID 1 and does not forward SIGTERM; exec form runs the program directly. PID 1 must handle SIGTERM or docker stop waits 10 s and then SIGKILLs (exit 137).
  • Q: COPY versus ADD?
    COPY copies files from the context or another stage. ADD also downloads URLs and auto-extracts local tar files, which are surprising behaviours, so use COPY unless you need those features deliberately.
  • Q: ARG versus ENV?
    ARG exists only at build time (and is recorded in the image history); ENV persists in the image and the running container. Neither is safe for secrets: use BuildKit secret mounts at build time and runtime secret injection.
  • Q: How do multi-stage builds help?
    Build tools and sources stay in early stages; the final stage starts from a slim runtime image and copies only the artifacts. The image is smaller, has less attack surface and never ships your source or build credentials.
  • Q: How do you build an efficient Docker image for Spring Boot?
    Build the layered jar, extract it with java -Djarmode=tools -jar app.jar extract --layers, copy dependencies, spring-boot-loader, snapshot-dependencies and application as separate layers in that order, run on a JRE base as a non-root user with exec-form ENTRYPOINT. Alternatively use mvn spring-boot:build-image (Cloud Native Buildpacks). Code changes then rebuild only a tiny top layer.
  • Q: Why can a Java container be OOMKilled although the heap is fine?
    The cgroup limit counts total process memory: heap plus metaspace, thread stacks, code cache, direct buffers, GC structures and native libraries. If heap equals the limit, the kernel kills the process (exit 137, OOMKilled=true) with no Java stack trace. Set MaxRAMPercentage around 70-75 and leave overhead.
  • Q: How does the JVM know the container limits?
    Since JDK 10 (backported to 8u191), UseContainerSupport reads cgroup limits to size the heap (default 25% of the limit via MaxRAMPercentage), CPU count and thread pools. Without a limit it sees the host, so set -m and --cpus.
  • Q: Bridge vs host networking? How do containers find each other?
    Bridge gives each container a private IP behind NAT; you publish ports with -p. Host shares the host's network namespace. On a user-defined bridge network, Docker's embedded DNS resolves container and service names; the default bridge does not.
  • Q: Volume versus bind mount versus tmpfs?
    Volumes are managed by Docker and are the default for persistent data; bind mounts expose a specific host path (development, config); tmpfs is RAM-backed and ephemeral. Writes to the container's own layer are slow and lost on removal.
  • Q: How do you make Compose wait for a database to be ready?
    Give the database a healthcheck (pg_isready) and use depends_on: db: condition: service_healthy. Plain depends_on only orders start. The application should still retry connections.
  • Q: How do you keep secrets out of an image?
    Never COPY, ARG or ENV them. Use RUN --mount=type=secret at build time (nothing is stored in layers) and inject runtime secrets from the platform. Deleting a file in a later layer does not remove it from earlier ones, and docker history shows build arguments.
  • Q: How would you harden a container at runtime?
    Non-root numeric user, read-only root filesystem with tmpfs for scratch, --cap-drop ALL, no-new-privileges, resource and pid limits, no --privileged, no docker socket mounts, published ports bound to the interfaces that need them, and regular image scanning.
  • Q: What do exit codes 137 and 143 mean?
    137 is 128 + 9 (SIGKILL): an OOM kill or a stop that exceeded its grace period. 143 is 128 + 15 (SIGTERM): a normal graceful shutdown of a JVM that handled the signal.
  • Q: Scenario: a container restarts every few minutes in production. How do you investigate?
    Check exit code and OOMKilled with docker inspect, read docker logs before the restart, look at docker events and docker stats for memory growth, compare heap flags to the limit, confirm the health check is not failing too early (start_period), and check for a missing dependency at start-up. Fix the cause, then add a memory dashboard so it is visible next time.
13
Chapter Thirteen - Summary

Docker CLI Cheat Sheet and Recap

Cheat sheet
TaskCommandRemember
Builddocker build -t shop:1.4.2 .-f for another Dockerfile, --target stage, --no-cache --pull for a clean build
Build with a secretdocker build --secret id=tok,src=token.txt .Read in the Dockerfile with RUN --mount=type=secret,id=tok
Multi-platformdocker buildx build --platform linux/amd64,linux/arm64 --push -t ... .Needs a builder that supports it; result is an index
Rundocker run -d --name app -p 8080:8080 -m 512m --cpus 1 shop:1.4.2--rm to auto-remove, -e KEY=v, --env-file, --init
Listdocker ps / docker ps -a / docker images / docker volume ls--format for scripting
Logsdocker logs -f --tail 100 appNeeds a log driver that keeps a local copy
Shell insidedocker exec -it app shDistroless has no shell
Inspectdocker inspect -f '{{.State.ExitCode}} {{.State.OOMKilled}}' appThe first command after a crash
Live usage / eventsdocker stats / docker events --since 30mContainer total memory, not heap
Stop / removedocker stop app / docker rm -f appStop = SIGTERM then SIGKILL after 10 s (-t to change)
Copy filesdocker cp app:/tmp/heap.hprof .Works on stopped containers too
Layers / sizedocker history --no-trunc image / docker system dfHistory shows ARG values
Networks / volumesdocker network create n / docker volume create vUser-defined network = DNS
Tag / pushdocker tag shop:1.4.2 reg/team/shop:1.4.2 && docker push reg/team/shop:1.4.2Version-plus-SHA, immutable
Clean up (careful)docker image prune, docker container prunePrune acts on the whole daemon; read the prompt
Composedocker compose up -d --wait / logs -f / down (-v deletes data)config -q validates the file
∑ Docker - what to remember
  • A container is a process with namespaces (view), cgroups (budget) and overlayfs (root). One shared kernel; not a VM, not a hard security boundary.
  • Images are stacks of content-addressed layers. Tags move, digests do not; latest is only a name. Deploy immutable tags or digests.
  • Order the Dockerfile for the cache: stable and slow first, changing and fast last. Use .dockerignore, cache mounts and multi-stage builds.
  • PID 1 must handle SIGTERM. Exec form, exec in scripts or --init; otherwise docker stop takes 10 s and ends in 137.
  • Spring Boot: layered jar via -Djarmode=tools ... extract --layers, JRE base, non-root numeric user; or buildpacks. Test a jlink runtime by running it.
  • JVM: set the container limit and MaxRAMPercentage (about 70-75), remember non-heap memory, know OOM error versus OOMKilled.
  • Networking and data: user-defined networks for DNS, never localhost between containers, volumes for state, health-gated depends_on in Compose.
  • Security: no secrets in layers or docker history, non-root, read-only root, dropped capabilities, scan images and dependencies, rebuild regularly.
  • Operate: rotate logs, read the exit code first (137 kill, 143 graceful), then logs, inspect, stats and events.
Version note

Verified on 2026-10-31. Run here: Docker Engine 29.4.1 (containerd image store, cgroup v2), Buildx v0.30.1, Compose v2.40.3, Trivy 0.76.1, Spring Boot 3.5.16 sample with Temurin 21, Paketo buildpacks via spring-boot:build-image. Documentation checked: docs.docker.com (Dockerfile reference: HEALTHCHECK options and defaults, RUN --mount types; Compose depends_on conditions; logging drivers and rotation; build best practices) and docs.spring.io Spring Boot 4.1.2 (jarmode tools extract and list-layers, layer order, Dockerfile examples, AOT cache note, build-image default builder). Not run: multi-platform buildx builds, --sbom/--provenance attestations, Docker Scout, pushing to a registry. Image sizes and Trivy counts are one machine on one day; tags such as 21-jre move.

Next in this section