#きょうのsystemd : ルートを変える (systemd for Administrators, Part 6)

これは

0pointer.de

の翻訳

ルートを変える

管理者であれ開発者であれ、遅かれ早かれ、chroot()環境に出会うことになる。 chroot()システムコールは単純にプロセスおよびその子がルートディレクト/ と考えるものをシフトさせ、そのプロセスが見ることができるファイルの階層をそのサブツリーに制限することができる。 主としてchroot()環境には2つの用途がある:

  1. セキュリティ目的: この用途では、特定の独立したデーモンがプライベートなサブディレクトリにchroot()され、デーモンが悪用された場合でも、攻撃者は完全なOSの階層ではなく、サブディレクトリしか見ることができないようにすることができる。
  2. OSイメージのデバッグ、テスト、ビルド、インストール、リカバリーをセットアップないしはコントロールするため: このためにゲストのOSの階層がホストOSのサブディレクトリにマウントないしはブートストラップされており、シェル(ないしはその他のアプリケーション)をその中で実行し、このサブディレクトリをその/にする。 例えば、異なるディストリビューションや異なるアーキテクチャ(例: ホスト側はx86_64でゲスト側がi386)であるかもしれない。 ホストOSの完全な階層はchroot()された環境から見ることはできない。

古典的なSystemVベースのOSの場合、chroot()環境を使うのは比較的簡単であった。 例えば、特定のデーモンをテストないしはその他の理由でchroot()ベースのゲストOSツリー内で実行するには、/proc/sysあるいはその他いくつかのAPIファイルシステムをツリーの内部にマウントし、それからchroot(1)を使ってchrootに入り、最終的にSysVスクリプトファイルを/sbin/service経由でchrootの中空実行すれば良い。

systemdベースのOSではそこまで簡単ではなくなってくる。 systemdの大きなアドバンテージの1つはすべてのデーモンはユーザーがサービスを開始させようとしたコンテクストとは一切関係がなく完全にクリーンで独立したコンテクストで実行されることが保証されていることにある。 SysVベースのシステムでは実行コンテクストの大きな部分(リソース制限や環境変数その他のようなもの)はinitスクリプトを実行したユーザーシェルから継承されたものである。 一方、systemdの場合、ユーザーはinitデーモンに通知をするだけであり、initデーモンはそれを受けて、デーモンを健全で良く定義された、きれいな実行コンテクストでフォークし、ユーザーコンテクストのパラメーターを一切継承させない。 これは驚くべき機能である一方、この機能により、サービスをchroot()環境で実行するための伝統的な手法は確かに破壊されてしまった。 というのも、実際のデーモンは常にPID1からスポーンされ、そこからchroot()の設定を継承するため、デーモンをスタートするように要求したクライアントがchroot()されたかどうかは関係なくなるのだ。 この最たるものとして、systemdは実際に自身のローカルコミュニケーションのソケットを/run/systemdに置いているため、chroot()環境にあるプロセスはinitシステムとトークすることすらできないのである(しかし、これはいいことかもしれないし、大胆な人間だったら、もちろんこれを、bindマウントを使うことで迂回することはできる)。

これで、当然のように、systemd環境でchroot()を適切に使うにはどうしたらいいのか?という問題が生じてくる。 そして、この記事がそれを説明するために思いついたものであり、この問題に対して徹底的かつ包括的に答えられたら嬉しいと思う。

最初のユースケースをまず考えよう。 セキュリティ目的でデーモンをchroot()監獄に閉じ込めるユースケースだ。 まずはセキュリティツールとしてのchroot()はかなり疑わしい。というのも、chroot()は一方通行ではないからだ。 manページですら指摘しているとおりchroot()環境から脱出するのは比較的簡単なのだ。 その他いくつかのテクニックを組み合わせた場合にのみ、chroot()はいくらかセキュアにすることができる。 そのため、これは通常はchroot()自体をタンパー防止な方法でサポートするアプリケーションで特定のサポートをする必要があるのだ。 そのてっぺんにあるのは、chroot()されたサービスの深い理解がないと、chroot()環境を設定することが通常できないということである。例えば、chroot()内でサービスが実際に要求するコミュニケーションチャンネルをすべて利用可能にするために、ホストのツリーからどのディレクトリをbindでマウントすべきか知っていることだ。 これらをまとめると、セキュリティ目的でサービスをchroot()することはデーモン自身のCコードの中でやってしまうことがほとんど常にベストな方法だ。 開発者はchroot()を適切にセキュアにするベストな方法、最小限のファイルのセット、chroot()内部でデーモンが必要とするファイルシステムディレクトリが何であるかを知っている(あるいは少なくとも知っているべきだ)。 今日では、多くのデーモンがこれをおこなうことができるが、不幸なことに、通常のFedoraのインストールでデフォルトで動いているデーモンのうちこれをやっているのはたったの2つである。 AvahiとRealtimeKitである。 どちらも同じ、本当にスマートなヤツによって明らかに書かれている。Chapeauだ! (これは ls -l /proc/*/root をシステムで実行したら簡単に確かめられる)

さて、systemdはもちろん特定のデーモンをchroot()させて、通常のツールでその他と同じように管理する方法を提供している。 これはsystemdサービスファイルのRootDirectory=オプションでサポートされている。 次のは例だ。

[Unit]
Description=A chroot()ed Service

[Service]
RootDirectory=/srv/chroot/foobar
ExecStartPre=/usr/local/bin/setup-foobar-chroot.sh
ExecStart=/usr/bin/foobard
RootDirectoryStartOnly=yes

この例では、RootDirectory=ExecStart=で指定されたデーモンのバイナリーを実行する前にどこでchroot()をすべきかを設定している。 ExecStart=で指定されるパスはchroot()内部のバイナリーを指定している必要があり、ホストツリーのバイナリーへのパスでないこと(つまり、この例の場合、実行されるバイナリーはホストOSからは/srv/chroot/foobar/usr/bin/foobardとして見えること)に注意してほしい。 デーモンが起動される前にsetup-foobar-chroot.shというシェルスクリプトが実行される。このスクリプトの目的は必要とされるchroot環境を設定することだ。例えば、/procや類似のファイルシステムchroot環境の中にマウントするなど、サービスが必要なものに応じて設定する。 RootDirectoryStartOnly=スイッチを使うことで、ExecStart=で指定されたデーモンだけをchrootさせ、ExecStartPre=スクリプトディレクトリをbindマウントするために完全なOS階層へのアクセスする必要があるために、chrootに入れないといったことを設定できる(これらのスイッチに関する詳細はmanページを見てほしい)。 このようなユニットファイルを/etc/systemd/system/foobar.serviceに置けば、systemctl start foobar.serviceとタイプすることで、chroot()されたサービスを起動することができる。 systemctl status foobar.serviceで中を見ることもできる。 他のサービスと同様にサービスへアクセス可能で、chroot()されたという事実は、--SysVの場合と異なり--その監視や制御ツールとの関わり方は変わらない。 比較的新しいLinuxカーネルファイルシステム名前空間をサポートしている。これらはchroot()と似ているが、さらにもっと強力で、chroot()と同じようなセキュリティ問題からの影響を受けない。 systemdはファイルシステム空間でできることのサブセットをユニットファイル自身にもそのまま適用する。 たいていの場合、サブディレクトリで完全なchroot()環境をセットアップすることの、便利でシンプルな代替案となる。 ReadOnlyDirectories=InaccessibleDirectories=と、2つのスイッチによって、サービスに対するファイルシステム名前空間の監獄を設定することができる。 最初は、これはホストOSのファイルシステム名前空間と全く同じである。 これらのディレクティブにディレクトリを列挙することで、ホストOSのディレクトリやマウントポイントをデーモンに対し読み取り専用ないしは完全にアクセス不能なものとしてさえ、マークすることができる。 例:

[Unit]
Description=A Service With No Access to /home

[Service]
ExecStart=/usr/bin/foobard
InaccessibleDirectories=/home

このサービスはホストOSのファイルシステムツリー全体へのアクセスを持つが、唯一例外がある。/homeはサービスからは見えず、潜在的な攻撃者からユーザーのデータは守られる(これらのオプションの詳細はこちらを見てほしい)。

ファイルシステム名前空間は実際に多くの方法でchroot()のより良い代替となる。 最終的にAvahiとRealtimeKitはchroot()で置き換える形で名前空間を利用するようにアップデートされるべきだろう。

セキュリティのユースケースはもう十分だろう。 さて、もう一つのユースケースを見てみよう。デバッグやテスト、ビルド、インストールあるいはリカバリーのためにOSイメージを設定し、管理する場合だ。

chroot()環境は比較的簡単なことである。つまり、ファイルシステム階層を仮想化しているに過ぎない。 サブディレクトリへchroot()しても、プロセスはすべてのシステムコールへの完全なアクセスは持ち、すべてのプロセスをkillすることができ、それの動いているホストとすべてを共有している。 したがって、chroot()の中でOS(ないしはOSの一部)を実行することは危険なことである。というのも、ホストとゲストの隔離はファイルシステムに限定されており、それ以外の全てはchroot()の中から自由にアクセスすることができてしまう。 例えば、もしchroot()内部のディストリビューションをアップグレードし、パッケージスクリプトがSIGTERMをPID1に送って、initシステムの再実行をトリガーした場合、これは実際、これがホストOSで起きてしまうのだ! その最たるものが、SysVは共有メモリー、抽象名前空間のソケット、その他のIPCプリミティブがホストとゲストの間で共有されている。 OSのテスト、デバッグ、ビルド、インストール、回復のために完全にセキュアな隔離はおそらく必要ないものの、chroot()環境内部からホストOSの偶発的な変更を避けるための基本的な隔離はあるに越したことはない。パッケージスクリプトが実行するホストOSに影響を与えるかもしれないコードを知ることは決してない。

この用途でchroot()セットアップを扱うために、systemdは2つの機能を提供している。

まず最初にsystemctlはそれがchrootで実行されていることを検知する。 もしchrootで実行されていることを検出した場合、systemctl enablesystemctl disableとを除いて、systemctlの操作はほとんどNOP(何もしない)になる。 もしパッケージインストールスクリプトがこれら2つのコマンドを実行した場合、ゲストOSの中でサービスは有効になる。 しかし、systemctl restartのようなコマンドをパッケージアップグレードプロセスに含んでいるパッケージインストールスクリプトがあるとして、これはchroot()環境では一切の効果を持たない。

さらに重要なことに、systemdはout-of-the-boxな状態(訳注: インストール直後の何も設定しない状態)でchroot(1)の強化版として機能するsystemd-nspawnが提供されている。これはファイルシステムおよびPID名前空間を使うことで、シンプルで軽量なコンテナをファイルシステムツリーでブートさせることができる。 これはchroot(1)とほとんど同じように使うことができる。違いはホストOSからの隔離がより完全であり、かなりセキュアで使いやすくもあることである。 事実、systemd-nspawn完全なsystemdないしはSysVinitのOSを1つのコマンドでコンテナ内にブートさせることが可能である。 PIDを仮想化しているため、コンテナ内のinitシステムはPID1として活動することが可能であり、そのため、その仕事を通常通りおこなうことができる。 chroot(1)と比較して、このツールは/proc/sysを黙示的にマウントする。

次は3つのコマンドでFedoraのマシンでnspawnコンテナ内にDebian OSをブートする例である。

# yum install debootstrap
# debootstrap --arch=amd64 unstable debian-tree/
# systemd-nspawn -D debian-tree/

これはOSディレクトリツリーをブートストラップし、単純にその中のシェルを起動させている。 もしコンテナ内に完全なシステムをブートしたい場合、このようなコマンドを使う。

# systemd-nspawn -D debian-tree/ /sbin/init

素早く起動したあと、コンテナにブートした完全なOSの内部のシェルプロンプトに移る。 コンテナはその外にあるプロセスは一切見ることができない。 ネットワーク設定は共有するが、変更することはできない(例外はブート時の2つのEPERMだが、これは致命的なものとはならない)。 /sys/proc/sysのようなディレクトリはコンテナ内でも利用可能だが、コンテナがカーネルやハードウェア設定を修正できないようにするため、読み取り専用でマウントされている。 しかし、注意してほしいのはこれはホストOSを偶発的なホストOSのパラメーターの変更から守るということである。 コンテナ内のプロセスは手動でファイルシステムを読み書き可能で再マウントし、加えたいだけの変更を加えることが可能である。

さて、systemd-nspawnのどこが偉大なのかもう一度まとめよう

  1. 簡単に使える。手動で/proc/syschroot()環境にマウントする必要がないこと。このツールはこれをやってくれ、コンテナが終了するときにはカーネルが自動でこれをきれいにしてくれる。
  2. 隔離がより完全で、コンテナ内部空の偶発的な変更からホストOSを守ってくれること。
  3. コンテナの中に完全なOSをブートさせることができること。たった1つの寂しいシェルではない。
  4. 実際に小さく、systemdがインストールされているならどこにでもインストールされていること。複雑なインストールやセットアップは不要だ。

systemd自体もこのようなコンテナでうまく動作するための修正が加えられている。 例えば、シャットダウンするときにコンテナで動いていることを検知すれば、systemdは最後の段階で、reboot()の代わりにexit()だけをコールする。

systemd-nspawnは完全なコンテナソリューションでないことに注意してほしい。 それが必要ならLXCがより良い選択だ。 LXCは同じ基礎となるカーネルテクノロジーを利用しているが、ネットワーク仮想化を含むより多くを提供している。 もし望むなら、systemd-nspawnはコンテナソリューション(分野で)のGNOME 3である。滑らかでほとんど努力せずに簡単に使える -- しかし、設定オプションはほとんどない。 一方で、LXCはよりKDEのようである。コードの行数以上の設定オプションがある。 systemd-nspawnを私が書いたのは、テスト、デバッグ、ビルド、インストール、リカバリーをカバーするためである。 これがsystemd-nspawnを利用するべきものであるし、これが得意なものだし、これがchroot(1) のより良い代替となるところである。

さて、そろそろ終わりにしよう。すでに長くなってしまっている。 以下が、このブログストーリーから家に持って帰ってもらいたいものだ。

  1. セキュアなchroot()はプログラムのCのソースコードでネイティブにやるのが一番。
  2. ReadOnlyDirectories=InaccessibleDirectories=は完全なchroot()環境への適切な代替手段である。
  3. RootDirectory=は特定のサービスをchroot()させたいなら、友となる。
  4. systemd-nspawnはすごいことができる。
  5. chroot()は不自由だが、ファイルシステム名前空間は全面的にエリートである。

Fedora15でこのすべてが利用できる。

さて、今日はここまで。次回また会おう。