[アップデート]Amazon CloudFrontからAmazon S3 Object LambdaへのアクセスをOACで設定してみた

2023.04.11

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

初めに

先週のアップデート?で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の設定としても手軽になったようです。

とはいえキャッシュが切れたタイミングで加工処理が発生する以上、事前にデータを加工して設置することに比べて応答の遅延が大きくなるタイミングがあるため要件に合わせて採用するかどうかの検討は引き続き必要となる点はご注意ください。