别再手动上线了:一条命令带备份、健康检查和自动回滚
.env该有多少配置、Docker 起不来怎么用原生进程兜底、日志怎么做到"出问题时一个 ID 定位"。
「AI Agent 工程化实战」系列 · 11 项目源码:Ticnix/weather-travel-recommend-system 基于气象大数据的出行推荐系统,AI Agent 全栈项目。FastAPI + PostgreSQL(TimescaleDB/pgvector/PostGIS) + Redis; LangGraph + MCP + Skill + RAG,对接 DeepSeek API;React/Vue 前后端分离,实现 3D 天气可视化、智能出行穿搭推荐。 (项目仍在更新中)
TL;DR
- 配置只有一个
.env,但必须守住三条线 :Key 不进仓库、未配 Key 不阻断启动、容器内的地址归 compose 管。项目用.env(开发)+.env.prod.example(模板)+ compose 的environment覆盖(容器网络地址)三段式解决。 - "上线"应该是一条命令 :备份 → 停应用 → 拉镜像 → 启动 → 健康检查 → 失败自动回滚。回滚不需要重新拉镜像------旧版本镜像就在本地,所以是秒级的。
- 日志要能"用一个 ID 串起一次请求" :
contextvars生成 request_id、贯穿所有 service 层日志、输出 JSON 单行、指标按路由模板聚合(不是真实 path)。
目录
- 一、先看那条"一条命令上线"的脚本
- [二、配置管理:一个 .env 里的三条线](#二、配置管理:一个 .env 里的三条线 "#%E4%BA%8C%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E4%B8%80%E4%B8%AA-env-%E9%87%8C%E7%9A%84%E4%B8%89%E6%9D%A1%E7%BA%BF")
- [三、Docker 起不来怎么办:原生进程兜底](#三、Docker 起不来怎么办:原生进程兜底 "#%E4%B8%89docker-%E8%B5%B7%E4%B8%8D%E6%9D%A5%E6%80%8E%E4%B9%88%E5%8A%9E%E5%8E%9F%E7%94%9F%E8%BF%9B%E7%A8%8B%E5%85%9C%E5%BA%95")
- [四、日志:一个 request_id 串起一次请求](#四、日志:一个 request_id 串起一次请求 "#%E5%9B%9B%E6%97%A5%E5%BF%97%E4%B8%80%E4%B8%AA-request_id-%E4%B8%B2%E8%B5%B7%E4%B8%80%E6%AC%A1%E8%AF%B7%E6%B1%82")
- 五、CI:坏代码进不了主干
- 六、小结与下一篇
一、先看那条"一条命令上线"的脚本
deploy.sh 的头部注释把设计意图写得很清楚,我直接引用:
bash
# deploy.sh ------ 一条命令完成上线:
# 备份数据库 → 停应用 → 拉取镜像 → 启动 → 健康检查 → 失败自动回滚
#
# 用法:
# ./deploy.sh # 部署主干最新版(latest)
# IMAGE_TAG=sha-4c3abe9 ./deploy.sh # 部署指定版本
# IMAGE_TAG=sha-1234567 ./deploy.sh # 同一条命令就是回滚:换个旧 tag 再跑
#
# 退出码:
# 0 部署成功
# 1 部署失败(已自动回滚到上一版本)
# 2 部署失败且回滚也失败 ------ 需要人工介入,此时数据库已有部署前备份
注意"同一条命令就是回滚" ------ 这是个很聪明的设计:不写单独的 rollback 脚本,而是把"部署哪个版本"变成参数。少一个脚本,少一处可能忘记维护的逻辑。
三个值得抄的决策
① 备份放在"停应用之后、换镜像之前"。
bash
# 设计说明:
# - 备份放在「停应用之后、换镜像之前」:应用停止后数据不再变化,
# pg_dump 拿到的是一致性快照;停机窗口本来就等于部署窗口,不额外损失
"应用停止后数据不再变化" ------ 这句是备份正确性的前提。如果边写边 dump,拿到的快照可能跨在两次事务中间。
② 备份要校验"真的能用",不只检查文件存在。
bash
# 校验备份真的可用:非空 + gzip 完整性。防止「备份了个寂寞」后继续往下走
[ -s "$BACKUP_FILE" ] || fail "备份文件为空,中止部署"
gzip -t "$BACKUP_FILE" || fail "备份文件损坏(gzip 校验未通过),中止部署"
"防止『备份了个寂寞』" ------ 磁盘满或 pg_dump 中途挂了,会留下一个 0 字节或截断的文件。不校验的话,你会在最需要它的那一刻才发现它没用。
③ 回滚不重新 pull。
bash
# - 回滚不重新 pull:旧版本的镜像在本地一定存在(刚才就是它跑着的),
# 所以回滚不需要网络,秒级完成
# - 数据库/Redis 容器全程不重启,业务数据卷不受影响
整个流程是这样:
一个小功能:让回滚流程可以被演练
健康检查函数有个"强制失败"开关:
bash
# 参数 $1:是否强制判定失败("1"=演练用)。注意只有「新版本」的检查受它影响,
# 回滚后的检查永远真实探测------否则演练时无法验证"回滚后服务真的恢复了"。
check_health() {
local force_fail="${1:-0}"
...
if [ "$force_fail" != "1" ] && curl -fsS ... ; then
用法是 FORCE_HEALTHCHECK_FAIL=1 ./deploy.sh。
注意括号里那句 :强制失败只作用于"新版本"的检查,回滚后的检查永远真实探测。否则演练就只能演练到"我以为回滚成功了"------这半句才是这个功能真正的价值。
而且首次部署失败时,脚本不假装回滚:
bash
if [ "$OLD_TAG" = "未知" ]; then
# 首次部署没有"上一版本"可回滚------如实报告,不假装恢复
printf '[deploy][错误] 这是首次部署且健康检查失败,没有旧版本可回滚。\n' >&2
...
exit 2
fi
"如实报告,不假装恢复" ------ 退出码 2 的含义就是"需要人工介入",跟退出码 1(已自动恢复)区分开。
二、配置管理:一个 .env 里的三条线
项目的环境变量分三处,各自职责明确:
| 文件 | 职责 | 是否入库 |
|---|---|---|
backend/.env |
本地开发全部配置 | ❌ 被 .gitignore 忽略 |
backend/.env.prod.example |
生产模板(只列会变的敏感项) | ✅ 入库 |
docker-compose.yml 的 environment |
容器网络地址覆盖 | ✅ 入库 |
第一条线:Key 不进仓库
.gitignore 里对应的规则很直接:
gitignore
.venv/
.env
# 各环境配置(.env.prod / .env.dev 等):敏感值一律不入库,参考 *.example
.env.*
*.log
注释里那句"参考 *.example"是关键的配套动作:光忽略不够,还得给一个模板,否则新人不知道该配哪些项。
模板文件自己就说了这件事:
bash
# ============================================================
# 生产环境配置示例
#
# 使用方式:
# 1. 复制本文件为 backend/.env.prod,填入真实值
# (.env.* 已被 .gitignore 忽略,真实 Key 不会进入仓库)
# 2. 部署时指定:
# ENV_FILE=./backend/.env.prod ./deploy.sh
# ============================================================
于是 deploy.sh 和 docker-compose.yml 之间就靠 ENV_FILE 这个变量串联:
yaml
env_file:
# 部署时可指定环境配置:ENV_FILE=./backend/.env.prod ./deploy.sh
# 本地开发默认使用 backend/.env
- ${ENV_FILE:-./backend/.env}
${ENV_FILE:-./backend/.env} 是 compose 的默认值语法:不传就用开发配置,传了就用生产的。一行搞定两套环境。
第二条线:未配 Key 不阻断启动
.env 里的注释写得非常明确:
bash
# ===== DeepSeek(Key 仅存后端,勿提交 git / 勿暴露前端)=====
# 请填入你自己的 Key;留空时 AI 相关功能不可用,其余模块不受影响
DEEPSEEK_API_KEY=
README 里也是同一条承诺:
复制
backend/.env并填写需要的 Key(详见下节)。未配置 Key 的功能会自动降级,不会导致启动失败。
这个约定配合第 09 篇的四层降级才有意义 ------ 降级做扎实了,"缺 Key"就只是"某个功能不可用",而不是"整个服务起不来"。降级设计在这里从"容错"变成了"可配置性"的基础。
第三条线:容器内的地址归 compose 管
这是最容易出错的一条。.env 里写的是本地开发地址:
bash
DB_URL=postgresql+asyncpg://admin:123456@127.0.0.1:5432/weather_db
REDIS_URL=redis://127.0.0.1:6379/0
但在容器里 127.0.0.1 指的是容器自己。所以 compose 用 environment 段覆盖:
yaml
environment:
# 容器内需用 compose 服务名互访,覆盖 .env 中本地开发的 127.0.0.1
DB_URL: postgresql+asyncpg://admin:123456@postgres:5432/weather_db
REDIS_URL: redis://redis:6379/0
而 .env.prod.example 里刻意不放这两个值,模板头部解释了原因:
bash
# 与开发的区别:只放「会变的敏感配置」。数据库/Redis 连接串
# 由 docker-compose.yml 的 environment 段覆盖为容器网络地址,
# 不需要(也不应该)写在这里。
"不需要,也不应该" ------ 两套环境连的是不同主机,这个差异归编排层管;.env 只管"会变的敏感值"。
三段式的价值在于每一层只有一个职责 :模板告诉你缺什么,
.env放你的真实值,compose 管拓扑差异。混在一起就会出现"我明明改了.env为什么不生效"这种无从排查的问题。
三、Docker 起不来怎么办:原生进程兜底
这是很多个人项目的真实处境(我的机器就是缺 CPU 虚拟化支持,Docker Desktop 起不来)。项目有一组 PowerShell 脚本做兜底。
场景一:改一行文案,不想等镜像重建
dev-user.ps1 的注释直指痛点:
powershell
# 为什么需要它:改一行文案就要重建镜像 + 重跑测试时,一轮十几分钟。
# vite dev 是热更新------改完保存、浏览器刷新即可,秒级;而且开发模式
# 不启用 Service Worker,不会出现"改了没生效、其实是缓存"的经典坑。
#
# 两个地址的分工:
# 5173 开发用:热更新,改完即见(不跑测试、不用重建镜像)
# 8080 正式用:Docker 镜像里构建好的静态包,只在你验收/发布时才重建
"改了没生效,其实是缓存" ------ Service Worker 缓存是前端调试的经典陷阱。注释里点明"开发模式不启用 SW",把这条经验固化了。
脚本还顺手解决了 Node 版本问题:
powershell
# 挑一个 Node 20+:优先 PATH,其次工具自带的多版本目录
...
if (-not $node) {
Write-Host '未找到 Node 20+(Vite 8 跑不了 Node 18),请先安装 Node 20 或 22' -ForegroundColor Red
exit 1
}
"Vite 8 跑不了 Node 18" ------ 与其让 Vite 抛一个难懂的错,不如启动前先检查版本、给一句人话。
场景二:Celery 在 Windows 上直接跑不起来
这是个高频坑。start_celery.ps1 的解法是多加一个 -P solo:
powershell
Start-Process -FilePath ".\venv\Scripts\python.exe" `
-ArgumentList "-m", "celery", "-A", "app.celery_app", "worker", "-l", "info", "-P", "solo" `
-P solo 是 Celery 的单进程执行池。Windows 上默认的 prefork 池依赖 fork(),而 Windows 没有 fork------不加这个参数会直接起不来。
README 里也留了同一句提醒:
bash
# Celery worker(Windows 需加 -P solo)
celery -A app.celery_app worker -l info -P solo
场景三:想给别人看,但没服务器
start-public.ps1 用 Cloudflare 免费隧道把本机端口暴露到公网:
powershell
# 原理:项目用 docker compose 跑在本机,再通过 Cloudflare 免费隧道
# (cloudflared quick tunnel)把本机端口暴露到公网。
# 无需服务器、无需备案、无需公网IP。
评论区里两个细节体现了安全意识:
powershell
# 3. 管理端默认不对外暴露(含 admin 账号,暴露到公网有风险)
# 如需暴露,执行 .\start-public.ps1 -ExposeAdmin
默认安全,需要时显式打开 ------ 这是个好默认值。
还有一个探活判据的选择:
powershell
# 用真实后端接口探活:前端 nginx 有 SPA 回退,访问 /health 也会返回 200,不能作判据
$r = Invoke-WebRequest -Uri "http://localhost:$UserPort/api/v1/weather/current" ...
"前端 nginx 有 SPA 回退,访问 /health 也会返回 200" ------ 这是很隐蔽的一个坑:用前端地址探活会永远"成功",因为任何路径都被 SPA 回退成 index.html 了。必须探一个真实的后端接口。
四、日志:一个 request_id 串起一次请求
第 03 篇讲过 contextvars 做用户隔离。同一个机制在这里用于可观测性------而且这次它解决的问题更直观。
observability.py 开头把三件事说得清清楚楚:
python
"""可观测性基础设施:请求 ID 贯穿、结构化日志、轻量指标。
三个部件,解决三个问题:
1. **request_id 贯穿**
每个请求生成唯一 ID(或继承上游的 X-Request-ID),通过 contextvars
在整个调用链传播------包括所有 service 层日志。排查问题时「用一个 ID
就能串起一次请求的全部日志」,不用再靠时间戳猜。
2. **结构化日志**
日志输出为 JSON 单行(字段固定:ts/level/logger/msg/request_id +
调用方通过 extra 传入的业务字段),便于日志采集与检索。
3. **轻量指标(/metrics)**
进程内计数器:按路由统计请求数 / 错误数 / 总耗时。
不引入 Prometheus 等依赖------当前「看调用量与错误率」的需求,
几十行代码即可满足,等真有可视化需求时再升级采集方式,对外接口不变。
"""
"不用再靠时间戳猜" ------ 这句话就是全部动机。没有 request_id 时,排查靠"大概那个时间点附近的日志",有并发请求就混乱了。
怎么做到"所有日志都带 request_id"
关键是 logging.Filter------它能在每条日志落盘前注入字段:
python
class RequestIdFilter(logging.Filter):
"""把 request_id 注入每条日志记录,供 Formatter 取用。"""
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_var.get()
return True
这就绕开了"每个 logger.info() 都要手动带 request_id"的麻烦 ------ 业务代码完全无感,只要它跑在请求上下文里,日志就自带 ID。
JSON 单行日志:extra 字段走白名单
python
class JsonFormatter(logging.Formatter):
"""JSON 单行日志。
extra 字段走白名单:业务代码通过 `logger.info("...", extra={"path": ...})`
传业务字段,这里只输出白名单内的键------否则 logging 内部自带的
几十个属性(funcName/lineno/msecs...)会把每行日志撑爆。
"""
_EXTRA_KEYS = ("method", "path", "route", "status", "duration_ms",
"task_id", "task", "retries")
"否则会把每行日志撑爆" ------ 如果直接把 record.__dict__ 全序列化,每行会带上几十个 logging 内部属性(funcName、lineno、msecs...),日志体积翻好几倍。
有意思的是这个白名单里同时有 task_id / retries------HTTP 请求的字段和 Celery 任务的字段共用一份 formatter ,所以第 09 篇那个 task_failure 告警的结构化日志是直接能用的。
指标按"路由模板"聚合,不是真实 path
这是整个模块里我觉得最见功力的一处:
python
# 用路由模板而不是真实 path 聚合指标------
# 否则 /news/84、/news/85 会被聚成两条,指标失去意义
route = getattr(request.scope.get("route"), "path", request.url.path)
如果没有这一行 ,/api/v1/news/84、/api/v1/news/85......每一个 ID 都会变成一条独立的指标记录。指标面板上会有几千条"只被访问过一次"的路由,完全失去统计意义。
而 request.scope["route"].path 拿到的是路由模板 (/api/v1/news/{news_id}),所以所有同类请求会聚到一条上。
还有个容易忽略的细节------为什么不用 uvicorn 自带的 access log:
python
def setup_logging() -> None:
"""...
关闭 uvicorn.access:请求日志由我们的中间件输出(带 request_id/耗时/路由),
两份 access log 内容重复只会干扰检索。
"""
logging.getLogger("uvicorn.access").disabled = True
配合 compose 里的启动参数:
yaml
# --no-access-log:请求日志由应用中间件输出(含 request_id/耗时/路由模板),
# uvicorn 自带的 access log 与其重复且无 request_id,只会在日志里制造噪音
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log
两处都要动 :代码里 disabled = True + 启动参数 --no-access-log。只做一处的话,另一路日志还是会冒出来。
最后还有一个 Windows 专属的小坑:
python
# Windows 下 stdout/stderr 重定向到文件时默认用本地编码(GBK),
# JSON 里的中文会变成一堆问号;Linux 容器本来就是 UTF-8,reconfigure 无副作用
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
with contextlib.suppress(Exception):
stream.reconfigure(encoding="utf-8")
"JSON 里的中文会变成一堆问号" ------ 而 contextlib.suppress 是必要的防御:不是所有流都支持 reconfigure。
五、CI:坏代码进不了主干
ci.yml 的三个层次,注释里表述得很精炼:
yaml
# 分三个层次,由快到慢、由便宜到贵:
# 1. lint 几秒钟 ------ 格式、未使用变量、明显的坏味道
# 2. test 几分钟 ------ 442 个用例(后端 379 + 前端单测 41 + E2E 由本地/部署后跑)
# 3. build 几分钟 ------ 四个镜像能不能构建出来
#
# 为什么 lint 和 test 拆成不同 job:
# lint 只要 10 秒,test 要 2 分钟。拆开后 lint 失败会立刻反馈,
# 不用等测试跑完;而且两者并行执行,总耗时取较慢的那个。
"lint 失败会立刻反馈" ------ 把快的放前面单独成 job,不是为了省时间(并行跑总时长取最慢的那个),而是为了让人更快知道最蠢的那类错误。
发布和回滚的闭环
yaml
release:
needs: [backend-lint, backend-test, frontend]
if: github.event_name == 'push'
permissions:
contents: read
packages: write
needs 保证"测试没过的代码不配被发布"。然后标签策略是回滚能力的基础:
yaml
# 标签策略(可回滚的关键):
# 应用镜像 → latest(始终指向主干最新)+ sha-<短hash>(与提交一一对应)
# 回滚时只要用旧提交的 sha-xxx tag 重新执行 deploy.sh 即可。
# postgres 镜像随 PG 版本走(pg17),不随应用版本变化,用固定 tag。
sha-<短hash> 是回滚的锚点。 只有 latest 的话,你无法回到"上一个具体版本"。
几个实用的 CI 细节
① 失败的日志要打进注解区,因为完整日志需要登录。
yaml
# 失败时把首尾日志输出成注解:pytest 的"用法错误"(exit 4) 会把原因
# 写在最前面,而"测试失败"的信息在最后面,两头都取才够定位。
head -12 pytest-output.log | while IFS= read -r line; do
echo "::error::[头部] $line"
done
"两头都取" ------ 这个观察很准:pytest 退出码 4(用法错误)的信息在最前面,而用例失败的信息在最后面。只 tail 会漏掉前者。
② 连发多次推送时,取消上一次没跑完的流水线。
yaml
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# 好处:省额度,且不会出现"旧提交的检查结果覆盖新提交"的困惑
③ 前端的 pre-commit 钩子不能用 npm 命令。
yaml
# 用本地 Python 脚本而不是直接写 npm 命令,原因见脚本注释:
# Windows 下 npm 实际是 npm.cmd,pre-commit 按字符串找可执行文件会找不到。
"npm 实际是 npm.cmd" ------ pre-commit 按可执行文件名查找,找不到 .cmd。这类平台差异只能靠踩坑积累。
六、小结与下一篇
可复用清单
| 场景 | 做法 |
|---|---|
| 环境配置 | 三段式:.env.example 模板 + .env 真实值(gitignore)+ compose 覆盖拓扑差异 |
| 敏感值 | .gitignore 里忽略,但必须给模板文件,否则新人不知道配什么 |
| 缺配置 | 未配 Key 应降级而非阻断启动(前提是降级做扎实了) |
| 上线脚本 | 备份 → 停应用 → 拉镜像 → 启动 → 健康检查 → 失败自动回滚 |
| 备份时机 | 放在停应用之后,数据不再变化时 dump 才是一致性快照 |
| 备份校验 | 非空 + 压缩完整性,别只检查文件存在------防"备份了个寂寞" |
| 回滚 | 不重新拉镜像(旧镜像在本地),秒级完成;换 tag 就是回滚 |
| 演练 | 提供一个"强制失败"开关,但回滚后的检查必须真实探测 |
| 难懂的错误 | 启动前检查前置条件(Node 版本等),给一句人话而不是让框架抛异常 |
| Windows + Celery | worker 必须加 -P solo(默认 prefork 依赖 fork()) |
| 前端探活 | 别探前端地址------SPA 回退会让任何路径都返回 200,要探真实后端接口 |
| 请求日志 | contextvars + logging.Filter 自动注入 request_id,业务代码无感 |
| JSON 日志 | extra 字段走白名单,不要全序列化(logging 内部属性会撑爆日志) |
| 指标聚合 | 用路由模板 (/news/{id})而不是真实 path,否则指标全是长尾 |
| access log | 代码里 disable + 启动参数 --no-access-log,两处都要改 |
| 镜像标签 | latest + sha-<短hash> 双标签,后者是回滚的锚点 |
| CI 分层 | 快的单独成 job 先反馈;失败日志打注解(完整日志要登录才能看) |
下一篇预告
运维这条线还能再往下走一层:数据库迁移与数据演化。项目用了 Alembic,但表结构里还有 TimescaleDB 超表、PostGIS 地理字段、pgvector 向量列这些"非标准 DDL"------它们怎么跟迁移脚本配合?改字段时历史数据怎么办?以及"测试库为什么每次都要重建"这个决定的真实代价。要的话我接着写,还是这个体量。
参考资料
- Docker Compose 官方文档 · Environment variables ------
${VAR:-default}默认值语法、env_file与environment的优先级关系 docs.docker.com/compose/env... - Cloudflare 官方文档 · TryCloudflare ------ 免账号的临时隧道(quick tunnel),域名每次变化 developers.cloudflare.com/cloudflare-...
- Celery 官方文档 · Workers 的
--pool参数 ------solo池的适用场景(Windows 无fork()) docs.celeryq.dev/en/stable/u... - Python 官方文档 ·
contextvars------ContextVar.set/reset与 asyncio 的上下文隔离 docs.python.org/3/library/c... - Python 官方文档 ·
logging.Filter------ 在日志落盘前修改LogRecorddocs.python.org/3/library/l... - GitHub Actions 官方文档 · Workflow 语法 ------
concurrency取消并发运行、needs依赖、::notice::与::error::注解 docs.github.com/en/actions/... - 项目源码(仍在更新中) ------ Ticnix/weather-travel-recommend-system github.com/Ticnix/weat...