2025-08-02 (↻2025-08-06更新) #aws #go

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


はじめに

AWS S3 から大容量ファイルをダウンロードする際,メモリ不足や処理時間の問題が問題になることがあります.

ローカル環境では問題なく動作していたコードが,メモリ制約のあるクラウド環境で実行すると以下のような問題が発生することがあります.

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

問題の原因

先述の問題が発生しているとき,多くはメモリの扱い方に問題があります.
S3 から取得したデータを一度にメモリ上に確保してしまっていたり,オンメモリで処理可能にもかかわらず,一度 /tmp に書き出してから処理を行うなどの非効率な実装が主な原因です.

もし,処理したいファイルが 100 GiB あった場合,100 GiB のメモリを用意するなんてことはコストの面でも現実的ではありません.

本記事では,Go 言語における高効率で高速な S3 ダウンロードの実装手法について,実測値を交えて説明します.

予備調査

まず,単純な実装である s3.GetObject で取得しオブジェクトを丸ごとメモリに乗せる場合,どれくらいのパフォーマンスか調査/測定しました.

bad_example.go
// とても非効率な実装
func badGetObject(ctx context.Context, s3Client *s3.Client, bucketName, objectKey, filename string) error {
	result, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
		Bucket: aws.String(bucketName),
		Key:    aws.String(objectKey),
	})
	if err != nil {
		return fmt.Errorf("failed to download object: %w", err)
	}
	defer result.Body.Close()

	fileSize := result.ContentLength
	buffer := make([]byte, *fileSize)  // ここでメモリ使用量が急増
    totalRead := 0
	for {
		n, err := result.Body.Read(buffer[totalRead:])

	...

この方法では,ファイルサイズと同等のメモリが必要になり,大容量ファイルでは実用的ではありません.

実装はこちらにあります.GitHub

測定環境

  • 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 モジュールバージョン

予備調査結果

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

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 単位での部分的な再試行)

実装例

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

type BucketBasics struct {
	S3Client   *s3.Client
	Downloader *manager.Downloader
}

func (b *BucketBasics) StreamDL(ctx context.Context, bucketName, objectKey, filename string) error {
	file, err := os.Create(filename)
	if err != nil {
		return fmt.Errorf("failed to create file: %w", err)
	}
	defer file.Close()

	// stream download
	_, err = b.Downloader.Download(ctx, file, &s3.GetObjectInput{
		Bucket: aws.String(bucketName),
		Key:    aws.String(objectKey),
	})
	if err != nil {
		return fmt.Errorf("failed to download object: %w", err)
	}

	if fileInfo, err := os.Stat(filename); err == nil {
		log.Printf("Downloaded %d bytes to %s", fileInfo.Size(), filename)
	}

	return nil
}

実際に実装したのがこちらです.GitHub

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

測定方法

パフォーマンス測定には 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 の使用を推奨します.

GitHub

まとめ

S3 の大きなオブジェクトのダウンロードでは,github.com/aws/aws-sdk-go-v2/feature/s3/manager を使用することをお勧めします.
ページサイズや並列数は使用する環境によって調整が必要だが,ページサイズは大きくするほど,メモリ割当回数が小さくなり,オーバヘッドは小さくなる傾向があります.
並列数も大きくするほど,総メモリ割当量が小さくなる傾向があります.

ただし,実行環境によって最適なパラメータは異なるため,実際のアプリケーションが動作する環境でパフォーマンステストを実施し,最適なパラメータを見つけることが重要です.

Footnotes

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