Python Deployment: WSGI with uWsgi
Tags: python, webdev, programming
Published: 2023-08-28 01:18:02

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

- [Installation](#installation)
- [The Somewhat Basics](#the-somewhat-basics)
- [Configuration](#configuration)
- [Notable HTTP Features](#notable-http-features)
  * [Chunked Input Support](#chunked-input-support)
  * [Chunked Response Support](#chunked-response-support)
  * [Range](#range)
- [uwsgi Protocol](#uwsgi-protocol)
- [Python Integration](#python-integration)
  * [Low Level API Calls](#low-level-api-calls)
  * [Decorators](#decorators)
- [Project Support Overview](#project-support-overview)
  * [Documentation](#documentation)
  * [Source Maintainability](#source-maintainability)
- [Final Thoughts](#final-thoughts)

{%- # TOC end -%}

In the previous installment gunicorn was looked at as a candidate for serving WSGI applications. As mentioned when discussing WSGI, pure python (as how gunicorn does it) and python C API interactions are ways to achieve integration with WSGI applications. For this article I'll be looking at [uWsgi](https://github.com/unbit/uwsgi/), which approaches the problem through the python C API method. 

## Installation

uWsgi is a C based application and also interacts with python's C API. That means your distro will need to have a compiler toolchain as well as the python header files and library. As an example:

```
$ apt-get install build-essential python3-dev
```

If you use something like [pyenv](https://github.com/pyenv/pyenv) or manage your python via source compilation then the `python3-dev` part will not be necessary. Given the development and release pace of uWsgi, I tend to utilize the master branch for the source. Then I use the `setup.py` installation process as it tracks a few python locations that you'd have to specify on the command line otherwise:

```
$ git clone https://github.com/unbit/uwsgi.git
$ cd uwsgi
$ python3 setup.py install
```

## The Somewhat Basics

uWsgi has a fairly extensive set of features bound to configuration options. Here's an example run with a basic app:

`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]
```

```
$ uwsgi --http :8000 --wsgi-file wsgi_test.py --master --processes 2 --threads 2
*** Starting uWSGI 2.1-dev+31c6c430 (64bit) on [Sun Aug 27 15:59:09 2023] ***
compiled with version: 10.2.1 20210110 on 17 August 2023 02:44:19
os: Linux-6.1.21-v8+ #1642 SMP PREEMPT Mon Apr  3 17:24:16 BST 2023
nodename: raspberrypi
machine: aarch64
clock source: unix
pcre jit disabled
detected number of CPU cores: 4
<snip>
Python version: 3.11.4 (main, Aug 17 2023, 03:18:09) [GCC 10.2.1 20210110]
Python main interpreter initialized at 0x7f89fc5b30
dropping root privileges after plugin initialization
python threads support enabled
your server socket listen backlog is limited to 100 connections
your mercy for graceful operations on workers is 60 seconds
your request buffer size is 4096 bytes
mapped 250368 bytes (244 KB) for 4 cores
*** Operational MODE: preforking+threaded ***
WSGI app 0 (mountpoint='') ready in 0 seconds on interpreter 0x7f89fc5b30 pid: 56323 (default app)
dropping root privileges after application loading
*** uWSGI is running in multiple interpreter mode ***
spawned uWSGI master process (pid: 56323)
spawned uWSGI worker 1 (pid: 56368, cores: 2)
spawned uWSGI worker 2 (pid: 56369, cores: 2)
spawned uWSGI http 1 (pid: 56370)
```

So there's certainly a lot going on here. For the options:

- `--http :8000` tells uWsgi to run an http server on port 8000.
- `--wsgi-file my_test.py` points to a python file with a WSGI application in it 
- `--master` This ensures a master process is available to manage workers
- `--processes 2` The number of processes to spin up, in this case 2
- `--threads 2` The number of threads per process to run under, in this case 2

The numbers here are fairly small compared to the usual settings you'd see as this is running on a Raspberry Pi. One thing to note is that when declaring the WSGI python file uWsgi knows to call an `application` callable by default. If we named it `app` instead of `application` then we'd use the `--callable` option to point to it instead:

```
$ uwsgi --http :8000 --wsgi-file wsgi_test.py --callable app --master --processes 2 --threads 2
```

With a simple curl run we get:

```
$ curl -v 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: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 11
< 
* Connection #0 to host 127.0.0.1 left intact
Hello World
```

## Configuration

Given the number of available arguments and for easier maintainability, you can also utilize a configuration file in ini format like so:

```ini
[uwsgi]
http = 8000
wsgi-file = wsgi_test.py
processes = 2
threads = 2
master = true
```

Now simply pass in the config file to `uwsgi`:

```
$ uwsgi ./uwsgi-config.ini
```

## 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

Support for chunked input via WSGI is available through `http11-socket` and `wsgi-manage-chunked-input` flags:
`wsgi_chunked_input.py`
```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]
```

```ini
[uwsgi]
http11-socket = :8000
wsgi-manage-chunked-input = true
wsgi-file = wsgi_chunked_input.py
processes = 2
threads = 2
master = true
```

The reason for this being a WSGI specific option is that uWsgi has its own [API for handling this](http://uwsgi-docs.readthedocs.io/en/latest/Chunked.html). An example `curl` call with a 25MB JSON file that the above would write back to `test.json`:

```
$ 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
> 
* Done waiting for 100-continue
* Signaling end of chunked upload via terminating chunk.
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 12
< 
Hello World
* Connection #0 to host 127.0.0.1 left intact
$ ls -lah test.json 
-rw-r--r-- 1 cwprogram cwprogram 25M Aug 27 19:35 test.json
```

### Chunked Response Support

Chunked response requires a few options to be set:

```ini
[uwsgi]
http = :8000
wsgi-file = wsgi_chunked_output.py
processes = 2
threads = 2
master = true
route-run = chunked:
route-run = last:
```

This utilizes the [uWsgi transformations feature](https://uwsgi-docs.readthedocs.io/en/latest/Transformations.html) to enable chunked encoding. For the WSGI code:

`wsgi_chunked_output.py`
```python
class TestIter(object):

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

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

A simple curl call confirms the chunked encoding works as expected:

```
$ curl -iv --raw 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: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Content-type: text/plain
Content-type: text/plain
< Transfer-Encoding: chunked
Transfer-Encoding: chunked

< 
7
line 1

7
line 2

0
```

### Range

If you want to handle range with dynamically generated content then you'll want to use `werkzeug` module's `parse_range_header` option:

`wsgi_range.py`
```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]
```

Outside of that the expectation is that you want range for static content such as video files. Simply have the `honour-range` option along with a folder to check static files for like so:

```ini
[uwsgi]
http = :8000
honour-range = true
check-static = /home/uwsgi/static/
wsgi-file = wsgi_range.py
processes = 2
threads = 2
master = true
```

When ran with curl using a range option and a file that's present in the static directory:

```
$ curl -v -r 1200-1299 http://127.0.0.1:8000/large-file.json > result.json
*   Trying 127.0.0.1:8000...
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0* Connected to 127.0.0.1 (127.0.0.1) port 8000 (#0)
> GET /large-file.json 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 206 Partial Content
< Content-Type: application/json
< Content-Length: 100
< Content-Range: bytes 1200-1299/26141343
< Last-Modified: Sat, 19 Aug 2023 00:48:33 GMT
< 
{ [100 bytes data]
100   100  100   100    0     0  33333      0 --:--:-- --:--:-- --:--:-- 33333
* Connection #0 to host 127.0.0.1 left intact
```

## uwsgi Protocol

The uwsgi protocol is something I've discussed in a [previous installment](https://dev.to/cwprogram/python-application-deployment-protocols-4ea8#uwsgi). This is primarily meant to support communication with frontend servers such as nginx. Taking an example nginx config:

```
server {
    listen 9898;
    listen [::]:9898;
    root /var/www/html;
    index index.html index.htm index.nginx-debian.html;
    server_name _;

    location / {
        uwsgi_pass 127.0.0.1:8087;
        include uwsgi_params;
    }
}
```

Now for the uwsgi config:

```ini
[uwsgi]
uwsgi-socket = :8087
wsgi-file = wsgi_test.py
processes = 2
threads = 2
master = true
```

This time `uwsgi-socket` is being used so the traffic is done via uwsgi protocol instead of the previous http protocol. Not much changes as far as the response is concerned:

```
$ curl -v http://127.0.0.1:9898
*   Trying 127.0.0.1:9898...
* Connected to 127.0.0.1 (127.0.0.1) port 9898 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:9898
> User-Agent: curl/7.74.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: nginx/1.18.0
< Date: Sun, 27 Aug 2023 23:42:05 GMT
< Content-Type: text/plain
< Content-Length: 11
< Connection: keep-alive
< 
* Connection #0 to host 127.0.0.1 left intact
Hello World
```

One thing with uwsgi is that while the uWsgi server receives the request uwsgi encoded, the actual response is still HTTP. This means things like chunked responses will still work. Chunked inputs however, don't quite work out of the box and alternatives will generally be required. This is something I'll most likely go over in a separate article.

## Python Integration

It's possible to interact with `uwsgi` [as a module in python](https://uwsgi-docs.readthedocs.io/en/latest/PythonModule.html). Do keep in mind that this does introduce some vendor lock in. I'd recommend a plugin/driver type abstraction if you do go this route that you can easily swap in something else if your requirements change. 

### Low Level API Calls

As an example for making calls to uWsgi's API, you can setup a [cache](https://uwsgi-docs.readthedocs.io/en/latest/Caching.html) like so:

```ini
[uwsgi]
uwsgi-socket = :8087
wsgi-file = uwsgi_module.py
processes = 2
threads = 2
master = true
cache2 = name=mycache,items=100
```

Then with python code you can easily access this cache (not really practical code but it does show access):

`uwsgi_module.py`
```python
from uwsgi import cache_get, cache_set, cache_clear

def application(env, start_response):
    data = b'Hello World'
    cache_set('return_data', data)

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

### Decorators

There are also some [decorators available](https://uwsgi-docs.readthedocs.io/en/latest/PythonDecorators.html) to simplify interaction with some of the uWsgi services. Take sending an email to a customer where you don't want to wait for it to finish. We can interact with [uWsgi's spooler](https://uwsgi-docs.readthedocs.io/en/latest/Spooler.html) like so:

```ini
[uwsgi]
uwsgi-socket = :8087
wsgi-file = wsgi_test.py
processes = 2
threads = 2
master = true
spooler = myspool
import = uwsgi_spool_task
```

Here a spool named `myspool` is setup, and code for the `uwsgi_spool_task` module is used to import the spool code.

`uwsgi_spool_task.py`
```python
from uwsgidecorators import *

@spool
def send_email(arguments):
    print(f"Send email to {arguments['email_address']}")
```

A spool decorator declares the spool worker code. This is where you would have code to reach out to your email provider and send off an email. Now for the WSGI app:

`wsgi_test.py`
```python
from uwsgi_spool_task import send_email

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)
    send_email.spool(email_address='someone@no.spam')
    return [data]
```

This will import the `send_email` and then send it off to the spool with the email address to send the email to. 

## Project Support Overview

Now we'll talk about project maintenance. This information can help you make a decision on if you want to utilize this solution against something else. I will say that while there is a supposed commercial support link, it's currently broken at this time.

### Documentation

[The documentation](https://uwsgi-docs.readthedocs.io/en/latest/) got a pretty decent update recently. This has substantially improved the overall layout from what it was before, making it easier to navigate. Even so the overwhelming amount of features can still make finding what you need a daunting task.

### Source Maintainability

The repo for this indicates the project is in maintenance mode and as such there are a large number of issues and pull requests left unattended. Given that python 3.12 is in RC mode, it will be interesting to see if it gets properly supported or not when the official release comes out. My general consensus is that if you plan to use the `uwsgi` python module, have a backup solution in place.

## Final Thoughts

My general thought is that for most developers that just want to test their code out gunicorn would be good enough. For the serious optimizer types, consider looking at the [Bloomberg article](https://www.bloomberg.com/company/stories/configuring-uwsgi-production-deployment/) and [Cloudbees article](https://www.cloudbees.com/blog/getting-every-microsecond-out-of-uwsgi) on production performance. 

Company wise I think that unbit allocating more resources to the project and actually having a valid link to some commercial support offering would help. The project has good potential and volunteers willing to help out, but without the maintainers stepping in to merge and release there's not much else that can be done. Either that or maybe we'll see a solid fork of it step up.