インフラエンジニアの羅針盤

インフラエンジニア1〜3年目のための技術ガイド

gVisor隔離とテナント境界【CKS第10回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第10回です。前回は「etcd の中に平文で置かれていたもの」を暗号化しました。今回は置き場所そのものを扱います。1 つのクラスタを複数のチームで共有するとき、どこまでが境界なのか。

本回の主題は 2 つあります。マルチテナンシーの境界は 1 つの機能では作れないこと。そして gVisor でカーネルを隔離しようとすると、第6回で入れた SELinux の拘束と正面からぶつかることです。

扱うのは テナント境界の 4 部品(namespace / RBAC / NetworkPolicy / ResourceQuota + LimitRange)、RuntimeClassgVisor(runsc) の 3 つです。Kata Containers は記法と分離モデルの紹介のみで、本書ラボでは動きません(/dev/kvm が無いため)。実機作業は k8s-wl-01(runsc の導入と containerd の設定)と k8s-ops からの kubectl / git です。alma-proxy の whitelist に追加は要りません。

  • ラボ Kubernetes v1.36.3
  • CKS 試験環境 v1.35
  • containerd v2.2.6(config version = 3
  • runsc release-20260721.0(spec 1.2.1)
  • AlmaLinux 10.2(kernel 6.12.0-211.22.1.el10_2)
  • Helm v4.1.4
  • ArgoCD v3.4 系
  • 確認日 2026-07-28
目次
  1. 第10回のスコープ・今ここマップ
    1. 既習範囲 / 本回で上書きする観点
    2. 本回の起点
    3. 本回で行き来するホスト
    4. 演習の進め方
  2. この回のゴール
  3. テナント境界は 1 つの機能では作れない —— 4 つの部品
    1. Namespace は境界そのものではない
    2. 最も重要なのは RBAC
    3. 4 部品を「両側に」張る
  4. 「ソフト / ハード」は二者択一ではない —— 公式はスペクトラムと書いている
    1. 段階 4 が本回の後半である理由
  5. やってみよう①:テナント namespace を 2 つ作り、越境が止まることを確かめる
    1. ステップ1:2 つのテナント namespace を作る
    2. ステップ2:tenant-a にだけ 4 部品を揃える
    3. ステップ3:RBAC の越境を確かめる
    4. ステップ4:両テナントに Pod を 1 つずつ置く
    5. ステップ5:境界が片側にしか無いことを実測する
    6. ステップ6:Quota の越境を確かめる
    7. ステップ7:後片付け
  6. コンテナはホストカーネルを共有している —— サンドボックスが要る理由
    1. 共有しているものは、共有しているぶんだけ攻撃面になる
    2. 「サンドボックスを入れれば安全」ではない
  7. gVisor の構造 —— Sentry と Gofer
    1. Sentry —— ユーザー空間のカーネル
    2. Gofer —— ファイルアクセスの代理
    3. プラットフォーム —— syscall をどうやって捕まえるか
    4. gVisor が肩代わりするということは、gVisor が実装していないものは動かない
  8. RuntimeClass の定義 —— handler / scheduling / overhead
    1. handler は CRI 側の設定名を指す
    2. scheduling —— どのノードへ載せるか
    3. overhead —— サンドボックス自身が食う分を申告する
    4. RuntimeClass が無いとき / handler が無いとき
  9. やってみよう②:runsc を導入し、gVisor Pod が起動しないことを実測する
    1. ステップ1:whitelist に追加が要らないことを確認する
    2. ステップ2:runsc と shim を取得して検証する
    3. ステップ3:runsc install は containerd 用ではない
    4. ステップ4:containerd の設定版を確認する
    5. ステップ5:runsc をランタイムとして登録する
    6. ステップ6:containerd を再起動する
    7. ステップ7:RuntimeClass とノードラベルを作る
    8. ステップ8:gVisor Pod を作る —— そして起動しないことを確認する
    9. ステップ9:describe で原因を読む
    10. ステップ10:他の失敗パターンと区別できるようにする
  10. 2 つのハードニングが正面衝突する —— なぜ runsc は SELinux ラベルを拒むのか
    1. runsc にフラグは無い
    2. containerd の enable_selinux はグローバル設定である
    3. Pod 側でも回避できない
    4. なぜ runsc は「無視して起動する」を選ばなかったのか
  11. サンドボックス専用ノードという決着 —— 代償を数えてから決める
    1. このノードは「防御を外した」のではなく「別の防御に置き換えた」
    2. 代償その 1 —— 同じノードの通常 Pod も MAC 拘束を失う
    3. 代償その 2 —— 可用性が下がる
    4. 本番ガードレール
  12. やってみよう③:wl-01 をサンドボックスノードにし、gVisor で動いている証拠を集める
    1. ステップ1:wl-01 の containerd で SELinux ラベリングを止める
    2. ステップ2:変更が 1 ノードに閉じていることを確認する
    3. ステップ3:止まっていた Pod が動き出すことを確認する
    4. ステップ4:scheduling.nodeSelector の注入を確認する
    5. ステップ5:カーネルが違うことを確認する(最短の判定法)
    6. ステップ6:通常 Pod と 5 つの観点で並べる
    7. ステップ7:ノード側からサンドボックスの構造を見る
    8. ステップ8:オーバーヘッドを測る
    9. ステップ9:代償を実測する —— 通常 Pod が MAC 拘束を失う
    10. ステップ10:観察用の資材を片付ける
  13. gVisor が動かないもの、観測できなくなるもの、そして Kata Containers
    1. 「gVisor は重い」は雑すぎる
    2. 未対応の syscall に当たったらどうなるか
    3. 観測できなくなるもの —— サンドボックスは監視ツールからも隠す
    4. Kata Containers —— 記法と分離モデルだけを扱う
  14. やってみよう④:fanclub-backend を GitOps で gVisor へ寄せ、代償を数えて元へ戻す
    1. ステップ1:base/backend-deployment.yaml に 1 行足す
    2. ステップ2:Helm chart 側にも同じ 1 行を足す
    3. ステップ3:Git へ push する
    4. ステップ4:ArgoCD の sync を待つ
    5. ステップ5:3 レプリカがどこへ載ったかを確認する
    6. ステップ6:サンドボックスで動いていることを確認する
    7. ステップ7:アプリの機能まで確認する(CRUD が維持されること)
    8. ステップ8:代償を 3 つ数える
    9. ステップ9:元へ戻す
    10. ステップ10:戻ったことを確認する
  15. PSA の exemptions.runtimeClasses —— サンドボックスが PSS の穴になる
    1. exemptions の 3 つの次元
    2. namespace のラベルより免除が優先する
    3. 本書では配線しない(判断とその理由)
    4. 試験でどう問われるか
  16. 暗記必須コマンド(RuntimeClass / gVisor / テナント境界)
  17. まとめ
  18. 理解度チェック(○×形式・全 9 問)
  19. 次回予告

第10回のスコープ・今ここマップ

本回も CKS ドメイン D4: Minimize Microservice Vulnerabilities(配点 20%) です。D4 は 4 つのコンピテンシーからなり、本回が担当するのは 3 つ目です。1 つ目と 2 つ目は第8回・第9回で終えており、残る 1 つは第11回で埋めます。

本回の主題は 2 文で書けます。テナント境界は 1 つの機能では作れない。 そして gVisor を入れると、第6回で入れた SELinux の拘束と正面からぶつかる。 前半が設計の話、後半が実装と判断の話です。

本回で新しく増える道具は 2 つだけです。 RuntimeClass と gVisor(runsc)。残りはすべて既習の部品を組み替える作業になります。前半で使う namespace・RBAC・NetworkPolicy・ResourceQuota は、第1巻から本巻第4回までで一度ずつ手を動かした道具です。覚え直す量は多くありません。 新しいのは、それらを「テナント境界」という 1 つの目的の下に並べ直す視点のほうです。

公式コンピテンシー(D4 Minimize Microservice Vulnerabilities・20%)担当対応セクション
Use appropriate Pod Security Standards第8回本回で一部を回収しますexemptions.runtimeClasses
Manage Kubernetes secrets第9回
Understand and implement isolation techniques (multi-tenancy, sandboxed containers)本回(第10回)本回のほぼ全セクション「サンドボックス専用ノードという決着」節と やってみよう④ の大半は試験範囲外の実務パート
Implement Pod-to-Pod encryption using Cilium, Istio第11回

マルチテナンシーの部品そのものは D4 固有ではありません。

namespace / ResourceQuota / LimitRange は第1巻・第2巻(CKA 範囲)で扱った道具です。NetworkPolicy は D1(Cluster Setup)、RBAC は D2(Cluster Hardening)の道具です。本回はそれらを「テナント境界」という 1 つの設計目的の下に束ね直します。 新しく増える道具は RuntimeClass と gVisor だけです。「これは何ドメインの話なのか」で迷わないよう、この関係を先に置いておきます。部品の出自はばらばらでも、束ねた結果を問うのが D4、と分けて覚えてください。

既習範囲 / 本回で上書きする観点

既習どこで本回で上書きする観点
Namespace + ResourceQuota + LimitRange第1巻第14回(初出)・第2巻第8回(運用)「リソースを配る道具」から 「1 テナントの暴走を他テナントへ波及させない境界」へ読み替えます
NetworkPolicy(default-deny + 最小許可)第3回境界は両側に張らないと片側しか止まらないことを、2 つのテナント namespace で実測します
RBAC(auth can-i --as=第4回テナント境界で最も重要な層として位置づけ直します(公式が「control plane で最も重要な isolation」と書いています)
containerd の enable_selinux = true・MCS ラベル第6回本回の gVisor と正面衝突します。 「防御を足すと別の防御が壊れる」という、本書で初めて起きる衝突として扱います
AppArmor は AlmaLinux で実機不可 → 記法のみ + 第19回第6回Kata Containers を同じ枠に入れます/dev/kvm が無いため実機不可)
ホスト OS の攻撃面最小化第7回本回は逆向きの判断をします。 ノード 1 台の MAC 拘束を意図的に外します。攻撃面は「減らす」だけでなく「置き換える」こともあります
PSA の enforce / AdmissionConfiguration の 3 CP 配布第8回exemptions.runtimeClasses を回収します。 第9回の次回予告で読者に約束した項目です
「apply は通るのに意図どおり動かない」型の診断第8回第9回本回は 2 例増えます。Quota 超過で Pod がそもそも作られない(3 例目)と、ContainerCreating で止まる(4 例目)。いずれも kubectl get pod の STATUS では切り分けられません
GitOps 経由の変更(base/ を編集して push)第9回同じ経路で runtimeClassName を適用し、同じ経路で revert して戻します(本回が 3 度目。手が覚える段階へ)。入れる経路と戻す経路が同じであることが、GitOps の利点そのものです

先に 1 つ、本回で覆る前提を予告しておきます。

RuntimeClass を作って runtimeClassName: gvisor と書けば Pod は gVisor 上で動く」——という手順を多くの記事が書いています。本書のラボでは動きませんでした。 Pod は ContainerCreating から進まず、イベントには SELinux is not supported と出ます。第6回で入れた防御が、第10回で入れる防御を拒んでいるのです。原因の確定は本回の後半で扱います。

第3巻 19 回のうち、現在位置は次のとおりです。

第1部 第3巻オリエンテーション
    第1回: 第3巻スコープ + CKA 境界 + 4C/脅威モデル + Kubestronaut

第2部 クラスタ堅牢化(D1/D2)
    第2回: kube-bench + CIS ベンチマーク修復 + バイナリ検証(D1)
    第3回: NetworkPolicy 完全設計 + ノードメタデータ保護 + TLS Ingress(D1)
    第4回: RBAC 監査 + ServiceAccount トークン管理(D2)
    第5回: API Server 堅牢化 + kubeadm アップグレード(D2)

第3部 OS / ノード堅牢化(D3)
    第6回: カーネル層の強制アクセス制御(SELinux/seccomp/capabilities/AppArmor)(D3)
    第7回: ホスト OS の攻撃面最小化と権限管理(D3)

第4部 ワークロード防御(D4)
    第8回: Pod Security Standards + Admission(D4)
    第9回: Secret 管理(etcd 暗号化 / SealedSecrets / ExternalSecrets)(D4)
  ★ 第10回: マルチテナンシー分離 + サンドボックス(gVisor/RuntimeClass)(D4)  ← 今ここ
    第11回: Cilium 透過暗号化 + L7 NetworkPolicy(Calico → Cilium 移行)(D4)

本回の起点

起点は第9回の完了状態です。第2巻から引き継いだ 9 VM 構成(Control Plane Node 3 台 + Workload Node 2 台 + 作業端末・レジストリ・LB・プロキシ)が稼働している前提で進めます。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd v2.2.6・AlmaLinux 10.2
Pod103 個・異常 0
fanclubhttps://fanclub.local/200
Longhorn4 ボリューム attached healthy
ArgoCDfanclub-api-prodSynced / Healthy
Helmfanclub revision 8 / sealed-secrets 2.19.1 / gatekeeper 3.23.0
etcdRAFT TERM 15 で 3 台一致・leader 1 台・RAFT INDEX の差は 2 以内・DB 226〜227 MB
全 namespace の Secret46 個第9回secretbox により暗号化済み
fanclub の PSA ラベルenforce=restricted第8回で付与)
k8s-wl-01 の Pod 数51 個(ArgoCD 7 / cert-manager 3 / Longhorn CSI 多数 / Calico / kube-proxy 等)
alma-proxy の whitelist42 行storage.googleapis.com を含みます。本回で追加はありません
containerd の enable_selinuxWorkload Node 2 台のみ true(Control Plane Node 3 台は false)(第6回で設定)
RuntimeClass0 個(本回で初めて作ります)

この表で本回に効いてくるのは、下の 4 行です。k8s-wl-01 に 51 個の Pod が乗っていることは、後半で「このノードに taint を打てるか」を判断する材料になります。containerd の enable_selinux が Workload Node 2 台で true であることは、gVisor が起動しない原因そのものです(第6回が Control Plane Node を意図的に対象外にしたので、そちらは false のままです)。whitelist が 42 行であることは、本回で 1 行も足さないことの確認になります。RuntimeClass が 0 個であることは、本回の作業前後で同じ値に戻ることの基準です。

スナップショットから復元した直後の見え方について。

順序起動と dnsmasq の再起動を終えた直後は、calico-node / coredns / fluent-bitUnknownmetallb-speakerCrashLoopBackOff に見えます。これは復元直後の既知の挙動で、5 分待つと Running / Completed に戻ります。 Pod 数が 103 に揃うまで、本回の作業は始めないでください。

curl https://fanclub.local/000 を返す場合。

k8s-ops の no_proxy/etc/profile.d/proxy.sh)に fanclub.local が入っていないため、Squid 経由になって exit 56 / HTTP コード 000 が返ります。本書の回帰確認コマンドは --noproxy "*" を付けた形で統一します。 恒久的に直すなら /etc/profile.d/proxy.shno_proxyfanclub.local を足してください。

本回で行き来するホスト

本回は 5 台のホストを行き来します。

手元の端末から各 VM へ直接 SSH してください(developer でログインし、root 権限が要る操作は sudo -i でログインシェルに入ります)。sudo だけでは /etc/profile.d/proxy.sh が読まれず、curl がプロキシを経由できません。

ホストIP本回で打つ内容
k8s-ops192.168.1.122kubectlgit(大半の作業)
k8s-wl-01192.168.1.128runsc の導入・containerd の設定変更(本回で唯一設定を変えるノード
k8s-wl-02192.168.1.129確認 1 本(変更が 1 ノードに閉じていること)
k8s-cp-01192.168.1.125確認 2 本(AdmissionConfiguration と API Server のフラグ)
alma-proxy192.168.1.121確認 2 本(whitelist)

設定を変えるのは k8s-wl-01 だけです。 他の 4 台では読み取りしか行いません。この非対称そのものが本回の設計なので、コマンドを打つ前に「今どのホストにいるか」を毎回確認してください。wl-02 で enable_selinux を書き換えてしまうと、演習の前提が崩れます。

演習の進め方

本回は「やってみよう」が 4 本あり、通しで実施すると約 165 分(30 / 35 / 40 / 60 分)かかります。④が長いのは、backend のロールアウト(適用時と撤去時にそれぞれ約 9 分)を待つ時間が含まれるためです。

①は独立しています。 テナント namespace を 2 つ作って越境を試し、最後に削除するだけで、fanclub には 1 バイトも触りません(30 分)。

②③④は地続きです。 ②で runsc を導入してわざと失敗させ、③でノードの設定を変えて成立させ、④で fanclub-backend へ適用して代償を数え、最後に元へ戻します②で止めると、RuntimeClass はあるのに Pod が起動しないクラスタが残ります。 ②と③は続けて 75 分を確保してから始めてください。

本回の変更は、回の最後にすべて元へ戻します。 ノードの containerd 設定・RuntimeClass・ノードラベル・base/backend-deployment.yaml の 1 行・導入したバイナリは、やってみよう④ のステップ9 で撤去します。 第11回以降のラボに、本回の変更は残りません。 戻す理由も演習の中で扱います(サンドボックス専用ノードを「本当に専用」にするには Workload Node が 2 台では足りない、という本書ラボの制約です)。作り方と判断基準を持ち帰ることが本回の目的で、恒久導入はその目的に含まれません。

元に戻さないものは 1 つだけです。 k8s-wl-01 の /root/config.toml.bak-10(containerd 設定のバックアップ)は残します。復元に使ったファイルで、消しても構いませんが、本書ラボでは「いつ何を変えたか」の記録として残しておきます。

本文のコマンドは読みやすさのため kubectl と書いていますが、試験では alias k=kubectl が最初から設定されています(第4回で導入済みです)。本回は第3巻で YAML を手書きする量が最も多い回——テナントの 4 部品・RuntimeClass 3 種・プローブ Pod 4 種を書きます——なので、手元でも alias k=kubectl を設定しておくと写経が速くなります。

この回のゴール

本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。

  • テナント境界を 4 つの部品で組み立て、越境が「どの部品で」止まるかを切り分けられる
  • 境界を片側の namespace にだけ張ると、片方向しか止まらないことを実測で説明できる
  • RuntimeClass を定義し、handler / scheduling.nodeSelector / overhead の役割を説明できる
  • gVisor(Sentry / Gofer)がホストカーネルとの間に何を挟んでいるかを、実行中の Pod の中から確認できる
  • gVisor と SELinux ラベリングが排他であることを説明し、ノード単位で決める設計とその代償を数えられる
  • 導入した結果を見てから「この環境では入れない」と決められる。 代償を 3 つ(MAC 拘束の喪失 / 可用性の後退 / ホストの syscall を見る監視から見えなくなること)数えたうえで判断し、入れた変更を安全に撤去できる
  • RuntimeClass が PSA の免除対象になりうることexemptions.runtimeClassesの危険性を説明できる

テナント境界は 1 つの機能では作れない —— 4 つの部品

Kubernetes には「テナント」というリソースがありません。kubectl get tenant は通りません。テナント境界は、複数の既存機能を組み合わせて読者が自分で組み立てるものです。

ここで テナント(tenant) という語を使います。クラスタを共有する単位のことで、チームのこともあれば、SaaS の顧客のこともあります。そして マルチテナンシーは、1 つのクラスタを複数のテナントで共有することです。共有する以上、どこかに境界を引く必要があります。

公式ドキュメント(kubernetes.io/docs/concepts/security/multi-tenancy/)は、境界を Control Plane IsolationData Plane Isolation の 2 面に分けて説明しています。本書ではそれを 4 つの部品として並べます。

部品何を止めるか学んだ回
Namespace名前の衝突。RBAC と NetworkPolicy のスコープを与える土台(これ単体では何も止めません)Control Plane第1巻・第2巻
RBAC他テナントの API リソースの読み書きControl Plane第4回
NetworkPolicy他テナントの Pod への通信Data Plane第3回
ResourceQuota + LimitRange1 テナントの消費が他テナントを枯渇させること(noisy neighbor)Control Plane第1巻第14回・第2巻第8回

この表に、本回で新しく出てくる道具は 1 つも含まれていません。4 つとも既習です。 本回の前半は、既に手が動く部品を「テナント境界」という目的の下に並べ直す作業になります。

4 部品は「面」「権限」「通信」「量」に 1 対 1 で対応しています。 Namespace が面を用意し、RBAC が API 越しの操作を絞り、NetworkPolicy が Pod 間の通信を絞り、ResourceQuota + LimitRange が消費できる量を絞ります。1 つでも欠けると、その軸だけが素通しになります。 RBAC を書き忘れれば他テナントの Secret が読め、NetworkPolicy を書き忘れれば他テナントの Pod へ届き、Quota を書き忘れればノードの CPU を 1 テナントが食い潰せます。

テナントを増やすたびに 4 部品を揃える——この運用を手作業で回すと必ず抜けが出ます。実務では、4 部品を 1 枚のマニフェストにまとめておき、Kustomize の base か Helm chart として namespace 名だけを差し替えて適用する形にします。本回の演習①で書く 4 ファイルは、そのテンプレートの最小形だと思って読んでください。

Namespace は境界そのものではない

ここを取り違えると設計全体が崩れます。 namespace は「名前の空間」であり、それ自体は通信もリソース消費も権限も何ひとつ止めません。 namespace を分けただけの状態では、tenant-a の Pod から tenant-b の Pod へ普通に通信できますし、権限があれば tenant-b の Secret も読めます。

namespace の役割は、RBAC の Role / RoleBinding と NetworkPolicy が「どこに効くか」を指定できるようにすることにあります。つまり 他の 3 部品を貼り付けるための面です。

公式ドキュメントは、たとえ同じチームが運用する複数のワークロードであっても 「ワークロードごとに namespace を分けるのが一般的な作法(a common practice is to isolate every workload in its own namespace)」 と書いています。分けておかないと、後から境界を張りたくなったときに張る面がありません。

本書ラボの fanclub namespace は、この 4 部品がすべて揃った状態です。PSA ラベル 6 本(第8回)・RoleRoleBinding(第1巻)・NetworkPolicy 9 本(第3回)・fanclub-quotafanclub-limits が乗っています。本回の演習①では、これに手を触れずに、まったく新しいテナントを 2 つ作って観察します。

最も重要なのは RBAC

公式ドキュメントは control plane の isolation について、RBAC が最も重要だと明言しています。理由も書かれています。

If teams or their workloads can access or modify each others’ API resources, they can change or disable all other types of policies thereby negating any protection those policies may offer.

(テナントが互いの API リソースを読み書きできてしまうと、他のあらゆるポリシーを書き換えたり無効にしたりできるため、それらの防御は意味を失う)

NetworkPolicy も ResourceQuota も、API リソースとして存在しています。 それを削除できる権限を持つテナントに対しては、NetworkPolicy も Quota も境界になりません。tenant-a の担当者が kubectl delete networkpolicy --all -n tenant-a を打てるなら、その namespace の通信制御は本人の意思ひとつで消えます。4 部品には依存関係があり、RBAC が最初に崩れると残り 3 部品が全部無効になります。

逆に言えば、テナント境界の設計をレビューするときは RBAC から見るのが最短です。第4回で扱った kubectl auth can-i --list --as= は、そのための道具です。演習①のステップ3 で再登場します。

4 部品を「両側に」張る

NetworkPolicy は その namespace に入ってくる通信(Ingress)と出ていく通信(Egress) を、その namespace のポリシーが制御します。tenant-a に default-deny を張っても、tenant-b には何の効果もありません。したがって次のようになります。

  • tenant-b → tenant-a の通信は 止まる(tenant-a の Ingress ポリシーが効く)
  • tenant-a → tenant-b の通信は 止まらない(tenant-b にポリシーが無い)

片側だけに境界を張ると、片方向しか止まりません。 文字で読むと当たり前に見えますが、テナントを 1 つずつ増やしていく運用では取りこぼしやすい性質です。演習①のステップ5 で、2 つの Pod の間で実測します。

この非対称性は Egress を書けば埋まりますが、Egress の設計は Ingress より難しくなります。 Ingress は「誰から受けるか」を列挙すればよいのに対し、Egress は「どこへ出てよいか」を列挙する必要があり、DNS(kube-dns への 53/UDP)や外部 API のアドレス範囲まで含めて洗い出すことになります。第3回fanclub に 9 本の NetworkPolicy を書いたとき、本数が増えた理由もここにあります。

実務での落としどころは 2 つあります。全テナントに同じ default-deny を配る(両側に張る運用をテンプレートで強制する)。②Egress も含めた default-deny を先に張り、必要な通信だけを足していく。どちらも「テナントが増えたときに自動で境界が付く」ことを狙っています。片側ずつ手で張る運用は、テナントが 3 つを超えたあたりから破綻します。

「ソフト / ハード」は二者択一ではない —— 公式はスペクトラムと書いている

多くの記事は「ソフトマルチテナンシー」と「ハードマルチテナンシー」を 2 つの箱として説明します。公式ドキュメントの記述は違います。

The level of isolation offered is sometimes described using terms like “hard” multi-tenancy, which implies strong isolation, and “soft” multi-tenancy, which implies weaker isolation. In particular, “hard” multi-tenancy is often used to describe cases where the tenants do not trust each other, often from security and resource sharing perspectives (e.g. guarding against attacks such as data exfiltration or DoS).

However, the terms “hard” and “soft” can often be confusing, as there is no single definition that will apply to all users. Rather, “hardness” or “softness” is better understood as a broad spectrum, with many different techniques that can be used to maintain different types of isolation in your clusters.

(提供される分離の水準は、強い分離を意味する「ハード」マルチテナンシー、弱い分離を意味する「ソフト」マルチテナンシーといった語で説明されることがある。とくに「ハード」は、テナント同士が互いを信頼していない状況——データ持ち出しや DoS といった攻撃を防ぐことを想定する状況——を指して使われることが多い。しかし「ハード」「ソフト」という語は、すべての利用者に当てはまる単一の定義が無いため、しばしば混乱を招く。むしろ「ハードさ」「ソフトさ」は幅のある連続体として理解するほうがよく、そこにはクラスタの中で異なる種類の分離を保つための多様な技法が含まれる)

要点は 2 つあります。

  1. 「ハード」が指すのは、テナント同士が互いを信頼していない状況である。データ持ち出しや DoS といった攻撃を想定するかどうかが分かれ目になる
  2. 「ハード / ソフト」に単一の定義は無く、幅のある連続体(a broad spectrum)として理解するほうがよい

本書ではこの立場を採り、「どこまで強くするか」を段階として並べる表を置きます。

段階想定するテナント加える部品本書での扱い
1同じチームの別ワークロードNamespace のみ第1巻・第2巻
2社内の別チーム(半信頼)+ RBAC / NetworkPolicy / Quota本回の演習①
3非信頼のコードを動かす(顧客のジョブ・外部入力を処理する)+ PSS / seccomp / SELinux第6回第8回
4上に加えてカーネル脆弱性経由の脱出も想定する+ サンドボックス(gVisor / Kata)本回の演習②〜④
5同じ control plane を共有すること自体が許容できないクラスタを分ける / 仮想 control plane本書では扱いません(言及のみ)

段階が上がるほど部品が積み増されていく点に注目してください。段階 2 は段階 1 に 3 部品を足したもの、段階 3 は段階 2 にカーネル層の拘束を足したもの、段階 4 は段階 3 にサンドボックスを足したものです。置き換えではなく積み増しです。 「gVisor を入れたから NetworkPolicy はいらない」という判断は、この表のどの段にも現れません。

どの段階を選ぶかは、テナントの信頼度で決めます。 社内の別チームであれば段階 2 で足りることが多く、顧客が投入したコードやジョブを動かすなら段階 3 以上、カーネル脆弱性経由の脱出まで想定するなら段階 4 になります。「全部やる」が正解ではありません。 段階 4 には本回の後半で数える代償があり、段階 5 にはクラスタを増やす運用コストがあります。想定する脅威に対して過不足のない段を選ぶのが設計です。

段階 5 について 1 段落だけ補足します。公式ドキュメントは 「virtual control plane per tenant」(テナントごとに仮想の control plane を持たせる)という選択肢も挙げています。本書ラボは 1 クラスタ構成なので実演しませんが、境界を強くしていった先には、そもそもクラスタを分けるという解があることは知っておいてください。CKS の設問はクラスタ内の設定を問うので試験範囲ではありませんが、設計判断としては最後の選択肢になります。

段階 4 が本回の後半である理由

段階 3 まででは、コンテナはホストカーネルを共有したままです。seccomp は「呼べる syscall を絞る」、SELinux は「触れるファイル・プロセスを絞る」ものであり、syscall そのものはホストカーネルが実行しています。したがって カーネル自体に脆弱性があった場合、絞られた範囲の syscall からでも脱出しうるということになります。

段階 4 は、そこに手を入れます。syscall を実行する主体をホストカーネルから引き剥がすのが gVisor です。ただしその前に、段階 2 までを手で組み立てておきます。 次の節は やってみよう① で、テナント境界の 4 部品を実際に張って越境が止まることを確かめます。gVisor を扱う後半は、その次の節から始まります。

テナント境界を作る 4 部品と境界の非対称性。tenant-a には Namespace・RBAC・NetworkPolicy・ResourceQuota + LimitRange の 4 部品が揃い、tenant-b には 1 つも無い。tenant-b から tenant-a への通信は default-deny で止まるが、tenant-a から tenant-b への通信はポリシーが無いため通る。下端にソフトからハードまでの 5 段階軸を置き、段階 2 が演習①、段階 4 が演習②〜④に対応することを示している
図1:テナント境界を作る 4 部品と、境界の非対称性

やってみよう①:テナント namespace を 2 つ作り、越境が止まることを確かめる

本回の 4 つの演習には、所要時間と「試験相当の粒度はどこか」を併記しています。

LF は出題数・各問の配点・試験クラスタのノード構成を公表していません。本書が書く「試験ではここが 1 問(何分)」は試験時間 120 分から逆算した本書独自の目安であり、公式情報ではありません。数字そのものではなく、ラボで手を動かす範囲と試験で問われる範囲の差を読み取ってください。

所要 30 分(うち試験相当の粒度は ステップ2〜4 の 10 分)。

公式コンピテンシー Understand and implement isolation techniques (multi-tenancy) に照らすと、試験では「指定した namespace に default-deny の NetworkPolicy と ResourceQuota を作れ」「この SA が他 namespace の Secret を読めないことを示せ」といった形で問われます。ステップ5〜7 は本番作法(境界が片側にしか無いことの確認と、Quota 超過の見え方)です。

本演習は fanclub に一切触れません。 最後に namespace ごと削除します。

対象は k8s-ops 上の kubectl(developer)のみです。ノードには触れません。

先に試験の時短技を押さえておきます。

本回は YAML を手書きする量が最も多い回です。 テナントの 4 部品・RuntimeClass 3 種・プローブ Pod 4 種を書きます。試験では手書きの時間がそのまま点数になるので、骨組みを kubectl に作らせる形を先に押さえます。

alias k=kubectl は第4回で導入済みで、試験環境にも既定で設定されています。 本書の本文では過去回との表記を揃えるため kubectl とフルスペルで書きますが、試験では k で打ってください。

--dry-run=client -o yaml は「API Server へ送らずに、送るはずだった YAML を表示する」オプションです。リダイレクトでファイルに落として編集するのが定石になります。

ステップ1:2 つのテナント namespace を作る

tenant-a と tenant-b という 2 つの使い捨てテナントを作ります。 tenant-a には 4 部品を全部揃え、tenant-b には namespace 以外を何も置きません。この非対称が、ステップ5 で「片方向しか止まらない」を作り出します。

tenant-ns.yaml(全量):

apiVersion: v1
kind: Namespace
metadata:
  name: tenant-a
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest
---
apiVersion: v1
kind: Namespace
metadata:
  name: tenant-b
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f tenant-ns.yaml

実行結果:

namespace/tenant-a created
namespace/tenant-b created

PSA ラベル 6 本は第8回で作った形をそのまま使います。 enforce だけでなく warnaudit も付けるのは、弾かれる前に警告で気づけるようにするためです。両方のテナントに同じ強さを掛けるのが出発点で、片方だけ緩めたくなったときに初めて例外の議論が始まります。

ステップ2:tenant-a にだけ 4 部品を揃える

tenant-b には何も張りません。 これが本演習の仕掛けです。

4 部品のうち 3 つは kubectl create で骨組みを起こせます。 まずその形を確認します。

実行コマンド(k8s-ops 上・developer):

$ kubectl create quota tenant-a-quota -n tenant-a --hard=pods=4,requests.cpu=500m,requests.memory=512Mi,limits.cpu=1,limits.memory=1Gi,persistentvolumeclaims=0 --dry-run=client -o yaml

実行結果:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-a-quota
  namespace: tenant-a
spec:
  hard:
    limits.cpu: "1"
    limits.memory: 1Gi
    persistentvolumeclaims: "0"
    pods: "4"
    requests.cpu: 500m
    requests.memory: 512Mi
status: {}

--hard= に並べた順序と、出力の並びが違う点に気づいてください。 --dry-run=client -o yaml の出力はキーがアルファベット順に並び替えられます(入力順ではありません)。status: {} という空の行が付くのも同じ理由で、API へ送るはずだったオブジェクトの完全な形が表示されているためです。

実行コマンド(k8s-ops 上・developer):

$ kubectl create serviceaccount tenant-a-sa -n tenant-a --dry-run=client -o yaml

実行結果:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: tenant-a-sa
  namespace: tenant-a

ServiceAccount は apiVersion / kind / metadata.name / metadata.namespace の 4 項目だけです。手書きしても差はありませんが、綴りを間違えないという一点で kubectl create のほうが速くなります。

実行コマンド(k8s-ops 上・developer):

$ kubectl create role tenant-a-editor -n tenant-a --verb=get,list,watch,create,update,patch,delete --resource=pods,services,configmaps,secrets --dry-run=client -o yaml

出力の rules には、apiGroups: [""]resourcespods / services / configmaps / secrets)・verbsget / list / watch / create / update / patch / delete)が並びます。 下に掲載する tenant-a-rbac.yamlRole 部分と同じ内容です。

実行コマンド(k8s-ops 上・developer):

$ kubectl create rolebinding tenant-a-editor-binding -n tenant-a --role=tenant-a-editor --serviceaccount=tenant-a:tenant-a-sa --dry-run=client -o yaml

出力には roleRefsubjects の 2 つが並びます。 --serviceaccount=namespace:name の形で渡した値が、subjectsnamespacename に分解されて入ります。

NetworkPolicyLimitRange には kubectl create のサブコマンドがありません。 この 2 つは手書きです。 どのリソースが起こせてどれが起こせないかを覚えておくと、試験で「起こせないリソースを探して時間を溶かす」事故を避けられます。

骨組みの形を確認したら、以下の 3 ファイルを書いて適用します。

tenant-a-rbac.yaml(全量):

apiVersion: v1
kind: ServiceAccount
metadata:
  name: tenant-a-sa
  namespace: tenant-a
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: tenant-a-editor
  namespace: tenant-a
rules:
  - apiGroups: [""]
    resources: ["pods", "services", "configmaps", "secrets"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: tenant-a-editor-binding
  namespace: tenant-a
subjects:
  - kind: ServiceAccount
    name: tenant-a-sa
    namespace: tenant-a
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: tenant-a-editor

tenant-a-netpol.yaml(全量):

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: tenant-a-default-deny
  namespace: tenant-a
spec:
  podSelector: {}
  policyTypes:
    - Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: tenant-a-allow-same-namespace
  namespace: tenant-a
spec:
  podSelector: {}
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector: {}

tenant-a-quota.yaml(全量):

apiVersion: v1
kind: ResourceQuota
metadata:
  name: tenant-a-quota
  namespace: tenant-a
spec:
  hard:
    pods: "4"
    requests.cpu: "500m"
    requests.memory: 512Mi
    limits.cpu: "1"
    limits.memory: 1Gi
    persistentvolumeclaims: "0"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: tenant-a-limits
  namespace: tenant-a
spec:
  limits:
    - type: Container
      default:
        cpu: 100m
        memory: 128Mi
      defaultRequest:
        cpu: 50m
        memory: 64Mi

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f tenant-a-rbac.yaml -f tenant-a-netpol.yaml -f tenant-a-quota.yaml

実行結果:

serviceaccount/tenant-a-sa created
role.rbac.authorization.k8s.io/tenant-a-editor created
rolebinding.rbac.authorization.k8s.io/tenant-a-editor-binding created
networkpolicy.networking.k8s.io/tenant-a-default-deny created
networkpolicy.networking.k8s.io/tenant-a-allow-same-namespace created
resourcequota/tenant-a-quota created
limitrange/tenant-a-limits created

7 行で 4 部品がすべて揃いました。 これが「テナントを 1 つ作る」の実体です。

podSelector: {} は「この namespace にあるすべての Pod」を指します第3回)。tenant-a-default-deny は Ingress を全部落とし、tenant-a-allow-same-namespacefrom: podSelector: {}namespaceSelector を書かない形)が「同じ namespace の Pod から」だけを許可します。 この 2 本で「同じテナントの中は通る・外からは通らない」が成立します。

persistentvolumeclaims: "0" を入れているのは、テナントに Longhorn のボリュームを取らせないためです。本書ラボの Longhorn は fanclub が 4 ボリュームを使っており、演習でここを増やすと後片付けが重くなります。

ステップ3:RBAC の越境を確かめる

kubectl auth can-i --as= は、他人の権限を自分の手元で評価させるコマンドです第4回)。実際にその ServiceAccount のトークンを取り出す必要はありません。API Server が「この主体がこの操作をできるか」を判定して yes / no だけを返します。 テナント境界のレビューで最初に打つコマンドがこれです。

実行コマンド(k8s-ops 上・developer):

$ kubectl auth can-i get secrets -n tenant-a --as=system:serviceaccount:tenant-a:tenant-a-sa

実行結果:

yes

実行コマンド(k8s-ops 上・developer):

$ kubectl auth can-i get secrets -n tenant-b --as=system:serviceaccount:tenant-a:tenant-a-sa

実行結果:

no

実行コマンド(k8s-ops 上・developer):

$ kubectl auth can-i --list -n tenant-b --as=system:serviceaccount:tenant-a:tenant-a-sa

実測では、tenant-b で許されている操作は次の 2 種類だけでした。 1 つは selfsubjectreviews.authentication.k8s.io / selfsubjectaccessreviews.authorization.k8s.io / selfsubjectrulesreviews.authorization.k8s.io[create]誰でも「自分に何ができるか」は問い合わせられるため、この 3 つは全主体に開いています)。もう 1 つは /healthz / /livez / /readyz / /version / /api / /apis / /openapi / /openid/v1/jwks / /.well-known/openid-configuration といった非リソース URL の [get] です。

アプリのリソース(pods / services / configmaps / secrets)は 1 行も出ません。 RoleRoleBindingtenant-a にしか作っていないので、tenant-b では「誰でも許される最小限」しか残らないのです。これが「境界が引けている」ことの証拠になります。

RoleRoleBinding は namespace リソースなので、tenant-a に作ったものは tenant-b には効きません。 これが RBAC で境界が引ける理由です。第4回で扱った 「ClusterRole を RoleBinding で namespace 限定する」手法も同じ原理で、権限の中身は共通化しつつ効く範囲だけを絞れます。

ステップ4:両テナントに Pod を 1 つずつ置く

通信を試すには、両側に受け口が要ります。 busybox の httpd を 8080 番で立て、互いに wget で叩きます。どちらの namespace も PSA が enforce=restricted なので、securityContext を省くと Pod の作成そのものが弾かれます。 ここで書く 7 項目は、第8回で覚えた形に runAsGroup を 1 つ足しただけです。

tenant-probes.yaml(全量):

apiVersion: v1
kind: Pod
metadata:
  name: probe-a
  namespace: tenant-a
  labels:
    app: probe
spec:
  serviceAccountName: tenant-a-sa
  containers:
    - name: probe
      image: busybox:1.37
      command: ["httpd", "-f", "-p", "8080", "-h", "/www"]
      ports:
        - containerPort: 8080
      volumeMounts:
        - name: www
          mountPath: /www
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop:
            - ALL
        seccompProfile:
          type: RuntimeDefault
  volumes:
    - name: www
      emptyDir: {}
---
apiVersion: v1
kind: Pod
metadata:
  name: probe-b
  namespace: tenant-b
  labels:
    app: probe
spec:
  containers:
    - name: probe
      image: busybox:1.37
      command: ["httpd", "-f", "-p", "8080", "-h", "/www"]
      ports:
        - containerPort: 8080
      volumeMounts:
        - name: www
          mountPath: /www
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop:
            - ALL
        seccompProfile:
          type: RuntimeDefault
  volumes:
    - name: www
      emptyDir: {}

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f tenant-probes.yaml

実行結果:

pod/probe-a created
pod/probe-b created

securityContext の 7 項目は PSS restricted を満たすための最小セットです第8回)。readOnlyRootFilesystem: true にしているので、httpd のドキュメントルートは emptyDir でマウントした /www を使います。 中身は空なので wget404 を返しますが、404 が返るということは TCP 接続が成立しているということです。本演習では「404 が返る = 通った」「タイムアウト = 止まった」と読みます。

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod -n tenant-a -o wide

IP 列に出た値を控えてください。 実測では probe-a10.244.119.137、NODE は k8s-wl-02 でした。Pod IP は毎回変わるので、以降のコマンドでは読者の環境の値に読み替えてください。

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod -n tenant-b -o wide

実測では probe-b10.244.119.167、NODE は k8s-wl-02 でした。

実測では 2 つとも同じノード(k8s-wl-02)に載りました。

スケジューラの都合でそうなっただけで、読者の環境では別々のノードに分かれることもあります。どちらでも構いません。NetworkPolicy は「同じノードかどうか」とは無関係に効きます。 判定するのは CNI(本書ラボは Calico)であって、経路がノード内で完結するか物理ネットワークを渡るかは結果に影響しません。次のステップの結果が変わらないことを、この行で確認しておいてください。

ステップ5:境界が片側にしか無いことを実測する

実行コマンド(k8s-ops 上・developer・<probe-a の Pod IP> は前ステップの値に置き換える):

$ kubectl exec -n tenant-b probe-b -- wget -T 5 -q -O- http://<probe-a の Pod IP>:8080/

実行結果:

wget: download timed out
command terminated with exit code 1

実行コマンド(k8s-ops 上・developer・<probe-b の Pod IP> は前ステップの値に置き換える):

$ kubectl exec -n tenant-a probe-a -- wget -T 5 -q -O- http://<probe-b の Pod IP>:8080/

実行結果:

wget: server returned error: HTTP/1.1 404 Not Found
command terminated with exit code 1

ここで読み方に注意が要ります。 2 つの実行は、どちらも終了コードが 1 です。 止まったほうも通ったほうも command terminated with exit code 1 で終わるため、終了コードでは区別できません。 区別するのはメッセージのほうで、download timed out なら届いていない404 Not Found なら届いたうえで httpd が応答を返したということになります。スクリプトで自動判定するときも、終了コードではなく標準エラーの文面を見る必要があります。

ここが本演習の核心です。

同じ 2 つのテナントの間で、通信が片方向だけ止まっています。 tenant-a には default-deny を張ったので入ってくる通信は止まりますが、出ていく通信は何も制限していません。tenant-b にはポリシーそのものが無いので、tenant-a から tenant-b へは素通りします。

テナント境界は「自分の namespace を守る」設定であって、「相手の namespace へ行かない」設定ではありません。 両方向を止めたければ、両方の namespace にポリシーを張るか、Egress 側も明示的に絞る必要があります。テナントを増やすたびに 4 部品を揃える運用(テンプレート化)が要るのは、この非対称性のためです。

実務では、テナントを 1 つ作るたびに 4 部品を並べた 1 枚のマニフェスト(あるいは Helm chart / Kustomize base)を適用する形にします。「新しいチームに namespace を切ってあげた」だけで止まると、境界の無いテナントが 1 つ増えることになります。

ステップ6:Quota の越境を確かめる

次は「1 テナントの暴走が他テナントへ波及しないか」を試します。 replicas: 10 の Deployment を tenant-a に置き、Quota の上限で止まることを確かめます。止まり方の見え方が本ステップの主題で、kubectl apply は成功したまま Pod だけが増えないという形になります。

tenant-a-bomb.yaml(全量):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: quota-bomb
  namespace: tenant-a
spec:
  replicas: 10
  selector:
    matchLabels:
      app: quota-bomb
  template:
    metadata:
      labels:
        app: quota-bomb
    spec:
      containers:
        - name: bomb
          image: busybox:1.37
          command: ["sleep", "3600"]
          resources:
            requests:
              cpu: 200m
              memory: 128Mi
            limits:
              cpu: 200m
              memory: 128Mi
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            runAsGroup: 1000
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL
            seccompProfile:
              type: RuntimeDefault

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f tenant-a-bomb.yaml

実行結果:

deployment.apps/quota-bomb created

実行コマンド(k8s-ops 上・developer):

$ kubectl get deployment,replicaset,pod -n tenant-a

実行結果:

NAME                         READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/quota-bomb   2/10    2            2           25s

NAME                                    DESIRED   CURRENT   READY   AGE
replicaset.apps/quota-bomb-8497b59dc9   10        2         2       25s

NAME                              READY   STATUS    RESTARTS   AGE
pod/probe-a                       1/1     Running   0          87s
pod/quota-bomb-8497b59dc9-58t6j   1/1     Running   0          25s
pod/quota-bomb-8497b59dc9-vgxbx   1/1     Running   0          25s

ReplicaSet の DESIRED は 10 のまま、CURRENT は 2 で止まっています。 Deployment は 2/10、Pod は probe-a を含めて 3 個です。

作れる個数は、適用する前に計算できます。Quota の各項目について「Hard − 既に使っている分」を求め、1 個あたりの消費で割ります。

項目Hardprobe-a の消費残りquota-bomb 1 個あたり作れる個数
pods41313
requests.cpu500m50m(LimitRange の既定)450m200m2(400m。3 個目は 600m で超過)
requests.memory512Mi64Mi(LimitRange の既定)448Mi128Mi3
limits.cpu1(1000m)100m(LimitRange の既定)900m200m4
limits.memory1Gi(1024Mi)128Mi(LimitRange の既定)896Mi128Mi7

律速は requests.cpu で、作られるのは 2 個です。Deployment は 2/10 で止まります。

実行コマンド(k8s-ops 上・developer):

$ kubectl describe replicaset -n tenant-a -l app=quota-bomb

実行結果(Conditions と Events の該当部分):

  ReplicaFailure   True    FailedCreate
Warning  FailedCreate  25s  replicaset-controller  Error creating: pods "quota-bomb-8497b59dc9-8d2p8" is forbidden: exceeded quota: tenant-a-quota, requested: requests.cpu=200m, used: requests.cpu=450m, limited: requests.cpu=500m

requested / used / limited の 3 つが並ぶのが、Quota 超過メッセージの型です。 200m を足そうとしたが、既に 450m 使っていて上限が 500m なので拒否された、と読みます。同じ内容の Events が連続すると、最後は (combined from similar events) にまとめられます。

実行コマンド(k8s-ops 上・developer):

$ kubectl describe resourcequota tenant-a-quota -n tenant-a

実行結果(末尾の対比部分):

Name:                   tenant-a-quota
Namespace:              tenant-a
Resource                Used   Hard
--------                ----   ----
limits.cpu              500m   1
limits.memory           384Mi  1Gi
persistentvolumeclaims  0      0
pods                    3      4
requests.cpu            450m   500m
requests.memory         320Mi  512Mi

検算の表と一致しています。 pods は 3/4 で 1 個ぶん空いているのに、requests.cpu だけが 450m/500m で頭打ちになっています。

pods: "4" に余裕があるのに、2 個で止まっています。

ResourceQuota は書いたすべての項目を同時に見るので、最初に上限へ当たった項目が実際の上限になります。しかも quota-bomb の隣で動いている probe-aresources を 1 行も書いていないのに、tenant-a-limits(LimitRange)の既定値が入って 50m を消費しています。

LimitRange が無いと、requests.cpu を含む Quota がある namespace では「resources を書いていない Pod」がそもそも作れません。 Quota と LimitRange をセットで張るのは、この組み合わせのためです。「Quota を張ったら Pod が作れなくなった」という相談の大半は、LimitRange が無いことが原因です。

kubectl apply は成功しています。 kubectl get deployment を見ても「Pod が増えない」ことしか分かりません。理由は ReplicaSet の Events にしか書かれていません。 第8回の Gatekeeper・第9回の SealedSecrets と同じ構造で、「apply が通ることと、意図どおり動くことは別」という型が 3 回目の登場になります。本回はさらに ContainerCreating という 4 例目が後半に出てきます。

ステップ7:後片付け

実行コマンド(k8s-ops 上・developer):

$ kubectl delete namespace tenant-a tenant-b

実行結果:

namespace "tenant-a" deleted
namespace "tenant-b" deleted

実行コマンド(k8s-ops 上・developer):

$ kubectl get namespace

実測では 14 個の namespace が残りました。 argocd / cert-manager / default / fanclub / gatekeeper-system / kube-node-lease / kube-public / kube-system / local-path-storage / longhorn-system / metallb-system / monitoring / traefik / velero の 14 個で、tenant-atenant-b は消えています。

削除には数十秒かかることがあります。 namespace の削除は「中のリソースを全部消してから namespace 自体を消す」という順序で進むため、Pod の終了を待つぶんだけ時間がかかります。 Terminating のまま止まって見えても、Pod の terminationGracePeriodSeconds が経過するまでは正常な待ち時間です。

namespace を消すと、その中のリソースはすべて消えます。 Pod・Role・RoleBinding・NetworkPolicy・ResourceQuota・LimitRange・Deployment・ReplicaSet が 1 コマンドで片付きます。使い捨ての演習を namespace 単位で切るのは、後片付けがこの 1 行で終わるからです。

この演習の合格条件

  • probe-bprobe-a がタイムアウトし、probe-aprobe-b が 404 を返すこと(同じ 2 テナント間で片方向だけ止まっている状態を自分の目で確認できていること)
  • exceeded quota が ReplicaSet の Events にだけ出ることを確認できていること
  • kubectl get namespacetenant-a / tenant-b が残っていないこと

コンテナはホストカーネルを共有している —— サンドボックスが要る理由

前半で組んだ 4 部品は、すべて Kubernetes の中で完結する防御です。RBAC は API Server が判定し、NetworkPolicy は CNI が実装し、Quota は API Server が数えます。どれもホストカーネルの外側の話です。

一方で、コンテナの実体は ホストの上で動く普通のプロセスです。namespace(Linux の名前空間)と cgroup で見える範囲と使える量を絞っているだけで、syscall を実行しているのはホストカーネル本体です。

ここで言う namespace は、Kubernetes の Namespace ではなく Linux カーネルの名前空間です。PID・ネットワーク・マウント・UTS・IPC・ユーザーといった種類があり、それぞれ「自分から見える範囲」を切り分けます。cgroup は CPU やメモリの使用量に上限を掛けます。どちらも「見える範囲」と「使える量」の話であって、「誰が syscall を実行するか」の話ではありません。 同じ名前の語が 2 つの層に出てくるので、混同しないでください。

この性質は、1 つの実験で確かめられます。

実行コマンド(k8s-ops 上・developer):

$ kubectl run kernel-check -n default --image=busybox:1.37 --restart=Never --rm -it -- uname -r

実行結果:

warning: couldn't attach to pod/kernel-check, falling back to streaming logs: unable to upgrade connection: container kernel-check not found in pod kernel-check_default
6.12.0-211.22.1.el10_2.x86_64
pod "kernel-check" deleted from default namespace

先頭の warning: couldn't attach to pod/... は異常ではありません。 uname -r は一瞬で終わるため、-it が端末を接続しにいく前にコンテナが終了してしまい、kubectlログの読み出しへ切り替えたという通知です。本回では --rm -it を使う確認のたびにこの 1 行が出ます。 結果の値は正しく取れているので、そのまま読み進めてください。

返ってきた 6.12.0-211.22.1.el10_2.x86_64 は、ノードで uname -r を打ったときの値そのものです。

--rm -it を付けると、出力を表示してから Pod を自動で削除します。 使い捨ての確認によく使う形です。--rm-it も無いと pod/kernel-check created の 1 行しか返らず、uname -r の結果を見るには kubectl logs を別に打つことになります。 そのうえ Pod が default に残ります。namespace を -n default で明示しているのは、直前の演習で -n tenant-a を打ち続けた流れで取り違えないためです。

コンテナの中で uname -r を打つと、ホストのカーネルバージョンが返ります。 仮想マシンなら自分のカーネルが返りますが、コンテナは自分のカーネルを持っていません。 同じノードで動いている 51 個の Pod は、全員が同じ 1 つのカーネルを共有しています。

共有しているものは、共有しているぶんだけ攻撃面になる

第6回で入れた seccomp と SELinux は、この共有を前提にしたまま「呼べる syscall」「触れるオブジェクト」を絞る防御です。効果はありますが、syscall を処理するコードがホストカーネルであること自体は変わりません。

Linux カーネルの syscall インタフェースは 300 以上あり、そのどれかに権限昇格の脆弱性が見つかれば、絞られた範囲の中からでも脱出が成立しうることになります。過去に報告されたコンテナ脱出(container escape)の多くは、この経路をたどっています。

サンドボックス(sandboxed container)は、この前提そのものに手を入れます。 コンテナとホストカーネルの間にもう 1 枚の層を挟む方式で、コンテナが通常共有しているホストカーネルを、共有しない形に置き換えます。

方式syscall を処理するのはホストカーネルとの距離
通常のコンテナ(runc)ホストカーネル直接
gVisor(runsc)Sentry(ユーザー空間で動く自前のカーネル)Sentry が肩代わりし、ホストへは最小限の syscall だけが届く
Kata Containers軽量 VM の中のゲストカーネルハイパーバイザー(KVM)で分離

gVisor は Google が開発したサンドボックスランタイムで、runsc がその実行バイナリ名です(”run sandboxed container” の略)。本回の後半で扱うのはこちらです。

3 行の表を、攻撃者の視点で読み直してみてください。 通常のコンテナで脱出を狙う攻撃者は、ホストカーネルの脆弱性を 1 つ見つければよいことになります。gVisor 上では、まず Sentry の脆弱性を見つけ、そのうえで Sentry がホストへ投げられる限られた syscall の中に脱出経路があることまで確かめる必要があります。Kata では ゲストカーネルとハイパーバイザーの両方を抜ける必要があります。攻撃に必要な段数が増えることが、サンドボックスの効果です。

「サンドボックスを入れれば安全」ではない

サンドボックスは前半の 4 部品を置き換えるものではありません。 RBAC が緩ければ、gVisor 上の Pod からでも他テナントの Secret は読めます(API 経由なので syscall 境界は関係ありません)。NetworkPolicy が無ければ、gVisor 上の Pod からでも他テナントへ通信できます。

サンドボックスが減らすのは「ホストカーネルへの直接の攻撃面」だけです。前節の表の段階 4 が、段階 2・3 に積み増す形になっているのはこのためで、置き換えではありません。

gVisor の構造 —— Sentry と Gofer

この節の内容は試験中に参照できません。

CKS の Resources Allowed は 8 件で、gvisor.dev は含まれていません第19回の重点復習項目です)。RuntimeClass の書式は kubernetes.io/docs で引けます。 containerd への登録記法は一部だけ引けます —— kubernetes.io/docs の RuntimeClass ページに載っているのは containerd 1.x 系のテーブル名 1 行だけで、runtime_type に何を書くかは載っていません。gVisor 側の仕組み・runsc の導入手順・containerd 2.x の記法・runtime_type の値は、いずれも暗記が要ります。 どこまで引けてどこからが暗記なのかは、本回の末尾の暗記表で整理します。

gVisor 公式ドキュメントは、gVisor を 「ユーザー空間で動くアプリケーションカーネル(gVisor acts as an application kernel, but runs in userspace)」 と説明しています。

Sentry —— ユーザー空間のカーネル

Sentry は gVisor の中核で、ユーザー空間で動くアプリケーションカーネルです。コンテナの syscall を自前で処理します。公式の Architecture Guide から要点を 3 つ引きます。

  1. Sentry は 「サンドボックス内のワークロードからの system call と page fault を捕まえて処理する(intercepts and handles system calls and page faults from the sandboxed workload)」
  2. 「gVisor は system call をホストへ素通しすることが決して無い(gVisor never passes through any system call to the host)」
  3. Sentry は 「Linux の system call インタフェース、メモリ管理、ファイルシステム、ネットワークスタック、プロセス管理、シグナル処理、名前空間などを、Go でゼロから再実装したものを含む」

ホストへ syscall を投げることはあります。 ただしそれは Sentry 自身の判断で、しかも極めて限られた種類に絞られています。公式のセキュリティモデルは、Sentry がホストに対してできることを次の 3 つに限定していると書いています。

  • Gofer プロセスとの通信の確立
  • 最小限のホスト system call新しいソケットの作成やファイルのオープンは含まれない。含まれるのはファイルディスクリプタの複製とクローズ、同期、タイマー、シグナル管理
  • 仮想イーサネットデバイスへのパケットの読み書き

Gofer —— ファイルアクセスの代理

上の 2 番目に 「ファイルのオープンは含まれない」とあります。では Sentry の中のアプリケーションがファイルを開きたくなったらどうするのか。 そこで出てくるのが Gofer です。Sentry から分離されたファイルアクセスの代理プロセスで、Sentry 自身にはファイルを開く権限を与えない構造になっています。

公式は Gofer を 「わずかに信頼度の高い、わずかに高い権限のコンテキストで動く伴走プロセス(a slightly-more-trusted companion process running in a slightly-more-privileged context)」 と説明しています。

ファイル I/O だけを別プロセスへ切り出していることが gVisor の設計の要点です。Sentry が乗っ取られても、そこからホストのファイルを直接開くことはできません。開けるのは Gofer だけで、Gofer には syscall の処理をさせません。

これは権限分離(privilege separation)と呼ばれる古典的な設計です。 「大きな権限を持つ 1 つのプロセス」を作らず、権限の種類ごとにプロセスを分けて、それぞれに最小限だけ与える。片方が破られても、もう片方の権限までは連鎖しません。第7回で扱った「ホスト OS の権限を最小にする」考え方が、サンドボックスの内部構造にも同じ形で現れています。

やってみよう③ のステップ7 では、ノード側から ps を打ってこの 2 つのプロセスを実際に見ます。

プラットフォーム —— syscall をどうやって捕まえるか

プラットフォームは、Sentry が syscall を捕まえる方式のことです。3 つあります。

プラットフォーム捕まえ方要件本書ラボでの扱い
systrapseccomp の SIGSYS シグナルで捕まえる特になし。VM の中でも動く既定。本書ラボもこれで動いています(下記の根拠を参照)
ptracePTRACE_SYSEMU特になし非推奨。 syscall 集約型のアプリで性能上の不利があります
KVMカーネルの KVM 拡張/dev/kvm が要る本書ラボでは使えません(Hyper-V ゲストに /dev/kvm が無いため)

公式は 「systrap は 2023 年半ばに ptrace を置き換えて gVisor の既定プラットフォームになった」と書き、「VM の中で動かす場合や仮想化支援の無いマシンでは systrap のほうが良い選択である(The systrap platform is a better choice when running inside a VM, or on a machine without virtualization support)」としています。

本書ラボは Hyper-V のゲスト OS です。 ネストされた仮想化が露出していないため /dev/kvm がありません。gVisor は systrap で問題なく動きますが、同じ理由で Kata Containers は動きません(後の節で扱います)。

どのプラットフォームで動いているかは、バイナリ自身に聞けます。

実行コマンド(k8s-wl-01 上・root):

# runsc flags 2>&1 | grep -A 1 "platform string"

実行結果:

  -platform string
    	specifies which platform to use: systrap (default), ptrace, kvm. (default "systrap")

runsc flags の出力に (default "systrap") と書かれています。 ここを指定していない本書ラボは、この既定値のまま動いています。

ps でプロセスの引数を見ても --platform は出てきません(やってみよう③ のステップ7 で確認します)。既定値は引数として渡されないためです。「引数に無い = 設定されていない」ではなく「既定値が使われている」と読みます。設定されている値を確かめたいときは、プロセスの引数ではなくバイナリの既定値表示を見る——これは runsc に限らず使える読み方です。

なお、このコマンドは runsc を導入したあとでなければ打てません。導入は やってみよう② のステップ2 で行います。

gVisor が肩代わりするということは、gVisor が実装していないものは動かない

Sentry は Linux の syscall インタフェースを再実装したものであり、Linux そのものではありません。 公式は 「(gVisor には)未実装の機能とバグが存在するし、これからも存在し続ける(there are (and always will be) unimplemented features and bugs)」と明記し、動かすコンテナごとにテストして確かめることを勧めています。

公式が挙げている「動かないもの」の例です。

  • ブロックデバイスのファイルシステム(fat32 / ext3 / ext4)
  • Docker-in-gVisor の範囲を超える完全な iptables サポート
  • 独自のハードウェアデバイスファイル
  • サンドボックスの中からの KVM の利用

この一覧の並びには共通点があります。どれも「ホストのカーネルやハードウェアに直接触る」機能です。Sentry はホストの代わりに syscall を処理する立場なので、ホストの実装に強く依存する機能ほど再現が難しくなります。 逆に、ファイルの読み書き・ソケット通信・プロセス生成といった一般的な操作は再実装されており、通常のアプリケーションはそのまま動きます。

本書では fanclub-backend(JDK 25 + Payara Micro)で実際に確かめます(やってみよう④)。動くことは実測済みですが、「確かめてから本番へ入れる」という手順自体が本回の教材です。

通常コンテナと gVisor の syscall 経路の対比。通常コンテナは seccomp フィルタと SELinux の MCS ラベルを通ってホストカーネル本体が syscall を実行する。gVisor はユーザー空間の Sentry が syscall を肩代わりし、ファイル I/O は Gofer が代理して、ホストカーネルへ届くのは最小限になる。下端に uname -r・/sys/module の件数・ファイル作成 20,000 回の user と sys の実測値を並べている
図2:通常コンテナと gVisor の syscall 経路の違い

RuntimeClass の定義 —— handler / scheduling / overhead

Kubernetes は「どのランタイムで動かすか」を RuntimeClass で選びます。Pod をどのコンテナランタイムで動かすかを指定するためのリソースで、非 namespace リソース(クラスタ全体で 1 つ)です。ここは試験中に kubernetes.io/docs で引ける唯一の部分です。

公式の最小例:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: myclass
handler: myconfiguration

Pod 側:

apiVersion: v1
kind: Pod
metadata:
  name: mypod
spec:
  runtimeClassName: myclass

handler は CRI 側の設定名を指す

handler は「gVisor」という製品名ではなく、CRI(本書では containerd)の設定ファイルに書いたランタイム名です。containerd の config.toml に書いた runtimes.<名前><名前> と一致している必要があります。公式はこう書いています。

Runtime handlers are configured through containerd’s configuration at /etc/containerd/config.toml. Valid handlers are configured under the runtimes section:

[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.${HANDLER_NAME}]

(ランタイムハンドラは containerd の設定ファイル /etc/containerd/config.toml で設定する。有効なハンドラは runtimes セクションの下に設定される)

ここに本書ラボとの差があります。

上の記法は containerd 1.x(config version = 2)のものです。 本書ラボの containerd は v2.2.6 で、設定ファイルは version = 3 です。containerd 2.0 で CRI プラグインの識別子が io.containerd.grpc.v1.cri から io.containerd.cri.v1.runtime へ変わりました。 したがって本書で書くのは次の形になります。

[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runsc]

公式ドキュメントの記法をそのままコピーすると効きません。 試験環境の containerd がどちらの版かは開いてみないと分かりません。head -1 /etc/containerd/config.tomlversion を確認してから書く、という手順を身につけてください。

試験中に引けるのは 1.x のテーブル名だけです。 上に引用した io.containerd.grpc.v1.cri の形は kubernetes.io/docs の RuntimeClass ページに載っているので、試験中にその場で開けます。一方 2.x の io.containerd.cri.v1.runtime は、kubernetes.io にも gvisor.dev にも載っていません。 試験環境が containerd 2.x なら、参照できる記法をそのまま写しても効きません。 head -1 で確認する手順が要るのは、このためです。

そして、引けるのはテーブル名の 1 行だけです。 RuntimeClass ページに載っているのは [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.${HANDLER_NAME}] という見出しの行だけで、その下に何を書くか——runtime_type というキーも、io.containerd.runsc.v1 という値も、runsc という語すら——ページには出てきません。 テーブル名は引ける。中身は暗記。 この線引きが、本回でいちばん試験に効く知識です(第19回の重点復習)。

scheduling —— どのノードへ載せるか

runsc はノードに導入した分しか使えません。すべてのノードに入れないなら、Pod をそのノードへ寄せる必要があります。 RuntimeClass はその機構を持っています。

To ensure pods land on nodes supporting a specific RuntimeClass, that set of nodes should have a common label which is then selected by the runtimeclass.scheduling.nodeSelector field. The RuntimeClass’s nodeSelector is merged with the pod’s nodeSelector in admission, effectively taking the intersection of the set of nodes selected by each. If there is a conflict, the pod will be rejected.

If the supported nodes are tainted to prevent other RuntimeClass pods from running on the node, you can add tolerations to the RuntimeClass. As with the nodeSelector, the tolerations are merged with the pod’s tolerations in admission, effectively taking the union of the set of nodes tolerated by each.

(特定の RuntimeClass に対応したノードへ Pod を確実に載せるには、そのノード群に共通のラベルを付け、runtimeclass.scheduling.nodeSelector でそれを選ぶ。RuntimeClass の nodeSelector は admission の段階で Pod の nodeSelector とマージされ、両者が選ぶノード集合の積を取る。衝突があれば Pod は拒否される。 対応ノードに taint を打って他の Pod を排除している場合は、RuntimeClass に tolerations を足せる。tolerations も admission で Pod のものとマージされ、両者の和を取る

2 つの合成規則が非対称であることに注意してください。

フィールド合成の仕方衝突したとき
scheduling.nodeSelector積(intersection) —— RuntimeClass 側と Pod 側の両方を満たすノードだけが候補になるPod が拒否される
scheduling.tolerations和(union) —— 両方の toleration を合わせ持つ衝突という概念が無い

本書ラボで使う形です。

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
scheduling:
  nodeSelector:
    sandbox.gvisor.dev/runtime: "runsc"

ラベルのキーとノードへの付与を忘れないでください。 scheduling.nodeSelector は「このラベルを持つノードだけを候補にする」という指定であって、ノードにラベルを付ける操作ではありません。 RuntimeClass を作っただけでノードにラベルが無いと、候補ノードが 0 件になり、Pod は Pending のまま止まります(この場合は ContainerCreating ではなく Pending です。スケジューリングそのものが成立しないためです)。

scheduling.nodeSelector に書いた内容は、Pod の spec.nodeSelector へ実際に書き込まれます。 Pod のマニフェストに nodeSelector を 1 行も書いていなくても、作成後に kubectl get pod -o jsonpath='{.spec.nodeSelector}' を打つと {"sandbox.gvisor.dev/runtime":"runsc"} が入っています(実測)。Admission の段階で書き換えられているためです。やってみよう③ のステップ4 で確認します。

overhead —— サンドボックス自身が食う分を申告する

サンドボックスは、コンテナのプロセスとは別に Sentry と Gofer という常駐プロセスを持ちます。これらが使う CPU とメモリは、Pod の resources.requests には含まれていません。 何も申告しないと、スケジューラは「この Pod は 100m しか使わない」と信じてノードを詰め込みます。

overhead は、その差分を宣言するフィールドです(Kubernetes v1.24 で stable)。

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
overhead:
  podFixed:
    cpu: 100m
    memory: 120Mi
scheduling:
  nodeSelector:
    sandbox.gvisor.dev/runtime: "runsc"

overhead はスケジューラの計算にだけ効くフィールドです。 書いても書かなくても Sentry と Gofer の実際の消費は変わりません。変わるのは「スケジューラがそのノードにあと何個の Pod を載せられると考えるか」だけです。書かなければスケジューラは実態より楽観的な計算をし、ノードを詰め込みすぎます。ノードのメモリが枯渇して OOM が起きるのは、この楽観の帰結です。

本書ラボでは overhead を設定しません。 理由は 2 つあります。適正値はワークロードとノードの構成に依存し、本書ラボで測った値を一般化できないこと。そして overhead を入れると fanclub-quota の集計に加算され、既存の Quota(requests.cpu 1120m/4)との整合を取り直す必要が生じることです。フィールドの存在と役割は試験対象なので本文で説明し、ラボへは入れないという扱いにします。上の YAML は「書くとしたらこう」という形として掲載しています。

RuntimeClass が無いとき / handler が無いとき

公式はこう書いています。

If the named RuntimeClass does not exist, or the CRI cannot run the corresponding handler, the pod will enter the Failed terminal phase. Look for a corresponding event for an error message.

(指定した RuntimeClass が存在しないか、CRI が対応する handler を実行できない場合、Pod は Failed という終端フェーズに入る。エラーメッセージは対応するイベントを見よ)

実機の挙動は公式の記述と一致しませんでした。

本書ラボで handler が未登録のノードへ Pod を載せた場合、Pod は Failed にはならず ContainerCreating のまま留まり続けました。 kubelet が 1 秒間隔で再試行を続けるためです。Failed になるから気づける」と考えていると診断を誤ります。 公式が誤っているという話ではなく、版や環境によって現れ方が異なるということです。

kubectl get pod の STATUS 列は ContainerCreating としか言いません。 原因は kubectl describe pod の Events にしか出ません。 本回の失敗パターン 3 種(やってみよう② のステップ10)は、すべてこの形で現れます。

やってみよう②:runsc を導入し、gVisor Pod が起動しないことを実測する

所要 35 分(うち試験相当の粒度は ステップ7〜10 の 10 分)。

試験で runsc をノードへ導入させる設問は出ません(インターネット接続が前提になるため)。出るのは「RuntimeClass を作れ」「この Pod をサンドボックスで動かせ」「サンドボックスで動いていないのはなぜか調べろ」という形です。ステップ1〜6 は本番作法(ノードへのランタイム導入)で、CKS の設計思想を理解するために踏みます。

本演習は「失敗して終わる」演習です。 Pod は起動しません。それが本回の主題で、続きは やってみよう③ で扱います。 ②と③は続けて 75 分を確保してから始めてください。

対象k8s-wl-01(root・192.168.1.128)と k8s-ops(developer)です。

ステップ1:whitelist に追加が要らないことを確認する

実行コマンド(alma-proxy 上・root):

# grep -c . /etc/squid/whitelist.txt

実行結果:

42

実行コマンド(alma-proxy 上・root):

# grep storage.googleapis.com /etc/squid/whitelist.txt

実行結果:

storage.googleapis.com

本回は whitelist を 1 行も足しません。 gVisor のバイナリは storage.googleapis.com に置かれており、この行は第2回以降のラボで既に許可されています。gvisor.dev(ドキュメントサイト)は取得元ではないので不要です。 第9回bitnami.github.io を足して 42 行になった状態のまま、本回は変わりません。

ステップ2:runsc と shim を取得して検証する

実行コマンド(k8s-wl-01 上・root):

# cd /root

実行コマンド(k8s-wl-01 上・root):

# curl -fsSLO https://storage.googleapis.com/gvisor/releases/release/20260721/x86_64/runsc

実行コマンド(k8s-wl-01 上・root):

# curl -fsSLO https://storage.googleapis.com/gvisor/releases/release/20260721/x86_64/runsc.sha512

実行コマンド(k8s-wl-01 上・root):

# curl -fsSLO https://storage.googleapis.com/gvisor/releases/release/20260721/x86_64/containerd-shim-runsc-v1

実行コマンド(k8s-wl-01 上・root):

# curl -fsSLO https://storage.googleapis.com/gvisor/releases/release/20260721/x86_64/containerd-shim-runsc-v1.sha512

取得元は「常に最新」ではなく日付で固定しています。

gVisor の配布元は最新版を指す URL も提供していて、多くの記事がそちらを使っています。本書は 20260721 という日付ディレクトリで固定します。

ノードに root 権限で置くバイナリの版が、実行のたびに変わる状態は避けてください。 これは再現性の問題であると同時に、サプライチェーンの問題です。最新版として引いた版が動いたことは、次に引く版が動くことを何も保証しません。 本書が Helm chart やコンテナイメージでバージョンをピン留めしているのと同じ理由です(第13回・第14回で正面から扱います)。

URL のディレクトリ名(20260721)と、runsc --version が返す版(release-20260721.0)は表記が違います。 末尾の .0 が付くかどうかの差です。20260721.0 を使っても同じバイナリが取得できますが、本書は短いほうで統一します。

root で curl を打つときはプロキシ設定に注意してください。 /etc/profile.d/proxy.sh はログインシェルでしか読まれないため、sudo の非ログインシェルでは http_proxy / https_proxy が空になります。sudo -i でログインシェルに入るか、http_proxy / https_proxy を明示してください。 第6回第7回第9回でも同じ形が出ています。

実行コマンド(k8s-wl-01 上・root):

# ls -l runsc containerd-shim-runsc-v1

実測のサイズは runsc102993171 バイト、containerd-shim-runsc-v142770726 バイトでした。 日付でディレクトリを固定した URL から取っているので、このサイズは読者の環境でも同じ値になります。 違う値になったら、URL か取得経路(プロキシが別のものを返していないか)を疑ってください。

実行コマンド(k8s-wl-01 上・root):

# sha512sum -c runsc.sha512 containerd-shim-runsc-v1.sha512

実行結果:

runsc: 完了
containerd-shim-runsc-v1: 完了

日本語ロケールでは 完了、英語ロケールでは OK と表示されます。 .sha512 ファイルの中にはパス無しのファイル名だけが書かれているので、バイナリと同じディレクトリで打つ必要があります。 冒頭で cd /root しているのはそのためです。

参考までに、日付固定 URL の runsc.sha512 に書かれている値を示します。 本書の事前確認では、この値が最新版として取得した実バイナリの sha512 と完全に一致しました。

5de08aa2c048f85289084a6c65648a49dea510da54677a196f223fbf08499c36741da79976b51e0e2b61063bde01ef2a709c7ced95b6d829d9a3c0f3c68b78ab  runsc

sha512sum -c は第2回で扱ったバイナリ検証の再登場です。 第2回では Kubernetes の公式バイナリを SHA256 で照合しました。「クラスタのノードで root として実行するバイナリをインターネットから取ってくる」という行為は、それ自体がサプライチェーンの問題です(第13回・第14回で正面から扱います)。チェックサムの照合は最低限の手当てであり、配布元が侵害されればチェックサムごと差し替えられるという限界も同時に理解してください。

実行コマンド(k8s-wl-01 上・root):

# install -m 755 runsc containerd-shim-runsc-v1 /usr/bin/

置き先が /usr/local/bin ではなく /usr/bin であることに注意してください。

gVisor 公式の手順は /usr/local/bin を使いますが、AlmaLinux の sudo/etc/sudoerssecure_path/sbin:/bin:/usr/sbin:/usr/bin に固定されており、/usr/local/bin を含みません。 そのため /usr/local/bin へ置くと、sudo runsc --versionコマンドが見つかりません で落ちます。

同じ理由の置き換えは、シリーズを通して 4 回目です(第3巻では 2 回目)。第1回の kube-bench、第2巻第5回etcdctl第2巻第15回crictl も同じ事情で /usr/bin へ置いています。「root で打てるはずのコマンドが見つからない」と言われたら、まず secure_path を疑ってください。

実行コマンド(k8s-wl-01 上・root):

# runsc --version

実行結果:

runsc version release-20260721.0
spec: 1.2.1

実行コマンド(k8s-wl-01 上・root):

# ls -Z /usr/bin/runsc /usr/bin/containerd-shim-runsc-v1

実行結果:

system_u:object_r:container_runtime_exec_t:s0 /usr/bin/containerd-shim-runsc-v1
                   system_u:object_r:bin_t:s0 /usr/bin/runsc

2 つのファイルで違うラベルが付いている点に注目してください。 SELinux が Enforcing のノードでは、新しく置いたファイルにもラベルが付きますが、それを決めるのはディレクトリだけではなくファイル名も含めたポリシー(fcontext)です。 containerd-shim- で始まる名前は、コンテナランタイムの実行ファイルとして container_runtime_exec_t が割り当たる規則に一致しているため、同じディレクトリに置いても runscbin_t)とは別のラベルになります。どちらも正常な状態です。

想定と違うラベルが付いてしまった場合は、restorecon -v /usr/bin/runsc でポリシーどおりのラベルへ戻せます。 ラベルが合っていないと containerd から実行できず、第6回で扱った ausearch --input-logs -m AVC -ts recent に拒否が記録されます。

OS の SELinux は本回を通して Enforcing のままです。 後で止めるのは containerd のラベリング設定(enable_selinux)だけで、OS の SELinux ではありません。 ここを混同しないでください。

実行コマンド(k8s-wl-01 上・root):

# rm -f /root/runsc /root/runsc.sha512 /root/containerd-shim-runsc-v1 /root/containerd-shim-runsc-v1.sha512

ダウンロードした一時ファイル(合計およそ 145 MB)を片付けます。 install/usr/bin へ複製済みなので、/root の分は不要です。/root/config.toml.bak-10(次のステップで作る containerd 設定のバックアップ)は消さないでください。 これは意図して残すファイルで、やってみよう④ の撤去手順で使います。

install -m 755 を使っているのは、コピーとパーミッション設定を 1 回で済ませるためです。 cp のあとに chmod を打つ形でも構いませんが、chmod を忘れると実行できないファイルが /usr/bin に置かれます。 containerd から見ると「shim が見つからない」ではなく「shim を起動できない」という別のエラーになり、切り分けが 1 段増えます。

containerd-shim-runsc-v1 を忘れないでください。 gVisor 公式は containerd-shim-runsc-v1${PATH} の中か、containerd バイナリと同じディレクトリにあることを確認せよ」と書いています。この shim が無いと containerd はタスクを作れません。 runsc だけを入れて設定を書き、後から「なぜ動かないのか」と悩むのがよくある形です。

ステップ3:runsc install は containerd 用ではない

実行コマンド(k8s-wl-01 上・root):

# runsc help

実行結果(サブコマンド一覧の該当部分):

	install          adds a runtime to docker daemon configuration
	uninstall        removes a runtime from docker daemon configuration

多くの記事が「runsc install を打てば containerd に登録される」と書いていますが、これは誤りです。

runsc install が書き換えるのは Docker のデーモン設定(既定で /etc/docker/daemon.jsonです。containerd には一切触れません。 gVisor 公式の containerd 向けガイドも、runsc install には触れず /etc/containerd/config.toml を手で編集する手順を示しています。

本書ラボのノードに Docker はありません(containerd のみ)。runsc install を打っても、Kubernetes から見て何も変わりません。 試験で「gVisor を使えるようにせよ」と問われてこれを打つと、何も変わらないまま時間を失います。

ステップ4:containerd の設定版を確認する

実行コマンド(k8s-wl-01 上・root):

# containerd --version

実行結果:

containerd containerd v2.2.6 11ce9d5f3c68c941867e82890e93e815c1304f1b

実行コマンド(k8s-wl-01 上・root):

# head -1 /etc/containerd/config.toml

実行結果:

version = 3

この 1 行が、次のステップで書く記法を決めます。 version = 2 なら io.containerd.grpc.v1.criversion = 3 なら io.containerd.cri.v1.runtime です。試験環境でも最初にこれを打ってください。

実行コマンド(k8s-wl-01 上・root):

# cp -p /etc/containerd/config.toml /root/config.toml.bak-10

設定を触る前に必ずバックアップを取ってください。 containerd の設定を壊すと そのノードの Pod が全部作り直せなくなります。 本書ラボの k8s-wl-01 には 51 個の Pod が乗っています。このファイルは やってみよう④ の撤去手順でそのまま使います。

ステップ5:runsc をランタイムとして登録する

/etc/containerd/config.toml の末尾に、次の 2 行を追記します(全量)。

[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runsc]
  runtime_type = 'io.containerd.runsc.v1'

runtime_type の値 io.containerd.runsc.v1 は、先ほど /usr/bin へ置いた shim(containerd-shim-runsc-v1)の名前と対応しています。 containerd は io.containerd.<name>.v1 という形の値から containerd-shim-<name>-v1 という実行ファイル名を組み立てて ${PATH} から探します。shim を置き忘れると、この行を正しく書いても「実行ファイルが見つからない」で失敗します。 設定と配置が対になっている点を押さえてください。

既存の runtimes テーブルの中に差し込む必要はありません。 TOML では完全修飾のテーブル名を書けばどこに置いても同じ構造になるので、ファイルの末尾に足すのが最も安全です。既存行を編集しないので、書き間違えても既存の設定を壊しません。

実行コマンド(k8s-wl-01 上・root):

# containerd config dump | grep -A 6 runtimes.runsc

実行結果:

        [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runsc]
          runtime_type = 'io.containerd.runsc.v1'
          runtime_path = ''
          pod_annotations = []
          container_annotations = []
          privileged_without_host_devices = false
          privileged_without_host_devices_all_devices_allowed = false

書いていない項目が既定値で埋まって出てくる点に注目してください。 追記したのは 2 行だけですが、config dumpcontainerd が解釈した後の完全な形を出します。

そしてもう 1 つ。この時点では、まだ containerd を再起動していません。 それでも config dump には追記が反映されています。config dump は起動中のプロセスが握っている設定ではなく、設定ファイルを読み直した結果を返すためです。config dump に出た = 動作に反映された」ではありません。 書式が正しいことの確認にはなりますが、実際にランタイムとして使えるようにするには次のステップの再起動が要ります。

crictl は k8s-wl-01 に入っていません。 本書では crictl第2巻第15回で 3 台の Control Plane Node にだけ導入しました。Workload Node での確認は containerd config dump を使います。 config dump設定ファイルを読み直した結果の完全な形を出力するので、書いた設定が実際に解釈されたかを確認できます(書式を間違えていれば、ここに出てきません)。

ステップ6:containerd を再起動する

実行コマンド(k8s-wl-01 上・root):

# systemctl restart containerd

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod --all-namespaces --field-selector spec.nodeName=k8s-wl-01 | grep -vc Running

実行結果:

1

この 1 は「Running でない Pod が 1 個ある」という意味ではありません。 kubectl get pod の 1 行目は NAMESPACE NAME READY STATUS ... というヘッダで、この行に Running という文字列が含まれないため、grep -vc Running が数えているのはヘッダ行だけです。つまり Running でない Pod は 0 個で、再起動後も 51 Pod がそのまま動いています。

第9回でも同じ形の読み違いが出てきました。grep -c で数を数えるときは、ヘッダ行が入るかどうかを毎回確認してください。 --no-headers を付ければ 0 が返り、意味と数字が一致します。

systemctl restart containerd は既存の Pod を落としません(実測)。 containerd は shim プロセスを介してコンテナを管理しており、containerd 本体が落ちても shim とコンテナは生き続けます。 再起動後に containerd が shim へ再接続します。

ただしこれは「無停止で設定を変えられる」という意味ではありません。 再起動の最中に kubelet が新しい Pod を作ろうとすれば失敗します。本番では、ノードを kubectl drain してから触るのが作法です。 本書ラボでは wl-01 に 51 Pod が乗っており drain の影響が大きいため、再起動が既存 Pod に影響しないことを確認したうえで、drain せずに進めます。

ステップ7:RuntimeClass とノードラベルを作る

RuntimeClass を作る kubectl create のサブコマンドはありません。 演習① のステップ2 で quota / serviceaccount / role / rolebinding の骨組みを起こしたような手は使えません。--dry-run で起こせないリソースなので、apiVersion: node.k8s.io/v1 / kind: RuntimeClass / metadata.name / handler の 4 行は暗記してください。 試験ではこの 4 行を空の YAML から書き始めることになります。

runtimeclass-gvisor.yaml(全量):

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
scheduling:
  nodeSelector:
    sandbox.gvisor.dev/runtime: "runsc"

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f runtimeclass-gvisor.yaml

実行結果:

runtimeclass.node.k8s.io/gvisor created

実行コマンド(k8s-ops 上・developer):

$ kubectl label node k8s-wl-01 sandbox.gvisor.dev/runtime=runsc

実行結果:

node/k8s-wl-01 labeled

実行コマンド(k8s-ops 上・developer):

$ kubectl get runtimeclass

実行結果:

NAME     HANDLER   AGE
gvisor   runsc     0s

ここで 2 つのものを別々に作った点を意識してください。 RuntimeClass は「gvisor という名前で呼ばれたら runsc という handler を使う」というクラスタ全体の対応表です。ノードラベルは「このノードは runsc を持っている」というノード側の申告です。2 つが揃って初めて、Pod が runsc の入ったノードへ載ります。 どちらか片方だけでは動きません。

ラベルのキー sandbox.gvisor.dev/runtime は本書が決めた名前です。 Kubernetes が定義した予約語ではありません。RuntimeClass の scheduling.nodeSelector とノードのラベルが一致していれば、キーは何でも構いません。 GKE Sandbox は sandbox.gke.io/runtime=gvisor を使っており、本書はその形に倣っています。

ステップ8:gVisor Pod を作る —— そして起動しないことを確認する

sandbox-probe.yaml(全量):

apiVersion: v1
kind: Namespace
metadata:
  name: gvisor-test
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest
---
apiVersion: v1
kind: Pod
metadata:
  name: sandbox-probe
  namespace: gvisor-test
spec:
  runtimeClassName: gvisor
  containers:
    - name: probe
      image: busybox:1.37
      command: ["sleep", "3600"]

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f sandbox-probe.yaml

実行結果:

namespace/gvisor-test created
pod/sandbox-probe created
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "probe" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "probe" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "probe" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "probe" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

警告が 1 行出ますが、Pod は作成されています。 gvisor-test の PSA は enforce: baseline / warn: restricted という組み合わせなので、この Pod は baseline は満たしており弾かれません。 一方で warn のほうは restricted を見ているため、「restricted なら弾いていた」という内容を警告として鳴らしています第8回で扱った enforce / warn / audit の 3 モードの違いが、そのまま出力に現れた形です)。Warning: で始まる行を見たら、まず enforce なのか warn なのかを確認してください。

gvisor-test namespace の PSA は enforce=baseline にしています。 本演習の観察対象(dmesg / /sys/module / /proc/self/attr/current)は restricted の下でも読めますが、restrictedsecurityContext の記述量が増えて YAML が読みにくくなるため、使い捨ての観察用 namespace に限って baseline を使いますwarn / auditrestricted のままにして、逸脱に気づける状態は保ちます)。この判断は使い捨ての観察用途に限ったものです。 本番のテナント namespace は演習①の形(enforce=restricted)を使ってください。

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod -n gvisor-test -o wide

実行結果:

NAME            READY   STATUS              RESTARTS   AGE   IP       NODE        NOMINATED NODE   READINESS GATES
sandbox-probe   0/1     ContainerCreating   0          40s   <none>   k8s-wl-01   <none>           <none>

NODE 列は k8s-wl-01 です。 RuntimeClassscheduling.nodeSelector が効いて、runsc を入れたノードへ載っています。スケジューリングは成功しており、止まっているのはコンテナの作成です。 IP が <none> なのも、まだ sandbox が作られていないためです。

ステップ9:describe で原因を読む

実行コマンド(k8s-ops 上・developer):

$ kubectl describe pod sandbox-probe -n gvisor-test

実行結果(Events 節の該当行):

Warning  FailedCreatePodSandBox  39s  kubelet  Failed to create pod sandbox: rpc error: code = Unknown desc = failed to start sandbox "7def9800730496905d35453f158e0a268ca4d8191d979e2cf6fe4e0a70a67616": failed to create containerd task: failed to create shim task: OCI runtime create failed: FetchSpec failed: reading spec: SELinux is not supported: system_u:system_r:container_t:s0:c64,c818

このイベントは 1 秒間隔で繰り返し記録され、最後は (combined from similar events) にまとめられます。 また、末尾の MCS カテゴリ(c64,c818 の部分)は試行ごとに変わります。 コンテナを作るたびに新しい組が割り当てられるためで、読者の環境では別の数字になります。 sandbox の ID も同様です。一致を見るべきなのは SELinux is not supported という文言のほうです。

SELinux is not supported と言っているのは runsc です。

続く system_u:system_r:container_t:s0:c64,c818 は、第6回enable_selinux = true にした containerd が、CRI 経由で OCI spec に書き込んだ MCS ラベルそのものです。MCS ラベルは SELinux がコンテナごとに割り当てるカテゴリの組で、同じラベルを持つプロセスどうしだけがアクセスできます。

第6回で入れた防御が、第10回で入れようとしている防御を拒んでいます。 設定を間違えたわけでも、バージョンが合っていないわけでもありません。2 つのハードニングが同じノードの上で両立しないという、設計の問題です。次の節で原因を確定させ、その次の節で決め方を扱います。

ステップ10:他の失敗パターンと区別できるようにする

同じ ContainerCreating でも、原因が違えばイベントの文面が違います。 3 パターンを並べて型を作ります。

runtimeclass-fail-cases.yaml(全量):

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor-nosched
handler: runsc
---
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: nosuchhandler
handler: doesnotexist
---
apiVersion: v1
kind: Pod
metadata:
  name: probe-wrong-node
  namespace: gvisor-test
spec:
  runtimeClassName: gvisor-nosched
  nodeName: k8s-wl-02
  containers:
    - name: probe
      image: busybox:1.37
      command: ["sleep", "3600"]
---
apiVersion: v1
kind: Pod
metadata:
  name: probe-bad-handler
  namespace: gvisor-test
spec:
  runtimeClassName: nosuchhandler
  nodeName: k8s-wl-01
  containers:
    - name: probe
      image: busybox:1.37
      command: ["sleep", "3600"]

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f runtimeclass-fail-cases.yaml

実行結果:

runtimeclass.node.k8s.io/gvisor-nosched created
runtimeclass.node.k8s.io/nosuchhandler created
pod/probe-wrong-node created
pod/probe-bad-handler created
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "probe" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "probe" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "probe" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "probe" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

4 つとも created で通ります。 存在しない handler を書いた RuntimeClass も、runsc の無いノードを指定した Pod も、API Server は受け付けます。 RuntimeClasshandler が実在するかどうかを検証する仕組みは Kubernetes にありません。問題が出るのは kubelet がノード上でコンテナを作ろうとした瞬間です。PodSecurity の警告は、やってみよう② のステップ8 と同じ理由(gvisor-testwarn: restricted)で出ています。Pod は 2 つ作っているのに、警告は 1 行しか出ません。 kubectl同じ内容の警告をまとめるためです。警告が出た数と、警告に該当する Pod の数は一致しません。 標準出力と標準エラーが混ざるので、警告が現れる位置も created の行の間に割り込むことがあります。

実行コマンド(k8s-ops 上・developer):

$ kubectl describe pod probe-wrong-node -n gvisor-test

実行結果(Events 節の該当行):

Failed to create pod sandbox: rpc error: code = Unknown desc = unable to get OCI runtime for sandbox "49b8accf6bdf554b1a7d1b82df4f2fbc84fd99d19877b702418f0d733d61246a": no runtime for "runsc" is configured

実行コマンド(k8s-ops 上・developer):

$ kubectl describe pod probe-bad-handler -n gvisor-test

実行結果(Events 節の該当行):

Failed to create pod sandbox: rpc error: code = Unknown desc = unable to get OCI runtime for sandbox "...": no runtime for "doesnotexist" is configured

引用符の中だけが違います。 sandbox の ID は毎回変わるので、ここでは省略して示しています。

症状イベントの文面原因直し方
ContainerCreating のままno runtime for "runsc" is configuredそのノードの containerd に runsc が登録されていないconfig.toml へ追記 + systemctl restart containerdまたは RuntimeClass に scheduling.nodeSelector を付けて導入済みノードへ寄せる
ContainerCreating のままno runtime for "doesnotexist" is configuredRuntimeClass の handler の綴りが CRI の設定名と一致していないhandlerconfig.tomlruntimes.<名前> に合わせる
ContainerCreating のままSELinux is not supported: system_u:...containerd の enable_selinux = true と runsc が排他次の 2 つの節で扱います

2 つの失敗ケースは、原因も直し方も違います。 probe-wrong-nodeRuntimeClass の書き方は正しいのに、載ったノードに runsc が無いケースです(scheduling を書かず nodeName: k8s-wl-02 で強制的に載せています)。probe-bad-handlerノードは正しいのに handler の綴りが CRI の設定名と一致していないケースです。イベントの引用符の中を読めば、どちらかがすぐ分かります。 "runsc" と出ていればノード側の導入漏れ、見覚えのない名前が出ていれば RuntimeClass 側の綴り間違いです。

3 つとも kubectl get pod の STATUS は同じ ContainerCreating です。 Pending ではありません(スケジューリングは成功していて、コンテナの作成で止まっています)。Pending なら kubectl describe node や Quota / taint を疑い、ContainerCreating なら kubectl describe pod の Events とノードの CRI 設定を疑う、という切り分けの型を作ってください。第2巻第16回・第8回で作った「STATUS で診断先を変える」型に、ContainerCreating の枝が加わります。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod probe-wrong-node probe-bad-handler -n gvisor-test --force --grace-period=0

実行結果:

Warning: Immediate deletion does not wait for confirmation that the running resource has been terminated. The resource may continue to run on the cluster indefinitely.
pod "probe-wrong-node" force deleted from gvisor-test namespace
pod "probe-bad-handler" force deleted from gvisor-test namespace

先頭の警告は --force --grace-period=0 を付けたときに必ず出ます。 「終了の確認を待たずに API から消すので、実体がノードに残り続ける可能性がある」という注意です。今回の 2 つはそもそもコンテナが作られていないので、実害はありません。 通常の Pod にこの指定を使うのは、削除が進まないときの最後の手段だと考えてください。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete runtimeclass gvisor-nosched nosuchhandler

実行結果:

runtimeclass.node.k8s.io "gvisor-nosched" deleted
runtimeclass.node.k8s.io "nosuchhandler" deleted

この演習の合格条件

  • sandbox-probeContainerCreating のまま留まり、その describeSELinux is not supported が出ていること
  • 3 つの失敗パターンをイベント文面で区別できるようになっていること
  • sandbox-probe は削除せず、やってみよう③ へ持ち越すこと

ここで作業を中断する場合は、kubectl delete namespace gvisor-test で観察用の資材だけ消しておいてください。RuntimeClass gvisor とノードラベルはそのままで構いません(どちらもクラスタの動作には影響しません)。再開するときは やってみよう③ のステップ1 から始められます。

2 つのハードニングが正面衝突する —— なぜ runsc は SELinux ラベルを拒むのか

エラーは 1 文しか言っていません。SELinux is not supported これを 3 つの問いに分解します。

  1. runsc 側に、SELinux を無視して起動するオプションはあるか
  2. containerd 側に、ランタイムごとに SELinux ラベリングを止める設定はあるか
  3. Pod 側で回避できるか

答えは 3 つとも「無い」です。 順に確かめます。「無いことを確かめる」作業は、あることを確かめる作業より丁寧さが要ります。 探し方が足りないだけかもしれないからです。ここでは runsc のフラグ全量・containerd の設定ダンプ全量・公式 FAQ の記述という 3 つの出所から確認します。

runsc にフラグは無い

実行コマンド(k8s-wl-01 上・root):

# runsc flags 2>&1 | wc -l

実行結果:

229

実行コマンド(k8s-wl-01 上・root):

# runsc flags 2>&1 | grep -ci selinux

実行結果:

0

runsc が持つ 229 行のフラグに selinux の語は 1 件もありません。 SELinux を無視する設定は、runsc 側には存在しません。

2>&1 を落とすと、0 件という答えが嘘になります。

runsc flags標準エラーへ出力します2>&1 を付けずにパイプへ渡すと、wc -l0grep -ci selinux0 を返します。後者は「探した結果 0 件」と区別が付きません——探す前に空だったからです。「無いことを確かめた」と言えるのは、出力が届いていることを確かめてからです。 wc -l229 を返すことが、その確認になっています。

containerd の enable_selinux はグローバル設定である

実行コマンド(k8s-wl-01 上・root):

# containerd config dump | grep -n enable_selinux

実測では、config dump の出力全体で enable_selinux は 1 行(60 行目)しか現れません。 場所は [plugins.'io.containerd.cri.v1.runtime'] の直下です。この時点ではまだ true のままで、値を変えるのは やってみよう③ のステップ2 になります。

実行コマンド(k8s-wl-01 上・root):

# containerd config dump | grep -A 12 "runtimes.runsc"

実行結果:

        [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runsc]
          runtime_type = 'io.containerd.runsc.v1'
          runtime_path = ''
          pod_annotations = []
          container_annotations = []
          privileged_without_host_devices = false
          privileged_without_host_devices_all_devices_allowed = false
          cgroup_writable = false
          base_runtime_spec = ''
          cni_conf_dir = ''
          cni_max_conf_num = 0
          snapshotter = ''
          sandboxer = ''

ランタイムごとに書けるフィールドは 12 個あり、SELinux 関連は 1 つもありません。

enable_selinux は CRI ランタイムプラグインのグローバル設定です。 ランタイムごとのテーブル(runtimes.runc / runtimes.runsc)の中には置けません。「runc の Pod には SELinux ラベルを付け、runsc の Pod には付けない」という設定は、containerd の設定ファイルでは書けません。

この 1 点が、後の設計判断の粒度を決めます。 ランタイム単位で分けられないなら、分けられる最小の単位はノードになります。

Pod 側でも回避できない

gVisor 公式の FAQ には、この症状がそのまま載っています。

I’m getting an error like SELinux is not supported: system_u:system_r:container_t:s0:...

(解決策として)you can disable SELinux specifically for the new container by passing the --security-opt label=disable argument during its creation.

SELinux is not supported: ... というエラーが出る場合、新しいコンテナの作成時に --security-opt label=disable を渡すことで、そのコンテナに限って SELinux を無効化できる)

ここに落とし穴があります。

--security-opt label=disable は Docker / Podman のコマンドライン引数です。Kubernetes には対応するフィールドがありません。 Pod の securityContext.seLinuxOptions は「どのラベルを付けるか」を指定するフィールドであって、「ラベルを付けない」という指定はできません。

enable_selinux = true である限り、CRI は必ずラベルを付けます。 Pod のマニフェストをどう書いても、この経路は変えられません。

なぜ runsc は「無視して起動する」を選ばなかったのか

runsc の中を見ると理由が分かります。

gVisor Pod の中には、SELinux のインタフェースそのものが存在しません。

やってみよう③ で Pod が起動したあと、その中で /proc/self/attr/current を読むと次が返ります(ここでは結果だけを先に示します。実際に打つのは やってみよう③ のステップ6 です)。

cat: can't open '/proc/self/attr/current': No such file or directory

/proc/self/attr/current は、プロセスが自分の SELinux コンテキストを読むための入り口です。通常のコンテナなら system_u:system_r:container_t:s0:c170,c324 のような値が返ります(第6回で読んだ形です)。gVisor では ファイルがありません。

理由は前々節で説明したとおりです。Sentry は Linux の syscall インタフェースを Go で再実装したもので、SELinux の実装を含んでいません。 ラベルを渡されても、それを強制する仕組みが中にありません。

ここで runsc には 2 つの選択肢がありました。

選択肢起きること
ラベルを黙って無視して起動するPod は動く。kubectl describe pod にも seLinuxOptions が表示される。しかし実際には何も拘束されていない。 運用者は「MAC が効いている」と信じたまま運用する
起動を拒否するPod は動かない。運用者は必ず気づく

runsc は後者を選んでいます。 「拘束されているつもりで拘束されていない」状態を作らない、という設計判断です。

3 つの問いの答えをまとめておきます。 ①runsc 側にフラグは無い(229 行中 0 件)。②containerd 側の enable_selinux はグローバル設定で、ランタイム単位に置けない(runtimes.runsc 配下の 12 フィールドに SELinux 関連は無い)。③Pod 側の securityContext では「ラベルを付けない」を指定できない。逃げ道が 3 つとも塞がっているので、決められる粒度はノードだけになります。 次の節はその粒度の話です。

セキュリティ機構が「動かない」ことで自己主張するのは、良い設計です。 静かに無効化される防御は、有効化されている防御より危険です。エラーで止まったおかげで、読者は今この問題に気づけています。

サンドボックス専用ノードという決着 —— 代償を数えてから決める

enable_selinux はノード上の containerd の設定です。ということは、ノードごとに違う値にできます。 選べる粒度は次の 3 つになります。

粒度何を選ぶか結果
クラスタ全体で SELinux を優先gVisor を使わない第6回の防御は全ノードで維持。D4 の isolation techniques が実装できない
クラスタ全体で gVisor を優先5 ノードすべてで enable_selinux = falsegVisor は使える。第6回の MAC 拘束を全ノードで失う
ノード単位で分けるk8s-wl-01 だけ enable_selinux = falsegVisor Pod は wl-01 に寄せる。他 4 ノードは第6回の拘束を維持

本書は 3 番目を採ります。 理由は 2 つあります。

  1. どちらの防御も全部は捨てなくてよい。 失うのは 5 ノードのうち 1 台の MAC 拘束であって、クラスタ全体ではない
  2. 実運用でも同じ形が使われている。 GKE Sandbox は 専用のノードプールに gVisor を分離する設計になっている。gVisor 公式も「gVisor を有効にしたノードプールをデプロイすればよい」と説明している

1 番目と 2 番目を選ばない理由も書いておきます。 1 番目(gVisor を使わない)は、防御の選択肢を 1 つ捨てる判断です。本当に非信頼のコードを動かす必要が出たときに、打つ手が残りません。 2 番目(全ノードで enable_selinux = false)は、gVisor を使わないノードまで MAC 拘束を失う判断です。いま true なのは Workload Node 2 台だけなので、失うのはその 2 台ぶん——つまりアプリケーションが実際に動いている場所の拘束を、全部まとめて捨てることになります(Control Plane Node は第6回の判断で元から false です)。gVisor で守りたいのは 1 ノードぶんの非信頼ワークロードなのに、代償は稼働中の全ワークロードに及びます。 得るものに対して失うものが大きすぎます。

3 番目が選べるのは、containerd の設定がノードごとのファイルだからです。 クラスタ全体の設定であれば、この選択肢は存在しませんでした。「どの層に設定が置かれているか」が、選べる粒度を決めます。 RBAC はクラスタ全体、PSA は namespace 単位、SELinux ラベリングはノード単位——本書でここまでに扱った防御は、それぞれ違う粒度を持っています。

このノードは「防御を外した」のではなく「別の防御に置き換えた」

enable_selinux = false にした wl-01 を「弱いノード」と呼ぶのは正確ではありません。 そのノードで動く gVisor Pod は、MAC ラベルによる拘束を失う代わりに、syscall 境界を得ています。

観点通常のコンテナ + SELinuxgVisor Pod(enable_selinux = false のノード)
syscall を処理するのはホストカーネルSentry(ユーザー空間)
ホストカーネルへ届く syscallアプリが呼んだもの全部(seccomp で絞った範囲)Sentry が必要と判断した最小限だけ
MAC による拘束ありcontainer_t:s0:cXXX,cYYY無し
ファイル I/Oコンテナプロセスが直接Gofer プロセス経由

第7回で扱った攻撃面の考え方が、ここで一段深まります。 攻撃面は「減らす」だけでなく 「置き換える」こともできます。減らす操作なら足し算・引き算で判断できますが、置き換える操作は、失うものと得るものを並べて比べる必要があります。

代償その 1 —— 同じノードの通常 Pod も MAC 拘束を失う

enable_selinux = falseそのノードの CRI 全体に効きます。gVisor Pod だけの話ではありません。

対象/proc/self/attr/current
変更に wl-01 で作った通常 Podsystem_u:system_r:spc_t:s0(= MCS カテゴリが無い = 拘束なし
同時刻に wl-02 で作った通常 Podsystem_u:system_r:container_t:s0:c55,c207MCS つきで拘束
変更から wl-01 で動いていた Podsystem_u:system_r:container_t:s0:c170,c324古いラベルのまま生き残る
gVisor Podファイルそのものが存在しない

2 つのことが同時に読み取れます。

  1. spc_t は “super privileged container” タイプで、MCS による分離を受けません。 wl-01 で新しく作られる通常 Pod は、第6回で入れた MAC 拘束の外に出ます
  2. 設定を変えても既存のコンテナは死にません。 変更前から動いていた 51 個の Pod は、古いラベルを持ったまま動き続けます。 ラベルを失うのは 次に作り直されたときです

第8回の PSA enforce とまったく同じ構造です。 「設定を変えた瞬間には何も起きない。次にリソースが作り直されたときに初めて変わる」。この時間差は、変更を入れた本人が忘れた頃に効いてきます。

この代償が、本書ラボで gVisor を恒久導入しない理由の 1 つ目です。 wl-01 でこの先作られる Pod は、すべて MAC 拘束の外に出ます。 ノードを本当にサンドボックス専用にできるなら、そこに通常 Pod は載らないので問題になりません。本書ラボは Workload Node が 2 台しかないため、専用にできません。 この続きは やってみよう④ のステップ8 で数え直します。

代償その 2 —— 可用性が下がる

RuntimeClassscheduling.nodeSelector は、その RuntimeClass を使う Pod をすべて 1 種類のノードへ寄せます。 本書ラボでサンドボックスノードは k8s-wl-01 の 1 台だけです。

fanclub-backend を gVisor へ寄せると、3 レプリカすべてが wl-01 に載ります。 これは第2巻で作った HA 構成(2 台の Workload Node へ分散)の後退です。wl-01 が落ちれば backend は全滅します。

この代償は本書ラボの制約から来ています。 Workload Node が 2 台しかなく、そのうち 1 台をサンドボックス専用にした時点で、「サンドボックスされたワークロードの可用性 = ノード 1 台の可用性」になります。

先に予告しておきます。本書ラボでは、やってみよう④ の最後に gVisor の適用を元へ戻します。 理由は次に書く本番ガードレールの 2 番目——taint——が、本書ラボでは打てないことに尽きます。taint を打てないなら「専用ノード」は名前だけで、代償は残り、得られるはずのものは得られません。 判断の中身は やってみよう④ のステップ8〜9 で数えたうえで扱います。

本番ガードレール

本書ラボの構成をそのまま本番へ持ち込まないでください。

本番で同じ設計を採るなら、少なくとも次の 4 点を足してください。

  1. サンドボックスノードは最低 2 台用意する。 1 台では可用性が確保できません。ノードプール / マシンセットの単位で管理し、台数を増やせる形にしておきます
  2. サンドボックスノードに taint を打つ。 kubectl taint node <node> sandbox.gvisor.dev/runtime=runsc:NoSchedule を打ち、RuntimeClass 側に対応する scheduling.tolerations を書きます。 これで サンドボックスノードにサンドボックス Pod 以外が載らなくなり、「MAC 拘束を失う通常 Pod」が発生しなくなります。本書ラボでは打ちません(wl-01 に既に 51 個の Pod が乗っており、taint を打つと大規模な退去が起きるためです)
  3. サンドボックス Pod 側に topologySpreadConstraints を書く。 複数のサンドボックスノードへ分散させます
  4. overhead を実測して設定する。 Sentry と Gofer の消費をスケジューラに申告しないと、ノードを詰め込みすぎます

taint を打つ形が本来の姿です。 本書ラボは既存 Pod の都合で label + scheduling.nodeSelector だけで寄せていますが、それは「サンドボックス Pod が wl-01 へ来る」ことしか保証しません。「wl-01 に他の Pod が来ない」ことは保証しません。

サンドボックス専用ノードという設計とその代償。5 ノードのうち k8s-wl-01 だけが enable_selinux = false でノードラベル sandbox.gvisor.dev/runtime=runsc を持つ。Pod の runtimeClassName から RuntimeClass を経て Admission が nodeSelector を注入し、スケジューラが wl-01 を選ぶ。wl-01 の内側には gVisor Pod・変更後に作られて MAC 拘束を失った通常 Pod・変更前から古いラベルのまま動いている通常 Pod の 3 種類が並び、右側に本番ガードレール 4 点を添えている
図3:サンドボックス専用ノードという設計と、その代償

やってみよう③:wl-01 をサンドボックスノードにし、gVisor で動いている証拠を集める

所要 40 分(うち試験相当の粒度は ステップ4〜6 の 10 分)。

試験で問われるのは「この Pod がサンドボックスで動いているか確認せよ」という形です。uname -r4.19.0-gvisor を返すかどうかが、最も短い判定法になります。ステップ1〜3・7〜9 は本番作法(ノード設定の変更と、その代償の確認)です。

やってみよう② の続きです。 sandbox-probeContainerCreating のまま残っている状態から始めます。

対象k8s-wl-01(root)と k8s-ops(developer)です。

ステップ1:wl-01 の containerd で SELinux ラベリングを止める

実行コマンド(k8s-wl-01 上・root):

# grep -n enable_selinux /etc/containerd/config.toml

実行結果:

60:    enable_selinux = true

該当行を vi で次のように書き換えます。

enable_selinux = false

実行コマンド(k8s-wl-01 上・root):

# containerd config dump | grep enable_selinux

実行結果:

    enable_selinux = false

ここでも、containerd をまだ再起動していないのに false が返ります。 やってみよう② のステップ5 と同じで、containerd config dump は設定ファイルを読み直した結果を返すためです。config dump で確認できた」ことと「動作に反映された」ことは別なので、この後の再起動を省略しないでください。

実行コマンド(k8s-wl-01 上・root):

# systemctl restart containerd

この 1 行が、本回で最も重い変更です。

wl-01 で今後作られる通常 Pod は、MCS による拘束を受けなくなります。 wl-02 は enable_selinux = true のままです(3 台の Control Plane Node は第6回の判断により元から false なので、本回の変更対象ではありません)。変更するのは wl-01 の 1 行だけです。

vi で直接編集してください。 sed -i や一時ファイル置換型のエディタは、containerd が読むファイルの inode を変えることがあります。

ステップ2:変更が 1 ノードに閉じていることを確認する

実行コマンド(k8s-wl-02 上・root):

# containerd config dump | grep enable_selinux

実行結果:

    enable_selinux = true

第8回第9回では「3 台すべてに配らないと『たまに効く』状態になる」ことを扱いました。 本回は逆で、1 台にだけ入れることが目的です。配り忘れではなく、意図的な非対称です。 その意図を残す方法は 2 つあります。ノードにラベルを付けること(次のステップまでに済ませています)と、構成管理のコードに残すことです。本書ラボは手作業で変更しているので、ラベルが唯一の痕跡になります。

ステップ3:止まっていた Pod が動き出すことを確認する

実行コマンド(k8s-ops 上・developer):

$ kubectl delete pod sandbox-probe -n gvisor-test --force --grace-period=0

やってみよう② のステップ10 と同じく、先頭に Warning: Immediate deletion does not wait for ... が出てから削除完了の 1 行が返ります。

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f sandbox-probe.yaml

ここでも Warning: would violate PodSecurity "restricted:latest": ... の 1 行が出ます。 warn: restricted が付いた namespace なので、作り直すたびに鳴ります。

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod -n gvisor-test -o wide

実行結果:

NAME            READY   STATUS    RESTARTS   AGE   IP               NODE        NOMINATED NODE   READINESS GATES
sandbox-probe   1/1     Running   0          35s   10.244.215.238   k8s-wl-01   <none>           <none>

今度は Running になり、Pod IP も付いています。 変えたのは enable_selinux の 1 行だけです。

既に ContainerCreating で回り続けている Pod は、containerd を直しただけでは動き出しません。 kubelet は同じ sandbox ID で再試行を続けます。作り直してください。 これも「設定を変えても既存のものは変わらない」型の一例です。

ステップ4:scheduling.nodeSelector の注入を確認する

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod sandbox-probe -n gvisor-test -o jsonpath='{.spec.nodeSelector}'

実行結果:

{"sandbox.gvisor.dev/runtime":"runsc"}

sandbox-probe.yamlnodeSelector は 1 行も書いていません。 RuntimeClass の scheduling.nodeSelectorAdmission の段階で Pod へ注入されています。 公式が「RuntimeClass の nodeSelector は Pod の nodeSelector と admission でマージされ、両者の積を取る」と書いているとおりの挙動です。

Pod 側に別のノードを指す nodeSelector を書くと、積が空になって Pod は拒否されます。 RuntimeClass 側とアプリ側で別々にノードを指定していると、組み合わせた瞬間に動かなくなります。

ステップ5:カーネルが違うことを確認する(最短の判定法)

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test sandbox-probe -- uname -r

実行結果:

4.19.0-gvisor

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test sandbox-probe -- cat /proc/version

実行結果:

Linux version 4.19.0-gvisor #1 SMP Sun Jan 10 15:06:54 PST 2016

ノードのカーネルは 6.12.0-211.22.1.el10_2.x86_64 です。 サンドボックスの中では 4.19.0-gvisor が返ります。これは Sentry が名乗っているバージョンで、ホストのカーネルとは無関係です。 gVisor 公式にこの表記の意味を説明した記述は見つからなかったので、本書は事実の記述に留めます。

試験で「この Pod がサンドボックスで動いているか確認せよ」と問われたら、このコマンドが最短です。 kubectl get pod -o yamlruntimeClassName を見る方法もありますが、それは「指定されているか」を見ているだけで、「実際にそのランタイムで動いているか」を見ていません。 handler が未登録なら指定はあっても動きません。中から uname -r を打つほうが確実です。

ステップ6:通常 Pod と 5 つの観点で並べる

normal-probe.yaml(全量):

apiVersion: v1
kind: Pod
metadata:
  name: normal-probe-wl01
  namespace: gvisor-test
spec:
  nodeName: k8s-wl-01
  containers:
    - name: probe
      image: busybox:1.37
      command: ["sleep", "3600"]
---
apiVersion: v1
kind: Pod
metadata:
  name: normal-probe-wl02
  namespace: gvisor-test
spec:
  nodeName: k8s-wl-02
  containers:
    - name: probe
      image: busybox:1.37
      command: ["sleep", "3600"]

実行コマンド(k8s-ops 上・developer):

$ kubectl apply -f normal-probe.yaml

実行結果:

pod/normal-probe-wl01 created
pod/normal-probe-wl02 created

sandbox-probe.yaml のときと同じく、Warning: would violate PodSecurity "restricted:latest": ... が出ます。 gvisor-testenforce: baseline なので作成そのものは通りwarn: restricted のほうが鳴っているだけです。

nodeName を直接書くとスケジューラを通りません。 kubelet がそのノードで直接 Pod を作ります。ここでは「必ずこのノードで作る」ことが目的なので使っていますが、通常のワークロードでは使いません。 スケジューラを迂回すると、ノードの残容量や taint の評価が働かないまま Pod が押し込まれます。本回で扱っている RuntimeClassscheduling.nodeSelector は逆に、スケジューラを通したまま候補ノードを絞る仕組みです。同じ「ノードを選ぶ」でも、通る経路がまったく違います。

ここから 5 つの観点を順に打ちます。各コマンドは sandbox-probenormal-probe-wl01 の 2 回ずつ打ちます。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test normal-probe-wl01 -- uname -r

実行結果:

6.12.0-211.22.1.el10_2.x86_64

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test sandbox-probe -- dmesg

実行結果:

[   0.000000] Starting gVisor...
[   0.496029] Consulting tar man page...
[   0.619471] Forking spaghetti code...
[   1.097648] Granting licence to kill(2)...
[   1.106008] Creating cloned children...
[   1.457955] Constructing home...
[   1.816121] Segmenting fault lines...
[   2.198013] Searching for needles in stacks...
[   2.325499] Mounting deweydecimalfs...
[   2.491306] Creating process schedule...
[   2.832812] Moving files to filing cabinet...
[   2.987361] Ready!

この出力は、読者の環境では一致しません。

gVisor は起動メッセージを毎回ランダムに選んで並べるため、行数も文面も起動のたびに変わります。 同じクラスタ・同じ手順で作り直した別の回では、次のようになりました(先頭部分の抜粋)。

[   0.000000] Starting gVisor...
[   0.473177] Searching for socket adapter...
[   0.531663] Recruiting cron-ies...
[   0.951508] Generating random numbers by fair dice roll...
[   0.988363] Politicking the oom killer...
[   1.383103] Committing treasure map to memory...

安定して出るのは、先頭の Starting gVisor... と末尾の Ready! だけです。 間の行は毎回変わるので、本書の出力と一致しなくても問題ありません。 見るべきなのはdmesg がエラーにならず、gVisor 由来のログが返ってくること」の 1 点です。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test normal-probe-wl01 -- dmesg

実行結果:

dmesg: klogctl: Operation not permitted
command terminated with exit code 1

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test sandbox-probe -- sh -c "ls /sys/module | wc -l"

実行結果:

0

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test normal-probe-wl01 -- sh -c "ls /sys/module | wc -l"

実行結果:

185

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test sandbox-probe -- cat /proc/self/attr/current

実行結果:

cat: can't open '/proc/self/attr/current': No such file or directory

5 つの観点をまとめると次のようになります。

観点gVisor Pod通常 Pod(wl-01)読み取れること
uname -r4.19.0-gvisor6.12.0-211.22.1.el10_2.x86_64別のカーネルが応答している
cat /proc/versionLinux version 4.19.0-gvisor #1 SMP Sun Jan 10 15:06:54 PST 2016Linux version 6.12.0-211.22.1.el10_2.x86_64 (mockbuild@x64-builder03.almalinux.org) ...ビルド情報も別物
dmesg成功Starting gVisor...失敗dmesg: klogctl: Operation not permitted逆転する。 通常コンテナは CAP_SYSLOG が無くて弾かれるが、gVisor は Sentry 自身のログなので読める
ls /sys/module | wc -l0185ホストのカーネルモジュール一覧が見えない=攻撃面が減っている
cat /proc/self/attr/currentファイルが存在しないsystem_u:system_r:spc_t:s0(変更後)SELinux のインタフェース自体が無い

dmesg の逆転が最も分かりやすい証拠です。 通常のコンテナで dmesg が弾かれるのは、ホストカーネルのログバッファを読む操作であり CAP_SYSLOG が要るからです。gVisor では 読んでいる相手が Sentry なので、ホストの権限は関係ありません。「読めるようになった」のではなく、「読んでいる相手が変わった」のです。

5 つの観点は「見えるものが減った」と「応答する相手が変わった」の 2 種類に分かれます。 uname -r/proc/versiondmesg応答する相手が Sentry に置き換わったことを示します。/sys/module/proc/self/attr/currentホスト由来の情報が消えたことを示します。前者はサンドボックスで動いていることの証拠、後者は攻撃面が減ったことの証拠として使い分けてください。

/sys/module が 0 件であることは、そのまま攻撃面の話になります。 通常のコンテナからは ホストに何のカーネルモジュールが載っているか(185 個)が見えます。 脆弱性のあるモジュールを探す偵察に使える情報です。gVisor ではそもそも見えません。

ステップ7:ノード側からサンドボックスの構造を見る

実行コマンド(k8s-wl-01 上・root):

# ps -eo pid,comm,args | grep runsc | grep -v grep

実行結果(引数を一部省略しています):

  48472 containerd-shim /usr/bin/containerd-shim-runsc-v1 -namespace k8s.io -address /run/containerd/containerd.sock -id 9cc8261b07dcf054e2355cc6ae497cec0b43f02fab0ae83830c85af9b085ba3d
  48490 exe             runsc-gofer --panic-log=/var/log/pods/gvisor-test_sandbox-probe_.../gvisor_panic.log --root=/run/containerd/runsc/k8s.io ...
  48497 exe             runsc-sandbox --root=/run/containerd/runsc/k8s.io --log=... boot ...
  48553 runsc           runsc --root=/run/containerd/runsc/k8s.io --log=... wait ...
  48588 containerd-shim /usr/bin/containerd-shim-runsc-v1 -namespace k8s.io ... -id aeb5a67a6b7ca9083ff57a35516de1b6d23c81e475032d0bc1ab76e5e1962286
  48617 exe             runsc-gofer ... -id aeb5a67a...
  48644 runsc           runsc ... wait aeb5a67a...

COMMAND 列が exe になっているプロセスがあります。 Sentry と Gofer は自分自身のバイナリを再実行する形で起動するため、comm には exe が入り、実体は引数のほう(runsc-gofer / runsc-sandbox)に現れます。 ps でサンドボックスを探すときは、comm ではなく args を見てください。

役割は次のとおりです。

プロセス役割
containerd-shim-runsc-v1containerd 側の shim
runsc-sandbox ... bootSentry 本体(ユーザー空間カーネル)。--total-memory --cpu-num=4 等をここで受ける
runsc-gofer ... goferファイルアクセスの代理--gofer-mount-confs=lisafs:...
runsc ... wait終了待ち

説明した Sentry と Gofer が、実際に別プロセスとして見えます。 runsc-gofer のコマンドライン引数に lisafs という文字列が入っています。 これは gVisor が Gofer との通信に使うプロトコルの名前です(かつては 9P が使われていました)。ファイル I/O だけがこの経路を通ります。

プロセスの数え方に注意してください。 Sentry(runsc-sandbox ... boot)は Pod(サンドボックス)ごとに 1 つだけで、shim・Gofer・wait はコンテナごとに立ちます。 上の出力で -id が 2 種類あるのは、Pod の sandbox コンテナと、その中の probe コンテナに対応しているためです。マルチコンテナ Pod ではさらに増えます。 「1 Pod = 何プロセス」と固定で覚えないでください。

プロセスの関係を整理しておきます。 containerd が containerd-shim-runsc-v1 を起動し、shim が runsc ... boot(Sentry)と runsc ... gofer(Gofer)を立ち上げます。コンテナのアプリケーションは Sentry の中で動いており、ホストの ps には単独のプロセスとして現れません。 通常のコンテナなら ps -ef にアプリのプロセスがそのまま見えるので、ここも「ホストから見える情報が減っている」ことの現れです。

ps の出力からノード上のサンドボックスの数を数えられます。 「このノードで何個の Pod がサンドボックスで動いているか」を、Kubernetes を経由せずに確認する手段になります。数えるなら runsc-sandbox ... boot の数です(Pod と 1 対 1 で対応します)。

--platform は引数に現れません。 既定値の systrap が使われているためです。引数に無いことは「設定されていない」ではなく「既定値のまま」を意味します。

ステップ8:オーバーヘッドを測る

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test sandbox-probe -- sh -c "time sh -c 'i=0; while [ \$i -lt 20000 ]; do : > /tmp/f\$i; i=\$((i+1)); done'"

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test normal-probe-wl01 -- sh -c "time sh -c 'i=0; while [ \$i -lt 20000 ]; do : > /tmp/f\$i; i=\$((i+1)); done'"

この 2 本は、同じ条件で 4 回ずつ打ってください。 1 回では読み違えます。実測は次のとおりでした(単位は秒)。

gVisor(real / user / sys)通常(real / user / sys)
10.42 / 0.35 / 0.060.77 / 0.06 / 0.67
20.44 / 0.35 / 0.080.88 / 0.04 / 0.78
30.39 / 0.30 / 0.080.23 / 0.04 / 0.18
40.39 / 0.33 / 0.050.23 / 0.04 / 0.18

ウォールクロック(real)で倍率を出さないでください。

gVisor は 0.39〜0.44 秒に収まって安定している一方、通常コンテナは 0.23〜0.88 秒とばらつき、3 回目・4 回目では gVisor より速く、1 回目・2 回目では gVisor より遅いという結果になりました。「gVisor は何倍遅い」という数字は、この環境では再現しません。 同居しているワークロードやページキャッシュの状態に左右されるためです。

4 回とも再現するのは usersys の内訳のほうです。 gVisor は user が大きく sys が小さい。通常コンテナはその逆で sys が大きい。ここは 1 回も逆転しません。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test sandbox-probe -- sh -c "dd if=/dev/zero of=/tmp/big bs=1M count=64 2>/dev/null; time md5sum /tmp/big"

実測は gVisor が real 0.21 / user 0.17 / sys 0.03、通常が real 0.10 / user 0.09 / sys 0.01 でした。 ハッシュ値は両方とも 7f614da9329cd3aebf59b91aadc30bf0 で一致します。計算そのものは同じ結果を返すことを確認しておいてください。

ファイル作成 20,000 回のほうで、sysuser の内訳が逆転していることに注目してください。 通常のコンテナは sys が大きく、ホストカーネルの中で時間を使っています。 gVisor は user が大きく、ユーザー空間で時間を使っています。

この内訳が、Sentry が syscall を肩代わりしていることの実物です。 カーネルの仕事がユーザー空間のプロセスへ移った結果、sys が減って user が増えました。 オーバーヘッドの正体は「Sentry が Linux カーネルより遅い」ことではなく、「1 回の syscall に、Sentry を経由するぶんの往復が加わる」ことです。syscall を大量に呼ぶ処理ほど、この往復の回数が増えます。

したがって、性能を評価するときに見るべきなのは倍率ではありません。 「自分のワークロードが syscall をどれだけ呼ぶか」です。本番へ入れる前に、そのアプリで測ってください。 やってみよう④ では fanclub-backend で実際に測ります。

ステップ9:代償を実測する —— 通常 Pod が MAC 拘束を失う

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test normal-probe-wl01 -- cat /proc/self/attr/current

実行結果:

system_u:system_r:spc_t:s0

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n gvisor-test normal-probe-wl02 -- cat /proc/self/attr/current

実行結果:

system_u:system_r:container_t:s0:c55,c207

実行コマンド(k8s-ops 上・developer・ステップ1 より前から動いている Pod を選ぶために打ちます):

$ kubectl get pod -n fanclub -o wide --field-selector spec.nodeName=k8s-wl-01

ここから fanclub-frontend-* を 1 つ選びます。 本回の作業を始める前から wl-01 で動いており、作り直されていない Pod であることが条件です。

実行コマンド(k8s-ops 上・developer・<pod> は前のコマンドで選んだ fanclub-frontend-*):

$ kubectl exec -n fanclub <pod> -- cat /proc/self/attr/current

実行結果:

system_u:system_r:container_t:s0:c170,c324

ここで calico-node を選んではいけません。

calico-node のような特権 Pod は、変更の前後にかかわらず最初から spc_tsuper privileged container)です。MCS カテゴリも付きません。 つまり spc_t が返ってきても、それが「拘束を失った結果」なのか「元からそうだった」のかを区別できません。 比較対象には、必ず特権を持たない通常の Pod を選んでください。

対象ラベル意味
変更に wl-01 で作った通常 Podsystem_u:system_r:spc_t:s0MCS カテゴリが無い=拘束なし
同時刻に wl-02 で作った通常 Podsystem_u:system_r:container_t:s0:c55,c207MCS つき=第6回の拘束が生きている
変更から wl-01 で動いていた fanclub-frontendsystem_u:system_r:container_t:s0:c170,c324古いラベルのまま生き残っている
参考: calico-node(wl-01・kube-systemsystem_u:system_r:spc_t:s0特権 Pod なので元から spc_t。判定には使えない

3 行目が重要です。 enable_selinux = false にした瞬間に既存の Pod がラベルを失うわけではありません。次に作り直されたときに変わります。 第8回の PSA enforce と同じ時間差です。

この状態を放置すると、wl-01 で今後作られるすべての Pod が MAC 拘束の外に出ます。 本番なら、この時点で taint を打って通常 Pod を wl-01 から追い出しておくのが正しい手順です(前節のガードレール)。本書ラボでは taint を打てないので、やってみよう④ の最後に enable_selinux = true へ戻します。

ステップ10:観察用の資材を片付ける

実行コマンド(k8s-ops 上・developer):

$ kubectl delete namespace gvisor-test

実行結果:

namespace "gvisor-test" deleted

RuntimeClass gvisor とノードラベル sandbox.gvisor.dev/runtime=runsc は残します。 次の やってみよう④ で使います。enable_selinux = false も、やってみよう④ が終わるまで残します。 ④のステップ9 で、この 3 つとも元へ戻します。

この演習の合格条件

  • sandbox-probeRunning になり、uname -r4.19.0-gvisor を返すこと
  • 同じノードの通常 Pod が spc_t になっていることを自分の目で確認できていること(代償を数えたうえで採った設計である、という理解に到達すること)
  • k8s-wl-02 の enable_selinuxtrue のままであること

gVisor が動かないもの、観測できなくなるもの、そして Kata Containers

「gVisor は重い」は雑すぎる

「オーバーヘッドが大きいので高リスク Pod に絞る」という説明をよく見ます。方向は正しいのですが、理由が正確ではありません。

fanclub-backend(JDK 25 + Payara Micro 7.2026.4)の起動時間を、gVisor と通常で比べた実測があります。

対象Payara の ready in
gVisor 上(wl-01)レプリカ 1147,611 ms
gVisor 上(wl-01)レプリカ 2147,683 ms
gVisor 上(wl-01)レプリカ 3147,487 ms
通常(gVisor 適用前・wl-01 / wl-02)155,000〜155,960 ms

遅くなっていません。むしろ gVisor のほうが約 5% 速いという結果になりました。gVisor 側の 3 レプリカは 147.5〜147.7 秒の範囲に収まっており、ばらつきも小さいです。

理由は やってみよう③ のステップ8 で見た内訳から説明できます。 JVM の起動はクラスロードとバイトコード検証が主で、CPU の中で完結する処理が大半です。syscall の呼び出し密度が低いので、Sentry を経由する往復のコストがほとんど乗りません。数秒の差が付いた向きは、同居しているワークロードやページキャッシュの状態で説明できる範囲であり、「gVisor のほうが速い」と一般化できる話ではありません。 言えるのは 「JVM の起動時間は gVisor では有意に悪化しなかった」という 1 点です。

一方、syscall を大量に呼ぶ処理では sysuser へ移ります(ファイル作成 20,000 回)。適用判断の指針は次のとおりです。

ワークロードの性質gVisor の影響判断
CPU 主体(計算・JVM の起動・暗号処理)ほぼ無い適用しやすい
syscall 集約型(大量の小さいファイル I/O・大量のネットワーク接続の生成)sysuser へ移る。所要時間への影響は環境しだい実測してから判断する
ブロックデバイスのファイルシステムを直接使う動かない適用しない
独自のカーネルモジュール / デバイスファイルを使う動かない適用しない
サンドボックスの中で KVM を使う動かない適用しない

syscall の密度は、導入前に測れます。 候補のワークロードを通常のコンテナで動かし、代表的な処理を流している間の time の内訳(usersys の比)を見ます。sys の比率が高いほど、gVisor へ移したときに変わる部分が大きくなります。 本回の測定でいえば、通常コンテナで sysuser の 4 倍以上を占めた処理が syscall 集約型で、gVisor 側では逆に user が大半になりました。逆に、通常コンテナの時点で user が大半を占める処理は、そのまま移して構いません。

「JVM だから重い」という思い込みを持たないでください。 重くなるかどうかは言語や実行基盤ではなく、そのワークロードが 1 秒あたり何回 syscall を呼ぶかで決まります。測ってから決めるのが唯一の方法です。

未対応の syscall に当たったらどうなるか

gVisor 公式は 「(gVisor には)未実装の機能とバグが存在するし、これからも存在し続ける」と明記し、コンテナごとにテストすることを勧めています。

未対応の syscall に当たったときの症状は、アプリによって違います。 起動時に落ちることもあれば、特定の機能だけが動かないこともあります。「起動したから大丈夫」とは言えません。

本書は fanclub-backend で次の 4 点を確認します(やってみよう④)。この 4 点が「本番へ入れてよいか」を判断する最低ラインになります。

  1. Pod が Running になる
  2. /health/started/health/ready が 200 を返す(MicroProfile Health)
  3. /api/members が実データを返す(PostgreSQL への接続と JPA が通っている)
  4. 本番の Service 経由(https://fanclub.local/api/members)でも応答する

1 だけを見て済ませないでください。 JVM が起動しても、DB ドライバがソケットを開く段階で未対応の syscall に当たることはありえます。アプリの機能まで通して確かめるのが、サンドボックス導入の手順です。

観測できなくなるもの —— サンドボックスは監視ツールからも隠す

先に引いた公式の一文を、もう一度置きます。

gVisor never passes through any system call to the host.

(gVisor は system call をホストへ素通しすることが決して無い)

これは防御の説明として読んできましたが、裏返すと監視の話になります。

ホストカーネルへ syscall が届かないということは、ホストカーネルで syscall を見張っている道具からも見えないということです。コンテナのランタイムセキュリティ製品の多くは、その方式で動いています。 カーネルモジュールや eBPF プログラムをホストカーネルに仕込み、そこを通過する syscall を観測します。

サンドボックス内のプロセスが execve を呼んでも、その execve はホストカーネルを通りません。 Sentry が自前で処理して終わります。ホスト側のフックは何も検知しません。

第15回で導入する Falco が、この方式です。

Falco はホストカーネル側のドライバ(カーネルモジュール / eBPF)で syscall を捕まえるので、既定の構成ではサンドボックスの内側を観測できません。 「コンテナの中で予期しないシェルが起動した」という検知は、gVisor 上の Pod に対しては鳴りません。

手段が無いわけではありません。 gVisor 側には --pod-init-config というフラグがあり(runsc flags に実在します)、サンドボックス内のイベントを外へ流す口が用意されています。Falco 側にも gVisor 向けの設定があります。ただし、どちらも別途の作り込みが要ります。 「gVisor を入れたら Falco がそのまま効く」ということはありません。

セキュリティ機構を足すと別の機構が効かなくなる——SELinux との衝突と同じ構造が、監視の層でも起きています。 違うのは気づき方です。SELinux との衝突は Pod が起動しないという形で表に出ました。 監視との衝突は、何も起きません。 アラートが鳴らないことと、脅威が無いことの区別がつかない状態になります。「静かになった」ことを「安全になった」と読み違えないでください。

本回で扱った衝突は、これで 2 例になりました。1 例目は SELinux と gVisor で、起動できないという形で表に出ます。 2 例目は監視と gVisor で、動くけれど見えなくなるという形で現れます。 後者のほうが気づきにくいぶん、設計時に意識しておく価値があります。

Kata Containers —— 記法と分離モデルだけを扱う

もう 1 つの主要なサンドボックス方式が Kata Containers です。 Pod ごとに軽量な仮想マシンを立てて分離する方式で、分離の仕方が gVisor と根本的に違います。

観点gVisor(runsc)Kata Containers
分離の実体ユーザー空間のアプリケーションカーネル(Sentry)Pod ごとの軽量な仮想マシン
ゲストのカーネルGo で再実装した syscall インタフェース本物の Linux カーネル(VM の中)
ホストへの依存最小限の syscallハイパーバイザー(KVM / QEMU / Cloud-Hypervisor / Firecracker)
必要なもの特になし(systrap は VM の中でも動く)/dev/kvm(ハードウェア仮想化。VM の中ならネストされた仮想化が要る)
syscall の互換性再実装なので未対応がある本物のカーネルなので互換性は高い
起動のコストプロセス 3 個VM 1 台分

Kata の RuntimeClass は、handler が変わるだけです。

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata

Pod 側の書き方も同じです。

spec:
  runtimeClassName: kata

本書ラボでは Kata を実機で扱いません。

本書ラボのノードは Hyper-V のゲスト OS で、ネストされた仮想化が露出していないため /dev/kvm がありません。 Kata は原理的に起動しません。

第6回の AppArmor と同じ扱いです。 AppArmor は AlmaLinux で実機不可だったため、記法と概念のみを扱い、第19回の重点復習に送りました。Kata も同じ枠に入れます。

試験で問われるのは RuntimeClass の書き方までで、Kata の導入そのものは出題範囲外です。 分離モデルの違い(Pod ごとの軽量 VM・本物のカーネル・/dev/kvm が要る)と、handler: kata という記法を押さえてください。「試験環境には Kata が入っている」といった前提は置かないでください(公式にそのような記載はありません)。

gVisor と Kata は、同じ「サンドボックス」でも守り方の思想が違います。 gVisor は syscall インタフェースを自前で実装し直すことでホストカーネルを隠します。Kata は 本物のカーネルをもう 1 つ立てることでホストカーネルを隠します。前者は互換性を犠牲にして軽さを取り、後者は重さを受け入れて互換性を取っています。 どちらが優れているという話ではありません。

2 つの方式の選び方は、次のように整理できます。

状況選ぶもの
syscall の互換性を最優先したい / 未対応 syscall を検証する時間が無いKata(本物のカーネル)
ハードウェア仮想化が使えない環境(VM の中・クラウドの一部インスタンス)gVisorsystrap なら動く)
起動の速さと密度を優先したいgVisor(VM を立てない)
どちらも使えないサンドボックス以外の層で守る(PSS / seccomp / SELinux / NetworkPolicy / RBAC)

やってみよう④:fanclub-backend を GitOps で gVisor へ寄せ、代償を数えて元へ戻す

所要 60 分(うち試験相当の粒度は ステップ1 の 5 分)。

60 分のうち約 20 分は待ち時間です。 maxUnavailable: 0 の設定により backend は 1 台ずつ入れ替わり、Payara の起動が 1 台あたり約 2 分半かかるため、ロールアウトの完了までに約 9 分かかります。ステップ9 の撤去でも同じ約 9 分が必要です。合計 18 分の待ちが含まれることを見込んで着手してください。 試験で問われるのは「この Deployment をサンドボックスで動かせ」という形で、spec.template.spec.runtimeClassName に 1 行足すだけです。ステップ2〜10 は本番作法(GitOps 経由の適用・検証・代償の計測・安全な撤去)で、試験相当の粒度はありません。

本演習で加えた変更は、演習の中ですべて元へ戻します。 ステップ9 で、runtimeClassName の 1 行・RuntimeClass・ノードラベル・containerd の設定・導入したバイナリを撤去します。第11回以降のラボに、本回の変更は残りません。 戻すところまでが本演習です。

対象は k8s-ops(developer)と k8s-wl-01(root・ステップ9 の撤去のみ)です。

なぜ backend を選ぶのかを先に書いておきます。fanclub-api の 3 つのコンポーネントのうち、外部からの入力を受け取って処理しているのは backend だけです。frontend(Nginx)は静的ファイルを返すだけで、リクエストの中身を解釈しません。PostgreSQL は Pod 内部からしか接続されません。backend は HTTP のリクエストボディ(JSON)を解釈し、JPA 経由で SQL を組み立てます。 未知の入力を処理するコードが最も多い場所なので、サンドボックスの適用先として最初に選ぶのはここです。

ステップ1:base/backend-deployment.yaml に 1 行足す

試験ならこの 1 行で終わります。

試験で「この Deployment をサンドボックスで動かせ」と問われたら、次の 1 本が最短です。

kubectl patch deployment fanclub-backend -n fanclub --type=merge -p '{"spec":{"template":{"spec":{"runtimeClassName":"gvisor"}}}}'

本書ラボでは打ちません。 ArgoCD が Git を正本として同期しているので、クラスタを直接触ると次の sync で巻き戻されます。 本書ラボでは Git を直します。 「どちらが正本か」で打つコマンドが変わるという点そのものが、GitOps 環境で働くうえでの分かれ目です。

実行コマンド(k8s-ops 上・developer):

$ cd ~/gitops/gitops-fanclub

実行コマンド(k8s-ops 上・developer):

$ grep -n "spec:" base/backend-deployment.yaml

実測では spec.template.spec は 23 行目でした。 ここが runtimeClassName を挿入する位置です。

挿入する前に、このファイルについて 3 点を確認しておいてください。

  1. replicas1 です。 3 レプリカにしているのは overlays/prod のパッチのほうで、base は 1 のままです。本番のレプリカ数を base で探すと見つかりません。
  2. affinitytopologySpreadConstraints も書かれていません。 ここが最優先の確認項目で、分散制約が書かれていると RuntimeClassnodeSelector と衝突し、3 レプリカを wl-01 へ寄せられず Pending が出ます。
  3. Pod レベルの securityContextseccompProfile.type: RuntimeDefault が入っています第6回)。この行はそのまま残します。 実測では、runtimeClassName: gvisor を足しても 3 レプリカとも問題なく起動しました。

spec.template.spec の直下に、次の 1 行を追記します。

      runtimeClassName: gvisor

挿入後は次のようになります。

    spec:
      runtimeClassName: gvisor
      serviceAccountName: fanclub-backend
      automountServiceAccountToken: false

1 行しか足さないのに、演習④が 60 分かかる理由はここから先にあります。 適用そのものは 1 行です。時間がかかるのは、ロールアウトを待ち、適用したあとに「本当に動いているか」を確かめ、「何を失ったか」を数え、「元へ戻す」作業です。 試験で問われるのは最初の 1 行だけですが、実務で価値があるのは残りの 55 分のほうになります。

runtimeClassName は Pod の spec のフィールドです。 Deployment に書くときは spec.template.spec の直下であり、spec の直下でも containers の中でもありません。位置を間違えると kubectl apply の段階でエラーになります(不明なフィールドとして弾かれるか、無視されるかは apply の方式によります)。

ステップ2:Helm chart 側にも同じ 1 行を足す

~/fanclub-chart/templates/backend-deployment.yamlspec.template.spec 直下にも同じ 1 行を足します。挿入位置は Git 側と 1 行ずれますので、先に確認します。

実行コマンド(k8s-ops 上・developer):

$ grep -n "spec:" ~/fanclub-chart/templates/backend-deployment.yaml

実行結果:

8:spec:
22:    spec:

足すのは 22 行目のほう(Pod テンプレートの spec)です。8 行目は Deployment の spec なので、そこへ足すと replicasselector と同じ階層になり、適用時に弾かれます。Git 側の base/backend-deployment.yaml では同じ位置が 23 行目でした。chart には namespace: の行がある分だけ 1 行ずれます。行番号を覚えるのではなく、毎回 grep -n で確認してください。

      runtimeClassName: gvisor

第8回で確立した「両方直す」方針を踏襲します。 本書ラボには Helm chart(~/fanclub-chartGitOps リポジトリ(~/gitops/gitops-fanclub という 2 つの正本があり、ArgoCD が見ているのは後者です。chart 側を放置すると、次に helm upgrade を打った人が変更を巻き戻します。 二重管理は本来避けるべき状態ですが、本書ラボでは第1巻からの継続で両方が存在しています。現場でも同じ状態はよく起きるので、「両方直す」を作法として身につけてください。

戻すときも両方戻します(ステップ9)。「入れたところを全部覚えていること」が、撤去できることの前提です。入れた場所が 2 つあるなら、戻す場所も 2 つです。

ステップ3:Git へ push する

第9回と同じ手順です。no_proxy の追加を忘れないでください。

実行コマンド(k8s-ops 上・developer):

$ export no_proxy="$no_proxy,k8s-registry"

実行コマンド(k8s-ops 上・developer):

$ git add base/backend-deployment.yaml && git commit -m "backend: run under gVisor sandbox (RuntimeClass gvisor)" && git push

実行結果:

[main 4aa5383] backend: run under gVisor sandbox (RuntimeClass gvisor)
 1 file changed, 1 insertion(+)
remote: . Processing 1 references
remote: Processed 1 references in total
To http://k8s-registry:3000/developer/gitops-fanclub.git
   ec5c8dc..4aa5383  main -> main

3 本を && でつないだ形はそのまま通り、所要は 1 秒未満でした。 git add は成功時に何も出力しないので、最初に見えるのは git commit の要約行です。commit ハッシュ(4aa5383)は環境ごとに変わります。

export no_proxy="$no_proxy,k8s-registry" を忘れると 403 で失敗します。 k8s-ops の /etc/profile.d/proxy.shno_proxyk8s-registry が入っていないため、Git の通信が Squid 経由になります。第9回・第6回・第8回でも同じ形が出ました。恒久的に直すなら /etc/profile.d/proxy.sh に足してください。

ステップ4:ArgoCD の sync を待つ

実行コマンド(k8s-ops 上・developer):

$ kubectl get application fanclub-api-prod -n argocd -w

実測では、push から Synced になるまで 15 秒でした。 ただし既定のポーリング周期に当たると最大 3 分かかります(第9回では 182 秒でした)。15 秒で来ることもあれば 3 分待つこともあると考えて、待ちを見込んでください。

その先のロールアウトのほうが長くかかります。 Synced のあと、Progressing を経て Healthy になるまで約 9 分でした。maxUnavailable: 0 で 1 台ずつ入れ替わり、Payara の起動が 1 台あたり約 2 分半かかるためです。

ここで待っているのは「ArgoCD が Git の変更に気づいて、クラスタへ反映し終える」までの時間です。 Synced は Git とクラスタの差分が無くなったこと、Healthy は反映後のリソースが正常な状態に達したことを示します。backend は起動に 2 分半かかるので、Synced になってから Healthy になるまでにさらに待ちが入ります。

ArgoCD の自動 sync は既定のポーリング間隔(3 分)に依存します。 本回の 15 秒と第9回の 182 秒はどちらも正常で、push した瞬間がポーリング周期のどこに当たったかで決まります。 待てないときは argocd app sync fanclub-api-prod で手動 sync を掛けてください。

ステップ5:3 レプリカがどこへ載ったかを確認する

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod -n fanclub -l app=fanclub-backend -o wide

実行結果:

NAME                               READY   STATUS    RESTARTS   AGE     IP               NODE
fanclub-backend-5d99475f98-66cx5   2/2     Running   0          5m52s   10.244.215.239   k8s-wl-01
fanclub-backend-5d99475f98-wk9z5   2/2     Running   0          2m41s   10.244.215.205   k8s-wl-01
fanclub-backend-5d99475f98-wrrxz   2/2     Running   0          9m14s   10.244.215.202   k8s-wl-01

3 レプリカとも k8s-wl-01 に載りました。 AGE 列に 9 分 14 秒・5 分 52 秒・2 分 41 秒と約 3 分ずつの差が付いている点にも注目してください。1 台ずつ順に入れ替わったことがそのまま出ています。

Pending の Pod が出た場合は、原因はステップ1 で確認した affinity / topologySpreadConstraints との衝突か、wl-01 の残容量不足のどちらかです。kubectl describe pod の Events で切り分けてください。

3 レプリカが 1 ノードに収まるかは、そのノードの残容量次第です。

寄せる前に kubectl describe node k8s-wl-01Allocated resources を見てください。 実測では CPU requests が 2190m (54%) / 4メモリ requests が 2340Mi (30%) / 7.4GiPod 数が 51/110 でした。backend 3 レプリカの追加分(+250m / +512Mi)に対して余裕があります。 Pending が出た場合は kubectl describe pod の Events に Insufficient cpu / Insufficient memory が出ます。

サンドボックスノードへ寄せる設計は、そのノードの容量計画とセットです。 しかも Sentry と Gofer の常駐分は overhead を設定していない限りスケジューラの計算に現れません。 スケジューラが「入る」と判断しても、実際のメモリは足りていないという状態がありえます。専用ノードを作るなら、容量も専用に確保してください。

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod -n fanclub -l app=fanclub-backend -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.nodeSelector}{"\n"}{end}'

3 つとも {"sandbox.gvisor.dev/runtime":"runsc"} が返ります。 base/backend-deployment.yaml にも chart にも nodeSelector は 1 行も書いていません。RuntimeClassscheduling.nodeSelector が Admission で注入された結果です。

ステップ6:サンドボックスで動いていることを確認する

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n fanclub deploy/fanclub-backend -- uname -r

実行結果:

Defaulted container "backend" out of: backend, wait-for-db (init), log-shipper (init)
4.19.0-gvisor

1 行目は警告ではありません。 このポッドには複数のコンテナがあるため、-c を省くと kubectl が先頭のコンテナを選んだことを知らせてきます。意図したコンテナを見ているかは、この行で確認してください。 明示するなら -c backend を付けます。

実行コマンド(k8s-ops 上・developer・<pod> はステップ5 で確認した backend の Pod 名):

$ kubectl logs -n fanclub <pod> -c backend | grep "ready in"

実測では 3 レプリカがそれぞれ 147,611 ms / 147,683 ms / 147,487 ms でした。 gVisor 適用前の 155,000〜155,960 ms と比べて遅くなっていません。

ここで deploy/fanclub-backend を指定しないでください。

kubectl logs deploy/...Found 4 pods, using pod/... と表示してそのうち 1 つを勝手に選びます。 実測では、ローリング更新の最中に打ったため「削除中の古い Pod」が選ばれ、runc 時代のログが返ってきました。

ローリング更新の最中は、Deployment 指定の logs が新旧どちらの Pod を拾うか決まりません。 新旧が混在しうる場面では、kubectl get pod で Pod 名を確認してから、その名前を指定して logs を打ってください。 3 レプリカぶんを見たいなら 3 回打ちます。

backend の起動には 2 分半近くかかります。 これは gVisor のせいではありません(適用前も 155 秒前後でした)。kubectl get pod1/2 を返している間は、まだ起動途中です。 readiness probe が通るまで待ってください。

ステップ7:アプリの機能まで確認する(CRUD が維持されること)

本ステップで確認するのは、https://fanclub.local/api/members が本番経路で実データを返すことです。参照系が通れば、Payara の起動・PostgreSQL への接続・JPA・Service / EndpointSlice・Gateway API・NetworkPolicy がすべて通っていることになります。ブラウザで CRUD を触れる状態が維持されているかを、必要なら画面からも確かめてください。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n fanclub deploy/fanclub-frontend -- curl -s -o /dev/null -w "%{http_code}\n" http://<backend の Pod IP>:8080/health/started

実測では /health/started/health/ready200 を返しました。 URL の末尾を /health/ready に変えて、もう 1 本打ってください。

backend のコンテナから curl は打てません。

backend のイメージ(JDK 25 ベース)に curl が入っていないためで、kubectl exec -n fanclub deploy/fanclub-backend -- curl ... を打つと次のように弾かれます。

error finding executable "curl" in PATH [/opt/java/openjdk/bin ...]

そこで curl を持っている frontend(Nginx)の Pod から、backend の Pod IP を直接叩きます。 Pod IP はステップ5 の -o wide で確認した値に読み替えてください。「確認したいコンテナに道具が入っているとは限らない」——これは第13回以降で扱う「イメージを最小化するほど中で調べにくくなる」という話の、最初の実例になります。

実行コマンド(k8s-ops 上・developer):

$ curl -s --noproxy "*" https://fanclub.local/api/members | head -c 400

実測では会員 3 名(鈴木花子 / 佐藤一郎 / 復元テスト太郎)の JSON が返りました。 Payara の起動・PostgreSQL への接続・JPA・Service / EndpointSlice・Gateway API・NetworkPolicy が、すべて gVisor 上で通っていることになります。

実行コマンド(k8s-ops 上・developer):

$ kubectl get endpointslice -n fanclub -l kubernetes.io/service-name=fanclub-backend

実測では、EndpointSlice のアドレスは 10.244.215.202 / 10.244.215.239 / 10.244.215.205 の 3 つでした。 ステップ5 で確認した 3 レプリカの Pod IP と一致しており、gVisor 上の Pod だけが載っていることになります。

本番の Service 経由で応答することまで確認してください。 Pod が Running になり uname -r が gVisor を返しても、EndpointSlice に載っていなければトラフィックは来ません。 サンドボックス化は runtime の話ですが、サービスとして成立しているかは別の観点です。

NetworkPolicy も runtime を問いません。 第3回で設計した 9 本の NetworkPolicy は、Pod のラベル(app: fanclub-backend)に基づいて適用されます。 gVisor 上でもそのまま効きます(実測で確認済みです)。CNI は Pod のネットワーク名前空間に対して働くので、サンドボックスの内側で何が動いているかとは独立しています。

ステップ8:代償を 3 つ数える

ここまでで 「gVisor 上で fanclub-backend が本番経路のまま動く」ことは確かめられました。動くことと、入れるべきことは別です。 何を失ったのかを 3 つ数えてから、入れるかどうかを決めます。

代償①: wl-01 で新規に作られる Pod が MAC 拘束を失う

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n fanclub deploy/fanclub-backend -- cat /proc/self/attr/current

実行結果:

Defaulted container "backend" out of: backend, wait-for-db (init), log-shipper (init)
cat: /proc/self/attr/current: No such file or directory

やってみよう③ で busybox に打ったときの cat: can't open '/proc/self/attr/current' とは文面が違います。 busybox の cat と、backend イメージに入っている GNU coreutils の cat でメッセージが異なるだけで、言っていることは同じ(そのファイルが無い)です。エラー文面はコマンドの実装によって変わるので、文字列で一致を取る自動チェックを書くときは注意してください。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n fanclub deploy/fanclub-frontend -- cat /proc/self/attr/current

実行結果:

system_u:system_r:container_t:s0:c170,c324

frontend は本回の作業を通して作り直されていないので、古いラベルのまま生きています。 結果は frontend がどのノードに載っているかでも変わります。 wl-01 で作り直されていれば spc_t、wl-02 なら container_t:s0:cXXX,cYYY です。読む前に -o wide でノードを確認してください。

やってみよう③ のステップ9 で見た spc_t は、観察用の Pod だけの話ではありません。 wl-01 でこれから作り直されるすべての Podが、第6回で入れた MAC 拘束の外に出ます。 サンドボックスとは無関係な Pod も含めてです。

本来はここで taint を打ちます。 ノードにサンドボックス Pod 以外が載らなくなれば、この代償は消えます(本番ガードレールの 2 番目)。本書ラボでは打てません。 wl-01 には 51 個の Pod が乗っており、taint を打つとその全部が行き先を探し始めます。 残る Workload Node は wl-02 の 1 台だけです。

代償②: backend の可用性が 1 ノードに依存する

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod -n fanclub -o wide --sort-by=.spec.nodeName

実測の配置は次のとおりでした。 k8s-wl-01 に backend × 3 + frontend × 1 + logcollector × 1、k8s-wl-02 に db-0 + frontend × 1 + logcollector × 1backend だけが 1 ノードに集中しています。

backend の 3 レプリカがすべて k8s-wl-01 に載っています。 第2巻で 2 台の Workload Node へ分散させて作った冗長性が、サンドボックス化と引き換えに無くなりました。 wl-01 が落ちれば backend は全滅します。

これは gVisor の欠陥ではなく、サンドボックスノードが 1 台しかない本書ラボの制約です。 本番なら サンドボックスノードを最低 2 台用意し、topologySpreadConstraints で分散させます(本番ガードレール)。「セキュリティを上げたら可用性が下がった」という交換を、無自覚に受け入れないでください。 交換したことを認識し、記録し、必要なら追加のノードで買い戻すのが設計です。

代償③: ホストの syscall を見る監視から、このワークロードが見えなくなる

前の節で扱ったとおりです。

サンドボックス内の syscall はホストカーネルへ届かないので、ホストの syscall を見張っている監視ツールからは観測できません。

第15回で導入する Falco は、既定のドライバではこの backend の中を見られません。 「backend のコンテナの中で予期しないシェルが起動した」という検知が、gVisor へ寄せた瞬間に鳴らなくなります。

gVisor 側には --pod-init-configrunsc flags に実在します)という監視連携の口があり、Falco 側にも gVisor 向けの設定があります。 ただし どちらも別途の作り込みが要ります。 「入れたら勝手に効く」ものではありません。

3 つの代償のうち、これが最も気づきにくいものです。 ①と②は kubectl で見えます。③は「何も起きない」という形で現れます。 アラートが鳴らないことを「安全になった」と読み違えると、検知の穴を自分で開けたまま運用することになります。

数え終わったところで、判断します。

本書ラボでは、gVisor を恒久導入しません。

理由は 1 つに集約できます。Workload Node が 2 台しかない本書ラボでは、サンドボックス専用ノードを本当の意味で専用にできません。 専用にするには taint が要りますが、taint を打つと 51 個の Pod の行き先が無くなります。 専用にできないなら、代償①(wl-01 の全 Pod が MAC 拘束を失う)はサンドボックスと無関係な Pod にまで及び続けます。 そして代償②(可用性)と代償③(監視の穴)も、サンドボックスノードを増やせない以上、買い戻せません。

専用にできないなら、代償だけが残ります。 よって本書ラボでは恒久化せず、作り方と判断基準を持ち帰ります。 Workload Node が 4 台以上ある環境なら、ここまでと同じ手順で「本当に専用の」サンドボックスノードを作れます。 そのときは taint を打ち、topologySpreadConstraints で 2 台以上へ分散させ、overhead を実測して設定してください。

ステップ9:元へ戻す

戻すものは 5 つあります。 順序が重要で、Kubernetes 側 → ノード側の順に戻します。

9-1: Git を revert して backend を runc へ戻す

実行コマンド(k8s-ops 上・developer):

$ cd ~/gitops/gitops-fanclub

実行コマンド(k8s-ops 上・developer):

$ git revert --no-edit HEAD

実行結果:

[main 5298be5] Revert "backend: run under gVisor sandbox (RuntimeClass gvisor)"
 1 file changed, 1 deletion(-)

1 insertion(+) だったものが 1 deletion(-) になっています。 足した 1 行がそのまま取り除かれました。コンフリクトが起きて git revert が通らない場合は、base/backend-deployment.yaml から runtimeClassName: gvisor の 1 行を手で削って commit する形でも構いません。

実行コマンド(k8s-ops 上・developer):

$ git push

実行結果(末尾の 1 行):

   4aa5383..5298be5  main -> main

no_proxy はステップ3 で設定済みですが、別のシェルに入り直している場合は再度 export が要ります。

実行コマンド(k8s-ops 上・developer):

$ kubectl get application fanclub-api-prod -n argocd -w

実測では、revert の反映(Synced)まで 61 秒でした。 そこからロールアウトが終わって Healthy になるまでは、適用時と同じく約 9 分かかります。戻すときも 1 台ずつ入れ替わるためで、ここが演習④で最も長い待ち時間になります。

git revert は「打ち消す commit を新しく作る」操作です。 git reset のように履歴を書き換えるわけではありません。適用した記録も、戻した記録も、両方が残ります。 共有リポジトリでは reset より revert のほうが安全で、しかも 「いつ入れて、いつ戻したか」が後から追えるという利点があります。

入れた経路と戻す経路が同じであることが、GitOps の利点そのものです。 クラスタを直接触って入れていたら、戻すときにも「どこを触ったか」を思い出す必要がありました。Git に 1 commit として残っているものは、1 commit で戻せます。

9-2: Helm chart 側も戻す

~/fanclub-chart/templates/backend-deployment.yaml から runtimeClassName: gvisor の 1 行を削除します。

実行コマンド(k8s-ops 上・developer):

$ grep -c runtimeClassName ~/fanclub-chart/templates/backend-deployment.yaml

実行結果:

0

入れた場所が 2 つなら、戻す場所も 2 つです(ステップ2)。chart 側に残したままにすると、次の helm upgrade で gVisor 指定が戻ってきます。 そのときノードには runsc が入っていないので、Pod は ContainerCreating から進まなくなります(やってみよう② のステップ10 で見た no runtime for "runsc" is configured)。片付け残しは、忘れた頃に同じエラーで返ってきます。

9-3: RuntimeClass とノードラベルを削除する

ここから先へ進む前に、backend が runc へ戻り切っていることを確認してください。

9-2 は待ち時間ゼロで終わりますが、9-1 で流したロールアウトはまだ走っています(適用時と同じく約 9 分かかります)。ロールアウトの途中で RuntimeClass を消したり、この先の 9-5 で runsc のバイナリを削除したりすると、まだ gVisor で動いている Pod が、実行ファイルの無い状態で取り残されます。 systemctl restart containerd が既存の Pod を落とさないのと同じ理由(shim が生き続ける)で、その Pod は Running のまま残ります——次に作り直されるまで、壊れていることに気づけません。

次の 2 つが揃ってから進んでください。

$ kubectl get pod -n fanclub -l app=fanclub-backend
$ kubectl exec -n fanclub deploy/fanclub-backend -- uname -r

3 レプリカとも 2/2 RunningAGE が新しく、uname -r6.12.0-211.22.1.el10_2.x86_64(ホストのカーネル)を返せば、撤去を続けて構いません。 4.19.0-gvisor が返る間は、まだ待ってください。

実行コマンド(k8s-ops 上・developer):

$ kubectl delete runtimeclass gvisor

実行結果:

runtimeclass.node.k8s.io "gvisor" deleted

実行コマンド(k8s-ops 上・developer):

$ kubectl label node k8s-wl-01 sandbox.gvisor.dev/runtime-

実行結果:

node/k8s-wl-01 unlabeled

ラベルのキーの末尾に - を付けると削除になります。 kubectl label の記法です。

9-4: wl-01 の containerd 設定をバックアップから復元する

実行コマンド(k8s-wl-01 上・root):

# cp -p /root/config.toml.bak-10 /etc/containerd/config.toml

実行コマンド(k8s-wl-01 上・root):

# systemctl restart containerd

実行コマンド(k8s-wl-01 上・root):

# containerd config dump | grep enable_selinux

実行結果:

    enable_selinux = true

実行コマンド(k8s-wl-01 上・root):

# containerd config dump | grep -c runtimes.runsc

実行結果:

0

runsc の登録が消えています。 1 回の復元で enable_selinux と runsc 登録の両方が同時に戻りました。

バックアップから戻すと、2 つの変更が同時に元へ戻ります。 やってみよう② のステップ5 で足した runsc の登録と、やってみよう③ で変えた enable_selinux = false です。どちらも /etc/containerd/config.toml の中にあるので、ファイルごと戻せば 1 回で済みます。

これが「触る前にバックアップを取る」ことの見返りです。 変更箇所を 1 つずつ思い出して手で戻す作業は、戻し漏れを必ず生みます。 cp -p で取ったファイルが 1 つあれば、その心配が消えます。

9-5: 導入したバイナリを削除する

実行コマンド(k8s-wl-01 上・root):

# rm -f /usr/bin/runsc /usr/bin/containerd-shim-runsc-v1

実行コマンド(k8s-wl-01 上・root):

# command -v runsc

実測では何も返らず、終了ステータスが非 0 になりました。 command -v は見つからないときに無出力で終了コード 1 を返します。「何も出ないこと」が正しい結果です。

ここを省かないでください。 ノードに root 権限で置いた実行ファイルは、消さない限り残り続けます。 containerd の設定から登録を消しても、バイナリ自体は /usr/bin に残ります。 誰かが runsc と打てば動きますし、次にそのノードを調べた人は「なぜここに gVisor があるのか」を判断できません。

検証で入れたものを片付けるのは、第13回・第14回で扱うサプライチェーンの話と地続きです。 「このノードで動く実行ファイルは、誰が・いつ・どこから持ってきたものか」を答えられる状態を保つことが、サプライチェーンを管理するということです。答えられない実行ファイルを 1 つ残すたびに、その状態から遠ざかります。

ステップ10:戻ったことを確認する

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod -n fanclub -l app=fanclub-backend -o wide

実測では、backend 3 レプリカが k8s-wl-02 に 2・k8s-wl-01 に 1 という配置に戻りました。 RuntimeClassnodeSelector が外れたことで、スケジューラが 2 台へ分散させています。 代償②(可用性の後退)が解消されたことを、この 1 コマンドで確認できます。

実行コマンド(k8s-ops 上・developer):

$ kubectl exec -n fanclub deploy/fanclub-backend -- uname -r

実行結果:

Defaulted container "backend" out of: backend, wait-for-db (init), log-shipper (init)
6.12.0-211.22.1.el10_2.x86_64

ノードのカーネルに戻っています。 4.19.0-gvisor は返りません。

実行コマンド(k8s-ops 上・developer):

$ kubectl run relabel-check -n default --image=busybox:1.37 --restart=Never --rm -it --overrides='{"spec":{"nodeName":"k8s-wl-01"}}' -- cat /proc/self/attr/current

実行結果:

warning: couldn't attach to pod/relabel-check, falling back to streaming logs: unable to upgrade connection: container relabel-check not found in pod relabel-check_default
system_u:system_r:container_t:s0:c350,c420

MCS カテゴリ(c350,c420)が付いています。 第6回の拘束が復活しました。カテゴリの数字は毎回変わるので、読者の環境では別の値になります。見るべきなのは spc_t ではなく container_t になっていることと、:cXXX,cYYY が付いていることの 2 点です。

先頭の warning: couldn't attach to pod/... は、コマンドが一瞬で終わって端末の接続が間に合わなかったときに出る通知です(本回の冒頭で kernel-check を打ったときと同じ形です)。

ラベルが戻るのは、撤去後に作り直された Pod だけです。 導入中に wl-01 で作られた Pod は spc_t のまま残ります(設定変更のときと同じ時間差です)。「戻したら全部戻る」わけではありません。 完全に揃えるには、該当する Pod を作り直す必要があります。

実行コマンド(k8s-ops 上・developer):

$ kubectl get runtimeclass

実行結果:

No resources found

本回の起点と同じ「RuntimeClass 0 個」に戻りました。

実行コマンド(k8s-ops 上・developer):

$ kubectl get node

実行コマンド(k8s-ops 上・developer):

$ kubectl get pod --all-namespaces --no-headers | grep -v "Running\|Completed" | wc -l

実行コマンド(k8s-ops 上・developer):

$ curl -s -o /dev/null -w "%{http_code}\n" --noproxy "*" https://fanclub.local/

実測では 3 つとも起点と同じ値に戻りました。 ノードは 5/5 Ready(v1.36.3)、異常な Pod は 0(Pod の総数は 103)、HTTPS は 200 です。ArgoCD も Synced / Healthy に戻っています。

実行コマンド(k8s-ops 上・developer):

$ curl -s --noproxy "*" https://fanclub.local/api/members | head -c 400

撤去後も会員 3 名(鈴木花子 / 佐藤一郎 / 復元テスト太郎)の JSON が返ります。 適用前・適用中・撤去後で、アプリの見え方は 1 度も変わりませんでした。

入れた設定を撤去して回を終えることに、後ろめたさを感じる必要はありません。

代償を数えられる状態にしてから決めるのが本回の目的で、決めた結果が「ここでは入れない」でも学習は完了しています。

本回で読者が身につけたのは、次の 4 つです。

  1. ノードにサンドボックスランタイムを導入して RuntimeClass で結線する手順
  2. サンドボックスで動いていることを証拠つきで確認する方法
  3. 入れたことで何を失うのかを 3 つ数える視点
  4. 入れたものを安全に撤去する手順

このうち 3 と 4 は、入れっぱなしにしていたら身につきません。 4 台以上の Workload ノードがある環境なら、ここまでと同じ手順で専用ノードを作れます。 そのときに変わるのは手順ではなく、taint を打てるかどうかという 1 点だけです。

この演習の合格条件

  • 適用時に https://fanclub.local/api/members が実データを返し、kubectl exec -n fanclub deploy/fanclub-backend -- uname -r4.19.0-gvisor を返したこと(サンドボックス化した状態でアプリが本番経路のまま動いたこと)
  • 代償を 3 つ自分の言葉で説明できること
  • 撤去後に backend が 2 ノードへ分散して 3/3 で動き、wl-01 の新規 Pod が container_t に戻り、https://fanclub.local/ が 200 を返すこと

PSA の exemptions.runtimeClasses —— サンドボックスが PSS の穴になる

RuntimeClass には、セキュリティ上もう 1 つの顔があります。Pod Security Admission の免除対象として指定できるという顔です。exemptions.runtimeClasses は、PSA の検査そのものを免除する RuntimeClass 名の一覧で、namespace に enforce ラベルが付いていても免除が優先します。

第8回で作った AdmissionConfiguration は、3 台の Control Plane Node にファイルとして残っています

実行コマンド(k8s-cp-01 上・root):

# cat /etc/kubernetes/admission/admission-config.yaml

実測では、defaultsenforce: baseline / warn: restricted / audit: restrictedexemptionsusernames: [] / runtimeClasses: [] / namespaces: ["kube-system"] という構造でした。 第8回で作ったまま、1 文字も変わらずに残っています。

AdmissionConfiguration の全体の構造は 第8回で全量を扱いました(3 台の Control Plane Node への配布と、kube-apiserver.yaml への 3 点セット追加まで実演しています)。ここでは本回に関係する exemptions の 3 行だけを再掲します。

    exemptions:
      usernames: []
      runtimeClasses: []
      namespaces: ["kube-system"]

exemptions の 3 つの次元

公式は免除の次元を 3 つ挙げています。このうち 1 行目と 3 行目は第8回で扱いました。本回の主題は 2 行目です。

次元何が免除されるか本回での扱い
Usernamesその認証済みユーザー名(またはなりすまし先)からのリクエストが無視される第8回で扱った
RuntimeClassNamesその RuntimeClass 名を指定した Pod とワークロードリソースが無視される本回で回収する
Namespacesその namespace の Pod とワークロードリソースが無視される第8回で扱った

「無視される(ignored)」の意味を正確に押さえてください。 PSS の検査に通るのではありません。検査そのものが行われません。

この違いは監査ログの見え方にも出ます。 検査に通った場合は「検査した結果、問題なし」という記録が残りますが、免除された場合は検査した記録そのものが残りません。 warn の警告も audit の記録も出ません。「PSS を通った Pod」と「PSS を通っていない Pod」が、あとから区別しにくくなります。

namespace のラベルより免除が優先する

本節で最も重要な点です。

第8回で fanclub namespace に pod-security.kubernetes.io/enforce: restricted を付けました。このラベルがあれば、AdmissionConfigurationdefaultsenforce: baseline)は fanclub には効きません。ラベルのほうが強いためです。

しかし exemptions は違います。 免除は 検査の前段で効きます。runtimeClasses: ["gvisor"] と書いた瞬間から、runtimeClassName: gvisor を持つ Pod は、enforce=restricted の namespace であっても PSS の検査を受けなくなります。

つまり fanclub namespace の中に、次のような Pod を作れるようになります。

apiVersion: v1
kind: Pod
metadata:
  name: escape
  namespace: fanclub
spec:
  runtimeClassName: gvisor
  hostPID: true
  hostNetwork: true
  containers:
    - name: escape
      image: busybox:1.37
      command: ["sleep", "3600"]
      securityContext:
        privileged: true

enforce=restricted の namespace に privileged: true の Pod が入ります。 通常なら Admission で弾かれる形です。免除を 1 行足しただけで、第8回で作った防御が特定の Pod に対して丸ごと無効になります。

本書では配線しない(判断とその理由)

AdmissionConfiguration は 3 台の Control Plane Node にファイルとして残っていますが、API Server には接続されていません。

実行コマンド(k8s-cp-01 上・root):

# grep -n "admission-control-config-file\|encryption-provider-config" /etc/kubernetes/manifests/kube-apiserver.yaml

実行結果:

24:    - --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
25:    - --encryption-provider-config-automatic-reload=true

--admission-control-config-file は 1 件も出ません。 第8回で撤去済みで、設定ファイルだけが Control Plane Node に残っている状態です。ファイルがあることと、API Server がそれを読んでいることは別です。

本書ラボでは --admission-control-config-file を接続しません。

理由を 4 つ挙げます。

  1. 手順としては第8回で既に扱っています。 3 台への配布と kube-apiserver.yaml への 3 点セット追加は、第8回・第9回で 2 度通った形です。同じ手順を 3 度繰り返しても、新しく学べるものがありません
  2. 配線には 3 台の API Server 再起動(実測 42〜45 秒 × 3)が要ります。 本回の主題(gVisor と SELinux の衝突・ノード単位の設計)から時間を奪います
  3. 配線して免除を書くと、以降 9 回分のラボに穴が残ります。 runtimeClasses: ["gvisor"] を入れたクラスタは、gvisor を指定すれば PSS を全部すり抜けられるクラスタ」になります。第8回で作った restricted の強制を、第11回以降ずっと弱めたまま進めることになります
  4. 免除を入れずに理解できます。 危険なのは「免除を書くとどうなるか」であって、書いた状態を実際に作る必要はありません。上の escape Pod の YAML を読んで、第8回で学んだ PSA の判定順から結論を導くほうが、理解としては確実です

exemptions を使う正当な場面もあります。 「PSS の restricted を満たせないが、サンドボックスで隔離しているから許容する」という設計判断です。ただしそれは、サンドボックスが PSS と同等以上の防御を提供していると評価できる場合に限ります。 gVisor は syscall 境界を提供しますが、hostNetwork: truehostPID: true を無害にはしません(これらは Kubernetes 層の設定で、サンドボックスの外側の話です)。免除は「サンドボックスだから安全」ではなく、「この Pod のこの逸脱を、この理由で許容する」という個別の判断として書いてください。

試験でどう問われるか

CKS は「設定を書け」だけでなく「危険な設定を見つけろ」も問います。 AdmissionConfigurationexemptions は、「なぜこの namespace で privileged な Pod が作れてしまうのか」という調査系の設問の答えになりえます。

確認の順序を型にしておきます。

  1. namespace のラベルを見る(kubectl get ns <ns> --show-labels
  2. API Server に --admission-control-config-file があるかgrep する)
  3. あればその中身の exemptions を見るusernames / runtimeClasses / namespaces の 3 つ)
  4. 対象 Pod の runtimeClassName と、それを作ったユーザー / SA を照合する

AdmissionConfigurationkubernetes.io/docs で引けます(Resources Allowed の 1 番目)。exemptions のフィールド名を暗記していなくても、その場で開けます。 一方 gVisor 側は引けません第19回の重点復習)。「どちらが引けるか」を意識してコマンドを覚えてください。

暗記必須コマンド(RuntimeClass / gVisor / テナント境界)

本回は「参照できるもの」と「暗記が要るもの」がはっきり分かれます。

CKS の Resources Allowed は 8 件で、kubernetes.io/docs は含まれますが gvisor.devcontainerd.io も含まれません。 RuntimeClass の書式は引けますが、gVisor 側は引けません。

containerd の記法だけは、途中に線が入ります。 containerd 1.x(config version = 2)の記法は kubernetes.io/docs の RuntimeClass ページに載っているので、試験中にその場で開けます。2.x(version = 3)の記法はどこにも載っていません。 試験環境がどちらの版かは開いてみないと分からないので、head -1 /etc/containerd/config.toml で確認してから書く手順と、2.x の記法を暗記してください。

用途コマンド / 設定試験中参照
RuntimeClass の一覧kubectl get runtimeclasskubernetes.io/docs
RuntimeClass の定義apiVersion: node.k8s.io/v1 / kind: RuntimeClass / handler: <CRI の設定名>
ノードを限定するscheduling.nodeSelectorPod の nodeSelector と積を取る。衝突すると拒否
taint されたノードへ載せるscheduling.tolerationsPod の tolerations と和を取る
サンドボックスの消費を申告overhead.podFixed.cpu / .memory
Pod へ適用spec.runtimeClassName: gvisor(Deployment なら spec.template.spec の直下
PSA の免除AdmissionConfigurationexemptions.runtimeClasses + --admission-control-config-file
テナント境界の 4 部品Namespace / Role + RoleBinding / NetworkPolicypodSelector: {})/ ResourceQuota + LimitRange
権限の越境確認kubectl auth can-i <verb> <resource> -n <ns> --as=system:serviceaccount:<ns>:<sa>
runsc の取得https://storage.googleapis.com/gvisor/releases/release/20260721/x86_64/runsc日付でピン留めする。containerd-shim-runsc-v1 も要る不可(暗記)
containerd への登録1.x / config version = 2のテーブル名[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.${HANDLER_NAME}]kubernetes.io/docs の RuntimeClass ページにこの 1 行だけ載っている)
containerd への登録2.x / config version = 3のテーブル名[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runsc]不可(暗記)kubernetes.io にも gvisor.dev にも 2.x の記法は無い
runtime_type の値(1.x / 2.x 共通)runtime_type = 'io.containerd.runsc.v1'不可(暗記)RuntimeClass ページにはテーブル名しか無く、runtime_typerunsc の語も出てこない
どちらの記法を使うかの判断head -1 /etc/containerd/config.tomlversion を確認する不可(暗記)
設定の反映確認containerd config dump | grep -A 6 runtimes.runsc不可(暗記)
サンドボックス判定(最短)kubectl exec <pod> -- uname -r4.19.0-gvisor不可(暗記)
ノード側の確認ps -eo pid,comm,args | grep runscrunsc-sandbox ... boot = Sentry / runsc-gofer ... gofer = Gofer)不可(暗記)
Kata の RuntimeClasshandler: kata/dev/kvm が要る不可(暗記)
runsc installDocker のデーモン設定を書き換えるコマンド。containerd には無関係不可(暗記)

runsc install は「打ってはいけない」わけではありません。 Docker が入っていれば正しく動きます。containerd 環境で打っても意味が無いというだけです。試験で「gVisor を使えるようにせよ」と問われて runsc install を打つと、何も変わらないまま時間を失います。

まとめ

本回では次の点を確認しました。

  • テナント境界は 1 つの機能では作れない。 Namespace / RBAC / NetworkPolicy / ResourceQuota + LimitRange の 4 部品を組み合わせて作る。Namespace 単体は何も止めない(他の 3 部品を貼るための面である)
  • 最も重要なのは RBAC。 公式が「互いの API リソースを読み書きできると、他のあらゆるポリシーを書き換えたり無効にしたりできる」と書いている。RBAC が崩れると残り 3 部品も無効になる
  • 境界は両側に張らないと片方向しか止まらない。 tenant-a にだけ default-deny を張ると、tenant-b → tenant-a は止まるが tenant-a → tenant-b は素通りする。テナントごとに 4 部品を揃えるテンプレート運用が要る
  • Quota 超過は kubectl apply では分からない。 exceeded quotaReplicaSet の Events にしか出ない。第8回・第9回に続く「apply は通るのに動かない」型の 3 例目
  • ResourceQuota は書いたすべての項目を同時に見る。 最初に上限へ当たった項目が実際の上限になる(演習①では pods: "4" に余裕があるのに requests.cpu が律速して 2 個で止まる)。LimitRange が無いと、requests.cpu を含む Quota がある namespace では resources を書いていない Pod がそもそも作れない
  • 「ハード / ソフト」は二者択一ではない。 公式は a broad spectrum(幅のある連続体)と書いている。「テナント同士が互いを信頼していないか」が分かれ目
  • コンテナはホストカーネルを共有している。 コンテナの中で uname -r を打つとホストのカーネルが返る。seccomp も SELinux も、この共有を前提にしたまま範囲を絞る防御である
  • サンドボックスは 4 部品を置き換えない。 RBAC が緩ければ gVisor 上の Pod からでも他テナントの Secret は API 経由で読める。サンドボックスが減らすのは「ホストカーネルへの直接の攻撃面」だけである
  • gVisor は syscall を処理する主体を置き換える。 Sentry(ユーザー空間のアプリケーションカーネル)が syscall を肩代わりし、Gofer がファイル I/O を代理する。公式は 「gVisor は system call をホストへ素通しすることが決して無い」と書いている
  • 既定のプラットフォームは systrap runsc flags の出力に (default "systrap") と明記されている。VM の中でも動く。KVM プラットフォームは /dev/kvm が要る
  • RuntimeClasshandler は CRI 側の設定名。 containerd 2.x(config version = 3)のキーは plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runsc gVisor 公式のサンプルは 1.x 系(io.containerd.grpc.v1.cri)なので、そのままでは効かない
  • runsc install は Docker 用。 containerd へは /etc/containerd/config.toml に手で書く。containerd-shim-runsc-v1/usr/bin へ置く(shim が無いと containerd はタスクを作れない。sudosecure_path/usr/local/bin が無いため、公式手順の置き先では コマンドが見つかりません になる)
  • scheduling.nodeSelector は Pod の spec.nodeSelector へ注入される(積を取る。衝突すると Pod が拒否される)。tolerations は和を取る
  • gVisor と SELinux ラベリングは同一 containerd 上で排他。 FetchSpec failed: reading spec: SELinux is not supported: system_u:...runsc に無視するフラグは無く(229 行中 0 件)、containerd の enable_selinux はグローバル設定で、Pod 側でも回避できない
  • runsc が起動を拒否するのは設計判断。 gVisor Pod には /proc/self/attr/current が存在しない(Sentry は SELinux を実装していない)。黙って無視すると「拘束されているつもりで拘束されていない」状態になるので、拒否する側を選んでいる
  • 決め方はノード単位。 k8s-wl-01 だけ enable_selinux = false にし、RuntimeClass の scheduling.nodeSelector で gVisor Pod をそこへ寄せる。GKE Sandbox も専用ノードプールに分離する設計
  • 代償その 1: 同じノードの通常 Pod も MAC 拘束を失う。 変更後に wl-01 で作られた通常 Pod は spc_t(拘束なし)になる。wl-02 は container_t:s0:c55,c207 のまま。変更前から動いていた Pod は古いラベルのまま生き残る(第8回の PSA enforce と同じ時間差)
  • 代償その 2: 可用性が下がる。 サンドボックスノードが 1 台なので、backend 3 レプリカがすべて wl-01 に載る。本番ではサンドボックスノードを 2 台以上用意し、taint + tolerations で通常 Pod を追い出し、topologySpreadConstraints で分散させる
  • 代償その 3: ホストの syscall を見る監視から見えなくなる。 サンドボックス内の syscall はホストカーネルへ届かないので、第15回で導入する Falco は既定のドライバでサンドボックス内を観測できない(gVisor 側の --pod-init-config と Falco 側の gVisor 向け設定による連携は別途の作り込みが要る)。①②は kubectl で見えるが、③は「何も起きない」という形で現れるので最も気づきにくい
  • 代償を数えたうえで、本書ラボでは恒久導入しないと決めた。 Workload Node が 2 台しかない本書ラボでは、サンドボックス専用ノードを本当の意味で専用にできない(taint を打つと 51 Pod の行き先が無い)。専用にできないなら、代償だけが残る。 作り方と判断基準を持ち帰り、演習④の最後に元へ戻した。 4 台以上の Workload ノードがある環境なら、同じ手順で専用ノードを作れる
  • 入れたものは戻せる形で入れる。 触る前に cp -p でバックアップを取っておけば、containerd の 2 つの変更(runsc の登録と enable_selinux)はファイルごと 1 回で戻せる。 ノードに root で置いた実行ファイルは、消さない限り残り続けるので、/usr/bin の 2 つも削除する。ただしラベルが戻るのは撤去後に作り直された Pod だけで、導入中に作られた Pod は spc_t のまま残る
  • 失敗はすべて Pending ではなく ContainerCreating で止まる。 3 パターンをイベント文面で切り分ける(no runtime for "runsc" is configured / no runtime for "doesnotexist" is configured / SELinux is not supported
  • オーバーヘッドは倍率では語れない。 JVM の起動は 147,487〜147,683 ms(gVisor)vs 155,000〜155,960 ms(通常)で、むしろ gVisor のほうが約 5% 速かった。syscall 集約型(ファイル作成 20,000 回)でも、ウォールクロックは通常コンテナのほうが遅い回があり再現しない(gVisor 0.39〜0.44 s / 通常 0.23〜0.88 s)。4 回とも再現するのは sysuser の内訳が逆転することで、これが Sentry の肩代わりの数字上の証拠
  • サンドボックスの適用先は、外部入力を処理するコンポーネントから選ぶ。 fanclub では backend(JSON を解釈し JPA で SQL を組む)
  • 導入前に機能まで確かめる。 Pod が Running になるだけでは足りない。ヘルスチェック・DB 接続・本番 Service 経由の応答まで通す
  • 骨組みは kubectl create ... --dry-run=client -o yaml に作らせる。 ただし NetworkPolicy / LimitRange / RuntimeClass には create サブコマンドが無いので手書きになる
  • NetworkPolicy は runtime を問わない。 CNI は Pod のネットワーク名前空間に働くので、gVisor 上でも第3回の 9 本がそのまま効く
  • Kata Containers は Pod ごとに軽量 VM を立てる。 本物のカーネルなので syscall 互換性は高いが、/dev/kvm が要る本書ラボでは動かない(記法と分離モデルのみ・第19回の重点復習)
  • exemptions.runtimeClasses は PSS の穴になる。 免除は namespace の enforce ラベルより優先し、検査そのものが行われなくなる本書ラボでは配線しない(以降 9 回分のラボに穴を残さないため)

理解度チェック(○×形式・全 9 問)

次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。

  1. Namespace を分ければ、テナント間の通信とリソース消費は自動的に分離される
  2. Kubernetes 公式ドキュメントは、ハード / ソフトの区別を二者択一ではなく幅のある連続体として説明している
  3. RuntimeClass の scheduling.nodeSelector は Pod の spec.nodeSelector と積を取り、衝突すると Pod は拒否される
  4. gVisor はコンテナの syscall をホストカーネルへ直接渡すため、性能上のオーバーヘッドはほとんど生じない
  5. containerd が SELinux ラベルを付けた Pod を runsc に渡すと、runsc はラベルを無視して起動する
  6. containerd の enable_selinux は、ランタイムごと(runc / runsc)に別々の値を設定できる
  7. RuntimeClass が指す handler が登録されていないノードに Pod が載ると、Pod は Pending のまま止まる
  8. exemptions.runtimeClasses に登録した RuntimeClass を使う Pod は、namespace に enforce ラベルが付いていても PSS の検査を受けない
  9. Kata Containers は Pod ごとに軽量な仮想マシンを起動するため、ホストに /dev/kvm が必要になる
解答と解説

1=×/2=○/3=○/4=×/5=×/6=×/7=×/8=○/9=○

問1 は誤りです。分離されません。 Namespace は名前の空間であり、それ自体は通信もリソース消費も権限も止めません。 RBAC / NetworkPolicy / ResourceQuota を貼って初めて境界になります。Namespace の役割は、それらを貼る面を用意することです。

問2 は正しいです。公式は “‘hardness’ or ‘softness’ is better understood as a broad spectrum” と書いています。「ハードにするかソフトにするか」ではなく、「どこまで強くするか」を段階で決めます。分かれ目は 「テナント同士が互いを信頼していないか」です。

問3 は正しいです。nodeSelector は積(intersection)で衝突すると拒否、tolerations は和(union)という非対称を覚えてください。実測では Pod 側に何も書いていなくても {"sandbox.gvisor.dev/runtime":"runsc"} が注入されました。

問4 は誤りです。理由づけの部分が成り立ちません。 gVisor は 「system call をホストへ素通しすることが決して無い」(公式)。Sentry がユーザー空間で肩代わりします。そしてオーバーヘッドは処理の種類で決まりますsyscall 集約型(ファイル作成 20,000 回)では sysuser へ移りますが、所要時間そのものは 4 回計測で再現しませんでした(gVisor 0.39〜0.44 s / 通常 0.23〜0.88 s で、通常のほうが遅い回もあります)。JVM の起動に至っては gVisor のほうが約 5% 速い(147,487〜147,683 ms vs 155,000〜155,960 ms)という結果でした。「gVisor は何倍遅い」という一般化そのものが雑です。

問5 は誤りです。無視せず、起動を拒否します。 FetchSpec failed: reading spec: SELinux is not supported: system_u:system_r:container_t:s0:c64,c818ContainerCreating から進みません(末尾の MCS カテゴリは試行ごとに変わります)。gVisor Pod には /proc/self/attr/current が存在せず、Sentry は SELinux を実装していません。 黙って無視すると「拘束されているつもりで拘束されていない」状態になるため、runsc は拒否する側を選んでいます。

問6 は誤りです。できません。 enable_selinuxCRI ランタイムプラグインのグローバル設定であり、runtimes.runsc テーブルの中には置けません。実測で runtimes.runsc 配下に出るフィールドは 12 個ありますが、SELinux 関連は 1 つもありません。 したがって決められる粒度は ノード単位になります。

問7 は誤りです。Pending ではなく ContainerCreating で止まります。 公式ドキュメントは「Pod は Failed フェーズに入る」と書いていますが、実機ではそうなりませんでした(kubelet が 1 秒間隔で再試行を続けます)。kubectl get pod の STATUS では切り分けられず、kubectl describe pod の Events を見るしかありませんno runtime for "runsc" is configured)。

問8 は正しいです。免除は検査の前段で効くので、namespace のラベルより優先します。enforce=restricted の namespace に privileged: true の Pod を作れるようになります。本書ラボでは配線しません(以降のラボに穴を残さないためです)。

問9 は正しいです。Kata は Pod ごとに軽量 VM を立て、その中で本物の Linux カーネルを動かします。 互換性は高いものの ハードウェア仮想化(/dev/kvm)が要ります。 本書ラボは Hyper-V のゲストでネストされた仮想化が露出していないため動きません。試験で問われるのは handler: kata という記法までで、導入そのものは出題範囲外です。

次回予告

第11回「Cilium 透過暗号化 + L7 NetworkPolicy(Calico → Cilium 移行)」では、D4 の 4 つ目のコンピテンシーImplement Pod-to-Pod encryption using Cilium, Istio)へ進みます。本回までで「境界を引く」「カーネルを隔離する」を扱いました。次回は 境界を越える通信そのものを扱います。

CNI を Calico から Cilium へ入れ替え、透過暗号化(WireGuard / IPsec)と mTLS の違いを区別したうえで、HTTP のメソッドとパス単位で通信を絞る L7 NetworkPolicy を設計します。Istio の PeerAuthentication STRICT は記法として紹介します。D4 の最終回です。

本回で加えた変更はすべて元へ戻したので、次回は本回開始前と同じ状態から始まります。 RuntimeClass は 0 個、ノードラベルは無し、wl-01 の containerd は enable_selinux = true/usr/bin に runsc 系のバイナリは無し、base/backend-deployment.yaml と Helm chart に runtimeClassName は無し、という状態です。残しているのは k8s-wl-01 の /root/config.toml.bak-10 だけです。

→ 詳しくは 第11回 Cilium 透過暗号化 + L7 NetworkPolicy(Calico → Cilium 移行)

前の記事
次の記事