LZWcc's Blog

Back

这篇和前几篇 Claude Code 系列不太一样,讲的不是工具配置,而是一个越来越常见的场景:几个人各自用 AI 编程工具开发同一个线上服务,而且每个人(以及每个人的 AI)都可能直接部署。

在这种场景下,最先出问题的往往不是代码本身,而是一个看似简单的问题:线上现在跑的到底是哪份代码? 这篇整理我在这类场景里用过、觉得成本低收益高的一套做法。

一、典型的混乱#

如果没有约定,很快就会出现这些情况:

  • 服务器上的源码目录和线上镜像不一致,里面还混着某个人没提交的改动。
  • 镜像 tag 用 :latest 或功能名,并且会被覆盖:同一个 tag 今天指向 A,明天指向 B。
  • 有人(或者有人的 AI)在服务器上直接改代码、构建、部署,提交留在服务器上没推送。
  • 你基于 GitHub 主线开发完准备上线,才发现主线落后于线上:直接部署会把别人已经上线的功能覆盖掉。

到了这一步,“回滚到上一个版本”都无从谈起,因为没人说得清上一个版本是什么。

二、版本可追溯:三件事#

1. 镜像带源码版本号,回滚按镜像 ID#

构建时把提交号写进镜像:

docker build \
  --build-arg SOURCE_REVISION=$(git rev-parse HEAD) \
  -t app-frontend:<功能>-<短sha>-<日期> frontend
bash
  • Dockerfile 里用 LABEL org.opencontainers.image.revision=$SOURCE_REVISION 写进镜像元数据,docker inspect 就能看到。
  • 前端额外暴露一个 /api/version 接口,返回构建时的提交号,在浏览器里就能确认线上前端是哪个提交。
  • tag 命名为 <功能>-<短sha>-<日期>,新版本用新 tag,绝不复用。

更重要的一条:核对线上版本、记录回滚点,一律用镜像 ID,而不是 tag 名。

docker inspect -f '{{.Image}}' app-frontend-1   # 容器实际运行的镜像 ID
bash

tag 是可变的指针,镜像 ID 是内容哈希。回滚点记录成”tag + 镜像 ID 前 12 位”,回滚前先核对镜像 ID 对不对得上。

一个顺带的小优化:如果版本 LABEL 写在 Dockerfile 靠前的位置(比如安装 ffmpeg 之前),版本号一变,后面所有层的缓存都会失效,每次构建都要重装一遍。把 LABEL 挪到这些重层之后就好了。会变的东西放在 Dockerfile 的后面。

2. 先推送,再部署#

规则很简单:要部署的提交必须已经推送到 GitHub(推到哪个分支都可以),构建前工作区必须干净。

这样任何时候都能从线上镜像的 revision 反查到 GitHub 上的源码。已经在服务器上直接上线、但没推送的提交,先原样推送到 GitHub(保留作者和提交号,线上的版本号才对得上),再用 merge --no-ff 合进主线——不用 rebase,因为 rebase 会改变提交号,线上镜像就对不上任何一个提交了。

3. 不用 bind mount 发版#

一种常见的”快速修复”是把新编译的二进制通过 bind mount 挂进容器,替换镜像里的文件。这样做之后,docker inspect 看到的镜像根本不是实际在跑的代码。规则是:改代码就提交、推送、构建新镜像,不用 bind mount 代替发版。

三、发布本身的几个坑#

旧标签页打开面板报 ChunkLoadError#

Next.js 每次构建,/_next/static 下的 chunk 文件名都会变。切换前端之后,还开着旧页面的用户一点开某个懒加载的面板,请求的是旧 chunk,新容器里已经没有了,于是加载报错。

解决办法:切换前,把当前线上前端容器里的 .next/static 归档到宿主机的一个目录,再配置 nginx:新容器里找不到的静态文件,回退到归档目录去找。下面是表达这个思路的示意配置:

location /_next/static/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_intercept_errors on;
    error_page 404 = @static_archive;
}
location @static_archive {
    root /srv/app/static-assets;   # 历次归档的 _next/static
}
nginx

归档只新增、不覆盖。遇到同名但内容不同的文件就停下来检查:chunk 文件名带内容哈希,同名不同内容说明哪里出了问题。

后端没有优雅退出:等”安全窗口”#

如果后端重启会切断进行中的请求、让进程内的任务卡住,那么后端上线前就要等一个安全窗口:最近几分钟没有关键请求提交,也没有在途任务,再执行切换。切换脚本里先检查这一条,不满足就自动放弃,等下一次。高峰期等不到窗口就不强切,改到低峰。

部署留痕#

每次部署后,在服务器上的 DEPLOY_LOG.md 末尾追加一行:

| 时间 | 服务 | 镜像 tag | 镜像 ID 前 12 位 | 完整 sha(已推送) | 谁 | 说明 |
plaintext

每次开工先看这个文件的最后几行,就知道最近谁部署了什么。

四、把约定写给 AI 看:AGENTS.md#

这些规则如果只存在于某个人的笔记里,其他人的 AI 工具是不知道的。所以要在仓库根目录放一个 AGENTS.md,写给所有在这个仓库里工作的 AI 编程工具。一个精简的模板:

第 5 条很重要:规则要给紧急情况留出口。没有出口的规则会被绕过,而且绕过之后不留痕迹。

五、我这边:四个只能手动触发的 Claude Code skill#

我把日常操作固化成了四个 skill,都设置了 disable-model-invocation: true,只能由我手动输入命令触发,模型不能自己决定去执行:

skill做什么关键约束
/preflight开工只读检查全程只读。对比笔记里记录的版本和线上实际镜像 ID;检查近 24 小时的 sshd 登录、构建目录里有没有未推送的提交、近 1 小时 5xx、磁盘
/sync把已上线但还没进主线的改动收进来先查清每个服务对应的源码;收进单独的 sync/ 分支后停下等我审核,审核通过才合进主线
/deploy-frontend前端上线构建 → 归档旧静态资源 → 停下等我确认可以切换 → 切换(脚本内检查安全窗口)→ 核对线上版本号 → 写 DEPLOY_LOG
/wrap-up收工整理检查本地有没有遗留的临时补丁,更新状态笔记并提交

几个设计原则:

  1. 只读和写操作分开。预检全程只读,发现异常只报告,由我决定下一步。
  2. 不可逆操作前必须停下。部署切换、合进主线之前,skill 会明确停住等我确认。
  3. 不相信 tag,只相信 docker inspect。每次部署前都重新核对实际镜像 ID,因为别人的部署随时可能改动线上。
  4. 登录审计要精确。统计登录时只看 sshd 的 Accepted 行,否则 grep 会匹配到 sudo 日志里记录的自己的命令。
  5. 一个小细节:skill 里 awk 的字段要写成 $(1) 而不是 $1,因为 skill 的参数替换会把 $1 当成传给 skill 的参数替换掉,命令就坏了。

另外我会维护一份 status.md,第一节固定是”三处版本对照”:GitHub 主线、本地、线上镜像,每次部署后更新。它既是给自己看的,也是给 Claude Code 每次开工时读的上下文。

六、收获#

  1. 多人(多 AI)协作的第一要务是可追溯:任何时候都能回答”线上跑的是哪个提交”。版本号写进镜像、核对用镜像 ID、先推送再部署,这三件事成本很低,收益很大。
  2. 约定要写在仓库里,写给 AI 看。AI 工具会读 AGENTS.md,但不会读你的脑子。
  3. 让 AI 做重复的检查,把关键决策留给人。skill 负责把”该查的都查一遍”,部署、合并这类不可逆的决定由人来确认。
  4. 给规则留紧急出口,并要求事后补齐留痕。
  5. 流程不能保证所有人都照做,所以预检的核心不是”相信流程”,而是”每次开工都重新核对现实”。
多人加 AI 维护同一个线上服务:让每次发布都可追溯
https://lzwcc.xyz/blog/ai-coworker-production-deploy/
Author LZWcc
Published at October 21, 2026