skip to content

questions

5

Why is `RUN apt-get upgrade` inside a Dockerfile treated as an antipattern for patching CVEs?

level: middleimportance: must knowfreq 58%

answer

  1. The build gains a hidden, dated input
  2. That instruction is cached after the first build
  3. Old files stay in the layers below
  4. The maintainer already rebuilds the base
  5. Pin one fixed package as a stopgap

basics

~20 s

Upgrading OS packages inside a Dockerfile makes the image depend on when it was built, its cached instruction stops re-running so it patches nothing, and the superseded files stay in the layers below. Rebuild on a refreshed base instead.

solid answer

~50 s

The image is supposed to be a function of its `FROM` and its instructions. `RUN apt-get update && apt-get upgrade -y` breaks that: two builds of the same Dockerfile a week apart produce different package sets, and if the `FROM` is digest-pinned you have quietly un-pinned it. It is also unreliable as a patch mechanism — that instruction's layer is cached, so after the first build it usually never executes again, and the scanner findings come back. And it costs size: the replaced files are written into a new layer while the originals remain in the lower ones, so a fat image grows rather than shrinks. The supported fix is to move the base forward — `--pull` a refreshed tag, or bump the pinned digest — and rebuild. A minimal base with no package manager at all makes the antipattern impossible by construction.

go deeper

for a junior

Recall the rule and one reason for it: to pick up OS security fixes you rebuild on a newer base image, you do not upgrade packages inside the Dockerfile. Know that base images are rebuilt and republished by their maintainers.

for a middle

Explain the mechanics behind the rule — the hidden build-time input, the cached RUN that stops executing, and the superseded files that remain in the lower layers — and describe the narrow, version-pinned stopgap that is still acceptable.

for a senior

Show judgement about the exception: when the base is behind and you must ship, pin one named package, document it, and put an expiry on it. Be ready to explain why a passing scan after an in-image upgrade is weak evidence.

for a principal

Own the policy: make rebuild-on-refreshed-base the only sanctioned patch path, decide how narrow stopgaps are recorded and expired, and reduce exposure structurally by standardising on slimmer bases that cannot be patched in place at all.

## What the instruction is trying to do A scanner reports critical findings in OS packages that came from the base image. The tempting one-line fix is to append a package upgrade to the Dockerfile: ```dockerfile RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/* ``` The first build after adding it really does reduce the count, which is why the pattern spreads. It is still the wrong mechanism, for four reasons that compound. ## 1. The image stops being reproducible An image should be a function of the base it names plus the instructions in the Dockerfile. An in-build upgrade adds a hidden input: the state of a distribution mirror at the moment of the build. Build the identical Dockerfile on Tuesday and again on Friday and you get different package versions with no source change to explain them. If the `FROM` line is digest-pinned — the whole point of which is to make the base exact — the upgrade instruction has silently undone that guarantee for everything the package manager touches. For a C++ daemon this is not academic: an upgrade can pull a newer `libstdc++` or `libssl` under a binary that was built and tested against the versions shipped in the base. ## 2. It usually does not run again The upgrade lives in a `RUN` instruction, and that instruction's result is cached. Unless something above it in the Dockerfile changes, later builds reuse the cached layer and the package manager never executes. Teams therefore believe they are patching continuously while shipping the same packages as the day the line was added; the findings reappear and nobody can explain why. Fixing it by forcing the cache open on every build costs you the build cache for everything downstream, and still leaves problems 1, 3 and 4. ## 3. It makes the image bigger, not cleaner An image is a stack of read-only layers. An upgrade does not edit the lower layers — it writes the new files into the new layer, and the superseded copies remain underneath, still shipped, still pulled by every host. On a payments reconciliation image whose runtime shared libraries already account for roughly 2.3 GB, an upgrade layer adds hundreds of megabytes of duplicates to every pull and every host's disk. The vulnerable files are still in the image, which also means a scanner that attributes findings per layer may keep reporting them. ## 4. It fights the way base images are maintained Distribution base images are rebuilt by their maintainers, tested as a set, and republished under the same tag. That rebuild is the supported patch channel. Re-implementing it inside your own build means you own the outcome of an upgrade nobody tested in that combination, on a schedule set by your build cache. And it does nothing for the findings that are not OS packages at all — application libraries vendored into the image are untouched by the distro's package manager. ## What to do instead Move the base forward and rebuild. If the `FROM` names a tag, `docker build --pull` re-resolves it to the maintainer's freshly rebuilt image. If it names a digest, an automated update pull request bumps the digest and CI rebuilds and tests. The rebuilt image is a clean stack: patched files come from the base layers, nothing is duplicated, and the result is reproducible from what the Dockerfile says. Shrinking the base — a slim variant, or a distroless base with no shell and no package manager — reduces how often you are in this position at all, and makes in-image patching impossible by construction, which is a feature. ## The narrow legitimate case Sometimes the base has not been rebuilt yet and you must ship today. Installing one specific fixed package, version-pinned, is a defensible stopgap: ```dockerfile RUN apt-get update \ && apt-get install -y --no-install-recommends libssl3=3.0.15-1~deb12u1 \ && rm -rf /var/lib/apt/lists/* ``` That is narrow, explicit, and reviewable — and it comes with an obligation: track it, and delete it as soon as the base ships the fix, or it will still be there, pinning an old version, two years later. The difference from `apt-get upgrade` is that this line states exactly what changed and why, instead of importing whatever the mirror happens to hold.

  • Does the same objection apply to `apk upgrade` on Alpine or `dnf update` on a RHEL-family base?
    Yes — the reasoning is about where the patch comes from, not about which package manager runs. Any in-build upgrade makes the contents depend on build time, sits behind the build cache, and layers new files over the old ones. The vendor-neutral rule is: change the base, rebuild, do not mutate a base you declared.
  • A distroless image has no package manager. How do you patch an OS-level CVE in it?
    You cannot patch it in place, and that is intentional. You rebuild against a newer distroless base — the publisher rebuilds those images with refreshed libraries — and redeploy. The absence of a shell and a package manager removes the in-image patching option entirely and forces the rebuild path, which is the one that produces a reproducible result.
  • Why does an image scanner sometimes still report a package you upgraded in a later layer?
    Because the original file was never removed — it is still present in a lower read-only layer, and the shipped image contains both copies. Depending on how the scanner reads the image and attributes findings to layers, the superseded version can still be visible. Removing a file in a later layer never removes it from the image; only a rebuild without it does.

saying these in an interview costs you the question

  • Says upgrading packages in the Dockerfile is the standard fix
  • Assumes the upgrade instruction re-runs on every build
  • Thinks an upgrade layer shrinks or replaces the old files
  • Adds an upgrade on top of a digest-pinned FROM without noticing
  • Believes deleting a file in a later layer removes it from the image
  • Expects the distro package manager to patch vendored app libraries

context

open as a page

What does `docker build --pull` do, and why can a nightly rebuild still ship an outdated base image?

level: juniorimportance: should knowfreq 42%

basics

~20 s

docker build --pull makes the builder re-resolve every FROM reference against the registry instead of reusing the base image already in the host's local image store. Without it a rebuild can inherit a months-old base, and its CVEs, forever.

open as a page

A Docker host keeps running the old image after you pushed a patched rebuild to the same tag — why?

level: seniorimportance: should knowfreq 46%

basics

~20 s

A tag is a mutable pointer, and the host already holds an image under that name, so docker run, docker compose up and docker restart never ask the registry. Only a pull plus a container recreate lands the rebuild.

open as a page

A platform team proposes rebuilding every Docker image in the estate weekly against refreshed bases — what does that cost, and how do you make it safe?

level: principalimportance: should knowfreq 40%

basics

~20 s

A rebuild produces new bytes, so unless application inputs are pinned the base refresh smuggles in unreviewed upgrades. Make the base the only floating input, gate on each service's tests, stagger the rollout, and remember rebuilding is not deploying.

open as a page

How does pinning a Dockerfile's `FROM` to an image digest change a multi-platform build?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

A multi-platform tag resolves to an index listing one manifest per architecture. Pinning the index digest keeps every platform buildable; pinning one architecture's manifest digest hard-codes that architecture, and a build requested for another platform finds no matching entry.

open as a page