メインコンテンツへスキップ

React Native Expo OTA Updates

自作の Expo アップデートサーバーを Codemagic Patch に置き換える

ネイティブプロジェクトごと管理している本物の Expo アプリを、expo-updates から Codemagic Patch へ移行しました。ローカルのスタック、移行作業、自分で作って OTA で直したバグ、そしてリリースが実際にクラッシュしたときのロールバックの様子まで。

開示。 この移行と記事の執筆には Codemagic から報酬を受けています。Codemagic のレビューは事実関係の確認だけで、論調には及ばず、先方の要望で書き換えた箇所はありません。問題にぶつかって報告した箇所はそう書いていますし、あなたが読む時点で直っているものは、直ったものとして書いてあります。

2月に、Django と Tigris で Expo のアップデートサーバーを自作した話 を書きました。あれは今も動いています。私が運営しているコミュニティアプリの maru は、それ以来すべての OTA 修正をあのサーバー経由で、両プラットフォームに、月1ドルほどで配信してきました。

それは同時に、部品を全部自分で抱えているということでもあります。Django のモデル2つとマイグレーション、430行の viewset、400行の管理コマンド、210行のパブリッシュスクリプト、インポート用のシークレット、そして壊れたアップデートを一つずつ経験して覚えたハマりどころの数々。今回の計画を立てようと腰を据えたとき、スクリプトが書いた日からずっと、本番へパブリッシュするたびに “Sent 0 push notifications” と出力していたことに気づきました。このサーバーが一度も返したことのないフィールドを読んでいたのです。誰も気づいていませんでした。誰よりも私が。自前で運用するというのは、だいたいこういうことになります。

そこで Codemagic から、CodePush をオープンソースでセルフホストできる形にした Patch を試してみないかと声をかけられたとき、条件を一つ付けて引き受けました。デモではなく本物のアプリでやること。本物の署名、本物のリリース手順、手で編集した箇所のあるコミット済みのネイティブプロジェクト、その全部です。EAS Update から移行する人の多くが動かしているのは、まっさらなテンプレートではなく、そういうアプリでしょう。

その顛末を、粗い部分もそのままにして書きます。

Patch とは何か
#

Patch は、自分で動かすサーバーと、アプリに組み込む SDK でできています。サーバー側は API、バックグラウンドワーカー、Postgres、S3 互換のオブジェクトストレージ、Web ダッシュボードで、Docker Compose のスタックとして配布されています。クライアント側は @codemagic/react-native-patch という iOS と Android 向けの TurboModule で、Expo の config plugin が付いています。それに加えて、JavaScript をバンドルしてパブリッシュする CLI の cmpatch があります。

ソースを読んで、設計上の判断が2つ目に留まりました。

1つ目は、アプリが API に「アップデートはあるか」と一度も尋ねないことです。リリースがパブリッシュされるとワーカーが素の JSON マニフェストをオブジェクトストレージに書き、アプリはそれを取りに行くだけ。アップデートの確認はストレージか CDN に対する静的な GET が2回で、API が受け取るのはメトリクスだけです。私の Django エンドポイントは、アプリを起動するたびにリクエスト経路の上に乗っていました。こちらではそれが起こり得ません。

2つ目は、すべてのリリースが対象とするバイナリのバージョンを厳密に指定することと、CLI がネイティブプロジェクトのフィンガープリントを計算して、ネイティブコードが一致しないバイナリへ JavaScript をパブリッシュするのを拒むことです。私のサーバーにあったのは runtime_version カラムと、たくさんの「大丈夫だろう」でした。

まずは手元のノートパソコンで
#

ドキュメントはローカルの評価用スタックから始めるように案内していて、実際そこから始めるのが正解です。

npm install -g @codemagic/patch-cli
cmpatch selfhost local-eval up

CLI のインストールは2秒でした。スタックのほうは初回に3分14秒かかり、大半はサーバーとダッシュボードのイメージをローカルでビルドしている時間です。非対話モードだとその間何も出力しないので、ハングしているようにしか見えません。そのあと、healthy なコンテナが4つ、すべて localhost に束縛された状態で現れました。8080 にダッシュボード、3000 に API、Postgres、そして 9100 に MinIO です。私自身のスタックがすでに使っているポートは、きちんと避けてくれました。

このモードではサインインが無効になっていて、外部に公開するなという警告がすべてのページに出ます。

ローカル評価用のサインインページ。メールアドレスが入力済みで、認証が無効だという警告が出ている
ローカル評価モードでは GitHub サインインの代わりに、入力済みのメールアドレスが使われます。

スタックには Example Data というアプリがあらかじめ入っています。何かをパブリッシュする前に一度眺めておく価値があります。ダッシュボードが何のためにあるのかが分かるからです。次の2枚のスクリーンショットの数字はそのシードデータで、実際のユーザーではありません。

シード済みの Example Data アプリのリリース履歴。10% のカナリアリリースと、リリースごとの成功数・失敗数が並んでいる
シードされたデモデータ: 10% のカナリア、対象バージョン、リリースごとの成功数と失敗数を含むリリース履歴。
シード済みアプリのデプロイメントメトリクス: バージョン分布、採用の推移、アップデートの結果
シードされたデモデータ: 端末が実際に動かしているリリースと、インストールの結果。

比較のために言うと、私のダッシュボードは Django admin のアップデート行の一覧に「is active」のチェックボックスが付いたものでした。実際に何台の端末がアップデートを取り込んだのか、まったく分かりませんでした。

CLI のログインは PKCE と localhost へのコールバックを使ったまっとうなブラウザフローで、承認ページにはどのアカウントが要求しているかと、コードが1分ほどで失効することが書いてあります。

CLI のサインインを承認するか尋ねるダッシュボード
cmpatch login はダッシュボードにターミナルの承認を求めます。

本物のアプリに向ける
#

Patch はプラットフォームごとに1アプリを求め、それぞれに Staging と Production のデプロイメントが作られます。maru-ios はダッシュボードで、maru-androidcmpatch app create で作りました。主に両方を見ておきたかったからです。アプリを作ったあとに出るダイアログは、移行にあたってこの製品でいちばん役に立つ画面です。両方のデプロイメントキー、SDK が必要とする2つの URL、そしてデプロイメントキーは秘密情報ではないという注記が載っています。

アプリ作成後のダイアログ。Staging と Production のデプロイメントキーと SDK の URL が表示されている
SDK に必要なものが、1画面に。

小さなことが2つ。cmpatch init はすでに存在するアプリにプロジェクトを紐付けるコマンドなので、移行ガイドがどう匂わせていようと、最初のコマンドにはなりません。それから、ダイアログは API の URL を http://localhost:8080 と示すのに、ドキュメントと CLI は 3000 と言います。どちらのポートも両方を配信しているのでどちらでも動きますが、首をかしげることにはなるでしょう。

次に、手を付けていないアプリに対して cmpatch doctor を走らせたところ、3秒で本物の問題を見つけてきました。iOS のバイナリバージョンを 2.0.0 と報告したのです。アプリは 2.5.1 です。

CLI は ios/ 以下のすべての Info.plist を探し、パスに “test” を含むものがなければ、アルファベット順で最初のものを取ります。maru には WidgetKit の extension があり、ios/ExpoWidgetsTarget/Info.plistios/maru/Info.plist より前に並びます。extension の plist が 2.0.0 のまま古くなっていたのは私のバグですが、その影響はたちが悪い。自動検出に任せると、iOS のリリースがすべて、どの端末も動かしていないバージョンを対象にしてしまい、しかも何もエラーになりません。アップデートが単に永遠に届かないだけ。まさに Tigris の記事で警告した失敗です。Android は versionName を正しく読みました。回避策は --target-binary-version--plist-file を渡すことで、私のパブリッシュスクリプトは今、app.config.js と両方のネイティブプロジェクトが一致していることを確認したうえで、バージョンを明示的に渡しています。あとになって同じバグのせいで、doctor 自身のマニフェスト確認が、正常に動いているデプロイメントに対して失敗しました。バージョン 2.0.0 のマニフェストを探しに行ったからです。古い plist を直したら、doctor は24項目すべてを通過しました。

JavaScript 側
#

expo-updates を外して SDK を足すのは2行の変更です。アプリがアップデートに対してやっていたことを置き換えるほうは、もう少し考える必要がありました。

maru は起動時と、フォアグラウンドに戻るたびにアップデートを確認します。ただし最短でも15分おきです。見つかればダウンロードして再起動を提案し、断られれば次のコールドスタートで適用します。設定画面には、動作中のバンドルを表示する手動チェックもあり、エラーレポーターはすべてのレポートに、それが来たアップデートのタグを付けます。

SDK のソースから最初に学んだのは、インポート時に TurboModuleRegistry.getEnforcing でネイティブモジュールを探しに行き、モジュールがなければ例外を投げるということです。maru は Web ビルドも出荷しているので、トップレベルでインポートすると Web アプリが落ちます。そこで、SDK を遅延ロードする小さなモジュールを1つ用意し、すべてそこを通すようにしました。何も返さない .web.ts の双子を置いてあるので、SDK は Web バンドルから完全に外れます。

// services/ota.ts
export function patchSdk(): PatchSdk | null {
  if (sdk !== undefined) return sdk;
  if (Platform.OS === "web") return (sdk = null);
  try {
    sdk = require("@codemagic/react-native-patch") as PatchSdk;
  } catch {
    sdk = null;
  }
  return sdk;
}

自動チェック自体はほぼ sync() です。決して例外を投げず、ステータスに解決します。

const status = await patch.sync({
  installMode: "ON_NEXT_RESTART",
  mandatoryInstallMode: "ON_NEXT_RESUME",
});
if (status === "update-installed") offerRestart();

SDK の必須リリースのデフォルトは IMMEDIATE で、ユーザーがどこにいようと JavaScript をリロードします。私はアプリが次にバックグラウンドから戻ってきたときに適用するよう変えました。書きかけの投稿が消えるのは、たいていの修正の効き目より重いバグだからです。それから、InstallMode はドキュメントの例が示唆する enum オブジェクトではなく、文字列の union 型です。

ネイティブ側、ios/ と android/ をコミットしている場合
#

移行ガイドは、config plugin を追加したあとで npx expo prebuild --clean を実行するように言っています。別のチェックアウトで実行してみたところ、チュートリアル画像6枚、Android のネットワークセキュリティ設定、iOS のプライバシーマニフェスト、Podfile.lock が消え、build.gradle の Detox 向けの修正と Info.plist の数百行が元に戻りました。--clean なしでも、Expo はどのみち両方のディレクトリを空にしました。

これは Patch のせいではありません。maru は managed な Expo アプリとして始まり、ネイティブのカスタマイズが増えていったので、ios/android/ はコミットされていて、EAS はそれをそのままビルドします。ただ、そういうアプリはいくらでもあるのに、ガイドは警告してくれません。

plugin が実際に変える内容は、蓋を開けてみれば手作業で済む程度でした。iOS では AppDelegate.swift に import が1つと1行。

return CodemagicPatch.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")

Android では MainApplication.kt に import が1つと引数が1つ(これは React Native 0.82 以降の形で、それより古いバージョンでは代わりに getJSBundleFile() をオーバーライドします)。

ExpoReactHostFactory.getDefaultReactHost(
  jsBundleFilePath = CodemagicPatch.getJSBundleFile(applicationContext),
  // ...

加えてプラットフォームごとに設定値が3つと、古い expo-updates の設定の削除。両プロジェクト合わせて13行の追加と20行の削除です。

plugin はこれらの設定値を prebuild 時にリテラルとして書き込みます。コミット済みのネイティブプロジェクトでは、ビルドごとにデプロイメントが1つハードコードされることになります。私は同じプロジェクトから、プレビュービルドは Staging に、ストア向けビルドは Production に向けたかったので、値はビルド環境から取るようにしました。Info.plist では Xcode がビルド設定を展開してくれます。

<key>CodemagicPatchDeploymentKey</key>
<string>$(PATCH_IOS_DEPLOYMENT_KEY)</string>

android/app/build.gradle ではこうです。

def patchEnv = { name -> System.getenv(name) ?: (findProperty(name) ?: "") }
resValue "string", "CodemagicPatchDeploymentKey", patchEnv("PATCH_ANDROID_DEPLOYMENT_KEY")

キーは eas.json の各ビルドプロファイルに、EXPO_PUBLIC_ の値と並べて置きます。値が空だと SDK は黙って何もしません。ローカルビルドならそれで構いませんが、ストア向けビルドでは大惨事なので、app.config.js は preview か production の EAS ビルドでキーが欠けていたり、localhost を向いたままだったりすると例外を投げるようにしました。

最初のアップデート
#

最初のリリースでは、サインインしなくても見えるオンボーディングの見出しを変えて、パブリッシュしました。

./scripts/publish-patch-update.sh ios --local --notes "Onboarding headline over the air"

30秒かかり、大半は Metro と Hermes です。サーバーのワーカーは、アップロード完了の0.6秒後にマニフェストをパブリッシュしました。アップデートは 7.3 MB の圧縮 tarball で、以前のエクスポートが生成していた 11 MB の Hermes バンドルとアセットに比べて小さくなっています。

ここで間違えてはいけないことが1つ。JavaScript バンドルはビルド時に EXPO_PUBLIC_ の値をインライン展開するので、アップデートは着地先のバイナリと同じ値でビルドしなければなりません。さもないと、間違ったバックエンドに話しかける JavaScript を出荷することになります。私のスクリプトは、バイナリをビルドしたのと同じ eas.json のプロファイルから値を読み込みます。

それからアプリを再起動しました。端末のログでは、アップデートの確認全体がオブジェクトストレージへの2リクエスト、meta.json2.5.1/manifest.json として現れました。SDK は起動の3秒後に Downloaded イベントを書き、アップデートをステージングし、アプリが再起動を提案しました。

再起動、sync() が update-installed を返して出るアプリ自身のプロンプト、そして OTA で届いたリリース v1。

バグは私のものだった
#

そこでダッシュボードを見ました。シミュレーター1台がリリース v1 をインストールしたのに、成功2件、アクティブユーザー2人と表示されています。

Staging デプロイメントが1台の端末に対してアクティブユーザー2人、成功2件を表示している
シミュレーター1台が、2回数えられています。

SDK は状態と未送信のメトリクスイベントをアプリのコンテナ内にファイルとして持っているので、追跡は簡単でした。タイムスタンプが同一で id が異なる Success イベントが2つありました。私のフックはマウント時に notifyAppReady() で動作中のバンドルを確定させ、その直後に sync() を呼んでいて、sync() は自分でも notifyAppReady() を呼びます。2つの呼び出しが重なり、どちらもリリースを pending と見なし、どちらもイベントを記録した。サーバーはイベント id で重複排除しますが、id が違うので両方数えられ、その重複は永久に残ります。

私の側の修正は、起動ごとに確認処理を1つだけ共有し、ほかはすべてそれを待つようにすることでした。SDK 自身が重なった呼び出しを防ぐこともできるはずで、そう提案してあります。

気持ちよかったのは、この修正の出荷です。JavaScript だけの変更なのでリリース v2 として出せて、v1 の端末にはバイナリ差分を送れるので、v2 は 7.3 MB のバンドルではなく 543 KB のパッチ、サイズにして約7% でダウンロードされました。再起動後: ダウンロード1件、インストール1件、成功1件、アクティブ端末1台。

リリース履歴。v1 は水増しされた数字、v2 は正しい数字
v1 の重複は残り、v2 は正しく数えられます。

わざと壊す
#

ロールバックは私がいちばん気にしていた機能です。以前の構成には端末側のロールバックがまったくありませんでした。悪いアップデートを出せば、私が気づいてチェックボックスを外すまで、起動のたびにクラッシュし続けたはずです。

そこで、最初の描画の前に例外を投げる v3 をリリースしました。つまり notifyAppReady() を決して呼べないリリースです。アプリはそれをダウンロードし、再起動を提案し、ホーム画面へ落ちました。

v3 が起動時にクラッシュ。

次の起動では再び v3 が立ち上がり、再びクラッシュしました。これには驚きました。ドキュメントには、notifyAppReady() の前にクラッシュしたバンドルはロールバックされると書いてあるからです。ソースが説明してくれました。SDK は未確認の起動を3回まで許してから、クラッシュだと判断します。未確認の起動1回は、JavaScript が動く前に iOS が健全なプロセスを殺しただけかもしれないからです。4回目の起動で v2 に戻ってきました。

4回目の起動: 誰もサーバーに触らずに v2 へロールバック。

そして端末は自分から失敗を crash_rollback として報告し、ダッシュボードは v3 に対してそれを表示しました。理にかなった設計ですが、それが何を意味するかははっきり言っておく価値があります。悪いリリースは、ユーザー一人あたり3回のクラッシュ起動を代償にしてから回復するのであって、ドキュメントにはその回数が書かれていません。だからこそ、まず Staging、次に小さな Production ロールアウトなのです。

ほかの全員に対して悪いリリースを止めるのはコマンド1つで、1秒かかりませんでした。

cmpatch release rollback --app maru-ios --deployment Staging

履歴を書き換えることはしません。前のリリースを新しいリリース(v4、v2 のロールバックとしてマーク)としてもう一度パブリッシュするので、クラッシュしたリリースは失敗数とともに一覧に残ります。これが正直な記録です。ダッシュボードからも、ダイアログで同じことができます。

ダッシュボードからのロールバック: v2 が v4 として戻り、v3 は一覧に残ります。
ロールバック後のリリース履歴。v4 がロールバックとしてマークされ、v3 に失敗1件が表示されている
v4 がロールバック。v3 には失敗が残ります。

Android
#

Android には驚きが2つあり、どちらも Patch のせいではありません。SDK 向けに足した R8 の keep ルールは app.config.js に置いてあって、これは prebuild を通してしかネイティブプロジェクトに届きません。つまり最初のリリースビルドは、keep ルールなしで SDK が R8 を生き延びるかどうかを、意図せず試すことになりました。生き延びます。R8 はクラスをすべてリネームしましたが、アップデートのダウンロード、インストール、ロールバックは問題なく動きました。もう1つはエミュレーターで、コールドブートしたばかりで Gradle がまだメモリを握っていたせいで極端に遅く、最初のテストは、SDK がすでに始めていたダウンロードを終える45秒前に諦めてしまいました。

リリース v1 は適用され、成功1件、アクティブ端末1台。クラッシュする v2 は、同じ3回の起動予算のあとにロールバックされました。

OTA で配信された見出しを表示している Android アプリ
Android: ロールバック後、再び v1 に。

ハッピーパスの先
#

アップデートが届いてロールバックされることが核ではありますが、チームが日々使うのはそれ以外の機能なので、一つずつ試していきました。

段階的ロールアウトは決定論的で、これは気に入りました。md5(deviceId + "-" + releaseLabel) の先頭8桁の16進数を 100 で割った余りがロールアウトのパーセンテージ未満なら対象になるので、同じ端末は同じリリースに対してずっと対象か対象外のままです。私のシミュレーターは次のリリースでバケット 91 に入ると計算できたので、25% でパブリッシュしたところ、シミュレーターには正しく何も来ませんでした。cmpatch release patch で 95% に上げると(1秒かかりません)、次の起動で更新されました。

部分ロールアウトを、再パブリッシュせずに 25% から 95% へ。

すると次のリリースが 409 で失敗しました。“deployment has an active rollout below 100 percent”。デプロイメントごとに部分ロールアウトは1つしか許されないので、ほかの何かを出す前にカナリアを完了するか、無効化するか、ロールバックする必要があります。妥当ですし、ドキュメントにも書いてありますが、マージのたびにパブリッシュするパイプラインは、カナリアの途中でここにぶつかります。

必須リリースは SDK の mandatoryInstallMode を使います。私は次のレジューム時に適用する設定にしていたので、アプリはアップデートをダウンロードしてプロンプトを表示し、プロンプトを無視してアプリをバックグラウンドに送ってから戻すと、新しいバージョンがただそこにありました。

リリースの無効化は、提供をやめる以上のことをします。最新のリリースを無効化した瞬間、マニフェストはまだ有効な中でいちばん新しいリリースを指すようになり、無効化したリリースにいる端末には、そこへ戻るパッチが提供されました。あるバイナリバージョンのリリースをすべて無効化すると、マニフェストのターゲットは null になり、SDK はアプリを出荷時のバンドルに戻します。私のアップデート用フックは最初このケースでプロンプトを出していなかったので、今は出すようにしてあります。

Staging から Production への昇格は1秒かからず、テストしたのとまったく同じパッケージを、同じハッシュとノートのまま再利用しました。ビルドし直すのではありません。ロールアウトのパーセンテージも受け付けるので、「Production の 10% に昇格」がコマンド1つで済みます。

ビルドとパブリッシュを別々の手順に分けることもできます。cmpatch bundle.cmpatch のアーティファクトを生成し、ダッシュボードの「Bundle upload」はアップロード前にそれを読み取ります。プラットフォーム、対象バージョン、フィンガープリント、署名の状態、サイズ、バンドラー。CI がアーティファクトをビルドし、いつパブリッシュするかは別の誰かが決める、という体制に向いています。

バンドルアップロード: ダッシュボードがアーティファクトを読み取り、続いてフィンガープリントのガードが働きます。

フィンガープリントのガードに本格的にぶつかったのもここです。古いウィジェットの plist を直したあと、サーバーは次のリリースを 409 で拒否しました。ネイティブプロジェクトのフィンガープリントが、2.5.1 に対して記録されていたものと一致しなくなったからです。Android では app.config.js のコメントを編集しただけで十分でした。Expo はアプリ設定をフィンガープリントに含めるからです。ダッシュボードは同じチェックを、両方のフィンガープリントと「Upload anyway」ボタンの付いた警告として表示します。どちらの場合もインストール済みのバイナリのネイティブ部分は何も変わっていないので、上書きが正解でした。ただ実運用では、正直な答えはほぼ必ずバージョンを上げてストア向けにビルドすることで、私のパブリッシュスクリプトはこれが起きたとき、今はまさにそう伝えます。

コード署名は RS256 です。CLI が RSA の秘密鍵でパッケージのハッシュに署名し、SDK がバイナリにコンパイルされた公開鍵でそれを検証します。鍵は base64 の1行として渡すので、私のビルド設定方式にそのまま収まり、両プラットフォームでテストしました。鍵をアプリに入れた状態では、署名なしのリリースはダウンロード前に拒否されました。アプリで「require code signing」を有効にすると、サーバーは署名なしのパブリッシュを、対処法を示しガイドへリンクするエラーで拒否しました。署名済みのリリースはインストールされ、SDK は署名を検証済みとして記録しました。署名済みのものを無効化したときにサーバーがフォールバックした、古い署名なしのリリースも拒否しています。注意点は、公開鍵が最初に出荷するバイナリに入っていなければならないことです。アップデートで後から足すことはできません。

リリース詳細。署名が Signed, sha256 と表示されている
署名済みのリリース。

チームと CI もカバーされています。メンバーにはチーム全体で共通の4つのロール(owner、admin、developer、viewer)のいずれかが付き、招待は相手が最初にサインインするまで保留されます。パーソナルアクセストークンは空のホームディレクトリでも動き、--token でも環境変数でも渡せて、トークン一覧には最後に使われた日時が出ます。Linux ランナーで同じパブリッシュスクリプトを、シークレットから取り出した署名鍵で走らせる手動の GitHub Actions ワークフローも書きましたが、ホスト済みのサーバーが要るので、まだ実行していません。

今のところ使わないでおくツールが1つ、cmpatch debug ios です。システムログを “OTA” で大文字小文字を区別せずにフィルタしてストリームするので “Rotation” にもマッチしてしまい、20秒のアプリ起動で SpringBoard のメッセージが画面いっぱいに流れ、アプリからは何も出ませんでした。

実際のところ、どれくらい大変か
#

Codemagic から意見を求められたので、できるだけ率直に書きます。

試すのは簡単です。コマンド2つと数分で、本物のサーバー、デモデータ入りのダッシュボード、自分のアプリに向けられる CLI が手に入ります。最初の1時間でいちばん良かった部分だと思います。

SDK の組み込みはそこそこの作業で、難しさの大半は Patch ではなく自分のプロジェクトの側にあります。ネイティブフォルダを生成させたまっさらな Expo アプリなら、本当に plugin と、expo-updates の呼び出しを置き換える数行だけです。maru のように、コミット済みのネイティブプロジェクト、ウィジェット extension、Web ビルド、R8、ビルドプロファイルを抱えたアプリでは、作業は端のほうにありました。prebuild を避けるためにネイティブの変更を手で当て、Staging と Production のビルドが別物になるように設定をビルド時に決め、SDK を Web から遠ざける必要がありました。アップデートの JavaScript がバイナリと同じ環境でビルドされることも保証しなければなりません。理解してしまえばどれも難しくはありませんでしたが、大半はまだドキュメントにありません。

日常の使い勝手は良いです。パブリッシュはコマンド1つと30秒ほど。ロールアウト、昇格、無効化、ロールバックは CLI からでもダッシュボードからでもすべて1秒未満で、エラーは何が悪くて何をすべきかを伝えてくれます。サーバーのガード(フィンガープリントのチェック、一度に1つのカナリア、署名)は厳格で、私が間違ったことをするのを何度も止めてくれました。

本番で運用するのは、より大きなコミットメントです。Postgres とオブジェクトストレージ付きの Docker Compose スタックで、ホスティング、バックアップ、アップグレードは自分でやらなければならず、ドメイン2つと OAuth アプリが必要です。すでにサーバーを運用しているチームなら静かな午後1回分。サーバーを持ちたくないからこそ EAS にいるチームにとっては、SDK の作業よりもここが乗り換えの本当のコストです。

実時間で言うと、12:29 に CLI をインストールし、13:34 にはシミュレーターで最初のアップデートが適用されていました。そのうち30分は、Patch と無関係な理由で止まった Xcode のビルドです。この記事にあることは両プラットフォームとも、ホスト済みのサーバーを除けば午後の半ばまでに終わっていました。

いちばん時間を取られたものを順に挙げると、expo prebuild --clean がコミット済みのネイティブプロジェクトを消してしまい、そのあと全部を手で配線する方法を考えたこと。iOS のバージョン検出のバグで、これは誰も動かしていないバージョンにアップデートを送るところでした。二重に数えられたインストールで、これは自分のせいですが、sync() が自分で notifyAppReady() を呼ぶ以上、誰でも簡単に再現してしまいます。そしてドキュメントにない3回の起動というクラッシュ予算で、テストの途中で驚かされました。その次に来るのが、正しいけれど計画が要るもの。無害なネイティブ周辺の編集のあとに発火するフィンガープリントのガード、デプロイメントごとに一度に1つのカナリア、重ねて実行できないライフサイクルジョブ。最後のはスクリプトが続けていくつか処理をした途端に効いてきます。残りは小さなものでした。無音の初回セットアップ、うるさいデバッグコマンド、コードと合わないドキュメントの例がいくつか。

これらはすべて、詳細とともに Codemagic に書いて渡してあります。

本番へ
#

ここまでの内容はすべて評価用スタックに対して動かしたものです。本番は同じアプリと同じスクリプトを本物のサーバーに向けるだけで、これが maru の計画です。

サーバーを動かすサポート対象の方法は Docker Compose でのインストールです。ポート 80 と 443 が開いた Linux マシン、ドメイン2つ(API とダッシュボード用、ダウンロード用)、そしてサインイン用の GitHub OAuth アプリ。OAuth アプリがないとサーバーは起動しません。あとはインストーラーが TLS まで含めてやってくれます。

scripts/selfhost/install.sh \
  --api-domain updates.example.com \
  --storage-domain storage.updates.example.com \
  --email [email protected] \
  --github-oauth-client-id <id> --github-oauth-client-secret <secret>

データベースとストレージのシークレットを書いた .env.selfhost が生成されるので、パスワードマネージャーに入れておきましょう。

とはいえ同梱のデータベースとストレージを使う必要はなく、それが私のようなスタックにも合う理由です。maru の API はすでに Fly.io で動いていて、ファイルは Tigris にあり、Patch はどちらでも動きます。Compose ファイルの下にあるのは MODE=all(API とリリースワーカーを一緒に)で動くコンテナ1つで、必要なのは Postgres のデータベース、S3 互換ストレージ、ファイル用の公開 HTTPS URL です。つまり、リポジトリの Dockerfile からビルドした Fly アプリ(公開されたイメージはまだないので fly deploy がビルドします)、Fly Postgres のデータベース、Tigris のバケットです。

MODE=all
DATABASE_URL=postgresql://…            # Fly Postgres
STORAGE_ADAPTER=s3
S3_ENDPOINT=https://fly.storage.tigris.dev
S3_REGION=auto
S3_BUCKET=maru-patch
S3_FORCE_PATH_STYLE=true
PUBLIC_BASE_URL=https://maru-patch.fly.storage.tigris.dev/codemagic-patch

注意点が2つ。Compose の外で動かす方法はリポジトリに参考資料として書かれているだけで、この最初のオープンソースリリースではサポート対象の経路ではないので、Fly 上でのアップグレードとバックアップは自己責任です。私もまだその形ではデプロイしていません。これは計画であって、報告ではありません。それから、Fly のゼロスケール の記事を読んだ方は、マシンを最低1台は動かしておいてください。マニフェストはストレージから直接来るので、端末がアップデートの確認で API を待つことはありませんが、そうしないとメトリクスのアップロードとリリースジョブがすべてコールドブートから始まることになります。

Tigris はいちばん楽しみにしている部分です。maru のファイルがすでにある場所ですし、アプリの起動のたびに叩かれる URL の前に CDN を置いてくれます。これをやるならマニフェストのキャッシュには気をつけてください。素の base-URL 配信アダプターでは、Patch は CDN をパージできないためマニフェストを no-cache で配信します。マニフェストが独自の TTL でキャッシュされると、ロールバックしたばかりのリリースを配り続けてしまうからです。

ホスト済みのサーバーでもう一度アプリを作り、新しいキーを eas.jsonpreviewproduction プロファイルに(Staging のキーと Production のキー)入れ、パブリッシュ用のトークンを作ります。

cmpatch token create --name ci

コード署名をするなら、今決めてください。本番用の鍵ペアを生成し、秘密鍵は CI のシークレットとして保管し、公開鍵をビルドプロファイルに入れ、両アプリで「require code signing」を有効にします。Patch 入りの最初のバイナリには新しいバージョン番号も必要です。ストアにすでにあるバイナリは expo-updates を動かしているからです。まず preview プロファイルで TestFlight と Play の内部テスト向けにビルドし、誰かに渡す前に、キーが実際にビルド済みの Info.plist と Android のリソースに入ったかを確認してください。環境変数を手でエクスポートしていたので、ここだけはローカルのテストで検証できていない繋ぎ目です。それから Staging にパブリッシュし、テスター1人につきダウンロード1件と成功1件を確認して、そこで初めてストア向けにビルドします。

Production のリリースは小さく始めて広げます。

npm run publish-update:prod -- --rollout 10
cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage 100

その間、古い Django サーバーは動かしたままにします。まだアップデートしていない 2.5.1 は今もそちらを確認していますし、そのユーザー向けのホットフィックスは引き続き古いスクリプトを通ります。ログでそのリクエストが止まったのを確認できたら、モデル、viewset、コマンド、スクリプト、シークレットをすべて消せます。

Expo の請求書を抱えている人に伝えたいこと
#

サーバーを作るのが大変そうに見えたから EAS Update にお金を払っているのなら、Patch は本物の答えです。サーバーは動かしますが、それは Compose スタックとインストーラーであって、自分でメンテナンスするコードではありません。私が作ったものと比べると、段階的ロールアウト、リリースごとのインストール数と失敗数、バイナリパッチ、フィンガープリントのガード、コード署名、端末側でのクラッシュロールバック、ロールバックボタンが手に入ります。私はどれも作っていませんでした。

代償は、SDK をアプリに入れる必要があるので、誰かに Patch のアップデートが届く前に新しいバイナリが要ること。アップグレードとバックアップを続けるサーバー。リリースが十分ひどければ、ユーザー一人あたり3回のクラッシュ起動。ネイティブプロジェクトをコミットしているなら、いくらかの手作業。それに、まだ若い。今回のバージョンは 0.3.0 で、バージョン検出のバグ、うるさいデバッグコマンド、ドキュメントの穴をいくつか踏み、SDK が防げたはずのバグを自分でも作りました。どれも移行を止めるものではなく、すべてが CLI、ダッシュボード、あるいは端末上の SDK 自身のファイルで見えていました。これは以前の構成には言えなかったことです。