TenForward

技術ブログ。はてなダイアリーから移転しました

マウントプロパゲーション(4)〜 unbindable mount 〜

引き続きマウントプロパゲーションについて書いていきます。完全に私個人が理解するための資料です。間違いの指摘は大歓迎です。そもそも、このシリーズ、カーネル付属ドキュメントと mount_namespaces(7) に書いてあるんですよねw

tenforward.hatenablog.com

tenforward.hatenablog.com

tenforward.hatenablog.com

unbindableは、そのマウント自身が他でバインドマウントできないようにするための指定です。マウントプロパゲーションとは少し違うものに見えます。しかし、これはprivateと同じでさらにバインドマウントを禁止するフラグがついたものと考えることができます。

unbindableはセキュリティ対策的な意味合いがあります。配下に多数のマウントを持ったマウントポイントがあったとします(例えば / なんかは通常はそうですよね)。その多数のマウントを持ったマウントを再帰的にバインドマウントするオプション(--rbind)を指定して何度もバインドマウントするとどうでしょう? 配下には大量のバインドマウントが再帰的に出現することになり、「バインドマウントボム(bomb)攻撃」とも言える状態になってしまいます。これを防ぐためにこのような指定ができるようになっています。

簡単に試してみましょう。まずはトップレベルのマウントとしてtestというディレクトリを作成し、ここをtmpfsでマウントします。このtestというマウント配下にふたつディレクトリ(ab)を作成し、その片方aもtmpfsをマウントします。

# mkdir test
# mount -t tmpfs tmpfs test (ディレクトリtestをtmpfsマウントする)
# mkdir test/a
# mount -t tmpfs tmpfs test/a (test/aディレクトリを作成しtmpfsマウントする)
# mkdir test/b
# tree .
.
└── test  (←tmpfsマウント)
    ├── a (←tmpfsマウント)
    └── b (←ディレクトリ)

3 directories, 0 files

上のようにtmpfs配下にtmpfsとディレクトリがひとつずつある状態になります。

ここでディレクトbに親ディレクトリであるtestをバインドマウントします。この際、再帰的にマウントするように--rbindを指定します。

# mount --rbind test test/b
# tree .
.
└── test
    ├── a (←tmpfsマウント)
    └── b (←バインドマウント)
        ├── a (←tmpfsマウント)
        └── b

5 directories, 0 files

再帰的にマウントしていますので、b配下のatest/b/a)はtmpfsとしてマウントされたまま見えています。

ここでtest配下にcというディレクトリを作成し、ここも上で行ったbのように再帰的にマウントします。

# mkdir test/c
# mount --rbind test test/c

再帰的にマウントしていますので、c配下にはさきほど再帰的にマウントしたb配下のマウントもすべて再帰的にマウントされています。そして、先に行ったバインドマウントb以下にもcが出現し、その配下でもb配下のマウントが見えています。

# tree .
.
└── test
    ├── a (←tmpfsマウント)
    ├── b (←バインドマウント)
    │   ├── a (←tmpfsマウント)
    │   ├── b
    │   └── c (←バインドマウント)
    │       ├── a
    │       ├── b (←バインドマウント)
    │       │   ├── a (←tmpfsマウント)
    │       │   ├── b
    │       │   └── c
    │       └── c
    └── c (←バインドマウント)
        ├── a (←tmpfsマウント)
        ├── b (←バインドマウント)
        │   ├── a
        │   ├── b
        │   └── c
        └── c

19 directories, 0 files

これが繰り返されると、延々と再帰的なマウントが出現することになり、リソースを食いつぶすDoS攻撃が成り立ちます。このようなことを防ぐためにunbindableが存在します。

# mount -t tmpfs tmpfs test
# mkdir test/{a,b}
# mount -t tmpfs tmpfs test/a

ここまでは先程の例と同じです。さきほどの例では--rbindとだけ指定したところで同時に--make-unbindableを指定してバインドマウントします。

# mount --rbind --make-unbindable test test/b
# tree .
.
└── test
    ├── a (←tmpfsマウント)
    └── b (←バインドマウント)
        ├── a (←tmpfsマウント,unbindable)
        └── b

5 directories, 0 files

ここでさきほどと同様にtest/cを作成し、test--make-unbindableでバインドマウントします。

# mkdir test/c
# mount --rbind --make-unbindable test test/c
# tree .
.
└── test
    ├── a (←tmpfsマウント)
    ├── b (←バインドマウント)
    │   ├── a (←tmpfsマウント,unbindable)
    │   ├── b
    │   └── c
    └── c (←バインドマウント)
        ├── a (←tmpfsマウント)
        ├── b
        └── c

10 directories, 0 files

unbindableを指定した以外は同じ操作を行っていますが、test/b配下もtest/c配下でもバインドマウントは再帰的に行われていません(tmpfsはマウントされています)。

このようにunbindableを指定すると爆発的にバインドマウントが増殖することを防げます。

マウントプロパゲーション(3)〜 slave mount 〜

引き続きマウントプロパゲーションについて書いていきます。完全に私個人が理解するための資料です。間違いの指摘は大歓迎です。

tenforward.hatenablog.com

tenforward.hatenablog.com

shared と private を説明すれば、頭の良い皆さんはもう slave がどのような動きになるのかわかったのではないでしょうか。

slaveは、複数のマウントの間に主従関係が生じます。マスター配下のマウント操作はスレーブ配下に伝播しますが、スレーブ配下のマウント操作はマスターには伝播しません。

これまでと同じような例を用いて説明しましょう。まずはorigbindにバインドマウントします。

# mount --bind orig bind
# cat /proc/self/mountinfo | grep bind
133 30 253:0 /root/test/orig /root/test/bind rw,relatime shared:1 - ext4 /dev/mapper/ubuntu--vg-ubuntu--lv rw

そしてorig側のマウントをsharedに、bind側のマウントをslaveに設定します。

# mount --make-shared /
# mount --make-slave bind
# cat /proc/self/mountinfo
  :(略)
30 1 253:0 / / rw,relatime shared:1 - ext4 /dev/mapper/ubuntu--vg-ubuntu--lv rw
  :(略)
133 30 253:0 /root/test/orig /root/test/bind rw,relatime master:1 - ext4 /dev/mapper/ubuntu--vg-ubuntu--lv rw
# tree .
.
├── bind
│   ├── tmp
│   └── tmp2
└── orig
    ├── tmp
    └── tmp2

6 directories, 0 files

/proc/self/mountinfoを確認すると、orig側のマウントポイントである/はsharedに、bindmasterと書かれており、/のsharedのあとに書かれた番号(1)と同じ番号が書かれています。これはbind/(つまりorig側)のslaveであることを示しています。origにはこれまでの例のようにあらかじめディレクトリがふたつ作成されています。

ここで、これまでの例と同様にorig/tmpにtmpfsをマウントしてファイルを作成しましょう。

# mount -t tmpfs tmpfs orig/tmp
# touch orig/tmp/testfile
# tree .
.
├── bind
│   ├── tmp
│   │   └── testfile
│   └── tmp2
└── orig
    ├── tmp
    │   └── testfile
    └── tmp2

6 directories, 2 files

orig側はsharedですのでbind側にも同じマウントが伝播し、同じようなツリーが見えます。

それでは逆にbind/tmp2にtmpfsをマウントし、ファイルを作成します。

# mount -t tmpfs tmpfs bind/tmp2
# touch bind/tmp2/testfile2
# tree .
.
├── bind
│   ├── tmp
│   │   └── testfile
│   └── tmp2
│       └── testfile2
└── orig
    ├── tmp
    │   └── testfile
    └── tmp2

6 directories, 3 files

確認してみると、bind/tmp2で行ったtmpfsのマウントはorigでは見えていないことがわかります。つまりorig側のマウントはbind側に伝播しますが、逆は伝播しないということです。

マウント情報も確認しておきましょう。

# cat /proc/self/mountinfo
  :(略)
714 30 0:74 / /root/test/orig/tmp rw,relatime shared:346 - tmpfs tmpfs rw
729 133 0:74 / /root/test/bind/tmp rw,relatime master:346 - tmpfs tmpfs rw
742 133 0:75 / /root/test/bind/tmp2 rw,relatime - tmpfs tmpfs rw
  :(略)

orig/tmpはsharedで、bind/tmporig/tmpを参照するslaveであり、bind/tmp2はprivateでorig/tmp側には伝播していなことが確認できます。

マウントプロパゲーション(2)〜 private mount 〜

昨日のエントリの続きです。引き続き完全に私個人が理解するための資料です。間違いの指摘は大歓迎です。

tenforward.hatenablog.com

それでは今日はprivateを見てみましょう。privateに設定したマウントのサブマウントは、他のマウントには反映されません。

sharedのときと同じディレクトリで試してみましょう。

# tree .
.
├── bind
└── orig
    └── tmp

3 directories, 0 files

まだ何もマウントが行われていない状態です。ここで、sharedと同様にorigbindにバインドマウントしてみましょう。

# mount --bind orig bind (バインドマウント)
# grep bind /proc/self/mountinfo (マウント情報の確認)
613 30 253:0 /root/test/orig /root/test/bind rw,relatime shared:1 - ext4 /dev/mapper/ubuntu--vg-ubuntu--lv rw
(sharedでマウントされている)
# mount --make-private bind (privateに設定)
# grep bind /proc/self/mountinfo 
613 30 253:0 /root/test/orig /root/test/bind rw,relatime - ext4 /dev/mapper/ubuntu--vg-ubuntu--lv rw
(sharedの文字が消えている)

バインドマウントした状態ではsharedになっていたマウントで、--make-privateでprivateに設定した後はsharedの文字が消えています。

この状態でbind配下のtmpディレクトリにtmpfsをマウントします。

# tree .
.
├── bind
│   └── tmp
└── orig
    └── tmp

4 directories, 0 files
(バインドマウントされているので両方にtmpが存在する)
# mount -t tmpfs tmpfs bind/tmp (tmpfsマウント)
# egrep "bind.*tmpfs" /proc/self/mountinfo
627 613 0:74 / /root/test/bind/tmp rw,relatime - tmpfs tmpfs rw
(マウントされている)

bind/tmpにtmpfsがマウントされました。このtmpfs上にファイルを置いてみましょう。

# touch bind/tmp/testfile
# tree .
.
├── bind
│   └── tmp
│       └── testfile
└── orig
    └── tmp

4 directories, 1 file

bind以下には作成したファイルが見えていますが、orig以下ではファイルが見えません。つまりtmpfsはバインドマウント先のbind配下でだけ有効で、バインドマウント元のorigでは反映されていないことがわかります。

sharedのときと同様にorig以下で行ったマウントがbindで行われないことも確認おきましょう。

# mkdir orig/tmp2 (orig以下にtmp2ディレクトリを作成)
# tree .
.
├── bind
│   ├── tmp
│   │   └── testfile
│   └── tmp2
└── orig
    ├── tmp
    └── tmp2

6 directories, 1 file
# mount --make-private / (orig側のマウントをprivateに設定)
# mount -t tmpfs tmpfs orig/tmp2 (tmp2にtmpfsをマウント)
# touch orig/tmp2/testfile2 (ファイルをtmpfs上に置く)
# tree .
.
├── bind
│   ├── tmp
│   │   └── testfile
│   └── tmp2
└── orig
    ├── tmp
    └── tmp2
        └── testfile2

6 directories, 2 files
(orig側のtmpfsマウントはbind側には反映されていない)

上のようにorig側でのtmpfsマウントがbind側では反映されていないことがわかります。つまりtmpfsはバインドマウント元のorig配下でだけ有効で、バインドマウント先のbindには反映されていないことがわかります。

このようにprivateに設定したマウントのサブマウントは、他のマウント配下には反映されません。

マウントプロパゲーション(1)〜 shared mount 〜

完全に私個人が理解するための資料です。間違いの指摘は大歓迎です。

2001 年に 2.4 kernel でバインド(bind)マウントが、2002 年の 2.4.19 で mount namespace が導入されました。ただ、ユースケースによっては mount namespace が行う分離は徹底しているので、もう少しマウントが共有されても良いシーンがあったようで、shared マウントなどのマウントプロパゲーションの考え方が 2.6.15(2006 年 1 月)で導入されました。

そのマウントプロパゲーションについてまとめていきます。

説明中の「サブマウント」とは、あるマウントポイントがあったとして、そのツリー配下で行われるマウント処理のことを指すとします。例えば、ホストで/dev/sda1というデバイスをルート/にマウントします。その配下の/home/dev/sda2をマウントしたりしますが、この/homeをサブマウントと呼ぶことにします。

プロパゲーションの設定 説明
shared マウント元のサブマウントはマウント先にも反映される。マウント先配下のサブマウントはマウント元にも反映される
private あるマウント配下でのサブマウントは他のマウント配下には公開されない
slave マウント元のサブマウントはマウント先にも反映されるが、マウント先のサブマウントはマウント元には反映されない
unbindable privateと同様でさらにこのマウントをバインドマウントできない

上のそれぞれについて、配下のマウントに再帰的に設定を反映させることができます。

プロパゲーションの設定 説明
rshared 配下で行われるマウントすべてを shared にする
rprivate 配下で行われるマウントすべてを private にする
rslave 配下で行われるマウントすべてを slave にする
runbindable 配下で行われるマウントすべてを unbindable にする

では、バインドマウントを使ってマウントプロパゲーションの説明をしていきましょう。

shared

まずはsharedです。sharedは、複数のマウント配下で行ったマウントがお互いに伝播します。

まず、バインドマウント用にorigbindというディレクトリをふたつ作ります。そしてバインドマウントを行います。バインドマウント元ディレクトorig内にtmpというディレクトリを作りました。

# mkdir orig bind
# mount --bind orig bind (origをbindにバインドマウント)
# mkdir orig/tmp (orig内にtmpというディレクトリを作成)

origbindにもバインドマウントされているので、次のように両方のディレクトリにtmpディレクトリが見えます。

# tree .
.
├── bind
│   └── tmp
└── orig
    └── tmp

4 directories, 0 files

ここでbindをsharedマウントとしてみましょう。sharedにするにはmountコマンドに--make-sharedを指定します。

# mount --make-shared bind
# grep bind /proc/self/mountinfo (マウント情報の確認)
518 30 253:0 /root/test/orig /root/test/bind rw,relatime shared:1 - ext4 /dev/mapper/ubuntu--vg-ubuntu--lv rw
(shared指定されている)

sharedになっていることを確認するために/proc/self/mountinfoを確認したところ、きちんと"shared"という文字がみえます。ここでshared指定されたbind以下でマウント操作を行ってみます。ここではtmpディレクトリにtmpfsをマウントしてみましょう。

# mount -t tmpfs tmpfs bind/tmp (bind以下のtmpにtmpfsマウント)
# egrep "bind.*tmpfs" /proc/self/mountinfo (マウントされたのを確認)
546 518 0:49 / /root/test/bind/tmp rw,relatime shared:253 - tmpfs tmpfs rw

tmpfsがマウントされていますので、このtmpfs上にファイルを置いてみましょう。そしてorigbindの配下がどうなっているのかを確認してみます。

# touch bind/tmp/testfile (tmpfs上にファイルを置く)
# tree .
.
├── bind
│   └── tmp
│       └── testfile
└── orig
    └── tmp
        └── testfile

4 directories, 2 files

バインドマウント先のbind内でマウントしたtmpfsがバインドマウント元でもマウントされた状態になっています。

この逆もやってみましょう。origディレクトリはマウントポイントではありませんので、ここではorigディレクトリが所属するマウントのマウントポイントであるルート(/)でsharedを指定します。ここは環境によって違いますので、実際に試す場合は適宜変更してください。

# mount --make-shared /

そしてorigディレクトリ以下にtmp2ディレクトリを作成しtmpfsをマウントし、ファイルを置きます。

# mkdir orig/tmp2
# mount -t tmpfs tmpfs orig/tmp2/ (tmp2にtmpfsをマウント)
# touch orig/tmp2/testfile2 (tmpfs上にファイルを置く)
# tree .
.
├── bind
│   ├── tmp
│   │   └── testfile
│   └── tmp2
│       └── testfile2
└── orig
    ├── tmp
    │   └── testfile
    └── tmp2
        └── testfile2

6 directories, 4 files

上のように、orig以下で行ったマウントがきちんとbind以下でも見えています。

このようにsharedに設定したマウント配下で行ったマウントは、他のマウント配下にも反映されます。

unshare コマンドがマウントプロパゲーションをまともに扱うような変更を入れていた

全国10万人の unshare でコンテナを作るのが好きなコンテナマニアの方々が systemd のおかげで余計な処理をさせられてたのですが、いつの間にか unshare(1) コマンド側であるべき姿に戻す処理が追加されていたというお話です。

結論

回りくどい話が嫌いな方も多いかと思うので、最初に結論を簡潔にまとめときます。ここだけ読めば結論はわかります。

前提として、

  • kernel デフォルトではマウントプロパゲーションは private
  • 8 年ほど前に systemd で起動時に shared にするように処理が追加されてる
  • このため unshare --mount で新たに Mount Namespace を作って、その中でマウント処理を行ってもホスト側でもそのマウントが見えるようになっていた

というような状態でした。ところが unshare --mount を実行したら当然 Namespace 内のマウントは他から見えないはずと思っていたユーザーが多く、直感的でないため、

  • unshare コマンドのデフォルトで Mount Namespace を作る際に private を指定するような変更が加わっていた

ということです。つまりホスト側で systemd がマウントプロパゲーションを shared に設定しても、unshare --mount して Namespace を作ったら、ちゃんと private になって他の Namespace からはマウントは見えないよ、となっています。

回りくどい話が嫌いな人はここまで読めば大丈夫です。

詳細

もう 8 年とか前の話なのですが、systemd が加えたこの変更、

github.com

kernel ではマウント時のプロパゲーションのデフォルトは private のはずですが、なぜかいつしか shared でマウントされるようになっててハマった人がいるのは、この変更のせいでした。

これは /proc/self/mountinfo なんかを見るとわかります。

$ grep '/ / ' /proc/self/mountinfo 
30 1 252:2 / / rw,relatime shared:1 - ext4 /dev/vda2 rw

sharedの文字が見えますね。ちなみにこのような制御を行わないシンプルな sysvinit を採用している Plamo Linux だと private でマウントされています。

$ grep '/ / ' /proc/self/mountinfo
22 1 259:6 / / rw,relatime - ext4 /dev/nvme0n1p6 rw

この変更のせいで、unshare --mount と新たに Mount Namespace を作って、Namespace 内でマウントしたにも関わらず、別の Namespace(unshareする前の Namespace)でマウントが共有されてしまっていてハマったりしたものです。例えば、

kernhack.hatenablog.com

私の連載でも後日補足を入れる羽目になってます。

gihyo.jp


で、まあこの辺りを色々あって見直していたら、イマドキは systemd 環境で Mount Namespace を新たに作ってもマウントが共有されなくなっています。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04 LTS
Release:    20.04
Codename:   focal
$ grep vda2 /proc/self/mountinfo 
30 1 252:2 / / rw,relatime shared:1 - ext4 /dev/vda2 rw (←確かにshared)
$ sudo unshare --mount /bin/bash
# grep vda2 /proc/self/mountinfo 
437 436 252:2 / / rw,relatime - ext4 /dev/vda2 rw (←sharedの文字が消えてる!)

このように private になってる感じです。試しにこの Namespace でマウントしてみると、

# losetup /dev/loop6 test.img 
# mount /dev/loop6 /mnt
#  grep loop6 /proc/self/mountinfo 
478 437 7:6 / /mnt rw,relatime - ext4 /dev/loop6 rw

マウントされていますが、ホスト環境で見ると、

$ grep loop6 /proc/self/mountinfo
$

と見えません。つまり private になってるってことですね。

誰が変わってしまったんだろう?と調べてみると、コレでした!util-linux のコミットです。

github.com

After "unshare --mount" users assume that mount operations within the new namespaces are unshared (invisible for the rest of the system).
Unfortunately, this is not true and the behavior depends on the current mount propagation setting. The kernel default is "private", but for example systemd based distros use "shared". The solution is to use (for example) "mount --make-private" after unshare(1).
I have been requested many times to provide less fragile and more unified unshared mount setting by default to make things user friendly.
The patch forces unshare(1) to explicitly use MS_REC|MS_PRIVATE for all tree by default.

超訳

"unshare --mount" なんてマニアックなコマンドを使うヤツは当然新しい Namespace ではマウントは共有されないと思ってるよね。
不幸なことにそんなことはなくて、今どんなマウントプロパゲーションの設定になってるかによるんだよね。カーネルのデフォルトが "private" なのに、クソsystemdのヤツが "shared" に設定してる。このクソ設定に対抗するには unshare のあとに "mount --make-private" って実行するしかないよね。
オレは何度もデフォルトでこんなマニアックなユーザーがわかりやすいようにちゃんとした共有されないマウント設定をすべきって頼まれてきた。
このパッチでソレに応えて unshare では明確に "MS_REC|MS_PRIVATE" を使うようにしたぜ。

man unshare にもその辺りの記載があります。

unshare since util-linux version 2.27 automatically sets propagation to private in a new mount namespace to make sure that the new namespace is really unshared. It's possible to disable this feature with option --propagation unchanged. Note that private is the kernel default.

ここにあるように --propagation unchanged を指定して unshare を実行すると、

$ sudo unshare --mount --propagation unchanged /bin/bash
# grep vda2 /proc/self/mountinfo 
438 436 252:2 / / rw,relatime shared:1 - ext4 /dev/vda2 rw

確かにホスト環境と同じように shared でマウントされたままです。この状態で Namespace 内でマウントすると、

# mount /dev/loop6 /mnt
# grep loop6 /proc/self/mountinfo 
479 438 7:6 / /mnt rw,relatime shared:350 - ext4 /dev/loop6 rw

shared です。ホスト側でも

$ grep loop6 /proc/self/mountinfo 
480 30 7:6 / /mnt rw,relatime shared:350 - ext4 /dev/loop6 rw

とマウントが見えます。

(関連する Issue)

github.com