Images, layers & optimization
Fast, lean, safe images: layers & the cache (modeled in Python), base-image choice, .dockerignore, multi-stage builds, finding bloat, the hardening checklist, and a CI scoring gate.
Learning objectives
- Explain layers, the build cache, and how to exploit them.
- Cut image size with slim bases, .dockerignore, and multi-stage builds.
- Apply the full production hardening checklist.
- Score and gate image quality in CI.
code/cd2-images-layers/. Python runs offline; configs are ready to use.1 · Images are stacks of layers essential
Every Dockerfile instruction creates a read-only layer; the image is those layers stacked. A running container adds a thin writable layer on top. Layers are cached and shared between images — the key to both speed and small storage.
A Docker image is built up in slices called layers, and this picture shows them left-to-right in the exact order the Dockerfile runs. Each box is one instruction, and each instruction adds one read-only layer on top of the one before it.
- Read the boxes left to right — that is the order the instructions run and the order the layers stack.
FROM baseis the starting point (a ready-made OS + Python), thenCOPY reqsbrings in your dependency list,RUN pip installinstalls those dependencies, andCOPY codeadds your own program last. - The small grey text under each box (shared base, deps list, installed deps, your code) tells you what that layer contains.
- The arrows mean "depends on everything before it". Docker can re-use a layer from a previous build (a cache hit) only if that instruction and every box to its left are unchanged.
- So if you edit
COPY code(the last box), only that one layer rebuilds. But if you change something early — sayCOPY reqs— every box to its right must rebuild too, because they all sit on top of it.
In short: Layers stack in order and each one depends on the ones before it. Put the things that rarely change (base image, dependencies) first so their layers stay cached, and put the thing you edit constantly (your code) last.
2 · The build cache — order matters enormously essential
Docker reuses a layer's cache if that instruction and everything before it are unchanged. So the golden rule: put things that change rarely (dependencies) before things that change often (your code).
cache_model.pydef rebuild_time(layers, changed):
"""layers: [(name, seconds)]. A layer rebuilds if it or anything before it changed."""
total, broken = 0, False
for name, secs in layers:
if name in changed: broken = True
if broken: total += secs
return total
good = [("FROM",2), ("COPY reqs",1), ("RUN pip install",40), ("COPY code",1)]
bad = [("FROM",2), ("COPY code",1), ("RUN pip install",40)] # code before install
print("edit code, GOOD order:", rebuild_time(good, {"COPY code"}), "s") # 1s
print("edit code, BAD order: ", rebuild_time(bad, {"COPY code"}), "s") # 41s
print("same edit, 40x slower rebuild just from instruction order")
edit code, GOOD order: 1 s
edit code, BAD order: 41 s
same edit, 40x slower rebuild just from instruction order
This tiny Python program models the Docker build cache so you can see, in plain numbers, why instruction order matters. It is not running Docker — it is simulating how long a rebuild takes given which layer you changed.
rebuild_time(layers, changed)takes a list of layers — each is a(name, seconds)pair saying how long that step takes — and a set of layer names thatchangedsince last build.- It walks the layers in order. The moment it hits a changed layer it flips
broken = True, meaning "the cache is broken from here on". Every layer from that point adds itssecstototal; layers before the change stay cached and cost nothing. goodinstalls dependencies before copying code;badcopies code before installing. Both then simulate the same everyday action: you editedCOPY code.- In
goodorder,COPY codeis last, so only its 1 second rebuilds. Inbadorder, changing the earlierCOPY codelayer forces the 40-secondRUN pip installthat sits after it to run again.
What the output means: GOOD order: 1 s vs BAD order: 41 s — the exact same code edit is 40× slower to rebuild purely because the dependency install was placed after the code copy.
Try this: Change {"COPY code"} to {"FROM"} and re-run: changing the very first layer rebuilds everything, so both orders become slow. That is why the base image goes first — you almost never change it.
Dockerfile.cacheFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt . # changes rarely -> this layer stays cached
RUN pip install --no-cache-dir -r requirements.txt
COPY . . # changes often -> only THIS layer rebuilds
CMD ["python", "app.py"]
This is the same lesson as the Python model, but written as a real Dockerfile. Each line is one instruction that becomes one layer, and the ordering is deliberately chosen so the slow step stays cached.
FROM python:3.12-slimpicks the starting image (a small Python).WORKDIR /appsets the folder inside the image where the next commands run.COPY requirements.txt .copies only the dependency list first — on its own, before your code. Your requirements change rarely, so this layer (and the install after it) usually stays cached.RUN pip install ... -r requirements.txtis the slow step. Because it sits right after the requirements copy, Docker re-uses its cached result on every build whererequirements.txtdid not change.COPY . .copies your actual source code last, because it changes on almost every build. Only this final layer rebuilds when you edit your code — the expensive install above stays cached.
Try this: If you instead put COPY . . above the pip install line, every code edit would re-run the install. This one ordering choice is the single most common Docker speed-up.
3 · Shrinking images — base image choice intermediate
Image size drives push/pull time, cold-start latency, and attack surface. The single biggest lever is the base image.
| Base | ~Size | Notes |
|---|---|---|
| python:3.12 | ~1 GB | full toolchain; dev only |
| python:3.12-slim | ~150 MB | the sensible default |
| python:3.12-alpine | ~50 MB | tiny; musl libc can break some wheels |
| distroless | ~50 MB | no shell/pkg mgr; most secure, harder to debug |
.dockerignore# .dockerignore (like .gitignore, for the build context)
.venv/
__pycache__/
*.pyc
.git/
tests/
.env
*.md
# smaller context = faster builds + nothing secret/bulky sneaks into the image
Before Docker builds anything, it bundles up your project folder and sends it to the builder — this bundle is called the build context. A .dockerignore file lists what to leave out of that bundle, exactly like .gitignore keeps files out of Git.
- Each line is a file or folder pattern to exclude.
.venv/,__pycache__/and*.pycare local Python junk that has no business inside the image. .git/(your whole version history) andtests/are large and not needed to run the app, so excluding them shrinks the context and the image..envand*.mdmatter for a different reason:.envholds secrets you must never copy into an image, and docs just add weight.- The final comment states the two payoffs: a smaller context builds faster, and nothing secret or bulky can accidentally get copied in by a broad
COPY . ..
Try this: Add a giant file like data/ to a project with no .dockerignore and watch docker build pause on "sending build context" — that pause is Docker uploading everything you forgot to ignore.
4 · Multi-stage builds — ship only what runs advanced
A multi-stage build uses a fat "builder" stage (compilers, dev headers) and copies only the result into a tiny final image. Build tools never ship to production — smaller and safer.
Dockerfile.multi# Stage 1 — build (has compilers/dev tools)
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt
# Stage 2 — runtime (tiny; only deps + code, no build tools)
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY . .
USER 1000 # non-root
CMD ["python", "app.py"]
A multi-stage build uses two images in one Dockerfile: a big "builder" that has all the compilers and dev tools needed to install things, and a tiny "runtime" that ships to production with only the finished result — no build tools.
FROM python:3.12 AS builderstarts the first stage on the full Python image (includes compilers). Naming itAS builderlets a later stage copy files out of it.RUN pip install --target=/deps ...installs all dependencies into one folder (/deps) inside the builder, so the whole result is easy to grab in one move.FROM python:3.12-slimbegins the second stage on the small image. This is a fresh start — none of the builder's compilers come along automatically.COPY --from=builder /deps ...reaches back into the builder stage and copies only the installed packages into the slim image.COPY . .adds your code,USER 1000switches off the root account, and only this slim stage is what actually ships.
Try this: The pairing with the next lab is the point: the fat builder can be 400 MB of tools, but because none of it is copied forward, the final image only carries the base + dependencies + your code.
size_estimate.pydef image_size(base_mb, deps_mb, build_tools_mb, multistage):
# multi-stage drops the build tools from the final image
return base_mb + deps_mb + (0 if multistage else build_tools_mb)
single = image_size(150, 120, 400, multistage=False)
multi = image_size(150, 120, 400, multistage=True)
print(f"single-stage: {single} MB")
print(f"multi-stage: {multi} MB ({round(100*(single-multi)/single)}% smaller)")
single-stage: 670 MB
multi-stage: 270 MB (60% smaller)
This program puts a number on the multi-stage saving. It adds up the megabytes an image carries and shows how much you cut by leaving build tools behind.
image_size(base_mb, deps_mb, build_tools_mb, multistage)sums the pieces of an image: the base OS, your dependencies, and (only for a single-stage build) the heavy build tools.- The key line adds
build_tools_mbonly whenmultistageisFalse:(0 if multistage else build_tools_mb). A multi-stage image carries zero build-tool weight. singleandmulticall it with identical numbers (150 MB base, 120 MB deps, 400 MB tools) — the only difference is themultistageflag.- The f-string prints each total, and
round(100*(single-multi)/single)computes the percentage shrink so the win is stated as a headline figure.
What the output means: single-stage: 670 MB vs multi-stage: 270 MB (60% smaller) — dropping the 400 MB of build tools cuts the shippable image well over half.
Try this: Bump build_tools_mb from 400 to 800 and re-run — the multi-stage image stays 270 MB while the single-stage balloons, so the percentage saving climbs even higher.
5 · Layer inspection & caching busting advanced
Diagnose bloated images: docker history shows each layer's size, so you can find the instruction that added 400MB. And know the cache busters — a changed file, a new arg, or --no-cache forces a rebuild from that point.
history.shdocker history myapp --human # size added by each layer
docker image inspect myapp --format '{{.Size}}'
docker build --no-cache -t myapp . # force full rebuild (ignore cache)
# a RUN that downloads then deletes in SEPARATE layers still keeps the download
# in the earlier layer -> combine with && in ONE RUN to keep it out
These shell commands are the detective tools for a bloated image: they show you which layer added the weight, and how to force a clean rebuild. Run them in a terminal where Docker is installed.
docker history myapp --humanlists every layer of the imagemyappwith the size each one added, in human-readable units (MB/GB). This is how you spot the one instruction that added 400 MB.docker image inspect myapp --format '{{.Size}}'prints just the total image size as a single number — handy for scripts and before/after comparisons.docker build --no-cache -t myapp .rebuilds from scratch, ignoring every cached layer. Use it to prove a fresh build works or to bust a stale cache.- The comment flags a classic trap: if a
RUNdownloads a big file and a separate laterRUNdeletes it, the bytes are already committed in the earlier layer forever. Chain download-use-delete in oneRUN ... && ...so they never land in a layer.
Try this: On any image you have locally, run docker history <image> --human and read it top-to-bottom: the fattest lines are your optimisation targets.
RUN wget big.tar then RUN rm big.tar leaves the file in the first layer forever. Do downloads, use, and cleanup in a single RUN … && … && rm … so the bytes never get committed to a layer.6 · Professional — the production hardening checklist professional
Dockerfile.prodFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home appuser # a non-root user
USER appuser # never run as root in prod
COPY --chown=appuser . .
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]
This is the production-ready Dockerfile — it folds in every safety practice from the lesson: small base, cache-friendly order, a non-root user, and a health check so the platform knows the app is alive.
- The first four lines are the cache-friendly pattern you already saw: slim base,
WORKDIR, copyrequirements.txtfirst, thenpip install. RUN useradd --create-home appusercreates an ordinary user, thenUSER appuserswitches to it. Everything after runs as this user, not root — so a break-in has far less power.COPY --chown=appuser . .gives that user ownership of the code.HEALTHCHECKtells Docker how to test if the app is healthy: every 30s it runs a tiny Python one-liner that fetches/healthz. If that fails, the container is marked unhealthy and can be restarted.CMD ["uvicorn", ...]is the command that actually launches the web app when the container starts, binding it to all interfaces on port 8000.
Try this: Compare this with Dockerfile.cache above: the differences (non-root user, --chown, HEALTHCHECK) are exactly the boxes the scorer in the next lab checks for.
COPY .env is committed to a layer forever and ships to every registry that pulls it — deleting it later doesn't help. Pass secrets at runtime and .dockerignore your .env.7 · Tech-lead — score images & gate CI tech-lead
Make image quality objective and enforced. A scorer turns the checklist into a CI pass/fail — no opinions, just policy.
image_score.pydef score_image(facts):
score, notes = 100, []
if facts["size_mb"] > 400: score -= 25; notes.append("image > 400MB")
if facts["runs_as_root"]: score -= 40; notes.append("runs as root")
if facts["has_secrets"]: score -= 100; notes.append("secrets baked in!")
if not facts["pinned_base"]: score -= 20; notes.append("unpinned base")
if not facts["has_healthcheck"]:score -= 10; notes.append("no HEALTHCHECK")
return max(score, 0), notes
good = score_image(dict(size_mb=180,runs_as_root=False,has_secrets=False,pinned_base=True,has_healthcheck=True))
bad = score_image(dict(size_mb=900,runs_as_root=True, has_secrets=True, pinned_base=False,has_healthcheck=False))
print("good:", good)
print("bad: ", bad)
print("CI gate (>=70):", "PASS" if good[0]>=70 else "FAIL", "/", "PASS" if bad[0]>=70 else "FAIL")
good: (100, [])
bad: (0, ['image > 400MB', 'runs as root', 'secrets baked in!', 'unpinned base', 'no HEALTHCHECK'])
CI gate (>=70): PASS / FAIL
This turns the hardening checklist into an automatic grade. Instead of arguing about whether an image is "good enough", a CI pipeline runs this scorer and blocks anything that scores too low — policy, not opinion.
score_image(facts)starts every image at a perfect100with an emptynoteslist, then subtracts points for each problem it finds.- The penalties are weighted by how much each issue matters: too big (
-25), runs as root (-40), andhas_secretsis a fatal-100— one baked-in secret alone drops the score to zero. Every deduction also appends a human-readable reason tonotes. max(score, 0)stops the score going negative, and the function returns the number plus the list of reasons so a human can see why it failed.goodandbadare two example fact sheets. The final line applies the gate:PASSif the score is>= 70, otherwiseFAIL.
What the output means: good: (100, []) passes clean; bad hits every penalty, lands at 0 with all five reasons listed, and the gate prints PASS / FAIL.
Try this: Flip one field of bad — say set has_secrets=False — and re-run. The score jumps by 100 but other penalties may still keep it under 70, showing how a single fix is rarely enough to pass the gate.
Exercise CD2.1 — Optimize a bloated image
Context: Taking a fat, naive image and driving it down to a lean, hardened one is the everyday craft of image work. Doing it against a measurable target — a passing score — is how real teams keep image quality from drifting.
Your task: Start from a single-stage image on the full python:3.12 base, measure it with docker history, then optimize it step by step and prove the improvement reaches a passing gate.
Requirements:
- Add a
.dockerignoreand reorder instructions for cache friendliness - Switch to a slim base and add a non-root user plus a
HEALTHCHECK - Convert the build to multi-stage so build tools never ship
- Run
size_estimateto quantify the size drop - Run
image_scoreand confirm the result passes the ≥ 70 gate
💡 Hint: Read docker history top to bottom to spot the fattest layers first — those are your highest-leverage optimization targets.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every Dockerfile instruction becomes a cached layer, and the order of those layers decides whether a rebuild takes one second or forty. Getting the ordering right is the single most common Docker speed-up.
Your task: Order the four instructions of a cache-friendly Dockerfile so that editing your source code only rebuilds the last, cheap layer, and explain which line stays cached.
Requirements:
- Copy
requirements.txtand runpip installbefore copying your code - Put
COPY . .last, because your code changes on almost every build - Keep the slow
pip installlayer cached when only code changed - Explain that Docker reuses a layer's cache only if it and everything before it are unchanged
💡 Hint: Docker walks layers top-down; anything after the first changed instruction must rebuild, so the rarely-changing dependency install belongs above your code copy.
Show solution
Docker reuses a layer's cache only if that instruction and everything before it are unchanged. So copy requirements.txt and install before copying your code.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt . # changes rarely -> this layer stays cached
RUN pip install --no-cache-dir -r requirements.txt
COPY . . # changes often -> only THIS layer rebuilds
CMD ["python", "app.py"]
Because your requirements change rarely, the slow pip install stays cached across builds; editing code only rebuilds the final COPY . . layer.
Context: Before Docker builds anything it bundles your project folder into a 'build context' and ships it to the builder. A .dockerignore keeps junk, bulk, and secrets out of that bundle — and out of the image.
Your task: Write a .dockerignore that keeps local Python junk, the git history, tests, secrets, and docs out of the build context, and explain the two payoffs the lesson names.
Requirements:
- Exclude local Python junk:
.venv/,__pycache__/,*.pyc - Exclude bulk not needed to run the app:
.git/,tests/ - Exclude secrets and docs:
.env,*.md - State payoff one: a smaller context builds faster
- State payoff two: nothing secret or bulky is pulled in by a broad
COPY . .
💡 Hint: It works exactly like .gitignore — one exclusion pattern per line; the .env line matters most because a baked secret ships with the image.
Show solution
The build context is the bundle Docker sends to the builder. .dockerignore works like .gitignore — each line is a pattern to exclude.
# .dockerignore (for the build context)
.venv/
__pycache__/
*.pyc
.git/
tests/
.env
*.md
Two payoffs: a smaller context builds faster, and nothing secret or bulky (like .env) can be accidentally pulled in by a broad COPY . .. The .env line matters most — it holds secrets that must never land in a layer.
Context: Claiming 'order matters' is abstract until you put a number on it. Modeling the build cache in plain Python turns instruction order into a concrete rebuild time you can compare.
Your task: Model the build cache in Python to quantify why instruction order matters: given (layer, seconds) pairs and which layer changed, compute the rebuild time for a good order (deps before code) versus a bad one (code before deps).
Requirements:
- Take a list of
(name, seconds)layers and a set of changed layer names - Walk the layers in order; once a changed layer is hit, the cache is broken from there on
- Sum only the seconds of layers at or after the first change; earlier layers cost zero
- Compare a good order (deps before code) to a bad one (code before deps) on the same code edit
- Show the same edit is ~40x slower to rebuild purely due to ordering
💡 Hint: A single broken flag that flips true at the first changed layer, then adds every subsequent layer's cost, is the whole model.
Show solution
Walk the layers in order; once you hit a changed layer, the cache is broken from there on and every subsequent layer's cost is added.
def rebuild_time(layers, changed):
"""layers: [(name, seconds)]. A layer rebuilds if it or anything before it changed."""
total, broken = 0, False
for name, secs in layers:
if name in changed:
broken = True
if broken:
total += secs
return total
good = [("FROM",2), ("COPY reqs",1), ("RUN pip install",40), ("COPY code",1)]
bad = [("FROM",2), ("COPY code",1), ("RUN pip install",40)]
print("edit code, GOOD:", rebuild_time(good, {"COPY code"}), "s") # 1s
print("edit code, BAD :", rebuild_time(bad, {"COPY code"}), "s") # 41s
Same code edit, 40x slower rebuild in the bad order — because the code copy sits before the 40-second install, changing it forces the install to re-run.
Context: Compilers and dev headers are needed to install dependencies but have no business shipping to production. A multi-stage build keeps a fat builder stage and copies only the finished result into a tiny runtime image — smaller and safer.
Your task: Convert a single-stage image to a multi-stage build that leaves compilers and dev tools behind, and model the size saving in Python (base 150MB, deps 120MB, build tools 400MB).
Requirements:
- Name the first stage
FROM python:3.12 AS builderand install deps into one folder (e.g.--target=/deps) - Start a fresh slim runtime stage and copy only the result with
COPY --from=builder - Switch off root in the runtime stage with a
USERline - Model the size as base + deps, adding build tools only for the single-stage case
- Report the percentage saving (670MB → 270MB, ~60% smaller)
💡 Hint: The runtime stage is a clean start — nothing from the builder comes along unless you explicitly COPY --from it, which is what strips the 400MB of tools.
Show solution
A fat builder stage installs deps into one folder; the slim runtime stage copies only that result via --from=builder, so build tools never ship.
# Stage 1 — build (has compilers/dev tools)
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt
# Stage 2 — runtime (tiny; only deps + code, no build tools)
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY . .
USER 1000 # non-root
CMD ["python", "app.py"]
def image_size(base_mb, deps_mb, build_tools_mb, multistage):
return base_mb + deps_mb + (0 if multistage else build_tools_mb)
single = image_size(150, 120, 400, multistage=False)
multi = image_size(150, 120, 400, multistage=True)
print(f"single: {single} MB")
print(f"multi: {multi} MB ({round(100*(single-multi)/single)}% smaller)")
670MB down to 270MB — 60% smaller — purely by not shipping the 400MB of build tools.
Context: A production-ready image folds every safety practice into one file: small base, cache-friendly order, a non-root user, and a health check the platform can poll. It also avoids the classic trap of thinking a later delete shrinks an image.
Your task: Write the production hardening Dockerfile that folds in every safety practice, and state why deleting a big file in a later RUN does not shrink the image.
Requirements:
- Use a slim base and the cache-friendly copy-then-install order
- Create a non-root user with
useraddand switch to it withUSER - Give that user ownership of the code with
COPY --chown - Add a
HEALTHCHECKthat polls the app's/healthzendpoint - Explain that a file deleted in a later layer stays committed in the earlier one — chain download-use-delete in a single
RUN ... && ... && rm ...
💡 Hint: Layers are append-only: once bytes are committed to a layer, a later rm only hides them; and never COPY .env — a baked secret ships to every registry.
Show solution
Create and switch to a non-root user, --chown the copied code to it, and add a health check the platform can poll.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home appuser # a non-root user
USER appuser # never run as root in prod
COPY --chown=appuser . .
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]
A file downloaded in one RUN and deleted in a later RUN stays committed in the earlier layer forever. Chain download, use, and cleanup in a single RUN ... && ... && rm ... so the bytes never land in a layer. Never COPY .env — a baked secret ships to every registry that pulls it.
Context: 'Is this image good enough?' should not be an opinion argued in review. A tech lead turns the hardening checklist into a weighted score and a hard CI gate.
Your task: As a tech lead, turn the hardening checklist into an objective CI scorer: start at 100 and deduct for size > 400MB (-25), running as root (-40), baked secrets (-100), an unpinned base (-20), and no HEALTHCHECK (-10), then gate at score ≥ 70.
Requirements:
- Start every image at 100 with an empty notes list
- Weight penalties by severity — a baked-in secret is a fatal -100
- Append a human-readable reason for each deduction
- Clamp the score at zero and return it alongside the notes
- Apply a PASS/FAIL gate at ≥ 70 and show a clean image versus a failing one
💡 Hint: Weighting matters: one fatal issue (secrets) should sink the score alone, and returning the reasons lets CI tell the engineer exactly what to fix.
Show solution
Weight the penalties by how much each issue matters — a baked-in secret is fatal on its own. Clamp the score at zero and return the reasons.
def score_image(facts):
score, notes = 100, []
if facts["size_mb"] > 400: score -= 25; notes.append("image > 400MB")
if facts["runs_as_root"]: score -= 40; notes.append("runs as root")
if facts["has_secrets"]: score -= 100; notes.append("secrets baked in!")
if not facts["pinned_base"]: score -= 20; notes.append("unpinned base")
if not facts["has_healthcheck"]: score -= 10; notes.append("no HEALTHCHECK")
return max(score, 0), notes
good = score_image(dict(size_mb=180, runs_as_root=False, has_secrets=False,
pinned_base=True, has_healthcheck=True))
bad = score_image(dict(size_mb=900, runs_as_root=True, has_secrets=True,
pinned_base=False, has_healthcheck=False))
print("good:", good)
print("bad :", bad)
gate = lambda s: "PASS" if s >= 70 else "FAIL"
print("CI gate:", gate(good[0]), "/", gate(bad[0]))
The clean image scores 100 (PASS); the bad one hits every penalty, lands at 0 with all five reasons, and FAILs the gate — policy, not opinion, blocks the merge.
✓ Checkpoint — you can move on when you can…
- Explain layers, the cache, and exploit instruction order.
- Shrink images (slim, .dockerignore, multi-stage) and quantify it.
- Find bloat with docker history; avoid delete-in-later-layer.
- Apply the hardening checklist and gate images in CI.
Knowledge check check yourself
Why does putting COPY requirements.txt + pip install before COPY . . dramatically speed up rebuilds?
Show answer
How does a multi-stage build shrink the final image, and why doesn't deleting a large file in a later RUN achieve the same thing?