Last week I showed you how to build a production-ready REST API using Python, FastAPI and Docker. And I promised you that today we would deploy it and make it accessible to the outside world.
However, I would like to show you one more thing before we enter into the deployment phase.
I want to show you how to write a better Dockerfile using Docker caching and multi-stage builds, so you build faster ⚡ and ligher 🪶 Docker images.
Let’s start!
You can find all the source code in this repository
Give it a star ⭐ on Github to support my work
What is the problem? 🤔
The Dockerfile we wrote last week works, meaning we can build a Docker image from it
$ docker build -f Dockerfile.naive -t taxi-data-api-python:naive-build .
and spin up a Docker container with our REST API.
$ docker run -p 8090:8000 taxi-data-api-python:naive-build
However, it has 2 problems.
Problem #1
Every time you update your Python code in src/ and rebuild the image, the Docker engine will re-install all the Python dependencies from your pyproject.toml file, even though you have not installed any new Python package!
And this takes A LOT of your precious time.
Solution 🧠
Dockerfile instructions are layered as a stack, with each layer adding one more step in your build process.
Docker caches your layers. Meaning, Docker rebuilds a layer only if this layer or any previous layer in the Dockerfile has changed.
When you make changes to your source code, layer 6 changes, so
all previous layers are cached and don’t need to be rebuilt
all layers after it are rebuilt, including layer 7, where you re-install the Python dependencies from an unchanged pyproject.toml file.
What a waste of precious time! ⏳⏳⏳
A smarter way to design your Dockerfile is to
COPY the pyproject.toml file first
Install dependencies, and then
Copy the rest of the code.
So whenever you work on your Python code, without install new dependencies, and rebuild the container you won’t waste time re-installing the exact same dependencies in the Docker image.
BOOM!
Problem #2
Your Dockerfile nows builds fast, however, the final image size is pretty big (almost 1GB)
$ docker images --format "{{.Size}}" taxi-data-api-python:naive-build
994MB
Taking into account how small our Python service is, we should do better than that.
A large percentage of this disk space is not taken by our Python code, but by the build tools we need to install this Python code image.
And the thing is, we don’t need these build tools anymore when we run our Docker container 😵💫.
Is there a way to remove them from our final image?
Solution 🧠
Docker multi-stage builds are an advanced feature in Docker that allow you to use multiple temporary stages to create a final, optimized Docker image.
This technique is particularly useful for creating smaller, more efficient container images.
In our case, our Dockerfile has one build stage that installs all the necessary Python packages
# Stage 1: Build stage
FROM python:3.10-slim AS builder
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Set the working directory
WORKDIR /app
# Install Poetry
RUN pip install poetry
# Copy only the pyproject.toml and poetry.lock files to leverage Docker cache
COPY pyproject.toml poetry.lock README.md /app/
# Install dependencies
RUN poetry config virtualenvs.create false \
&& poetry install --no-dev --no-root
# Copy the rest of the application code
COPY . /app
and a final stage that copies the installed dependencies from the first stage, and launches the REST API server
# Stage 2: Runtime stage
FROM python:3.10-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Set the working directory
WORKDIR /app
# Copy only the necessary files from the builder stage
COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY --from=builder /app /app
# Expose the port the app runs on
EXPOSE 8000
# Run the application
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
BOOM!
Thanks Aleksandr!
Last week, I asked my students from Building a Real-Time ML System. Together to optimize the Dockerfile we built during our live session.
And Aleksandr hit the bullseye 🎯 BRAVO!
Wanna build a real-time ML system, from scratch, with us? 👨👨👦👦🌍
Next December 2nd, 200+ students and myself we will start building a NEW real-time ML system in my Building a Real-Time ML System. Together course.
This time, we will build a real-time ML system to predict taxi trip durations, like Uber does every time you ask for a ride 🚕.
Wanna know more about this course?
⬇️⬇️⬇️
What’s next? 🔜
Next week I will (finally) show you how to deploy this API and make it accessible to the whole world. So you escape from the localhost hell, and start shipping ML software that others can use.
Talk to you soon,
Enjoy the weekend
Pau
Thanks for this article, I tried the same approach. The python sitepackages directory itself is huge and when we copy that into the second docker image, its size is almost identical to the one built in single stage. Am I missing something?
Very interesting , thank you