LoginSignup
7
3

More than 1 year has passed since last update.

CDKであるあるWebホスティング構成を構築・デプロイする

Last updated at Posted at 2021-11-14

以前の記事でだいぶざっくり書いたが、もう少しStep-By-Stepで再現性の確認も兼ねて本記事に再度まとめることにした。

image.png

環境構築

$ npm i -g aws-cdk

$ cdk --version
1.132.0 (build 5c75891)

// インフラ/バックエンドの作業ディレクトリ
$ tree
.
├── bin
│   └── main.ts
├── lib
│   └── hosting-stack.ts
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json
(一部略 cdk initで構成)

// フロントエンドのCICDパイプラインの作業ディレクトリ
$ tree
.
├── bin
│   └── main.ts
├── lib
│   └── pipeline-stack.ts
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json
(一部略 cdk initで構成)

// フロントエンドの作業ディレクトリ
$ tree
.
└── frontapp
    ├── buildspec.yml
    └── src
(一部略 create-react-appで構成しbuild.specを追加)

1. CloudFront + S3 ホスティング構成の構築。

1-1. 構築

lib/hosting-stack.ts
import * as iam from '@aws-cdk/aws-iam';
import * as s3 from '@aws-cdk/aws-s3';
import * as _lambda from '@aws-cdk/aws-lambda';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as cloudfront_origins from '@aws-cdk/aws-cloudfront-origins';
import * as cdk from '@aws-cdk/core';

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

    // == const ==
    const hostingBucketName = "***";
    const oaiName = "***";
    const cloudfrontDistributionName = "***";

    // == S3 ==
    // Hosting Bucket
    const hostingBucket = new s3.Bucket(this, `${hostingBucketName}Bucket`, {
      bucketName: hostingBucketName,
      blockPublicAccess: new s3.BlockPublicAccess({
        blockPublicAcls: false,
        blockPublicPolicy: false,
        ignorePublicAcls: false,
        restrictPublicBuckets: false
      }),
      versioned: false,
    });

    // == CloudFront ==
    const oai = new cloudfront.OriginAccessIdentity(this, oaiName, {
      comment: `s3-bucket-${hostingBucketName}`
    });

    const cloudfrontDistribution = new cloudfront.Distribution(this, `${cloudfrontDistributionName}Cloudfront`, {
      enabled: true,
      defaultRootObject: "index.html",
      priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
      defaultBehavior: {
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        origin: new cloudfront_origins.S3Origin(
          hostingBucket, {
            originAccessIdentity: oai
          }
        )
      }
    });

    hostingBucket.addToResourcePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ["s3:GetObject"],
      principals: [
        new iam.CanonicalUserPrincipal(oai.cloudFrontOriginAccessIdentityS3CanonicalUserId)
      ],
      resources: [
        `${bucket.bucketArn}/*`
      ]
    }));
  }
}

1-2. 確認

任意のindex.htmlをS3にアップロードし、CloudFrontのドメイン名を使ってアクセス。
index.htmlを確認できればOK。

2. 独自ドメインの設定

2-1. ドメイン取得とRoute53の設定 【手動】

freenomで取得。
軒並みドメインが取得できない場合について別記事で。

Route53のHostedZoneを作成する記述をCDKで追加。。。と当初は考えていたが、$ cdk destroy時にレコードセットが削除済みでないとHostedZoneを削除できず残ってしまうことと、ACMでの証明書発行にはRoute53への紐付けが終了していないといけない(←多分)という依存関係からRoute53だけは手動作成に戻しました。

hosting-stack.ts(本来はこうしたかった)
    // == const ==
    const hostingDomeinName = "***.tk";
    // == Route53 ==
    const hostedZone = new route53.HostedZone(this, `${hostingDomeinName}HostedZone`, {
      zoneName: hostingDomeinName
    })

Route53コンソールでホストゾーンの作成後、freenomに戻り、ヘッダ部のMy Domainsから取得したドメイン横のManage DomainManagement ToolsNameserversを選択。その後Use custom nameservers (enter below)を選択し、Route53で作成したHostedZoneのNSレコードの4つの値をコピペする(最後のドットは不要(入力しても自動で消される))。

2-2. 証明書の発行 【手動】

北部バージニアリージョンus-east-1に移動し、ACMコンソールを開く。
ACMにて証明書を発行する。DNS検証にしばらく時間がかかるので待つ。
1時間待っても検証中、、待ちきれずここでRoute53でレコードを作成するをクリック。Route53にレコードを作成した。Route53のHostedZoneに追加されたことを確認する。
→ その後10min程度で検証済みステータスになってた。もしかしたらレコード作成しないといけなかったのか?

ACMコンソールからARNを取得し、以下を追記してデプロイ。

lib/hosting-stack.ts
  // == const ==
  const acmCertificateArn = "arn:aws:acm:us-east-1:************:certificate/***";

    // == Route53 ==
    const hostedZone = route53.PublicHostedZone.fromLookup(this, `${hostingDomeinName}HostedZone`, {
      domainName: hostingDomeinName,
    })

  // == CloudFront ==
  const cloudfrontDistribution = new cloudfront.Distribution(this, `${cloudfrontDistributionName}Cloudfront`, {
    // ----- ↓ 追記 ↓ -----
    domainNames: [
        hostingDomeinName
    ],
    certificate: acm.Certificate.fromCertificateArn(this, "acmCertificate", acmCertificateArn),
    // ----- ↑ 追記 ↑ -----
    enabled: true,
    defaultRootObject: "index.html",
    priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
    defaultBehavior: {
      allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
      cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
      cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
      viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
      origin: new cloudfront_origins.S3Origin(
        hostingBucket, {
          originAccessIdentity: oai
        }
      )
    }
  });

2-3. 確認

独自ドメインでアクセス。ページが見れることを確認。

3. S3へのCICDパイプラインを構築する。

手順前後だが、ACMの証明書発行待ちの間にS3へのデプロイパイプラインを構築する。

3-1. CodeCommitリポジトリの作成。 【手動】

リポジトリ自体はCDKで構成しないこととする。destroy後にdeployする度にエラーしそうで面倒なので。
CodeCommitコンソールからリポジトリを作成。ローカルでssh-keygenし、IAMに公開鍵を登録。

3-2. Pipelineの構築。

フロントエンドのデプロイ用のCDK/リポジトリはインフラ/バックエンド側のものとは別で用意する。

$ cdk init --langage typescript

$ tree -L 1
.
├── bin
├── lib
├── node_modules
├── package-lock.json
├── package.json
├── src
└── tsconfig.json

masterブランチにマージされた時にパイプラインが実行されるようにする。

lib/pipeline-stack
import * as s3 from '@aws-cdk/aws-s3';
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 * as sns from '@aws-cdk/aws-sns';
import * as subscriptions from '@aws-cdk/aws-sns-subscriptions';
import * as cdk from '@aws-cdk/core';

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

    // == const ==
    const distBucketName = "***";
    const snsTopicName = "***";
    const buildProjectName ="***";
    const pipelineName = "***";
    const repositoryName = "***";
    const approvalEmailAddress = "***@gmail.com"

    // == S3 ==
    const dist_bucket = s3.Bucket.fromBucketAttributes(this, `${distBucketName}DistBucket`, {
      bucketName: distBucketName
    });

    // == Artifact ==
    // This bucket is not deleted when execute `cdk destroy`.
    const sourceOutput = new codepipeline.Artifact('source_output');
    const buildOutput = new codepipeline.Artifact("build_output");

    // == SNS ==
    const snsTopic = new sns.Topic(this, `${snsTopicName}Topic`, {
      topicName: snsTopicName
    })

    snsTopic.addSubscription(new subscriptions.EmailSubscription(approvalEmailAddress))

    // == CodeCommit ==
    const repository = codecommit.Repository.fromRepositoryName(this, `${repositoryName}Repository`, repositoryName);

    // == CodeBuild ==
    const buildProject = new codebuild.PipelineProject(this, `${buildProjectName}BuildProject`, {
        projectName: buildProjectName,
        buildSpec: codebuild.BuildSpec.fromSourceFilename("frontapp/buildspec.yml")
    });

    // == CodePipeline ==
    const pipeline = new codepipeline.Pipeline(this, `${pipelineName}Pipeline`, {
        pipelineName: pipelineName
    });

    // == CodePipeline Actions == 
    const sourceAction = new codepipeline_actions.CodeCommitSourceAction({
      output: sourceOutput,
      repository: repository,
      branch: "master",
      actionName: "Suorce",
      trigger: codepipeline_actions.CodeCommitTrigger.EVENTS
    });
    const buildAction = new codepipeline_actions.CodeBuildAction({
      actionName: "Build",
      project: buildProject,
      input: sourceOutput,
      outputs: [buildOutput]
    });
    const approvalAction = new codepipeline_actions.ManualApprovalAction({
      actionName: "Approval",
      notificationTopic: snsTopic
    })
    const deployAction = new codepipeline_actions.S3DeployAction({
      bucket: dist_bucket,
      input: buildOutput,
      extract: true,
      actionName: "Deploy"
    })

    pipeline.addStage({
      stageName: "Source",
      actions: [sourceAction]
    });
    pipeline.addStage({
      stageName: "Build",
      actions: [buildAction]
    });
    pipeline.addStage({
      stageName: "Approval",
      actions: [approvalAction]
    })
    pipeline.addStage({
      stageName: "Deploy",
      actions: [deployAction]
    });
  }
}     
buildspec.yml
version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 10
  pre_build:
    commands:
      - cd frontapp
      - npm update -g npm
      - npm ci
  build:
    commands:
      - npm run build
artifacts:
  files:
    - '**/*'
  base-directory: 'frontapp/build'

新規のディレクトリを作成し、reactページを生成。

$ npx create-react-app frontapp --template typescript
$ git add .
$ git commit -m "xxx"
$ git push

3-3. 確認

CodePipelineコンソールで承認後、デプロイされることを確認。
CloudFrontのキャッシュが残っている場合はinvalidateする。

4. invalidate処理をPipleineに載せる。

4-1. Lambda作成

毎回手動でinvalidateするのは面倒なのでLamdbaで処理する。
Lambdaのロールにpipelineへのアクセス権はデプロイ時に自動で付与される。

lib/pipeline-stack.ts
    // == const ==
    const cacheClearLambdaName = "cacheClear";
    const cloudfrontDistributionId = "***";

    // == Lambda ==
    const cacheClearLambda = new lambda.Function(this, `${cacheClearLambdaName}Lambda`, {
      code: lambda.Code.fromAsset("src/cacheClear"),
      functionName: cacheClearLambdaName,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_14_X,
    })
    cacheClearLambda.addEnvironment("DISTRIBUTION_ID", cloudfrontDistributionId);
    cacheClearLambda.addToRolePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ["cloudfront:CreateInvalidation"],
      resources: ["*"]
    }))

    // == CodePipeline ==
    // 中略...
    const ClearAction = new codepipeline_actions.LambdaInvokeAction({
      actionName: "ClearCache",
      lambda: cacheClearLambda,
    })
    // 中略...
    pipeline.addStage({
      stageName: "Clear",
      actions: [ClearAction]
    })

pipelineの更新

PipelineStackでPipeline末尾にLambda呼び出しステートを追加。

src/cacheClear/index.js
const AWS = require("aws-sdk");
const cloudfront = new AWS.CloudFront();
const codepipeline = new AWS.CodePipeline();
const DISTRIBUTION_ID = process.env.DISTRIBUTION_ID;

const invalidateCache = async () => {
    try {
        const invalidatePaths = ["/*"];
        const params = {
            DistributionId: DISTRIBUTION_ID,
            InvalidationBatch: {
            CallerReference: new Date().getTime().toString(),
            Paths: {
                Quantity: invalidatePaths.length,
                Items: invalidatePaths,
            }
            }
        };

        return new Promise((resolve, reject) => {
            cloudfront.createInvalidation(params, (err, data) => {
                if (err) reject(err);
                else resolve(data);
            });
        });
    } catch (err) {
        return err;
    }
};

const finalizeJob = async (jobId, failureMessage) => {
    try {
        const param = {jobId};
        if (failureMessage) {
            param.failureDetails = {
                message: JSON.stringify(failureMessage),
                type: "JobFailed",
            };
        }
        console.log(JSON.stringify(param));
        return new Promise((resolve, reject) => {
            codepipeline.putJobSuccessResult({jobId}, (err, data) => {
                if (err) reject(err);
                else resolve(data);
            });
        });
    } catch (err) {
        return err;
    }
};

exports.handler = async (event) => {
    try {
        console.log("start function");
        console.log(JSON.stringify(event));
        await invalidateCache();
        await finalizeJob(event["CodePipeline.job"].id);
        return {
            status: 200,
            body: JSON.stringify({})
        };
    } catch (err) {
        console.error(`[Error] ${JSON.stringify(err)}`);
        await finalizeJob(event["CodePipeline.job"].id, err);
        return {
            status: 500,
            body: JSON.stringify(err)
        };
    }
};

4-2. 確認

Pipelineを動かしたのち、しばらくしてブラウザからアクセス。キャッシュがクリアされ、最新のページが見れることを確認。

5. Lambda@EdgeでBasic認証。

このままだと野晒しのCloudFrontになってしまうのでここで気持ちばかりのBasic認証をかけておく。

5-1. Lambda@Edgeの作成

以前の記事でCDK化したものを流用。typescript化。

src/authenticator/index.js
'use strict';

exports.handler = main;
async function main(event) {
    try {
        console.log("start function");
        const request = event.Records[0].cf.request;
        console.log(JSON.stringify(request));
        const headers = request.headers;

        // config
        const BASIC_AUTH_USER = "****";
        const BASIC_AUTH_PASS = "****";
        const authString = `Basic ${Buffer.from(BASIC_AUTH_USER + ':' + BASIC_AUTH_PASS).toString("base64")}`;
        if (typeof headers.authorization !== "undefined" && headers.authorization[0].value === authString)  {
            console.log(JSON.stringify(request));
            return request;
        }
        const response = {
            status: "401",
            statusDescription: "Unauthorized",
            body: "<b>Unauthorized</b>",
            headers: {
                'www-authenticate': [{key: 'WWW-Authenticate', value:'Basic'}]
            }
        };
        console.log(JSON.stringify(response));
        return response;
    } catch (err) {
        console.error(JSON.stringify(err));
        const response = {
            status: "500",
            statusDescription: "InternalServerError",
            body: "<b>InternalServerError</b>"
        };
        console.log(JSON.stringify(response));
        return response;
    }
};

5-2. HostingStackとMainStackの更新

originRequestPolicyheaderBehaviorでCloudFront以降に送りたいヘッダを指定する。
なお、ここでALLを指定してしまうとHostが一致しないだかなんかでエラーになったはず。

lib/hosting-stack.ts
    // == const ==
    const authEdgeLambdaName = "***";

    // == Lambda@Edge ==
    const authEdgeLambda = new cloudfront.experimental.EdgeFunction(this, `${authEdgeLambdaName}Edgelambda`, {
      code: lambda.Code.fromAsset(`src/${authEdgeLambdaName}`),
      functionName: authEdgeLambdaName,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_14_X,
    });

    // == CloudFront ==
    const originRequestPolicy = new cloudfront.OriginRequestPolicy(this, `${cloudfrontDistributionName}OriginRequestPolicy`,{
      headerBehavior: cloudfront.OriginRequestHeaderBehavior.allowList("Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"),
      cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
      queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none()
    })

    // 中略...
    const cloudfrontDistribution = new cloudfront.Distribution(this, `${cloudfrontDistributionName}Cloudfront`, {
      domainNames: [
        hostingDomeinName
      ],
      certificate: acm.Certificate.fromCertificateArn(this, "acmCertificate", acmCertificateArn),
      enabled: true,
      defaultRootObject: "index.html",
      priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
      defaultBehavior: {
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        originRequestPolicy: originRequestPolicy,
        // ----- ↓ 追記 ↓ -----
        edgeLambdas: [
          {
            eventType: cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
            functionVersion: authEdgeLambda.currentVersion,
            includeBody: false
          }
        ],
        // ----- ↑ 追記 ↑ -----
        origin: new cloudfront_origins.S3Origin(
          hostingBucket, {
            originAccessIdentity: oai
          }
        )
      }
    });
bin/main.ts
new HostingStack(app, 'HostingStack', {
    env: {                            // 追記
        account: "************",      // 追記
        region: "ap-northeast-1"      // 追記
    }                                 // 追記
});

Lambda@Edgeはus-eastに作られるため、Stackが分かれて作成される。
そのため以降はデプロイ/削除時には--allが必要。

// デプロイ
$ cdk deploy --all

5-3. 確認

ブラウザからアクセス。1回目のリクエストで401が返り、レスポンスヘッダにWWW-AuthenticateでBasic認証を指定される。ブラウザはこれを受けてユーザ名/パスワード入力蘭が出るので、Lambdaで設定した値を入力。再度リクエストが飛び、今度は200でコンテンツが返る。

6. 削除と冪等性の確認

一度CDKで作成した全てのリソースを削除する。(手動作成した項目を除く)
手動リソース作成→CDK実行の順で作成する条件下でCDKでリソース作成できることの冪等性を確認しておく。

// 削除
$ cdk destroy --all

6-1. S3バケット

デフォルトの削除ポリシーがRetainのためS3バケットは削除されない。Destroyを指定したとしても中身が空でないと削除できないので空にするようにしないと結局エラーになる。
あまりスマートな解決策を思いついてない上に、頻繁にdestroyすることもないだろうから毎回手作業で消すことを我慢している。。

6-2. Lambda@Edge

これは結構厄介で、CDKでは削除できない。
直接CloudFormationコンソールに移動し、スタックを削除。この時にレプリカも削除するようにする。
その上でLambdaも削除する。
(レプリカはしばらく経つと消されるので少し待たないと上記共に削除エラーになる可能性がある。というか数分待った程度じゃあ消せない。)

7. APIGW + Lambda + DynamoDB構成

例えば製品管理を行うDBを立て、全データを取得するAPIを拵えてみる。

7-1. 通常構成

lib/hosting-stack.ts
    // == const ==
    const apiName = "***";
    const apiStageName = "api";
    const getDataLambdaName = "***";
    const dynamodbName = "***";
    const primaryKeyName = "***";

    // == Lambda ==
    const getDataLambda = new lambda.Function(this, `${getDataLambdaName}Lambda`, {
      code: lambda.Code.fromAsset(`src/${getDataLambdaName}`),
      functionName: getDataLambdaName,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_14_X,
    })
    getDataLambda.addEnvironment("DYNAMODB_TABLE_NAME", dynamodbName);
    getDataLambda.addEnvironment("DYNAMODB_PRIMARY_KEY", primaryKeyName);

    // == DynamoDB ==
    const productsManagementTable = new dynamodb.Table(this, `${dynamodbName}Table`, {
      partitionKey: {
        name: primaryKeyName,
        type: dynamodb.AttributeType.NUMBER,
      },
      tableName: dynamodbName,
    })

    // attach IAM policy
    productsManagementTable.grantReadWriteData(getDataLambda);

    // == API Gateway ==
    const internalApi = new apigw.RestApi(this, apiName, {
      restApiName: apiName,
      deployOptions: {
        stageName: apiStageName,
      },
    });

    // Path
    const productsPath = internalApi.root.addResource("products");

    // Method
    productsPath.addMethod("GET", new apigw.LambdaIntegration(getDataLambda), {
      methodResponses: [
        {
          statusCode: "200",
        }
      ]
    });
js/src/getProductsData/index.js
"use strict";
const AWS = require("aws-sdk");
const DynamoDBdocClient = new AWS.DynamoDB.DocumentClient();
const DYNAMODB_TABLE_NAME = process.env.DYNAMODB_TABLE_NAME;

const getDataFromDynamoDB = async () => {
  try {
    const params = {
      TableName: DYNAMODB_TABLE_NAME,
    };
    return new Promise((resolve, reject) => {
      DynamoDBdocClient.scan(params, (err, data) => {
          if (err) {
            reject(err);
          } else {
            resolve(data);
          }
      });
    });
  } catch (err) {
    return err;
  }
};

exports.handler = main;
async function main(event) {
  try {
    console.log("start function");
    console.log(`event : ${JSON.stringify(event)}`);
    const products = await getDataFromDynamoDB();
    return {
        statusCode: 200,
        headers: {
          "Access-Control-Allow-Origin": "*"
        },
        body: JSON.stringify(products)
    };
  } catch (err) {
    console.error(`[Error] ${JSON.stringify(err)}`);
    return {
        statusCode: 500,
        body: JSON.stringify(err)
    };
  }
}

7-2. CloudFront + APIGW構成

CloudFrontとドメインを揃えてホストしたい場合にとる構成。
CloudFrontではパス毎に転送先のオリジンを設定可能なので/apiパスへのリクエストはAPIGWに飛ばすようにしたらいい。
なお、APIGWにカスタムドメインを利用しない場合はCloudFrontで設定するパス名はAPIGWのステージ名に一致させる必要がある。

lib/hosting-stack.ts
    // cloudfront
    const apiOrigin = new cloudfront_origins.HttpOrigin(
      `${internalApi.restApiId}.execute-api.${this.region}.amazonaws.com`,
    )

    cloudfrontDistribution.addBehavior(`${apiStageName}/*`, apiOrigin, {
      allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
      viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
      originRequestPolicy: originRequestPolicy,
      cachePolicy: new cloudfront.CachePolicy(this, "cache", {
        maxTtl: cdk.Duration.seconds(0),
        minTtl: cdk.Duration.seconds(0),
        defaultTtl: cdk.Duration.seconds(0)
      })
    })

7-3. 確認

  • ブラウザの検索バーから直接APIコール。正常にjsonを取得できればOK。
  • MaterialUIのButtonを使ってlocalhost:3000からアクセス(CORSのテスト)
  const getRecords = async () => {
    console.log("push button");
    await axios.get(API_URL).then(function (response) {
      console.log(response);
    })
  };

  // 中略
  <Button variant="contained" onClick={getRecords}>Get Data</Button>

8. CdkPipelineに載せる。

現状、Reactのフロント側のコードはpushすると自動でCICDを回せているが、Lambdaのコードやインフラを変更しても自動で反映することができていない。
ここで CdkPipelineを利用してそのあたりもCICDに乗せる。

8-1. cdk.jsonの更新

cdk.json
{
  // 中略...
  "context": {
    // 中略...
    "@aws-cdk/core:newStyleStackSynthesis": true  //追加
  }
}

8-2. cdk bootstrap

Lambda@edgeをデプロイするリージョンにも実施する。

$ cdk bootstrap --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess aws://************/ap-northeast-1
$ cdk bootstrap --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess aws://************/us-east-1

8-3. npm install

$ npm i --save @aws-cdk/pipelines

8-4. pipelineスタックを作成。

lib/pipeline-stack.ts(インフラ側作業ディレクトリに新規作成)
import * as cdk from '@aws-cdk/core';
import * as cdk_pipelines from '@aws-cdk/pipelines';
import * as codecommit from '@aws-cdk/aws-codecommit';
import { HostingStack } from './hosting-stack';


export class PipelineStack extends cdk.Stack {
    constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        const repositoryName = "***";
        const repository = codecommit.Repository.fromRepositoryName(this, `${repositoryName}Repository`, repositoryName);


        const pipeline = new cdk_pipelines.CodePipeline(this, 'Pipeline', {
            synth: new cdk_pipelines.ShellStep('Synth', {
                input: cdk_pipelines.CodePipelineSource.codeCommit(repository, 'master'),
                commands: [
                    'npm ci',
                    'npm run build',
                    'npx cdk synth',
                ],
            }),
        });

        pipeline.addStage(new MyApplication(this, 'DeployToDev', {
            env: {
                "account": "************",
                "region": "ap-northeast-1"
            }
        }));
    }
}

class MyApplication extends cdk.Stage {
    constructor(scope: cdk.Construct, id: string, props?: cdk.StageProps) {
        super(scope, id, props);
        new HostingStack(this, "HostingStack", {
            env: {
                account: "************",
                region: "ap-northeast-1"
            }
        });
    }
}
bin/main.ts
#!/usr/bin/env node
import * as cdk from '@aws-cdk/core';
import { PipelineStack } from '../lib/pipeline-stack';

const app = new cdk.App();
new PipelineStack(app, 'PipelineStack', {
    env: {
        "account": "************",
        "region": "ap-northeast-1"
    }
});

8-5. 確認

$ git remote add origin リポジトリのURL // リポジトリに紐づけ
$ git add .
$ git commit -m "***"
$ git push 
$ cdk deploy --all

Pipelineが作成され、パイプライン上でHostingStackが作成/デプロイされる。
この時、HostingStackの内容はCodeCommitなどのリポジトリの中身なので、git pushし忘れていると意図した挙動と異なる可能性がある(localでエラーを直しても直してもCodeBuildでコケるとかはpush忘れを疑ってみる)。

9. CdkPipelineの挙動を見る。

cdkPipelineのフィードバック的なSelfUpdateを見てみよう。
ここではApprovalステートを追加してみる。(11/22時点ではSNSを介した通知が出来なさそう?)

        pipeline.addStage(new MyApplication(this, 'DeployToDev', {
            env: {
                "account": "************",
                "region": "ap-northeast-1"
            }
        }), {
            pre: [
                new cdk_pipelines.ManualApprovalStep('Approval', {})
            ]
        });

10. 感想

インフラ構築自体をpipelineに載せて検証/本番環境へのデプロイも一貫してできそうな点はめちゃくちゃ良さげ。ただ、以下の点で仕事で使うには躊躇う感じがある。

  • ローカルでcdk diffしてアプリケーションスタックとの差分が見れない。
    → 毎回pushしてみないと変更点がわからないしそれが正しい変更かを確認できない。
    → ローカルでの試行錯誤を全てpushすることになる。
    → 複数人での開発とかどうなるんだろう、、
  • ローカルから削除できない。コンソールからdeleteしないといけない。

簡単に調べた感じだとベストプラクティスがなさそう

-1. トラブルシュート

途中でCDKのversion 1.133.0がリリースされていたのでアップデートし、node_modulesを消して全てのパッケージを1.333.0に合わせて再インストールした。
すると、それ以降以下のようなエラーが発生しdiffやdeployができなくなった。

$ cdk diff --all      
This CDK CLI is not compatible with the CDK library used by your application. Please upgrade the CLI to the latest version.
(Cloud assembly schema version mismatch: Maximum schema version supported is 14.0.0, but found 15.0.0)

原因は不明だが、アンストして再度入れ直せとのことなので実施。直った。
https://github.com/aws/aws-cdk/issues/14738

$ npm uninstall -g aws-cdk
$ npm install -g aws-cdk

いくつか記事を見ると、そもそもグローバルインストールをすべきでないという話があった。確かにnpxで良いかもしれない。

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3