feat: run FastAPI under gunicorn with uvicorn ASGI workers

Production entrypoint switch:
  - pyproject.toml: add gunicorn>=23.0 dep
  - gunicorn.conf.py: env-var-driven config (bind, workers, threads,
    timeout, graceful_timeout, keepalive, log level, access-log format)
  - Dockerfile: CMD gunicorn main:app (auto-loads gunicorn.conf.py from
    WORKDIR /app)
  - docker-compose.yml: forward GUNICORN_WORKERS / GUNICORN_TIMEOUT /
    GUNICORN_BIND
  - .env.example: document the new tunables

Why gunicorn over standalone uvicorn for production:
  - Process supervision: master restarts crashed workers, restarts on
    memory leaks
  - Graceful shutdown: SIGTERM drains workers in-flight
  - Multi-worker: concurrent requests actually run in parallel
  - Standard ops: k8s readiness probes, log aggregators, etc. all know
    gunicorn

Why uvicorn workers (not sync workers): gunicorn can't natively serve
ASGI; uvicorn.workers.UvicornWorker is the canonical way to run an
ASGI app under gunicorn.

Defaults:
  - 2 workers (small MCP service; raise for high concurrency)
  - 1 thread per worker (no blocking I/O)
  - 120s timeout (yarn logs can be slow; uvicorn's 30s default is too
    tight)

Verified: gunicorn boots, lifespan runs (14 tools logged), MCP
initialize + tools/list + tools/call all work, /openapi.json = 200,
multiple gunicorn worker processes visible in ps.

uv sync picked up gunicorn 26.0.0. Tests still 116/116.
This commit is contained in:
Claude
2026-06-25 10:44:25 +08:00
parent dd197019f9
commit 6bcf3b9ac9
6 changed files with 551 additions and 457 deletions
+8
View File
@@ -13,3 +13,11 @@ YARN_RESOURCE_MANAGER_URL=
# Optional: JVM flags forwarded to spark-submit. Useful for proxies, custom
# truststores, or driver memory caps.
# SPARK_SUBMIT_OPTS=-Dhttps.proxyHost=proxy.corp -Dhttps.proxyPort=3128
# Gunicorn process model (see gunicorn.conf.py). Defaults shown.
# GUNICORN_WORKERS=2
# GUNICORN_THREADS=1
# GUNICORN_TIMEOUT=120 # generous; yarn logs can be slow
# GUNICORN_GRACEFUL_TIMEOUT=30
# GUNICORN_KEEPALIVE=5
# GUNICORN_BIND=0.0.0.0:8000
+11 -2
View File
@@ -55,11 +55,20 @@ RUN uv sync --index-url=https://pypi.tuna.tsinghua.edu.cn/simple/ --frozen --no-
COPY main.py ./
COPY spark_executor ./spark_executor
COPY common ./common
COPY gunicorn.conf.py ./
RUN uv sync --index-url=https://pypi.tuna.tsinghua.edu.cn/simple/ --frozen --no-dev
# Put the venv on PATH so `python` / `uvicorn` resolve to the project env.
# Put the venv on PATH so `python` / `gunicorn` / `uvicorn` resolve to the project env.
ENV PATH=/app/.venv/bin:$PATH
ENV PYTHONUNBUFFERED=1
# gunicorn is the prod entrypoint — multiple ASGI workers, graceful
# shutdown, stdout/stderr logs. Config knobs are env-var driven (see
# gunicorn.conf.py).
#
# Common overrides via -e flags at `docker run`:
# -e GUNICORN_WORKERS=4
# -e GUNICORN_TIMEOUT=180
# -e GUNICORN_BIND=0.0.0.0:9000
EXPOSE 8000
CMD ["python", "main.py"]
CMD ["gunicorn", "main:app"]
+7
View File
@@ -36,6 +36,13 @@ services:
# you always set yarn_rm_url per Connection via save_connection.
YARN_RESOURCE_MANAGER_URL: ${YARN_RESOURCE_MANAGER_URL:-}
# Gunicorn tuning (see gunicorn.conf.py for full list of knobs).
# 2 workers is a good default for a small MCP service; raise for
# high-concurrency deploys.
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-2}
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120}
GUNICORN_BIND: ${GUNICORN_BIND:-0.0.0.0:8000}
# Optional: pass JVM options to spark-submit (e.g. for proxies, memory).
# SPARK_SUBMIT_OPTS: "-Dhttps.proxyHost=..."
+50
View File
@@ -0,0 +1,50 @@
# coding=utf-8
"""
@Time :2026/6/24
@Author :tao.chen
Gunicorn config for the Spark Executor MCP service.
ASGI workers (uvicorn.workers.UvicornWorker) so we get gunicorn's process
supervision, graceful shutdown, and graceful reload semantics on top of
uvicorn's ASGI implementation.
All knobs are env-var driven so the same image runs in dev (workers=1)
and prod (workers=2-4) without rebuilding.
"""
import os
# --- Network ---
bind = os.environ.get("GUNICORN_BIND", "0.0.0.0:8000")
# --- Process model ---
# 2 is a sensible default for a small containerized MCP server: lets a slow
# yarn logs request run in parallel with a status check. Scale up by
# setting GUNICORN_WORKERS at deploy time.
workers = int(os.environ.get("GUNICORN_WORKERS", "2"))
worker_class = "uvicorn.workers.UvicornWorker"
# 1 thread per worker is enough for ASGI handlers (no blocking I/O).
threads = int(os.environ.get("GUNICORN_THREADS", "1"))
# --- Lifecycle ---
# Generous timeout because the slowest tool call here is yarn logs (which
# can take 30+ seconds on a busy cluster). uvicorn standalone defaults to
# 30s; gunicorn's default is 30s too — both too tight for log fetch.
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "120"))
graceful_timeout = int(os.environ.get("GUNICORN_GRACEFUL_TIMEOUT", "30"))
keepalive = int(os.environ.get("GUNICORN_KEEPALIVE", "5"))
# --- Logging ---
# Stream access + error to stdout/stderr so docker logs / k8s logs capture
# them. gunicorn's "[INFO] Booting worker" lines interleave with loguru's
# output — both go to stderr.
accesslog = "-"
errorlog = "-"
loglevel = os.environ.get("GUNICORN_LOGLEVEL", "info")
access_log_format = (
'%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(L)s'
)
# --- Process naming (visible in `ps aux`) ---
proc_name = "spark-executor-mcp"
+1
View File
@@ -5,6 +5,7 @@ requires-python = ">=3.12"
dependencies = [
"fastapi>=0.138.0",
"fastapi-mcp>=0.4.0",
"gunicorn>=23.0",
"httpx>=0.28.1",
"loguru>=0.7.3",
"pydantic>=2.13.4",
Generated
+474 -455
View File
File diff suppressed because it is too large Load Diff