1ヶ月ほど前、私が個人で運営している壁紙アプリの一つで、Gemini API を使ったテーマ別レコメンド機能が早朝5時頃から突然エラーになり始めました。Crashlytics の通知で起きたとき、症状を見て胃がきゅっとしました。クラッシュ率がじわじわ伸びていて、エラーの中身は Type 'undefined' is not assignable to type 'string' — クライアント側の Zod バリデーション失敗でした。
原因は単純でした。前日に Google AI Studio で response_schema の optional フィールドを一つ追加していて、Gemini が新しいフィールドを生成するついでに、別の optional フィールドの「未設定時の挙動」が undefined から空文字に変わっていたのです。文書化されていない挙動の変化で、運用中のアプリ群のうち、その日アクセスしてきたユーザーの約2割が影響を受けました。AdMob の eCPM が安定していた中で、レコメンド機能経由のセッション継続率が一晩で 12% 落ち、月収換算で 18万円相当のインプレッションが消える計算になりました。
このとき身に染みて理解したのは、Gemini API が返す JSON は単なるデータ構造ではなく、クライアントと結ぶ「契約」だということです。それも、サードパーティのモデル更新で勝手に書き換えられかねない、かなり脆い契約です。今回まとめておきたいのは、その事故をきっかけに私が組み直した「スキーマを契約として扱う」設計と、2026年4月から実運用している Dual-Emit パターン・Sunset プロトコル・compatibility checker の実装記録です。
壁紙アプリで起きた JSON 契約破綻 — 数値で振り返る
事故の輪郭を数字で示しておきます。私の個人開発アプリは壁紙系・癒し系・引き寄せ系を中心に複数本を運用しており、Gemini API は2026年初頭からレコメンド・タグ生成・短い導入文の自動生成に組み込んでいました。
事故当日の影響範囲は次の通りでした。
影響を受けたアプリ: 18本中6本(Gemini レコメンドを使っているもの)
セッション継続率の下落: 平均 12.4%、最悪のアプリで 18.7%
AdMob インプレッション減: 24時間で約 36,500 インプレッション
月収換算の損失: 18万円相当(eCPM 5.20 USD ベースで概算)
完全復旧までの所要時間: コード修正は1時間、ストア審査と段階配信を含めると約 23時間
経緯を振り返ると、特に重い問題は事故そのものより、原因究明にかかった時間でした。クライアント側の Zod バリデーション失敗ログだけが手元にあって、「Gemini が何を返したか」の生レスポンスがほぼ残っていなかったのです。Cloudflare Workers 側のログは7日分しか保持しておらず、しかも構造化出力の body は省略する設定になっていました。事故の根本対策の半分は、この観測性の欠如への対処でした。
スキーマを「契約」として扱う設計 — 内部 IR と外部公開層を分離する
事故後にまず変えたのは、「Gemini からのレスポンスをそのまま使う」のをやめて、内部表現(Internal Representation, IR)と外部公開スキーマを2層に分けたことです。
それまでの設計は単純で、Gemini が返した JSON を Zod でパースして即クライアントに返していました。これだとモデル側の挙動が変わるたびに契約も勝手に変わってしまいます。新しい設計では「Gemini から受け取ったものをまず内部 IR に正規化し、そこから別の Zod スキーマで外部公開形式に整形する」という流れに統一しました。
// schemas/recommendation.ts
import { z } from "zod" ;
// 入口スキーマ: Gemini が返しうるブレを吸収する
export const GeminiRawRecommendation = z. object ({
theme_tag: z. string (). min ( 1 ). max ( 64 ),
// optional でも null / undefined / 空文字すべて許容して内部で正規化
subtitle: z. string (). optional (). nullable (). transform (( v ) => v?. trim () || null ),
confidence: z. number (). min ( 0 ). max ( 1 ). default ( 0.5 ),
// モデルが余計なフィールドを返しても拒否しない
}). passthrough ();
// 内部 IR: アプリ内部の不変条件を守る正規化済みの形
export const Recommendation = z. object ({
theme: z. string (). min ( 1 ). max ( 64 ),
subtitle: z. string (). nullable (),
confidence: z. number (). min ( 0 ). max ( 1 ),
source: z. enum ([ "gemini-3.2" , "gemini-3.1-fallback" , "cached" ]),
});
// 外部公開: クライアントが受け取る安定した契約。バージョンを切る
export const RecommendationV2 = z. object ({
schemaVersion: z. literal ( "v2" ),
theme: z. string (),
subtitle: z. string (). default ( "" ), // クライアントは undefined を受け取らない保証
confidence: z. number (),
});
ポイントは3つあります。
入口スキーマでは passthrough() を使って未知のフィールドを拒否せず、optional は nullable も同時に許容します。モデル側の挙動変動を入口で吸収するためです
内部 IR で subtitle: z.string().nullable() のように「未設定なら null」と決め切る。undefined と空文字を内部に持ち込まない判断は、私の経験では一番効きました
外部公開スキーマは schemaVersion リテラル付きで切る。クライアントは schemaVersion を見てパーサを選びます
undefined を内部に流さない、という一手間が後でいちばん効きます。境界面で形を整えてしまえば、内側のコードはずっと働きやすくなります。
Dual-Emit パターン — 旧版と新版を同時に返す移行設計
スキーマを v1 から v2 に切り替えるとき、クライアントのアップデート率が 100% になるのを待ってから配信を切り替える、という設計は個人開発者には現実的ではありません。私のアプリ群は Day 30 のリテンションが 22% 前後で、放っておいても1ヶ月で全ユーザーが新版に来てくれるわけではないのです。
そこで採用したのが Dual-Emit パターンです。サーバー側は移行期間中、v1 と v2 の両方を同じレスポンスに含めて返します。
// workers/recommend.ts
import { Recommendation, RecommendationV2 } from "../schemas/recommendation" ;
export async function handleRecommend ( req : Request , env : Env ) {
const raw = await callGeminiAPI (env, prompt);
const ir = await normalizeToIR (raw); // 内部 IR への正規化
// 旧版(v1)と新版(v2)を同時に整形して返す
const v1Payload = toV1Shape (ir);
const v2Payload = RecommendationV2. parse ({
schemaVersion: "v2" ,
theme: ir.theme,
subtitle: ir.subtitle ?? "" ,
confidence: ir.confidence,
});
return Response. json ({
// 旧クライアント向け
... v1Payload,
// 新クライアント向け(同じレスポンスに同居)
v2: v2Payload,
_meta: {
generated_at: new Date (). toISOString (),
ir_source: ir.source,
},
});
}
クライアント側は新版にアップデートされた時点で v2 フィールドを優先して読みます。古いクライアントは v2 を知らないので無視されます。これで、両者を並行して動かせる期間が確保できます。
私の場合、この期間は2週間と決めました。Day 14 を過ぎると、旧版アプリの月間アクティブ率が平均で 67% → 8% まで下がっていることがリテンションデータからわかっていたためです。経験則として「Day 7 が観察、Day 14 が安全圏」と運用しています。
Cloudflare Workers での実装と観測性の工夫
Dual-Emit を実装するときの落とし穴は、生レスポンスと IR と公開形式の3つを「全部ログに残しておかないと事故時に何も診断できない」ことです。最初の事故で私はここでハマりました。
採用したのが、Cloudflare R2 にバージョン付きでサンプリングログを書き出す形です。1% のリクエストだけ、Gemini の生レスポンス・IR・v1 出力・v2 出力をまとめて JSON Lines で保存します。
async function logSample ( env : Env , payload : SamplePayload ) {
// 1% サンプリング。本番運用で多すぎず少なすぎないライン
if (Math. random () > 0.01 ) return ;
const date = new Date (). toISOString (). slice ( 0 , 10 );
const key = `gemini-samples/${ date }/${ crypto . randomUUID () }.jsonl` ;
const line = JSON . stringify ({
ts: Date. now (),
schema_version: "v2" ,
gemini_raw: payload.raw,
ir: payload.ir,
v1_output: payload.v1,
v2_output: payload.v2,
// app バージョンや locale も必ず一緒に
app_version: payload.appVersion,
locale: payload.locale,
});
await env. SAMPLES . put (key, line, {
httpMetadata: { contentType: "application/x-ndjson" },
});
}
このログは事故時の診断だけでなく、後述する compatibility checker への入力にもなります。R2 は30日保持で月額数十円のコストに収まっています。AdMob の収益と比べれば、観測性に投資する価値は十分にあると考えています。
サンプリング率の決め方
私の経験では、Day 1 で 1%、安定したら 0.1% に落とすのが現実的です。最初は事故再発の検知のために多めに取りたいのですが、コストとストレージ容量が膨らみます。0.1% でも 1日数万件動く壁紙アプリでは数十件が手元に残るので、デバッグには十分でした。
app_version と locale の同梱は必須
これは強くお勧めします。事故の影響を「どのアプリの、どのバージョン、どのロケールで起きているか」で切り分けられないと、移行判断ができません。私の場合は app_version と locale がログに入っていなかったため、「日本語版だけで起きている問題なのか、全体なのか」を見極めるのに半日を溶かしました。
旧スキーマ利用箇所を事前に見積もる compatibility checker
新しいスキーマを設計するとき、「クライアントが現状どのフィールドにどう依存しているか」がわからないと、安全な変更計画を立てられません。私の場合は手元の6本のアプリ実装と Cloudflare Workers のサンプリングログを突き合わせて、フィールド利用率を出す Python スクリプトを書きました。
# scripts/schema_compat.py
import json
import re
from pathlib import Path
from collections import Counter
# 1. クライアントコードからフィールド参照を抽出
FIELD_RE = re.compile( r " \. recommendation \. (\w + ) | response \[ [ ' \" ](\w + )[ ' \" ] \] " )
def extract_field_usage (app_dirs: list[Path]) -> Counter:
usage = Counter()
for d in app_dirs:
for ext in ( "*.swift" , "*.kt" ):
for f in d.rglob(ext):
text = f.read_text( encoding = "utf-8" , errors = "ignore" )
for m in FIELD_RE .finditer(text):
field = m.group( 1 ) or m.group( 2 )
if field:
usage[field] += 1
return usage
# 2. R2 のサンプリングログから「実際に返している値の分布」を取る
def sample_field_distribution (log_dir: Path) -> dict[ str , dict ]:
dist = {}
for f in log_dir.rglob( "*.jsonl" ):
for line in f.read_text().splitlines():
row = json.loads(line)
for k, v in (row.get( "v1_output" ) or {}).items():
d = dist.setdefault(k, { "null" : 0 , "empty" : 0 , "value" : 0 })
if v is None :
d[ "null" ] += 1
elif v == "" :
d[ "empty" ] += 1
else :
d[ "value" ] += 1
return dist
# 3. 突き合わせて「破壊的変更か非破壊的変更か」を判定
def classify_change (field: str , removed: bool , became_required: bool ,
usage: Counter, dist: dict ) -> str :
used = usage.get(field, 0 )
nulls = dist.get(field, {}).get( "null" , 0 )
total = sum (dist.get(field, {}).values()) or 1
null_ratio = nulls / total
if removed and used > 0 :
return f "BREAKING: field ' { field } ' removed but referenced { used } times"
if became_required and null_ratio > 0.05 :
return f "BREAKING: ' { field } ' required but { null_ratio :.1% } of past responses were null"
return "SAFE"
if __name__ == "__main__" :
apps = [Path( "./apps/wallpaper-a" ), Path( "./apps/healing-b" )]
logs = Path( "./samples/2026-05" )
usage = extract_field_usage(apps)
dist = sample_field_distribution(logs)
for field in [ "subtitle" , "confidence" , "theme" ]:
print (field, classify_change(field, False , True , usage, dist))
このスクリプトは粗削りですが、実運用ではこの粗さで十分でした。subtitle フィールドの null 率が事故後の集計で 14% もあったことが、これを書いて初めて見えました。「optional だから null でいい」と頭でわかっていても、14% という具体的な数値を目にすると、それを no-default で required に格上げするのは無理だと判断できます。
Sunset プロトコル — 段階的廃止の通告と打ち切り判断
Dual-Emit で2週間並行運用したあとは、旧版を切る判断が必要になります。私の運用ルールは次の通りです。
Day 0: 新スキーマを Dual-Emit でリリース。クライアント側は新版を読む実装でストア審査に出す
Day 7: 旧版(v1)レスポンスに _meta.deprecated = true と _meta.sunset_at = "2026-XX-XX" を含める
Day 10: 旧版を読んでいるアプリのバージョン分布を確認。Day 14 までに 90% 以上が新版に到達する見込みかを判定する
Day 14: 条件を満たせば旧版(v1)の生成を停止し、v2 のみを返すレスポンスに切り替える
Day 14 で 90% に届かない場合は Sunset を Day 21 に延長し、ストアアップデート促進バナーを出す
打ち切り判断の閾値を「90%」にしたのは、私のアプリの過去30日のアップデート完了率が、ストア審査が滑らかに通った場合で 91〜94% の範囲にあったためです。各アプリのアップデート完了曲線をいちど集計しておくと、この閾値は経験で決められます。
なお _meta.sunset_at をクライアント側で読んで「アップデートを促すダイアログ」を出す実装は強く推奨します。コードは5行で済みますが、強制アップデートではないのでユーザー体験を損なわず、移行を後押しできました。
個人開発者が「契約」を意識し始めるきっかけ
私自身、こうした「スキーマの版管理」は大規模サービスの世界の話だと思っていました。個人で書いて個人で使うアプリのうちは、Gemini が返す JSON は「とりあえずパースできればいいもの」で、契約という発想は要らなかったのです。
考え方が変わったのは、個人開発で同じ機能を長く運用し、自分以外の端末や古いバージョンがそのレスポンスに依存し始めてからでした。一度スキーマを変えただけで、数週間前に配信した古いクライアントが静かにクラッシュする。自分一人で「直しておきます」と言って済む範囲を、いつの間にか超えていたのです。
スキーマを契約として扱うのは、目の前のレスポンス1件の精度を上げる話ではありません。まだ見ぬ利用者と、過去に自分が配ったコードに対して、壊さないという約束を形にする作業だと、今は考えています。
なぜスキーマは必ず変わるのか
ここまでは「一度の破壊的変更をどう乗り切るか」を見てきました。ここからは視点を変えて、そもそも変更がなぜ繰り返し起きるのか、そして変更そのものを安全に回す仕組みを整理します。
最初に渡したスキーマがそのまま 1 年使えることは、ほぼありません。理由は単純で、
ユーザーの期待が変わる — 「カテゴリだけでよかったのが、サブカテゴリも欲しくなった」
モデルの能力が変わる — Gemini の新バージョンが新しい型のフィールドを返せるようになった
下流の都合が変わる — DB のインデックス設計、フロントエンドの表示要件
過去のスキーマで気づいた間違いを直したくなる — tags: string を tags: string[] にしたい
これらは「もう少し早く気づいていれば」と思うものの、実際は本番で 1 万件以上のデータが流れてからしか見えてこないものばかりです。だから「変えない」ではなく「変えても壊れない」設計を最初から組み込む必要があります。
三層に分ける — Gemini スキーマ・保存スキーマ・ドメインモデル
先ほどは入口 IR と外部公開層という 2 層で「契約」を説明しました。長く運用するなら、ここに保存層を挟んだ 3 層で考えると、変更の影響をそれぞれの層に閉じ込めやすくなります。それぞれが別々のタイミングで変更され、互いの変更が他層に波及しないようにするのが狙いです。
[Gemini に渡すスキーマ] → Gemini API の JSON Schema 定義
↓ パース
[保存スキーマ(バージョン付き)] → Postgres / Firestore / KV に保存する型
↓ 変換
[ドメインモデル] → アプリケーションコードが扱う型
それぞれの責務を分けると、次のような変更がそれぞれの層に閉じます。
Gemini スキーマだけ変える → 古い保存データはそのまま、新規データだけが新スキーマで保存される
保存スキーマだけ変える → 過去データのマイグレーションが必要だが、Gemini と無関係に進められる
ドメインモデルだけ変える → アプリケーション層の関心事、保存層を巻き込まずに済む
実装の骨格はこんな感じです。
// schemas/v1/article-classification.ts
export const GeminiSchemaV1 = {
type: "object" ,
properties: {
category: { type: "string" , enum: [ "tech" , "lifestyle" , "business" ] },
confidence: { type: "number" },
},
required: [ "category" , "confidence" ],
} as const ;
export interface StoredV1 {
schemaVersion : 1 ;
category : "tech" | "lifestyle" | "business" ;
confidence : number ;
createdAt : string ;
}
// schemas/v2/article-classification.ts
export const GeminiSchemaV2 = {
type: "object" ,
properties: {
category: { type: "string" , enum: [ "tech" , "lifestyle" , "business" ] },
subCategory: { type: "string" },
confidence: { type: "number" },
},
required: [ "category" , "confidence" ],
} as const ;
export interface StoredV2 {
schemaVersion : 2 ;
category : "tech" | "lifestyle" | "business" ;
subCategory : string | null ;
confidence : number ;
createdAt : string ;
}
// domain/article-classification.ts
export interface Classification {
primary : string ;
secondary : string | null ; // V1 では常に null
confidence : number ;
}
export function toDomain ( stored : StoredV1 | StoredV2 ) : Classification {
return {
primary: stored.category,
secondary: stored.schemaVersion === 2 ? stored.subCategory : null ,
confidence: stored.confidence,
};
}
ポイントは schemaVersion を保存スキーマに必ず付けること 。これがあると、コード側は「どのバージョンの形か」を判定できます。バージョンを保存しなかった過去のレコードは、私の経験上、後から読み解くのに何倍もの時間がかかります。
4 種類の変更を安全に通す手順
スキーマ変更は、変更の種類によって難易度がまったく違います。私は次の 4 種類に分けて運用しています。
1. フィールド追加(簡単)
新しい必須でないフィールドを追加するだけ。Gemini スキーマに optional 扱いで追加し、保存スキーマでも optional にします。古いデータは新フィールドが null で読める形になります。リスクが低いので、新スキーマと旧スキーマを並走させる必要はありません。
2. 型変更(中難度)
tags: string を tags: string[] にするような変更です。これは新スキーマを別バージョンで切ります。
function migrateV1toV2 ( v1 : { tags : string }) : { tags : string [] } {
return { tags: v1.tags ? v1.tags. split ( "," ). map ( s => s. trim ()) : [] };
}
過去データはバックグラウンドジョブで段階的に変換します。重要なのは、変換中も V1 と V2 の両方を読める状態を保つこと 。一気に全件変換しようとすると、変換中の障害が全レコードを破壊します。
3. リネーム(要注意)
title を headline にリネームしたい、というケースです。これは「新フィールドを追加」「両方を書く期間」「読み出しを新フィールドに切替」「古いフィールドを削除」の 4 段階に分けます。Stripe や GitHub の公開 API でも採用されている Expand-Contract パターンと同じ考え方です。
// Phase 1: 両方書く
function write ( record : { headline ?: string ; title ?: string }) {
const value = record.headline ?? record.title;
return { headline: value, title: value }; // 旧読み出し向けに残す
}
// Phase 2: 新フィールドだけを読む(旧読み出しコードは消す)
// Phase 3: 旧フィールドを書かなくなる
// Phase 4: 旧フィールドを削除
各フェーズは最低 1 週間以上空けます。本番で何かが残っていても気づける期間を取るためです。Gemini への新しいリクエストは Phase 1 の最初から headline を返してもらいますが、保存層側で 2 つのフィールドを持つ期間が要る、と理解しておくと運用がぶれません。
4. 廃止(最難)
「このフィールドは使わなくなった、削除したい」というケース。実は最も慎重に進める必要があります。downstream で誰がそれを読んでいるか、本当に把握できていますか? 私の運用では、廃止前に 3 か月以上の deprecation アナウンス を出してから、最後に削除します。アナウンスのチャネルはコード上の JSDoc コメント、Slack の社内告知、API consumer 向けの changelog の 3 つです。
バージョニングを Gemini API に伝える方法
Gemini API はリクエストごとにスキーマを渡せるので、バージョン管理はクライアント側の責務です。私の実装では、リクエストヘッダ的なメタデータをロギングに含めて、どのスキーマバージョンで生成されたかを追跡できるようにしています。
async function classify ( text : string , version : 1 | 2 = 2 ) : Promise < Classification > {
const schema = version === 2 ? GeminiSchemaV2 : GeminiSchemaV1;
const response = await ai.models. generateContent ({
model: "gemini-2.5-flash" ,
contents: text,
config: {
responseMimeType: "application/json" ,
responseSchema: schema,
},
});
const parsed = JSON . parse (response.text ! );
const stored = saveWithVersion (parsed, version);
await logUsage ({
schemaVersion: version,
inputTokens: response.usageMetadata?.promptTokenCount,
outputTokens: response.usageMetadata?.candidatesTokenCount,
});
return toDomain (stored);
}
「どのスキーマバージョンが本番で使われているか」を継続的に観測できるようにしておくと、移行の進捗が可視化できます。私のチームでは Grafana で「V1 / V2 比率」を時系列で見ており、新スキーマへの切り替えが想定通りに進んでいるかを毎週レビューしています。
旧データを段階的にアップグレードするバッチ設計
過去データの変換は、一括ではなく段階的に行います。私の運用では、次の 5 つのステップを踏みます。
対象レコードを schemaVersion = 1 でフィルタ
小さなバッチ(例: 100 件)で変換を試行、エラー率を計測
エラー率が 1 % 未満ならバッチサイズを段階的に上げる
失敗レコードは別キューに退避し、変換コード自体を直してから再走させる
schemaVersion = 2 に書き換え後、24 時間後に旧フィールドの整合性を再確認
async function migrateBatch ( size : number ) {
const records = await db. query < StoredV1 >({
where: { schemaVersion: 1 },
limit: size,
});
const failures : Array <{ id : string ; error : string }> = [];
for ( const record of records) {
try {
const upgraded = migrateV1toV2 (record);
await db. update (record.id, upgraded);
} catch (err) {
failures. push ({ id: record.id, error: String (err) });
}
}
console. log ( `Migrated ${ records . length - failures . length }/${ records . length }` );
if (failures. length > 0 ) {
await db. insertFailureQueue (failures);
}
return { processed: records. length , failures: failures. length };
}
最初は size = 100 で走らせ、エラー率と所要時間を見ながら 1,000 / 5,000 / 10,000 と上げます。エラーレコードは破棄せず、必ず別キューに退避 。後から「なぜ変換できなかったか」を調べる手がかりになります。
落とし穴:Gemini が「古いスキーマを忘れる」現象
長く運用していて気づいたのは、Gemini に新スキーマでプロンプトを渡し続けていると、ごくたまに「旧スキーマを聞かれていないのに混ぜてくる」ことです。具体的には、V2 のスキーマを渡しているのに category の enum 値に V1 時代の古い値が混じることがありました。
これは、新スキーマで category の enum を狭めたとき(例: tech を tech-news と tech-tutorial に分割した)に起きます。プロンプトの履歴に古い分類が残っていると、モデルが混在した出力を返してしまうのです。対処は、enum を狭めるときには 「過去の enum 値は残し、新しい値も加える」 (unionで広げる)アプローチに切り替えました。enum は広げるのは安全だが狭めるのは難しい、というのが運用上の経験則です。
いま手をつけるなら
スキーマ変更の予定がない方も、いま「保存スキーマに schemaVersion を必ず付ける」だけは始めることをおすすめします。これさえあれば、半年後に変更したくなったとき、自分を 10 倍楽にできます。
// 最低限これだけ追加しておく
interface Stored {
schemaVersion : number ;
// ... 他のフィールド
}
スキーマは育てるもの、という前提を最初から組み込んでおく。それが、Gemini を使った機能を「捨て駒の試作」ではなく「長く運用するシステム」に育てるための、いちばん地味で効果的な投資だと感じています。