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
- Read an introduction to containers here
- 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:
- 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. - Just like you install
pipenv
on your laptop’s operating system, we need to installpipenv
in container so we are able to install our packages. ENV SRC_DIR ...
introduces an environment variable calledSRC_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.WORKDIR
sets the container’s working directory to
. To learn more about why we install the software to/usr/local/src/containers-first-steps
/usr/local/src
, read this.- Next we copy our
Pipfile
andPipfile.lock
into the image. - We then use the information in
Pipfile.lock
to install all our required packages into the docker image. COPY
our application files such asapp.py
into the image- Update the working dir to
/usr/local/src/containers-first-steps/src/webapp
to prepare for executing theflask run
command - 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:
- Copy our new
run-gunicorn
script into the image - Make the script executable
- Set the appropriate working directory
- 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!