GKE 用 Workload Identity 連携 で Google サービスアカウント作成が不要になりました
こんにちは、クラウドエース株式会社 SRE 部の阿部です。
今回は Workload Identity Federation for GKE で Google サービスアカウント作成が不要となる機能拡張について紹介します。
はじめに
この機能は 2024 年 3 月 30 日に Cynthia Thomas さんから紹介されました。
また、スリーシェイク Annosuke Yokoo さんが 3 月 31 日に速攻で検証記事を執筆されています。
そのため、本記事の主旨は概ね上記のものと同様になりますが、個人として理解した内容を紹介していけたらと思います。
Workload Identity とは
Workload Identity は 2020 年 3 月 16 日に GA になった機能であり、Pod 用のメタデータサーバーを追加し、Google サービスアカウント(GSA)と Kubernetes サービスアカウント(KSA)を連携させ、 Google Cloud リソースにアクセスするためのアクセストークンを参照できる機能です。この機能がリリースされる以前は、Pod が Google Cloud リソースにアクセスするためには GSA のキーファイルを別途作成して Secret 経由でアクセスするか、ノードのサービスアカウントにもろもろロールを付与してアクセスするかのどちらかしかありませんでした。
ただ、この機能は KSA と連携させるための GSA を別途作成する必要があり、さらに GSA と KSA を連携させるための追加設定も必要でした。特に KSA の追加設定はマニフェスト管理が煩雑になりがちで、複数環境でシステム運用しているときに頭を悩ませがちでした。
また、 GSA は 1 プロジェクトあたりの作成数上限があり、サービスメッシュで多数のアプリケーションを動作させたい場合や、1 クラスタ上で Namespace を分割して複数の環境を管理するケースでは、GSA 数が上限に到達しがちという問題も発生します。
GKE 用 Workload Identity 連携 (Workload Identity Federation for GKE) とは
今回機能追加された GKE 用 Workload Identity 連携は、 以前の Workload Identity とは異なり、「Kubernetes サービス アカウント名」「Kubernetes サービス アカウントの名前空間」「Google Cloud プロジェクト ID」の 3 つを組み合わせて Cloud IAM における ID 情報(プリンシパル)として扱います。
これにより、Workload Identity で必要な設定は下記のような違いが発生します。
設定項目 | 以前の Workload Identity | GKE 用 Workload Identity 連携 |
---|---|---|
Namespace 作成 | ✅ | ✅ |
KSA 作成 | ✅ | ✅ |
GSA 作成 | ✅ | ❌ |
IAM 設定 | ✅ (GSA のプリンシパルを設定) | ✅ (KSA のプリンシパルを設定) |
KSA から GSA の設定 (annotations) |
✅ | ❌ |
GSA トークン作成ロール付与 | ✅ | ❌ |
※✅ が必要、❌ が不要
これまでは、以下のように KSA と GSA を対応付けて Cloud IAM の認可プリンシパルとして扱っていました。
これが、以下のように KSA を直接 Cloud IAM の認可プリンシパルとして扱うことが可能になりました。
GKE 用 Workload Identity 連携の設定
ここからは実際に GKE 用 Workload Identity 連携の設定を確認していきます。
下準備
まずは、動作確認のためのサンプルアプリを作ります。ちょっと良い感じのサンプルが見つからなかったので、Node.js でサンプルアプリを作ってみました。(慣れてないため拙い部分はご容赦ください)
このサンプルアプリは、 BUCKET_NAME
環境変数に設定した Cloud Storage バケットにアクセスし、オブジェクトの一覧を表示する動作です。
package.json
{
"name": "cloud_storage_sample",
"version": "1.0.0",
"description": "",
"main": "main.js",
"scripts": {
"build": "tsc",
"start": "npm run build && node app/main.js"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@types/node": "^20.12.2",
"eslint": "^8.57.0",
"typescript": "^5.4.3"
},
"dependencies": {
"@google-cloud/storage": "^7.9.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"sourceMap": true,
"outDir": "./app",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
Dockerfile
FROM node:18
COPY package.json package-lock.json tsconfig.json main.ts /
RUN npm ci && npm run build
ENTRYPOINT ["node", "/app/main.js"]
.gcloudignore
app/**
node_modules
main.ts
import {Storage, File, ApiError} from "@google-cloud/storage";
import * as http from "http";
const bucketName = process.env.BUCKET_NAME;
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080;
if (!bucketName) {
console.error("BUCKET_NAME variable is not defined.");
process.exit(1);
}
async function listFiles(bucketName: string): Promise<string[]> {
const storage = new Storage();
const bucket = storage.bucket(bucketName);
try {
const [files] = await bucket.getFiles({autoPaginate: true});
return files.map((file: File) => file.name);
} catch (error) {
throw error;
}
}
const server = http.createServer(async (request, response) => {
if (request.method !== "GET") {
response.writeHead(400);
response.end("");
return;
}
if (request.url == "/ready") {
response.writeHead(200);
response.end("Health OK");
return;
}
try {
const files = await listFiles(bucketName);
response.write("<html lang=\"ja\"><head><title>Test</title></head><body><main>");
response.write('<ul>');
files.forEach((name) => {
response.write(`<li>${name}</li>\n`);
});
response.end('</ul></main></body></html>\n')
} catch (error: unknown) {
if (error instanceof ApiError) {
response.writeHead((error as ApiError).code || 503);
response.end((error as ApiError).message);
} else {
response.writeHead(503);
response.end("unknown error");
}
console.error(error);
}
console.log("response end.");
});
server.listen(port);
console.log(`listening port on ${port}`);
上記のファイルを特定のディレクトリにコピーし作成した後、下記のコマンドを実行します。
## 変数設定
PROJECT_ID=your-google-cloud-project
LOCATION=asia-northeast1
REPO_NAME=sample-repo
## サンプルアプリをArtifact Registryに格納
gcloud artifacts repositories create ${REPO_NAME} --project ${PROJECT_ID} --location ${LOCATION}
## サンプルアプリをビルド
gcloud builds submit --region ${LOCATION} -t ${LOCATION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/list-gcs .
※PROJECT_ID
はお使いのプロジェクト ID に修正してください。
上記がうまくいくと、 sample-repo
という Artifact Registry リポジトリが作成され、リポジトリに list-gcs
というコンテナイメージが作成されます。
下準備 2
次に、テスト用の Cloud Storage バケットとテストファイルを用意します。
## 変数設定
PROJECT_ID=your-google-cloud-project
LOCATION=asia-northeast1
BUCKET_NAME=${PROJECT_ID}-wif-test
## Cloud Storage バケット作成
gcloud storage buckets create gs://${BUCKET_NAME} --pap -b -l ${LOCATION}
## テストファイル作成
mkdir work
cd work
touch file0{0..9}
## テストファイル転送
gcloud storage cp * gs://${BUCKET_NAME}
※PROJECT_ID
はお使いのプロジェクト ID に修正してください。
これで、PROJECT_ID-wif-test
というバケットが作成され、file00 ~ file09 の 10 個のファイルがコピーされたはずです。
GKE クラスタ作成
## 変数設定
PROJECT_ID=your-google-cloud-project
LOCATION=asia-northeast1
## GKE クラスタ作成 (Workload Identity 有効)
gcloud container clusters create wif-test \
--project ${PROJECT_ID} --location ${LOCATION} \
--num-nodes 1 --machine-type=e2-medium --disk-size=50 --spot \
--workload-pool=${PROJECT_ID}.svc.id.goog --workload-metadata=GKE_METADATA
※PROJECT_ID
はお使いのプロジェクト ID に修正してください。
※Workload Identity を有効にするため、--workload-pool=${PROJECT_ID}.svc.id.goog
と --workload-metadata=GKE_METADATA
のオプションは必ず付与してください。
※--num-nodes 1 --machine-type=e2-medium --disk-size=50 --spot
は Standard クラスタのノードプール設定です。必要に応じて修正してください。
サンプルアプリケーションのデプロイ
サンプルアプリケーションのデプロイのため、下記の YAML ファイルを作成します。
app.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: gcs-sample
---
apiVersion: v1
kind: Service
metadata:
name: gcs-sample
spec:
selector:
app: gcs-sample
ports:
- name: http-alt
port: 8080
protocol: TCP
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gcs-sample
labels:
app: gcs-sample
spec:
replicas: 1
selector:
matchLabels:
app: gcs-sample
template:
metadata:
labels:
app: gcs-sample
spec:
containers:
- name: gcs-sample
image: LOCATION-docker.pkg.dev/PROJECT_ID/sample-repo/list-gcs:latest
ports:
- containerPort: 8080
protocol: TCP
name: http-alt
env:
- name: BUCKET_NAME
value: your-gcs-bucket
livenessProbe:
httpGet:
port: 8080
path: /ready
failureThreshold: 2
successThreshold: 1
timeoutSeconds: 1
periodSeconds: 10
serviceAccountName: gcs-sample
このとき、YAML ファイルの以下の部分を修正する必要があります。
- Deployment の
containers.image
に指定するコンテナは、前段の手順で作成したコンテナイメージのパスに修正する - Deployment の
env.value
は、前段の手順で作成した GCS バケットの名前に修正する
上記修正後、下記のコマンドを実行します。
kubectl apply -f app.yaml
しばらくして、 サンプルアプリの Pod が Running になれば準備完了です。
サンプルアプリの動作確認 (GKE 用 Workload Identity 連携設定前)
まず、IAM 設定前の動作を確認します。下記のコマンドを実行し、 Pod の名前を確認します。
kubectl get pod
NAME READY STATUS RESTARTS AGE
gcs-sample-6f9f6f4d4f-m22n8 1/1 Running 0 3h47m
※Pod 名は gcs-sample
で始まるランダムな名前で表示されます。
その後、 上記アプリを自端末にポート転送します。
kubectl port-forward gcs-sample-****** 8080:8080
※gcs-sample-******
の部分は、前段の kubectl get pod
で確認した名前に置き換えてください。
上記コマンドにより、自端末の 8080 番ポートに通信が転送される状態になります。
curl 等で動作確認します。
curl localhost:8080
すると、以下のようなパーミッションエラーが応答されるはずです。
Caller does not have storage.objects.list access to the Google Cloud Storage bucket. Permission 'storage.objects.list' denied on resource (or it may not exist).
Workload Identity 連携設定がない場合は Pod のアクセストークンは IAM で許可設定していないため、このエラー応答は想定通りです。
いったん、 kubectl port-forward
は終了しましょう。
サンプルアプリの動作確認 (GKE 用 Workload Identity 連携設定後)
Pod にアクセス権限を付与するため、 GCS バケットの IAM で許可設定を追加します。
GKE 用 Workload Identity 連携におけるプリンシパルの書式は以下の通りです。
principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/PROJECT_ID.svc.id.goog/subject/ns/NAMESPACE/sa/SERVICE_ACCOUNT_NAME
-
PROJECT_NUMBER
: Google Cloud プロジェクト番号 (プロジェクト ID ではないため注意)-
gcloud projects describe ${PROJECT_ID} --format="value(projectNumber)"
で確認可能です。
-
-
PROJECT_ID
: Google Cloud プロジェクト ID -
NAMESPACE
: Kubernetes サービスアカウントの名前空間 -
SERVICE_ACCOUNT_NAME
: Kubernetes サービスアカウント名
今回のサンプルアプリの YAML ではデフォルトとは異なる gcs-sample
という Kubernetes サービスアカウントを作成しています。ただし、名前空間は変更していないため、 default
名前空間で起動しているはずです。
よって、プリンシパルの要素は以下のようになります。
- Kubernetes サービス アカウント名:
gcs-sample
- Kubernetes サービス アカウントの名前空間:
default
上記から、プリンシパル名は以下のようになります。
principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/PROJECT_ID.svc.id.goog/subject/ns/default/sa/gcs-sample
よって、GCS バケットにオブジェクトの一覧を表示するロール(roles/storage.objectViewer
)を付与するときのコマンドは以下の通りです。
gcloud storage buckets add-iam-policy-binding gs://BUCKET_NAME \
--role="roles/storage.objectViewer" \
--member="principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/PROJECT_ID.svc.id.goog/subject/ns/default/sa/gcs-sample"
※PROJECT_ID
と PROJECT_NUMBER
の部分は適宜置き換えてください。
このコマンドを実行してから再度 kubectl port-forward
と curl localhost:8080
を実行すると以下のような応答になります。
<html lang="ja"><head><title>Test</title></head><body><main><ul><li>file00</li>
<li>file01</li>
<li>file02</li>
<li>file03</li>
<li>file04</li>
<li>file05</li>
<li>file06</li>
<li>file07</li>
<li>file08</li>
<li>file09</li>
</ul></main></body></html>
上記は下準備 2 でアップロードしたファイルがサンプルアプリで表示できたことを示してます。
GKE 用 Workload Identity 連携設定のまとめ
本記事では下準備の手順が多く、なんだか煩雑に思えてしまったかもしれないですが、GKE 用 Workload Identity 連携は本質的には以下 3 つの設定のみです。
- GKE クラスタの Workload Identity 連携設定
- サンプルアプリケーションの YAML ファイルで追加した Kubernetes サービスアカウント
- IAM で設定した Kubernetes サービスアカウントの認可設定
以前の Workload Identity と GKE 用 Workload Identity 連携の機能差分
確認できた範囲での機能差について説明します。
監査ログに現れるプリンシパルの形式について
GCS 等でデータアクセス監査ログを有効にすると、アクセスログを取得することができます。
このとき、監査ログに記録されるアカウントは、以下のような違いがあります。
- 以前の Workload Identity:
protoPayload.authenticationInfo.principalEmail
フィールドにサービスアカウントの Email 形式で記録される。また、protoPayload.authenticationInfo.serviceAccountDelegationInfo.principalSubject
に権限借用している KSA のプリンシパルが記録される。 - GKE 用 Workload Identity 連携:
protoPayload.authenticationInfo.principalEmail
フィールドは記録されず、protoPayload.authenticationInfo.serviceAccountDelegationInfo.principalSubject
に KSA のプリンシパルが記録される。
ログシンク等のフィルターでアカウント名を指定している場合は、上記の違いに注意しましょう。
GKE メタデータサーバ (Pod から参照するメタデータ) で取得するデータ
Google Cloud ではインスタンスに関するメタデータをメタデータサーバ(metadata.google.internal
または、169.254.169.254
)にアクセスすることで取得できます。
このとき、Workload Identity を有効にしていると、 Compute Engine のメタデータサーバではなく、GKE メタデータサーバが中継して応答します。
今回紹介した Workload Identity のアクセストークンも、このメタデータサーバ経由で取得しています。
このメタデータサーバの動作も、微妙に異なっています。
- 以前の Workload Identity
-
/computeMetadata/v1/instance/service-accounts/default/email
: GSA の Email が表示される -
/computeMetadata/v1/instance/service-accounts/default/identity
:non-empty audience parameter required
のメッセージが表示される (レスポンスコードは 404)
-
- GKE 用 Workload Identity 連携
-
/computeMetadata/v1/instance/service-accounts/default/email
:PROJECT_ID.svc.id.goog
が表示される -
/computeMetadata/v1/instance/service-accounts/default/identity
: KSA に GSA の annotaion が設定されていないことを示すメッセージが表示される (レスポンスコードは 404)
-
つまり、新しい方法だと ID トークンは生成されない(するための情報が取得できない)ため、もし ID トークンを使いたい場合は旧方式を使用し、 IAM Credential API を使って ID トークンを生成する必要があります。
なお、どちらの場合も /computeMetadata/v1/instance/service-accounts/default/token
にアクセスすることでアクセストークンを取得できます。
まとめ
GKE 用 Workload Identity 連携の設定手順、動作検証について紹介しました。
以前対応した案件では、プロジェクトにおけるサービスアカウント管理数の上限に到達してしまいプロジェクト分割等で対応した記憶がありますが、GKE 用 Workload Identity 連携を使えば管理上の悩みが相当軽減できそうです。
GKE でアプリケーションを運用している方、特にサービスメッシュで多数のアプリケーションを運用している方は恩恵が大きいと思います。
この記事が誰かのお役に立てましたら幸いです。
参考情報
Google の GKE 用 Workload Identity 連携設定は下記の通りです。ただし、本記事執筆時点(4 月 3 日)では、英語版のみが最新化されており、日本語版は旧設定手順のままでした。
Discussion