06-一键发布背后:Docker-Compose、SWR与生产发布纪律

一键发布背后:Docker Compose、SWR 与生产发布纪律

我们的生产发布命令是一行 Python:

python deploy/scripts/release-prod.py --yes

回车之后:本地构建前后端镜像 → 推华为云 SWR → SSH 更新生产容器 → 健康检查 → 汇报结果。整个动作十几分钟,平时一个人就能发版。还有 --backend-only 只发后端、--rollback 一键回滚。

但如果你以为这篇要讲"怎么写发布脚本",那就猜错了。脚本是最简单的部分,真正让发布可靠的是一堆脚本之外的纪律——它们全部来自真实事故。这也是为什么 2026 年 10 月 17 日我们能一天发 7 个版本:不是胆子大,是每个环节的确定性攒够了。

发布的形态

生产是两台角色的机器:应用栈和媒体栈跑在一起(/data2/cc 与 /data2/cc-middleware),全容器化,Docker Compose 编排。镜像版本号统一用时间戳,形如 v202610171806——一眼看出哪天几点发的,回滚时报个版本号就行。

启动顺序靠 compose 的 depends_on: service_healthy 锁死:postgres → redis → rabbitmq → RustFS → freeswitch → backend → web。这个顺序不能乱,backend 起太早会因 PG 没就绪而崩溃循环。

日常发版节奏是"小步快跑":改动小的 fix 走 --backend-only,涉及前端才发 web。发布前的固定动作清单:.env 备份(cp .env .env.bak.$(date +%F_%H%M))、数据库增量 SQL 先备份再执行、直改过菜单表的要清 Redis 权限缓存(permission_menu_ids:*、menu_role_ids:*——直改库不触发应用层缓存失效,这个坑专门写过复盘)。

SWR 会过期,发布不能停

2026 年 10 月 18 日晚上发版,推 SWR 时 token 过期了。当时是晚上,客户等着一个报表口径修复上线,等 token 续期来不及。

于是有了这条应急路径,全程手工但完整走通:

# 本地构建并加载进本地 daemon
docker buildx build --load ...
# 导出、压缩、SFTP 上传(506MB)
docker save java-cc-backend:v202610181957 | gzip > backend.tgz
# 生产侧加载、按本地镜像起(跳过 pull)
docker load < backend.tgz
docker compose up -d --pull never backend web

--pull never 是关键参数——不然 compose 一看远端没有这个 tag 又去拉,白绕一圈。SWR 恢复后重发了正式版本(v202610182054),应急镜像就地淘汰。

这次之后,docker save | ssh docker load 这套"土法"被正式写进了发布文档的应急章节。兜底手段不体面没关系,要紧的是它存在、有人验证过、写下来了。

顺带一提,Windows 开发机上还有个小坑值得记录:Docker Desktop 有时会丢 buildx/compose CLI 插件,docker buildx 报 rc=125,多数是插件文件没了而不是 Docker 本身坏了,补插件即可。这类"工具突然不工作"的问题,先查插件再重装,能省一小时。

磁盘是最先倒下的东西

要说发布相关最狼狈的一次事故,是 2026 年 10 月 11 日。

那天生产上 cc-backend 和 cc-web 集体崩溃循环,重启个不停,健康检查全红。登上去一看:系统盘 98G 用了 100%,可用 0 字节。Spring Boot 连临时目录都建不出来(Files.createTempDirectory 抛磁盘满),而 492G 的数据盘 /data2 几乎是空的。

什么东西吃了系统盘?清点结果很有画面感:

  • 失控的 tcpdump 占了 42G——9 月中旬排呼入故障时起的 3 个抓包进程,连跑了 25 天没人管。抓包文件早就被挪走了,但进程还握着已删除的 inode 继续写(lsof +L1 一查,42G 的 deleted 文件赫然在列);
  • 82 个历史镜像占了 34G——我们每发一版就留一个 1.31G 的后端镜像,docker system df 显示 81% 可回收;
  • journal 归档 788M、SSH 爆破失败日志 btmp 279M,加起来又 1G 多。

处置倒是干脆:杀 tcpdump(先按流程把 pcaps 归档到 /data2/pcap-evidence/ 留证据)、docker image prune -a -f 回收 34G、journal 压到 200M。系统盘从 94G/100% 降到 14G/15%。

但真正的问题没结束:Docker 的数据根目录还在系统盘上,镜像迟早再把盘吃满。于是趁夜间窗口把 /var/lib/docker 和 /var/lib/containerd 整体迁到 /data2(rsync + 改 daemon.json 的 data-root + 改 containerd 的 root)。迁移本身又触发了次生故障——rsync 拷贝的是运行中状态,起来后 cc-backend 报 Network is unreachable、cc-web 报 host not found in upstream "backend",靠 docker compose up -d --force-recreate 重建容器才恢复。

这次事故之后固化下来几条规矩:

  1. 长任务必须落大盘加轮转——tcpdump 一律 -C 500M -W 10,且不允许在系统盘跑;
  2. journald 限 SystemMaxUse=500M、SystemKeepFree=2G,daemon.json 给容器日志 max-size=50m max-file=3;
  3. 磁盘水位进日常巡检,"可用 0 字节"这种事不该由崩溃循环来通知我们;
  4. 顺手把 max-concurrent-downloads=10 加上——单连接拉镜像被限到 ~100KB/s 的问题一并解决,拉取速度 384KB/s 涨到 1.29MB/s。

回滚不是按钮,是决策

发布脚本里有 --rollback,但比按钮更重要的是什么时候按。我们的决策原则写在上线清单里,两条:

  • 核心通话链路不可用且 30 分钟内无法定位修复 → 回滚,不纠结;
  • 数据库已做不兼容变更 → 优先"应用回滚到兼容版本",而不是回数据库。增量 SQL 永远附 undo 脚本,且保证"先库后应用"的向后兼容一个版本。

应用回滚的目标时间是 10 分钟内:.env 改回旧 VERSION,docker compose up -d backend web,本地永远保留上一版镜像(回滚不等网络)。回滚后必做复盘——2026 年 10 月那次计费双扣(下下篇专门讲)就是"没敢回滚、选择了热修"的案例,事后看当时的判断没错,但那是基于影响面算过的,不是靠运气。

写在最后

有人问过:为什么不上 K8s、不上 GitOps 流水线?以现在两个容器栈、一天最多七个版本的节奏,Compose + 一段 300 行的 Python 发布脚本,加上这堆"备份、版本递增、冒烟、回滚预案"的纪律,够用了。发布系统的演进应该跟着事故和规模走,而不是跟着技术潮流走——等哪天机器数量和发版频次真的上来了,再重构不迟。


本文是「云呼叫中心技术内幕」系列第 6 篇。

posted @ 2026-10-25 21:15  cliffdev  阅读(6)  评论(0)    收藏  举报