1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

10数万件のS3コピーに何分かかる?4手法で計測した

1
Last updated at Posted at 2026-06-16

S3バケット間コピー4手法の速度比較:PutObject vs CopyObject vs AWS CLI vs S3 Batch Operations

対象読者

  • S3バケット間の大量ファイルコピーについて実測値に興味がある
  • S3 Batch Operationsを実務で使おうとしている

前提技術

  • AWSでS3を使ったことがある
  • バッチ処理の設計をしたことがある
  • GoのAWS SDKのコードを読める(コードを読む場合)

はじめに

実務でユーザーごとのファイルを10数万件規模でS3バケット間コピーする必要があった。どの手法を選ぶべきか、またどのくらい時間がかかるのかを事前に把握したかったため、以下の4手法でベンチマークを取った。

  • PutObject:ローカルからS3へのアップロード。ベースラインとして計測
  • CopyObject:Go SDKを使いバケット間を直接コピー
  • AWS CLI cp:GoからAWS CLIのaws s3 cp --recursiveをサブプロセスとして呼び出してコピー
  • S3 Batch Operations:AWSのマネージドバッチコピーサービス

計測環境

項目
リージョン ap-northeast-1
コピー元バケット shrimp-parent-s3
コピー先バケット shrimp-child-s3
オブジェクト数 5,000
1ファイルサイズ 100 KiB
総データ量 488.28 MiB
キー構造 {company_id}/{user_id}/file-xx.json
会社数 13
ユーザー数 1,250
1ユーザーあたりのファイル数 4
実行環境 WSL(CPUコア数: 4)
会社単位の並行数 4(WSLのCPU数に合わせた)

PutObject・CopyObject・AWS CLI cpはどちらも「会社」を処理単位とし、最大4社を並行して処理する設計にした。各会社内のファイルは順次処理する。

手法1: PutObject(s3seed)

ローカルで生成したオブジェクトをPutObjectでS3にアップロードする。S3間のコピーではなく初期データ作成を兼ねているが、「ゼロから配置する場合のベースライン」として計測した。

internal/s3bench パッケージで以下を共通化している。

  • LoadAWSConfig:AWS設定のロード(リトライ回数を1回に固定)
  • NewS3Client:S3クライアントの生成
  • BuildObjectSpecs{company_id}/{user_id}/file-xx.json のキー構造でオブジェクト仕様を生成
  • BuildObjectBody:指定サイズのJSONボディを生成
s3seed/main.go
package main

import (
	"bytes"
	"context"
	"flag"
	"fmt"
	"slices"
	"strings"
	"sync"
	"time"

	"dynamodb_practice/internal/s3bench"

	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type seedConfig struct {
	bucket             string
	prefix             string
	region             string
	objectCount        int
	filesPerUser       int
	usersPerCompany    int
	objectSizeBytes    int
	companyConcurrency int
}

const defaultSeedBucket = "shrimp-parent-s3"

type companySeedResult struct {
	companyID          string
	objectCount        int
	totalBytes         int64
	duration           time.Duration
	objectsPerSecond   float64
	megabytesPerSecond float64
}

func main() {
	cfg := parseFlags()
	ctx := context.Background()

	awsCfg, err := s3bench.LoadAWSConfig(ctx, cfg.region)
	if err != nil {
		panic(err)
	}

	client := s3bench.NewS3Client(awsCfg, cfg.region)
	specs := s3bench.BuildObjectSpecs(
		cfg.objectCount,
		cfg.filesPerUser,
		cfg.usersPerCompany,
		cfg.objectSizeBytes,
	)

	grouped := groupByCompany(specs)
	startedAt := time.Now()
	results := runSeed(ctx, client, cfg, grouped)
	totalDuration := time.Since(startedAt)

	printResults(cfg, results, totalDuration)
}

func parseFlags() seedConfig {
	cfg := seedConfig{}

	flag.StringVar(&cfg.bucket, "bucket", defaultSeedBucket, "target S3 bucket")
	flag.StringVar(&cfg.prefix, "prefix", "", "target key prefix")
	flag.StringVar(&cfg.region, "region", "ap-northeast-1", "AWS region")
	flag.IntVar(&cfg.objectCount, "object-count", s3bench.DefaultObjectCount, "number of objects")
	flag.IntVar(&cfg.filesPerUser, "files-per-user", s3bench.DefaultFilesPerUser, "files per user")
	flag.IntVar(&cfg.usersPerCompany, "users-per-company", s3bench.DefaultUsersPerCompany, "users per company")
	flag.IntVar(&cfg.objectSizeBytes, "object-size-bytes", s3bench.DefaultObjectSizeBytes, "size of each object in bytes")
	flag.IntVar(&cfg.companyConcurrency, "company-concurrency", 4, "number of companies to upload in parallel")
	flag.Parse()

	if cfg.companyConcurrency < 1 {
		panic("company-concurrency must be >= 1")
	}

	return cfg
}

func groupByCompany(specs []s3bench.ObjectSpec) [][]s3bench.ObjectSpec {
	order := make([]string, 0)
	grouped := make(map[string][]s3bench.ObjectSpec)

	for _, spec := range specs {
		if _, ok := grouped[spec.CompanyID]; !ok {
			order = append(order, spec.CompanyID)
		}
		grouped[spec.CompanyID] = append(grouped[spec.CompanyID], spec)
	}

	result := make([][]s3bench.ObjectSpec, 0, len(order))
	for _, companyID := range order {
		result = append(result, grouped[companyID])
	}

	return result
}

func runSeed(
	ctx context.Context,
	client *s3.Client,
	cfg seedConfig,
	grouped [][]s3bench.ObjectSpec,
) []companySeedResult {
	companyCh := make(chan []s3bench.ObjectSpec)
	resultCh := make(chan companySeedResult, len(grouped))

	var wg sync.WaitGroup
	for range cfg.companyConcurrency {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for companySpecs := range companyCh {
				startedAt := time.Now()
				var totalBytes int64

				for _, spec := range companySpecs {
					body := s3bench.BuildObjectBody(spec)
					_, err := client.PutObject(ctx, &s3.PutObjectInput{
						Bucket:        &cfg.bucket,
						Key:           stringPtr(joinKey(cfg.prefix, spec.Key)),
						Body:          bytes.NewReader(body),
						ContentLength: int64Ptr(int64(len(body))),
						ContentType:   stringPtr("application/json"),
					})
					if err != nil {
						panic(err)
					}

					totalBytes += int64(len(body))
				}

				duration := time.Since(startedAt)
				resultCh <- companySeedResult{
					companyID:          companySpecs[0].CompanyID,
					objectCount:        len(companySpecs),
					totalBytes:         totalBytes,
					duration:           duration,
					objectsPerSecond:   float64(len(companySpecs)) / duration.Seconds(),
					megabytesPerSecond: bytesToMiB(totalBytes) / duration.Seconds(),
				}
			}
		}()
	}

	for _, companySpecs := range grouped {
		companyCh <- companySpecs
	}
	close(companyCh)

	wg.Wait()
	close(resultCh)

	results := make([]companySeedResult, 0, len(grouped))
	for result := range resultCh {
		results = append(results, result)
	}

	slices.SortFunc(results, func(a, b companySeedResult) int {
		return strings.Compare(a.companyID, b.companyID)
	})

	return results
}

func printResults(cfg seedConfig, results []companySeedResult, totalDuration time.Duration) {
	// ... 省略
}

func bytesToMiB(sizeBytes int64) float64 {
	return float64(sizeBytes) / (1024 * 1024)
}

func joinKey(prefix, key string) string {
	if prefix == "" {
		return key
	}
	return fmt.Sprintf("%s/%s", trimSlash(prefix), key)
}

func trimSlash(value string) string {
	for len(value) > 0 && value[0] == '/' {
		value = value[1:]
	}
	for len(value) > 0 && value[len(value)-1] == '/' {
		value = value[:len(value)-1]
	}
	return value
}

func stringPtr(v string) *string { return &v }
func int64Ptr(v int64) *int64   { return &v }

計測結果:

seed completed
bucket=shrimp-parent-s3 prefix= region=ap-northeast-1 company_concurrency=4
companies=13 objects=5000 total_mib=488.28 total_seconds=441.001 effective_mib_per_sec=1.11 effective_objects_per_sec=11.34
company_seconds avg=129.574 min=31.771 max=148.409
company_mib_per_sec avg=0.31 min=0.26 max=0.61
company_objects_per_sec avg=3.17 min=2.70 max=6.29
  • 所要時間: 441.001 秒
  • スループット: 11.34 objects/s、1.11 MiB/s

手法2: CopyObject(s3copybench)

S3 APIのCopyObjectを使いバケット間を直接コピーする。s3seedと同様に会社単位で並行処理する実装。コピー元バケット内のオブジェクト一覧をListObjectsV2で取得してからコピーする。

s3copybench/main.go
package main

import (
	"context"
	"flag"
	"fmt"
	"slices"
	"strings"
	"sync"
	"time"

	"dynamodb_practice/internal/s3bench"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type copyConfig struct {
	sourceBucket       string
	sourcePrefix       string
	destinationBucket  string
	destinationPrefix  string
	region             string
	companyConcurrency int
}

const (
	defaultSourceBucket      = "shrimp-parent-s3"
	defaultDestinationBucket = "shrimp-child-s3"
)

type objectToCopy struct {
	companyID string
	sourceKey string
	targetKey string
	sizeBytes int64
}

type companyResult struct {
	companyID          string
	objectCount        int
	totalBytes         int64
	duration           time.Duration
	objectsPerSecond   float64
	megabytesPerSecond float64
}

func main() {
	cfg := parseFlags()
	ctx := context.Background()

	awsCfg, err := s3bench.LoadAWSConfig(ctx, cfg.region)
	if err != nil {
		panic(err)
	}

	client := s3bench.NewS3Client(awsCfg, cfg.region)
	companyObjects, err := listCompanyObjects(ctx, client, cfg)
	if err != nil {
		panic(err)
	}

	companyIDs := make([]string, 0, len(companyObjects))
	for companyID := range companyObjects {
		companyIDs = append(companyIDs, companyID)
	}
	slices.Sort(companyIDs)

	startedAt := time.Now()
	results := runCopy(ctx, client, cfg, companyIDs, companyObjects)
	totalDuration := time.Since(startedAt)

	printResults(cfg, results, totalDuration)
}

func parseFlags() copyConfig {
	cfg := copyConfig{}

	flag.StringVar(&cfg.sourceBucket, "source-bucket", defaultSourceBucket, "source S3 bucket")
	flag.StringVar(&cfg.sourcePrefix, "source-prefix", "", "source key prefix")
	flag.StringVar(&cfg.destinationBucket, "destination-bucket", defaultDestinationBucket, "destination S3 bucket")
	flag.StringVar(&cfg.destinationPrefix, "destination-prefix", "", "destination key prefix")
	flag.StringVar(&cfg.region, "region", "ap-northeast-1", "AWS region")
	flag.IntVar(&cfg.companyConcurrency, "company-concurrency", 4, "number of companies to copy in parallel")
	flag.Parse()

	if cfg.companyConcurrency < 1 {
		panic("company-concurrency must be >= 1")
	}

	return cfg
}

func listCompanyObjects(ctx context.Context, client *s3.Client, cfg copyConfig) (map[string][]objectToCopy, error) {
	prefix := normalizedPrefix(cfg.sourcePrefix)
	input := &s3.ListObjectsV2Input{
		Bucket: aws.String(cfg.sourceBucket),
	}
	if prefix != "" {
		input.Prefix = aws.String(prefix)
	}

	paginator := s3.NewListObjectsV2Paginator(client, input)
	result := make(map[string][]objectToCopy)

	for paginator.HasMorePages() {
		page, err := paginator.NextPage(ctx)
		if err != nil {
			return nil, err
		}

		for _, object := range page.Contents {
			if object.Key == nil {
				continue
			}

			relativeKey := strings.TrimPrefix(*object.Key, prefix)
			relativeKey = strings.TrimPrefix(relativeKey, "/")
			parts := strings.Split(relativeKey, "/")
			if len(parts) < 3 {
				continue
			}

			companyID := parts[0]
			targetKey := joinKey(cfg.destinationPrefix, relativeKey)

			result[companyID] = append(result[companyID], objectToCopy{
				companyID: companyID,
				sourceKey: *object.Key,
				targetKey: targetKey,
				sizeBytes: aws.ToInt64(object.Size),
			})
		}
	}

	return result, nil
}

func runCopy(
	ctx context.Context,
	client *s3.Client,
	cfg copyConfig,
	companyIDs []string,
	companyObjects map[string][]objectToCopy,
) []companyResult {
	companyCh := make(chan string)
	resultCh := make(chan companyResult, len(companyIDs))

	var wg sync.WaitGroup
	for range cfg.companyConcurrency {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for companyID := range companyCh {
				objects := companyObjects[companyID]
				startedAt := time.Now()

				var totalBytes int64
				for _, object := range objects {
					copySource := cfg.sourceBucket + "/" + object.sourceKey
					_, err := client.CopyObject(ctx, &s3.CopyObjectInput{
						Bucket:     aws.String(cfg.destinationBucket),
						Key:        aws.String(object.targetKey),
						CopySource: aws.String(copySource),
					})
					if err != nil {
						panic(err)
					}
					totalBytes += object.sizeBytes
				}

				duration := time.Since(startedAt)
				resultCh <- companyResult{
					companyID:          companyID,
					objectCount:        len(objects),
					totalBytes:         totalBytes,
					duration:           duration,
					objectsPerSecond:   float64(len(objects)) / duration.Seconds(),
					megabytesPerSecond: bytesToMiB(totalBytes) / duration.Seconds(),
				}
			}
		}()
	}

	for _, companyID := range companyIDs {
		companyCh <- companyID
	}
	close(companyCh)

	wg.Wait()
	close(resultCh)

	results := make([]companyResult, 0, len(companyIDs))
	for result := range resultCh {
		results = append(results, result)
	}

	slices.SortFunc(results, func(a, b companyResult) int {
		return strings.Compare(a.companyID, b.companyID)
	})

	return results
}

func printResults(cfg copyConfig, results []companyResult, totalDuration time.Duration) {
	// ... 省略
}

func bytesToMiB(sizeBytes int64) float64 {
	return float64(sizeBytes) / (1024 * 1024)
}

func normalizedPrefix(prefix string) string {
	if prefix == "" {
		return ""
	}
	return trimSlash(prefix) + "/"
}

func joinKey(prefix, key string) string {
	if prefix == "" {
		return key
	}
	return trimSlash(prefix) + "/" + key
}

func trimSlash(value string) string {
	value = strings.TrimPrefix(value, "/")
	value = strings.TrimSuffix(value, "/")
	return value
}

計測結果:

copy completed
source=shrimp-parent-s3/ destination=shrimp-child-s3/ region=ap-northeast-1 company_concurrency=4
companies=13 objects=5000 total_mib=488.28 total_seconds=166.704 effective_mib_per_sec=2.93 effective_objects_per_sec=29.99
company_seconds avg=45.938 min=24.148 max=50.033
company_mib_per_sec avg=0.82 min=0.78 max=0.84
company_objects_per_sec avg=8.37 min=7.99 max=8.61
  • 所要時間: 166.704 秒
  • スループット: 29.99 objects/s、2.93 MiB/s

手法3: AWS CLI cp(s3clicopybench)

会社単位の並行管理はGoで行いつつ、実際のコピーはaws s3 cp --recursiveをサブプロセスとして呼び出す。CLIが会社内のファイルのリスト取得・並列コピーを自動で処理するため、Go側の実装は会社の振り分けのみに絞られる。

s3clicopybench/main.go
package main

import (
	"context"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"slices"
	"strings"
	"sync"
	"time"

	"dynamodb_practice/internal/s3bench"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type copyConfig struct {
	sourceBucket       string
	sourcePrefix       string
	destinationBucket  string
	destinationPrefix  string
	region             string
	companyConcurrency int
}

const (
	defaultSourceBucket      = "shrimp-parent-s3"
	defaultDestinationBucket = "shrimp-child-s3"
)

type objectToCopy struct {
	companyID string
	sourceKey string
	sizeBytes int64
}

type companyResult struct {
	companyID          string
	objectCount        int
	totalBytes         int64
	duration           time.Duration
	objectsPerSecond   float64
	megabytesPerSecond float64
}

func main() {
	cfg := parseFlags()
	ctx := context.Background()

	awsCfg, err := s3bench.LoadAWSConfig(ctx, cfg.region)
	if err != nil {
		panic(err)
	}

	client := s3bench.NewS3Client(awsCfg, cfg.region)
	companyObjects, err := listCompanyObjects(ctx, client, cfg)
	if err != nil {
		panic(err)
	}

	companyIDs := make([]string, 0, len(companyObjects))
	for companyID := range companyObjects {
		companyIDs = append(companyIDs, companyID)
	}
	slices.Sort(companyIDs)

	startedAt := time.Now()
	results := runCopy(ctx, cfg, companyIDs, companyObjects)
	totalDuration := time.Since(startedAt)

	printResults(cfg, results, totalDuration)
}

func parseFlags() copyConfig {
	cfg := copyConfig{}

	flag.StringVar(&cfg.sourceBucket, "source-bucket", defaultSourceBucket, "source S3 bucket")
	flag.StringVar(&cfg.sourcePrefix, "source-prefix", "", "source key prefix")
	flag.StringVar(&cfg.destinationBucket, "destination-bucket", defaultDestinationBucket, "destination S3 bucket")
	flag.StringVar(&cfg.destinationPrefix, "destination-prefix", "", "destination key prefix")
	flag.StringVar(&cfg.region, "region", "ap-northeast-1", "AWS region")
	flag.IntVar(&cfg.companyConcurrency, "company-concurrency", 4, "number of companies to copy in parallel")
	flag.Parse()

	if cfg.companyConcurrency < 1 {
		panic("company-concurrency must be >= 1")
	}

	return cfg
}

func listCompanyObjects(ctx context.Context, client *s3.Client, cfg copyConfig) (map[string][]objectToCopy, error) {
	prefix := normalizedPrefix(cfg.sourcePrefix)
	input := &s3.ListObjectsV2Input{
		Bucket: aws.String(cfg.sourceBucket),
	}
	if prefix != "" {
		input.Prefix = aws.String(prefix)
	}

	paginator := s3.NewListObjectsV2Paginator(client, input)
	result := make(map[string][]objectToCopy)

	for paginator.HasMorePages() {
		page, err := paginator.NextPage(ctx)
		if err != nil {
			return nil, err
		}

		for _, object := range page.Contents {
			if object.Key == nil {
				continue
			}

			relativeKey := strings.TrimPrefix(*object.Key, prefix)
			relativeKey = strings.TrimPrefix(relativeKey, "/")
			parts := strings.Split(relativeKey, "/")
			if len(parts) < 3 {
				continue
			}

			companyID := parts[0]
			result[companyID] = append(result[companyID], objectToCopy{
				companyID: companyID,
				sourceKey: *object.Key,
				sizeBytes: aws.ToInt64(object.Size),
			})
		}
	}

	return result, nil
}

func runCopy(
	ctx context.Context,
	cfg copyConfig,
	companyIDs []string,
	companyObjects map[string][]objectToCopy,
) []companyResult {
	companyCh := make(chan string)
	resultCh := make(chan companyResult, len(companyIDs))

	var wg sync.WaitGroup
	for range cfg.companyConcurrency {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for companyID := range companyCh {
				objects := companyObjects[companyID]
				startedAt := time.Now()

				if err := runAWSCLI(ctx, cfg, companyID); err != nil {
					panic(err)
				}

				var totalBytes int64
				for _, object := range objects {
					totalBytes += object.sizeBytes
				}

				duration := time.Since(startedAt)
				resultCh <- companyResult{
					companyID:          companyID,
					objectCount:        len(objects),
					totalBytes:         totalBytes,
					duration:           duration,
					objectsPerSecond:   float64(len(objects)) / duration.Seconds(),
					megabytesPerSecond: bytesToMiB(totalBytes) / duration.Seconds(),
				}
			}
		}()
	}

	for _, companyID := range companyIDs {
		companyCh <- companyID
	}
	close(companyCh)

	wg.Wait()
	close(resultCh)

	results := make([]companyResult, 0, len(companyIDs))
	for result := range resultCh {
		results = append(results, result)
	}

	slices.SortFunc(results, func(a, b companyResult) int {
		return strings.Compare(a.companyID, b.companyID)
	})

	return results
}

func runAWSCLI(ctx context.Context, cfg copyConfig, companyID string) error {
	srcPrefix := joinKey(cfg.sourcePrefix, companyID)
	dstPrefix := joinKey(cfg.destinationPrefix, companyID)

	srcURI := fmt.Sprintf("s3://%s/%s/", cfg.sourceBucket, trimSlash(srcPrefix))
	dstURI := fmt.Sprintf("s3://%s/%s/", cfg.destinationBucket, trimSlash(dstPrefix))

	cmd := exec.CommandContext(
		ctx,
		"aws",
		"s3",
		"cp",
		srcURI,
		dstURI,
		"--recursive",
		"--region",
		cfg.region,
	)
	cmd.Env = filteredAWSEnv(cfg.region)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	return cmd.Run()
}

func filteredAWSEnv(region string) []string {
	env := make([]string, 0, len(os.Environ())+1)
	for _, entry := range os.Environ() {
		switch {
		case strings.HasPrefix(entry, "AWS_ENDPOINT_URL="):
			continue
		case strings.HasPrefix(entry, "AWS_ENDPOINT_URL_S3="):
			continue
		case strings.HasPrefix(entry, "AWS_DEFAULT_REGION="):
			continue
		default:
			env = append(env, entry)
		}
	}

	env = append(env, "AWS_DEFAULT_REGION="+region)
	return env
}

func printResults(cfg copyConfig, results []companyResult, totalDuration time.Duration) {
	// ... 省略
}

func bytesToMiB(sizeBytes int64) float64 {
	return float64(sizeBytes) / (1024 * 1024)
}

func normalizedPrefix(prefix string) string {
	if prefix == "" {
		return ""
	}
	return trimSlash(prefix) + "/"
}

func joinKey(prefix, key string) string {
	if prefix == "" {
		return key
	}
	return trimSlash(prefix) + "/" + key
}

func trimSlash(value string) string {
	value = strings.TrimPrefix(value, "/")
	value = strings.TrimSuffix(value, "/")
	return value
}

計測結果:

cli copy completed
source=shrimp-parent-s3/ destination=shrimp-child-s3/ region=ap-northeast-1 company_concurrency=4
companies=13 objects=5000 total_mib=488.28 total_seconds=19.886 effective_mib_per_sec=24.55 effective_objects_per_sec=251.43
company_seconds avg=5.334 min=3.576 max=6.355
company_mib_per_sec avg=7.06 min=5.46 max=7.89
company_objects_per_sec avg=72.33 min=55.93 max=80.77
  • 所要時間: 19.886 秒
  • スループット: 251.43 objects/s、24.55 MiB/s

手法4: S3 Batch Operations

セットアップ

前提

  • コピー対象: shrimp-parent-s3 直下の全オブジェクト
  • マニフェスト: 固定CSVを使用
  • completionレポートの出力先: shrimp-child-s3

接続先の確認

aws sts get-caller-identity --region ap-northeast-1
aws s3api head-bucket --bucket shrimp-parent-s3 --region ap-northeast-1
aws s3api head-bucket --bucket shrimp-child-s3 --region ap-northeast-1

manifest.csv の作成

マニフェストは Bucket,Key の2列CSVにする。

aws s3api list-objects-v2 \
  --bucket shrimp-parent-s3 \
  --region ap-northeast-1 \
  --output json \
| jq -r '.Contents[].Key | "shrimp-parent-s3,\(.)"' \
> manifest.csv

行数と形式を確認する。

wc -l manifest.csv
sed -n '1,5p' manifest.csv
tail -n 5 manifest.csv
awk -F, 'NF!=2 {print NR ":" $0}' manifest.csv | head

期待値:

  • 行数が対象オブジェクト数と一致すること
  • 各行が shrimp-parent-s3,company-xxxx/... の形式であること
  • awk のエラー出力が空であること

manifest を S3 にアップロード

aws s3 cp manifest.csv \
  s3://shrimp-child-s3/_batch/manifest/manifest.csv \
  --region ap-northeast-1

ジョブ作成時にETagが必要なので取得しておく。

aws s3api head-object \
  --bucket shrimp-child-s3 \
  --key _batch/manifest/manifest.csv \
  --region ap-northeast-1 \
  --query ETag \
  --output text

IAMロールの作成

S3 Batch OperationsがAssumeRoleできる信頼ポリシーを設定する。

信頼ポリシー
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowS3BatchOperationsAssumeRole",
      "Effect": "Allow",
      "Principal": {
        "Service": "batchoperations.s3.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
許可ポリシー(インラインポリシー)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadSourceObjects",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:GetObjectAcl",
        "s3:GetObjectTagging"
      ],
      "Resource": "arn:aws:s3:::shrimp-parent-s3/*"
    },
    {
      "Sid": "ReadManifestObject",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion"
      ],
      "Resource": "arn:aws:s3:::shrimp-child-s3/_batch/manifest/*"
    },
    {
      "Sid": "WriteCopiedObjects",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:PutObjectAcl",
        "s3:PutObjectTagging"
      ],
      "Resource": "arn:aws:s3:::shrimp-child-s3/*"
    },
    {
      "Sid": "WriteCompletionReports",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "arn:aws:s3:::shrimp-child-s3/_batch/report/*"
    },
    {
      "Sid": "ReadBucketMetadata",
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketLocation",
        "s3:GetBucketAcl"
      ],
      "Resource": [
        "arn:aws:s3:::shrimp-parent-s3",
        "arn:aws:s3:::shrimp-child-s3"
      ]
    }
  ]
}

ジョブの作成(AWSコンソール)

Amazon S3 > Batch Operations > Create job を開き、以下を設定する。

項目
Region ap-northeast-1
Object list Use an existing manifest
Manifest location shrimp-child-s3/_batch/manifest/manifest.csv
Manifest ETag 先ほど取得したETag
Operation Copy
Destination bucket shrimp-child-s3
コピー先prefix なし
Completion report bucket shrimp-child-s3
Completion report prefix _batch/report/
IAM role 作成したロール

コピー先のキーはコピー元と同じパスになる。

  • source: shrimp-parent-s3/company-0001/user-0001/file-01.json
  • destination: shrimp-child-s3/company-0001/user-0001/file-01.json

_batch/manifest/_batch/report/ はジョブ管理用であり、コピー本体の保存先ではない。

実行と結果確認

コンソールでジョブを送信し、確認画面でそのまま実行する。

Submit job → Confirm and run

CLIで結果を確認する。

aws s3control describe-job \
  --region ap-northeast-1 \
  --account-id <YOUR_ACCOUNT_ID> \
  --job-id <JOB_ID>

主に確認する項目:

項目 説明
Status Complete であれば正常終了
NumberOfTasksSucceeded 成功件数
NumberOfTasksFailed 失敗件数
ElapsedTimeInActiveSeconds 実際のコピー処理にかかった秒数
CreationTime / TerminationDate 壁時計ベースの開始〜終了時刻

速度の比較には ElapsedTimeInActiveSeconds を採用した。CreationTime から TerminationDate の差分にはジョブの起動オーバーヘッドが含まれるため、コピー処理自体の速度比較には適していない。

今回の計測値:

JobId: 668800f4-75a7-4dec-bc14-84bb08e87fc4
Status: Complete
TotalNumberOfTasks: 5000
NumberOfTasksSucceeded: 5000
NumberOfTasksFailed: 0
ElapsedTimeInActiveSeconds: 15
CreationTime: 2026-06-16T09:29:28.476Z
TerminationDate: 2026-06-16T09:30:31.754Z
  • 所要時間: 15 秒(ElapsedTimeInActiveSeconds)
  • スループット: 333.33 objects/s、32.55 MiB/s
  • 参考(壁時計ベース): 63.3秒 / 79.02 objects/s / 7.72 MiB/s

結果比較

所要時間/スループット比較

手法 所要時間 objects/s MiB/s
PutObject (s3seed) 441.001 s 11.34 1.11
CopyObject SDK (s3copybench) 166.704 s 29.99 2.93
AWS CLI cp (s3clicopybench) 19.886 s 251.43 24.55
S3 Batch Operations 15 s 333.33 32.55
  • CopyObject SDK は PutObject の約 2.6倍 高速
  • AWS CLI cp は CopyObject SDK の約 8.4倍 高速
  • S3 Batch Operations は AWS CLI cp の約 1.3倍 高速

コスト概算(2026年6月時点、ap-northeast-1)

手法 5,000件 100,000件 備考
PutObject ~$0.024 ~$0.47 PUTリクエスト料金のみ
CopyObject SDK ~$0.024 ~$0.47 COPYリクエスト料金のみ
AWS CLI cp ~$0.024 ~$0.47 同上
S3 Batch Operations ~$0.28 ~$0.82 $0.25(ジョブ固定費)+ $0.005(5,000件のオブジェクト処理費)+ COPYリクエスト)

前提となる料金:

  • PUT / COPY リクエスト: $0.0047 / 1,000リクエスト
  • S3 Batch Operations ジョブ: $0.25 / ジョブ(固定)
  • S3 Batch Operations オブジェクト処理: $1.00 / 100万オブジェクト
  • 同一リージョン内のデータ転送・S3へのデータ転送IN: 無料

PutObject / CopyObject / AWS CLI cpは件数に比例してリニアに増える。S3 Batch Operationsはジョブ固定費$0.25が常にかかるため少量では割高で、件数が増えるにつれて差は縮まる。

トレードオフ

件数が増えるとどうなるか

今回の計測値(5,000件)から線形スケールで外挿した参考値。

手法 5,000件 50,000件 100,000件 150,000件
PutObject 7.4分 73.5分 147分 220分
CopyObject SDK 2.8分 27.8分 55.6分 83.4分
AWS CLI cp 0.3分 3.3分 6.6分 9.9分
S3 Batch Operations 0.3分 2.5分 5分 7.5分

※ 線形スケールを仮定した参考値。実際の性能はバケットの状態・ネットワーク・スロットリング等によって変わる。

AWS CLI cpとS3 Batch Operationsの差は今回の計測では1.3倍にとどまった。件数が増えてもこの傾向が続くとすれば、セットアップの手間を考えるとAWS CLI cpも十分な選択肢になりうる。

Go SDKとAWS CLIの実装コストの差

CopyObject SDK実装では、ファイルレベルの並列化・リトライ・進捗管理を自前で書く必要がある。今回の実装は会社単位の並行のみで、会社内のファイルは逐次処理しているため、CLIに比べてスループットが低い。

AWS CLI cp(aws s3 cp --recursive)はこれらを自動で処理するため、Goの実装はCLIの呼び出しと会社単位の並行管理に集中できる。

S3 Batch Operationsを選ぶ理由

S3 Batch Operationsはジョブ単位で実行・監視・リトライができるため、大規模コピーの運用管理に向いている。一方でマニフェストの作成・IAMロール設定・ジョブ管理といった事前準備が必要になる。アプリケーションのコードフローからトリガーしたい場合は、ジョブ作成APIを呼び出す実装が別途必要になる点も考慮が必要。

まとめ

5,000件の計測では、AWS CLI cpとS3 Batch Operationsが近い速度(19.9秒 vs 15秒)で、CopyObject SDKの約8〜11倍高速だった。

10数万件規模では、AWS CLI cpで10分以内、S3 Batch Operationsで7〜8分以内に収まる見込みとなった。セットアップの手間を許容できるならS3 Batch Operationsが最速だが、AWS CLI cpも大きく劣らない速度で、実装コストは低い。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?