S3とGCSのアップロード処理を整理する
オブジェクトのアップロード処理を行いたくて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の場合
以下の2種類があります。
- オブジェクトのアップロード(本記事では区別のため以下「単一のアップロード」と書く)
- 1回のPUTで最大5GBの単一オブジェクトをアップロードできる。コンソールでは最大160GBになる
- マルチパートアップロード
- 大容量オブジェクトを複数のパートに分けて効率的にアップロードするためのもの
- 5MB~5TBのオブジェクトで使用できる
小さいファイルと大きいファイル用の二種類ということで、分かります。
GCSの場合
まず「方法」として以下の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/*
)
- 再開可能なアップロードを行う際、content-rangeに指定するファイル合計サイズを
ただS3のマルチパートアップロードとGCSの再開可能なアップロードがどう違うのか、いまいちよく分かりませんでした。名前とかそれぞれのドキュメントで書いてあることはわかるのですが、S3のマルチパートアップロードもcontent-range使っていないだけで再開みたいなことはできるよね?再開可能なアップロードも複数チャンクを送れるけど、並列では送れないのか?手段が違うだけで結局のところできることは同じ認識で良いのか?みたいなところが確信が持てなかったので、以下検証です。
ちなみに今回は仕組みを知りたいのでAPIベースで考えています。各言語のライブラリもcliも結局できることはAPIに集約されるはずなので。
S3のマルチパートアップロード
流れ
流れとしては以下のようになります。
- セッションの開始(
CreateMultipartUpload
リクエスト)- s3から
アップロードid
を含むレスポンスがくる
- s3から
- 複数パートに分かれたオブジェクトをアップロードしていく(
UploadPart
リクエスト)。リクエストボディにアップロードid
とパート番号
を持たせる- アップロードするたびにs3はオブジェクトパートの
ETag
を返す
- アップロードするたびにs3はオブジェクトパートの
- 完了のリクエストを送る(
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は色々書いてあって分かりにくいのは確かだと思うので、同じような課題感を持っていた人の何かの参考になれば幸いです。
参考
GCPのドキュメント見ただけだと再開可能なアップロードがいまいちよくわからず、参考にさせていただきました🙏
Discussion