こんにちは、ソーイ株式会社の髙﨑です。
最近の業務で、S3からWasabiストレージへ大量のデータを移行するバッチ処理を実装する機会があり、その中で、処理時間やメモリ使用量、途中失敗時の再実行方法など、大量データを扱うバッチ処理ならではの考慮事項がいくつかありました。
今回は、実際のデータ移行で直面した課題とその対応策について、備忘録も兼ねてまとめてみました。
TL;DR
この記事では、以下のポイントについて紹介します。
- 処理時間とメモリ使用量への対策
- ジョブの責務分離
- 冪等性を考慮した再実行可能な設計
- 進捗管理による運用性の向上
- 安全なデータ削除の考え方
特に、Laravelで大量データを扱うバッチ処理を実装する方の参考になれば幸いです。
目次
- はじめに
- 実際に問題になったこと
2-1.処理時間が長くなる
2-2.1つの巨大なジョブでは管理が難しい
2-3.再実行時の重複処理
2-4.進捗が分からない
2-5.移行直後の削除リスク - まとめ
はじめに
弊社で運用・保守しているサービスにて、S3に保存されている大量のデータをWasabiストレージへ移行する対応を行いました。
今回の移行先であるWasabiは、S3互換のオブジェクトストレージサービスです。S3と互換性のあるAPIを提供しているため、アプリケーション側の変更を最小限に抑えながら移行できる点が特徴です。
当初は「S3からWasabiへファイルをコピーすればよい」と考えていましたが、実際に進めてみると、そう単純な話ではありませんでした。
今回の移行では、単にデータを移行するだけでなく、既存サービスへの影響を最小限に抑えることも要件の一つでした。そのため、短時間で一括移行するのではなく、システム負荷を分散しながら安全に移行を進める方針を採用しました。
また、大量のデータを扱うため、処理時間やメモリ使用量への配慮はもちろん、途中で失敗した際の再実行方法や移行状況の管理、移行後のデータ削除タイミングなど、運用面も含めた設計が必要となりました。
今回は、S3からWasabiへのデータ移行を通して、大量データを扱うバッチ処理を実装する際に考慮したことや、実際に採用した対応について、備忘録も兼ねてまとめます。
同様のバッチ処理を実装する際の参考になれば幸いです。
実際に問題になったこと
この章では、データ移行処理の実装にて発生した問題と対応策についてまとめます。
1. 処理時間が長くなる
問題
移行対象のデータが大量に存在する場合、単純に全件を順番にコピーする実装では処理時間が非常に長くなります。
今回の移行対象は数十万件規模だったため、単純な全件ループでは現実的な時間での完了が難しい状況でした。
また、全件をメモリ上に展開する実装では、アプリケーションへの負荷も懸念されました。
例えば、以下のような実装はシンプルではあるものの、大量データを扱うケースでは現実的ではありません。
$items = Item::all();
foreach ($items as $item) {
migrate($item);
}
データ件数が数万件規模になると、処理時間の増加だけでなく、メモリ使用量の増大による性能劣化や障害の原因にもなります。
対応
そこで、移行処理はジョブとして非同期実行し、さらに chunkById を利用して一定件数ごとに分割して処理する構成を採用しました。
Item::where('organization_id', $organizationId)
->chunkById(100, function ($items) use ($migrationId) {
foreach ($items as $item) {
MigrateSingleItemToWasabiJob::dispatch(
$migrationId,
$item->id
);
}
});
このようにチャンク単位でジョブを投入することで、
- メモリ使用量を一定に保てる
- 個別のアイテム単位で再試行できる
といったメリットがあります。
2. 1つの巨大なジョブでは管理が難しい
問題
今回の実装では、「移行処理の起動・管理」「移行対象データの取得」「取得したデータのWasabiへのコピー」「移行完了後のS3データ削除」といった処理が必要となりました。
これらをすべて1つのジョブにまとめてしまうと、
- 途中で失敗した際に、どこまで処理できたのか分からない
- リトライ時に、正常に完了したデータも含めて最初から処理し直す必要がある
- ジョブの責務が肥大化し、保守やテストが難しくなる
といった問題がありました。
対応
そこで、処理内容ごとにジョブを分割し、それぞれに明確な責務を持たせる構成を採用しました。
-
DispatchJob
└─ 移行処理の開始と進捗管理レコードの作成 -
ChunkMigrationJob
└─ 移行対象データをチャンク単位で取得し、移行ジョブを投入 -
SingleItemMigrationJob
└─ 1件ずつS3からWasabiへデータをコピー -
CleanupS3ItemJob
└─ 猶予期間経過後にS3上のデータを削除
このようにオーケストレータを中心としてジョブを分割することで、責務を明確にし、途中失敗時の再実行や障害時の切り分けを容易にしました。
また、ジョブごとに役割を限定したことで、実装時にも「このファイルではこの処理のみを実装する」という意識をもち、実装範囲を整理しながら開発を進めることができました。
3. 再実行時の重複処理
問題
大量データに限らず、長時間実行されるバッチ処理では、途中で失敗することを前提のロジック設計が必要となります。
例えば、10万件のデータ移行中に9万件目で障害が発生した場合、ジョブを再実行するケースが考えられます。
しかし、再実行時に同じデータを何度もコピーしてしまうと、
- すでに正常終了したデータまで再処理される
- 不要なストレージアクセスが発生する
- 処理時間が増加する
- データの整合性に影響する可能性がある
といった問題があります。
対応
そこで、「何度実行しても結果が変わらない」状態、いわゆる冪等性を持たせるようにしました。
具体的には、移行処理の開始前に以下の状態を確認しています。
- 移行データ自体に「保存先ストレージ」の情報をもたせ、移行済みかどうかを判定
- 移行管理テーブルにて履歴を管理し、移行が成功しているかを判定
// items.storage_type が Wasabi になっている場合は、すでに移行済みとして処理をスキップする
if ($item->storage_type === StorageType::WASABI) {
return;
}
// 移行管理テーブルに成功履歴が存在する場合は、再コピーを行わない
if ($migrationItem->status === MigrationStatus::COPIED) {
return;
}
このように事前に状態を確認することで、ジョブが途中で失敗して再実行された場合でも、未処理のデータのみを対象に処理を継続できます。
また、失敗したアイテムだけを再実行できるため、全件を最初からやり直す必要がなくなり、運用負荷の軽減にもつながります。
4. 進捗が分からない
問題
大量データを扱う場合、「今どこまで処理が完了しているのか」を把握できないと、運用が困難になります。
特に障害発生時には、「全体の何件が完了しているのか」「どのデータで失敗したのか」「再実行が必要なのはどれか」といった情報が必要になり、これらの情報があるのとないのとでは、原因特定や復旧対応のしやすさが大きく異なります。
対応
そこで、進捗管理用のテーブルを用意しました。
-
organization_item_storage_migrations
- 移行処理全体の進捗を管理するテーブル
- 対象件数や成功件数、失敗件数などを保持
-
organization_item_storage_migration_items
- アイテムごとの移行状況を管理するテーブル
- 移行ステータスやエラー内容などを保持
これにより、
- 全体の進捗状況の把握
- 失敗したデータの特定
- 必要なデータのみの再実行
が可能になりました。
5. 移行直後の削除リスク
問題
Wasabiへのコピーが成功したとしても、移行直後にS3上のデータを削除してしまうと、Wasabi側で何らかの問題が発生した際に復旧手段がなくなってしまいます。
例えば、移行後にデータの欠損やアプリケーション側の不具合が発覚した場合、元データが残っていなければ迅速な切り戻しができません。
対応
そこで、Wasabiへのコピー完了後も一定期間はS3上のデータを保持し、猶予期間経過後に後続ジョブで削除する方式を採用しました。
このように削除処理を後続ジョブとして分離することで、移行後に問題が発生した場合でも安全に切り戻しを行えるようにしました。
まとめ
今回は、S3からWasabiへのデータ移行を題材に、大量データを扱うバッチ処理を実装する際に考慮したポイントを紹介しました。
実装を始める前は、「データをコピーして、移行が完了したら元データを削除する」という比較的シンプルな処理を想定していました。しかし実際には、処理時間やメモリ使用量への配慮だけでなく、障害発生時の再実行方法や進捗管理、削除タイミングなど、運用面も含めた設計が欠かせないことを実感しました。
特に、大量データを扱うバッチ処理では、「正常に完了すること」だけでなく、「途中で失敗しても安全に再開できること」を前提に設計することが重要だと感じています。
今回の実装を通して、以下の点が重要だと考えるようになりました。
- 処理時間やメモリ使用量を考慮し、処理を小さく分割すること
- 再実行を前提とし、冪等性を持たせること
- 障害対応を見据え、進捗を可視化すること
- データ削除は慎重に行い、切り戻し手段を確保すること
大量データを扱う処理では、実装そのものよりも「障害が起きたときにどう振る舞うか」を事前に考えておくことが、運用負荷の軽減や安定したシステム運用につながるのではないでしょうか。
本記事が、同様のバッチ処理を実装する際の参考になれば幸いです。
参考
- https://qiita.com/moeka-k0729/items/6b10e4ff2eef4687115b
- https://tech.timee.co.jp/entry/2022/05/26/113337
お知らせ
技術ブログを週1〜2本更新中、ソーイをフォローして最新記事をチェック!
https://qiita.com/organizations/sewii