技術 約13分で読めます

CodePush終了後のReact Native OTA更新をEAS Update・Stallion・自己ホストで比べる

いけさん目次

React NativeアプリでJavaScript層の修正だけを配るOTA(Over-the-Air)更新は、MicrosoftのCodePushが2025年3月にサービスを終了し、定番ツールがなくなった。
本番の文言ミスや機能フラグをストア審査なしで直す手段として、現在はEAS Updateへ寄せるか、自己ホストするか、React Native Stallionのような独立サービスを使うかの選択になる。
EASを使わない場合の有力候補となるStallion(React Native Stallionを使ったOTA更新ガイド)の機能を起点に、OTA配信で直せる範囲と、配信事故を防ぐためのロールバックや署名の要件を見る。

OTA更新は、React Nativeアプリのネイティブバイナリを差し替えずにJavaScript bundleや一部アセットを配る仕組みだ。
便利な抜け道ではあるが、ストア審査を回避して何でも変えられる仕組みではない。
AppleのApp Review Guidelinesでも、ダウンロードして実行するコードがアプリの目的を大きく変えることは許されない。
OTAで直せるのは「すでに審査されたアプリの範囲内で、JavaScript層を直す」部分に限られる。

OTAで直せる境界はネイティブ境界で切れる

React Nativeアプリは大きく分けると、App StoreやGoogle Playに提出するネイティブ側の殻と、その中で読み込まれるJavaScript bundleに分かれる。
画面の文言、API呼び出し、状態管理、JavaScriptで書かれたUIロジックならOTAの対象になる。
新しいネイティブモジュールを追加する、カメラや位置情報の権限を増やす、Info.plistやAndroidManifest.xmlを変える、SDKのネイティブ依存を更新する、といった変更はストア提出が必要だ。

この境界を曖昧にすると、OTAは運用を楽にするどころか事故の入口になる。
ExpoのEAS Updateの説明では、native code layerとJavaScript update layerの互換性を runtimeVersion で表す。
更新とビルドはplatformとruntimeVersionが一致したときだけ適用される。
ネイティブ側のインターフェイスが変わったのに同じruntimeVersionへ更新を投げると、古いバイナリに新しいJavaScriptを読ませることになる。

CodePush終了後の選択肢

React Native OTA更新の定番は長くMicrosoftのCodePushだった。
App Centerとセットで使われ、無料で、資料も多く、React Nativeの本番運用ではかなり雑に「CodePushでいい」と言える時期があったが、MicrosoftはVisual Studio App Centerの廃止を発表し、2025年3月31日にサービスを終了した。
CodePushをホストサービスとして使っていたチームは、Expo EAS Updateへ寄せるか、CodePush互換サーバーを自分で持つか、StallionやHot Updaterのような別サービスへ移るかを選ぶことになった。

EAS UpdateはExpoプロジェクトなら自然な選択だ。
eas update でbundleを生成してEASサーバーへ上げ、channelとbranchを使って対象ビルドへ配る。
Expoのドキュメントでは、channelはビルド時にネイティブコードへ埋め込まれ、branchは更新の並びとして扱われる。
どのbranchをどのchannelへ向けるかを変えれば、同じproduction channelのビルドへ別の更新列を配れる。

bare React NativeでExpoの流儀に寄せたくない場合は、自己ホストか別の管理サービスになる。
自己ホストは自由度が高いが、配信サーバー、署名、ロールバック、メトリクス、CDN、障害対応まで自分の責任になる。
OTA更新は「JavaScriptを置くだけ」と見えるが、本番で本当に必要なのは壊れたbundleを止める仕組みと、どの端末に何が入ったかを追う仕組みだ。

StallionはCodePush互換より配信事故の処理に寄っている

React Native Stallionは、managedなReact Native向けOTA配信サービスだ。
GitHubリポジトリでは、JavaScript bundleの配信、テスト、ロールバック、分析、段階的ロールアウトを含むプラットフォームとして説明されている。
SDKとCLIは公開されているが、管理コンソールはホスト型のサービスとして提供される。

Stallionで目立つのは、単に「ストアなしで配れる」ことより、配信後に止める・戻す・絞るための機能だ。
bundleをbucketにアップロードし、ダッシュボードからproductionへpromoteし、対象アプリバージョンやロールアウト率を指定する流れが作れる。
初期状態を0%にして社内ユーザーだけで確認し、5%、25%、50%、100%へ上げる運用なら、ストアの段階的ロールアウトに近い確認をJavaScript更新にも持ち込める。

差分配信も大きい。
従来型のOTAは、1行の修正でもJavaScript bundle全体を配ることが多い。
Stallion側のOTA更新ガイドやpatch updateの説明では、file-level diffやdelta配信で更新サイズを小さくする設計が強調されている。
この数字は変更内容やbundle構成に左右されるので、自分のアプリで「文言修正」「API層修正」「画像差し替え」をそれぞれ測るほうがいい。

署名も外せない。
OTA配信サーバーは、アプリが実行するJavaScriptを後から渡す場所になる。
署名なしでbundleを受け入れる設計は、配信経路や管理画面が破られたときの被害がそのまま実行コードに届く。
Stallionはbundle signingを売りにしているが、採用時に見るべきなのは「署名があるか」だけではなく、鍵を誰が持つか、CI上でどう扱うか、失効やローテーションをどう行うかだ。

導入コードよりリリース単位の設計が先に要る

Stallionの導入自体は難しくない。
SDKを入れ、Androidでは getJSBundleFile、iOSでは bundleURL をStallion側へ向ける。
development buildではMetroを使い、release buildだけOTAを読むように分ける。
ここまでは他のOTAツールと同じだ。

詰まりやすいのは導入コードより、どの単位で更新を切るかだ。
store version、runtimeVersion、OTA release、Git commit、API互換性がばらばらになると、障害時に「どの端末がどのコードを読んでいるか」が見えなくなる。
EAS Updateならchannelとbranch、Stallionならbucketやrelease、自己ホストなら自前のrelease tableに、ネイティブビルド番号とJavaScript更新の対応を残す必要がある。

ロールバックの意味も決めておく。
新規ダウンロードを止めるpauseと、既に更新済みの端末を前のbundleへ戻すrollbackは別物だ。
起動直後にクラッシュするbundleなら自動ロールバックが欲しい。
特定APIだけ壊れているなら、サーバー側のfeature flagで止めるほうが速い場合もある。
OTAを入れると、アプリ側のリリース管理とサーバー側の機能制御が同じ障害対応フローに入ってくる。

ゲームアプリの「更新なしデータ配信」はどこまで行けるのか

React NativeのOTA更新はJavaScript bundleの差し替えで、コード側が動くからUI変更もロジック修正もストアなしで配れる。
逆にネイティブモジュールを足すような変更は手が出ない。

Unity製のゲームアプリは反対方向からこの問題に取り組んでいる。
ミリシタのバックエンド記事で触れたが、ミリシタは楽曲・衣装・3DモデルをすべてAssetBundleでCDN配信し、イベント期間やガチャ確率をサーバーからのマスターデータで回し、新機能はフィーチャーフラグで開閉する。
結果として、ストア更新なしで新曲も新イベントも出せるし、中規模の機能追加まで行ける状態になっている。
ではこのアプローチ、ぶっちゃけどこまで伸ばせるのか。

コード自体は配れない

UnityのAssetBundleはモデル、テクスチャ、音声、アニメーション、ScriptableObject、Timelineなど、Unityのシリアライズ対象ほぼ全般を外部化できる。
画面に出るものと音はだいたいカバーされる。

C#スクリプト自体はAssetBundleに含められない。
IL2CPPビルドではC#コードはネイティブバイナリにコンパイルされるし、iOSではJITコンパイルをAppleが許可していないので動的なコード読み込みは禁止されている。
React NativeのOTAが「コードは配れるがネイティブモジュールは無理」なのに対し、Unityゲームは「アセットは何でも配れるがコードは無理」という逆の制約になっている。

フィーチャーフラグも万能ではない。
サーバーのフラグでONにするためには、そのコードがすでにアプリ本体に入っている必要がある。
ミリシタでアイドルグランプリやAR撮影のような新モードがある日突然使えるようになったように見える場合、コード自体はその前のストア更新で仕込まれていて、フラグが開くまで起動しなかっただけだ。
先にコードを埋めて後からデータで起こす、という二段構えになっている。

Luaを挟むとロジックまで配れるが、規約上はグレー

C#コードは配れないが、ゲーム内にスクリプトエンジンを組み込めばロジック変更もデータ配信で回せる。
中国系のゲームでよく使われるのがLuaバインディング(xLua、ToLuaなど)で、LuaスクリプトファイルはテキストデータだからAssetBundleやサーバーダウンロードで差し替え可能。
バトルロジック、AI挙動、イベントシナリオの分岐まで、Lua側に実装を寄せておけばストア更新なしで変更できる。

原神やアークナイツがこの方式を採用しているのは、中国のAndroidストアがGoogle Play以外に数十あり、全ストアへのアップデート配信が現実的でないという事情もある。
ストア更新を減らしたい動機が構造的に強い。

ただしAppleのApp Store Review Guidelines 2.5.2では「ダウンロードされたコードでアプリの機能を変更してはならない(例外: JavaScriptCore等のWebKit系)」と書かれている。
Luaスクリプトによるロジック変更がこの条項に引っかかるかはグレーゾーンで、審査を通っているタイトルが多数存在する一方、弾かれた報告もある。
React NativeのOTAが「JavaScriptCoreでの実行」として明示的に許可されているのと比べると、Lua方式は規約上の根拠が弱い。

コンテンツ更新と機能更新の境界

ゲームアプリで実際にストア更新なしで回せている範囲をざっくり分けると、コンテンツ追加(キャラ、衣装、楽曲、マップ、シナリオテキスト)はAssetBundleで完全にストア外。
数値バランス調整(パラメータ、ドロップ率、イベント報酬)はマスターデータで完全にサーバー側。
ミリシタでいえば、毎月の楽曲追加、毎週のイベント開催、ガチャの更新、衣装追加はすべてこの範囲に収まっている。

一方で新しいゲームルールや新しいUI画面はコード変更を伴うので、事前にバイナリに仕込んでおくかストア更新が必要になる。
スマホゲームが「大型アップデート」と称してストア更新を要求するタイミングは、だいたいこの境界に当たっている。

この境界をどこに引くかはゲームの設計思想で大きく変わる。
データ駆動設計を徹底すれば、バトルロジックすらパラメータとルールテーブルの組み合わせとしてマスターデータに落とし込める。
カードゲームのルールを全部サーバー側のルールテーブルで定義する設計なら、新カード効果の追加がコード変更なしで済む。
逆にロジックをクライアント側にハードコードしていると、小さなルール変更でもストア更新が必要になる。

React NativeのOTAもこの点では同じ構造で、APIのレスポンスで挙動を変える設計にしておけばOTAすら不要になるし、クライアント側にビジネスロジックを書き込んでしまうとOTAの頻度が上がる。
「ストア審査を回避するツール」をどれだけ充実させるかより、そもそもクライアントにどれだけの判断を持たせるかというアーキテクチャ判断が先にある。

TestFlight配布なら審査回避は論点にならない

ここまでの話はApp StoreやGoogle Playに公開するアプリが前提だった。
個人制作で自分が使うだけ、あるいは少人数に配るだけなら、ストア審査はそもそも関係ない。

iOSならTestFlightが使える。
Apple Developer Programに加入していれば、App Store Connectからビルドをアップロードしてinternal testingで自分のデバイスに直接インストールできる。
internal testingはApple側の審査を通らないので、ビルドを上げた瞬間にインストール可能だ。
最大100人まで配れるし、TestFlight betaの有効期限は90日だが新しいビルドを上げ直せばリセットされる。
AndroidならGoogle Play Consoleのinternal testing trackか、APKをそのまま入れるだけでいい。

この前提だと、OTAの最大の利点だった「ストア審査を待たずに配れる」が消える。
TestFlightビルドをXcodeからArchive→Uploadする手間と、OTAでbundleを上げる手間は大差ない。
OTAに残る利点はアプリの再インストールなしで更新が反映されることと、bundle差分だけ配るぶん転送量が小さいことだが、個人用アプリなら再インストールも大した作業ではない。

OTAを運用するにはどの方式でも配信サーバー、署名検証、ロールバック判定のインフラが要る。
個人開発でこれを維持するコストと、TestFlightでビルドを上げ直す手間を天秤にかけると、ほとんどの場面でTestFlightのほうが楽だ。
OTAが個人開発でも欲しくなるのは、複数台のテスト端末に即座に同じ修正を配りたいとか、設定値だけを頻繁に変えてA/Bテストしたいとか、更新頻度が週に何度もあるような場合に限られる。

PWAのService Workerキャッシュ更新はOTAと同じ難しさ

かなチャットはPWAで動かしている。
PWAはウェブアプリなのでApp StoreもGoogle Playも関係なく、サーバーにデプロイすればユーザーに届く。
OTAという概念自体が不要に見えるが、Service Workerがキャッシュを持ち始めるとそうでもなくなる。

Service Workerはオフライン動作やパフォーマンスのためにHTML、CSS、JavaScript、画像をブラウザ側にキャッシュする。
キャッシュ戦略をCache First(キャッシュにあればネットワークに問い合わせない)で動かしていると、サーバーにデプロイしてもブラウザが新しいファイルを取りに行かない。
ユーザーからは「デプロイしたのに反映されない」に見える。

React NativeのOTAでは「壊れたbundleを配ったときに戻す仕組みが必要」という話を書いたが、PWAでは逆方向の問題が起きやすい。
OTAは「配りすぎ」が怖く、PWAのService Workerは「届かない」が怖い。

安牌なのは、デプロイのたびにService Workerファイル自体のハッシュを変え、skipWaiting()で待機中のService Workerを即座にアクティブにし、clients.claim()で既存タブも新しいService Workerに切り替える方式だ。
Workboxを使っているならprecacheAndRouteでファイルごとのリビジョンハッシュを管理し、変更のあったファイルだけ新しいキャッシュに入れ替えてくれる。
結局やっていることはOTAの差分配信と同じで、「変更を検知して、差分を配って、即座に切り替える」だ。

ただしskipWaitingには注意がある。
古いService Workerで読み込んだページの途中で新しいService Workerに切り替わると、古いHTMLが新しいJSやCSSを参照してしまう。
ページ全体の整合性を保つなら、ユーザーに「新しいバージョンがあります。再読み込みしますか?」と通知して、明示的にリロードさせるほうが安全だ。
これもOTAで「起動時に更新チェック→次回起動で反映」にするのと構造が似ている。

サーバーサイドのワーカーは「消す」にもデプロイが要る

このブログも以前、Cloudflare Workersでバックエンド側のアナリティクス基盤を動かしていた。
ブラウザからイベントをWorkerのエンドポイントへ飛ばし、Worker側でSupabaseに書き込む構成で、GA4のクライアントサイド計測とは別にサーバーサイドでデータを取っていた。

使わなくなったとき、クライアント側のトラッキングスクリプトを外しただけではWorkerは止まらない。
Cloudflare Workersはルーティングにマッチするリクエストが来る限り応答し続ける。
クライアントからのイベント送信をやめても、Workerプロセス自体はエッジに残っている。
完全に止めるにはwrangler deleteでWorkerを消すか、Cloudflareダッシュボードから削除するか、空のWorkerをデプロイして上書きする必要がある。

PWAのService Workerが「古いキャッシュを手放さない」問題だったのに対し、エッジワーカーは「もう要らないコードが動き続ける」問題だ。
方向は逆だが、デプロイしたコードが勝手には消えないという性質は同じで、止めるにも能動的なデプロイが要る。

OTAで端末に配ったbundleも同じ構造を持っている。
配信サーバーから新しいbundleを止めても、すでにダウンロード済みの端末には古いbundleが残る。
端末のbundleを上書きするにはロールバック用のbundleを新たに配る必要がある。
React NativeのOTA、PWAのService Worker、CDNのエッジワーカー、どれも「一度配置した実行コードを引っ込めるには、別のコードを配置する」という同じライフサイクルの上にある。