LabHub

ブログ

自宅サーバーでハンズオン学習プラットフォームを作る — 特権なしの Pod の中に Kubernetes を入れる方法

한국어English日本語中文

自宅に Kubernetes クラスタを立てたあとに来る質問は、たいてい同じです。これで何をしよう?

私は学習プラットフォームを作ることにしました。受講者がコースを開いて「ラボ開始」を押すと、その人専用のコンテナがすぐに立ち上がり、ブラウザ内のターミナルで本物の Linux シェルにつながり、ステップごとに採点ボタンを押すとサーバーが Pod の中の実際の状態を検査して合否を返す、という構造です。ラボを終えるか制限時間が過ぎると、コンテナは消えます。

作ってから振り返ると、難しかったのは機能ではありませんでした。隔離です。

問題は root を渡さなければならないこと

ハンズオンである以上、受講者はコンテナの中で root でなければなりません。useradd も試し、chmod も試し、ファイルシステムを掘り返してこそ学べます。そこは交渉できません。

しかし、その root がコンテナの外へ出てしまっては困ります。このクラスタは自宅ネットワークにあり、同じネットワークにはルーターと NAS とレジストリと他のノードがいます。

最初に作ったときのラボ Pod のスペックはこうでした。

spec:
  containers:
    - name: lab
      image: registry.internal/labhub/lab-linux:v1
      command: ["sleep", "infinity"]
      resources:
        requests: { cpu: 100m, memory: 256Mi }
        limits:   { cpu: "1",  memory: 1Gi }

securityContext がありません。ネットワークポリシーもありませんでした。実際に通ってしまうのか確かめてみました。

# ラボ Pod の中から
cat < /dev/null > /dev/tcp/192.168.219.1/80    # ルーターの管理ページ
cat < /dev/null > /dev/tcp/10.96.0.1/443       # Kubernetes API サーバー

どちらも開きました。おまけに automountServiceAccountToken を切っていなかったので、デフォルトのサービスアカウントトークンが Pod の中にマウントされていました。受講者がその気になればクラスタの API を呼べる、ということです。

1 層目 — ネットワークをラボごとに開ける

Cilium は IP ではなくアイデンティティでポリシーを判断します。だから toEndpoints を kube-dns ひとつだけ開ければクラスタ内の他の Pod とは完全に切れ、toEntities をひとつも開けなければノードと API サーバーが切れます。toCIDRSet はクラスタ外の宛先にだけ適用されるので、そこでプライベート網をくり抜けばよいわけです。

最初はすべてのラボにインターネットの 80/443 を開けていました。しかし考えてみると、Linux・Git・データベースのラボは必要なものがすべてイメージの中にあり、インターネットは 1 バイトも要りません。開けておく理由のない扉は閉じておくほうがましです。

そこでプロファイルを 3 つに分けました。

プロファイル開放範囲使うラボ
デフォルト (ラベルなし)DNS のみLinux・Git・FDE・DB・Kubernetes
net=internet+ 公衆インターネット 80/443apt・pip が必要なラボ
net=registry+ レジストリのドメインのみコンテナのラボ

デフォルトがいちばん狭いです。Pod にラベルがなければ自動的に DNS だけが開きます。

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: lab-egress-base
spec:
  endpointSelector:
    matchLabels:
      app: lab-session
  egress:
    # 名前解決のみ。L7 DNS パーサーを噛ませておくと誰がどのドメインを引いたか Hubble に残る。
    - toEndpoints:
        - matchLabels:
            io.kubernetes.pod.namespace: kube-system
            k8s-app: kube-dns
      toPorts:
        - ports: [{ port: "53", protocol: ANY }]
          rules:
            dns: [{ matchPattern: "*" }]

インターネットを開けるプロファイルでは、プライベート帯をすべてくり抜きます。

    - toCIDRSet:
        - cidr: 0.0.0.0/0
          except:
            - 10.0.0.0/8          # プライベート A + Pod CIDR + Service CIDR
            - 172.16.0.0/12       # プライベート B
            - 192.168.0.0/16      # 自宅ネットワーク・ルーター・NAS
            - 169.254.0.0/16      # リンクローカル + クラウドメタデータ
            - 100.64.0.0/10       # 通信事業者の CGNAT
      toPorts:
        - ports:
            - { port: "80",  protocol: TCP }
            - { port: "443", protocol: TCP }

ポートを 80/443 に絞ったのも意図的です。aptpipgit clone は通りますが、アウトバウンドの SSH ブルートフォースや SMTP スパムやポートスキャンは通りません。ラボ環境が他人を攻撃する踏み台になっては困りますから。

イングレスは全面的に遮断しました。ブラウザターミナルは API サーバーと kubelet を経由する exec の経路なので Pod ネットワークを使わず、何も開ける必要がありません。唯一の例外はウェブプレビューです。受講者がラボ Pod の中で立ち上げたアプリをブラウザで見るには、バックエンドがそのポートへ入っていく必要があるので、バックエンド Pod から来る指定ポートだけを許可します。このルールのおかげで、受講者どうしが互いの Pod を攻撃することも同時に防げます。

適用したあと、もう一度叩いてみました。

ルーター 192.168.219.1:80遮断 (タイムアウト)
NAS 192.168.219.109:5001        → 遮断
レジストリ :80                   → 遮断
Kubernetes API 10.96.0.1:443    → 遮断
隣のラボ Pod                     → 遮断
DNS                             → 許可

遮断が拒否 (Connection refused) ではなくタイムアウトである点が重要です。パケットが静かに捨てられているという意味であり、これがファイアウォールが正しく動いているときの姿です。

2 層目 — カーネル権限をラボごとに渡す

ネットワークを塞いでも、コンテナの中の root は依然として root です。ここで Linux ケーパビリティが登場します。

最初はすべてのラボに同じ 9 個を渡していました。Linux のラボが useradd のために SETUID を必要とするからといって、Kubernetes のラボまで同じ権限をもらう理由はないのに、です。そこでデフォルトを「何もなし」に変え、ラボが実際に要求する分だけを載せるようプロファイルに分けました。

CAP_PROFILES = {
    # デフォルト。ケーパビリティがひとつもない。
    # Kubernetes・DB・FDE・Git のラボはすべてこれで足りる — 自分が所有する
    # ファイルを作りプロセスを起動するのに特別な権限は要らない。
    "none": [],
    # 他人が所有するファイルを扱うラボ
    "files": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID"],
    # ユーザーを作って切り替えるラボ
    "users": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID",
              "SETUID", "SETGID", "KILL"],
    # rootless コンテナを回すラボ
    "container": ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "KILL",
                  "SETUID", "SETGID", "SETPCAP", "SYS_CHROOT"],
}

どのプロファイルにも入らないものがあります。

そして allowPrivilegeEscalation を切ります。これが有効だと、上でどれだけケーパビリティを削っても setuid バイナリひとつで戻ってきます。この設定は no_new_privs をかけて setuid 自体を無力化します。

副作用がひとつあります。sudo が動かなくなります。しかしコンテナの中ではすでに root なのでラボに支障はなく、visudo -c のような構文チェックはそのまま通るので資格試験向けのラボにも問題はありません。

3 層目 — 資格情報を渡さない

serviceAccountName: lab-nobody      # RoleBinding がひとつもないアカウント
automountServiceAccountToken: false # トークン自体をマウントしない
enableServiceLinks: false           # 他のサービスのアドレスを環境変数で漏らさない

最後の行が忘れられがちな部分です。Kubernetes はデフォルトで、同じネームスペースのすべてのサービスのアドレスを環境変数として注入します。ラボ Pod には何の役にも立たないうえに、内部の地形だけを教えてしまうわけです。

では Kubernetes のラボはどうするのか

ここで詰まりました。Kubernetes を教えるには受講者にクラスタを渡さなければなりませんが、kind はノードをコンテナとして立ち上げ、k3s は kubelet を回す必要があるので、どちらも特権が要ります。特権を渡した瞬間、ここまで積み上げた 3 つの層がすべて無意味になります。特権コンテナから脱出するのは難しくなく、脱出した先は自宅ネットワークです。

答えは kwok でした。

kwok は etcd と API サーバーとコントローラーマネージャーとスケジューラーを ごく普通のプロセスとして 起動します。そして kwok コントローラーが偽のノードを模倣して Pod を Running まで持っていきます。本物のコンテナランタイムがないので、特権はひと粒も要りません。

kwokctl create cluster --name lab --runtime binary --wait 180s

--runtime binary が肝です。kwokctl のデフォルトは docker や podman でコンポーネントをコンテナとして立ち上げる方式ですが、この Pod にはコンテナランタイムがありません。binary ランタイムはバイナリをそのままプロセスとして実行します。

ケーパビリティをすべて剥奪した Pod で実際に回してみました。

ノード 3Ready
CRD 39 個 登録
起動時間 11.4

この環境でできることとできないことは、正直に区別しておく必要があります。

できること — すべてのリソースの CRUD、Deployment のスケール、ロールアウトと巻き戻し、スケジューリング (ノードセレクター・アフィニティ・テイント・ドレイン)、RBAC の検証、ResourceQuota と NetworkPolicy のオブジェクト、kubectl explain、kustomize、helm、etcd スナップショットの保存と復元、サードパーティ CRD の適用。

できないこと — ワークロード Pod への kubectl exec、本物のログ出力、ポートフォワード、実際の CNI トラフィック、ボリュームに入ったデータ。本物のコンテナがないので当然です。

奇しくも CKA と CKAD の試験が筆記で問う領域は、ほとんどが前者です。後者はハンズオンの代わりに理論とクイズで扱いました。

踏んだ罠

レディネスプローブを業務 API に向けた

デプロイが止まりました。新しい Pod は次々と立つのに Ready にならず、ロールアウトがタイムアウトし、パイプラインが失敗します。ログには何のエラーもありません。

原因はプローブでした。

readinessProbe:
  httpGet: { path: /api/courses, port: 8000 }   # 業務 API

リファクタリングの過程で /api/courses/api/paths に変えました。プローブは 404 を受け取り、Pod は永久に Ready になれませんでした。アプリケーションはまったく問題なく動いていたのに、です。

教訓は単純です。プローブは専用のエンドポイントにだけ向けるべきです。

startupProbe:                       # 初回起動は余裕をもって
  httpGet: { path: /healthz, port: 8000 }
  periodSeconds: 5
  failureThreshold: 36              # 最大 3 分
readinessProbe:
  httpGet: { path: /healthz, port: 8000 }
livenessProbe:
  httpGet: { path: /healthz, port: 8000 }
  failureThreshold: 5

/healthz はプロセスだけを確認し、/readyz は DB 接続まで確認します。リブネスに DB チェックをかけなかった理由は、DB が一瞬揺れたくらいでアプリケーションを再起動すると、事態がさらに悪くなるからです。

この事故にもいくらか良い面がありました。本番系が古いバージョンを保ったこと。パイプラインが役目を果たしたわけです。

動くタグと imagePullPolicy

ラボのイメージを焼き直したのに反映されませんでした。何度ビルドし直しても Pod の中のファイルは以前のままでした。ビルドログには「Pushed」が出ていました。

Kubernetes のデフォルトの imagePullPolicy は、タグが latest のときだけ Always で、それ以外は IfNotPresent です。私は v2 という動くタグを使っていたので、ノードは一度取得したイメージを永遠に使い回していました。エラーは何ひとつ残りません。

image: registry.internal/labhub/lab-k8s:v2
imagePullPolicy: Always     # 動くタグには必須

正攻法は不変タグを使うことです。ただしカリキュラムが増え続けてイメージを頻繁に焼き直す状況なので、レジストリが同じネットワークにある点を考慮して Always で行きました。

kwok がキャッシュを無視した

ラボ Pod は DNS 以外どこへも出られません。ところが kwokctl は起動時にコンポーネントのバイナリをインターネットから取得します。キャッシュが空だとクラスタ作成がそのまま失敗します。

そこでイメージのビルド時にあらかじめ取得しておいたのですが、役に立ちませんでした。

{"level":"INFO","msg":"Download","uri":"https://github.com/etcd-io/etcd/.../etcd-v3.5.11-linux-amd64.tar.gz"}
{"level":"ERROR","msg":"Failed to setup config","err":"... i/o timeout"}

kwok は自分が決めたデフォルトのバージョンを使い (私が取得しておいたのは別のバージョンでした)、キャッシュのパスも URL の構造をそのままミラーリングします。

/root/.kwok/cache/https/dl.k8s.io/release/v1.30.4/bin/linux/amd64/kube-apiserver

バージョンでもパスでも、ひとつずれるだけでキャッシュを見つけられず取り直します。URL を推測する代わりに、ビルド中にクラスタを一度作って消すやり方に変えました。

RUN kwokctl create cluster --name warmup --runtime binary --wait 180s && \
    kwokctl delete cluster --name warmup && \
    rm -rf /root/.kwok/clusters

kwok が必要なものを自分のやり方で取得し自分のパスに置くので、ずれる余地がありません。クラスタの状態 (PKI と etcd のデータ) は消し、キャッシュだけを残します。起動時間が 1 分 52 秒から 11.4 秒に縮みました。

Pod が死んだセッションが枠を永久に占有した

受講者ひとりが同時に開けるラボを 1 つに制限しました。ウィンドウを閉じて忘れた瞬間に Pod が無駄になるからです。

しかしデータベースが「実行中」と言っているからといって、Pod が生きているとは限りません。ノードが再起動したか、OOM で死んだか、誰かが手で消せば、Pod はなく記録だけが残ります。するとその受講者は新しいラボを永久に開けなくなります。

セッションを再利用する前に Pod の生存を確認し、死んでいれば記録を締めてから作り直すよう直しました。回収器にも方向をひとつ足しました。

3 つ目が今回追加したものです。ただし作成から 1 分以内は除外しなければなりません。作りたての Pod がまだ API に見えていない可能性があるからです。

リソースを漏らさないための三重の仕掛け

ラボ Pod は高価な資源です。受講者がウィンドウをそのまま閉じても、資源が残ってはいけません。

  1. セッションごとに有効期限を固定します。作成時に決まり、残り時間が画面に表示されます。残り 5 分で通知が出て、延長はできますが合計で元の 3 倍までです。
  2. 1 人 1 セッションです。新しいラボを開くには既存のものを先に終える必要があります。開いているラボが何かを知らせ、すぐ移動するか終了できるようにします。
  3. 60 秒ごとに回収器が回ります。上の 3 方向で整理し、運用画面がデータベースと実際の Pod の一致状況を常時見せます。

ネームスペースには ResourceQuota と LimitRange をかけてあります。Pod 数と CPU・メモリ・一時ストレージの総量に上限があり、ラボ Pod が Service や PVC を作れないよう count/services: "0" も入れました。一時ストレージの上限がとくに重要で、これがないとディスクを埋めてノードを殺せてしまいます。

セキュリティのリグレッションをテストで縛る

この隔離は複数のファイルに散らばっています。Pod スペック、ネットワークポリシー、ネームスペースのラベル。そのうちひとつでも戻れば隔離は破れるのに、破れた状態でもサービスは平然と動きます。だからテストがなければ誰も気づきません。

プッシュのたびに回る検査を作りました。

デフォルトのプロファイルが空である (権限 0)
NET_RAW をどのプロファイルにも渡していない
SYS_ADMIN をどのプロファイルにも渡していない
✅ 権限昇格を防いでいる
SA トークンをマウントしていない
✅ インターネットのプロファイルが 192.168.0.0/16 を除外している
✅ イングレスを遮断している
✅ イングレスの例外がバックエンドだけに限定されている
PSA が privileged に下げられていない
✅ ソースに API キーがハードコードされていない
...
合格 — 38 項目すべて正常

ひとつ学んだことがあります。最初に作った検査はソースから NET_RAW という文字列を探していたのですが、コメントに「NET_RAW を渡さない」と書いておいたせいで誤検知しました。いまは Python の AST で実際のプロファイル辞書をパースして値を見ています。検査器は検査したい対象を正確に狙わなければならないという当たり前の話ですが、実際に踏むまではなかなか見えません。

デプロイは git で

最初は CI が kubectl set image でクラスタを直接押していました。そうすると、いま何が動いているかを知るにはクラスタに尋ねるしかなく、ロールバックは古いタグを覚えている人に依存し、誰かが手で直したものとデプロイされたものが区別できません。

いまはリポジトリの gitops/ が真実です。

gitops/
  base/    環境共通 — イメージタグもネームスペースもない
  dev/     namespace、セッション TTL、LoadBalancer 公開
  prod/    namespace、replicas 2SSO シークレット、公開 URL

イメージタグはオーバーレイの images.newTag 一行です。CI はその行だけを直してコミットし、ArgoCD が勝手に同期します。ロールバックはその行を戻すコミットひとつです。kubectl で手を入れれば selfHeal が戻すので、ドリフトは残りません。

罠がひとつありました。ベースのマニフェストに Service を ClusterIP だけで定義したところ、ArgoCD が開発環境の LoadBalancer を ClusterIP に変えてしまい、接続が切れました。GitOps へ移すときは、クラスタにすでにあるものを先に正確に書き写さなければなりません。書き写さなかったものは消えると考えておくほうが安全です。

いまの状態

ラボ Pod が立ち上がるまでの時間を測ってみました。

Linux のラボ    セッション作成 15 秒 → すぐ使用可能
Kubernetes      セッション作成 6 秒 → 環境準備 15  (kwok クラスタ起動)

Kubernetes のラボは、Pod が Running になったあともクラスタが立ち上がる時間がさらに必要です。これを画面で知らせないと、受講者がその間に採点を押して自分が間違えたと思ってしまいます。そこで準備スクリプトが終わる際に目印のファイルを残し、バックエンドがそれを読んで準備中かどうかを知らせ、画面は準備が整うまで採点ボタンをロックします。

限界と残っているもの

正直に書いておくべきことがあります。

コンテナのラボは rootless podman で本物のコンテナを回しますが、ストレージドライバが vfs なので遅いです。overlayfs はマウント権限が必要で fuse-overlayfs はデバイスファイルが必要ですが、どちらも渡せません。ラボ用の小さなイメージでは体感差は大きくないものの、大きなイメージを扱うラボを作るのは難しいです。

仮想化のラボは QEMU をソフトウェアエミュレーションで回します。/dev/kvm がないので選択肢がありません。小さな Alpine の VM は立ちますが遅いです。KVM がなぜ速いのかは理論で扱い、ハンズオンはディスクイメージの操作とスナップショットの方向で設計しました。

tcpdump は動きません。NET_RAW を渡さないからです。パケットキャプチャを教えるには別の方法を探す必要がありますが、まだ良い答えがないので理論とクイズで扱っています。ネットワーク診断のラボは ssdiggetent/proc/net で設計しました。

まとめ

自宅にあるサーバーでハンズオン環境を作るとき、いちばん大きな制約は性能ではなく隔離でした。そしてその制約がむしろ設計を良くしてくれました。特権を渡せないので kwok を探し当てることになり、結果としてクラスタが 11 秒で立つ環境になりました。インターネットを開けられないので必要なものをすべてイメージに焼き込むことになり、結果としてオフラインでも完全に動きます。

権限を広げる方向で問題を解く癖があるなら、一度は逆へ行ってみるのも悪くありません。

コメント

まだコメントはありません。

ログインするとコメントできます