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:
@@ -13,3 +13,11 @@ YARN_RESOURCE_MANAGER_URL=
|
|||||||
# Optional: JVM flags forwarded to spark-submit. Useful for proxies, custom
|
# Optional: JVM flags forwarded to spark-submit. Useful for proxies, custom
|
||||||
# truststores, or driver memory caps.
|
# truststores, or driver memory caps.
|
||||||
# SPARK_SUBMIT_OPTS=-Dhttps.proxyHost=proxy.corp -Dhttps.proxyPort=3128
|
# 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
@@ -55,11 +55,20 @@ RUN uv sync --index-url=https://pypi.tuna.tsinghua.edu.cn/simple/ --frozen --no-
|
|||||||
COPY main.py ./
|
COPY main.py ./
|
||||||
COPY spark_executor ./spark_executor
|
COPY spark_executor ./spark_executor
|
||||||
COPY common ./common
|
COPY common ./common
|
||||||
|
COPY gunicorn.conf.py ./
|
||||||
RUN uv sync --index-url=https://pypi.tuna.tsinghua.edu.cn/simple/ --frozen --no-dev
|
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 PATH=/app/.venv/bin:$PATH
|
||||||
ENV PYTHONUNBUFFERED=1
|
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
|
EXPOSE 8000
|
||||||
CMD ["python", "main.py"]
|
CMD ["gunicorn", "main:app"]
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ services:
|
|||||||
# you always set yarn_rm_url per Connection via save_connection.
|
# you always set yarn_rm_url per Connection via save_connection.
|
||||||
YARN_RESOURCE_MANAGER_URL: ${YARN_RESOURCE_MANAGER_URL:-}
|
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).
|
# Optional: pass JVM options to spark-submit (e.g. for proxies, memory).
|
||||||
# SPARK_SUBMIT_OPTS: "-Dhttps.proxyHost=..."
|
# SPARK_SUBMIT_OPTS: "-Dhttps.proxyHost=..."
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -5,6 +5,7 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.138.0",
|
"fastapi>=0.138.0",
|
||||||
"fastapi-mcp>=0.4.0",
|
"fastapi-mcp>=0.4.0",
|
||||||
|
"gunicorn>=23.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"loguru>=0.7.3",
|
"loguru>=0.7.3",
|
||||||
"pydantic>=2.13.4",
|
"pydantic>=2.13.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user