「このアプリ、EU の利用者のテキストはどこで処理されているんですか」— 個人開発のアプリについて、そう問い合わせをもらったとき、私は即答できませんでした。コードでは google-genai の既定クライアントをそのまま使っていて、リージョンを指定していなかったからです。既定の global エンドポイントは、内部でどこにルーティングされても不思議ではありません。速くて楽ですが、「どこで処理したか」を後から一行でも示せない、というのは運用として弱い状態でした。私自身、AdMob 由来の収益で複数の個人開発アプリを回していて、その一部は海外の利用者にも届きます。だからこそ「処理された場所を説明できない」状態は、他人事ではありませんでした。
データレジデンシは、多くの個人開発者にとって「大企業の話」に見えます。けれども、EU の利用者を一人でも抱えた瞬間から、これは自分の問題になります。厄介なのは、事故が起きてから気づく種類の欠落だということです。ふだんは何も問題なく動き、問い合わせや監査の場面で初めて「記録がない」ことが露呈します。ここでは、Gemini を Vertex AI 経由で使う前提で、リージョンを固定し、暗黙のフォールバックを禁じ、呼び出しを後から説明できる形に残す設計を、動くコードとともに共有します。
なぜ「global 既定」が後から効いてくるのか
global エンドポイントは可用性の面で優れています。あるリージョンが混雑していても、空いている場所へ流してくれます。ところがレジデンシの観点では、この「どこへでも流せる」性質がそのまま説明責任の欠落になります。処理された場所が要求のたびに変わりうるのに、それを記録していなければ、後から「EU 内で処理した」とは言えません。
私の環境で global のまま数日ログを取ってみたところ、同じ EU 利用者向けの呼び出しでも、ラウンドトリップの分布が二山になっていました。近い場所へ流れた回と、遠い場所へ流れた回が混ざっていたのだと考えられます。つまり global は「速い」のではなく「そのときたまたま近ければ速い」だけで、しかもどちらだったかを手元では区別できていませんでした。
SELECT location, status, COUNT(*) AS nFROM callsWHERE data_class = 'residency' AND ts >= strftime('%s', '2026-06-01')GROUP BY location, statusORDER BY n DESC;
residency 行に global や ok-global-fallback が一件でも混ざっていれば、それは設計が破れた証拠です。理想は、この区分の結果が許可リージョンだけで埋まっていること。台帳は「守れている」ことの証明であると同時に、「破れた瞬間」を検知するアラート源にもなります。私は日次でこのクエリを回し、residency に許可外リージョンが出たら通知するようにしています。
リージョン固定のレイテンシ代を測る
固定には代償があります。どれくらいかを、自分の環境で測ってから決めるべきです。私の EU 利用者向け要約タスク(短文・gemini-flash-latest 相当)で、global と europe-west4 固定を各 500 回ずつ流したときの分布はこうでした。
構成
p50
p95
失敗/不可
global 既定
540ms
1,910ms
0%(ただし処理地不明)
europe-west4 固定
470ms
980ms
2%(一部プレビュー系が未提供)
EU 利用者に対しては、固定したほうが p50 も p95 も改善しました。global が遠い場所へ流れる回が混ざっていた分、テールが伸びていたのだと考えています。一方で固定側には「そのリージョンに目的のモデルが無い」という別種の失敗が現れました。最新のプレビュー系モデルは、リージョンによって提供が遅れることがあります。ここが次の論点です。
モデルがそのリージョンに無いときの振る舞い
「residency は守りたいが、使いたい最新モデルが europe-west4 にまだ無い」という衝突は現実に起きます。ここで安易に global へ逃がすのは、可用性を口実に制約そのものを捨ててしまう典型的な落とし穴で、最初のポリシーが無意味になります。私が採っている順序はこうです。
RESIDENCY_MODEL_FALLBACK = { # 第一希望が許可リージョンに無い場合の、安定モデルへの縮退先 "gemini-flash-latest": "gemini-2.5-flash",}def pick_model(data_class: str, wanted: str, location: str) -> str: if data_class == "residency" and not _model_available(wanted, location): alt = RESIDENCY_MODEL_FALLBACK.get(wanted) if alt and _model_available(alt, location): return alt raise ResidencyError(f"{wanted} も代替も {location} で利用できません") return wanted