Python Deployment: WSGI with gunicorn
Tags: python, webdev, devops
Published: 2023-08-23 11:06:08

{%- # TOC start (generated with https://github.com/derlin/bitdowntoc) -%}

- [On Benchmarks](#on-benchmarks)
- [gunicorn Overview](#gunicorn-overview)
- [Basic Running](#basic-running)
- [Configuration](#configuration)
- [Notable HTTP Features](#notable-http-features)
  * [Chunked Input Support](#chunked-input-support)
  * [Chunked Response Support](#chunked-response-support)
  * [Range Support](#range-support)
- [PEX bundling](#pex-bundling)
- [Process Titles](#process-titles)
- [Project Support Overview](#project-support-overview)
  * [Documentation](#documentation)
  * [Source Maintainability](#source-maintainability)
- [Conclusion](#conclusion)

{%- # TOC end -%}

In the last section the basics of WSGI were introduced along with some helpful enhancements via the [werkzeug](https://werkzeug.palletsprojects.com/en/2.3.x/). Now there are many servers which implement WSGI and can be utilized towards more production level environments. In this article I'll be looking at gunicorn as one of those WSGI server solutions. While my original plan was to introduce all WSGI servers in a single post, I've found that describing each has enough content for a single article. With this in mind future posts in this series will introduce WSGI server solutions as their own dedicated article. 

## On Benchmarks

There won't be much in the way of benchmarks here and future server posts. Software is constantly evolving, and what may be slow now could change to very performant in a week. WSGI servers in a more production environment can also be part of clusters or diversified to meet different application needs. Even in cases where performance metrics are important, they should be measured in a more controlled environment that's closer to where deployment will occur.

## gunicorn Overview

[Gunicorn](https://gunicorn.org/) is a pure python WSGI capable server which runs on a pre-fork worker model as well as [other alternatives](https://docs.gunicorn.org/en/stable/design.html).  Being purely python it loads up a WSGI app [via module import](https://github.com/benoitc/gunicorn/blob/master/gunicorn/util.py#L363). The pure python architecture also means easier integration with [PyPy](https://www.pypy.org/) which provides JIT optimizations on long running processes.

## Basic Running

gunicorn can be installed via `pip install gunicorn`. Given a simple WSGI application:

`wsgi_test.py`
```python
def application(env, start_response):
    data = b'Hello World'

    status = '200 OK'
    response_headers = [
        ('Content-Type', 'text/plain'),
        ('Content-Length', str(len(data))),
    ]
    start_response(status, response_headers)
    return [data]
```

gunicorn would be executed like this:

```
$ gunicorn --workers=2 wsgi_test:application
[2023-08-23 05:00:33 +0000] [13126] [INFO] Starting gunicorn 21.2.0
[2023-08-23 05:00:33 +0000] [13126] [INFO] Listening at: http://127.0.0.1:8000 (13126)
[2023-08-23 05:00:33 +0000] [13126] [INFO] Using worker: sync
[2023-08-23 05:00:33 +0000] [13176] [INFO] Booting worker with pid: 13176
[2023-08-23 05:00:33 +0000] [13177] [INFO] Booting worker with pid: 13177
```

where `wsgi_test.py` without the extension as the modeule, followed by a `:`, and then the name of the callable (the `application` function in this case). 

## Configuration

A python file can be used for configuration purposes. The configuration pulls from settings [described in the documentation](https://docs.gunicorn.org/en/stable/settings.html#settings). It also works as a standard python file meaning you can do something like:

`gunicorn.config.py`
```python
import multiprocessing

bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
wsgi_app = "wsgi_test:application"
```

Then the configuration can be checked to ensure it's valid:

```
$ gunicorn --check-config -c gunicorn.config.py
```

Finally run gunicorn with the `--config`/`-c` option and the name of the config file:

```
$ gunicorn -c gunicorn.config.py
[2023-08-23 02:50:37 +0000] [18928] [INFO] Starting gunicorn 21.2.0
[2023-08-23 02:50:37 +0000] [18928] [INFO] Listening at: http://127.0.0.1:8000 (18928)
[2023-08-23 02:50:37 +0000] [18928] [INFO] Using worker: sync
[2023-08-23 02:50:37 +0000] [18984] [INFO] Booting worker with pid: 18984
[2023-08-23 02:50:37 +0000] [18985] [INFO] Booting worker with pid: 18985
[2023-08-23 02:50:37 +0000] [18986] [INFO] Booting worker with pid: 18986
[2023-08-23 02:50:37 +0000] [18987] [INFO] Booting worker with pid: 18987
[2023-08-23 02:50:37 +0000] [18988] [INFO] Booting worker with pid: 18988
[2023-08-23 02:50:37 +0000] [18989] [INFO] Booting worker with pid: 18989
[2023-08-23 02:50:37 +0000] [18990] [INFO] Booting worker with pid: 18990
[2023-08-23 02:50:37 +0000] [18991] [INFO] Booting worker with pid: 18991
[2023-08-23 02:50:37 +0000] [18992] [INFO] Booting worker with pid: 18992
```

## Notable HTTP Features

Here I'll look at the ability to support chunked input, chunked output, and range headers. These features may or may not be necessary depending on your use case.

### Chunked Input Support

gunicorn supports chunked input via `wsgi.input_terminated` as shown by this simple application:

`wsgi_chunked_input`
```python
def application(environ, start_response):
    input = environ['wsgi.input']
    with open('test.json', 'wb') as stream_fp:
        stream_fp.write(input.read())

    status = '200 OK'
    body = b'Hello World\n'
    response_headers = [
        ('Content-Type', 'text/plain'),
        ('Content-Length', str(len(body))),
    ]
    start_response(status, response_headers)
    return [body]
```

Sending a 25MB JSON file comes back with:

```
$ curl -v -H "Transfer-Encoding: chunked" -d @large-file.json http://127.0.0.1:8000
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> POST / HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/7.74.0
> Accept: */*
> Transfer-Encoding: chunked
> Content-Type: application/x-www-form-urlencoded
> Expect: 100-continue
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 100 Continue
* Signaling end of chunked upload via terminating chunk.
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: gunicorn
< Date: Tue, 22 Aug 2023 17:47:21 GMT
< Connection: close
< Content-Type: text/plain
< Content-Length: 12
< 
Hello World
* Closing connection 0
```

Which shows up just fine on the server side:

```
$ ls -lh test.json 
-rw-r--r-- 1 john doe 25M Aug 22 18:47 test.json
```

### Chunked Response Support

Chunked responses work as well, and simply need the `Transfer-Encoding: chunked` header added as per a [modified example](https://github.com/benoitc/gunicorn/blob/ab9c8301cb9ae573ba597154ddeea16f0326fc15/examples/longpoll.py#L25):

```python
class TestIter(object):

    def __iter__(self):
        lines = [b'line 1\n', b'line 2\n']
        for line in lines:
            yield line

def app(environ, start_response):
    status = '200 OK'
    response_headers = [
        ('Content-type', 'text/plain'),
        ('Transfer-Encoding', "chunked"),
    ]
    start_response(status, response_headers)
    return TestIter()
```

Which when ran against via curl produces:

```
$ curl -iv --raw -H "Transfer-Encoding: chunked" http://127.0.0.1:8000/
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/7.74.0
> Accept: */*
> Transfer-Encoding: chunked
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Server: gunicorn
Server: gunicorn
< Date: Tue, 22 Aug 2023 21:40:12 GMT
Date: Tue, 22 Aug 2023 21:40:12 GMT
< Connection: close
Connection: close
< Transfer-Encoding: chunked
Transfer-Encoding: chunked
< Content-type: text/plain
Content-type: text/plain

< 
7
line 1

7
line 2

0

* Closing connection 0
```

### Range Support

Ranges do not have an explicit wrapper and would requires a helper function such as [werkzeug.http.parse_range_header](https://werkzeug.palletsprojects.com/en/2.3.x/http/#werkzeug.http.parse_range_header). The value to be passed would be available through `HTTP_RANGE`:

```python
from werkzeug.http import parse_range_header

def application(environ, start_response):
    range = parse_range_header(environ['HTTP_RANGE'])
    start, end = range.ranges[0]

    with open('large-file.json', 'rb') as stream_fp:
        stream_fp.seek(start)
        data = stream_fp.read(end - start)

    status = '200 OK'
    response_headers = [
        ('Content-type', 'application/json')
    ]
    start_response(status, response_headers)
    return [data]
```

Which when ran against with curl:

```
$ curl -v -r 1200-1299 http://127.0.0.1:8000/
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:8000
> Range: bytes=1200-1299
> User-Agent: curl/7.74.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: gunicorn
< Date: Wed, 23 Aug 2023 10:59:27 GMT
< Connection: close
< Transfer-Encoding: chunked
< Content-type: application/json
< 
* Closing connection 0
pt"},"message":"Fix main header height on mobile","distinct":true,"url":"https://api.github.com/repo
```

## PEX bundling

Being pure python, gunicorn can also be packaged using [pex](https://pex.readthedocs.io/en/v2.1.144/) (Python EXecutable). As an example, I'll create a simple WSGI application `wsgi_test.py` along with a simplified `gunicorn.config.py`:

`wsgi_test.py`
```python
def application(env, start_response):
    data = b'Hello World'

    status = '200 OK'
    response_headers = [
        ('Content-Type', 'text/plain'),
        ('Content-Length', str(len(data))),
    ]
    start_response(status, response_headers)
    return [data]
```

`gunicorn.config.py`
```python
bind = "127.0.0.1:8000"
workers = 2
wsgi_app = "wsgi_test:application"
```

I'll put these in a dedicated folder so it's easy to copy over. Then the executable will need to be packaged:

```
$ pex gunicorn -c gunicorn -o wsgi_app.pex --python pypy --inject-args "--config gunicorn.config.py"
```

So the first part of this is the modules to include. We'll be including `gunicorn` since it's what we want to run our WSGI app as. The next is an entry point, which is the `console_script` gunicorn defined in the [setup.py of the project](https://github.com/benoitc/gunicorn/blob/master/setup.py#L114). This will allow for the resulting executable to be run just as if we were running `gunicorn` on the command line. `--python pypy` sets pypy to be the python binary to utilize. You'll want to make sure this matches a python available on the target machine. `--inject-args` makes it so that the config file argument is always passed in so they don't have to be written out at execution. Finally `-o wsgi_app.pex` is the executable file to output. If I copy this to another Linux system with pypy installed:

```
$ scp server:~/projects/pex_example/* .
$ ./wsgi_app.pex
[2023-08-23 05:00:33 +0000] [13126] [INFO] Starting gunicorn 21.2.0
[2023-08-23 05:00:33 +0000] [13126] [INFO] Listening at: http://127.0.0.1:8000 (13126)
[2023-08-23 05:00:33 +0000] [13126] [INFO] Using worker: sync
[2023-08-23 05:00:33 +0000] [13176] [INFO] Booting worker with pid: 13176
[2023-08-23 05:00:33 +0000] [13177] [INFO] Booting worker with pid: 13177
```

Everything is running despite not having gunicorn installed on the target machine. Note that this works due to the config file pointing to `wsgi_test:application`. To have that bundled along with the executable you would need to have it installed through a `setup.py`/`pyproject.toml` style build system. Then it could be bundled like:

```
$ pex wsgi_test gunicorn -c gunicorn -o wsgi_app.pex --python pypy --inject-args "--config gunicorn.config.py"
```

## Process Titles

Installing [steproctitle](https://pypi.org/project/setproctitle/) will allow gunicorn to name it's worker processes in a way that makes it easier to manage. Simply install through `pip install setproctitle` and looking at the process list will show something similar to:

```
  28928 pts/2    00:00:01 gunicorn: maste
  28984 pts/2    00:00:00 gunicorn: worke
  28985 pts/2    00:00:00 gunicorn: worke
  28986 pts/2    00:00:00 gunicorn: worke
  28987 pts/2    00:00:00 gunicorn: worke
  28988 pts/2    00:00:00 gunicorn: worke
  28989 pts/2    00:00:00 gunicorn: worke
  28990 pts/2    00:00:00 gunicorn: worke
  28991 pts/2    00:00:00 gunicorn: worke
  28992 pts/2    00:00:00 gunicorn: worke
```

Which allows for easy differentiating between master and worker processes.

## Project Support Overview

This looks at how a project is maintained for those considering use in production environments. As with any software evaluation be sure to test things in an environment close to what you expect your production environment to be.

### Documentation

gunicorn's main site has a simple [documentation page](https://gunicorn.org/#docs). For more extensive documentation there's a [full docs site](https://docs.gunicorn.org/). Documentation itself is fairly well organized. Multi-version documentation allows pointing to a specific version or a version pointer such as `latest` and `stable`.

### Source Maintainability

The last commit shows to be around 1 month old from writing. There are 274 issues and 85 open PRs. A good amount of recent activity seems to be a one person effort. Given the size of the code base forkability is fairly reasonable if the need arises. 

## Conclusion

I think the overall package is good. Getting up and running is fairly straightforward if you want to prototype quickly. The availability of multiple worker types means you'll want to evaluate each one to find the best fit if doing production deployments (except tornado worker is probably not a good idea as WSGI is a minor bonus versus being the main focus). As far as the source repository, having primarily a one person maintainer does have a point of concern. Deciding on a few co-maintainers and bug wranglers would really help the project out.