OnCallReady

Lesson 10.8 · Images & Builds · 17 min read

The Dockerfile, and reading a build

In plain words

A Dockerfile is a recipe card. Each line is one step: start from this bread, add butter, cut in half. Some lines actually change the sandwich; others just write a note on the box, like 'best eaten cold' or 'made for Learner'. The notes weigh nothing, but they still count as steps.

FROM, RUN, COPY, ADD and WORKDIR change files and each produces a layer. ENV, USER, EXPOSE, CMD, ENTRYPOINT and friends only change the image config. docker build follows the card and prints a numbered line per filesystem step with how long it took. Reading that output, the load metadata line, the transferring context size, the failing step with >>>, is how you know what the kitchen actually did.

Why this matters

Every image your team ships is built from a Dockerfile: a text file with one instruction per line, read top to bottom by the builder. When a build is slow or fails, the build output tells you exactly which line and why - if you can read it. This lesson is the vocabulary for the rest of the chapter.

What you need to know already: images, layers and config (the two previous lessons), shell commands and exit codes (Ch 6, "The header every script gets"), apt (Ch 1).

The example app

Most examples in this chapter build orders, a small Java web service. Four Java words you need:

eclipse-temurin is a popular image with Java in it (21-jre, 21-jdk); maven is an image with Java plus Maven.

Every instruction, in one place

# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jre            # base image. Every stage starts with one
ARG APP_VERSION=dev                    # build-time variable (--build-arg)
ENV SPRING_PROFILES_ACTIVE=prod        # runtime variable, stored in the image
WORKDIR /app                           # cd, creating the directory if needed
COPY target/app.jar app.jar            # files from the build context
ADD https://example.com/x.tar.gz /opt/ # COPY plus URLs and tar extraction - avoid
RUN apt-get update && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*         # run a command, snapshot the result
USER 1000:1000                         # who RUN, CMD and ENTRYPOINT run as from here
EXPOSE 8080                            # documentation only
HEALTHCHECK CMD curl -f http://localhost:8080/actuator/health || exit 1
LABEL org.opencontainers.image.source=https://github.com/ing/orders
STOPSIGNAL SIGTERM                     # what docker stop sends
ENTRYPOINT ["java","-jar","app.jar"]   # the executable
CMD ["--server.port=8080"]             # default arguments to it

One line at a time:

Two groups, and the difference drives the rest of the chapter:

ADD surprises people: a tarball you meant to copy gets unpacked, and URL downloads cannot be verified. Use COPY, and RUN curl when you really need a download.

EXPOSE publishes nothing. It records "this image listens on 8080" for humans and for docker run -P. Only docker run -p 8080:8080 (publish: forward host port 8080 to the container's port 8080) opens a port on the host.

Reading BuildKit output

docker build -t orders:1 . builds an image from the Dockerfile in the current directory. -t orders:1 tags the result (names it); the . is the build context.

$ docker build -t orders:1 .
[+] Building 74.6s (11/11) FINISHED                                        docker:default
 => [internal] load build definition from Dockerfile                                  0.0s
 => => transferring dockerfile: 243B                                                  0.0s
 => [internal] load metadata for docker.io/library/maven:3.9-eclipse-temurin-21      1.1s
 => [internal] load .dockerignore                                                     0.0s
 => => transferring context: 2B                                                       0.0s
 => [1/4] FROM docker.io/library/maven:3.9-eclipse-temurin-21@sha256:0c7f714b...     29.0s
 => [internal] load build context                                                     0.0s
 => => transferring context: 1.52kB                                                   0.0s
 => [2/4] WORKDIR /app                                                                0.0s
 => [3/4] COPY . .                                                                    0.1s
 => [4/4] RUN mvn package -DskipTests                                                71.7s
 => exporting to image                                                                0.2s
 => => exporting layers                                                               0.1s
 => => writing image sha256:cf3ab9a158c8214341caa63372af70c79291dc8c3819fa4f705...    0.0s
 => => naming to docker.io/library/orders:1                                           0.0s

The first line: total time, steps done / steps total. Then line by line:

In a Dockerfile with several FROM lines (a multi-stage build, later) the step labels carry the stage name: [build 3/6] COPY pom.xml ., [stage-1 2/3] WORKDIR /app.

When you need the actual output: --progress=plain

The normal view hides what each RUN prints. --progress=plain shows it, with the seconds since the step started. --no-cache forces every step to run again (the cache lesson explains why it normally would not):

$ docker build --progress=plain --no-cache -t orders:1 .
#0 building with "default" instance using docker driver

#7 [4/4] RUN mvn package -DskipTests
#7 0.100 [INFO] Scanning for projects...
#7 0.431 [INFO] Building orders 2.14.1
#7 9.874 [INFO] BUILD SUCCESS
#7 DONE 71.7s

#7 is the step's number in this build; every line of that step starts with it. When a build fails on a build server, plain output is what you want in the logs.

When a step fails

 => ERROR [4/4] RUN mvn package -o                                                    2.1s
------
 > [4/4] RUN mvn package -o:
2.100 [ERROR] Failed to execute goal on project orders: Could not resolve dependencies ...
------
Dockerfile:4
--------------------
   2 |     WORKDIR /app
   3 |     COPY . .
   4 | >>> RUN mvn package -o
   5 |     ENTRYPOINT ["java","-jar","target/orders-2.14.1.jar"]
--------------------
ERROR: failed to solve: process "/bin/sh -c mvn package -o" did not complete successfully: exit code: 1

Three parts: the last lines of the failing command's output (here mvn -o, offline mode, could not download what it needed), the Dockerfile line with >>>, and the exit code. exit code: 127 is the shell's "command not found" (Ch 6): /bin/sh: 1: mvn: not found means the tool is not in that base image.

Build checks

BuildKit checks the Dockerfile for common mistakes as it builds, and prints warnings at the end:

 2 warnings found (use docker --debug to expand):
 - JSONArgsRecommended: JSON arguments recommended for ENTRYPOINT to prevent unintended behavior related to OS signals (line 5)
 - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "DB_PASSWORD") (line 3)

Both of those are real bugs that this chapter spends a mission on each. Others you will see: FromAsCasing (FROM x as build - make the AS match), StageNameCasing, LegacyKeyValueFormat (ENV KEY value instead of ENV KEY=value), WorkdirRelativePath, MaintainerDeprecated.

docker build --check . runs only the checks, without building:

# in a project whose ENTRYPOINT is still shell form
docker build --check .
JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for ENTRYPOINT to prevent unintended behavior related to OS signals
Dockerfile:5
--------------------
   3 |     COPY . .
   4 |     RUN mvn package -DskipTests
   5 | >>> ENTRYPOINT java -jar target/orders-2.14.1.jar
--------------------

It costs a second; put it next to your other checks on every commit.

The build context, in one sentence

The last argument to docker build (the .) is the build context: a directory whose entire contents are sent to the builder. COPY can only see files in it, and everything in it that is not excluded by .dockerignore is transferred on every build. The context lesson is about why that matters.

What you can now do

Why it helps

When a CI build goes red, the first skill is reading the BuildKit output: which step failed, the >>> line, the exit code. exit code: 127 means the command is not in that base image, so you are building in the wrong stage. pull access denied on load metadata means a typo or missing login, not a network outage. When a build is slow, the time column tells you which step to fix. In PR reviews you will catch EXPOSE being mistaken for publishing a port, ADD with a URL, and the warnings docker build --check prints: JSONArgsRecommended (shell-form entrypoint, lost SIGTERM) and SecretsUsedInArgOrEnv (a password in the image). And --progress=plain is what you want in CI logs when someone asks 'why did my build fail?'.

Commands in this lesson

docker

FAQ

Does EXPOSE 8080 open the port?

No. EXPOSE only records in the image config that the app listens on 8080. Nothing is published to the host. docker run -p 8080:8080 publishes a port; docker run -P publishes all exposed ports to random host ports. Other tools that run containers ignore it as well. Treat it as documentation, and keep it truthful.

Should I use ADD or COPY?

COPY, almost always. ADD also downloads URLs and auto-extracts local tar archives, both of which surprise people: a tarball you meant to copy gets unpacked, and URL downloads are not verified and cache poorly. When you need a download, RUN curl with a checksum check is clearer. Keep ADD for the rare case where you want a local tarball extracted.

Why do only some steps get a number in the build output?

BuildKit numbers the steps that build filesystem state: FROM, RUN, COPY, ADD, WORKDIR. Metadata instructions such as ENV, USER, EXPOSE or CMD only change the config and do not get their own line, although they still take part in the cache key. In multi-stage builds the label includes the stage name, like [build 3/6].

What does # syntax=docker/dockerfile:1 at the top do?

It tells BuildKit which Dockerfile frontend to use, pulling the latest stable 1.x version of the parser. That gives you newer features, such as RUN --mount, COPY --chmod and the build checks, independently of the Docker version installed. It must be the very first line. Without it you get the frontend bundled with your BuildKit, which is usually fine but can be older.

What is the difference between ARG and ENV?

ARG exists only during the build, is set with --build-arg, and is not in the running container's environment. ENV is stored in the image config and set in every container started from it. Neither is secret: ENV is visible in docker inspect, and ARG values used by a RUN are recorded in docker history. Use ARG for build choices like versions, ENV for runtime defaults.

In an interview Junior

Which Dockerfile instructions create layers, and why does that matter?

Why it matters: layers are what the image weighs and what every server downloads, and in BuildKit output only filesystem steps are numbered ([3/4]), with the time each took on the right - that is where build time goes. Metadata still counts for the build cache.

Two classics to mention: EXPOSE publishes nothing (only docker run -p does), and prefer COPY over ADD, which also unpacks tarballs and downloads URLs. When a build fails, the output shows the failing line with >>> and its exit code (127 = command not found); --progress=plain shows the full output of each step.

Also asked: What is the difference between COPY and ADD? · Does EXPOSE in a Dockerfile open a port on the host? · How do you read the output of a failed docker build?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.