Introduction
In my ongoing effort to learn the tools and practices for DevOps engineering, today I wanted to containerize the FastAPI backend of the notes app that I created previously, using Docker.
This post is part of a series where I create a basic CRUD app and improve upon the development and deployment of the app using DevOps tools and techniques.
Title |
---|
Creating a Notes App with FARM Stack |
Containerize a FastAPI App with Docker |
Deploying a FastAPI Container to AWS ECS |
Setting Up GitHub Actions to Deploy to ECS |
Using Terraform for ECS/EC2 |
Getting Started
I started off by creating a Dockerfile. Well, I really started off by questioning whether my flat folder structure was best practice (it clearly isn’t), but decided that it would be better to focus my attention on the learning task at hand. So, I created a file called Dockerfile
in the root of my backend folder and added the following:
FROM python:3.10-slim
COPY . ./app
WORKDIR /app
RUN pip3 install -r requirements.txt
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host=0.0.0.0"]
This is the file Docker uses when building the image. Here’s a quick rundown of what it does:
FROM python:3.10-slim
- The image it starts with, which is made up a very trimmed down version of Linux and Python 3.10
COPY . ./app
- Copies all files in the local folder to the image’s filesystem under /app
WORKDIR /app
- Sets the working directory to /app for commands that follow
RUN pip3 install -r requirements.txt
- Runs the command to install python dependencies with pip
EXPOSE 8000
- This tells Docker that the container listens at port 8000 (but doesn’t open it to the host)
CMD ["uvicorn", "main:app", "--host=0.0.0.0"]
- Sets the default execution command for running the container, which can be overwritten at runtime
With the Dockerfile ready, I could build a new image by running the docker build command with an option name (tag): docker build -t notes-api .
That worked, and I was able to run the container, mapping port 8000 from the host to the container using: docker run -p 8000:8000 notes-api
It all worked, great!
There was a problem, though. I knew full well that my new image now contained my MongoDB Atlas credentials that would be exposed to the internet should I upload it to Docker Hub - not a best practice to say the least. So I needed to find a solution for mitigating that security risk.
Handling Secrets
It was surprising difficult to find information on how to handle secrets when creating images without greatly increasing complexity. It seems like everyone uses a secrets manager like Hashicorp’s Vault or doesn’t want to talk about their practice, perhaps because they aren’t proud of it. I would like to learn Vault, but that’s going to have to be further down the road. So for now here is what I came up with:
I had already built my code to keep it’s secrets in a separate keys.env
file, so I just needed to exclude that at the image build, then re-add it at container runtime. To do that, I created a .dockerignore
with the following:
env/
__pycache__/
*.env
*.env.*
env.*
docker-compose.yaml
I reran the build, then ran the container using the same docker run -p 8000:8000 notes-api
command and got this:
Which seemed bad, but then I remembered that it should break, because it no longer had the credentials! So I had to figure out how to inject the keys.env
into the container at runtime, which led me to learning about Docker Volumes. Essentially, I could give the container access to part of the host’s filesystem. Here is the command I used for that: docker run -p 8000:8000 -v ./:/app notes-api
Success!
There was just one more quality of life improvement I wanted to implement. I knew from working with Docker before (see Docker for Home Services) that defining all of your runtime arguments using the CLI was not ideal, especially as a project grows in complexity. So, the last step was to create a docker-compose file.
Adding Docker-Compose
I know this isn’t totally necessary with such a simple app that will run in only one container, but it’s relatively simple to implement and helps me build skills I anticipate needing in the future.
After looking at my previous examples and some others specific to FastAPI, I ended up with this:
name: notes
services:
api:
build: .
command: sh -c "uvicorn main:app --port=8000 --host=0.0.0.0"
ports:
- 8000:8000
volumes:
- ./:/app
Now I could run the container with a simple docker compose up
Cool!
Conclusion
I enjoyed this step in learning the tools and practices for DevOps engineering. It’s a very rewarding feeling to build something and to tackle problems as they arise. I also appreciate having a project that I’ve built from scratch, which I can use to learn each new technology or practice.
I am excited and looking forward to the next thing to learn. Until then…
Cheers!
Rick