一个软链接引发的 502
说一个很典型的场景。为了做到"改代码不宕机、出事能秒回滚",很多人会借鉴 Capistrano 的发布方式:服务器上放 releases/20260927-1530/ 这样的待发布目录,再用一个 current 软链接指向当前生效的那一份。更新时把新代码传到新的 release 目录,然后一条 ln -sfn 把 current 指过去——理论上零停机切换。
这套方案本身很成熟,但个人站长自己实现时,经常出现这几类故障:切完之后整站 502;PHP 报 Warning: require(): Failed opening required;或者更隐蔽的:前台看着正常,但 Nginx 一直缓存着旧文件的 open_file_cache,新代码怎么都不生效。根因几乎都指向同一件事——没有真正理解软链接在 Nginx 与 PHP 里的解析方式。本文把软链接发布的机制、原子性、以及断链排查讲透。
相对软链接 vs 绝对软链接:断链的第一大来源
创建软链接时,目标路径写相对还是绝对,行为完全不同。
# 相对软链接(危险)
cd /var/www/example
ln -s releases/20260927-1530 current
# ls -l 显示: current -> releases/20260927-1530
# 这个链接是相对于"链接所在目录"解析的
# 绝对软链接(推荐)
ln -s /var/www/example/releases/20260927-1530 /var/www/example/current
# ls -l 显示: current -> /var/www/example/releases/20260927-1530相对软链接的风险在于:它的目标是"相对于链接自身所在目录"来解析的。只要 current 移动了位置、或者被别的脚本从另一个工作目录创建,指向就会漂移,变成断链(dangling symlink)。生产环境一律用绝对路径建软链接,多打几十个字符,省掉一大堆"为什么突然 404"。
判断一个软链接是否断了,最可靠的是 readlink -f:
# -f 会一路解析到底,断链时输出为空或报错
readlink -f /var/www/example/current
# 更直观:用 -e 判断目标是否存在
[ -e /var/www/example/current/index.php ] && echo OK || echo BROKEN
# 列出目录下所有断链
find /var/www/example -xtype l -printfind -xtype l 专门匹配"类型判定为链接、但指向不存在"的项,比 -type l 精确。每次发布后跑一遍,能把断链问题挡在上线前。
ln -sfn 的原子性:为什么它比 rm + ln 安全
切换发布版本时,很多人写的是两步:
# 反例:先删再建,中间有窗口期
rm -f current
ln -s /var/www/example/releases/new current这两条命令之间有一个极短的窗口:current 不存在。如果恰好有请求在这几毫秒内进来,Nginx 解析路径失败,整站 502 或 403。对低流量站可能一辈子碰不上,对有一定访问量的站,这就是"偶发 502"里最难查的一类。
正确写法是 ln -sfn——用创建同名临时链接再原子重命名的方式,确保任意时刻 current 都指向一个有效目录:
ln -sfn /var/www/example/releases/20260927-1530 /var/www/example/current-n(--no-dereference)是关键:当 current 已经是一个指向目录的软链接时,如果没有 -n,ln -sf 会把新链接建到目标目录里面(也就是 releases/.../current),而不是替换 current 本身。这是新手最常踩的坑:命令没报错,但 current 根本没变,还在指向旧 release。加 -n 才是"替换链接本身"的语义。
更严谨的做法是用 mv 实现真正的原子替换(同文件系统内 rename(2) 是原子的):
ln -sfn /var/www/example/releases/20260927-1530 /var/www/example/.current.tmp
mv -T /var/www/example/.current.tmp /var/www/example/currentmv -T 把源当作普通文件处理,直接覆盖目标,等价于一次原子 rename。这样连 ln 内部可能的多步操作都省了,切换瞬间完成。
权限与属主:软链接发布最常见的"莫名 403"
软链接发布还牵出一个权限问题:releases/ 下的目录是由部署用户(比如 deploy)创建的,而 Nginx/PHP-FPM 是以 www-data 运行的。如果新目录的权限不对,切换之后立刻满屏 403。典型症状是:current 明明指向正确的目录、文件也在,但访问就是 403 Forbidden。
排查时不要只 ls -l 看文件本身,要注意路径上每一级目录的执行权限(x 位)。Linux 的目录 x 位代表"能否进入该目录",如果父目录缺 x,即使里面的文件是 644、属主正确,Nginx 也进不去:
# 逐级检查路径权限(每一级都要有 x 位)
namei -l /var/www/example/current/index.php
# 输出示例:
# drwxr-xr-x root root /
# drwxr-xr-x root root var
# drwxr-xr-x root root www
# lrwxrwxrwx deploy deploy example/current -> releases/20260927-1530
# drwxr-xr-x deploy deploy releases/20260927-1530
# -rw-r--r-- www-data www-data 20260927-1530/index.phpnamei -l 会展开软链接并逐级显示权限,是查这类问题的利器。关键结论:releases 和每一级 release 目录必须有 o+x(其他用户可进入),而且新建 release 目录时就要设好,不能等切完再补——补权限的过程中线上就是 403。
# 部署脚本里创建目录时一次到位
mkdir -p /var/www/example/releases/$1
chown -R deploy:www-data /var/www/example/releases/$1
find /var/www/example/releases/$1 -type d -exec chmod 755 {} \;
find /var/www/example/releases/$1 -type f -exec chmod 644 {} \;Nginx 怎么对待软链接:root、alias 与 disable_symlinks
Nginx 默认跟随软链接,所以 root /var/www/example/current; 能正常工作。但有几个细节要注意。
1. 结尾不加斜杠
server {
server_name example.com;
root /var/www/example/current; # 不要写 current/
index index.php index.html;
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
}root .../current 和 root .../current/ 在 Nginx 里的拼接行为不同,前者是标准写法。另外用 $document_root 给 PHP 传 SCRIPT_FILENAME,能让 PHP-FPM 拿到的是软链接解析后的路径——但这也引出下一个坑。
2. PHP-FPM 的 open_basedir 与真实路径
PHP-FPM 解析软链接后,脚本的真实路径是 releases/20260927-1530/...,不是 current/...。如果 php.ini 里配了:
open_basedir = /var/www/example/current:/tmp那么当 current 切换后,PHP 解析出的真实路径 /var/www/example/releases/新版本/ 不在 open_basedir 白名单里,会直接报 open_basedir restriction in effect,页面 500。正确做法是放行整个站点根,或者放行 releases:
open_basedir = /var/www/example:/tmp3. disable_symlinks 的取舍
Nginx 提供了 disable_symlinks 指令,可以禁止跟随软链接(防某些目录穿越攻击)。但如果你用了软链接发布,一旦打开它就是自断接口:
# 用了软链接发布就不要开这个
# disable_symlinks on;
# 折中:只对指定目录关闭,或用 if_not_owner 模式
disable_symlinks if_not_owner from=/var/www/example/current;我的建议:个人站不必开 disable_symlinks,用文件权限和 Nginx 的 location 白名单来防目录穿越更简单。
切换后新代码不生效:open_file_cache 缓存的坑
软链接发布还有一个非常隐蔽的副作用。Nginx 的 open_file_cache(减少 stat() 系统调用用的文件句柄缓存)会缓存文件描述符和路径解析结果。当你把 current 指向新目录后,Nginx 可能仍持有旧目录下文件的缓存,导致新代码不生效,或者更糟——旧目录被删掉后,缓存的 fd 指向已被删除的 inode,返回空内容或 404。
两个处理办法:
# 方案一:发布后 reload nginx(reload 会重建缓存,但不中断连接)
nginx -t && nginx -s reload
# 方案二:调小 open_file_cache 的 inactive,让它快速过期
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;发布后 nginx -s reload 是最省心的做法——它不影响正在处理的连接,只是让 worker 进程重新加载配置、丢弃旧的文件缓存。把 reload 写进发布脚本的最后一步,能避免 90% 的"明明发布了却没生效"。
别只留一份:清理旧 release 的时机
有个容易忽略的问题:旧 release 目录不能马上删。因为 Nginx worker 可能还持有旧目录里文件的文件描述符(特别是 open_file_cache 和正在处理的长连接)。如果发布脚本立刻 rm -rf 旧目录,正在读旧文件的请求会拿到空数据。
稳妥的做法是保留最近三到五个版本,用时间戳目录名天然排序,定期清理最老的:
# 保留最新 5 个 release,删掉更早的(-t 先预览,确认后再执行)
cd /var/www/example/releases
ls -1dt */ | tail -n +6 | head
# 确认无误后删除
ls -1dt */ | tail -n +6 | xargs -r rm -rf同时在发布脚本里加一步健康检查:切换完成后 curl -sf -o /dev/null -w '%{http_code}' 请求首页与一个动态页,如果不是 200,自动把 current 指回上一个版本并 reload。这就是"一键回滚"的真正含义——不是手动改软链接,而是自动化的失败回退。
一套完整的软链接发布脚本骨架
#!/bin/bash
# /root/deploy.sh <release_id> —— 软链接原子发布
set -euo pipefail
BASE=/var/www/example
NEW=$BASE/releases/$1
[ -d "$NEW" ] || { echo "release $1 不存在"; exit 1; }
# 1. 基本完整性检查
[ -f "$NEW/index.php" ] || { echo "缺 index.php,拒绝发布"; exit 1; }
# 2. 原子切换
ln -sfn "$NEW" "$BASE/.current.tmp"
mv -T "$BASE/.current.tmp" "$BASE/current"
# 3. reload,丢弃旧文件缓存
nginx -t && nginx -s reload
# 4. 健康检查,失败自动回滚
code=$(curl -s -o /dev/null -w '%{http_code}' https://example.com/ || true)
if [ "$code" != "200" ]; then
echo "健康检查失败 ($code),回滚"
ln -sfn "$BASE/releases/$(ls -1t $BASE/releases | sed -n 2p)" "$BASE/current"
nginx -s reload
exit 1
fi
echo "发布 $1 成功"这个骨架的关键就是三点:绝对路径建链、mv -T 做原子切换、失败自动回退。它比 Git Hooks 更底层,能和各种发布方式(rsync、CI 打包、手工上传)配合。
结语:软链接是手段,原子性才是目的
软链接发布之所以可靠,不是因为"软链接"这个技术有多神奇,而是因为它把"切换生效版本"变成了一次原子操作——要么旧目录、要么新目录,不存在中间态。理解了相对/绝对路径、ln -sfn 的 -n 语义、open_file_cache 与 open_basedir 的路径解析,你就能把这套方案落地得很稳。
发布这件事,最终拼的不是花哨的工具链,而是每一次上线都可预期、每一步失败都可回退。做到这两点,个人站也能有接近专业团队的发布体验。