7.2 to 7.3 The run image

Read the tutorial. Build it yourself is the other way to read this part.

This walk goes through rahuldave/cheesecracker on GitHub. This part starts from the code at 2a28a5c704d6, partway through step-07. The code before this part

To look at this code on your machine, run these commands. The first one makes a clone, if this folder has none:

[ -d cheesecracker ] || git clone https://github.com/rahuldave/cheesecracker
cd cheesecracker
git fetch --quiet --tags --force origin
git switch --detach 2a28a5c704d6
just setup

The run image holds Python, the dependencies of the server and its code, and starts the server. Three recipes build it, start it and stop it.

7.2 the run image, the server as it runs in production

The run image needs Python, the dependencies of the server, and the code. It does not need uv, the tests, ruff or pre-commit. The Dockerfile builds the image in stages, and the run image takes only what the server needs.

± See the new file Dockerfile

The stage uv takes uv from its own image. The stage build installs the dependencies of uv.lock into /opt/venv, with --no-default-groups, so no test tool and no ruff. The stage run copies /opt/venv and the package api, and runs uvicorn as the user app, with four workers.

docker build --target run --tag cheesecracker-run .

Docker prints each line of the Dockerfile as it runs. Near the end, the line naming to docker.io/library/cheesecracker-run names the image.

docker image ls cheesecracker-run

The image is about 173 MB, nearly all of it Python and the dependencies of the server.

What changed

  • Dockerfile: the stages uv, build and run. New, 34 lines. The file
    • look at RUN uv sync --locked --no-default-groups

The diff of Dockerfile
@@ -0,0 +1,34 @@
+# The images of the API server, built from uv.lock, so they hold the versions that the lockfile names.
+#
+#   docker build --target run --tag cheesecracker-run .
+#       the production image: Python, the dependencies of the server and its code. It starts the server
+#
+# The environment lives in /opt/venv, never in the project folder.
+
+# uv itself, copied from its own image
+FROM ghcr.io/astral-sh/uv:0.12.13 AS uv
+
+# The environment of the server, without the dependency groups: no tests, no ruff, no pre-commit
+FROM python:3.12-slim-bookworm AS build
+COPY --from=uv /uv /usr/local/bin/uv
+ENV UV_PROJECT_ENVIRONMENT=/opt/venv \
+    UV_PYTHON_DOWNLOADS=never \
+    UV_COMPILE_BYTECODE=1 \
+    UV_LINK_MODE=copy \
+    UV_NO_CACHE=1
+WORKDIR /app
+COPY pyproject.toml uv.lock .python-version ./
+RUN uv sync --locked --no-default-groups
+
+# The run image: Python, the environment from the build stage, and the code. No uv, and no dev tools
+FROM python:3.12-slim-bookworm AS run
+RUN useradd --create-home --uid 1000 app
+COPY --from=build /opt/venv /opt/venv
+WORKDIR /app
+COPY src/api-service/api ./api
+ENV PATH=/opt/venv/bin:$PATH \
+    PYTHONUNBUFFERED=1
+USER app
+EXPOSE 9000
+# Four workers, and no restart on a change: the server as it runs in production
+CMD ["uvicorn", "api.service:app", "--host", "0.0.0.0", "--port", "9000", "--workers", "4"]

This change on GitHub The code after it

7.3 just docker-build, just docker-serve and just docker-stop

Three recipes give the Docker commands short names. docker-build builds an image of the Dockerfile. docker-serve starts the server of the run image in the background, at port 9000, and docker-stop stops it.

± See the change to justfile

The recipe docker-build takes the name of a stage, and runs docker build --target with it. docker-serve runs the image cheesecracker-run with --detach, so the container runs in the background, as in the original tutorial's docker run -d. --publish connects its port 9000 to port 9000 of this machine. The recipe then asks /health until the server answers. docker-stop stops the container, and --rm removes it.

just docker-build run

Docker takes each layer that has not changed since the last build from its cache, and prints CACHED for it.

just docker-serve

The recipe prints docker-serve: http://localhost:9000, in the container cheesecracker-run. Ask the server for its health.

curl -s localhost:9000/health

The server in Docker answers {"status":"healthy"}, as the server on this machine did. The container runs in the background, so you do not see what it prints. Docker keeps that output, and docker logs shows it.

docker logs cheesecracker-run

uvicorn printed Uvicorn running on http://0.0.0.0:9000, and started four server processes, its workers.

just docker-stop

Docker prints the name of the container, cheesecracker-run, and the server stops.

What changed

  • justfile: the recipes docker-build, docker-serve and docker-stop. 20 lines added. The file
    • look at docker run --rm --detach --name cheesecracker-run --publish 9000:9000 cheesecracker-run > /dev/null || exit 1

The diff of justfile
@@ -62,3 +62,23 @@ check:
     just fmt --check
     just lint
     just test -q
+
+# Build an image of the Dockerfile: just docker-build run
+docker-build kind:
+    docker build --target "$1" --tag "cheesecracker-$1" .
+
+# Start the server in the run image, in the background, at http://localhost:9000, as it runs in production. just docker-stop stops it
+docker-serve:
+    #!/usr/bin/env sh
+    if curl -sf localhost:9000/health > /dev/null; then echo "docker-serve: a server already answers at http://localhost:9000" >&2; exit 1; fi
+    docker run --rm --detach --name cheesecracker-run --publish 9000:9000 cheesecracker-run > /dev/null || exit 1
+    for i in $(seq 1 30); do
+        if curl -sf localhost:9000/health > /dev/null; then echo "docker-serve: http://localhost:9000, in the container cheesecracker-run"; exit 0; fi
+        sleep 1
+    done
+    echo "docker-serve: no answer after 30 seconds; read docker logs cheesecracker-run" >&2
+    exit 1
+
+# Stop the server of just docker-serve, and remove its container
+docker-stop:
+    docker stop cheesecracker-run

This change on GitHub

This part ends at the code at step-07. The code after this part