WSGI vs ASGI · master-worker 进程模型 · 信号与优雅停机 · systemd / Docker / K8s 三种宿主的正确姿势
gunicorn/uvicorn 的部署本质是"一个 master 管着一群 worker, 靠信号接受指挥"。代码写完只完成了一半 — 剩下一半是让这个进程族在 systemd/Docker/K8s 里信号直达、优雅启停、健康可探。90% 的"部署玄学问题"(改了代码不生效、stop 卡死、滚动更新丢请求)都不是应用 bug, 而是信号没有到达 master, 或宽限期没对齐。
# 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 # 轻量等价
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
(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=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 同时重启造成周期性抖动。 # gunicorn.conf.py max_requests = 2000 # 关键: 每 worker 满 2000 请求自杀重生 max_requests_jitter = 200 # 关键: 随机错峰, 防集体重启跌谷
# gunicorn.conf.py timeout = 60 # 看门狗: worker 无响应 60s 被强杀 graceful_timeout = 30 # 关键: TERM 后等存量请求的时间 # 注意: 不是请求超时! SSE/长任务单独部署组调大
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
kill -TERM $(pidof gunicorn) # 优雅停: 等存量完成再退 kill -HUP $(pidof gunicorn) # 平滑换 worker, master 不变 kill -TTIN $(pidof gunicorn) # 加一个 worker; TTOU 减 kill -USR2 $(pidof gunicorn) # 关键: 热升级, 配 WINCH 收旧
参数进版本库, 命令行只留一个 -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-*
# 方式 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 进生产 (那是开发期热重载)
# /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
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 永远到不了
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 数对齐这里, 不是宿主机
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 } # 滚动不断流
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 对齐
}
}
# 裸机/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
# 现象: RSS 缓涨不降 → 先量化再自愈 # 1. 容器内存接近 limit 才是问题: 监控 container_memory_working_set_bytes # 2. 自愈阀: worker 定期换新, 泄漏被定期清零 max_requests = 2000; max_requests_jitter = 200 # 3. 根治仍要 tracemalloc 定位(见内存管理页), max_requests 是止血不是治病
迁移、种子数据、定时注册在 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()
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"]
--init / compose init: true / 镜像里装 tini。 # 错: 无 init, 异常退出的子进程没人收尸 → 僵尸堆积 docker run app:latest # 对: 交给 init 进程收养 docker run --init app:latest # 或 compose: init: true / 装 tini
# 错: 热重载监听文件变更, 生产开销大且危险 uvicorn app:app --reload --workers 4 # 对: 生产命令无 --reload, CI 校验镜像启动命令 uvicorn app:app --workers 4
-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
# 错: 容器 limit=2, 却抄宿主机 32 核 workers = 2 * 32 + 1 # → 65 个 worker 自相残杀 # 对: workers 对齐 cgroup 配额 (limit=2) workers = 2 * 2 + 1 # → 5 个
# 错: 当请求超时设, 慢请求连人带枪被杀 timeout = 5 # 对: 看门狗给 P99 留余量; 慢请求用应用层超时 timeout = 60
max_requests_jitter = max_requests 的 10% 错峰。 # 错: 全员同刻到期 → 吞吐周期性跌谷 max_requests = 2000 # 对: 加 10% jitter 错峰重启 max_requests = 2000 max_requests_jitter = 200
# 错: fork 前建连接, 子进程共享同一套接字 → 协议错乱 preload_app = True; engine = create_engine(url) # 模块级建池 # 对: fork 后各 worker 重建自己的连接池 def post_fork(server, worker): engine.dispose(close=False)
# 错: 没传头 → 应用以为全 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;
livenessProbe:
httpGet: { path: /db-ping } # 错: DB 抖 → 全 Pod 重启雪崩
livenessProbe:
httpGet: { path: /livez } # 对: 只测进程活
readinessProbe:
httpGet: { path: /readyz } # 依赖健康放这里, 摘流量不杀# 错: 直接收 TERM, 摘除未传播完, 新请求还打进来 lifecycle: {} # 对: preStop 先睡几秒, 摘流生效后再等 TERM lifecycle: preStop: { exec: { command: ["sleep", "5"] } }
# 错: drain 要 30s, 宽限期 20s → 后半被 SIGKILL 掐断 terminationGracePeriodSeconds: 20 # 对: 宽限期 ≥ preStop + graceful_timeout + 缓冲 terminationGracePeriodSeconds: 45 # 5 + 30 + 余量
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
kill -HUP $MAINPID # 错: 当零停机发布, SSE/WS 必断 # 对: 长连接服务用热升级或滚动 kill -USR2 $MAINPID; kill -WINCH $MAINPID # 热升级收旧 kubectl set image deploy/api api=v2 # 滚动不断流
# 错: 启动钩子跑迁移, 4 个 worker 跑 4 遍, DDL 冲突 # 对: 迁移独立 initContainer, 完成后才起 web initContainers: - name: migrate command: ["alembic", "upgrade", "head"]
# 错: 每请求同步刷盘, 磁盘抖动整体变慢 accesslog = "/var/log/app/access.log" # 对: 容器只写 stdout, 收集器负责落盘 accesslog = "-"; errorlog = "-"
# 错: 默认 1024, 流量上来 too many open files 雪崩 # (ulimit 只在 shell 会话生效, systemd 不吃这套) # 对: 写进单元文件才算数 [Service] LimitNOFILE=65535
# 错: 猴补丁进程里再跑 asyncio, 两套调度器互踩 gunicorn app:app -k gevent # app 内部用 httpx.AsyncClient # 对: 二选一, 新代码统一 asyncio gunicorn app:app -k uvicorn.workers.UvicornWorker
@app.get("/healthz") async def healthz(): # 错: 查 DB+缓存+下游, 自我压测 await check_db(); await check_redis() @app.get("/healthz") def healthz(): return {"ok": True} # 对: 纯静态, 轻检查放 readiness
workers = 8 # 错: 又开多 worker 又开 HPA 扩 Pod # Pod 数 × 8, 下游连接数爆炸 workers = 1 # 对: 二选一, Pod 内单进程 + HPA # 连接池上限 = 副本 × worker 总账