停止当日の朝、サーバー側の呼び出しはすべて差し替え済みでした。それでも落ち着かなかった理由は、はっきりしています。App Store と Google Play に並んでいる自分のアプリの旧バージョンが、まだ古いモデル名を握ったまま動いているからです。
私が個人開発で運用している壁紙アプリ群は、画像のカテゴリ判定や説明文の生成を Gemini に任せています。呼び出しの本体はサーバーに置いてありますが、初期のバージョンには「どの機能でどのモデルを使うか」という情報がクライアント側の設定ファイルに残っていた時期がありました。
6月末にプレビュー系のモデルが停止したとき、その残骸の一部が表に出ました。サーバーのログには、誰も更新していないはずの古いモデル名が、少ないながら確かに流れ続けていたのです。
停止日というのは、自分の移行が終わった日ではありません。自分がまだサポートしている最も古いクライアントが、動かなくなる日 です。この違いを見落とすと、当日の準備は半分しか終わっていないことになります。
サーバー側の差し替えが終わっても、配布済みのバイナリは書き換わりません
Web サービスだけを運用しているなら、モデル停止への対応は原理的に単純です。デプロイすれば、次のリクエストから新しい経路が使われます。旧経路は誰も通りません。
モバイルアプリが混じると、この前提が崩れます。アプリのバイナリはユーザーの端末にあり、こちらから書き換える手段がありません。更新はユーザーの意思とストアの都合に従います。
層 差し替えが効くまで 停止日に対して
サーバー実装 デプロイ直後 当日までに完了できます
リモート設定(Remote Config 等) 数分〜次回起動 当日でも間に合います
アプリのバイナリ 審査+段階公開+ユーザーの更新 当日には揃いません
三段目だけが、こちらの意思では動きません。ここに モデル名という「期限のある値」 を焼き込んでしまった時点で、停止日は自分の手を離れています。
私はこの構造に気づいてから、クライアントの設定ファイルから固有名を1つずつ剥がしていきました。作業自体は地味ですが、期限のある値を、期限のない場所に置き直す作業だと考えると納得できます。
停止日をまたいだ瞬間、切り戻しは復旧手段ではなくなります
もう1つ、見落としやすい点があります。停止日を境に、ロールバックの意味が反転する ことです。
移行の作業中、私たちは当然のように切り戻しの経路を用意します。環境変数を戻す、1つ前のデプロイに戻す、段階公開を止めて旧バージョンを残す。どれも普段は正しい手順です。
ところが停止日を過ぎると、旧経路の先にあるモデルはもう存在しません。切り戻しは「安全な状態に戻る操作」ではなく、確実に落ちる状態を選ぶ操作 に変わります。停止後の呼び出しは非推奨の警告ではなくハードエラーで止まるため、部分的な劣化として観測される猶予もありません。しかも障害対応の最中は、反射的にこのボタンを押したくなります。
私は停止日の1週間ほど前から、次の3つを実際に確認するようにしました。
直近のデプロイ履歴のうち、どこまで戻ると旧モデルを呼ぶ経路が復活するかを特定する
その地点より古いリビジョンへの切り戻しを、デプロイ設定側でブロックする
段階公開を止めた場合に残る「1つ前のアプリバージョン」が、どの経路で Gemini に到達するかを追う
3つ目が一番厄介でした。iOS の段階公開を停止すると、新バージョンの配信が止まるだけで、すでに更新した人は戻りません。一方で未更新の人は旧バージョンのまま残ります。つまり停止操作は「全員を旧に戻す」わけでも「全員を新に進める」わけでもなく、二層を固定する操作 です。旧側の経路が停止日で死ぬなら、その固定は障害を長引かせます。
モデル名をクライアントから剥がす
対処の方向は1つです。クライアントには「何をしたいか」だけを持たせ、「どのモデルで実現するか」はサーバーが決めます。
配布物には機能名だけを持たせる
クライアントが送るのは image.describe や wallpaper.classify のような機能名です。モデル名は一切含めません。以下は Swift 側の最小形です。
struct AIRequest : Encodable {
let capability: String // "wallpaper.classify" のような機能名
let assetId: String
let locale: String
}
func classify ( assetId : String ) async throws -> Classification {
var req = URLRequest ( url : URL ( string : "https://api.example.com/v1/ai" ) ! )
req.httpMethod = "POST"
req. setValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
// クライアントのバージョンは送る。モデル名は送らない
req. setValue (Bundle.main.appVersion, forHTTPHeaderField : "X-App-Version" )
req.httpBody = try JSONEncoder (). encode (
AIRequest ( capability : "wallpaper.classify" ,
assetId : assetId,
locale : Locale.current.identifier)
)
let (data, resp) = try await URLSession.shared. data ( for : req)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
// 機能単位で静かに縮退する。ここで画面全体を落とさない
throw AIError.unavailable
}
return try JSONDecoder (). decode (Classification. self , from : data)
}
App Version をヘッダで送っている点だけが、後から効いてきます。どのバージョンがまだ生きているかを、サーバーのログだけで数えられるようになります。
サーバー側に解決テーブルを1か所だけ置く
モデル名が登場する場所を、コードベース全体で1か所に閉じ込めます。停止の告知が出たとき、書き換える先が1ファイルで済むかどうかが、当日の余裕を決めます。
import os
from google import genai
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
# モデル名が出てくるのは、このテーブルだけに限定する
CAPABILITY_MODELS = {
"wallpaper.classify" : "gemini-3.7-flash" ,
"image.describe" : "gemini-3.7-flash" ,
"image.generate" : "gemini-3.1-flash-image" ,
}
# 旧クライアントが送ってくるかもしれない機能名の読み替え
CAPABILITY_ALIASES = {
"image.imagen4" : "image.generate" , # v2.3.0 以前が送ってくる旧称
}
class UnknownCapability ( Exception ):
pass
def resolve_model (capability: str ) -> str :
key = CAPABILITY_ALIASES .get(capability, capability)
model = CAPABILITY_MODELS .get(key)
if model is None :
# 未知の機能名は落とす。推測して既定モデルに流すと事故が見えなくなる
raise UnknownCapability(capability)
return model
def run (capability: str , parts: list ) -> str :
model = resolve_model(capability)
resp = client.models.generate_content( model = model, contents = parts)
return resp.text
ここで意識したのは、未知の機能名を既定モデルに流さないことです。親切のつもりの既定値は、旧クライアントの存在を隠します。落としておけば、ログに UnknownCapability が並び、どのバージョンが取り残されているかがその日のうちに分かります。
なお generate_images() を使っていたコードを generate_content() へ移すときの引数の対応は、素直に1対1では移りません。詳細はgenerate_images から generate_content へ書き換えるとき、引数は素直に移りません に分けて書いています。
クライアントから来たモデル名は受け付けない
移行の途中で一番危ないのは、「クライアントがモデル名を送れる」余地を残したまま停止日を迎えることです。旧アプリが送る文字列は、こちらでは変更できません。
FORBIDDEN_FIELDS = ( "model" , "model_name" , "engine" )
def sanitize (payload: dict ) -> dict :
# 旧クライアントが送ってくるモデル指定は、黙って捨てるのではなく記録して捨てる
leaked = [f for f in FORBIDDEN_FIELDS if f in payload]
if leaked:
logger.warning(
"client sent model hint: fields= %s version= %s " ,
leaked, payload.get( "_app_version" , "unknown" ),
)
for f in leaked:
payload.pop(f)
return payload
黙って捨てるのではなく、記録してから捨てます。この警告ログの本数が、そのまま「まだ残っている旧クライアント」の目安になります。
互換分岐こそが最初に壊れます
ここが、事前の予想と一番食い違った点です。
私は当初、旧クライアントを支えるために互換分岐を厚くする方向で考えていました。古いバージョンから来たリクエストは旧モデルへ、新しいバージョンからは新モデルへ、という素直な発想です。
ところが停止日を意識して読み直すと、その分岐は逆向きに働きます。互換のために書いた分岐こそが、停止日に確実に落ちる唯一のコード だからです。しかも旧クライアントからのリクエストにしか出現しないため、こちらの検証環境では踏みません。当日に本番環境だけが赤くなります。
結論として、私は互換分岐を厚くするのをやめました。旧バージョンから来たリクエストも、新しいモデルへ同じように流します。出力の形が変わって旧アプリの表示が少し崩れる可能性は残りますが、落ちるよりは軽い障害です。
方針 停止日より前 停止日以降
旧クライアントを旧モデルへ分岐 表示は完全に一致します その経路だけが落ちます
旧クライアントも新モデルへ集約 表示に軽い差が出ます 落ちません
互換性を守る努力が、期限のあるリソースに向いているときは、守らないほうが安全になる。この反転は、実際に停止日を1度またぐまで腹に落ちませんでした。
強制更新は停止日には間に合いません
旧バージョンを畳む手段として、強制更新を思い浮かべる方は多いと思います。私も一度検討しました。
ただ、時間の計算が合いません。強制更新を仕込んだビルドを作り、審査を通し、段階公開を回し、ユーザーが実際に更新するまで。個人開発の体感では、告知から普及までに数週間を見ておく必要があります。停止の告知から当日までの期間と、正面から競合します。
しかも強制更新は、更新できない事情のある利用者を締め出します。私が運用しているアプリの利用者には、古い端末をそのまま使い続けている方が一定数いらっしゃいます。モデル停止という自分側の都合で、その方々のアプリを起動不能にする判断は、私には取れませんでした。
この落とし穴を回避する現実的な順序は、次のとおりです。
サーバー側でモデル解決を吸収し、旧クライアントも新モデルへ流す(当日までに必ず完了させる)
機能単位の縮退経路を用意し、失敗しても画面全体は生かす
強制更新は、停止日対策ではなく、次の通常アップデートの中で自然に片付ける
強制更新を「当日の武器」ではなく「翌月の整理」として置き直すと、判断がずいぶん楽になりました。
停止日の前に見ておく3つの経路
当日の朝に確認するのは、コードではなくログです。私は次の3つを順に見ています。
モデル名の出現箇所 : リポジトリ全体を検索し、解決テーブル以外にモデル名が残っていないかを確認します。設定ファイル・テスト・ノートブック・ドキュメントのサンプルまで含めます
クライアントのバージョン分布 : X-App-Version ヘッダの集計で、直近1週間にどのバージョンからリクエストが来ているかを見ます。ここに古い番号が並ぶ限り、旧経路は生きています
切り戻しの到達点 : デプロイ履歴のどのリビジョンまでなら安全に戻れるかを、実際にその差分を開いて確認します
2つ目の集計は、停止の告知が出た時点で仕込んでおくことをお勧めします。当日になってから足すと、比較する過去のデータが手元にありません。
どこから呼んでいるかの洗い出し自体については、8月17日の Gemini 画像生成モデル停止に備える に手順をまとめてあります。本記事はその先、洗い出しが終わった後に残る「自分で書き換えられない層」を扱っています。
今日できる一手
まず、自分のクライアントが送っているリクエストの中身を1つ、実際に覗いてみてください。そこにモデル名が含まれていたら、それは今日から期限付きの値になります。
私自身、この線引きに行き着くまでに1度停止日をまたぎました。期限のある値を、更新できない場所に置かない。書いてしまえば当たり前ですが、当たり前に見えるからこそ、設定ファイルの片隅で見過ごされます。同じところで足を止める方が1人でも減れば嬉しく思います。