AWSCLI、Python(boto3)などからS3フォルダ間のコピーしてみる

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

先日のS3フォルダの削除に引き続き、S3フォルダをコピーしてみます。先日の削除と同様にコピー対象のオブジェクトのリストが取得した後、個々のオブジェクトをコピーするプロブラムを書く必要がありました。これも毎回関数を作成するのは面倒なので、いざというときに備えて作成しましたのでご紹介します。

検証用データ

以下のような階層のデータを事前に作成しました。

$ aws s3 ls s3://s3-copy-folder/ --recursive
2018-06-29 20:24:28          0 source-folder/
2018-06-29 21:08:40          0 source-folder/source-sub-folder/
2018-06-29 21:09:28          0 source-folder/source-sub-folder/test-sub.txt
2018-06-29 20:27:08          0 source-folder/test.txt

AWSCLIでS3フォルダのコピーする

サブコマンドcpによるS3フォルダのコピー

S3フォルダ名を指定するとエラーになり、コピーができません。

$ aws s3 cp s3://s3-copy-folder/source-folder s3://s3-delete-folder/target-folder
fatal error: An error occurred (404) when calling the HeadObject operation: Key "source-folder" does not exist

S3フォルダ名を指定してコピーするには、--recursiveを指定します。S3フォルダごとコピーされたことが確認できます。

$ aws s3 cp s3://s3-copy-folder/source-folder s3://s3-delete-folder/target-folder --recursive
copy: s3://s3-copy-folder/source-folder/test.txt to s3://s3-delete-folder/target-folder/test.txt
copy: s3://s3-copy-folder/source-folder/source-sub-folder/test-sub.txt to s3://s3-delete-folder/target-folder/source-sub-folder/test-sub.txt

2999ファイルのコピー時間を計測します。

$ time aws s3 cp s3://s3-delete-folder/my_folder/folder_1 s3://s3-delete-folder/target-folder --recursive
copy: s3://s3-delete-folder/my_folder/folder_1/test_1001 to s3://s3-delete-folder/target-folder/test_1001
copy: s3://s3-delete-folder/my_folder/folder_1/aaaa_1.txt to s3://s3-delete-folder/target-folder/aaaa_1.txt
copy: s3://s3-delete-folder/my_folder/folder_1/test_1 to s3://s3-delete-folder/target-folder/test_1
:
copy: s3://s3-delete-folder/my_folder/folder_1/test_989 to s3://s3-delete-folder/target-folder/test_989

real    0m28.670s
user    0m12.608s
sys 0m1.201s

サブコマンドsyncによるS3フォルダのコピー

syncは、2つのフォルダ間のオブジェクトを同期します。同じく、2999ファイルのコピー時間を計測しましたが、速度はサブコマンドcpと殆ど差がありません。

$ time aws s3 sync s3://s3-delete-folder/my_folder/folder_1 s3://s3-delete-folder/target-folder
copy: s3://s3-delete-folder/my_folder/folder_1/aaaa_1.txt to s3://s3-delete-folder/target-folder/aaaa_1.txt
copy: s3://s3-delete-folder/my_folder/folder_1/test_1002 to s3://s3-delete-folder/target-folder/test_1002
copy: s3://s3-delete-folder/my_folder/folder_1/test_1006 to s3://s3-delete-folder/target-folder/test_1006
:
copy: s3://s3-delete-folder/my_folder/folder_1/test_949 to s3://s3-delete-folder/target-folder/test_949

real    0m29.505s
user    0m12.450s
sys 0m1.189s

もう一度、サブコマンドsyncを実行するとすでにコピー済みなのでコピーしません。サブコマンドcpは実行するたびに同じファイルをコピーしますが、サブコマンドsyncはすでにコピー済みであるかのチェックがおこり、無駄なコピーが不要になり、結果として処理が速く終わります。

$ time aws s3 sync s3://s3-delete-folder/my_folder/folder_1 s3://s3-delete-folder/target-folder

real    0m5.245s
user    0m1.714s
sys 0m0.132s

また、サブコマンドsyncの場合、万が一コピーが中断したとしても、もう一度実行することで続きのコピーができる点で優れています。

補足:オブジェクト数のカウント

なお、オブジェクト数は以下のコマンドで高速に取得できます。下記の方法では、S3基盤でオブジェクト数をカウントし、レスポンスにはオブジェクト数のみが含まれるため、オブジェクトリスト全体を取得するよりは高速に動作します。

$ time aws s3api list-objects-v2 --bucket s3-delete-folder --prefix my_folder/folder_1 --query 'length(Contents)'
2999

real    0m3.156s
user    0m0.640s
sys 0m0.144s

Python(boto3)でS3フォルダ間でコピーする方法

S3フォルダをまとめてコピーするには

S3フォルダをまとめてコピーするには、まずファイルの一覧を取得した後、オブジェクトごとにコピーを実行する必要があります。しかし、バケットとキー指定してオブジェクトの一覧を取得する関数(list_objects や list_objects_v2)では、一度に最大1000個までしか取得できません。そのため1000以上のオブジェクトを考慮して、リストの終わりまで繰り返し処理するような実装が必要です。

オブジェクトの一覧を取得する関数は2種類あり、従来のlist_objects関数 と 現在のlist_objects_v2関数の2種類ありますので、それぞれのAPIで一覧を取得し、オブジェクトを削除する関数を作成しました。新規でスクリプトを作成する場合は、現在のlist_objects_v2関数で構いませんが、比較的古いBoto3の場合は、従来のlist_objects関数を用いた関数を利用したほうが良いでしょう。

S3フォルダのコピーでは、指定したフォルダの下のオブジェクトのすべてをコピーしますので、ソースフォルダの相対パスを取得して、コピーしなけれなりません。そうすることで、AWSCLIのcpコマンドの--recursive指定したときと同じ動作を再現しています。

[推奨]list_objects_v2関数によるS3フォルダ間のコピー

現在のAPI(list_objects_v2)では取得されたオブジェクト件数が、リクエストパラメータで指定されたMaxKeysか、デフォルト値(1000)以上存在した場合には、 NextContinuationTokenという値がレスポンスで返却されていました。その値を次のAPIコールにContinuationTokenパラメータとして与えることで残りの値を取得することができます。v1のサポートも続けられていますが、今後の新規開発ではv2を利用することが推奨されています。

ソースデータにソースバケット名(s3-copy-folder)とソースプレフィックス(source_folder/)、ターゲットデータにターゲットバケット名(s3-target-folder)とターゲットプレフィックス(target_folder/)、を指定して実行します。プレフィックスは共にフォルダを指定するので、プレフィックスの最後の文字はスラッシュ「/」にしてください。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import boto3
import re

source_bucket = 's3-copy-folder'
source_prefix = 'source-folder/'
target_bucket = 's3-target-folder'
target_prefix = 'target-folder/'

def copy_all_keys_v2(source_bucket='', source_prefix='', target_bucket='', target_prefix='', dryrun=False):

    contents_count = 0
    next_token = ''

    while True:
        if next_token == '':
            response = s3client.list_objects_v2(Bucket=source_bucket, Prefix=source_prefix)
        else:
            response = s3client.list_objects_v2(Bucket=source_bucket, Prefix=source_prefix, ContinuationToken=next_token)

        if 'Contents' in response:
            contents = response['Contents']
            contents_count = contents_count + len(contents)
            for content in contents:
                relative_prefix = re.sub('^' + source_prefix, '', content['Key'])
                if not dryrun:
                    print('Copying: s3://' + source_bucket + '/' + content['Key'] + ' To s3://' + target_bucket + '/' + target_prefix + relative_prefix)
                    s3client.copy_object(Bucket=target_bucket, Key=target_prefix + relative_prefix, CopySource={'Bucket': source_bucket, 'Key': content['Key']})
                else:
                    print('DryRun: s3://' + source_bucket + '/' + content['Key'] + ' To s3://' + target_bucket + '/' + target_prefix + relative_prefix)

        if 'NextContinuationToken' in response:
            next_token = response['NextContinuationToken']
        else:
            break

    print(contents_count)


if __name__ == "__main__":
    s3client = boto3.client('s3')
    copy_all_keys_v2(source_bucket, source_prefix, target_bucket, target_prefix, True)

list_objects_v2関数の詳細については、以下のブログを御覧ください。

S3 ListObjects APIの新バージョン ListObjectsV2を使おう

[非推奨]旧API list_objects関数によるS3フォルダ間のコピー

従来のAPI(list_objects)では取得されたオブジェクト件数が、リクエストパラメータで指定されたmax-itemsか、デフォルト値(1000)以上存在した場合には、NextMarkerという値がレスポンスで返却されていました。その値を次のAPIコールにmarkerパラメータとして与えることで残りの値を取得することができました。

ソースデータにソースバケット名(s3-copy-folder)とソースプレフィックス(source_folder/)、ターゲットデータにターゲットバケット名(s3-target-folder)とターゲットプレフィックス(target_folder/)、を指定して実行します。プレフィックスは共にフォルダを指定するので、プレフィックスの最後の文字はスラッシュ「/」にしてください。

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import boto3
import re

source_bucket = 's3-copy-folder'
source_prefix = 'source-folder/'
target_bucket = 's3-target-folder'
target_prefix = 'target-folder/'

def copy_all_keys(source_bucket='', source_prefix='', target_bucket='', target_prefix='', dryrun=False):

    contents_count = 0
    marker = None

    while True:
        if marker:
            response = s3client.list_objects(Bucket=source_bucket, Prefix=source_prefix, Marker=marker)
        else:
            response = s3client.list_objects(Bucket=source_bucket, Prefix=source_prefix)

        if 'Contents' in response:
            contents = response['Contents']
            contents_count = contents_count + len(contents)
            for content in contents:
                relative_prefix = re.sub('^' + source_prefix, '', content['Key'])
                if not dryrun:
                    print('Copying: s3://' + source_bucket + '/' + content['Key'] + ' To s3://' + target_bucket + '/' + target_prefix + relative_prefix)
                    s3client.copy_object(Bucket=target_bucket, Key=target_prefix + relative_prefix, CopySource={'Bucket': source_bucket, 'Key': content['Key']})
                else:
                    print('DryRun: s3://' + source_bucket + '/' + content['Key'] + ' To s3://' + target_bucket + '/' + target_prefix + relative_prefix)

        if response['IsTruncated']:
            marker = response['Contents'][-1]['Key']
        else:
            break

    print(contents_count)


if __name__ == '__main__':
    s3client = boto3.client('s3')
    copy_all_keys(source_bucket, source_prefix, target_bucket, target_prefix, True)

最後に

S3フォルダ間のコピーは、マネジメントコンソールから操作できませんが、AWSCLIでは--recursive指定することでコピーできます。できるだけこれと同じことができるようにしたのがboto3を用いたS3フォルダ間のコピーです。

私は、AWS Glueでターゲットフォルダに直接更新を加えるのではなく、ステージングフォルダに結果を作成します。変換が成功すれば、既存のフォルダを削除した後、ステージングフォルダの内容をコピーします。そうすることで変換に失敗したときのロールバックを可能にします。S3フォルダ間のコピーが済んだら、先日のS3フォルダの削除で直ちに削除します。なお、実際のプログラムに組み込む際には、DryRunを実行して削除対象の一覧を確認して、削除対象の確認を怠らないでくださいね。

なお、何度も「S3フォルダ」なんて言ってましたが、フォルダなんてないものはないことは承知しています。

Amazon S3における「フォルダ」という幻想をぶち壊し、その実体を明らかにする

合わせて読みたい

AWSCLI、Python(boto3)などからS3フォルダを削除してみる