9月に入りました。手元の Gemini 関連コードで今月中に片付けなければならないのは、標準 API キーから auth キーへの移行です。個人開発で回している壁紙アプリのカテゴリ分類バッチが一番わかりやすい対象だったので、そこから手を付けました。
.env の値を新しいキーに書き換え、ローカルで一度走らせ、応答が返ることを確認して、そのまま定期実行へ戻しました。ところが翌朝のログを見ると、リクエストに乗っていたのは古いキーのままでした。
.env は確かに書き換わっています。プロセスは確かにそのファイルを読んでいます。それでも古い値が使われていました。
同じ名前を三つの経路が取り合っていました
原因を探す前に、まず「そのプロセスに GEMINI_API_KEY を渡している場所」を全部並べました。私の環境では三つありました。
| 供給経路 | 置き場所 | コード検索で見つかるか |
| シェル環境変数 | 定期実行のラッパースクリプト内の export | 見つかる(ただし別リポジトリ) |
| dotenv ファイル | プロジェクト直下の .env | ファイル名は見つかるが値は追えない |
| シークレットファイル | デプロイ時に配置される JSON | 見つからない |
ここで最初のつまずきが起きます。grep -rn "GEMINI_API_KEY" で出てくるのは、ほとんどが読む側のコードです。os.environ.get(...) や process.env.... の行です。値を入れる側は、CI の設定画面、デプロイ時に展開されるファイル、ラッパースクリプトの export といった、リポジトリの外か、リポジトリの隅に散らばっています。
読む側だけを数えて「三箇所で読んでいるから三箇所直せばいい」と考えると、供給側の棚卸しが丸ごと抜け落ちます。私はここで一度、手順の順番を間違えました。
読み込み順を四通り入れ替えても、勝者は変わりませんでした
最初に疑ったのは読み込み順です。「後から読んだ方が勝つのなら、新しいキーを最後に読ませればいい」と考えました。素直な仮説だと思います。
そこで、三つの経路を持つ最小のローダを書いて、順序を入れ替えながら実際に走らせました。
# probe.py — 供給経路が三つある設定ローダ。読み込み順を入れ替えて勝者を見る
import os, sys, json
from dotenv import load_dotenv
def from_shell():
# すでにプロセスに入っている値を読むだけ(何も書き込まない)
return os.environ.get("GEMINI_API_KEY")
def from_dotenv():
# python-dotenv の既定は override=False。既存の値があれば書き込まない
load_dotenv("/tmp/keyaudit/.env")
return os.environ.get("GEMINI_API_KEY")
def from_secretfile():
# 素直な代入。既存の値があっても無条件に上書きする
d = json.load(open("/tmp/keyaudit/secrets.json"))
os.environ["GEMINI_API_KEY"] = d["GEMINI_API_KEY"]
return os.environ["GEMINI_API_KEY"]
fns = {"shell": from_shell, "dotenv": from_dotenv, "secretfile": from_secretfile}
for name in sys.argv[1].split(","):
fns[name]()
print(f"order={sys.argv[1]:32s} winner={os.environ.get('GEMINI_API_KEY')}")
.env には新しいキー、シークレットファイルには古いキー、シェルにも古いキーを置いた状態で、順序を四通り試しました。実行結果はこうなりました。
order=shell,dotenv,secretfile winner=OLD_STANDARD_KEY_from_secretfile
order=shell,secretfile,dotenv winner=OLD_STANDARD_KEY_from_secretfile
order=dotenv,secretfile,shell winner=OLD_STANDARD_KEY_from_secretfile
order=secretfile,dotenv,shell winner=OLD_STANDARD_KEY_from_secretfile
四通りすべてで、勝ったのは古いキーを持つシークレットファイルでした。順番を入れ替えても結果が動きません。
理由は、経路ごとに「既存の値をどう扱うか」の方針が違うからです。load_dotenv() は既定で既存の値を尊重して書き込みません。素直な os.environ[...] = value は既存の値を見ずに上書きします。したがって、後から読まれるかどうかではなく、上書きを許す方針を持った経路が一つでもあれば、その経路が最終的な勝者になります。
私はこの結果が出るまで、import 文の位置を並べ替えるという遠回りをしていました。順序に原因があると信じている間は、どれだけ並べ替えても症状が動かないので、手がかりが増えません。
dotenv は「読めているのに入っていない」
もう一つ、デバッグを長引かせた挙動があります。dotenv がファイルをパースはしているのに、環境変数へは注入していないという状態です。
Node.js 側で確かめました。dotenv 17.4.2 で、シェルに古いキーが入った状態で config() を呼びます。
import dotenv from "dotenv";
const before = process.env.GEMINI_API_KEY; // シェル由来の古いキー
const r1 = dotenv.config({ path: "/tmp/keyaudit/.env" });
const after = process.env.GEMINI_API_KEY;
const r2 = dotenv.config({ path: "/tmp/keyaudit/.env", override: true });
console.log(JSON.stringify({
before,
after_default: after,
after_override_true: process.env.GEMINI_API_KEY,
parsed_seen: r1.parsed?.GEMINI_API_KEY, // ← ここが罠
}, null, 2));
出力です。
◇ injected env (0) from .env // tip: ⌘ override existing { override: true }
{
"before": "OLD_STANDARD_KEY_from_shell",
"after_default": "OLD_STANDARD_KEY_from_shell",
"after_override_true": "NEW_AUTH_KEY_from_dotenv",
"parsed_seen": "NEW_AUTH_KEY_from_dotenv"
}
注目したいのは parsed_seen です。config() の戻り値の parsed には、新しいキーがきちんと入っています。ファイルは読めていて、値も正しくパースされています。それでも process.env は古いままです。
デバッグ中に「本当に .env を読めているのか」を確かめようとして result.parsed を出力すると、新しい値が表示されます。そこで「読めている」と判断してしまうと、注入されていないという本当の問題が視界から外れます。私はこれで数十分を使いました。
injected env (0) という行が唯一の手がかりですが、他のログに埋もれると気づきにくい表示です。Python 版も挙動は同じで、load_dotenv() の前後で値が変わりませんでした。
| 状況 | python-dotenv 1.2.2 | dotenv (Node) 17.4.2 |
| 環境変数が未設定 | ファイルの値が入る | ファイルの値が入る |
| 環境変数が設定済み・既定 | 変わらない | 変わらない(injected env (0)) |
| 環境変数が設定済み・override 有効 | ファイルの値で上書き | ファイルの値で上書き |
| 戻り値・パース結果 | 読み込み成否のみ | parsed に新しい値が見える |
エラーは出ません。ステータスコードも 200 のままです。この「静かに前提だけが崩れる」形は、temperature や top_p が受理されたうえで無視される非推奨の効き方とよく似ています。止まってくれる変更のほうが、まだ扱いやすいと感じます。
上書きポリシーを一箇所に集めます
原因がわかったので、直し方は「どの経路に上書きを許すか」を明示的に一箇所へ集めることにしました。暗黙の override を各所に散らしたままでは、次の移行でも同じ場所で詰まります。
本番環境ではデプロイ時に展開されるファイルが加わるぶん、手元よりも経路が一つ多くなります。手元では再現しない不一致を回避するには、経路の数そのものを設計の対象として扱う必要がありました。
方針はこうです。
- すべての供給を、直接の
os.environ[...] = ... ではなく、一つの関数を通す
- その関数の中で、上書きを許す経路を集合として持つ
- 供給が実際に適用されたかどうかを、その場で記録する
三つ目が重要です。「新しいキーを渡した」という事実と、「新しいキーが採用された」という事実は別物でした。今回の失敗は、その二つを同じものだと思い込んだことから始まっています。
供給と読み取りを台帳に残します
実際に入れた監査シムです。os.environ そのものは差し替えません。差し替えると、自分が把握していないライブラリの挙動まで巻き込むためです。供給と読み取りを、こちら側の関数経由に寄せるだけにとどめました。
"""読み取り時点でキーの由来を記録する監査シム"""
import os, json
class KeyLedger:
def __init__(self):
self.writes = []
self.reads = []
def supply(self, name, value, source):
prev = os.environ.get(name)
# 既存値が無いときは入れる。あるときは、その経路に上書き権があるかで決める
applied = value is not None and (prev is None or self.policy(source))
self.writes.append({
"name": name, "source": source,
"had_previous": prev is not None,
"applied": bool(applied),
"fingerprint": self.fp(value),
})
if applied:
os.environ[name] = value
return applied
def policy(self, source):
# 上書きを許す経路をここで一元管理する(暗黙の override を潰す)
return source in {"secret-manager", "explicit-cli-flag"}
def read(self, name):
v = os.environ.get(name)
owner = next((w["source"] for w in reversed(self.writes)
if w["name"] == name and w["applied"]), "pre-existing-process-env")
self.reads.append({"name": name, "resolved_from": owner, "fingerprint": self.fp(v)})
return v
@staticmethod
def fp(v):
# 値そのものは残さない。先頭4文字・末尾4文字・長さだけを指紋にする
if v is None:
return None
return f"{v[:4]}…{v[-4:]}(len={len(v)})"
def report(self):
return json.dumps({"writes": self.writes, "reads": self.reads},
ensure_ascii=False, indent=2)
CI が古いキーを export 済みという想定で走らせた結果です。
{
"writes": [
{
"name": "GEMINI_API_KEY",
"source": "dotenv-file",
"had_previous": true,
"applied": false,
"fingerprint": "NEW_…tenv(len=24)"
},
{
"name": "GEMINI_API_KEY",
"source": "secret-manager",
"had_previous": true,
"applied": true,
"fingerprint": "NEW_…ager(len=32)"
}
],
"reads": [
{
"name": "GEMINI_API_KEY",
"resolved_from": "secret-manager",
"fingerprint": "NEW_…ager(len=32)"
}
]
}
applied: false の行が、今回ほしかった情報のすべてです。「新しいキーを供給したのに採用されなかった経路がある」という事実が、一行で残ります。
fingerprint は指紋だけを残す設計にしました。キーの値そのものをログへ書く実装にすると、この台帳自体が新しい漏えい経路になります。先頭4文字・末尾4文字・長さの三点があれば、新旧の判別には十分でした。長さが 24 と 32 で違うことも、目視での切り分けに役立っています。
resolved_from を出しておくと、障害時に「どの供給元を直せばいいか」がそのまま読み取れます。私はこの一行があるかないかで、切り分けにかかる時間が変わると感じています。
移行の順番は、コードではなく供給元から決めます
台帳を入れたあと、移行の手順を組み直しました。以前は「コードを直す → 動かす → 通ったら本番へ」でしたが、この順序だと供給元の棚卸しが最後まで先送りされます。
いまはこの順です。
GEMINI_API_KEY を供給している場所をすべて列挙する(コード検索ではなく、実行環境の設定から)
- 上書きを許す経路を一本だけに決める
- その一本に新しいキーを入れる
- 台帳の
applied が true になっていることを確認する
- 残りの経路から古いキーを削除する
5 を最後に置くのが要点です。先に古いキーを消してしまうと、どの経路が実際に効いていたのかを確かめる手段が失われます。一度でも applied: true を目で見てから消す、という順序にしてから、片肺のまま切り替わる事故が起きなくなりました。
そのうえで、上書きを許す経路は一本に絞ることを推奨します。二本あると、どちらが勝つかは実行してみるまで判明しません。判定を実行時まで先送りする設計は、期限のある移行とは相性が良くないと感じています。
このパターンは、モデル ID の差し替えでも同じ形で現れます。テストは緑のままなのに本番だけが止まる構造については、録画フィクスチャがモデル停止を隠す仕組みと freshness gate に別の角度でまとめてあります。「静かに古い値が生き残る」という一点で、両者は同じ問題です。
9月の期限を、二種類に分けて並べます
今月の期限は性質の違う二つが並んでいます。
| 期限 | 対象 | 探し方 |
| 9月30日 | gemini-omni-flash-preview エンドポイントの廃止 | 文字列がコードに書いてある。検索で見つかる |
| 9月中 | 標準 API キーからのリクエスト拒否 | 値が設定側に散っている。検索では見つからない |
前者はモデル ID の置換なので、リポジトリ内の文字列検索で漏れなく洗えます。手順が組みやすく、終わったかどうかも判定できます。期限そのものの整理は 9月30日の廃止期限と依存箇所の棚卸し にまとめました。
後者が今回の話です。値がコードの外にあるため、検索で「終わった」と判定できません。判定するには、走っているプロセスに何が渡ったかを見るしかありませんでした。私は個人開発で複数の定期実行を抱えているので、残り日数の少ないほうではなく、判定手段の少ないほうから先に着手する方針を採っています。
参照するモデル名やキーを、どのタイミングで固定して、どのタイミングで動かすかという設計は、gemini-flash-latest への追従をやめた記録 とも地続きです。
手順を一つだけ持ち帰るなら
自分のプロジェクトで GEMINI_API_KEY を供給している場所を、今日のうちに紙かメモに書き出してみてください。読んでいる場所ではなく、値を入れている場所です。三つ以上出てきたら、そのうち上書きを許す経路がいくつあるかを確認します。二つ以上あるなら、キーを差し替えても効かない状態が、いつでも起こり得ます。
私自身、原因にたどり着くまでに読み込み順という誤った仮説へ時間を使いました。同じ遠回りを一人でも減らせたなら嬉しく思います。お読みいただきありがとうございました。