Skip to content

Commit

Permalink
feat(extension): Add option to enable async workers in Flask and Djan…
Browse files Browse the repository at this point in the history
…go (#1986)

Related to [Rockcraft
PR](canonical/rockcraft#747) and [Paas Charm
PR](canonical/paas-charm#11). Adds charm option
to enable Async Gunicorn Workers.
  • Loading branch information
alithethird authored Jan 10, 2025
1 parent 33519b4 commit 3128c62
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 1 deletion.
4 changes: 4 additions & 0 deletions charmcraft/extensions/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ def get_image_name(self) -> str:
"type": "int",
"description": "The number of webserver worker processes for handling requests.",
},
"webserver-worker-class": {
"type": "string",
"description": "The webserver worker process class for handling requests. Can be either 'gevent' or 'sync'.",
},
}


Expand Down
19 changes: 19 additions & 0 deletions docs/howto/code/flask-async/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from time import sleep

import flask

app = flask.Flask(__name__)


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


@app.route("/io")
def pseudo_io():
sleep(2)
return "ok\n"

if __name__ == "__main__":
app.run()
2 changes: 2 additions & 0 deletions docs/howto/code/flask-async/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Flask
gevent
182 changes: 182 additions & 0 deletions docs/howto/code/flask-async/task.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
###########################################
# IMPORTANT
# Comments matter!
# The docs use the wrapping comments as
# markers for including said instructions
# as snippets in the docs.
###########################################
summary: How to create async Flask Charm

kill-timeout: 90m

environment:

execute: |
# Move everything to $HOME so that Juju deployment works
mv *.yaml *.py *.txt $HOME
cd $HOME
# Don't use the staging store for this test
unset CHARMCRAFT_STORE_API_URL
unset CHARMCRAFT_UPLOAD_URL
unset CHARMCRAFT_REGISTRY_URL
# Add setup instructions
snap install rockcraft --channel=latest/edge --classic
snap install microk8s --channel=1.31-strict/stable
snap install juju --channel=3/stable
mkdir -p ~/.local/share
# MicroK8s config setup
microk8s status --wait-ready
microk8s enable hostpath-storage
microk8s enable registry
microk8s enable ingress
# Bootstrap controller
juju bootstrap microk8s dev-controller
cd $HOME
# [docs:create-venv]
sudo apt-get update && sudo apt-get install python3-venv -y
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# [docs:create-venv-end]
flask run -p 8000 &
retry -n 5 --wait 2 curl --fail localhost:8000
# [docs:curl-flask]
curl localhost:8000
# [docs:curl-flask-end]
# [docs:curl-flask-async-app]
curl localhost:8000/io
# [docs:curl-flask-async-app-end]
kill $!
# [docs:create-rockcraft-yaml]
rockcraft init --profile flask-framework
# [docs:create-rockcraft-yaml-end]
sed -i "s/name: .*/name: flask-async-app/g" rockcraft.yaml
sed -i "s/amd64/$(dpkg --print-architecture)/g" rockcraft.yaml
# [docs:pack]
rockcraft pack
# [docs:pack-end]
# [docs:ls-rock]
ls *.rock -l
# [docs:ls-rock-end]
# [docs:skopeo-copy]
rockcraft.skopeo --insecure-policy copy --dest-tls-verify=false \
oci-archive:flask-async-app_0.1_$(dpkg --print-architecture).rock \
docker://localhost:32000/flask-async-app:0.1
# [docs:skopeo-copy-end]
# [docs:create-charm-dir]
mkdir charm
cd charm
# [docs:create-charm-dir-end]
# [docs:charm-init]
charmcraft init --profile flask-framework --name flask-async-app
# [docs:charm-init-end]
sed -i "s/paas-charm.*/https:\/\/github.com\/canonical\/paas-charm\/archive\/async-workers.tar.gz/g" requirements.txt
# [docs:charm-pack]
charmcraft pack
# [docs:charm-pack-end]
# [docs:ls-charm]
ls *.charm -l
# [docs:ls-charm-end]
# [docs:add-juju-model]
juju add-model flask-async-app
# [docs:add-juju-model-end]
juju set-model-constraints -m flask-async-app arch=$(dpkg --print-architecture)
# [docs:deploy-juju-model]
juju deploy ./flask-async-app_ubuntu-22.04-$(dpkg --print-architecture).charm \
flask-async-app --resource \
flask-app-image=localhost:32000/flask-async-app:0.1
# [docs:deploy-juju-model-end]
# [docs:deploy-nginx]
juju deploy nginx-ingress-integrator --channel=latest/edge --base ubuntu@20.04
juju integrate nginx-ingress-integrator flask-async-app
# [docs:deploy-nginx-end]
# [docs:config-nginx]
juju config nginx-ingress-integrator \
service-hostname=flask-async-app path-routes=/
# [docs:config-nginx-end]
# give Juju some time to deploy the apps
juju wait-for application flask-async-app --query='status=="active"' --timeout 10m
juju wait-for application nginx-ingress-integrator --query='status=="active"' --timeout 10m
# [docs:curl-init-deployment]
curl http://flask-async-app --resolve flask-async-app:80:127.0.0.1
# [docs:curl-init-deployment-end]
# [docs:config-async]
juju config flask-async-app webserver-worker-class=gevent
# [docs:config-async-end]
juju wait-for application flask-async-app --query='status=="active"' --timeout 10m
# test the async flask service
NUM_REQUESTS=15
ASYNC_RESULT='TRUE'
echo "Firing $NUM_REQUESTS requests to http://flask-async-app/io..."
overall_start_time=$(date +%s)
for i in $(seq 1 $NUM_REQUESTS); do
(
start_time=$(date +%s)
echo "Request $i start time: $start_time"
curl -s http://flask-async-app/io --resolve flask-async-app:80:127.0.0.1
end_time=$(date +%s)
pass_time=$((end_time - start_time))
echo "Request $i end time: $end_time == $pass_time"
) &
done
wait
end_time=$(date +%s)
overall_passtime=$((end_time - overall_start_time))
echo "Total pass time: $overall_passtime"
if [ $((3 < overall_passtime)) -eq 1 ]; then
echo "Error!"
ASYNC_RESULT='FALSE'
exit 2
fi
[ "$ASYNC_RESULT" == 'TRUE' ]
# Back out to main directory for clean-up
cd ..
# [docs:clean-environment]
# exit and delete the virtual environment
deactivate
rm -rf charm .venv __pycache__
# delete all the files created during the tutorial
rm flask-async-app_0.1_$(dpkg --print-architecture).rock rockcraft.yaml app.py \
requirements.txt migrate.py
# Remove the juju model
juju destroy-model flask-async-app --destroy-storage --no-prompt --force
# [docs:clean-environment-end]
65 changes: 65 additions & 0 deletions docs/howto/flask-async.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
.. _write-a-kubernetes-charm-for-an-async-flask-app:

How to write a Kubernetes charm for an async Flask app
======================================================

In this how-to guide you will configure a 12-factor Flask
application to use asynchronous Gunicorn workers to be
able to serve to multiple users easily.

Make the rock async
===================

To make the rock async, make sure to put the following in its ``requirements.txt``
file:

.. literalinclude:: code/flask-async/requirements.txt

Pack the rock using ``rockcraft pack`` and redeploy the charm with the new rock using
[``juju refresh``](https://juju.is/docs/juju/juju-refresh).

Configure the async application
-------------------------------

Now let's enable async Gunicorn workers. We will
expect this configuration option to be available in the Flask app configuration
under the ``webserver-worker-class`` key. Verify that the new configuration
has been added by running:

.. code:: bash
juju config flask-async-app | grep -A 6 webserver-worker-class:
The result should contain the key.

The worker class can be changed using Juju:

.. literalinclude:: code/flask-async/task.yaml
:language: bash
:start-after: [docs:config-async]
:end-before: [docs:config-async-end]
:dedent: 2

Test that the workers are operating in parallel by sending multiple
simultaneous requests with curl:

.. code:: bash
curl --parallel --parallel-immediate --resolve flask-async-app:80:127.0.0.1 \
http://flask-async-app/io http://flask-async-app/io http://flask-async-app/io \
http://flask-async-app/io http://flask-async-app/io
and they will all return at the same time.

The results should arrive simultaneously and contain five instances of ``ok``:

.. terminal::

ok
ok
ok
ok
ok

It can take up to a minute for the configuration to take effect. When the
configuration changes, the charm will re-enter the active state.
1 change: 1 addition & 0 deletions docs/howto/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ How-To
charm-to-poetry
charm-to-python
shared-cache
flask-async
8 changes: 7 additions & 1 deletion spread.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ backends:
system=$(echo "${SPREAD_SYSTEM}" | tr . -)
instance_name="spread-${SPREAD_BACKEND}-${instance_num}-${system}"
multipass launch --cpus 4 --disk 40G --memory 4G --name "${instance_name}" "${multipass_image}"
multipass launch --cpus 4 --disk 40G --memory 8G --name "${instance_name}" "${multipass_image}"
# Enable PasswordAuthentication for root over SSH.
multipass exec "$instance_name" -- \
Expand Down Expand Up @@ -82,6 +82,8 @@ backends:
workers: 1
- ubuntu-22.04-64:
workers: 4
- ubuntu-24.04-64:
workers: 4
prepare: |
set -e
Expand Down Expand Up @@ -129,6 +131,10 @@ prepare: |
install_charmcraft
suites:
docs/howto/code/:
summary: tests howto from the docs
systems:
- ubuntu-24.04-64
docs/tutorial/code/:
summary: tests tutorial from the docs
systems:
Expand Down

0 comments on commit 3128c62

Please sign in to comment.