本节目标:把「本地能跑」变成「敢上生产」——逐项过一遍上线检查清单,真跑迁移的前滚与回滚,看懂健康检查与优雅关闭的日志,并掌握一份可复用的复盘模板与演进路线。
适用版本:Python 3.12+(实测 3.14.6);alembic 1.20.0、uvicorn 0.54.0
18.3 上线、复盘与后续演进
18.1 拆需求、18.2 写代码,TaskFlow 在本地已经「能跑」了。但「能跑」和「敢上线」之间隔着一整套纪律:配置会不会漏、密钥会不会进镜像、迁移能不能回滚、进程被杀时请求会不会丢、出事之后能不能查。这一节把这些逐项收口,也给全书收尾。
18.3.1 上线检查清单
上线不是「把代码推上去」,而是过一遍清单。这份清单可以贴在发布流程里,每项都能勾:
| # | 检查项 | 合格标准 | 本项目做法 |
|---|---|---|---|
| 1 | 配置外置 | 无硬编码的环境相关值 | pydantic-settings 读 TASKFLOW_* 环境变量 |
| 2 | 密钥不落盘 | 密钥只走环境变量/密钥服务 | secret_key 默认值仅用于开发,生产必须覆盖 |
| 3 | 依赖锁死 | 版本精确 + 哈希校验 | pyproject.toml 全部 ==,CI 用 --require-hashes |
| 4 | 迁移可前滚可回滚 | upgrade/downgrade 都验证过 | 见 18.3.2 |
| 5 | 健康探针 | /healthz 返回 200 且不含敏感信息 | 返回 {"status": "ok"} |
| 6 | 优雅关闭 | 收到 SIGTERM 先停接收、再收尾 | lifespan 里 flush 任务队列 |
| 7 | 日志结构化 | 关键事件带级别与上下文 | logging 输出启动/关闭事件 |
| 8 | 非 root 运行 | 容器内不用 root | Dockerfile 里 USER(第 15 章) |
| 9 | 关调试 | 生产 debug=False,异常不回传堆栈 | uvicorn 默认不返回堆栈 |
| 10 | 限流与超时 | 外部依赖有超时,接口有限流 | 第 16 章 |
配置是第一项,也是最容易出事的一项。用 pydantic-settings 让环境变量成为唯一来源:
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="TASKFLOW_", env_file=".env")
database_url: str = "sqlite+aiosqlite:///./taskflow.db"
secret_key: str = "dev-only-secret-change-me"
access_token_ttl: int = 3600
cache_ttl: int = 60
注意 secret_key 的默认值:它是「开发方便」和「生产安全」之间的取舍——默认值让本地开箱即跑,但生产部署必须用 TASKFLOW_SECRET_KEY 覆盖。更好的做法是让默认值在 APP_ENV=production 时直接拒绝启动(fail-fast):
from pydantic import model_validator
class Settings(BaseSettings):
app_env: str = "development"
secret_key: str = "dev-only-secret-change-me"
@model_validator(mode="after")
def guard_production(self) -> "Settings":
if self.app_env == "production" and self.secret_key == "dev-only-secret-change-me":
raise ValueError("生产环境必须显式设置 TASKFLOW_SECRET_KEY")
return self
fail-fast 的价值:宁可进程启动就崩,也不要带着开发密钥静默跑在生产上。配置错误的暴露成本,远低于安全事故的成本。
第 8 项「非 root 运行」落到 Dockerfile 上(本机未执行 docker build,仅示意):
FROM python:3.14-slim AS base
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir .
COPY app ./app
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
多阶段与镜像瘦身在 多阶段 Dockerfile 与镜像瘦身
已详述;这里只强调**USER appuser**——容器默认以 root 跑,一旦被攻破就是最高权限。上线检查清单里的每一项,最终都要落到一行真实配置。
18.3.2 迁移与回滚:Alembic 的实战口径
上线前必须验证迁移能回滚——只会前滚不会回滚的迁移,出事时就是单向门。真实跑一遍前滚 → 回滚 → 再前滚:
$ alembic upgrade head
INFO [alembic.runtime.migration] Running upgrade -> 9ae51e968831, init tickets schema
INFO [alembic.runtime.migration] Running upgrade 9ae51e968831 -> c021935f2bed, add ticket due_at
$ alembic current
c021935f2bed (head)
$ alembic downgrade -1
INFO [alembic.runtime.migration] Running downgrade c021935f2bed -> 9ae51e968831, add ticket due_at
$ alembic current
9ae51e968831
tickets 列: ['id', 'title', 'body', 'status', 'priority', 'owner_id', 'created_at']
回滚一版后 due_at 列真的消失了,再 upgrade head 又回来。这就是「迁移可回滚」的验证标准:不是看代码,而是看数据库的实际列。
两条实战口径:生产迁移只前滚不轻易回滚(downgrade 会丢数据,回滚应该用「新写一版补偿迁移」而不是 downgrade);SQLite 的 DDL 有坑——它不支持 ALTER COLUMN,改列类型要建新表再搬数据,Alembic 生成 SQLite 迁移时需要 render_as_batch=True。切到 PostgreSQL 后这些限制消失,但本机无 PG 服务端,PG 专属行为未实测。
18.3.3 健康检查与优雅关闭
真实启动与关闭日志(SIGTERM 触发):
INFO: Started server process [58074]
INFO: Waiting for application startup.
2026-10-09 12:11:08,394 INFO database ready
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8019 (Press CTRL+C to quit)
INFO: Shutting down
INFO: Waiting for application shutdown.
2026-10-09 12:11:08,604 INFO shutdown complete
INFO: Application shutdown complete.
关键在 lifespan 的两端:启动时建好资源、关闭时收尾,而不是在请求里临时创建:
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
log.info("database ready")
yield
await tasks.worker() # 关闭前把队列里的任务处理完
await engine.dispose() # 释放连接池
log.info("shutdown complete")
收到 SIGTERM 后 uvicorn 会先停止接收新连接,等进行中的请求结束,再跑 lifespan 的 yield 之后那段——所以 await tasks.worker() 能把积压任务处理掉,engine.dispose() 干净释放连接。K8s 的探针就靠 /healthz,而优雅关闭决定了滚动更新时会不会丢请求。
18.3.4 上线后的可观测性接入点
上线不是终点,是「开始能观测」的起点。三个接入点在前面章节都已备好,这里只列落地点:
| 维度 | 工具 | 落地点 |
|---|---|---|
| 日志 | 结构化日志 | 请求中间件记录方法/路径/耗时/状态码 |
| 指标 | prometheus-client 0.26.0 | /metrics 暴露 QPS、延迟直方图、错误计数 |
| 追踪 | opentelemetry-sdk 1.45.1 | FastAPI 自动埋点,跨服务串联 trace_id |
本项目已经埋了最小可用的一个:中间件把每请求耗时写进 X-Process-Time-ms 响应头(真实代码):
@app.middleware("http")
async def add_timing(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
response.headers["X-Process-Time-ms"] = f"{(time.perf_counter() - start) * 1000:.2f}"
return response
真实联调时它帮我们确认了缓存命中把延迟从 13 ms 压到 0.97 ms——没有这个头,就只能靠猜。生产里把它升级成 Prometheus 直方图,就能画出 P50/P95/P99 曲线:
from prometheus_client import CONTENT_TYPE_LATEST, Histogram, generate_latest
REQ_LATENCY = Histogram("http_request_seconds", "请求耗时", ["method", "status"])
@app.middleware("http")
async def observe(request: Request, call_next):
response = await call_next(request)
REQ_LATENCY.labels(request.method, str(response.status_code)).observe(
response.headers.get("X-Process-Time-ms", 0) # 简化示意
)
return response
@app.get("/metrics", include_in_schema=False)
async def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
(prometheus_client 已随环境安装,上面这段是接入示意,未跑完整抓取链路;/metrics 的完整实践见第 3 章。)
18.3.5 复盘:做对了什么、踩了什么坑
复盘不是走过场,而是把「这次的运气」变成「下次的流程」。TaskFlow 开发中真实踩到四个坑,都值得记下来:
| 坑 | 现象 | 根因 | 修法 |
|---|---|---|---|
| httpx 走代理 | 本地请求返回 503,body 是 Squid 错误页 | httpx 默认 trust_env=True,读到了系统代理 | 本地调用显式 trust_env=False |
| Enum 名值错位 | 库里存 OPEN,接口返回 open | SQLAlchemy Enum 默认按成员名落库 | 明确知晓,或加 values_callable |
| 422 而非 401 | 密码太短时登录返回 422 | Pydantic 校验在认证逻辑之前 | 属预期行为,测试里显式断言 |
| 迁移版本失联 | alembic current 为空、downgrade -1 报错 | create_all 建表但没写 alembic_version | 生产只走 alembic upgrade,别用 create_all |
前三个坑在 18.2 的联调里被抓出来;第四个尤其值得记:Base.metadata.create_all() 和 Alembic 不能混用——前者建表但不登记版本号,导致 Alembic 以为数据库还在 base,downgrade -1 会报 Relative revision -1 didn't produce 1 migrations。测试环境用 create_all 图快可以,生产环境必须只用 alembic upgrade head。
复盘模板可以固定成三栏:
## 迭代复盘 <版本号>
### 做对了什么(保持)
- 契约先行:接口先定死,前后端不互相等
- 租户隔离收敛到一个函数,越权返回 404
- 后台任务一次做齐幂等/重试/死信
### 踩了什么坑(改进)
- httpx 默认走系统代理,本地联调被拦 -> 测试脚本统一 trust_env=False
- create_all 与 alembic 混用导致版本失联 -> 生产禁用 create_all
### 下一步(行动项)
- 补 /metrics 与 OpenTelemetry 埋点
- 给登录接口加限流(scrypt 每次约 36 ms,易被刷)
「做对了什么」要写成可保持的机制(不是「大家很努力」);「踩了什么坑」要落到可执行的改进;「下一步」每条都要有负责人和期限,否则只是许愿。
18.3.6 后续演进方向
TaskFlow 是 MVP,不是终点。按「先补短板、再谈拆分」的顺序演进:
| 方向 | 触发条件 | 做法 | 代价 |
|---|---|---|---|
| 换真基础设施 | 本地验证完成 | SQLite → PostgreSQL,fakeredis → 真 Redis | 连接串与方言需重测 |
| 补可观测性 | 上线即做 | /metrics + OTel 埋点 | 少量性能开销 |
| 加限流 | 登录被刷 | 令牌桶(第 16 章) | 需共享计数存储 |
| 多租户强化 | 团队扩张 | owner_id → tenant_id + 行级隔离 | 迁移 + 全表加索引 |
| 拆服务 | 单模块成为瓶颈 | 通知/报表拆独立服务,走异步消息 | 分布式复杂度陡增 |
| 审计与合规 | 有合规要求 | 写操作留审计日志 | 存储与隐私成本 |
顺序很重要:先「换真基础设施 + 补可观测性」,因为这两件是其他一切的前提——没有真数据库测不出真瓶颈,没有指标不知道要不要拆服务。「拆微服务」永远排最后,因为拆分会把「函数调用」变成「网络调用」,把「本地事务」变成「分布式事务」,复杂度陡增而收益未必成正比。第 5 章和第 17 章都讲过:单体分层的边界划清楚,将来拆分才不痛。
延伸阅读
- Python 微服务架构:拆分、通信与治理 —— 什么时候该拆、怎么拆
- Python 设计模式:常用模式与工程落地 —— 演进中反复用到的模式
- 灰度发布、回滚与故障演练 —— 上线的安全网
- 指标、健康检查与告警接入 —— 可观测性的第一步
小结
- 上线靠清单而非记忆:配置、密钥、依赖、迁移、探针、关闭、日志、权限、调试、限流,逐项勾。
- 迁移必须验证可回滚(看数据库实际列,不是看代码);生产只前滚,回滚用补偿迁移;
create_all与 Alembic 不能混用。 lifespan的关闭段决定优雅关闭:先停接收、再 flush 队列、最后释放连接池。- 可观测性三件套(日志/指标/追踪)上线即接,
X-Process-Time-ms是最小可用起点。 - 复盘要落到可保持的机制与可执行的行动项,而不是感想。
- 演进顺序:先补基础设施与可观测性,最后才拆服务。
全书到这里收束。 18 章走下来,你手上有的不只是一堆 API,而是一条完整的工程链:从 uv 建项目、pydantic-settings 管配置、pytest 保质量,到 FastAPI + SQLAlchemy 写服务、Redis + 任务队列扛异步、Docker + uvicorn 上生产、剖析 + 可观测性守运行。TaskFlow 只是这条链的一个最小样例——把它的每一步换成你真实的业务,方法论不变。真正的成长来自「把系统跑起来、看它出问题、再修好」的循环。祝你的下一次交付顺利上线。
阅读导航:上一节:迭代开发、联调与压测 · 下一节:《Python编程实战》目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。