Lab Guide — First docker build of the API
Lesson 01 · Cloud Infrastructure & DevOps · ~45 min hands-on
1. Prerequisites
- macOS, Linux, or WSL2 (all commands work on all three)
- Docker Desktop 24+ (macOS / Windows) or
docker-ce(Linux). Verify:docker versionshould print both client and server versions without error. - Python 3.10+ — only needed for Step 1’s baseline run; everything after that uses only Docker.
- The course repo — clone
Claude_Lessonsor ensurecloud-devops/sample-app/is available locally.
2. Step 1 — Run the API locally first (the “no container” baseline)
Before adding Docker to the picture, run the URL shortener the old-fashioned way. This establishes the baseline we will reproduce exactly inside a container.
cd cloud-devops/sample-app/api
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app:app --port 8000
In a second terminal, test the two endpoints:
curl -s localhost:8000/health
# {"status":"ok"}
curl -s -X POST localhost:8000/shorten \
-H 'content-type: application/json' \
-d '{"url":"https://anthropic.com"}'
# {"code":"abc123","short_url":"http://localhost:8000/abc123"}
Stop the server with Ctrl-C. This is the baseline. The container will produce the same output from the same endpoints.
3. Step 2 — Inspect the Dockerfile
The Dockerfile is already present at cloud-devops/sample-app/api/Dockerfile:
# Lesson 01: the simplest Dockerfile that boots the API.
# Lesson 02 will replace this with a multi-stage build.
FROM python:3.11-slim
WORKDIR /app
# Copy the pin file FIRST so pip-install is cached when only app.py changes.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
# Use the exec form so uvicorn is PID 1 (gets SIGTERM, can shut down cleanly).
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Why each line:
python:3.11-slim— slim Debian base, ~120 MB. Gives us a working Python without hundreds of MB of dev tools.WORKDIR /app— auto-creates the directory and sets it as the default for all subsequent instructions.COPY requirements.txtbeforeCOPY app.py— layer-cache friendliness (see Theory, Section 7). When you only changeapp.py, the pip-install layer stays cached.--no-cache-dir— prevents pip from writing a download cache into the layer, keeping the image smaller.EXPOSE 8000— documentation only; does not open the port. The-pflag ondocker rundoes that.CMD [...]— exec form (JSON array). No shell wrapper: uvicorn becomes PID 1 and receives SIGTERM ondocker stop.
4. Step 3 — Build the image
Run from the repo root (the build context is the api/ directory):
docker build -t url-api:0.1 cloud-devops/sample-app/api
Expected output: a series of [X/Y] step lines ending with something like naming to docker.io/library/url-api:0.1. First build takes ~60 seconds (Docker must pull the ~120 MB base image and run pip install). Subsequent builds with no changes take < 1 second.
Inspect what was built:
docker images url-api
# REPOSITORY TAG IMAGE ID CREATED SIZE
# url-api 0.1 abc123def456 2 minutes ago ~180MB
Inspect the layer history:
docker history url-api:0.1
# IMAGE CREATED CREATED BY SIZE
# ... just now CMD ["uvicorn", "app:app", ...] 0B
# ... just now EXPOSE map[8000/tcp:{}] 0B
# ... just now COPY app.py . ~2kB
# ... just now RUN pip install --no-cache-dir ... ~60MB
# ... just now COPY requirements.txt . ~60B
# ... just now WORKDIR /app 0B
# ... 2 weeks ago (python:3.11-slim base layers) ~120MB
5. Step 4 — Run the container (foreground)
docker run --rm -p 8000:8000 --name url-api url-api:0.1
The container blocks the terminal (uvicorn is running in the foreground). In a second terminal, run the same curl commands from Step 1 — identical output, this time served by a containerized process. Stop with Ctrl-C.
6. Step 5 — Run in the background and inspect
docker run -d -p 8000:8000 --name url-api url-api:0.1
docker ps # the container is listed
docker logs url-api # uvicorn's startup line
curl -s localhost:8000/health # {"status":"ok"}
Drop into the running container:
docker exec -it url-api /bin/sh
# Inside the container:
ps -ef # uvicorn is PID 1
ls /app # app.py + requirements.txt
exit
Stop and remove:
docker stop url-api && docker rm url-api
7. Step 6 — Prove the cache works
Open cloud-devops/sample-app/api/app.py in an editor, make a trivial no-op change (add a blank line or a comment), and save. Rebuild:
docker build -t url-api:0.2 cloud-devops/sample-app/api
Expected: Docker prints CACHED [4/5] RUN pip install --no-cache-dir .... Only the COPY app.py and final metadata layers rebuild. Build time drops to under a second.
Now edit requirements.txt (add a trailing comment line) and rebuild:
docker build -t url-api:0.3 cloud-devops/sample-app/api
Expected: the COPY requirements.txt layer and the pip install layer both rebuild (no CACHED). Changing a dependency file busts the cache for everything downstream. This is expected and correct behavior.
8. Step 7 — Two containers from one image
Show that one image can back many simultaneous containers:
docker run -d -p 8001:8000 --name url-api-1 url-api:0.1
docker run -d -p 8002:8000 --name url-api-2 url-api:0.1
docker ps # two instances listed
curl -s localhost:8001/health # both respond
curl -s localhost:8002/health
docker stop url-api-1 url-api-2 && docker rm url-api-1 url-api-2
Both containers share the read-only image layers; only the thin writable top layer is per-container. This is the foundation of horizontal scaling.
9. What you should leave with
- A
cloud-devops/sample-app/api/Dockerfilethat you understand line-by-line - A working
url-api:0.1image on your local Docker daemon - Comfortable familiarity with
docker build/docker run/docker logs/docker exec - Proof — in your own shell history — that the build cache works when you change code and busts when you change deps
10. Troubleshooting
port is already allocated— another process is using port 8000. Use a different host port:-p 8001:8000.unable to connectwhen curl-ing — the container is up but uvicorn hasn’t finished starting. Checkdocker logs url-apifor the startup line.exec format error— you built on Apple Silicon (arm64) and tried to run on amd64 (or vice versa). Add--platform linux/amd64todocker buildto force a specific architecture.COPY failed: file not found— you randocker buildfrom the wrong directory. The path you pass todocker build(the build context) is the directory relative to which allCOPYpaths resolve.pip installis slow on every build — you probably editedrequirements.txt, which busted the cache. That’s expected. If the cache still misses on an unchangedrequirements.txt, check that BuildKit is enabled (DOCKER_BUILDKIT=1 docker build ...).