==============================
Hosting WebSocket Applications
==============================
mod_wsgi cannot serve a WebSocket endpoint. The WSGI specification
is a request/response model with a synchronous ``start_response``
plus iterable body, and has no way to negotiate an ``Upgrade``
handshake or carry bidirectional frames after one. This is a
limitation of WSGI, not of mod_wsgi specifically; ASGI and the
non-WSGI Python frameworks (Starlette, FastAPI, aiohttp,
hypercorn-hosted Django Channels, and so on) exist precisely to
fill this gap.
What mod_wsgi can do is run a separate WebSocket-capable
application as a *sidecar* process while continuing to serve the
main WSGI application normally, and use Apache's ``mod_proxy`` to
route WebSocket-bearing URLs to that sidecar. This page walks
through the deployment shape, the mod_wsgi-specific way of
hosting the sidecar via a service script, the proxy wiring, and
the capacity implications. The worked example at the end is a
small live Apache server-metrics dashboard, which fits naturally
because reading mod_wsgi's scoreboard requires the sidecar to run
inside mod_wsgi.
The deployment shape
--------------------
Two long-lived processes share one Apache instance:
::
+--------+ +-----------------------------------------+
| client | ---> | Apache + mod_wsgi (front-end) |
+--------+ | :443 / * --> WSGI app |
| /ws/* --> proxy to: |
| |
| sidecar: aiohttp on 127.0.0.1:8765 |
+-----------------------------------------+
* The WSGI application stays exactly as it is, served by mod_wsgi
in daemon (or embedded) mode.
* A separate WebSocket-capable Python process runs alongside it
on a private listener (loopback TCP or a unix-domain socket).
* Apache routes WebSocket-bearing URLs to the sidecar via
``mod_proxy`` and ``mod_proxy_http`` (with the ``upgrade=
websocket`` parameter on ``ProxyPass``); everything else
continues to be handled by the WSGI application.
The two processes are independent at the Python level. They do
not share Python objects, in-memory state, or imported modules.
If the WebSocket side needs to react to events in the WSGI side
(or vice versa), the integration goes through some out-of-process
medium: a Redis pub/sub, a database row, a message queue, or a
local socket. Designing that integration is outside the scope of
this page; what follows is the deployment plumbing.
Hosting the sidecar via a service script
----------------------------------------
mod_wsgi can run the sidecar process itself rather than leaving
it to systemd, supervisor, or a separate process manager. The
mechanism is the **service script**: a Python script imported
into a daemon process group dedicated to running it forever, with
``threads=0`` so the daemon does not participate in normal request
handling.
For a manually-configured Apache, two directives do the work:
* :doc:`../configuration-directives/WSGIDaemonProcess` declares
the daemon process group with ``threads=0`` (no request
handling) and any options the script needs (``server-metrics=
on`` for the example below).
* :doc:`../configuration-directives/WSGIImportScript` imports
the script into that process group at start-up. The script's
top-level code runs as the daemon process body; if it never
returns (because it enters an event loop, for example), the
daemon process simply runs that loop until Apache shuts it
down.
::
WSGIDaemonProcess metrics-sidecar \
threads=0 server-metrics=on
WSGIImportScript /etc/mod_wsgi/server_metrics_sidecar.py \
process-group=metrics-sidecar \
application-group=%{GLOBAL}
For ``mod_wsgi-express``, the equivalent is the
``--service-script`` option, which translates into the directive
pair above::
mod_wsgi-express start-server wsgi.py \
--service-script metrics-sidecar \
/etc/mod_wsgi/server_metrics_sidecar.py
The first argument names the daemon process group; the second is
the path to the script. The script can use any framework that
runs in a single process; aiohttp, Starlette with uvicorn started
programmatically, or a hand-rolled ``asyncio`` server are all
fine. The script is imported once; if its top-level code starts
an event loop with ``asyncio.run(...)``, that call blocks for
the lifetime of the process and Apache treats the still-running
import as a healthy daemon.
A service-script daemon process sits outside the normal daemon
recycling triggers: ``maximum-requests`` does not apply (the
process handles no requests), and the per-request inactivity
timers do not either. The only restart triggers are an Apache
restart and an external signal. When the script does start
afresh, it is re-imported in the new process; the sidecar must
not keep state in process memory that the WSGI side relies on
persisting across restarts.
Why the service script, rather than an external process
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A WebSocket sidecar can equally well be started by systemd or
any other process supervisor. The reasons to prefer a service
script are pragmatic:
* The sidecar's lifecycle is tied to Apache's. Restarting
Apache restarts the sidecar; stopping Apache stops the
sidecar. There is no second supervisor to keep in sync.
* The sidecar runs as the same user, with the same Python
environment, working directory, and ``python-path`` as the
WSGI application. There is no second virtual environment or
service unit to maintain.
* The sidecar can call ``import mod_wsgi`` and use the
in-process API the running Apache exposes. Reading
``mod_wsgi.server_metrics()`` (the example below) is a
primary case; only a process running inside mod_wsgi has
access to the Apache scoreboard.
The third point is the one that promotes the service script
from convenient to required: an external sidecar cannot use the
mod_wsgi Python API to read the scoreboard, so an Apache
metrics dashboard genuinely needs the in-process route.
Listener: loopback TCP or unix socket
-------------------------------------
The sidecar must listen somewhere the front-end Apache can reach
it but external clients cannot. Two choices.
Loopback TCP (``127.0.0.1:NNNN``) is the simpler one. Most
async frameworks accept a host/port out of the box. Pick a port
that is not in use and bind to ``127.0.0.1`` (not ``0.0.0.0``)
so the sidecar is not exposed on the network. The proxy URL is
then ``http://127.0.0.1:NNNN/``.
A unix-domain socket avoids port allocation and is reachable
only via the filesystem path, so its access is naturally scoped
by directory permissions. Most async frameworks support binding
to a unix socket through a ``--uds`` or ``path=`` argument
(uvicorn, hypercorn, aiohttp's ``UnixSite``). The proxy URL
takes Apache's unix-socket form ``unix:/var/run/sidecar.sock|http://localhost/``; the host name after ``|`` is a syntactic
placeholder, not used for routing. See
:doc:`running-behind-a-reverse-proxy` for the URL form details.
Either choice works equally well with the proxy options
described below. The example in this page uses loopback TCP for
clarity; the unix-socket form is a one-line substitution on the
``--proxy-mount-point`` URL.
Wiring the Apache proxy
-----------------------
The proxy mechanics, including the headers Apache adds, the
trust list mod_wsgi consults on the receiving side, and how
``X-Forwarded-Prefix`` is handled, are covered in detail in
:doc:`running-behind-a-reverse-proxy`. The summary for this
page:
* ``--proxy-mount-point /ws/ http://127.0.0.1:8765/`` mounts the
sidecar under a sub-URL of the main site. ``mod_wsgi-express``
emits a ``ProxyPass`` with ``upgrade=websocket``, a
``ProxyPassReverse`` for back-end-emitted redirects, and
``RequestHeader set X-Forwarded-Prefix /ws`` so the sidecar
can construct correct URLs in any HTML, JSON, or WebSocket
greeting it serves.
* ``--proxy-virtual-host ws.example.com http://127.0.0.1:8765/``
proxies an entire hostname to the sidecar. No prefix is
stripped, so the sidecar sees the same URL space as the
client and ``X-Forwarded-Prefix`` is not relevant.
The equivalent raw directive form for the sub-URL case::
ProxyPass /ws/ http://127.0.0.1:8765/ upgrade=websocket
ProxyPassReverse /ws/ http://127.0.0.1:8765/
connecting...""" async def index(request: web.Request) -> web.Response: # X-Forwarded-Prefix is set by mod_wsgi-express's # --proxy-mount-point, or by the equivalent RequestHeader # in raw Apache config. Inject it so the JS builds the # WebSocket URL with the right prefix when the sidecar is # mounted under a sub-URL. base = request.headers.get("X-Forwarded-Prefix", "").rstrip("/") inject = f'\n' return web.Response( text=INDEX_HTML.replace("", inject + "", 1), content_type="text/html", ) async def ws_handler(request: web.Request) -> web.WebSocketResponse: ws = web.WebSocketResponse(heartbeat=30) await ws.prepare(request) request.app["clients"].add(ws) try: async for msg in ws: if msg.type == WSMsgType.ERROR: break finally: request.app["clients"].discard(ws) return ws async def metrics_loop(app: web.Application) -> None: while True: snapshot = mod_wsgi.server_metrics() if snapshot is None: payload = json.dumps({ "error": ( "scoreboard access is not enabled for this daemon " "process group; set server-metrics=on on its " "WSGIDaemonProcess directive (or pass " "--server-metrics to mod_wsgi-express)" ), }, indent=2) else: payload = json.dumps({"snapshot": snapshot}, indent=2) for ws in list(app["clients"]): try: await ws.send_str(payload) except ConnectionResetError: pass await asyncio.sleep(POLL_INTERVAL) async def serve() -> None: logging.basicConfig( level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s", ) app = web.Application() app["clients"] = set() app.router.add_get("/", index) app.router.add_get("/ws", ws_handler) async def start_loop(_: web.Application) -> None: app["loop_task"] = asyncio.create_task(metrics_loop(app)) app.on_startup.append(start_loop) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "127.0.0.1", 8765) await site.start() log.info("server-metrics sidecar on 127.0.0.1:8765") await asyncio.Event().wait() asyncio.run(serve()) Three things are worth pointing out about the script. The top-level call to ``asyncio.run(serve())`` is what makes this a service script rather than an ordinary Python module. It never returns: ``serve()`` brings up the aiohttp app and then awaits an event that is never set, so the import call never completes. ``WSGIImportScript`` is happy with this; the daemon process just stays in that import for its lifetime. ``mod_wsgi.server_metrics()`` returns ``None`` unless the daemon process group running the script was configured with ``server-metrics=on``. The script runs in a daemon process, so that per-group option is the only flag that matters; the server-wide :doc:`../configuration-directives/WSGIServerMetrics` directive only applies in embedded mode and does not propagate to daemon groups. The script handles the ``None`` case explicitly by broadcasting an error frame so the dashboard tells the user which option they forgot rather than silently showing stale data. The directive page also covers the information-disclosure implications of opening the API up. The ``X-Forwarded-Prefix`` handling in ``index`` is what makes the dashboard work both directly (``http://127.0.0.1:8765/``, no header, no prefix) and behind the proxy at a sub-URL (``https://www.example.com/metrics/``, header set by the proxy, JS builds ``wss://.../metrics/ws``). The sidecar does not need a startup-time ``--root-path`` analogue; the header tells it per-request. Wiring it up: ``mod_wsgi-express`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :: mod_wsgi-express start-server wsgi.py \ --server-metrics \ --service-script metrics-sidecar \ /etc/mod_wsgi/server_metrics_sidecar.py \ --proxy-mount-point /metrics/ http://127.0.0.1:8765/ What each option contributes: * ``--server-metrics`` sets ``server-metrics=on`` on every daemon process group ``mod_wsgi-express`` creates, including the one ``--service-script`` adds. That per-group option is what actually gates the sidecar's ``mod_wsgi.server_metrics()`` calls; the same flag also emits ``WSGIServerMetrics On`` at server scope, which is what would gate the same call from any embedded-mode handler in the generated configuration. * ``--service-script`` declares the daemon process group (``service:metrics-sidecar``) with ``threads=0`` and starts the script in it. * ``--proxy-mount-point`` emits the ``ProxyPass`` / ``ProxyPassReverse`` pair with ``upgrade=websocket``, the ``X-Forwarded-Prefix`` header, and the bare-``/metrics`` redirect, all as a unit. Wiring it up: manually-configured Apache ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The same configuration as a set of directives, suitable for inclusion in a ``