别再手动上线了:一条命令带备份、健康检查和自动回滚

文章来源声明: 原文作者:Ticnix; 来源站点:掘金; 原文链接:https://juejin.cn/post/7687513961740959753; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

可复用清单式总结,适合想给个人或小团队项目补上部署、回滚与可观测性短板的开发者,尤其推荐 Docker 起不来、排查日志靠猜时间戳的同学对着逐条落地。

别再手动上线了:一条命令带备份、健康检查和自动回滚 -------------------------

.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

  1. 配置只有一个 .env,但必须守住三条线:Key 不进仓库、未配 Key 不阻断启动、容器内的地址归 compose 管。项目用 .env(开发)+ .env.prod.example(模板)+ compose 的 environment 覆盖(容器网络地址)三段式解决。
  2. "上线"应该是一条命令:备份 → 停应用 → 拉镜像 → 启动 → 健康检查 → 失败自动回滚。回滚不需要重新拉镜像——旧版本镜像就在本地,所以是秒级的。
  3. 日志要能"用一个 ID 串起一次请求"contextvars 生成 request_id、贯穿所有 service 层日志、输出 JSON 单行、指标按路由模板聚合(不是真实 path)。

目录


一、先看那条"一条命令上线"的脚本

deploy.sh 的头部注释把设计意图写得很清楚,我直接引用:

<span># deploy.sh —— 一条命令完成上线:</span>
<span>#   备份数据库 → 停应用 → 拉取镜像 → 启动 → 健康检查 → 失败自动回滚</span>
<span>#</span>
<span># 用法:</span>
<span>#   ./deploy.sh                             # 部署主干最新版(latest)</span>
<span>#   IMAGE_TAG=sha-4c3abe9 ./deploy.sh       # 部署指定版本</span>
<span>#   IMAGE_TAG=sha-1234567 ./deploy.sh       # 同一条命令就是回滚:换个旧 tag 再跑</span>
<span>#</span>
<span># 退出码:</span>
<span>#   0  部署成功</span>
<span>#   1  部署失败(已自动回滚到上一版本)</span>
<span>#   2  部署失败且回滚也失败 —— 需要人工介入,此时数据库已有部署前备份</span>

注意"同一条命令就是回滚" —— 这是个很聪明的设计:不写单独的 rollback 脚本,而是把"部署哪个版本"变成参数。少一个脚本,少一处可能忘记维护的逻辑。

三个值得抄的决策

① 备份放在"停应用之后、换镜像之前"。

<span># 设计说明:</span>
<span>#   - 备份放在「停应用之后、换镜像之前」:应用停止后数据不再变化,</span>
<span>#     pg_dump 拿到的是一致性快照;停机窗口本来就等于部署窗口,不额外损失</span>

"应用停止后数据不再变化" —— 这句是备份正确性的前提。如果边写边 dump,拿到的快照可能跨在两次事务中间。

② 备份要校验"真的能用",不只检查文件存在。

<span># 校验备份真的可用:非空 + gzip 完整性。防止「备份了个寂寞」后继续往下走</span>
[ -s <span>"<span>$BACKUP_FILE</span>"</span> ] || fail <span>"备份文件为空,中止部署"</span>
gzip -t <span>"<span>$BACKUP_FILE</span>"</span> || fail <span>"备份文件损坏(gzip 校验未通过),中止部署"</span>

"防止『备份了个寂寞』" —— 磁盘满或 pg_dump 中途挂了,会留下一个 0 字节或截断的文件。不校验的话,你会在最需要它的那一刻才发现它没用。

③ 回滚不重新 pull。

<span>#   - 回滚不重新 pull:旧版本的镜像在本地一定存在(刚才就是它跑着的),</span>
<span>#     所以回滚不需要网络,秒级完成</span>
<span>#   - 数据库/Redis 容器全程不重启,业务数据卷不受影响</span>

整个流程是这样:

flowchart TB
    S[&#34;开始部署&#34;] --> A[&#34;记录当前版本<br/>.last_deployed_tag&#34;]
    A --> B[&#34;停应用容器<br/>postgres/redis 不动&#34;]
    B --> C[&#34;pg_dump 备份<br/>+ 校验 + 清理过期&#34;]
    C --> D{&#34;备份可用吗&#34;}
    D -->|&#34;否&#34;| X1[&#34;中止部署<br/>数据未受影响&#34;]
    D -->|&#34;是&#34;| E[&#34;拉取新镜像&#34;]
    E --> F[&#34;启动新版本&#34;]
    F --> G{&#34;健康检查<br/>三个入口&#34;}
    G -->|&#34;通过&#34;| Y1[&#34;写入版本记录<br/>退出码 0&#34;]
    G -->|&#34;失败&#34;| H{&#34;有旧版本吗&#34;}
    H -->|&#34;无(首次部署)&#34;| X2[&#34;如实报告<br/>退出码 2&#34;]
    H -->|&#34;有&#34;| I[&#34;用旧 tag 重启<br/>不重新拉取&#34;]
    I --> J{&#34;再次健康检查&#34;}
    J -->|&#34;通过&#34;| Y2[&#34;已回滚·退出码 1&#34;]
    J -->|&#34;失败&#34;| X2

    style Y1 fill:#E1F5EE,stroke:#0F6E56,color:#04342C
    style Y2 fill:#FAEEDA,stroke:#854F0B,color:#633806
    style X1 fill:#FCEBEB,stroke:#A32D2D,color:#501313
    style X2 fill:#FCEBEB,stroke:#A32D2D,color:#501313
    style G fill:#E6F1FB,stroke:#185FA5,color:#0C447C
    style J fill:#E6F1FB,stroke:#185FA5,color:#0C447C
    style D fill:#E6F1FB,stroke:#185FA5,color:#0C447C
    style H fill:#E6F1FB,stroke:#185FA5,color:#0C447C

一个小功能:让回滚流程可以被演练

健康检查函数有个"强制失败"开关:

<span># 参数 $1:是否强制判定失败("1"=演练用)。注意只有「新版本」的检查受它影响,</span>
<span># 回滚后的检查永远真实探测——否则演练时无法验证"回滚后服务真的恢复了"。</span>
<span><span>check_health</span></span>() {
  <span>local</span> force_fail=<span>"<span>${1:-0}</span>"</span>
  ...
  <span>if</span> [ <span>"<span>$force_fail</span>"</span> != <span>"1"</span> ] && curl -fsS ... ; <span>then</span>

用法是 FORCE_HEALTHCHECK_FAIL=1 ./deploy.sh

注意括号里那句:强制失败只作用于"新版本"的检查,回滚后的检查永远真实探测。否则演练就只能演练到"我以为回滚成功了"——这半句才是这个功能真正的价值。

而且首次部署失败时,脚本不假装回滚

<span>if</span> [ <span>"<span>$OLD_TAG</span>"</span> = <span>"未知"</span> ]; <span>then</span>
  <span># 首次部署没有"上一版本"可回滚——如实报告,不假装恢复</span>
  <span>printf</span> <span>'[deploy][错误] 这是首次部署且健康检查失败,没有旧版本可回滚。\n'</span> >&2
  ...
  <span>exit</span> 2
<span>fi</span>

"如实报告,不假装恢复" —— 退出码 2 的含义就是"需要人工介入",跟退出码 1(已自动恢复)区分开。


二、配置管理:一个 .env 里的三条线

项目的环境变量分三处,各自职责明确:

文件职责是否入库
`backend/.env`本地开发全部配置❌ 被 `.gitignore` 忽略
`backend/.env.prod.example`生产模板(**只列会变的敏感项**)✅ 入库
`docker-compose.yml` 的 `environment`容器网络地址覆盖✅ 入库

第一条线:Key 不进仓库

.gitignore 里对应的规则很直接:

.venv/
.env
# 各环境配置(.env.prod / .env.dev 等):敏感值一律不入库,参考 *.example
.env.*
*.log

注释里那句"参考 *.example"是关键的配套动作:光忽略不够,还得给一个模板,否则新人不知道该配哪些项。

模板文件自己就说了这件事:

<span># ============================================================</span>
<span># 生产环境配置示例</span>
<span>#</span>
<span># 使用方式:</span>
<span>#   1. 复制本文件为 backend/.env.prod,填入真实值</span>
<span>#      (.env.* 已被 .gitignore 忽略,真实 Key 不会进入仓库)</span>
<span>#   2. 部署时指定:</span>
<span>#        ENV_FILE=./backend/.env.prod ./deploy.sh</span>
<span># ============================================================</span>

于是 deploy.shdocker-compose.yml 之间就靠 ENV_FILE 这个变量串联:

    <span>env_file:</span>
      <span># 部署时可指定环境配置:ENV_FILE=./backend/.env.prod ./deploy.sh</span>
      <span># 本地开发默认使用 backend/.env</span>
      <span>-</span> <span>${ENV_FILE:-./backend/.env}</span>

${ENV_FILE:-./backend/.env} 是 compose 的默认值语法:不传就用开发配置,传了就用生产的。一行搞定两套环境。

第二条线:未配 Key 不阻断启动

.env 里的注释写得非常明确:

<span># ===== DeepSeek(Key 仅存后端,勿提交 git / 勿暴露前端)=====</span>
<span># 请填入你自己的 Key;留空时 AI 相关功能不可用,其余模块不受影响</span>
DEEPSEEK_API_KEY=

README 里也是同一条承诺:

复制 backend/.env 并填写需要的 Key(详见下节)。未配置 Key 的功能会自动降级,不会导致启动失败。

这个约定配合第 09 篇的四层降级才有意义 —— 降级做扎实了,"缺 Key"就只是"某个功能不可用",而不是"整个服务起不来"。降级设计在这里从"容错"变成了"可配置性"的基础。

第三条线:容器内的地址归 compose 管

这是最容易出错的一条。.env 里写的是本地开发地址:

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覆盖

    <span>environment:</span>
      <span># 容器内需用 compose 服务名互访,覆盖 .env 中本地开发的 127.0.0.1</span>
      <span>DB_URL:</span> <span>postgresql+asyncpg://admin:123456@postgres:5432/weather_db</span>
      <span>REDIS_URL:</span> <span>redis://redis:6379/0</span>

.env.prod.example刻意不放这两个值,模板头部解释了原因:

<span># 与开发的区别:只放「会变的敏感配置」。数据库/Redis 连接串</span>
<span># 由 docker-compose.yml 的 environment 段覆盖为容器网络地址,</span>
<span># 不需要(也不应该)写在这里。</span>

"不需要,也不应该" —— 两套环境连的是不同主机,这个差异归编排层管;.env 只管"会变的敏感值"。

三段式的价值在于每一层只有一个职责:模板告诉你缺什么,.env 放你的真实值,compose 管拓扑差异。混在一起就会出现"我明明改了 .env 为什么不生效"这种无从排查的问题。


三、Docker 起不来怎么办:原生进程兜底

这是很多个人项目的真实处境(我的机器就是缺 CPU 虚拟化支持,Docker Desktop 起不来)。项目有一组 PowerShell 脚本做兜底。

场景一:改一行文案,不想等镜像重建

dev-user.ps1 的注释直指痛点:

#  为什么需要它:改一行文案就要重建镜像 + 重跑测试时,一轮十几分钟。
#  vite dev 是热更新——改完保存、浏览器刷新即可,秒级;而且开发模式
#  不启用 Service Worker,不会出现"改了没生效、其实是缓存"的经典坑。
#
#  两个地址的分工:
#    5173  开发用:热更新,改完即见(不跑测试、不用重建镜像)
#    8080  正式用:Docker 镜像里构建好的静态包,只在你验收/发布时才重建

"改了没生效,其实是缓存" —— Service Worker 缓存是前端调试的经典陷阱。注释里点明"开发模式不启用 SW",把这条经验固化了。

脚本还顺手解决了 Node 版本问题:

# 挑一个 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

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 里也留了同一句提醒:

<span># Celery worker(Windows 需加 -P solo)</span>
celery -A app.celery_app worker -l info -P solo

场景三:想给别人看,但没服务器

start-public.ps1 用 Cloudflare 免费隧道把本机端口暴露到公网:

#  原理:项目用 docker compose 跑在本机,再通过 Cloudflare 免费隧道
#        (cloudflared quick tunnel)把本机端口暴露到公网。
#        无需服务器、无需备案、无需公网IP。

评论区里两个细节体现了安全意识:

#   3. 管理端默认不对外暴露(含 admin 账号,暴露到公网有风险)
#      如需暴露,执行 .\start-public.ps1 -ExposeAdmin

默认安全,需要时显式打开 —— 这是个好默认值。

还有一个探活判据的选择:

# 用真实后端接口探活:前端 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 开头把三件事说得清清楚楚:

<span>"""可观测性基础设施:请求 ID 贯穿、结构化日志、轻量指标。

三个部件,解决三个问题:

1. **request_id 贯穿**
   每个请求生成唯一 ID(或继承上游的 X-Request-ID),通过 contextvars
   在整个调用链传播——包括所有 service 层日志。排查问题时「用一个 ID
   就能串起一次请求的全部日志」,不用再靠时间戳猜。

2. **结构化日志**
   日志输出为 JSON 单行(字段固定:ts/level/logger/msg/request_id +
   调用方通过 extra 传入的业务字段),便于日志采集与检索。

3. **轻量指标(/metrics)**
   进程内计数器:按路由统计请求数 / 错误数 / 总耗时。
   不引入 Prometheus 等依赖——当前「看调用量与错误率」的需求,
   几十行代码即可满足,等真有可视化需求时再升级采集方式,对外接口不变。
"""</span>

"不用再靠时间戳猜" —— 这句话就是全部动机。没有 request_id 时,排查靠"大概那个时间点附近的日志",有并发请求就混乱了。

怎么做到"所有日志都带 request_id"

关键是 logging.Filter——它能在每条日志落盘前注入字段:

<span>class</span> <span>RequestIdFilter</span>(logging.Filter):
    <span>"""把 request_id 注入每条日志记录,供 Formatter 取用。"""</span>

    <span>def</span> <span>filter</span>(<span>self, record: logging.LogRecord</span>) -> <span>bool</span>:
        record.request_id = request_id_var.get()
        <span>return</span> <span>True</span>

这就绕开了"每个 logger.info() 都要手动带 request_id"的麻烦 —— 业务代码完全无感,只要它跑在请求上下文里,日志就自带 ID。

JSON 单行日志:extra 字段走白名单

<span>class</span> <span>JsonFormatter</span>(logging.Formatter):
    <span>"""JSON 单行日志。

    extra 字段走白名单:业务代码通过 `logger.info("...", extra={"path": ...})`
    传业务字段,这里只输出白名单内的键——否则 logging 内部自带的
    几十个属性(funcName/lineno/msecs...)会把每行日志撑爆。
    """</span>

    _EXTRA_KEYS = (<span>"method"</span>, <span>"path"</span>, <span>"route"</span>, <span>"status"</span>, <span>"duration_ms"</span>,
                   <span>"task_id"</span>, <span>"task"</span>, <span>"retries"</span>)

"否则会把每行日志撑爆" —— 如果直接把 record.__dict__ 全序列化,每行会带上几十个 logging 内部属性(funcNamelinenomsecs…),日志体积翻好几倍。

有意思的是这个白名单里同时有 task_id / retries——HTTP 请求的字段和 Celery 任务的字段共用一份 formatter,所以第 09 篇那个 task_failure 告警的结构化日志是直接能用的。

指标按"路由模板"聚合,不是真实 path

这是整个模块里我觉得最见功力的一处:

<span># 用路由模板而不是真实 path 聚合指标——</span>
<span># 否则 /news/84、/news/85 会被聚成两条,指标失去意义</span>
route = <span>getattr</span>(request.scope.get(<span>"route"</span>), <span>"path"</span>, request.url.path)

如果没有这一行/api/v1/news/84/api/v1/news/85……每一个 ID 都会变成一条独立的指标记录。指标面板上会有几千条"只被访问过一次"的路由,完全失去统计意义

request.scope["route"].path 拿到的是路由模板/api/v1/news/{news_id}),所以所有同类请求会聚到一条上。

还有个容易忽略的细节——为什么不用 uvicorn 自带的 access log

<span>def</span> <span>setup_logging</span>() -> <span>None</span>:
    <span>"""...
    关闭 uvicorn.access:请求日志由我们的中间件输出(带 request_id/耗时/路由),
    两份 access log 内容重复只会干扰检索。
    """</span>
    logging.getLogger(<span>"uvicorn.access"</span>).disabled = <span>True</span>

配合 compose 里的启动参数:

    <span># --no-access-log:请求日志由应用中间件输出(含 request_id/耗时/路由模板),</span>
    <span># uvicorn 自带的 access log 与其重复且无 request_id,只会在日志里制造噪音</span>
    <span>command:</span> <span>uvicorn</span> <span>app.main:app</span> <span>--host</span> <span>0.0</span><span>.0</span><span>.0</span> <span>--port</span> <span>8000</span> <span>--no-access-log</span>

两处都要动:代码里 disabled = True + 启动参数 --no-access-log。只做一处的话,另一路日志还是会冒出来。

最后还有一个 Windows 专属的小坑:

    <span># Windows 下 stdout/stderr 重定向到文件时默认用本地编码(GBK),</span>
    <span># JSON 里的中文会变成一堆问号;Linux 容器本来就是 UTF-8,reconfigure 无副作用</span>
    <span>for</span> stream <span>in</span> (sys.stdout, sys.stderr):
        <span>if</span> <span>hasattr</span>(stream, <span>"reconfigure"</span>):
            <span>with</span> contextlib.suppress(Exception):
                stream.reconfigure(encoding=<span>"utf-8"</span>)

"JSON 里的中文会变成一堆问号" —— 而 contextlib.suppress 是必要的防御:不是所有流都支持 reconfigure


五、CI:坏代码进不了主干

ci.yml 的三个层次,注释里表述得很精炼:

<span># 分三个层次,由快到慢、由便宜到贵:</span>
<span>#   1. lint      几秒钟   —— 格式、未使用变量、明显的坏味道</span>
<span>#   2. test      几分钟   —— 442 个用例(后端 379 + 前端单测 41 + E2E 由本地/部署后跑)</span>
<span>#   3. build     几分钟   —— 四个镜像能不能构建出来</span>
<span>#</span>
<span># 为什么 lint 和 test 拆成不同 job:</span>
<span>#   lint 只要 10 秒,test 要 2 分钟。拆开后 lint 失败会立刻反馈,</span>
<span>#   不用等测试跑完;而且两者并行执行,总耗时取较慢的那个。</span>

"lint 失败会立刻反馈" —— 把快的放前面单独成 job,不是为了省时间(并行跑总时长取最慢的那个),而是为了让人更快知道最蠢的那类错误

发布和回滚的闭环

  <span>release:</span>
    <span>needs:</span> [<span>backend-lint</span>, <span>backend-test</span>, <span>frontend</span>]
    <span>if:</span> <span>github.event_name</span> <span>==</span> <span>'push'</span>
    <span>permissions:</span>
      <span>contents:</span> <span>read</span>
      <span>packages:</span> <span>write</span>

needs 保证"测试没过的代码不配被发布"。然后标签策略是回滚能力的基础:

      <span># 标签策略(可回滚的关键):</span>
      <span>#   应用镜像 → latest(始终指向主干最新)+ sha-<短hash>(与提交一一对应)</span>
      <span>#   回滚时只要用旧提交的 sha-xxx tag 重新执行 deploy.sh 即可。</span>
      <span>#   postgres 镜像随 PG 版本走(pg17),不随应用版本变化,用固定 tag。</span>

sha-<短hash> 是回滚的锚点。 只有 latest 的话,你无法回到"上一个具体版本"。

几个实用的 CI 细节

① 失败的日志要打进注解区,因为完整日志需要登录。

      <span># 失败时把首尾日志输出成注解:pytest 的"用法错误"(exit 4) 会把原因</span>
      <span># 写在最前面,而"测试失败"的信息在最后面,两头都取才够定位。</span>
          <span>head</span> <span>-12</span> <span>pytest-output.log</span> <span>|</span> <span>while</span> <span>IFS=</span> <span>read</span> <span>-r</span> <span>line;</span> <span>do</span>
            <span>echo</span> <span>"::error::[头部] $line"</span>
          <span>done</span>

"两头都取" —— 这个观察很准:pytest 退出码 4(用法错误)的信息在最前面,而用例失败的信息在最后面。只 tail 会漏掉前者。

② 连发多次推送时,取消上一次没跑完的流水线。

<span>concurrency:</span>
  <span>group:</span> <span>ci-${{</span> <span>github.ref</span> <span>}}</span>
  <span>cancel-in-progress:</span> <span>true</span>
<span># 好处:省额度,且不会出现"旧提交的检查结果覆盖新提交"的困惑</span>

③ 前端的 pre-commit 钩子不能用 npm 命令。

  <span># 用本地 Python 脚本而不是直接写 npm 命令,原因见脚本注释:</span>
  <span># Windows 下 npm 实际是 npm.cmd,pre-commit 按字符串找可执行文件会找不到。</span>

"npm 实际是 npm.cmd" —— pre-commit 按可执行文件名查找,找不到 .cmd。这类平台差异只能靠踩坑积累。


六、小结与下一篇

可复用清单

场景做法
环境配置三段式:`.env.example` 模板 + `.env` 真实值(gitignore)+ compose 覆盖拓扑差异
敏感值`.gitignore` 里忽略,但**必须给模板文件**,否则新人不知道配什么
缺配置未配 Key 应**降级**而非阻断启动(前提是降级做扎实了)
上线脚本备份 → 停应用 → 拉镜像 → 启动 → 健康检查 → 失败自动回滚
备份时机放在**停应用之后**,数据不再变化时 dump 才是一致性快照
备份校验非空 + 压缩完整性,**别只检查文件存在**——防"备份了个寂寞"
回滚不重新拉镜像(旧镜像在本地),秒级完成;换 tag 就是回滚
演练提供一个"强制失败"开关,但**回滚后的检查必须真实探测**
难懂的错误启动前检查前置条件(Node 版本等),给一句人话而不是让框架抛异常
Windows + Celeryworker 必须加 `-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"——它们怎么跟迁移脚本配合?改字段时历史数据怎么办?以及"测试库为什么每次都要重建"这个决定的真实代价。要的话我接着写,还是这个体量。


参考资料

  1. Docker Compose 官方文档 · Environment variables —— ${VAR:-default} 默认值语法、env_fileenvironment 的优先级关系 docs.docker.com/compose/env…
  2. Cloudflare 官方文档 · TryCloudflare —— 免账号的临时隧道(quick tunnel),域名每次变化 developers.cloudflare.com/cloudflare-…
  3. Celery 官方文档 · Workers 的 --pool 参数 —— solo 池的适用场景(Windows 无 fork()docs.celeryq.dev/en/stable/u…
  4. Python 官方文档 · contextvars —— ContextVar.set/reset 与 asyncio 的上下文隔离 docs.python.org/3/library/c…
  5. Python 官方文档 · logging.Filter —— 在日志落盘前修改 LogRecorddocs.python.org/3/library/l…
  6. GitHub Actions 官方文档 · Workflow 语法 —— concurrency 取消并发运行、needs 依赖、::notice::::error:: 注解 docs.github.com/en/actions/…
  7. 项目源码(仍在更新中) —— Ticnix/weather-travel-recommend-system github.com/Ticnix/weat…