こんにちは、広野です。
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 バケットには OAC (旧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 をかけます。手順はネット上に多いので、ここでは省略しますが、公式には以下のリンク先のようなことを実施します。
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:
Service: cloudfront.amazonaws.com
Condition:
StringEquals:
AWS:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistributionApp}
DependsOn:
- S3BucketApp
- CloudFrontDistributionApp
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: !Sub ${S3BucketApp}.s3.${AWS::Region}.amazonaws.com
S3OriginConfig:
OriginAccessIdentity: ""
OriginAccessControlId: !GetAtt CloudFrontOriginAccessControlApp.Id
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
- CloudFrontOriginAccessControlApp
CloudFrontOriginAccessControlApp:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Description: !Sub CloudFront OAC for example-${SystemName}-app
Name: !Sub OriginAccessControl-example-${SystemName}-app
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
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 は高機能で、本記事の構成では再現できていない機能がまだまだあります。そこは必要に応じて作りこんでいくしかないでしょう。
本記事が皆様のお役に立てれば幸いです。


