diff --git a/charmcraft/extensions/app.py b/charmcraft/extensions/app.py index f7a6eb46d..7e9c7a681 100644 --- a/charmcraft/extensions/app.py +++ b/charmcraft/extensions/app.py @@ -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'.", + }, } diff --git a/docs/howto/code/flask-async/app.py b/docs/howto/code/flask-async/app.py new file mode 100644 index 000000000..2f1b61c7d --- /dev/null +++ b/docs/howto/code/flask-async/app.py @@ -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() diff --git a/docs/howto/code/flask-async/requirements.txt b/docs/howto/code/flask-async/requirements.txt new file mode 100644 index 000000000..ea9f5b21c --- /dev/null +++ b/docs/howto/code/flask-async/requirements.txt @@ -0,0 +1,2 @@ +Flask +gevent diff --git a/docs/howto/code/flask-async/task.yaml b/docs/howto/code/flask-async/task.yaml new file mode 100644 index 000000000..c4088fa0c --- /dev/null +++ b/docs/howto/code/flask-async/task.yaml @@ -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] diff --git a/docs/howto/flask-async.rst b/docs/howto/flask-async.rst new file mode 100644 index 000000000..eefc5408c --- /dev/null +++ b/docs/howto/flask-async.rst @@ -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. diff --git a/docs/howto/index.rst b/docs/howto/index.rst index 9bf000898..fc641d4a8 100644 --- a/docs/howto/index.rst +++ b/docs/howto/index.rst @@ -9,3 +9,4 @@ How-To charm-to-poetry charm-to-python shared-cache + flask-async diff --git a/spread.yaml b/spread.yaml index 2f53cace3..834d7ffb2 100644 --- a/spread.yaml +++ b/spread.yaml @@ -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" -- \ @@ -82,6 +82,8 @@ backends: workers: 1 - ubuntu-22.04-64: workers: 4 + - ubuntu-24.04-64: + workers: 4 prepare: | set -e @@ -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: