Python: Building an image for a Flask app served from Gunicorn

python-logoGunicorn is a WSGI HTTP server commonly used to run Flask applications in production.  Running Flask applications directly is great for development and testing of the basic request/response flow, but you need gunicorn to handle production level loads,  concurrency, logging, and timeouts.

In this article, I will show you how to build a Docker image of a Flask application served with Gunicorn.

Docker Prerequisite

See my previous article on installing Docker on Ubuntu.

Python Prerequisites

Make sure python, pip, and virtualenv are installed via the apt OS package manager.

# get packages
sudo apt update
sudo apt install git make python3 python3-dev python3-pip -y

# check python version
python3 --version

# get latest python virtual env
python_major_minor=$(python3 --version | grep -Po 'Python \K.*' | cut -d. -f1-2)
echo "installing venv package for python $python_major_minor"
sudo apt install python${python_major_minor}-venv -y

Download Project

Grab my github project code.

git clone https://github.com/fabianlee/docker-gunicorn-hello-world-web.git
cd docker-gunicorn-hello-world-web

# use '1.0.0' tag, which is simplest version of code, ignore warnings
git checkout 1.0.0

# should report back '1.0.0'
grep ^VERSION Makefile

Create Python virtualenv

Create a Python virtualenv that sandboxes the pip modules for this specific project.

# create virtualenv
python3 -m venv myvenv

# enter virtualenv
source myvenv/bin/activate

# install exact modules needed for this project
pip install -r requirements.txt

# shows pip modules installed
pip list

Test Flask app run directly

Our first test will be to run the Python Flask app directly.  This is the mode used most frequently by the developer of the application logic, used to iteratively test and debug.

# run directly with python
python myflaskpackage/flask_module.py

From another console, validate a curl against the localhost on port 8000.

$ curl http://localhost:8000
Hello, Flask
request 0 GET /
Host: localhost:8000

Notice that the message is “Hello, Flask”.

Test Gunicorn server run directly

Since we installed gunicorn from the requirements.txt earlier, we can also run gunicorn directly.

# run directly with python
gunicorn --config gunicorn.conf.py --log-config=gunicorn-logging.conf myflaskpackage.flask_module:app

From another console, validate a curl against the localhost on port 8000.

$ curl http://localhost:8000
Hello, gunicorn
request 0 GET /
Host: localhost:8000

Notice that the message is now “Hello, gunicorn” versus the “Hello, Flask” from the previous section.

Build Gunicorn docker image

We will now copy the configuration and source code into a container using our Dockerfile to build an image.

# build docker image
docker build -f Dockerfile -t fabianlee/docker-gunicorn-hello-world-web:1.0.0 .

If you list the docker images available, you should see the base python-slim-bullseye take up 125Mb, while our custom image uses ~139Mb (14Mb more).

$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
fabianlee/docker-gunicorn-hello-world-web 1.0.0 fa8e9a65c5c5 58 minutes ago 139MB
...
python 3.10.4-slim-bullseye c3e38abaf684 2 weeks ago 125MB

Test Gunicorn docker image

To run this local image in the foreground, invoke docker and expose the port on local 8000.

docker run -it -p 8000:8000 -e MESSAGE_TO=gunicorn_docker --rm fabianlee/docker-gunicorn-hello-world-web:1.0.0

From another console, validate a curl against the localhost on port 8000.

$ curl http://localhost:8000
Hello, gunicorn_docker
request 0 GET /
Host: localhost:8000

Notice that the message is now “Hello, gunicorn_docker” because we overrode the default message by defining the MESSAGE_TO environment variable in flask_module.py.

message_to = os.getenv("MESSAGE_TO","gunicorn")

Configuring Gunicorn

Containers are typically configured via environment variables.  However, gunicorn itself is configured either by a python file or command line flags.

To facilitate our use of containers, we are using the python configuration method with gunicorn.conf.py, and this python script provides two ways of tweaking the gunicorn settings.

Reads env vars starting with ‘GUNICORN_’

As Sebest Nuage showed, you  can identify which environment variables are set, and translate those that start with “GUNICORN_” to their lowercase counterpart.

for k,v in os.environ.items():
  if k.startswith("GUNICORN_"):
    key = k.split('_', 1)[1].lower()
    print(f"GUNICORN {key} = {v}")
    locals()[key] = v

This allows you to send env keys like GUNICORN_WORKERS” (translates to ‘workers’), or GUNICORN_BIND (translates to ‘bind’) to configure the gunicorn settings.

Sets values using python

Because gunicorn.conf.py is just a python file, we can also assign and calculate variables just like any other python program.

# set static value
threads = 3

# if variable is not defined, use default
if not 'workers' in locals():
  workers = 2

# use 'multiprocessing' module to calculate number of cpu
calc_workers = multiprocessing.cpu_count()*2+1

Configure logging for container

We use gunicorn-logging.conf to write to stdout of the console, which is typically the best way for a container to deliver content to centralized logging solutions.

You can change the global logging level in the logger_root section.

[logger_root]
# controls log level globally: DEBUG|INFO|WARN|ERROR
level=DEBUG
handlers=console

And if outputting json format is better ingested by your centralized logging solution, change the ‘formatter’ in the handler_console section.

[handler_console]
class=StreamHandler
# control output format: generic|json
formatter=generic

Performance

Just as a quick validation that the Gunicorn server handles concurrency much faster than the default Flask development server, let’s run Apache Bench for a baseline measurement.

# install Apache Bench
$ sudo apt install apache2-utils -y

# run 10k tests with 25 concurrent users
$ ab -n 10000 -c 25 http://localhost:8000/

Here is the summary of results:

Method Time (sec)
Flask local 24.4 sec
Gunicorn local 10.9 sec
Gunicorn container 7.8 sec

This is obviously not a rigorous test, we are running the load test on the same host as the applications, etc.  But from this example you would start experimenting with higher user concurrency and tweaking the number of workers, threads, log levels, and other settings that could lead to improved performance for your production workload profile.

 

REFERENCES

Sebest Nuage, use of config files for gunicorn settings and logging

Luis Sena, choosing a worker-type for gunicorn (default=sync)

Omar Rayward, concurrency with gunicorn

pythonspeed.com, avoiding delays of workers in Docker by placing temp directory into tmpfs location

gunicorn github, logging.conf example

github issues for gunicorn, example of gunicorn.conf.py using python logic and hooks

trstring.com, gunicorn logging in python app code

digitalocean.com, venv for python gunicorn app

golinuxcloud.com, flask gunicorn app with render_template and systemd service

gunicorn site

gunicorn docs

gunicorn, settings documentation

fullstackpython.com, good overview of gunicorn and WSGI and link resources

stackoverflow, “running flask” means running Werkzeug’s development WSGI server

denji, list of load testers

NOTES

If “Error saving credentials” from docker login, delete credsStore line from ~/.docker/config.json