AWS CDKで作る CI/CDパイプライン


はじめに

今回、静的サイトをホスティングするための環境を構築することになりました。
環境構築にあたって、以下の記事を参考にCDKを用いてCI/CDを構築しました。

今回構築した構成

上記の記事に合ったCodeシリーズを用いたCI/CDに加え、静的サイトをホスティングするためのS3、CDNとしてCloudFront、ベーシック認証のためにCloudFront Functionを用いています。
これらのサービスを以下の図のような構成で作成しました。
dev、stg、prdの3環境分作成します。

一連の挙動としては以下の流れとなります。

  • クライアントからCodeCommitのリモートリポジトリへPush、または各ブランチへマージする
  • Push・マージ契機でCodePipelineが走り、CodeBuildが実行される
  • CodeBuild実行後、CodeCommitの内容がCodeDeployによりS3へデプロイされる
  • S3へデプロイされたリソースは、CloudFront経由で配信される
  • 閲覧する際には予め、CloudFrontFuntionにて定められたベーシック認証が行われる

実際に作っていく

AWS CDKのインストール

AWS CDKコマンドをインストールします。
下記のコマンドでインストールして下さい。

npm install -g aws-cdk

プロジェクト作成

まずAWS CDKのプロジェクトを作成します。

本記事では、CodeCommitのリポジトリもCDKで作成するためCDKのコード自体を管理する場合は、別途リポジトリをご用意下さい。

任意のディレクトリを作成し、そのディレクトリ配下で下記コマンドを実行します。

cdk init app --language=typescript

appには任意のPJ名を入力してください。
今回はtypescriptを利用しますが、languageオプションで他言語を選択することも可能です。

上記、コマンドを実行して初期化後には下記のようなフォルダ構成ができています。

app
├ .git
├ .npmignore
  └ bin
├ app.ts
├ cdk.json
├ jest.config.js
├ lib
  └ app-stack.ts
├ node_modules
  └ ...
├ package-lock.json
├ package.json
├ README.md
├ test
  └ app.test.ts
├ tsconfig.json

AWSアカウント内で初めてCDKを利用する場合は、ブートストラップコマンドも実行しておいてください。

cdk bootstrap

各種モジュールをインストール

下記、コマンドを実行し使用する各モジュールをインストールします。

npm install @aws-cdk/aws-codecommit
npm install @aws-cdk/aws-codebuild
npm install @aws-cdk/aws-codepipeline
npm install @aws-cdk/aws-iam
npm install @aws-cdk/aws-s3
npm install @aws-cdk/aws-cloudfront

上記、コマンドを実行しても、package.jsonに追記した後にnpm installしてもどちらでも大丈夫です。

* 上記の各モジュールのバージョンが揃っていないと後続の実際にコードを書く際に、エラーが出る場合があるので、エラーが出た際はバージョンを確認してみてください。

また、複数スタックを逐次デプロイするために下記のnpm-run-allもインストールします。

npm install --save-dev npm-run-all

テンプレートの作成

ここからは実際に各サービスについて、コードを書いて定義していきます。
実際に定義するファイルは スタック毎にlib配下にtsファイルを作成していきます。

まずは、ClouFrontとS3の定義をしていきます。

lib/s3-stack.ts

import * as cdk from '@aws-cdk/core';
import * as s3 from '@aws-cdk/aws-s3';
import * as iam from '@aws-cdk/aws-iam';
import { StringParameter } from '@aws-cdk/aws-ssm';
import * as cloudfront from '@aws-cdk/aws-cloudfront';


export class s3Stack extends cdk.Stack {
    constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        console.log("*****************S3Stack START*****************")

        // プロジェクト名をcontextから取得
        const projectName = this.node.tryGetContext('projectName');
        console.log('ProjectName:' + projectName);

        // 3環境分のS3バケット、CloudFrontを生成
        ["prd", "stg", "dev"].forEach(stage => {
            // バケット名を設定
            const bucketName= stage + '-' + projectName

            // バケットを生成
            const s3Bucket = new s3.Bucket(this, stage + '-pjBucket', {
                bucketName: bucketName,
                blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
            });

            // OAIを設定
            const oai = new cloudfront.OriginAccessIdentity(this, bucketName)

            // バケットポリシーを生成
            const bucketPolicy = new iam.PolicyStatement({
                effect: iam.Effect.ALLOW,
                actions: ["s3:GetObject"],
                principals: [
                    new iam.CanonicalUserPrincipal(
                        oai.cloudFrontOriginAccessIdentityS3CanonicalUserId
                    ),
                ],
                resources: [s3Bucket.bucketArn + "/*"],
            })
            s3Bucket.addToResourcePolicy(bucketPolicy)

            // CloudFrontFunctionsの定義
            if (stage == 'dev') {
              const basicAuthFunction = new cloudfront.Function(
                this,
                stage + '-BasicAuthFunction',
                {
                    functionName: bucketName + '-BasicAuth',
                    code: cloudfront.FunctionCode.fromFile({
                        filePath: "lambda/BasicAuth/dev-auth.js",
                    }),
                }
              );
              this.createCloudFront(stage, s3Bucket, oai, basicAuthFunction)
            } else if(stage == 'stg') {
                const basicAuthFunction = new cloudfront.Function(
                  this,
                  stage + '-BasicAuthFunction',
                  {
                      functionName: bucketName + '-BasicAuth',
                      code: cloudfront.FunctionCode.fromFile({
                          filePath: "lambda/BasicAuth/stg-auth.js",
                      }),
                  }
                );
                this.createCloudFront(stage, s3Bucket, oai, basicAuthFunction)
              } else {
              const basicAuthFunction = new cloudfront.Function(
                this,
                stage + '-BasicAuthFunction',
                {
                    functionName: bucketName + '-BasicAuth',
                    code: cloudfront.FunctionCode.fromFile({
                        filePath: "lambda/BasicAuth/prd-auth.js",
                    }),
                }
              );
              this.createCloudFront(stage, s3Bucket, oai, basicAuthFunction)
            }

          // パラメータストアへS3BucketArnを登録
            new StringParameter(this, stage + '-bucketArn', {
                parameterName: bucketName + '-bucketArn',
                stringValue: s3Bucket.bucketArn,
              });
        })
        console.log("*****************S3Stack END*****************")
    }

    //****************************************************/
    // CLoudFrontディストリビューションの作成
    //****************************************************/
    private createCloudFront(stage: string, s3Bucket: s3.Bucket, oai: cloudfront.OriginAccessIdentity, basicAuthFunction: cloudfront.Function) {
      new cloudfront.CloudFrontWebDistribution(this, stage + '-Distribution', {
        viewerCertificate: {
          aliases: [],
          props: {
            cloudFrontDefaultCertificate: true,
          },
        },
        priceClass: cloudfront.PriceClass.PRICE_CLASS_200,
        originConfigs: [
          {
            s3OriginSource: {
              s3BucketSource: s3Bucket,
              originAccessIdentity: oai,
            },
            behaviors: [
              {
                isDefaultBehavior: true,
                functionAssociations: [
                    {
                        eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
                        function: basicAuthFunction
                    },
                ],
                minTtl: cdk.Duration.seconds(0),
                maxTtl: cdk.Duration.days(365),
                defaultTtl: cdk.Duration.days(1),
                pathPattern: "*",
              },
            ],
          },
        ],
        errorConfigurations: [
          {
            errorCode: 403,
            responsePagePath: "/error_403.html",
            responseCode: 200,
            errorCachingMinTtl: 0,
          },
          {
            errorCode: 404,
            responsePagePath: "/error_404.html",
            responseCode: 200,
            errorCachingMinTtl: 0,
          },
    ],
  });
    }
}

続いて、CI/CD部分の定義をapp-stack.tsに記載していきます。

lib/app-stack.ts

import * as cdk from '@aws-cdk/core';
import * as codecommit from '@aws-cdk/aws-codecommit';
import * as codebuild from '@aws-cdk/aws-codebuild';
import * as codepipeline from '@aws-cdk/aws-codepipeline';
import * as codePipeline_actions from '@aws-cdk/aws-codepipeline-actions';
import { StringParameter } from '@aws-cdk/aws-ssm';
import { Bucket, IBucket } from '@aws-cdk/aws-s3';


export class CiCdStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);


    console.log("*****************CI/CDStack START*****************")

    // プロジェクト名をcontextから取得
    const projectName = this.node.tryGetContext('projectName');
    console.log('ProjectName:' + projectName);

    // タグを作成
    const tag: string = projectName;

    // PJ名でリポジトリを作成する
    const repoName = projectName
    const repo = new codecommit.Repository(this, 'pjRepo', {
      repositoryName: repoName,
      description: 'repository'
    });




    ["prd", "stg", "dev"].forEach(stage => {

      // パラメータストアからS3のarnを取得する
      const bucketArn = StringParameter.valueForStringParameter(this, stage + '-' + projectName + '-bucketArn');
      console.log("bucketArn:" + bucketArn);
      const targetBucket = Bucket.fromBucketArn(this, stage + 'BucketByArn', bucketArn);
      console.log("targetBucket:" + targetBucket);

      // プロジェクトを作成
      const project = this.createProject(stage, targetBucket, tag)

      // パイプラインを作成
      const sourceOutput = new codepipeline.Artifact();
      // 対象ブランチ(prd:main, dev:develop)
      let branch;
      if (stage == 'dev') {
        branch = 'develop';
      } else if (stage == 'stg') {
        branch = 'staging';
      } else {
        branch = 'main'
      }

      new codepipeline.Pipeline(this, this.createId('Pipline', stage, tag), {
        pipelineName: this.createName(stage, tag),
        stages: [{
          stageName: 'Source',
          actions: [
            this.createSourceAction(repo, branch, sourceOutput)
          ],
        },
        {
          stageName: 'Build',
          actions: [
            this.createBuildAction(project, sourceOutput)
          ]
        },
        {
          stageName: 'Deploy',
          actions: [
            this.createDeployAction(targetBucket, sourceOutput)
          ]
        }
        ]
      })
    })
    console.log("*****************CI/CDStack END*****************")
  }

  //**************************************************** */
  // idの生成(tag + name + stage)
  //**************************************************** */
  private createId(name: string, stage: string, tag: string): string {
    return tag + '-' + name + '-' + stage;
  }

  //**************************************************** */
  // 名前の生成(stage + tag)
  //**************************************************** */
  private createName(stage: string, tag: string): string {
    return stage + '-' + tag
  }

  //**************************************************** */
  // プロジェクトの生成
  //**************************************************** */
  private createProject(stage: string, s3BucketName: IBucket, tag: string): codebuild.PipelineProject {
    const project = new codebuild.PipelineProject(this, this.createId('Project', stage, tag), {
      projectName: this.createName(stage, tag),
      buildSpec: codebuild.BuildSpec.fromObject({
        version: '0.2',
        // ビルドプロジェクトで実行するコマンドを定義
        phases: {
          build: {
            commands: [
              'echo "*******Start Build*******"',
              // 'npm build',
            ]
          }
        }
      })
    })
    return project
  }

  //**************************************************** */
  // CodePipelineのソースアクション(CodeCommit)の生成
  //**************************************************** */
  private createSourceAction(repo: codecommit.Repository, branch: string, sourceOutput: codepipeline.Artifact): codePipeline_actions.CodeCommitSourceAction {
    return new codePipeline_actions.CodeCommitSourceAction({
      actionName: 'CodeCommit',
      repository: repo,
      branch: branch,
      output: sourceOutput
    });
  }
  //**************************************************** */
  // CodePipelineのビルドアクション(CodeBuild)の生成
  //**************************************************** */
  private createBuildAction(project: codebuild.IProject, sourceOutput: codepipeline.Artifact) {
    return new codePipeline_actions.CodeBuildAction({
      actionName: 'CodeBuild',
      project: project,
      input: sourceOutput,
      outputs: [new codepipeline.Artifact()],
    });
  }

  //**************************************************** */
  // CodePipelineのデプロイアクションの生成
  //**************************************************** */
  private createDeployAction(targetBucket: IBucket, sourceOutput: codepipeline.Artifact) {
    return new codePipeline_actions.S3DeployAction({
      actionName: 'CodeDeploy',
      bucket: targetBucket,
      input: sourceOutput
    });
  }
}

s3-stackで作成したS3バケットのarnをパラメータストアに登録し、app-stackでパラメータストアから取得し、デプロイ先に設定しています。

続いてlib配下で定義したスタックを実行ファイルに記載します。

bin/app.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { AppStack } from '../lib/app-stack';
import {s3Stack} from '../lib/s3-stack';

const app = new cdk.App();
new AppStack(app, 'AppStack');
new s3Stack(app, 's3Stack');

ベーシック認証コード作成

ベーシック認証を行うためのCloudFunctionのコードを作成していきます。
CloudFunctionのコードはs3-stack内で定義した場所に配置して下さい。
今回の場合は、app/lambda/BasicAuth/配下に環境毎にコードを用意します。

環境 ファイル名
dev dev-auth.js
stg stg-auth.js
prd prd-auth.js

今回はいずれの環境もベーシック認証の内容は同じものとし、いずれのユーザー名とパスワードを以下のようにします。

  • ユーザー名:user
  • パスワード:pass

ユーザー名とパスワードはbase64エンコーディングした文字列を記載します。

lambda/dev-auth.js
lambda/stg-auth.js
lambda/prd-auth.js

function handler(event) {
    var request = event.request;
    var headers = request.headers;

    // echo -n user:pass | base64
    var authString = "Basic dXNlcjpwYXNz";

    if (
        typeof headers.authorization === "undefined" ||
        headers.authorization.value !== authString
    ) {
        return {
            statusCode: 401,
            statusDescription: "Unauthorized",
            headers: { "www-authenticate": { value: "Basic" } }
        };
    }

    return request;
}

npmスクリプトの作成

スタックのビルドとデプロイを行うためのスクリプトを記載していきます。
今回の場合はS3・CloudFrontと、CI/CDの2スタックを順番にデプロイする必要があります。
(CI/CDスタックでデプロイ先のS3バケットのArnを取得しているため)
そこで、モジュールインストール時に追加したnpm-run-allを利用します。
S3・CloudFrontと、CI/CDの2スタックをそれぞれデプロイするスクリプト、および、全てをデプロイするスクリプトの3種を記載します。

実際のコードが以下となります。

package.json

"deploy:ci/cd": "run-s build \"cdk deploy -- {1}  --context projectName={2} --profile {3}\" --",
"deploy:s3": "run-s build \"cdk deploy -- {1} --context projectName={2} --profile {3}\" --",
"deploy:all": "run-s \"deploy:s3  {3}  {1}  {2}\" \"deploy:ci/cd  {4}  {1}  {2}\" --"

プロジェクト名、awsプロファイル、各スタック名を引数として実行します。
実行例は以下の通りです。

npm run deploy:s3 {S3_StackName} {PJ_Name} {PROFILE}
npm run deploy:ci/cd {CI/CD_StackName} {PJ_Name} {PROFILE}
npm run deploy:all {PJ_Name} {PROFILE} {S3_StackName} {CI/CD_StackName}

おわりに

今回、S3やCloudFrontを用いたフロント側のCI/CDを作成しました。
また、今回作成したテンプレートにはまだ、下記のような課題が残っているので更新していきたいと思っています。

  • CI/CDのステータスの通知
  • CDK自体のテストコード

今回記載したテンプレートの全体像ははgithubに公開しています。
https://github.com/masahiro-sanya/front-pipeline
ご参考になればと思います。

その他、誤りや指摘事項があればコメント頂けると幸いです。