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も大きく劣らない速度で、実装コストは低い。