Granian: Better Python web app hosting
This is part of the Poddla series of posts.
Until recently I’ve been hosting my Python web apps using Gunicorn or Uvicorn (HTTP/1.1) behind a web server (HTTP/2). The web server Docker image has contained the static files (CSS, JavaScript, image files) that the app needs. This has been an easy way to avoid having Python do the serving of static files (slow, not recommended) while also avoiding more complex CDN-based workflows.
The problems:
- HTTP/1.1
- Awkward static file deploys
1. Why HTTP/1.1 is bad and how to do HTTP/2 end-to-end
Web servers have long supported HTTP/2, and with long-lived SSE requests becoming more common (at least in my world) the restrictions with HTTP/1.1 seem very silly. A browser can have at most 6 concurrent HTTP/1.1 connections per server, while HTTP/2 has no such restriction. A single HTTP/2 connection can serve multiple requests, which is another benefit over 1.1. There are probably a lot of other benefits with 2 that I don’t remember or know about.
Having a web server like Traefik, Nginx or Caddy that supports HTTP/2 in front of your Python app server that only speaks HTTP/1.1 has seemed like a decent enough solution to that: HTTP/2 for the browsers and HTTP/1.1 is hidden within the server deployment. The problem with that, however, is that HTTP/1.1 behind HTTP/2 is a security problem.
But you may ask, doesn’t HTTP/2 require HTTPS? Do we need TLS certificates and all that pain in the
app servers as well? No! HTTP/2 requiring TLS is only a web browser thing. Servers are free to speak
unencrypted HTTP/2 with each other. It’s called h2c. We can continue doing TLS only on the web
server.
Months ago when I started looking into this I found Granian, a Python ASGI/WSGI/RSGI app server, an alternative to Gunicorn, etc., but with support for HTTP/2 among other features. It’s even possible to turn off HTTP/1.1 support, which seems like a sensible thing to do, to avoid accidentally deploying 1.1 behind 2.
2. Simpler static file hosting
As I described earlier, I had been deploying static files non-atomically: static files embedded within the web server container, and the app server in another container. “Non-atomically” because when deploying a new version of the app there was a (hopefully short) period of time when the containers were out of sync. Users were served buggy or broken apps if they happened to arrive in the middle of a deploy. In practice this wasn’t as bad as it may seem: deploys are usually quick, can be planned to occur at low-traffic times. There were more important things to worry about. Maybe it would have been possible to orchestrate it better. Maybe a CDN-based workflow would have been worth it.
A better solution: Granian again! Granian has support for serving static files. It’s built with Rust and is highly performant. Suddenly there’s nothing bad about having the app server serve the static files. The app is all contained in one image, with one restart.
Relatedly, if you use ASGI with Granian, your app can use an ASGI extension called pathsend to
have Granian serve a file. That sounds quite familiar if you’ve previously had your app server
instruct your web server to serve a file using X-Accel-Redirect with Nginx or intercept with
Caddy. Note, however, that ASGI’s pathsend doesn’t support RANGE requests or any content-type
smarts by itself, so such headers need to be provided by the app.
Note
I only recently started using Granian. I don’t have months or years of experience with it. I know there are alternatives, but this was the one that appealed to me when I did my initial research into the topic.
Examples
I arrived at these things while working on this project, the relevant files being:
deploy/docker-compose.yml
deploy/Caddyfile
devenv.nix
dev/Caddyfile
nix/docker.nix
If you have async views, and esp. if they are long-running requests, like SSE, it’s worth running a proper ASGI server while developing as well. While developing I run Granian something like this:
uv run granian \
--http 2 \
--host 127.0.0.1 \
--port 8000 \
--interface asgi \
--workers 1 \
--workers-kill-timeout 1 \
--reload \
--reload-paths ./src/ \
your_python_app.asgi:application
With a Caddy in front configured something like this:
https://localhost {
reverse_proxy h2c://your_app:8080
}
Note the h2c:// part.
And when deployed:
(with environment variables to show that alternative, plus it made copy-pasting from my Docker setup easier.)
GRANIAN_STATIC_PATH_MOUNT=/static
GRANIAN_STATIC_PATH_ROUTE=/static
GRANIAN_HOST=0.0.0.0
GRANIAN_PORT=8080
GRANIAN_HTTP=2
granian \
--interface asgi \
--workers 1 \
--workers-kill-timeout 1 \
--no-reload \
your_python_app.asgi:application