GitHub Actions × Workload Identity Federation で GCS に自動デプロイした話

Next.js の静的ブログを GCS(Cloud Storage)にホスティングしているのですが、
最初はローカルで npm run buildgsutil で手動デプロイしていました。

でも、

  • ローカルで毎回デプロイするのが面倒
  • main に push したら自動で公開されてほしい
  • サービスアカウントキー(JSON)はできれば使いたくない

という理由から、
GitHub Actions × Workload Identity Federation(WIF)で鍵なしデプロイできるようにしたので、その手順とハマりポイントを書いておきます。


Workload Identity Federation を使うメリット

まずは「なぜわざわざ WIF を使うのか?」という話から書いておきます。

1. サービスアカウントキー(JSON)を持たなくてよい

WIF を使わない場合、典型的なパターンは:

  • GCP でサービスアカウントキー(JSON)を発行
  • それを GitHub Secrets に登録
  • Actions 実行時に GOOGLE_APPLICATION_CREDENTIALS などで読み込む

という流れになります。

この方式だと、

  • 鍵ファイル(JSON)が長期的に有効
  • 万一 GitHub の設定画面を見られたり、別リポジトリにコピーされたりするとそのまま悪用され得る
  • 鍵のローテーション(定期的な作り直し・張り替え)も手作業になりがち

と、運用・セキュリティ両面で負担が大きいです。

WIF を使うと、GitHub Actions からは OIDC トークン(短命なトークン)だけを GCP に渡し、GCP 側でその場限りの認証情報を発行してもらう形になります。
そのため、長期間有効な秘密鍵をどこにも保存しなくて済むのが一番のメリットです。

2. 「どのリポジトリからの実行なのか」でアクセス制御できる

この記事の構成では、Provider 側の属性マッピングと条件で:

google.subject = assertion.repository
assertion.repository == "organization名/repository名"

としています。

これにより、

  • 「この GitHub リポジトリからの Actions 実行だけ GCP へのアクセスを許可」
  • 逆に言うと「それ以外のリポジトリからは認証自体が通らない」

という状態になります。

もしサービスアカウントキー(JSON)方式だと、

  • 一度 Secrets に登録してしまうと、そのキーを使えるかどうかは GitHub 側の権限依存
  • 別リポジトリや Fork にコピーされても、GCP 側からは「誰が使っているのか」判断がつかない

という状態になりがちです。

WIF の場合は OIDC トークンの中に「どのリポジトリから来たのか」が入っているので、
GCP 側で条件を書いておけば「このリポジトリ以外はそもそも認証失敗」にできます。

3. 権限の取り消しやローテーションが楽

WIF では、

  • 不要になったら Workload Identity Pool / Provider のバインディングを消す
  • もしくは attributeCondition を変えて該当リポジトリを外す

だけで、その瞬間から GitHub Actions 側は認証できなくなります。

サービスアカウントキー(JSON)方式だと、

  • どこかにコピーされていても気づきにくい
  • 使われていそうなキーを消すのが怖くて放置される

といったことが起こりがちですが、
WIF は 「鍵をもたない前提の設計」なので、止めるのも楽です。


Workload Identity Federation を設定しなかった場合どうなるか?

もし WIF を使わずに同じことをやろうとすると、だいたい次のような構成になります。

  1. GCP でサービスアカウントキー(JSON)を発行
  2. GitHub Secrets に保存(例:GCP_SA_KEY
  3. workflow の中で GOOGLE_APPLICATION_CREDENTIALS として書き出し
  4. gcloud auth activate-service-account --key-file=... で認証

一見シンプルですが、以下のようなデメリットがあります。

セキュリティ面のデメリット

  • 秘密鍵(JSON)が 1 つ漏れると、その SA の権限で GCP にフルアクセス可能
  • 鍵がどこにコピーされたか、GitHub 上のどこで使い回されているか、追跡しづらい
  • 権限を絞ったつもりでも、あとからどのジョブ・どのワークフローで使われているのか把握しづらい

WIF であれば、

  • 長期鍵は存在せず、OIDC トークンは短命
  • 「このリポジトリ / この条件を満たすトークンしか通さない」と GCP 側に書ける
  • 怪しければ Provider の設定を一旦切るだけで止められる

といった違いがあります。

運用面のデメリット

  • 鍵の期限が近づいたら作り直して Secrets を更新…が面倒
  • どの鍵がどのリポジトリで使われているかの棚卸しが大変
  • 組織的に「鍵ファイルは原則持たない方針」の場合、そもそもやりづらい

WIF を最初に少し頑張って設定しておくと、

  • 以降のリポジトリでも同じパターンで使い回せる
  • 「json をダウンロードして secrets に貼る」という運用そのものを無くせる

というメリットがあります。

個人ブログレベルでも、一度仕組みを作っておくと 「あとから別リポジトリの自動デプロイを増やすときにも使い回せる」 のが地味に嬉しいポイントでした。


サービスアカウントキー方式 vs WIF 比較

最後に、従来の「サービスアカウントキー(JSON)方式」と、今回使った WIF をざっくり比較しておきます。

項目 サービスアカウントキー(JSON)方式 Workload Identity Federation 方式
認証方法 長期有効な鍵ファイルを Secrets に保存し、gcloud auth activate-service-account で使用 GitHub OIDC トークンを GCP に渡し、その場限りの一時トークンを発行してもらう
秘密情報の扱い JSON キーを人間が一度ダウンロードして管理する必要がある 人間が鍵を扱わずに済む(トークンはマシン間でやり取りされる)
アクセス制御の粒度 「この鍵を持っている人は誰でも同じ権限」になりがち assertion.repository などの属性で「このリポジトリからの実行だけ許可」など細かく制御できる
ローテーション キーの作り直し → Secrets の更新が必要 基本的に手動ローテーション不要(OIDC トークンは短命)
事故時の影響範囲 キーが漏れると、気づくまでその SA 権限が丸ごと悪用されうる Provider/バインディングを切ればすぐ無効化できる。長期鍵が存在しない
初期セットアップの手間 比較的シンプル(キー発行 → Secrets 登録) 最初の設定はやや多い(Pool/Provider/Binding 設定が必要)
中長期の運用コスト 鍵管理・棚卸し・ローテーションが地味に重い 一度仕組みを作れば、その後はほぼ放置でよい

「とりあえず動かしたい」だけならキー方式でも動きますが、
長く運用することを考えると WIF に寄せておいた方が、運用コストとセキュリティのバランスが良いと感じました。


全体構成イメージ

ざっくり構成はこんな感じです。

GitHub Actions
   ↓(OIDC トークン)
Workload Identity Pool(github-pool)
   ↓(条件: assertion.repository == "gitのリポジトリ名")
Workload Identity Provider(GitHub OIDC)
   ↓(impersonate)
Service Account([email protected])
   ↓
Cloud Storage へデプロイ

ポイントは GitHub Actions からサービスアカウントキー(JSON)を使わずに GCP 認証しているところです。


1. サービスアカウントを作る

まずは GCS にデプロイするためのサービスアカウント(SA)を作ります。

GCP コンソールから:

  1. 「IAM と管理」→「サービス アカウント」
  2. 「サービス アカウントを作成」
    • 名前: github-actions
    • 説明: GitHub Actions 用

権限はとりあえずバケット操作用に以下あたりを付けました:

  • Storage Object Admin (roles/storage.objectAdmin)

(細かくやるなら、対象バケット単位の権限だけに絞るのがベターです)


2. Workload Identity Pool を作成する

次に GitHub の OIDC と連携するためのプールを作ります。

  1. 「IAM と管理」→「Workload Identity Federation」
  2. 「プールを作成」
    • 名前: github-pool
    • 説明: GitHub Actions 用
    • ロケーション: global

作成後、
projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/github-pool
という名前が付きます。


3. Provider(GitHub OIDC)を作る

同じ画面から Provider を作成します。

  • プロバイダ名: github

  • プロバイダの種類: OIDC

  • 発行者(issuer) URL:

    https://token.actions.githubusercontent.com
    

属性マッピング

今回はシンプルに、リポジトリ名を subject として扱うようにしてみました。

google.subject        = assertion.repository

条件(attributeCondition)

ブログ記事を管理しているリポジトリからの Actions のみ許可するようにします:

assertion.repository == "organization名/repository名"

これで「特定リポジトリからの GitHub Actions だけ GCP に入ってきてよい」という状態になります。


4. サービスアカウントに WIF 用の権限を付ける(=impersonate を許可する)

続いて、さっきの Provider からサービスアカウントを impersonate(なりすまし)できるように権限を付与します。

ここで言う「impersonate」は、

「Workload Identity Pool 経由で入ってきた principal(GitHub Actions の実行)が、
指定したサービスアカウントになりすまして GCP にアクセスできるようにする」

というイメージです。

実際のコマンドはこんな感じです。

gcloud iam service-accounts add-iam-policy-binding \
  [email protected] \
  --role="roles/iam.workloadIdentityUser" \
  --member="principal://iam.googleapis.com/projects/xxxxxxxxxxxx/locations/global/workloadIdentityPools/github-pool/subject/organization名/repository名"

ポイントは:

  • 付与しているロールが roles/iam.workloadIdentityUser であること
    • これは「この principal はこのサービスアカウントを impersonate してよい」という意味のロール
  • google.subject = assertion.repository としているので、
    principal://.../subject/organization名/repository名 という形になること

ポリシーを確認すると、こんな感じで出てきます:

bindings:
- role: roles/iam.workloadIdentityUser
  members:
    - principal://iam.googleapis.com/projects/xxxxxxxxxxxx/locations/global/workloadIdentityPools/github-pool/subject/organization名/repository名
etag: ...
version: 1

impersonate は実質「必須」

今回の構成では、

  • GitHub Actions
  • Workload Identity Federation
  • 特定のサービスアカウントとして GCS にアクセスする

というルートを通っているので、
この impersonate(=roles/iam.workloadIdentityUser の付与)が無いと、そもそも SA としてトークンが発行できません。

結果として、google-github-actions/auth@v2 のステップで次のようなエラーになります。

Permission 'iam.serviceAccounts.getAccessToken' denied
IAM_PERMISSION_DENIED

この時点で 認証が失敗 しており、gsutil にたどり着く前にコケる、という動きになります。


5. GitHub Secrets に必要情報を登録

workflow から直接フルパスを書かずにすむように、
GitHub リポジトリの Settings → Secrets and variables → Actions で以下を登録しました。

  • WORKLOAD_IDENTITY_PROVIDER
    • 例:
      projects/xxxxxxxxx/locations/global/workloadIdentityPools/github-pool/providers/github
  • SERVICE_ACCOUNT
    • 例:
      サービスアカウントのメールアドレス

これをやっておくと、workflow からは ${{ secrets.xxx }} で参照できます。


6. GitHub Actions の workflow(最終形)

最終的な workflow はこんな感じになりました。

デプロイを自動化したワークフロー
name: Deploy to GCS

on:
  workflow_dispatch:
  push:
    branches: ["main"]

jobs:
  deploy:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      # Node をセットアップ(Next.js ビルドのため)
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      # Google Cloud 認証(Workload Identity Federation)
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      # gcloud CLI セットアップ
      - name: Setup gcloud CLI
        uses: google-github-actions/setup-gcloud@v2

      # Cloud SDK に gsutil が含まれるため、ここで利用可能になる
      - name: Confirm gcloud and gsutil versions
        run: |
          gcloud --version
          gsutil --version

      # npm install
      - name: Install dependencies
        run: npm ci

      # ---- deploy.sh を実行 ----
      - name: Run deploy script
        run: bash ./deploy.sh
```

deploy.sh の中では、

  • npm run build
  • npm run export
  • gsutil -m rsync or gsutil -m cp

などを行って ./outgs://my-blog-site にアップロードしています。


7. エラーの出方の違い(どこでコケるか)

実際にハマったとき、「どの段階で権限が足りていないか」 を切り分けると理解しやすかったので、そのあたりもメモしておきます。

パターン A: impersonate の権限がない場合

  • ❌ サービスアカウントに roles/iam.workloadIdentityUser を付けていない
  • → サービスアカウントのアクセストークンが発行できない
  • google-github-actions/auth@v2 のステップで失敗

エラーメッセージの例:

Permission 'iam.serviceAccounts.getAccessToken' denied on resource ...
reason: IAM_PERMISSION_DENIED

この場合は 認証そのものが失敗している 状態です。

パターン B: impersonate はできるが、Storage 権限がない場合

  • roles/iam.workloadIdentityUser は正しく付いている(= impersonate OK)
  • ❌ しかし、そのサービスアカウント自体に roles/storage.objectAdmin などの Storage 権限が付いていない
  • → 認証ステップは成功するが、gsutil 実行時に 403

エラーメッセージの例:

AccessDeniedException: 403 ... does not have storage.objects.list access to the Google Cloud Storage bucket.
Permission 'storage.objects.create' denied on resource ...

この場合は 認証は成功しているが、GCS を触る権限が足りていない 状態です。


まとめ

  • GitHub Actions × Workload Identity Federation を使うと
    サービスアカウントキー(JSON)無しで GCP に安全にアクセスできる
  • GitHub 側の OIDC と assertion.repository でリポジトリ単位の制御ができる
  • 一度構成ができてしまえば、あとは main に push するだけで Cloud Storage に静的ブログが自動デプロイされて快適

そして、もし WIF を使わずサービスアカウントキーに頼った場合は、

  • 鍵管理・ローテーション・漏えいリスクが一気に重くなる
  • どのリポジトリから使われているかの制御・可視化もしづらい

さらに、WIF 構成では、

  • roles/iam.workloadIdentityUser による impersonate 設定が実質必須 であり、
  • そこが足りていないと iam.serviceAccounts.getAccessToken で 403 になる

というポイントも、実際にハマってみて体感したところでした。

個人ブログ規模でも「最初から WIF に寄せておくと後々ラクだったな」と感じたので、
同じように GitHub Actions から GCP に安全に繋ぎたい方の参考になればうれしいです。