目次

概要

以前に 1つのAWS Lambda関数でSPAフレームワーク作成1つのAWS Lambda関数でSPA(その2) という記事を書いたが、当記事では API Gateway ではなく CloudFront を利用して似たような事をやってみる。

  • 静的コンテンツ(HTML、CSS、JS等) はS3に配置する。
  • 動的コンテンツは Lambda で提供する。
  • 動的コンテンツへのリクエストの場合は、Lambda@Edge を使用してレスポンスを返却する
  • 作成する Lambda は1つのみとし、リクエスト情報から処理を判定する。(Lambda内部でルーティング)
  • フロントエンドには Vue.js を利用する
  • S3バケット内のフォルダ構成

/css
/html
/js

・・・ CSS格納用
・・・ HTML格納用
・・・ JS格納用

  • URI設計

/api
/css
/html
/js

・・・ Lambdaで処理
・・・ オリジン(S3)にリクエストさせる
・・・ 〃
・・・ 〃

イメージ

TODO:

この構成のメリット/デメリット

  • メリット
    • 静的コンテンツは CloudFront のキャッシュにより高速配信が期待できる
    • 1つのアプリケーションとしてデプロイする事ができる
  • デメリット
    • CloudFrontのデプロイに時間がかかる(10〜15分)
        ※恐らく更新内容をエッジロケーションに反映させるのに時間がかかっている。
        ※Lambdaも各リージョンにレプリカを作るらしい。
    • グローバルに配信するコンテンツでない場合、高速配信のメリットは殆どない。
    • 1 Lambdaの為、個別にメモリ調整等ができない

ここまで書いて気づく。
グローバル配信しない場合、高速配信のメリットはあまり無いうえにデプロイに時間がかかり過ぎる(10分〜15分程かかる)
今の所、グローバル配信の予定もないので、当記事の記載はいったん中断する。(覚書として調査/確認した所を記載する程度に留める)

料金試算

TODO:

静的コンテンツの準備

src/html/index.html

<!doctype html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <title>Sample</title>
</head>
<body>

<div id="app">
    <button v-on:click="callApi('sample1')">Sample API1</button>
    <div>
        <p>Message : {{ message }}</p>
        <p>Datetime: {{ datetime }}</p>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
var app = new Vue({
    el: '#app',
    data: {
        message: "init message",
        datetime: ""
    },
    methods: {
        callApi: function(apiName){
            var self = this;
            var uri = "/api/" + apiName;
            axios.get(uri)
                .then(function (res) {
                    self.message = res.data.message;
                    if (res.data.datetime) {
                        self.datetime = res.data.datetime;
                    }
                });
        }
    },
});
</script>
</body>
</html>

Lambdaの作成

src/index.js

'use strict';

var myapp = require("myapp");

exports.handler = (event, context, callback) => {

    const request = event.Records[0].cf.request;
    const headers = request.headers;

    console.log("event:\n" + JSON.stringify(event, null, 2));

    // /api 配下へのリクエストの場合
    if (request["uri"] && request["uri"].match("/api/.+")) {

        // ルーティング( /api/xxxx.js を実行 )
        //   TODO: ヘッダの検証による認証/認可など
        //   TODO: コンテキストに依存しないIFにしておく方が良い
        let path = request["uri"].replace(/^\//,"") + ".js";  // TODO: ../ への対応など
        if (myapp.fileExists(path)){
            var module = require(path);
            module.handler(event, context, function(error, response){
                callback(error, response);
            });
            return;
        } else if (コンテンツアップロード用のURIの場合) {  // 例) /api/76ee3de97a1b8b903319b7c013d8c877
            // TODO: src/html、src/css、src/js を対象のS3バケットにアップロード
        }

        // 対象の処理がない場合
        let d = new Date();
        let result = {
            message: "target API not found!",
            datetime: d.toLocaleDateString() + " " + d.toLocaleTimeString()
        }
        const response = {
            status: 200,
            statusDescription: "",
            headers: {
                "content-type": [
                    {key: "Content-Type", value: "application/json"}
                ]
            },
            body: JSON.stringify(result),
            bodyEncoding: "text"
        };
        callback(null, response);
    } else {

        // API以外へのリクエストはそのままオリジンに流す
        callback(null, request);
    }
};

src/api/sample1.js

'use strict';

/**
 * サンプルAPI1
 */
exports.handler = (event, context, callback) => {

    const request = event.Records[0].cf.request;
    const headers = request.headers;

    console.log("event:\n" + JSON.stringify(event, null, 2));

    let d = new Date();
    let result = {
        message: "Sample1 API!!",
        datetime: d.toLocaleDateString() + " " + d.toLocaleTimeString()
    }

    const response = {
        status: 200,
        headers: {
            "content-type": [
                {key: "Content-Type", value: "application/json"}
            ],
            "cache-control": [
                {key: "Cache-Control", value: "max-age=0"}
            ]   
        },
        body: JSON.stringify(result),
        bodyEncoding: "text"
    };
    callback(null, response);
};

src/node_modules/myapp.js

'use strict';

var fs = require('fs');

/**
 * 共通関数などの定義.
 */
const myapp = { 
    "fileExists" : function(path){
        try {
          return fs.statSync(path);
        } catch (e) {
          console.error("not found : " + path);
          return false;
        }   
    }   
}

module.exports = myapp;

CloudFormationテンプレート

参考)
https://github.com/awslabs/serverless-application-model/tree/master/examples/2016-10-31/lambda_edge

注意)
edgelambda.amazonaws.com を assumable role しないと、
「The function execution role must be assumable with edgelambda.amazonaws.com as well as lambda.amazonaws.com principals.」と怒られる。

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31

Resources:

  # アクセスログ用バケット
  CloudFrontSpaLogBucket:
    Type: AWS::S3::Bucket
    #DeletionPolicy: Retain
    Properties:
      BucketName: !Sub 'cloudfront-spa-lambda-log-${AWS::Region}-${AWS::AccountId}'

  # 静的コンテンツ用バケット
  CloudFrontSpaBucket:
    Type: AWS::S3::Bucket
    #DeletionPolicy: Retain
    Properties:
      BucketName: !Sub 'cloudfront-spa-lambda-${AWS::Region}-${AWS::AccountId}'
      AccessControl: PublicRead

  # 静的コンテンツ用バケットポリシー
  # (OAIを使用してCloudFront経由のアクセスのみ許可)
  CloudFrontSpaBucketPolicy:
    Type: AWS::S3::BucketPolicy
    DependsOn:
      - CloudFrontSpaBucket
      - CloudFrontSpaOai
    Properties:
      Bucket: !Ref CloudFrontSpaBucket
      PolicyDocument:
        Version: 2008-10-17
        Statement:
          - Action:
              - s3:GetObject
            Effect: Allow
            Resource: !Sub "${CloudFrontSpaBucket.Arn}/*"
            Principal:
              CanonicalUser: !GetAtt CloudFrontSpaOai.S3CanonicalUserId

  # オリジンリクエストトリガーから起動されるLambda関数
  CloudFrontSpaFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: CloudFrontSpaFunction
      CodeUri: ./src
      Handler: index.handler
      Role: !GetAtt CloudFrontSpaFunctionRole.Arn
      #Runtime: python3.6     # Lambda@Edgeでは python はサポート対象外
      Runtime: nodejs8.10
      AutoPublishAlias: live  # 追加プロパティである Version を使用可能にする

  # Lambda関数用のロール
  CloudFrontSpaFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: CloudFrontSpaFunctionRole
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - edgelambda.amazonaws.com
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: "CloudFrontSpaFunctionRolePolicy"
          PolicyDocument:
            # 許可する操作/リソースなど
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  #- logs:CreateLogGroup
                  #- logs:CreateLogStream
                  #- logs:PutLogEvents
                  - s3:*
                Resource: "*"

  # CloudFrontディストリビューション
  SpaCloudfrontDistribution:
    Type: AWS::CloudFront::Distribution
    DependsOn:
      - CloudFrontSpaBucket
      - CloudFrontSpaOai
    Properties:
      DistributionConfig:
        Enabled: true
        # デフォルトのキャッシュ動作
        DefaultCacheBehavior:
          # 許可するメソッド
          AllowedMethods:
            - GET
            - HEAD
          CachedMethods:
            - GET
            - HEAD
          # ターゲットのオリジンのID ( Origins 配下に定義したオリジンと紐付ける )
          TargetOriginId: CloudFrontSpaBucket-Origin
          # オリジンに転送する情報の設定
          ForwardedValues:
            # オリジンに転送するCookie
            Cookies:
              # Cookie転送するならLambda側で値だけ取得して、S3に転送する際には値を空に書き換える等?(キャッシュ効率を考慮)
              Forward: all
              #WhitelistedNames:
              #  - String
            # オリジンに転送するヘッダー ( キャッシュに影響するので定義しない方が良さそう )
            Headers:
              #- "User-Agent"                    # キャッシュ効率が悪くなる為、User-Agent は定義しない方が良さそう
              - "CloudFront-Is-Desktop-Viewer"   # 代わりに CloudFront-Is-XXXX-Viewer を使用する事が可能
              - "CloudFront-Is-Mobile-Viewer"
              - "CloudFront-Is-SmartTV-Viewer"
              - "CloudFront-Is-Tablet-Viewer"
            # オリジンにクエリ文字列を転送するか
            QueryString: true
            # キャッシュに使用するクエリ文字列
            # (転送はするがキャッシュキーにしたくない場合はダミーを1つ定義しておく)
            QueryStringCacheKeys:
              - "dummy"
          # オリジンリクエストトリガーを使用してLambdaを起動する設定
          LambdaFunctionAssociations:
            - EventType: origin-request
              #LambdaFunctionARN: !Sub "${CloudFrontSpaFunction.Arn}:バージョン"
              LambdaFunctionARN: !Ref CloudFrontSpaFunction.Version
          # HTTPSのみ許可
          ViewerProtocolPolicy:
            https-only
          #MaxTTL: 
          #MinTTL: 
          #DefaultTTL:
        # IPv6アドレス使用するか
        IPV6Enabled: true
        # ビューワーで使用する最大のHTTPバージョン
        HttpVersion: http2
        # ルートURL指定時に公開するオブジェクト名
        DefaultRootObject: index.html
        ViewerCertificate:
          CloudFrontDefaultCertificate: true   # CloudFrontドメイン名を使用する場合
          #AcmCertificateArn: String           # ACMで作成した証明書を使用する場合
        # オリジンに関する情報
        Origins:
          - DomainName: !GetAtt CloudFrontSpaBucket.DomainName
            Id: CloudFrontSpaBucket-Origin
            S3OriginConfig: 
              OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontSpaOai}
            #OriginPath: String
            #CustomOriginConfig: 
            #  OriginKeepaliveTimeout: integer-value
            #  OriginReadTimeout: integer-value
        # カスタムエラーページ
        CustomErrorResponses:
          - ErrorCachingMinTTL: 5
            ErrorCode: 403
            ResponseCode: 403
            ResponsePagePath: /html/error.html
          - ErrorCachingMinTTL: 5
            ErrorCode: 404
            ResponseCode: 404
            ResponsePagePath: /html/error.html
        # ログの出力先
        Logging:
            Bucket: !GetAtt CloudFrontSpaLogBucket.DomainName
            IncludeCookies: true

  # S3保護用のオリジンアクセスアイデンティティ(OAI)
  CloudFrontSpaOai:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Ref AWS::StackName

Outputs:
  SpaCloudfrontDistributionId:
    Value: !Ref SpaCloudfrontDistribution
    Export:
      Name: SpaCloudfrontDistributionId
  SpaCloudfrontDistributionDomainName:
    Value: !GetAtt SpaCloudfrontDistribution.DomainName
    Export:
      Name: SpaCloudfrontDistributionDomainName
  CloudFrontSpaFunctionVersion:
    Value: !Ref CloudFrontSpaFunction.Version
    Export:
      Name: CloudFrontSpaFunctionVersion

AutoPublishAlias: live について

AutoPublishAlias: live を指定すると Version という追加のプロパティが使用できるようになるらしい。
AWS::CloudFront::Distribution 側の LambdaFunctionAssociations 配下の LambdaFunctionARN では、これを使用する事ができるようになる。

https://github.com/awslabs/serverless-application-model/tree/master/examples/2016-10-31/lambda_edge

例)

  LambdaEdgeFunctionSample:
    Type: AWS::Serverless::Function
    Properties:
       :
      CodeUri: ./src
      Handler: index.handler
      AutoPublishAlias: live 
    
  SampleDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
        DistributionConfig:
             :
            LambdaFunctionAssociations:
                - 
                EventType: origin-request
                LambdaFunctionARN: !Ref LambdaEdgeFunctionSample.Version

補足(Lambda@Edge)

Lambda@Edge では、Node.js Lambda 関数により返却するコンテンツをカスタマイズする事ができる。

Lambda 関数が実行出来るタイミングは以下の通り。

  • CloudFront がビューワーからリクエストを受信したとき (ビューワーリクエスト)
  • CloudFront がリクエストをオリジンに転送する前 (オリジンリクエスト)
  • CloudFront がオリジンからレスポンスを受信したとき (オリジンレスポンス)
  • CloudFront がビューワーにレスポンスを返す前 (ビューワーレスポンス)

当記事では、上記の「オリジンリクエスト」イベントによってLambdaをトリガーし、動的コンテンツか静的コンテンツかを判定する。
動的コンテンツへのリクエストだった場合はLambd 内でレスポンスデータを作成/返却する。
静的コンテンツへのリクエストだった場合は何もしない。(そのままオリジンに対してリクエストさせる)


トップ   差分 バックアップ リロード   一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2019-09-16 (月) 18:24:00 (742d)