シンボリックリンクの更新におけるatomicity
シンボリックリンクをアトミックに更新するには、単にln -snf
ではダメだという記事を昔読んでずっとそうなんだと思っていた。
この記事ではstraceでlnの挙動を追いかけ、シンボリックリンクの更新には unlink
してから symlink
しているのでアトミックではないと書かれていた。
StackOverflowなどでもこのような回答は多く見られた。
unix - Can you change what a symlink points to after it is created? - Stack Overflow
web deployment - atomic way of deploying website updates? - Stack Overflow
やってみた
Ubuntu 22.04 LTS
$ uname -r 5.15.0-91-generic $ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 22.04.3 LTS Release: 22.04 Codename: jammy
ふとしたときにlnのstraceを取る機会があり、見ていたところ思っていたのと違う挙動になっていた。 適当なディレクトリを作成し、その中で以下の様なコマンドを実行してみた。
echo "hello old" > hello.txt strace ln -sf hello.txt hello.sym
straceの結果を見ると、このときは単に symlinkat
が呼ばれている。
... 省略 ... symlinkat("hello.txt", AT_FDCWD, "hello.sym") = 0 ... 省略 ...
次に、新しいファイルでこれを上書きしてみる。
echo "hello new" > hello.new strace ln -sf hello.new hello.sym
hello.sym
には既に既存のリンクがあるため、symlinkat
をしようとして EEXISTS
で失敗し、最終的に乱数名のsymlinkを作った後renameしているようだ。
... 省略 ... symlinkat("hello.new", AT_FDCWD, "hello.sym") = -1 EEXIST (File exists) openat(AT_FDCWD, "hello.sym", O_RDONLY|O_PATH|O_DIRECTORY) = -1 ENOTDIR (Not a directory) newfstatat(AT_FDCWD, "hello.sym", {st_mode=S_IFLNK|0777, st_size=9, ...}, AT_SYMLINK_NOFOLLOW) = 0 newfstatat(AT_FDCWD, "hello.new", {st_mode=S_IFREG|0664, st_size=10, ...}, 0) = 0 openat(AT_FDCWD, "/dev/urandom", O_RDONLY) = 3 read(3, "\37f\22\377\241\351", 6) = 6 close(3) = 0 getpid() = 4322 getppid() = 4319 getuid() = 1000 getgid() = 1000 symlinkat("hello.new", AT_FDCWD, "CurXXqPD") = 0 renameat(AT_FDCWD, "CurXXqPD", AT_FDCWD, "hello.sym") = 0 ... 省略 ...
この挙動は、適当な名前のsymlinkを作ってからmvする方法と全く同じ事をやっているように見える。 実際に先のページに書いてあるようなテストコードを実行してみてもエラーになることは無かった。
$ cat << EOF > ln.sh #!/bin/sh echo test1 > src1 echo test2 > src2 while : do ln -snf src1 dst ln -snf src2 dst done EOF $ chmod +x ./ln.sh $ cat << EOF > cat.sh #!/bin/sh while : do cat dst done EOF $ chmod +x ./cat.sh $ ./ln.sh
↓別のシェルで実行
$ ./cat.sh
macOS 14
macOSには strace
がなく、代わりに dtruss
というコマンドが使えるらしい*1。なお、 ln
コマンドのシステムコールをトレースするためにはSIPによる保護を無効化しなければならなかった。
SIPを無効化せず、codesign
コマンドで署名を削除することで dtruss
が使えるようになるというハックを見つけた*2が、少なくとも自分の環境(M1 Pro macOS 14.1)ではエラーになった。
$ cp /bin/ln ./ $ sudo codesign --remove-signature ./ln $ sudo dtruss -deflo ./ln -sf hello.txt hello.sym dtrace: system integrity protection is on, some features will not be available dtrace: failed to execute ./ln: Could not create symbolicator for task
一時的にSIPを無効にしてやってみた結果:
$ echo "hello old" > hello.old $ sudo dtruss -deflo ln -sf hello.old hello.sym ... 省略 ... 1883/0x385d: 1317 3 2 lstat64("hello.sym\0", 0x16D91A370, 0x0) = -1 Err#2 1883/0x385d: 1318 0 0 stat64("hello.sym\0", 0x16D91A370, 0x0) = -1 Err#2 1883/0x385d: 1319 1 0 lstat64("hello.sym\0", 0x16D91A370, 0x0) = -1 Err#2 1883/0x385d: 1460 140 140 symlink("hello.old\0", "hello.sym\0") = 0 0 $ echo "hello new" > hello.new $ sudo dtruss -deflo ln -sf hello.new hello.sym ... 省略 ... 2177/0x41df: 1200 10 9 lstat64("hello.sym\0", 0x16D686370, 0x0) = 0 0 2177/0x41df: 1206 5 5 stat64("hello.sym\0", 0x16D686370, 0x0) = 0 0 2177/0x41df: 1208 2 1 lstat64("hello.sym\0", 0x16D686370, 0x0) = 0 0 2177/0x41df: 1276 68 68 unlink("hello.sym\0", 0x0, 0x0) = 0 0 2177/0x41df: 1305 63 28 symlink("hello.new\0", "hello.sym\0") = 0 0
よってmacOSの組み込みlnコマンドではアトミックになっていない。実際にあの単純なテストコマンドを実行すると以下の様にエラーになるケースが確認できた。
$ ./cat.sh ... 省略 ... test1 test2 test1 test1 cat: dst: No such file or directory test2 test1 test2 cat: dst: No such file or directory cat: dst: No such file or directory ^C
macOSではHomebrew等を使ってGNU coreutilsをインストールして使うことができる。
$ brew install coreutils
なおこのとき ln
ではなく gln
のように g
というプレフィクスを付けて呼ぶ*3。
こちらは当然symlinkat
した後にrenameat
しており問題は発生しない。
$ sudo dtruss -deflo gln -sf hello.new hello.sym ... 省略 ... 21550/0x11e09: 1837 15 15 symlinkat("hello.txt\0", 0xFFFFFFFFFFFFFFFE, "hello.sym\0") = -1 Err#17 21550/0x11e09: 1844 7 6 openat(0xFFFFFFFFFFFFFFFE, "hello.sym\0", 0x40100000, 0x0) = -1 Err#20 21550/0x11e09: 1849 3 3 fstatat64(0xFFFFFFFFFFFFFFFE, 0x16DD8BA6A, 0x16DD8B3F8) = 0 0 21550/0x11e09: 1851 1 1 stat64("hello.txt\0", 0x16DD8B488, 0x0) = 0 0 21550/0x11e09: 1862 10 10 open("/dev/urandom\0", 0x1000004, 0x0) = 3 0 21550/0x11e09: 1863 1 1 read(0x3, "?\275L\205Gm\321\225@\004\0", 0x8) = 8 0 21550/0x11e09: 1953 90 89 symlinkat("hello.txt\0", 0xFFFFFFFFFFFFFFFE, "CuXBf0rC\0") = 0 0 21550/0x11e09: 2032 79 78 renameat(0xFFFFFFFFFFFFFFFE, "CuXBf0rC\0", 0xFFFFFFFFFFFFFFFE, "hello.sym\0") = 0 0 ... 省略 ...
いつ変わった?
2017年のこのあたりのコミットで挙動が変わったのだろうか……?
このコミットはv8.27から含まれていそう。
If the file B already exists, commands like 'ln -f A B' and 'cp -fl A B' no longer remove B before creating the new link. That is, there is no longer a brief moment when B does not exist.
このあたりが該当する……?少なくともここ最近のcoreutilsが入っている環境なら問題無さそうだ。
まとめ
- coreutils v8.27以降の
ln
コマンドは既存のシンボリックリンクを更新する際、乱数名でsymlinkat
した後にrenameat
していそう - 少なくとも最近リリースされたLinuxディストリビューションではシンボリックリンクの更新は単に
ln -snf
とかを使っても問題無さそう - macOS組み込みの
ln
コマンドはunlink
してsymlink
しているのでダメそう
何か落とし穴あったら教えてください。