CloudFormationのテンプレート

少しマニアックなAWSのCloudFormationのテンプレート3種類です。最初はよくわからずコピペで動作確認をする状態でしたが、色々とシステム構築をしていたらCloudFormationのスクリプトを自作できるようになってきました。CloudFormationは取っつきにくい仕組みですが、わかってくると簡単に安いサーバーレスなシステムが構築できるので楽しくなってきました。

なお、API GatewayとLambdaによるREST APIのシステム構築はserverless frameworkの"aws-nodejs-typescript"を利用しています。

①CloudFront + S3によるReactのWebサイト

CloudFrontとS3でReact用のWebサイトを構築するテンプレートです。

  • CloudFrontとS3にReactで作成したWebサイトを公開するシステムです。
  • 必ずドメイン/{パス}のURL構成になります。{パス}で指定したフォルダにReactのプロジェクトを置くことができます。このルートのマッピングは、CloudFrontのFunctionで行っています。
  • ドメインのルートには、デフォルトのパスにリダイレクトするHTMLファイルを置いて利用してください。
  • CloudFrontに行うカスタムドメインの名の設定とARNの設定は行っていますが、Route 53へのルートの割付は行っていないので、手動で行ってください。

このスクリプトに指定する証明書は、AWSのus-east-1リージョンで指定するカスタムドメイン名の証明書を手動で予め作成しておいてください

作成した証明書のARNは以下のコマンドで取得する事ができます。

aws acm list-certificates --region us-east-1

ProjectNamePrefixにはプロジェクトの名称、CertArnは上記のコマンドで取得したARNをDomainNameはWebサーバーを利用するドメイン名を設定してください。

AWSTemplateFormatVersion: 2010-09-09
Description: Static contents distribution using S3 and CloudFront.

Parameters:
  ProjectNamePrefix:
    Type: String
    Default: {各リソース名のPrefix}
  CertArn:
    Type: String
    Default: {証明書のArn} 
  DomainName:
    Type: String
    Default: {Domain名}

Resources:
  # S3 bucket contains static contents
  AssetsBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketName: !Sub '${ProjectNamePrefix}-web-bucket'

  # S3 bucket policy to allow access from CloudFront OAI
  AssetsBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref AssetsBucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${AssetsBucket}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}

  # CloudFront Distribution for contents delivery
  CloudFrontFunction:
    Type: AWS::CloudFront::Function
    Properties:
      Name: !Sub '${ProjectNamePrefix}-CloudFront-Function'
      FunctionConfig:
        Comment: 'Setting path of React Application'
        Runtime: cloudfront-js-1.0
      FunctionCode: |
        function handler(event) {
          var request = event.request;
          var uri = request.uri;
          if (uri.endsWith('/') || (!uri.includes('.'))) {
            var version =request.uri.split("/")[1];
            if(!version) {
              request.uri = "/index.html";
            } else {
              request.uri = `/${version}/index.html`;
            }
          }
          return request;
        }
      AutoPublish: true

  AssetsDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Comment: !Sub '${ProjectNamePrefix}'
        Aliases:
          - !Sub ${DomainName}
        Origins:
        - Id: S3Origin
          DomainName: !GetAtt AssetsBucket.DomainName
          S3OriginConfig:
            OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}
        Enabled: true
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ForwardedValues:
            QueryString: false
          ViewerProtocolPolicy: redirect-to-https
          FunctionAssociations:
            - EventType: "viewer-request"
              FunctionARN: !GetAtt CloudFrontFunction.FunctionMetadata.FunctionARN
        CustomErrorResponses:
          - ErrorCode: 403
            ResponseCode: 200
            ResponsePagePath: '/index.html'
            ErrorCachingMinTTL: 10
        ViewerCertificate:
          AcmCertificateArn: !Sub ${CertArn}
          SslSupportMethod: sni-only

  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Ref AWS::StackName

Outputs:
  URL:
    Value: !Join [ "", [ "https://", !GetAtt [ AssetsDistribution, DomainName ]]]

ルートのindex.html

S3のルートにはこのようにデフォルトのパスに置いておくhtmlファイルの例

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta charset="utf-8">
</head>
<script>
location.href='/v0'
</script>
</html>

②S3へのファイル書き込み => Lambdaトリガー

S3にファイルを書き込みしたらLambda関数が起動するというCloudFormationのテンプレートです。

Lambda関数にはTriggerLambdaRoleで、CloudWatchのログを有効にしています。

AWSTemplateFormatVersion: "2010-09-09"
Description: "Test resources."
Parameters:
  ProjectNamePrefix:
    Type: String
    Default: {各リソース名のPrefix}
  Stage:
    Type: String
    Default: {stage名}
Resources:
  TriggerLambdaRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      ManagedPolicyArns: 
        - "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
        - "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
      RoleName: !Sub "${ProjectNamePrefix}-trigger-dev-role"

  TriggerLambda:
    Type: "AWS::Lambda::Function"
    Properties:
      Code:
        ZipFile: |
          exports.handler = async (event) => {
            // TODO implement
            const response = {
              statusCode: 200,
              body: JSON.stringify('Hello from Lambda!'),
            };
            return response;
          };
      FunctionName: !Sub "${ProjectNamePrefix}-trigger-dev-function"
      Handler: "index.handler"
      MemorySize: 128
      Role: !GetAtt "TriggerLambdaRole.Arn"
      Runtime: "nodejs14.x"
      Timeout: 5

  TriggerLambdaPermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !GetAtt 
        - TriggerLambda
        - Arn
      Principal: "s3.amazonaws.com"
      SourceArn:  !Sub "arn:aws:s3:::${ProjectNamePrefix}-dev-bucket"
  SrcS3Bucket:
    Type: "AWS::S3::Bucket"
    DependsOn: "TriggerLambdaPermission"
    Properties:
      BucketName: !Sub "${ProjectNamePrefix}-dev-bucket"
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: "s3:ObjectCreated:*"
            Function: !GetAtt
              - TriggerLambda
              - Arn
      CorsConfiguration:
        CorsRules:
          - AllowedHeaders: 
              - "*"
            AllowedMethods:
              - "PUT"
              - "POST"
              - "DELETE"
              - "GET"
            AllowedOrigins:
              - "*"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true


③API GatewayのWebSocket + Lambda

このテンプレートの仕様は次のとおりです。

  • WebSocketのイベントであるConnect, Disconnect, メッセージは全て同じLambda関数に転送しています。
  • WebSocketはURLのクエリにAuthorizerを付与し認証をするAuthorizerが設定されています。(ApiGatewayV2Authorizer)
  • Lambda関数にはWebSocketFunctionRoleで、CloudWatchのログを有効にしています。また、DynamoDB(2種類)と登録したWebSocketへのアクセス権を付与しています。

AWSTemplateFormatVersion: "2010-09-09"
Description: "Test resources."

Parameters:
  ProjectNamePrefix:
    Type: String
    Default: {各リソース名のPrefix}
  Stage:
    Type: String
    Default: {stage名}

Resources:
  WebSocketApiGateway:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: !Sub "${ProjectNamePrefix}-socket-${Stage}"
      ProtocolType: WEBSOCKET
      RouteSelectionExpression: "$request.body.action"
  ConnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketApiGateway
      RouteKey: $connect
      AuthorizationType: CUSTOM
      AuthorizerId: !Ref ApiGatewayV2Authorizer
      OperationName: ConnectRoute
      Target: !Join
        - '/'
        - - 'integrations'
          - !Ref ConnectInteg

  ConnectInteg:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketApiGateway
      Description: Connect Integration
      IntegrationType: AWS_PROXY
      IntegrationUri:
        Fn::Sub:
          arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebSocketFunction.Arn}/invocations

  DisconnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketApiGateway
      RouteKey: $disconnect
      AuthorizationType: NONE
      OperationName: DisconnectRoute
      Target: !Join
        - '/'
        - - 'integrations'
          - !Ref DisconnectInteg
  DisconnectInteg:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketApiGateway
      Description: Disconnect Integration
      IntegrationType: AWS_PROXY
      IntegrationUri:
        Fn::Sub:
          arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebSocketFunction.Arn}/invocations

  DefaultRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketApiGateway
      RouteKey: $default
      AuthorizationType: NONE
      OperationName: DefaultRoute
      Target: !Join
        - '/'
        - - 'integrations'
          - !Ref SendInteg
  SendInteg:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketApiGateway
      Description: Send Integration
      IntegrationType: AWS_PROXY
      IntegrationUri:
        Fn::Sub:
          arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebSocketFunction.Arn}/invocations

  Deployment:
    Type: AWS::ApiGatewayV2::Deployment
    DependsOn:
    - ConnectRoute
    - DefaultRoute
    - DisconnectRoute
    Properties:
      ApiId: !Ref WebSocketApiGateway

  WebSocketApiGatewayStage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      StageName: !Ref Stage
      Description: Prod Stage
      DeploymentId: !Ref Deployment
      ApiId: !Ref WebSocketApiGateway

  ApiGatewayV2Authorizer:
    Type: AWS::ApiGatewayV2::Authorizer
    Properties: 
      ApiId: !Ref WebSocketApiGateway
      AuthorizerType: "REQUEST"
      AuthorizerUri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebSocketFunction.Arn}/invocations"
      IdentitySource: 
        - "route.request.querystring.Authorization"
      Name: auth

  WebSocketFunctionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      ManagedPolicyArns: 
        - "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
        - "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
      RoleName: !Sub "${ProjectNamePrefix}-socket-${Stage}-role"
      Policies:
        - PolicyName:  !Sub "${ProjectNamePrefix}-socket-${Stage}-policy"
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:Query
                  - dynamodb:DeleteItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                Resource: 
                  - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ProjectNamePrefix}-prod-data"
              - Effect: Allow
                Action:
                  - dynamodb:Query
                  - dynamodb:DeleteItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                Resource: 
                  - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ProjectNamePrefix}-prod-doc"
              - Effect: Allow
                Action:
                  - ses:SendEmail
                Resource: "*"
              - Effect: Allow
                Action:
                  - 'execute-api:ManageConnections'
                Resource:
                  - !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApiGateway}/*'

  WebSocketFunction:
    Type: AWS::Lambda::Function
    Properties: 
      Code: 
        ZipFile: |
          exports.handler = async (event) => {
            // TODO implement
            const response = {
              statusCode: 200,
              body: JSON.stringify('Hello from Lambda!'),
            };
            return response;
          };
      FunctionName: !Sub "${ProjectNamePrefix}-socket-${Stage}"
      Handler: "index.handler"
      MemorySize: 128
      Role: !GetAtt WebSocketFunctionRole.Arn
      Runtime: "nodejs14.x"
      Timeout: 5

  WebSocketLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref WebSocketFunction
      Principal: apigateway.amazonaws.com
    DependsOn:
      - WebSocketApiGateway

Outputs:
  WebSocketFunctionArn:
    Value: !GetAtt WebSocketFunction.Arn

  WebSocketURI:
    Description: "The WSS Protocol URI to connect to"
    Value: !Join [ '', [ 'wss://', !Ref WebSocketApiGateway, '.execute-api.',!Ref 'AWS::Region','.amazonaws.com/',!Ref 'Stage'] ]