こんにちは!PR TIMES 開発本部のエンジニア、トゥ(codyzard)です。
今回は、AWS 上で動くバッチから利用している複数の Google API の認証を、Service Account キーから WIF(Workload Identity Federation) へ移行した話を紹介します。対象となる API は、BigQuery、Retail API、Google Analytics Data API(以下、GA4 Data API)です。
このうち、GA4 Data API の移行では、次の2つの理由により追加対応が必要になりました。
- Google Analytics 固有の権限モデルにより、Service Account impersonation が必要だった
- GA4 バッチの既存実装で利用していた認証ライブラリが
external_accountに対応しておらず、アプリケーション側にも追加対応が必要だった
本記事では、この2つの理由を分けて説明し、それぞれにどう対応したかを紹介します。
はじめに:システム構成と WIF 移行の全体像
弊社では、いくつかのバッチ処理を AWS EC2 上の PHP アプリケーションとして動かしており、そこから BigQuery、Retail API、GA4 Data API を呼び出しています。
移行前:Service Account キーによる認証
これらの API を呼び出すには Google 側の認証が必要です。移行前はいずれも Service Account を権限主体とし、その Service Account として認証するために、Service Account キー(秘密鍵を含む JSON ファイル)を EC2 上に配置していました。このキーは構成管理リポジトリ上では Ansible Vault で暗号化し、デプロイ時にサーバーへ配置していました。
SA キーには次のような課題があります。
- 秘密鍵が漏洩した場合のリスク
- 定期的なローテーション・失効管理の運用負担
- サーバー上に鍵ファイルを置き続ける必要がある
移行後:WIF による認証
そこで、長期的な秘密鍵を持たずに認証できる WIF へ移行しました。Google Cloud CLI の認証処理は、EC2 の Instance Metadata Service v2(IMDSv2)から AWS の一時認証情報を取得し、署名付き GetCallerIdentity リクエストを subject token として Google STS へ渡します。BigQuery と Retail API では、Google STS で取得した短命なフェデレーショントークンを利用します。GA4 Data API では、さらに Service Account impersonation によって短命な Service Account のアクセストークンを取得します。どの方式でも、Service Account の秘密鍵ファイルは不要です。

図では、共通する WIF のトークン交換後に、認証経路が3つに分かれることを示しています。BigQuery では bq CLI がトークン取得と API 呼び出しの両方を行い、Retail API では gcloud CLI が取得したトークンを使って PHP の HTTP クライアントが API を呼び出します。GA4 Data API における実際の呼び出し順序は、「解決策:gcloud CLI にトークン取得を委譲する」のシーケンス図で説明します。
移行前の API ごとの認証実装
移行前の実装について、API を呼び出すコンポーネント、トークンを取得するコンポーネント、Google 側の権限主体、認証情報を分けて整理すると次のようになります。
| API | API を呼び出すコンポーネント | トークンを取得するコンポーネント | 権限主体 | 認証情報 |
|---|---|---|---|---|
| BigQuery | bq CLI | bq CLI | Service Account | Service Account キー |
| Retail API | 独自の HTTP クライアント | gcloud CLI | Service Account | Service Account キー |
| GA4 Data API | BetaAnalyticsDataClient(Google 提供クライアント) | google/auth ライブラリ(ADC) | Service Account | Service Account キー |
移行前はいずれも Service Account を権限主体とし、その認証に Service Account キーを使用していました。API ごとの違いは、アクセストークンを取得するコンポーネントです。BigQuery と Retail API は Google Cloud CLI の認証実装に委譲していましたが、GA4 Data API は Google 提供のクライアントライブラリと、その内部の google/auth に任せていました。この違いが、移行時の対応差につながりました。
先行して移行した BigQuery と Retail API
BigQuery と Retail API は比較的スムーズに WIF 化できました。
- BigQuery:WIF 用の
CLOUDSDK_CONFIGを指定してbq queryを実行する構成へ切り替えました。API 呼び出しロジック自体は変更せず、PHP 側で指定する認証設定を WIF 用へ切り替えました。 - Retail API:WIF 設定を読み込んだ gcloud CLI でアクセストークンを取得し、独自の HTTP クライアントへ渡す構成へ切り替えました。
どちらも AWS の ID 連携を WIF へ切り替え、Google 側では WIF で連携した外部 ID をそのまま権限主体として利用しました。認証処理を Google Cloud CLI の実装へ委譲できていたため、GA4 Data API のような追加対応は必要ありませんでした。
GA4 Data API で必要になった Service Account impersonation
GA4 Data API で最初に問題になったのは、Google 側の権限モデルです。Google Analytics のアクセス管理は、Google Cloud IAM とは別の仕組みになっています。
- Google Cloud IAM(BigQuery や Retail API が使う)では、WIF で連携した外部 ID に対して直接権限を付与できます。
- 一方、Google Analytics のプロパティアクセス管理には、WIF で連携した外部 ID を直接登録する仕組みがありません。Service Account にはプロパティの権限を付与できます。
そのため GA4 Data API では、Google Analytics 側で権限を付与できる Service Account を最終的な権限主体とし、WIF で連携した外部 ID からその Service Account を impersonate する構成にしました。具体的には、Service Account を Google Analytics プロパティに「閲覧者」として追加し、WIF で連携した外部 ID には、その Service Account のアクセストークンを生成する権限を付与しています。
これが、GA4 Data API だけ Google STS の後に IAM Service Account Credentials API を介する理由です。
利用中の認証ライブラリの互換性問題
GA4 Data API で使っている Google 提供クライアント BetaAnalyticsDataClient は、既存実装では認証情報(credentials)を明示的に渡しておらず、ADC(Application Default Credentials)に任せていました。ADC の内部では google/auth が認証情報の読み込みとトークン取得を担当します。
Service Account impersonation を含む external account 設定を ADC から読み込ませたところ、バッチが起動直後に次の例外で終了しました。
InvalidArgumentException: invalid value in the type field
本番環境で利用していた認証ライブラリでは、external_account 形式を解釈できず、invalid value in the type field が発生しました。対応版への更新も検討しましたが、依存関係と実行環境の互換性制約から見送りました。
一方、BigQuery と Retail API は認証処理を Google Cloud CLI に委譲しており、CLI は external_account 設定を読み込めるため、この問題は発生しませんでした。
解決策:gcloud CLI にトークン取得を委譲する
この制約を回避するため、Retail API で使っていた「gcloud にトークン取得を任せ、その結果をアプリケーションへ注入する」方式を GA4 Data API 向けに応用しました。
BetaAnalyticsDataClient は、FetchAuthTokenInterface を実装したオブジェクトを credentials として受け取れます。そこで、gcloud を呼び出してアクセストークンを返すアダプターを用意し、それをクライアントへ注入することにしました。これにより、external_account の解釈は gcloud に任せ、Google クライアントには取得済みのアクセストークンを渡すだけになります。
external_account の認証情報ファイルとは
gcloud には、あらかじめ WIF の認証情報を読み込ませておきます。この認証情報ファイル(external_account 形式の JSON)は Workload Identity Provider に対応した設定ファイルで、次のような内容を持ちます。
- どの Workload Identity Provider を使うか
- EC2 IMDSv2 のセッショントークンを取得する URL
- IMDSv2 からリージョンと AWS の一時認証情報を取得する metadata URL
- 署名付き
GetCallerIdentityリクエストの対象となる AWS STS の URL - subject token を交換する Google STS の URL
service_account_impersonation_url:最終的にどの Service Account を impersonate するか
重要なのは、このファイルには Service Account の秘密鍵が含まれていないという点です。AWS の ID を Google にどう連携し、どの Service Account を impersonate するかを定義した設定ファイルです。
この設定は gcloud auth login --cred-file=<external_account.json> で、GA4 Data API 専用の gcloud 設定ディレクトリに読み込ませています。共有の WIF 設定とは分離し、実行ユーザーだけがアクセスできる権限で管理しています。これらの配置・設定はインフラ側(Ansible)で行っています。
アダプターの実装
アダプターの責務を示す疑似コードは次のとおりです。実装では、gcloud の実行、タイムアウト、終了コードの確認、標準出力と標準エラー出力の分離を行っています。ここでは、特定の gcloud バージョンや未文書化のオプション挙動に依存するコマンドラインを、一般的なサンプルとして示すことを避けています。
use Google\Auth\FetchAuthTokenInterface;
class GcloudWifCredentials implements FetchAuthTokenInterface
{
// GA4 Data API 専用の gcloud 設定ディレクトリ(external_account 設定を読み込み済み)
private const CONFIG_PATH = '/path/to/ga4-gcloud-config';
private const SCOPE = '<https://www.googleapis.com/auth/analytics.readonly>';
public function fetchAuthToken(?callable $httpHandler = null): array
{
// gcloud CLI に WIF と Service Account impersonation を委譲する。
// CONFIG_PATH は子プロセスの CLOUDSDK_CONFIG として渡す。
$token = $this->getAccessTokenFromGcloud(
self::CONFIG_PATH,
self::SCOPE
);
return ['access_token' => $token, 'expires_at' => time() + 2700];
}
// getLastReceivedToken() などは省略
}疑似コード内の SCOPE は、GA4 Data API に必要な analytics.readonly スコープを指定する責務を示しています。実際の gcloud への渡し方は実装および利用する gcloud のバージョンに依存するため、ここでは省略しています。
ポイントは次のとおりです。
- WIF によるトークン交換と Service Account impersonation を gcloud が担当すること
CLOUDSDK_CONFIGで GA4 Data API 専用の設定を指定し、PHP プロセス全体の環境を汚さないこと- stdout(トークン)と stderr(警告・エラー)を分離し、失敗時もトークンをログに出さないこと
- 通信停止でバッチがハングしないよう、gcloud の実行にタイムアウトを設定すること
作成したアダプターは、クライアント生成時に注入します。
$client = new BetaAnalyticsDataClient([
'credentials' => new GcloudWifCredentials(),
]);認証情報がクライアントへ渡り、GA4 Data API を呼び出すまでの流れは次のとおりです。

毎回 gcloud を実行するのはオーバーヘッドが大きいため、同一のクライアントインスタンスを利用している間は、取得したトークンを有効期限までメモリ上で再利用します。利用中の Google クライアントライブラリは、getLastReceivedToken() が返す expires_at を確認し、期限内であれば同じトークンを利用します。expires_at に設定している 2700 秒は、gcloud が発行するトークン(有効期限 約 3600 秒)に対する安全マージンで、失効直前のトークンを API 呼び出しに使わないよう短めにしています。
なお、この gcloud アダプターは 認証ライブラリを更新できるようになるまでの互換レイヤーという位置づけです。PHP から外部プロセス(gcloud)を起動し、その導入・設定に依存するため、恒久的に推奨する構成ではありません。将来、依存関係と実行環境の制約を解消し、external_account 対応版へ安全に更新できるようになれば、ADC ベースの実装へ戻す方針です。
安全な移行と SA キーの廃止
本番で稼働中のバッチのため、旧キーを削除する最終段階までは、各ステップでロールバックできることと、無停止であることを原則に、段階的に進めました。
- WIF が安定するまで、旧 SA キーはロールバック用に残す
- cron から環境変数(
GOOGLE_APPLICATION_CREDENTIALS)を削除する(トークンはコード側で注入するため不要) - 構成管理リポジトリから、Ansible Vault で暗号化していた鍵データと、サーバーへ配置する処理を削除する
- サーバー上の鍵ファイルを、アプリケーションから参照されない状態にする(ロールバック確認のための一時退避で、キーそのものの無効化ではありません)
- 安定稼働を確認したら、Google Cloud 上で旧キーを無効化・削除し、サーバー上に一時退避していた鍵ファイルも削除する
補足として、サーバーからファイルを消しても、キーが Google Cloud 上で有効なままなら、その秘密鍵を使えば認証できてしまいます。「SA キーの廃止」を本当の意味で完了させるには、最終的に Google Cloud 上でのキーの無効化・削除まで行う必要があります。
まとめ:認証を3つの観点で振り返る
今回の移行を振り返ると、認証を次の3つの観点に分けることで、API ごとの差を整理できます。
- AWS の ID を Google へ連携する(Federation):EC2 の AWS IAM ロールを WIF で Google へ連携し、短命なトークンへ交換する
- Google 側で利用する権限主体と権限モデルを決める:WIF で連携した外部 ID を直接利用するか、Service Account を impersonate するか
- アプリケーションへトークンを渡す(Application integration):Google Cloud CLI に委譲するか、認証ライブラリやアダプターを介してクライアントへ渡すか
BigQuery と Retail API は、WIF で連携した外部 ID を権限主体として直接利用でき、認証処理も Google Cloud CLI に委譲できました。GA4 Data API は Google Analytics 固有の権限モデルから Service Account impersonation が必要で、さらに GA4 バッチの既存実装で利用していた認証ライブラリが external_account 設定を解釈できなかったため、アプリケーションとの接続部分にも追加対応が必要になりました。
今回採用した gcloud アダプターは、依存関係と実行環境の制約下で WIF へ移行するための互換レイヤーです。将来、external_account 対応版の認証ライブラリへ安全に更新できるようになれば、ADC ベースの実装へ戻す方針です。

