Set up a Gunicorn Configuration File, and Test It

If you use Gunicorn, it’s likely you have a configuration file. This is a Python module that contains settings as module-level variables. Here’s an example with some essential settings:
# Gunicorn configuration file
# https://docs.gunicorn.org/en/stable/configure.html#configuration-file
# https://docs.gunicorn.org/en/stable/settings.html
import multiprocessing
max_requests = 1000
max_requests_jitter = 50
log_file = "-"
workers = multiprocessing.cpu_count() * 2 + 1
These settings do the following things:
max_requests
andmax_requests_jitter
restart workers after so many requests, with some variability. This is a key tool for defending against memory leaks, as I previously discussed.log_file = "-"
sets logging to use stdout. This makes gunicorn follow the 12 factor log recommendation.workers = ...
configures Gunicorn to run 2N+1 workers, where N is the number of CPU cores on the current machine. This is the recommendation in the docs. Annoyingly, it’s not the default, which is instead only a single process!(I wonder just how many Django apps out there feel “slow” because of this…)
Note: as Markus Holtermann points out, if you restrict the CPU’s available to Gunicorn, then using
cpu_count()
will give the wrong answer. In this case you could hardcodeworkers
, use an environment variable, or parse information from Linux’s/proc/self/cgroup
file as per snippets on Python bug 36054.
There are many more settings available.
When you’ve set up a config file you can test it with gunicorn --check-config
:
$ gunicorn --check-config --config python:example.gunicorn example.wsgi
Here:
--config
defines the config file to check. Using thepython:
prefix allows us to use the Python module name, rather than filename.- The WSGI application module is required.
If all is well, this command exits with an exit code of 0. Otherwise, it prints an error message and exits with a non-zero value. For example, if there was a typo in the above file:
$ gunicorn --check-config --config python:example.gunicorn example.wsgi
Error: module 'multiprocessing' has no attribute 'cpu_coun'
$ echo $? # print last command's exit code
1
So once you have a Gunicorn config file, it’s best to ensure you run gunicorn --check-config
as part of your CI. Gunicorn may change or you may add broken logic in your config file, and you don’t want to be surprised only when you deploy.
You can verify by running the above command as an extra step in your CI system. This is a fine approach but it’s “yet another thing” and won’t be run locally by default.
An alternative that I prefer is to run the command within a test, combining it with other checks. This also has the bonus of including the config file in test coverage. Let’s look at how to write this test, with Django’s Test Framework and then pytest.
With Django’s Test Framework
Here’s the test:
import sys
from unittest import mock
from django.test import SimpleTestCase
from gunicorn.app.wsgiapp import run
class GunicornConfigTests(SimpleTestCase):
def test_config(self):
argv = [
"gunicorn",
"--check-config",
"--config",
"python:example.gunicorn",
"example.wsgi",
]
mock_argv = mock.patch.object(sys, "argv", argv)
with self.assertRaises(SystemExit) as cm, mock_argv:
run()
exit_code = cm.exception.args[0]
self.assertEqual(exit_code, 0)
What’s going on?
The test runs Gunicorn’s main entrypoint (
run()
) with the necessary arguments fromargv
. Since Gunicorn does not accept an alternative list of arguments, the test usesunittest.mock
to put them in place in sys.argv.(Testing is why my script template has
main()
optionally accept an argument list.)Gunicorn always raises
SystemExit
to quit, which the test catches withassertRaises()
. The only assertion is then that the exit code is 0 for success.Output capturing would normally be a good idea to add when running a tool like this. It’s not needed here though, since Gunicorn doesn’t output any message if the config file is okay.
(Anyway, Django’s test runner also has a global output capturing flag.)
Cool beans.
If the test succeeds, we get the usual .
in our test run. If it fails, Gunicorn’s output is visible above the failure:
$ ./manage.py test example.tests.test_gunicorn
Found 1 test(s).
System check identified no issues (0 silenced).
Error: module 'multiprocessing' has no attribute 'cpu_coun'
F
======================================================================
FAIL: test_config (example.tests.test_gunicorn.GunicornConfigTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/.../example/tests/test_gunicorn.py", line 21, in test_config
self.assertEqual(exit_code, 0)
AssertionError: 1 != 0
----------------------------------------------------------------------
Ran 1 test in 0.006s
FAILED (failures=1)
Easy to debug!
With pytest
The pytest version is not that different:
from __future__ import annotations
import sys
from unittest import mock
import pytest
from gunicorn.app.wsgiapp import run
from db_buddy.test import SimpleTestCase
class GunicornConfigTests(SimpleTestCase):
def test_config_imports(self):
argv = [
"gunicorn",
"--check-config",
"--config",
"python:db_buddy.gunicorn",
"db_buddy.wsgi",
]
mock_argv = mock.patch.object(sys, "argv", argv)
with pytest.raises(SystemExit) as excinfo, mock_argv:
run()
assert excinfo.value.args[0] == 0
The test is as before but with a couple bits of pytest-ness:
pytest.raises()
instead ofself.assertRaises()
- plain
assert instead of ``self.assertEqual()
You could also write this as a function-based test, but I prefer to stick to Django TestCase
s.
Learn how to make your tests run quickly in my book Speed Up Your Django Tests.
One summary email a week, no spam, I pinky promise.