skip to content

BuildKit vs Legacy Builder

How BuildKit actually executes a Dockerfile: a dependency graph of content-addressed steps, so independent stages run in parallel and unreferenced ones never run. Asked because 'why did my build get faster, and where did my image go' has a mechanical answer.

part ofDockeroverview, primer and where to startread it →
on this pageshow

questions

4

Why does BuildKit never run a Dockerfile stage that nothing references, when the legacy builder ran every stage?

level: middleimportance: must knowfreq 62%

answer

  1. Not a script, a graph
  2. Only what the output needs
  3. Unreferenced branches are pruned
  4. Independent branches solve concurrently
  5. --target, or a COPY --from edge

basics

~20 s

BuildKit converts the Dockerfile into a dependency graph and solves only the steps the requested output actually needs, so a stage nothing copies from is pruned. The legacy builder walked the file top to bottom, so every stage ran.

solid answer

~50 s

The Dockerfile frontend turns the file into **LLB**, BuildKit's low-level build definition: a directed acyclic graph of content-addressed steps, not a script. A build asks the solver for one output — the last stage, or the stage named by `--target` — and the solver walks backwards from it, evaluating only the vertices that output depends on. A stage that no later stage `COPY --from`s, and that is not the target, is not in that subgraph, so it never executes. Independent branches of the graph have no edge between them and are solved concurrently, which is where most of the speed-up comes from. The classic builder had no graph: it ran instructions in file order, so a lint or test stage executed as a side effect of its position. To make such a stage run under BuildKit, build it with `--target`, or make the final stage consume something it produces.

code

dockerfile · 13 lines
dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /src
COPY . .
RUN npm ci && npm run build

FROM node:20-alpine AS lint
WORKDIR /src
COPY . .
RUN npm ci && npm run lint

FROM nginx:1.27-alpine
COPY --from=build /src/dist /usr/share/nginx/html

go deeper

for a junior

Recall that a modern docker build does not necessarily run every stage in the file, and that --target picks which stage you are asking for. Being able to say "only the stages the final image depends on run" is enough at this level.

for a middle

Explain the mechanism: the Dockerfile frontend emits LLB, a graph of content-addressed steps, and the solver evaluates only what the requested output reaches. Be ready to trace a three-stage Dockerfile and say which stages run and which do not.

for a senior

Show you have hit this in production — a check stage that silently stopped running after an engine upgrade — and give the fix that survives review: a dedicated --target invocation in the pipeline rather than a fake COPY edge or disabling BuildKit.

for a principal

Own the consequence for pipeline design: what verification must be its own build step versus what is allowed to live inside the image graph, and how you stop teams re-creating the silent-skip failure across many repositories.

### Two execution models, one Dockerfile The classic builder (the one that ran before BuildKit became the default) read a Dockerfile as a **script**. It parsed the file, then walked the instructions from the first line to the last, running each one in a throwaway container, committing the result as an image layer, and feeding that layer to the next instruction. Multi-stage builds did not change that model: a second `FROM` simply reset the base for the instructions that followed. Every stage in the file executed, in file order, whether or not anything ever consumed its output. BuildKit does not execute the Dockerfile at all. It executes a **graph**. ### Frontend, LLB, solver A BuildKit build has three moving parts. The **frontend** translates a build definition into LLB. For a Dockerfile that frontend is `docker/dockerfile` — either the version compiled into BuildKit, or the frontend image named by a `# syntax=` directive at the top of the file. Its only job is translation: Dockerfile in, LLB out. **LLB** (low-level build definition) is the intermediate representation: a directed acyclic graph of vertices, where each vertex is one operation — run this command over that filesystem, copy these paths out of that result, resolve and fetch this base image. Every vertex is content-addressed: its identity is a digest computed over the operation itself plus the digests of its inputs. Nothing in LLB records "this was line 14". The edges record only "this result needs that result". The **solver** is asked to produce exactly one thing: the result of the requested output. That is the last stage in the file, or the stage named by `--target`. It walks the graph backwards from that vertex, collects the transitive set of vertices the output depends on, and evaluates that set — nothing else. ### Why an unreferenced stage vanishes Take a Dockerfile for an nginx-fronted static bundle that carries three stages: `build`, which runs the bundler; `lint`, which runs the style checks; and a final nginx stage that does `COPY --from=build /src/dist /usr/share/nginx/html`. Ask the solver for the final stage. It needs the `COPY --from=build` vertex, which needs the `build` stage's `RUN`, which needs its `COPY`, which needs the build context and the `node` base image. Trace every edge and the `lint` stage is nowhere in that subgraph — nothing in the final image derives from it. So it is never evaluated. It is not cached, not skipped-for-speed, not run-and-discarded; it is simply not part of the solve. Teams meet this as a regression, not as a feature. A pipeline that took 4 m 52 s drops to 1 m 17 s after an engine upgrade, everyone celebrates, and three weeks later someone notices the lint stage has not failed a build since — because it has not run since. ### Parallelism comes from the same property The same graph gives BuildKit concurrency for free. If two stages share no edges, neither is an input to the other, so the solver can evaluate them at the same time; it does not have to serialize them just because one appears above the other in the file. In a build with a compile stage and an asset-bundling stage that only meet at the final `COPY --from`, both run concurrently and the build takes roughly the time of the slower branch rather than the sum. The visible side effect is that log output no longer arrives top-to-bottom. `--progress=plain` prefixes every line with the vertex number that emitted it (`#9 3.412 ...`), which is how you reconstruct what happened when two steps interleave. ### Making a stage run on purpose Three honest options, in rough order of preference: 1. **Make it the target.** `docker build --target lint .` asks the solver for that stage, so it and its dependencies are exactly what runs. This is the normal CI shape: one invocation per checking stage. 2. **Put it in the graph.** Have the stage write something the final stage consumes — `COPY --from=lint /reports/lint.txt /var/log/lint.txt` — so the edge exists and the solver must evaluate it. It works, but it drags a checking artefact into the shipped image and it makes the lint stage block the final result. 3. **Run it as its own build.** A separate `docker build --target test` step in the pipeline, whose failure fails the job. ### The legacy builder, and why "just turn BuildKit off" is not the fix `DOCKER_BUILDKIT=0 docker build .` selects the classic builder, which will indeed run every stage again — because it has no graph and no notion of pruning. That is a diagnosis tool, not a solution. The classic builder is deprecated: BuildKit became the default in Docker Engine 23.0, `docker build` routes to `docker buildx build` when the buildx plugin is present, and the classic path is being removed, so a pipeline that depends on its side effects is on borrowed time. Fix the Dockerfile or the pipeline invocation instead. ### The mental shift The one-line version: under the classic builder, position in the file determined what ran; under BuildKit, **reachability from the requested output** determines what runs. Everything else — pruning, parallel branches, out-of-order logs — follows from that single change.

  • How would you keep a lint stage failing the build without dragging its output into the shipped image?
    Run it as its own build invocation in the pipeline: `docker build --target lint .` as a separate step, whose non-zero exit fails the job. That keeps the edge out of the final image's graph. The alternative — having the final stage `COPY --from=lint` a report file — does force the stage to run, but it ships a checking artefact and makes the lint stage block the image result.
  • Does BuildKit's pruning apply to individual instructions inside a single stage as well?
    Not in the same way. Within one stage each instruction takes the previous filesystem as its input, so they form a chain and every one of them is reachable from the stage's result. Pruning shows up between stages, and for inputs a stage never touches — an extra named build context or a base image referenced only by a stage that is not solved is never even fetched.
  • Why do BuildKit build logs no longer read top to bottom, and how do you follow them?
    Because independent vertices execute concurrently, so their output interleaves. `--progress=plain` prints unbuffered lines prefixed with the vertex number and elapsed seconds (`#9 3.412 ...`), so you can reconstruct which step emitted what. The default TTY progress display collapses each step to a live status line, which is easier to watch and much harder to read after the fact — use plain in CI.

The classic builder read the Dockerfile like a shopping list, buying every item top to bottom. BuildKit reads it like a recipe and buys only the ingredients the dish you ordered actually contains.

saying these in an interview costs you the question

  • Says BuildKit runs every stage, just faster
  • Thinks position in the Dockerfile decides what executes
  • Claims the skipped stage was served from cache
  • Expects build log output in strict file order
  • Recommends DOCKER_BUILDKIT=0 as the permanent fix
  • Believes --target only tags, not selects, a stage

context

open as a page

A `docker buildx build` on a docker-container builder succeeds, yet `docker images` lists nothing new. Why, and how do you get the image out?

level: seniorimportance: should knowfreq 45%

basics

~20 s

The docker-container buildx driver runs BuildKit outside the engine's image store and exports nothing by default, so the result stays in the builder. Add --load to import it into the local engine, or --push to publish it.

open as a page

How do you decide which buildx driver and exporter every team's image builds should use, from laptops to CI?

level: principalimportance: should knowfreq 36%

basics

~20 s

Decide by where the result must land and how insulated the build must be from the host: the built-in docker driver for laptop iteration, and a docker-container or remote builder in CI with push as the default exporter.

open as a page

What does the `# syntax=docker/dockerfile:1` line at the top of a Dockerfile do?

level: juniorimportance: nice to knowfreq 30%

basics

~20 s

It is a BuildKit parser directive naming the frontend image that parses the Dockerfile. BuildKit pulls docker/dockerfile:1 and uses it instead of its built-in parser, so newer Dockerfile syntax works without upgrading the Docker Engine.

open as a page