diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f6d96..12f406a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release of `tna-python-dev` Docker image +- Added `uvicorn` support for async applications such as FastAPI ### Changed - Update Poetry to [1.7.1](https://github.com/python-poetry/poetry/releases/tag/1.7.1) - Updated Base Docker image from `python:3.11-slim` to `python:3.12-slim-bookworm` +- Install `libcurl4` version `7.88.1-10+deb12u4` ### Deprecated ### Removed diff --git a/docker/tna-python/Dockerfile b/docker/tna-python/Dockerfile index 6cdbe6c..e52b5b0 100644 --- a/docker/tna-python/Dockerfile +++ b/docker/tna-python/Dockerfile @@ -108,7 +108,7 @@ RUN set -eux; \ \ apt-get update; \ apt-get -y upgrade; \ - apt-get install -y --no-install-recommends curl=7.88.1-10+deb12u4 build-essential=12.9 libmagic-dev=1:5.44-3; \ + apt-get install -y --no-install-recommends libcurl4=7.88.1-10+deb12u4 curl=7.88.1-10+deb12u4 build-essential=12.9 libmagic-dev=1:5.44-3; \ \ apt-get clean; \ apt-get autoremove -y --purge; \ diff --git a/docker/tna-python/README.md b/docker/tna-python/README.md index f3f710e..3637a75 100644 --- a/docker/tna-python/README.md +++ b/docker/tna-python/README.md @@ -5,7 +5,8 @@ This image comes with: - A version of Python approved by TNA - [Poetry](https://python-poetry.org/) for dependency management - [nvm](https://github.com/nvm-sh/nvm) for compiling assets such as CSS and JavaScript -- [Gunicorn](https://gunicorn.org/) for serving the Python application +- [Gunicorn](https://gunicorn.org/) for serving the Python application via a WSGI +- [Uvicorn](https://www.uvicorn.org/) for serving the Python application via a ASGI This image requires you have the following files in the root of your project: @@ -35,12 +36,16 @@ This image requires you have the following files in the root of your project: [^6]: [Gunicorn docs - timeout](https://docs.gunicorn.org/en/stable/settings.html#timeout) [^7]: [Gunicorn docs - keepalive](https://docs.gunicorn.org/en/stable/settings.html#keepalive) +### Secret key + A secret key (for `SECRET_KEY`) can be generated using: ```sh python -c 'import secrets; print(secrets.token_hex())' ``` +Alternatively, using the [`tna-dev` image](https://github.com/nationalarchives/docker/tree/main/docker/tna-python-dev), you can run `secret-key` to generate one. + ## Commands for the Dockerfile There are two commands to use within your `Dockerfile`: @@ -76,10 +81,22 @@ tna-run my_app:app 1. Count the number of CPU cores, multiply it by 2 and add 1 to get a suggested worker and thread count 1. Start `gunicorn` with values appropriate to the environment taking into account any overrides +#### Asynchronous support + +For frameworks that require or can use an ASGI rather than a WSGI you can use `tna-run` with a `-a` flag: + +```sh +tna-run -a my_app:app +``` + +When working in a development environment (`ENVIRONMENT=production`) and using FastAPI, Uvicorn is used as the ASGI for `tna-run`. + +When using FastAPI in production, `tna-run -a` should be explicitly specified so Gunicron can use the Uvicorn worker class. + ## Using Node To use Node to build your assets you need three files in your project: - `package.json` - `package-lock.json` -- `.nvmrc` +- `.nvmrc` containing the version of Node you would like to support (e.g. `lts/iron`) diff --git a/docker/tna-python/bin/tna-build b/docker/tna-python/bin/tna-build index 971fbc3..a6c4411 100755 --- a/docker/tna-python/bin/tna-build +++ b/docker/tna-python/bin/tna-build @@ -20,4 +20,5 @@ then fi poetry add gunicorn@21.2.0 +poetry add uvicorn@0.25.0 poetry install --sync --no-root diff --git a/docker/tna-python/bin/tna-run b/docker/tna-python/bin/tna-run index 8bb88b5..7cfb331 100755 --- a/docker/tna-python/bin/tna-run +++ b/docker/tna-python/bin/tna-run @@ -2,7 +2,28 @@ set -e -if [ -z "$1" ] +# Set flag defaults +ASYNC=false + +# Parse flags +while getopts ":a" option; do + case "${option}" in + a) + ASYNC=true + shift 1 + ;; + ?) + echo "Invalid option: -${OPTARG}" + exit 1 + ;; + esac +done + +# Get the application +APPLICATION=$1 + +# Error if no application is passed +if [ -z "$APPLICATION" ] then echo -e "Error: application not specified in tna-run\n"; echo "PARAMETERS" @@ -11,32 +32,50 @@ then exit 1 fi +# Error if $SECRET_KEY is not defined if [[ -z $SECRET_KEY ]] then echo -e "Error: Environment variable SECRET_KEY not set"; exit 1 fi +# Convert $ENVIRONMENT to lowercase +ENVIRONMENT=$(echo "$ENVIRONMENT" | tr '[:upper:]' '[:lower:]') + +# If there is an $NPM_DEVELOP_COMMAND and we are running a development environment, run the development command now if [ "$ENVIRONMENT" == 'develop' ] && [ -n "$NPM_DEVELOP_COMMAND" ] then tna-node "$NPM_DEVELOP_COMMAND" fi -MAX_REQUESTS=$(python -c "import multiprocessing; print(multiprocessing.cpu_count() * 2 + 1)") -DEFAULT_WORKERS=$MAX_REQUESTS -DEFAULT_THREADS=1 +# Set up the default number of workers and threads +DEFAULT_WORKERS=$(python -c "import multiprocessing; print(multiprocessing.cpu_count() * 2 + 1)") +DEFAULT_THREADS=$((DEFAULT_WORKERS * 2)) + +# Set the worker class +[[ "$ASYNC" = true ]] && WORKER_CLASS=uvicorn.workers.UvicornWorker || WORKER_CLASS=sync + +# Use the number of workers and threads (if provided) else use defaults +[[ -z $WORKERS ]] && WORKERS=$DEFAULT_WORKERS [[ -z $THREADS ]] && THREADS=$DEFAULT_THREADS if [ "$ENVIRONMENT" == 'production' ] then + # Production environment [[ -z $LOG_LEVEL ]] && LOG_LEVEL=warn - [[ -z $WORKERS ]] && WORKERS=$DEFAULT_WORKERS [[ -z $TIMEOUT ]] && TIMEOUT=30 [[ -z $KEEP_ALIVE ]] && KEEP_ALIVE=30 - poetry run gunicorn "$1" --workers "$WORKERS" --threads "$THREADS" --log-level "$LOG_LEVEL" --timeout "$TIMEOUT" --keep-alive "$KEEP_ALIVE" --access-logfile - --bind 0.0.0.0:8080 elif [ "$ENVIRONMENT" == 'develop' ] then + # Development environment echo "ENVIRONMENT is develop" + [[ -z $LOG_LEVEL ]] && LOG_LEVEL=debug + [[ -z $WORKERS ]] && WORKERS=3 + [[ -z $THREADS ]] && THREADS=1 + [[ -z $TIMEOUT ]] && TIMEOUT=600 + [[ -z $KEEP_ALIVE ]] && KEEP_ALIVE=5 + + # Try using the Django development server if the application uses Django echo "Trying Django development server..." if poetry show django ; then @@ -44,6 +83,8 @@ then poetry run python /app/manage.py runserver 0.0.0.0:8080 fi echo "Django not found" + + # Try using the Flask development server if the application uses Flask echo "Trying Flask development server..." if poetry show flask ; then @@ -51,16 +92,26 @@ then poetry run flask run --debug --host 0.0.0.0 --port 8080 fi echo "Flask not found" + + # Use a uvicorn server if the application uses FastAPI + echo "Trying FastAPI server..." + if poetry show fastapi ; + then + echo "FastAPI found, starting server" + poetry run uvicorn "$APPLICATION" --workers "$WORKERS" --log-level "$LOG_LEVEL" --timeout-keep-alive "$KEEP_ALIVE" --host 0.0.0.0 --port 8080 --reload + fi + echo "FastAPI not found" + + # Fall back to using Gunicorn echo "No framework found, using Gunicorn to serve development application" - [[ -z $LOG_LEVEL ]] && LOG_LEVEL=debug - [[ -z $WORKERS ]] && WORKERS=3 - [[ -z $TIMEOUT ]] && TIMEOUT=600 - [[ -z $KEEP_ALIVE ]] && KEEP_ALIVE=5 - poetry run gunicorn "$1" --workers "$WORKERS" --threads "$THREADS" --log-level "$LOG_LEVEL" --timeout "$TIMEOUT" --keep-alive "$KEEP_ALIVE" --reload --bind 0.0.0.0:8080 + poetry run gunicorn "$APPLICATION" --workers "$WORKERS" --threads "$THREADS" --log-level "$LOG_LEVEL" --timeout "$TIMEOUT" --keep-alive "$KEEP_ALIVE" --bind 0.0.0.0:8080 --worker-class="$WORKER_CLASS" --reload else + # All other environments [[ -z $LOG_LEVEL ]] && LOG_LEVEL=info - [[ -z $WORKERS ]] && WORKERS=$DEFAULT_WORKERS [[ -z $TIMEOUT ]] && TIMEOUT=30 [[ -z $KEEP_ALIVE ]] && KEEP_ALIVE=5 - poetry run gunicorn "$1" --workers "$WORKERS" --threads "$THREADS" --log-level "$LOG_LEVEL" --timeout "$TIMEOUT" --keep-alive "$KEEP_ALIVE" --access-logfile - --bind 0.0.0.0:8080 fi + +# Start the server +echo "Starting $ENVIRONMENT server" +poetry run gunicorn "$APPLICATION" --workers "$WORKERS" --threads "$THREADS" --log-level "$LOG_LEVEL" --timeout "$TIMEOUT" --keep-alive "$KEEP_ALIVE" --access-logfile - --bind 0.0.0.0:8080 --worker-class="$WORKER_CLASS"