分離不安:Linux名前空間を使用してシステムを分離するためのチュートリアル

公開: 2022-03-11

Docker、Linux Containersなどのツールの出現により、Linuxプロセスを独自の小さなシステム環境に分離することが非常に簡単になりました。 これにより、1台の実際のLinuxマシンであらゆる種類のアプリケーションを実行し、仮想マシンを使用せずに、2台のアプリケーションが相互に干渉しないようにすることができます。 これらのツールは、PaaSプロバイダーにとって大きな恩恵となっています。 しかし、内部で正確に何が起こるのでしょうか?

これらのツールは、Linuxカーネルの多くの機能とコンポーネントに依存しています。 これらの機能のいくつかはごく最近導入されましたが、他の機能ではまだカーネル自体にパッチを適用する必要があります。 しかし、Linux名前空間を使用する重要なコンポーネントの1つは、バージョン2.6.24が2008年にリリースされて以来、Linuxの機能となっています。

chrootに精通している人なら誰でも、Linux名前空間で何ができるか、そして名前空間を一般的に使用する方法についての基本的な考え方をすでに知っています。 chrootでプロセスが任意のディレクトリをシステムのルート(残りのプロセスとは無関係)として認識できるように、Linux名前空間ではオペレーティングシステムの他の側面も独立して変更できます。 これには、プロセスツリー、ネットワークインターフェイス、マウントポイント、プロセス間通信リソースなどが含まれます。

プロセスの分離に名前空間を使用する理由

シングルユーザーコンピュータでは、単一のシステム環境で問題ない場合があります。 ただし、複数のサービスを実行するサーバーでは、サービスを可能な限り分離することがセキュリティと安定性にとって不可欠です。 複数のサービスを実行しているサーバーを想像してみてください。そのうちの1つが侵入者によって侵害されています。 このような場合、侵入者はそのサービスを悪用して他のサービスに侵入する可能性があり、サーバー全体を危険にさらす可能性さえあります。 名前空間の分離は、このリスクを排除するための安全な環境を提供できます。

たとえば、名前空間を使用すると、サーバー上で任意または不明なプログラムを安全に実行できます。 最近、HackerRank、TopCoder、Codeforcesなどのプログラミングコンテストや「ハッカソン」プラットフォームの数が増えています。 それらの多くは、自動パイプラインを利用して、参加者によって提出されたプログラムを実行および検証します。 多くの場合、参加者のプログラムの本質を事前に知ることは不可能であり、悪意のある要素が含まれている場合もあります。 システムの他の部分から完全に分離された名前空間でこれらのプログラムを実行することにより、マシンの他の部分を危険にさらすことなく、ソフトウェアをテストおよび検証できます。 同様に、Drone.ioなどのオンライン継続的インテグレーションサービスは、コードリポジトリを自動的にフェッチし、独自のサーバーでテストスクリプトを実行します。 繰り返しますが、名前空間の分離は、これらのサービスを安全に提供することを可能にするものです。

Dockerのような名前空間ツールを使用すると、プロセスによるシステムリソースの使用をより適切に制御できるため、このようなツールはPaaSプロバイダーでの使用に非常に人気があります。 HerokuやGoogleAppEngineなどのサービスは、このようなツールを使用して、同じ実際のハードウェア上で複数のWebサーバーアプリケーションを分離して実行します。 これらのツールを使用すると、アプリケーションの1つがシステムリソースを使いすぎたり、同じマシン上に展開されている他のサービスと干渉したり、競合したりすることを心配せずに、各アプリケーション(多数の異なるユーザーのいずれかによって展開された可能性があります)を実行できます。 このようなプロセスの分離により、分離された環境ごとに依存関係ソフトウェア(およびバージョン)のスタックを完全に異なるものにすることも可能です。

Dockerのようなツールを使用したことがある場合は、これらのツールが小さな「コンテナー」内のプロセスを分離できることをすでにご存知でしょう。 Dockerコンテナーでプロセスを実行することは、仮想マシンでプロセスを実行することに似ています。これらのコンテナーのみが、仮想マシンよりも大幅に軽量です。 仮想マシンは通常、オペレーティングシステム上でハードウェアレイヤーをエミュレートし、その上で別のオペレーティングシステムを実行します。 これにより、実際のオペレーティングシステムから完全に分離して、仮想マシン内でプロセスを実行できます。 しかし、仮想マシンは重いです! 一方、Dockerコンテナは、名前空間など、実際のオペレーティングシステムのいくつかの重要な機能を使用し、同様のレベルの分離を保証しますが、ハードウェアをエミュレートしたり、同じマシンでさらに別のオペレーティングシステムを実行したりすることはありません。 これにより、非常に軽量になります。

プロセスの名前空間

歴史的に、Linuxカーネルは単一のプロセスツリーを維持してきました。 ツリーには、親子階層で現在実行されているすべてのプロセスへの参照が含まれています。 プロセスは、十分な特権を持ち、特定の条件を満たす場合、トレーサーを接続して別のプロセスを検査したり、プロセスを強制終了したりすることもできます。

Linux名前空間の導入により、複数の「ネストされた」プロセスツリーを持つことが可能になりました。 各プロセスツリーは、完全に分離されたプロセスのセットを持つことができます。 これにより、1つのプロセスツリーに属するプロセスが、他の兄弟または親プロセスツリー内のプロセスを検査または強制終了できず、実際にはその存在を知ることさえできなくなります。

Linuxを搭載したコンピューターが起動するたびに、プロセスID(PID)1の1つのプロセスから開始します。このプロセスはプロセスツリーのルートであり、適切なメンテナンス作業を実行して開始することにより、システムの残りの部分を開始します。正しいデーモン/サービス。 他のすべてのプロセスは、ツリー内のこのプロセスの下から始まります。 PID名前空間を使用すると、独自のPID1プロセスを使用して新しいツリーをスピンオフできます。 これを行うプロセスは、元のツリーの親名前空間に残りますが、子を独自のプロセスツリーのルートにします。

PID名前空間の分離では、子名前空間のプロセスは親プロセスの存在を知る方法がありません。 ただし、親名前空間のプロセスは、親名前空間の他のプロセスであるかのように、子名前空間のプロセスの完全なビューを持ちます。

この名前空間チュートリアルでは、Linuxの名前空間システムを使用したさまざまなプロセスツリーの分離について概説します。

ネストされた子名前空間のセットを作成することができます。1つのプロセスが新しいPID名前空間で子プロセスを開始し、その子プロセスが新しいPID名前空間でさらに別のプロセスを生成します。

PID名前空間の導入により、1つのプロセスに複数のPIDを関連付けることができるようになりました。これは、該当する名前空間ごとに1つずつです。 Linuxのソースコードでは、単一のPIDだけを追跡していたpidという名前の構造体が、 upidという名前の構造体を使用して複数のPIDを追跡するようになっていることがわかります。

 struct upid { int nr; // the PID value struct pid_namespace *ns; // namespace where this PID is relevant // ... }; struct pid { // ... int level; // number of upids struct upid numbers[0]; // array of upids };

新しいPID名前空間を作成するには、特別なフラグCLONE_NEWPIDを使用してclone()システムコールを呼び出す必要があります。 (Cは、このシステムコールを公開するラッパーを提供し、他の多くの一般的な言語も同様です。)以下で説明する他の名前空間もunshare()システムコールを使用して作成できますが、PID名前空間は新しいときにのみ作成できます。プロセスはclone()を使用して生成されます。 このフラグを指定してclone()が呼び出されると、新しいプロセスは、新しいプロセスツリーの下の新しいPID名前空間ですぐに開始されます。 これは、単純なCプログラムで実証できます。

 #define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("PID: %ld\n", (long)getpid()); return 0; } int main() { pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); printf("clone() = %ld\n", (long)child_pid); waitpid(child_pid, NULL, 0); return 0; }

このプログラムをコンパイルしてroot権限で実行すると、次のような出力が表示されます。

 clone() = 5304 PID: 1

child_fn内から出力されるPIDは1になります。

上記の名前空間チュートリアルコードは、一部の言語では「Hello、world」よりも長くはありませんが、舞台裏で多くのことが行われています。 clone()関数は、ご想像のとおり、現在のプロセスのクローンを作成して新しいプロセスを作成し、 child_fn()関数の開始時に実行を開始しました。 ただし、その際、元のプロセスツリーから新しいプロセスを切り離し、新しいプロセス用に別のプロセスツリーを作成しました。

static int child_fn()関数を次のように置き換えて、分離されたプロセスの観点から親PIDを出力してみてください。

 static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }

今回プログラムを実行すると、次の出力が得られます。

 clone() = 11449 Parent PID: 0

分離されたプロセスの観点から見た親PIDが0であり、親がないことを示していることに注意してください。 同じプログラムをもう一度実行してみてください。ただし、今回は、 clone()関数呼び出し内からCLONE_NEWPIDフラグを削除します。

 pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);

今回は、親PIDが0ではなくなったことに気付くでしょう。

 clone() = 11561 Parent PID: 11560

ただし、これはチュートリアルの最初のステップにすぎません。 これらのプロセスは、他の共通または共有リソースに無制限にアクセスできます。 たとえば、ネットワークインターフェイス:上記で作成した子プロセスがポート80でリッスンする場合、システム上の他のすべてのプロセスがそれをリッスンできなくなります。

Linuxネットワーク名前空間

ここで、ネットワーク名前空間が役立ちます。 ネットワーク名前空間を使用すると、これらの各プロセスでまったく異なるネットワークインターフェイスのセットを確認できます。 ループバックインターフェイスでさえ、ネットワーク名前空間ごとに異なります。

プロセスを独自のネットワーク名前空間に分離するには、 clone()関数呼び出しに別のフラグを導入する必要がありますCLONE_NEWNET ;

 #define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }

出力:

 Original `net` Namespace: 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: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000 link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff New `net` Namespace: 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

何が起きてる? 物理イーサネットデバイスenp4s0は、この名前空間から実行される「ip」ツールによって示されるように、グローバルネットワーク名前空間に属しています。 ただし、物理インターフェイスは新しいネットワーク名前空間では使用できません。 さらに、ループバックデバイスは元のネットワーク名前空間ではアクティブですが、子ネットワーク名前空間では「ダウン」しています。

子名前空間で使用可能なネットワークインターフェイスを提供するには、複数の名前空間にまたがる追加の「仮想」ネットワークインターフェイスを設定する必要があります。 それが完了すると、イーサネットブリッジを作成し、名前空間間でパケットをルーティングすることもできます。 最後に、すべてを機能させるには、「ルーティングプロセス」をグローバルネットワーク名前空間で実行して、物理インターフェイスからトラフィックを受信し、適切な仮想インターフェイスを介して正しい子ネットワーク名前空間にルーティングする必要があります。 たぶん、Dockerのような、このような手間のかかる作業をすべて行うツールがなぜそれほど人気が​​あるのか​​がわかるでしょう。

Linuxネットワーク名前空間は、複数の子ネット名前空間へのルーティングプロセスで構成されています。

これを手動で行うには、親名前空間から1つのコマンドを実行することにより、親名前空間と子名前空間の間に仮想イーサネット接続のペアを作成できます。

 ip link add name veth0 type veth peer name veth1 netns <pid>

ここで、 <pid>は、親によって監視される子名前空間内のプロセスのプロセスIDに置き換える必要があります。 このコマンドを実行すると、これら2つの名前空間間にパイプのような接続が確立されます。 親名前空間はveth0デバイスを保持し、 veth0デバイスを子名前空間にveth1ます。 2つの実際のノード間の実際のイーサネット接続から予想されるように、一方の端に入るものはすべて、もう一方の端から出ます。 したがって、この仮想イーサネット接続の両側にIPアドレスを割り当てる必要があります。

名前空間をマウントする

Linuxは、システムのすべてのマウントポイントのデータ構造も維持します。 これには、マウントされているディスクパーティション、マウントされている場所、読み取り専用かどうかなどの情報が含まれます。 Linux名前空間を使用すると、このデータ構造のクローンを作成できるため、異なる名前空間のプロセスは、相互に影響を与えることなくマウントポイントを変更できます。

個別のマウント名前空間を作成すると、 chroot()を実行するのと同様の効果があります。 chroot()は優れていますが、完全な分離を提供するわけではなく、その効果はルートマウントポイントのみに制限されます。 個別のマウント名前空間を作成すると、これらの分離された各プロセスで、システム全体のマウントポイント構造を元のプロセスとは完全に異なるビューにすることができます。 これにより、分離されたプロセスごとに異なるルートを設定したり、それらのプロセスに固有の他のマウントポイントを設定したりできます。 このチュートリアルに従って注意して使用すると、基盤となるシステムに関する情報を公開することを回避できます。

この名前空間チュートリアルで概説されているように、名前空間を正しく使用する方法を学ぶことには複数の利点があります。

これを実現するために必要なclone()フラグはCLONE_NEWNSです。

 clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)

最初に、子プロセスは、親プロセスとまったく同じマウントポイントを認識します。 ただし、新しいマウント名前空間の下にあるため、子プロセスは必要なエンドポイントをマウントまたはアンマウントできます。この変更は、親の名前空間にも、システム全体の他のマウント名前空間にも影響しません。 たとえば、親プロセスのルートに特定のディスクパーティションがマウントされている場合、分離されたプロセスには、最初にルートにマウントされたまったく同じディスクパーティションが表示されます。 ただし、マウント名前空間を分離することの利点は、分離されたプロセスがルートパーティションを別のものに変更しようとしたときに明らかになります。これは、変更が分離されたマウント名前空間にのみ影響するためです。

興味深いことに、これは実際には、 CLONE_NEWNSフラグを使用してターゲットの子プロセスを直接生成することをお勧めしません。 より良いアプローチは、 CLONE_NEWNSフラグを使用して特別な「init」プロセスを開始し、その「init」プロセスに「/」、「/ proc」、「/ dev」、またはその他のマウントポイントを必要に応じて変更させてから、ターゲットプロセスを開始することです。 。 これについては、この名前空間チュートリアルの終わり近くでもう少し詳しく説明します。

その他の名前空間

これらのプロセスを分離できる他の名前空間、つまり、ユーザー、IPC、およびUTSがあります。 ユーザー名前空間を使用すると、プロセスは、名前空間外のプロセスへのアクセスを許可せずに、名前空間内でroot権限を持つことができます。 プロセスをIPC名前空間で分離すると、SystemVIPCやPOSIXメッセージなどの独自のプロセス間通信リソースが提供されます。 UTS名前空間は、システムの2つの特定の識別子( nodenamedomainname )を分離します。

UTS名前空間がどのように分離されるかを示す簡単な例を以下に示します。

 #define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/utsname.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static void print_nodename() { struct utsname utsname; uname(&utsname); printf("%s\n", utsname.nodename); } static int child_fn() { printf("New UTS namespace nodename: "); print_nodename(); printf("Changing nodename inside new UTS namespace\n"); sethostname("GLaDOS", 6); printf("New UTS namespace nodename: "); print_nodename(); return 0; } int main() { printf("Original UTS namespace nodename: "); print_nodename(); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL); sleep(1); printf("Original UTS namespace nodename: "); print_nodename(); waitpid(child_pid, NULL, 0); return 0; }

このプログラムは、次の出力を生成します。

 Original UTS namespace nodename: XT New UTS namespace nodename: XT Changing nodename inside new UTS namespace New UTS namespace nodename: GLaDOS Original UTS namespace nodename: XT

ここで、 child_fn()nodename名を出力し、それを別の名前に変更して、再度出力します。 当然、変更は新しいUTS名前空間内でのみ発生します。

すべての名前空間が提供および分離するものの詳細については、こちらのチュートリアルを参照してください。

クロスネームスペースコミュニケーション

多くの場合、親と子の名前空間の間で何らかの通信を確立する必要があります。 これは、隔離された環境内で構成作業を行うためのものである場合もあれば、単に外部からその環境の状態を覗き見する機能を保持するためのものである場合もあります。 これを行う1つの方法は、SSHデーモンをその環境内で実行し続けることです。 各ネットワーク名前空間内に個別のSSHデーモンを含めることができます。 ただし、複数のSSHデーモンを実行すると、メモリなどの貴重なリソースが大量に使用されます。 ここで、特別な「init」プロセスを使用することをお勧めします。

「init」プロセスは、親名前空間と子名前空間の間に通信チャネルを確立できます。 このチャネルは、UNIXソケットに基づくことも、TCPを使用することもできます。 2つの異なるマウント名前空間にまたがるUNIXソケットを作成するには、最初に子プロセスを作成し、次にUNIXソケットを作成してから、子を別のマウント名前空間に分離する必要があります。 しかし、最初にプロセスを作成し、後でそれを分離するにはどうすればよいでしょうか。 Linuxはunshare()を提供します。 この特別なシステムコールにより、プロセスは、最初に親に子を分離させる代わりに、元の名前空間からそれ自体を分離することができます。 たとえば、次のコードは、ネットワーク名前空間のセクションで前述したコードとまったく同じ効果があります。

 #define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { // calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned unshare(CLONE_NEWNET); printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }

また、「init」プロセスはユーザーが考案したものであるため、最初に必要なすべての作業を実行させてから、ターゲットの子を実行する前にシステムの他の部分から自身を分離することができます。

結論

このチュートリアルは、Linuxで名前空間を使用する方法の概要にすぎません。 これにより、Linux開発者がDockerやLinuxコンテナーなどのツールのアーキテクチャーの不可欠な部分であるシステム分離の実装を開始する方法についての基本的な考え方が得られるはずです。 ほとんどの場合、すでによく知られていてテストされているこれらの既存のツールの1つを使用するのが最善です。 ただし、場合によっては、独自のカスタマイズされたプロセス分離メカニズムを使用することが理にかなっている場合があります。その場合、この名前空間チュートリアルは非常に役立ちます。

この記事で説明したよりも多くのことが内部で行われています。安全性と分離性を高めるために、ターゲットプロセスを制限する方法は他にもあります。 しかし、うまくいけば、これは、Linuxでの名前空間の分離が実際にどのように機能するかについてもっと知りたいと思っている人にとって有用な出発点として役立つことができます。