Skip to content
  • Home
  • How To
  • M0
  • M1
  • Agents
  • M2
  • M3
  • M4
  • M5
  • Module 6 - MLOps
  • Progress
  • Contact
  • Pricing
  • Sign in
  • Future
  1. Module 6M6
  2. ›
    S3Containers, Databases and Pipelines
  3. ›
    Lesson 1 of 4Docker - Images and Containers
  4. ›
    S3›Lesson 1 of 4
  1. Module 6
  2. ›
  3. Section 3
  4. ›
  5. Lesson 1

Docker - Images and Containers

FREE·~1.5 h·2 labs·4 questions·1 assignment


Context

An image is a read-only filesystem that Docker builds from a recipe called a Dockerfile, one layer per step: a small Linux system with Python, the project's locked libraries, its code, its model and the command that starts the service. A container is a running process started from an image, with that filesystem as its own and a network address of its own, which Docker starts, stops and removes by name.

A service that another team calls ships as an image, so the team runs docker run on your image instead of repeating your setup on their machine. The same image is what a continuous-integration machine starts to check that the service answers, what a cloud host runs behind a public address, and what a colleague runs to reproduce a wrong price you cannot reproduce yourself. In every case the libraries, the interpreter and the model inside are the ones you built with.

Lesson 6.2.4's service runs on one machine only. Its .venv points at a Python inside your home folder, its model is found through a registry whose rows name folders on your disk, and Uvicorn listens on 127.0.0.1. Copy the project to another machine and each of those breaks, and inside a container the last one breaks quietly: the server starts, answers requests sent from inside the container, and drops every request sent from outside it.


Goal

  • Export the champion into the project with mlflow artifacts download, serve it by default from MODEL_URI "model", and keep the folder out of Git and inside the image.
  • Write a Dockerfile for the service from python:3.12.8-slim and uv 0.13.0, build it with docker build, and read its layers with docker history and its size with docker images.
  • Predict which build steps a rebuild runs again from what changed in the project, and explain why a folder deleted by a later step still costs the image its full size.
  • Cut the build context with .dockerignore from about 690 MB to under 1 MB and the image from 2.06 GB to 1.12 GB, and name what must stay in the context.
  • Run the service with docker run -d -p 8000:8000, check it with docker ps, docker logs and docker exec, and diagnose a missing --host 0.0.0.0, a missing -p, a port already taken and a request sent before startup finished.

Prerequisites

Neither is required, and the lesson builds everything it uses. They are here for a picture of Docker before you build with it.

  • YouTube - "Docker in 100 Seconds" by Fireship. A short overview of the three things this lesson makes: a Dockerfile, the image built from it, and a container run from that image.
  • Docker documentation - "What is an image?". Its "Explanation" says an image is immutable and made of layers, each a set of filesystem changes that add, remove or modify files, and its "Try it out" lists an image's layers with docker image history.

Table of Contents

  • Exporting the Model Into the Project
  • A First Dockerfile
  • Building the Image, and Its Layers
  • The Build Context and .dockerignore
  • Running a Container: -p and --host 0.0.0.0

Theory

Docker Engine is the one new piece of software, and Module 6's setup page installed it. The first docker build below downloads the two images the Dockerfile starts from, python:3.12.8-slim and ghcr.io/astral-sh/uv:0.13.0, about 48 MB and 23 MB compressed, once, and nothing else installs on your machine. Everything runs in ~/dev/listings-service at the state Lesson 6.2.4 left it, with (bootcamp) deactivated: the registry in mlflow.db holds listings-price versions 1 and 2, champion points at version 1, and 26 tests pass. The lesson changes src/listings/app.py and .gitignore and adds Dockerfile and .dockerignore, and ends in one commit. Containers run in the background, so every command goes in Terminal 2, and Terminal 1 holds only the server the last subtitle runs on your machine. Container ids, image ids, model ids, timings and date headers differ on your machine and every run, and every other line matches.

Exporting the Model Into the Project

What. uv run mlflow artifacts download -u models:/listings-price@champion -d model resolves the alias to its version and copies that version's files into a folder model/: the MLmodel description, model.skops, the requirements and the input examples, the kind of folder mlflow.sklearn.save_model wrote for Lesson 6.2.4's tests, plus one file of its own, registered_model_meta, that names the registered model and its version. The app's default MODEL_URI becomes "model", a path read against the folder the server starts in. Why. An image must carry everything the service loads at startup, and the registry cannot travel. Its versions live in mlflow.db, and each row names its files by an absolute path on your disk. An exported folder is the model alone, loads from any folder that holds it, and says which version it is. When. Every time a model is about to go into an image: promote, export, then build.

The registry's rows, read with sqlite3 as Lesson 6.2.1 read mlflow.db:

uv run python -c "import sqlite3; print(sqlite3.connect('mlflow.db').execute('SELECT version, storage_location FROM model_versions').fetchall())"
[(1, '/home/goku/dev/listings-service/mlruns/1/models/m-08d2dc04593c495a9492cb1166c62aab/artifacts'), (2, '/home/goku/dev/listings-service/mlruns/1/models/m-0439e4e81ca241eca9457761c4ea3d2e/artifacts')]

Each version's files are recorded at /home/goku/dev/listings-service/mlruns/..., a path that exists on this machine and in no container. Export the champion:

uv run mlflow artifacts download -u models:/listings-price@champion -d model

/home/goku/dev/listings-service/model

After an empty line comes the folder it wrote, about two seconds after the command. Its files:

ls -l model
cat model/registered_model_meta
total 216
-rw-r--r-- 1 goku goku   1622 Nov  3 15:19 MLmodel
-rw-r--r-- 1 goku goku   2304 Nov  3 15:19 conda.yaml
-rw-r--r-- 1 goku goku    348 Nov  3 15:19 input_example.json
-rw-r--r-- 1 goku goku 188680 Nov  3 15:19 model.skops
-rw-r--r-- 1 goku goku     98 Nov  3 15:19 python_env.yaml
-rw-r--r-- 1 goku goku     44 Nov  3 15:19 registered_model_meta
-rw-r--r-- 1 goku goku   1855 Nov  3 15:19 requirements.txt
-rw-r--r-- 1 goku goku    709 Nov  3 15:19 serving_input_example.json
model_name: listings-price
model_version: 1

model.skops is the pipeline, the 188,680 bytes Lesson 6.2.1 found in mlruns/, and registered_model_meta is how the folder still knows it is version 1 once it is far from the registry. The alias was read once, at export: promote version 2 tomorrow and this folder still holds version 1 until you export again.

The service now loads that folder unless told otherwise. One line of src/listings/app.py changes:

DEFAULT_MODEL_URI = "model"

mlflow.pyfunc.load_model takes a folder path as readily as a models:/ address, the way Lesson 6.2.4's tests loaded theirs, and a relative path is resolved against the working directory, the folder the process was started in. uv run uvicorn listings.app:app from the project folder loads model/ and prices the reference listing at 384235, the champion's price, and MODEL_URI=models:/listings-price@champion in front of the same command still serves from the registry, at the same 384235. The tests set MODEL_URI to their own folder, so they never read the default, and uv run pytest still reports 26 passed. Git sees the new folder:

git status --short
 M src/listings/app.py
?? model/

model/ is produced by a command from the registry, exactly like mlflow.db and mlruns/, so it goes in the same block of .gitignore, which ends:

# What the code produces, rebuilt by running it
mlflow.db
mlruns/
model/

git status --short then lists .gitignore and app.py as modified and nothing else.

DON'T:

  • DON'T copy mlflow.db and mlruns/ into an image to load by alias. An image built with both inside failed at startup with mlflow.exceptions.MlflowException: No such artifact: '', because the row for version 1 pointed at /home/goku/dev/listings-service/mlruns/..., which the image does not have.
  • DON'T commit model/. It is a copy of a registry version, rebuilt by one command, and a committed copy goes stale the moment champion moves.
  • DON'T read the alias as live inside the folder. registered_model_meta says version 1 until the next export, whatever the registry says.

A First Dockerfile

What. A Dockerfile is a text file of instructions, each one a step of the build. FROM names the image to start from, COPY copies files into the image, WORKDIR sets the folder later steps run in, RUN runs a command at build time, ENV sets an environment variable for every later step and every container, EXPOSE records the port the service listens on, and CMD is the command a container runs when it starts. Why. The Dockerfile is the setup you did by hand since Lesson 6.1.1, written down where a machine can repeat it: the same interpreter, uv sync from the same lock, the same start command. Committed beside the code, it is the one description of how the service is built and started. When. Once per service, at the root of the project, and changed whenever the way the service is installed or started changes.

Save Dockerfile, no extension, in the project folder:

FROM python:3.12.8-slim
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /bin/uv

WORKDIR /app
COPY . .
RUN uv sync --locked --no-dev
ENV PATH="/app/.venv/bin:$PATH"

EXPOSE 8000
CMD ["uvicorn", "listings.app:app", "--host", "0.0.0.0", "--port", "8000"]

The file carries no comments because each line is one instruction, and they read as follows. python:3.12.8-slim is the official Python image at exactly the interpreter every Module 6 figure came from, on a minimal Debian Linux, slim meaning that build tools such as the C compiler gcc are left out. COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /bin/uv copies one file out of a second image, the uv program from uv's own image at the version Module 6's setup page names, into /bin, so the build needs no installer. WORKDIR /app creates /app and makes it the current folder for everything after it, the container's process included. COPY . . copies the build context, the folder named at the end of the build command, into /app.

RUN uv sync --locked --no-dev builds /app/.venv from uv.lock, the command Lesson 6.1.1 rebuilt .venv with, plus two flags. --locked refuses to go on when uv.lock no longer matches pyproject.toml: with a pin edited and no new lock, it printed error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. and exited with 1, so the image holds exactly what the committed lock says or is not built. --no-dev leaves out the dev group, pytest and httpx, which the service never imports. uv finds Python 3.12.8 at /usr/local/bin/python3.12, the base image's own, which satisfies .python-version, so it downloads no interpreter.

ENV PATH="/app/.venv/bin:$PATH" puts the environment's bin folder first in PATH, Module 0's list of folders a command name is searched in, so uvicorn and python mean the ones in .venv without uv run. EXPOSE 8000 writes the port into the image's description and opens nothing, which the last subtitle shows. CMD is written as a JSON list, the exec form, so Docker starts uvicorn itself as the container's first process with these five arguments. --host 0.0.0.0 is the one argument Lesson 6.2.4's command did not have, and the last subtitle shows what the container does without it.

DON'T:

  • DON'T leave out the ENV PATH line. An image built without it failed before Python started, exec: "uvicorn": executable file not found in $PATH, exit status 127, because the base image's PATH does not reach /app/.venv/bin.
  • DON'T write CMD as a plain string, CMD uvicorn listings.app:app --host 0.0.0.0 --port 8000. That shell form runs the line under /bin/sh -c, so the first process is the shell, which does not pass on the stop signal: docker stop then waited its full 10 seconds and killed the container, Exited (137), where the exec form stopped in 1.2 seconds with Exited (0) and Uvicorn's shutdown lines in the log.
  • DON'T write FROM python:3.12-slim or FROM python. A tag without the full version points at whichever release is newest when the image is pulled, an interpreter none of the module's figures came from.

Building the Image, and Its Layers

What. docker build -t listings-service . sends the folder . to Docker as the build context, runs the Dockerfile's steps in order and names the result listings-service, the tag latest added when none is given. Each step that changes files leaves a layer, the set of files it added, changed or removed, and an image is its base image's layers with these stacked on top. Docker keeps every layer in a build cache, and a step whose instruction and inputs are the same as last time reuses its layer, reported CACHED, instead of running again. Why. Layers make a rebuild cost only what changed: an unchanged step is free. They also explain an image's size, because the image is the sum of its layers, and a file deleted in a later layer is still stored in the layer that added it. When. Every time the code, the lock, the model or the Dockerfile changes. docker history and docker images are what you read after a build.

Build it, with time in front, the shell keyword that prints how long the command after it took:

time docker build --progress=plain -t listings-service .

--progress=plain prints every step's output in full, in order, where the default view redraws a short summary and folds each step's lines away once the step is done. The lines that matter, from the build context to the end of the install, with uv's Downloading and Downloaded lines between them cut:

#8 [internal] load build context
#8 transferring context: 687.30MB 3.2s done
#8 DONE 3.2s

#9 [stage-0 4/5] COPY . .
#9 DONE 2.8s

#10 [stage-0 5/5] RUN uv sync --locked --no-dev
#10 0.319 warning: Ignoring existing virtual environment linked to non-existent Python interpreter: `.venv/bin/python` -> `/home/goku/.local/share/uv/python/cpython-3.12.8-linux-x86_64-gnu/bin/python3.12`
#10 0.429 Using CPython 3.12.8 interpreter at: /usr/local/bin/python3.12
#10 2.046 Removed virtual environment at: .venv
#10 2.046 Creating virtual environment at: .venv
#10 2.052 Resolved 98 packages in 1ms
#10 2.125    Building listings @ file:///app
#10 2.163       Built listings @ file:///app
#10 169.5 Prepared 88 packages in 2m 47s
#10 169.7 Installed 88 packages in 176ms
#10 DONE 169.8s

The build ends by naming the image, naming to docker.io/library/listings-service:latest done, and time printed real 3m31.844s. Most of it is uv downloading 88 packages into an image that has no uv cache of its own, two to three minutes here and less on a faster connection. Resolved 98 packages is the whole lock and Installed 88 the runtime set: the dev group and what only it needs stay out. The warning and the Removed virtual environment line come from what COPY . . copied, which belongs to the next subtitle. The image and its size:

docker images listings-service
IMAGE                     ID             DISK USAGE   CONTENT SIZE   EXTRA
listings-service:latest   b0045e35dc89       2.06GB          461MB

DISK USAGE is what the image takes on this machine, its layers unpacked, and CONTENT SIZE is its layers compressed, what a registry of images would store and another machine would download. 2.06 GB holds a service whose code is a few kilobytes. docker history lists the layers, newest first, with the step that made each and its size:

docker history --format "table {{.CreatedBy}}\t{{.Size}}" listings-service
CREATED BY                                      SIZE
CMD ["uvicorn" "listings.app:app" "--host" "…   0B
EXPOSE [8000/tcp]                               0B
ENV PATH=/app/.venv/bin:/usr/local/bin:/usr/…   0B
RUN /bin/sh -c uv sync --locked --no-dev # b…   682MB
COPY . . # buildkit                             731MB
WORKDIR /app                                    8.19kB
COPY /uv /bin/uv # buildkit                     45.1MB
CMD ["python3"]                                 0B
RUN /bin/sh -c set -eux;  for src in idle3 p…   16.4kB
RUN /bin/sh -c set -eux;   savedAptMark="$(a…   45.1MB
ENV PYTHON_SHA256=c909157bb25ec114e5869124cc…   0B
ENV PYTHON_VERSION=3.12.8                       0B
ENV GPG_KEY=7169605F62C751356D054A26A821E680…   0B
RUN /bin/sh -c set -eux;  apt-get update;  a…   10.4MB
ENV LANG=C.UTF-8                                0B
ENV PATH=/usr/local/bin:/usr/local/sbin:/usr…   0B
# debian.sh --arch 'amd64' out/ 'bookworm' '…   85.2MB

--format picks two columns of the full table, which also prints a layer id and when each layer was made. The bottom ten rows are python:3.12.8-slim's own, Debian then Python, and the top seven are this Dockerfile's, in its order. ENV, EXPOSE and CMD change no files, so their rows are 0 bytes. The two that weigh are COPY . . at 731 MB and RUN uv sync at 682 MB: the copy of your folder outweighs the environment built from the lock.

Build again at once, with nothing changed:

time docker build --progress=plain -t listings-service .

The four steps after FROM report CACHED, the last two of them:

#10 [stage-0 4/5] COPY . .
#10 CACHED

#11 [stage-0 5/5] RUN uv sync --locked --no-dev
#11 CACHED

and real reads about one second. Docker compares each step's instruction and the files it reads with the cache. COPY . . read the same files, so its layer was reused, and every step after a reused one with the same instruction is reused too. Change any file the context copies, and COPY . . runs again, and so does every step after it, uv sync included, because a step's cache entry depends on every layer under it. In this Dockerfile a one-line change to the README pays for the whole install again.

DON'T:

  • DON'T read 2.06 GB as the cost of the libraries. docker history put 731 MB on COPY . . and 682 MB on the install, and the copy of the folder is the larger.
  • DON'T expect a later step to shrink an earlier layer. RUN uv sync removed the copied .venv and built a new one, and the 731 MB layer that copied it is still part of the image.
  • DON'T build without --progress=plain when something looks wrong. The default view folds a finished step's lines away, uv's warning among them.

The Build Context and .dockerignore

What. The build context is every file under the folder given to docker build, sent to Docker before the first step runs, and it is all that COPY can reach. .dockerignore, a file at the root of the context, lists patterns, one per line, in the pattern language of .gitignore, and every file that matches is left out of the context, so no COPY can copy it. Why. COPY . . copies whatever the context holds. Here that is your .venv with every library and an interpreter link into your home folder, .git with every past version of every file, the registry, the data and the tests. None of it belongs in a service image, and the .venv both doubles the image and is useless inside it. When. In every project with a Dockerfile, written before the first build that matters, and read again whenever a new kind of file appears in the project.

The warning in the build is the .venv that COPY . . brought along:

warning: Ignoring existing virtual environment linked to non-existent Python interpreter: `.venv/bin/python` -> `/home/goku/.local/share/uv/python/cpython-3.12.8-linux-x86_64-gnu/bin/python3.12`

A virtual environment's bin/python is a symbolic link to the interpreter it was made with, and yours points at the Python uv installed in your home folder, which the image does not contain. uv refused to use it, removed it and created a new one. The copy did no harm to the result and cost 687 MB of context, three seconds of transfer and the 731 MB layer. Save .dockerignore in the project folder:

.git
.venv
**/__pycache__
.pytest_cache
mlflow.db
mlruns
data
tests

Each line keeps one kind of file out. .git is the history, which a running service never reads. .venv is your machine's environment, which the build rebuilds from uv.lock anyway. **/__pycache__ matches a __pycache__ folder at any depth, the ** standing for any number of folders, because Python rebuilds compiled files on import, and .pytest_cache is pytest's own record of the last run. mlflow.db and mlruns are the registry, which the image does not use since it carries model/. data is the training CSV and tests the test suite, used to train and to test, never to serve. What stays is the project's own files and model/. Unlike .gitignore, this file does not list model/: Git keeps it out because a command rebuilds it, and the image needs it because it is what the service loads.

Build again:

time docker build --progress=plain -t listings-service .
#8 [internal] load build context
#8 transferring context: 995B 0.3s done
#8 DONE 0.4s

#9 [stage-0 4/5] COPY . .
#9 DONE 0.1s

#10 [stage-0 5/5] RUN uv sync --locked --no-dev
#10 0.383 Using CPython 3.12.8 interpreter at: /usr/local/bin/python3.12
#10 0.383 Creating virtual environment at: .venv
#10 0.391 Resolved 98 packages in 1ms
#10 0.447    Building listings @ file:///app
#10 0.472       Built listings @ file:///app
#10 129.2 Prepared 88 packages in 2m 08s
#10 129.4 Installed 88 packages in 181ms
#10 DONE 129.5s

Docker keeps what this folder sent last time and transfers only what changed, under 1 kB here. The context itself, every file .dockerignore lets through, fell from 687.30 MB to 819.14 kB, the figure a build from a fresh copy of the folder transfers, about 196 kB of it the model. The warning and the Removed line are gone, because there was no .venv to remove. The install ran again in full, because COPY . . copied different files. The image:

docker images listings-service
docker history --format "table {{.CreatedBy}}\t{{.Size}}" listings-service | head -7
IMAGE                     ID             DISK USAGE   CONTENT SIZE   EXTRA
listings-service:latest   e6cf6abfbc3f       1.12GB          253MB
CREATED BY                                      SIZE
CMD ["uvicorn" "listings.app:app" "--host" "…   0B
EXPOSE [8000/tcp]                               0B
ENV PATH=/app/.venv/bin:/usr/local/bin:/usr/…   0B
RUN /bin/sh -c uv sync --locked --no-dev # b…   682MB
COPY . . # buildkit                             913kB
WORKDIR /app                                    8.19kB

1.12 GB on disk and 253 MB compressed, against 2.06 GB and 461 MB, and COPY . . is now 913 kB. The name listings-service:latest moved to the new image, so docker images lists one, and the 682 MB of the install is now nearly all of what the Dockerfile adds. head -7 keeps the header and the six newest rows.

DON'T:

  • DON'T copy .gitignore into .dockerignore line for line. An image built with model added to .dockerignore stopped at startup with mlflow.exceptions.MlflowException: No such artifact: 'model', because the one folder Git must not keep is the one the service must load.
  • DON'T leave .venv in the context because uv rebuilds it anyway. The rebuilt copy is the one that runs, and the copied one still cost the image 731 MB.
  • DON'T put the data in a service image to be safe. The service never opens data/listings.csv, and an image is copied to every machine that runs it, the training data with it.

Running a Container: -p and --host 0.0.0.0

What. docker run -d --name listings-service -p 8000:8000 listings-service creates a container from the image and starts its CMD. -d runs it detached, in the background, printing only the new container's id, --name gives it a name every later command can use, and -p 8000:8000 publishes a port: Docker listens on port 8000 of the host, the number left of the colon, and forwards each connection to port 8000 of the container, the number right of it. docker ps lists the running containers, docker logs prints what a container's process wrote, docker exec runs one more command inside a running container, and docker stop and docker rm stop a container and delete it. Why. A container has its own network: its own 127.0.0.1 and its own address on a network Docker keeps for containers, so nothing on the host reaches its ports unless one is published. Inside, the service must listen on more than its own loopback, because a published connection arrives at the container's address, not at its 127.0.0.1. When. Every time the image runs on its own, on your machine now, and on any machine that runs it later.

Start it, and ask for its health straight away:

docker run -d --name listings-service -p 8000:8000 listings-service
curl http://127.0.0.1:8000/health
9bb408a4f5054ddd9e940a81f4090e72046e5d2290e6f674014938b2a2bbec5b
curl: (56) Recv failure: Connection reset by peer

The id is the container's, its first twelve characters the short id docker ps shows. The curl failed because it came too early. docker run -d returns the moment the process starts, and the service then imports MLflow and loads the model before Uvicorn opens the port inside the container, about eight seconds here. Docker already listened on the host's port 8000, took the connection, found nothing to hand it to in the container yet and dropped it, which curl reports as exit code 56, a connection reset. Lesson 6.2.4's server, run directly on the host, failed the same early request with curl: (7), because there nothing listened at all. The log says when it is ready:

docker logs listings-service
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

[1] is the process id inside the container: Uvicorn is the container's first process, the exec form of CMD at work. 0.0.0.0:8000 is the address it listens on inside the container, every address the container has, as Lesson 0.2.3's http.server listened on every address the machine has. Once Uvicorn running on appears:

curl -i -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d '{"area_sqm": 85.0, "year_built": 1998, "distance_km": 3.0, "rooms": 3, "bathrooms": 1, "neighborhood": "Riverside", "property_type": "apartment", "energy_rating": "C", "condition": "good"}'
HTTP/1.1 200 OK
date: Tue, 03 Nov 2026 14:28:30 GMT
server: uvicorn
content-length: 16
content-type: application/json

{"price":384235}

The champion's price for the reference listing, now from an interpreter, an environment and a model that all live in the image. docker logs listings-service ends with the request, INFO: 172.17.0.1:40222 - "POST /predict HTTP/1.1" 200 OK: the client is 172.17.0.1, the host's address on Docker's network, not 127.0.0.1, because the request crossed from the host into the container. The running containers:

docker ps
CONTAINER ID   IMAGE              COMMAND                  CREATED          STATUS          PORTS                                         NAMES
9bb408a4f505   listings-service   "uvicorn listings.ap…"   16 seconds ago   Up 16 seconds   0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp   listings-service

PORTS reads 0.0.0.0:8000->8000/tcp, host port 8000 on every IPv4 address forwarded to container port 8000, and [::]:8000 is the same for IPv6. docker exec asks the container directly:

docker exec listings-service whoami
docker exec listings-service cat model/registered_model_meta
root
model_name: listings-price
model_version: 1

The command runs in /app, WORKDIR's folder, as root, the user an image runs as when its Dockerfile names no other. The model inside is version 1. Stop and delete the container:

docker stop listings-service
docker rm listings-service

Each prints the name. docker stop sends the process the signal to stop and took about a second, the log gaining Uvicorn's Shutting down and Finished server process [1], and the stopped container stays listed under docker ps -a, which shows stopped containers too, as Exited (0), until docker rm deletes it. docker rm -f does both at once.

Now the three ways the same service does not answer. First, the CMD without --host 0.0.0.0. Any words after the image name in docker run replace its CMD, so this runs Lesson 6.2.4's command in the same image:

docker run -d --name listings-service -p 8000:8000 listings-service uvicorn listings.app:app --port 8000

Once docker logs listings-service has printed Uvicorn running on http://127.0.0.1:8000, the request from the host:

curl http://127.0.0.1:8000/health
curl: (56) Recv failure: Connection reset by peer

and the same request from inside the container, through the image's Python:

docker exec listings-service python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/health').read())"
docker exec listings-service hostname -I
b'{"status":"ok"}'
172.17.0.4

urllib.request.urlopen, the standard library's HTTP client, reached the service, because inside the container 127.0.0.1 is the container's own loopback and Uvicorn listens there. hostname -I prints the container's address on Docker's network, 172.17.0.4 here and another on yours, and that is where Docker delivers a published connection. Nothing listens on 172.17.0.4:8000, so Docker dropped every connection from the host, the same (56) as a request sent before startup finished, from a service that is up and healthy inside. docker rm -f listings-service removes it. Second, the right CMD and no -p:

docker run -d --name listings-service listings-service
docker ps
CONTAINER ID   IMAGE              COMMAND                  CREATED         STATUS         PORTS      NAMES
f78d7444bd57   listings-service   "uvicorn listings.ap…"   7 seconds ago   Up 7 seconds   8000/tcp   listings-service
curl http://127.0.0.1:8000/health
curl: (7) Failed to connect to 127.0.0.1 port 8000 after 0 ms: Could not connect to server

PORTS reads 8000/tcp with no arrow: that is EXPOSE 8000, the port the image says it uses, and no host port is published. Nothing on the host listens on 8000, so curl failed to connect at all, exit code 7. docker rm -f listings-service again. Third, a host port already taken. With Lesson 6.2.4's uv run uvicorn listings.app:app running on the host in Terminal 1:

docker run -d --name listings-service -p 8000:8000 listings-service
625d245e897d2553cdb9a700b307c207ceffc2a327bc093dd9918fbaf7964cb7
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint listings-service (be5e6323f2e459cc939fd522c8183a54526d49fc41612f483c38c46dfffe3d5e): failed to bind host port 0.0.0.0:8000/tcp: address already in use

Run 'docker run --help' for more information

and with a first container holding the port, a second one, --name listings-service-2, on the same -p 8000:8000 prints its id, then:

docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint listings-service-2 (097b77c48b89734a56ef34ada9162aab1c5cb2c4b7072deae62fed274955bf6b): Bind for 0.0.0.0:8000 failed: port is already allocated

Run 'docker run --help' for more information

Lesson 0.2.3's rule, one server per port, reported in two wordings: address already in use when a process outside Docker holds the port, here a host Uvicorn listening on 127.0.0.1:8000, and port is already allocated when another container has published it. Both commands exit with status 125, Docker's own failure, and both leave the container behind, created and never started. After the first:

docker ps -a --format "table {{.Names}}\t{{.Status}}"
NAMES              STATUS
listings-service   Created

so the next docker run --name listings-service fails on the name before it reaches the port, with exit status 125 again:

docker: Error response from daemon: Conflict. The container name "/listings-service" is already in use by container "625d245e897d2553cdb9a700b307c207ceffc2a327bc093dd9918fbaf7964cb7". You have to remove (or rename) that container to be able to reuse that name.

Run 'docker run --help' for more information

docker rm listings-service clears it, Ctrl+C in Terminal 1 stops the host server, and the port is free again. Commit the lesson's four files:

git add .dockerignore .gitignore Dockerfile src/listings/app.py
git commit -m "feat: the service in a Docker image"
[main e0475c2] feat: the service in a Docker image
 4 files changed, 20 insertions(+), 1 deletion(-)
 create mode 100644 .dockerignore
 create mode 100644 Dockerfile

The hash is your commit's own.

DON'T:

  • DON'T read curl: (56) as a missing --host 0.0.0.0 until the log says Uvicorn running on. A request sent before startup finished got the same reset from a correct image.
  • DON'T take EXPOSE 8000 for a published port. Without -p, docker ps showed 8000/tcp and curl failed to connect.
  • DON'T rerun docker run --name listings-service after a failed start without docker rm. The container a failed start leaves behind holds the name, and the rerun failed with Conflict.

Lab Exercises

Lab Exercise 1: What a Change Rebuilds

With the image built from the project and its .dockerignore, time three more builds with --progress=plain and read which steps run. First build with nothing changed. Then append one line to README.md, echo "Run it with Docker: see the Dockerfile." >> README.md, and build again. Then put the README back with git restore README.md, Lesson 0.3.2's command, and build a third time. For each build, note real and whether COPY . . and RUN uv sync say CACHED, and finish with docker history --format "table {{.CreatedBy}}\t{{.Size}}" listings-service | head -7. Say in a comment why a one-line README change cost the most and why the third build was quick.

Lab Exercise 2: Inside a Running Container

Run the service as listings-service on port 8000 and wait for Uvicorn running on in its log. With docker exec, list everything in /app with ls -a, and print where the image's .venv/bin/python points with readlink, then print where your own project's .venv/bin/python points with the same command on the host. Stop the container, show its status with docker ps -a, and send a curl to /health. Start it again with docker start listings-service, the command that restarts a stopped container under its name, wait for it, and send the curl again. Try docker rm listings-service while it runs, then remove it. Say what the ls -a shows about .dockerignore, why the two readlinks differ, and what stopping and starting kept.


Solution 1: What a Change Rebuilds
time docker build --progress=plain -t listings-service .
echo "Run it with Docker: see the Dockerfile." >> README.md
time docker build --progress=plain -t listings-service .
git restore README.md
time docker build --progress=plain -t listings-service .
docker history --format "table {{.CreatedBy}}\t{{.Size}}" listings-service | head -7
# Build 1, nothing changed: every step after FROM is CACHED.
# Build 2: COPY . . read a changed README, so its layer and every
# layer above it were rebuilt, the full install of 88 packages too.
# Build 3: the context matches one the cache already holds, so
# every step is CACHED again and the history is the same image's.

The first build reports CACHED for the four steps after FROM and real 0m1.166s. The second, after the README line:

#9 [stage-0 4/5] COPY . .
#9 DONE 0.1s

#10 [stage-0 5/5] RUN uv sync --locked --no-dev
#10 67.61 Prepared 88 packages in 1m 07s
#10 67.78 Installed 88 packages in 172ms
#10 DONE 67.9s

with real 1m38.523s, nearly all of it spent on the install and on writing the new layers, none of which the README needs. COPY . . reads every file of the context, the README among them, so a changed byte anywhere changes its layer, and RUN uv sync sits on that layer, so it runs again from nothing, downloads included. The third build, after git restore, reports CACHED for both steps again in real 0m1.002s: the cache keeps a layer after a newer build takes the image's name, and the restored context is byte for byte the one it was built from. The history then shows the same 682 MB install over a 913 kB copy.

DON'T:

  • DON'T judge a Dockerfile by its first build. The one-line README change cost a minute and a half, and only a build after a change shows what each later change costs.
  • DON'T expect the cache to forget an older context. Restoring the README brought back the earlier layers in about a second.
Solution 2: Inside a Running Container
docker run -d --name listings-service -p 8000:8000 listings-service
docker logs listings-service
docker exec listings-service ls -a
docker exec listings-service readlink .venv/bin/python
readlink .venv/bin/python

Once the log shows Uvicorn running on, the ls -a and the two readlinks print:

.
..
.dockerignore
.gitignore
.python-version
.venv
Dockerfile
README.md
model
pyproject.toml
src
uv.lock
/usr/local/bin/python3.12
/home/goku/.local/share/uv/python/cpython-3.12.8-linux-x86_64-gnu/bin/python3.12
docker stop listings-service
docker ps -a --format "table {{.Names}}\t{{.Status}}"
curl http://127.0.0.1:8000/health
listings-service
NAMES              STATUS
listings-service   Exited (0) Less than a second ago
curl: (7) Failed to connect to 127.0.0.1 port 8000 after 0 ms: Could not connect to server
docker start listings-service
curl http://127.0.0.1:8000/health
docker rm listings-service
docker rm -f listings-service

docker start prints the name, and the curl, once the log shows a second Uvicorn running on, prints {"status":"ok"}. The docker rm refuses, with exit status 1:

Error response from daemon: cannot remove container "listings-service": container is running: stop the container before removing or force remove

and docker rm -f prints the name and removes it. /app holds the project's own files, the .venv that uv sync built inside the image, model, and nothing the context left out: no .git, data, tests, mlflow.db or mlruns. That .venv is linked to the base image's /usr/local/bin/python3.12, while your project's links into your home folder, which is why the copied one could not run there. Stopping kept the container, its name and its published port, and docker start ran the same CMD again: docker logs then printed Started server process [1] twice, one startup per run, the model loaded again on each.

DON'T:

  • DON'T read Exited (0) as deleted. The container kept its name and came back with docker start.
  • DON'T send the first request after docker start at once. It loads the model again, and the port answers only after Uvicorn running on.

Theory Assessment

Q1: Where the Model Comes From

The image's /app holds model/ and no mlflow.db. Say what uv run mlflow artifacts download -u models:/listings-price@champion -d model copied and how the running container knows which version it serves, why loading models:/listings-price@champion inside an image fails even when mlflow.db and mlruns/ are copied in, and why model/ is in .gitignore but not in .dockerignore.

Hint: Read what storage_location holds for each version, and what each ignore file decides.

Q2: Two Gigabytes for a Few Kilobytes

The first image was 2.06 GB, and uv sync had removed the copied .venv before installing its own. Say where the size came from by layer, why removing .venv in the RUN step did not make the image smaller, and what .dockerignore changed in the context, the layers and the image.

Hint: An image is the sum of its layers, and a layer is written once.

Q3: Three Errors From One Image

Three docker run commands were typed against the same image. After one, curl printed curl: (7) Failed to connect. After another, curl printed curl: (56) Recv failure: Connection reset by peer, with docker logs ending in Uvicorn running on http://127.0.0.1:8000. The third docker run itself printed failed to bind host port 0.0.0.0:8000/tcp: address already in use. Say what each command was missing or ran into, and what docker ps or docker exec shows that confirms it.

Hint: Ask in each case whether anything listens on the host's port 8000, and on which address inside the container the service listens.

Q4: "It Needs --host 0.0.0.0"

A teammate types docker run -d --name listings-service -p 8000:8000 listings-service and then, on the next line, curl http://127.0.0.1:8000/health, which prints curl: (56) Recv failure: Connection reset by peer. They conclude the image's CMD is missing --host 0.0.0.0 and start editing the Dockerfile. Say whether they are right about this image, what produced the reset, what they should read before touching the Dockerfile and what they would find there in each of the two cases, and why the same curl sent too early to Lesson 6.2.4's host server printed (7) instead.

Hint: docker run -d returns before the lifespan has loaded the model. Ask who holds the host's port 8000 in each setup during those seconds.


Answer 1:

The download resolved champion to version 1 and copied that version's files, MLmodel, model.skops, the requirements and the input examples, into model/, and added registered_model_meta, which names listings-price and model_version: 1. The container serves whatever the folder holds: the app's default MODEL_URI is "model", resolved against /app, and docker exec listings-service cat model/registered_model_meta prints the version. Loading by alias inside an image fails because the registry's rows name each version's files by absolute host paths, /home/goku/dev/listings-service/mlruns/..., which no image has, so even with mlflow.db and mlruns/ copied in, startup stopped with MlflowException: No such artifact: ''. The two ignore files answer different questions: .gitignore keeps out of Git what a command rebuilds, and model/ is rebuilt by the download, while .dockerignore keeps out of the image what the service does not need, and model/ is the one thing it loads. With model in .dockerignore, startup stopped with No such artifact: 'model'.

DON'T treat the exported folder as following the alias. It holds the version it was exported at, and a promote changes nothing in it until the next export.

Answer 2:

docker history gave COPY . . 731 MB and RUN uv sync 682 MB, on top of the base image and uv's 45.1 MB binary. The copy was the larger because the context was the whole folder, .venv above all, 687.30 MB sent before the first step. uv sync found the copied .venv linked to an interpreter the image does not have, printed its warning, removed it and built a new one, but that removal happened in the RUN layer, and the COPY layer below it still holds every byte it copied: a layer is written once, and a later layer can hide a file, never take back its bytes. .dockerignore removed .venv, .git, the registry, the data and the tests from the context before any step ran, so the context fell to 819.14 kB, COPY . . to 913 kB, the warning disappeared, and the image fell from 2.06 GB and 461 MB compressed to 1.12 GB and 253 MB.

DON'T try to shrink an image by deleting files in a later RUN. What a step copied stays in its layer, and only keeping it out of the context or out of the step removes it.

Answer 3:

(7) Failed to connect came from a docker run without -p: nothing listens on the host's port 8000, and docker ps shows 8000/tcp with no arrow, the port EXPOSE records and nothing more. (56) Connection reset with Uvicorn running on http://127.0.0.1:8000 came from a run whose command replaced CMD without --host 0.0.0.0: Docker listened on the host's port and forwarded each connection to the container's address, 172.17.0.4 in this lesson, where nothing listens, so it dropped them. docker ps shows the published port, and docker exec with urllib on 127.0.0.1:8000 gets {"status":"ok"} from inside, proving the service is up and listening only on the container's loopback. address already in use is a port held by a process outside Docker, a host Uvicorn here: the container was created and never started, docker ps -a shows it as Created, and it must be removed before the name is free. Had another container held the port, the wording would have been Bind for 0.0.0.0:8000 failed: port is already allocated.

DON'T answer every curl failure by editing the Dockerfile. Two of the three came from the docker run line or from the host, and the image was the same in all three.

Answer 4:

They are not right about this image: its CMD carries --host 0.0.0.0, and the reset came from asking too early. docker run -d returned as soon as the process started, while the lifespan still had about eight seconds of importing MLflow and loading the model ahead of it. Docker was already listening on the host's port 8000 for the published port, took the connection, had nothing inside the container to pass it to, and dropped it, the same (56) a missing --host produces. Before touching the Dockerfile they should read docker logs listings-service. If it ends in Uvicorn running on http://0.0.0.0:8000, the image is correct and the same curl now prints {"status":"ok"}. If it ends in Uvicorn running on http://127.0.0.1:8000, the host really is missing, from the CMD or from a command typed after the image name. Lesson 6.2.4's host server printed (7) because there no program at all held port 8000 until Uvicorn bound it after startup, so the connection was refused outright instead of accepted and dropped.

DON'T diagnose a container from a request sent in its first seconds. The log line Uvicorn running on says when it can answer and on which address.


Assignment

Assignment: Version 2 From the Same Image

A colleague asks for version 2, Lesson 6.2.2's model without the missing indicators, served for a comparison, and the image already built holds version 1. Serve version 2 from the same image without rebuilding it, by mounting a folder of your machine over the image's /app/model. docker run -v SOURCE:TARGET:ro makes the host folder SOURCE, given as an absolute path or one starting with . or .., appear at TARGET inside the container, hiding whatever the image holds there for as long as that container runs, and :ro makes it read-only inside. The shell's until COMMAND; do sleep 1; done runs sleep 1 again and again until COMMAND succeeds, and SECONDS, a variable bash counts up in, set to 0 before it, gives how long that took.

Export version 2 with models:/listings-price/2 into ~/dev/listings-probes/model-v2, outside the project so no build context ever holds it. Run the image as listings-service-v2 on port 8000 with that folder mounted read-only, wait with until for /health and print the seconds it took, price the complete reference listing and the one with the area, the year and the rating left out, print the mounted registered_model_meta, and try to create a file in /app/model from inside. Then remove it and do the same with the image as built, under the name listings-service, with no mount. Last, print the image's own model/registered_model_meta with a throwaway container, docker run --rm. Say in a comment what the four prices show and what the last command proves about the image.

Hints:

  • docker run --rm listings-service cat model/registered_model_meta replaces CMD with cat, so it prints the file and exits without starting the service.
  • curl -s prints only the body, and > /dev/null throws even that away, so until curl -s URL > /dev/null; do sleep 1; done waits quietly for the first answer.

Solution:
uv run mlflow artifacts download -u models:/listings-price/2 -d ../listings-probes/model-v2
LISTING='{"area_sqm": 85.0, "year_built": 1998, "distance_km": 3.0, "rooms": 3, "bathrooms": 1, "neighborhood": "Riverside", "property_type": "apartment", "energy_rating": "C", "condition": "good"}'
BLANK='{"distance_km": 3.0, "rooms": 3, "bathrooms": 1, "neighborhood": "Riverside", "property_type": "apartment", "condition": "good"}'
docker run -d --name listings-service-v2 -p 8000:8000 -v ../listings-probes/model-v2:/app/model:ro listings-service
SECONDS=0; until curl -s http://127.0.0.1:8000/health > /dev/null; do sleep 1; done; echo "up after ${SECONDS}s"
curl -s -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d "$LISTING"; echo
curl -s -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d "$BLANK"; echo
docker exec listings-service-v2 cat model/registered_model_meta
docker exec listings-service-v2 touch model/x
docker rm -f listings-service-v2

/home/goku/dev/listings-probes/model-v2
up after 7s
{"price":385826}
{"price":392147}
model_name: listings-price
model_version: '2'
touch: cannot touch 'model/x': Read-only file system
listings-service-v2
docker run -d --name listings-service -p 8000:8000 listings-service
SECONDS=0; until curl -s http://127.0.0.1:8000/health > /dev/null; do sleep 1; done; echo "up after ${SECONDS}s"
curl -s -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d "$LISTING"; echo
curl -s -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" -d "$BLANK"; echo
docker exec listings-service cat model/registered_model_meta
docker rm -f listings-service
docker run --rm listings-service cat model/registered_model_meta
# 385826 and 392147 are version 2's prices, 384235 and 434348 version
# 1's: the same code in the same image priced by two models. The last
# command shows the image still holds version 1, untouched by the mount.
up after 6s
{"price":384235}
{"price":434348}
model_name: listings-price
model_version: 1
listings-service
model_name: listings-price
model_version: 1

The docker run -d lines print the new container's id, cut here. The mounted container priced the two listings at 385826 and 392147, Lesson 6.2.4's version 2 prices to the unit, and its registered_model_meta names version 2, written '2', the way the download by version number records it. The image as built priced them at 384235 and 434348, version 1's. The mount hid the image's /app/model for one container only, and :ro refused the write with Read-only file system, so the service cannot change the folder it was handed. The throwaway container read the image's own model/ and found version 1: a mount changes what one container sees and never the image, which is why the image that ships is rebuilt after a new export.

DON'T ship a model by mounting it into an image built for another. The image a colleague pulls still holds version 1, and only the container with the mount served version 2.


Additional Resources

Optional, and nothing later in the module depends on them. Worth a look for the next steps in a Dockerfile for a uv project, and for the two forms every instruction can take.

  • uv documentation - "Using uv in Docker", the sections "Caching" and "Intermediate layers". A cache mount for uv's downloads, and a first uv sync --no-install-project from the lock alone, so a change to the code no longer reinstalls every library.
  • Docker documentation - "Dockerfile reference", Shell and exec form. The two ways to write RUN, CMD and ENTRYPOINT: the exec form, a JSON array in double quotes that starts the program with no shell, and the shell form, which always runs through a command shell.

Summary

Nothing about the model changed in this lesson, and nothing about the service's code beyond one default. What changed is what the service depends on. It depended on a Python in your home folder, on a database whose rows name folders on your disk, and on a server that listened only on your own loopback. Each of those was invisible while everything ran on one machine, and each became an error, a warning or a dropped connection the moment the service ran somewhere that was not that machine, even when the somewhere was a container on the same laptop.

The image is the answer, and it is also a mirror of every choice in the project. The first one weighed two gigabytes because COPY . . took the whole folder literally, and the folder held an environment that could not run inside it. The fix was not cleverness but a list of what the service does not need, and the one place it had to part from .gitignore was the model, because keeping something out of history and keeping it out of a running service are different questions.

The failures that remain are the quiet ones. A reset connection meant two different things depending on whether the log had reached Uvicorn running on, and the image was correct in one of them. That is the habit the rest of the section depends on: a container is a process with its own filesystem and its own network, and reading its log and asking it from the inside comes before changing the recipe that built it.


Cheat Sheet

Part A - Reference Table

Idea Command or line Notes
Export a version uv run mlflow artifacts download -u models:/listings-price@champion -d model alias read once, registered_model_meta names the version
Default model DEFAULT_MODEL_URI = "model" relative to the working directory, /app in the image
Keep it out of Git model/ in .gitignore rebuilt by the download
Base image FROM python:3.12.8-slim full version, never a moving tag
uv in the image COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /bin/uv one binary, pinned
Install from the lock RUN uv sync --locked --no-dev fails on a stale lock, no pytest or httpx
Commands from .venv ENV PATH="/app/.venv/bin:$PATH" without it executable file not found
Start command CMD ["uvicorn", ..., "--host", "0.0.0.0", ...] exec form, Uvicorn is process 1
Build docker build --progress=plain -t listings-service . . is the build context
Size docker images listings-service disk usage and compressed content
Layers docker history listings-service one row per step, sizes
Cache CACHED a changed input reruns its step and every step after
Keep out of the context .dockerignore .venv, .git, registry, data, tests, never model
Run docker run -d --name listings-service -p 8000:8000 listings-service host port left, container port right
Ready docker logs listings-service wait for Uvicorn running on
Inspect docker ps, docker ps -a, docker exec NAME CMD Up, Exited (0), Created
Stop and remove docker stop, docker start, docker rm, docker rm -f rm refuses a running container
Early request curl: (56) ... reset by peer startup not finished
No --host 0.0.0.0 curl: (56), log says 127.0.0.1:8000 answers only inside
No -p curl: (7), PORTS shows 8000/tcp EXPOSE publishes nothing
Port taken address already in use, port is already allocated host process, other container, exit 125
Mount a folder -v ../listings-probes/model-v2:/app/model:ro hides the image's folder, one container

Part B - Compact Code Block

FROM python:3.12.8-slim
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /bin/uv

WORKDIR /app
# .dockerignore keeps .venv, .git, data out
COPY . .
RUN uv sync --locked --no-dev
ENV PATH="/app/.venv/bin:$PATH"

# documents the port, publishes nothing
EXPOSE 8000
CMD ["uvicorn", "listings.app:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t listings-service .   # cached steps say CACHED
docker images listings-service       # size on disk
docker run -d --name listings-service -p 8000:8000 listings-service
docker logs listings-service         # wait for Uvicorn running on
docker ps                            # PORTS 0.0.0.0:8000->8000/tcp
docker exec listings-service whoami  # root
docker rm -f listings-service        # stop and delete
←Previous: Section 2, Lesson 4
Next: Lesson 2→

Questions about this lesson: #module-6 on Discord

Found an error in this lesson? Report it

Your progress
Current streak
🔥 0
Lessons marked today
0 / 3
Longest streak
0

Sign in

The bootcamp

  • Curriculum
  • How To
  • Pricing
  • Changelog
  • Future

The community

  • Discord
  • Reviews
  • Contact

Follow

  • Instagram
  • X
  • TikTok
  • Reddit

Legal

  • Terms
  • Privacy
  • Refunds

Coming soon on November 16, 2026

Theme