从停机部署到零停机:蓝绿部署实践
记录项目从原始的停机编译部署,到实现蓝绿部署零停机发布、秒级回滚的完整演进过程。
背景
该项目基于 Next.js 15 构建,使用 PM2 进行进程管理,Nginx 作为反向代理。在很长一段时间里,我的部署方式简单粗暴——一行 npm 脚本搞定一切。
直到有一天,一次编译失败让网站直接宕机了好几分钟,我才意识到:是时候认真对待部署流程了。
一、痛点:原始部署方式的致命缺陷
我们原来的部署脚本是这样的:
1"build": "pm2 stop nextjs || true && next build && pm2 start nextjs || true"
2这行看似简洁的命令,背后隐藏着两个严重问题。
1.1 编译期间网站完全不可用
部署流程是串行的:
1pm2 stop(停掉旧进程)→ next build(编译)→ pm2 start(启动新进程)
2next 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-blue | 9000 |
| 绿色 (Green) | nextjs-12club-green | 9001 |
Nginx 始终将流量代理到当前活跃实例的端口。部署时在另一个端口启动新版本,验证通过后切换 Nginx 的 proxy_pass。
2.2 部署流程
1用户正常访问(Nginx → Blue:9000)
2 │
3 ▼
4 ① next build 编译 ──── 失败? → 直接退出,Blue 不受影响
5 │
6 ▼
7 ② Green:9001 启动新实例
8 │
9 ▼
10 ③ 健康检查 ──── 失败? → 清理 Green,Blue 不受影响
11 │
12 ▼
13 ④ Nginx proxy_pass 切换到 9001
14 │
15 ▼
16 ⑤ 停止 Blue(保留不删除,用于回滚)
17 │
18 ▼
19 用户无感知(Nginx → Green:9001)
20整个过程中旧实例始终在运行,编译、启动、健康检查任何一步失败,都不会影响线上服务。
2.3 初版实现
部署脚本的核心逻辑:
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:
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 的代理配置,就可能被误修改。
优化:改为精确匹配旧端口号:
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};|"
63.2 Nginx 配置修改失败无法恢复
如果 sed 替换出了问题导致 nginx.conf 语法错误,虽然 nginx -t 能检测到,但此时配置文件已经被改坏了。如果管理员没有备份,恢复会很麻烦。
优化:每次修改前自动备份,任何一步失败都自动恢复:
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}
243.3 sudo 命令可能阻塞脚本
脚本中使用了 sudo sed 和 sudo nginx,如果当前用户没有免密 sudo 权限,执行时会弹出密码提示导致脚本卡住。
优化:通过 visudo 为部署用户配置针对性的免密权限:
1deploy_user ALL=(ALL) NOPASSWD: /usr/bin/sed, /usr/sbin/nginx, /usr/bin/cp
2只对必要的命令开放免密,不影响安全性。
3.4 首次使用的迁移风险
从旧部署方式迁移到蓝绿部署时,需要先手动停掉旧的 PM2 实例(名称为 nextjs-12club),再首次执行新脚本。如果首次部署失败,既没有旧实例在运行,也没有新实例起来,网站就会宕机。
应对:明确记录手动恢复步骤,确保出问题时能在 1 分钟内恢复:
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 脚本的组合,在单机环境下实现了零停机部署和秒级回滚,对于中小型项目来说已经是一个成本很低、效果很好的方案。
如果你的项目也面临类似的部署痛点,不妨试试这个思路。