Gunicorn 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, settings documentation
fullstackpython.com, good overview of gunicorn and WSGI and link resources
stackoverflow, “running flask” means running Werkzeug’s development WSGI server
NOTES
If “Error saving credentials” from docker login, delete credsStore line from ~/.docker/config.json