02性能优化
2026 · 02 · 27
8 分钟
← 返回列表

从停机部署到零停机:蓝绿部署实践

记录项目从原始的停机编译部署,到实现蓝绿部署零停机发布、秒级回滚的完整演进过程。

背景

该项目基于 Next.js 15 构建,使用 PM2 进行进程管理,Nginx 作为反向代理。在很长一段时间里,我的部署方式简单粗暴——一行 npm 脚本搞定一切。

直到有一天,一次编译失败让网站直接宕机了好几分钟,我才意识到:是时候认真对待部署流程了。

一、痛点:原始部署方式的致命缺陷

我们原来的部署脚本是这样的:

json
1"build": "pm2 stop nextjs || true && next build && pm2 start nextjs || true" 2

这行看似简洁的命令,背后隐藏着两个严重问题。

1.1 编译期间网站完全不可用

部署流程是串行的:

text
1pm2 stop(停掉旧进程)→ next build(编译)→ pm2 start(启动新进程) 2

next build 的耗时取决于项目规模,12Club 大概需要 1~2 分钟。在这段时间里,PM2 进程已经停掉了,但新代码还没编译完,Nginx 转发请求到 localhost:9000 只会得到一个冰冷的 502 Bad Gateway

对用户来说,这意味着每次部署都会遇到"网站挂了"。

1.2 编译失败无法回滚

更糟糕的是,如果 next build 编译失败——比如新代码引入了 TypeScript 错误——此时旧进程已经被 pm2 stop 停掉了,新进程又起不来,网站直接宕机,且没有任何自动恢复机制

唯一的恢复手段是:手动回退代码 → 重新编译 → 重新启动。一套流程下来又是好几分钟。

1.3 问题本质

根本原因在于:停服和编译是串行的,且没有隔离。旧版本在新版本就绪之前就被销毁了,这就像拆掉旧桥之后才开始建新桥,中间只能游泳。

二、改进方案:蓝绿部署

蓝绿部署(Blue-Green Deployment)是业界成熟的零停机发布策略。核心思想很简单:

始终保持两套环境,一套在线服务,一套用于部署。新版本验证通过后,一键切换流量。

2.1 架构设计

我们将 PM2 管理的 Next.js 实例分为两个:

实例PM2 名称端口
蓝色 (Blue)nextjs-12club-blue9000
绿色 (Green)nextjs-12club-green9001

Nginx 始终将流量代理到当前活跃实例的端口。部署时在另一个端口启动新版本,验证通过后切换 Nginx 的 proxy_pass

2.2 部署流程

text
1用户正常访问(Nginx → Blue:9000) 234 ① next build 编译 ──── 失败? → 直接退出,Blue 不受影响 567 ② Green:9001 启动新实例 8910 ③ 健康检查 ──── 失败? → 清理 Green,Blue 不受影响 111213 ④ Nginx proxy_pass 切换到 9001 141516 ⑤ 停止 Blue(保留不删除,用于回滚) 171819 用户无感知(Nginx → Green:9001) 20

整个过程中旧实例始终在运行,编译、启动、健康检查任何一步失败,都不会影响线上服务。

2.3 初版实现

部署脚本的核心逻辑:

bash
1# 编译(旧实例继续服务) 2if ! next build; then 3 echo "编译失败!当前实例不受影响。" 4 exit 1 5fi 6 7# 在新端口启动 8PORT=$TARGET_PORT pm2 start npm --name "$TARGET_PM2" -- start -- -p "$TARGET_PORT" 9 10# 健康检查 11health_check "$TARGET_PORT" || { 12 pm2 delete "$TARGET_PM2" 13 exit 1 14} 15 16# 切换 Nginx 17sudo sed -i "s|proxy_pass http://localhost:[0-9]\+;|proxy_pass http://localhost:${TARGET_PORT};|" /etc/nginx/nginx.conf 18sudo nginx -s reload 19 20# 停止旧实例 21pm2 stop "$ACTIVE_PM2" 22

同时用一个 .deploy-state 文件记录当前活跃的是 Blue 还是 Green,下次部署时自动切换到另一个。

回滚只需要反向操作:重启被停止的旧实例 → 切换 Nginx → 停止当前实例。

看起来很完美?但实际上这个初版存在几个需要注意的风险。

三、风险识别与优化

3.1 sed 正则可能误伤其他配置

我们的 nginx.conf 中有两个 proxy_pass

nginx
1# Next.js 主站点 2location / { 3 proxy_pass http://localhost:9000; 4} 5 6# OpenList 文件服务 7location /openlist/ { 8 proxy_pass http://127.0.0.1:5244/openlist/; 9} 10

初版使用的正则 proxy_pass http://localhost:[0-9]\+; 虽然不会匹配到 127.0.0.1,但属于模糊匹配,如果未来新增了其他 localhost 的代理配置,就可能被误修改。

优化:改为精确匹配旧端口号:

bash
1# 旧:模糊匹配所有 localhost 端口 2sed -i "s|proxy_pass http://localhost:[0-9]\+;|...|" 3 4# 新:精确匹配已知的旧端口 5sed -i "s|proxy_pass http://localhost:${OLD_PORT};|proxy_pass http://localhost:${NEW_PORT};|" 6

3.2 Nginx 配置修改失败无法恢复

如果 sed 替换出了问题导致 nginx.conf 语法错误,虽然 nginx -t 能检测到,但此时配置文件已经被改坏了。如果管理员没有备份,恢复会很麻烦。

优化:每次修改前自动备份,任何一步失败都自动恢复:

bash
1switch_nginx_port() { 2 local OLD_PORT=$1 3 local NEW_PORT=$2 4 5 # 1. 修改前备份 6 sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak 7 8 # 2. 执行替换 9 sudo sed -i "s|...|...|" /etc/nginx/nginx.conf 10 11 # 3. 语法检查失败 → 自动恢复 12 if ! sudo nginx -t; then 13 sudo cp /etc/nginx/nginx.conf.bak /etc/nginx/nginx.conf 14 return 1 15 fi 16 17 # 4. reload 失败 → 自动恢复 18 if ! sudo nginx -s reload; then 19 sudo cp /etc/nginx/nginx.conf.bak /etc/nginx/nginx.conf 20 sudo nginx -s reload 21 return 1 22 fi 23} 24

3.3 sudo 命令可能阻塞脚本

脚本中使用了 sudo sedsudo nginx,如果当前用户没有免密 sudo 权限,执行时会弹出密码提示导致脚本卡住。

优化:通过 visudo 为部署用户配置针对性的免密权限:

text
1deploy_user ALL=(ALL) NOPASSWD: /usr/bin/sed, /usr/sbin/nginx, /usr/bin/cp 2

只对必要的命令开放免密,不影响安全性。

3.4 首次使用的迁移风险

从旧部署方式迁移到蓝绿部署时,需要先手动停掉旧的 PM2 实例(名称为 nextjs-12club),再首次执行新脚本。如果首次部署失败,既没有旧实例在运行,也没有新实例起来,网站就会宕机。

应对:明确记录手动恢复步骤,确保出问题时能在 1 分钟内恢复:

bash
1# 恢复 Nginx(如果被改坏了) 2sudo cp /etc/nginx/nginx.conf.bak /etc/nginx/nginx.conf 3sudo nginx -s reload 4 5# 恢复 PM2(用旧方式启动) 6pm2 delete nextjs-12club-blue 2>/dev/null || true 7pm2 delete nextjs-12club-green 2>/dev/null || true 8pm2 start npm --name nextjs-12club -- start -- -p 9000 9pm2 save 10 11# 清理状态文件 12rm -f .deploy-state 13

四、最终方案

经过优化后,完整的部署体系包含两个脚本:

部署 (npm run deploy)

每次部署自动完成:编译 → 新端口启动 → 健康检查 → 备份 Nginx → 切换流量 → 保留旧实例。任何一步失败都安全退出,不影响线上。

回滚 (npm run rollback)

由于旧实例只是 pm2 stop 而非 pm2 delete,回滚时直接 pm2 restart 即可秒级恢复,无需重新编译。

安全保障层级

失败场景处理方式线上影响
编译失败直接退出
新实例启动失败清理新实例并退出
健康检查不通过清理新实例并退出
Nginx 语法错误自动恢复备份并退出
Nginx reload 失败自动恢复备份并退出

每一个可能出错的环节都有对应的回退机制,确保线上服务始终可用。

总结

这次从"停机部署"到"蓝绿部署"的演进,本质上解决的是一个部署原子性的问题:

  • 旧方案:停服 → 编译 → 启动,三步不是原子的,中间任何一步失败都会导致服务不可用。
  • 新方案:编译和启动都在旧实例运行的情况下完成,流量切换是瞬时的,切换前后都有健康检查和自动回退。

虽然我没有用到 Docker 或 Kubernetes 这样的重型工具,但通过 PM2 + Nginx + Shell 脚本的组合,在单机环境下实现了零停机部署和秒级回滚,对于中小型项目来说已经是一个成本很低、效果很好的方案。

如果你的项目也面临类似的部署痛点,不妨试试这个思路。