サーバーレスCloudKit同期:オフラインファースト実装の実例#
MeteoHealth(iPhone・iPad・Apple Watchでユーザーの健康指標を記録するアプリ)の同期を設計していたとき、最初に考えたのは「どのフレームワークを使うか」ではなく「そもそもサーバーは必要なのか」だった。個人の健康データ、3つのプラットフォーム、飛行機の中や病院の地下でもオフラインで動作しなければならない要件——そして、たった一つのアプリのためにバックエンドとデータベースとDevOpsを維持したくないという気持ち。
数ヶ月間本番運用した末にたどり着いた答えは、CloudKitの上に正しくオフラインファースト同期を設計すれば、サーバーは不要だということだった。この記事はApple公式ドキュメントの焼き直しではない。実際に何がうまくいき、何が壊れ、今の知識があれば何を変えるかという具体的な経験を書く。
CloudKit・Firebase・Supabaseで迷った理由#
最初は一般的なBaaS選定に見えた。Firebaseは成熟していてクロスプラットフォーム、コミュニティも巨大。Supabaseはオープンソースで裏側はPostgres、開発体験も良い。CloudKitはAppleエコシステム専用だが、OSに標準搭載されている。
健康データ特有の3つの要因が決め手になった。
- データが物理的にユーザー自身のiCloudに保存される。自分が管理・パッチ・保護しなければならないサーバーではない。これは単なる利便性ではなく、攻撃対象領域が小さく、機微なデータを預かる責任も軽くなるということだ。
- 認証はすでに解決済み。ユーザーはすでにApple IDを持っているので、健康データのために登録・パスワード再設定などを自前で構築する必要がない。
- インフラがゼロ。深夜3時に落ちるサーバーがない。ユーザーが増えるほど膨らむホスティング費用もない。
代償はAppleへのベンダーロックインと、カスタマイズの天井が低いことだ。複数レコードにまたがる複雑なトランザクションや任意のサーバーサイドクエリは、Postgresに比べてCloudKitでは難しい。Appleエコシステムの中だけで完結するアプリにとっては、この代償は割に合った。
| 基準 | CloudKit | Firebase | Supabase |
|---|---|---|---|
| 自前サーバー | 不要 | 不要(マネージド) | self-hostまたはSupabase Cloudが必要 |
| データ保存先 | ユーザーのiCloudプライベートデータベース | Googleのサーバー | Postgres(マネージド/self-host) |
| クロスプラットフォーム | Appleエコシステムのみ | iOS/Android/Web | iOS/Android/Web |
| 認証 | Apple IDがそのまま使える | Firebase Auth(要設定) | Supabase Auth(要設定) |
| オフラインファースト | NSPersistentCloudKitContainer経由でCore Dataに標準搭載 | Firestoreのオフラインキャッシュ | 手動実装が必要 |
| 規模拡大時のコスト | Appleのユーザー単位無料枠 | Firestoreクエリ課金に応じて増加 | Postgresインスタンスに応じて増加 |
アーキテクチャ:NSPersistentCloudKitContainer上のCore Data#
MeteoHealthの同期の最初のバージョンはNSPersistentCloudKitContainerの上に構築されている。Core Dataはもともとネットワークなしでローカルに動作するので、オフラインファースト挙動への最短経路であり、NSPersistentCloudKitContainerはその上にCloudKitへのミラーリングを追加する。
import CoreData
import CloudKit
final class PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "MeteoHealthModel")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("No persistent store description found")
}
// Required for CloudKit mirroring: history tracking + remote change notifications
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: "iCloud.pro.dodecaidr.meteohealth"
)
container.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error loading store: \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
// Field-level last-writer-wins. Good enough for most entities,
// but not for health metrics where "who wrote last" isn't "who is right".
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}ドキュメントを読むだけでは気づきにくい点がある。CloudKit対応のCore Dataモデルには厳しい制約がある——すべての属性はオプショナルかデフォルト値を持たなければならず、unique制約は使えず、リレーションもオプショナルでなければならない。この制約のせいで、同期コードを1行書く前にデータモデルを見直す必要があった。以前はスキーマレベルで保証していたビジネス上の不変条件の一部を、アプリケーションレベルのバリデーションに移した。
プライベートデータベースとゾーン:iPhone・iPad・Apple Watch間の同期の仕組み#
CloudKitはデータをパブリック・プライベート・共有の3種類のデータベースに分ける。個人の健康データに適しているのはプライベートデータベースだけだ——ユーザー自身のiCloudストレージに物理的に存在し、他のユーザーからはアクセスできず、自分が管理するインフラを一切経由しない。
プライベートデータベースの中で、データはゾーン(CKRecordZone)にグループ化される。デフォルトではすべてdefaultZoneに入るが、オフラインファースト同期にはカスタムゾーンが決定的な利点をもたらす——同一ゾーン内での原子的なバッチ保存・削除操作と、独立した変更トークンだ。関連するレコード群(例えば通院1回分のすべての測定値)を、他のデバイスで部分的にしか反映されない状態になるリスクなしに、ひとまとまりとして同期できる。
MeteoHealthの実際の運用では、3つのプラットフォームすべてで共有する単一のHealthMetricsゾーンを使っている——iPhone・iPad・Apple Watchは同じiCloudアカウントの同じプライベートデータベースの同じゾーンを読み書きする。「デバイス間同期」専用のサーバーは不要で、CloudKit自体が転送層になる。
本番環境での競合:serverRecordChangedが実際に意味すること#
ここからがチュートリアルではない本物のエンジニアリングだ。NSPersistentCloudKitContainerはNSMergePolicyを通じて自動的に競合を解決するが、それはレコード単位ではなくフィールド単位であり、「祖先」バージョンへのアクセスもない。ほとんどのフィールドではそれで問題ない——後で変更された方が勝つ。しかし健康の測定値では、「誰が最後に書いたか」と「どちらが正しいか」は別の問題だ。ユーザーが9:00にiPhoneで値を入力し、遅延した同期がApple Watchの8:45の自動測定値を9:05に届けた場合、単純なlast-writer-winsは、より古いが決して無効ではない値を上書きしてしまう。
ここでNSPersistentCloudKitContainerは高レベルすぎることが判明した。serverRecordChangedの背後にある3つのレコードバージョン——ローカル、サーバー、祖先——へのアクセスを提供してくれない。重要なエンティティについては、CKSyncEngineを通じて生のCKRecordを扱うレベルまで降りた。
CKSyncEngine:Core Dataより一段下に降りるべきとき#
WWDC23でiOS 17+向けに発表されたCKSyncEngineは、より低レベルだが宣言的なAPIだ。変更トークン、リトライ、プッシュ通知のバッチ処理といった面倒な作業の大半を肩代わりしてくれる一方で、何を送信するか、競合をどう解決するかについて完全な制御を返してくれる。
import CloudKit
final class HealthSyncEngine: NSObject, CKSyncEngineDelegate {
private var syncEngine: CKSyncEngine!
private let zoneID = CKRecordZone.ID(zoneName: "HealthMetrics")
func configure(savedState: CKSyncEngine.State.Serialization?) {
let configuration = CKSyncEngine.Configuration(
database: CKContainer(identifier: "iCloud.pro.dodecaidr.meteohealth").privateCloudDatabase,
stateSerialization: savedState,
delegate: self
)
syncEngine = CKSyncEngine(configuration)
}
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) {
switch event {
case .stateUpdate(let update):
persistState(update.stateSerialization)
case .fetchedRecordZoneChanges(let event):
event.modifications.forEach { applyToLocalStore($0.record) }
event.deletions.forEach { deleteFromLocalStore(recordID: $0.recordID) }
case .sentRecordZoneChanges(let event):
// Records CloudKit rejected because the server version moved on
// while we were offline.
for failure in event.failedRecordSaves {
guard failure.error.code == .serverRecordChanged,
let serverRecord = failure.error.serverRecord else { continue }
let resolved = mergeHealthRecord(local: failure.record, server: serverRecord)
syncEngine.state.add(pendingRecordZoneChanges: [.saveRecord(resolved.recordID)])
stageForNextSave(resolved)
}
default:
break
}
}
func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
await CKSyncEngine.RecordZoneChangeBatch(
pendingChanges: context.options.scope.pendingRecordZoneChanges
) { recordID in
recordToSave(for: recordID)
}
}
}重要なのは、エラーコード.serverRecordChangedを持つfailedRecordSavesだ。NSPersistentCloudKitContainerと違い、CKSyncEngineは実際のサーバーレコードを渡してくれる——新しいrecordChangeTag付きで。これがないと再送信は再び拒否されてしまう。サーバー版の上に新しいレコードを組み立て、独自のビジネスロジックを適用する。
/// serverRecordChanged gives us the current server record with a valid
/// change tag. Re-sending the local record's stale tag would just be
/// rejected again — we must copy the server record and reapply our fields.
private func mergeHealthRecord(local: CKRecord, server: CKRecord) -> CKRecord {
let merged = server.copy() as! CKRecord
let localRecordedAt = local["recordedAt"] as? Date ?? .distantPast
let serverRecordedAt = server["recordedAt"] as? Date ?? .distantPast
// The freshest measurement wins — not whoever reached the server first.
if localRecordedAt > serverRecordedAt {
merged["value"] = local["value"]
merged["recordedAt"] = local["recordedAt"]
merged["source"] = local["source"]
}
return merged
}こうすることで、競合はネットワーク遅延という偶然ではなく、データの意味(実際の測定時刻)に基づいて解決される。MeteoHealthの他の重要度の低いエンティティについてはNSPersistentCloudKitContainerのままにしている——プロパティレベルのlast-writer-winsで何も困らない箇所まで、全スタックをCKSyncEngineに書き直す意味はない。
プッシュとサブスクリプション:デバイスが即座に変更を知る仕組み#
プッシュ通知がなければ、iPhone・iPad・Apple Watch間の同期はタイマーかアプリを開いたときにしか動かない——「1つのiCloudアカウント」にユーザーが期待するものとは違う。CKDatabaseSubscriptionはプライベートデータベース全体(新しいゾーンを含む)を監視し、変更のたびにサイレントプッシュを送る。
func setupDatabaseSubscription(database: CKDatabase) async throws {
let subscription = CKDatabaseSubscription(subscriptionID: "meteohealth-private-db-changes")
let notificationInfo = CKSubscription.NotificationInfo()
notificationInfo.shouldSendContentAvailable = true // silent push, no banner
subscription.notificationInfo = notificationInfo
try await database.save(subscription)
}このプッシュを受け取っても、アプリはユーザーに何も表示しない——CKSyncEngine経由で保留中の変更のフェッチをトリガーするだけだ。
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo),
notification.subscriptionID == "meteohealth-private-db-changes" else {
completionHandler(.noData)
return
}
Task {
try? await syncEngine.fetchChanges()
completionHandler(.newData)
}
}NSPersistentCloudKitContainerではこのサブスクリプションを手動で設定する必要はない——フレームワーク内部ですでに配線済みだ。手動設定が必要になるのは、CKSyncEngineを直接使う場合だけだ。
落とし穴、制限、本番前チェックリスト#
どのチュートリアルにも書かれていないが、実際にデバッグで時間を奪われたことをまとめる。
- CloudKit Dashboardはライブマイグレーションの場ではない。Development環境に先にデプロイして昇格させずに本番でレコードスキーマを変更すると、まだアップデートしていない端末で互換性問題が確実に起きる。
- レート制限は静かに失敗する。リクエストクォータを超えてもCloudKitは派手なエラーを投げない——操作が単に先送りされ、後で再試行されることがある。アプリのロジックが即座のレスポンスを期待していると、これは「固まった」同期のように見える。
- シミュレータはオフラインモードについて嘘をつく。ネットワーク断とそれに続くマージ競合の実シナリオは、シミュレータのデバッグ用トグルではなく、実際にWi-Fiをオフにした実機でテストする必要がある。
- iCloudアカウントがサインインしていない、または制限モード(ファミリー共有、管理対象Apple ID)である可能性がある。アプリはクラッシュせず、ローカルで動作し明確なステータスを表示するという形で優雅に劣化しなければならない。
- 再インストール後の最初のフル同期は、トラフィックと時間の両面で最も重いシナリオだ。すべてが滑らかに動くインクリメンタルなシナリオに頼らず、意図的にこれを個別にテストしている。
CloudKit同期機能をリリースする前のチェックリスト。
- Core DataスキーマがCloudKitの制約(オプショナル属性、unique制約なし)に対応している
- 重要なエンティティにはデフォルトのproperty trumpではなく、明示的な競合解決戦略がある
-
CKDatabaseSubscriptionが設定・テスト済み(手動CKSyncEngineの場合) - 2台のデバイスが同時にオフラインで競合する変更を行うシナリオをテスト済み
- アカウントがiCloudにサインインしていないシナリオをテスト済み
- ゼロからのフル同期をテスト済み
- ログがサイレントなリトライを致命的エラーとして扱っていない
結論。 CloudKitは「データをどう同期するか」への万能の答えではない。しかしAppleエコシステムの中で完結し、ユーザーの個人データを扱うアプリにとっては、独自バックエンドの正当な代替になる——インフラは少なく、認証は標準搭載で、データは自分のサーバーではなく物理的にユーザーのiCloudに留まる。その代償は、便利だが粗いNSPersistentCloudKitContainerと、精密だが冗長なCKSyncEngineの違いを理解し、プロジェクト全体ではなくエンティティごとに適切なツールを選ぶ必要があることだ。



