Workload Identity連携でAzure App ServiceからGemini（VertexAI API）を利用する

## はじめに
Azure App Service の Python アプリケーションから Google Cloud の Gemini（VertexAI API）を利用する際、Workload Identity 連携の設定方法がわからず苦労したので、同じような課題に直面している方々の役に立つことを願って記事にしておこうと思います。

## Workload Identity 連携とは
Google Cloud の公式ブログで以下のように説明がされています。

> Workload Identity 連携は、キーなしの新しいアプリケーション認証メカニズムであり、オンプレミス、AWS、Azure で実行するワークロードは、外部 ID プロバイダ（IdP）と連携し、サービス アカウント キーを使用せずに Google Cloud リソースを呼び出すことができます。ワークロードはセキュリティ トークン サービス（STS）エンドポイントを呼び出して、IdP から取得した認証トークンを有効期間が短い GCP アクセス トークンと交換します。そして、このアクセス トークンを使用してサービス アカウントになりすまして、GCP リソースにアクセスするサービス アカウントの権限を継承します。

[キーなしの API 認証 - サービス アカウント キーを必要としない Workload Identity 連携によるクラウド セキュリティの向上](https://cloud.google.com/blog/ja/products/identity-security/enable-keyless-access-to-gcp-with-workload-identity-federation)


ひとことでいうと、「**外部 ID プロバイダと連携してサービスアカウントキーを使わずに Google Cloud リソースへのアクセスができる認証方式**」といえます。

サービスアカウントキーは秘密鍵を含むため、保管場所の管理や定期的なキーローテーションの実施など、漏洩リスクへの対策が必要です。
また、必要最低限の権限を持つように設定されたサービスアカウントキーが増えると管理がさらに複雑になる問題もあります。そのため、公式ではサービスアカウントキーに代わる、より安全な代替手段の使用を推奨しています。

https://cloud.google.com/docs/authentication?hl=ja

ということで、Workload Identity 連携を使った認証方式で Azure App Service のアプリケーションから Google Cloud の VertexAI API（Gemini）を利用するための設定手順を Azure、Google Cloud、アプリケーションの順に紹介していきます。

## Azure 側の設定手順

Google Cloud の公式ドキュメントでは Azure VM での設定方法が記載されていますが、App Service での設定方法とは異なる部分があるのでまとめておきます。

https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds?hl=ja

### 1. App Service の「システム割り当てマネージド ID」を有効化

#### 1-1. Azure ポータルで対象の App Service の選択 → [設定] → [ID]をクリック

#### 1-2. [システム割り当て済み] を選択し、[状態] をオンにする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/528b49bc-4357-c621-65ff-39766c0943e7.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/d6b2cf78-b5ff-9f5b-9f01-a7d62dfd332d.png)


- 「システム割り当て」と「ユーザー割り当て」があるが、今回は App Service 内でのみ認証が必要なため「システム割り当て」を選択
- 複数のリソースで同一の認証をしたい場合は、「ユーザー割り当て」を選択

https://learn.microsoft.com/ja-jp/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#add-a-system-assigned-identity

### 2. App Service のアクセストークンを取得

#### 2-1. Azure ポータルで対象の App Service の選択 → [開発ツール] → [SSH] → [移動] で App Service に ssh ログイン

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/522aff68-9758-7c08-5117-bab4d30b2c93.png)

#### 2-2. 下記の curl コマンドを実行してアクセストークンを取得

```bash
curl -H "X-IDENTITY-HEADER: ${IDENTITY_HEADER}" \
    "${IDENTITY_ENDPOINT}?resource=https://management.azure.com/&api-version=2019-08-01" | jq
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/8f5123cf-67a4-8aac-3c31-db0052c4761a.png)

- 上記の curl コマンドを貼り付けるときは、`Ctrl ＋ C` が効かないので [右クリック] → [貼り付け] で貼り付ける
- マネージド ID を有効化すると App Service に環境変数の `IDENTITY_ENDPOINT`、`IDENTITY_HEADER` が自動で設定されるので、この `IDENTITY_ENDPOINT` に必要なパラメータを指定してリクエストするとアクセストークンを取得することができる（参考: [REST エンドポイントの参照 | Microsoft Learn](https://learn.microsoft.com/ja-jp/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#rest-endpoint-reference)）
- jq は見やすさのために使用しているが、デフォルトでは App Service にインストールされていないので必要であればインストールする
- `access_token` の値を選択して、[右クリック] → [コピー] でコピーする

### 3. GCP 側に設定する `aud`、`iss`、`sub` クレームの値を取得
#### 3-1. 取得したトークンは JWT 形式なので、[jwt.ms](https://jwt.ms/) や [jwt.io](https://jwt.io/) などにアクセス（今回は [jwt.ms](https://jwt.ms/)  を利用）

#### 3-2. コピーした `access_token` を貼り付けると下記のようにデコードされるので、`aud`、`iss`、`sub`の値をメモしておく

- **`aud`（オーディエンス）クレーム**は、curl の `resource` に指定した `https://management.azure.com/` のはず
- **`iss`（発行者）クレーム**の赤の部分は、Azure のテナント ID になっているはず
  - テナント ID は、[Azure ポータル] → [Microsoft Entra ID] の概要から確認できる
- **`sub`（サブジェクト）クレーム**は、App Service のマネージド ID（オブジェクト（プリンシパル）ID）になっているはず

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/237cafee-5713-5196-4f6c-88dfd234b60d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/005b3709-ebdc-6aa7-c2c2-9e8dff010d13.png)


## Google Cloud 側の設定手順

### 1. Workload Identity プールの作成
#### 1-1. Google Cloud Console にログインし、ナビゲーションメニューから「IAM と管理」→「Workload Identity 連携」を選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/ae39b4d3-3365-d11d-257d-42c5991df961.png)

#### 1-2.「プールを作成」ボタンをクリックし、以下の情報を入力して「続行」 をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/426c68dc-b624-bc15-344e-97bc748087b9.png)

- **名前（プール名）:** 適切な名前（例: azure-workload-pool）
- **プール ID:** プール名と同じで良さそう（例: azure-workload-pool）
- **プール説明:** 任意

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/cb716265-c893-4753-6b63-f4b6a1c658a5.png)


#### 1-3. プロバイダの選択で「OpenID Connect（OIDC）」を選択し、下記を入力

- **プロバイダ名:** 適切な名前（例: azure-workload-provider）
- **プロバイダ ID:** プロバイダ名と同じで良さそう（例: azure-workload-provider）
- **発行元（URL):** Azure 側の設定の 3 で取得した `iss` クレームの値を入力（例: `https://sts.windows.net/{TENANT_ID}/`）

#### 1-4. オーディエンスで「許可するオーディエンス」を選択し、Azure 側の設定の 3 で取得した `aud` クレームの値を入力（例: `https://management.azure.com/`）して「続行」 をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/9c38d419-788f-0689-799c-afa65aeded5e.png)

#### 1-5. プロバイダの属性の構成を下記のように設定して「保存」をクリック
  - **OIDC1** に「`assertion.sub`」を入力
  - **「マッピングを追加」** で、**Google2** に「`attribute.subject`」、**OIDC2** に「`assertion.sub`」を入力

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/72d6e868-cfdd-7675-d858-d03254ead73b.png)


### 2. フェデレーション ID に Vertex AI ユーザー権限を付与

※ Workload Identity 連携で Google Cloud リソースへのアクセス権を付与するには、「連携 ID（フェデレーション ID）にリソース（今回は Vertex AI ）への直接アクセス権を付与する方法」と「サービスアカウントの権限借用する方法」の 2 種類ありますが、今回は推奨されている「連携 ID（フェデレーション ID）にリソース（今回は Vertex AI）への直接アクセス権を付与する方法」で設定を行います。

https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds?authuser=3&hl=ja#mappings-and-conditions

#### 2-1. Google Cloud Console で「IAM と管理」→「IAM」を選択

#### 2-2. 「GRANT ACCESS」をクリックし、下記を入力して保存

- 「新しいプリンシパル」に下記をそれぞれ置き換えて入力（参考: [外部ワークロードが Google Cloud リソースにアクセスできるようにする | IAM Documentation | Google Cloud](https://cloud.google.com/iam/docs/workload-download-cred-and-grant-access?hl=ja&authuser=3#allow-access)）
  - **PROJECT_NUMBER:** Google Cloud のプロジェクト番号（Google Cloud Console のナビゲーションメニューの「Cloudの概要」の[プロジェクト情報]で確認できる）
  - **POOL_ID:** Workload Identity プール ID（例: azure-workload-pool）
  - **ATTRIBUTE_NAME:** プロバイダの属性の **Google2** に設定した値（例: attribute.subject）
  - **ATTRIBUTE_VALUE:** Azure 側の設定の 3 で取得した `sub`（サブジェクト）クレームの値を入力（App Service のマネージド ID（オブジェクト（プリンシパル）ID）と同じ値のはず）

```
principalSet://iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{POOL_ID}/{ATTRIBUTE_NAME}/{ATTRIBUTE_VALUE}
```

- 「ロールを選択」で「 Vertex AI ユーザー」を選択し、保存

## アプリケーション側の設定手順

アプリケーションから Google Cloud にアクセスする際は、認証情報の構成ファイルを配置し、環境変数 `GOOGLE_APPLICATION_CREDENTIALS` にそのパスを指定する必要があります。

構成ファイルは下記の情報を設定する必要があります。

```json
{
  "universe_domain": "googleapis.com",
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/{PROJECT_NUMBER}/locations/global/workloadIdentityPools/{POOL_ID}/providers/{PROVIDER_ID}",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "credential_source": {
    "url": "{IDENTITY_ENDPOINT}?resource=https://management.azure.com/&api-version=2019-08-01",
    "headers": {
      "X-IDENTITY-HEADER": "{IDENTITY_HEADER}"
    },
    "format": {
      "type": "json",
      "subject_token_field_name": "access_token"
    }
  }
}
```

キーなどの秘匿情報は含まれないので厳重に管理をする必要はありませんが、`IDENTITY_ENDPOINT` と`IDENTITY_HEADER` は App Service に自動で設定される環境変数の動的な値なので、都度書き換える必要があります。

なので、バックエンドのアプリケーションに「構成ファイルを生成して環境変数 `GOOGLE_APPLICATION_CREDENTIALS` を設定する」コードの例を紹介します。（参考: [Authenticating to Google Cloud from Azure App Services](https://jpassing.com/2024/03/22/authenticating-to-google-cloud-from-azure-app-services/)）

### 1. 構成ファイルを生成して環境変数 `GOOGLE_APPLICATION_CREDENTIALS` を設定するクラスを実装
- 構成ファイルの配置場所は、環境変数 `HOME` の直下に配置
  - App Service の環境変数 `HOME` は `/root` になっており、`/root/gcp_credentials.json` に配置されている

```py
import os
import json
from typing import Dict, List
 
 
class GCPWorkloadIdentityCredentialGenerator:
    def __init__(self) -> None:
        # 必要な環境変数を取得
        self.project_number: str = os.environ.get("GCP_PROJECT_NUMBER", "")
        self.pool_id: str = os.environ.get("GCP_WORKLOAD_IDENTITY_POOL_ID", "")
        self.provider_id: str = os.environ.get("GCP_WORKLOAD_IDENTITY_PROVIDER_ID", "")
        self.identity_endpoint: str = os.environ.get("IDENTITY_ENDPOINT", "")
        self.identity_header: str = os.environ.get("IDENTITY_HEADER", "")
 
        # 環境変数の検証
        required_vars: Dict[str, str] = {
            "GCP_PROJECT_NUMBER": self.project_number,
            "GCP_WORKLOAD_IDENTITY_POOL_ID": self.pool_id,
            "GCP_WORKLOAD_IDENTITY_PROVIDER_ID": self.provider_id,
            "IDENTITY_ENDPOINT": self.identity_endpoint,
            "IDENTITY_HEADER": self.identity_header,
        }
        missing_vars: List[str] = [
            var for var, value in required_vars.items() if not value
        ]
        if missing_vars:
            raise EnvironmentError(
                f"以下の環境変数が設定されていません: {', '.join(missing_vars)}"
            )
 
        # オプションの環境変数（デフォルト値あり）
        self.api_version: str = os.environ.get("AZURE_IMDS_API_VERSION", "2019-08-01")
        self.resource: str = os.environ.get(
            "AZURE_RESOURCE_URI", "https://management.azure.com/"
        )
        self.subject_token_type: str = os.environ.get(
            "GCP_WORKLOAD_IDENTITY_SUBJECT_TOKEN_TYPE",
            "urn:ietf:params:oauth:token-type:jwt",
        )
        self.token_url: str = os.environ.get(
            "GCP_WORKLOAD_IDENTITY_TOKEN_URL", "https://sts.googleapis.com/v1/token"
        )
        self.universe_domain: str = os.environ.get(
            "GCP_WORKLOAD_IDENTITY_UNIVERSE_DOMAIN", "googleapis.com"
        )
        self.credential_type: str = os.environ.get(
            "GCP_WORKLOAD_IDENTITY_CREDENTIAL_TYPE", "external_account"
        )
        self.subject_token_field_name: str = os.environ.get(
            "AZURE_SUBJECT_TOKEN_FIELD_NAME", "access_token"
        )
 
    def generate_credentials_json(self, output_file_path: str) -> None:
        # プレースホルダを置き換えた値を生成
        audience: str = (
            f"//iam.googleapis.com/projects/{self.project_number}"
            f"/locations/global/workloadIdentityPools/{self.pool_id}"
            f"/providers/{self.provider_id}"
        )
        identity_url: str = (
            f"{self.identity_endpoint}?resource={self.resource}&api-version={self.api_version}"
        )
 
        # 認証情報の構成 JSON データを作成
        credentials: Dict[str, object] = {
            "universe_domain": self.universe_domain,
            "type": self.credential_type,
            "audience": audience,
            "subject_token_type": self.subject_token_type,
            "token_url": self.token_url,
            "credential_source": {
                "url": identity_url,
                "headers": {"X-IDENTITY-HEADER": self.identity_header},
                "format": {
                    "type": "json",
                    "subject_token_field_name": self.subject_token_field_name,
                },
            },
        }
 
        home_dir = os.environ.get("HOME", "/home")
        full_output_path = os.path.join(home_dir, output_file_path)
        # JSON ファイルを書き込み
        with open(full_output_path, "w") as f:
            json.dump(credentials, f, indent=2)
        # 環境変数に設定
        os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = full_output_path
 
        print(f"認証情報の構成 JSON ファイルを {output_file_path} に生成しました。")
```

### 2. vertexai.init の前で 1 を実行するように実装

```diff
+ import vertexai

+ gcp_credential_generator = GCPWorkloadIdentityCredentialGenerator()
+ gcp_credential_generator.generate_credentials_json("gcp_credentials.json")
 
vertexai.init(project="xxxxx-project", location="asia-northeast1")
gcp_model = GenerativeModel(model_name="gemini-1.5-pro-002")
```

### 3 App Service に環境変数を追加
- 3-1. Azure ポータルで対象の App Service の選択 → [設定] → [環境変数] で設定

  - **GCP_PROJECT_NUMBER:** Google Cloud のプロジェクト番号（Google Cloud Console のナビゲーションメニューの「Cloud の概要」の [プロジェクト情報] で確認できる）
  - **GCP_WORKLOAD_IDENTITY_POOL_ID:** Workload Identity プール ID（例: azure-workload-pool）
  - **GCP_WORKLOAD_IDENTITY_PROVIDER_ID:** Workload Identity プールのプロバイダ ID（例: azure-workload-provider）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422652/98650769-d9bf-4769-f3dd-b5b3b84dad86.png)

以上で設定は完了です。

## さいごに

今回は、Azure App Service から Google Cloud の Vertex AI API（Gemini）を利用する際の Workload Identity 連携を使った認証方法について紹介しました。サービスアカウントキーを使用せずに認証を行うことで、セキュリティリスクを低減しつつ、クラウド間連携を実現することができます。
設定手順は多少複雑に見えるかもしれませんが、一度構築してしまえば安全で管理しやすい認証基盤として活用できます。特に、複数環境での運用や本番環境でのセキュリティ要件が厳しいケースでは、この方式を採用する価値は大きいと考えています。
なお、この記事で紹介した方法は 2024 年 10 月時点での実装例となります。
公式ドキュメントも合わせて確認することをお勧めします。

この記事が誰かの参考になれば幸いです。