AWS Amplify Console と同等の CI/CD 環境を AWS Code サービスシリーズでつくる [AWS CloudFormation でかんたん構築!!]

こんにちは、広野です。

React, Vue 等の SPA (Single Page Application) 開発者にとって、すぐに動く CI/CD 環境を提供してくれる AWS Amplify Console は神サービスです。しかしながら、環境をカスタマイズできないのも事実で、要件によっては AWS Amplify の採用をあきらめざるを得ないケースもあると思います。

以前、それに近い状況があり、この度 AWS Code サービスを駆使して SPA 用の CI/CD 環境をつくってみました。AWS Amplify を使用していないので、機能のカスタマイズができるようになります。環境はもちろん AWS CloudFormation で簡単に構築ができます。

用意した AWS CloudFormation テンプレートや関連情報は React で使用する想定であることと、少し決め打ちの仕様がありますので、必要に応じてカスタマイズして頂けたらと思います。とは言え説明が少ないので、ある程度 SPA に必要なインフラ環境や CI/CD の知識がある上級者向けの内容となっています。ご了承ください。

つくり始めた経緯

  • ある案件で SPA のフロントエンドに AWS Amplify Console を採用したが、ユーザのソース IP アドレスでアクセス制御したいという要件に対し AWS Amplify Console では対応できず、当時はリリースを優先し要件をあきらめた。ということがあった。
  • そんなことから、CI/CD 環境はそのままに、ソース IP アドレスでアクセス制御できるようにしたい、その他ちょっとしたカスタマイズもできるようにしておきたい、と思っていた。
  • AWS Code サービスシリーズ (CodePipeline, CodeCommit, CodeBuild, CodeDeploy, 等) を一度きちんといじってみたかった。

最後は個人的なモチベーションですが w 、今後対応可能な幅を広げるために勉強しておきたかったというのが大きいです。

アーキテクチャ・仕様

つくったのはこんなものです。ごちゃごちゃしてて失礼します。

AWS Amplify が元々持っていた機能を再現しているのは、以下のサービスです。数式のように表現しています。書いた後に思い出しましたが、Amazon Route 53 もありました。他にも細かいことを言い出せばきりがないのですが。

ここに、以下の仕様追加のため、サービスを追加しています。

  • ユーザのソース IP アドレスによるアクセス制御のため、AWS WAF を追加
  • インフラの環境変数を外出ししたかったので、AWS Systems Manager のパラメータストアを利用

その他、以下の設計があります。

  • ソースコードリポジトリは AWS CodeCommit を利用。
  • AWS CodeCommit のコード変更を検知するため、Amazon EventBridge を利用。
  • 開発フレームワークは React、モジュール管理には npm の利用を想定。
  • AWS CodeBuild でのビルド時に使用する buildspec.yml はソースコードのルートディレクトリに配置。
  • デプロイ先(アプリ稼働環境)は Amazon S3 を利用。AWS CodePipeline ネイティブのデプロイステップを使用。
  • デプロイ方式はミュータブルのみ。(流行りのブルーグリーンでなくてすいません)
  • コンテンツ配信には Amazon CloudFront を利用。Amazon S3 バケットには OAI の設定を入れ込み、Amazon S3 へのアクセスを Amazon CloudFront 経由限定にする。Amazon CloudFront には、前述 AWS WAF をアタッチ。
  • AWS WAF に設定するアクセス可能なソース IP アドレスは、IPv4, IPv6 の両方が指定可能。複数指定時はカンマ区切りで指定。パラメータは AWS CloudFormation テンプレートで設定、変更可能。
  • Amazon CloudFront 経由のコンテンツ配信のため、デプロイ時に Amazon CloudFront のキャッシュクリア (Invalidation) が必須。その作業は AWS Lambda で自動化、AWS CodePipeline 内で CI/CD パイプラインの最終ステップに組込。
  • アプリの URL は独自ドメイン。Amazon Route 53 ホストゾーンに予めドメインが登録されており、バージニア北部リージョンの AWS Certificate Manager で SSL 証明書が作成済みであることが前提。
  • AWS Cloud9 をプロビジョニング可能な VPC があることが前提。
  • 一連のリソースプロビジョニングは極力 AWS CloudFormation を利用。

その他細かい仕様は説明しきれないので、AWS CloudFormation テンプレートを読み込むか、実際にスタックを作成して確認して頂けましたら幸いです。

実装手順

以下の順序で実装します。AWS CloudFormation テンプレートによる実装がメインですが、途中、手作業が入ります。

開発環境

最初に、AWS CodeCommit と AWS Cloud9 を用意します。

AWS CodeCommit のプロビジョニングは以下の AWS CloudFormation テンプレートを使用します。

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates a CodeCommit repository.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System name of example.
    Default: xxxxx
    MaxLength: 10
    MinLength: 1

Resources:
# ------------------------------------------------------------#
#  CodeCommit Repository
# ------------------------------------------------------------#
  CodeCommitRepo:
    Type: AWS::CodeCommit::Repository
    Properties:
      RepositoryName: !Sub example-${SystemName}
      RepositoryDescription: !Sub Source Code for example-${SystemName}
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
# CodeCommit
  CodeCommitRepoUrl:
    Value: !GetAtt CodeCommitRepo.CloneUrlHttp
    Export:
      Name: !Sub CodeCommitRepoUrl-example-${SystemName}
  CodeCommitRepoArn:
    Value: !GetAtt CodeCommitRepo.Arn
    Export:
      Name: !Sub CodeCommitRepoArn-example-${SystemName}
  CodeCommitRepoName:
    Value: !GetAtt CodeCommitRepo.Name
    Export:
      Name: !Sub CodeCommitRepoName-example-${SystemName}

プロビジョニングされた AWS Commit に対して、AWS Cloud9 から git clone をかけます。手順はネット上に多いので、ここでは省略しますが、公式には以下のリンク先のようなことを実施します。

ステップ 1: 環境を作成する - AWS Cloud9
(「 」の最初のステップ)
AWS CodeCommit の サンプルAWS Cloud9 - AWS Cloud9
AWS Cloud9 で AWS CodeCommit リポジトリを試すために使用できる実践的なサンプルを提供します。

AWS Cloud9 上で、React のサンプルアプリが動作する環境を構築します。ここでは、シンプルに create-react-app だけ実行します。

$ npm install -g create-react-app #create-react-app のインストール
$ create-react-app example-xxxxx  #CodeCommitにgit cloneして作成されたディレクトリを引数に指定してcreate-react-appを実行

インストールしたら、example-xxxxx ディレクトリ (React プロジェクトのルートディレクトリ) に以下の内容の buildspec.yml を作成します。

version: 0.2
env:
  parameter-store:
    REACT_APP_REGION: "example_xxxxx_REGION"
phases:
  install:
    runtime-versions:
      nodejs: 14
    commands:
      - npm ci
  build:
    commands:
      - npm run build
      - echo REACT_APP_REGION=${REACT_APP_REGION} >> .env
artifacts:
  files:
    - "**/*"
  base-directory: "build"

その後、Cloud9 上のコードを CodeCommit に push します。これで、create-react-app のサンプルコードがアプリ稼働環境にビルド、デプロイされれば動く状態になります。

$ cd example-xxxxx #Reactプロジェクトのルートディレクトリに移動
$ git add -A
$ git commit -m "initial commit"
$ git push origin master

WAF

ソース IP アドレス制御に必要となる、AWS WAF リソースを先にプロビジョニングします。これは、Amazon CloudFront に AWS WAF をアタッチする前に実施しておかなければならないのと、Amazon CloudFront アタッチ用の AWS WAF Web ACL はバージニア北部リージョン (us-east-1) にプロビジョニングしないと機能しないという制約があるためです。

パラメータとして、アクセス可能とする IPv4、IPv6 アドレスそれぞれの CIDR をカンマ区切りで入力します。/0 のサブネットマスクは入力不可能という制約があるのでご注意ください。そもそもソース IP アドレス制御が不要な場合は、このテンプレートを実行する必要はありません。

以下、AWS CloudFormation テンプレートです。

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates a WAF for the IP-based restricted access. This template must be used in us-east-1 region.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System name of example.
    Default: xxxxx
    MaxLength: 10
    MinLength: 1

  SourceIpv4RangeList:
    Type: CommaDelimitedList
    Description: The comma delimited list of allowed source IPv4 address range (CIDR) to access this web site. (e.g. xxx.xxx.xxx.xxx/xx,yyy.yyy.yyy.yyy/yy)
    Default: "192.168.1.1/32"

  SourceIpv6RangeList:
    Type: CommaDelimitedList
    Description: The comma delimited list of allowed source IPv6 address range (CIDR) to access this web site. (e.g. 1111:0000:0000:0000:0000:0000:0000:0111/128)
    Default: "1111:0000:0000:0000:0000:0000:0000:0111/128"

Resources:
# ------------------------------------------------------------#
# WAF v2
# ------------------------------------------------------------#
  WAFv2WebAcl:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: !Sub WebACL-example-${SystemName}-app
      Description: WAF v2 WebACL for the IP-based restricted access
      DefaultAction:
        Block: {}
      Scope: CLOUDFRONT
      Rules:
        - Name: !Sub CustomRule-IpWhiteList-example-${SystemName}-app
          Action:
            Allow: {}
          Priority: 0
          Statement:
            OrStatement:
              Statements:
                - IPSetReferenceStatement:
                    Arn: !GetAtt WAFv2Ipv4WhiteList.Arn
                - IPSetReferenceStatement:
                    Arn: !GetAtt WAFv2Ipv6WhiteList.Arn
          VisibilityConfig:
            CloudWatchMetricsEnabled: true
            MetricName: !Sub CustomRule-IpWhiteList-example-${SystemName}-app
            SampledRequestsEnabled: true
      VisibilityConfig:
        CloudWatchMetricsEnabled: true
        MetricName: !Sub WebACL-example-${SystemName}-app
        SampledRequestsEnabled: true
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}
    DependsOn:
      - WAFv2Ipv4WhiteList
      - WAFv2Ipv6WhiteList
  WAFv2Ipv4WhiteList:
    Type: AWS::WAFv2::IPSet
    Properties:
      Name: !Sub Ipv4WhiteList-example-${SystemName}-app
      Description: WAF v2 IPv4 white list for the IP-based restricted access
      IPAddressVersion: IPV4
      Addresses: !Ref SourceIpv4RangeList
      Scope: CLOUDFRONT
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}
  WAFv2Ipv6WhiteList:
    Type: AWS::WAFv2::IPSet
    Properties:
      Name: !Sub Ipv6WhiteList-example-${SystemName}-app
      Description: WAF v2 IPv6 white list for the IP-based restricted access
      IPAddressVersion: IPV6
      Addresses: !Ref SourceIpv6RangeList
      Scope: CLOUDFRONT
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
# WAF v2 WebACL
  WAFv2WebAclArn:
    Value: !GetAtt WAFv2WebAcl.Arn

プロビジョニング完了後、スタックの出力タブに AWS WAF の ARN が表示されます。次のステップでコピペして使用します。

CI/CD環境

以下、AWS CloudFormation テンプレートです。

パラメータとして、Amazon Route 53 ホストゾーンに登録済みのドメイン名、サブドメイン名が必要です。また、バージニア北部リージョン (us-east-1) に登録済みの SSL 証明書 ID と、前ステップでプロビジョニングした AWS WAF の ARN を入力します。

そもそも AWS WAF が不要な場合は、テンプレート内の WebACLId: !Ref WAFv2WebAclArn の行を削除します。

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates an application frontend infrastructre including S3 buckets, a CloudFront distribution, Route 53 DNS records and a CI/CD environment with Code service series.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System name of example.
    Default: xxxxx
    MaxLength: 10
    MinLength: 1

  DomainName:
    Type: String
    Description: Domain name for URL.
    Default: example.com
    MaxLength: 40
    MinLength: 5

  SubDomainName:
    Type: String
    Description: Sub domain name for URL. xxxxx.example.com
    Default: example-xxxxx
    MaxLength: 20
    MinLength: 1

  CertificateId:
    Type: String
    Description: ACM certificate ID. CloudFront only supports ACM certificates in us-east-1 region.
    Default: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    MaxLength: 36
    MinLength: 36

  WAFv2WebAclArn:
    Type: String
    Description: The ARN of WAF v2 Web Acl associated with the CloudFront distribution for app. The resource is provisionned in us-east-1 region.
    Default: "arn:aws:wafv2:us-east-1:xxxxxxxxxxxx:global/webacl/WebACL-xxxxxxxxxxxxxxxxx/xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxx"
    MaxLength: 150
    MinLength: 80

Resources:
# ------------------------------------------------------------#
# S3
# ------------------------------------------------------------#
  S3BucketApp:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub example-${SystemName}-app
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      CorsConfiguration:
        CorsRules:
          - AllowedHeaders:
              - "*"
            AllowedMethods:
              - "GET"
              - "HEAD"
            AllowedOrigins:
              - !Sub https://${SubDomainName}.${DomainName}
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}
  S3BucketPolicyApp:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3BucketApp
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Action:
              - "s3:GetObject"
            Effect: Allow
            Resource: !Sub "arn:aws:s3:::${S3BucketApp}/*"
            Principal:
              AWS: !Sub "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentityApp}"
    DependsOn:
      - S3BucketApp
      - CloudFrontOriginAccessIdentityApp

  S3BucketArtifact:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub example-${SystemName}-artifact
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}

  S3BucketLogs:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub example-${SystemName}-logs
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}

# ------------------------------------------------------------#
# CloudFront Distribution
# ------------------------------------------------------------#
  CloudFrontDistributionApp:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: !Sub CloudFront distribution for example-${SystemName}-app
        Aliases:
          - !Sub ${SubDomainName}.${DomainName}
        HttpVersion: http2
        IPV6Enabled: true
        PriceClass: PriceClass_200
        Logging:
          Bucket: !GetAtt S3BucketLogs.DomainName
          IncludeCookies: false
          Prefix: cloudfrontAccesslog/app/
        DefaultCacheBehavior:
          TargetOriginId: !Sub S3Origin-${SubDomainName}-app
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods:
            - GET
            - HEAD
            - OPTIONS
          CachedMethods:
            - GET
            - HEAD
          CachePolicyId: !Ref CloudFrontCachePolicyApp
          OriginRequestPolicyId: !Ref CloudFrontOriginRequestPolicyApp
          Compress: true
          SmoothStreaming: false
        DefaultRootObject: index.html
        Origins:
          - Id: !Sub S3Origin-${SubDomainName}-app
            DomainName: !GetAtt S3BucketApp.DomainName
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentityApp}"
            ConnectionAttempts: 3
            ConnectionTimeout: 10
        ViewerCertificate:
          AcmCertificateArn: !Sub "arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CertificateId}"
          MinimumProtocolVersion: TLSv1.2_2021
          SslSupportMethod: sni-only
        WebACLId: !Ref WAFv2WebAclArn
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}
    DependsOn:
      - S3BucketLogs
      - CloudFrontCachePolicyApp
      - CloudFrontOriginRequestPolicyApp
      - CloudFrontOriginAccessIdentityApp
  CloudFrontOriginAccessIdentityApp:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub CloudFront OAI for example-${SystemName}-app
  CloudFrontCachePolicyApp:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        Name: !Sub CachePolicy-${SubDomainName}-app
        Comment: !Sub CloudFront Cache Policy for example-${SystemName}-app
        DefaultTTL: 3600
        MaxTTL: 86400
        MinTTL: 60
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          EnableAcceptEncodingBrotli: true
          EnableAcceptEncodingGzip: true
          HeadersConfig:
            HeaderBehavior: whitelist
            Headers:
              - Access-Control-Request-Headers
              - Access-Control-Request-Method
              - Origin
              - user-agent
          QueryStringsConfig:
            QueryStringBehavior: none
  CloudFrontOriginRequestPolicyApp:
    Type: AWS::CloudFront::OriginRequestPolicy
    Properties:
      OriginRequestPolicyConfig:
        Name: !Sub OriginRequestPolicy-${SubDomainName}-app
        Comment: !Sub CloudFront Origin Request Policy for example-${SystemName}-app
        CookiesConfig:
          CookieBehavior: none
        HeadersConfig:
          HeaderBehavior: whitelist
          Headers:
            - Access-Control-Request-Headers
            - Access-Control-Request-Method
            - Origin
            - user-agent
        QueryStringsConfig:
          QueryStringBehavior: none

# ------------------------------------------------------------#
# Route 53
# ------------------------------------------------------------#
  Route53RecordIpv4App:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Sub ${DomainName}.
      Name: !Sub ${SubDomainName}.${DomainName}.
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt CloudFrontDistributionApp.DomainName
    DependsOn:
      - CloudFrontDistributionApp

  Route53RecordIpv6App:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Sub ${DomainName}.
      Name: !Sub ${SubDomainName}.${DomainName}.
      Type: AAAA
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt CloudFrontDistributionApp.DomainName
    DependsOn:
      - CloudFrontDistributionApp

# ------------------------------------------------------------#
# CodePipeline
# ------------------------------------------------------------#
  CodePipelineApp:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      Name: !Sub example-${SystemName}-app
      ArtifactStore:
        Location: !Ref S3BucketArtifact
        Type: S3
      RestartExecutionOnUpdate: false
      RoleArn: !GetAtt CodePipelineServiceRoleApp.Arn
      Stages:
        - Name: Source
          Actions:
            - Name: Source
              RunOrder: 1
              ActionTypeId:
                Category: Source
                Owner: AWS
                Version: 1
                Provider: CodeCommit
              Configuration:
                RepositoryName:
                  Fn::ImportValue:
                    !Sub CodeCommitRepoName-example-${SystemName}
                BranchName: master
                PollForSourceChanges: false
                OutputArtifactFormat: CODEBUILD_CLONE_REF
              Namespace: SourceVariables
              OutputArtifacts:
                - Name: Source
        - Name: Build
          Actions:
            - Name: Build
              RunOrder: 1
              Region: !Sub ${AWS::Region}
              ActionTypeId:
                Category: Build
                Owner: AWS
                Version: 1
                Provider: CodeBuild
              Configuration:
                ProjectName: !Ref CodeBuildProjectApp
                BatchEnabled: false
              Namespace: BuildVariables
              InputArtifacts:
                - Name: Source
              OutputArtifacts:
                - Name: Build
        - Name: Deploy
          Actions:
            - Name: Deploy
              RunOrder: 1
              Region: !Sub ${AWS::Region}
              ActionTypeId:
                Category: Deploy
                Owner: AWS
                Version: 1
                Provider: S3
              Configuration:
                BucketName: !Ref S3BucketApp
                Extract: true
              Namespace: DeployVariables
              InputArtifacts:
                - Name: Build
        - Name: CloudFrontInvalidation
          Actions:
            - Name: Invalidate
              RunOrder: 1
              Region: !Sub ${AWS::Region}
              ActionTypeId:
                Category: Invoke
                Owner: AWS
                Version: 1
                Provider: Lambda
              Configuration:
                FunctionName: !Ref LambdaCfInvalidationInCp
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}
    DependsOn:
      - S3BucketApp
      - S3BucketArtifact
      - CodePipelineServiceRoleApp
      - CodeBuildProjectApp
      - LambdaCfInvalidationInCp

# ------------------------------------------------------------#
# CodePipeline Service Role (IAM)
# ------------------------------------------------------------#
  CodePipelineServiceRoleApp:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub example-CodePipelineServiceRole-${SystemName}
      Description: This role allows CodePipeline to call each stages.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - codepipeline.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: !Sub example-CodePipelineServiceRolePolicy-${SystemName}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - "codecommit:CancelUploadArchive"
                  - "codecommit:GetBranch"
                  - "codecommit:GetCommit"
                  - "codecommit:GetRepository"
                  - "codecommit:GetUploadArchiveStatus"
                  - "codecommit:UploadArchive"
                Resource:
                  Fn::ImportValue:
                    !Sub CodeCommitRepoArn-example-${SystemName}
              - Effect: Allow
                Action:
                  - "codebuild:BatchGetBuilds"
                  - "codebuild:StartBuild"
                  - "codebuild:BatchGetBuildBatches"
                  - "codebuild:StartBuildBatch"
                Resource: "*"
              - Effect: Allow
                Action:
                  - "cloudwatch:*"
                  - "s3:*"
                Resource: "*"
              - Effect: Allow
                Action:
                  - "lambda:InvokeFunction"
                  - "lambda:ListFunctions"
                Resource: "*"

# ------------------------------------------------------------#
# EventBridge Rule for Starting CodePipeline
# ------------------------------------------------------------#
  EventBridgeRuleStartCodePipelineApp:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub example-StartCpRule-${SystemName}
      Description: !Sub This rule starts CodePipeline for example-${SystemName} app when its source code in CodeCommit is changed.
      EventBusName: !Sub "arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/default"
      EventPattern:
        source:
          - "aws.codecommit"
        detail-type:
          - "CodeCommit Repository State Change"
        resources:
          - Fn::ImportValue:
              !Sub CodeCommitRepoArn-example-${SystemName}
        detail:
          event:
            - referenceCreated
            - referenceUpdated
          referenceType:
            - branch
          referenceName:
            - master
      RoleArn: !GetAtt EventBridgeRuleStartCpRole.Arn
      State: ENABLED
      Targets:
        - Arn: !Sub "arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${CodePipelineApp}"
          Id: !Sub Target-example-CodePipelineApp-${SystemName}
          RoleArn: !GetAtt EventBridgeRuleStartCpRole.Arn
    DependsOn:
      - EventBridgeRuleStartCpRole

# ------------------------------------------------------------#
# EventBridge Rule Start CodePipeline Role (IAM)
# ------------------------------------------------------------#
  EventBridgeRuleStartCpRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub example-EventBridgeRuleStartCpRole-${SystemName}
      Description: !Sub This role allows EventBridge to start example-${SystemName} app CodePipeline.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - events.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: !Sub example-EventBridgeRuleStartCpRolePolicy-${SystemName}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - "codepipeline:StartPipelineExecution"
                Resource:
                  - !Sub "arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${CodePipelineApp}"
    DependsOn:
      - CodePipelineApp

# ------------------------------------------------------------#
# CodeBuild Project
# ------------------------------------------------------------#
  CodeBuildProjectApp:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: !Sub example-${SystemName}-BP
      Description: !Sub The build project for example-${SystemName} App.
      ResourceAccessRole: !GetAtt CodeBuildResourceAccessRole.Arn
      ServiceRole: !GetAtt CodeBuildServiceRole.Arn
      ConcurrentBuildLimit: 1
      Visibility: PRIVATE
      Source:
        Type: CODECOMMIT
        Location:
          Fn::ImportValue:
            !Sub CodeCommitRepoUrl-example-${SystemName}
        GitCloneDepth: 1
        GitSubmodulesConfig:
          FetchSubmodules: false
      SourceVersion: refs/heads/master
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: "aws/codebuild/standard:5.0"
        ImagePullCredentialsType: CODEBUILD
        PrivilegedMode: false
      TimeoutInMinutes: 30
      QueuedTimeoutInMinutes: 60
      Artifacts:
        Type: S3
        Location: !Ref S3BucketArtifact
        Name: artifact.zip
        OverrideArtifactName: false
        NamespaceType: NONE
        Packaging: ZIP
        EncryptionDisabled: true
      Cache:
        Type: NO_CACHE
      LogsConfig:
        CloudWatchLogs:
          GroupName: !Sub /aws/codebuild/example-${SystemName}
          Status: ENABLED
        S3Logs:
          EncryptionDisabled: true
          Location: !Sub ${S3BucketLogs}/codebuildBuildlog
          Status: ENABLED
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}
    DependsOn:
      - S3BucketArtifact
      - S3BucketLogs
      - CodeBuildResourceAccessRole
      - CodeBuildServiceRole

# ------------------------------------------------------------#
# CodeBuild Resource Access Role (IAM)
# ------------------------------------------------------------#
  CodeBuildResourceAccessRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub example-CodeBuildResourceAccessRole-${SystemName}
      Description: This role allows CodeBuild to access CloudWatch Logs and Amazon S3 artifacts for the project's builds.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - codebuild.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: !Sub example-CodeBuildResourceAccessRolePolicy-${SystemName}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource:
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/example-${SystemName}"
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/example-${SystemName}:*"
              - Effect: Allow
                Action:
                  - "s3:PutObject"
                  - "s3:GetObject"
                  - "s3:GetObjectVersion"
                  - "s3:GetBucketAcl"
                  - "s3:GetBucketLocation"
                Resource:
                  - !Sub "arn:aws:s3:::${S3BucketLogs}"
                  - !Sub "arn:aws:s3:::${S3BucketLogs}/*"
    DependsOn:
      - S3BucketLogs

# ------------------------------------------------------------#
# CodeBuild Service Role (IAM)
# ------------------------------------------------------------#
  CodeBuildServiceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub example-CodeBuildServiceRole-${SystemName}
      Description: This role allows CodeBuild to interact with dependant AWS services.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - codebuild.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: !Sub example-CodeBuildServiceRolePolicy-${SystemName}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - "codecommit:GitPull"
                Resource:
                  Fn::ImportValue:
                    !Sub CodeCommitRepoArn-example-${SystemName}
              - Effect: Allow
                Action:
                  - "ssm:GetParameters"
                Resource:
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/*"
              - Effect: Allow
                Action:
                  - "s3:*"
                Resource:
                  - !Sub "arn:aws:s3:::${S3BucketArtifact}"
                  - !Sub "arn:aws:s3:::${S3BucketArtifact}/*"
                  - !Sub "arn:aws:s3:::${S3BucketLogs}"
                  - !Sub "arn:aws:s3:::${S3BucketLogs}/*"
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource:
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/example-${SystemName}"
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/example-${SystemName}:*"
              - Effect: Allow
                Action:
                  - "codebuild:CreateReportGroup"
                  - "codebuild:CreateReport"
                  - "codebuild:UpdateReport"
                  - "codebuild:BatchPutTestCases"
                  - "codebuild:BatchPutCodeCoverages"
                Resource:
                  - !Sub "arn:aws:codebuild:${AWS::Region}:${AWS::AccountId}:report-group/example-${SystemName}*"
    DependsOn:
      - S3BucketArtifact
      - S3BucketLogs

# ------------------------------------------------------------#
# Lambda
# ------------------------------------------------------------#
  LambdaCfInvalidationInCp:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub example-CfInvalidationInCp-${SystemName}
      Description: !Sub Lambda Function to clear cache of the CloudFront distribution, called from CodePipeline.
      Runtime: python3.9
      Timeout: 180
      MemorySize: 128
      Role: !GetAtt LambdaCfCpInvocationRole.Arn
      Handler: index.lambda_handler
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}
      Code:
        ZipFile: !Sub |
          import boto3
          import json
          import time
          codepipeline = boto3.client('codepipeline')
          cloudfront = boto3.client('cloudfront')
          def put_job_success(job):
            print('Putting job success')
            codepipeline.put_job_success_result(jobId=job)
          def put_job_failure(job, message):
            print('Putting job failure')
            print(message)
            codepipeline.put_job_failure_result(jobId=job, failureDetails={'message': message, 'type': 'JobFailed'})
          def continue_job_later(job, invalidation_id):
            continuation_token = json.dumps({'InvalidationId': invalidation_id})
            print('Putting job continuation')
            codepipeline.put_job_success_result(jobId=job, continuationToken=continuation_token)
          def lambda_handler(event, context):
            try:
              # Retrieve the accepted data from CodePipeline
              job_id = event['CodePipeline.job']['id']
              job_data = event['CodePipeline.job']['data']
              distribution_id = '${CloudFrontDistributionApp}'
              # Invalidate
              if 'continuationToken' in job_data:
                continuation_token = json.loads(job_data['continuationToken'])
                invalidation_id = continuation_token['InvalidationId']
                res = cloudfront.get_invalidation(
                  DistributionId=distribution_id,
                  Id=invalidation_id
                )
                status = res['Invalidation']['Status']
                if status == 'Completed':
                  put_job_success(job_id)
                else:
                  continue_job_later(job_id, invalidation_id)
              else:
                res = cloudfront.create_invalidation(
                  DistributionId=distribution_id,
                  InvalidationBatch={
                    'Paths': {
                      'Quantity': 1,
                      'Items': ['/*'],
                    },
                    'CallerReference': str(time.time())
                  }
                )
                invalidation_id = res['Invalidation']['Id']
                continue_job_later(job_id, invalidation_id)
            except Exception as e:
              print(e)
              put_job_failure(job_id, str(e))
            else:
              print("Invalidation Completed.")
              return "Invalidation Completed."
    DependsOn:
      - LambdaCfCpInvocationRole

# ------------------------------------------------------------#
# Lambda - CloudFront and CodePipeline Invocation Role (IAM)
# ------------------------------------------------------------#
  LambdaCfCpInvocationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub example-CfCpInvocationRole-${SystemName}
      Description: This role allows Lambda functions to invoke CloudFront and CodePipeline.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AWSCodePipelineCustomActionAccess
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess
      Policies:
        - PolicyName: !Sub example-CfCpInvocationRolePolicy-${SystemName}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - "cloudfront:CreateInvalidation"
                  - "cloudfront:GetInvalidation"
                Resource: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistributionApp}"
    DependsOn:
      - CloudFrontDistributionApp

# ------------------------------------------------------------#
# SSM Parameter Store
# ------------------------------------------------------------#
  SsmParameterAwsRegion:
    Type: AWS::SSM::Parameter
    Properties:
      Name: !Sub example_${SystemName}_REGION
      Type: String
      DataType: text
      Value: !Sub ${AWS::Region}
      Description: AWS Region name
      Tier: Standard
      Tags:
        Cost: !Sub example-${SystemName}

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
#S3
  AppBucketName:
    Value: !Ref S3BucketApp
  ArtifactBucketName:
    Value: !Ref S3BucketArtifact
  LogsBucketName:
    Value: !Ref S3BucketLogs
  LogsBucketArn:
    Value: !GetAtt S3BucketLogs.Arn
    Export:
      Name: !Sub example-S3BucketLogsArn-${SystemName}
#CloudFront
  AppURL:
    Value: !Sub https://${SubDomainName}.${DomainName}

めっちゃ長くてスミマセン・・・。

ここまでプロビジョニングすると、完了時に1回勝手に CodePipeline が走ってしまうかもしれません。もし失敗したら、もしくは実行されなかったら、再実行するとよいと思います。

CI/CD パイプラインの実行が成功すると、指定したドメイン名、サブドメイン名の URL に以下のような React サンプルアプリ画面が表示されます。

CI/CD環境の利用

一度構築した後は、AWS CodeCommit の master ブランチのコードに変更がかかると自動的にビルド、デプロイが走ります。

試しに、AWS Cloud9 にある App.js を以下のように更新して AWS CodeCommit に push します。AWS CloudFormation テンプレートと buildspec.yml 内にあらかじめ仕込んでおいた環境変数(リージョン名)がありますので、それを画面に表示させるようにする変更です。

import logo from './logo.svg';
import './App.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
        {/* ここから */}
        <p>
          REGION: {process.env.REACT_APP_REGION}
        </p>
        {/* ここまでを追加 */}
      </header>
    </div>
  );
}

export default App;

ここで、process.env.REACT_APP_REGION は、あらかじめ AWS CloudFormation テンプレートで AWS Systems Manager に登録しておいた環境変数、ここではリージョン名を読み込んで表示しています。buildspec.yml であらかじめ定義しておけば、アプリのコードから読み込めるようになります。そうすることで、AWS CloudFormation でプロビジョニングしたバックエンドのエンドポイントを環境変数として登録し、アプリからの呼出先として当て込むことができます。

実際には以下のような画面になります。画面の一番下に、アプリをプロビジョニングしたリージョンのリージョン名が入っています。

まとめ

いかがでしたでしょうか?

私としては、これを完成させることができて心の中のモヤモヤが晴れたような感覚です。AWS Amplify の代替を作ろうと思ったときには、この構成が唯一の解ではなく、いろいろなやり方がありますのであくまでも一例です。と申していますが、AWS Amplify は高機能で、本記事の構成では再現できていない機能がまだまだあります。そこは必要に応じて作りこんでいくしかないでしょう。

本記事が皆様のお役に立てれば幸いです。

タイトルとURLをコピーしました