TL;DR
- テナント単位でデータを整理するバッチで、DB の一括処理と、S3 のオブジェクトの削除を行っていました
- S3 にはプレフィックスを指定して消す API がありません。全キーを列挙してから、1 回 1,000 件までの
DeleteObjectsで消すしかなく、時間の大半は列挙にかかりました - DB の一括処理はテーブルのロックを伴うので、メンテナンス時間の中で終える必要があります。一方、S3 の削除はサービスに影響しません
- そこで 2 つを別の実行モードに分け、S3 の削除はメンテナンス時間と関係なく、時間をかけて回せるようにしました
- 1 回に処理する件数(バッチサイズ)の上限は、計測して 1,000 に決めました。1,500 件以上では処理時間が安定しなかったため、安定する 1,000 件を複数回に分けて実行します
この記事の対象読者
- S3 の大量のオブジェクトを、プレフィックス単位で消す処理を書いている方
- DB の一括処理と S3 の削除を、1 つのバッチでまとめて扱っている方
- バッチサイズのような上限値を、どう決めるか迷っている方
環境
- Go(AWS SDK for Go v2)で書いたバッチ
- データベース: MySQL(Amazon RDS)
- ストレージ: Amazon S3
背景:S3 の削除が、処理全体のボトルネックになった
このバッチは、テナント単位でデータを整理します。テナントごとに、DB の多数のテーブルを一括で処理し、そのテナントの S3 のプレフィックス配下のオブジェクトも削除します。
当初は、DB の一括処理と S3 の削除を、同じ処理の中で順に行っていました。ところが削除の対象のバケットが増えたことで、S3 の削除が処理全体のボトルネックになりました。
開発環境と検証環境での目安は次のとおりです。
| 処理 | かかった時間 |
|---|---|
| DB の一括処理だけ | 約 200 処理単位で 1 分 |
| S3 の削除 | 1 処理単位あたり約 5 分 |
DB の一括処理に比べて、S3 の削除は桁違いに遅いことが分かります。
S3 には「プレフィックスごと消す」API が無い
遅い理由は、S3 の API の作りにあります。
S3 には、プレフィックスを指定して配下をまとめて消す API がありません。消すには次の手順を踏む必要があります。
-
ListObjectsV2で、プレフィックス配下のキーを列挙する(1 回あたり最大 1,000 件で、続きはページネーションで取る) - 列挙したキーを、
DeleteObjectsでまとめて消す(1 回あたり最大 1,000 件) - 次のページがあれば、1 に戻る
時間の大半は、1 の列挙にかかりました。オブジェクトの数が多いと、列挙だけで長い時間がかかります。
DB の一括処理と S3 の削除は、制約がまったく違う
2 つの処理を見比べると、守るべき制約が正反対でした。
| DB の一括処理 | S3 の削除 | |
|---|---|---|
| サービスへの影響 | 多数のテーブルにロックをかける | 無い |
| 時間の制約 | 月 1 回のメンテナンス時間の中で終える必要がある | いつでも、時間をかけて実行できる |
| 速さ | 速い | 遅い(列挙に時間がかかる) |
1 つの処理にまとめていると、遅い S3 の削除に引きずられて、DB の一括処理もメンテナンス時間に収まらなくなります。ロックがメンテナンス時間を超えれば、サービスに影響します。
4 つの案を比べた
| 案 | 概要 | 評価 |
|---|---|---|
| 1. 同期削除 | DB の一括処理と同じ流れの中で、プレフィックス配下を列挙して消す | 実装は単純だが、メンテナンス時間の中で終わらない |
| 2. 別の実行モードで削除 | 1 と同じ削除処理を、DB の一括処理とは別の実行モードで動かす | 採用 |
| 3. ライフサイクルルール | プレフィックスごとに、期限切れで消すルールを設定する | ルールはバケットあたり 1,000 件までで、大量のプレフィックスに使えない |
| 4. タグベースの削除 | S3 インベントリとバッチオペレーションで削除用のタグを付け、ライフサイクルで消す | 大量の削除に向く。ただし Lambda・インベントリ・バッチオペレーションと AWS 側の構成が複雑になり、ローカルで再現できない |
案 3 と案 4 は、S3 の機能に削除を任せる案です。うまくはまれば速いのですが、案 3 はルール数の上限で使えず、案 4 は構成の重さとローカルで再現できないことがネックでした。
案 2 を選んだのは、次の 3 つの理由です。
- 既存の削除処理をそのまま使える。 案 1 の処理を、実行モードで分けるだけで済みます
- AWS 側に追加の構成が要らない。 Lambda もインベントリも要りません
- 時間がかかっても困らない。 S3 の削除はサービスに影響しないので、数日かかっても許容できます
削除の速さそのものは改善しません。対象が増えて許容できなくなったら、案 4 を再検討する、と判断の記録(ADR)に残しました。
実装:別の実行モードと、削除済みの記録
実行モードは、コマンドライン引数で切り替えます。DB の一括処理と S3 の削除は、別々に起動します。
# DB の一括処理(メンテナンス時間の中で実行)
batch --exec --batch-size 1000
# S3 の削除(いつでも実行できる)
batch --del-obj
S3 のオブジェクトを消し終えたかどうかは、DB 側のフラグで管理しています。DB の一括処理が済んでいて、まだ S3 のオブジェクトを消していないテナントだけが、S3 の削除の対象になります。途中で止まっても、次に実行したときは続きから消せます。
プレフィックス配下の削除は、列挙と削除を並行に進めます。
// プレフィックス配下を、列挙しながら並行して消す(説明用に簡略化したコード)
func deletePrefix(ctx context.Context, client *s3.Client, bucket, prefix string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // 同時に走らせる DeleteObjects の数を絞る
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix), // 1 ページは最大 1,000 件
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return err
}
ids := toObjectIdentifiers(page.Contents)
if len(ids) == 0 {
continue
}
// 次のページの列挙を待たずに、取れた分から消し始める
g.Go(func() error {
out, err := client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(bucket),
Delete: &types.Delete{Objects: ids},
})
if err != nil {
return err
}
// リクエストが成功しても、一部のキーだけ失敗することがある
if len(out.Errors) > 0 {
return fmt.Errorf("%d 件の削除に失敗", len(out.Errors))
}
return nil
})
}
return g.Wait()
}
ポイントは 3 つです。
- 次のページの列挙を待たずに削除を始める。 列挙が遅いので、取れた分から削除をゴルーチンに渡します
- 同時に走らせる削除の数を絞る。 削除の並列数に上限を付けています。プレフィックス単位の並列数にも、別に上限があります
-
全体にタイムアウトを付ける。 削除全体を
context.WithTimeout(6 時間)で包み、終わらない削除がいつまでも走り続けないようにしています
DeleteObjects は、リクエスト自体が成功しても、一部のキーだけ削除に失敗することがあります。応答に含まれる失敗したキーも、エラーとして拾うようにしています。
バッチサイズの上限は、計測で決めた
もう 1 つ決める必要があったのが、1 回に処理する件数(バッチサイズ)の上限です。バッチサイズは実行する人が引数で自由に指定できます。大きすぎる値を指定すると、2 つのリスクがあります。
- DB の一括処理の実行時間が読めず、メンテナンス時間を超えてロックがサービスに影響する
- S3 の削除で、列挙と削除がタイムアウトを超える
計測して決めた
上限を勘で決めず、バッチサイズごとの DB の一括処理の時間を計測しました。
| 処理単位(件) | DB の一括処理(分) |
|---|---|
| 250(デフォルト) | 1 |
| 500 | 4 |
| 1,000 | 10 |
| 1,500 | 7 |
| 2,000 | 30 |
1,000 件までは、10 分に収まりました。ところが 1,500 件以上では、処理時間が安定しませんでした。1,500 件は 7 分と 1,000 件より短く済んだ一方、2,000 件では 30 分と、1,000 件の 3 倍に跳ね上がっています。件数を増やしたときに、処理時間が読めなくなるということです。
安定する 1,000 件を、複数回に分けて実行する
この結果から、上限を 1,000 にしました。処理時間が安定しない件数で 1 回に済ませるより、安定する 1,000 件を複数回実行する方を選んでいます。メンテナンス時間の中で行う処理なので、速さよりも、時間を見積もれることを優先しました。
| 実行のしかた | 見込みの時間 |
|---|---|
| 1,000 件 × 2 回 | 約 20 分 |
| 2,000 件 × 1 回 | 30 分 |
見込みでは、分けて実行した方が合計の時間も短く済みます。途中で問題が起きたときに、やり直す範囲も小さくなります。
S3 の削除も、1 回の処理件数を同じ上限で絞っています。サービスへの影響は無いものの、1 回あたりの処理量を抑えて、タイムアウトを避けるためです。
上限を超える指定は、エラーにせず上限で実行する
上限を超える値が指定されたときは、エラーで止めずに、上限値で実行してログに出します。
// 上限を超える指定は、エラーにせず上限に丸めてログに残す(説明用に簡略化したコード)
const maxBatchSize = 1000
if batchSize > maxBatchSize {
log.Printf("batch size %d exceeds the limit; using %d", batchSize, maxBatchSize)
batchSize = maxBatchSize
}
エラーにすると、メンテナンス時間の中で実行し直す手間がかかります。上限で実行してログに残せば、作業を止めずに、指定と実際の値の違いを後から確かめられます。
計測はデータの偏りを考慮していないので、実際の運用で 1,000 件の処理が想定より長くかかる場合は、上限を見直すことにしています。
まとめ
| 判断 | 理由 |
|---|---|
| DB の一括処理と S3 の削除を、別の実行モードに分けた | 前者はメンテナンス時間に縛られ、後者はサービスに影響しない。制約が正反対 |
| S3 の機能(ライフサイクル・バッチオペレーション)に任せなかった | ルール数の上限と、構成の重さとローカルで再現できないこと |
| 削除済みのフラグで、続きから消せるようにした | 時間をかけて回す処理は、途中で止まる前提で作る |
| 列挙を待たずに削除を並行して始め、並列数とタイムアウトに上限を付けた | 時間の大半は列挙。終わらない削除を走らせ続けない |
| バッチサイズの上限を、計測して 1,000 に決めた | 1,500 件以上では処理時間が安定せず、見積もれない |
| 上限を超える指定は、エラーにせず上限で実行した | メンテナンス時間の中の作業を止めない |
学びは 2 つです。
- 制約が違う処理は、1 つにまとめない。 速さが求められる処理と、時間をかけてよい処理を同じ流れに入れると、遅い方に全体が縛られます。分ければ、それぞれに合った時間の使い方ができます
- 上限値は計測で決める。 「2,000 くらいなら大丈夫だろう」と決めていたら、処理時間が安定せず、メンテナンス時間に収まるかを見積もれないことに気づけませんでした。数字で決めた上限は、見直すときの基準にもなります