Aug 26 - Add gunicorn.conf.py
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled

This commit is contained in:
2026-08-26 10:58:55 -04:00
parent 0304095e53
commit 82dd7c5aef
2 changed files with 169 additions and 13 deletions
+148
View File
@@ -0,0 +1,148 @@
"""
gunicorn.conf.py — Gunicorn runtime configuration for PassKeeper.
Loaded explicitly by the systemd unit:
ExecStart=/home/spuser/.venv/bin/gunicorn -c /home/spuser/PassKeeper/gunicorn.conf.py wsgi:app
Every value can be overridden from the environment (systemd reads
EnvironmentFile=/home/spuser/PassKeeper/.env), so tuning a production box does
not require editing this file or the unit.
── Timeout ordering (this is what causes 502s) ───────────────────────────────
Nginx must give up BEFORE Gunicorn kills the worker:
nginx proxy_read_timeout < gunicorn timeout
If Gunicorn kills first, the connection is severed mid-response and Nginx
reports 502 Bad Gateway. If Nginx gives up first, the client gets a clean
504 Gateway Timeout instead. `timeout` below is therefore set comfortably
above the proxy_read_timeout in scripts/passkeeper-nginx.conf — raise this
value first if you ever raise that one.
"""
import multiprocessing
import os
def _env_int(name: str, default: int) -> int:
"""Read an int from the environment, falling back on unset/garbage values."""
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
# ── Socket ───────────────────────────────────────────────────────────────────
# Loopback only — Nginx is the sole public entry point and terminates TLS.
bind = os.environ.get('GUNICORN_BIND', '127.0.0.1:5000')
backlog = 2048
# ── Worker processes ─────────────────────────────────────────────────────────
# gthread rather than the default sync worker.
#
# Login is the expensive path: Argon2id at ARGON2_MEMORY_COST=65536 (64 MB) with
# parallelism=4 blocks a sync worker completely for the duration of the hash, and
# a sync worker serves exactly one request at a time. A handful of concurrent
# logins was enough to saturate all workers, queue everything behind them, and
# push requests past the worker timeout — which surfaces as 502.
#
# Threads let a worker keep serving while another request is blocked, and make
# the `keepalive` setting below meaningful (sync workers ignore keep-alive
# entirely, so Nginx's `proxy_http_version 1.1` was a no-op against them).
worker_class = 'gthread'
# Memory, not CPU, is the binding constraint: each in-flight Argon2id hash claims
# 64 MB. Keep workers modest and add concurrency with threads instead.
workers = _env_int('GUNICORN_WORKERS', min(4, multiprocessing.cpu_count() + 1))
threads = _env_int('GUNICORN_THREADS', 4)
# ── Timeouts ─────────────────────────────────────────────────────────────────
# Must exceed nginx proxy_read_timeout — see the module docstring.
# Long enough to cover the genuinely slow flows: bulk vault import/export and
# the atomic re-encryption of every item during a password change or recovery.
timeout = _env_int('GUNICORN_TIMEOUT', 90)
# Let in-flight requests finish on reload/restart rather than cutting them off.
graceful_timeout = _env_int('GUNICORN_GRACEFUL_TIMEOUT', 30)
# Idle keep-alive window for connections from Nginx. Slightly above Nginx's
# keepalive_timeout (15s) so Gunicorn is never the side that closes first —
# a connection closed underneath Nginx as it reuses it is a classic 502.
keepalive = _env_int('GUNICORN_KEEPALIVE', 20)
# ── Worker recycling ─────────────────────────────────────────────────────────
# Recycle workers periodically to bound the impact of any slow memory growth.
# The jitter staggers restarts so workers never all recycle at once (which would
# briefly leave nothing to serve — another source of intermittent 502s).
max_requests = _env_int('GUNICORN_MAX_REQUESTS', 1000)
max_requests_jitter = _env_int('GUNICORN_MAX_REQUESTS_JITTER', 100)
# Heartbeat file location. The default (/tmp) is disk-backed on many systems,
# and a slow disk makes the arbiter believe healthy workers have died and kill
# them. /dev/shm is always tmpfs. The unit sets PrivateTmp=true, which does not
# cover /dev/shm, so this stays writable.
if os.path.isdir('/dev/shm'):
worker_tmp_dir = '/dev/shm'
# ── Application loading ──────────────────────────────────────────────────────
# preload_app MUST stay False.
#
# create_app() starts an APScheduler BackgroundScheduler for the hourly cleanup
# of token_blacklist / recovery_challenges / totp_used_codes / expired shares.
# With preload_app=True the app is built once in the arbiter and then forked —
# and threads do not survive fork(), so the scheduler thread would exist only in
# the arbiter, which serves no requests. The cleanup job would silently never
# run and those tables would grow without bound.
#
# Note: Deloy.md's "test Gunicorn manually" step suggests --preload. That is fine
# for a one-off smoke test, but must never reach the service definition.
#
# The cost of preload_app=False is one scheduler per worker, so the cleanup runs
# `workers` times an hour instead of once. The job only DELETEs already-expired
# rows, so it is idempotent and the redundancy is harmless.
preload_app = False
# ── Proxy ────────────────────────────────────────────────────────────────────
# Only trust X-Forwarded-* from the local Nginx. ProxyFix(x_for=1) in the app
# factory does the actual header parsing; this stops Gunicorn honouring
# forwarded headers from anything else.
forwarded_allow_ips = os.environ.get('GUNICORN_FORWARDED_ALLOW_IPS', '127.0.0.1')
# ── Request limits ───────────────────────────────────────────────────────────
# Defence in depth behind Nginx's client_max_body_size 1m.
limit_request_line = 8190
limit_request_fields = 100
limit_request_field_size = 8190
# ── Logging ──────────────────────────────────────────────────────────────────
# Paths must be inside the unit's ReadWritePaths= or Gunicorn cannot start.
accesslog = os.environ.get('GUNICORN_ACCESS_LOG', '/home/spuser/logs/access.log')
errorlog = os.environ.get('GUNICORN_ERROR_LOG', '/home/spuser/logs/error.log')
loglevel = os.environ.get('GUNICORN_LOG_LEVEL', 'warning')
# %({X-Forwarded-For}i)s rather than %(h)s — %(h)s would log Nginx's loopback
# address for every request. Never log Authorization headers or request bodies:
# they carry bearer tokens and auth_hash values.
access_log_format = (
'%({X-Forwarded-For}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)sus'
)
proc_name = 'passkeeper'
# ── Hooks ────────────────────────────────────────────────────────────────────
def on_starting(server):
server.log.info(
'[PassKeeper] starting: %s worker(s) x %s thread(s), class=%s, timeout=%ss',
workers, threads, worker_class, timeout,
)
def worker_abort(worker):
"""Fires when a worker is killed for exceeding `timeout`."""
worker.log.error(
'[PassKeeper] worker %s aborted after %ss — a request exceeded the '
'timeout. Nginx will have reported 502 to the client.',
worker.pid, timeout,
)
+21 -13
View File
@@ -9,8 +9,8 @@
# #
# Phase 5 additions vs original: # Phase 5 additions vs original:
# - Restart=on-failure: systemd restarts Gunicorn if the master exits non-zero # - Restart=on-failure: systemd restarts Gunicorn if the master exits non-zero
# - Gunicorn --timeout: workers that don't respond within 25 s are replaced # - Gunicorn settings live in gunicorn.conf.py (gthread workers, timeout
# - Gunicorn --graceful-timeout: allows in-flight requests to finish on reload # ordered above nginx proxy_read_timeout, /dev/shm heartbeat dir)
# - PrivateTmp, NoNewPrivileges, ProtectSystem: basic systemd sandboxing # - PrivateTmp, NoNewPrivileges, ProtectSystem: basic systemd sandboxing
# - StartLimitIntervalSec / StartLimitBurst: caps restart storm # - StartLimitIntervalSec / StartLimitBurst: caps restart storm
@@ -29,19 +29,27 @@ Group=www-data
WorkingDirectory=/home/spuser/PassKeeper WorkingDirectory=/home/spuser/PassKeeper
EnvironmentFile=/home/spuser/PassKeeper/.env EnvironmentFile=/home/spuser/PassKeeper/.env
# All tuning lives in gunicorn.conf.py (worker class, counts, timeouts,
# logging) so it is versioned with the code and documented in one place.
# Override any of it with GUNICORN_* variables in the EnvironmentFile above
# rather than editing this line.
# Absolute path — do not rely on WorkingDirectory for config lookup.
ExecStart=/home/spuser/.venv/bin/gunicorn \ ExecStart=/home/spuser/.venv/bin/gunicorn \
--workers 4 \ -c /home/spuser/PassKeeper/gunicorn.conf.py \
--bind 127.0.0.1:5000 \
--timeout 25 \
--graceful-timeout 20 \
--keep-alive 5 \
--access-logfile /home/spuser/logs/access.log \
--error-logfile /home/spuser/logs/error.log \
--log-level warning \
wsgi:app wsgi:app
# Reload (zero-downtime): send USR2 to Gunicorn master # Reload: HUP re-reads config and restarts workers in place under the
ExecReload=/bin/kill -s USR2 $MAINPID # existing master.
#
# NOT USR2. USR2 forks a *second* master that inherits the listening socket,
# and retiring the old one needs a follow-up WINCH + QUIT that this unit never
# sent. The result was two masters competing for the port with systemd $MAINPID
# tracking the stale one — a later stop/restart then signalled the wrong
# process and the socket vanished, which Nginx reports as 502.
#
# HUP does not pick up changed Python source: use `systemctl restart` for code
# deploys, reload only for config-only changes.
ExecReload=/bin/kill -s HUP $MAINPID
# NO WatchdogSec here — deliberately. # NO WatchdogSec here — deliberately.
# #
@@ -52,7 +60,7 @@ ExecReload=/bin/kill -s USR2 $MAINPID
# SIGKILLed it every ~30 s. Restart=on-failure then brought it back after RestartSec, # SIGKILLed it every ~30 s. Restart=on-failure then brought it back after RestartSec,
# producing a repeating window of 502s from Nginx. # producing a repeating window of 502s from Nginx.
# #
# Hung *workers* are already handled by Gunicorn's own --timeout above; a crashed # Hung *workers* are already handled by Gunicorn's own `timeout` in gunicorn.conf.py; a crashed
# *master* is already handled by Restart=on-failure below. The watchdog added no # *master* is already handled by Restart=on-failure below. The watchdog added no
# coverage, only outages. # coverage, only outages.
# #