🎉

GKE 用 Workload Identity 連携 で Google サービスアカウント作成が不要になりました

2024/04/05に公開

こんにちは、クラウドエース株式会社 SRE 部の阿部です。
今回は Workload Identity Federation for GKE で Google サービスアカウント作成が不要となる機能拡張について紹介します。

はじめに

この機能は 2024 年 3 月 30 日に Cynthia Thomas さんから紹介されました。

また、スリーシェイク Annosuke Yokoo さんが 3 月 31 日に速攻で検証記事を執筆されています。

https://zenn.dev/yokoo_an209/articles/new-workload-identity-federation-for-gke

そのため、本記事の主旨は概ね上記のものと同様になりますが、個人として理解した内容を紹介していけたらと思います。

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 の認可プリンシパルとして扱っていました。

以前の Workload Identity の図

これが、以下のように KSA を直接 Cloud IAM の認可プリンシパルとして扱うことが可能になりました。

GKE 用 Workload Identity 連携の図

GKE 用 Workload Identity 連携の設定

ここからは実際に GKE 用 Workload Identity 連携の設定を確認していきます。

下準備

まずは、動作確認のためのサンプルアプリを作ります。ちょっと良い感じのサンプルが見つからなかったので、Node.js でサンプルアプリを作ってみました。(慣れてないため拙い部分はご容赦ください)
このサンプルアプリは、 BUCKET_NAME 環境変数に設定した Cloud Storage バケットにアクセスし、オブジェクトの一覧を表示する動作です。

package.json
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
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
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
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 ファイルの以下の部分を修正する必要があります。

  1. Deployment の containers.image に指定するコンテナは、前段の手順で作成したコンテナイメージのパスに修正する
  2. 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_IDPROJECT_NUMBER の部分は適宜置き換えてください。

このコマンドを実行してから再度 kubectl port-forwardcurl 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 つの設定のみです。

  1. GKE クラスタの Workload Identity 連携設定
  2. サンプルアプリケーションの YAML ファイルで追加した Kubernetes サービスアカウント
  3. 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 日)では、英語版のみが最新化されており、日本語版は旧設定手順のままでした。

https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity

https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity

Discussion