ネームスペース

このページの目次

コンテナ内で PID 1 として表示されていても、ホスト側で PID 1 が見えるわけではありません。新しいマウントネームスペースを作成しても、ファイルの内容がコピーされるわけではありません。ネームスペースは各種リソースに対してビューと権限の境界を確立し、cgroup はリソースの使用量を管理します。

コンテナは、ルートファイルシステム、capability、seccomp、セキュリティポリシーと組み合わせて使用され、すべての分離を一元管理するネームスペースオブジェクトは存在しません。

参照目標読書位置
オブジェクトの範囲と参照型、作成、進入
身元とファイルのビューPID、User、Mount
再利用可能な実験とその他の境界分離の例、ネットワークと時間
コンテナは複数のネームスペースを組み合わせ、プロセスはその一部を共有できる プロセス A 独立した mount / PID プロセス B 別の mount / PID のセット 同じ network ns ポートとネットワークデバイスビューの共有 ネームスペースのメンバシップは種類ごとに選択されます。それは分割不可能な「コンテナの身元」ではありません。

各ネームスペースの管理対象

型フラグ範囲と境界
MountCLONE_NEWNSマウントツリーのビュー;ファイル内容は共有されたままになる可能性があり、イベントは伝播する可能性があります
PIDCLONE_NEWPIDPID 番号と可視階層;すべてのメンバーが PID 1 になるわけではありません
UserCLONE_NEWUSERUID/GID のマッピングと capability のスコープ
NetworkCLONE_NEWNETネットワークデバイス、ルーティング、プロトコルスタートの状態、ポートなど
IPCCLONE_NEWIPCSysV IPC、POSIX メッセージキューなど
UTSCLONE_NEWUTSホスト名と NIS ドメイン名;独立した DNS システムではありません
CgroupCLONE_NEWCGROUPcgroup パスのビュー;リソースクォータは作成しません
TimeCLONE_NEWTIMEmonotonic/boottime などのクロックオフセット;すべてのクロックを仮想化しません

具体的な機能はカーネルの設定に依存し、作成と進入は権限によって制限されます。ネームスペースの概要

作成、進入、参照の保持

clone/clone3 は、新しいタスクを新しいネームスペースに進入させることができます。unshare は、呼び出し元の一部の共有関係を解除します。setns は、fd で指定された既存のネームスペースに進入します。各型には特別なルールがあり、これらを完全に同等の「コンテナの切り替え」として扱うことはできません。

/proc/PID/ns 下のリンクはメンバシップを比較するために使用でき、対応する fd を開くか bind mount を保持することで、ネームスペースの参照を維持できます。プロセスが終了しても、保持されているネームスペースオブジェクトがすぐに破棄されるとは限りません。

nsproxy は複数のネームスペースの参照を保持しますが、user namespace はプロセスの資格情報に関連付けられており、現在の PID namespace は PID 階層に関連しています。nsproxy をすべてを含む完全なスナップショットとして単純化することはできません。pid_for_children と time_for_children は、「現在所属しているもの」と「後続のサブプロセスが所属するもの」の違いを特に示しています。

PID namespace:番号は相対的である

新しい PID namespace の最初のプロセスはそこで PID 1 となり、後続のメンバーは各自の番号を持ちます。同じタスクは祖先の PID namespace にも他の PID を持っています。祖先は子孫を見ることができますが、子 namespace はこれによってすべての祖親プロセスを閲覧する能力を得るわけではありません。

unshare(CLONE_NEWPID) は、呼び出し元自身の PID namespace を変更するのではなく、その後作成されるサブプロセスに影響を与えます。setns も PID namespace に対して同様のサブプロセスセマンティクスを持ちます。これが、unshare コマンドが実行されるプログラムを新しい PID ビューに進入させるために通常 --fork が必要である理由です。PID namespace

まずサブプロセスを作成し、その後 unshare して後続のサブプロセスを作成します。2人の観察者の間でビューを切り替え、同じタスクの身元と可視番号を比較します。ネームスペースをプロセスのコピーとして扱うのではなく、ビューの切り替えとして捉えてください。

図を準備しています
unshare 後、親プロセスの PID はなぜ変わらないのか?

unshare(CLONE_NEWPID) は pid_for_children を変更し、呼び出し元の現在の PID namespace は移動しません。同じサブタスクは、祖先と自身の空間で異なる PID を持つことができ、2つのプロセスがコピーされるわけではありません。

PID 1 には特別なシグナルルールがあります。namespace 内の他のメンバーは通常、インストールされたハンドラを持つシグナルのみを送信できます。SIGKILL/SIGSTOP は、通常のプロセスのように内部で有効にはなりません。祖先 namespace からの SIGKILL/SIGSTOP には特定の例外があります。したがって、「内部では kill できないが、SIGSTOP は可能である」という一般化は誤りです。

PID 1 が終了すると、カーネルはその namespace 内の他のプロセスを殺し、その namespace への後続のプロセス作成は制限されます。オブジェクトは fd によって保持されたままになる可能性があり、すべての参照が即座に解放されるわけではありません。

「現在の空間は変わらず、後続のサブプロセスが変わる」ことを検証する

以下では、Python 3.12+ の Linux os.unshare() インターフェースを使用して、これらの2つのメンバシップを直接観察します。まずコマンドラインから一時的な user namespace を作成し、プログラムが自身の PID namespace を作成するために必要なスコープ内の権限を取得させます。プログラムは外側の proc マウントを保持し、親プロセスが外側の PID でサブプロセスをチェックできるようにします。

これを pid_view.py として保存し、unshare --user --map-root-user python3 pid_view.py で実行します。プログラムは1つのサブプロセスのみを作成し、パイプを使用してその終了タイミングを制御します。

import errno
import os

def ns_identity(path):
    info = os.stat(path)
    return info.st_dev, info.st_ino

before_pid = os.getpid()
before_ns = ns_identity("/proc/self/ns/pid")
os.unshare(os.CLONE_NEWPID)
assert os.getpid() == before_pid
assert ns_identity("/proc/self/ns/pid") == before_ns

read_fd, write_fd = os.pipe()
child = os.fork()
if child == 0:
    os.close(write_fd)
    try:
        assert os.getpid() == 1 and os.getppid() == 0
        assert os.read(read_fd, 1) == b"x"
    except BaseException:
        os._exit(1)
    os._exit(0)

os.close(read_fd)
try:
    child_ns = ns_identity(f"/proc/{child}/ns/pid")
    assert child_ns != before_ns
    assert ns_identity("/proc/self/ns/pid_for_children") == child_ns
    assert ns_identity("/proc/self/ns/pid") == before_ns
    assert os.write(write_fd, b"x") == 1
finally:
    os.close(write_fd)
    _, status = os.waitpid(child, 0)
assert os.waitstatus_to_exitcode(status) == 0
print(f"parent PID unchanged: {before_pid}; child seen outside: {child}; inside: 1")

# 新空間の init は既に終了しており、その空間に fork しようとすると失敗するはずです。
try:
    unexpected = os.fork()
except OSError as error:
    assert error.errno == errno.ENOMEM
else:
    if unexpected == 0:
        os._exit(1)
    os.waitpid(unexpected, 0)
    raise AssertionError("init が終了した PID namespace は新しいプロセスを受け付けてはならない")

PID 番号は実行のたびに変わりますが、実際に確認すべきは4つの関係です。親プロセス自身の番号は変わっていないこと。サブプロセスは内部で 1 であること。サブプロセスは外部の親プロセスの PPID を 0 として見ていること。親プロセスの pid_for_children がサブプロセスの現在の PID namespace と同じであること。pid_for_children は最初の子プロセス作成前にも特別な可視性を持つため、コードは fork の後にそれを比較します。

unshare(PID) は後続のサブプロセス空間を変更し、呼び出し元は元の場所に残る 親プロセス:現在の PID namespace A getpid() は元の番号を保持 子プロセス:現在の PID namespace B 内部 PID=1、外部には別の番号 fork 親の pid_for_children → B;親の ns/pid → A 子プロセスの親は B の外側に位置するため、getppid() は 0 を返します。PID 1 が終了すると、B は fork を受け付けなくなります。

この制御された軌跡における最後の ENOMEM は、namespace init が終了したことに由来しており、物理的なメモリ不足として一概に説明できるものではありません。実際のプログラムでは、errno を保持し、前置操作と組み合わせて診断する必要があります。実験では proc を再マウントしないため、コンテナ内での ps の完全なビューは示されていません。このビューが必要な場合は、独立した mount namespace と新しい proc インスタンスと併用してください。PID namespace のメンバシップと proc セマンティクス

PID 1 として実行するには、シグナルと回收の責任も負う必要がある

孤児は適切な subreaper または namespace init によって養子縁組され、責任ある側が wait して回收する必要があります。アプリケーションが PID 1 として動作する場合、終了シグナルとサブプロセスの回收を適切に処理する必要があり、通常の対話型プログラムのデフォルトの動作にのみ依存することはできません。

User namespace:root の意味はマッピングに依存する

非初期 user namespace 内で自分の /proc/self/uid_map を読み取ったとき、0 1000 1 が表示される場合、3つのフィールドとして解釈できます。

フィールド数値意味
内部開始 UID0新しい空間内の身元
親空間開始 UID1000外部の身元に対応
範囲の長さ1この UID 1つだけをマッピング

これは他のユーザーマッピングを自動的に作成するものではなく、内部の root を単純にホストの root に換算することもできません。

内部の root はホストの nobody ではありません。また、自動的にホストのファイルを読み取れないわけではありません。マウントビューがホストのファイルを露出させている場合、マッピングされた身元は通常のアクセス権限を持っている可能性があります。user namespace 内の capability は、主にその権限スコープ内のリソースに対して有効になり、初期 user namespace のグローバル権限を自動的に取得するわけではありません。User namespace

複数の UID/GID マッピングには、subuid/subgid、マッピングヘルパー、setgroups の制限などが関与します。特権を持たない user namespace の作成可否は、ディストリビューションのセキュリティポリシー、LSM、および数の制限によって影響を受ける可能性があります。kernel.unprivileged_userns_clone は、すべての Linux で統一して存在するスイッチではありません。

入れ子になったマッピングは段階的に合成し、読者の視点も記録する

第1層の namespace から初期 namespace までの範囲を 0 100000 65536、第2層から第1層までのマッピングを 0 1000 1 と設定します。この場合、第2層の UID 0 はまず第1層の UID 1000 にマッピングされ、さらに初期 namespace の UID 101000 にマッピングされます。第1層自身の UID 0 は、初期 UID 100000 に対応します。両方の層で root と呼ばれていても、それらが同じ外部身元を表すわけではありません。

def mapped(uid, ranges):
    for inside, outside, count in ranges:
        if inside <= uid < inside + count:
            return outside + uid - inside
    return None

first_to_initial = [(0, 100000, 65536)]
second_to_first = [(0, 1000, 1)]
intermediate = mapped(0, second_to_first)
assert intermediate == 1000
assert mapped(intermediate, first_to_initial) == 101000
assert mapped(0, first_to_initial) == 100000
assert mapped(65535, first_to_initial) == 165535
assert mapped(65536, first_to_initial) is None
assert mapped(1, second_to_first) is None

モデル内の None は、対応するマッピングがないことを示し、UID 0 ではなく、自動的に nobody の権限を持つことを意味するものでもありません。カーネルは異なるインターフェースで、表現できない所有者を示すために overflow UID を表示したり、有効なマッピングを必要とする操作を拒否したりする場合があります。表示結果は認可判断に代わるものではありません。上記の範囲はマッピング計算の例に過ぎず、実際にそれらを作成するには、マッピングの書き込み権限と親層のマッピング可能な範囲を満たす必要があります。

見落としがちなもう1つの詳細があります。読者と対象プロセスが同じ user namespace にいる場合、uid_map の2列目は対象の親 namespace として解釈されます。両者が異なる user namespace にいる場合、2列目はそのファイルを開いた読者の namespace として解釈されます。したがって、マッピングを収集する際は、読者の位置を同時に記録する必要があり、任意の /proc/PID/uid_map の2列目を「ホスト UID」または「親 UID」としてマークすることはできません。User namespace:UID/GID マッピングの読み取りルール

Mount namespace:独立したマウントテーブルが独立したファイル内容を意味するわけではない

新しい空間は通常、既存のマウントビューからコピーされますが、基盤となるファイルシステムは同じままになる可能性があります。共有サブツリーの shared/slave/private などの伝播属性は、マウントイベントがビュー間でどのように伝播するかを決定します。したがって、「1つの空間での mount が常に他方に影響を与えない」という主張は成立しません。マウントネームスペース

ルートディレクトリの分離には、適切なマウントとルート切り替えの設計が必要です。chroot や新しい mount namespace 自体は、すべてのファイルをコピーするものではなく、既に開かれている fd のアクセス能力を自動的に取り消すものでもありません。

ホストの hostname を変更しないローカル例

以下のコマンドは、特権を持たない user namespace が許可された環境で user、UTS、PID、および mount namespace を作成し、サブプロセスが終了すると実験が終了します。

unshare --user --map-root-user --uts --pid --fork --mount --mount-proc   sh -c 'hostname kb-demo; printf "pid=%s hostname=%s\n" "$$" "$(hostname)"; cat /proc/self/uid_map'

hostname の変更は新しい UTS namespace で発生します。--mount-proc は新しい PID ビューに proc をマウントし、ps などのツールが外部の proc インスタンスを読み取らないようにします。出力される UID マッピングは実行者に依存し、必ずしもホストの 1000 になるとは限りません。権限が拒否された場合は、まず環境ポリシーを特定し、教学例を実行するために全体のセキュリティ設定を緩和しないでください。

ネットワークと時間空間の一般的な誤解

コンテナはネットワークネームスペースを共有でき、ホストネットワークを使用することもできるため、すべてのコンテナが独立した netns を持つわけではありません。新しいネットワーク空間が自動的に利用可能な外部ネットワークを取得するわけでもなく、デバイス、アドレス、ルーティング、またはユーザー空間ネットワークバックエンドを構成する必要があります。

time namespace は、対応する monotonic/boottime クロックにオフセットを提供し、移行後の時間ビューを維持するのに適しています。CLOCK_REALTIME は、これによってコンテナごとの独立した壁掛け時計を取得するわけではありません。作成後のサブプロセスとオフセット設定のタイミングにも、特別な制限があります。Time namespace

リソースの上限と委譲についてさらに理解するには、cgroup を参照してください。