CloudFormationでSPA用のWAF+CloudFront+S3構成を一発で構築

CloudFormationでSPA用のWAF+CloudFront+S3構成を一発で構築
この記事をシェアする

はじめに

こんにちは。スカイアーチHRソリューションズのさとゆうです。

今回はSPAでよく使われるAWSの構成をCloudFormationで作成していきます。

きっかけ

よく見られる構成図ではありますが、今回改めて記事にしようと思ったきっかけは以下になります。

  1. WAFとCloudFrontの連携の際、AWS WAFをバージニア北部リージョンに作る必要がある。

2. 1の制約によりテンプレートを東京/バージニア北部リージョンそれぞれに用意する必要がある。

これらが理由でテンプレート1つで一気にリソースが作れないことにもどかしさを感じていたためです。

リソースを一気に作ろうとして怒られた方も多いようです。自分もそのうちの一人です笑

どうにかしてリソースを一気に作ることができないか考えていたところ、CloudFormationのスタックセット機能が使えそうでしたのでこちらの方法を紹介します。

スタックセットとは?

スタックセットは複数のAWSアカウントやAWSリージョンにまたがるスタックを一元的に管理するための機能です。1つのCloudFormationテンプレートを使用し、複数のAWSアカウントやAWSリージョンに対して一括でスタックを作成、更新、削除することができます。

またAWS Organizationsのマルチアカウント機能と連携して利用することができ、手軽かつ効率的なAWSインフラストラクチャの管理ができます。

構築手順

前準備

CloudFormationでスタックセットを扱うにあたって名前が固定のIAMロールを2つ作成する必要がありますので準備してください。すでに作成済みかつ必要な権限を設定済みの場合、前準備のステップはスキップしてください。

必要なIAMロール

  • AWSCloudFormationStackSetAdministrationRole
  • AWSCloudFormationStackSetExecutionRole

以下参考テンプレートになりますので、必要に応じて作成してください。

AWSTemplateFormatVersion: 2010-09-09
Description: |
  iam setting for cloudformation stacksets
# ------------------------------------------------------------#
# Parameter
# 依存循環を回避するため、入力パラメータで値を管理しています。
# ------------------------------------------------------------#
Parameters: 
  AdminAccountRoleName:
    Type: String
    Description: fixed input
    Default: AWSCloudFormationStackSetAdministrationRole

  TargetAccountRoleName:
    Type: String
    Description: fixed input
    Default: AWSCloudFormationStackSetExecutionRole
# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
  StackSetAdministrationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Ref AdminAccountRoleName
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: cloudformation.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: AssumeRole-Policy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - sts:AssumeRole
                Resource:
                  - !Sub arn:aws:iam::${AWS::AccountId}:role/${TargetAccountRoleName}

  StackSetExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Ref TargetAccountRoleName
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !GetAtt StackSetAdministrationRole.Arn
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - !Sub arn:${AWS::Partition}:iam::aws:policy/PowerUserAccess
        - !Sub arn:${AWS::Partition}:iam::aws:policy/IAMReadOnlyAccess
      Policies:
        - PolicyName: PassRole-Policy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: "*"
                Resource: "*"
Outputs:
  StackSetAdministrationRoleOutput:
    Value: !Ref StackSetAdministrationRole
    Export: 
      Name: StackSetAdministrationRole-output
  StackSetAdministrationRoleArnOutput:
    Value: !GetAtt StackSetAdministrationRole.Arn 
    Export: 
      Name: StackSetAdministrationRole-arn-output
  StackSetExecutionRoleOutput:
    Value: !Ref StackSetExecutionRole
    Export: 
      Name: StackSetExecutionRole-output
  StackSetExecutionRoleArnOutput:
    Value: !GetAtt StackSetExecutionRole.Arn 
    Export: 
      Name: StackSetExecutionRole-arn-output

今回はStackSetExecutionRoleにPowerUserAccessとIAMReadOnlyAccessのポリシーを設定していますが、権限不足の問題が生じた際はAdministratorAccessの権限に変更してみてください。

用意したテンプレート

今回リソースを一気に作るために用意したテンプレートが以下になります。

バージニア北部で利用するテンプレート

# Create from us-east-1
AWSTemplateFormatVersion: 2010-09-09
# ------------------------------------------------------------#
# Label
# ------------------------------------------------------------#
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Label:
          default: "Common param"
        Parameters:
          - StackSetAdministrationRoleArn
          - StackSetExecutionRole
          - StackSetTemplateUrl
      -
        Label:
          default: "CloudFront param"
        Parameters:
          - CachePolicyName
          - DefaultRootObject
# ------------------------------------------------------------#
# Parameter
# ------------------------------------------------------------#
Parameters: 
  StackSetAdministrationRoleArn:
    Type: String
    Description: input from StackSetAdministrationRoleArnOutput

  StackSetExecutionRole:
    Type: String
    Description: input from StackSetExecutionRoleOutput

  StackSetTemplateUrl:
    Type: String
    Description: input stackset template url

  CachePolicyName:
    Type: String
    Default: Managed-CachingDisabled
    Description: (Required) Name of Managed Cache Policy
    AllowedValues:
      - Managed-CachingDisabled
      - Managed-CachingOptimized
      - Managed-CachingOptimizedForUncompressedObjects

  DefaultRootObject:
    Type: String
    Default: index.html
    Description: (Required) Default Root Object of CloudFront Distribution
# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
  # ---------------------------------#
  # WAF
  # ---------------------------------#
  WebACL:
    Type: AWS::WAFv2::WebACL
    Properties:
      DefaultAction:
        Allow: {}
      Name: !Sub ${AWS::StackName}-cloudfront-webacl
      Scope: CLOUDFRONT
      Rules:
        -
          Name: AWS-AWSManagedRulesCommonRuleSet
          Priority: 1
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesCommonRuleSet
          OverrideAction:
            None: {} # or Count for monitoring
          VisibilityConfig:
            CloudWatchMetricsEnabled: true
            SampledRequestsEnabled: false # or true for monitoring
            MetricName: AWS-AWSManagedRulesCommonRuleSet

      VisibilityConfig:
        CloudWatchMetricsEnabled: true
        MetricName: !Sub ${AWS::StackName}-cloudfront-waf-metric
        SampledRequestsEnabled: true
      
  # ------------------------------------------------------------#
  #  StackSets:ap-northeast-1
  # ------------------------------------------------------------#
  StacksetForTokyo:
    Type: AWS::CloudFormation::StackSet
    Properties:
      StackSetName: !Sub ${AWS::StackName}-ap-northeast-1-stackset
      AdministrationRoleARN: !Ref StackSetAdministrationRoleArn
      Capabilities:
        - CAPABILITY_IAM
        - CAPABILITY_NAMED_IAM
      ExecutionRoleName: !Ref StackSetExecutionRole
      OperationPreferences:
        FailureTolerancePercentage: 0
        MaxConcurrentPercentage: 100
        RegionConcurrencyType: PARALLEL
      Parameters:
        - ParameterKey: CachePolicyName
          ParameterValue: !Ref CachePolicyName
        - ParameterKey: DefaultRootObject
          ParameterValue: !Ref DefaultRootObject
        - ParameterKey: WebACLArn
          ParameterValue: !GetAtt WebACL.Arn
      PermissionModel: SELF_MANAGED
      StackInstancesGroup:
        - DeploymentTargets:
            Accounts:
              - !Ref AWS::AccountId
          Regions:
            - ap-northeast-1
      TemplateURL: !Ref StackSetTemplateUrl

東京リージョンで利用するテンプレート

AWSTemplateFormatVersion: 2010-09-09
# ------------------------------------------------------------#
# Mapping
# ------------------------------------------------------------#
Mappings:
  CachePolicyMap:
    Managed-CachingOptimized:
      Id: 658327ea-f89d-4fab-a63d-7e88639e58f6
    Managed-CachingDisabled:
      Id: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
    Managed-CachingOptimizedForUncompressedObjects:
      Id: b2884449-e4de-46a7-ac36-70bc7f1ddd6d
# ------------------------------------------------------------#
# Parameter
# ------------------------------------------------------------#
Parameters: 
  CachePolicyName:
    Type: String
  DefaultRootObject:
    Type: String
  WebACLArn:
    Type: String

# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------# 
Resources:
  # ---------------------------------#
  # S3:Contents Bucket
  # ---------------------------------#
  ContentsBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      VersioningConfiguration:
        Status: Enabled

  # ---------------------------------#
  # S3:Contents Bucket Policy
  # ---------------------------------#
  ContentsBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref ContentsBucket
      PolicyDocument:
        Id: PolicyForCloudFrontPrivateContent
        Version: 2012-10-17
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Principal:
              Service:
                - cloudfront.amazonaws.com
            Resource: !Sub ${ContentsBucket.Arn}/*
            Condition: 
              StringEquals:
                AWS:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${Cloudfront}

  # ---------------------------------#
  # CloudFront
  # ---------------------------------#
  Cloudfront:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Comment: !Sub create from ${AWS::StackName}
        DefaultCacheBehavior:
          AllowedMethods:
            - GET
            - HEAD
          CachePolicyId: !FindInMap [ CachePolicyMap, !Ref CachePolicyName, Id ]
          TargetOriginId: S3
          ViewerProtocolPolicy: redirect-to-https
        DefaultRootObject: !Ref DefaultRootObject
        Enabled: true
        HttpVersion: http2
        Origins:
          - DomainName: !GetAtt ContentsBucket.RegionalDomainName
            Id: S3
            OriginAccessControlId: !GetAtt OAC.Id
            S3OriginConfig:
              OriginAccessIdentity: ''
        PriceClass: PriceClass_200
        WebACLId: !Ref WebACLArn
  # ---------------------------------#
  # OAC
  # ---------------------------------#
  OAC: 
    Type: AWS::CloudFront::OriginAccessControl
    Properties: 
      OriginAccessControlConfig:
        Description: Access Control
        Name: Sample-OAC
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4

テンプレートは2つありますが、手動によるスタック作成は一度だけになります。

テンプレートをS3へ配置

任意リージョンのS3バケットに以下の構成でテンプレート配置しましょう。

S3バケット
│ template.yml
└─ap-northeast-1
   stackset-template.yml

スタックを作成

スタック作成でS3に配置したtemplate.ymlのオブジェクトurlを指定し、入力パラメータを入力する。

スタック作成はバージニア北部から実施してください。

パラメータに入力する値は以下の通り。

  • StackSetAdministrationRoleArn:前準備で作成したStackSetAdministrationRoleのARNを入力
    • 例)arn:aws:iam::<アカウントID>:role/AWSCloudFormationStackSetAdministrationRole
  • StackSetExecutionRole:前準備で作成したStackSetExecutionRoleのロール名を入力
    • 例)AWSCloudFormationStackSetExecutionRole
  • StackSetTemplateUrl:S3へ配置したstackset.ymlのオブジェクトurlを入力
    • 例)https://<バケット名>.s3.<リージョン>.amazonaws.com/ap-northeast-1/stackset-template.yml
  • CachePolicyName:必要なCloudFrontキャッシュポリシーを選択
    • Managed-CachingDisabled (キャッシュ無効のポリシー)
  • DefaultRootObject:アプリケーションのルートオブジェクトを指定
    • 例)index.html

ステップ3、4は初期値のままでスタック作成まで実施します。

スタックステータス確認

スタック作成後、それぞれステータスが完了しているか確認する。

バージニア北部

東京リージョン

スタックセット機能によって作成されます。

配信用S3バケットにSPAリソースを配置

インフラのリソースはすべて作成されたので配信用S3バケットにSPAリソースを配置します。

今回はVueのチュートリアルアプリを配置しました。

具体的には以下クイックスタートの「npm run build」で作成した ./dist 配下のファイル群を配置します。

アプリケーションの動作確認

CloudFrontで生成されたドメインにアクセスし、アプリケーションが動作しているか確認します。

説明にスタックセットからされていることも確認できます。

Vueのサンプル画面が表示されれば完了です!

おわりに

CloudFormationのスタックセット機能を使うことでリージョンまたぎに関する制約をクリアし、一発でWAF+CloudFront+S3を構築することができました。

気になる点としてはスタック作成は値渡しが一方通行である関係で、バージニア北部から実施することが必須であることでしょうか。

バージニア北部⇒東京リージョンへの値渡しは今回のテンプレートで行えますが、逆パターンの東京⇒バージニア北部の場合はCloudFormationのカスタムリソース(Lambda)など駆使する必要があります。

今回はRoute53等のドメイン設定はしていませんが、今回のテンプレートを応用して設定いただければと思います。


この記事をシェアする
著者:さとゆう
CloudFormationによるインフラ構築の効率化が好きなAWSエンジニア。最近はAIサービスのプロンプトエンジニアリングを勉強中。