AI 基础设施实验日志

一键"在线更新",点掉了一台线上服务

结论先行: 一次深夜的线上 502,根因不是 Nginx、不是内存、不是数据库连不上,而是管理后台的一键"在线更新"按钮,把官方上游二进制直接换进了跑着本地 fork 的生产容器。Fork 的迁移编号被本地重编号过,上游二进制读不懂这套编号,把已经存在的字段又创建了一次,应用在启动迁移阶段报错、进入重启循环,Nginx 对着一个起不来的 upstream,只能对外返回 502。

这次故障最值得记的不是技术细节,而是一条流程纪律:Fork 的生产环境,升级只能走自己设定的通道——选择性吸收上游、迁移重编号审计、不可变镜像、蓝绿发布。任何绕过这套通道的"便捷入口",哪怕是产品自己带的一键更新按钮,都要当作禁区对待。

时间线:点了一下更新,服务起不来了

2026 年 8 月 14 日 18:49,管理接口先后处理了"系统更新"与"系统重启"两个请求。这是后台自带的官方在线更新入口,平时看起来无害。

重启之后,应用主容器开始以退出码 1 反复重启,OOMKilled 为 false——不是被系统杀掉的,是进程自己起不来。同机上的数据库、缓存、Nginx 都正常,但本地 8080 端口没有任何监听。Nginx 因为 upstream 连接被拒绝,对公网返回 502。

第一组证据把故障定在应用层内部:容器在反复重启,而依赖它的基础设施都健康。于是排查转向容器日志:新二进制在启动时先登记了上游的一条迁移,随后执行下一条迁移时,因为目标表的某个字段已经存在,直接报错退出。

但数据库里明明有一条同类结构的迁移记录。为什么一条"已执行"的迁移会被执行两次?

根因:迁移编号冲突,语义映射丢失

答案藏在 fork 与上游的编号差异里。生产运行的 fork,历史上把上游的迁移做了本地重编号:同类结构在本地叫 216 和 217,而上游的对应版本叫 188 和 189。数据库里登记的是本地编号 216/217,字段也已经存在。

一键在线更新换进来的是官方上游二进制,它只认识上游的编号体系。它登记了上游的 188,然后去执行上游的 189——而 189 要创建的字段,本地的 217 早就建好了。上游二进制不知道"本地 216/217"和"上游 188/189"是同一语义的两套编号,于是把已存在的字段当成从未执行过的迁移,又创建一次,事务报错,进程退出,容器进入重启循环。

这里有两个关键判断:

  1. 502 是外部表现,不是根因。 Nginx 返回 502 的直接原因是 upstream 连不上,而 upstream 连不上的原因是应用在启动阶段死在了迁移执行。顺着"502 看 Nginx"的直觉走,会浪费时间;先看"请求死在哪一层",才能一击即中。
  2. 这不是哪条迁移写错了。 两套迁移各自都是对的,错的是让"不认识本地编号的二进制"接管了"按本地编号登记的数据库"。故障发生在升级通道,而不是代码本身。

排障纪律:先只读诊断,再备份取证

这是一次生产故障,排障的第一原则是"先看清楚,再动手"。当时的处理是纯只读诊断:检查容器状态、日志、数据库迁移登记表、字段实际存在情况,全程没有恢复二进制、没有写数据库、没有重启或切换生产流量。

确认恢复方案之前,先做了取证备份:把一键更新换进来的二进制、容器可写层里自动生成的旧二进制备份,以及本地不可变镜像里的原始二进制,一起保存到独立的恢复目录并计算 SHA-256 校验。结果证实了判断:备份的旧二进制与镜像原始二进制完全一致,一键更新换进来的二进制与两者都不同——容器里的二进制确实被替换了,证据链闭环。

这一步的价值在于可逆性:先冻结现场、留下哈希,后面无论怎么操作,都有一个确定的回滚起点。

恢复:不可变镜像强制重建,数据库一笔不写

恢复方案没有去"修"数据库,也没有尝试在迁移登记表里手工补映射,而是直接用不可变的本地镜像对主服务做强制重建。整个恢复过程没有覆盖或删除数据库、缓存、Nginx 配置、数据卷、旧镜像和发布目录——数据库一笔没写。

重建后容器恢复健康,重启计数归零,8080 端口重新监听,健康检查与生产入口全部返回正常。观察窗口里没有新的 panic、致命日志、迁移失败、校验和不一致或重复字段错误。数据库后验确认:上游 188 与本地 216/217 的登记都保留着,失败的那条上游 189 没有登记,一切停在故障前的状态。

恢复成功的关键不是技术高超,而是承认一件事:当升级通道本身是错的,最快最安全的修复是回到最后一个已知的正确状态,而不是在错误的状态上继续打补丁。

防复发:四条禁区

这次故障留下四条正式纪律:

  1. Fork 的生产环境,禁用或拦截后台的官方在线更新入口。 升级继续走本地通道:选择性吸收上游改动、迁移重编号审计、不可变镜像、蓝绿发布。一键更新按钮在 fork 环境里不是便利功能,是事故入口。
  2. 迁移编号是语义契约,不只是文件名。 只要做过本地重编号,就必须维护"本地编号 ↔ 上游编号"的语义映射,并且让任何要接触数据库的二进制都认得这套映射。文件名对上不等于语义对上。
  3. 502 先看应用层,再看接入层。 上游连接被拒绝时,先查应用容器的存活与启动日志,确认"谁在拒绝、死在哪一步",再决定看 Nginx 还是看代码。
  4. 生产排障先只读、再取证、最后动手。 冻结现场、计算哈希、确认回滚起点之后,再执行恢复动作。

收束

"一键更新"四个字,只对官方原版环境负责。Fork 的环境有自己的编号体系、自己的升级通道,把官方的便捷入口原样保留在生产后台,等于在防线里留了一扇不上锁的门。这次是迁移编号冲突,下次可能是别的——关掉的应该是那扇门,而不是每次都去救一次火。

© 2026 rotor®GitHub 开源CC BY-NC-ND · 转载请注明出处