당신의 'Atomic' 배포는 아마도 원자적이지 않을 것입니다
Source: Dev.to

제로 다운타임 배포를 위해 심링크 교체를 사용하고 있다면, 아마도 다음과 같은 코드를 작성했을 것입니다:
ln -sfn releases/20260112 current
원자적으로 보이지만, 실제로는 그렇지 않습니다.
버그
strace로 해당 명령을 실행해 보세요:
symlink("releases/20260112", "current") = -1 EEXIST
unlink("current") = 0
symlink("releases/20260112", "current") = 0
unlink와 두 번째 symlink 사이에 current 심볼릭 링크가 존재하지 않습니다. 부하가 걸린 상황에서는 일부 요청이 그 틈새에 걸려 ENOENT 오류를 받게 됩니다. 여러분의 “zero‑downtime”(무중단) 배포가 오히려 다운타임을 초래한 것입니다.
The Linux Fix
The well‑documented solution is:
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는 임시 심볼릭 링크를 해당 디렉터리 안으로 이동시켜서 교체하지 않습니다. 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
}
플랫폼 감지는 uname에 의존하는 대신 GNU coreutils(mv --version)를 확인하며, 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
- Atomic symlink swap on Linux and macOS → Linux와 macOS에서 원자적 심링크 교체
- Directory‑based locking with stale PID detection → 디렉터리 기반 잠금 및 오래된 PID 감지
- Automatic rollback on
SIGINT/SIGTERM→SIGINT/SIGTERM발생 시 자동 롤백 - State‑machine cleanup that knows whether to rollback or just remove temporary files → 상태 머신 정리 로, 롤백이 필요한지 아니면 임시 파일만 제거하면 되는지 판단
GitHub:
이것이 하지 않는 것
- 공유 디렉터리 – Capistrano 스타일의 공유 폴더 심링크 없음
- 원격 배포 – 직접
ssh/rsync로 감싸세요 - 릴리즈 정리 – 필요하면 cron 작업을 추가하세요
- 서비스 재시작 – init 시스템을 사용하세요
한 가지, 제대로 된 것.
FAQ
왜 Capistrano/Deployer를 그냥 쓰지 않나요?
이미 Ruby/PHP 환경에 있다면 훌륭합니다. 이 스크립트는 어떤 CI 파이프라인에도 바로 넣어 사용할 수 있습니다.
왜 컨테이너가 아니죠?
모두가 Kubernetes를 사용하는 것은 아닙니다. VM, 베어 메탈, 엣지 디바이스도 여전히 존재합니다.
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년 원본 버그 보고서