EAS Update を入れて最初に感じる安心感は、しばらくすると別の不安に変わります。「eas update は成功した。Published と出た。なのに、ユーザーからの不具合報告が止まらない」。私自身、個人開発で運用している Rork 製アプリで、同じ修正を二度も三度も配信し直した経験があります。問題はコードではなく、更新が一部の端末に「静かに届いていなかった」ことでした。
OTA 配信は、ビルド審査の待ち時間をなくす強力な仕組みです。一方で、配信が成功することと、それが実際にユーザーの画面に反映されることは別問題です。以下では、配信したのに直らないという状況を分解し、原因を切り分け、再発を防ぐための本番運用での勘所を整理します。チュートリアルではなく、実際に運用してつまずいた箇所の記録です。
「Published」は配信の成功であって、採用の成功ではない
eas update が返す Published は、EAS のサーバーに新しい更新が登録されたことを意味します。ここから先、その更新が実際に動くまでには、いくつもの関門があります。
段階 意味 失敗するとどうなるか
Publish 更新が EAS に登録される CLI がエラーを返すのですぐ気づける
マッチング 端末の runtimeVersion と更新の runtimeVersion が一致 不一致だと永遠に届かない(無言)
ダウンロード 起動時にバンドルを取得 通信状況・チェック設定次第で遅延する
適用 次回起動で新バンドルに切り替わる 1回目の起動では旧バンドルのまま動く
ここで最も厄介なのが2段目のマッチングです。CLI には何のエラーも出ません。配信は成功し、ダッシュボードにも更新が並びます。それでも、runtimeVersion が合わない端末には更新が一切届きません。「成功したのに直らない」の大半はここが原因です。対処の出発点は、コードを疑う前にこの一致を疑うことです。
まず確認すべきは、その更新がどの runtimeVersion に紐づいているか
切り分けの最初の一歩は、配信した更新と、ユーザーが使っているビルドの runtimeVersion が一致しているかを見ることです。
# 配信済み更新を runtimeVersion つきで一覧する
eas update:list --channel production --json --non-interactive \
| jq '.[] | {id, runtimeVersion, message, createdAt}'
出力された runtimeVersion が、ストアに出ている現行ビルドの runtimeVersion と一致していなければ、その更新は現行ユーザーには届きません。現行ビルドの値は、ビルド一覧から確認できます。
# 直近のビルドが持つ runtimeVersion を確認する
eas build:list --platform ios --status finished --limit 5 --json --non-interactive \
| jq '.[] | {id, runtimeVersion, appVersion, channel}'
この2つの値が食い違っていたら、コードの問題ではありません。配信先の runtimeVersion を現行ビルドに合わせて publish し直す必要があります。私はこの確認を後回しにして、正しいコードを何度も配信し直すという徒労を経験しました。最初にここを見る癖をつけてからは、切り分けが一気に速くなりました。
runtimeVersion ドリフトは「設計」で防ぐ
runtimeVersion がいつの間にかずれていく現象を、ここではドリフトと呼びます。これはポリシーの選び方で大きく変わります。
// app.json
{
"expo" : {
"runtimeVersion" : { "policy" : "appVersion" },
"updates" : {
"url" : "https://u.expo.dev/YOUR_PROJECT_ID" ,
"enabled" : true ,
"checkAutomatically" : "ON_LOAD" ,
"fallbackToCacheTimeout" : 0
}
}
}
ポリシーごとの性格は次のとおりです。
policy runtimeVersion が変わるタイミング 向いている運用
sdkVersionExpo SDK を上げたとき SDK を頻繁に上げない・OTA を広く届けたい
appVersionアプリのバージョン番号を上げたとき リリース単位で OTA 対象を区切りたい
fingerprintネイティブ依存が変わったとき(自動算出) ネイティブ互換性を厳密に守りたい
判断の軸はシンプルです。OTA をできるだけ多くのユーザーに届けたいなら、runtimeVersion が変わりにくい sdkVersion を推奨します。一方で、ネイティブの互換性を取り違えたくないなら fingerprint が安全です。fingerprint はネイティブ構成のハッシュから自動算出されるため、互換性のない端末に JavaScript だけ送り込んで落とす事故を防げます。
私の運用では、安定して同じ SDK を使い続ける小規模アプリでは sdkVersion、ネイティブモジュールを足したり外したりするアプリでは fingerprint に寄せています。appVersion は「リリースごとに OTA を切り直したい」という明確な意図がある場合に限って使うようにしています。意図なく appVersion にすると、バージョンを上げるたびに過去ビルドのユーザーが OTA から取り残されていきます。
更新が届いているかを、推測ではなく数字で見る
「届いているはず」を「届いている」に変えるには、アプリ側で採用状況を計装するのが確実です。expo-updates は、いま動いているバンドルの素性を実行時に返してくれます。
import * as Updates from "expo-updates" ;
export function reportUpdateState () {
// 起動時に走らせる。どのバンドルで動いているかを可観測にする
const state = {
updateId: Updates.updateId ?? "embedded" , // 埋め込みバンドルなら null
channel: Updates.channel ?? "unknown" ,
runtimeVersion: Updates.runtimeVersion ?? "unknown" ,
isEmbedded: Updates.updateId == null ,
createdAt: Updates.createdAt?. toISOString () ?? null ,
};
// 自前の計測基盤や Amplitude などへ送る
analytics. track ( "app_boot_update_state" , state);
return state;
}
このイベントを集計すると、「最新の updateId で動いている端末の割合」が見えるようになります。配信から数時間たっても embedded(埋め込みバンドル)の比率が下がらないなら、それは更新が採用されていない明確な兆候です。私はこの採用率を配信のたびに確認するようにしてから、「直したのに直っていない」を配信前に察知できるようになりました。
なぜ実行時に確認する必要があるのか。eas update の成功はサーバー側の事実であって、端末側の事実ではないからです。両者の差を埋めるのが、この起動時イベントの役割です。
即時反映が必要なら、手動チェックでユーザーを待たせない
既定の checkAutomatically: "ON_LOAD" は、起動時に更新を確認し、ダウンロードした更新は「次回起動」で適用します。つまり緊急修正でも、ユーザーが一度アプリを閉じて開き直すまでは旧バンドルのままです。決済が壊れているような場面、たとえば Stripe や RevenueCat を通した課金導線が落ちているときには、この一拍が許容できないことがあります。
import * as Updates from "expo-updates" ;
// 緊急度の高い修正のときだけ、その場で取得・適用する
export async function applyCriticalUpdateIfAny () {
if (__DEV__) return ; // 開発ビルドでは OTA は動かない
try {
const result = await Updates. checkForUpdateAsync ();
if ( ! result.isAvailable) return ;
await Updates. fetchUpdateAsync ();
// ユーザーに一言ことわってから再起動するのが親切です
await Updates. reloadAsync ();
} catch (e) {
// 失敗しても旧バンドルで動き続けるので、握りつぶさず記録だけする
analytics. track ( "ota_force_apply_failed" , { message: String (e) });
}
}
ここで大切なのは、この強制適用を全更新に対して常時走らせないことです。起動のたびに reloadAsync を呼ぶと、ユーザーの操作が中断されます。私は「緊急修正フラグの立った更新のときだけ」この経路を通すようにしています。日常的な更新は既定の次回起動反映に任せ、画面のちらつきや操作の中断を避ける、という使い分けです。
ロールバックは一種類ではない。事故の種類で戻し方を変える
「悪い更新を戻す」と一口に言っても、状況によって正しい手段が変わります。ここを取り違えると、戻したつもりで戻っていない、という二次災害になります。
事故の種類 正しい戻し方 理由
直前の OTA で不具合が出た 一つ前の更新を republish する 既知の良い状態へ最短で戻せる
チャンネルの参照ブランチを切り替えたい channel:edit で安定ブランチへ向ける配信元そのものを差し替える
OTA 全体が信用できない embedded(埋め込みバンドル)へ戻す ストアに出した既知のバイナリへ退避する
最も多用するのは、一つ前の良い更新の republish です。
# 直前の正常な更新 ID を選んで再配信する
eas update:list --channel production --json --non-interactive \
| jq '.[1] | {id, message}' # [0] が問題の更新、[1] が一つ前
eas update:republish --channel production --group GOOD_GROUP_ID \
--message "ロールバック: 決済不具合の更新を取り消し"
一方、OTA の仕組みそのものを一旦信用しないと決めたときは、埋め込みバンドルへ戻すロールバックが有効です。これは「新しい JavaScript を送らない」のではなく「ストアに出した時点のバイナリへ明示的に戻す」指示で、端末を確実に既知の状態へ引き戻せます。
# ストア審査済みの埋め込みバンドルへ全員を戻す
eas update:rollback --channel production \
--message "緊急: 埋め込みバンドルへ退避"
選択の基準は、原因の所在です。問題が特定の更新にあるなら republish で十分です。OTA 経路そのものや runtimeVersion の取り違えが疑わしいなら、審査済みバイナリへ退避する方が安全です。私は「原因が更新内容か、配信機構か」を最初に分けて考えるようにしてから、戻し方で迷うことが減りました。
一気に全員へ配るのをやめる
OTA は全ユーザーへ即座に届く力がある分、悪い更新も即座に全員へ届きます。これを和らげるのが段階配信です。まず一部にだけ配り、採用率と異常がないかを確認してから、全体へ広げます。
# まず 10% のユーザーにだけロールアウトする
eas update --channel production --message "プロフィール改修" \
--rollout-percentage 10
# 採用率と crash-free をしばらく観察し、問題なければ全体へ
eas update:edit --rollout-percentage 100
段階配信の判断材料になるのが、前述の起動時イベントと、クラッシュ計測です。10% に配った直後、その更新で動いている端末群の crash-free 率が母集団より明確に低ければ、100% へ広げる前に止められます。私はこの「広げる前に観察する一拍」を入れるようにしてから、全体障害になる前に取り消せた事例が何度かありました。OTA の速さは、悪い更新に対しては牙にもなります。段階配信は、その牙を鈍らせるための保険です。
配信前に通る、自分用のチェック
最後に、私が配信の直前に必ず通している確認を共有します。特別なものではありませんが、これを飛ばしたときに限って事故が起きました。
まず、配信しようとしている更新の runtimeVersion が、ストアに出ている現行ビルドの runtimeVersion と一致しているか。次に、緊急修正なら段階配信ではなく即時反映の経路を通すか、逆に通常更新で reloadAsync を誤って常時呼んでいないか。そして、もし戻すことになったら republish と embedded ロールバックのどちらが妥当かを、配信前に決めておくこと。この三点を口に出して確認するだけで、配信後の不安はかなり小さくなります。
OTA は、個人開発者にとって審査の壁を取り払ってくれる頼もしい仕組みです。同時に、成功表示と実際の反映のあいだに静かな隙間を持っています。その隙間を数字で見えるようにしておくことが、夜中のサポートメールを減らす一番の近道だと、運用を続けながら感じています。
同じように OTA の「届かない」に悩んでいる方の切り分けが、少しでも速くなれば幸いです。