静的ウェブサイトをコードで一発デプロイ！AWS CDK を使って CI/CD パイプラインを構築する

# 要約
この記事を参照することで、以下のインフラストラクチャを AWS CDK のコードとして管理できます。
- S3 + CloudFront による静的ウェブサイトの配信
- Code シリーズによる CI/CD パイプライン
- ACM による証明書の管理
- Route53 による登録済みドメインとの紐付け

# 静的ウェブサイトをデプロイするには
[このサイト](http://blog.usaneko-xlarge.com/madeit/howtodeploythissite.html)は AWS CDK でデプロイされており、 CodePipeline と GitHub の統合により、私の管理するプライベートリポジトリにコードがプッシュされると自動的にビルドが行われ、 S3 に静的ファイルが配置されることによって更新されます。
また、 CloudFront による配信や、Route53 による登録済みドメインとの紐づけも CDK によって行っています。
以下、このサイトを作成する手順について書いていきます。

## 概要・アーキテクチャ図
### インフラ部分
以下のようなアーキテクチャを AWS CDK でデプロイしています
![アーキテクチャ図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884991/f77bf14b-a9b0-cdb6-f144-73afc7bf47ed.png)
### 静的コンテンツの生成
[Sphinx](https://www.sphinx-doc.org/en/master/)を使って、reStructuredText 形式もしくは Markdown 形式のドキュメントを html 形式にビルドしています。
先ほどのアーキテクチャ図の CodeBuild 内でビルドを行っています。

## 詳細な実装（インフラ部分）
GitHub のリポジトリを貼ればそれで終わりなのですが、実際に CI/CD に利用しているリポジトリ自体を全世界に公開するのもどうなのかなと思ったので、一つ一つコードをコピーして解説していきます。
もしどうしてもアクセス権を得たいという方やこのブログにコミットしたい方がいらっしゃいましたら、個別に[@usaneko_xlarge](https://twitter.com/usaneko_xlarge)までご連絡ください。

------
`cdk init app --language typescript`した後の初期のフォルダ構成のなかに、以下のファイルを作っていきます。

### コンストラクタ
再利用性やコードの可読性が高いように、S3 バケットや CloudFront ディストリビューション、CodePipeline などのコンポーネントコンストラクタとしてまとめています。


以下は S3 バケットのコンストラクタです。CloudFront の OriginAccessIdentity と、そこから S3 を参照できるようにする設定も書き込んでいます。
```typescript ./lib/constructs/bucket.ts
import { aws_s3,RemovalPolicy,aws_iam,aws_cloudfront } from "aws-cdk-lib";
import { Construct } from "constructs";
export class Bucket extends Construct {
    public readonly s3Bucket:aws_s3.Bucket;
    public readonly originAccessIdentity:aws_cloudfront.OriginAccessIdentity;
    constructor(
        scope: Construct,
        id: string,
      ) {
        super(scope, id);
        this.s3Bucket = new aws_s3.Bucket(this, 'BlogBucket', {
            bucketName: 'usaneko-blog-bucket',
            removalPolicy: RemovalPolicy.DESTROY,
            autoDeleteObjects: true
        });
        this.originAccessIdentity = new aws_cloudfront.OriginAccessIdentity(this,'OriginAccessIdentity');
      
        const s3BucketPolicyStatement = new aws_iam.PolicyStatement({
            actions: ['s3:GetObject'],
            effect: aws_iam.Effect.ALLOW,
            principals: [
                new aws_iam.CanonicalUserPrincipal(
                    this.originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
                ),
            ],
            resources: [`${this.s3Bucket.bucketArn}/*`],
        });
      
        this.s3Bucket.addToResourcePolicy(s3BucketPolicyStatement);
      }
    }
```

以下は CloudFront のコンストラクタです。ディストリビューションを Routr53 Hosted Zone のレコードに登録するところも含めています。
```typescript ./lib/constructs/cloudfront.ts
import { aws_s3,aws_cloudfront,aws_cloudfront_origins,Duration, aws_certificatemanager, aws_route53, aws_route53_targets } from "aws-cdk-lib";
import { Construct } from "constructs";
export class CloudFront extends Construct {
    public readonly distribution:aws_cloudfront.Distribution;
    constructor(
        scope: Construct,
        id: string,
        props:{
            s3Bucket:aws_s3.Bucket;
            originAccessIdentity: aws_cloudfront.OriginAccessIdentity;
            hostedZone:aws_route53.IHostedZone;
            cert:aws_certificatemanager.Certificate;
            deployDomain:string;
        }
      ) {
        super(scope, id);
        this.distribution = new aws_cloudfront.Distribution(this, 'distribution', {
            defaultBehavior: {
                origin: new aws_cloudfront_origins.S3Origin(props.s3Bucket, {
                    originAccessIdentity:props.originAccessIdentity,
                }),
            },
            defaultRootObject: 'index.html',
            domainNames: [props.deployDomain],
            certificate:props.cert,
            minimumProtocolVersion: aws_cloudfront.SecurityPolicyProtocol.TLS_V1,
            sslSupportMethod: aws_cloudfront.SSLMethod.SNI,
            priceClass: aws_cloudfront.PriceClass.PRICE_CLASS_200,
          });
        const propsForRoute53Records = {
            zone: props.hostedZone,
            recordName: props.deployDomain,
            target: aws_route53.RecordTarget.fromAlias(
                new aws_route53_targets.CloudFrontTarget(this.distribution)
            ),
        }
        new aws_route53.ARecord(this, 'ARecord', propsForRoute53Records)
        new aws_route53.AaaaRecord(this, 'AaaaRecord', propsForRoute53Records)
      }
    }
```

以下は CodePipeline のコンストラクタです。CloudFront にキャッシュが残っていると、静的ファイルをビルドして S3 にデプロイした後でも前のページが見えてしまうので、デプロイステージにおいて消去しています。そのための IAM ロールをアタッチすることを忘れずに。cloudfront:CreateInvalidation の許可が必要です。
```typescript ./lib/constructs/codepipeline.ts
import { Construct } from "constructs";
import { aws_codebuild, aws_codepipeline,aws_codepipeline_actions,SecretValue,aws_logs, aws_s3, aws_cloudfront, aws_iam } from "aws-cdk-lib";

export class CodePipeline extends Construct {
    constructor(
        scope: Construct,
        id: string,
        props: {
            s3Bucket:aws_s3.Bucket;
            distribution:aws_cloudfront.Distribution;
        }
      ) {
        super(scope, id);
        const pipeline = new aws_codepipeline.Pipeline(this, 'WebappPipeline', {
        });
        const sourceOutput = new aws_codepipeline.Artifact('SourceArtifact');
        const sourceAction = new aws_codepipeline_actions.GitHubSourceAction({
            actionName: 'GetSourceCodeFromGitHub',
            owner: 'rayofhopejp',
            repo: 'SphinxBlog',
            oauthToken: SecretValue.secretsManager('github'),
            output: sourceOutput,
            branch: 'master',
        });
      
        pipeline.addStage({
            stageName: 'Source',
            actions: [sourceAction],
        });
      
        // Build stage
        const buildLogGroup = new aws_logs.LogGroup(this, 'BuildLogGroup');
      
        const buildActionProject = new aws_codebuild.PipelineProject(this, 'BuildProject', {
            buildSpec: aws_codebuild.BuildSpec.fromSourceFilename('src/buildspec.yaml'),
            logging: {
                cloudWatch: {
                enabled: true,
                logGroup: buildLogGroup,
                },
            },
            environment: {
                privileged: true,
                buildImage: aws_codebuild.LinuxBuildImage.AMAZON_LINUX_2_5,
            },
            environmentVariables: {
            },
        });
      
        const buildOutput = new aws_codepipeline.Artifact();
        const buildAction = new aws_codepipeline_actions.CodeBuildAction({
            actionName: 'BuildOnCodeBuild',
            project: buildActionProject,
            input: sourceOutput,
            outputs: [buildOutput],
        });
        pipeline.addStage({
            stageName: 'Build',
            actions: [buildAction],
        });
      
        // Deploy stage
        const deployAction = new aws_codepipeline_actions.S3DeployAction({
            actionName: 'S3Deploy',
            bucket: props.s3Bucket,
            input: buildOutput,
        });
        const policy = new aws_iam.PolicyStatement({
          actions: [
            'cloudfront:CreateInvalidation',
          ],
          resources: [
            `arn:aws:cloudfront::${process.env.CDK_DEFAULT_ACCOUNT}:distribution/${props.distribution.distributionId}`,
          ],
        })
        const role=new aws_iam.Role(this,'codeBuildRole',{
          assumedBy: new aws_iam.ServicePrincipal("codebuild.amazonaws.com")
        })
        role.addToPrincipalPolicy(policy)
        const invalidateBuildProject = new aws_codebuild.PipelineProject(this, `InvalidateProject`, {
            buildSpec: aws_codebuild.BuildSpec.fromObject({
              version: '0.2',
              phases: {
                build: {
                  commands:[
                    'aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_ID} --paths "/*"',
                  ],
                },
              },
            }),
            environmentVariables: {
              CLOUDFRONT_ID: { value: props.distribution.distributionId },
            },
            role:role
          });
        const invalidation = new aws_codepipeline_actions.CodeBuildAction({
            actionName: 'InvalidateCache',
            project: invalidateBuildProject,
            input: buildOutput,
            runOrder: 2,
          });
        pipeline.addStage({
            stageName: 'Deploy',
            actions: [deployAction,invalidation],
        });
      }
}
```

### スタック
スタックでは、上記の 3 つのコンストラクタを呼び出しています。
```typescript ./lib/infra-stack.ts
import { App, Stack, StackProps, aws_s3, RemovalPolicy, aws_route53, aws_certificatemanager} from 'aws-cdk-lib'
import { CodePipeline } from './constructs/codepipeline';
import { Bucket } from './constructs/bucket';
import { CloudFront } from './constructs/cloudfront';

interface InfraStackProps extends StackProps {
    hostedZone:aws_route53.IHostedZone;
    cert:aws_certificatemanager.Certificate;
    deployDomain:string;
}

export class InfraStack extends Stack {
    constructor(scope: App, id: string, props: InfraStackProps) {
        super(scope, id, props);
        const bucket=new Bucket(this,'BlogBucket');
        const cloudfront = new CloudFront(this,'BlogDistribution',{
            s3Bucket:bucket.s3Bucket,
            originAccessIdentity:bucket.originAccessIdentity,
            hostedZone:props.hostedZone,
            cert:props.cert,
            deployDomain:props.deployDomain
        })
        const pipeline = new CodePipeline(this,'BlogPipeline',{
            s3Bucket:bucket.s3Bucket,
            distribution:cloudfront.distribution
        })
        
    }
}
```

### スタックの呼び出し
CloudFront は CloudFormation をデプロイするリージョンと関係なく us-east-1 に紐づけられるので、 ACM で管理され CloudFormation に紐づけられる証明書も us-east-1 のものでなければなりません。そこで、インフラの基本スタックとは別に us-east-1 にもスタックを配置し、ACM の証明書と Routr53 Hosted Zone をそのスタックに配置しました。 
```typescript ./bin/infra.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { InfraStack } from '../lib/infra-stack';
import { Stack,aws_route53,aws_certificatemanager } from 'aws-cdk-lib';

const app = new cdk.App();
const usStack = new Stack(app, `FrontCdkUsStack`, { 
  env: {  account: process.env.CDK_DEFAULT_ACCOUNT, region: "us-east-1" },
  crossRegionReferences: true,
});
const rootDomain = 'usaneko-xlarge.com'
const deployDomain = `blog.${rootDomain}`
const hostedZone = aws_route53.HostedZone.fromLookup(usStack, 'HostedZone', {
  domainName: `${rootDomain}.`,
})
const cert = new aws_certificatemanager.Certificate(usStack, 'Certificate', {
  domainName: deployDomain,
  validation: aws_certificatemanager.CertificateValidation.fromDns(hostedZone),
})
const jpStack = new InfraStack(app, 'InfraStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
  /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
  crossRegionReferences: true,
  hostedZone:hostedZone,
  cert:cert,
  deployDomain:deployDomain
});
```

## Sphinx をデプロイする buildspec.yaml ファイル
あまり Sphinx を AWS にデプロイしたいという人間はいない気がしますが、念のためおいておきます。
```yaml buildspec.yaml
version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.11
    commands:
      # OS
      - cd src
      - sudo yum -y update
      - sudo yum -y install graphviz
      # Python
      - pip install --upgrade pip
      - pip install -r requirements.pi
  build:
    commands:
      - cd docs
      - sphinx-build -b html source build
artifacts:
  files:
    - "**/*"
  base-directory: "src/docs/build/"
```
## デプロイ
以下のコマンドでデプロイできます。cdk deploy のあとのワイルドカードは、 us-east-1 のスタックと CDK_DEFAULT_REGION のスタックがあるため、全てをデプロイすることを指定しています。
```bash
npm install -g aws-cdk
npm install
export CDK_DEFAULT_REGION=ap-northeast-1 CDK_DEFAULT_ACCOUNT=111122223333
cdk bootstrap aws://$CDK_DEFAULT_ACCOUNT/$CDK_DEFAULT_REGION
cdk bootstrap aws://$CDK_DEFAULT_ACCOUNT/$us-east-1
cdk deploy '*'
```