こんにちは、広野です。
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 は高機能で、本記事の構成では再現できていない機能がまだまだあります。そこは必要に応じて作りこんでいくしかないでしょう。
本記事が皆様のお役に立てれば幸いです。