AWS S3には一定期間だけ認証情報を持たなくてもキーにオブジェクトをアップロード/ダウンロードするための署名付きURLという機能がある。以下にその構成の例を示す。
この機能はとても便利ではあるが、メタデータの扱いや各種類似サーバーの細かな仕様の違いなど、落とし穴のような罠もいくつか存在する。今回はこの署名付きURLの発行、署名付きURLへのオブジェクトをアップロードを試すなかで、陥った問題を備忘録として残すことにする。
この記事内で使用する環境変数
以下の環境変数を適宜設定する。
環境変数 | 用途 | 例 |
---|---|---|
AWS_S3_ENDPOINT_URL | APIの送信先として用いるオリジン。localで動作させるサーバを使用する場合に指定する。 | |
AWS_S3_ACCESS_KEY_ID | AWSのアクセスキー。 | XXXXXXXXXX |
AWS_S3_SECRET_ACCESS_KEY | AWSのシークレットアクセスキー。 | XXXXXXXXXX |
AWS_S3_BUCKET_NAME | S3で今回テスト用として扱うのバケット名。 | symdon-testing |
AWS_S3_OBJECT_KEY | S3で今回テスト用として扱うのバケットに保存するキー名。 | aaa.csv |
AWS_S3_PRESIGNED_URL | 発行した署名付きURL。 | aaa.csv |
EXPIRES_SEC | 署名付きURLが有効な秒数。 | 300 |
AWS S3
起動
これはAWSが提供するサービスのため、ローカルで起動することはできない。必要に応じてAWSアカウントと、そのアカウントが管理するAWSのリソースにアクセス可能なアクセスキーとシークレットアクセスキーが必要になる。適切な権限を付与したIAMユーザーの認証情報の画面からCLIアクセス用のアクセスキーとシークレットアクセスキーを発行できる。
バケットの作成
まずテスト用のS3バケットを準備する。バケットの作成は aws s3 mb
で作成できる。
署名付きURL
公開ではないS3バケットへのアクセスには、アクセスキーとシークレットアクセスキーが必要になる。しかし、その認証情報を持たないユーザーにS3バケット内のキーへのアクセスを許可するための方法として、署名付きURL(Presigned URL)が提供されている。アクセスキーとシークレットアクセスキーを元に、S3バケット内のキーへのアクセス可能な情報(署名)を持つURLを予め発行し、認証情報を持たないユーザーにそのURLを教える。認証情報を持たないユーザーは、その署名付きURLを用いて、ファイルをダウンロードしたり、アップロードしたりできる。
署名の生成
ここではPythonを用いて署名を生成するロジックを示す。
ダウンロード用の署名付きURLを発行
ダウンロード用の署名付きURLはAWS CLIで発行できる。ダウンロードさせる事 が目的であるため、既にS3にキーが存在している必要がある。キーが存在して いなくてもURLは発行できるが、ダウンロードの時に当然404 Not Foundとなる。
aws s3 presign s3://${AWS_S3_BUCKET_NAME}/${AWS_S3_OBJECT_KEY}
--expires-in
を用いて署名の有効期限を秒で指定できる。
aws s3 presign s3://${AWS_S3_BUCKET_NAME}/${AWS_S3_OBJECT_KEY} --expires-in ${EXPIRES_SEC}
ダウンロード用の署名付きURLを用いてダウンロード
生成した署名付きURLにGETリクエストを送信すればそのデータを取得できる。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
GET :PRESIGNED_URL
アップロード用の署名付きURLを発行
アップロード用の署名付きURLはAWS CLIでは発行できない。ここではPythonとboto3を用いて署名を生成するロジックを示す。
import os
import boto3
from botocore.client import Config
AWS_S3_ACCESS_KEY_ID = os.environ.get("AWS_S3_ACCESS_KEY_ID")
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL")
AWS_S3_SECRET_ACCESS_KEY = os.environ.get("AWS_S3_SECRET_ACCESS_KEY")
AWS_S3_BUCKET_NAME = os.environ["AWS_S3_BUCKET_NAME"]
AWS_S3_OBJECT_KEY = os.environ["AWS_S3_OBJECT_KEY"]
EXPIRES_SEC = int(os.environ.get("EXPIRES_SEC", "300"))
config = Config(signature_version='s3v4')
s3_client = boto3.client(
's3',
aws_access_key_id=AWS_S3_ACCESS_KEY_ID or None,
aws_secret_access_key=AWS_S3_SECRET_ACCESS_KEY or None,
endpoint_url=AWS_S3_ENDPOINT_URL or None,
config=config,
)
presigned_url = s3_client.generate_presigned_url(
ClientMethod='put_object',
Params={'Bucket' : AWS_S3_BUCKET_NAME, 'Key' : AWS_S3_OBJECT_KEY},
ExpiresIn=EXPIRES_SEC,
HttpMethod='PUT',
)
return presigned_url # returnはorg-modeで便宜上必要になったため指定している
アップロード用の署名付きURLを用いてアップロード
プリフライトリクエストを送信する
プリフライトリクエストはCORSの構成ではブラウザが自動的に送信するリクエストだ。そのためこの処理はプログラムなどから直接送信処理をする場合には必須ではない。しかしプリフライトリクエストを直接送信することで、適切に設定ができているかを確認することができる。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
OPTIONS :PRESIGNED_URL
Origin: http://localhost:3000
Access-Control-Request-Method: PUT
アップロード
オブジェクトのアップロードはPUTを送信する。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
PUT :PRESIGNED_URL
Content-Type: text/plain
foo
メタデータを含めてアップロードする
S3ではそれぞれのキーにメタデータを設定できる。
署名付きURLを発行する
署名付きURLを用いてアップロードするためには、署名付きURLの生成時にメタデータも含めて署名する必要がある。そうしなければアップロード時に403や500のエラーが返却されることになる。
import os
import boto3
from botocore.client import Config
AWS_S3_ACCESS_KEY_ID = os.environ.get("AWS_S3_ACCESS_KEY_ID")
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL")
AWS_S3_SECRET_ACCESS_KEY = os.environ.get("AWS_S3_SECRET_ACCESS_KEY")
AWS_S3_BUCKET_NAME = os.environ["AWS_S3_BUCKET_NAME"]
AWS_S3_OBJECT_KEY = os.environ["AWS_S3_OBJECT_KEY"]
EXPIRES_SEC = int(os.environ.get("EXPIRES_SEC", "300"))
config = Config(signature_version='s3v4')
s3_client = boto3.client(
's3',
aws_access_key_id=AWS_S3_ACCESS_KEY_ID or None,
aws_secret_access_key=AWS_S3_SECRET_ACCESS_KEY or None,
endpoint_url=AWS_S3_ENDPOINT_URL or None,
config=config,
)
presigned_url = s3_client.generate_presigned_url(
ClientMethod='put_object',
Params={'Bucket' : AWS_S3_BUCKET_NAME, 'Key' : AWS_S3_OBJECT_KEY, 'Metadata': {'foo-bar-baz': 'testing'}}, # <- ここでメタデータを指定
ExpiresIn=EXPIRES_SEC,
HttpMethod='PUT',
)
return presigned_url # returnはorg-modeで便宜上必要になったため指定している
CORSポリシーを変更する
メタデータはアップロード時にはHTTPリクエストヘッダーによってS3によって渡される。そのためCORSの構成でアップロードする場合、CORSポリシーにもそのヘッダーの設定を追加する必要がある。=foo-bar-baz= というメタデータを追加する場合のCORSポリシーの例を以下に示す。
[
{
"AllowedHeaders": [
"access-control-allow-origin",
"content-type",
"x-amz-meta-foo-bar-baz"
],
"AllowedMethods": [
"PUT"
],
"AllowedOrigins": [
"http://localhost:3000"
],
"ExposeHeaders": [
],
"MaxAgeSeconds": 3000
}
]
プリフライトリクエストを送信する
ここでも設定が正しいことを確認するために、プリフライトリクエストを手動で送信する。メタデータ foo-bar-baz
を追加するために、ヘッダーに x-amz-meta-foo-bar-baz
を含める必要がある。そのため Access-Control-Request-Headers
に x-amz-meta-foo-bar-baz
を指定する。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
OPTIONS :PRESIGNED_URL
Origin: http://localhost:3000
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: x-amz-meta-foo-bar-baz
データをアップロードする
メタデータはアップロード時にはhttpリクエストヘッダーで渡す。ヘッダーには x-amz-meta-
がプレフィックスとして付く。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
PUT :PRESIGNED_URL
Content-Type: text/plain
x-amz-meta-foo-bar-baz: testing
foo
メタデータに非ASCII文字を含める
先程メタデータを設定する方法を確認したが、これはメタデータの値がASCII文字である場合に有効である。もし先の方法でメタデータを設定しようとすると次のエラーが発生する。
botocore.exceptions.ParamValidationError: Parameter validation failed: Non ascii characters found in S3 metadata for key "foo-bar-baz", value: "日本語". S3 metadata can only contain ASCII characters.
もし非ASCII文字を含める場合、異なる手順が必要になる。ドキュメントには以下のように説明がある1。
ただしboto3経由で署名付きURLを発行する場合、メタデータの値はUnicodeである必要があるため、UTF8でエンコードしたバイト列を渡すことはできなかった。そのため方法としてはbase64などでASCII文字にエンコードした状態でメタデータとして設定する必要がある。以下にその手順を示す。
import base64
metadata_value = "日本語"
return base64.b64encode(metadata_value.encode()).decode()
5pel5pys6Kqe
生成した署名付きURLにプリフライトリクエストを送信する。
生成した署名付きURLにPUTを送信し、データをアップロードする。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
PUT :PRESIGNED_URL
Content-Type: text/plain
x-amz-meta-foo-bar-baz: 5pel5pys6Kqe
foo
// PUT https://symdon-test-bucket.s3.amazonaws.com/aaa.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA2M2EVRPJ7JXFGTNV%2F20220423%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20220423T112529Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host%3Bx-amz-meta-foo-bar-baz&X-Amz-Signature=414db01b022b0ed7f3d65630df63a3a02a92fff5e0d42f0ebc94dec6fdfc784d
// HTTP/1.1 200 OK
// x-amz-id-2: VC+kHZvSpOiriRw1fR+rja6KFhaYvgyvlSljzt568jeC0VrLSMsuSSKwOVHeOAHyLZ0xgOSV+Fk=
// x-amz-request-id: TBVX9J795RTS87R8
// Date: Sat, 23 Apr 2022 11:27:15 GMT
// ETag: "acbd18db4cc2f85cedef654fccc4a4d8"
// Content-Length: 0
// Server: AmazonS3
// Request duration: 19.834636s
アップロードができたことが分かる。
雑多な疑問点
ファイルがアップロードされなかった署名付きURLはどうなるのか?
localstackでは署名付きURLを発行しただけではキーは作成されなかった。
各種サーバーのデータのアップロードの挙動の違い
S3の署名付きURLを利用してファイルのアップロードを実装しようとしたとこ ろ400 Bad Requestが返される問題に遭遇した。ローカルでの検証には localstackを利用していたが、その時点で問題は発生していなかった。S3とそ の他互換サーバーについてその状況を整理する。
AWS S3
ブログ記事などにはS3からSIgnatureDoesNotMatchが返されてアップロードで きない事象に苦しめられた人がかなり多くいた2。署名を生成する 時のパラメータ(アクセスキーIDやシークレットアクセスキーやオブジェクト のキー)に間違いがあることがほとんどだと思う。再確認して試してみるとよ いだろう。またそれでも上手くいかない場合は一晩寝てから、再度コードを実 装しなおしてみると間違いに気付けることもある。
次のリクエストにより署名付きURLを使用してアップロードできる。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
PUT :PRESIGNED_URL
a,b,c,d
ヘッダーにContent-Typeを指定しないとアップロードに失敗すると説明する記 事もあったが、私が確認した範囲ではそのようなことはなくContent-Typeは省 略可能だった3。
もしどうしても解消できないようならTwitterで@TakesxiSximadaにメッセージ をくれれば一緒に悩んであげることぐらいはできると思う4。
MinIO
MinIOは非常に良くできたソフトウェアではあるが、S3と完全に同じになるか といえばそうではない。MinIOはアップロード用の署名付きURLの生成時に設定 するメタデータを含める必要はない。
S3ではメタデータの名前と値を署名付きURLの生成時に渡す必要がある。S3で はその整合性が取れていないと403を返す仕様になっている。
S3とMinIOには、このような微妙な違いがある。もし開発用にMinIO、本番用に S3を用いているのであれば、このようなケースがあることを認識しておく必要 がある。
次のリクエストにより署名付きURLを使用してアップロードできる。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
PUT :PRESIGNED_URL
OK
// PUT http://localhost:9000/example/foo?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=local-dummy%2F20220301%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20220301T084717Z&X-Amz-Expires=100&X-Amz-SignedHeaders=host&X-Amz-Signature=216142eeee75b7f9780f19ad3da3f3ee63a927398f0b95997b5d00da400c69be
// HTTP/1.1 200 OK
// Accept-Ranges: bytes
// Content-Length: 0
// Content-Security-Policy: block-all-mixed-content
// ETag: "e0aa021e21dddbd6d8cecec71e9cf564"
// Server: MinIO
// Strict-Transport-Security: max-age=31536000; includeSubDomains
// Vary: Origin
// Vary: Accept-Encoding
// X-Amz-Request-Id: 16D835780ED46CA0
// X-Content-Type-Options: nosniff
// X-Xss-Protection: 1; mode=block
// Date: Tue, 01 Mar 2022 08:47:33 GMT
// Request duration: 0.013916s
LocalStack
次のリクエストにより署名付きURLを使用してアップロードできる。
:PRESIGNED_URL := (getenv "AWS_S3_PRESIGNED_URL")
PUT :PRESIGNED_URL
OK
まとめ
S3のアップロード/ダウンロード両方の署名付きURLを発行し、発行したダウンロード用の署名付きURLを使用してデータを実際にアップロードできることを確認した。 またLocalStackやMinIOなどのS3互換サーバーでも同様に署名付きURLが使用できることを確認した。 ただし細かな仕様の違いなどがあることがわかった。