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

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

RBAC棚卸しとSAトークン期限【CKS第4回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第4回です。第3回では「どこから、どこへ、何が通るか」を絞りました。本回は「誰が、何をできるか」を絞ります。ただし本回も白紙からは始めません。fanclub の Role は既に configmapsget / list だけに絞られており、fanclub-backend のトークン自動マウントも既に止まっています。本回の到達点は、クラスタ全体を見渡して、与えすぎた権限とトークンの露出を見つけられることです。実務でも試験でも、権限をゼロから設計する場面より「入れたものが持ち込んだ権限を点検する」場面のほうが多くあります。

第3回で NetworkPolicy を扱ったときと同じ構図です。fanclub の中は締まっている一方で、クラスタ全体には cluster-admin を持つ ServiceAccount が実在します。本回はそれを実機で洗い出し、トークンの露出を削り、トークンの有効期限に上限を課すところまで進みます。

  • ラボ Kubernetes v1.36.2
  • CKS 試験環境 v1.35
  • kubectl(k8s-ops) v1.35.6
  • rbac-tool v1.20.0
  • containerd v2.2.6
  • AlmaLinux 10.2
  • 確認日 2026-07-26
目次
  1. 第4回のスコープ・今ここマップ
    1. RBAC は三部作で 3 回目のテーマになる
    2. 本回で新しく扱うこと
  2. この回のゴール
    1. 本回で扱わない範囲
  3. 起点の確認 ——fanclub の RBAC と SA トークンはどこまで締まっているか
    1. fanclub の ServiceAccount は 2 個しかない
    2. Pod と ServiceAccount の対応を 1 コマンドで見る
    3. fanclub の Role は既に最小になっている
    4. fanclub:default は何の権限も持たない ——ではトークンは無害か
    5. 本回で行うことを 3 つに整理する
  4. RBAC の判定規則 ——4 象限・許可は加算・impersonation で読む
    1. 4 象限 ——効果範囲を決めるのは Binding の側
    2. RBAC は許可のみ ——複数の Binding は加算される
    3. 誰として認証されているかを確かめる
    4. impersonation で「他人として試す」
    5. –list で実効権限を俯瞰する
    6. escalate / bind / impersonate の 3 つの特殊 verb
  5. やってみよう①:クラスタ全体の RBAC 棚卸し
    1. ステップ1:規模を把握する
    2. ステップ2:cluster-admin を参照している Binding を数える
    3. ステップ3:実効権限を impersonation で確かめる
    4. ステップ4:休眠している cluster-admin と、常時稼働している cluster-admin
    5. ステップ5:Secret を読める Subject を洗い出す
    6. ステップ6:棚卸し表を作る
    7. ステップ7:削るか、削らないか
    8. 試験相当の粒度
  6. rbac-tool ——棚卸しの機械化と「試験では使えない」の線引き
    1. なぜツールが要るのか
    2. 試験で使えるもの・使えないもの
    3. 導入 ——バージョンをピン留めして入れる
    4. 3 つのサブコマンド
    5. 103 件をどう読むか
  7. ServiceAccount トークンの実体 ——Projected Bound Token と旧 Secret 型
    1. マウントされているものを見る
    2. 「Bound」とは何に紐づいているのか
    3. automountServiceAccountToken: false にすると何が消えるのか
    4. 旧式の Secret 型トークンは存在しない
    5. 3 つのトークン取得経路を整理する
  8. やってみよう②:fanclub の SA トークン露出を削る
    1. ステップ1:削り代を確認する
    2. ステップ2:ServiceAccount 単位で止める
    3. ステップ3:既存 Pod には効かないことを確認する
    4. ステップ4:Pod を作り直す
    5. ステップ4-b:backend も作り直す(実機で判明した必須手順)
    6. Pod 単位で止める場合
    7. ステップ5:壊れていないことを確認する
    8. CronJob の Pod にもトークンは入らなかった
    9. この変更は helm upgrade で巻き戻る
    10. ArgoCD は本演習を巻き戻したか
    11. 試験相当の粒度
  9. kubectl create token –duration に上限は無い ——2 つのフラグはセットで効く
    1. TokenRequest でトークンを発行する
    2. 上限を試す
    3. API Server 側の上限 ——2 つのフラグ
    4. 第2回で入れたフラグとの接続
    5. 発行済みトークンをどう扱うか
  10. やってみよう③:トークン期限に上限を入れ、3 台へ揃える
    1. ステップ1:現状を測る(フラグを入れる前)
    2. ステップ2:k8s-cp-01 にフラグを揃える
    3. ステップ3:再起動と復帰を確認する
    4. ステップ3-b:戻し方を先に確認しておく
    5. ステップ4:1 台だけ入れた状態で測る
    6. ステップ5:k8s-cp-02 / k8s-cp-03 へ展開する
    7. ステップ6:3 台揃えたあとに測り直す
    8. ステップ7:正規の動作が壊れていないことを確認する
    9. Pod に注入されるトークンは変わらない
    10. このフラグも「手で入れたもの」である
    11. 24h という値の選び方
    12. 試験相当の粒度
  11. ClusterRole × RoleBinding で namespace に閉じる設計
    1. 同じ権限を複数の Namespace で使いたいとき
    2. ClusterRoleBinding を使うべき場面
    3. kubectl create の generator を使う
    4. 権限を「絞る」ときの粒度
  12. やってみよう④:ClusterRole を RoleBinding で namespace 限定して検証する
    1. ステップ1:検証用の Namespace と ServiceAccount を作る
    2. ステップ2:何もできないことを先に確認する
    3. ステップ3:ClusterRole と RoleBinding を作る
    4. ステップ4:namespace に閉じていることを 2 方向で確認する
    5. ステップ5:ClusterRoleBinding にすると何が変わるか(作らずに読む)
    6. 補足:白紙から書くドリル(試験対策)
    7. ステップ6:後片付け
    8. 試験相当の粒度
  13. 暗記必須コマンド(第4回)
    1. 試験で使えるもの(kubectl のみ)
    2. 試験では使えないもの
    3. 暗記の要点
  14. まとめ
  15. 理解度チェック(○×形式・全 9 問)
  16. 次回予告

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

本回は CKS ドメイン D2: Cluster Hardening(配点 15%) の前半にあたります。D2 は 4 つのコンピテンシーからなり、本回は「RBAC による露出の最小化」と「ServiceAccount の慎重な扱い(既定の無効化・新規作成分の権限最小化)」の 2 つを担当します。残る「API へのアクセス制限」と「脆弱性回避のためのアップグレード」は第5回へ送ります。

公式カリキュラムと突き合わせられるよう、D2 の英文も挙げておきます。Use Role Based Access Controls to minimize exposure / Exercise caution in using service accounts e.g. disable defaults, minimize permissions on newly created ones / Restrict access to the Kubernetes API / Upgrade Kubernetes to avoid vulnerability の 4 つです。本回が担当するのは前の 2 つです。

第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回の完了状態です。第2巻から引き継いだ 9 VM 構成(Control Plane Node 3 台 + Workload Node 2 台 + 作業端末・レジストリ・LB・プロキシ)が稼働し、5 ノードが Ready・Kubernetes は v1.36.2・fanclub の HTTPS が 200 を返すという前提で進めます。

RBAC は三部作で 3 回目のテーマになる

RBAC そのものは、本シリーズで初めて扱うテーマではありません。同じ機能を 3 回に分けて積み上げてきたので、それぞれの文脈を先に整理します。

既習内容文脈
第1巻 第15回Role / RoleBinding の基本(開発者視点)権限を理解する
第2巻 第8回管理者視点の RBAC・kubectl auth can-i --as による検証・ServiceAccount への権限委譲権限を与える
本回(第3巻 第4回)クラスタ全体の棚卸し / automountServiceAccountToken / TokenRequest の期限とその上限 / ClusterRole × RoleBinding の namespace 限定権限を疑って削る

ここで先に断っておきます。本回は第2巻の設計を「間違い」として否定する回ではありません。第2巻と本回では文脈が違うだけです。第2巻は権限を与えてワークロードを動かすために書き、本回は与えすぎていないかを疑うために読みます。書く技術と読む技術は別物で、CKS が問うのは後者の比重が大きくなります。

本回で新しく扱うこと

第2巻第8回で扱っていない範囲を先に挙げます。ここが本回の新規性です。

  • automountServiceAccountToken(ServiceAccount 単位と Pod 単位の 2 つがある)
  • kubectl create token(TokenRequest)と、その期限に上限が無いという事実
  • ClusterRole を RoleBinding で紐づけて Namespace に閉じる設計
  • クラスタ全体を対象にした過剰権限の棚卸し

この回のゴール

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

  • Role / ClusterRole × RoleBinding / ClusterRoleBinding の 4 象限を、実物を見ながら使い分けられる
  • kubectl auth can-i --as / --list / kubectl auth whoami で、ある Subject の実効権限を確かめられる
  • クラスタ全体から cluster-admin を持つ ServiceAccount を洗い出せる
  • 休眠している過剰権限と、常時稼働している過剰権限のリスクの違いを説明できる
  • 自動マウントされたトークンの実体(Projected Bound Token)を Pod の spec から読める
  • ServiceAccount 単位・Pod 単位のどちらで自動マウントを止めるかを判断できる
  • kubectl create token --duration に上限が無いことと、その上限を API Server 側で設定する方法を説明できる
  • HA クラスタでは API Server のフラグを 3 台に揃えないと効果が確率的になることを、実測で説明できる

本回で扱わない範囲

RBAC に隣接するテーマは多いので、どこで回収するかを明示しておきます。

本回で扱わない領域理由回収する回
--anonymous-auth=false / --authorization-mode / NodeRestrictionAPI Server のフラグ堅牢化としてまとめて扱う第5回
Admission による権限付与の制限(Gatekeeper / ValidatingAdmissionPolicy)Admission の文脈第8回
Secret 自体の保護(etcd 暗号化 / SealedSecrets)「Secret を読める ServiceAccount が多数いる」ことの受け皿第9回
Namespace をセキュリティ境界として設計する話マルチテナンシーの文脈第10回
「誰がその権限を実際に使ったか」の追跡監査ログの文脈第16回

escalate / bind / impersonate の 3 つの特殊 verb は、本回では名前と意味だけ扱います。Admission による封じ込めは第8回で扱います。

起点の確認 ——fanclub の RBAC と SA トークンはどこまで締まっているか

まず現状を見ます。監査は現物の確認から始まります。

fanclub の ServiceAccount は 2 個しかない

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

$ kubectl get serviceaccount -n fanclub

実行結果:

NAME              AGE
default           12h
fanclub-backend   12h

AGE はスナップショットを取ってからの経過時間なので、読者の環境では別の値になります。

アプリケーションは frontend / backend / db の 3 種類あるのに、ServiceAccount は 2 個しかありません。ServiceAccount を持たないワークロードは default ServiceAccount を使う——この既定の挙動が、本回で削る対象になります。

Pod と ServiceAccount の対応を 1 コマンドで見る

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

$ kubectl get pod -n fanclub -o custom-columns='NAME:.metadata.name,SA:.spec.serviceAccountName,AUTOMOUNT:.spec.automountServiceAccountToken'

実行結果:

NAME                               SA                AUTOMOUNT
fanclub-backend-585549545f-7vnn8   fanclub-backend   false
fanclub-backend-585549545f-dksms   fanclub-backend   false
fanclub-backend-585549545f-gp252   fanclub-backend   false
fanclub-db-0                       default           <none>
fanclub-frontend-7d9b4667f-j88b8   default           <none>
fanclub-frontend-7d9b4667f-jc6vl   default           <none>
fanclub-logcollector-dlmkk         default           <none>
fanclub-logcollector-fgnpg         default           <none>

Pod 名のハッシュ部分は再作成のたびに変わるので、読者の環境では別の文字列になります。読み方は 3 点です。

  • backend の 3 Pod は既に false。第1巻から積み上げた Helm chart にこの指定が入っている
  • <none> は「指定なし」であって「無効」ではない。指定が無い場合の既定は自動マウントする側なので、db 1 個・frontend 2 個・logcollector 2 個の計 5 Pod にはトークンが入っている
  • ServiceAccount 名が default である Pod は、その Namespace の default ServiceAccount の権限で API を叩ける状態にある

fanclub の Role は既に最小になっている

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

$ kubectl get role,rolebinding -n fanclub

fanclub にあるのは fanclub-backend-reader(Role)と fanclub-backend-reader-binding(RoleBinding)の 2 つだけです。Role の中身を確認します。

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

$ kubectl get role fanclub-backend-reader -n fanclub -o yaml

実行結果(metadata.managedFields などの自動付与フィールドを落として整形しています):

kind: Role
metadata:
  name: fanclub-backend-reader
  namespace: fanclub
rules:
- apiGroups:
  - ""
  resources:
  - configmaps
  verbs:
  - get
  - list

configmapsgetlist だけで、Secret を読む権限はありません。RoleBinding は ServiceAccount fanclub:fanclub-backend にこの Role を紐づけています。fanclub-backend の実効権限は、これに加えて全 ServiceAccount が既定で持つ selfsubjectaccessreviews / selfsubjectrulesreviewscreate と、/healthz/version といった非リソース URL だけです。fanclub Namespace の中に、削るべき過剰権限はありません

fanclub:default は何の権限も持たない ——ではトークンは無害か

トークンが自動マウントされている 5 Pod は default ServiceAccount を使っています。その default に何ができるかを確かめます。

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

$ kubectl auth can-i get secrets --as=system:serviceaccount:fanclub:default -n fanclub
$ kubectl auth can-i get configmaps --as=system:serviceaccount:fanclub:default -n fanclub
$ kubectl auth can-i list pods --as=system:serviceaccount:fanclub:default -n fanclub

実行結果:

no
no
no

権限がゼロなら、そのトークンが漏れても何もできない——この理屈は半分しか正しくありません

権限がゼロの ServiceAccount のトークンでも、API Server に到達できること自体が攻撃者にとっての情報になります。認証が通れば selfsubjectrulesreviews で「自分に何ができるか」を列挙でき、API Server のエンドポイントが生きていることも分かります。そして権限は後から増えます。RoleBinding が 1 本足されただけで、昨日まで無害だったトークンが今日から Secret を読めるようになります。トークンを置かないことは、権限設計とは独立した層の防御です。

本回で行うことを 3 つに整理する

以降のセクションは、次の 3 つに答える形で進みます。

  1. クラスタ全体を見る ——fanclub は締まっているが、クラスタには cluster-admin を持つ ServiceAccount が実在する
  2. トークンの露出を削る ——backend は済んでいる。残る 5 Pod を止める
  3. トークンの期限に上限を設ける ——--duration は発行側の自己申告にすぎない
fanclub Namespace の締まっている側と残っている側、およびクラスタ全体に実在する cluster-admin を持つ ServiceAccount 2 個を対比した図
図1:fanclub は締まっているが、クラスタには過剰権限が実在する

RBAC の判定規則 ——4 象限・許可は加算・impersonation で読む

棚卸しの前に、読解に必要な判定規則を固めます。第2巻第8回で扱った基本は繰り返さず、監査に必要な部分だけを厚くします。

4 象限 ——効果範囲を決めるのは Binding の側

Role(Namespace リソース)ClusterRole(クラスタスコープのリソース)
RoleBinding(Namespace リソース)その Namespace 内で Role の権限その Namespace 内で ClusterRole の権限(最小権限の定石)
ClusterRoleBinding(クラスタスコープ)組み合わせ不成立(RoleBinding からしか Role は参照できない)全 Namespace で ClusterRole の権限(強力・誤用注意)

押さえる点は 3 つです。

  • Role を ClusterRoleBinding で参照することはできないroleRef.kindRole を書いた ClusterRoleBinding は作れない
  • ClusterRole は「権限の定義を使い回すための入れ物」でもある。定義がクラスタスコープであることと、効果範囲がクラスタ全体であることは別。効果範囲を決めるのは Binding の側
  • Node / PersistentVolume / Namespace などクラスタスコープのリソースを扱う権限は、ClusterRole でしか定義できない

RBAC は許可のみ ——複数の Binding は加算される

  • RBAC に「拒否ルール」は存在しない。NetworkPolicy と同じ構造で、第3回で扱った「許可は加算される」という読み方がそのまま効く
  • ある Subject に複数の Binding が刺さっていれば、権限は和集合になる。1 本削っても他が残っていれば権限は残る
  • したがって監査は「その Subject に刺さっている Binding をすべて数える」ことから始まる。1 本だけ見て安心しない

この性質は、本回のやってみよう① で実物として現れます。cert-manager の ServiceAccount は 5 本の ClusterRole から同じ Secret 読み取り権限を得ており、1 本削っても残り 4 本で権限は残ります。

誰として認証されているかを確かめる

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

$ kubectl auth whoami

実行結果:

ATTRIBUTE                                           VALUE
Username                                            kubernetes-admin
Groups                                              [kubeadm:cluster-admins system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [X509SHA256=5f68c210...]

X509SHA256= の値は環境ごとに異なるため、先頭 8 文字だけを載せて残りは伏せています。読み方はこうです。kubernetes-admin は個別に cluster-admin を与えられているのではなく、kubeadm:cluster-admins というグループ経由で権限を得ています。kubeadm が作った admin.conf の証明書に、この Group が入っているためです。次のセクションで見る ClusterRoleBinding の一覧と繋がります。

kubectl auth whoami は v1.28 で GA になったサブコマンドで、CKS 試験環境の v1.35 でも使えます。「自分が誰として認証されているか分からないまま権限を確かめる」ことがなくなるので、監査の最初の 1 行に置いてください。

impersonation で「他人として試す」

--as は、その Subject になりすまして権限判定だけを行います。実際にリソースを操作するわけではないので、監査に使っても安全です。

書き方意味
--as=system:serviceaccount:<ns>:<sa>ServiceAccount としての判定
--as=<username>ユーザーとしての判定
--as-group=<group>グループ所属を加えた判定

ServiceAccount の正式名は system:serviceaccount:<namespace>:<name> です。この綴りは試験で頻出なので手に覚えてください(暗記コーナーで再掲します)。

注意点が 1 つあります。--as を使うには自分が impersonate 権限を持っている必要があります。本書ラボの kubernetes-admincluster-admin なので使えますが、権限を絞られたユーザーでは --as 自体が拒否されます。試験では管理者権限の kubeconfig が渡されるため、この点が問題になることはありません。

–list で実効権限を俯瞰する

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

$ kubectl auth can-i --list --as=system:serviceaccount:fanclub:fanclub-backend -n fanclub

出力は「Resources / Non-Resource URLs / Resource Names / Verbs」の 4 列です。読むときの注意が 2 点あります。

  • 全 ServiceAccount が最初から持っている行があるselfsubjectaccessreviews / selfsubjectrulesreviewscreate/healthz/version などの非リソース URL)。これらは既定であって過剰権限ではない
  • --list-n で指定した Namespace での実効権限を出す。Namespace を変えれば結果も変わる。ClusterRoleBinding 由来の権限はどの Namespace でも出る

escalate / bind / impersonate の 3 つの特殊 verb

verbできること
escalate自分が持っていない権限を含む Role / ClusterRole を作成・更新できる。通常は「自分の持つ権限を超える Role は作れない」制限があり、これを外す
bind自分が持っていない権限の Role を、他の Subject に Binding できる
impersonate他のユーザー / ServiceAccount / グループになりすませる。cluster-admin を持つ ServiceAccount になりすませれば、実質 cluster-admin

本書ラボでは kube-system/clusterrole-aggregation-controllerescalate を持っています。これは ClusterRole の集約(aggregation)を行う Kubernetes 標準のコントローラで、動作上必要な権限です。「危険な verb を持つ=直ちに問題」ではなく、持ち主が誰かで判断が変わります。ここが監査の勘所です。標準コンポーネントに付いている escalate を消しにいくと、クラスタの基本機能が壊れます。

やってみよう①:クラスタ全体の RBAC 棚卸し

この演習を始める前に

  • 本演習は読み取りのみです。クラスタの状態は変えません
  • --as による impersonation は権限判定だけを行い、リソースを操作しません
  • 所要時間の目安は 15〜20 分です
  • 起点は第3回完了状態のクラスタです。5 ノードが Ready になってから始めてください

ステップ1:規模を把握する

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

$ kubectl get clusterrole --no-headers | wc -l
$ kubectl get clusterrolebinding --no-headers | wc -l
$ kubectl get role -A --no-headers | wc -l
$ kubectl get rolebinding -A --no-headers | wc -l
$ kubectl get serviceaccount -A --no-headers | wc -l

実行結果:

104
90
30
31
83

数字だけが 5 行返るので、何の数か分からなくなります。次のように整理してください。

種別個数
ClusterRole104
ClusterRoleBinding90
Role(全 Namespace)30
RoleBinding(全 Namespace)31
ServiceAccount(全 Namespace)83

この数字をどう受け止めるかが重要です。読者が自分で書いた RBAC は fanclub の Role 1 本と RoleBinding 1 本だけです。残りの 100 本以上は Kubernetes 本体と、入れた Helm chart(ArgoCD / cert-manager / Longhorn / Velero / kube-prometheus-stack / Loki / Traefik)が持ち込んだものです。CKS が問う「過剰権限」は、たいていこちら側にあります

ステップ2:cluster-admin を参照している Binding を数える

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

$ kubectl get clusterrolebinding -o custom-columns='NAME:.metadata.name,ROLE:.roleRef.name,KIND:.subjects[*].kind,NS:.subjects[*].namespace,SUBJECT:.subjects[*].name' | grep cluster-admin

実行結果:

cluster-admin             cluster-admin   Group            <none>            system:masters
kubeadm:cluster-admins    cluster-admin   Group            <none>            kubeadm:cluster-admins
longhorn-support-bundle   cluster-admin   ServiceAccount   longhorn-system   longhorn-support-bundle
velero                    cluster-admin   ServiceAccount   velero            velero

KIND 列と NS 列を足しているのが要点です。Subject の名前だけでは種別が分からず、本回の主眼(Group は人間の管理者・ServiceAccount は Pod に紐づく)が読み取れません。Group の NS<none> になるのは、Group がクラスタ全体の概念で Namespace を持たないためです。

ClusterRoleBindingSubject の種別Subject
cluster-adminGroupsystem:masters
kubeadm:cluster-adminsGroupkubeadm:cluster-admins
longhorn-support-bundleServiceAccountlonghorn-system/longhorn-support-bundle
veleroServiceAccountvelero/velero

上の 2 本は kubeadm が作る標準の経路です(kubectl auth whoami で見た kubeadm:cluster-admins がこれにあたります)。問題は下の 2 本です。Group は人間の管理者に対応しますが、ServiceAccount は Pod に紐づきます。つまり、そのコンテナを取られた時点で cluster-admin が渡ります。

ステップ3:実効権限を impersonation で確かめる

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

$ kubectl auth can-i get secrets --all-namespaces --as=system:serviceaccount:longhorn-system:longhorn-support-bundle
$ kubectl auth can-i create pods -n kube-system --as=system:serviceaccount:longhorn-system:longhorn-support-bundle
$ kubectl auth can-i delete nodes --as=system:serviceaccount:longhorn-system:longhorn-support-bundle
$ kubectl auth can-i get pods -n fanclub --as=system:serviceaccount:longhorn-system:longhorn-support-bundle

実行結果:

yes
yes
yes
yes

velero/velero でも get secrets --all-namespacesyes になります。

この 4 行の意味を読み取ってください。「バックアップのための ServiceAccount」「サポート情報を集めるための ServiceAccount」という名前から想像される役割を、権限は大きく超えていますdelete nodes ができる必要はどこにもありません。これが「入れたものが持ち込んだ過剰権限」の実例です。

ステップ4:休眠している cluster-admin と、常時稼働している cluster-admin

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

$ kubectl get pod -n longhorn-system -o custom-columns='NAME:.metadata.name,SA:.spec.serviceAccountName' | grep support-bundle
$ kubectl get pod -n velero -o custom-columns='NAME:.metadata.name,SA:.spec.serviceAccountName' --no-headers

実行結果(1 本目は該当行なし・以下は 2 本目):

fanclub-default-kopia-maintain-job-1785042548789-ggzxk   velero
fanclub-default-kopia-maintain-job-1785046448493-9xjhj   velero
fanclub-default-kopia-maintain-job-1785050323663-n6hkm   velero
node-agent-hx2th                                         velero
node-agent-qd8zc                                         velero
velero-68cbd5f7c8-j4l5b                                  velero

1 本目が何も返さないこと自体が、本ステップの土台です。longhorn-support-bundle を使う Pod は 1 つも動いていません。

2 本目のうち常時稼働は 3 個です(velero-* Deployment 1 個 + node-agent DaemonSet 2 個)。残る 3 個は定期実行の kopia-maintain Job で状態は Completed ですが、これも velero ServiceAccount を使います。Pod 名のハッシュと Job 名の数字は実行のたびに変わるので、読者の環境では別の値になります。

ServiceAccount稼働 Podリスクの性質
longhorn-system/longhorn-support-bundleなし休眠 cluster-admin。サポートバンドル採取時にだけ Pod が起動する。ふだんは誰もこのトークンを持っていない
velero/velero3 個(Deployment 1 + node-agent DaemonSet 2)常時稼働の cluster-admin。3 つのコンテナのどれかが侵害されれば、その瞬間に cluster-admin のトークンが手に入る

同じ cluster-admin でも、リスクの質が違います。ここが本回で持ち帰ってほしい観点です。

常時稼働している側は「侵害されれば即座に奪われる」ものです。攻撃者はコンテナに入った時点で /var/run/secrets/kubernetes.io/serviceaccount/token を読むだけで済みます。

休眠している側は「トークンを持った Pod が存在しない」ぶんだけ猶予があります。ただし無害ではありません。その Namespace に Pod を作れる者は、serviceAccountName: longhorn-support-bundle と 1 行書くだけで cluster-admin の Pod を起動できます。つまり「longhorn-system に Pod を作る権限」が、実質的に「cluster-admin へ昇格する権限」になっています。

監査の結論はこうなります。常時稼働側は「今すぐ効く穴」、休眠側は「Namespace への書き込み権限とセットで効く穴」です。優先度は前者が高いものの、後者を「使われていないから安全」と片付けてはいけません。

ステップ5:Secret を読める Subject を洗い出す

cluster-admin だけを見て終わりにはしません。CKS の脅威モデルで次に重いのは Secret の読み取りです。

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

$ kubectl auth can-i get secrets --all-namespaces --as=system:serviceaccount:monitoring:kube-prometheus-stack-grafana

この 1 行はサンプルです。ServiceAccount は全 Namespace で 83 個あり、全部を手で試すのは現実的ではありません。その行き詰まりが、次のセクションで rbac-tool を使う理由になります。

ここで書式の注意を 1 つ挟みます。--as の区切りはコロン 3 個system:serviceaccount:<ns>:<name>)です。ここを / で書くとエラーにならずsystem:serviceaccount:monitoring/kube-prometheus-stack-grafana という存在しない Subject として判定され no が返ります。「権限が無いことを確認したつもりで、別の存在しない Subject を測っていた」という取り違えが起きます。

ClusterRoleBinding から ClusterRole を辿って、危険な verb / resource を持つ Subject を機械的に抽出すると、本書ラボでは次の分布になりました。

argocd/argocd-application-controller           <- argocd-application-controller            ['*/*']
argocd/argocd-notifications-controller         <- argocd-notifications-controller          ['secrets:get', 'secrets:list,watch']
cert-manager/cert-manager-cainjector           <- cert-manager-cainjector                  ['secrets:get,list,watch']
cert-manager/cert-manager                      <- cert-manager-controller-certificates     ['secrets:get,list,watch']
cert-manager/cert-manager                      <- cert-manager-controller-challenges       ['secrets:get,list,watch']
cert-manager/cert-manager                      <- cert-manager-controller-clusterissuers   ['secrets:get,list,watch']
cert-manager/cert-manager                      <- cert-manager-controller-issuers          ['secrets:get,list,watch']
cert-manager/cert-manager                      <- cert-manager-controller-orders           ['secrets:get,list,watch']
monitoring/kube-prometheus-stack-grafana       <- kube-prometheus-stack-grafana-clusterrole ['secrets:get,list,watch']
monitoring/kube-prometheus-stack-kube-state-metrics <- kube-prometheus-stack-kube-state-metrics ['secrets:list,watch']
monitoring/kube-prometheus-stack-operator      <- kube-prometheus-stack-operator           ['secrets:*']
monitoring/loki                                <- loki-clusterrole                         ['secrets:get,list,watch']
longhorn-system/longhorn-service-account       <- longhorn-role                            ['secrets:get,list,watch']
longhorn-system/longhorn-support-bundle        <- cluster-admin                            ['*/*']
kube-system/clusterrole-aggregation-controller <- system:controller:clusterrole-aggregation-controller ['escalate']
traefik/traefik                                <- traefik-traefik                          ['secrets:get,list,watch']
velero/velero                                  <- cluster-admin                            ['*/*']
分類Subject件数
*/*(すべて)longhorn-system/longhorn-support-bundlevelero/veleroargocd/argocd-application-controller3
Secret 読み取りを明示的に持つcert-manager 系 6 行・monitoring 系 4 行・argocd 1 行・longhorn 1 行・traefik 1 行13 行(ServiceAccount の実数は 9 個)
escalatekube-system/clusterrole-aggregation-controller1

同じ ServiceAccount が複数の ClusterRole から同じ権限を得ていることがあります。 cert-manager/cert-manager は 5 本の ClusterRole(certificates / challenges / clusterissuers / issuers / orders)から Secret 読み取りを得ており、1 本削っても残り 4 本で権限は残ります。前のセクションで扱った「複数の Binding は加算される」がここに現れています。だから行数と Subject の実数は別物として数えてください。

では、この 9 個をどう扱うのか。cert-manager が Secret を読むのは証明書を管理するためで、Traefik が読むのは TLS Secret をロードするためです。役割から必要性が説明できるものは、権限を削る対象ではありません。監査で本当に見るのは次の 2 点です。

  1. 役割から説明できない権限を持っていないかvelerodelete nodes がこれにあたる)
  2. その Secret を読める Pod が、実際に何個動いているか(露出面の広さ)

そして「Secret を読める Subject がこれだけいる」という事実そのものが、第9回で etcd の保存時暗号化と SealedSecrets を扱う理由になります。RBAC だけで Secret は守り切れません

もう 1 点、この棚卸しはまだ完全ではありません。ここで辿ったのは ClusterRoleBinding だけで、Namespace スコープの RoleBinding 経由の付与は 1 件も見ていません。次のセクションでツールに同じ問いを投げると数が合わないことに気づきます。そこが rbac-tool を使う理由になります。

ステップ6:棚卸し表を作る

本演習の成果物は次の表です。手を動かして自分の環境の値で埋めてください。

Subject由来危険な権限稼働 Pod判断
velero/veleroVelero chart*/*cluster-admin3常時稼働・優先度 高
longhorn-system/longhorn-support-bundleLonghorn chart*/*cluster-admin0休眠・Namespace への書き込み権限とセットで効く
argocd/argocd-application-controllerArgoCD chart*/*1役割上ほぼ不可避(全リソースを同期する)
cert-manager/cert-manager ほかcert-manager chartsecrets:get,list,watch3(SA 3 個で各 1)役割から説明できる
fanclub/fanclub-backend自作 chartなし(configmaps:get,list のみ)3最小
fanclub/defaultKubernetes 既定なし5 Pod が使用中権限はゼロだがトークンは配られている

「稼働 Pod」の欄は kubectl get pods -n <ns> -o custom-columns=NAME:.metadata.name,SA:.spec.serviceAccountName で数えます。cert-manager は 3 つの ServiceAccount(cert-manager / cert-manager-cainjector / cert-manager-webhook)を持ち、それぞれ 1 Pod ずつ動いています。 ここまでの手作業で Secret 読み取りが見えているのは前の 2 つですが、残る cert-manager-webhook も Secret を読めます——それが手作業では見えていないことは、次のセクションでツールを使うと分かります。1 つの chart が複数の ServiceAccount を持ち込むことは珍しくないので、chart 単位ではなく ServiceAccount 単位で数えてください。

ステップ7:削るか、削らないか

本演習では削りません。理由を明示します。

velerocluster-admin を削ると、バックアップ・リストアが動かなくなる可能性が高いです。Velero は任意のリソースを読み書きするため、必要な権限の特定には chart のドキュメントと実際の操作ログの突き合わせが要ります。本番で権限を削るときは、削ったあとに壊れるものを先に洗い出します。第3回で NetworkPolicy を締めたときと同じ手順です。

本書ラボでは Velero を第2巻から使い続けており、ここで壊すと以降の回に影響します。したがって本回は「見つけて記録する」までを到達点とします。実務では、この棚卸し表を持って chart の提供元へ「なぜこの権限が要るのか」を確認するところから始まります。

代わりに、すぐ打てる緩和策を 2 つ示します。

  • その Namespace に Pod を作れる人を絞る(休眠 cluster-admin への対処。RBAC の問題を RBAC で解く)。具体的には longhorn-system / velero の 2 つの Namespace に対する create pods を持つ Subject を kubectl auth can-i create pods -n longhorn-system --as=<subject> で棚卸しし、開発者向けの Role がこの 2 つに紐づいていないことを確かめる。cluster-admin の ServiceAccount が置かれている Namespace は、Pod を作れる範囲を最も狭くすべき場所である
  • 常時稼働側のコンテナが侵害されにくいようにする。Pod Security Standards(第8回)とランタイム検知(第15回)で扱う領域である

試験相当の粒度

試験では「指定された ServiceAccount が Secret を読めるか調べよ」「cluster-admin を持つ Subject を列挙せよ」という形で問われます。auth can-i --as の 1 行なら 30 秒〜1 分、grep での列挙を含めて 3〜5 分が目安です。

rbac-tool ——棚卸しの機械化と「試験では使えない」の線引き

先に結論を書きます。本セクションで扱う rbac-tool は、CKS 試験では使えません。試験中に参照できる公式ドキュメントは 8 件(第1回で扱いました)で、その 8 件に rbac-tool は含まれません。試験の主力はあくまで kubectl auth can-i --as です。そのうえで、実務の棚卸しでは手作業に構造的な穴があることを、本セクションで確認します。

なぜツールが要るのか

やってみよう① のステップ5 で行き詰まったところです。ClusterRoleBinding 90 本 × ClusterRole 104 本の組み合わせを、auth can-i --as の 1 行ずつで潰すのは現実的ではありません。Binding から Role を辿って権限を展開する作業は機械にやらせます

試験で使えるもの・使えないもの

場面使うもの
CKS 試験での棚卸しkubectl auth can-i / --list / --as / kubectl auth whoami に限られる(権限を作る側のコマンドは暗記コーナーにまとめます)
実務の棚卸しrbac-tool(+ kubectl の組み合わせ)

試験対策としては、本セクションは「読まなくてよい」節です。ただし実務では、90 本の Binding を手で追う代わりに 1 コマンドで済みます。試験と実務で道具が違うことを認めたうえで、両方を持っておくというのが本書の方針です。

しかも制約は「ドキュメントが 8 件に無い」だけではありません。CKS では github.com そのものが参照不可です(CKA では helm.sh/docsgateway-api.sigs.k8s.io が許可されますが、CKS では許可されません)。つまり試験中はリポジトリの README すら開けません。試験環境に無いものは、持ち込む手段も調べる手段も無いと考えておいてください。

導入 ——バージョンをピン留めして入れる

ここから先はラボの話です。

直前に「github.com は試験では参照不可」と書いたばかりで github.com からツールを取ってくるので、念のため区別を確認しておきます。参照できないのは CKS 試験環境の話で、本書ラボは alma-proxy の whitelist に github.com を登録済みです(第1回・第2回で扱いました)。試験環境(許可 8 件のみ)とラボ環境(whitelist に追加できる)を混同しないでください。

  • リポジトリは alcideio/rbac-tool。最新リリースは v1.20.0(2024-10-29)。取得先は https://github.com/alcideio/rbac-tool/releases/download/v1.20.0/ 配下の rbac-tool_v1.20.0_linux_amd64.tar.gz
  • 更新は約 21 ヶ月止まっています。その間に Kubernetes は v1.31 から v1.36 まで進みました。それでも本書ラボの v1.36.2 で動作します
  • 導入先は k8s-ops です。バージョンをピン留めして入れますlatest を取りません)
  • Squid の whitelist は追加不要です。github.com / objects.githubusercontent.com / release-assets.githubusercontent.com は第2巻までに登録済みです

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

$ cd ~
$ curl -sL -o rbac-tool.tar.gz https://github.com/alcideio/rbac-tool/releases/download/v1.20.0/rbac-tool_v1.20.0_linux_amd64.tar.gz
$ tar -xzf rbac-tool.tar.gz
$ sudo install -m 0755 rbac-tool /usr/local/bin/rbac-tool

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

$ command -v rbac-tool
$ rbac-tool version

実行結果:

/usr/local/bin/rbac-tool
Version: 1.20.0
Commit: a0b8c036b90b8ed31de9ff1fcef854312f52c418

置き場所が /usr/local/bin でよいのは、rbac-tool を developer 権限で実行するからです。第1回で kube-bench を /usr/bin に置いたのは、あちらが sudo でファイル権限を見るツールで、sudosecure_path/usr/local/bin が含まれていなかったためでした。どのユーザーで動かすツールかによって、置き場所の正解が変わります

導入確認で 1 点だけ注意があります。AlmaLinux 10 の最小構成には which コマンドが入っていません。導入確認は command -v rbac-tool で行ってください。which を打って command not found が返ると「入れたはずのツールが無い」と誤解しやすいのですが、無いのは which の側です。

ここで、セキュリティの棚卸しに 2 年近く更新のないツールを使ってよいのか、という問いに答えておきます。このツールは API Server の RBAC リソースを読んで展開するだけで、クラスタを変更しません。読み取り専用であること、出力を kubectl で検算できることが、採用の根拠です。逆に、クラスタを変更するツールであれば同じ判断はしません。更新が止まっているツールを入れる判断は、そのツールが何を触るかで決めます。

3 つのサブコマンド

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

$ rbac-tool who-can get secrets

get secrets ができる Subject を列挙します。出力は TYPE / SUBJECT / NAMESPACE の 3 列の表で、種別ごとにまとまって並びます。本書ラボの実測は合計 26 Subject(Group 3 + ServiceAccount 22 + User 1)でした。

実行結果:

  TYPE           | SUBJECT                                         | NAMESPACE
-----------------+-------------------------------------------------+------------------
  Group          | kubeadm:cluster-admins                          |
  Group          | system:bootstrappers:kubeadm:default-node-token |
  Group          | system:masters                                  |
  ServiceAccount | argocd-application-controller                   | argocd
  ServiceAccount | argocd-applicationset-controller                | argocd
  ServiceAccount | argocd-dex-server                               | argocd
  ServiceAccount | argocd-notifications-controller                 | argocd
  ServiceAccount | argocd-redis-secret-init                        | argocd
  ServiceAccount | argocd-server                                   | argocd
  ServiceAccount | bootstrap-signer                                | kube-system
  ServiceAccount | cert-manager                                    | cert-manager
  ServiceAccount | cert-manager-cainjector                         | cert-manager
  ServiceAccount | cert-manager-webhook                            | cert-manager
  ServiceAccount | generic-garbage-collector                       | kube-system
  ServiceAccount | kube-prometheus-stack-grafana                   | monitoring
  ServiceAccount | kube-prometheus-stack-operator                  | monitoring
  ServiceAccount | loki                                            | monitoring
  ServiceAccount | longhorn-service-account                        | longhorn-system
  ServiceAccount | longhorn-support-bundle                         | longhorn-system
  ServiceAccount | metallb-controller                              | metallb-system
  ServiceAccount | metallb-speaker                                 | metallb-system
  ServiceAccount | namespace-controller                            | kube-system
  ServiceAccount | token-cleaner                                   | kube-system
  ServiceAccount | traefik                                         | traefik
  ServiceAccount | velero                                          | velero
  User           | system:kube-controller-manager                  |

Group と User の行は NAMESPACE 列が空です。どちらもクラスタ全体の概念で、Namespace を持たないためで、やってみよう①のステップ2 で NS 列が <none> になったのと同じ理由です。

手作業で明示的に洗い出した ServiceAccount は 9 個でした。ツールは 22 個を挙げます。 差の内訳は 2 つあります。

  1. cluster-admin 2 個と */*argocd-application-controller を別枠に置いていた分(3 個)。ここまでで 12 個
  2. こちらが重要です ——手作業では ClusterRoleBinding しか辿っていなかったため、Namespace スコープの RoleBinding 経由で Secret を読める ServiceAccount を丸ごと取りこぼしていました

数を合わせると、手作業の 12 個のうち 11 個がツールの 22 個にも含まれます。差し引き 11 個が手作業では見えていなかった分です。12 個のうち 1 個がツール側に出ない理由は、この後で扱います。

取りこぼしていた側の実例です。いずれも Role + RoleBinding で与えられています。

kube-public/system:controller:bootstrap-signer  -> Role/system:controller:bootstrap-signer  | subject: kube-system bootstrap-signer
kube-system/system:controller:bootstrap-signer  -> Role/system:controller:bootstrap-signer  | subject: kube-system bootstrap-signer
kube-system/system:controller:token-cleaner     -> Role/system:controller:token-cleaner     | subject: kube-system token-cleaner
metallb-system/metallb-controller               -> Role/metallb-controller                  | subject: metallb-controller
metallb-system/metallb-pod-lister               -> Role/metallb-pod-lister                  | subject: metallb-controller

ツールを使う理由はここにあります。 手で追える範囲には構造的な穴があり、その穴は「見落とした」ではなく「そもそも見ていなかった」という形で開きます。ClusterRoleBinding だけを数えて「9 個です」と報告すれば、その報告は誤りではないものの、答えとしては足りていません。

逆にツール側に出ないものもあります。monitoring/kube-prometheus-stack-kube-state-metrics は手作業の抽出には現れましたが、secretslist / watch は持つ一方 get は持たないため who-can get secrets には出てきません。問う verb を変えれば答えも変わります。ツールの出力を鵜呑みにせず、auth can-i --as で検算する作法をここで身につけてください。

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

$ rbac-tool policy-rules -e velero

Subject の実効権限を展開します。ORIGINATED FROM 欄でどの ClusterRole 由来かまで表示されるのが特徴です(velero なら ClusterRoles>>cluster-admin)。kubectl auth can-i --list では分からない「その権限がどこから来たのか」が読めます。権限を削る作業では、削る対象の Binding を特定しなければ何も始まらないので、この列が実務での価値になります。

実行結果:

  TYPE           | SUBJECT       | VERBS | NAMESPACE | API GROUP | KIND | NAMES | NONRESOURCEURI | ORIGINATED FROM
-----------------+---------------+-------+-----------+-----------+------+-------+----------------+------------------------------
  ServiceAccount | velero/velero | *     | *         |           |      |       | *              | ClusterRoles>>cluster-admin
  ServiceAccount | velero/velero | *     | *         | *         | *    |       |                | ClusterRoles>>cluster-admin

2 行に分かれているのは、cluster-adminリソースに対する権限(下の行・API GROUPKIND*)と非リソース URL に対する権限(上の行・NONRESOURCEURI*)の 2 種類を持つためです。/healthz/metrics のようなパスは、リソースとは別の枠で許可されます。

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

$ rbac-tool analysis

組み込みルールでクラスタ全体を評価します。出力は YAMLで、そのまま眺めるには長すぎます。まず件数だけを数えます。

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

$ rbac-tool analysis | grep -c '^- Finding:'

実行結果:

103

本書ラボの実測は 103 件の findingsで、内訳は 15 種類のルールに分かれていました。ルールの説明文には複数行にまたがるものがあるため grep だけでは正しく集計できませんが、分類そのものは次のとおりです(長い説明は末尾を省略しています)。

ルール別の件数:

 19  Capture principals that can read secrets
  8  Capture principals that can create or modify workloads of any kind (Deployments, Jobs, ...)
  8  Capture principals that can manipulate shared cluster networking services ...
  8  Capture principals that can install/update Kubernetes admission controllers of any kind
  8  Capture principals that can install/delete/update Kubernetes Custom Resources
  7  Capture principals that can manipulate shared cluster storage resources ...
  6  Capture principals that can escalate privileges through the use of special API verbs 'bind' or 'escalate' ...
  6  Capture principals that can manipulate shared cluster networking services (Gateway API) ...
  5  Capture principals that can escalate privileges through the use of impersonation
  5  ... OPA GateKeeper shared resources
  5  ... create ephemeral containers in running Pods
  5  ... Kyverno shared resources
  5  ... install/update Kubernetes Admission Policies of any kind
  4  ... create node proxies (Kubelet API 直接アクセス)
  4  ... exec into running Pods

出力の冒頭には、どのルールセットで評価したかが記録されます。既定は Rapid7 InsightCloudSec default RBAC analysis rules で、kube-systemsystem: 系のロールはあらかじめ除外されています(除外された Subject は ExclusionsInfo 節に理由つきで並びます)。「何を見ていないか」が出力に明記されているのは、監査ツールとして扱いやすい性質です。

103 件をどう読むか

103 件をゼロにしようとしないでください。 第2回で kube-bench の WARN について書いたことと同じ構図です。

analysis の findings は「危険な可能性がある権限を持つ Subject」の列挙であって、「直すべき設定」の一覧ではありません。19 件が「Secret を読める」に分類されますが、その大半は cert-manager や Traefik が役割上必要としているものです。ツールは候補を出すところまでで、必要かどうかを判断するのは人間です。

実務での使い方は「クラスタを立てた直後にベースラインを取り、以降は件数の増分を見る」です。ある日 findings が 103 件から 108 件に増えたら、その 5 件が何かを調べます。絶対値ではなく差分を見てください

ServiceAccount トークンの実体 ——Projected Bound Token と旧 Secret 型

ここからトークンの話に移ります。まず「何がマウントされているのか」を Pod の spec から読みます。

マウントされているものを見る

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

$ kubectl get pod -n fanclub -l app=fanclub-frontend -o jsonpath='{.items[0].spec.volumes}' | jq '.[] | select(.projected)'

frontend Pod には設定ファイル用の configMap ボリュームも付いているので、jqprojected を持つものだけに絞っています。k8s-ops には jq が導入済みです(python3 -m json.tool でも整形できますが、その場合は全ボリュームが出ます)。

ここはラボの道具であって、試験の道具ではありません。

CKS 試験環境の SSH ホストにプリインストールされているのは kubectlk エイリアスと bash 補完つき)/ yq / curl / wget / man で、jq は公式の一覧に含まれていません

試験では jq を当てにせず、-o jsonpath-o custom-columns で必要な値だけを取り出す形に置き換えてください。YAML を加工したいときは、プリインストールされている yq が使えます。第1回で立てた「ラボ環境と試験環境は別物」という原則は、コマンドラインの道具にもそのまま当てはまります。

実行結果:

{
  "name": "kube-api-access-vbbw6",
  "projected": {
    "defaultMode": 420,
    "sources": [
      {
        "serviceAccountToken": {
          "expirationSeconds": 3607,
          "path": "token"
        }
      },
      {
        "configMap": {
          "items": [
            {
              "key": "ca.crt",
              "path": "ca.crt"
            }
          ],
          "name": "kube-root-ca.crt"
        }
      },
      {
        "downwardAPI": {
          "items": [
            {
              "fieldRef": {
                "apiVersion": "v1",
                "fieldPath": "metadata.namespace"
              },
              "path": "namespace"
            }
          ]
        }
      }
    ]
  }
}

読み方は 4 点です。

  • projected ボリュームである。3 つのソース(トークン / CA 証明書 / Namespace 名)を 1 つのディレクトリに束ねている
  • マウント先は /var/run/secrets/kubernetes.io/serviceaccount。攻撃者が真っ先に読む場所であり、CKS で覚えておくパスである
  • expirationSeconds: 3607(約 1 時間)。kubelet がこのトークンを定期的に更新する。Pod の中のファイルは書き換わり続ける
  • ボリューム名の末尾(vbbw6)は Pod ごとに変わる。名前で探さず、projected の中身で判断する

「Bound」とは何に紐づいているのか

「Bound」は「紐づいている」という意味です。

Pod に注入されるトークンは、その ServiceAccount と、その Pod に紐づいています。Pod が消えればトークンは無効になり、ServiceAccount を作り直しても無効になります。v1.24 より前の Secret 型トークンが「ServiceAccount を消すまで永久に有効」だったのと対照的で、トークンの寿命が、それを使う対象の寿命に縛られています。これが Bound Service Account Token と呼ばれる理由です。

一方 kubectl create token で発行するトークンは、既定では ServiceAccount にしか紐づいていません。だから Pod の外へ持ち出せますし、Pod が消えても生き続けます。後のセクションで扱う「100 年トークン」が成立するのは、この違いによるものです。

automountServiceAccountToken: false にすると何が消えるのか

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

$ kubectl get pod -n fanclub -l app=fanclub-backend -o jsonpath='{.items[0].spec.volumes[*].name}'

実行結果:

applog payara-tmp

kube-api-access-* が存在しません。残っている 2 つは第1巻で readOnlyRootFilesystem に対応するために入れた emptyDir(アプリログ用と Payara のテンポラリ用)で、トークンとは無関係です。

ここで確認してほしいのは、設定値ではなく結果です。「automountServiceAccountToken: false と書いてある」ことを見るのではなく、その結果として Pod から何が消えたかを見てください。第3回で「遮断=無応答」を実際に確かめたのと同じ考え方です。ボリュームごと消えるので、コンテナの中にトークンファイルは存在しません。

旧式の Secret 型トークンは存在しない

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

$ kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token

実行結果:

No resources found

歴史的な経緯を短く押さえておきます。Kubernetes v1.24 より前は、ServiceAccount を作ると kubernetes.io/service-account-token 型の Secret が自動生成され、その中の JWT には有効期限がありませんでした。一度漏れれば、ServiceAccount を消すまで永久に使えます。v1.24 以降は自動生成が止まり、Pod には期限付きの projected token が配られるようになりました。本書ラボに旧式トークンが 1 件も無いのは、この世代の後に構築されたからです。

ただし手で作れば今でも作れますtype: kubernetes.io/service-account-token の Secret に kubernetes.io/service-account.name アノテーションを付ける形です)。CKS の監査観点としては、この型の Secret が存在すること自体が指摘対象になります。上のコマンドは、その確認に使える 1 行です。引き継いだクラスタで最初に打つコマンドの 1 つに入れてください。

3 つのトークン取得経路を整理する

経路期限用途
Pod への自動マウント(projected)既定 約 1 時間・kubelet が更新Pod 内のアプリが API を叩く
kubectl create token(TokenRequest API)--duration で指定・既定 1 時間外部の CI や人が一時的に使う
手で作る Secret 型(旧式)無期限使わない(監査で指摘する側)

TokenRequest とは、Kubernetes API に対して「この ServiceAccount のトークンを、この期限で発行してほしい」と要求する API のことです。kubectl create token はその呼び出しにすぎません。発行を要求する側が期限を申告するという構造が、後のセクションの問題に直結します。

期限があるトークンは、期限が来れば無効になります。これが projected token と TokenRequest の価値です。ただし --duration に上限が無いなら、その価値は発行する側の良心に依存することになります。この点を次に確かめます。

やってみよう②:fanclub の SA トークン露出を削る

この演習を始める前に

  • 本演習は稼働中のワークロードを再作成します。スナップショット等で復元できる環境で行ってください
  • 変更前に kubectl get serviceaccount default -n fanclub -o yaml > default-sa.yaml.bak でバックアップを取ってください(第2回の鉄則1 と同じ作法です)
  • 終わったら必ずステップ5 の確認を行ってください。HTTPS 200 と /api/members の JSON が返ることを確かめてから先へ進みます
  • ArgoCD が fanclub-api-prod を管理しています。chart 管理下のリソースを直接変更すると同期で巻き戻る可能性があります(最後の h3 で扱います)
  • 所要時間の目安は 15〜20 分です

ステップ1:削り代を確認する

対象ServiceAccount現状本演習での扱い
backend ×3fanclub-backendautomount: false済み(触らない)
frontend ×2default自動マウント中止める
db ×1default自動マウント中止める
logcollector ×2default自動マウント中止める

ここに挙げた 8 Pod のほかに、fanclub には CronJob fanclub-report があります。毎日 09:00 に起動するため、棚卸しの時点では Pod 一覧に現れません。

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

$ kubectl get cronjob -n fanclub -o custom-columns='NAME:.metadata.name,SCHEDULE:.spec.schedule,SA:.spec.jobTemplate.spec.template.spec.serviceAccountName,AUTOMOUNT:.spec.jobTemplate.spec.template.spec.automountServiceAccountToken'

実行結果:

NAME             SCHEDULE    SA       AUTOMOUNT
fanclub-report   0 9 * * *   <none>   <none>

serviceAccountNameautomountServiceAccountToken未指定です。つまりこの Job の Pod も default ServiceAccount でトークンを自動マウントします

ServiceAccount 単位で止めることの価値はここにあります。 default ServiceAccount を automountServiceAccountToken: false にしておけば、明日の朝はじめて作られる Pod にもトークンは入りません。Pod を 1 つずつ潰して回るやり方では、いま存在しないものを取りこぼします。

止める前に、この 5 Pod が Kubernetes API を叩いていないことを確かめます。

  • frontend は Nginx で、静的 HTML の配信と /api/ のリバースプロキシだけを行う。Kubernetes API は叩かない
  • db は PostgreSQL。API は叩かない
  • logcollector は busybox でメッセージを出力するだけのダミー(第1巻の DaemonSet 教材)。API は叩かない

止める前に「使っていないこと」を確認してください。NetworkPolicy を締める前にトポロジを確認したのと同じ手順です。使っているものを止めれば、アプリは API 呼び出しに失敗します。CKS の演習では「API を叩かない Pod のトークンを止めよ」と条件が明示されることが多いのですが、実務では自分で確かめる必要があります。

ステップ2:ServiceAccount 単位で止める

目指す状態は次の ServiceAccount です(全量)。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: default
  namespace: fanclub
automountServiceAccountToken: false

automountServiceAccountTokenspec の下ではなくトップレベルにある点に注意してください。ServiceAccount には spec がありません。

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

$ kubectl patch serviceaccount default -n fanclub -p '{"automountServiceAccountToken":false}'

default ServiceAccount は削除しないでください。 default は Namespace ごとに Kubernetes が自動生成します。削除しても再生成されるので、「消す」という対処は成立しません。止めるのは自動マウントの側です。CKS の設問で「default ServiceAccount を無効化せよ」と読める文面が出ても、実際にやることは automountServiceAccountToken: false の設定です。

この変更は以降の回にも効き続けます。fanclub の default を止めたあと、本回より後の回で kubectl run した検証用 Pod にもトークンは入りません。fanclub 内から API を叩く必要が出たときは、その Pod の spec で automountServiceAccountToken: true と明示して戻してください。

ステップ3:既存 Pod には効かないことを確認する

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

$ kubectl get pod -n fanclub -o custom-columns='NAME:.metadata.name,SA:.spec.serviceAccountName,AUTOMOUNT:.spec.automountServiceAccountToken'

変更前と同じ結果が返ります。 ServiceAccount の設定は Pod の作成時に評価されるため、既に動いている Pod のボリュームは消えません。

設定を入れた=守られた、ではありません。

Pod が作り直されるまで、トークンはマウントされたまま残ります。第2回で「static Pod は保存=即再起動」だったのとは対照的で、いつ効き始めるかがリソースによって違います。この差を意識しないと、「設定したのに kube-api-access-* が消えない」と迷うことになります。

なぜ Pod の作成時なのか。トークンのボリュームを Pod に差し込んでいるのは API Server の中の仕組み(ServiceAccount Admission Controller)で、それが動くのは Pod が作られる瞬間だけだからです。すでに作られた Pod の spec を後から書き換えに来る仕組みはありません。だから作り直すまで効きません。

ステップ4:Pod を作り直す

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

$ kubectl rollout restart deployment/fanclub-frontend -n fanclub
$ kubectl rollout restart daemonset/fanclub-logcollector -n fanclub
$ kubectl rollout restart statefulset/fanclub-db -n fanclub

実行結果:

deployment.apps/fanclub-frontend restarted
daemonset.apps/fanclub-logcollector restarted
statefulset.apps/fanclub-db restarted

frontend と logcollector は 1 分ほどで入れ替わります。db は Longhorn のボリューム再アタッチを挟むため遅く、本書ラボでは約 3 分かかりました。

この直後に backend が 1/2 Running のまま止まります。異常ではありません——待っても直らない性質のものなので、次のステップ4-b でそのまま解消してください。

ステップ4-b:backend も作り直す(実機で判明した必須手順)

db を作り直すと backend が 1/2 Running のまま戻らなくなります。

本書ラボで実測したところ、db の入れ替えから 13 分待っても自力では復帰しませんでした/api/members は空を返し続けます。

原因は backend(Payara Micro)の DB 接続プールです。接続先の Pod が入れ替わっても、プールが握っている古い接続は張り直されません。Readiness プローブの /health/ready がタイムアウトし続け、Readiness probe failed: context deadline exceeded が繰り返し記録されます。

Liveness プローブでは救われません。 Liveness が見ている /health/live は応答を返し続けるため、Kubernetes はコンテナを異常と判定しません。「Readiness だけが落ちて、いつまでも Ready にならない」という状態で安定してしまいます。

backend の automountServiceAccountToken は既に false なので、本演習の目的からは backend を触る必要はありません。しかしdb を作り直した以上、backend も作り直すのが後始末になります。

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

$ kubectl rollout restart deployment/fanclub-backend -n fanclub

約 3 分で新しい Pod が 2/2 Running になり、/api/members が JSON を返すようになります。

ここから持ち帰ってほしいのは、依存関係の順序です。 データベースを入れ替えたら、そこへ接続を張っているアプリケーションも入れ替える。これは SA トークンの話ではなく、ステートフルな依存を持つ構成を触るときに常に付いて回る作法です。第2巻第12回で Longhorn へ移行したときにも同じ現象が出ています。

再作成後に kube-api-access-* が消えたことを確認します。ここで見るのはボリュームです。 ステップ3 で使った AUTOMOUNT 列はこの後もずっと <none> のまま変わりません。ServiceAccount 側に入れた設定は Pod spec の automountServiceAccountToken フィールドを書き換えるわけではなく、トークンのボリュームを差し込まないという形で効くからです。列を見て「効いていない」と判断しないでください。

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

$ kubectl get pods -n fanclub -o custom-columns='NAME:.metadata.name,VOLUMES:.spec.volumes[*].name'

実行結果:

NAME                                VOLUMES
fanclub-backend-788b7bbd7c-98blp    applog,payara-tmp
fanclub-backend-788b7bbd7c-mmhrr    applog,payara-tmp
fanclub-backend-788b7bbd7c-tfzv8    applog,payara-tmp
fanclub-db-0                        data,initdb
fanclub-frontend-76b8b6b9c5-fgxqr   config
fanclub-frontend-76b8b6b9c5-hpj6r   config
fanclub-logcollector-sh5tq          <none>
fanclub-logcollector-sx96h          <none>

どの Pod にも kube-api-access-* がありません。 残っているのはアプリが必要とするボリュームだけです(backend は第1巻で readOnlyRootFilesystem に対応するために入れた 2 つの emptyDir、db は Longhorn の data と初期化用の initdb、frontend は設定ファイル用の config)。logcollector はボリュームを 1 つも持たない構成なので <none> になります。

Pod 名のハッシュ部分は再作成のたびに変わります。 読者の環境では別の値になります。

Pod 単位で止める場合

指定箇所効果範囲優先度
ServiceAccount の automountServiceAccountTokenその ServiceAccount を使うすべての Pod
Pod spec の automountServiceAccountTokenその Pod のみ(ServiceAccount 側の指定を上書きする)

Pod 側の書き方は次のとおりです。これは Pod spec の該当部分の抜粋で、そのままでは適用できませんapiVersion / kind / metadata が欠けています)。指定を書く位置の確認用です。

spec:
  automountServiceAccountToken: false
  containers:
    - name: app
      image: nginx:1.27-alpine

使い分けはこうです。ServiceAccount 単位で止めるのは「この ServiceAccount は API を使わない」と言い切れるとき。Pod 単位は「同じ ServiceAccount を使う Pod のうち、この Pod だけは要らない、あるいはこの Pod だけは要る」というときです。Pod 側の指定が勝つので、「ServiceAccount 単位で全部止めて、必要な Pod だけ Pod 単位で true に戻す」という設計が取れます。これが最小権限の形になります。

ステップ5:壊れていないことを確認する

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

$ kubectl get pod -n fanclub
$ curl -sk --noproxy '*' -o /dev/null -w '%{http_code}\n' -H 'Host: fanclub.local' https://192.168.1.124/
$ curl -sk --noproxy '*' -H 'Host: fanclub.local' https://192.168.1.124/api/members

確認するのは 3 点です。

  1. fanclub の 8 Pod がすべて Running
  2. HTTPS が 200
  3. /api/members が JSON を返す

/ だけでは不十分です(第3回と同じ理由です)。/ は frontend の静的 HTML が返るだけで、frontend → backend → db の経路を通りません。/api/members を叩いて初めて「壊していない」と言えます。--noproxy '*' を付けるのは、本書ラボが Squid の whitelist 方式で、環境変数のプロキシ設定を回避する必要があるためです(第2巻から継承した作法です)。192.168.1.124 は本書ラボの k8s-lb の IP なので、読者の環境では自分の値に読み替えてください。

実行結果(3 本目は先頭のみ抜粋):

NAME                                READY   STATUS      RESTARTS   AGE
fanclub-backend-788b7bbd7c-98blp    2/2     Running     0          13m
fanclub-backend-788b7bbd7c-mmhrr    2/2     Running     0          10m
fanclub-backend-788b7bbd7c-tfzv8    2/2     Running     0          6m42s
fanclub-db-0                        1/1     Running     0          27m
fanclub-frontend-76b8b6b9c5-fgxqr   1/1     Running     0          27m
fanclub-frontend-76b8b6b9c5-hpj6r   1/1     Running     0          27m
fanclub-logcollector-sh5tq          1/1     Running     0          27m
fanclub-logcollector-sx96h          1/1     Running     0          26m
fanclub-report-29750940-tf7gl       0/1     Completed   0          2m21s
200
[{"createdAt":"2026-07-25T14:28:03.572932","email":"hanako@example.com","id":1,"name":"鈴木花子","plan":"standard"},...

CronJob の Pod にもトークンは入らなかった

上の一覧に fanclub-report-29750940-tf7glCompleted で並んでいます。ステップ1 で「明日の朝はじめて作られる Pod にも効く」と書いた CronJob が、検証中に実際に走りました。 この Pod にトークンが入っていないことを確認します。

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

$ kubectl get pod fanclub-report-29750940-tf7gl -n fanclub -o custom-columns='NAME:.metadata.name,SA:.spec.serviceAccountName,VOLUMES:.spec.volumes[*].name'

実行結果:

NAME                            SA        VOLUMES
fanclub-report-29750940-tf7gl   default   <none>

default ServiceAccount を使っているのにボリュームが 1 つもありません。 Job も正常に完了しています。ServiceAccount 単位で止めるとは、こういうことです——設定した時点で存在しなかった Pod にも、作られた瞬間から効きます。Pod を 1 つずつ数えて潰す方法では、この Job を取りこぼしていました。

Job 名の数字(29750940)は起動時刻から決まるため、読者の環境では別の値になります。CronJob の実行を待たずに確かめたい場合は kubectl create job --from=cronjob/fanclub-report <任意の名前> -n fanclub で手動起動できます。

この変更は helm upgrade で巻き戻る

第3回で NetworkPolicy について書いたのと同じ論点を、RBAC と ServiceAccount についてもう一度立てます。まず chart 管理下にあるものを整理します。

リソースchart のテンプレート
fanclub-backend(ServiceAccount)templates/serviceaccount.yaml
fanclub-backend-reader(Role)templates/role.yaml
fanclub-backend-reader-binding(RoleBinding)templates/rolebinding.yaml
backend の automountServiceAccountToken: falseDeployment のテンプレート

chart 管理下にないものは default ServiceAccount です(Kubernetes が自動生成します)。したがって本演習の変更のうち、default への patchhelm upgrade では巻き戻りません。一方、frontend / db / logcollector の Pod spec を直接 kubectl edit で書き換えた場合は巻き戻ります。同じ「トークンを止める」でも、どこに書いたかで寿命が変わります。

恒久対応は chart 側に書くことです。values の例を挙げます。

serviceAccount:
  create: true
  name: fanclub-backend
  automountServiceAccountToken: false

手で締めた設定は、chart を更新した瞬間に消えます。

CKS 試験では「今この場で締める」ことが問われるので kubectl で直接直してよいのですが、実務では chart / GitOps 側に反映しないと、次のデプロイで元に戻ります。第3回の NetworkPolicy と同じ構図であり、本シリーズで繰り返し出てくる論点です。

さらに本書ラボでは ArgoCD が fanclub-api-prod を管理しています。自動同期が有効なら、Git と食い違う変更は helm upgrade を待たずに巻き戻ります。GitOps 環境では「手で直す」の寿命はさらに短くなります。

ArgoCD は本演習を巻き戻したか

「巻き戻る可能性がある」で終わらせず、実際に確かめます。まず同期ポリシーを見ます。

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

$ kubectl get application fanclub-api-prod -n argocd -o jsonpath='{.spec.syncPolicy}' | jq .

実行結果:

{
  "automated": {
    "prune": true,
    "selfHeal": true
  }
}

selfHeal: true です。 Git と食い違う変更は自動的に戻されます。では本演習の変更は戻されるのか——鍵は「ArgoCD が何を管理しているか」です。

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

$ kubectl get application fanclub-api-prod -n argocd -o json | jq -r '.status.resources[] | "\(.kind)/\(.name)"' | sort

実行結果:

ConfigMap/fanclub-config
ConfigMap/fanclub-db-init
ConfigMap/fanclub-frontend-config
Deployment/fanclub-backend
Deployment/fanclub-frontend
ResourceQuota/fanclub-quota
Service/fanclub-backend
Service/fanclub-frontend

8 リソースだけです。 ServiceAccount/default は入っていません。ArgoCD の管理対象は Git に書かれているものに限られ、Kubernetes が自動生成する default ServiceAccount は Git のどこにも書かれていないためです。

結果として、本書ラボでは default への patch は ArgoCD に巻き戻されませんでした。演習後も fanclub-api-prodSynced のままです。

ただし「巻き戻らなかった」を一般化しないでください。

巻き戻らなかったのは、たまたま default ServiceAccount が GitOps の管理対象外だったからです。もし frontend や backend の Deployment に automountServiceAccountToken: falsekubectl edit で足していたら、この 2 つは管理対象なので selfHeal に戻されていました

GitOps 環境でハードニングを入れるときは、手を付ける前に「これは管理対象か」を status.resources で確かめるのが順序です。管理対象なら Git 側を直す。管理対象外なら直接直してよいが、次に誰かが Git へ書き足した瞬間に管理対象へ変わることは頭に置いておきます。

試験相当の粒度

「指定の Deployment で ServiceAccount トークンの自動マウントを無効にせよ」という 1 問で 3〜5 分が目安です。「Namespace の default ServiceAccount でトークンの自動マウントを止めよ」も同程度になります。Pod を作り直すまで既存 Pod には効かない点を、試験でも忘れないでください。設定だけ入れて確認を怠ると、採点対象の Pod が古いままになります。

fanclub の 8 Pod における ServiceAccount トークン露出の Before / After を比較し、攻撃者によるトークン奪取経路が断たれることを示す図
図2:ServiceAccount トークン露出の Before / After

kubectl create token –duration に上限は無い ——2 つのフラグはセットで効く

ここが本回の核です。「期限付きだから安全」という思い込みを、実機で崩します。

TokenRequest でトークンを発行する

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

$ kubectl create token fanclub-backend -n fanclub

1 行の JWT が標準出力に返ります(トークン文字列そのものは資格情報なので本文には載せません)。既定の有効期限は 1 時間で、--duration で変えられます。

上限を試す

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

$ kubectl create token fanclub-backend -n fanclub --duration=876000h -o json

実行結果(expirationTimestamp の行のみ抜粋):

        "expirationTimestamp": "2126-07-02T06:55:24Z"

2126 年です。100 年有効のトークンが、エラーも警告も無く発行されました。876000h という値は 100 年を時間に直したもので、365 × 24 × 100 = 876,000 時間です。返る日付は実行した時点から 100 年後になるため、読者の環境では別の日付になります。

kubectl create token は「期限付きトークンを発行する仕組み」として紹介されることが多く、それ自体は正しい説明です。ただし期限を決めるのは発行を要求した側です。トークンを要求できる権限を持つ者は、自分で好きな期限を指定できます。

攻撃者が cluster-admin 相当の権限を一時的に得た場合、真っ先に行うことのひとつが長期トークンの発行です。侵入経路を塞がれても、100 年有効のトークンが 1 本残っていれば、そのトークンで戻ってこられます。しかもトークンには「発行済みのものを無効化する」仕組みが標準ではありません(ServiceAccount を作り直すか削除するしかありません)。

したがって「期限付きだから安全」は不完全です。期限に上限を課すのはクラスタ側の責務になります。

API Server 側の上限 ——2 つのフラグ

上限は kube-apiserver のフラグで設定します。関係するのは 2 つです。

フラグの説明は、動いている API Server のコンテナに --help を打たせれば読めます。k8s-ops から developer 権限で実行できます(Control Plane Node へログインする必要はありません)。

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

$ kubectl -n kube-system exec kube-apiserver-k8s-cp-01 -- kube-apiserver --help | grep 'service-account-max-token-expiration duration'

実行結果:

      --service-account-max-token-expiration duration     The maximum validity duration of a token created by the service account token issuer. If an otherwise valid TokenRequest with a validity duration larger than this value is requested, a token will be issued with a validity duration of this value.

説明文の末尾に (default ...) がありません。 このフラグには既定値が無く、未設定なら上限も無いということです。もう一方を見ると対照的です。

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

$ kubectl -n kube-system exec kube-apiserver-k8s-cp-01 -- kube-apiserver --help | grep 'service-account-extend-token-expiration'

実行結果:

      --service-account-extend-token-expiration           Turns on projected service account expiration extension during token generation, which helps safe transition from legacy token to bound service account token feature. If this flag is enabled, admission injected tokens would be extended up to 1 year to prevent unexpected failure during transition, ignoring value of service-account-max-token-expiration. (default true)

こちらには (default true) があり、さらに ignoring value of service-account-max-token-expiration(もう一方の値を無視する)と書かれています。2 つのフラグの関係が、公式ヘルプの 1 文に明記されているということです。

フラグ既定値意味
--service-account-max-token-expiration記載なし=未設定=上限なしTokenRequest で要求された有効期限の上限。これより長い要求は、この値に切り詰められる
--service-account-extend-token-expirationtrueadmission が注入したトークンの期限を最大 1 年まで延長する。service-account-max-token-expiration の値を無視すると公式ヘルプに明記されている

後者の既定が true になっているのには理由があります。レガシートークンから bound token へ移行する途中のクラスタで、トークンを読み直さない古いクライアントを壊さないための移行措置だからです。互換性のための既定値であって、安全側の既定値ではありません。

2 つはセットで効きます。

--service-account-max-token-expiration だけを入れても、Pod へ注入されるトークンには効きません--service-account-extend-token-expiration が既定の true のままだと、そちらが max を無視するためです。

逆に --service-account-extend-token-expiration=false だけを入れても、kubectl create token --duration=876000h は通ります。上限が無いからです。

片方だけでは片手落ちです。 CIS ベンチマークが 1.2.30 で要求しているのは --service-account-extend-token-expiration=false だけで、--service-account-max-token-expiration は CIS の項目にありません。つまり CIS を満たしたクラスタでも「100 年トークンを発行できる」穴はふさがっていませんベンチマークを通しても穴は残ります。第2回で chown etcd:etcd について書いたのと同じ構図です。

第2回で入れたフラグとの接続

第2回 のやってみよう① で CIS 1.2.30 を修復し、k8s-cp-01 にだけ --service-account-extend-token-expiration=false を追加しました。k8s-cp-02 / k8s-cp-03 には入っていません。現状を確認します。

実行コマンド(k8s-cp-01 / k8s-cp-02 / k8s-cp-03 の各ノード上・root):

# grep -c service-account-extend-token-expiration /etc/kubernetes/manifests/kube-apiserver.yaml
# grep -c service-account-max-token-expiration /etc/kubernetes/manifests/kube-apiserver.yaml

実行結果(3 台分をまとめたもの):

ノードextend-token-expirationmax-token-expiration
k8s-cp-0110
k8s-cp-0200
k8s-cp-0300

grep -c は該当行が無いと 0 を出力したうえで終了ステータス 1 を返します。エラーではないので驚かないでください。

足りないものが 2 つあります。--service-account-max-token-expiration が 1 台も入っていないことと、--service-account-extend-token-expiration=false が 1 台にしか入っていないことです。両方を 3 台に揃えるのが、次のやってみよう③ です。

発行済みトークンをどう扱うか

上限を設けても、既に発行された 100 年トークンは有効なままです。フラグは発行時に効きます。対処は次の 3 つしかありません。

  • その ServiceAccount を作り直す(ServiceAccount を削除すると、その ServiceAccount 名義のトークンは検証に失敗する)
  • 監査ログで発行を検知するserviceaccounts/token への create のイベント。第16回で扱う)
  • 発行時にオブジェクトへ紐づける ——kubectl create token <sa> -n <ns> --bound-object-kind=Pod --bound-object-name=<pod> と指定すると、そのトークンは指定した Pod に紐づく。Pod が消えればトークンも無効になる。前のセクションで扱った「Bound」を、kubectl create token の側でも作れるということである。これは発行を止める対処ではなく、発行するときに寿命を対象へ縛る対処である

設定を入れる前に発行されたものは、設定では消せません。 だから「いつ入れるか」が効いてきます。クラスタを立てた直後に入れるのが最も安く、運用が始まってからでは既存トークンの棚卸しが要ります。

やってみよう③:トークン期限に上限を入れ、3 台へ揃える

この演習を始める前に

  • 本演習は Control Plane Node 3 台の static Pod マニフェストを編集します。第2回で立てた鉄則(編集前にバックアップ・保存=即再起動・戻し方を先に覚える)をそのまま適用してください
  • 本回で足すのは値だけのフラグなので、第2回の鉄則4「3 点セット原則」(フラグ + volumeMounts + volumes)は要りません。ホスト上のファイルを参照しないためです
  • 1 台ずつ行い、1 台が復帰してから次へ進んでください。3 台同時に編集すると、書き間違えたときに API Server が全滅してクラスタが操作できなくなります
  • 編集前に cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak-04 を必ず実行してください
  • スナップショット等で復元できる環境で行ってください
  • --service-account-extend-token-expiration=false は、トークンを読み直さない古いクライアントを 1 時間強で認証失敗させます。本番へ入れる前に、API Server の serviceaccount_stale_tokens_total メトリクスが増えていないか(=期限切れトークンを使い続けているワークロードが無いか)を先に確認してください。本書ラボはすべて新しいコンポーネントで構成されているため、この値は増えません
  • 所要時間の目安は 25〜35 分です

ステップ1:現状を測る(フラグを入れる前)

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

$ kubectl create token fanclub-backend -n fanclub --duration=876000h -o json | grep expirationTimestamp

実行結果:

        "expirationTimestamp": "2126-07-02T06:55:24Z"

ここを起点にします。 演習の終わりに同じコマンドを打って、結果が変わることを確認します。

ステップ2:k8s-cp-01 にフラグを揃える

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

# cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak-04
# vi /etc/kubernetes/manifests/kube-apiserver.yaml

command の配列に次の行を足します。

    - --service-account-max-token-expiration=24h
    - --service-account-extend-token-expiration=false

k8s-cp-01 で実際に足すのは 1 行目だけです。 2 行目は第2回で既に入っているためです。k8s-cp-02 / k8s-cp-03 では2 行とも足します。この差を取り違えると、同じフラグを 2 回書いた状態になって API Server が起動しません。

挿入する位置はどこでも構いません。 command 配列の要素はフラグの集まりで、順序に意味はありません。kubeadm が生成したマニフェストはアルファベット順に並んでいるので、それに合わせておくと後から差分を追いやすくなります。本書では読みやすさを優先し、関係する 2 つのフラグが並ぶように --service-account-extend-token-expiration=false の直後へ足しました。厳密なアルファベット順なら --service-account-key-file--service-account-signing-key-file の間になります。どちらでも動作は変わりません。

編集後の該当部分(k8s-cp-01):

    - --service-account-extend-token-expiration=false
    - --service-account-max-token-expiration=24h
    - --service-account-issuer=https://kubernetes.default.svc.cluster.local
    - --service-account-key-file=/etc/kubernetes/pki/sa.pub
    - --service-account-signing-key-file=/etc/kubernetes/pki/sa.key

インデントは既存の行に正確に合わせてください(半角スペース 4 個 + - )。YAML なので、ここがずれると API Server は起動しません。

ステップ3:再起動と復帰を確認する

保存した瞬間に kubelet が static Pod を作り直します。 第2回で扱った挙動そのままです。この間 API Server は応答しないので、ノード上で確認します。

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

# crictl ps | grep kube-apiserver
# curl -sk https://localhost:6443/healthz

実行結果(crictl ps は該当行のみ抜粋):

CONTAINER      IMAGE          CREATED         STATE     NAME             ATTEMPT   POD ID          POD
27202c38f93dc  9ec369c0ddb96  9 seconds ago   Running   kube-apiserver   0         7168599154565   kube-apiserver-k8s-cp-01
ok

CREATED が「数秒前」になっていることが、作り直された証拠です。 ATTEMPT0 なのは、起動をやり直していない(=設定が正しく読めた)ことを示します。本書ラボでは保存から /healthzok を返すまで約 36 秒でした。

ATTEMPT が増え続けていたり、crictl pskube-apiserver が出てこない場合は、書き間違えています。次のステップ3-b で戻してください。

ステップ3-b:戻し方を先に確認しておく

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

# cp /root/kube-apiserver.yaml.bak-04 /etc/kubernetes/manifests/kube-apiserver.yaml

このコピー 1 本で元に戻ります。 kubelet がマニフェストの変更を検知して static Pod を作り直すため、systemctl の操作は要りません。復帰は上と同じ crictl ps/healthz で確認します。

1 台ずつ進めることの意味はここにあります。 k8s-cp-01 の API Server が落ちても、HAProxy は残る k8s-cp-02 / k8s-cp-03 へ振り分けるので、k8s-ops からの kubectl は生き続けます。3 台を同時に編集して 3 台とも書き間違えると、この逃げ道が無くなります。

ステップ4:1 台だけ入れた状態で測る

これから 3 台に入れますが、まず 1 台だけ入れた状態で測ります。そこで何が起きるかが本演習の中心です。3 台目まで進めずに、いったん測ってください。

各 API Server を直接指定して発行します。

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

$ kubectl --server=https://192.168.1.125:6443 create token fanclub-backend -n fanclub --duration=876000h -o json | grep expirationTimestamp
$ kubectl --server=https://192.168.1.126:6443 create token fanclub-backend -n fanclub --duration=876000h -o json | grep expirationTimestamp

実行結果:

Warning: requested expiration of 3153600000 seconds shortened to 86400 seconds
        "expirationTimestamp": "2026-07-27T09:07:23Z"
        "expirationTimestamp": "2126-07-02T09:07:23Z"

1 行目の Warning: requested expiration of 3153600000 seconds shortened to 86400 seconds は k8s-cp-01 側の応答に付いたものです(標準エラー出力に出るため grep を素通りします)。上限で切り詰めたことを API Server がきちんと知らせてくれます。3,153,600,000 秒が要求した 100 年、86,400 秒が適用された 24 時間です。k8s-cp-02 側にはこの警告が出ません——切り詰めていないからです。

192.168.1.125 が k8s-cp-01、192.168.1.126 が k8s-cp-02 です。本書ラボ固有の値なので、読者の環境では自分の Control Plane Node の IP に読み替えてください。--insecure-skip-tls-verify は不要です。kubeadm は各 Control Plane Node の IP を API Server 証明書の SAN に含めるため、ノード IP を直接指定しても TLS 検証が通ります。セキュリティの教科書で TLS 検証を無効化する手順は載せません。

同じコマンドが、当たった API Server によって 24 時間と 100 年に分かれました。

続けて、通常どおり k8s-lb(HAProxy)経由で 8 回連続実行します。

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

$ for i in 1 2 3 4 5 6 7 8; do kubectl create token fanclub-backend -n fanclub --duration=876000h -o json 2>/dev/null | grep -o '"expirationTimestamp": "....'; done

2>/dev/null で警告を伏せ、年の部分だけを取り出しています(警告まで出すと 8 回分が読みにくくなるためです)。

実行結果:

"expirationTimestamp": "2126
"expirationTimestamp": "2126
"expirationTimestamp": "2026
"expirationTimestamp": "2126
"expirationTimestamp": "2126
"expirationTimestamp": "2026
"expirationTimestamp": "2126
"expirationTimestamp": "2126

8 回中 2 回だけが 24 時間で、残り 6 回は 100 年のままです。どの回で当たるかは振り分け次第なので、読者の環境では並びが変わります。

HAProxy が 3 台へ振り分けるため、リクエストがどの API Server に当たるかで結果が変わります。フラグを 1 台にしか入れていない状態は、「入れていない」のと実質的に同じです。攻撃者はコマンドを 2 回打てば済みます。100 年トークンが返るまで繰り返せばよいだけです。

さらに悪いのは、運用者が「入れたつもり」になることです。k8s-cp-01 で確認すれば 24 時間が返るので、設定は効いているように見えます。断続的にしか現れない不整合は、テストをすり抜けます。

第2回のやってみよう③ でも同じ形が出ました。--kubelet-certificate-authority を k8s-cp-01 にだけ入れたとき、kubectl logsたまに失敗するという現れ方をしました。HA クラスタでは、Control Plane の設定は 3 台に揃えて初めて設定になります。

cp-01 にだけフラグを入れた状態では HAProxy の振り分け先によってトークン期限が 24 時間と 100 年に分かれ、3 台に揃えるとすべて 24 時間になることを示す図
図3:HA クラスタでフラグを 1 台だけに入れた場合と 3 台に揃えた場合の違い

ステップ5:k8s-cp-02 / k8s-cp-03 へ展開する

k8s-cp-02 → 復帰確認 → k8s-cp-03 → 復帰確認 の順で進めます。k8s-cp-02 / k8s-cp-03 では2 行とも追加します。手順はステップ2・ステップ3 と同じです。

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

$ kubectl get node
$ kubectl get pod -n kube-system -l component=kube-apiserver

3 台とも Running になり、5 ノードが Ready であることを確認してから次へ進んでください。

ステップ6:3 台揃えたあとに測り直す

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

$ for i in 1 2 3 4 5 6 7 8; do kubectl create token fanclub-backend -n fanclub --duration=876000h -o json 2>/dev/null | grep -o '"expirationTimestamp": "....'; done

実行結果:

"expirationTimestamp": "2026
"expirationTimestamp": "2026
"expirationTimestamp": "2026
"expirationTimestamp": "2026
"expirationTimestamp": "2026
"expirationTimestamp": "2026
"expirationTimestamp": "2026
"expirationTimestamp": "2026

8 回すべてが 24 時間側に揃いました。 ステップ4 と同じコマンドで結果だけが変わる——これが「設定が設定になった」ということです。2>/dev/null を外せば、8 回とも切り詰めの警告が出ることも確認できます。

ステップ7:正規の動作が壊れていないことを確認する

確認項目期待
5 ノード Ready維持
fanclub 8 Pod Running維持
HTTPS 200 + /api/members の JSON維持
ArgoCD fanclub-api-prodSynced / Healthy
Pod の projected token の expirationSeconds3607 のまま変わらない

本書ラボでは 5 ノード Ready・fanclub 8 Pod Running・HTTPS 200 + JSON・ArgoCD Synced / Healthy がすべて維持されました。

Pod に注入されるトークンは変わらない

最後の行が意外に見えるかもしれません。上限を 24 時間に設定したのに、Pod へ注入される projected token の expirationSeconds3607 のままです。

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

$ kubectl run tokentest --image=nginx:1.27-alpine -n default --restart=Never --dry-run=server -o jsonpath='{.spec.volumes}' | jq -r '.[] | select(.projected) | .projected.sources[0].serviceAccountToken'

実行結果:

{
  "expirationSeconds": 3607,
  "path": "token"
}

--dry-run=server を付けているので Pod は作られません。API Server が admission を通したあとの姿だけを見ています——実物を作らずに「注入されるとしたら何が入るか」を確認する手です。

変わらないのが正しい挙動です。

上限は「要求された期限がそれを超えていたら切り詰める」ものです。Pod へ注入されるトークンは admission が約 1 時間で要求しており、24 時間の上限に最初から引っかかりません。切り詰める対象が無いので、何も起きません。

ここで効いているのはもう一方のフラグです。--service-account-extend-token-expiration が既定の true だと、admission が注入したトークンは最大 1 年まで延長され得ます。3 台とも false に揃えたことで、この延長の余地が消えました。2 つのフラグは効く相手が違います——maxkubectl create token のような外からの要求に、extend は Pod へ注入されるトークンに効きます。だから両方が要ります。

このフラグも「手で入れたもの」である

kube-apiserver.yaml を直接編集して入れたフラグは、kubeadm upgrade でマニフェストが再生成された時点で失われます。kubeadm は kubeadm-config ConfigMap の ClusterConfiguration から static Pod マニフェストを作り直すため、そこに書かれていない設定は残りません。

恒久対応は kubeadm-config ConfigMap の apiServer.extraArgs に書くことです。試験では「今この場で効かせる」ことが問われるので直接編集でよいのですが、実務ではアップグレードのたびに堅牢化が巻き戻ります

やってみよう② で chart について書いたことと同じ構図です。手で締めた設定は、それを生成する側に書かない限り寿命が短くなります。 第5回で kubeadm アップグレードを扱うので、本回で入れたフラグがそこでどうなるかを確かめます。

24h という値の選び方

本書ラボで 24h が安全だと言えるのは、Pod へ注入されている projected token が expirationSeconds: 3607(約 1 時間)で、上限を大きく下回っているからです。上限を決める前に、いま発行されているトークンの最長がいくつかを見てください。 順序はここから始まります。

24h は本書ラボでの例であり、絶対的な正解ではありません。上限は「運用で必要な最長の期限」に合わせます。 CI が 4 時間かかるジョブでトークンを使うなら 24h でも足りますが、上限を 1h にすればそのジョブは失敗します。

決め方の順序は「まず現状で発行されているトークンの期限を調べる」→「その最大値に余裕を足す」→「上限を入れる」→「失敗が出ないか監視する」です。いきなり厳しい値を入れて壊すのは、締めているのではなく落としているだけです。第3回で NetworkPolicy を締めたときと同じ手順になります。

試験相当の粒度

ステップ試験相当の所要時間
kube-apiserver.yaml にフラグを 1 行追加して復帰確認約 4〜6 分
kubectl create token --duration で効果を確認約 1〜2 分

試験クラスタのノード構成は公式に公開されていません。 ただし CKS の設問は「指定されたホストで、指定された設定を入れる」形で示されるため、本回のように自分で 3 台を数えて揃えにいく作業そのものは問われにくいと考えられます。本回 4 本の演習のうち、試験の出題形式から最も遠いのがこの やってみよう③ になります。ただし「フラグを足す → static Pod が再起動する → 復帰を待つ」という手順は D2 の中心的な作業なので、手順そのものは確実に手に覚えてください。

ClusterRole × RoleBinding で namespace に閉じる設計

最小権限の中核にある設計です。第2巻第8回では 1 文で触れただけで、実際に作って効果範囲を測るところまではやっていません。本回はここを手を動かして確かめます。

同じ権限を複数の Namespace で使いたいとき

「Pod を読む」「Secret を読む」といった権限の定義は、Namespace ごとに Role を作ると同じ内容が並びます。定義は ClusterRole として 1 つだけ作り、RoleBinding で各 Namespace に紐づけます

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: exam-pod-reader
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: exam-pod-reader-binding
  namespace: rbactest
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: exam-pod-reader
subjects:
  - kind: ServiceAccount
    name: exam-auditor
    namespace: rbactest

効果範囲は rbactest Namespace の中だけになります。ClusterRole という名前がついていても、RoleBinding で紐づけた以上、他の Namespace では何も与えません。押さえる点は 3 つです。

  • 効果範囲を決めるのは Binding の側roleRef.kind: ClusterRole は「定義をどこから持ってくるか」を指すだけ
  • ClusterRole の定義に Namespace リソースでない対象nodes / persistentvolumes など)が含まれていても、RoleBinding で紐づけた場合それらには効かない。Namespace に属さないリソースへの権限は RoleBinding では与えられない
  • roleRef作成後に変更できない。差し替えたいときは Binding を削除して作り直す

ClusterRoleBinding を使うべき場面

場面選ぶもの
特定の Namespace の中だけで権限を与えたいClusterRole × RoleBinding
Node / PersistentVolume / Namespace などクラスタスコープのリソースを扱わせたいClusterRole × ClusterRoleBinding
全 Namespace を横断して読ませたい(監視エージェント等)ClusterRole × ClusterRoleBinding

判断の順序を決めておきます。まず RoleBinding で足りるかを考え、足りない理由が言えるときだけ ClusterRoleBinding を使ってください。 やってみよう① で見た velerolonghorn-support-bundle は、この順序を通らずに ClusterRoleBinding で cluster-admin を与えた形になっています。

kubectl create の generator を使う

RBAC は generator が充実しており、手書きより速く済みます。

用途コマンド
ClusterRolekubectl create clusterrole <name> --verb=get,list,watch --resource=pods
Rolekubectl create role <name> --verb=get --resource=configmaps -n <ns>
RoleBinding(ClusterRole 参照)kubectl create rolebinding <name> --clusterrole=<cr> --serviceaccount=<ns>:<sa> -n <ns>
ClusterRoleBindingkubectl create clusterrolebinding <name> --clusterrole=<cr> --serviceaccount=<ns>:<sa>

--serviceaccount の書式は <namespace>:<name> で、--assystem:serviceaccount:<ns>:<sa> とは違います。この 2 つの書式を取り違えるのが、試験での典型的な時間の損失になります。暗記コーナーで並べて再掲します。

generator の出力をそのまま適用しない使い方も覚えておいてください。--dry-run=client -o yaml を付けると YAML が標準出力に返るので、それを編集してから apply できます。試験環境では開始直後に export do="--dry-run=client -o yaml" を設定しておくと、次の 1 行でひな型が手に入ります。

本書ラボでも同じ設定を入れておきます。以降のコマンドは、この変数が設定されている前提です。

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

$ export do="--dry-run=client -o yaml"

この設定を忘れると $do は空に展開され、ひな型を出力するつもりが ClusterRole を実際に作成してしまいます。 その状態でやってみよう④ のステップ3 へ進むと、同名の ClusterRole が既に存在するため AlreadyExists で止まります。

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

$ kubectl create clusterrole exam-pod-reader --verb=get,list,watch --resource=pods $do > cr.yaml

generator が対応していないフィールド(aggregationRule など)を足したいときや、複数の rules を持つ ClusterRole を作りたいときは、この形になります。

実行結果(v1.35.6 の kubectl・cr.yaml の中身):

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: exam-pod-reader
rules:
- apiGroups:
  - ""
  resources:
  - pods
  verbs:
  - get
  - list
  - watch

先に手書きで示した YAML と同じ内容です(配列の書き方がブロック形式になっているだけで、意味は変わりません)。metadata には名前しか入らず、余計なフィールドは付きません。generator の出力はそのまま提出できる品質だと分かれば、試験で手書きに時間を使う理由がなくなります。

権限を「絞る」ときの粒度

resourceNames を使うと、対象を個別のリソース名まで絞れます。次は rules の書き方を示す抜粋であり、そのままでは適用できませんapiVersion / kind / metadata が欠けています)。

rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get"]
    resourceNames: ["fanclub-config"]

注意点があります。resourceNames を指定すると listwatch は使えなくなります。一覧を取る操作は「名前を知らないまま全部を取る」ものなので、名前で絞る指定とは両立しません。get だけを許すときに使ってください。fanclub の fanclub-backend-readergetlist の両方を持っているのは、アプリが一覧を必要とするためです。

resourceNames は generator でも指定できます--resource-name フラグです)。次のやってみよう④ で rbactest Namespace と exam-auditor ServiceAccount を作ったあとであれば、この挙動を自分の手で検算できます。ここは読み物として先に結論を示しておくので、実際に打つのはやってみよう④ のステップ5 まで進んだ時点で構いません。 ただしステップ6 の後片付けで rbactest Namespace を削除すると打てなくなるので、後片付けの前に済ませてください。

実行コマンド(k8s-ops 上・developer・やってみよう④ のステップ5 の後・ステップ6 の前に実行):

$ kubectl create clusterrole exam-cm-named --verb=get --resource=configmaps --resource-name=fanclub-config
$ kubectl create rolebinding exam-cm-binding --clusterrole=exam-cm-named --serviceaccount=rbactest:exam-auditor -n rbactest

--resource-name を付けると、生成される rulesresourceNames が入ります。上に示した抜粋と同じ内容です。続けて 4 通りの聞き方で測ります。resourceNames を付けたときは、確認する側も名前を指定する必要があります。

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

$ kubectl auth can-i get configmaps -n rbactest --as=system:serviceaccount:rbactest:exam-auditor
$ kubectl auth can-i get configmaps/fanclub-config -n rbactest --as=system:serviceaccount:rbactest:exam-auditor
$ kubectl auth can-i get configmaps/other-config -n rbactest --as=system:serviceaccount:rbactest:exam-auditor
$ kubectl auth can-i list configmaps/fanclub-config -n rbactest --as=system:serviceaccount:rbactest:exam-auditor

実行結果:

no
yes
no
no

4 行を上から読みます。

  1. 名前を付けずに get configmapsno。「どの ConfigMap でもいいから取れるか」という問いには許可していないため
  2. get configmaps/fanclub-configyes。許可した名前そのもの
  3. get configmaps/other-configno。名前が違えば通らない
  4. list は名前を指定しても no。名前で絞る指定と一覧取得は両立しない

1 行目が試験で引っかかりやすいところです。 権限を絞ったつもりが「no しか返ってこない」と見えて、設定を間違えたと思い込みます。resourceNames を使ったときは、can-i 側も <リソース>/<名前> の形で聞いてください。

やってみよう④:ClusterRole を RoleBinding で namespace 限定して検証する

この演習を始める前に

  • 本演習は専用の Namespace rbactest を作って行います。fanclub には手を入れません
  • 終わったら Namespace と ClusterRole を両方削除します(ステップ6)
  • 所要時間の目安は 10〜15 分です
  • 本回 4 本の演習のうち、試験の出題粒度に最も近いのが本演習です

ステップ1:検証用の Namespace と ServiceAccount を作る

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

$ kubectl create namespace rbactest
$ kubectl create serviceaccount exam-auditor -n rbactest

ステップ2:何もできないことを先に確認する

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

$ kubectl auth can-i list pods --as=system:serviceaccount:rbactest:exam-auditor -n rbactest

実行結果:

no

できないことを先に測ってください(第3回で立てた検証の型です)。あとで yes になったとき、それが本当にこの Binding のおかげだと言えるようにするための起点です。「効いていれば結果が変わる条件」を先に作っておきます。

ステップ3:ClusterRole と RoleBinding を作る

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

$ kubectl create clusterrole exam-pod-reader --verb=get,list,watch --resource=pods
$ kubectl create rolebinding exam-pod-reader-binding --clusterrole=exam-pod-reader --serviceaccount=rbactest:exam-auditor -n rbactest

実行結果:

clusterrole.rbac.authorization.k8s.io/exam-pod-reader created
rolebinding.rbac.authorization.k8s.io/exam-pod-reader-binding created

生成されたものを -o yaml で確認し、前のセクションで示した全量掲載と一致することを見てください。roleRef.kindClusterRole で、RoleBinding 側に metadata.namespace: rbactest が入っているのが要点です。この 2 つの組み合わせが「定義はクラスタ全体から、効果は Namespace の中だけ」を作ります。

ステップ4:namespace に閉じていることを 2 方向で確認する

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

$ kubectl auth can-i list pods --as=system:serviceaccount:rbactest:exam-auditor -n rbactest
$ kubectl auth can-i list pods --as=system:serviceaccount:rbactest:exam-auditor -n fanclub
$ kubectl auth can-i list pods --as=system:serviceaccount:rbactest:exam-auditor --all-namespaces
$ kubectl auth can-i delete pods --as=system:serviceaccount:rbactest:exam-auditor -n rbactest

実行結果:

yes
no
no
no

4 行の意味は次のとおりです。

確認結果意味
list pods -n rbactestyesBinding が効いている
list pods -n fanclubnoRoleBinding なので他 Namespace には効かない
list pods --all-namespacesno横断はできない
delete pods -n rbactestno与えた verb(get/list/watch)の外

「1 つ目が yes」だけを見て終わらないでください。 権限を与えたら与えていない側も確かめます。CKS の設問は「A ができること」と「B ができないこと」の両方を満たして初めて正解になります。第3回の NetworkPolicy で「通す」と「止める」を両立させたのと同じ構造です。

ステップ5:ClusterRoleBinding にすると何が変わるか(作らずに読む)

これは実際には作りません。 作れば全 Namespace に効いてしまうため、YAML を読んで違いを確認するに留めます。

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: exam-pod-reader-binding-cluster
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: exam-pod-reader
subjects:
  - kind: ServiceAccount
    name: exam-auditor
    namespace: rbactest

違いは 2 箇所だけです。kindClusterRoleBinding になり、metadata.namespace が消えました。参照している ClusterRole は同じです。それでも効果範囲は「rbactest の中だけ」から「全 Namespace」へ変わります。

kind の 1 語が効果範囲を決めます。 ここが やってみよう① で見た velero / longhorn-support-bundle の形と繋がります。2 つの YAML を並べて、この 1 語の違いを目で覚えてください。

補足:白紙から書くドリル(試験対策)

課題

fanclub Namespace の ServiceAccount fanclub-backend に対して、「fanclub の Secret を get だけできる」権限を、ClusterRole を新規に作らず既存の仕組みで与える構成を白紙から書いてください。書けたら 3 点を確認します。(a) Role と ClusterRole のどちらを選んだか、その理由を言えるか (b) Binding の kind は RoleBinding か (c) verbslist を入れていないか。

書いたものは適用しないでください。 fanclub-backend に Secret 権限を与えるのは、本書ラボの設計として不要です(実機の Role は configmaps だけで足りています)。設計を書く練習であり、権限を増やす演習ではありません。所要は 4〜6 分です。

ステップ6:後片付け

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

$ kubectl delete namespace rbactest
$ kubectl delete clusterrole exam-pod-reader exam-cm-named

exam-cm-named は前のセクションの resourceNames の検算で作ったものです(exam-cm-bindingrbactest Namespace の中なので、Namespace ごと消えます)。ClusterRole は Namespace に属さないので、Namespace を消しても残ります。 個別に削除してください。クラスタスコープのリソースは掃除し忘れやすいものです。やってみよう① で数えた ClusterRole 104 本のうちいくつかは、こうして残ったものかもしれません。

試験相当の粒度

「ServiceAccount を作り、指定の Namespace で Pod を読める権限だけを与えよ」という 1 問で 5〜8 分が目安です。generator を使えば 2 行で済みます。手書きの YAML に時間を使わず、生成してから auth can-i --as で 2 方向を検証する時間に回してください。

暗記必須コマンド(第4回)

本回は「試験で使えるもの」と「使えないもの」の区別が特に重要になるので、分けて示します。

試験で使えるもの(kubectl のみ)

用途コマンド補足
権限の確認kubectl auth can-i <verb> <resource> --as=system:serviceaccount:<ns>:<sa> -n <ns>試験の主力yes / no だけ返る
実効権限の一覧kubectl auth can-i --list --as=system:serviceaccount:<ns>:<sa> -n <ns>-n で指定した Namespace での実効権限
自分の身元kubectl auth whoamiUsername と Groups が読める
ClusterRole 作成kubectl create clusterrole <name> --verb=get,list,watch --resource=podsgenerator
Role 作成kubectl create role <name> --verb=get --resource=configmaps -n <ns>generator
RoleBinding(ClusterRole 参照)kubectl create rolebinding <name> --clusterrole=<cr> --serviceaccount=<ns>:<sa> -n <ns>namespace 限定の定石
ClusterRoleBindingkubectl create clusterrolebinding <name> --clusterrole=<cr> --serviceaccount=<ns>:<sa>効果範囲が全 Namespace
ServiceAccount 作成kubectl create serviceaccount <name> -n <ns>
期限付きトークン発行kubectl create token <sa> -n <ns> --duration=1hTokenRequest
自動マウントの抑止(SA 単位)kubectl patch serviceaccount <sa> -n <ns> -p '{"automountServiceAccountToken":false}'Pod を作り直すまで既存 Pod には効かない
Pod と SA の対応を一覧kubectl get pod -n <ns> -o custom-columns='NAME:.metadata.name,SA:.spec.serviceAccountName,AUTOMOUNT:.spec.automountServiceAccountToken'棚卸しの起点
旧式トークンの検出kubectl get secrets -A --field-selector type=kubernetes.io/service-account-tokenあれば指摘対象

書式の取り違えに注意してください。 同じ ServiceAccount を指す指定なのに、コマンドによって書式が違います。

場面書式
--as で impersonate するときsystem:serviceaccount:<ns>:<sa>(コロン 3 個)
--serviceaccount で Binding を作るとき<ns>:<sa>(コロン 1 個)

この 2 つを取り違えると、エラーにならずに別の Subject を指すことがあります。試験で最も時間を失いやすい箇所のひとつです。

試験では使えないもの

ツール理由実務での使いどころ
rbac-toolwho-can / policy-rules / analysisCKS 参照可能ドキュメント 8 件に含まれないgithub.com 自体も参照不可)90 本の Binding を機械的に展開する。ベースラインを取って増分を見る

暗記の要点

kubernetes.io/docs は試験の許可ドキュメントに含まれるので、RBAC の YAML サンプルも書式も公式から取れます。本回の範囲に「調べられないから暗記するしかないもの」はありません。それでも次の 4 点を手に覚えるのは、調べる時間そのものを削るためです。1 問 5〜12 分の試験で、書式を確かめにドキュメントを開くたびに 1〜2 分が消えます。ここに挙げるのは「載っていないから覚える」ではなく「開かずに打てるようにしておくと差がつく」4 点です。

  • system:serviceaccount:<ns>:<sa> の綴り--as で使う)
  • --serviceaccount=<ns>:<sa> の書式(generator で使う)
  • ClusterRole × RoleBinding で namespace に閉じられるという組み合わせ
  • automountServiceAccountToken は ServiceAccount 単位と Pod 単位があり、Pod 側が勝つ

CKS 試験環境には alias k=kubectl がプリインストールされています。本文は読みやすさのため kubectl の全綴りで統一していますが、試験では k で打ってください

道具の差もここで確認しておきます。試験環境の SSH ホストには yq がプリインストールされています。既存のマニフェストにフィールドを足す、値を差し替えるといった操作を、エディタを開かずに済ませられます。逆に jq は公式のプリインストール一覧にありません。本文でラボ用に使った jq のパイプは、試験には持ち込めないものとして扱ってください。

まとめ

本回では次の点を確認しました。

  • fanclub の Role は既に configmapsget / list だけで、fanclub-backend の automount も既に false だった。本回は権限を作る回ではなく、既にあるものを疑う回だった
  • クラスタには ClusterRole 104 / ClusterRoleBinding 90 / ServiceAccount 83 が存在し、その大半は Helm chart が持ち込んだもの。CKS が問う過剰権限は、たいてい自分で書いていない側にある
  • cluster-admin を持つ ServiceAccount が 2 個実在した(longhorn-system/longhorn-support-bundlevelero/velero)。impersonation で delete nodes まで yes になることを確認した
  • 同じ cluster-admin でもリスクの質が違う。常時稼働の velero(Pod 3 個)は「侵害されれば即座に奪われる」、休眠の longhorn-support-bundle(Pod 0 個)は「その Namespace に Pod を作れる権限とセットで効く」
  • Secret を読める ServiceAccount は、手作業(ClusterRoleBinding 経由)では 9 個、rbac-tool では 22 個。差は RoleBinding 経由の付与を見ていなかったことによる。役割から説明できる権限は削る対象ではない。監査で見るのは「役割から説明できない権限」と「露出面の広さ」
  • rbac-tool は 90 本の Binding を機械的に展開できるが、CKS の参照可能ドキュメント 8 件に含まれない。試験の主力は kubectl auth can-i --as である
  • 自動マウントされるトークンは projected volume で、/var/run/secrets/kubernetes.io/serviceaccount に置かれ、期限は約 1 時間で kubelet が更新する。旧式の Secret 型トークンはラボに 1 件も無い
  • default ServiceAccount を automountServiceAccountToken: false にして 5 Pod のトークンを止めた。設定は Pod の作成時に評価されるため、既存 Pod は作り直すまで変わらない
  • kubectl create token --duration に上限は無い--duration=876000h で 100 年トークンが発行できた。上限を課すのはクラスタ側の責務である
  • --service-account-max-token-expiration--service-account-extend-token-expirationセットで効く。後者が既定の true のままだと、前者の値を無視する
  • HA では 3 台に揃えないと設定にならない。k8s-cp-01 にだけ上限を入れた状態では、HAProxy 経由 8 回のうち 2 回だけが 24 時間で、残り 6 回は 100 年のままだった
  • 手で締めた設定は helm upgradekubeadm upgrade で巻き戻る。Role / RoleBinding / ServiceAccount / automount はいずれも chart 管理下にある。恒久対応は生成する側に書く

理解度チェック(○×形式・全 9 問)

次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。

  1. default ServiceAccount は削除すれば再生成されないので、削除するのが正しい対処である
  2. ClusterRole を RoleBinding で紐づけると、権限はその Namespace の中だけに限定される
  3. Role を ClusterRoleBinding で紐づけると、その Role の権限が全 Namespace に効く
  4. ServiceAccount の automountServiceAccountToken: false を設定すると、既に動いている Pod からもトークンが即座に消える
  5. kubectl create token--duration には、API Server 側で上限を設定しない限り制限が無い
  6. --service-account-max-token-expiration を設定すれば、--service-account-extend-token-expiration の値にかかわらず Pod へ注入されるトークンの期限が制限される
  7. HA 構成で kube-apiserver のフラグを 1 台だけに追加した場合、その設定は全リクエストに一貫して適用される
  8. kubectl auth can-i --as は権限判定だけを行い、対象のリソースを変更しない
  9. 稼働 Pod が 0 個の ServiceAccount が cluster-admin を持っていても、リスクは無い
解答と解説

1=×/2=○/3=×/4=×/5=○/6=×/7=×/8=○/9=×

問1 は誤りです。default ServiceAccount は Namespace ごとに Kubernetes が自動生成するため、削除しても再生成されます。止めるのは自動マウントの側automountServiceAccountToken: false)です。設問文が「default SA を無効化せよ」と読める形で出ても、手を動かす先はここになります。

問3 は誤りで、組み合わせとして成立しません。ClusterRoleBinding の roleRef には ClusterRole しか指定できません。全 Namespace に効かせたいなら ClusterRole × ClusterRoleBinding を使います。問2 の ClusterRole × RoleBinding と対で押さえてください。

問4 は誤りです。ServiceAccount の設定は Pod の作成時に評価されます。既存 Pod のボリュームは残るので、rollout restart などで作り直すまで効果は現れません。設定を入れただけで確認を終えると、トークンが刺さったままの Pod が残ります。

問5 は正しく、--service-account-max-token-expiration には既定値が無く、未設定=上限なしです。ラボでは --duration=876000h で 2126 年まで有効なトークンが発行できました。問6 は誤りで、--service-account-extend-token-expiration の既定は true、公式ヘルプに「service-account-max-token-expiration の値を無視する」と明記されています。2 つのフラグはセットで入れてください。

問7 は誤りです。HAProxy が 3 台へ振り分けるため、当たった API Server によって結果が変わります。ラボでは 8 回中 2 回だけが上限の効いた結果になりました。HA では 3 台に揃えて初めて設定になります。

問9 は誤りです。Pod が 0 個なら「今この瞬間にトークンを持っている者はいない」というだけです。その Namespace に Pod を作れる者は、serviceAccountName に 1 行書くだけで cluster-admin の Pod を起動できます。休眠している過剰権限は、Namespace への書き込み権限とセットで効きます。問8 は正しく、impersonation は権限判定だけを行うので、監査に使っても対象を変更しません。

次回予告

第5回「API Server 堅牢化 + kubeadm アップグレード」では、D2 の後半に進みます。本回は「誰が何をできるか」を絞りました。次回は API Server そのものの入口を絞ります。--anonymous-auth=false / --authorization-mode=Node,RBAC / NodeRestriction Admission プラグイン、そして kubelet の認証・認可設定を扱います。あわせて、脆弱性を塞ぐための手段としてのクラスタアップグレードを、第2巻とは違う動機(運用ではなく防御)から見直します。

本回で扱った 「HA では 3 台に揃えないと設定にならない」 という教訓は、次回のフラグ操作でもそのまま効きます。static Pod 編集の鉄則も引き続き使います。本回で kube-apiserver.yaml に手で足したフラグが、kubeadm upgrade を通したあとに残っているかどうかも次回で確かめます。

→ 詳しくは 第5回 API Server 堅牢化 + kubeadm アップグレード

前の記事
次の記事