单机蓝绿发布:软链接切换与回滚预案

大版本上线那晚,我盯着屏幕二十分钟

去年给站做一次大改版,改了主题模板、加了新栏目、还顺手换了 PHP 框架的一个版本。按照老办法,我是这么干的:本地改好 → rsync 推到服务器 → 跑一遍数据库迁移脚本 → 刷首页看看。

推完第一分钟,首页 500。原因是新模板引用了一个只在本地存在的配置文件。我手忙脚乱地 git checkout 回滚,但数据库迁移脚本已经跑了——它给文章表加了三列,还改了两个字段的类型。代码能回滚,数据库回不去。那二十分钟里站点是半挂状态:老代码 + 新表结构,查询报错。

这件事之后我想明白一个道理:回滚不是「把代码退回去」,而是「让整个系统回到某个时间点的一致状态」。代码、配置、数据库、静态资源、缓存,这五样东西任何一样落在旧版本上,都会变成不一致。

蓝绿发布这个词听着像是大厂才用的架构,但它的核心思想简单到可以在单台服务器上落地:准备两套完整的环境,切换的是「流量指向哪一套」,而不是「就地改哪一套」。这篇文章讲的是我在这台单机上做这件事的做法,以及哪些地方必须放弃完美的蓝绿。

单机做蓝绿,到底是哪一层在做

先澄清一个常见的误解。标准蓝绿发布说的是两台完整的机器(蓝组、绿组),负载均衡器切流量。单机上做不到这个——你没那么多内存跑两套 MySQL。

所以单机版的蓝绿,实际上是「代码目录级」的蓝绿:在硬盘上有两个(或更多)目录装不同版本的代码,Nginx 的 root 指向其中一个,切换的方式是改软链接(symlink)。数据库、Redis 这些有状态的组件是共享的,不能蓝绿。

这个限制决定了它能解决什么、不能解决什么:

能解决的:
  ✓ 新代码有致命 bug(语法错误、依赖缺失、模板写错)
  ✓ 新版本静态资源缺失
  ✓ 配置改错导致启动失败
  ✓ 新版本性能明显劣化(切回旧目录立刻恢复)

不能解决的:
  ✗ 数据库迁移出错(表结构已改,旧代码读不了新结构)
  ✗ 数据被新代码写坏(旧代码回滚也救不回来)
  ✗ 依赖的外部服务故障

认清这条边界之后,重点就变成了「如何让数据库迁移也能安全回退」。这是单机蓝绿里最难的部分,后面单独讲。

目录结构:把「当前版本」变成一个指针

核心只有三行目录:

/srv/app/
├── releases/
│   ├── 20261020-1030/     # 每次发布一个新目录
│   ├── 20261023-1500/
│   └── 20261024-0930/
├── shared/                # 跨版本共享的部分,不随发布变化
│   ├── uploads/           # 用户上传(绝对不能跟着版本回滚!)
│   ├── .env               # 环境配置
│   └── cache/
└── current -> releases/20261024-0930   # 软链接,Nginx 指向这里

要点逐条说明。

为什么用时间戳命名

用 git commit 前八位也可以,但时间戳有额外好处:ls -lt releases/ 直接就是时间顺序,出问题的时候一眼看到「最近三次发布是什么时候」。而且时间戳不依赖 git 仓库存在——有些项目部署的是打包产物,没有 .git 目录。

为什么 uploads 必须放 shared

这是新手最容易出大事故的地方。如果用户上传的文件放在 releases/版本/uploads/ 里,那么回滚到上一个版本时,用户在这期间上传的所有文件都会「消失」——不是删除,是新版本的目录变成了当前目录,旧目录里的新文件自然看不到了。更糟的是,如果发布脚本带 rsync --delete,那些文件会被真的删掉。

shared/uploads 通过符号链接接入每个 release:

cd /srv/app/releases/20261024-0930
ln -s /srv/app/shared/uploads ./public/uploads

这样无论切到哪个版本,看到的都是同一份上传目录。

为什么 current 要是指向目录的软链接,而不是目录本身

因为替换软链接是可以做到「原子」的:

ln -sfn /srv/app/releases/20261024-0930 /srv/app/current.new
mv -T /srv/app/current.new /srv/app/current

mv -T 把 current.new 重命名成 current,这是一个 rename(2) 系统调用,原子发生。任何时刻 current 要么指向旧版本,要么指向新版本,不存在「指向一个不存在的目录」的瞬间。

如果直接 ln -sfn 目标 /srv/app/current,在某些实现下会先删除旧链接再创建新的,中间有个极短的空窗。虽然短到几乎不可能被请求撞上,但用 mv -T 是零成本的消除。注意 -T 不能省:不加它,mv 会把源目录移到目标目录里面,变成 current/current.new。

Nginx 怎么指向

server {
    listen 80;
    server_name www.example.com;
    root /srv/app/current/public;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location /uploads/ {
        # uploads 是指向 shared 的软链接
        try_files $uri =404;
        expires 30d;
    }
}

一个必须知道的细节:Nginx 缓存了 root 路径,而且默认会把软链接解析成真实路径。这意味着切换 current 之后,Nginx 不会立刻看到新目录,必须 nginx -s reload。reload 会重新读取配置并重新解析路径,这正好是我们需要的。

但如果你希望切换后不用 reload(比如想让切换更快),可以关掉软链接解析:

disable_symlinks off;  # 默认就是 off,但某些发行版配置里会打开

实际上保持默认 + reload 更清晰,因为 reload 本身是平滑的(老 worker 处理完手上的请求才退出),不会断连。所以我选择「切换链接 + reload」这个组合,语义明确、易于理解。

发布脚本:五步,每步都可退回

把上面这些串起来,发布脚本的骨架是这样:

#!/bin/bash
set -euo pipefail

APP=/srv/app
TS=$(date +%Y%m%d-%H%M)
REL=$APP/releases/$TS
SRC=/tmp/build-$TS   # 假设构建产物在这里

echo "==> 1. 准备新目录"
mkdir -p "$REL"
rsync -a --exclude '.env' --exclude 'uploads' "$SRC/" "$REL/"

echo "==> 2. 接入共享资源"
ln -sfn "$APP/shared/.env" "$REL/.env"
ln -sfn "$APP/shared/uploads" "$REL/public/uploads"
mkdir -p "$REL/runtime" "$REL/runtime/cache"

echo "==> 3. 静态检查(不进流量)"
php -l "$REL/public/index.php"
if [ ! -f "$REL/public/index.php" ]; then
    echo "FATAL: 入口文件缺失,中止"; rm -rf "$REL"; exit 1
fi

echo "==> 4. 切换"
ln -sfn "$REL" "$APP/current.new"
mv -T "$APP/current.new" "$APP/current"
nginx -t && nginx -s reload

echo "==> 5. 探针"
for i in 1 2 3 4 5; do
    code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: www.example.com' http://127.0.0.1/)
    if [ "$code" = "200" ]; then echo "OK ($i)"; exit 0; fi
    sleep 1
done
echo "FATAL: 五次探针均失败,自动回滚"; exit 1

逐段说明设计意图。

第一步:为什么用 rsync 而不是 git clone

两者都行。rsync 的好处是「构建产物在本地已经验证过」——你本地跑过一遍的东西原样传上去,不依赖服务器上的 PHP 版本能跑 composer。缺点是传输量比 git 大。如果你的构建是在服务器上做的(服务器装 composer、npm),那直接在 releases 目录里 git clone 更省事。

无论哪种,--exclude '.env' 和 --exclude 'uploads' 都不能漏,否则会把共享资源覆盖掉。

第三步:静态检查为什么重要

这一步是整个脚本里最「廉价」的部分,却能拦掉大部分事故。php -l 语法检查、关键文件存在性检查、配置文件的必填项检查——这些都不需要流量就能做,提前做等于提前发现。

我還會在这一步加一个「依赖完整性」检查:

# 检查 vendor 目录是否完整
if [ ! -d "$REL/vendor" ]; then
    echo "FATAL: vendor 目录缺失,忘记 composer install?"
    rm -rf "$REL"; exit 1
fi
cd "$REL" && php -r 'require "vendor/autoload.php";' 2>/dev/null \
    || { echo "FATAL: autoload 失败"; exit 1; }

这不只是防御——它把「依赖没装」这类低级错误挡在了流量之外,代价只有零点几秒。

第四步:nginx -t 和 reload 的顺序

nginx -t 在前,reload 在后。如果配置有语法错误,-t 会失败,set -e 让脚本直接退出,此时的 current 已经指向新版本——这意味着你处于「新代码 + 旧配置」的状态。这不好。

改进版是:先 nginx -t,通过之后再切链接:

nginx -t || { echo "FATAL: nginx 配置校验失败"; rm -rf "$REL"; exit 1; }
ln -sfn "$REL" "$APP/current.new" && mv -T "$APP/current.new" "$APP/current"
nginx -s reload

这次 nginx -t 检的是「旧配置」,但因为它和代码目录无关,所以实际上是在确认「nginx 当前状态正常」。这个顺序问题不大,但如果你在发布时同时改了 Nginx 配置,就要用两阶段:先校验新配置再 reload,出问题立刻用备份配置回滚。

第五步:探针为什么要打五次

单次探针会被偶发抖动骗到。五次、间隔一秒,允许中间的抖动量,但要求至少一次 200——实际上更严格的做法是要求连续两次成功。我这里用「任意一次成功就退出」,是因为探针之后还有人工确认环节;如果你想让脚本自己决定是否回滚,判据应该是「连续三次失败」。

探针必须打回源(127.0.0.1 + 伪装 Host 头),不能打域名——域名会经过 CDN,CDN 可能返回缓存的旧内容,探针就完全失效了。

自动回滚:写清楚才敢用

上面脚本最后一步 exit 1 只是让脚本失败。真正的回滚要单独写一个脚本,而且这个脚本要能在紧张状态下被无脑执行:

#!/bin/bash
# rollback.sh —— 回到上一个版本
set -euo pipefail
APP=/srv/app

CURRENT=$(readlink -f "$APP/current")
PREV=$(ls -1dt "$APP/releases"/*/ | sed -n '2p' | sed 's:/$::')

if [ -z "$PREV" ]; then echo "FATAL: 没有上一个版本"; exit 1; fi
if [ "$CURRENT" = "$PREV" ]; then echo "已是上一个版本"; exit 1; fi

echo "从 $(basename "$CURRENT") 回滚到 $(basename "$PREV")"

ln -sfn "$PREV" "$APP/current.new"
mv -T "$APP/current.new" "$APP/current"
nginx -s reload

for i in 1 2 3; do
    code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: www.example.com' http://127.0.0.1/)
    [ "$code" = "200" ] && { echo "回滚完成,探针 OK"; exit 0; }
    sleep 1
done
echo "WARNING: 回滚后探针仍失败,问题可能不在代码"
exit 1

几条设计原则。

回滚脚本必须比发布脚本更简单。 凌晨三点、脑子不清楚的时候要能执行。所以回滚脚本不做构建、不做检查、不做任何有分叉的逻辑,就是「指向上一个目录 + reload + 探针」。

回滚后探针还失败,说明问题不在代码。 这时候应该去看数据库、Redis、磁盘空间。脚本给出这句提示,能省下很多瞎忙的时间。

用 ls -1dt 取「目录修改时间最新的第二个」,而不是按名字排序。 因为如果目录名是时间戳,两种方式结果一样;但如果你后来改成按 commit 命名,按名字排序就没意义了。-t 按时间排序更稳。

回滚也要留痕迹。 建议在回滚时往一个日志文件里追加一行:「时间 + 从哪个版本回滚到哪个版本 + 操作人」。事后复盘的时候,这行日志能救命。

最难的部分:数据库迁移怎么退

前面说了,单机蓝绿解决不了数据库问题。但这件事不是无解,关键是把迁移拆成「可回退」和「不可回退」两类。

原则:两次发布,而不是一次

任何「破坏性」的迁移(删列、改类型、改字段含义)都不要和代码发布放在同一次。标准做法是三步走,跨越三次发布:

第 1 次发布:只加结构,不改逻辑
  加新列(允许 NULL 或有默认值)
  加新表
  老代码完全不认识这些新东西,照常运行 → 可以随时回滚

第 2 次发布:双写 + 读切换
  新代码同时写新旧两处
  读的时候优先读新列,新列为空则回落读旧列
  → 回滚到老代码,数据仍然完整(因为老列还在写)

第 3 次发布:清理
  确认稳定运行一周之后
  停止写旧列、删除旧列
  这一步才真正不可回退

代价是三次发布、三倍工作量。但对于任何有数据的表,这个成本远小于一次失败的迁移。而如果表很小、数据可以重来(比如一张统计缓存表),那就直接改,不必折腾。

可以立刻用的一招:迁移前自动快照

对于小表(几万行以内),最省事的办法是在发布脚本里加一步数据库快照:

SNAPSHOT=/srv/app/shared/db-snapshots/$TS.sql.gz
mysqldump --single-transaction --quick \
  --no-create-db --skip-add-locks \
  你的库名 文章表 分类表 设置表 | gzip -6 > "$SNAPSHOT"

# 保留最近 10 份
ls -1t /srv/app/shared/db-snapshots/*.sql.gz | tail -n +11 | xargs -r rm -f

--single-transaction 在 InnoDB 上做一致性快照,不锁表。--skip-add-locks 只是让恢复时少些噪音。注意:快照存在同一台机器上没有意义,如果磁盘挂了就一起没了。这份快照的价值是「回滚代码时顺便把数据也恢复」,属于短期应急,不是备份。真正的备份还是要推到别的机器。

迁移脚本必须幂等

如果迁移脚本执行到一半失败,你需要能重跑。这就要求脚本判断「这一列是否已经存在」再决定加不加:

-- 不幂等(第二次执行会报错)
ALTER TABLE posts ADD COLUMN view_count INT DEFAULT 0;

-- 幂等的写法(MySQL 8.0 没有 IF NOT EXISTS for ADD COLUMN,用存储过程或者应用层判断)
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'posts' AND COLUMN_NAME = 'view_count';

大多数框架的迁移工具(Laravel 的 migrations、Typecho 的插件升级钩子)都自带版本记录,天然幂等。如果你是手写 SQL 发布,务必自己加这层判断,否则「失败后重跑」这个动作就变成「第二次报错,然后开始慌」。

缓存:切了目录之后最容易漏的一环

代码切换完成、探针通过,但你刷页面发现——还是老的样式。这几乎必然是缓存。

三层缓存依次检查:

OPcache。 如果你的 opcache.validate_timestamps=0(生产环境常见配置),那么 FPM 进程里缓存的字节码不会因为文件变了而失效。nginx reload 只重载 Nginx,不影响 FPM。必须显式清:

systemctl reload php8.2-fpm
# 或者用 opcache_reset() 通过一个受保护的内部接口触发

注意 reload 之后 FPM master 会重新 fork worker,新 worker 加载的是新目录的文件——只要 root 已经切了。但 reload 不会清 shared memory 里的 user cache(APCu),如果代码里有 APCu 版本号机制,记得把版本号也改掉。

框架自身的缓存。 Laravel 的 bootstrap/cache/、Symfony 的 var cache、Typecho 的插件缓存——这些通常放在 release 目录里(我在脚本里 mkdir 了 runtime/cache),切目录自然就换了。但如果它们在 shared 里,就必须手动清。 这是设计时要明确的一件事:哪些缓存跟着版本走(放 release),哪些跨版本共享(放 shared,但需要版本号隔离)。

浏览器和 CDN。 HTML 绝不能长缓存,静态资源必须带指纹。如果 HTML 被 CDN 缓存了十分钟,那你回滚之后用户还是看到新版本十分钟。检查方法:

curl -sI -H 'Host: www.example.com' http://127.0.0.1/ | grep -i 'cache-control\|age'
curl -sI https://www.example.com/ | grep -i 'cache-control\|age'

源站应该返回 no-cache 或极短 max-age;如果 CDN 返回的 Age 大于 0,说明 HTML 被缓存了,这是配置问题,得改。

什么情况下蓝绿其实是过度设计

写到这里我想泼点冷水。蓝绿发布对「每天改几次代码」的站点有价值,但对我这种一天最多发一两次的博客,它其实有点重。如果你的情况是下面几种,可能不需要这么复杂:

纯静态站或很少改的站。 直接 rsync 到一个目录,出问题 rsync 回来,二十秒的事。做 releases 目录反而增加心智负担。

没有用户上传、没有数据库迁移的站。 蓝绿的最大价值是「切换干净」,如果没有有状态的东西要保护,这个价值就打折扣。

只有你一个人维护,且能接受几分钟的停机。 那就用维护窗口 + 备份 + 直接改。简单直接的方案,只要备份是真的,就够了。

真正让蓝绿变得必须的,是这三件事之一:有用户上传必须跨版本保留、有数据库迁移需要配合代码、或者发布失败的影响面大于一分钟。满足任意一条,就值得投入这一天时间把目录结构搭起来。

而且这套结构有个隐藏收益:每次发布都是一个完整目录,你可以拿 diff -r 对比任意两个版本,也可以把某个 release 目录直接拷到测试机上复现问题。这种「历史版本随手可得」的能力,在排查那种「上线三天后才出现的古怪问题」时特别有用。我现在遇到疑难问题,第一反应就是「回到上一版目录跑一遍看看」。

Last modification:October 24th, 2026 at 08:27 pm

Leave a Comment