·约 10 分钟阅读·DeepSeek

单机上的零停机部署:原子符号链接、蓝绿发布与优雅重载

我在一台服务器上做零停机部署的完整做法:静态站点用原子符号链接切换,nginx 背后的服务用蓝绿发布,带健康检查的切换、连接排空、nginx reload 与 restart 的区别、快速回滚,以及那些真正会导致故障的失效模式。

devopsself-hostingnginx

你不需要 Kubernetes 来避免“维护窗口”。在一台机器上做零停机部署是一个早就解决掉的问题,而且答案故意很无聊:把新东西在旁边的位置构建好,证明它是健康的,翻转一个指针,然后让旧的东西把手上还没处理完的请求做完。一台机器、一个 nginx、一个 systemd 单元。这就是我在这个博客上用的形态——也就是这个博客如何发布自己里描述的那套静态流水线——也是我用在几个挂在同一个反向代理后面的小服务上的形态。

为什么停机是可以避免的

部署时的停机,通常并不是软件本身的硬性要求,而是原地部署带来的副作用:你停掉进程、覆盖它的文件、再把它起来。在这段窗口里进程根本没在监听,请求当然会失败。几乎所有“感觉必须停服”的部署,其实只是把这三步的顺序做错了。

对静态站点来说更明显:Web 服务器根本不需要停,因为它提供的只是文件。唯一的问题是,请求会不会落在“旧内容已经没了、新内容还没到位”的中间态上。如果你把这个过渡变成一个单一的原子操作,窗口就是零——不是很小,是零。这正是原子符号链接切换的核心思想。

对大多数服务来说,同样的推理也成立。如果应用能在旧副本旁边再起一个新副本,或者能在不丢弃监听套接字的前提下重载配置,那么部署就是一次切换事件,而不是一次“停掉再启动”事件。难点不在服务器本身,而在于应用依赖的状态——数据库 schema、会话存储、缓存。这才是真正让我操心的地方。

原子符号链接:静态站点的零停机部署

模式是这样的:永远不要直接用新文件覆盖正在使用的目录。把新文件写进一个全新的、带版本号的目录,然后改一个指向它的符号链接。在 POSIX 文件系统上,符号链接切换是原子的,因为在同一目录内 rename(2) 是原子的——读到的要么是旧目标,要么是新目标,绝不会是写了一半的混合体。

/var/www/blog/
├── releases/
│   ├── 2026-11-08T21-40-12/   # 上一次构建,仍然可达
│   └── 2026-11-09T09-12-03/   # 这次要切过去的新构建
│       ├── index.html
│       └── assets/
└── current -> releases/2026-11-08T21-40-12   # nginx 服务的就是它

nginx 服务的是 /var/www/blog/current。要部署,就构建到 releases/<timestamp>,验证它,然后切换:

# 以部署用户运行;CURRENT 与 NEW 是绝对路径
ln -sfn "$NEW" /var/www/blog/current.tmp
mv -T /var/www/blog/current.tmp /var/www/blog/current

对临时符号链接执行 mv -T 的那个 rename 就是让切换变原子的关键。不要直接对 current 用 ln -sfn——某些 shell 和旧版 ln 会沿着已有的符号链接解析,然后写进它指向的那个 release 里,而这恰恰就是你想避免的 bug。

因为两个 release 都还留在磁盘上,回滚就是再切回上一个 release 目录的一行命令。不用重新构建,不用重新上传。保留最近两三个,其余清理掉。

反向代理背后的蓝绿发布

静态站点可以从两个目录提供服务,但一个正在运行的服务需要两个进程。这就是蓝绿发布:让当前版本(蓝)继续运行,同时在不同端口或套接字上启动新版本(绿),然后把流量移过去。

我不会为这件事上整套编排系统。两个 systemd 单元加一个 nginx upstream 就够了:

# /etc/systemd/system/api-blue.service(green 完全相同,只是端口不同)
[Service]
ExecStart=/opt/api/releases/blue/bin/api --listen 127.0.0.1:9001
Restart=on-failure
# nginx upstream 指向当前存活的那个颜色
upstream api {
    server 127.0.0.1:9001;   # 蓝;切到绿时改成 9002
    keepalive 32;
}

切换本身就是改配置加一次 nginx -s reload。但顺序并不是“reload 然后祈祷”——见后面两节,因为真正有价值的是切换前后的纪律,而不是切换动作本身。

如果应用持有真实状态,蓝绿就更难:两个颜色都写同一个数据库,所以 schema 必须同时兼容两个版本。这就是“数据库只迁移了一半”的失效模式,也是蓝绿的真实成本。

应用的优雅重载

在 nginx 把流量甩过去之前,应用本身值得同样对待。优雅重载的意思是:启动新版本,让旧版本把已经在处理中的请求做完,然后再停掉它——如果可能,还不要丢弃监听套接字。systemd 把你可控的部分都显式化了:

# /etc/systemd/system/api.service
[Service]
ExecStart=/opt/api/current/bin/api --listen 127.0.0.1:9001
KillSignal=SIGTERM
TimeoutStopSec=30          # 给在途请求 30 秒收尾
KillMode=mixed             # 先给主进程发信号,再给整个进程组

应用应该把 SIGTERM 理解成“停止接受新请求、排空、退出”,而不是“立刻死”。一个规矩的 Go 或 Node 服务开箱就是这样;Python 的 WSGI 应用通常需要一个配置开关(比如 gunicorn --graceful-timeout 30)。如果你的应用把 SIGTERM 当成立即死亡,那无论反向代理多聪明,重载都会变成一次故障。

关键性质是长期存活的监听套接字:如果新进程能在旧进程排空的同时继承这个套接字,那就永远不存在“没有任何东西在监听”的时刻。systemd 的套接字激活就能做到。做不到的话,就把排空时间压短,接受几毫秒的拒绝连接。

重要的顺序:构建、健康检查、切换、reload nginx

这个顺序就是部署本身。搞错了,你要么切换到一个还没启动完的进程,要么在新 upstream 还没就绪时就 reload 了 nginx。

  1. 在旁边的位置构建。 新的 release 目录或新端口。公开流量还没碰到它。
  2. 启动新进程(蓝绿)或者完成构建(静态)。它已经跑起来了,但还没接收流量。
  3. 对它做健康检查。 轮询一个能证明应用真的能干活的端点——而不只是端口开着。一个进程活着、却连不上它的数据库,那不算健康。
  4. 翻转指针。 原子符号链接切换,或者改 nginx upstream。
  5. reload nginx(nginx -s reload),然后等旧实例的连接排空,再退出/退役它。

第 3 步在第 4 步之前,这就是全部关键。一个只有在应用真能干活时才返回 200 的健康检查,把“这次部署成没成?”从一个猜测变成一个闸门。

#!/usr/bin/env bash
# deploy-api.sh —— 骨架,不是成品
set -euo pipefail

NEW_PORT=9002
HEALTH_URL="http://127.0.0.1:${NEW_PORT}/healthz"

# 1. 准备新 release(在旁边的位置拉取/构建)
systemctl start api-green.service

# 2. 健康闸门:没就绪就大声失败,绝不切换
for i in {1..30}; do
  if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null; then ready=1; break; fi
  sleep 1
done
[ "${ready:-0}" = 1 ] || { echo "green 不健康,中止"; systemctl stop api-green.service; exit 1; }

# 3. 把 nginx upstream 切到 green,reload,再排空 blue
sed -i 's/9001/9002/' /etc/nginx/conf.d/api-upstream.conf
nginx -t && nginx -s reload

# 4. 让 blue 把在途请求做完,然后退役它
sleep 30
systemctl stop api-blue.service

那句 sed 很粗糙——实际中我会用模板渲染 upstream 文件。重要的是形态:先健康、再切换、后排空。

reload 还是 restart:关于 nginx 的一条说明

systemctl reload nginx 发送 SIGHUP。master 进程重新读取配置,启动新的 worker,然后等旧 worker 的连接关闭后优雅地把它们关掉。已经在途的请求会在旧 worker 上完成,不会掉连接。这就是为什么部署时是 reload nginx,而几乎从不 restart 它。

systemctl restart nginx 会把 master 整个停掉,再起一个新的。监听套接字会掉,在途请求会死,而配置写错会直接让整台服务器挂掉,而不是 reload 失败。要升级二进制时才用 restart,切换流量时不用。

唯一的纪律:reload 前永远先 nginx -t。 有笔误的配置会被 reload 拒掉,旧 worker 继续服务——这正是你想要的结果。不做校验就 reload,再碰上一套忽略 reload 错误的设置,你迟早会 reload 进一个坏状态。

回滚:让它变成一次“反向切换”

回滚永远不该是一次重新构建。因为上面每一步都是指针切换,快路径就是把切换反过来:

  • 静态: 用 mv -T 把 current 符号链接切回上一个 release 目录。
  • 蓝绿: 把 nginx 指回旧颜色并 reload;只要你还没停掉旧进程,它就还在运行。

把上一个 release 保留一个窗口期——一小时、一天——正是为了让回滚能瞬间完成。一次坏部署真正昂贵的部分是“你发现它之前”的那段时间,所以切换之后也要对健康检查告警,而不只是切换期间。

真正会导致故障的失效模式

  • 数据库只迁移了一半。 蓝绿共用同一个数据库,意味着两个版本跑在同一套 schema 上。任何迁移都必须是向后兼容的:先加列,再部署用它的代码,最后才删掉旧列。绝不要在切换代码的同一次部署里执行删除或重命名。
  • 粘性会话。 如果会话存在进程内存里、用户被粘在某个颜色上,切到 green 会让人人掉线,更糟的是把同一个用户拆散到两个进程里。做蓝绿之前,先把会话挪到 Redis 或签名 cookie 里。
  • 长连接。 WebSocket、SSE 和长轮询不会按正常的重载节奏排空。要给它们的生命周期设上限,否则旧 worker 永远不退出,你的“优雅”重载会泄露进程好几个小时。
  • 缓存失效。 原子符号链接改变的是目录,不是用户手里已有的 URL。要用内容哈希给静态资源文件名做缓存击穿,而不是去删缓存条目;并且记住,切换前面的 CDN 或浏览器缓存,无论你怎么切,都会在几分钟内继续提供旧页面。
  • 健康检查在撒谎。 一个因为 HTTP 服务器起来了就返回 200、而数据库连接池其实是空的 /healthz,什么都没把关。让它去测真正重要的那些依赖。

让复杂度匹配你的爆炸半径

最后一点,也是我真正在意的:部署复杂度应该匹配你的爆炸半径。 一个静态博客不需要绿色进程和连接排空;它需要的是一次原子符号链接切换,和一行 mv 就能完成的回滚。一个带数据库的支付服务则没有资格跳过这些东西。我用同一台机器承载这两者,各自配上合适数量的活动部件——小的静态切换保持简单,而那两个可能亏钱的服务则拿到健康闸门和排空窗口。

如果你是从零开始,就先从符号链接切换做起。它免费给你一个零停机部署和一行命令的回滚,而且它是所有更花哨方案赖以建立的心智模型。如果你的 TLS 还是手工管理,先把它修好——见规模化后的自动化 TLS——因为一次和证书续期抢时间的部署,就是一场你自己制造的故障。