ぽよメモ

レガシーシステム考古学専攻

coreutilsのlnでのシンボリックリンクの更新はアトミックになっている気がする

シンボリックリンクの更新におけるatomicity

シンボリックリンクをアトミックに更新するには、単にln -snf ではダメだという記事を昔読んでずっとそうなんだと思っていた。

qiita.com

この記事ではstraceでlnの挙動を追いかけ、シンボリックリンクの更新には unlink してから symlink しているのでアトミックではないと書かれていた。

StackOverflowなどでもこのような回答は多く見られた。

unix - Can you change what a symlink points to after it is created? - Stack Overflow

linux - How does one atomically change a symlink to a directory in busybox? - Unix & Linux Stack Exchange

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年のこのあたりのコミットで挙動が変わったのだろうか……?

github.com

このコミットはv8.27から含まれていそう。

github.com

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が入っている環境なら問題無さそうだ。

まとめ

何か落とし穴あったら教えてください。


*1:yohei-a.hatenablog.jp

*2:www.deepanseeralan.com

*3:プレフィクス無しで呼びたい場合は $(brew --prefix coreutils)/libexec/gnubin へパスを通す。