LXCで学ぶコンテナ入門 -軽量仮想化環境を実現する技術

第16回Linuxカーネルのコンテナ機能 [6] ─ユーザ名前空間

年末を迎えて今年もAdvent Calendarが多数作られていますね。この連載の今回の記事はLinuxカーネルの機能を紹介するので、Linux Advent Calendar 2014の16日目の記事としても書きました。興味深い記事が並んでいて勉強になりますね。

さて、第13回から3回、田向さんにPlamo LinuxでのLXCの利用に焦点を当てて記事を書いていただきました。テンプレート内部の詳しい解説から、Plamo Linuxでのコンテナの作成、ネットワーク構成の応用的な解説、コンテナでサウンドを扱う話まで、面白い記事が続きましたね。

ネットワークの話やサウンドの話はPlamo Linux以外でも十分に応用ができる話でしたし、サウンドの記事に関してはサウンド以外のデバイスをコンテナで使う場合にも非常に参考になる話だったと思います。

田向さん担当の記事のうち、第14回第15回では一般ユーザによるコンテナの利用の話が出てきました。

今回はこの一般ユーザがコンテナを起動する際に利用するLinuxカーネルのユーザ名前空間(User Namespace)について説明しましょう。

ユーザ名前空間

ユーザ名前空間については第2回で少し説明しました。しかし、だいぶ回が開いてしまいましたので、改めて基本的なところから説明していきたいと思います。

ユーザ名前空間は3.8カーネルで実装された、現時点では一番新しい名前空間です。この機能により名前空間内で独立したUID、GIDを持てるようになります。名前空間内のUID、GIDとホストOS上のUID、GIDの間はマッピングによるひもづけが行われます。

つまり名前空間内のUID、GIDはホスト上では別のUID、GIDを持つことになります。たとえば、名前空間内ではUID、GIDが共に0のrootユーザを、ホストOS上ではUID、GID共に100000である一般ユーザとして扱えます。

この機能により、コンテナ内では特権を持ちつつ、ホストOS上では特権を持たないユーザが作成でき、ホストOSとコンテナの間で権限の分離ができるようになり、セキュアにコンテナ環境を提供できるようになります。

このような機能であるため、他の名前空間と違って ユーザ名前空間は一般ユーザが作成できます

もちろん名前空間内の特権ユーザは本来の特権ユーザとは異なりますので、名前空間内であってもホストOS上の特権ユーザができる操作の全てができるわけではありません。

名前空間内の特権ユーザに許可する操作についてはまだ議論がされていたりしますし、未知のセキュリティ問題が出てくるかも知れません。今後も細かい動きは変化していくかもしれません。

それではユーザ名前空間について細かく見ていきましょう。

UID、GIDのマッピング

前述のように、ユーザ名前空間を作成すると、名前空間内と名前空間外で二重にIDを持つことになります。名前空間を作成した後にこの二重のIDの間をひもづけるためのマッピングを作成します。

このマッピングは/proc以下の各PID名のディレクトリ以下のuid_mapgid_mapというファイルに書き込みます。つまり名前空間内のプロセスに対してマッピングを定義するわけですね。

この定義は同じユーザ名前空間内のプロセスにも継承されますので、コンテナの最初のプロセスにマッピングを書けば、コンテナ内のプロセスには全て同じマッピングが定義されます。

まずはホストOS上の通常のプロセスのこの2つのファイルの中身を見てみましょう。

# cat /proc/1/uid_map
         0          0 4294967295
# cat /proc/1/gid_map
         0          0 4294967295

3つの数字が並んでいますね。このようにuid_mapgid_mapの書式は同じで、以下のような意味となります。

(名前空間内の最初のID) (名前空間外の最初のID) (範囲)

名前空間内で使用する最初のUID/GID「⁠⁠名前空間内の最初のID⁠⁠」で指定します。"0"を指定すると、名前空間内で通常のOS環境のようにrootユーザから使い始められます。アプリケーションコンテナを使う場合でrootユーザは不要であれば必ずしも"0"から始める必要はありません。

そして、「⁠⁠名前空間内の最初のID⁠⁠」に対応する名前空間外のID「⁠⁠名前空間外の最初のID⁠⁠」で指定します。⁠名前空間内の最初のUID」に"0"を、⁠名前空間外の最初のUID」に"100000"と指定すると、名前空間内のrootは名前空間外のID=100000にひもづけられます。

最後に、名前空間内で使用するIDの個数「⁠⁠範囲⁠⁠」で指定します。たとえば"65536"と指定すると名前空間内で0~65535までのIDが使用できるということになります。そして前述の例だと名前空間外では100000~165535までにひもづけられます。

前述の例をマッピングファイルに書くと以下のようになります。

0 100000 65536

名前空間を作成した直後など、マッピングファイルへのマッピングがされていない状態では名前空間内部のIDは

  • UIDとして/proc/sys/kernel/overflowuidの値
  • GIDとして/proc/sys/kernel/overflowgidの値

が使用されます。

ユーザ名前空間でのUID、GIDのマッピングの様子

では、実際にユーザ名前空間を作成して、マッピングの様子を見てみましょう。

ここではUbuntu 14.10の環境を使用しています。14.04 LTSではutil-linuxのバージョンが低いために、これから使用するunshareコマンドにユーザ名前空間を作成する機能がありません。unshareコマンドがユーザ名前空間をサポートするのはutil-linux 2.23以降です。

まずは現在のユーザの確認です。

$ whoami ; id -u ; id -g
ubuntu
1000
1000

ご覧のようにUID/GID=1000/1000のubuntuというユーザです。

先に説明したマッピングがされない状態でのマッピング先も確認しておきます。

$ cat /proc/sys/kernel/overflowuid /proc/sys/kernel/overflowgid
65534
65534

次にunshareコマンドを実行して新しいユーザ名前空間を作成してみましょう。ユーザ名前空間を作成するには--userオプションを指定します。特にコマンドを指定しなければ作成した名前空間でシェルが起動します。

$ unshare --user  (ユーザ名前空間を作成してシェルを起動)
$ echo $$         (作成した名前空間内で実行されている)
1551

新しいユーザ名前空間で起動したシェルで、自身のUID/GIDとPIDを確認します。

$ grep "[U|G]id" /proc/1551/status
Uid:    65534   65534   65534   65534
Gid:    65534   65534   65534   65534
$ ls -ld /proc/1551
dr-xr-xr-x 9 nobody nogroup 0 Dec  1 16:53 /proc/1551
$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)

まだマッピングのための操作を行っていませんので、先に説明した通りデフォルトのUID/GIDへマッピングされているのがわかります。/proc/1551/statusのUid/Gidの行で、いずれの値も65534となっていますね。statusファイルのUidGidの行の意味はman 5 procで調べてくださいね。

さて、ここで別のターミナルを開いて、ホストOS上の親の名前空間上でどうなっているかを同じコマンドを実行して見てみましょう。

$ grep "[U|G]id" /proc/1551/status
Uid:    1000    1000    1000    1000
Gid:    1000    1000    1000    1000
$ ls -ld /proc/1551/status
-r--r--r-- 1 ubuntu ubuntu 0 Dec  1 16:54 /proc/1551/status

ご覧のように親の名前空間上では、いずれのIDも1000となっています。つまり

  • ホストOSの名前空間のUID/GID=1000/1000 → 作成した名前空間内のUID/GID=65534/65534

というマッピングがされており、それぞれの名前空間ではそれぞれの名前空間でのIDで処理が行われているのがわかります。

ではマッピングを作成してみましょう。このマッピング操作は作成したユーザ名前空間の親の名前空間から実行する必要があります

新しく作成したユーザ名前空間内のUID/GID=0/0のユーザ、つまりrootを、ホストOSの名前空間上のUID/GID=1000/1000のユーザに割り当ててみます。

$ echo '0 1000 1' > /proc/1551/uid_map
$ echo '0 1000 1' > /proc/1551/gid_map
$ grep "[U|G]id" /proc/1551/status
Uid:    1000    1000    1000    1000
Gid:    1000    1000    1000    1000
※)
その後、カーネルの修正によりgid_mapへの書き込みは一般ユーザ権限ではできなくなっています(正確には`CAP_SETGID`のケーパビリティが必要⁠⁠。上記のecho '0 1000 1' > /proc/1551/gid_mapという操作はエラーになりますので、echo '0 1000 1' | sudo tee /proc/1551/gid_mapのようにする必要があります。

ホストOS上ではプロセスのUID/GIDは変化していません。ずっと同じユーザで実行しているのでこれは当たり前ですね。

ここで作成したユーザ名前空間のシェルに戻ってみましょう。

$ grep "[U|G]id" /proc/1551/status
Uid:    0   0   0   0
Gid:    0   0   0   0
$ id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
$ ls -ld /proc/1551
dr-xr-xr-x 9 root root 0 Dec  1 16:53 /proc/1551

ご覧のように、先ほどは65534だったUIDとGIDが0になっています。つまりマッピングを定義した時点でそのマッピングが有効になり、定義したユーザでの処理になります。

ユーザ名前空間内でのいろいろな操作

このユーザ名前空間内でファイルを作成してみましょう。

$ pwd
/home/ubuntu
$ touch testfile
$ ls -l testfile 
-rw-rw-r-- 1 root root 0 Dec  1 17:22 testfile

rootの所有でファイルが作成されましたね。親の名前空間でこのファイルを見ると、

$ ls -l /home/ubuntu/testfile 
-rw-rw-r-- 1 ubuntu ubuntu 0 Dec  1 17:22 /home/ubuntu/testfile

親の名前空間上のユーザの所有のファイルとして見えていますね。

次に、ユーザ名前空間内ではrootユーザであっても、名前空間の外では特権を持たないことがわかる例を見てみましょう。

ここまでの例では、新たなユーザ名前空間のみを作成しました。しかし他の名前空間は作成していませんので、親であるホストOSの名前空間と同じ名前空間にいることになります。ネットワークインターフェースを見てみると、親の名前空間と同じインターフェースが見えます。

$ ip link show  (親の名前空間でも作成した名前空間でも同じ結果が得られる)
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default 
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 52:54:00:53:a8:9c brd ff:ff:ff:ff:ff:ff

通常はrootであればネットワークインターフェースをdownさせたり、新しくインターフェースを作成することができます。しかしユーザ名前空間内のrootは親の名前空間では一般ユーザであり特権を持ちませんのでそのような操作はできません。

$ whoami
root
$ ip link set eth0 down
RTNETLINK answers: Operation not permitted
$ ip link add name veth0-host type veth peer name veth0-ct
RTNETLINK answers: Operation not permitted

rootであっても、このようにいずれの操作もエラーで返ってきているのがわかります。

せっかくrootユーザになったのに、これでは何もできないのではないか? と思われるかも知れません。しかしそんなことはありません。ユーザ名前空間内の特権ユーザは新たに他の名前空間を作ることができます。通常は一般ユーザではユーザ名前空間以外の名前空間は新たに作れません。

試しにホストOS上の一般ユーザでネットワーク名前空間を作るコマンドを実行してみましょう。

$ whoami
ubuntu
$ unshare --net  (親の名前空間では一般ユーザはユーザ以外の名前空間は作れない)
unshare: unshare failed: Operation not permitted

以上のように親の名前空間で一般ユーザで実行するとエラーになりますね。ところが、ユーザ名前空間内のrootユーザのシェルで同じコマンドを実行すると、この操作が実行できます。

$ whoami
root
$ unshare --net    (ユーザ名前空間内では新たにネットワーク名前空間を作れる)
# ip link show     (新しいネットワーク名前空間なのでループバックインターフェースのみ存在している)
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default 
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
# ip link add name veth0-host type veth peer name veth0-ct (vethインターフェースの作成)
# ip link show     (作成できた)
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default 
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: veth0-ct: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 76:29:7f:1f:8f:60 brd ff:ff:ff:ff:ff:ff
3: veth0-host: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether ce:89:db:2d:33:2b brd ff:ff:ff:ff:ff:ff

以上のように作成したユーザ名前空間内の特権ユーザであれば、新たにネットワーク名前空間が作れます。この新たに作ったネットワーク名前空間では特権がありますので、自由にインターフェースを定義できます。

もちろん、外部と通信を行う場合は親環境であるホストOS上での特権が必要になるので、ユーザ名前空間を作成したからと言って、自由に外部と通信ができるわけではありません。しかし、名前空間内部では通常のrootのように自由に操作できますので、通常のrootと同じようにコンテナが管理できるわけです。

複数のIDのマッピング

ここまでの例では、UID/GID=1000/1000の一般ユーザであるubuntuユーザのIDのみを、新たに作成したユーザ名前空間にマッピングしていましたので、マッピングファイルに書き込む3つの数値のうち、⁠範囲」を表す値を1にしていました。

この場合、一般ユーザであるubuntuユーザでマッピングファイルへの書き込みができました。自身で起動したプロセスに対する操作ですので権限があるためです。

マッピングファイルに書き込む値の「範囲」を表す数値を2以上にすれば、複数のIDに対してマッピングできます。しかしその場合は親の名前空間でroot権限が必要になります。なぜなら自身のUID/GID以外のIDと名前空間内のIDのマッピングを行うことになるからです。自身のID以外のマッピングが自由にできるのは問題がありますよね。

コンテナを使う場合、通常はコンテナ内で複数のユーザを使う必要があることが多いでしょう。一般ユーザでコンテナを起動する際にもroot権限でマッピングを書き込む必要があるということになると、利便性が大きく損なわれます。

このような一般ユーザでのコンテナの利用の際のマッピングの問題を解決するために、DebianとUbuntuではshadowを拡張し、あるユーザが自由に使える「サブID」を定義できるようになりました。Plamoでもこの拡張されたshadowを採用しています。

この「サブID」はUID、GIDそれぞれに定義でき、管理者があらかじめ定義しておきます。丁度、ユーザ作成時にそのユーザがどのグループに属するかを定義するのと同じようなものですね。

この「サブID」を定義するにはusermodコマンドの-vオプション(サブUID)-wオプション(サブGID)を使います。

たとえば、testユーザにUID/GID共に200000から65536個の使用を許可する場合、以下のように実行します。

# usermod -v 200000-265535 -w 200000-265535 test

この定義は/etc/subuid/etc/subgidに保存されます。

# grep test /etc/subuid
test:200000:65536
# grep test /etc/subgid
test:200000:65536

ちなみにUbuntuではインストール時に作成したubuntuユーザに対して、デフォルトで100000から65536個のサブUID/GIDが割り当てられていました。

この状態で新たにユーザを追加すると、以下のようにその後の使われていない範囲から65536個割り当たるようになっているようです。

※)
Ubuntu 14.04 LTS と 14.10 で確認
$ sudo adduser test
Adding user `test' ...
Adding new group `test' (1001) ...
Adding new user `test' (1001) with group `test' ...
Creating home directory `/home/test' ...
Copying files from `/etc/skel' ...
Enter new UNIX password: 
Retype new UNIX password: 
    : (略)
$ grep test /etc/subuid
test:165536:65536
$ grep test /etc/subgid
test:165536:65536

このサブUIDとサブGIDを使ってマッピングを作成するには、uidmapパッケージが必要です。

$ sudo apt-get install uidmap
$ ls -l /usr/bin/new?idmap
-rwsr-xr-x 1 root root 33688 Jul 18 23:29 /usr/bin/newgidmap
-rwsr-xr-x 1 root root 33688 Jul 18 23:29 /usr/bin/newuidmap

インストールすると、以上のようにsetuidされたnewuidmapコマンドとnewgidmapコマンドがインストールされます。

では、newuidmapコマンドを使って複数のIDをユーザ名前空間に対してマッピングしてみましょう。

ここでは実験のためにデフォルトで定義されているサブUID以外にさらに定義を追加します。

$ sudo usermod -v 1000-1001 ubuntu
$ cat /etc/subuid
ubuntu:100000:65536
ubuntu:1000:2

1000と1001の2つがubuntuユーザで使えるようになりました。

新しいユーザ名前空間で起動しているPID=1777のシェルに対してマッピングを定義します。名前空間内のUID=0と1を名前空間外のUID=1000と1001にそれぞれマッピングします。

$ newuidmap 1777 0 1000 2
$ cat /proc/1777/uid_map 
         0       1000          2

newuidmapコマンドには、以上のように「⁠⁠PID⁠⁠名前空間内の最初のUID⁠⁠名前空間外の最初のUID⁠⁠範囲⁠⁠」の順で引数を与えて実行します。uid_mapgid_mapファイルと似ていますね。

それではユーザ名前空間内で実効UIDを1に変えてみましょう。

$ echo $$
1777
$ whoami
root
$ python
Python 2.7.8 (default, Oct 20 2014, 15:05:19) 
[GCC 4.9.1] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import os
>>> os.seteuid(1)
>>> os.getpid()
1800

以上のようにPythonを起動し、seteuidしてみました。Pythonのpid=1800ですので、親の名前空間であるホストOS上からこのプロセスの状態を確認してみましょう。

$ ls -ld /proc/1800
dr-xr-xr-x 9 1001 ubuntu 0 Dec  1 20:13 /proc/1800
$ grep '[U|G]id' /proc/1800/status
Uid:    1000    1001    1000    1001
Gid:    1000    1000    1000    1000

以上のように実効UID=1001で実行されているのがわかります。

LXCでは、一般ユーザでコンテナを作成したり起動したりする際に、ユーザ名前空間を作成し、このnewuidmapnewgidmapコマンドを使って、マッピングの範囲を指定し、コンテナの操作を行っています。

まとめ

今回は一般ユーザでコンテナを操作する際に使うLinuxカーネルの機能であるユーザ名前空間について説明しました。

次回はUbuntu上での一般ユーザでのコンテナの操作について説明したいと思います。

最近のLXC関連の動き

この記事の原稿を書いている間にLXC 1.0.7がリリースされました(12月5日⁠⁠。1.1.0リリースの話もメーリングリスト上で話題にのぼるようになってきました。原稿執筆時点で1.1.0.alpha3というバージョンになっています。2015年早々に1.1.0をリリースしたいようですが、最新のsystemdへの対応など、結構大きなテーマが残っているのでスケジュール通りに進むかどうかはわかりません。

これまでLXC関連のプロジェクトはLXCとcgroupを管理するためのCGManagerの2つでしたが、11月の初めにはLXDというプロジェクトがアナウンスされました

当初は文書だけでコードはない状態でしたが、その後急速に開発が進み、とりあえず動作する状態になっているようです。LXDはliblxcのGoバインディングを使って開発されており、最近の流行に乗っているような感じがしますね。今後が楽しみです。

また、プロジェクトが増えたのに合わせて、LXCのメンテナであるStéphane Graber氏が、ウェブサイトのリニューアルに着手しました。サイトのコンテンツはGithubで管理されており英語以外の言語のコンテンツも簡単に追加できることを考えて作られています。

この新しいサイトも12月の初めには正式に公開されました。URLは変わっていません。

英語のコンテンツはまだとりあえず揃えただけという感が強いですが一通りのコンテンツが揃いました。日本語の翻訳もとりあえず私が一通り行い、公開が済んでいます。

言語独自のコンテンツを追加することも特に問題なさそうですので、この連載へのリンクを早速公式ページの日本語コンテンツに追加しておきました。日本独自の役に立つのコンテンツを充実させていくこともできると思います。

誰でもGitHub上でforkして変更や追加を行ってプルリクエストを送れますので、興味のある方は是非ご参加いただければと思います。日本語訳はまだまだ改良の余地があると思っていますので、品質の向上に協力いただける方もお待ちいたしております。

おすすめ記事

記事・ニュース一覧