CUBE SUGAR CONTAINER

技術系のこと書きます。

Linux の Mount Namespace について

Linux のコンテナ仮想化を構成する要素の一つに、カーネルの Namespace (名前空間) という機能がある。 これは、プロセスが動作する際のリソースを Namespace という単位で隔離して扱うための仕組み。 以下のとおり、隔離する対象によって Namespace は色々とある。

man7.org

今回は、その中でもマウント情報を隔離するための Mount Namespace を扱ってみる。 具体的には、unshare(1) と unshare(2) を使ってマウント情報が隔離される様子を観察してみる。 unshare(2) というのは Namespace を操作するためのシステムコールで、unshare(1) は同名のシステムコールを利用したコマンドラインツールになっている。

使った環境は次のとおり。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.4.0-91-generic aarch64
$ unshare --version
unshare from util-linux 2.34
$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

もくじ

下準備

下準備として、mount(1) と unshare(1) を使うために util-linux をインストールする。 同様に、unshare(2) を使ったコードをビルドするために build-essential をインストールする。

$ sudo apt-get update
$ sudo apt-get -y install util-linux build-essential

unshare(1) を使って Mount Namespace を操作する

まずは unshare(1) を使って Mount Namespace を試してみよう。 現在のプロセスの Mount Namespace の識別子は procfs から取得できる。 具体的には /proc/self/ns/mnt を読めば良い。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

上記で右端にあるブラケットに囲まれた数字は、現在のプロセス、つまりシェルが所属する Mount Namespace を表している。

今回は /tmp/mntpoint というディレクトリを例にして説明していこう。 このディレクトリに tmpfs をマウントして、マウントされたのが見えるか否かで動作確認していく。 まずはマウント先としてディレクトリを作っておこう。

$ mkdir -p /tmp/mntpoint

作ったディレクトリに動作確認用のファイルを適当に作っておく。

$ echo "Hello, World" > /tmp/mntpoint/greet.txt
$ ls /tmp/mntpoint
greet.txt
$ cat /tmp/mntpoint/greet.txt
Hello, World

続いては肝心の unshare(1) を使って Mount Namespace を作る。 このとき --mount オプションを付与することで Mount Namespace を作ることを指定しよう。 末尾には bash を指定してシェルを起動する。

$ sudo unshare --mount bash

上記を実行したら、先ほどと同じように /proc/self/ns/mnt にアクセスしてみよう。 すると、識別子が変わっていることがわかる。 つまり、先ほどとは異なる Mount Namespace にプロセスが所属している。

# file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026532128]

この時点では、まだ /tmp/mntpoint 以下には何もしていないのでファイルが見える。

# ls /tmp/mntpoint/
greet.txt

ここでおもむろに tmpfs を上記のディレクトリにマウントしてみよう。

# mount -t tmpfs tmpfs /tmp/mntpoint

すると、当然だけどマウントされてファイルは見えなくなる。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,relatime)
# ls /tmp/mntpoint/| wc -l
0

マウントできたことが確認できたら元のシェルに戻ろう。

# exit

元のシェルに戻ると Mount Namespace も元に戻っていることがわかる。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

確認すると /tmp/mntpoint には何もマウントされておらず、ファイルがそのままある。

$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/
greet.txt
$ cat /tmp/mntpoint/greet.txt 
Hello, World

これこそ Mount Namespace の効果を示している。 つまり、異なる Namespace 上で実施したマウントの操作は、別の Namespace には影響を与えない。

unshare(2) を使って Mount Namespace を操作する

続いては、システムコールを直接使って Mount Namespace を操作してみよう。

早速だけど以下にサンプルコードを示す。 unshare(2) で Mount Namespace を作るには引数に CLONE_NEWNS を指定して呼び出せば良い。 その上で、mount(2) を使って /tmp/mntpoint に tmpfs をマウントしている。 最終的にやるのは execvp(3) を使って bash を起動すること。

#define _GNU_SOURCE

#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/mount.h>

int main(int argc, char *argv[]) {
    // unshare(2) で Mount Namespace を作成する
    if (unshare(CLONE_NEWNS) != 0) {
        fprintf(stderr, "Failed to create a new mount namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // ルートディレクトリから再帰的にマウントのプロパゲーションを無効にする
    if (mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) {
        fprintf(stderr, "Can not change root filesystem propagation: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // tmpfs をマウントする
    char mount_dst[] = "/tmp/mntpoint";
    if (mount("tmpfs", mount_dst, "tmpfs", MS_NOSUID | MS_NODEV, NULL) != 0) {
        fprintf(stderr, "Failed to mount %s: %s\n", mount_dst, strerror(errno));
        exit(EXIT_FAILURE);
    }

    // execvp(3) でシェルを起動する
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

まずは上記に適当な名前をつけて保存したらコンパイルする。

$ gcc -Wall example.c 

できたバイナリを実行・・・する前に、一旦 Mount Namespace の識別子を確認しておこう。

$ file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026531840]

改めて、できたバイナリをスーパーユーザの権限で実行する。

$ sudo ./a.out

するとシェルが起動するので、また Mount Namespace の識別子を確認する。 ちゃんと先ほどと異なっている。

# file /proc/self/ns/mnt
/proc/self/ns/mnt: symbolic link to mnt:[4026532128]

ソースコードの中でマウントしているので /tmp/mntpoint に tmpfs がマウントされてファイルが見えなくなっている。 さっきとはマウントするときのオプションがちょっと違ってるけど、まあそこは良いか。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint/ | wc -l
0

ひとしきり満足したら元のシェルに戻ろう。

# exit

もちろん、別の Mount Namespace での出来事なので、元のシェルにはマウントされていない。

$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/ | wc -l
1

マウントのプロパゲーションについて

ところで、先ほどのソースコードに次のような処理が入っていることを不思議に感じたと思う。

    // ルートディレクトリから再帰的にマウントのプロパゲーションを無効にする
    if (mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) {
        fprintf(stderr, "cannot change root filesystem propagation: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

これはマウントのプロパゲーションという機能を抑制するために入れている。 というのも、プロパゲーションの設定によっては Mount Namespace が別れていても、操作が別の Namespace まで伝搬してしまうのだ。

Linux カーネルのデフォルトの設定では伝搬しない (PRIVATE) ようになっている。 しかし、systemd(1) は起動時に伝搬する (SHARED) ように設定を書きかえてしまう。 この振る舞いは以下のドキュメントにも記述がある。

man7.org

実際に確認してみよう。 /proc/self/mountinfo を見ると、マウントポイントごとにプロパゲーションのフラグが確認できる。 以下はルートファイルシステムの設定。

$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw

上記で中ほどにある shared:1 というのが、伝搬する (SHARED) 設定になっていることを示している。 これを伝搬しない (PRIVATE) に設定するには、次のように mount(1) に --make-private オプションをつけて実行する。

$ sudo mount --make-private /

もう一度 /proc/self/mountinfo を見ると、shared:1 だった部分が - になった。

$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime - ext4 /dev/sda1 rw

この状況であればマウントの操作は伝搬しないので、先ほどの処理は不要になる。 試しに、処理を除外したソースコードを用意して試してみよう。

#define _GNU_SOURCE

#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/mount.h>

int main(int argc, char *argv[]) {
    // Mount Namespace を作成する
    if (unshare(CLONE_NEWNS) != 0) {
        fprintf(stderr, "Failed to create a new mount namespace: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // mount(2) で tmpfs をマウントする
    char mount_dst[] = "/tmp/mntpoint";
    if (mount("tmpfs", mount_dst, "tmpfs", MS_NOSUID | MS_NODEV, NULL) != 0) {
        fprintf(stderr, "Failed to mount %s: %s\n", mount_dst, strerror(errno));
        exit(EXIT_FAILURE);
    }

    // execvp(3) でシェルを起動する
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記をコンパイルして実行する。

$ gcc -Wall example.c
$ sudo ./a.out

起動したシェルでは、もちろん tmpfs のマウントが確認できる。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint | wc -l
0

シェルを抜けて戻ると、マウントは見えなくなっている。 ちゃんと伝搬しないことが確認できた。

# exit
$ mount | grep /tmp/mntpoint
$ ls /tmp/mntpoint/
greet.txt

ちなみに、mount(1) に --make-shared を指定すると、プロパゲーションの振る舞いを元に戻せる。

$ sudo mount --make-shared /
$ cat /proc/self/mountinfo | grep ' / / '
32 1 8:1 / / rw,relatime shared:327 - ext4 /dev/sda1 rw

この状況だと、マウントの操作が Namespace を越えて伝搬してしまう。 実際に試してみよう。 先ほどコンパイルしたバイナリを実行する。

$ sudo ./a.out

起動したシェルでは tmpfs のマウントが確認できる。 ここまでは変わらない。

# mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
# ls /tmp/mntpoint | wc -l
0

しかし、シェルから抜けた後で確認しても、マウントが維持されている。 これはつまり、マウントの操作が Namespace を越えて伝搬してしまった、ということ。

# exit
$ mount | grep /tmp/mntpoint
tmpfs on /tmp/mntpoint type tmpfs (rw,nosuid,nodev,relatime)
$ ls /tmp/mntpoint/ | wc -l
0

ところで、unshare(1) を使うときはプロパゲーションについて特に意識する必要がなかったことを思い出してほしい。 これは、unshare(1) が最初に示したソースコードと同等の処理を実行しているため。 つまり、デフォルトでルートディレクトリのプロパゲーションを明示的に無効にしている。

まとめ

今回は Mount Namespace を使ってマウントの情報をプロセスごとに隔離してみた。 ただし、プロパゲーションの設定によっては操作が伝搬してしまう点に注意する必要がある。