デプロイし忘れた 1 件のレコードで、iPhone が 18 日間沈黙した話
「物の森」4.0 のテスト中、1 台の iPhone が 18 日間ひとつも変更を iCloud にアップロードしていませんでした。それなのに同期ログにはエラーが 1 行もない。原因は、新しいモデルが 1 つ CloudKit の本番環境に入っていなかっただけ。直すのはデプロイ 1 回で済みましたが、見つけるまでにずいぶん遠回りしました。調査の経緯と、その後に加えた再発防止策をまとめます。
こんにちは、風森です。
「物の森」4.0 のリリース前テストで、手元のテスト用 iPhone が 18 日間、ひとつも変更を iCloud にアップロードしていなかったことがわかりました。しかも App 内の同期ログには、エラーが 1 行も出ていません。
調べてみると、原因は 4.0 で追加したデータモデルの 1 つが、CloudKit の本番環境(Production)の schema に一度も入っていなかったことでした。サーバーがこの 1 件のレコードを拒否すると、Core Data はバッチ全体のアップロードを止め、さらにその起動中の同期をすべて止めてしまいます。
直すのに必要だったのは schema のデプロイ 1 回だけ。本当に時間がかかったのは、原因を見つけるまででした。今回はその調査の流れと、あとから加えたいくつかの防衛策を記録しておきます。
まずは「物の森」の同期の仕組みから
「物の森」のデータは SwiftData で端末内に保存しています。iCloud 同期をオンにすると、SwiftData の裏側にある NSPersistentCloudKitContainer が、ユーザー自身の CloudKit プライベートデータベースへミラーリングします。
CloudKit には 2 つの環境があり、今回の問題はすべてその違いに潜んでいました。
- Development:Xcode から直接実行した Debug ビルドはこちらにつながります。ここの schema は「使ったら作られる」方式で、あるレコードが初めてアップロードされたとき、CloudKit がその中身に合わせて record type とフィールドを自動で作ります。値の入っていないフィールドは作られません。
- Production:TestFlight と App Store 版はこちらにつながります。ここでは何も自動で作られず、コンソールの「Deploy Schema Changes」で Development からデプロイされた構造だけを受け付けます。
SwiftData の各モデルは、CloudKit 上では CD_ で始まる record type に対応します。たとえば SDItem なら CD_SDItem です。そして一度 Production に入ったフィールドは、変更も削除もできません。
4.0 ではモデルをいくつも追加しました。そのひとつが SDBasketSharedItem で、共有の森にあるアイテムをかごに入れたときに 1 件作られます。ふだんはほとんど使われない——この点が、あとで効いてきます。
2 台が同期しないのに、ログはきれいなまま
問題に気づいたのは、4.0 で新しく作った宝箱の原本同期をテストしていたときです。iPhone と iPad で交互にアイテムを追加していたのですが、iPhone で追加したものがいつまでたっても iPad に届かず、CloudKit コンソールにも変化がありません。最初は「新機能のバグだな」と思いました。
ところが不思議なことに、App の隠し診断メニューにある同期ログには何のエラーもありません。2 台とも同じ build、同じ Apple ID、同じサブスクリプションで、環境もどちらも Production。いつもなら真っ先に疑うポイントが、ひとつずつ全部問題なしでした。
調査:5 ステップ、2 回の見当違い
今振り返ると、本当の手がかりはかなり早い段階で出ていました。ただ、最初はそれを気に留めていなかったんです。
- 2 台の診断レポートを見比べる。 違いはとても目立たない項目にありました。iPad の「最終アップロード」は数分前、iPhone は 10 月 13 日。つまり iPhone はその日から一度もアップロードに成功していなかったのです。起動時に 1 回ダウンロードするだけで、そのあと iPad が何度アップロードしても、1 回も受け取っていませんでした。
- App を完全に終了して、もう一度開く。 ようやくログにアップロード失敗が 1 件出ました。
CKErrorDomain 2(partialFailure、一部のレコードが拒否された)。それまでログがきれいだったのは、失敗が起きるのは再起動してアップロードを再試行する瞬間だけだったからです。ただ、このエラーにはドメインとコードしかなく、どのレコードがなぜ拒否されたのかはわかりません。Core Data が App に渡すイベントの時点で、レコードごとの詳細は捨てられていました。 - CloudKit コンソールの Logs を確認する。 UTC で該当のリクエストを探すと、
RecordDelete、BAD_REQUESTと表示されていました。何かの削除が拒否されたのかと思い込み、「schema が足りないのでは」という最初の直感まで一度ひっくり返してしまいました。これが 1 回目の見当違いです。実際にはそのバッチに 25 件のレコードが含まれていて、コンソールはざっくりした分類を 1 つ返していただけでした。 - Mac につないで「コンソール」App でデバイスログを見る。 Core Data のエラーは確かに出ているものの、肝心の中身はすべて
<private>。iOS はログを書き込む時点で、デバッグ中でないプロセスのプライベートな値を隠してしまうので、「App 自身にログを読ませる」方法も使えません。危うくそのために build をもう 1 つ作るところでした。これが 2 回目の見当違いです。 - Xcode から実行しつつ、Production につなぐ。 Xcode のコンソールなら完全なエラーが見えますが、通常の Debug ビルドは Development につながるので、この端末のデータを別の環境に切り替えることになってしまいます。そこで entitlements に一時的に 1 行足して、Debug ビルドでも Production につなぎ、見終わったらすぐに削除しました。
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
ここでようやく、サーバーの生のメッセージが見えました。
"Invalid Arguments" (12/2006); server message = "Cannot create new type CD_SDBasketSharedItem in production schema"
... 24 "Batch Request Failed" CKErrors omitted ...
根本原因:Production はデプロイされた構造しか認めない
あの iPhone は 10 月 13 日ごろ、「共有の森のアイテムをかごに入れる」操作をテストしていて、そこで初めて SDBasketSharedItem が作られました。ところがこのモデルは、どの Debug ビルドからも一度もアップロードされたことがありません。当然 Development には CD_SDBasketSharedItem がなく、それまでのデプロイにも含まれていませんでした。だから Production が受け付けなかったのです。
では、なぜたった 1 件のレコードで端末まるごと止まってしまうのか。
- アップロードはバッチ単位でアトミック。 1 件が拒否されると、同じバッチの残り 24 件も一緒に失敗します(
Batch Request Failed)。 - Core Data はこのエラーを自動回復できないと判断する。 内部状態をリセットしたうえで、その起動中はすべてのリクエストを拒否します。アップロードもダウンロードも止まります。
- 次に開くと、同じバッチがまた拒否される。 これが繰り返され、App が受け取るのは毎回
CKErrorDomain 2の一言だけ。
iPad が何の問題もなかったのは、このレコードを一度も作っていなかったから。一方の iPhone は、10 月 13 日以降の変更がすべて端末内に留まったままでした。
これまでの私のやり方は「モデルを追加したら、まず開発環境でデータを 1 件保存してからデプロイする」というもの。でもこの方法では、今回のようにめったに使われないモデルや、ふだん空のままのフィールドは拾えないんですよね。
修正:Development を一度で全部作ってから、デプロイする
いちばん手っ取り早いのは、コンソールで手動でこの型を作ってしまうことです。でも、そうはしませんでした。Production のフィールドは一度デプロイすると変更も削除もできないので、1 つ間違えたらそれが永久に残ります。あとで Production の CD_SDBasketSharedItem を見てみると、Core Data が CD_moveReceipt(BYTES)、CD_moveReceipt_ckAsset、CD_spaceKey_ckAsset といったフィールドを自前で追加していて、手作業で正しく作るのはほぼ不可能でした。
Apple が SwiftData 向けに用意しているのは initializeCloudKitSchema() です。SwiftData のモデルから Core Data のモデルを生成し、モデルごとに全フィールドに値が入ったサンプルレコードを 1 件ずつアップロードして、Development を一度で作り切ります。これを Debug ビルドでだけ動く小さなツールにして、使い捨ての空のデータベースで実行するようにしました。本物のデータには一切触れません。
let model = NSManagedObjectModel.makeManagedObjectModel(for: AppSchema.models)!
let description = NSPersistentStoreDescription(url: throwawayStoreURL)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: containerID)
description.shouldAddStoreAsynchronously = false
let container = NSPersistentCloudKitContainer(name: "Store", managedObjectModel: model)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in /* エラー処理 */ }
try container.initializeCloudKitSchema()
最初はシミュレーターで動かすつもりでしたが、Xcode 27 のシミュレーターウインドウ(DeviceHub)が、手元の macOS ベータ版では開いた瞬間にクラッシュしてしまい、シミュレーターで iCloud にサインインできません。そこで発想を変えて、App を iPad App として Mac 上で直接動かすことにしました。Mac はもともと iCloud にサインイン済みなので、起動引数をつけて 1 回実行するだけ。20 秒ほどで作り終わってすぐ終了し、森のデータベースは一度も開きません。
続けて CloudKit コンソールから Production にデプロイし、Apple の cktool で両方の schema を書き出して照合しました。record type 39 個、フィールド 466 個、完全に一致です。
あの iPhone に TestFlight 版を入れ直すと、18 日分たまっていた変更が一気に iPad に届きました。その瞬間、ようやく肩の荷が下りました。
二度と起こさないために
Production に何が欠けているかは、どこかのユーザーが実際にそのフィールドを使ったときにしか表に出ません。しかもその出方は「静かにアップロードが止まる」というもの。だから防衛線はリリース前に置く必要があり、私が覚えているかどうかに頼ってはいけません。
- リリース前に、コマンド 1 つで Development を作り切る。
./scripts/cloudkit_schema.sh createが Mac 版の Debug App をビルドし、起動引数つきでinitializeCloudKitSchema()を 1 回実行します。使うのは Mac の iCloud アカウント。シミュレーターも実機も不要で、ビルドキャッシュがあれば 1 分ほどです。 - コンソールでデプロイする。 Apple は Production へのデプロイ用コマンドラインを提供していないので、ここだけは手動のままです。
- アップロード前に自動で照合する。
checkはcktoolで Development と Production の schema を書き出し、record type とフィールドを 1 つずつ比べて、Production に足りないものを一覧にします。アップロードスクリプトはアーカイブ前に自動でgateを実行し、モデルファイルに変更があればまずcreate、続けてcheck、揃っていなければアップロードを止めます。(cktoolには CloudKit 管理トークンの初回設定が必要です。) - App 内の診断で、問題が直接見える。 隠し診断メニューに Uploads、Downloads、Setup の 3 行を追加しました。最後に成功した日時と「いつから失敗しているか」を記録し続けます。たとえば
Failing since 10月13日 · CKErrorDomain 2のように表示され、アップロードの失敗が続くと下に注意書きも出ます。さらに「Check the CloudKit schema」を追加して、TestFlight 版から Production に直接「App が使う record type は全部そろっているか」を問い合わせられるようにしました。 - ルールを開発ドキュメントに書く。 以前のルールは「モデルを追加したら、開発環境でデータを 1 件保存してからデプロイ」。まさにこれが今回の見落としの原因でした。今は「リリース前に毎回スクリプトを実行 → デプロイ → 照合」に変えています。
それとは別に、リリース前には今も 2 台の端末間で iCloud 同期を手動で一通りテストしています。自動チェックが見るのは「構造がそろっているか」、手動テストが見るのは「実際に使って正しく動くか」。どちらも欠かせません。
次はもっと早く見つけるために
振り返ると、今回の時間の大半は修正そのものではなく「見当違いの場所を見ていたこと」に使っていました。次の自分のために、いくつか残しておきます。
- ログのエラーの有無ではなく、「最後に成功したアップロード」を先に見る。 ログは消えるし件数の上限もあります。そもそも止まっている端末は、起動と起動のあいだにログを一切出しません。
- 調査の順番を決めておく。 診断の Uploads → Check the CloudKit schema → 必要なら「一時的な Production entitlement + Xcode 実行」でサーバーの生メッセージを見る。最初の 2 つは iPhone だけで数分、3 つ目で初めて Mac につなげば十分です。
- 行き止まりの道を先に知っておく。 Core Data の同期イベントの error にはドメインとコードしかない。デバイスログの詳細は
<private>で、App 自身が読んでも同じ。CloudKit コンソールの Logs はリクエスト単位のざっくりした分類しか出さない。これを知っていれば「とりあえずもう 1 build 出してみよう」を何度も繰り返さずに済みます。 - schema 関連の作業に実機はいらない。 iPad App は Apple シリコンの Mac でそのまま動き、Mac の iCloud アカウントも使えます。使い捨ての開発ツールを動かすなら、シミュレーター(サインインやウインドウが必要)や実機(デベロッパーモードやロック解除が必要)よりずっと手軽です。
- 自動化できるものはスクリプトに任せ、できないものはスクリプトに止めて知らせてもらう。 リリースフローでチェックできることはすべてアップロードスクリプトへ。コンソールでのデプロイは自動化できないので、そこではスクリプトが止まって、何が足りないかをはっきり示します。
SwiftData / Core Data + CloudKit を使っている方へ。少しでも遠回りを減らせたらうれしいです。
- Production にはデプロイした構造しかなく、Development には実際にアップロードされ、かつ値の入ったフィールドしかない。モデルを変えたら、まず
initializeCloudKitSchema()で作り切ってからデプロイする。 - Core Data の型を CloudKit コンソールで手作りしない。Production のフィールドは変更も削除もできない。
- 1 件のレコードが拒否されるとバッチ全体が失敗し、Core Data はその起動中の同期をすべて止める。
- 同期が健全かどうかは、ログのエラーの有無ではなく「最後に成功したアップロード」で判断する。
- Production の完全なエラーを見たいときは、一時的に
icloud-container-environment = Productionを追加して Xcode から実行。見終わったら削除して、絶対にコミットしない。 - アップロードに失敗している端末では、未送信の変更はその端末にしか存在しない。まずバックアップを取り、リセット・App の削除・「iCloud から再ダウンロード」はしない。
- TestFlight と App Store は Production、Xcode からの実行は Development につながる。大事なデータが入った端末に Debug ビルドを入れない。
おわりに
幸い、今回影響を受けたユーザーは一人もいません。問題が起きたのは私のテスト端末で、4.0 もまだ TestFlight でテスト中です。でも、もしこれが正式版と一緒に出ていたら、ユーザーが目にするのは「なんだか同期の調子が悪い」ということだけ。App には何の表示も出ず、私のところにも何の報告も届かなかったはずです。
CloudKit と Core Data は複雑なところをほとんど隠してくれるので、ふだんはとても楽です。その代わり、問題が起きたときも同じように静かなんですよね。
ひとりで作っている App にとって、ユーザーが「物の森」に預けてくれているのは、ひとつひとつのモノにまつわる記録と思い出です。私にできるのは、リリース前のチェックをできるだけ自動化すること、そして App の中に本当の状態が見える窓をひとつ残しておくこと。そうすれば、たとえ森に霧が立ちこめても、いち早く気づけるはずです。