GCP の Workload Identity 連携で「AWS」プロバイダを選ぶと EKS の IRSA では動かない
GCP では、サービスアカウント (SA) の鍵に頼らず Workload Identity 連携 (WIF) で認証することが推奨されています。
ですが、いざ移行しようとして unauthorized_client というエラーが出て困った経験はありませんか?
しかもこのエラーは、Pool やプロバイダを設定している最中には出ません。
アプリケーションが GCP の API を呼び、認証トークンを交換しようとした瞬間に返ってきます。
ワークロードが AWS の EKS で動いているなら、それは設定ミスではないかもしれません。
GCP の WIF で「AWS」プロバイダを選ぶと、EKS の IRSA (IAM Roles for Service Accounts) では原理的に動きません。
私はこれに気付くまでに、構成ファイルを 3 世代作り直しました。
本記事では動かない理由を説明したうえで、EKS で正しく動く設定と、動くけれど採ってはいけない回避策の見分け方までを解説します。
遭遇するエラー
GCP の WIF には 「AWS」プロバイダという種別があり、AWS アカウント ID と IAM ロールを指定するだけで設定できます。
AWS で動いているのだから、これを選ぶのが自然な発想です。
ですがこの方式で進めると、次の順番でエラーに当たります。
| エラー | 直接の原因 |
|---|---|
Failed to retrieve AWS IAM role. (401) | IMDSv2 必須のノードで IMDSv1 形式の構成ファイルを使っている |
unauthorized_client | 属性条件と、実際に提示されたロールが一致しない |
1 つ目は構成ファイルに imdsv2_session_token_url を足せば解消します。
ここで「IMDSv2 対応が原因だった」と考えて先に進むと、遠回りになります。
次に出る unauthorized_client が本質的な問題で、これは構成ファイルをどう直しても解消しません。
なぜ AWS プロバイダでは動かないのか
AWS プロバイダは、ワークロードが EC2 インスタンスメタデータサービス (IMDS) から取得した AWS 認証情報を提示する前提で作られています。
GCP のドキュメントも、この方式を「AWS の一時的なセキュリティ認証情報 (IAM ロールまたはインスタンスプロファイル) を使い、Google Cloud が GetCallerIdentity API で検証する」ものと説明しています。
属性マッピングの推奨値が google.subject=assertion.arn であることからも、想定されているのがインスタンスに紐づくロールであることが分かります。
ところが IRSA の仕組みは、これと噛み合いません。
| 取得経路 | 実際に得られるロール |
|---|---|
| IMDS が返すもの | ノードのインスタンスプロファイルのロール |
| IRSA が Pod に付けるもの | ServiceAccount に紐づく IAM ロール |
IRSA は、Web Identity トークンをファイルとしてマウントする仕組みです。
EKS Pod Identity Webhook が Pod に環境変数とトークンを注入し、AWS SDK がそれを使って AssumeRoleWithWebIdentity を呼びます。
つまり IRSA のロールは IMDS 経由では取得できません。
そのため AWS プロバイダの属性条件に IRSA のロール ARN を設定しても、実際に提示されるのはノードのロールになり、条件に一致せず unauthorized_client で拒否されます。
正解は OIDC プロバイダ
EKS は、クラスタごとに公開された OIDC 発行元を持っています。
AWS STS を経由せず、この発行元を GCP に直接信頼させるのが正しい方式です。
発行元 URL は次のコマンドで取得できます。
aws eks describe-cluster --name <クラスタ名> --region ap-northeast-1 \
--query 'cluster.identity.oidc.issuer' --output textWorkload Identity Pool にプロバイダを追加する際、種別は OpenID Connect (OIDC) を選びます。
JWK ファイルの登録は不要です。
EKS の OIDC は一般公開されているためです。
この方式では、Pod の ServiceAccount 単位で権限を分離できます。
トレードオフ: プロバイダがクラスタごとに必要になる
OIDC 発行元 URL はクラスタ固有のため、環境をまたいで共用できません。
| 項目 | AWS プロバイダ | OIDC プロバイダ |
|---|---|---|
| プロバイダの数 | 1 つに ARN を並べて複数環境をカバー | クラスタごとに 1 つ |
| 構成ファイル | 環境間で共用可能 | 環境ごとに別ファイル |
subject_token_type | ...aws:token-type:aws4_request | urn:ietf:params:oauth:token-type:jwt |
credential_source | environment_id / IMDS の URL 群 | file + format |
1 つのアプリをテスト環境と本番環境で動かすだけでも、プロバイダは 2 つ必要になります。
プロバイダ名が構成ファイルの audience に埋まるため、構成ファイルも環境ごとに分かれます。
なお Pool は 1 つで足ります。
1 つの Pool に複数のプロバイダを共存させられるためです。
Pool は「どこからの ID を信頼するか」を表すので、用途名ではなく信頼元で命名する方が後から流用しやすくなります。
ちなみに Pool ID とプロバイダ ID は後から変更できません。
公式手順は Deployment の変更を要求する
ここが判断の分かれ目になります。
GCP の Kubernetes 向け WIF の手順は EKS も対象にしていますが、GCP 専用のオーディエンスを持つトークンを別途マウントすることを前提としています。
具体的には、Deployment に projected token volume を追加し、その audience に次の形式の値を指定します。
https://iam.googleapis.com/projects/<プロジェクト番号>/locations/global/workloadIdentityPools/<Pool ID>/providers/<プロバイダ ID>この方式は素直ですが、運用上の代償があります。
- Deployment に GCP 固有の値が入る (プロジェクト番号・Pool 名・プロバイダ名)
- プロバイダは環境ごとに異なるため、連携先の GCP プロジェクト × 環境の数だけ設定が増える
- 認証情報の構成ファイルと Deployment の二重管理になる
連携する GCP プロジェクトが増えるほど、Deployment に書く値の組み合わせも増えていきます。
そのひとつひとつを各アプリケーションの担当者に依頼することになるので、管理対象が多い環境ほど運用の負荷が跳ね上がります。
IRSA のトークンを流用する
そこで、IRSA が既にマウントしているトークンをそのまま使う選択肢があります。
私の環境ではこちらを採用しました。
連携先の GCP プロジェクトが多く、そのすべてについて各アプリケーションの Deployment 変更を依頼するコストが見合わなかったためです。
IRSA は Pod に次のパスでトークンを注入します。
/var/run/secrets/eks.amazonaws.com/serviceaccount/tokenこれは Pod Identity Webhook が自動で行うため、Deployment 側の変更は不要です。
ただし、このトークンの aud は sts.amazonaws.com (AWS STS 向け) です。
そのため GCP のプロバイダ側で、「許可するオーディエンス」に sts.amazonaws.com を明示的に登録します。
「デフォルトのオーディエンス」は GCP のリソース名と一致することを要求するため、IRSA のトークンは受け入れられません。
構成ファイルは次の形になります。
{
"universe_domain": "googleapis.com",
"type": "external_account",
"audience": "//iam.googleapis.com/projects/<プロジェクト番号>/locations/global/workloadIdentityPools/<Pool ID>/providers/<プロバイダ ID>",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": {
"file": "/var/run/secrets/eks.amazonaws.com/serviceaccount/token",
"format": { "type": "text" }
},
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/<SA のメールアドレス>:generateAccessToken"
}なお、GCP コンソールからダウンロードした構成ファイルはそのままでは使えません。
ダウンロードされるのは、アプリケーションのデフォルト認証情報 (ADC) 形式か、IMDS を参照する AWS 形式のどちらかです。
IRSA のトークンを参照する形式は出力されないので、手で作成します。
このファイルに秘密情報は含まれません。
プロジェクト番号・Pool 名・SA のメールアドレスといった構成情報のみです。
構成ファイルを Pod にどう届けるか
ここで 1 つ疑問が残ります。
トークンのマウントが不要になっても、構成ファイル自体は Pod から読める場所に無ければ意味がありません。
ConfigMap でマウントするなら volume の定義が要るので、結局は Deployment を触ることになります。
ですが前述のとおり、このファイルに秘密情報は含まれません。
そのため配布経路は自由に選べます。
私の環境では、構成ファイルの内容を DB に登録し、アプリケーションから読み込む形にしました。
Google の認証ライブラリは、ファイルパスではなく構成情報そのものを渡して認証情報を組み立てられるためです。
構成ファイルの中で参照している IRSA のトークンは、Pod Identity Webhook がすでにその場所へ置いています。
つまりファイルシステム上に用意すべきものは何も残らず、Deployment もコンテナイメージも変更せずに済みました。
ここで公式手順との違いがはっきりします。
projected token volume は Deployment のマニフェストに書くしかありません。
一方の構成ファイルは、アプリケーションが設定を読む既存の仕組みに相乗りできます。
本当の分かれ目は「Deployment を触るかどうか」ではありません。
GCP 固有の値がインフラの定義に入り込むかどうかです。
トークンの期限切れは気にしなくてよい
ファイルを参照する方式で不安になるのが、トークンの有効期限です。
projected token は永続的なものではなく、kubelet が期限の前に書き換えます。
起動時に読んだ値を保持し続ける実装なら、しばらく動いた後に落ちることになります。
ですが Google の認証ライブラリは、トークンを更新するたびにファイルを開き直します。
Python の実装では、読み取ったトークンをキャッシュせず毎回ファイルを読むことがコード上で明示されています。
そのため長時間動き続けるワークロードでも、ローテーションをアプリ側で意識する必要はありません。
どちらの方式を選ぶか
2 つの方式は、触れるインフラの範囲で選び分けるのが現実的です。
連携先の GCP プロジェクトが少なく、自分たちで Deployment を変更できるなら公式手順が安全です。
オーディエンスがプロバイダ固有の値になるので、設定を誤っても他の Pod のトークンは弾かれます。
逆に GCP プロジェクトや対象のアプリケーションが多いなら、IRSA のトークンを流用する方式が現実的です。
ただしその場合、次章の属性条件は省略できない前提条件になります。
最重要: 属性条件が唯一の権限境界になる
ここが本記事で一番伝えたい点です。
sts.amazonaws.com を許可する構成は、そのクラスタで IRSA を使う全ての ServiceAccount のトークンを受け入れうる状態を作ります。
GCP 専用オーディエンスの方式なら、aud 自体がプロバイダ固有の値なので、意図しない Pod のトークンは aud 不一致で弾かれます。
IRSA のトークンを流用する場合、その防壁がありません。
したがって、属性条件 (CEL) で信頼する ServiceAccount を明示的に絞り込む必要があります。
assertion.sub == "system:serviceaccount:<名前空間>:<ServiceAccount 名>"動かしてはいけない回避策
ここまでは正しい設定の話でした。
ですが AWS プロバイダ方式のまま、「動かす」こともできてしまいます。
AWS プロバイダ方式で unauthorized_client が出たとき、属性条件をノードのロールに合わせれば認証は通ります。
提示されているのがノードのロールなのだから、条件をそちらに合わせれば一致します。
エラーは消え、API も成功します。
しかしその状態は、そのノード上の全ての Pod が SA を借用できることを意味します。
ノードのロールは、そのノードに乗る全ての Pod で共通だからです。
これは AWS 自身が IRSA の利点として挙げている「認証情報の分離」を捨てる行為です。
AWS のドキュメントも、IMDS へのアクセスを制限しない場合は「同じノード上の他の Pod の IAM ロールの認証情報にアクセスできる可能性がある」と警告しています。
そもそも鍵レス化はセキュリティ施策であり、権限の最小化がその目的です。
認証が通ることを優先して権限境界を捨てるなら、施策の意味が失われます。
「動いた」を完了条件にしないでください。
エラーメッセージからの切り分け
WIF の設定は登場要素が多く、どこが悪いのか分かりにくいです。
エラーメッセージから原因を絞り込めます。
| メッセージ | 原因 |
|---|---|
invalid_target: The target service indicated by the "audience" parameters is invalid. | audience の Pool / プロバイダ名が誤っている、または無効化・削除されている |
The given credential is rejected by the attribute condition. | 属性条件と実際の ServiceAccount が不一致 |
Invalid attribute mapping. (コンソールの保存時) | 属性マッピングの右欄に式全体を貼っている |
unable to impersonate / 403 | SA への権限付与漏れ、または反映待ち |
Failed to retrieve AWS IAM role. (401) | IMDSv2 必須のノードで IMDSv1 形式の構成ファイルを使用 (AWS プロバイダ方式) |
unauthorized_client | IMDS が返すのはノードのロールで、IRSA のロールと一致しない (AWS プロバイダ方式) |
GCP の IAM の変更は反映に時間差があるため、設定直後の失敗は数分待って再試行してください。
属性マッピングの入力欄でつまずく場合
コンソールの属性マッピングは、左に属性名、右に値 (CEL 式) を分けて入力します。
この 2 つの欄には、それぞれ次の値だけを入れます。
| 欄 | 入力する値 |
|---|---|
| Google 1 (左) | google.subject |
| OIDC 1 (右) | assertion.sub |
右欄に google.subject = assertion.sub と丸ごと貼ると Invalid attribute mapping. になります。
右欄は値だけです。
構成ファイルが正しいのに動かないとき
設定を何度見直しても動かない場合は、認証ライブラリのバージョンも疑ってください。
外部アカウント認証情報 (external_account) に対応していない古いライブラリでは、この構成ファイルを読めません。
WIF は C++・C#・Go・Java・Node.js・PHP・Python・Ruby・Objective-C で利用できます。
言語ごとの最低バージョンは公式に明示されていないため、認証ライブラリを上げて切り分けるのが早道です。
補足: Google Play Developer API を叩く場合
ここから先は、WIF の先で Google Play Developer API を使う場合に限った話です。
他の GCP の API を叩くだけなら読み飛ばして構いません。
Play の API を利用する場合は、もう一段ハマりどころがあります。
Pool の「アクセスを許可」で、「サービス アカウントの権限借用を使用してアクセス権を付与する」を選ぶ必要があります。
デフォルトでは「フェデレーション ID を使用してアクセス権を付与する (推奨)」が選択されていますが、こちらでは Play の API を実行できません。
Play Developer API は GCP のリソースではなく、Play Console 側で SA に権限を付与する構造のためです。
そのため SA になりすます (Impersonation) 必要があります。
構成ファイルに service_account_impersonation_url が含まれていることを確認してください。
また、属性値には ServiceAccount の識別子だけを入れます。
principal://iam.googleapis.com/... の完成形を貼るとエラーになります。
GCP が属性名と属性値からプリンシパルを組み立てるためです。
そして権限の反映には時間がかかります。
Play Console でユーザーを招待した直後は、アクセストークンは取得できるのに API が 401 を返すことがあります。
"code": 401,
"message": "The current user has insufficient permissions to perform the requested operation.",
"reason": "permissionDenied"設定が正しくても発生します。
私の環境では数十分で解消しました。
Play Console 上のステータスが「有効」でも、API 側への反映は別タイミングになります。
移行は段階的に行える
最後に、移行を進める上で安心できる材料を挙げておきます。
鍵と WIF は排他ではありません。
同じ SA に両方が同時に存在できます。
そのため既存の鍵付き SA をそのまま使い、次の流れで段階的に移行でき、切り戻しも可能です。
- 鍵のみ有効 (現状)
- 鍵 + WIF が有効 (WIF 設定を追加)
- アプリ側の設定を WIF に切り替え
- 動作確認
- 鍵を削除 (安定後)
また、IRSA 化と WIF 化は別のレイヤの作業です。
先に IRSA 化だけを行って既存の鍵認証が壊れないことを確認しておくと、後で GCP 側の問題と AWS 側の問題を切り分けやすくなります。
まとめ
- GCP の WIF の「AWS」プロバイダは EKS の IRSA では機能しない。IMDS が返すのはノードのロールで、IRSA のロールは IMDS 経由では取得できない
- AWS プロバイダの解説ページは明示的に EKS を対象外としている。EKS では OIDC プロバイダを使う
- 公式の Kubernetes 手順は GCP 専用オーディエンスのトークンを別途マウントする前提。Deployment に GCP 固有の値が入り、連携先の GCP プロジェクト × 環境の数だけ設定が必要になる
- IRSA のトークン (
aud: sts.amazonaws.com) を流用すれば Deployment を触らずに済むが、属性条件が唯一の権限境界になる - 構成ファイルに秘密情報は無いので配布経路は自由。DB に置いてアプリから読ませれば、Deployment もイメージも変更せずに済む
- 属性条件をノードのロールに合わせると「動くが間違っている」状態になる。そのノードの全 Pod が SA を借用できてしまい、鍵レス化の目的を壊す
- Play の API を叩くなら「権限借用」を選ぶ。デフォルトの「フェデレーション ID」では実行できない
クラウドをまたぐ認証は、片方の常識がもう片方で通じないところにハマりどころが集中します。
「AWS で動いているから AWS プロバイダ」という素直な発想が外れるのは、その典型でした。
参考(出典)
- Configure Workload Identity Federation with AWS or Azure VMs(Google Cloud)
- Configure Workload Identity Federation with Kubernetes(Google Cloud)
- Configure Workload Identity Federation with other identity providers(Google Cloud)
- IAM roles for service accounts(Amazon EKS)
- google-auth-library-python identity_pool.py(トークンファイルの読み直し実装)
