12-Factor Web Development: Flask and Django Async Support

Since the last update, I published a post on the Canonical blog about the Flask support in Charmcraft and Rockcraft!

In terms of the roadmap, we have delivered support for asynchronous workers for Flask and Django. You can read more about the feature in the Gunicorn documentation. Async workers can help increase the scale for I/O-bound applications. The default synchronous worker will handle one request at a time and scale is managed by increasing the number of workers. For async workers, the scale is further enhanced by Gunicorn patching I/O-bound operations with asynchronous functions. An example are database interactions. This means there is no need to have async def functions in your code to take advantage of async workers.

To illustrate the benefits of async workers, let’s look at an example.

Flask Async Example

Let’s start with creating a Multipass VM:

multipass launch --cpus 4 --disk 50G --memory 4G --name charm-dev 24.04
multipass shell charm-dev

Then we need to install dependencies and set things up:

sudo snap install rockcraft --classic --channel latest/edge
sudo snap install charmcraft --classic --channel latest/edge

sudo snap install lxd
lxd init --auto

sudo snap install microk8s --channel 1.31-strict/stable
sudo adduser $USER snap_microk8s
newgrp snap_microk8s
sudo microk8s enable hostpath-storage
sudo microk8s enable registry
sudo microk8s enable ingress

sudo snap install juju --channel 3.5/stable
mkdir -p ~/.local/share
juju bootstrap microk8s dev-controller

mkdir flask-async
cd flask-async

Next we need to create the Flask application. We will have one root endpoint which will sleep for 2 seconds. This will simulate an I/O-bound task such as a slow database call. Create the requirements.txt file with the following contents:

Flask
gunicorn[gevent]

Note that gunicorn[gevent] is required for async workers. Create the app.py file with the following contents:

from time import sleep

import flask

app = flask.Flask(__name__)

@app.route("/")
def index():
    sleep(2)
    return "Hello, world!\n"

if __name__ == "__main__":
    app.run()

Now let’s create the charm and rock and deploy them:

rockcraft init --profile flask-framework
rockcraft pack
rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \
   oci-archive:flask-async_0.1_amd64.rock \
   docker://localhost:32000/flask-async:0.1

mkdir charm
cd charm
charmcraft init --profile flask-framework --name flask-async
charmcraft pack

juju add-model flask-async
juju deploy ./flask-async_ubuntu-22.04-amd64.charm \
   flask-async --resource \
   flask-app-image=localhost:32000/flask-async:0.1

Use:

juju status --watch 2s

To wait for the application to be active. You should see the following output from juju status:

Model        Controller      Cloud/Region        Version  SLA          Timestamp
flask-async  dev-controller  microk8s/localhost  3.5.5    unsupported  23:39:22+11:00

App          Version  Status  Scale  Charm        Channel  Rev  Address        Exposed  Message
flask-async           active      1  flask-async             0  10.152.183.51  no       

Unit            Workload  Agent  Address      Ports  Message
flask-async/0*  active    idle   10.1.157.80

We can test everything is working by sending a curl request to the IP address listed for the unit (above 10.1.157.80, yours may differ):

curl 10.1.157.80:8000

This should return Hello, world! after 2 seconds. Run

juju config flask-async

Which will return all of the configurations of the charm. There is a new configuration option webserver-worker-class (for both Flask and Django charms) which can be used to enable async mode. First, let’s check that the app is running in sync mode. Let’s create a shell script test-async.sh with the following contents:

for i in $(seq 1 5); do
  (
    start_time=$(date +%s)
    echo "Request $i start time: $start_time"
    curl 10.1.157.80:8000
    end_time=$(date +%s)
    pass_time=$((end_time - start_time))
    echo "Request $i end time: $end_time == $pass_time"
    echo "Request $i end time: $end_time == $pass_time"
  ) &
done

Note that you will need to update the IP address based on your juju status output. Make the shell script executable and run it using

chmod +x test-async.sh
./test-async.sh

You will notice that the total time the script takes to execute is roughly 10 seconds. Let’s change to the gevent worker class which will make the app async:

juju config flask-async webserver-worker-class=gevent

Give the app a moment to restart, get the new IP address from juju status and update it in the script and then run the test script again:

./test-async.sh

All of the requests will now return within 2 seconds.

Thats the end of the example! We saw the new webserver-worker-class config option which changes the app to use async workers when it is set to gevent. This meant that simulated I/O-bound application could respond to several requests at the same time rather than be blocked.

2 Likes