強制アップデートが誰にも出なかった — 制作日誌 #6
サーバー側で「この版より古い人は更新必須」と宣言した。なのに旧バージョンのユーザーには何も出ない。原因は、通信が失敗したときに素通りさせる設計だった。13世代ぶんの旧バージョンに個別配信して塞いだ記録。
自作のiOSアプリで、強制アップデート(古い版を使い続けられなくする仕組み)を用意していた。サーバーが「最低でもこの版以上」という数字を返し、アプリが自分の版と比べて古ければ、更新を促す画面から先へ進めなくする、というよくある作りだ。
新しい版を公開したので、サーバーの数字を引き上げた。ところが、旧版を使っているユーザーには更新画面が一切出ないという報告が来た。しかもこれは2度目で、前の版でも同じことが起きていた。
何が起きていたのか
まずサーバーを確認した。最低バージョンの数字は、正しく新しい値を返していた。ここは問題ない。
問題はアプリ側にあった。当時のコードを読むと、判定はこういう構造になっていた。
- アプリの中に「最低でもこの版」という数字が焼き込まれている
- 起動時にサーバーへ問い合わせ、より新しい数字があればそれで上書きする
- 自分の版が、その数字より古ければ更新画面を出す
一見まともに見える。落とし穴は1の焼き込みの値だった。ここに入っていたのは、遥か昔の版の数字。つまりサーバーへの問い合わせが成功しなければ、事実上どの版も「古くない」と判定される。
さらに悪いことに、自分の版番号が何らかの理由で読めなかった場合、コードは「無限に新しい」ものとして扱っていた。読めない=最新扱い=素通りである。
この2つを合わせると、判定はこうなる。
通信が失敗しても、版番号が読めなくても、とりあえず通す
セキュリティの言葉でいう fail-open(失敗したら開ける/通す設計)だ。反対は fail-closed(失敗したら閉じる/止める設計)。強制アップデートのような「止めるための仕組み」は、fail-closed でなければ意味がない。 止まってほしい状況とは、たいてい何かがうまくいっていない状況だからだ。
なぜサーバーの引き上げだけでは直せないのか
ここが厄介なところで、この設計ミスはアプリの中にある。そしてアプリの中身は、原則としてアプリを更新しないと直らない。だが今まさに困っているのは、アプリを更新していない人たちである。
鶏と卵になっている。
幸い、このアプリは配信の仕組み(アプリ本体を審査に出さずに、中身のコードだけ差し替えられる仕組み)を持っていた。ただしこれには制約があって、配信は「対応する版の系統」ごとに分かれている。新しい系統へ配信しても、古い系統のユーザーには届かない。
実際に配信履歴を確認すると、直近の配信はすべて最新の2系統に対してだけで、問題の旧版が属する系統には、新しい配信が1件も届いていなかった。
直した手順
やったことは単純だが、慎重さが要る作業だった。旧版それぞれについて、その版が実際にビルドされた時点のソースコードまで戻り、判定部分だけを直して、その系統へ配信する。
なぜ当時のソースまで戻るのか。最新のコードをそのまま古い系統へ配信すると、アプリが壊れるからだ。アプリ本体(審査を通した部分)と、配信で差し替わる部分は、噛み合っていなければならない。新しいコードが、古いアプリ本体に入っていない機能を使おうとした瞬間、起動できなくなる。
そこで、各版について次を確認した。
- その版がビルドされた正確な時点のソースを特定する
- 当時から今までに追加された部品はあるが、既存の部品の版が変わっていないことを確認する
- 当時のソースは新しい部品を使っていないので、混ざらない=安全と判断する
そのうえで、判定部分を2箇所だけ直した。
- 焼き込みの数字を、遥か昔の値から今の最低ラインへ引き上げる(サーバーに聞かなくても判定が成立する)
- 版番号が読めなかった場合の扱いを、「無限に新しい」から**「限りなく古い」へ反転**する(読めなければ必ず止まる)
これで fail-open が fail-closed になる。あとは同じ作業を、対象となる13世代ぶんの系統すべてに繰り返した。
手作業で13回やると必ずどこかで間違えるので、途中からスクリプトに落とした。既定を「実行せず、何をするかだけ表示する」モードにして、対象の取りこぼしも自動で照合させた。
その最中に起こした事故
正直に書いておく。この作業の途中で、本番の全ユーザーに壊れた状態を配信した。
原因はこうだ。上の手順は、作業中にリポジトリを一時的に「昔のソース+判定を書き換えた状態」にする。その状態のまま、私は別件の配信コマンドを実行してしまった。
配信の仕組みは、そのときの作業ツリーの中身をそのまま固めて配る。つまり、書き換え中の中途半端な状態が、最新版を使っている全ユーザーへ配られた。結果として、最新版を使っているのに強制アップデートの壁が出る、という状態になった。
クリーンな状態から正しい内容を配信し直して復旧したが、教訓ははっきりしている。
リポジトリを書き換えるスクリプトが走っている間は、配信系のコマンドを絶対に打たない。
配信が「今のフォルダの中身」を見ている以上、フォルダを触っている最中の配信は必ず事故になる。今はこれを恒久ルールとして明文化し、作業手順書の先頭に置いている。
残っている課題
正直に言うと、根本原因はまだ直っていない。判定が fail-open であること自体を直すには、アプリ本体を作り直して審査に通す必要がある。次のビルドで入る予定だ。
それまでは、最低バージョンを引き上げるたびに、全ての旧系統へ個別に配信し直す運用が続く。スクリプト化してあるので手間は減ったが、構造的な負債であることに変わりはない。
得た教訓
- 止めるための仕組みは fail-closed で書く。 通信失敗・値が読めない・想定外、すべて「止める」側に倒す
- 既定値は「一番安全な値」にする。 「あとでサーバーが上書きするから」は、上書きが届かない日に破綻する
- アプリの中の判定ロジックは、直すのに一番時間がかかる。 だからこそ最初から厳しく書く
- 作業ツリーを書き換えている最中に、外向きのコマンドを打たない
よくある質問
Q. そもそも強制アップデートは必要ですか? 古いアプリがサーバーの古い仕様に依存していると、サーバーを進化させられなくなります。「いつでも旧版を切れる」状態を確保しておくことは、後々の自由度に直結します。ただし、その仕組み自体が確実に動くと検証するまでは、あるつもりでいないほうがいいというのが今回の学びです。
Q. 配信で中身を差し替えるのは危なくないですか? 危ないです。アプリ本体と噛み合わない中身を配ると起動しなくなります。今回、旧版へ配る前に「部品の追加はあるが既存部品の版は変わっていない」ことを1件ずつ確認したのは、そのためです。
この記事で触れているアプリ
本稿の題材は、筆者が個人開発している iOS アプリ THE TASK:AI下調べToDoリスト(App Store・無料)です。書いたタスクをAIが着手できる状態まで補完する、という発想で作っています。他の回は制作日誌シリーズにまとまっています。
あわせて読みたい
コメント (0)
まだコメントはありません。最初の一言を残しませんか?