Serve gunicorn with threaded workers so a retry storm cannot starve the UI
Some checks failed
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Failing after 4m55s

Taken from the other site, who hit it in production today (their commit
db75ca7): agents that fail the TLS handshake retry in a loop, and with four
blocking sync workers a handful of them pinned every worker. Real UI requests
queued for 20+ seconds while the CPU sat idle - the box was not busy, it was
blocked.

gthread, 4 workers x 8 threads, and a 30-second timeout rather than 60. A stuck
connection now holds one thread out of 32 instead of one worker out of four, and
it lets go in half the time, which is the difference between absorbing a retry
storm and queueing behind it.

Their server, our agent. The client behaviour that caused it is the endpoint
agent, so it will reach any site running one - which makes this a product fix
rather than something that belongs on one site's branch.

Applied to the Dockerfile as well as docker-compose.yml. A compose file
overrides the image CMD, but docker-compose.airgap.yml, .channel.yml and
.pull.yml do not specify a command at all, so they take the image default - and
so does anybody running the image by hand. Fixing only compose would have left
every air-gapped install on sync workers.

WJ production is unaffected either way: it runs waitress under IIS, already with
--threads=8, so it was never exposed to this. That is worth knowing before
anyone treats it as a prod hardening item that was missing.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 17:35:48 -04:00
parent b9f4aea4b8
commit b5737f47c1
2 changed files with 24 additions and 2 deletions

View File

@@ -139,10 +139,16 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
-H "Content-Type: application/json" -d '{}' \ -H "Content-Type: application/json" -d '{}' \
| grep -q "VALIDATION_ERROR" || exit 1 | grep -q "VALIDATION_ERROR" || exit 1
# Threaded workers, matching docker-compose.yml - see the reasoning there. A
# compose file overrides this CMD, but an image run without one (a plain
# `docker run`, a site's own orchestrator) would otherwise still get the
# blocking sync default and the same starvation.
CMD ["gunicorn", \ CMD ["gunicorn", \
"--bind", "0.0.0.0:5001", \ "--bind", "0.0.0.0:5001", \
"--worker-class", "gthread", \
"--workers", "4", \ "--workers", "4", \
"--timeout", "60", \ "--threads", "8", \
"--timeout", "30", \
"--access-logfile", "-", \ "--access-logfile", "-", \
"--error-logfile", "-", \ "--error-logfile", "-", \
"wsgi:app"] "wsgi:app"]

View File

@@ -79,10 +79,26 @@ services:
- gunicorn - gunicorn
- --bind - --bind
- 0.0.0.0:5001 - 0.0.0.0:5001
# Threaded workers, not the blocking sync default. An agent that fails
# the TLS handshake retries in a loop, and with 4 sync workers a handful
# of those pinned every worker: real UI requests queued 20+ seconds while
# the CPU sat idle. A stuck connection now holds one THREAD, and 32
# (4 x 8) have to be exhausted before a person waits.
#
# Found in production at the other site (their commit db75ca7). Their
# server, our agent - so the same client behaviour will reach any site
# running the endpoint agent, and the fix belongs in the product rather
# than on one branch.
- --worker-class
- gthread
- --workers - --workers
- "4" - "4"
- --threads
- "8"
# 30, not 60: a hung connection frees its thread in half the time, which
# is what decides whether a retry storm is absorbed or queues.
- --timeout - --timeout
- "60" - "30"
- --access-logfile - --access-logfile
- "-" - "-"
- --error-logfile - --error-logfile