Lesson 2 - Layers and the Build Cache
Welcome to Layers and the Build Cache
Lesson 1 proved every instruction produces its own layer. This lesson answers the question that matters once you’re rebuilding an image several times a day: does the order of those instructions change anything real? The answer is yes, and rather than take that on faith, you’re going to build the same functional image twice, with two instructions swapped, and time both builds for real — before and after a one-line code change.
By the end of this lesson, you will be able to:
- Explain how Docker’s build cache decides whether to reuse a layer or rebuild it.
- Explain why one layer missing the cache forces every layer built after it to miss too.
- Order Dockerfile instructions so that changes to your own code don’t force a dependency reinstall.
- Read real, measured build times and connect them back to which instructions changed.
The rule in one sentence
Docker caches each layer, and reuses a cached layer instead of rebuilding it only if the instruction is identical and every layer before it in the build was also reused. That second half is the part that trips people up: cache reuse isn’t decided layer by layer in isolation — it’s a chain. The moment one layer misses the cache, every layer built after it misses too, whether or not that later instruction actually depends on what changed.
The rest of this lesson makes that concrete with two Dockerfiles that do the exact same thing, in a different order.
The script: a tiny pandas report
CityFlow’s ops team wants a one-line report of average fares by zone. It’s deliberately small — the point of this lesson is measuring the build, not the script:
"""CityFlow's zone-fare report - a small pandas job used only to demonstrate
Docker layer caching in this lesson. It is not the course's real ETL job
(that comes in Module 3); it's deliberately tiny so the RUN pip install
step is the expensive part of the build, not the script itself.
"""
import pandas as pd
df = pd.DataFrame({
"zone": ["JFK", "LGA", "EWR", "Midtown", "Financial District"],
"avg_fare": [58.20, 42.75, 71.10, 18.40, 15.90],
})
print(df.sort_values("avg_fare", ascending=False).to_string(index=False))Save this as cityflow_report.py, alongside a requirements.txt with one real dependency:
pandas==2.2.3pandas is the perfect instruction to build this lesson around: installing it takes real, measurable seconds (downloading and installing several packages), which is exactly what makes a caching difference show up clearly instead of disappearing into noise.
Two Dockerfiles, one difference
Cache-friendly — dependencies first, application code last:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY cityflow_report.py .
CMD ["python", "cityflow_report.py"]Cache-hostile — everything copied in one step, before installing:
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "cityflow_report.py"]Both Dockerfiles produce an image that runs identically — same base, same dependency, same script, same output. The only difference is when cityflow_report.py gets copied in relative to the expensive RUN pip install step. That’s the entire experiment.
First builds: no cache, both cold
To get a fair baseline, build both with --no-cache so neither one can reuse anything:
time docker build --no-cache -f Dockerfile.friendly -t cityflow-report:friendly .#8 [3/4] RUN pip install --no-cache-dir -r requirements.txt
#8 34.92 Successfully installed numpy-2.5.1 pandas-2.2.3 python-dateutil-2.9.0.post0 pytz-2026.2 six-1.17.0 tzdata-2026.3
#8 DONE 35.1s
#9 [4/4] COPY cityflow_report.py .
#9 DONE 0.0sdocker build --no-cache -f Dockerfile.friendly -t cityflow-report:friendly . 36.764 totaltime docker build --no-cache -f Dockerfile.hostile -t cityflow-report:hostile .#8 [3/4] RUN pip install --no-cache-dir -r requirements.txt
#8 31.67 Successfully installed numpy-2.5.1 pandas-2.2.3 python-dateutil-2.9.0.post0 pytz-2026.2 six-1.17.0 tzdata-2026.3
#8 DONE 31.8sdocker build --no-cache -f Dockerfile.hostile -t cityflow-report:hostile . 34.184 total36.8 seconds versus 34.2 seconds — close enough to call the same. That’s expected: with no cache at all, order can’t help or hurt anything, because nothing is being reused either way. Both builds pay the full pip install cost once. The real test is what happens on the next build.
The real test: change only the application code
Make a small, realistic edit to cityflow_report.py — the kind of one-line change you’d make on an ordinary day, with nothing touching requirements.txt:
print(df.sort_values("avg_fare", ascending=False).to_string(index=False))
print("report generated for the CityFlow ops standup")Now rebuild both — this time without --no-cache, so Docker’s cache is allowed to do its job:
time docker build -f Dockerfile.friendly -t cityflow-report:friendly .#6 [2/5] WORKDIR /app
#6 CACHED
#7 [3/5] COPY requirements.txt .
#7 CACHED
#8 [4/5] RUN pip install --no-cache-dir -r requirements.txt
#8 CACHED
#9 [5/5] COPY cityflow_report.py .
#9 DONE 0.0sdocker build -f Dockerfile.friendly -t cityflow-report:friendly . 0.583 totaltime docker build -f Dockerfile.hostile -t cityflow-report:hostile .#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#7 DONE 0.0s
#8 [4/4] RUN pip install --no-cache-dir -r requirements.txt
#8 6.226 Collecting pandas==2.2.3 (from -r requirements.txt (line 1))
#8 25.09 Installing collected packages: pytz, tzdata, six, numpy, python-dateutil, pandas
#8 30.27 Successfully installed numpy-2.5.1 pandas-2.2.3 python-dateutil-2.9.0.post0 pytz-2026.2 six-1.17.0 tzdata-2026.3
#8 DONE 30.5sdocker build -f Dockerfile.hostile -t cityflow-report:hostile . 31.414 total0.583 seconds for the friendly order. 31.414 seconds for the hostile one. That’s roughly 53x faster, on the exact same one-line edit, produced by nothing but instruction order.
Read the two build logs closely and the reason is right there. In the friendly build, COPY requirements.txt . and RUN pip install both say CACHED — Docker recognized that neither requirements.txt nor the instruction text had changed, so it reused both layers untouched, and only the final COPY cityflow_report.py . (which genuinely changed) had to redo any work. In the hostile build, COPY . . copies everything, including the file you just edited — so that layer’s content hash changes, Docker marks it DONE rather than CACHED, and because RUN pip install comes after it in the Dockerfile, RUN inherits that cache miss and reinstalls pandas from scratch, even though nothing about pandas itself changed at all.
The cache doesn’t know what RUN actually depends on
This is the detail worth sitting with: RUN pip install -r requirements.txt in the hostile Dockerfile has no idea that cityflow_report.py changed, or that it’s irrelevant to what pip install does. Docker’s cache doesn’t inspect the meaning of a command — it only checks whether the previous layer’s cache was reused and whether this instruction’s text is identical to last time. A cache miss anywhere in the chain poisons everything built after it, regardless of relevance.
The general pattern
Put instructions that change rarely — your base image, your dependency list, a RUN pip install — before instructions that change often — your actual application code. Once you internalize that, most “why is my Docker build so slow” questions answer themselves by just reading the Dockerfile top to bottom and asking, honestly, how often each COPY target actually changes.
This is why real Dockerfiles copy requirements.txt separately
You’ve probably seen COPY requirements.txt . followed by RUN pip install ... followed by COPY . . in real-world Dockerfiles and wondered why they don’t just COPY . . once. Now you’ve measured the answer yourself: it’s not a stylistic habit, it’s the difference between a sub-second rebuild and a half-minute one on every single code change.
Practice Exercises
Exercise 1: Reproduce both timings yourself
Build both Dockerfiles from this lesson with --no-cache first, note the times, then make a one-line edit to cityflow_report.py and rebuild both without --no-cache. Confirm the friendly order finishes in under a second and the hostile order takes roughly as long as the original cold build.
Hint
Use time docker build ... exactly as shown in this lesson so you get a real wall-clock number to compare, not just a subjective sense of “fast” or “slow.”
Exercise 2: Change a dependency instead of the code
This time, edit requirements.txt instead of cityflow_report.py (for example, pin a different pandas version) and rebuild both Dockerfiles. Explain, in your own words, why the friendly order no longer has a speed advantage on this particular change.
Hint
In the friendly Dockerfile, COPY requirements.txt . comes before RUN pip install, so a change to requirements.txt invalidates that COPY layer and, by the same cascading rule, the RUN layer right after it — on this specific edit, both orderings genuinely have to reinstall.
Exercise 3: Add a third instruction and predict its effect
Add a COPY for a second, rarely-changing file (for example, a small README.md) to the friendly Dockerfile, placed after RUN pip install but before COPY cityflow_report.py .. Predict whether editing cityflow_report.py alone will still produce a sub-second rebuild, then test it.
Hint
Editing cityflow_report.py only affects the layer that copies it and nothing else, no matter how many other COPY instructions sit between it and RUN pip install — as long as those other instructions’ own inputs haven’t changed too.
Summary
You built one functional image two different ways, reordering exactly two instructions, and measured the consequence rather than assuming it: cold builds took about the same time either way (36.8s versus 34.2s), but after a one-line, dependency-unrelated code edit, the cache-friendly order rebuilt in 0.583 seconds while the cache-hostile order rebuilt in 31.414 seconds — roughly 53x slower for no reason but instruction order. The mechanism is a chain, not a per-layer lookup: one cache miss forces every layer built after it to miss too, regardless of whether that later instruction’s actual inputs changed.
Key Concepts
- Layer cache — Docker reuses a layer only if its instruction is identical and every layer before it in the build was also reused.
- Cache chain — a single cache miss invalidates every subsequent layer, whether or not they depend on what actually changed.
- Cache-friendly ordering — copy and install rarely-changing dependencies first; copy frequently-changing application code last.
docker build --no-cache— forces a full rebuild with no cache reuse, useful as a fair cold-start baseline.time docker build ...— the way to turn “this feels slow” into a real, comparable number.
Why This Matters
Rebuild speed compounds. A team that rebuilds a Dockerfile ten times a day pays the difference between 0.6 seconds and 31 seconds ten times over — minutes of real, wasted developer time, every single day, purely from instruction order. This isn’t a micro-optimization reserved for large teams either: the exact same reasoning you just measured on a five-line pandas script scales directly to Module 3’s real, larger CityFlow ETL image, where a slow rebuild loop is the difference between iterating on a bug fix in seconds versus minutes.
Continue Building Your Skills
You can now build an image efficiently, but every image you’ve built so far has stayed on this one machine. In Lesson 3, you’ll learn what a registry actually is, and push an image of your own to one for the first time — using a real, local registry rather than a public account, so the whole exercise stays reversible and entirely under your control.