Containerize A Flask App With Docker

Understanding containers is a must for any developer looking to level up beyond simply writing application code.

Between kubernetes and “serverless” deployments, developing in containers are now an industry best practice.

In this post I will show you how to build and run a flask app using docker containers and docker-compose.

Tutorial Prerequisites

  1. Read an introduction to containers here
  2. Configure a bare bones local python environment as described here

With our pre-reqs out of the way, let’s get started!

Setup

Create a project directory and use pipenv to configure your initial virtual environment:

$ mkdir -p ~/Projects/personal/containers-first-steps
$ cd ~/Projects/personal/containers-first-steps
$ pipenv shell --python=python3

Install Flask

$ pipenv install flask

Create Simple Flask App

$ mkdir -p ~/Projects/personal/containers-first-steps/src/webapp

Create an empty __init__.py to make the directory a python module:

$ touch ~/Projects/personal/containers-first-steps/src/webapp/__init__.py

Now create a flask app as follows:

~/Projects/personal/containers-first-steps/src/webapp/app.py
from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!

This small app will return an HTTP response of “Hello, World!” when we send a GET request to the API’s / endpoint.

Now test everything runs with:

$ cd  ~/Projects/personal/containers-first-steps/src/webapp
$ flask run
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Then in a separate shell:

$ curl http://127.0.0.1:5000 -iv
* Rebuilt URL to: http://127.0.0.1:5000/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:5000
> User-Agent: curl/7.54.0
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
HTTP/1.0 200 OK
< Content-Type: text/html; charset=utf-8
Content-Type: text/html; charset=utf-8
< Content-Length: 13
Content-Length: 13
< Server: Werkzeug/0.15.5 Python/3.7.4
Server: Werkzeug/0.15.5 Python/3.7.4
< Date: Thu, 05 Sep 2019 03:15:05 GMT
Date: Thu, 05 Sep 2019 03:15:05 GMT

<
* Closing connection 0
Hello, World!

We can see from the 200 OK response code and Hello, World! text that everything worked as expected.

Switch back to the shell you ran flask run from and hit CTRL and c together (ctrl+c) to stop the running flask process. We will need to use that port later to run our container.
You’re doing great!

Create A Dockerfile

Now that we have a working application running locally on our operating system, let’s create a Dockerfile so that we can build the application into a docker image and then run it as a container.

To start, create the following file:

~/Projects/personal/containers-first-steps/Dockerfile

FROM python:3.7-slim

RUN pip install pipenv

ENV SRC_DIR /usr/local/src/containers-first-steps

WORKDIR ${SRC_DIR}

COPY Pipfile Pipfile.lock ${SRC_DIR}/

RUN pipenv install --system --clear

COPY ./ ${SRC_DIR}/

WORKDIR ${SRC_DIR}/src/webapp

CMD ["flask", "run", "-h", "0.0.0.0"]

Let’s walk through this file line by line:

  1. The FROM line indicates this image is based on the python 3.7-slim image. This is simply a light-weight (only ~80MBs in size) image with python 3.7 already installed.
  2. Just like you install pipenv on your laptop’s operating system, we need to install pipenv in container so we are able to install our packages.
  3. ENV SRC_DIR ... introduces an environment variable called SRC_DIR with value /usr/local/src/containers-first-steps into the environment. This variable stores the directory that we will copy our application files into later on in the image.
  4. WORKDIR sets the container’s working directory to /usr/local/src/containers-first-steps . To learn more about why we install the software to /usr/local/src, read this.
  5. Next we copy our Pipfile and Pipfile.lock into the image.
  6. We then use the information in Pipfile.lock to install all our required packages into the docker image.
  7. COPY our application files such as app.py into the image
  8. Update the working dir to /usr/local/src/containers-first-steps/src/webapp to prepare for executing the flask run command
  9. When the image is executed as a container, run flask run -h 0.0.0.0 as the default command. This will run the same webserver that we ran previously.

Now that you have a Dockerfile, let’s give it a test run. We can do this using docker.

$ cd ~/Projects/personal/containers-first-steps
$ docker build . -t first-steps
$ docker run --rm -p 5000:5000 first-steps
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)

If you go into another shell, you can hit the endpoint again by running:

$ curl http://127.0.0.1:5000 -iv
* Rebuilt URL to: http://127.0.0.1:5000/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:5000
> User-Agent: curl/7.54.0
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
HTTP/1.0 200 OK
< Content-Type: text/html; charset=utf-8
Content-Type: text/html; charset=utf-8
< Content-Length: 13
Content-Length: 13
< Server: Werkzeug/0.15.5 Python/3.7.4
Server: Werkzeug/0.15.5 Python/3.7.4
< Date: Thu, 05 Sep 2019 03:38:05 GMT
Date: Thu, 05 Sep 2019 03:38:05 GMT

<
* Closing connection 0
Hello, World!

It worked!

Adding A Real Application Server: Gunicorn

As you may have noticed, when running our API with flask run we see the following warnings:

   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.

This is because the server built into flask is only for development purposes and should not run in production.

A WSGI server is just a web server that knows how to run a python program that has a specific interface. If you want to know more about what those words mean, check out this article.

In order to run a WSGI application server, we need to configure a wsgi object. Create the following:

~Projects/personal/containers-first-steps/src/webapp/wsgi.py
from webapp.app import app

if __name__ == "__main__":
    app.run()

This defines a python module wsgi.py. When the module is run, our flask app object executes its run() method.

With our wsgi module in place, we now need a program to run it. Green Unicorn, or simply gunicorn, is one of a handful of standard python application servers.

$ pipenv install gunicorn

Now we need a way to run the server. We can achieve this easily by creating a small bash script:

~/Projects/personal/containers-first-steps/files/usr/local/run-gunicorn
#!/bin/sh

exec gunicorn --bind 0.0.0.0:5000 webapp.wsgi:app

Now replace the last two lines with four new lines in the Dockerfile:

COPY ./ ${SRC_DIR}/

# NEW LINES
COPY files/ /
RUN chmod +x /usr/local/bin/*

WORKDIR ${SRC_DIR}/src

CMD ["run-gunicorn"]

These four lines will:

  1. Copy our new run-gunicorn script into the image
  2. Make the script executable
  3. Set the appropriate working directory
  4. Execute the run-gunicorn script

Now that everything is in place, we can test it out:

$ docker build . -t first-steps
$ docker run --rm -p 5000:5000 first-steps
[2019-09-05 04:14:32 +0000] [1] [INFO] Starting gunicorn 19.9.0
[2019-09-05 04:14:32 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000 (1)
[2019-09-05 04:14:32 +0000] [1] [INFO] Using worker: sync
[2019-09-05 04:14:32 +0000] [9] [INFO] Booting worker with pid: 9

As you can see, we get a different set of error logs out of the box. This is because we are now using the gunicorn server which has different default log configs.

As before, we can test the endpoint by opening another shell and curl’ing it:

$ curl http://127.0.0.1:5000 -iv
* Rebuilt URL to: http://127.0.0.1:5000/
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 5000 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:5000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Server: gunicorn/19.9.0
Server: gunicorn/19.9.0
< Date: Thu, 05 Sep 2019 04:14:37 GMT
Date: Thu, 05 Sep 2019 04:14:37 GMT
< Connection: close
Connection: close
< Content-Type: text/html; charset=utf-8
Content-Type: text/html; charset=utf-8
< Content-Length: 13
Content-Length: 13

<
* Closing connection 0
Hello, World!

Unlike before when the Server header was set to Werkzeug/0.15.5 Python/3.7.4, we can now see we are serving from gunicorn due to Server: gunicorn/19.9.0.

Simplifying: docker-compose

As you may have noticed, typing -p 5000:5000 every time we run the server via docker can become cumbersome.

We can get around writing this every time by simply creating a docker-compose config that specifies our port mapping.

~/Projects/personal/containers-first-steps/docker-compose.yml
version: "3.4"
services:
    app:
        build: .
        ports:
            - 5000:5000

This configuration tells docker-compose that we have a service named app. The service runs on the image generated by building the Dockerfile in directory ..

The . means “the same directory as the docker-compose.yml file.

We then map port 5000 on our laptop to port 5000 on the container so that we can communicate with it easily.

Now instead of running separate docker build and docker run commands, we can simply run:

$ docker-compose up

From ~/Projects/personal/containers-first-steps and we are good to go!

End

That’s it! You have successfully built a containerized API using flask, docker, and docker-compose.

This is just the beginning of containerized applications. Using containers gives you all kinds of benefits!

Leave a Reply

Your email address will not be published. Required fields are marked *