skip to content

Why do Dockerfiles so often chain many shell commands into a single RUN instruction with `&&`, and what goes wrong if you split them into separate RUN lines and clean up afterwards?

level: middleimportance: must knowfreq 65%

answer

  1. Each RUN/COPY/ADD = one immutable layer
  2. Delete later = whiteout, bytes still ship
  3. apt-get update && install in ONE RUN
  4. Stale index → 404 on package fetch
  5. Group by lifecycle, not by minimum count

basics

~20 s

Every RUN, COPY and ADD commits a new immutable layer. Files created in one layer and deleted in a later one still ship — deletion only writes a whiteout marker. Chaining with && lets the cleanup happen inside the same layer, so the bytes never exist in the image.

solid answer

~60 s

Each `RUN`, `COPY` and `ADD` commits a filesystem diff as an immutable layer, and the image is the stack of those layers. Deleting a file in a later layer records a **whiteout** entry that hides it; the original bytes remain in the earlier layer, still pushed, still pulled, still extractable. So this is broken: ``` RUN apt-get update RUN apt-get install -y build-essential RUN rm -rf /var/lib/apt/lists/* ``` The package lists and the downloaded `.deb` files ship anyway. Chained into one `RUN ... && ... && rm -rf ...`, the cleanup is part of the same diff and nothing is committed. There is a second reason for this specific pair: a standalone `RUN apt-get update` gets cached, so a later edit to the install line reuses a stale package index and installs outdated (or missing) versions. Keeping `update && install` in one instruction ties them to one cache key. Balance it: chain related steps, but don't collapse everything — layer boundaries at stable points preserve cache reuse and readability.

code

dockerfile · 9 lines
dockerfile
# BAD: three layers; the index and .debs still ship
RUN apt-get update
RUN apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/*

# GOOD: one layer, nothing left behind, update tied to install
RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential=12.9 \
 && rm -rf /var/lib/apt/lists/*

go deeper

for a junior

Say that each RUN makes a layer and that cleanup must be in the same RUN as the thing it cleans up; show the apt-get one-liner.

for a middle

Explain whiteouts and immutability, the apt-get update cache staleness failure, and grouping by change frequency rather than minimising layers.

for a senior

Add the security angle (secrets remain extractable, rotate not delete), diagnosis via docker history, version pinning for reproducibility, and how multi-stage builds change what layer hygiene is actually worth.

for a principal

Set the standard: layer boundaries by lifecycle, pinned package versions, lint rules against split update/install, and image-size budgets tied to pull latency and registry cost.

## How layers are produced Building an image runs instructions in order. `RUN`, `COPY` and `ADD` each execute in a temporary container started from the current image state, then commit the resulting filesystem diff as a new **layer**. Metadata-only instructions (`ENV`, `WORKDIR`, `LABEL`, `USER`, `EXPOSE`, `CMD`, `ENTRYPOINT`, `ARG`) change the image config, not the filesystem, so they no longer add filesystem layers. Layers are immutable and stacked by a union filesystem (overlay2 in practice). Reading a path walks down the stack until it is found. ## Why deletion doesn't shrink an image There is no way to remove content from a layer that is already committed. Deleting a file in layer N+1 writes a **whiteout** entry — a marker saying "this path is gone from here up". The union filesystem honours it, so the running container cannot see the file. But the manifest still references layer N, that layer is still pushed to the registry, still pulled by every node, and its content is still recoverable by anyone who unpacks the layers with `docker save` or a registry client. The consequences: 1. **Size.** Deleting a 300 MB toolchain in a later RUN saves nothing; it actually adds a small layer. 2. **Secrets.** A credential file written in one layer and deleted in a later one is still in the image, fully readable. This is why removing a leaked key needs a rebuild, not a follow-up deletion. ## What chaining fixes When create and delete happen inside the *same* `RUN`, only the net result is committed: ``` RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential \ && rm -rf /var/lib/apt/lists/* ``` The package index existed only inside that temporary container; the committed diff never contains it. Equivalents in other ecosystems: `apk add --no-cache`, `pip install --no-cache-dir`, `npm ci && npm cache clean --force`, `yum install && yum clean all`. ## The apt-get update cache trap The second reason for chaining `update` with `install` is cache correctness. A `RUN`'s cache key is essentially the literal command string plus the parent layer. So `RUN apt-get update` on its own hits cache forever — its text never changes. Weeks later you edit the separate install line; the update layer is reused, and you install against a package index snapshot from weeks ago. Symptoms are ugly and confusing: `404 Not Found` on package URLs (the mirror rotated versions away), or a silently outdated, unpatched package. Chaining puts both into one instruction whose cache key changes whenever the package list changes. Pinning versions (`build-essential=12.9`) also forces invalidation and makes builds far more reproducible. ## Don't overcorrect "One giant RUN" is not the goal. Layers exist for cache reuse and for sharing between images: - Layers are content-addressed and **shared**: ten images on the same base pull those base layers once. - A stable early layer is reused across rebuilds. Collapsing everything into one instruction means any change re-runs everything. So group by *lifecycle*, not by minimising count. A good shape is: OS packages in one RUN (they change monthly), dependency install in another keyed on manifest files (weekly), application source and build last (per commit). Ordering by volatility this way is why `COPY package.json ./ && RUN npm ci` before `COPY . .` is standard: a source-only edit does not re-resolve dependencies. The old advice about a hard layer limit is obsolete — overlay2 supports well over a hundred layers — so readability and cache behaviour, not a magic number, should drive the split. Also note that many small layers add per-layer overhead in pull and extraction, so extremes hurt in both directions. ## Diagnosing `docker history <image>` shows each layer with its size and the instruction that produced it, which is the fastest way to find the fat layer. Tools that walk the layers can show which files each one added. When you find a 400 MB layer whose command ends in a `rm`, you have found a split that should have been a chain. In a multi-stage build, layer hygiene in the *builder* stage matters much less, since those layers never ship — but it still costs cache space and build time, so the discipline is worth keeping where it is cheap.

  • A previous build accidentally wrote an API token to a file and a later RUN deleted it. Is the token in the pushed image?
    Yes. The later deletion only adds a whiteout marker; the layer that created the file is still part of the manifest and is pushed and pulled intact. Anyone with the image can extract it with `docker save` or by pulling the layer blobs. The only fixes are rebuilding without ever writing the secret into a layer and treating the credential as compromised — rotate it.
  • If layers are the problem, why not collapse the entire Dockerfile into one RUN?
    Because you lose cache reuse and layer sharing. Distinct layers let unchanged, expensive steps be reused across rebuilds and shared between images built on the same base, which is where most build-time and pull-time savings come from. Group instructions by how often they change — OS packages, then dependencies, then application source — rather than minimising the count.
  • Why does a standalone `RUN apt-get update` cause package installs to fail weeks later?
    Its cache key is just the command text, which never changes, so the layer is reused indefinitely and the package index inside the image stays frozen at whatever it was when first built. Upstream mirrors rotate package versions out, so the install step then requests URLs that no longer exist and fails with 404s. Chaining update and install into one RUN gives them a single cache key.

Layers are sheets of tracing paper stacked on a drawing. Painting white over a line on a new sheet hides it, but the original sheet — and the line — is still in the stack you hand over.

saying these in an interview costs you the question

  • Believing a later `rm -rf` reduces the pushed image size
  • Thinking a deleted secret is gone from the image
  • Putting `apt-get update` in its own RUN
  • Collapsing everything into one RUN and destroying cache reuse
  • Citing an obsolete hard limit on layer count as the reason to chain

context