📁

S3とGCSのアップロード処理を整理する

2023/01/22に公開

オブジェクトのアップロード処理を行いたくてGCSのドキュメントを読むと、「再開可能なアップロード」「マルチパートアップロード」「並列複合アップロード」等複数の表現が出てきます。また各ページを見ると、コードサンプルがあったりなかったり、APIやcliコマンドがあったりなかったりと「できること × 手段」の分岐がカオスな印象で、記憶が薄れたタイミングで読み返すたびにモヤモヤしていました。

一方でS3のドキュメントを見ると、こちらは単一アップロードとマルチパートアップロードの二種類しか存在しません。似たようなストレージサービスなのにこうも差が出るものなのかと気になり、余計にモヤモヤに拍車がかかります。

今回の記事はストレージサービスのアップロード処理に関する理解を一歩深めるための調査検証ログになります。流れとしては、ドキュメントからわかる情報を整理したのち、自分にとってとりわけ疑問だったGCSの再会可能なアップロードとS3のマルチパートアップロードを実装したものになっています。忙しい人はサマリだけ読めば十分です。

サマリ

S3でファイルをアップロードする際は、以下の2種類があります。

  • 単一オブジェクトのアップロード
    • サイズが小さい時用
  • マルチパートアップロード
    • サイズが大きい時用
    • オブジェクトを複数パートに分けて並列でアップロードすることができる。アップロードした各パートは完了のタイミングで一つのオブジェクトとしてS3に置かれる
    • sessionの開始、データのアップロード、sessionの終了、の最小3回のリクエストが必要になる

GCSでファイルをアップロードする際は、以下の4種類があります。

  • 単一のリクエストのアップロード
    • サイズが小さい時用
  • 再開可能なアップロード(Resumable uploads)
    • サイズが大きい時用
    • HTTPのcontent-rangeヘッダーにアップロードするオブジェクトのサイズを指定し、オブジェクトを続きから分割でアップロードすることができる。トータルサイズが不明な時でも使用可能
    • sessionの開始、オブジェクトのアップロードの最小2回のリクエストが必要になる
    • 並列でのアップロードはできない
  • composeオペレーション
    • GCS上にある複数のオブジェクトを一つのオブジェクトにするもの
    • GCSで並列でアップロードする場合は、複数のファイルをアップロードした後にcomposeで一つのオブジェクトにまとめる、という流れになる
  • XML API マルチパートアップロード
    • S3のマルチパートアップロードと同じ形式でオブジェクトをアップロードできる
    • S3との互換性を意識して用意されたと思われるもの

ドキュメントからわかる情報を整理する

S3の場合

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/upload-objects.html

以下の2種類があります。

  • オブジェクトのアップロード(本記事では区別のため以下「単一のアップロード」と書く)
    • 1回のPUTで最大5GBの単一オブジェクトをアップロードできる。コンソールでは最大160GBになる
  • マルチパートアップロード
    • 大容量オブジェクトを複数のパートに分けて効率的にアップロードするためのもの
    • 5MB~5TBのオブジェクトで使用できる

小さいファイルと大きいファイル用の二種類ということで、分かります。

GCSの場合

https://cloud.google.com/storage/docs/uploads-downloads?hl=ja#uploads

まず「方法」として以下の3つが示されています。

  • 単一のリクエストのアップロード
    • 目安として、許容待機時間が30s、アップロードの平均速度が8Mbpsの条件下の場合、30MBまでのファイルに推奨
    • 処理失敗時に全体を再アップロードできる場合に使用を推奨
  • XML API マルチパートアップロード
    • S3のマルチパートアップロードと互換性のある方法として提供されているもの
    • 詳細
  • 再開可能なアップロード(英語ではResumable uploads)
    • リクエスト時にヘッダーにcontent-range情報を渡すことで、アップロード時に中断したところからアップロードを再開できる
    • 詳細

加えて、基本的な「アップロードタイプ」として以下の2つが示されています。

  • 並列複合アップロード
    • ファイルを並列でアップロードし、compose というオペレーションを使って、複合オブジェクトとして保存する
    • 詳細
  • ストリーミングアップロード
    • データをファイルに保存することなくアップロードできるもの
    • 詳細

上記5つのうち、4つに関しては以下のような形で一旦腑に落ちます。

  • 単一リクエストのアップロード
    • 小さいファイルを送るときに使うやつ
  • XML API マルチパートアップロード
    • 先発サービスであるS3が提供するマルチパートアップロードと互換性を持たせるために同じものを作った。(リクエストの流れや中身もS3のものと同じだった)
  • 並列複合アップロード
    • なんらかの手段でアップロードした複数のファイルを、compose オペレーションというものを使って一つのファイルにまとめたもの
    • compose はS3にはない概念
  • ストリーミングアップロード
    • 再開可能なアップロードを行う際、content-rangeに指定するファイル合計サイズを * にする(例:bytes 0-524287/*

ただS3のマルチパートアップロードとGCSの再開可能なアップロードがどう違うのか、いまいちよく分かりませんでした。名前とかそれぞれのドキュメントで書いてあることはわかるのですが、S3のマルチパートアップロードもcontent-range使っていないだけで再開みたいなことはできるよね?再開可能なアップロードも複数チャンクを送れるけど、並列では送れないのか?手段が違うだけで結局のところできることは同じ認識で良いのか?みたいなところが確信が持てなかったので、以下検証です。

ちなみに今回は仕組みを知りたいのでAPIベースで考えています。各言語のライブラリもcliも結局できることはAPIに集約されるはずなので。

S3のマルチパートアップロード

流れ

流れとしては以下のようになります。

  • セッションの開始(CreateMultipartUpload リクエスト)
    • s3からアップロードid を含むレスポンスがくる
  • 複数パートに分かれたオブジェクトをアップロードしていく(UploadPart リクエスト)。リクエストボディにアップロードidパート番号を持たせる
    • アップロードするたびにs3はオブジェクトパートのETag を返す
  • 完了のリクエストを送る(CompleteMultipartUpload リクエスト)。リクエストボディに全パートの[{”PartNumber”: 1, “ETag”: “xxx”}, … ] のようなリストを持たせる必要がある
    • リストなのだからETagだけで良いように見えるが、PartNumberも必要になる

GCSのXMLマルチパートアップロードも同様になります。

Pythonで実装したもの

各APIに対応するboto3のメソッドがあったので、今回はPythonで実装しました。

    import os
    
    import boto3
    
    def gen_stream():
        # s3のマルチパートアップロードは最小5MBから
        return os.urandom(5 * 1024 * 1024)
    
    def main():
        bucket = "test-bucket"
        bucket_key = "test_key"
        my_session = boto3.Session()
        client = my_session.client('s3')
    
	# 1.セッションの開始(CreateMultipartUpload リクエスト)
        create_res = client.create_multipart_upload(Bucket=bucket, Key=bucket_key)
        upload_id = create_res.get('UploadId')
    
        try:
    	    # 2.オブジェクトを複数パートでアップロード(UploadPart リクエスト)
            parts = []
            for i in range(1,3):
                upload_res = client.upload_part(
                    Body=gen_stream(),
                    Bucket=bucket,
                    Key=bucket_key,
                    PartNumber=i,
                    UploadId=upload_id,
                )
                parts.append({
                    'ETag': upload_res.get('ETag'),
                    'PartNumber': i
                })
            print({'Parts': parts})
    
    	    # 3. 完了リクエストを送る(CompleteMultipartUpload リクエスト)
            client.complete_multipart_upload(
                Bucket=bucket,
                Key=bucket_key,
                MultipartUpload={
                    'Parts': parts
                },
                UploadId=upload_id,
            )
            print('complete.')
        except Exception as e:
            print(f'error:\n {e}')
            res = client.abort_multipart_upload(
                Bucket=bucket,
                Key=bucket_key,
                UploadId=upload_id,
            )
            print(f'abort:\n {res}')
    
    if __name__ == "__main__":
        main()

順番である必要があるのか

結論から言うと、順番に送る必要はありません。ただし、バラバラにアップロードした場合でも、最後のリクエストボディで正しい順番(PartNumber昇順)に並び替えて送る必要があります。

# completeで以下のようにPartNumber2,1の順で送るとエラーが出る
# {'Parts': [{'ETag': '"b2af5c03f90220b0eee7642ef3af8694"', 'PartNumber': 2}, {'ETag': '"ab41f3ca49b9c19fa7889897431ee143"', 'PartNumber': 1}]}

An error occurred (InvalidPartOrder) when calling the CompleteMultipartUpload operation: The list of parts was not in ascending order. Parts must be ordered by part number.

順番を入れ替えて送った時のサンプルコードは以下です。

    import os
    
    import boto3
    
    def gen_stream(i):
        # s3のマルチパートアップロードは最小5MBから
        return bytes(f'part{i}', 'utf-8') + os.urandom(5 * 1024 * 1024)
    
    def main():
        bucket = "test-bucket"
        bucket_key = "test_key"
        my_session = boto3.Session()
        client = my_session.client('s3')
        create_res = client.create_multipart_upload(Bucket=bucket, Key=bucket_key)
        upload_id = create_res.get('UploadId')
    
        try:
            parts = []
            for i in range(2,0,-1):
                upload_res = client.upload_part(
                    Body=gen_stream(i),
                    Bucket=bucket,
                    Key=bucket_key,
                    PartNumber=i,
                    UploadId=upload_id,
                )
                parts.append({
                    'ETag': upload_res.get('ETag'),
                    'PartNumber': i
                })
            print({'Parts': parts})
    
            client.complete_multipart_upload(
                Bucket=bucket,
                Key=bucket_key,
                MultipartUpload={
                    'Parts': sorted(parts, key=lambda x: x['PartNumber'])
                },
                UploadId=upload_id,
            )
            print(f'complete.')
        except Exception as e:
            print(f'error:\n {e}')
            res = client.abort_multipart_upload(
                Bucket=bucket,
                Key=bucket_key,
                UploadId=upload_id,
            )
            print(f'abort:\n {res}')
    
    if __name__ == "__main__":
        main()

アップロード時にPartNumberを降順に送りました。ドキュメントにも

パート番号に基づいて昇順に連結されたオブジェクトが Amazon S3 によって作成されます

と記載があった通り、part番号順で連結されていました。

$ strings ~/Downloads/storage_upload | grep part
part1
part2

GCSの再開可能なアップロード

流れ

基本的な流れとしてはS3のマルチパートアップロードと大体同じです。

APIをcurlで叩いてアップロードを行う

APIに対応するPythonのエンドポイントがなかったのでcurlでなぞりました。

※事前準備としてAPIの認証をOAuth2.0 playgroundを使って取得する をやっておきます。

  • セッションの開始(urlはアップロード対象バケットと作成するオブジェクト名を含めた以下のようなもの)

    • /b/{バケット名}/
    • &name={オブジェクト名}
    curl -i -X POST \
        -H "Authorization: Bearer $GCP_OAUTH_TOKEN" \
        "https://storage.googleapis.com/upload/storage/v1/b/test-bucket/o?uploadType=resumable&name=test.txt"
    
    • レスポンスの「location」ヘッダーに再開可能なセッション URIが返るので、それを使ってオブジェクトチャンクをアップロードしていく
      • ちなみにこのセッションurlは、リクエストしたエンドポイントのパラメータにupload_idが追加されたもの
    HTTP/2 200
    content-type: text/plain; charset=utf-8
    x-guploader-uploadid: ADPycdvRNAETiIzo3ER8NT7E6m1vXee_e8RqjfNqmGM-aGXlxpJII-K7hSuFOHOOF1Tup2blf_cu3FoAgCIZ-ImTft22
    location: https://storage.googleapis.com/upload/storage/v1/b/test-bucket/o?uploadType=resumable&name=test.text&upload_id=ADPycdvRNAETiIzo3ER8NT7E6m1vXee_e8RqjfNqmGM-aGXlxpJII-K7hSuFOHOOF1Tup2blf_cu3FoAgCIZ-ImTft22
    
  • セッションurlを指定して、オブジェクトをアップロードする

    curl -v -X PUT --upload-file test.txt $STORAGE_SESSION_URL
    
    • 今回は検証のため途中でctrl + cで中断する
  • アップロードステータスの確認

    • -H "Content-Length: 0" -H "Content-Range: bytes */ファイルの合計サイズ" を指定すると確認できる
    curl -i -X PUT -H "Content-Length: 0" -H "Content-Range: bytes */1000000000" -d "" $STORAGE_SESSION_URL
    
    HTTP/2 308
    content-type: text/plain; charset=utf-8
    x-guploader-uploadid: ADPycdsZHFP8CzgxFoFV1jGeUoI0TCS0BBkxdSMwRs_2EroPbs52WJDcpqoyr30wHy2NVJPsNq_BOjrJQazdpocepyvFYD5kSyId
    range: bytes=0-167772159
    x-range-md5: 307bbc8729e497135f23f7f6ed6776b6
    content-length: 0
    date: Thu, 17 Nov 2022 00:22:40 GMT
    server: UploadServer
    alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
    
    • 途中の場合は308が返る
    • rangeの部分で今何byteまでアップロードされているかがわかる
  • 残りの部分をアップロード

    • するにあたり、送信していない部分でファイルを生成しておく(dd skip=167772160 if=test.txt of=remains.txt ibs=1
    • 最初に送った時とは違い、content-rangeを指定する
    curl -v -X PUT --upload-file remains.txt -H "Content-Range: bytes 167772160-999999999/1000000000" $STORAGE_SESSION_URL
    
  • 再度ステータス確認

    curl -i -X PUT -H "Content-Length: 0" -H "Content-Range: bytes */1000000000" -d "" $UPLOADURL
    
    • 先ほどは308だったステータスが、完了すると200になり、オブジェクトの情報が返ってきます
    HTTP/2 200
    x-guploader-uploadid: ADPycdsZHFP8CzgxFoFV1jGeUoI0TCS0BBkxdSMwRs_2EroPbs52WJDcpqoyr30wHy2NVJPsNq_BOjrJQazdpocepyvFYD5kSyId
    etag: CPXMuOf9s/sCEAE=
    content-type: application/json; charset=UTF-8
    date: Thu, 17 Nov 2022 00:42:25 GMT
    vary: Origin
    vary: X-Origin
    cache-control: no-cache, no-store, max-age=0, must-revalidate
    expires: Mon, 01 Jan 1990 00:00:00 GMT
    pragma: no-cache
    content-length: 710
    server: UploadServer
    alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
    
    {
      "kind": "storage#object",
      "id": "test-bucket/test.txt/1668645745731189",
      "selfLink": "https://www.googleapis.com/storage/v1/b/sample-upload/o/test.txt",
      "mediaLink": "https://storage.googleapis.com/download/storage/v1/b/test-bucket/o/test.txt?generation=1668645745731189&alt=media",
      "name": "test.txt",
      "bucket": "test-bucket",
      "generation": "1668645745731189",
      "metageneration": "1",
      "storageClass": "STANDARD",
      "size": "1000000000",
      "md5Hash": "MTiwY4GgHTDy6FTEEqAsWw==",
      "crc32c": "bv4MUQ==",
      "etag": "CPXMuOf9s/sCEAE=",
      "timeCreated": "2022-11-17T00:42:25.756Z",
      "updated": "2022-11-17T00:42:25.756Z",
      "timeStorageClassUpdated": "2022-11-17T00:42:25.756Z"
    }
    

S3のようなcompleteリクエストはありません。

ちなみにGCPのPythonライブラリでは、file or file like objectを渡したらサイズによっていい感じにやってくれる高水準ライブラリしか存在しないようでした(セッション開始するメソッドはあったのでもしかしたらできるかも?)。

アップロード処理をどう実装しているか知りたくてライブラリの中身を追ったりもしていたのですが、ライブラリがさらに「google-resumable-media」という別のライブラリを参照していて、時間が足りなそうだったのでまた別の機会にします。

順番である必要があるのか

content-rangeで範囲持っているし、あわよくばいい感じに並び替えてくれたりしないだろうかと思っていたのですが、そんなことはありませんでした。

  • 新しくセッションを開始

  • ステータスを確認。0byteになっている

    curl -i -X PUT -H "Content-Length: 0" -H "Content-Range: bytes */1000000000" -d "" $STORAGE_SESSION_URL
    
    HTTP/2 308
    content-type: text/plain; charset=utf-8
    x-guploader-uploadid: ADPycdsmk0Wr1G1R7dmNRVo_C90jgLj6RhM1kiTjjJv0RTuq9Q9OMkEF3PYE5JjK4Pn65m6sYbqF476iARtaud9Y4KHLTQ
    content-length: 0
    date: Fri, 18 Nov 2022 00:27:11 GMT
    server: UploadServer
    alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
    
  • 先ほど使ったファイルの、後から送った部分を先に送ってみる

    curl -i -X PUT --upload-file remains.txt -H "Content-Range: bytes 167772160-999999999/1000000000" $STORAGE_SESSION_URL
    
  • 怒られる。エラーメッセージを見た感じ、やはり順番に送る必要がありそうでした。

    HTTP/2 503
    content-type: text/plain; charset=utf-8
    x-guploader-uploadid: ADPycdsmk0Wr1G1R7dmNRVo_C90jgLj6RhM1kiTjjJv0RTuq9Q9OMkEF3PYE5JjK4Pn65m6sYbqF476iARtaud9Y4KHLTQ
    content-length: 146
    date: Fri, 18 Nov 2022 00:29:00 GMT
    server: UploadServer
    alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
    
    Invalid request.  According to the Content-Range header, the upload offset is 167772160 byte(s), which exceeds already uploaded size of 0 byte(s).
    

まとめ

「GCSの再開可能なアップロードって、S3のマルチパートアップロードのpart numberを使ってやっていたことをcontent-rangeでやるようにしたものでは」と無意識の内に思い込んでいたのですが、そんなことはなかったです。

ただS3のファイルアップロードに比べると、GCSは色々書いてあって分かりにくいのは確かだと思うので、同じような課題感を持っていた人の何かの参考になれば幸いです。

参考

https://leandrodamascena.medium.com/understanding-resumable-upload-in-google-cloud-storage-and-curl-example-9988534de0a6

GCPのドキュメント見ただけだと再開可能なアップロードがいまいちよくわからず、参考にさせていただきました🙏

Discussion