你的 'Atomic' 部署可能并非原子性的
Source: Dev.to

如果你使用符号链接交换来实现零停机部署,可能会写出类似下面的代码:
ln -sfn releases/20260112 current
看起来是原子的。其实并不是。
The Bug
Run that command through strace:
symlink("releases/20260112", "current") = -1 EEXIST
unlink("current") = 0
symlink("releases/20260112", "current") = 0
在 unlink 与第二个 symlink 之间,current 符号链接并不存在。负载较高时,一些请求会在这段空窗期触发,返回 ENOENT。你的“零停机时间”部署实际上导致了停机。
Linux 修复
文档完善的解决方案是:
ln -s releases/20260112 .tmp/current.$$
mv -T .tmp/current.$$ current
创建一个临时符号链接,然后使用 mv -T 原子地替换目标。-T 选项强制 mv 调用 rename(2),在 POSIX 文件系统上是原子的。
macOS 问题
BSD mv 缺少 -T 参数,并且对符号链接的处理方式不同——如果 current 指向一个目录,mv .tmp/current.$$ current 会把临时符号链接 into 那个目录中,而不是替换它。Capistrano 社区早已知晓此问题,并通过 Ruby 进行规避,但大多数工具要么在 macOS 上接受这种竞争条件,要么要求在 Linux 上进行开发。
不同的方法
一种可移植的解决方案通过使用 Python 的 os.replace() 在 Linux 和 macOS 上均可工作,该函数在所有 POSIX 系统上直接调用 rename(2)。
detect_platform() {
if mv --version 2>/dev/null | grep -q 'GNU'; then
printf 'linux'
else
printf 'bsd'
fi
}
activate_release() {
local tmp_link=".tmp/current.$$"
ln -s "releases/$release_id" "$tmp_link"
if [[ "$(detect_platform)" == "linux" ]]; then
mv -T "$tmp_link" "current"
else
python3 -c "import os; os.replace('$tmp_link', 'current')"
fi
}
平台检测通过检查 GNU coreutils (mv --version) 而不是依赖 uname,从而处理诸如 macOS 上 Homebrew GNU coreutils 等边缘情况。
证明竞争条件
#!/bin/bash
mkdir -p releases/v1 releases/v2
echo "v1" > releases/v1/version
echo "v2" > releases/v2/version
ln -s releases/v1 current
# Reader loop in background
(
for i in {1..10000}; do
cat current/version 2>/dev/null || echo "ENOENT"
done
) > reads.log &
reader_pid=$!
# Writer loop – rapidly swap symlink
for i in {1..1000}; do
ln -sfn releases/v1 current
ln -sfn releases/v2 current
done
wait $reader_pid
errors=$(grep -c ENOENT reads.log || true)
echo "Errors: $errors / 10000 reads"
在普通系统上,每次运行大约会出现 10–50 条 ENOENT 错误。使用原子方式可以消除这些错误。
The Full Script
作者将逻辑封装到一个单文件部署脚本中,提供了以下功能:
- 原子符号链接交换 在 Linux 和 macOS 上
- 基于目录的锁定,并具备过期 PID 检测
- 自动回滚,在
SIGINT/SIGTERM时触发 - 状态机清理,能够判断是回滚还是仅删除临时文件
GitHub:
它不做的事
- 共享目录 – 没有 Capistrano‑style 共享文件夹符号链接
- 远程部署 – 请自行使用
ssh/rsync包装 - 发布修剪 – 如有需要可添加定时任务
- 服务重启 – 使用你的 init 系统
一件事,做好了。
FAQ
为什么不直接使用 Capistrano/Deployer?
它们在 Ruby/PHP 环境中非常好用。这个脚本可以直接放入任何 CI 流水线。
为什么不使用容器?
并非所有人都在运行 Kubernetes。虚拟机、裸金属和边缘设备仍然存在。
Python 是依赖项。
Python 3 随 macOS 以及几乎所有 Linux 发行版一起提供。它和 Bash 一样无处不在。
renameat2() 与 RENAME_EXCHANGE 怎么样?
那是 Linux 3.15+ 配合 glibc 2.28+ 的特性。它提供真正的原子交换,但不可移植。
这在 NFS 上能工作吗?
不能。rename(2) 的原子性保证在网络文件系统上不成立。
进一步阅读
- Atomic symlinks – 对该问题的深入探讨
- Things UNIX can do atomically –
mv -T的洞见 - Capistrano issue #346 – 2013 年的原始 bug 报告