3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

CdkPipelineで静的Webホスティング環境(CloudFront/S3)を構築。

Last updated at Posted at 2021-11-07

CdkPipeline

CdkPipeline is 何?

CDKの登場によりコードベースでAWSリソースを管理できるようになった。
しかもCloudFormationのような形式ではなく、typescrictやpythonなどの慣れ親しんだ言語でそれらを記述できるためメンテナンスコストを抑えられる。
コードベースで管理できるというとこは、git管理が効くということであり、そうなると人はCICDパイプラインに乗せ込みたくなるものである。
しかし、CdkPipelineのGA以前にCodePipelineでそれをやろうとすると、CodeBuildの中で自前で色々しないといけなかったり、そもそもそのPipeline自体はCICDに載せられないので別管理になる等課題があった。

触ってみる

2021/7にGAしたCdkPipelineを使って、以下の超入門構成を管理してみる。

その上で最終的にはCloudFront-S3のあるあるな静的Webホスティング構成をCDKで書いてみる。

image.png

インフラ周りとフロント周りはリポジトリを分けている。
1つのリポジトリにまとめようとすると複数階層のnode_modulesのjestのversionがどうたらと怒られてしまい、解決できなかったのと他いろいろでこうすることにした(cdk initした階層のnode_modulesとsrc/などで階層を切った先でcreate-react-appした階層のnude_modules)。
まとめた方がいいかはもう少し検討。

→ 本目的のあるあるWebホスティング構成は再現性確認を目的にStep-By-Stepで別記事にも再度まとめ直した。

環境

$ npm --version
7.13.0
$ cdk --version     
1.130.0 (build 9c094ae)

package.jsonより抜粋
  "dependencies": {
    "@aws-cdk/aws-cloudfront-origins": "1.130.0",
    "@aws-cdk/aws-dynamodb": "1.130.0",
    "@aws-cdk/aws-sns": "1.130.0",
    "@aws-cdk/aws-sns-subscriptions": "1.130.0",
    "@aws-cdk/aws-sqs": "1.130.0",
    "@aws-cdk/core": "1.130.0",
    "@aws-cdk/pipelines": "1.130.0"
  }

なお、CDKを使う上でversionは極めて重要。1つでもずれているとTypeErrorが出るとかあるので固定することが望ましい(^は消した方がいいと思う)。
とはいえ、versionが永遠に固定されるのはpackage.jsonの考え方にあってない気がする。この場合問題なのは各モジュールのversionがバラバラになることなので、そこを揃えるようにしたらいいだけな気もしてきた。

全体構成

$ cdk init sample-app --langage typescriptをして必要箇所をいじっていくのが楽な気がする。
package.jsonのversionは固定すること。

$ tree
.
├── README.md
├── bin
│   └── main.ts
├── cdk.json
├── lambda
│   └── hogeLambda
│       └── index.js
├── lib
│   ├── app-stack.ts
│   └── pipeline-stack.ts
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json

メイン部分の記述

アプリケーションのリソース群はPipelineの中で構築されるので、ここではPipelineしか記述しない。

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": "123456789012",
        "region": "ap-northeast-1"
    }
});

Pipeline部分の定義

pipeline-stack.ts
import { Construct, Stage, Stack, StackProps, StageProps } from '@aws-cdk/core';
import { CodePipeline, CodePipelineSource, ShellStep } from '@aws-cdk/pipelines';
import * as codecommit from '@aws-cdk/aws-codecommit';
import { AppStack } from './app-stack';


// パイプライン自体の構成
export class PipelineStack extends Stack {
    constructor(scope: Construct, id: string, props?: StackProps) {
        super(scope, id, props);
        const repositoryName = "YOUR_REPOSITORY_NAME";
        const repository = codecommit.Repository.fromRepositoryName(this, `${repositoryName}Repository`, repositoryName);


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

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

// 今までのbin/xxx.tsに記述していた部分。main部分。
// 第一引数は cdk.appではなくcdk.Construct型に変更必要。
class MyApplication extends Stage {
    constructor(scope: Construct, id: string, props?: StageProps) {
        super(scope, id, props);
        // 構築するスタックを列挙していく
        new AppStack(this, 'AppStack');
    }
}

アプリケーション部分の記述

Pipelineの中でcdkが実行、構築される。

app-stack.ts
import * as iam from '@aws-cdk/aws-iam';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import * as _lambda from '@aws-cdk/aws-lambda';
import * as apigw from '@aws-cdk/aws-apigateway';
import * as cdk from '@aws-cdk/core';

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

    // == const ==
    const dynamodbTableName = "YOUR_DB_NAME";
    const partitionKeyName = "DB_PARTITION_KEY";
    const apiName = "YOUR_API_NAME";
    const lambdaName = "YOUR_LAMBDA_NAME";

    // == DynamoDB ==
    const dynamoTable = new dynamodb.Table(this, `${dynamodbTableName}DynamoTbl`, {
      tableName: dynamodbTableName,
      partitionKey: {
        name: partitionKeyName,
        type: dynamodb.AttributeType.STRING,
      },
      removalPolicy: cdk.RemovalPolicy.DESTROY
    })
    
    // == Lambda ==
    const myLambda = new _lambda.Function(this, `${lambdaName}Lambda`, {
        code: new _lambda.AssetCode("lambda/hogeLambda"),
        handler: "index.handler",
        runtime: _lambda.Runtime.NODEJS_14_X,
        environment: {
          TABLE_NAME: dynamoTable.tableName,
          PRIMARY_KEY: partitionKeyName,
        },
    });
    dynamoTable.grantReadData(myLambda);

    // == API GW ==
    const api = new apigw.RestApi(this, `${apiName}Api`, {
        restApiName: apiName,
    });

    const userPath = api.root.addResource("user");
    const specificUserPath = userPath.addResource("{id}");
    const getUserIntegration = new apigw.LambdaIntegration(myLambda);
    specificUserPath.addMethod("GET", getUserIntegration);
  }
}

その他

@aws-cdk/pipelinesが必要なので追加。

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

cdk.jsonに以下を追加。

cdk.json
{
  // ...
  "context": {
    "@aws-cdk/core:newStyleStackSynthesis": true
  }
}

CDKToolkitは単にCDKを利用するためのものと中身が違うっぽい。以前作ったものがあっても構わないので(勝手に更新されるので)以下実行。

$ npx cdk bootstrap --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess  aws://123456789012/ap-northeast-1

実行

$ cdk deploy PipelineStack

実行されると、PipelineStackが構築される。
PipelineStackが構築されると、Piplelineが動き、gitリポジトリからコードを一式取得し、以下の順にビルドが始まる。

  • Pipeline自体のStack作成
  • その他pipeline-stack.tsのMyApplicationクラスに記載したスタックの作成。
  • Piepline自体の更新があればそれが適応される。
  • その他のスタックのデプロイ。

試しに上記のPipelineスタックにApprovalサブステートを挟んで再実行し、CodePipelineコンソールを眺めてみるとその変化がわかる。
※ なぜかApprovalステートでSNSと連携できなさそう、、

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

Trouble Shoot

Build

No stacks match the name(s) HogeStack
[Container] 2021/11/06 16:51:14 Phase context status code: COMMAND_EXECUTION_ERROR Message: Error while executing command: cdk -a . deploy HogeStack --require-approval=never --verbose. Reason: exit status 1

HogeStackがないと言われている。
CodeBuild(PipelineUpdateステージ)でcdk deployを実行する時に元となるコードはSourceステージに指定したgitリポジトリになる。そのため、ローカルでHogeStackを作成(というかhoge-stack.tsなどのファイルを作成)し、cdk deployを実行しても、gitにpushしていない状態だとこのようになる。

Deploy

直前のパイプライン実行でスタックの作成/デプロイに失敗した場合、残骸が残ってしまうと次からの実行に差し支える。
直前のスタックを削除してから再実行する必要がある。(この辺はどうにかならないのだろうか、、)

あるある構成作ってみる。

image.png

CDKで実行する。
なお、この構成だとLambda@Edgeus-east-1リージョンに置くためCDKToolKitus-east-1リージョンにも配置する必要がある。

本来はSPAのPipelineも1つのCDKで管理したいが、stag環境やprod環境へデプロイする時にpipelineは除外する必要が出てくるのでこの辺りの方法は要調査。現在はとりあえず別CDKで構築しておく。

全体構成(CdkPipeline)

$ tree 
.
├── bin              <- ここから始まる。ここではmain.tsと呼んでおく。
├── lib              <- binで実行したmain.tsから呼ばれる。lib内にStack毎にts作成。
├── cdk.json
├── lambda           <- LambdaのStackなどから参照するLambdaのコードまとめといた方がいい。
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json

まずmainから。
ここでは呼び出すStackをずらっと書いていく。
なお、実際にCDKで各リソースを構築するのはパイプラインの中にしたいので、ここではそのパイプライン自体しか呼ばない。(cdk destroy時も然りなので注意。)

mainコードはここ
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": "123456789012",
        "region": "ap-northeast-1"
    }
});

そのPipelineスタックについてみていく。
さっきつくったお試し構成API-Lambda-DBのAppStackと、Hostingの骨格になるCloudFront/S3構成のHostingStackを分けて作成し、2つをMyApplicationとしてまとめて、pipelineのsynth後のステップで呼び出すようにしている。なお、synthはts→jsへの変換だったり、いろいろやってる。詳細まで理解はできてない。
MyApplicationの中で実行するLambda@Edgeのデプロイには環境情報が必須なのでここで指定しておく。普通だったらいらないかも。

Pipelineコードはここ
lib/pipeline-stack.ts
import { Construct, Stage, Stack, StackProps, StageProps } from '@aws-cdk/core';
import { CodePipeline, CodePipelineSource, ShellStep, ManualApprovalStep } from '@aws-cdk/pipelines';
import * as codecommit from '@aws-cdk/aws-codecommit';
import { AppStack } from './app-stack';
import { HostingStack } from './hosting-stack';

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

         // == const ==
        const repositoryName = "YOUR_REPOSITORY_NAME";

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

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

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

class MyApplication extends Stage {
    constructor(scope: Construct, id: string, props?: StageProps) {
        super(scope, id, props);

        new HostingStack(this, 'HostingStack');
        new AppStack(this, 'AppStack');
    }
}
lib/app-stack.ts
import * as iam from '@aws-cdk/aws-iam';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import * as _lambda from '@aws-cdk/aws-lambda';
import * as apigw from '@aws-cdk/aws-apigateway';
import * as cdk from '@aws-cdk/core';

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

    // == const ==
    const dynamodbTableName = "YOUR_TABLE_NAME";
    const apiName = "YOUR_API_NAME";
    const lambdaName = "YOUR_LAMBDA_NAME";
    const partitionKeyName = "userId";

    // == DynamoDB ==
    const dynamoTable = new dynamodb.Table(this, `${dynamodbTableName}_dynamo_tbl`, {
      tableName: dynamodbTableName,
      partitionKey: {
        name: partitionKeyName,
        type: dynamodb.AttributeType.STRING,
      },
      removalPolicy: cdk.RemovalPolicy.DESTROY // for dev only production
    })
    
    // == Lambda ==
    const getUserLambda = new _lambda.Function(this, `${lambdaName}_get`, {
        code: new _lambda.AssetCode("lambda/getUserLambda"),
        handler: "index.handler",
        runtime: _lambda.Runtime.NODEJS_14_X,
        environment: {
          TABLE_NAME: dynamoTable.tableName,
          PRIMARY_KEY: partitionKeyName,
        },
    });

    dynamoTable.grantReadData(getUserLambda);

    // == API GW ==
    const api = new apigw.RestApi(this, `${apiName}_api`, {
        restApiName: apiName,
    });

    const userPath = api.root.addResource("user");
    const specificUserPath = userPath.addResource("{id}");
    const getUserIntegration = new apigw.LambdaIntegration(getUserLambda);
    specificUserPath.addMethod("GET", getUserIntegration);
  }
}
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 bucket_name = "YOUR_HOSTING_S3_BUCKET";
    const cloudfront_name = "YOUR_CLOUDFRONT";
    const oai_name = "YOUR_OAI";

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

    // == Lambda@Edge ==
    const auth_lambda = new cloudfront.experimental.EdgeFunction(this, `${cloudfront_name}_edgelambda`, {
      code: _lambda.Code.fromAsset("src/edgelambda"),
      handler: "index.handler",
      runtime: _lambda.Runtime.NODEJS_14_X
    });
  
    // == CloudFront ==
    const oai = new cloudfront.OriginAccessIdentity(this, oai_name);

    const cloudfront_distribution = new cloudfront.Distribution(this, `${cloudfront_name}_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.REDIRECT_TO_HTTPS,
        originRequestPolicy: new cloudfront.OriginRequestPolicy(this, `${cloudfront_name}_origin_request_policy`,{
          headerBehavior: cloudfront.OriginRequestHeaderBehavior.allowList("Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"),
          cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
          queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none()
        }),
        edgeLambdas: [
          {
            eventType: cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
            functionVersion: auth_lambda.currentVersion,
            includeBody: false
          }
        ],
        origin: new cloudfront_origins.S3Origin(
          bucket, {
            originAccessIdentity: oai
          }
        )
      }
    });

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

S3バケットにReackアプリをデプロイするCICDパイプラインのCDK。
こちらはCdkPipelineに乗せるか悩んだ。
そもそも、開発用途であってproj自体のcdkに含めるべきか、そうした場合、 stag,prodの他環境にデプロイする時にPipelineは除外するような環境変数を指定しないといけなくなりそうで面倒なので一旦別管理。

全体構成(CodePipeline)

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

こちらもmainから。
こっちはこれまでのCDKの書き方と一緒。

mainのコードはここ
bin/main.ts
#!/usr/bin/env node
import * as cdk from '@aws-cdk/core';
import { CICDStack } from '../lib/cicd-stack';

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

CICDStackスタックにパイプラインを設定。
さっきのはCdkPipeline。こっちは普通のCodePipeline。入れてるパッケージが異なるので関数がやや異なる。CodeBuildの定義野中でyamlファイルの階層を指定するので、後述のSPA自体のディレクトリの構成に対応したパスを指定すること。

Pipelineのコードはここ
lib/cicd-stack.ts
import * as iam from '@aws-cdk/aws-iam';
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 = "YOUR_HOSTING_S3_BUCKET";
    const snsTopicName = "APPROUVAL_TOPIC";
    const buildProjectName ="YOUR_BUILD_PROJECT_NAME";
    const pipelineName = "YOUR_PIPELINE(S3)_NAME";
    const repositoryName = "YOUR_REPOSITORY(S3)_NAME";
    const approvalEmailAddress = "YOUR_EMAIL_ADDRESS";

    // == S3 ==
    const dist_bucket = s3.Bucket.fromBucketAttributes(this, `${distBucketName}_dist_bucket`, {
      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}ForApproval`
    })

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

    // == CodeCommit ==
    const repository = codecommit.Repository.fromRepositoryName(this, `${repositoryName}_repository`, repositoryName);
    
    // == CodeBuild ==
    const buildProject = new codebuild.PipelineProject(this, `${buildProjectName}_build_project`, {
        projectName: buildProjectName,
        buildSpec: codebuild.BuildSpec.fromSourceFilename("frontapp/buildspec.yml")
    });

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

    // == 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]
    });
  }
}

最後にSPA自体のディレクトリ。
上のCICDパイプラインのResourceステージに該当するCodeCommitと紐付けすることになる。
Reactの中身はここでは主題じゃないので何も変えない。
ここではBuildspec.ymlを置くことだけ焦点を当てる。

$ npx create-react-app frontapp --template typescript

$ tree
.
└── frontapp
    ├── buildspec.yml
    ├── node_modules
    ├── package-lock.json
    ├── package.json
    ├── public
    ├── src
    └── tsconfig.json
Buildspecのコードはここ
buildspec.yml
version: 0.2

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

後片付け

CdkPipelineで削除するとき、ローカルからcdk destroy -allとしても削除されるのPipeline自体(PipelineStack)だけで、各リソースのStackや実体は残る。直接CloudFormationからStackを削除するしかないのかもしれない。

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?