Python · uvicorn / gunicorn 部署全景

WSGI vs ASGI · master-worker 进程模型 · 信号与优雅停机 · systemd / Docker / K8s 三种宿主的正确姿势

生产流量链路 — master 不接客, 只管人 Client 浏览器 / App TLS 从这里开始 Nginx / LB TLS 终止 · 静态文件 X-Forwarded-* 转发头 upstream keepalive 复用连接 gunicorn / uvicorn master 不处理任何请求! 拉起/回收 worker · 转发信号 TTIN/TTOU 动态加减 · HUP 平滑重启 workers × N w1 w2 w3 wN max_requests 到期自动换新 (防泄漏) App → 依赖 FastAPI / Django / Flask Redis / DB / 下游 API 真正的瓶颈常在这里 两种运行模式 — 谁当 master? 模式 A · gunicorn 当 master (推荐生产) gunicorn -k uvicorn.workers.UvicornWorker app:app -w 4 成熟进程管理: max_requests / timeout / HUP 平滑重启全支持 模式 B · uvicorn 自管多进程 (轻量) uvicorn app:app --workers 4 --host 0.0.0.0 --port 8000 依赖少、配置直观; 进程管理与信号语义弱于 gunicorn 信号速查 — 运维的遥控器 TERM 优雅停机: 等存量请求完成再退 (K8s/docker stop 发的就是它) HUP 平滑重启 worker (重新加载代码/配置), master 不变 TTIN/TTOU 动态加/减一个 worker (扩缩容不用重启) USR2 热升级: 启动新 master/新二进制, 配合 WINCH 收旧 worker # 这些信号必须直达 master 进程 — 部署方式的第一设计约束 三种宿主部署矩阵 — 同一个应用, 三套关注点 systemd (裸机/VM) Type=notify (sd_notify 就绪通知) ExecReload=kill -HUP $MAINPID → reload 即生效 Restart=on-failure + LimitNOFILE=65536 After=network-online.target postgresql.service 优势: 开机自启 / 依赖编排 / journal 日志一体 信号直达 PID1=master, 语义最完整 systemctl reload app / restart app / status Docker / Compose CMD 必须 exec 形式 (信号直达, 别让 sh 当 PID1) 配 tini/--init 回收僵尸; USER 非 root 运行 STOPSIGNAL SIGTERM + stop_grace_period: 30s HEALTHCHECK 打 /healthz; 日志只写 stdout/stderr 镜像内 workers 数取 CPU limit, 不是宿主机核数 一个容器一个进程族: master+workers 由 gunicorn 管 docker stop → TERM → 宽限期 → KILL Kubernetes readiness 摘流量; liveness 只测进程活 (勿测 DB) preStop sleep 5 + TERM 优雅 drain terminationGracePeriodSeconds ≥ 应用收尾时间 HPA 按 CPU 扩 Pod (不是扩 worker 数) 滚动发布: maxUnavailable=0 + maxSurge=1 不断流 资源: requests=limits (CPU) 防 throttling 毛刺 DB 迁移用 Job/initContainer + 锁, 不在 web 进程里跑 Legend 接入/模式 master/信号 worker/systemd 依赖/K8s 外部/说明

master 不接客

  • • master 只负责拉起/回收 worker、转发信号
  • • 请求由 worker 进程处理, 挂了 master 立刻补位
  • • 所有部署问题的第一问: 信号能直达 master 吗

WSGI vs ASGI 选型

  • • Flask/Django 同步 → gunicorn sync/gthread worker
  • • FastAPI/async → ASGI: UvicornWorker 或 uvicorn --workers
  • • 用错协议 = 退化为串行或直接跑不起来

优雅停机三步曲

  • • 摘流量(readiness/preStop) → 收 TERM → drain 存量
  • • 宽限期 ≥ 最长请求耗时, 否则被 KILL 截断
  • • docker stop 默认只等 10s — 记得调 stop_grace_period

💡 一句话理解

gunicorn/uvicorn 的部署本质是"一个 master 管着一群 worker, 靠信号接受指挥"。代码写完只完成了一半 — 剩下一半是让这个进程族在 systemd/Docker/K8s 里信号直达、优雅启停、健康可探。90% 的"部署玄学问题"(改了代码不生效、stop 卡死、滚动更新丢请求)都不是应用 bug, 而是信号没有到达 master, 或宽限期没对齐。

🧠 必知必会 必考 & 必会

WSGI vs ASGI
WSGI(gunicorn 传统模式): 一请求一同步调用, Flask/Django 经典栈; ASGI(uvicorn): 异步协议, 支持 asyncio/WebSocket/长连接, FastAPI/Starlette 原生基于它。选错协议的典型症状: Fastapi 挂在 sync worker 下并发归零。
# Flask/Django(同步) → WSGI
gunicorn wsgi:app -w 4                  # 默认 sync worker
# FastAPI(异步) → ASGI, 关键: 协议与应用模型匹配
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker
uvicorn main:app --workers 4             # 轻量等价
worker 类型
gunicorn -k 可选: sync(稳, 同步应用)/gthread(线程模型, IO 密集)/gevent·eventlet(协程猴补丁, 老异步方案)/uvicorn.workers.UvicornWorker(ASGI 应用交给 uvicorn 事件循环)。类型与应用模型必须匹配。
gunicorn app:app -k sync       # 同步应用, 最稳
gunicorn app:app -k gthread --threads 8  # IO 密集线程模型
gunicorn app:app -k gevent     # 老异步(猴补丁)
# 关键: FastAPI/async 应用必须 ↓
gunicorn app:app -k uvicorn.workers.UvicornWorker
worker 数公式
sync/gthread 起点 (2 × CPU) + 1; ASGI 单 worker 已能数千并发, worker 数按"每核 1 个 + 冗余"配, 瓶颈通常在内存与下游而非进程数。最终以压测拐点为准。
# gunicorn.conf.py
import multiprocessing as mp
workers = 2 * mp.cpu_count() + 1    # 关键: sync/gthread 起步公式
# ASGI 单 worker 数千并发: 按每核 1 个 + 冗余, 压测定稿
preload_app
preload=True 在 fork 前加载应用: 启动快 + COW 共享内存; 代价是 HUP/改代码必须整体重启 worker, 且 fork 前建立的连接/锁要能安全被 fork(数据库连接池要 preload 后重建)。
# gunicorn.conf.py
preload_app = True          # 关键: fork 前加载, 启动快+COW 省内存
def post_fork(server, worker):
    engine.dispose(close=False)  # fork 后各 worker 重建连接池
# 代价: 改代码必须整体重启 worker, HUP 不够
max_requests + jitter
每个 worker 处理 N 个请求后自杀重生 — 微服务内存泄漏的"自愈阀"。加 max_requests_jitter 随机化, 避免所有 worker 同时重启造成周期性抖动。
# gunicorn.conf.py
max_requests = 2000          # 关键: 每 worker 满 2000 请求自杀重生
max_requests_jitter = 200     # 关键: 随机错峰, 防集体重启跌谷
timeout 语义
worker 超过 timeout(默认 30s)无响应被 master 强杀 — 是"看门狗"不是"请求超时"。SSE/长任务要么放后台任务系统, 要么调大并接受慢死检测延迟。
# gunicorn.conf.py
timeout = 60                # 看门狗: worker 无响应 60s 被强杀
graceful_timeout = 30        # 关键: TERM 后等存量请求的时间
# 注意: 不是请求超时! SSE/长任务单独部署组调大
两种多进程取舍
生产 ASGI 主流: gunicorn -k UvicornWorker(进程管理全功能) 或 uvicorn --workers N(更轻)。K8s 里还有第三派: 每 Pod 单 uvicorn 进程, 副本数交给 K8s 扩 — 进程管理与容器编排不重叠。
gunicorn app:app -k UvicornWorker -w 4   # 进程管理全功能
uvicorn app:app --workers 4             # 更轻, 少一层依赖
# 关键: K8s 第三派 — 每 Pod 单进程, 副本数交 HPA
uvicorn app:app --host 0.0.0.0 --port 8000
信号是部署的 API
TERM=优雅停, HUP=平滑换 worker, TTIN/TTOU=热加减, USR2+WINCH=热升级二进制。systemd/docker/k8s 的一切"重启/发布/扩缩"最终都翻译成这几个信号。
kill -TERM  $(pidof gunicorn)  # 优雅停: 等存量完成再退
kill -HUP   $(pidof gunicorn)  # 平滑换 worker, master 不变
kill -TTIN  $(pidof gunicorn)  # 加一个 worker; TTOU 减
kill -USR2  $(pidof gunicorn)  # 关键: 热升级, 配 WINCH 收旧

🏭 生产实战 real world · 10 场景

场景 1 · gunicorn 生产配置文件(而非命令行堆参数)

参数进版本库, 命令行只留一个 -c:

# gunicorn.conf.py — 与代码同库评审
bind = "0.0.0.0:8000"
workers = 4                          # 起步 (2×CPU)+1, 压测修正
worker_class = "uvicorn.workers.UvicornWorker"   # ASGI 应用
timeout = 60                        # 看门狗: 比 P99 最长请求宽裕
graceful_timeout = 30              # TERM 后等存量请求的最长时间
max_requests = 2000                 # 防 worker 内存泄漏自愈
max_requests_jitter = 200           # 随机化, 防集体重启抖动
accesslog = "-"; errorlog = "-"     # 容器环境: 只写 stdout
forwarded_allow_ips = "*"            # 信任反代传来的 X-Forwarded-*

场景 2 · FastAPI 两种启动方式的取舍

# 方式 A: gunicorn 管理 (成熟, 配置项全)
gunicorn app.main:app -c gunicorn.conf.py

# 方式 B: uvicorn 自管 (轻量, 少一层依赖)
uvicorn app.main:app --host 0.0.0.0 --port 8000 \
    --workers 4 --timeout-keep-alive 5 \
    --proxy-headers --forwarded-allow-ips='*'
# 共同底线: 绝不带 --reload 进生产 (那是开发期热重载)

场景 3 · systemd 单元文件(裸机/VM 标准姿势)

# /etc/systemd/system/app.service
[Unit]
After=network-online.target postgresql.service
[Service]
User=app; WorkingDirectory=/opt/app
Environment=PATH=/opt/app/venv/bin
ExecStart=/opt/app/venv/bin/gunicorn app.main:app -c gunicorn.conf.py
ExecReload=/bin/kill -HUP $MAINPID      # reload = 平滑换 worker
Restart=on-failure; RestartSec=2
LimitNOFILE=65535                       # 高连接量的前提
KillSignal=SIGTERM; TimeoutStopSec=40      # ≥ graceful_timeout
[Install]
WantedBy=multi-user.target
# systemctl daemon-reload && systemctl enable --now app

场景 4 · Dockerfile: 信号直达 + 非 root + init

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt . && RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd -m appuser && chown -R appuser /app
USER appuser
# exec 形式: master 直接做 PID 1 前台进程, 信号直达
CMD ["gunicorn", "app.main:app", "-c", "gunicorn.conf.py"]
# 坑位: shell 形式 CMD gunicorn ... 会让 sh 抢占 PID1, TERM 永远到不了

场景 5 · docker-compose: 健康检查 + 优雅停窗口

services:
  api:
    image: registry/app:${TAG}
    init: true                          # tini 回收僵尸进程
    stop_grace_period: 40s             # 默认 10s 太短, 对齐 graceful_timeout
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
      interval: 10s; timeout: 3s; retries: 3
      start_period: 15s                # 给启动预热留时间
    deploy:
      resources:
        limits: { cpus: "2", memory: 1G }  # workers 数对齐这里, 不是宿主机

场景 6 · K8s Deployment: 探针 + preStop + 宽限期三件套

containers:
- name: api
  image: registry/app:${TAG}
  readinessProbe:
    httpGet: { path: /readyz, port: 8000 }   # 摘流量用
    periodSeconds: 5
  lifecycle:
    preStop:
      exec: { command: ["sleep", "5"] }   # 等 endpoint 摘除传播, 再收 TERM
  resources:
    requests: { cpu: "2" }                  # requests=limits 防 CPU 抖动
    limits:   { cpu: "2", memory: 1G }
terminationGracePeriodSeconds: 45       # > graceful_timeout + preStop
strategy:
  rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }  # 滚动不断流

场景 7 · Nginx 反代: 转发头 + 超时 + upstream keepalive

upstream app { server 127.0.0.1:8000; keepalive 64; }
server {
  location / {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";            # 启用 keepalive 复用
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;   # 否则应用以为自己是 http
    proxy_read_timeout 60s;                   # 与 gunicorn timeout 对齐
  }
}

场景 8 · 平滑发布: 裸机 HUP vs K8s 滚动

# 裸机/VM: 拉新代码 → HUP 平滑换 worker (master 不断流)
cd /opt/app && git pull && systemctl reload app
# K8s: 换镜像即滚动, 新 Pod ready 才摘旧 Pod (配合场景 6 的 maxUnavailable=0)
kubectl set image deploy/api api=registry/app:v2 && kubectl rollout status deploy/api
# 回滚就是换回旧 tag, 不需要碰进程
kubectl rollout undo deploy/api

场景 9 · 内存缓涨的自愈配置与定位

# 现象: RSS 缓涨不降 → 先量化再自愈
# 1. 容器内存接近 limit 才是问题: 监控 container_memory_working_set_bytes
# 2. 自愈阀: worker 定期换新, 泄漏被定期清零
max_requests = 2000; max_requests_jitter = 200
# 3. 根治仍要 tracemalloc 定位(见内存管理页), max_requests 是止血不是治病

场景 10 · 多 worker 下的"只跑一次"初始化

迁移、种子数据、定时注册在 N 个 worker 里各跑一遍就是事故:

# 坏: 应用启动钩子里直接跑迁移 (N 个 worker 跑 N 遍, 并发 DDL 冲突)
# 好: 迁移独立成 K8s Job / initContainer, 完成后才起 web
initContainers:
- name: migrate
  image: registry/app:${TAG}
  command: ["alembic", "upgrade", "head"]
# 真要在 worker 间互斥: 文件锁/advisory lock 包住, 拿到锁的才执行
with fcntl.flock(open('/tmp/init.lock', 'w'), fcntl.LOCK_EX):
    run_once_migrations()

⚠️ 编码注意与常见坑 pitfalls · 20 条

坑 1 · CMD 用 shell 形式吞信号 — CMD gunicorn ... 时 PID1 是 sh, docker stop 的 TERM 被 sh 吃掉, 只能等 10s 后被 KILL。正解: 一律 exec 数组形式 ["gunicorn", ...]。
# 错: sh 抢占 PID1, TERM 被吃, 只能等 KILL
CMD gunicorn app:app -c gunicorn.conf.py
# 对: exec 数组形式, master 即 PID1, 信号直达
CMD ["gunicorn", "app:app", "-c", "gunicorn.conf.py"]
坑 2 · 容器里没有 init, 僵尸堆积 — master fork 出的子进程异常退出后无人收尸, 长跑容器僵尸进程堆积。正解: docker run --init / compose init: true / 镜像里装 tini。
# 错: 无 init, 异常退出的子进程没人收尸 → 僵尸堆积
docker run app:latest
# 对: 交给 init 进程收养
docker run --init app:latest    # 或 compose: init: true / 装 tini
坑 3 · --reload 带进生产 — 开发热重载开销大且监听文件变更, 生产必炸。正解: 生产命令与开发命令分开写在配置里, CI 检查镜像启动命令。
# 错: 热重载监听文件变更, 生产开销大且危险
uvicorn app:app --reload --workers 4
# 对: 生产命令无 --reload, CI 校验镜像启动命令
uvicorn app:app --workers 4
坑 4 · sync worker 跑 FastAPI — ASGI 应用挂在默认 sync worker 上, async 端点被串行执行, 并发归零。正解: -k uvicorn.workers.UvicornWorker 或直接 uvicorn --workers。
# 错: 默认 sync worker, async 端点串行, 并发归零
gunicorn main:app -w 4
# 对: ASGI worker 接管事件循环
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker
坑 5 · worker 数抄宿主机核数 — 容器 CPU limit=2 而宿主机 32 核, 抄出 65 个 worker 自相残杀。正解: workers 对齐 limit; 或入口脚本读 cgroup 配额算核数。
# 错: 容器 limit=2, 却抄宿主机 32 核
workers = 2 * 32 + 1            # → 65 个 worker 自相残杀
# 对: workers 对齐 cgroup 配额 (limit=2)
workers = 2 * 2 + 1              # → 5 个
坑 6 · timeout 当请求超时用 — 它是杀"无响应 worker"的看门狗; 设 5s 会把正常慢请求连人带枪杀掉。正解: 慢请求治理用应用层超时, timeout 给 P99 留足余量; SSE/长连接单独部署组调大。
# 错: 当请求超时设, 慢请求连人带枪被杀
timeout = 5
# 对: 看门狗给 P99 留余量; 慢请求用应用层超时
timeout = 60
坑 7 · max_requests 无 jitter 集体重启 — 所有 worker 同时刻到期重启, 吞吐周期性跌谷。正解: max_requests_jitter = max_requests 的 10% 错峰。
# 错: 全员同刻到期 → 吞吐周期性跌谷
max_requests = 2000
# 对: 加 10% jitter 错峰重启
max_requests = 2000
max_requests_jitter = 200
坑 8 · preload_app 与连接池打架 — fork 前建的 DB 连接被子进程共享套接字, 协议错乱。正解: preload 模式下连接初始化放 fork 后(engine.dispose(close) 再连), 或 gunicorn 的 post_fork 钩子重建。
# 错: fork 前建连接, 子进程共享同一套接字 → 协议错乱
preload_app = True; engine = create_engine(url)  # 模块级建池
# 对: fork 后各 worker 重建自己的连接池
def post_fork(server, worker):
    engine.dispose(close=False)
坑 9 · 反代不传 X-Forwarded-Proto — 应用以为全是 http: https 重定向死循环、oauth 回调 URL 生成错。正解: nginx 两个 X-Forwarded 头必配, 应用侧 proxy-headers/forwarded_allow_ips 打开。
# 错: 没传头 → 应用以为全 http, https 重定向死循环
proxy_set_header Host $host;
# 对: 两个转发头必配
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
坑 10 · liveness 探针里查数据库 — DB 抖一下全部 Pod 被重启, 把依赖故障放大成雪崩。正解: liveness 只测进程能响应(/healthz); 依赖健康放 readiness, 挂了摘流量不杀进程。
livenessProbe:
  httpGet: { path: /db-ping }    # 错: DB 抖 → 全 Pod 重启雪崩
livenessProbe:
  httpGet: { path: /livez }      # 对: 只测进程活
readinessProbe:
  httpGet: { path: /readyz }     # 依赖健康放这里, 摘流量不杀
坑 11 · 没有 preStop 直接收 TERM — endpoint 摘除有传播延迟, 新请求仍打进正在退出的 Pod。正解: preStop sleep 3-5s 再等 TERM, 让摘流先生效。
# 错: 直接收 TERM, 摘除未传播完, 新请求还打进来
lifecycle: {}
# 对: preStop 先睡几秒, 摘流生效后再等 TERM
lifecycle:
  preStop: { exec: { command: ["sleep", "5"] } }
坑 12 · terminationGracePeriodSeconds 太短 — 应用 drain 要 30s, 宽限期只给 20s, 后半被 SIGKILL 掐断。正解: 宽限期 ≥ preStop + graceful_timeout + 缓冲。
# 错: drain 要 30s, 宽限期 20s → 后半被 SIGKILL 掐断
terminationGracePeriodSeconds: 20
# 对: 宽限期 ≥ preStop + graceful_timeout + 缓冲
terminationGracePeriodSeconds: 45   # 5 + 30 + 余量
坑 13 · docker stop 默认 10s 就 KILL — graceful_timeout 30s 根本等不完。正解: compose stop_grace_period: 40s, docker stop -t 40。
# 错: 默认只等 10s, graceful_timeout 30s 等不完
docker stop api              # 10s 一到直接 SIGKILL
# 对: 宽限期对齐 graceful_timeout
docker stop -t 40 api        # 或 compose: stop_grace_period: 40s
坑 14 · HUP 当零停机发布用 — HUP 换 worker 期间仍有短暂数量下降, 长连接(SSE/WebSocket)必断。正解: 对长连接服务用 USR2+WINCH 热升级或 K8s 滚动; HUP 只当"快速应用配置"。
kill -HUP $MAINPID       # 错: 当零停机发布, SSE/WS 必断
# 对: 长连接服务用热升级或滚动
kill -USR2 $MAINPID; kill -WINCH $MAINPID  # 热升级收旧
kubectl set image deploy/api api=v2        # 滚动不断流
坑 15 · 多 worker 各自跑迁移/定时任务 — 4 个 worker 把迁移跑 4 遍, DDL 冲突直接起不来。正解: 迁移独立 Job/initContainer; APScheduler 用文件锁/数据库锁保证单实例, 或拆成独立 CronJob。
# 错: 启动钩子跑迁移, 4 个 worker 跑 4 遍, DDL 冲突
# 对: 迁移独立 initContainer, 完成后才起 web
initContainers:
- name: migrate
  command: ["alembic", "upgrade", "head"]
坑 16 · access log 高频写盘卡住 worker — 每请求同步刷盘, 磁盘抖动时整体变慢。正解: 容器内日志走 stdout 由收集器处理; 极高流量下评估访问日志采样。
# 错: 每请求同步刷盘, 磁盘抖动整体变慢
accesslog = "/var/log/app/access.log"
# 对: 容器只写 stdout, 收集器负责落盘
accesslog = "-"; errorlog = "-"
坑 17 · systemd 没配 LimitNOFILE — 默认 1024, 流量一上来 too many open files 雪崩。正解: LimitNOFILE=65535 起步; ulimit 只在 shell 生效, systemd 单元里配才算数。
# 错: 默认 1024, 流量上来 too many open files 雪崩
# (ulimit 只在 shell 会话生效, systemd 不吃这套)
# 对: 写进单元文件才算数
[Service]
LimitNOFILE=65535
坑 18 · gevent monkey-patch 混入 async 库 — 打了猴补丁的进程里再跑 asyncio/httpx 异步客户端, 两套调度器互相踩踏。正解: 老同步栈才用 gevent; 新代码统一 asyncio, 不混用。
# 错: 猴补丁进程里再跑 asyncio, 两套调度器互踩
gunicorn app:app -k gevent    # app 内部用 httpx.AsyncClient
# 对: 二选一, 新代码统一 asyncio
gunicorn app:app -k uvicorn.workers.UvicornWorker
坑 19 · 健康检查端点太重 — /healthz 里查 DB+查缓存+查下游, 探针高频打变成自我压测。正解: liveness 纯静态; readiness 轻量依赖检查并加缓存(1s 内的结果复用)。
@app.get("/healthz")
async def healthz():               # 错: 查 DB+缓存+下游, 自我压测
    await check_db(); await check_redis()
@app.get("/healthz")
def healthz(): return {"ok": True}  # 对: 纯静态, 轻检查放 readiness
坑 20 · K8s 里还开多 worker + HPA 双重扩缩 — Pod 扩容 × worker 数叠加, 下游连接数爆炸。正解: 二选一: Pod 内单进程 + HPA 扩副本(K8s 原生派), 或固定 worker 数只扩 Pod; 连接池上限按"副本×worker"总账设置。
workers = 8        # 错: 又开多 worker 又开 HPA 扩 Pod
                      # Pod 数 × 8, 下游连接数爆炸
workers = 1        # 对: 二选一, Pod 内单进程 + HPA
                      # 连接池上限 = 副本 × worker 总账