(↻更新) #aws #go #performance

Go での高効率で高速な S3 ダウンロードの実装


はじめに

AWS S3 から大容量ファイルをダウンロードする際は,メモリ使用量と処理時間が問題になります.
ローカルでは動いていたコードでも,メモリ制約のある環境では次のような問題が出ます.

  • OOMKiller
  • 想定以上の処理時間
  • リソース使用量の急激な増加

問題の原因

原因の多くはメモリの扱い方です.S3 から取得したデータを一度にメモリへ載せたり,オンメモリで処理できるのに /tmp に書き出したりすると,無駄が大きくなります.
100 GiB のファイルのために 100 GiB のメモリを用意するのは現実的ではありません.

本稿では,Go で S3 ダウンロードを効率化する実装を,実測値つきで比較します.

予備調査

まず,単純な実装である s3.GetObject で取得しオブジェクトを丸ごとメモリに乗せる場合,どれくらいのパフォーマンスか調査/測定しました.
この方法では,ファイルサイズと同等のメモリが必要になり,大容量ファイルでは実用的ではありません.

測定環境

  • S3 オブジェクトサイズ: 1.5 GiB (=1,604,197,053 bytes)
  • 実際の動画ファイルを使用 (非圧縮性データ)

ハードウェアリソース (結構リッチな構成ですね)

項目スペック
CPUIntel Core i5-13500 (6P+8E cores)
メモリ32 GiB (DDR5-5600)
ストレージ1 TB NVMe SSD
ネットワーク1 Gbps Ethernet

ソフトウェア環境

項目バージョン
Linux DistroFedora 41
Linux Kernel6.6.87
Go1.22.4

その他 Go モジュールバージョン.

https://github.com/jjjsmz/playground/blob/55b4fd75c5c1505b70d1108e6d8d09e9cd2bfce9/aws/s3go/go.mod

予備調査結果

パフォーマンス測定には,以下のコマンドを使用しました.

go test -bench=BenchmarkBadS3GetObject -benchmem -benchtime=1x
実行時間 (s)総メモリ割当量 (MiB)アロケーション数 (allocs)
120.61543.1203,067

アロケーション数は多くはないですが,総メモリ割当量はとても大きくなっています.

最適化に向けたアプローチ

AWS SDK for Go v2のアーキテクチャ

AWS SDK for Go v2 では,異なる抽象レベルで 2 種類の API が提供されています.

1. 低レベルAPI (Service Package)

import "github.com/aws/aws-sdk-go-v2/service/s3"
  • S3 API を直接操作
  • 細かい制御が可能だが,実装が複雑
  • e.g. s3.GetObject, s3.PutObject など

2. 高レベルAPI (Manager Package)

import "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
  • よく使われる操作を簡単に実行
  • 自動的な並列処理,リトライ,分割アップロード/ダウンロード
  • e.g. manager.Downloader, manager.Uploader など

大容量ファイルの場合,Manager Package の使用を推奨します.
Download Manager は以下の最適化を自動的に行います.

  • Range Request によるファイル分割
  • メモリ効率的なストリーミング
  • 自動エラーハンドリング (Part 単位での部分的な再試行)

実装例

以下が高効率なダウンロード実装例です.

性能測定と最適パラメータの決定

測定方法

パフォーマンス測定には go test -bench を使用します.
PartSize や並列数を変えて,どのようなパフォーマンスになるかを測定しました.

PartSizeの影響 (並列数は 1 に固定)

PartSize実行時間(s)メモリ使用量(MiB)アロケーション数
512 KiB226.92692,410,394
1 MiB185.11301,193,982
2 MiB152.366.3645,293
5 MiB128.027.9309,246
10 MiB99.215.1201,328

PartSize が大きいほど,実行時間・メモリ使用量・アロケーション数すべてが改善.

Concurrency の影響 (PartSize は 1 MiB に固定)

並列数実行時間(s)メモリ使用量(MiB)アロケーション数
1174.91331,195,747
2104.01311,196,784
459.31381,200,366
839.81321,208,494
1630.51331,218,981

並列数は,メモリ使用量への影響はほとんどない一方で,実行時間に大きな影響があった.

最適組み合わせの探索

PartSize並列数実行時間(s)メモリ使用量(MiB)
10 MiB1630.417.6
20 MiB1631.322.2
50 MiB1632.47.49
100 MiB1638.06.30
200 MiB1647.64.24

PartSize を大きくすると,予想に反してメモリ使用量は減少した一方で,実行時間は PartSize に比例し増加.

補足

AWS SDK for Go v2 のドキュメントでは「PartSize の最小値は 5MB」と記載されていますが1,実際にはそれ以下の値でも動作し,性能に影響があることが確認できました.

余談 単純なストリーミング読み込みの場合

s3.GetObject でメモリを確保しなかった場合の例.
s3.GetObject でも,io.Reader を使用してストリーミングで読み込むことで,メモリ効率を改善できます.

resp, err := s3Client.GetObject(ctx, &s3.GetObjectInput{...})
if err != nil {
    return err
}
defer resp.Body.Close()

// ストリーミングでファイルにコピー
_, err = io.Copy(file, resp.Body)  // メモリ効率が良い
実行時間 (s)総メモリ割当量 (MiB)アロケーション数 (allocs)
134.013.1200,063

この方法でも十分にメモリ効率は良いですが,並列処理による高速化は実現できません.計算リソースに余裕があり,大容量ファイルの場合は,Download Manager の使用を推奨します.

まとめ

S3 の大きなオブジェクトをダウンロードするなら,github.com/aws/aws-sdk-go-v2/feature/s3/manager が扱いやすいです.
ページサイズと並列数は実行環境に合わせて調整が必要なので,実際のワークロードで計測して決めるのが確実です.

Footnotes

  1. https://github.com/aws/aws-sdk-go-v2/blob/55b4fd75c5c1505b70d1108e6d8d09e9cd2bfce9/feature/s3/manager/download.go#L50