CDKを使用した案件のまとめ記事 - その1(パイプラインを使用してS3とCloudFrontのサイトを構成する)

記事タイトルとURLをコピーする

CI1-1の石井です。

はじめに

今回の記事は直近の案件をcdkで環境を作った内容のまとめです。 CDKとはプログラミング言語(本記事ではtypescript)を用いてAWSのリソース作成を定義できるIaCツールです。 AWSのリソース作成はCloudFormationが担いますが、yamlでは書けない繰り返し処理や、IAM作成など、一部リソース作成が抽象化されておりコード全体がスリムになる点が特徴です。

本記事では何回かに分けて作成した内容をまとめていきたいと思います。

対象

  • CDKに興味がある人
  • インフラエンジニアでGitやプログラム言語について知見がないが、CDKやIaCをやってみたい人

本記事で記載すること

本記事ではパイプラインを用いてCloudFrontでS3バケットの構成でサイトを公開する方法を記載します。 CDKはL1/L2コンストラクトを使ってCloudFrontとS3の作成を行い、パラメータはcdk.jsonに記載した内容で設定を施します。

※L1コンストラクトは、AWS CDKの基本的なリソースを表し、L2コンストラクトは、L1コンストラクトをラップして、より高レベルな抽象化を提供します。

案件の概要

私のプロジェクトではインフラはサーバーワークスが担当しますが、上物のアプリに関しては複数の別会社のアプリベンダーが担当しています。

プロジェクトで開始するサービスは大抵、CloudFront、S3、ApiGateway、Lambdaの組み合わせでサイトを公開しています。

私は、CDK導入前は主にセキュリティ観点の設定やLambdaのネットワーク周りの設定を行っていました。

CDK採用の背景

私が参加しているプロジェクトはAサービスのアプリはA社、BサービスのアプリはB社、と言った感じでマルチベンダーでアプリ開発を行っていました。

そのため、下記のような問題が発生していました。

  • 各会社でそれぞれの異なるデプロイ手法でアプリをデプロイしているため統一感がない
  • ステージング用と本番用で別々のCodeCommitが存在する
  • 一部のデプロイは手動

前々からサーバレス環境は統一的にパイプラインを整備したい、と思っていた事と個人的にIaCが好きなのでCDKを採用しました。

実際に作成した内容

プロジェクトは前々からお客様からパイプラインに手動承認を入れたり、デプロイ時間の指定などがあり、色々な要素を入れたいと思っていました。

そのため下記のような要件でパイプラインを組もうと考えました。

  • CDKはリソース管理用のアカウントのCodeCommitに保管
  • CodeCommitには本番用とステージング用の二つのブランチを用意し、ブランチのコミットでパイプラインが起動する
  • デプロイ先のアカウントはステージング用と本番用のアカウントで別々にする
  • アプリベンダーはApiGatewayのAPI定義とLambdaの本体を格納し、CDKの土台はサーバーワークスが記載しておく
  • 本番環境へのデプロイはプロジェクト上位者の手動承認と指定時間になったらデプロイを行うような仕組みを入れる

最終的に下記のような構成を作成しました。 ※StepFunctionはデプロイ時間を指定するステートマシンです。

本記事で紹介・作成する内容

最終的には先ほどの図の通りにリソースを作成したのですが、本記事で全てのコードを書いてもよくわからなくなるため リソースごとに区切って作成したCDKを紹介していきたいと思います。

そのため、本記事ではまずシングルアカウントを対象にパイプラインとS3+CloudFrontのリソースデプロイまで記載したいと思います。

この記事で作成するAWSリソースは下図となります。

環境準備

CDKはすでにインストールされている前提で記載しています。 インストールされていない方は下記のドキュメントを参考にインストールしてください。 https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/getting_started.html

筆者の環境では自端末にgitやcdkがすでにインストールされていますが cdkコマンドやgitが未インストールの方はCloud9で実施することをお勧めします。

また、検証用に使うアカウントにあらかじめ下記のコマンドを実行しておいてください。 cdk bootstrap

CDKの実行

まずはCDKのプロジェクト、ディレクトリ及びファイルを作成します。

mkdir CFDemo && cd CFDemo
cdk init --language typescript
touch lib/pipeline-stacks.ts
touch lib/apnortheast1stage.ts
touch lib/CFDemo.ts
mkdir -p src/demos3
touch src/demos3/index.html

プロジェクトのディレクトリ構造についてはworkshopの下記ページが参考になります。

https://cdkworkshop.com/ja/20-typescript/20-create-project/300-structure.html

本記事ではbinとlib配下のファイルが主に編集対象となります。

ファイル名 役割
lib/pipeline-stacks.ts パイプラインを構成するファイル
lib/apnortheast1stage.ts パイプラインのステージにデプロイする対象を定義したファイル
lib/CFDemo.ts デプロイされるCloudFrontをどう作るか?を記載したファイル
bin/cf_demo.ts initで作成されたファイル。CDKアプリケーションのエントリポイント
cdk.json CDKの設定を司るファイル。本記事では主に環境変数を記載
src/demos3/index.html デモ用のHTMLファイル

パイプラインの準備

プロジェクトの準備ができたため、まずはパイプラインを作成します。

エントリポイントを編集

cdkコマンドを実行した際、開始点となるcf_demo.tsを編集してみます。

#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { DemoPipelineStack } from '../lib/pipeline-stacks';

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

この時点だとインポート文がエラーになりますが、後々作成するクラスをロードする記載が書いてあります。

なお、本ファイルはinitで自動生成されたものを使いますが、ファイル名を変更した場合はcdk.jsonの「app」を修正してください。 下記の画像はcdk.jsonの内容です、2行目のappでcdkコマンドの実行対象ファイルを指定しているため、この部分を変更したファイル名に変更します。

パイプライン作成

lib/pipeline-stacks.tsを編集してパイプラインの要素を記載します。 ※ワークショップのpipelineのサンプルを参考に作っています。本家のワークショップもぜひお試しください。 https://cdkworkshop.com/ja/20-typescript/70-advanced-topics/200-pipelines/3000-new-pipeline.html

//ファイル名:lib/pipeline-stacks.ts

import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as pipelines from 'aws-cdk-lib/pipelines';
import { CodePipelineSource } from 'aws-cdk-lib/pipelines';
import * as codecommit from 'aws-cdk-lib/aws-codecommit';
// import { ANE1Deploy } from './apnortheast1stage';

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

    // CodeCommitのリポジトリを作成
    const repo = new codecommit.Repository(this, 'DemoRepo', {
      repositoryName: "DemoRepo"
    });

    // CDKをデプロイするためのパイプラインを構成
    const StgPipeline = new pipelines.CodePipeline(this, 'StgPipeline', {
      // CDKのコードをCodeBUildで生成する
      synth: new pipelines.CodeBuildStep('Synth', {
        // コミットが行われた時にパイプラインを起動するブランチ名(master)を指定
        input: CodePipelineSource.codeCommit(repo, 'master'),
        installCommands: [
          'npm i -g npm@latest',
          'npm install -g aws-cdk',
        ],
        commands: [
          'npm ci',
          'npm run build',
          'npx cdk synth '
        ],
      }),
    });
    // // 東京リージョンにデプロイするステージをパイプラインに追加
    // const ANE1Stg = new ANE1Deploy(this, 'Ane1Stage', {
    //   env: {account: '968841012693',region: 'ap-northeast-1'}
    // });
    // StgPipeline.addStage(ANE1Stg);
  }
}

ワークショップの内容とほぼ同じですが、まずは余計なオプションなどは考えずに単一のアカウントにデプロイするだけのパイプラインを組んでみます。

コメントアウトされている部分は後で使います、一旦この状態でコミットしてみます。

※gitをあまり触ってことない人向けの補足: VSCodeの拡張を使えばgitがよくわかってなくてもなんとかなります。

本記事では基本的にcommitというファイルの更新履歴を確定させるコマンドとpushというリモートのリポジトリにファイルをアップロードするコマンドしか使いません。

コミットが完了したらdeployを行います。

cdk deploy

デプロイ確認画面が出るので、yを押してデプロイします。

デプロイ完了後、AWSのマネージドコンソールからCodeCommitの画面に遷移してHTTPS(GRC)のURLをコピーします。

下記コマンドを実行してリモートのリポジトリ内容を変更します。

git checkout -b master
git remote add origin codecommit::ap-northeast-1://プロファイル名@DemoRepo
git push --set-upstream origin master

上記のgit pushコマンドが通ればCodeCommitにファイルがアップされ、パイプラインが起動し始めます。

ただし、この状態ではただ自身のパイプラインをデプロイするだけであり、特に意味はありません。

S3バケットとCloudFrontの作成

パイプラインの土台ができたため、実際にリソースを作成するステージを追加してみます。

追加するステージにはCloudFrontとS3の作成を記載しますが、まずはCloudFront用のS3バケットを作成します。

なお、CloudFrontにはOAC(Origin Access Control)を設定します。OACは、CloudFrontからS3バケットへのアクセスを制御する機能です。 これにより、特定のCloudFrontディストリビューションのみがS3バケットにアクセスできるようになります。

東京リージョンにデプロイするステージを追加

パイプラインに実際にリソースを作成するステージを作成します。

lib/apnortheast1stage.tsに下記コードを追加します。

//ファイル名:lib/apnortheast1stage.ts

import { CFDemo } from './CFDemo';
import { Construct } from 'constructs';
import { Stage, StageProps, Tags } from 'aws-cdk-lib';

export class ANE1Deploy extends Stage {
  constructor(scope: Construct, id: string, props?: StageProps) {
    super(scope, id, props);
    const FDemo = new CFDemo(this, 'FDemo');
  }
}

S3の作成

さらに実際に作成するリソースの記載を担うlib/CFDemo.tsに下記コードを追加します。

まずはkmsで暗号化されたS3を作成してみます。 なお、キーポリシーはCloudFrontからのアクセスを許可するための設定となります。

// ファイル名:lib/CFDemo.ts

    // KMS キーを作成
    const kmsKey = new kms.Key(this, 'MyKmsKey', {
      description: 'KMS key for encrypting S3 bucket',
      enableKeyRotation: true,
    });

    kmsKey.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'Allow CloudFront for OAC',
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
        actions: ['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*'],
        resources: ['*'],
      })
    );
    const cert = acm.Certificate.fromCertificateArn(this, 'Cert', "arn:aws:acm:us-east-1:968841012693:certificate/65d7c7dd-64c8-4d90-bbbc-aef8cdad7e99");

    
    // S3の作成
    const websiteBucket = new s3.Bucket(this, ' demos3',{
      encryption: s3.BucketEncryption.KMS,
      encryptionKey: kmsKey,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      versioned: true,
    })

最後にパイプラインを作成するファイルからコメントアウトされていた下記のコードのコメントをはずします。

//ファイル名:lib/pipeline-stacks.ts
〜中略〜
import { ANE1Deploy } from './apnortheast1stage';
〜中略〜
    // 東京リージョンにデプロイするステージをパイプラインに追加
    const ANE1Stg = new ANE1Deploy(this, 'Ane1Stage', {
      env: {account: '968841012693',region: 'ap-northeast-1'}
    });
    StgPipeline.addStage(ANE1Stg);

これで一旦コミットしてリモートにプッシュしてみます。

lib/pipeline-stacks.tsに新しくステージを追加したため、CDKが自身のパイプラインのアップデートを行います。

ステージ追加前

ステージ追加後

問題なく終わればKMSで暗号化されたS3が作成されているはずです。

CloudFrontの作成

次はCloudFrontを作成してみます。証明書は事前にus-east-1に登録しておいてください。

lib/CFDemo.tsに下記のコードを追加します。

// ファイル名:lib/CFDemo.ts
import { Duration, Stack, StackProps,RemovalPolicy, PermissionsBoundary } from 'aws-cdk-lib';
import {
          aws_kms as kms,
          aws_s3 as s3,
          aws_certificatemanager as acm,
          aws_cloudfront as cloudfront,
          aws_cloudfront_origins as cloudFrontOrigins,
          aws_s3_deployment as deployment,
          aws_iam as iam,
        } from 'aws-cdk-lib';
import { Construct,IConstruct } from 'constructs';

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

    // 証明書のARNをセット
    const cert = acm.Certificate.fromCertificateArn(this, 'Cert', "arn:aws:acm:us-east-1:968841012693:certificate/65d7c7dd-64c8-4d90-bbbc-aef8cdad7e99");

    // KMS キーを作成
    const kmsKey = new kms.Key(this, 'MyKmsKey', {
      description: 'KMS key for encrypting S3 bucket',
      enableKeyRotation: true,
    });

    kmsKey.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'Allow CloudFront for OAC',
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
        actions: ['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*'],
        resources: ['*'],
      })
    );

    
    // S3の作成
    const websiteBucket = new s3.Bucket(this, ' demos3',{
      encryption: s3.BucketEncryption.KMS,
      encryptionKey: kmsKey,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      versioned: true,
    })

    // CloudFrontディストリビューションの設定を行う
    const cloudFrontDistribution = new cloudfront.Distribution(this, ` DemoCloudFront`, {
        comment: ' DemoCloudFront',
        defaultRootObject: 'index.html',
        priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
        httpVersion: cloudfront.HttpVersion.HTTP2,
        certificate: cert,
        minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
        sslSupportMethod: cloudfront.SSLMethod.SNI,
        domainNames: ["hogehoge.co.jp"],
        defaultBehavior: {
        origin: new cloudFrontOrigins.S3Origin(websiteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        },
    });
    
    // OAC用にバケットポリシー設定
    const websiteBucketPolicy = new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ['s3:GetObject'],
        principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
        resources: [
          websiteBucket.bucketArn + '/*'
        ],
        conditions: {
          'StringEquals': {
            'AWS:SourceArn': [
              `arn:aws:cloudfront::${this.account}:distribution/${cloudFrontDistribution.distributionId}`,
            ]
          }
        }
      })
      websiteBucket.addToResourcePolicy(websiteBucketPolicy)
  
      // OAC設定
      const oac = new cloudfront.CfnOriginAccessControl(this, ' DemoOAC', {
        originAccessControlConfig: {
          name: `${websiteBucket.bucketName}`,
          originAccessControlOriginType: 's3',
          signingBehavior: 'always',
          signingProtocol: 'sigv4',
        },
      })
      const cfnDistribution = cloudFrontDistribution.node.defaultChild as cloudfront.CfnDistribution
      cfnDistribution.addPropertyOverride('DistributionConfig.Origins.0.OriginAccessControlId', oac.getAtt('Id'))
      const public_variable_distribution = cloudFrontDistribution.node.defaultChild as cloudfront.CfnDistribution
      public_variable_distribution.addOverride('Properties.DistributionConfig.Origins.0.S3OriginConfig.OriginAccessIdentity', "")
  
      // S3へのデプロイ内容を定義
      new deployment.BucketDeployment(this, 'WebsiteDeploy', {
        sources: [
          deployment.Source.asset('src/demos3')
        ],
        destinationBucket: websiteBucket,
        distribution: cloudFrontDistribution,
        distributionPaths: ['/*'],
      });
  }}

なお、OACに関する設定はまだL2コンストラクトで設定できないようです。

L1コンストラクトに下記のサイトを参考にバケットポリシーとOACの定義を直接記載しています。 https://aws.amazon.com/jp/blogs/news/amazon-cloudfront-introduces-origin-access-control-oac/ https://github.com/aws/aws-cdk/issues/21771

最後にデプロイするhtmlファイルを記載します。

// ファイル名:src/demos3/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  Hello, World!
</body>
</html>

上記ファイルをpushすればCloudFrontで下記のようなページが表示されるはずです。

環境分離

ここまでの編集で一旦パイプライン経由でS3とCloudFrontでWEBサイトが公開できたかと思います。 ただ、arnやドメイン名などが直接記載されてしまっており、ブランチベースで環境を分けることができません。

どうすれば環境分離ができるか?

cdkにはcontextという要素があり、ドキュメント(https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/context.html)の説明では

コンテキスト値は、アプリケーション、スタック、またはコンストラクトに関連付けることのできるキー/バリューペアをいいます。これらはファイル (通常はまたはプロジェクトディレクトリ)cdk.jsoncdk.context.json またはコマンドラインからアプリに提供できます。

とあります。cdk synthのコマンドに --contextまたは-cでコンテキストを引数として渡せるようなので、早速パイプラインを改造します。

//ファイル名:lib/pipeline-stacks.ts

〜中略〜
    // CDKをデプロイするためのパイプラインを構成
    const StgPipeline = new pipelines.CodePipeline(this, 'StgPipeline', {
      // CDKのコードをCodeBUildで生成する
      synth: new pipelines.CodeBuildStep('Synth', {
        // コミットが行われた時にパイプラインを起動するブランチ名(master)を指定
        input: CodePipelineSource.codeCommit(repo, 'master'),
        installCommands: [
          'npm i -g npm@latest',
          'npm install -g aws-cdk',
        ],
        commands: [
          'npm ci',
          'npm run build',
          'npx cdk synth -c stage=Stg'
        ],
      }),
    });
〜中略〜
  }
}

buildspecのcommandsに「npx cdk synth -c stage=Stg」にしてみました。これでcontextに「Stg」という文字列が含まれるようになりました。

スタックの中でcontextは下記のように取得できます

    // -cで定義されたkeyのstageに対応する'Stg'という文字列がstagenameに入ります。
    const stagename = this.node.tryGetContext('stage') 

cdk.jsonの編集

上記のようにcontextはtryGetContextで取得できることがわかったので、cdk.jsonを下記の内容を追記します。

    "Stg": {
      "$comment": "Stg環境",
      "Aliases": {
        "demodomain": ["hogehoge.co.jp"]
      },
      "CertArn": "証明書のARN"
    }

実際にcdk.jsonからデータを取得してリソースを作成する

早速contextからデータを取得して、先ほどまでハードコーディングしていた部分を修正してみました。

// ファイル名:lib/CFDemo.ts
〜中略〜
export class CFDemo extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // Dev や Prod ごとの環境変数を取得 from cdk.json
    const stagename = this.node.tryGetContext('stage')
    const environment = this.node.tryGetContext(stagename)
    const cert = acm.Certificate.fromCertificateArn(this, 'Cert', environment.CertArn);

〜中略〜

    // CloudFront作成
    const cloudFrontDistribution = new cloudfront.Distribution(this, ` DemoCloudFront`, {
        comment: ' DemoCloudFront',
        defaultRootObject: 'index.html',
        priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
        httpVersion: cloudfront.HttpVersion.HTTP2,
        certificate: cert,
        minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
        sslSupportMethod: cloudfront.SSLMethod.SNI,
        domainNames: environment.Aliases.demodomain,
        defaultBehavior: {
        origin: new cloudFrontOrigins.S3Origin(websiteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        },
    });

〜中略〜

これで一旦コミットしてプッシュします。プッシュすればデプロイはパイプラインが自動でやってくれるのですが 今回はbuildspecが更新されていないため、ビルドステージでcontextの値が取得できずに失敗します。

そのため、手動でデプロイを実施してビルドステージの内容を更新します。

cdk deploy

正常にcontextが取得できていれば正常にデプロイできます。またcontextの指定が間違っていればbuildステージのsynthコマンドで失敗します。

まとめ

この記事では、AWS CDKを使用して、CloudFrontとS3を用いたウェブサイトの構成を作成しました。

さらに、パイプラインを設定して自動デプロイが行われるようにしました。また、環境分離のための土台を作成し、CDKのcontext機能を使って、異なる環境でのデプロイを簡単に行えるようにしました。

次の記事では、マルチアカウントでのデプロイを行うパイプラインの作成を記載予定です。これによりブランチベースで環境を分離できるインフラストラクチャ管理が可能になります。