Session 7 · Writing a Dockerfile#
The big idea#
A Dockerfile is a recipe: a text file of instructions that Docker follows to build an image.
Instead of installing things by hand inside a container, you write them down once, and anyone can
rebuild the same image with docker build. Each instruction adds a layer, and Docker caches layers,
so rebuilds are fast when little has changed.
Everyday example: a recipe card for biryani. “Take basmati rice. Add masala. Add chicken. Cook 30 minutes.” Anyone who follows the card gets the same dish. If you only change the salt in the last step, you don’t re-buy the rice; you reuse everything before it. That reuse is the layer cache.
A small app and its Dockerfile#
app.py is a tiny web server that greets you and counts visits.
"""A tiny web app: counts visits in memory and prints a greeting."""
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
GREETING = os.environ.get("GREETING", "Hello")
visits = 0
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
global visits
visits += 1
body = f"{GREETING} from Docker! visit {visits}\n".encode()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
The Dockerfile, five instructions:
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
ENV GREETING=Hello
EXPOSE 8000
CMD ["python", "app.py"]
Instruction |
Meaning |
|---|---|
|
The base image to start from (always first) |
|
The folder inside the image where later commands run |
|
Copy files from your folder into the image |
|
Set an environment variable (a default setting) |
|
Document which port the app uses (it does not publish it) |
|
The command to run when a container starts |
Build it, run it, change it#
cd "$(dirname "$0")/app"
docker rm -f dm-app >/dev/null 2>&1
echo '--- build the image'
docker build -q -t dm-app:1.0 . | sed 's/sha256:\(.\{12\}\).*/built image sha256:\1.../'
echo '--- the layers, one per Dockerfile instruction'
docker history dm-app:1.0 --format '{{.CreatedBy}}' | head -6
echo '--- run it and call it twice'
docker run -d --name dm-app -p 8089:8000 dm-app:1.0 >/dev/null
sleep 1
curl -s http://localhost:8089/
curl -s http://localhost:8089/
echo '--- change a setting without rebuilding (-e)'
docker rm -f dm-app >/dev/null
docker run -d --name dm-app -e GREETING=Vanakkam -p 8089:8000 dm-app:1.0 >/dev/null
sleep 1
curl -s http://localhost:8089/
echo '--- build again with no changes: every step comes from the cache'
docker build --progress=plain -t dm-app:1.0 . 2>&1 | grep CACHED | sed 's/^#[0-9]* //'
docker rm -f dm-app >/dev/null
--- build the image
built image sha256:6eceb6c3161d...
--- the layers, one per Dockerfile instruction
CMD ["python" "app.py"]
EXPOSE map[8000/tcp:{}]
ENV GREETING=Hello
COPY app.py . # buildkit
WORKDIR /app
CMD ["python3"]
--- run it and call it twice
Hello from Docker! visit 1
Hello from Docker! visit 2
--- change a setting without rebuilding (-e)
Vanakkam from Docker! visit 1
--- build again with no changes: every step comes from the cache
CACHED
CACHED
What it shows:
docker build -t dm-app:1.0 .built the image;-tgives it a name and tag, and.is the folder with the Dockerfile and files.docker historylists the layers newest first:CMD,EXPOSE,ENV,COPY,WORKDIR, and below them the layers ofpython:3.12-slim. One instruction, one layer.The app answered twice with
visit 1thenvisit 2. The counter lives in memory, so it resets when the container restarts (state belongs in a volume or database, Session 8).-e GREETING=Vanakkamreplaced the default setting fromENVat run time without rebuilding.A second build with nothing changed shows
CACHEDlines: Docker reused the layers.
Order matters for the cache#
If you change app.py, the COPY layer and every layer after it must rebuild; the layers above stay
cached. So put what changes least (base image, dependency install) first and what changes most
(your code) last. In bigger apps the usual pattern is: copy requirements.txt, run pip install, and
only then copy the source code.
Common mistakes#
Forgetting the
.dockerignorefile. Without itCOPY . .sends everything, including.gitand secrets, into the image.Using
latestas the base. Your build changes silently. Pin a version, aspython:3.12-slimdoes here.Thinking
EXPOSEopens the port. You still need-patdocker run.CMD python app.py(shell form) versusCMD ["python", "app.py"](exec form). Prefer the list form, so the app is PID 1 and receives stop signals.Baking secrets into the image with
ENV PASSWORD=.... Anyone with the image can read it. Pass secrets at run time.Installing build tools and leaving them in. Bigger image, bigger attack surface. Use a slim base, or multi-stage builds for compiled programs.
Hands-on exercises#
Exercise 1. Build the image as dm-app:2.0 and run it on host port 9100. Call it once.
Solution
cd code/app
docker build -t dm-app:2.0 .
docker run -d --name dm-app-2 -p 9100:8000 dm-app:2.0
sleep 1; curl -s http://localhost:9100/
docker rm -f dm-app-2
It prints Hello from Docker! visit 1.
Exercise 2. Change the default greeting to Welcome in the Dockerfile, rebuild, and look at the build
output. Which steps are cached?
Solution
Edit ENV GREETING=Welcome and run docker build --progress=plain -t dm-app:2.0 .. FROM, WORKDIR and
COPY are still CACHED because nothing above the changed line differs; only ENV and the steps after
it rebuild.
Exercise 3. Why is CMD ["python", "app.py"] better than CMD python app.py?
Solution
The list (exec) form starts Python directly as PID 1, so docker stop signals reach it and it shuts down
cleanly. The shell form wraps it in /bin/sh -c, and the shell may not pass the signal on.
Practice#
Take the Session 7 quiz. The original Detailed Structure of a Dockerfile page and the six Docker tasks are in the reference section.