« ^ »

AWS S3の署名付きURLを用いてファイルをアップロードする

所要時間: 約 10分

AWS S3には一定期間だけ認証情報を持たなくてもキーにオブジェクトをアップロード/ダウンロードするための署名付きURLという機能がある。以下にその構成の例を示す。

https://res.cloudinary.com/symdon/image/upload/v1650684744/blog.symdon.info/1646124784/presigned-url_o7c5sr.png

この機能はとても便利ではあるが、メタデータの扱いや各種類似サーバーの細かな仕様の違いなど、落とし穴のような罠もいくつか存在する。今回はこの署名付きURLの発行、署名付きURLへのオブジェクトをアップロードを試すなかで、陥った問題を備忘録として残すことにする。

この記事内で使用する環境変数

以下の環境変数を適宜設定する。

環境変数用途
AWS_S3_ENDPOINT_URLAPIの送信先として用いるオリジン。localで動作させるサーバを使用する場合に指定する。
AWS_S3_ACCESS_KEY_IDAWSのアクセスキー。XXXXXXXXXX
AWS_S3_SECRET_ACCESS_KEYAWSのシークレットアクセスキー。XXXXXXXXXX
AWS_S3_BUCKET_NAMES3で今回テスト用として扱うのバケット名。symdon-testing
AWS_S3_OBJECT_KEYS3で今回テスト用として扱うのバケットに保存するキー名。aaa.csv
AWS_S3_PRESIGNED_URL発行した署名付きURL。aaa.csv
EXPIRES_SEC署名付きURLが有効な秒数。300

AWS S3

起動

これはAWSが提供するサービスのため、ローカルで起動することはできない。必要に応じてAWSアカウントと、そのアカウントが管理するAWSのリソースにアクセス可能なアクセスキーとシークレットアクセスキーが必要になる。適切な権限を付与したIAMユーザーの認証情報の画面からCLIアクセス用のアクセスキーとシークレットアクセスキーを発行できる。

バケットの作成

まずテスト用のS3バケットを準備する。バケットの作成は aws s3 mb で作成できる。

aws s3 mb s3://${AWS_S3_BUCKET_NAME}
AWS CLIを用いてバケットを作成する

署名付きURL

公開ではないS3バケットへのアクセスには、アクセスキーとシークレットアクセスキーが必要になる。しかし、その認証情報を持たないユーザーにS3バケット内のキーへのアクセスを許可するための方法として、署名付きURL(Presigned URL)が提供されている。アクセスキーとシークレットアクセスキーを元に、S3バケット内のキーへのアクセス可能な情報(署名)を持つURLを予め発行し、認証情報を持たないユーザーにそのURLを教える。認証情報を持たないユーザーは、その署名付きURLを用いて、ファイルをダウンロードしたり、アップロードしたりできる。

署名の生成

ここではPythonを用いて署名を生成するロジックを示す。

from datetime import date
import os
import hmac
import hashlib

AWS_S3_ACCESS_KEY = os.environ["AWS_S3_ACCESS_KEY_ID"]
AWS_S3_ENDPOINT_URL = os.environ["AWS_S3_ENDPOINT_URL"]
AWS_S3_SECRET_ACCESS_KEY = os.environ["AWS_S3_SECRET_ACCESS_KEY"]
AWS_S3_BUCKET_NAME = os.environ["AWS_S3_BUCKET_NAME"]

def sign(key, msg):
    return hmac.new(key, msg.encode("utf-8"), hashlib.sha256)

def getSignatureKey(key, dateStamp, regionName, serviceName):
    kDate = sign(("AWS4" + key).encode("utf-8"), dateStamp)
    kRegion = sign(kDate.digest(), regionName)
    kService = sign(kRegion.digest(), serviceName)
    kSigning = sign(kService.digest(), "aws4_request")
    return kSigning.hexdigest()

kSecret = AWS_S3_SECRET_ACCESS_KEY

return getSignatureKey(kSecret, "20150830", "us-east-1", "iam")  # returnはorg-modeで便宜上必要になったため指定している
5fb878afea687a32abae794c9fb5dee243634bd755d9714dbdb8d44d88c5b2f9
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-Headersx-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

US−ASCII 以外の文字をメタデータ値で使用すると、指定した Unicode 文字列に US−ASCII 以外の文字が含まれていないかどうか調べられます。文字列に US−ASCII 文字のみが含まれている場合は、そのまま表示されます。文字列に US−ASCII 以外の文字が含まれている場合は、最初に UTF−8 を使用して文字エンコードされ、次に US−ASCII にエンコードされます。

AWSドキュメント Amazon Simple Storage Service (S3) ユーザーガイド ユーザー定義のオブジェクトメタデータ 抜粋

ただしboto3経由で署名付きURLを発行する場合、メタデータの値はUnicodeである必要があるため、UTF8でエンコードしたバイト列を渡すことはできなかった。そのため方法としてはbase64などでASCII文字にエンコードした状態でメタデータとして設定する必要がある。以下にその手順を示す。

import base64

metadata_value = "日本語"
return base64.b64encode(metadata_value.encode()).decode()
5pel5pys6Kqe
import os
import boto3
import base64
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"))

metadata_value = "日本語"

encoded_metadata_value = base64.b64encode(metadata_value.encode()).decode()

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': encoded_metadata_value}},
    ExpiresIn=EXPIRES_SEC,
    HttpMethod='PUT',
)

return presigned_url   # returnはorg-modeで便宜上必要になったため指定している
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
署名付きURLの生成

生成した署名付きURLにプリフライトリクエストを送信する。

: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
// OPTIONS 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: ySUUTViAOPTmGobqYgthVzGSBj9rrBZ6Cd/DH6wbx8/Jboy7f5+cdtg9kJuU+EcCA/JGpGAPQrc=
// x-amz-request-id: Z1KM16PTJV70YDT5
// Date: Sat, 23 Apr 2022 11:25:45 GMT
// Access-Control-Allow-Origin: http://localhost:3000
// Access-Control-Allow-Methods: PUT
// Access-Control-Allow-Headers: x-amz-meta-foo-bar-baz
// Access-Control-Expose-Headers: x-amz-server-side-encryption, x-amz-meta-display-file-name, x-amz-request-id, x-amz-id-2
// Access-Control-Max-Age: 3000
// Access-Control-Allow-Credentials: true
// Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method
// Server: AmazonS3
// Content-Length: 0
// Request duration: 0.774393s
プリフライトリクエスト

生成した署名付き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が使用できることを確認した。 ただし細かな仕様の違いなどがあることがわかった。

脚注