Lambda から Vertex AI を呼ぶときにサービスアカウント鍵 (SSM Parameter Store) をやめて Workload Identity Federation にする方法

こんにちは、株式会社Spacelyでバックエンドエンジニアをしている toshichanapp です。開発本部で横断的にインフラ・基盤領域を担当しています。

前回の記事では、ECS / Rails から Maps Platform を呼ぶときに API key をやめてサービスアカウント + Workload Identity Federation (WIF) にした話を書きました。今回はその姉妹編で、AWS Lambda から Vertex AI (Gemini) を呼ぶケースを扱います。前回が「API key → WIF」だったのに対し、今回は 「サービスアカウントの JSON 鍵を SSM Parameter Store に置く方式 → WIF」 が出発点です。

題材は Vertex AI ですが、これは認証の話なので AI に固有ではありません。「Lambda から GCP のサービスをサービスアカウントで呼んでいて、そのサービスアカウント鍵が SSM に置いてある」というパターン全般に同じく当てはまります。鍵をなくす部分 (サービスアカウント + WIF) はサービス非依存で、変わるのは最後の SDK 呼び出しだけです。

TL;DR

  • Lambda から Vertex AI を呼ぶのに、サービスアカウントの JSON 鍵を SSM Parameter Store (SecureString) に置き、実行時に /tmp へ展開して GOOGLE_APPLICATION_CREDENTIALS で読ませる方式が出発点です。API key よりはマシですが、「長期鍵が存在する」という一点で API key と同じ穴を抱えています — 漏洩リスク・ローテーション運用・環境ごとの鍵管理が残ります。
  • 解決策は前回と同じサービスアカウント + Workload Identity Federation。Lambda 実行ロールから GCP サービスアカウントを impersonate し、鍵そのものをなくす。SSM も /tmpssm:GetParameter 権限も不要になります。
  • GCP 側 (WIF / サービスアカウント / impersonation) は前回の ECS 版と完全に同一です。違うのは AWS 側でクレデンシャルを取り出す「配線」だけで、Lambda は環境変数経路なので google-auth 標準がそのまま動き、最も簡単でした。

1. 現状: サービスアカウント鍵を SSM Parameter Store に置く方式

まず「API key ではないのに、なぜ変えるのか」を整理します。たとえば、サービスアカウントの JSON 鍵を SSM から取得して使う、次のような構成です。

# Before: SSM からサービスアカウント鍵を取得して /tmp に展開する共通処理
def setup_google_credentials_from_ssm(parameter_name: str) -> str:
    ssm = boto3.client("ssm")
    response = ssm.get_parameter(Name=parameter_name, WithDecryption=True)

    credentials_path = "/tmp/google-credentials.json"
    with open(credentials_path, "w") as f:
        f.write(response["Parameter"]["Value"])

    os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials_path
    return credentials_path

# 呼び出し側
setup_google_credentials_from_ssm("my-service-sa-credential-json")
client = genai.Client(vertexai=True, project=PROJECT, location=LOCATION)

これは API key を直書きするよりは確実に良い方式です。SecureString で暗号化され、コードにも環境変数にも鍵の値は出てきません。しかし「サービスアカウントの JSON 鍵という長期クレデンシャルが、SSM と実行中の /tmp に実体として存在する」 という構造は変わりません。

サービスアカウント鍵を SSM に置くことの課題

前回の記事では、GCP 外からサービスアカウントを使う方法として「(a) サービスアカウント鍵 (.json) を環境変数に注入する」「(b) WIF」の 2 つを挙げ、(a) は「鍵を持ち回る状態は API key と本質的に同じ問題」と書きました。SSM に置くのは (a) と本質的に同じです。保管場所が環境変数から SecureString に変わって少し堅くなりますが、長期鍵であることは同じです。具体的には以下が残ります。

観点 サービスアカウント鍵を SSM に置く方式 WIF (キーレス)
鍵の実体 SSM + 実行時 /tmp に存在 無し
漏洩時の影響 長期鍵。手動失効するまで悪用可能 一時トークン (~1時間) のみ
ローテーション 手動運用が必要 (放置されがち) 不要
環境ごとの管理 dev/stag/prod で鍵を個別に発行・登録 principalSet の設定だけ
必要な IAM 権限 実行ロールに ssm:GetParameter + KMS Decrypt 追加権限不要
初期セットアップ gcloud で鍵生成 → SSM へ登録する手作業 鍵生成・登録が無い
漏洩経路 SSM 読取権限を持つ全 principal / ログ誤出力 / /tmp ダンプ (長期鍵が無いので該当なし)

とくに効くのが漏洩時の影響とローテーションです。サービスアカウント鍵は明示的に失効させるまで有効なので、一度漏れると長期間悪用できます。一方 WIF で得られるのは ~1時間で切れる一時トークンなので、漏れても被害が時間で頭打ちになります。そして「鍵が無い」ので、そもそもローテーションという運用自体が消えます。

2. 推奨解: サービスアカウント + WIF (キーレス)

仕組みは前回の ECS 版とまったく同じです。サービスアカウントそのものは廃止せず、「サービスアカウントを impersonate する入り口」から鍵を消すのが WIF の本質です。サービスアカウントのロール (roles/aiplatform.user 等) はこれまで通りサービスアカウントに付与します。

sequenceDiagram
    participant Lambda as AWS Lambda<br/>(実行ロール)
    participant STS as GCP STS<br/>(Workload Identity Pool / Provider)
    participant IAM as GCP IAM Credentials API
    participant API as Vertex AI<br/>(Gemini)

    Lambda->>STS: AWS STS GetCallerIdentity 署名済みリクエスト<br/>(AWS 認証情報は環境変数から取得)
    STS-->>Lambda: Google access token (federated)
    Lambda->>IAM: generateAccessToken (target サービスアカウントを impersonate)
    IAM-->>Lambda: target サービスアカウントの access token
    Lambda->>API: Authorization: Bearer ... (google-genai が自動付与)
    API-->>Lambda: 生成結果

ポイントは前回同様、Lambda 自身は AWS STS を叩かないことです。GetCallerIdentity の署名済みリクエストを作って GCP STS に渡すと、GCP STS が代理で AWS STS に問い合わせてロール ARN を検証します。Lambda から実際に出ていく通信は GCP 側 (sts.googleapis.com / iamcredentials.googleapis.com / aiplatform.googleapis.com) だけです。

3. インフラ (Terraform)

GCP 側の Terraform も前回と同型です。WIF Pool / Provider で「指定 AWS アカウントの、特定 Lambda 実行ロールからの認証」だけを受け入れ、サービスアカウントに roles/iam.workloadIdentityUser を principalSet で追加します。

resource "google_iam_workload_identity_pool" "sample_lambda_aws_pool" {
  workload_identity_pool_id = "sample-lambda-aws-pool"
}

resource "google_iam_workload_identity_pool_provider" "sample_lambda_aws_provider" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.sample_lambda_aws_pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "sample-lambda-aws-provider"

  # 特定の Lambda 実行ロールのみ許可
  attribute_condition = "assertion.arn.contains('assumed-role/${local.sample_vertexai_wif_lambda_role}/')"

  attribute_mapping = {
    "google.subject"     = "assertion.arn"
    "attribute.aws_role" = "assertion.arn.contains('assumed-role') ? 'arn:aws:iam::${local.aws_account_id}:role/' + assertion.arn.extract('assumed-role/{role_name}/') : assertion.arn"
  }

  aws {
    account_id = local.aws_account_id
  }
}

resource "google_service_account" "sample_vertexai_wif" {
  account_id = "sample-vertexai-wif"
}

resource "google_project_iam_member" "sample_vertexai_wif_vertex_ai" {
  project = local.project_id
  role    = "roles/aiplatform.user"
  member  = "serviceAccount:${google_service_account.sample_vertexai_wif.email}"
}

resource "google_service_account_iam_member" "sample_vertexai_wif" {
  service_account_id = google_service_account.sample_vertexai_wif.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.sample_lambda_aws_pool.workload_identity_pool_id}/attribute.aws_role/arn:aws:iam::${local.aws_account_id}:role/${local.sample_vertexai_wif_lambda_role}"
}

既に AWS 向けの WIF Pool が別用途 (ECS 等) で存在すれば、Provider を足すだけで流用もできます。今回は独立したサンプルとして専用 Pool を作成しました。

4. Lambda 実装 (Python / google-auth)

Python では google-auth が WIF (external_account) をネイティブサポートしています。鍵を一切持たず、環境変数から credential config を組み立てて渡すだけです。

使う環境変数を先に整理します。とくに プロジェクト番号 (GCP_PROJECT_NUMBER) とプロジェクト ID (GOOGLE_CLOUD_PROJECT) は別物で、前者は WIF の audience を組み立てるため、後者は Vertex AI クライアントに渡すために使います。名前が似ていて片方だけ設定するとハマりやすいので注意してください。

環境変数 用途 備考
GCP_PROJECT_NUMBER WIF の audience を組み立てる GCP プロジェクト番号 (数字)
WIF_POOL_ID Workload Identity Pool ID Terraform の workload_identity_pool_id
WIF_PROVIDER_ID Workload Identity Provider ID Terraform の workload_identity_pool_provider_id
GCP_SERVICE_ACCOUNT_EMAIL impersonate するサービスアカウント 例: sample-vertexai-wif@<project>.iam.gserviceaccount.com
GOOGLE_CLOUD_PROJECT Vertex AI クライアントの project GCP プロジェクトID (文字列)
GOOGLE_CLOUD_LOCATION Vertex AI のロケーション 任意。未設定なら global
GEMINI_MODEL 使用するモデル 任意。未設定なら gemini-2.5-flash
import os
import google.auth
from google import genai


def build_wif_credentials():
    """環境変数から external_account の credential config を組み立て、
    キーレスで GCP サービスアカウントを impersonate する認証情報を返す。"""
    project_number = os.environ["GCP_PROJECT_NUMBER"]
    pool_id = os.environ["WIF_POOL_ID"]
    provider_id = os.environ["WIF_PROVIDER_ID"]
    sa_email = os.environ["GCP_SERVICE_ACCOUNT_EMAIL"]

    credential_config = {
        "type": "external_account",
        "audience": (
            f"//iam.googleapis.com/projects/{project_number}"
            f"/locations/global/workloadIdentityPools/{pool_id}/providers/{provider_id}"
        ),
        "subject_token_type": "urn:ietf:params:aws:token-type:aws4_request",
        "token_url": "https://sts.googleapis.com/v1/token",
        "service_account_impersonation_url": (
            "https://iamcredentials.googleapis.com/v1/projects/-/"
            f"serviceAccounts/{sa_email}:generateAccessToken"
        ),
        "credential_source": {
            "environment_id": "aws1",
            "regional_cred_verification_url": (
                "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
            ),
        },
    }
    credentials, _ = google.auth.load_credentials_from_dict(
        credential_config, scopes=["https://www.googleapis.com/auth/cloud-platform"]
    )
    return credentials


def handler(event, context=None):
    client = genai.Client(
        vertexai=True,
        project=os.environ["GOOGLE_CLOUD_PROJECT"],
        location=os.environ.get("GOOGLE_CLOUD_LOCATION", "global"),
        credentials=build_wif_credentials(),
    )
    response = client.models.generate_content(
        model=os.environ.get("GEMINI_MODEL", "gemini-2.5-flash"),
        contents=(event or {}).get("prompt", "接続テストです。一文で挨拶してください。"),
    )
    return {"statusCode": 200, "body": response.text}

setup_google_credentials_from_ssm() を呼んで /tmp に鍵を書く処理が丸ごと消え、build_wif_credentials() に置き換わりました。SSM アクセスも消えるので、Lambda 実行ロールから ssm:GetParameter も外せます。genai.Client の代わりに任意の GCP クライアント (例: google-cloud-storage) に差し替えても、ここで作ったクレデンシャルはそのまま渡せます。鍵をなくす部分は呼び出すサービスに依存しません。

ここで効いているのが、前回の記事で表にした 「Lambda は AWS クレデンシャルを環境変数から取れる」 という点です。

プラットフォーム AWS クレデンシャルの出所 google-auth 標準で動くか
Lambda 環境変数 (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN) ✅ そのまま動く
EC2 IMDS (http://169.254.169.254/) ✅ 標準対応
ECS / Fargate container credentials endpoint (http://169.254.170.2) ❌ → supplier 実装が必要

Lambda には EC2 のような IMDS がありませんが、実行ロールの一時クレデンシャルを環境変数で渡してくれます。google-auth はこれを最優先で読み (環境変数があればメタデータ取得に進む前に即 return します)、region も AWS_REGION で解決するので、credential_source に IMDS の URL (region_url / url) を書く必要がありません (書いても環境変数が優先されて参照されないデッドコンフィグになるだけなので、書かないのが素直です)。前回 Ruby/ECS では container endpoint 未対応で subclass を自作し、impersonation の prepare_auth_header 問題の patch も必要でしたが、Python + Lambda はそうした回避策ゼロで動きました。

なお Python で ECS から動かす場合も google-auth 標準は container endpoint 非対応ですが、Ruby のように subclass を当てるのではなく、公式の拡張点 AwsSecurityCredentialsSupplier を実装して aws_security_credentials_supplier に渡すのが正攻法です (今回は Lambda なのでそれも不要)。

5. 本番運用に向けて

サンプルとして動作確認した後、本番化にあたって考慮したいポイントです。

  • 共通 Lambda layer 化: GCP クライアントを共通 layer で配っている構成なら、WIF 移行時に build_wif_credentials() も同じ layer (SSM ヘルパーを置き換える形) に寄せると、DRY・バージョン統一・zip サイズ削減になります。1 関数だけなら不要 (YAGNI) で、移行フェーズでまとめて対応するといいでしょう。
  • クライアントのグローバルキャッシュ: 上のサンプルは呼び出しごとに build_wif_credentials() → トークン取得 (STS 往復) が走ります。Lambda のウォームスタートで使い回すなら、モジュールのグローバルスコープでクライアントを lazy init するとトークンが再利用され速くなります。
  • 追加ライブラリは不要: WIF は google-auth の標準機能です。dict で組み立てる代わりに、gcloud iam workload-identity-pools create-cred-config で credential config JSON を生成し GOOGLE_APPLICATION_CREDENTIALS で指す方法もあります (既存の鍵方式と同じ「環境変数でファイルを指す」形なので移行が楽。反面ファイルが 1 つ増える)。今回は「キーレス」を明示したくて dict 方式にしました。
  • 関数ごとの実行ロール: 関数ごとに実行ロールが分かれている場合、WIF は実行ロール ARN を信頼するので、移行時はロールごとに Provider の attribute_condition とサービスアカウントの principalSet へ登録します。

6. まとめ

  • API key でなくても、サービスアカウントの JSON 鍵を SSM に置く方式には「長期鍵」の課題 (漏洩・ローテーション・環境ごとの管理) が残ります。
  • サービスアカウント + WIF でキーレス化すれば、鍵も SSM も /tmpssm:GetParameter 権限も消え、得られるのは ~1時間で切れる一時トークンだけになります。
  • GCP 側 (WIF / サービスアカウント / impersonation) は前回の ECS 版と同一。Lambda は AWS クレデンシャルを環境変数で取れるので、google-auth 標準がそのまま動き、ECS のような subclass / patch は不要 — 前回書いた「Lambda が一番簡単」を実地で確認できました。

題材は Vertex AI でしたが、これは「Lambda から GCP のサービスをサービスアカウントで呼んでいて、その鍵が SSM に置いてある」状況一般に当てはまる話です。鍵を持たない Lambda は、運用上とても気が楽です。SSM に置いたサービスアカウント鍵の管理が地味に煩わしいと感じているなら、WIF への移行は十分に労力に見合います。


参考


Spacely では、Rails / Sidekiq を中心としたバックエンド開発と、Terraform によるインフラ・クラウド基盤の運用に取り組んでいます。今回の記事のような、認証やインフラの課題を一つずつ改善していく仕事に興味がある方と、一緒により良いプロダクトを作っていけたら嬉しいです。

採用ページ