Dockerize Python Web Apps

This tutorial is intended for Docker beginners.

We will use a Django web app as an example, but it can be easily substituted with other web apps such as flask, tornado, etc.

Prerequisite

  • have docker installed on your local machine
  • a requirements.txt for all the dependencies of the app

Writing the Dockerfile

Choosing a base image

Every Dockerfile has a base image. Choosing the correct base image is important, because docker images tend to be very large in size, and can potentially cause trouble in space and be costly. Bigger images also needs more time to build.

If you have some time to do research, that’s good, but generally speaking I would recommend try the basic one for now, and then you can switch to a smaller one later. For example, for any python projects you can start with python:3.6, or whatever the version of python you’re using; and then after everything is working, you can switch to python:3.6-slim, or alpine.

At the end of this tutorial (or maybe in the next tutorial?) I will also provide a way to use multi-stage building to reduce the image size but still have what you need in the slim version.

1
2
# Dockerfile
FROM python:3.6

Dockerfile almost always starts with the FROM command. The FROM command specifies which base image you want to build on top of.

Don’t forget to provide the tag, otherwise docker will automatically use latest as tag, and it might not be want you want. Check Docker Hub for available tags, don’t just make it up.

You can always use other local images as base. For example, if I already have a custom image called kelly locally, I can use kelly:latest in my Dockerfile.

Installing system packages

This step is actually optional – if you don’t need any system level packages, such as MySQL-related packages, you can skip this step.

It is common to use Debian/Ubuntu based images, so the first thing you would do is likely just update and install some system packages that you need with the apt or apt-get command.

The official python image is also Debian-based, so we can easily write something like this:

1
RUN apt-get update && apt-get install -y mysql-client

The RUN command executes any provided commands in a new layer on top of the current image.

One thing to keep in mind is, docker images have multiple layers; the more layers, the bigger the file size. That’s why it is common to use && to concatenate commands into one line in order to reduce layers.

Installing python dependencies

Now we can finally install python dependencies. I recommend a two-step method here – first copy just the requirements.txt and install all the dependencies, and then copy the code.

This might seem counter intuitive. If requirements.txt is part of my code, why can’t I copy everything at once? The reason is to save build time. When you’re developing, the code will change in almost every commit. Since docker is built on layers, if any layers change, all the layers on top of it need to change. This means if we copy all the code in this layer ans run pip install in next layer, everytime the code changes, you need to re-install everything when building a new image. What about if we reverse it? If we only install dependencies now, and then copy the code in next layer, as long as your requirements.txt stays the same, you don’t have to rebuild this layer again. In rapid development process, this saves a lot of time.

1
2
COPY requirements.txt .
RUN pip install -r requirements.txt

The COPY command adds a file or directory from the host machine into the image. Here we need it to copy the requirements.txt to access it inside the image.

Copy your code

We’re almost there! The next step is to copy the code.

1
2
WORKDIR /app
ADD . /app

The WORKDIR command sets the working directory, similar to cd command we use in terminal.

The ADD command lets you add the whole directory to docker image. It is similar to COPY, but ADD also supports adding from an external URL. Here I’m assuming you want to add everything in current working directory on host machine into the image, thus the ..

Entrypoint

Now that we have everything, we just need a way to start running the app when we create a container from image.

Usually for web apps, it can be as simple as:

1
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

But of course, there are some slight improvements that can be done. For example, we should never use the python manage.py runserver for production. I personally use gunicorn instead.

Upon starting the server, you might also want to run some other commands too, such as migrations or create admin user accounts, etc. Therefore, I recommend writing your own start.sh script and calling it from Dockerfile.

1
CMD ["/bin/bash","start.sh"]

While my start.sh includes something like:

1
2
3
4
# start.sh
python manage.py migrate
python manage.py collectstatic --noinput
gunicorn mysite.wsgi:application --bind 0.0.0.0:8000

Finally, don’t forget to expose the correct port. Since gunicorn is running on port 8000, I have to expose this port by:

1
EXPOSE 8000

Don’t worry, if you don’t want your server to listen on this port, you can map it to other ports later.

Building the Image

Now your Dockerfile should be something like this:

1
2
3
4
5
6
7
8
9
10
11
12
FROM python:3.6
RUN apt-get update && apt-get install -y mysql-client

COPY requirements/prod.txt .
RUN pip install prod.txt

WORKDIR /app
ADD . /app

EXPOSE 8000

CMD ["/bin/bash","start.sh"]

You can run:

1
docker build -t <image_name>:<tag> .

If it runs successfully, you will have a docker image with the name you provided.

Running Docker Containers

But how do we run a container with the image we just built?

You need to be able to answet these questions first:

  • What port do you want your service to run on?
  • Is there any extra file you want to add in?

For example, for me, I always want my web app on run on port 80. I have a secret .env file that includes credentials I need which is not included in commits, which I need to add in just before the server starts. I can run the container by the below command:

1
docker run -v ~/.env:/app/.env -p 80:8000 -t <image_name>:<tag>

I like to use -t to show the logs before ctrl+C out of it, but you can also use -d for detached mode. If you do not use either, you will find yourself not able to interrupt it and will have to close the terminal, which is not ideal if you’re SSHing into other machines.

There are a lot of other flags that you can use, check the documentation for more info.

Final Words

In the next tutorial, I will talk about how to use multi-stage building to reduce image size.