K8s 高級 #2 RBAC / ServiceAccount 深さ — Aggregated ClusterRole / Impersonation / IRSA / Workload Identity

K8s 高級シリーズの 2 番目の記事です。中級 #7 で RBAC の 4 つのオブジェクト(RoleClusterRoleRoleBindingClusterRoleBinding)と権限を受ける 3 主体(User、Group、ServiceAccount)を扱いました。「最小権限」という原則と標準 ClusterRole を RoleBinding で namespace に限定して使うパターンまでがその記事の締めくくりでした。この記事ではその上に 1 層さらに入るテーマ 4 つをまとめます — Aggregated ClusterRole(権限の束をラベルで拡張)、Impersonation(別の主体の権限で一時的に呼び出す)、ServiceAccount トークンの lifecycle 変化(K8s 1.22 の projected token デフォルト化と 1.24 の legacy secret 自動生成停止)、外部 IAM との接続(EKS の IRSA、GKE の Workload Identity)。

このシリーズは K8s 高級 6 編です。

Aggregated ClusterRole — ラベルで合わさる権限の束 #

運用クラスタで標準 ClusterRole を直接触らずに権限を拡張したいときがあります。たとえば K8s が事前に作っておく view ClusterRole はすべての標準リソースの read 権限を束ねたオブジェクトです。私たちのチームが CRD(CustomResourceDefinition)で定義した新しいリソース種を追加したときに、「view 権限を持つ人はこの新しいリソースも自動で読めるべき」と考えるなら、view ClusterRole を直接修正せずにこの動作を表現できなければなりません。

この空白を埋めるオブジェクトが Aggregated ClusterRole です。モデルは単純です — 1 つの ClusterRole が自分の権限リストを直接書く代わりに、aggregationRule でラベルセレクタを書いておくと K8s がそのセレクタに合う他の ClusterRole の rules を集めて合わせてくれます。

aggregated-view.yaml — 標準 view ClusterRole の形 (単純化)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: view
aggregationRule:
  clusterRoleSelectors:
    - matchLabels:
        rbac.authorization.k8s.io/aggregate-to-view: "true"
rules: []  # 空 — コントローラが自動で埋める

rules が空である理由が核心です。K8s の RBAC コントローラがクラスタのすべての ClusterRole を舐めて、ラベルが rbac.authorization.k8s.io/aggregate-to-view: "true" のものたちの rules を集めて上の ClusterRole の rules フィールドに埋めてくれます。新しい権限を追加したいときは、そのラベルが付いた新しい ClusterRole を 1 つ作るだけで済みます。

my-crd-view.yaml — 新しい CRD の view 権限追加
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: my-crd-view
  labels:
    rbac.authorization.k8s.io/aggregate-to-view: "true"
rules:
  - apiGroups: ["myteam.example.com"]
    resources: ["widgets"]
    verbs: ["get", "list", "watch"]

このマニフェスト 1 枚を適用するとその瞬間から標準 view ClusterRole に widgets リソースの read 権限が自動で合わさります。view を RoleBinding で受けていたすべてのユーザーがコード 1 行も変えずに新しいリソースの read 権限を持ちます。

標準ラベルは 3 つです — aggregate-to-viewaggregate-to-editaggregate-to-admin。K8s が事前に作っておく view / edit / admin ClusterRole がそれぞれのラベルで権限を集めます。CRD を導入した運用チームがユーザー定義 RBAC をもっともきれいに拡張する道がこのモデルです。権限の source of truth が散らばりますが、標準 ClusterRole の意味を自然に拡張するという利点が大きいです。

Impersonation — 別の主体の権限で呼び出す #

運用中には「このユーザーはどんな動作ができるか」を事前に確認したいときがあります。人間ユーザーに新しい権限を付与する前にその権限でどんな動作が可能か検証したり、外部から入ってきた権限の問題を再現してみたり、ServiceAccount の権限範囲を確認するケースです。

K8s の Impersonation 機能はこの要求を満たしてくれます。呼び出し側が自分の資格で API を呼びながら「この呼び出しはユーザー X として振る舞ってくれ」とヘッダを一緒に送ると、API サーバが権限検査をその X の権限で実行します。呼び出し側自身の権限ではなく X の権限が適用されるので、呼び出し側にはまず impersonate 権限が必要です。

kubectl --as オプションで別のユーザーとして振る舞う
kubectl --as=alice@example.com get pods -n payments
kubectl --as=system:serviceaccount:default:my-sa get secrets

--as オプションが呼び出し側の impersonation 表現です。上の最初の行は「alice@example.com ユーザーの権限で payments namespace の Pod を照会」する呼び出しで、2 行目は「default namespace の my-sa ServiceAccount の権限で Secret を照会」する呼び出しです。

この呼び出しが通るためには呼び出し側の RBAC に次の権限が必要です。

impersonator-clusterrole.yaml — impersonate 権限付与
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: user-impersonator
rules:
  - apiGroups: [""]
    resources: ["users", "groups", "serviceaccounts"]
    verbs: ["impersonate"]

impersonate verb が核心です。この権限があるユーザーは自分の権限を超えて他のユーザーの権限で呼び出しを送れるので、事実上クラスタの権限モデル全体を回避できる権限です。impersonate 権限自体が非常にセンシティブで、運用環境では SRE またはセキュリティチームの少数人員にだけ付与します。

権限点検の標準ツール — kubectl auth can-i #

impersonation の日常的な活用は通常 kubectl auth can-i コマンドと組み合わさります。

権限点検 — alice が secret を作れるか
kubectl auth can-i create secrets --as=alice@example.com -n payments
出力
yes

この呼び出しは実際に Secret を作りはしません。API サーバの権限決定ロジックだけ回して yes/no を返します。新しい RoleBinding を適用する前にその意図が合うように解けるか確認するとき、または権限の問題が入ってきたときにどこで止まるかを押さえるときにもっとも先に呼ぶツールです。

ServiceAccount トークン — legacy secret から projected token へ #

中級 #7 で ServiceAccount のトークンが Pod 内の /var/run/secrets/kubernetes.io/serviceaccount/token に自動でマウントされると押さえました。この流れは 2 回に分かれて変わりました。K8s 1.22 では projected token がデフォルトになり、K8s 1.24 では legacy secret の自動生成が停止されました。運用クラスタでよくぶつかる変化なので順番に押さえておきます。

古いモデル — 自動生成される Secret オブジェクトの永久トークン #

K8s 1.21 までのデフォルト動作は次のとおりでした。ServiceAccount を作ると K8s が自動で同じ名前の Secret オブジェクトを作り、その Secret 内に ServiceAccount の JWT トークンを入れておきました。このトークンは 有効期限のない永久トークン でした。Pod がその ServiceAccount を使うと kubelet がその Secret をマウントしてトークンをコンテナ内に入れる方式です。

このモデルの問題は 2 つでした。

  • 有効期限がない — トークンが一度外部に漏れるとその ServiceAccount を削除するかトークンを回転するまで永久に有効です。
  • Pod とトークンが分離する — Secret 内のトークンは Pod のライフサイクルと無関係です。トークンの audience(誰のためのトークンか)情報もありません。

新しいモデル — Projected Token (Bound ServiceAccount Token) #

K8s 1.22 から projected token がデフォルト動作になりました。ServiceAccount を作っても legacy Secret オブジェクトが自動で生まれません。代わりに Pod が立つときに kubelet が その Pod 専用の短期 JWT トークンを発行して Pod のファイルシステムに直接マウントします。 このトークンの特徴は 3 つです。

  • 有効期限がある — デフォルト 1 時間、オプションでより短く / より長く調整可能。kubelet が期限前に自動で新しいトークンを発行して再マウントします(rotate)。
  • audience が明示される — このトークンがどの API サーバの呼び出しにのみ有効かがトークン内に書かれています。
  • Pod に縛られる — トークンが Pod の UID と縛られていて、Pod が消えるとそのトークンももう有効ではありません。
Pod マニフェストの projected token (自動で入る)
spec:
  containers:
    - name: app
      volumeMounts:
        - name: kube-api-access
          mountPath: /var/run/secrets/kubernetes.io/serviceaccount
          readOnly: true
  volumes:
    - name: kube-api-access
      projected:
        sources:
          - serviceAccountToken:
              path: token
              expirationSeconds: 3607
              audience: ""
          - configMap:
              name: kube-root-ca.crt
              items:
                - key: ca.crt
                  path: ca.crt
          - downwardAPI:
              items:
                - path: namespace
                  fieldRef:
                    fieldPath: metadata.namespace

kubelet が Pod を作るときにこの projected ボリュームを自動で追加します。マニフェストに直接書かなくてもすべての Pod に入るデフォルト動作です。コンテナ内のパス(/var/run/secrets/...)は古いモデルと同じなので、コンテナ内のコードはトークンモデルの変化を気にする必要がありません。

古い方式が必要なとき — 明示的 Secret #

CI パイプラインの外部ツールが K8s API を呼ぶとき、または IDE の K8s プラグインが一度受け取って継続的に使うトークンが必要なときのように 有効期限のないトークン が必要なケースが残っています。こういうケースは今は明示的に Secret を作らなければなりません。

legacy-token.yaml
apiVersion: v1
kind: Secret
metadata:
  name: my-sa-token
  annotations:
    kubernetes.io/service-account.name: my-sa
type: kubernetes.io/service-account-token

type と annotation が核心です。この Secret を作ると K8s がその中に my-sa の永久トークンを埋めてくれます。有効期限のないトークンなので外部露出時の影響が大きいです — 運用では可能な限り projected token を使い、本当に必要な場合にのみ明示的 Secret を作ってトークン回転ポリシーを一緒に立てておく方が安全です。

外部 IAM との接続 — IRSA と Workload Identity #

ここまでの話は K8s API 自体に対する権限でした。しかし運用クラスタのワークロードは K8s API 以外にもクラウドの他のサービスを呼びます — S3 バケット、RDS データベース、KMS のキー、Secrets Manager の秘密。これらの呼び出しは K8s の RBAC の外の領域、つまり クラウドの IAM で権限が評価されます。

伝統的な方法はクラウド資格情報(access key + secret key)を K8s Secret に入れて Pod にマウントすることでした。この方法の問題は明らかです — 資格情報が Secret として一度入ると有効期限がなく、回転が難しく、一度漏れるとその影響が大きいです。

EKS の IRSA(IAM Roles for Service Accounts) と GKE の Workload Identity はこの問題を同じ方式で解きます — K8s の ServiceAccount をクラウドの IAM Role と接続し、projected token を IAM の一時資格情報と交換 します。資格情報を静的に保管せず、呼び出し時点に短期トークンを発行されて使うモデルです。

IRSA の流れ — EKS の ServiceAccount + IAM Role #

IRSA のセットアップは 3 段階です。

IRSA セットアップ — 単純化した流れ
1. EKS クラスタに OIDC provider を有効化
   → EKS の ServiceAccount JWT トークンを AWS IAM が信頼するように設定
2. AWS IAM Role を作って trust policy に上の OIDC provider + 特定 ServiceAccount を書く
   → 「ns/my-app の SA = app-sa が発行したトークンだけがこの Role を取れる」
3. K8s ServiceAccount に Role ARN annotation を付加
   → eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT:role/my-app-role

このセットアップが終わったクラスタで Pod が AWS API を呼ぶと次の流れが自動で回ります。

Pod 内の AWS SDK が S3 を呼ぶとき
1. Pod にマウントされた projected token を読む
2. AWS STS の AssumeRoleWithWebIdentity API にそのトークンを送る
3. STS がトークンを EKS OIDC provider で検証
4. trust policy に書かれた ServiceAccount と一致するか確認
5. 一致すれば IAM Role の一時資格情報(15 分~12 時間)を返す
6. AWS SDK がその資格情報で S3 を呼ぶ

このすべての段階が AWS SDK 内で自動で起こるので、アプリケーションコードは資格情報を直接扱いません。コードはただ boto3.client('s3') を呼び出すだけで、SDK 内部の資格情報チェーンが上記の流れに従って動作します。

ServiceAccount + Pod マニフェスト (IRSA 適用)
apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: my-app
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/my-app-s3-role
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  namespace: my-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app
  template:
    metadata:
      labels:
        app: app
    spec:
      serviceAccountName: app-sa  # ← 上で作った SA を使用
      containers:
        - name: app
          image: my-app:latest

ServiceAccount 1 つと IAM Role 1 つが 1:1 で結ばれ、その ServiceAccount を使うすべての Pod がその IAM Role の権限を持ちます。静的資格情報を K8s Secret に保管する必要がなくなります。

GKE Workload Identity — 同じモデル、別の名前 #

GKE の Workload Identity も本質的に同じモデルです。K8s ServiceAccount を GCP IAM の Service Account と組み合わせ、projected token を GCP STS と交換して一時資格情報を受け取る流れです。

GKE Workload Identity — ServiceAccount annotation
apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: my-app
  annotations:
    iam.gke.io/gcp-service-account: my-app-sa@PROJECT.iam.gserviceaccount.com

annotation のキーと値の形式だけ違います。AKS も Azure Workload Identity で同じモデルを導入し、OIDC + STS ベースのトークン交換という同一の構造の上にクラウド事業者別アダプタが載っている形です。

導入時に押さえる運用原則 #

IRSA と Workload Identity はセキュリティ面ではほぼ標準に近いですが、セットアップ段階の運用原則をいくつか押さえておきます。

  • ServiceAccount 1 個 = クラウド IAM Role 1 個の 1:1 マッピング — 1 つの SA に多すぎる権限を集めずに、ワークロード単位で ServiceAccount を分け、各 SA に最小権限 IAM Role を付けます。
  • trust policy に namespace と SA name の両方を明示 — namespace だけ明示するとその namespace の他の SA が同じ Role を取れて隔離が崩れます。
  • トークン audience 検証 — STS がトークンを検証するときに audience(sts.amazonaws.com など)を確認します。ServiceAccount の projected token の audience を STS が期待する値と合わせる必要があります。
  • 資格情報の lifetime — IAM Role の一時資格情報は STS 呼び出し時点に発行されて通常 1 時間有効です。AWS SDK が自動で更新するのでアプリケーション側の処理は必要ありません。

締めくくり #

K8s 権限モデルの 1 層さらに深い部分をまとめました。Aggregated ClusterRole で標準権限の束をラベルで拡張するパターン、Impersonation で別の主体の権限で呼び出しながら kubectl auth can-i で権限の意図を検証する流れ、ServiceAccount トークン が永久 Secret から有効期限・rotation・audience を備えた projected token に変わった変化、IRSA / Workload Identity で K8s ServiceAccount をクラウド IAM と組み合わせて静的資格情報をクラスタの外に出したモデルまでがこの記事の領域でした。次の記事では K8s API サーバの admission 段階 — マニフェストが etcd に保存される直前に検査・変形する段階を扱う OPA Gatekeeper と Kyverno をまとめます。

X