I’ve recently been doing a bit of work setting up Django applications to work with Gunicorn and Caddy of late, and I’ve settled on these settings for now, as the ones I wish I had at my fingertips when working with Gunicorn to serve python apps. Read on for a sample config with explanations of each key part.
Here’s a sample config file, to make it easier to keep the various settings in source control in future, and avoid passing ever more arcane sets of flags into command line invocations:
import os
# https://docs.gunicorn.org/en/stable/settings.html
wsgi_app = 'config.wsgi'
# listen on all interfaces by default
bind = os.getenv("GUNICORN_BIND", default="0.0.0.0:8000")
# Kill and restart workers silent for more than this many seconds
# Helps with memory leaks
timeout = 300
# increasing workers uses more RAM, but provides a simple model for scaling up resources
workers = os.getenv("GUNICORN_WORKERS", default=4)
# increasing threads saves RAM at the cost of using more CPU
threads = os.getenv("GUNICORN_THREAD", default=1)
# turn up the logging if you need to debug.
# loglevel = 'info'
# By default gunicorn does not log requests
# unless you set the `accesslog`` below
accesslog = '-'
Let’s cover these in more detail.
Which wsgi config to use, and which interface to listen for incoming requests on.
These two are typically the ones you see the most commonly invoked on the command line when gunicorn is called with first argument being a path to the wsgi file, and the second being a set of IP addresses to listen on, using –b
or --bind
.
wsgi_app = 'config.wsgi'
# listen on all interfaces by default
bind = os.getenv("GUNICORN_BIND", default="0.0.0.0:8000")
Seeing bind
in a config file here tells gunicorn to listen on all IP addresses, and in production you probably don’t want to do this, but when setting it up, it’s useful to check that requests are reaching the server at least. You’d either tighten it this down to listen on localhost,
or perhaps a private IP address that is placed ‘behind’ a reverse proxy server like Nginx, or Caddy.
Managing memory
Next, you likely want a way to control how you manage incoming requests, and managing how resource hungry a server is.
# Kill and restart workers silent for more than this many seconds
# Helps with memory leaks
timeout = 300
# increasing workers uses more RAM, but provides a simple model for scaling up resources
workers = os.getenv("GUNICORN_WORKERS", default=4)
# increasing threads saves RAM at the cost of using more CPU
threads = os.getenv("GUNICORN_THREAD", default=1)
Gunicorn is based on a forking model – there is one supervisor process, that doesn’t actually serve any requests by itself, but instead it ‘spawns’ workers who it then delegates the incoming work to.
Generally speaking, each worker costs memory and CPU to run, but depending on the kinds of traffic you expect, you might choose to tune Gunicorn by setting differing numbers of workers and different numbers of threads.
This is a rabbit hole you can dive quite far down, and we haven’t even touched on handling async requests with django. I’ve found the following settings below useful.
Further reading on gunicorns many workers
- More on using ‘sync’ gunicorn workers compared to other types
- Different means of acheiving concurrency with gunicorn
- Using the univorn.worker class to serve async requests with Gunicorn
Making gunicorn more observable
Finally, when you’re running gunicorn in production, there’s a few settings that will be very helpful for debugging later. If you’re using a platform as a service offering like Heroku, or Fly.io you might have these already set up and exposed to you, but knowing how to set them up yourself helps if you’re looking to recreate the same level of observability on other platforms.
# turn up the logging if you need to debug.
# loglevel = 'info'
# By default gunicorn does not log requests
# unless you set the `accesslog`` below
accesslog = '-'
# If you are aggregating metrics
# statsd_prefix='your_app_name'
# statsd_host='metrics-aggregator:9125'
By default, as of Jan 2023, does not log requests anywhere. Seeing inbound requests is almost always worth the cost in storage, so set the accesslog
you see here be able to see the inbound requests
Similarly, having some basic metrics on how many requests gunicorn is handling pers second, and how long they are taking to service requests is useful too.
While there are some plugins expose these metrics for prometheus type scraping, getting reliable metrics can be a bit tricky, because prometheus tends to assume you have one process that uses a bunch of threads to manage concurrency, rather than spawning multiple processes.
In the end I found it easier to use the statsd exporter options to push metrics to a centralised endpoint, and then collect the metrics from there.
Further reading on making django and gunicorn easier to monitor
- Monitoring Gunicorn with Prometheus
- Prometheus Python Client
- Monitoring OpenMetrics for Gunicorn and Django application in Prometheus
- django-prometheus
- Exporting /metrics in a WSGI application with multiple processes per process
OK, that’s it for now. Next I’ll cover Caddy, a handy golang based webserver, and how to use it with Django.