Cloudflare Workers のダッシュボードでシークレットの一覧を開いたのは、先週の夜でした。Lab のサイトと個人開発のアプリを合わせて、Gemini のキーがどこにいくつ置いてあるのか、私は即答できませんでした。コードを検索すれば分かると思っていたのですが、出てきたのは環境変数の名前だけで、値そのものは画面の向こう側に置かれたままでした。
9月に入り、その曖昧さを残しておけなくなりました。標準の API キーは、制限を付けたものも含めて、今月中にリクエストが拒否されるようになります。移行先は Google Cloud のサービスアカウントに紐付く auth キーです。
最初にお伝えしたいのは、これがキーの文字列を新しいものに置き換えるだけの作業ではない、ということです。差し替えはコード検索で終わりますが、出どころの移行は棚卸しでしか終わりません。
変わるのは文字列ではなく、発行のしかたです
これまでの標準キーは、コンソールで発行して環境変数に入れれば、それだけで呼び出せる仕組みでした。キーは単体で完結していて、どのプロジェクトの持ち物かを意識せずに使えました。手軽さの代わりに、漏れた瞬間にそのキーの権限がまるごと外へ出ていく作りでもありました。
auth キーはサービスアカウントに紐付きます。誰が発行できるか、どのプロジェクトに属するか、どの API を呼べるかが、アプリケーションの設定ではなく IAM の側で決まります。鍵の管理がコードの都合から、プロジェクトの権限設計へ移る、と言い換えてもよいかもしれません。
移行の重さがここに出ます。文字列の置き換えなら grep で足りますが、権限の持ち方が変わる以上、置き場所と発行者と用途を一度並べ直さないと、何が終わって何が残っているのかを自分で把握できないのです。
一方で、助かる面もあります。ローテーションのたびに全環境へ新しい文字列を配って回る運用から、権限を絞ったアカウントを役割ごとに用意する運用へ移れます。私はこれを機に、記事生成のバッチと公開サイトの推論とを別のアカウントに分けました。
三層に分けて洗い出します
私が最初に失敗したのは、コードベースだけを見て「三箇所ですね」と結論づけたことでした。実際にはデプロイ設定と手元の環境に、コードには現れない鍵が残っていました。以下の三層に分けてから数え直したところで、ようやく全体が見えてきました。
| 層 | 探す場所 | 見落としやすいもの |
|---|---|---|
| コード | ソース、テスト、サンプル、ドキュメント、Notebook | README のコピー用スニペット、動作確認用の使い捨てスクリプト |
| デプロイ設定 | CI のシークレット、Workers や関数の環境変数、コンテナの起動引数 | 停止したまま残っているワークフロー、プレビュー環境の設定 |
| 手元 | シェルの rc ファイル、.env、エディタの設定、キーチェーン | 数か月前に検証で入れたまま忘れている値 |
洗い出しは手で追うより、一度スクリプトにしてしまったほうが確実でした。次のものを走らせて、出力をそのままテキストに残しています。
#!/usr/bin/env bash
# gemini-key-inventory.sh — キーの在り処を三層で洗い出します
set -uo pipefail
PATTERN='GEMINI_API_KEY|GOOGLE_API_KEY|generativelanguage\.googleapis\.com|AIza'
echo "===== 層1: コード ====="
# .git や node_modules を除いて、キーの名前と直書きの痕跡を探します
grep -rInE "$PATTERN" . \
--exclude-dir={.git,node_modules,.next,dist,build,vendor} \
2>/dev/null | head -50
echo
echo "===== 層2: デプロイ設定 ====="
# Cloudflare Workers(wrangler.toml のあるディレクトリで実行します)
if command -v wrangler >/dev/null 2>&1; then
wrangler secret list 2>/dev/null || echo " (wrangler: 認証されていないか対象外です)"
fi
# GitHub Actions のリポジトリシークレットと環境シークレット
if command -v gh >/dev/null 2>&1; then
gh secret list 2>/dev/null || echo " (gh: 権限がないか対象外です)"
for env in $(gh api repos/:owner/:repo/environments --jq '.environments[].name' 2>/dev/null); do
echo " [environment] $env"
gh secret list --env "$env" 2>/dev/null
done
fi
echo
echo "===== 層3: 手元 ====="
for f in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile" "$HOME/.zprofile"; do
[ -f "$f" ] && grep -nE "$PATTERN" "$f" 2>/dev/null | sed "s|^|${f}:|"
done
# 直下2階層までの .env 系だけを見ます(深追いすると終わりません)
find . -maxdepth 3 -name '.env*' -not -path '*/node_modules/*' -print 2>/dev/null \
| while read -r f; do grep -lE "$PATTERN" "$f" 2>/dev/null; done
echo
echo "棚卸しは以上です。件数ではなく『誰が発行したか』を各行に書き足してください。"最後の一行が、私にとっていちばん効きました。場所の一覧は機械が作れますが、発行者と用途は自分で思い出すしかありません。思い出せない鍵が出てきたら、それは移行対象ではなく削除対象です。
両対応のクライアントを一枚挟んでから切り替えます
期限がある移行で怖いのは、切り替えた瞬間に全部が止まることです。私は認証経路を選ぶ関数を一枚挟み、サービスアカウント経由を優先しつつ、失敗したら旧経路へ落ちる形にしました。落ちたことが警告としてログに残る点が要です。
# gemini_client.py — 認証経路を選び、どちらで起動したかを必ず記録します
from __future__ import annotations
import logging
import os
from google import genai
log = logging.getLogger(__name__)
def build_client() -> tuple[genai.Client, str]:
project = os.getenv("GOOGLE_CLOUD_PROJECT")
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
legacy_key = os.getenv("GEMINI_API_KEY")
if project:
try:
client = genai.Client(vertexai=True, project=project, location=location)
# クライアント生成では認証は検証されません。1回だけ実際に呼びます
next(iter(client.models.list()), None)
return client, "service-account"
except Exception as exc: # 認証・権限・ネットワークをまとめて拾います
if not legacy_key:
raise
log.warning("サービスアカウント経路を使えませんでした: %s", exc)
if not legacy_key:
raise RuntimeError(
"GOOGLE_CLOUD_PROJECT も GEMINI_API_KEY も設定されていません"
)
log.warning("旧経路の API キーで起動しました。9月の停止までに移行が必要です")
return genai.Client(api_key=legacy_key), "legacy-api-key"
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
_, route = build_client()
log.info("gemini auth route=%s", route)next(iter(client.models.list()), None) を入れているのは、クライアントを作った時点では認証が確かめられないためです。ここを省くと、最初の推論リクエストまで失敗が表に出てきません。起動時に一度だけ呼んでおけば、デプロイ直後のログで判定できます。
あとは route=legacy-api-key の出現をログ検索するだけで、まだ旧経路に残っている環境が分かります。私はこの一行を各サービスの起動ログに入れておいて、朝に一度眺めています。ゼロになった環境から順に、旧キーを消していきました。
環境変数の名前が重複していて、差し替えたはずの値が効かないという別の落とし穴もあります。そちらはキーを差し替えても旧キーが勝つ話にまとめてありますので、棚卸しの前に一度目を通していただけると、原因の切り分けが早くなるかもしれません。
つまずいたのは、権限の粒度と手元の環境でした
サービスアカウントを作ったあと、最初のうち私は広めのロールを付けて動かしていました。まず動く状態を作りたかったからですが、そのまま忘れると、標準キーの頃より広い権限を持った鍵をばら撒くことになります。動作を確認したあとで、推論だけを行うアカウントの権限を絞り直しました。先に広く付けるのは構いませんが、絞り直す日をその場でカレンダーに置く、というのが今回の私の線引きです。
手元の環境では、サービスアカウントの JSON をダウンロードして置きかけたところで手が止まりました。私の作業フォルダは Dropbox の同期下にあり、鍵ファイルをそこへ置けば、同期の履歴にまで鍵が残ります。結局ローカルでは JSON を持たず、gcloud auth application-default login による ADC に切り替えました。手元は鍵ファイルを持たない、という一点だけでも決めておくと、移行後の不安がだいぶ減ります。
CI については、JSON をシークレットに貼るより Workload Identity 連携のほうが後々の管理は楽になります。ただ、期限が今月末に迫っている状況では、まずシークレット方式で通してから連携へ寄せる、という二段構えでも遅くはありません。期限のある移行では、正しい形を一度で作ろうとするより、止まらない形を先に作るほうが結果的に安全でした。
残りの日数をどう並べるか
順序を決めておくと、途中で気力が切れにくくなります。私は次のように並べました。
- 棚卸しスクリプトの実行と、三層の一覧のテキスト化
- 各行への発行者と用途の追記、そして思い出せない鍵の削除対象への振り分け
- 両対応クライアントの導入と、
routeのログ出力 - 影響のいちばん小さい系統から順に、サービスアカウントへの切り替え
- ログから
legacy-api-keyが消えた環境における、旧キーの削除
先に鍵を消してから直すのではなく、消せる状態を作ってから消す順です。当たり前のようでいて、期限が近いと逆をやりたくなります。
なお、9月にはもう一つ gemini-omni-flash-preview エンドポイントの廃止という期限も並んでいます。こちらはモデル ID の差し替えでおおむね片付きますので、影響範囲の広いキー移行を先に片付けてしまうほうが、月末の慌ただしさは軽くなるはずです。
まずは棚卸しスクリプトを一度走らせて、鍵の在り処の一覧をテキストに書き出すところまで、今日のうちに終えてみてください。私自身、そこまで進めた時点でようやく肩の荷が下りました。最後までお読みくださり、ありがとうございました。