OTA(無線)アップデートで一番怖いのは、配信ボタンを押した瞬間に全ユーザーの端末が新バンドルを取りに行く、という挙動です。私が累計5,000万ダウンロードのアプリ群を運用していて、もっとも肝を冷やしたのも、まさにこの「即時・全量配信」で壊れたJavaScriptバンドルが数万台に同時に届いてしまった夜でした。アプリはストア審査を通さずに直せるのがOTAの利点ですが、裏を返せば、壊れたものも審査を通さずに一瞬で広がります。
EAS Update(Expoの無線更新基盤)はとても優秀ですが、デフォルトの使い方は「ブランチにpublishしたら、そのチャンネルを見ている端末が次回起動時に取得する」というシンプルなものです。これは小規模なうちは十分でも、ユーザー数が増えると「いきなり全員」がリスクそのものになります。即時全量配信を、5%→25%→100%の段階配信に作り替え、さらに壊れたバンドルを掴んだ端末を自動で前バージョンへ戻すところまでを、実際に運用しているコードと数値で共有します。
なぜ「チャンネル=環境」「ブランチ=中身」で分けるのか
EAS Updateには channel(チャンネル)と branch(ブランチ)という二つの概念があり、ここの設計を最初に間違えると後で必ず破綻します。私の整理はシンプルで、**channel はビルドに焼き込む「環境ラベル」、branch は実際に配信する「コードの中身」**です。ビルド(ネイティブバイナリ)は channel を見にいき、channel は任意の branch に向け替えられます。この間接参照こそが段階配信とロールバックの土台になります。
// eas.json — production を一段の channel ではなく、配信レーンとして設計する
{
"cli" : { "version" : ">= 12.0.0" },
"build" : {
"production" : {
"channel" : "production" ,
"autoIncrement" : true
},
"production-canary" : {
"extends" : "production" ,
"channel" : "production-canary"
}
},
"submit" : { "production" : {} }
}
ポイントは、本番ビルドに production チャンネルを焼き込みつつ、配信時には production チャンネルを production-v1.4.0 のようなバージョン付きブランチ に向けることです。こうしておくと、巻き戻したいときに「チャンネルの向き先を前のブランチに戻す」だけで全端末が次回起動時に旧バンドルへ戻ります。新しいバンドルを再publishして上書きするのではなく、向き先を変える——これが事故対応の速度を決めます。
段階配信の手順(そのまま回せるチェックリスト)
段階配信は、頭の中だけで運用すると必ずどこかで「今何%だったか」を見失います。私は次の手順をスクリプトとチェックリストに固定し、迷いをなくしました。
新バンドルを誰にも見せないバージョン付きブランチ に publish する。
production チャンネルを、そのブランチへ 5% だけ向ける。
配信から6時間 、新旧のクラッシュ率差を最優先で監視する。
24時間 経過時点で adoption 率を確認し、条件を満たせば 25% へ昇格する。
25% を 48時間 維持して問題なければ 100% へ昇格する。
100% 到達後も 24時間 は監視を緩めない。
#!/usr/bin/env bash
# scripts/staged-publish.sh — 段階配信を1コマンドに畳む
set -euo pipefail
BRANCH = "production-$( date +%Y%m%d-%H%M)"
MESSAGE = " ${1 :? commit message required } "
# 1. 新ブランチに publish(まだ誰にも見せない)
eas update --branch " $BRANCH " --message " $MESSAGE " --non-interactive
# 2. production チャンネルで 5% だけ新ブランチに向ける
eas channel:edit production \
--branch " $BRANCH " \
--rollout-percentage 5 \
--non-interactive
echo "✅ $BRANCH を 5% に配信しました。"
echo " 24時間後に scripts/promote.sh $BRANCH 25 を実行してください。"
#!/usr/bin/env bash
# scripts/promote.sh — 様子を見て昇格する。引数で % を指定
set -euo pipefail
BRANCH = " ${1 :? branch required } "
PCT = " ${2 :? percentage required } "
eas channel:edit production \
--branch " $BRANCH " \
--rollout-percentage " $PCT " \
--non-interactive
echo "✅ $BRANCH を ${ PCT }% に昇格しました。"
私が昇格の前に必ず見る数字は二つです。ひとつはadoption率 (その新バンドルを実際に取得・適用した端末の割合)。配信から24時間で、ロールアウト対象の70%以上が新バンドルを掴んでいなければ、配信経路かバンドルサイズに問題があると見ます。もうひとつは新旧のクラッシュ率の差 で、新バンドル側のクラッシュ率が旧バンドルより20%以上悪化していたら昇格しません。5,000万DL規模だと5%でも数十万台あり、統計的に十分な母数が24時間で揃うため、この粒度で判断できます。
壊れたバンドルを掴んだ端末を、自分で前に戻させる
段階配信をしても、5%の端末が起動直後にクラッシュするバンドルを掴むことはあります。サーバー側で巻き戻しても、すでに壊れたバンドルを保存した端末は、次の正常バンドルを取得するまでに起動を繰り返してクラッシュし続けます。そこで、起動時に「前回の起動が正常に完了したか」を記録し、起動直後クラッシュを検知したら自前で旧バンドルへロールバックする ヘルスチェックを入れます。
// src/lib/updateHealthGuard.ts
import * as Updates from 'expo-updates' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
const PENDING_KEY = 'update.pendingVerify' ;
const FAIL_COUNT_KEY = 'update.bootFailCount' ;
const MAX_BOOT_FAILS = 2 ;
// アプリ起動の最初期(描画前)に呼ぶ
export async function guardOnBoot () : Promise < void > {
if (__DEV__ || ! Updates.isEnabled) return ;
const currentId = Updates.updateId ?? 'embedded' ;
const pending = await AsyncStorage. getItem ( PENDING_KEY );
// このバンドルは「検証待ち」だった。つまり前回起動できず、また同じものを掴んでいる
if (pending === currentId) {
const fails = Number ( await AsyncStorage. getItem ( FAIL_COUNT_KEY ) ?? '0' ) + 1 ;
await AsyncStorage. setItem ( FAIL_COUNT_KEY , String (fails));
if (fails >= MAX_BOOT_FAILS ) {
// 連続で起動に失敗 → 埋め込みバンドル(ストア版)へ強制ロールバック
await AsyncStorage. multiRemove ([ PENDING_KEY , FAIL_COUNT_KEY ]);
await Updates. fetchUpdateAsync (). catch (() => {});
await Updates. reloadAsync (); // 次回サーバーが正常ブランチを返せばそれを掴む
return ;
}
} else {
// 新しいバンドルを掴んだ → 「検証待ち」として記録
await AsyncStorage. setItem ( PENDING_KEY , currentId);
await AsyncStorage. setItem ( FAIL_COUNT_KEY , '0' );
}
}
// 重要な初期化(ナビゲーション構築・初回API応答)まで到達したら呼ぶ
export async function markBootHealthy () : Promise < void > {
await AsyncStorage. multiRemove ([ PENDING_KEY , FAIL_COUNT_KEY ]);
}
// App.tsx — guard を最上流に、healthy マークを「描画に成功した地点」に置く
import { useEffect } from 'react' ;
import { guardOnBoot, markBootHealthy } from './src/lib/updateHealthGuard' ;
export default function App () {
useEffect (() => {
guardOnBoot (); // 描画前のロールバック判定
}, []);
return (
< AppNavigator
onReady = {() => {
// ナビゲーションが実際に立ち上がった = このバンドルは生きている
markBootHealthy ();
}}
/>
);
}
この仕組みの肝は、markBootHealthy() を呼ぶ場所を「アプリが実用的に動き始めた地点」に置くことです。App のマウント直後ではなく、ナビゲーションの onReady や最初の画面のデータ取得成功後に置くことで、「JSは起動したがレンダリングで即死する」タイプのバグも捕捉できます。
公式ドキュメントに書かれていない運用知見
expo-updates の公式ドキュメントはAPI自体をよく説明していますが、運用してみて初めて分かった、ドキュメントには書かれていない勘所がいくつかあります。
第一に、「正常起動の定義」をどこに置くかは運用で決めるしかない 部分です。私は実害が出てから、markBootHealthy() の呼び出し位置を「マウント直後」から「描画完了地点」まで深くしました。ここが浅いと、JSは起動するのに画面描画で即死するバグを取りこぼします。
第二に、rollout の % を上げる操作と、ブランチを切り替える操作を混同しない ことです。同じブランチの % を上げるのが「昇格」、別ブランチへ向け替えるのが「巻き戻し」です。事故時に慌てて新ブランチを再publishすると、壊れたものに上書きするだけで状況が悪化します。巻き戻しは必ず「向き先を旧ブランチへ戻す」で行います。
第三に、adoption率は端末のアクティブ率に強く依存する 点です。デイリーアクティブが低いアプリでは、24時間で70%という閾値は厳しすぎます。私は週次しか開かれない系のアプリでは、判断窓を72時間に延ばしています。
配信後72時間の監視と推奨アクション
段階配信は「出して終わり」ではなく、各段で次の判断をするための監視がセットです。私が実際に使っている閾値と、状況別の推奨アクションを共有します。
配信〜6時間: 新バンドルのクラッシュ率を最優先で監視します。旧バンドル比で +20% を超えたら、昇格せず即、チャンネルの向き先を旧ブランチへ戻す ことを推奨します。
6〜24時間: adoption率を確認します。ロールアウト対象の70%未満なら、バンドルサイズ(差分が大きすぎないか)と配信CDNを疑うのが定石です。
24時間〜: クラッシュ率が同等以下かつadoption率70%以上なら、5%→25%へ昇格します。
25%で同条件を48時間維持: 100%へ昇格します。
100%到達後も24時間は監視を緩めません。全量になって初めて顕在化する、特定端末・特定OSバージョン依存のクラッシュがあるためです。
数字を一つだけ挙げると、私の運用では「最初の6時間でクラッシュ率が悪化しなかったロールアウトは、その後に巻き戻しが必要になった割合が1割以下」でした。逆に言えば、最初の6時間の監視に最も人手を割く価値があり、それ以降は自動化された閾値チェックで十分回ります。
どこから始めるか
すでにEAS Updateで全量配信しているなら、最初にやるべきは「チャンネルをバージョン付きブランチに向け替える」運用への移行だけです。コードの変更はゼロで、eas channel:edit の運用ルールを変えるだけで、巻き戻しが「向き先を戻す1コマンド」になります。段階配信の --rollout-percentage とヘルスチェックは、その次の段で足していけば十分です。一度に全部を入れようとせず、まず「即時全量をやめる」一点だけ今日から変えてみてください。
同じように個人で大規模アプリを運用されている方の、夜の事故が一つでも減れば嬉しいです。お読みいただきありがとうございました。