背景:一台 VPS,也要零停机

某 AI 日记应用服务端跑在一台 VPS 上。小团队常见部署是“停旧、起新”:先停旧容器再拉起新容器,中间几秒到几十秒空窗,用户正好在写日记、提交时撞上发布窗口,就会遇到连接失败。对日记应用来说,这种中断尤其糟糕。

2026-09-28 的三笔提交系统地解决了这个问题:(feat: implement blue-green zero-downtime deployment, client retry resilience, graceful draining and portrait photo check)、(fix: scope entrypoint flock per port to enable concurrent Blue-Green rolling deployment)、(docs: update DEPLOYMENT.md with blue-green zero-downtime architecture and version verification)。核心是蓝绿部署:同一台机器上准备两个槽位,交替发布,健康检查通过后再切流,旧槽位优雅排空。

本次草稿依据提交记录与部署手册,未逐行核对实现。不编造健康检查的具体路径。

固定拓扑:请求从哪来,到哪去

部署手册记载的拓扑是固定的:浏览器 → Cloudflare → 1Panel OpenResty 反代,动态切流到 123ABC(blue)或 456DEF(green),共享挂载 /789GHI。

每层分工明确:Cloudflare 在最外层,负责 TLS 终结与边缘接入;1Panel 的 OpenResty 在 VPS 上做反向代理,是真正决定“这次请求去 blue 还是 green”的切流点;两个槽位是两个容器,分别监听 123ABC 与 456DEF,某一时刻只有一个承接新流量;共享挂载保证两个槽位看到同一份数据,切换槽位不会导致用户数据“只在旧容器里”。

TLS 细节值得记一笔:TLS 由 Cloudflare 灵活模式终结,源站不装证书,源站域名 必须显式声明在 server_name 中。加密终点在 Cloudflare,源站反代必须明确写出回源域名,否则回源请求可能落到错误的 server 块。省掉在 VPS 上管证书的麻烦,前提是反代域名声明必须准确。

发布来源同样固定:发布来源是本地已提交的 Git 版本,VPS 只拉镜像、不克隆仓库。VPS 上不放源码、不跑构建,构建在别处完成并推成镜像,VPS 只负责拉取与运行。版本来源唯一,发布可重复、可追溯,回滚也只需指回上一个镜像版本。

双槽位交替:永远留一个能跑的版本

某一时刻只有一个槽位是 live 的,承接全部新请求;另一个待命,用来部署新版本。发布时新版本先部署到待命槽位,旧版本继续服务;新版本就绪后再把流量切过去;切流完成、旧槽位排空后,旧槽位变成下一个待命槽位,等下一次发布再用。

好处是任何时刻都有完整可用的版本在跑。发布失败时不需要“抢修新版本”,只需不切流,或把流量切回旧槽位。发布本身也不需要停服务,因为旧槽位一直在承接请求,直到新槽位被确认可用。共享挂载很关键:槽位切换只切换“代码与进程”,不切换“数据”,切流前后数据视图一致。

健康检查后切流:先证明能跑,再接流量

最容易被忽略的一步是“什么时候切”。不是“新容器启动了就切”,而是“新容器通过健康检查后才切”。手册描述是:发版时新容器通过健康检查后,用 nginx -s reload 切换新请求,排空期间保留已接入请求。顺序很重要:先部署、再检查、检查通过、再 reload 切流。nginx -s reload 是平滑重载反代配置,不需重启整个反代进程,新请求按新配置走,旧请求不受影响。

健康检查是把“容器起来了”与“服务能用了”区分开:进程启动成功不等于应用初始化完成、数据库连接正常、关键接口能响应。健康检查就是切流前先由新容器自己证明“我真的能服务”。检查失败,流量留在旧槽位,新容器慢慢排查,而不是让用户先替你发现问题。

优雅排空:已接入的请求要跑完

切流不是一刀切断旧槽位。手册明确写了排空期间保留已接入请求:切流后新请求去新槽位,但已接入旧槽位、还在处理中的请求,要让它们在旧槽位上跑完,而不是直接被杀掉。这叫优雅排空(graceful draining)。用户正在提交日记、等 AI 回复时,切流瞬间停掉旧容器,这次请求就会中断。排空做法是旧槽位不再接新请求,但等手头请求处理完再退出。

健康检查与排空是配合的:前者保证新槽位“能接”,后者保证旧槽位“能收尾”。两者缺一不可,否则要么新槽位没准备好就接流量,要么旧槽位在途请求被粗暴打断。

按端口隔离的 flock 锁:让两个容器能并发跑

蓝绿在过渡期有个现实问题:两个容器要同时运行。旧容器还没排空,新容器已启动;若启动过程有“只能一个人做”的步骤,两个容器就会互相阻塞。其中一次提交就是修这个:scope entrypoint flock per port。手册记载:容器启动锁按槽位端口隔离(.server-123ABC.flock),支持双容器在发版过渡期并发运行。

文件锁(flock)原本防同一入口重复执行,但若锁文件全局唯一,blue 与 green 就会抢同一把锁,一个拿到另一个就得等,滚动发布退化成串行。按端口隔离是让锁文件名带端口号:123ABC 用自己的锁,456DEF 用自己的锁,互不干扰,各自初始化、各自就绪。这个修复很小,只改锁的粒度,却是蓝绿能成立的前提。

客户端重试与版本验证

零停机不只是服务端责任,客户端也要有韧性。其中一次提交的标题里明确提到 client retry resilience。即使切流再平滑,网络层面仍可能有短暂抖动:reload 瞬间、旧容器排空边界、Cloudflare 回源切换间隙。客户端对可安全重试的请求,失败时按策略重试一次或几次,而不是立刻报错。重试要区分哪些错误值得重试、间隔与上限,避免把短暂抖动放大成重试风暴。服务端用蓝绿与排空把中断窗口压到最小,客户端用重试消化残余的短暂失败,用户感知到的才是“发布时也能正常用”。

发布完成后还有版本验证。其中一次提交把蓝绿架构与版本验证写进 DEPLOYMENT.md:切流后要能明确证明“现在对外服务的确实是新版本”,而不是“看起来切过去了,实际还是旧版本”。常见做法是让服务暴露构建号、提交哈希或发布标签,发布后对照是否与预期新版本一致。没有验证的切流是盲切,有验证的切流才是可确认的发布。

小结

一台 VPS 上的蓝绿部署,核心不是“用了两个容器”,而是一组配合的机制:固定拓扑让切流点唯一明确,双槽位交替让任何时刻都有可用版本,健康检查让切流发生在“证明能跑之后”,优雅排空让在途请求跑完,按端口隔离的 flock 锁让过渡期双容器真正并发,客户端重试消化残余抖动,版本验证确认切流结果。代价是复杂度:要维护两个槽位、共享数据、健康检查、锁粒度。但对希望“发版时用户无感知”的应用,这笔复杂度值得——发布不再是需挑凌晨、提前公告的停机事件,而是随时可做、做错也能快速切回的常规操作。


2024 | ixs.im
此情此景, [随机]吟诗一首: