初めに
先週のアップデート?でCloudFrontのオリジンとしてS3 Object Lambdaのアクセスポイントをサポートするようになっていました。
Amazon CloudFront supports S3 Object Lambda Access Point origin
Prior to this launch, you were required to use Lambda@Edge as the signing principal with AWS Signature Version 4 (SigV4) for authentication with the origin. You can now use CloudFront as the signing principal for SigV4 authentication with the S3 Object Lambda Access Point origin.
実のところ本アップデートでS3 Object Lambdaの存在を初めて知りましたが、
What's newの説明を読む限りこれ以前はCloudFrontをプリンシパルとして署名することができないためLambda@EdgeによるSIGv4による署名が必要であったようです。
ただ1ヶ月近く前の2023/03/17(英語版は2023/03/14)の公式ブログでは既にOACを使ったアクセス制御が紹介されていました。
タイミング的には以下の告知とほぼ同タイミングなので今回のアップデートで私の方で何か勘違いがあるか、明記されていないだけで元々対応していたのかもしれません。
当ブログでCloudFront + S3 Object Lambdaの対応アップデートの紹介も見当たらなかったのでOACの制御と合わせて実際に試してみようと思います。
Amazon S3 Object Lambdaとは
S3にGETやListのアクセスが発生した場合に利用者に返却する前にAWS Lambdaによってその返却データを処理できるようになるための機能となります。
こちらの機能によりデータマスタとしては1つのデータとして持ちつつ、要求に応じてデータ形式を変換したりテキストデータを翻訳したりと複数のファイルパターンを提供することが可能です。
別の方が本機能の実装時のアップデートブログとして例を作成されていますのでこちらもご参照ください。
通常の静的コンテンツに比べて恩恵が相対的に大きそう(主観)
CloudFrontはCDNとして静的コンテンツのキャッシュを返却することでオリジンの負荷やコストを減らすことが一般的に言われます。
S3 Object Lambdaの場合はシンプルにコンテンツを返却する静的なオブジェクトと異なり、処理を行った上で応答を行うためある種の動的コンテンツでもありますし当然応答にはその処理時間が必要となります。
ケースバイケースとなりますがそういったコンテンツをキャッシュできるため通常の静的コンテンツに比べ相対的にレイテンシの改善の効果が大きいのではないかなと個人的には考えております。
実装
構成
CloudFrontから特定のパスにアクセスした場合指定したバケットの同等のパスのファイルを取得しLambdaハッシュ化し返却します。
構成としては以下のようになります。
依存関係が若干自信ないですがドキュメント上のリソースベースポリシーの設定とS3 Object Lambdaのドキュメントのトップを見る限りはこの形になるはずです。
S3のアクセスポイントに対するURLへの署名はCloudFrontで行われている点、LambdaからのObject Lambdaアクセスポイントへの応答はreturnではなく明示的にS3 APIを呼び出し書き込みが必要となるのでその権限が必要な点は注意が必要です。
CloudFormationテンプレート
CloudFrontは設定量が非常に多くなるのでこの部分の設定割り当ては手動で行い、それ以外の部分をCloudFormationで実装します。
ガイドページを参考に設定を作成しましたが、リソースベースポリシーの設定があちこちで必要なので人によっては難しく感じるかもしれません。
作成時にはDistributionIdを存在しなさそうな適当な値で入力し、後ほどこの値は差し替えます。
AWSTemplateFormatVersion: 2010-09-09
Parameters:
DistributionId:
Type: String
Resources:
Bucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub object-lambda-test-${AWS::AccountId}
PublicAccessBlockConfiguration:
BlockPublicAcls: True
BlockPublicPolicy: True
IgnorePublicAcls: True
RestrictPublicBuckets: True
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
BucketEncryption:
ServerSideEncryptionConfiguration:
- BucketKeyEnabled: True
ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
ObjectLambdaAccessPoint:
Type: AWS::S3ObjectLambda::AccessPoint
Properties:
Name: from-cloudfront-object-lambda-ap
ObjectLambdaConfiguration:
SupportingAccessPoint: !GetAtt S3AccessPoint.Arn
TransformationConfigurations:
- ContentTransformation:
AwsLambda:
FunctionArn: !GetAtt ConvertFunction.Arn
Actions:
- GetObject
ObjectLambdaAccessPointPolicy:
Type: AWS::S3ObjectLambda::AccessPointPolicy
Properties:
ObjectLambdaAccessPoint: !Ref ObjectLambdaAccessPoint
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action:
- s3-object-lambda:Get*
Resource: !Sub "arn:aws:s3-object-lambda:${AWS::Region}:${AWS::AccountId}:accesspoint/${ObjectLambdaAccessPoint}"
Condition:
StringEquals:
aws:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId}"
S3AccessPoint:
Type: AWS::S3::AccessPoint
Properties:
Bucket: !Ref Bucket
Name: from-cloudfront-ap
Policy:
Version: "2012-10-17"
Statement:
- Sid: FromCloudFront
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action:
- s3:*
Resource:
- !Sub "arn:aws:s3:${AWS::Region}:${AWS::AccountId}:accesspoint/from-cloudfront-ap"
- !Sub "arn:aws:s3:${AWS::Region}:${AWS::AccountId}:accesspoint/from-cloudfront-ap/object/*"
Condition:
ForAnyValue:StringEquals:
aws:CalledVia: "s3-object-lambda.amazonaws.com"
PublicAccessBlockConfiguration:
BlockPublicAcls: True
BlockPublicPolicy: True
IgnorePublicAcls: True
RestrictPublicBuckets: True
BucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref Bucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Principal:
AWS: "*"
Action:
- s3:GetObject
Resource:
- !Sub "arn:aws:s3:::${Bucket}"
- !Sub "arn:aws:s3:::${Bucket}/*"
Condition:
StringEquals:
s3:DataAccessPointAccount: !Ref AWS::AccountId
ConvertFunction:
Type: AWS::Lambda::Function
Properties:
Runtime: python3.9
Handler: index.lambda_handler
Role: !GetAtt FunctionExecuteRole.Arn
Code:
ZipFile: | #後述
ConvertFunctionPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref ConvertFunction
Action: lambda:InvokeFunction
Principal: cloudfront.amazonaws.com
SourceArn: !Ref Distribution
FunctionExecuteRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action:
- 'sts:AssumeRole'
Policies:
- PolicyName: inline-policy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: LogAccess
Effect: "Allow"
Action:
- "logs:CreateLogGroup"
- "logs:CreateLogStream"
- "logs:PutLogEvents"
Resource:
"arn:aws:logs:*:*:*"
- Sid: reponseWriter
Effect: "Allow"
Action:
- "s3-object-lambda:WriteGetObjectResponse"
Resource:
#循環参照回避のためのベタ書き
- !Sub "arn:aws:s3-object-lambda:${AWS::Region}:${AWS::AccountId}:accesspoint/from-cloudfront-object-lambda-ap"
Lambdaコード
リクエストのあったファイルを読み込みMD5ハッシュを返すコードとなります。
自分は普段requests
を使いHTTPアクセスを行いますが、マネージドランタイム環境にデフォルトで入っていないので今回はurllib
を利用しています。
注意点としては通常のLambdaのようにreturnで値を戻すのではなくwrite_get_object_response()
によって返却ストリームに応答を書き込むという点です。
import boto3
import json
import hashlib
import urllib
s3 = boto3.client('s3')
def lambda_handler(event, context):
print(json.dumps(event))
obj = event['getObjectContext']
res = urllib.request.urlopen(obj['inputS3Url'])
body_hash = hash.hexdigest(hashlib.md5(res.read()))
return s3.write_get_object_response(
RequestRoute=obj['outputRoute'],
RequestToken=obj['outputToken'],
Body=body_hash
)
Distributionの作成
上記のテンプレートのデプロイが完了した後デフォルトの先をS3 Object Lambdaアクセスポイントに設定したCloudFront Distributionを作成します。
S3の部分を全て隠してしまってるのでこれだと伝わらないかと思いますが、現時点ではS3に表示されるのはあくまでS3バケットでありS3 Object Lambdaのアクセスポイント相当の値は表示されません。
表示はされませんが手動で入力することで割り当てが可能ですので指定した値を書き込みます。
設定する値はガイドページによると{{alias}}.s3.{{region}}.amazonaws.com
となります。
{{region}}
の値はS3 Object Lambdaアクセスポイントのリージョンとなり、{{alias}}
は作成時に固有に割り当てられた値となります。
値はいずれもS3 Object Lambdaアクセスポイントのページから確認可能です。
オリジンドメインの末尾でS3と判定しているのか、正しく入力されていれば入力インターフェースがS3バケットを指定した時と同等のものに切り替わります。
設定値としてはS3 Object Lambda固有のものは特になくS3バケットをオリジンに設定しOACでアクセスさせる時と同等のものを設定すれば問題ありません。
画面外の値はデフォルトの値を設定しており特別な値は設定しておりません。
作成後は先ほどのCloudFormationテンプレートのDistributionId
の値を差し替えておきます。
テストデータの設定
hello.txt
という名前で中身がhello world.
というテキストファイルを設置します。
CloudFrontにアクセス
初回アクセス
CloudFront経由でhello.txtに対してアクセスをします。
加工されたデータが出力されていることを確認できました。
AWS Lambda側に引き渡されたevent
パラメータの中身は以下のような形です。
{
"xAmzRequestId": "xxxxxx",
"getObjectContext": {
"outputRoute": "io-cell001",
"outputToken": "xxxxxxxx=",
"inputS3Url": "https://from-cloudfront-ap-xxxx.s3-accesspoint.ap-northeast-1.amazonaws.com/hello.txt?X-Amz-Security-Token=xxxxxx&X-Amz-SignedHeaders=host&X-Amz-Expires=61&X-Amz-Credential=xxxxxx&X-Amz-Signature=xxxxxx"
},
"configuration": {
"accessPointArn": "arn:aws:s3-object-lambda:ap-northeast-1:xxxxx:accesspoint/from-cloudfront-object-lambda-ap",
"supportingAccessPointArn": "arn:aws:s3:ap-northeast-1:xxxx:accesspoint/from-cloudfront-ap",
"payload": ""
},
"userRequest": {
"url": "https://xxxxx--ol-s3.s3.ap-northeast-1.amazonaws.com/hello.txt",
"headers": {
"x-amz-content-sha256": "xxxxxxx",
"X-Amz-Source-Arn": "arn:aws:cloudfront::xxxxxx:distribution/xxxxxx",
"Cache-Control": "no-cache",
"Connection": "Keep-Alive",
"Host": "from-cloudfront-obje-xxxxxx--ol-s3.s3.ap-northeast-1.amazonaws.com",
"Accept-Encoding": "br,gzip",
"Pragma": "no-cache",
"Via": "2.0 xxxxxx.cloudfront.net (CloudFront)",
"X-Amz-Source-Account": "xxxxxx"
}
},
"userIdentity": {
"type": "AWSService",
"invokedBy": "cloudfront.amazonaws.com"
},
"protocolVersion": "1.00"
}
最初にドキュメントを読んでいた時はなぜS3アクセスポイントのプリンシパルがCloudFrontになるのだろうかと思っていたのですが、S3アクセスポイントに対するURLの署名もCloudFront側で行なっているようです。
補足ですがfaviconの要求で500エラーが起きているのはurllibの場合ステータスが403となる場合HTTPError
例外が発生しますが、これを適切にハンドリングしていないからとなります。
2回目以降のアクセス
確認後CDNに再度アクセスしキャッシュが存在していることを確認します。
上記にアクセスしたのは12:26頃となりますが、この後にCloudFront側のキャッシュを削除し、再度12:27にアクセスしたところ12:26時点でLambdaのログが出力されずCDNキャッシュのおかげでLambda処理が不要になったことがわかります。
終わりに
今回はCloudFront + S3 Object LambdaをOACによるアクセス制御で実装してみました。
Object Lambdaは毎回加工が発生する関係上どうしてもレスポンスが遅くなってしまいレイテンシを気になる部分では使いづらかった部分をCloudFrontでキャッシュ化できるようになったことでレスポンスの高速化やLambdaのコストの低減などにより使いやすくなったのではないかと思います。
また、OACの追加対応(?)によりCloudFront以外からのアクセス制御もより簡単になったのでCloudFrontの設定としても手軽になったようです。
とはいえキャッシュが切れたタイミングで加工処理が発生する以上、事前にデータを加工して設置することに比べて応答の遅延が大きくなるタイミングがあるため要件に合わせて採用するかどうかの検討は引き続き必要となる点はご注意ください。