未制限キーが遮断されるようになって最初に困ったのは、ブラウザ向けのキーではなく、誰も見ていないところで動いているサーバー側の定期処理でした。個人開発で複数のサイトの更新処理を回していると、その大半は headless な環境で走ります。手元の PC ではなく、使い捨てに近い実行環境の上で起動して、終わったら消える。そういう作りにしていると、リクエスト元の IP アドレスが実行のたびに変わります。
ここに、今回のキー制限の面倒さが凝縮されています。ブラウザなら HTTP リファラー、モバイルアプリなら Android/iOS のアプリ制限が使えます。ところが「毎回 IP が変わる headless なサーバー処理」には、その手のアプリケーション制限がどれも素直に当てはまりません。IP 許可リストを設定した途端、次の実行では別の IP から来て自分で自分を弾く、という間の抜けた事故が起きます。
ここでは、その headless 自動処理に絞って、キーの制限をどう効かせるかを整理します。結論を先に言うと、当座は「API 制限」で最低ラインを満たしつつ、腰を据えるなら「サーバー処理は API キーを捨ててサービスアカウント認証へ移す」のが私自身の到達点です。順を追って、なぜそうなるかと、止めずに移す手順を書きます。
なぜサーバー自動処理では「アプリケーション制限」が効きにくいのか
Gemini API キーに設定できる制限は、大きく二層に分かれます。ひとつは、そのキーで叩ける API を絞る API 制限。もうひとつは、リクエスト元を絞る アプリケーション制限で、HTTP リファラー・IP アドレス・Android アプリ・iOS アプリの4種類があります。
問題は、アプリケーション制限の4種類がいずれも「呼び出し元が安定して識別できる」ことを前提にしている点です。ブラウザにはリファラーがあり、モバイルアプリにはパッケージ名と署名があります。ところが headless なサーバー処理には、そのどれもありません。残るのは IP アドレス制限だけですが、これが厄介です。
CI・サーバーレス・使い捨ての実行環境は、起動のたびに別のノードに割り当てられ、egress IP が変わります。
固定 IP を持たない構成では、そもそも許可リストに書く値が確定しません。
無理に広い CIDR を許可すると、制限をかけている意味が薄れます。
つまり headless 処理では、アプリケーション制限を諦めて API 制限だけで最低ラインを満たすか、egress を固定 IP に寄せて IP 制限を成立させるか、API キーという仕組みそのものから降りるか、の三択に自然と絞られます。以下、順に見ていきます。
まず現状を測る:キーに何の制限がついているか確かめる
選ぶ前に、いま自分のキーに何がついているかを機械的に把握します。手作業でコンソールを眺めるより、API Keys API で一覧を取ってしまうほうが、複数プロジェクトを横断していると確実です。以下は Service Usage / API Keys の管理 API を使い、キーごとの制限有無を棚卸しするコードです。認証にはサービスアカウント(後述)か、gcloud auth application-default login の資格情報を使います。
# キーの制限状況を棚卸しする(google-cloud-api-keys を使用)# pip install google-cloud-api-keysfrom google.cloud import api_keys_v2def audit_keys(project_id: str) -> None: client = api_keys_v2.ApiKeysClient() parent = f"projects/{project_id}/locations/global" for key in client.list_keys(parent=parent): restrictions = key.restrictions api_targets = list(restrictions.api_targets) if restrictions else [] # アプリケーション制限の種別を判定 app = "none" if restrictions: if restrictions.browser_key_restrictions.allowed_referrers: app = "referrer" elif restrictions.server_key_restrictions.allowed_ips: app = "ip" elif restrictions.android_key_restrictions.allowed_applications: app = "android" elif restrictions.ios_key_restrictions.allowed_bundle_ids: app = "ios" api_ok = "restricted" if api_targets else "ALL-APIs" flag = " <-- 未制限(遮断対象の可能性)" if app == "none" and not api_targets else "" print(f"{key.display_name:<28} app={app:<9} api={api_ok}{flag}")audit_keys("your-project-id")# 出力例:# cron-gemilab-pipeline app=none api=ALL-APIs <-- 未制限(遮断対象の可能性)# web-demo-key app=referrer api=restricted
いちばん摩擦の少ない当座の手当ては、アプリケーション制限は空のまま、API 制限だけを付けることです。キーで叩ける API を「Generative Language API」一本に絞ります。これだけでも、キーが流出したときに他の Google API へ横展開される被害を止められますし、未制限キーの遮断ポリシーの観点でも「制限あり」の側に入ります。
headless 処理でも、出口の IP を一箇所に固める構成にできれば、IP 制限が成立します。実行環境そのものの IP は変わっても、その手前に固定 IP を持つ中継点(NAT ゲートウェイや固定 IP の転送プロキシ)を置き、すべての Gemini リクエストをそこ経由にするやり方です。
# 固定 IP を持つ転送プロキシ経由で Gemini を叩く# 実行環境の IP は毎回変わっても、キーには「プロキシの固定 IP」だけを許可すればよいimport osfrom google import genai# HTTPS_PROXY にプロキシを指定すると、SDK 下の httpx が経由してくれるos.environ["HTTPS_PROXY"] = "http://PROXY_HOST:PROXY_PORT"client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])resp = client.models.generate_content( model="gemini-flash-latest", contents="このリクエストは固定 IP のプロキシを経由して届きます。",)print(resp.text)# キー側には server_key_restrictions.allowed_ips にプロキシの固定 IP だけを登録する
この構成の利点は、キーの仕組みを変えずに「元を縛れる」ことです。欠点は、固定 IP を維持する中継点そのものが運用対象として増えること。NAT ゲートウェイは時間課金が積み上がりますし、自前プロキシは可用性を自分で守る必要があります。処理の本数が少ない個人運用だと、この中継点のコストと手間が、守りたいものに見合うかは微妙なところです。私は一時これを使いましたが、維持の面倒さから次の選択肢へ移りました。
選択肢C:サーバーは API キーを捨て、サービスアカウント認証へ移す
腰を据えるなら、これが本命だと考えています。そもそも headless なサーバー処理に API キーは向いていません。 API キーは「持っている人=誰でも使える」共有シークレットで、リクエスト元を縛る手立てが弱い。サーバー間通信には、短命のトークンを都度発行する OAuth 2.0(サービスアカウント)認証のほうが素直です。
Gemini は Vertex AI 経由で呼ぶと、API キーではなくサービスアカウントの資格情報で認証できます。同じ google-genai SDK のまま、vertexai=True に切り替えるだけで、呼び出しコードの大部分はそのまま使えます。
使い捨てのキーで一度きりの検証をするだけなら A で十分です。一方、Dolice Labs のように毎日いくつもの定期処理を回す前提なら、初期の移行コストを払ってでも C に寄せたほうが、後々の点検が楽になります。B は「キーを前提にした既存コードをすぐには書き換えられない」場合の中間解として位置づけると収まりが良いです。