【AWS-CDK】CodePipelineでGithubにpush⇨ビルド⇨ECSにデプロイを自動化する

# はじめに
前回記事[「お名前.comで購入したドメインを使ってALBでECSにホストベースルーティングする」](https://qiita.com/theFirstPenguin/items/fb223859d6e7b31b192c)を拡張してデプロイパイプラインまで作ります。

# 全体図
![スクリーンショット 2022-06-03 10.30.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/ca158b66-164d-a881-0b1a-cb49e046ba70.png)


# 前提
-　お名前.comでドメイン(example.com)を購入済み
-　Route53でexample.comのホストゾーンを作成し、そのNSレコードをお名前.comで設定
-　ECRのプライベートリポジトリを作成後、アプリのイメージをpush済
-　(今回追加)GithubのトークンなどをSecretManagerに保存済

https://blog.i-tale.jp/2020/04/13_02/

# 実装
## ディレクトリ構成
前回の内容に下記を追加
* buildspec.ts
* codepipeline.ts

```
.
├── bin
│   └── app.ts
├── cdk.json
├── jest.config.js
├── lib
│   ├── app-stack.ts
│   ├── getENV.ts
│   └── resources
│       ├── buildspec.ts
│       ├── codePipeline.ts
│       ├── fargateService.ts
│       ├── fargateServiceWithAlb.ts
│       └── vpc.ts
├── package-lock.json
├── package.json
├── test
│   └── app.test.ts
├── tsconfig.json
└── yarn.lock

```

## 環境変数の設定
前回同様

```lib/getENV.ts
const ENV_NAMES = ['stg', 'prd'] as const;
type EnvName = typeof ENV_NAMES[number];
type EnvValues = {
  envName: string;
  domainName: string;
  subDomainName: string;
  HostedZoneId: string;
};

export function getEnv(): EnvValues {
  const envName = (process.env.ENV_NAME) as EnvName;
  switch (envName) {
    case 'stg':
      return {
        envName,
        domainName: 'example.com', // お名前.comでRoute53のNSレコードを事前設定済
        subDomainName: 'www.example.com',
        HostedZoneId: 'Zxxxxxxxxxxxx' // Route53でexample.comのホストゾーンを事前設定済
      };
    case 'prd':
      return {
        envName,
        domainName: 'xxxxx.com',
        subDomainName: 'xxxx.xxxxx.com',
        HostedZoneId: 'Zxxxxxxxxxxxx'
      };
    default:
      const _: never = envName;
      throw new Error(
        `Invalid environment variable ENV_NAME has given. ${envName}`
      );
  }
}
```
```package.json
{
  ...
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk",
    "synth:stg": "ENV_NAME=stg npx cdk synth",
    "synth:prd": "ENV_NAME=prd npx cdk synth",
    "diff:stg": "ENV_NAME=stg npx cdk diff",
    "diff:prd": "ENV_NAME=prd npx cdk diff",
    "deploy:stg": "ENV_NAME=stg npx cdk deploy",
    "deploy:prd": "ENV_NAME=prd npx cdk deploy"
  },
  ...
}
```

## App
前回同様

```bin/app.ts
#!/usr/bin/env node
import 'source-map-support/register';
import { App } from 'aws-cdk-lib';
import { AppStack } from '../lib/app-stack';

const app = new App();
new AppStack(app, 'AppStack', {});

```
## Stack
前回に`CodePipeline`を追加
![スクリーンショット 2022-06-03 10.30.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/ca158b66-164d-a881-0b1a-cb49e046ba70.png)

```lib/app-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Cluster } from 'aws-cdk-lib/aws-ecs';
import { Construct } from 'constructs';
import {
  Certificate,
  CertificateValidation
} from 'aws-cdk-lib/aws-certificatemanager';
import { HostedZone } from 'aws-cdk-lib/aws-route53';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { RemovalPolicy } from 'aws-cdk-lib';
import { SecretValue } from 'aws-cdk-lib';
import { BuildEnvironmentVariableType } from 'aws-cdk-lib/aws-codebuild';
import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';

import { CustomVpc } from './resources/vpc';
import { FargateServiceWithSslAlb } from './resources/fargateServiceWithAlb';
import { HostBasedRoutingFargateService } from './resources/fargateService';
import { CustomCodePipeline } from './resources/codepipeline';
import { getEnv } from './getENV';
import { buildSpec } from './resources/buildspec';

export class AppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    
    // VPC
    const VpcCdk = new CustomVpc(this, `${getEnv().envName}-cdk-VPC`);

    // ECS Cluster
    const EcsClusterCdk = new Cluster(
      this,
      `${getEnv().envName}-cdk-EcsCluster`,
      {
        clusterName: `${getEnv().envName}-cdk-EcsCluster`,
        vpc: VpcCdk.vpc
      }
    );

    // 作成済のホストゾーン（example.com）の読み込み
    const HostedZoneCdk = HostedZone.fromHostedZoneAttributes(
      this,
      `${getEnv().envName}-cdk-HostedZone`,
      { hostedZoneId: getEnv().HostedZoneId, zoneName: getEnv().domainName }
    );

    // SSL証明書
    const CertificateCdk = new Certificate(
      this,
      `${getEnv().envName}-cdk-Cert`,
      {
        domainName: getEnv().domainName,
        subjectAlternativeNames: [getEnv().subDomainName],
        validation: CertificateValidation.fromDns(HostedZoneCdk)
      }
    );
 
    // ALB + Service1
    const serviceName1 = 'sample-service-1';
    const ServiceWithAlb = new FargateServiceWithSslAlb(
      this,
      `${getEnv().envName}-cdk-Service1`,
      {
        loadBalancerName: `${getEnv().envName}-cdk-ALB`,
        domainName: getEnv().domainName,
        domainZone: HostedZoneCdk,
        certificate: CertificateCdk,
        serviceName: serviceName1,
        cluster: EcsClusterCdk,
        taskSubnets: { subnets: VpcCdk.vpc.privateSubnets },
        containerPort: 3000,　// Fargateにバインドするコンテナのポート番号（アプリが動いているポート番号）
        ecrRepoName: serviceName1,　// ECRリポジトリ名
        imageTag: 'xxxxx' // ECRイメージのタグ
        awsAccountNumber: this.account
      }
    );

    // ECR repo of Serviec1
    const ecrRepo1 = ServiceWithAlb.ecrRepo;

    // Service2
    const serviceName2 = 'sample-service-1';
    const AnotherService = new HostBasedRoutingFargateService(
      this,
      `${getEnv().envName}-cdk-Service2`,
      {
        domainName: getEnv().subDomainName,
        domainZone: HostedZoneCdk,
        serviceName: serviceName2,
        cluster: EcsClusterCdk,
        vpc: VpcCdk.vpc,
        vpcSubnets: { subnets: VpcCdk.vpc.privateSubnets },
        alb: ServiceWithAlb.loadBalancer,
        listener: ServiceWithAlb.listener,
        containerPort: 3000,
        ecrRepoName: serviceName2,
        imageTag: 'yyyyy',
        listenerPriority: 2,
        awsAccountNumber: this.account
      }
    );

    // ECR repo of Serviec2
    const ecrRepo2 = AnotherService.ecrRepo;

    // Artifactバケットを追加
    const artifactBucket = new Bucket(
      this,
      `${getEnv().envName}-artifact-bucket`,
      {
        bucketName: `${getEnv().envName}-artifact-bucket`,
        autoDeleteObjects: true,
        removalPolicy: RemovalPolicy.DESTROY
      }
    );

    // Secret ManagerからGithubのpersonal access tokenを読み込む
    // 本来ならCodeStarConnectionsを使うべきだが、CDKではまだ対応していない模様。

    const githubToken = SecretValue.secretsManager(
      'arn:aws:secretsmanager:ap-northeast-1:12345678910:secret:your-secrets-xxxxxx',
      { jsonField: 'GITHUB_PAT' }
    );

    // CodeBuildがVPC内にENIを設置するためにroleが必要。今回は面倒くさがってアドミン権限を与える。
    const codeBuildRole = new Role(
      this,
      `${getEnv().envName}-codebuild-role`,
      {
        assumedBy: new ServicePrincipal('codebuild.amazonaws.com'),
        managedPolicies: [
          {
            managedPolicyArn: 'arn:aws:iam::aws:policy/AdministratorAccess'
          }
        ]
      }
    );

    // CodePipelineの設定。とりあえずService1のみ。Service2の設定も同様。
    const artifactFileName = 'my-custom-artifact-file.json';
    const CodePipeline = new CustomCodePipeline(
      this,
      `${getEnv().envName}-codepipeline`,
      {
        gitHubAccountName: 'your-account',
        githubBranchName: 'develop',
        githubRepoName: 'your-repo',
        githubToken,
        serviceName: serviceName1,
        artifactBucket,
        vpc: VpcCdk.vpc,
        ecrRepo: ecrRepo1,
        buildSpec,
        codeBuildRole,
        artifactFileName,
        ecsService: ServiceWithAlb.service,
        codebuildEnvironmentVariables: {
          AWS_DEFAULT_REGION: { value: this.region },
          AWS_ACCOUNT_ID: { value: this.account },
          CONTAINER_NAME: { value: ecrRepo1.repositoryName },
          REPOSITORY_URI: { value: ecrRepo1.repositoryUri },
          ARTIFACT_FILE_NAME: { value: artifactFileName },
          SOME_SECRET: {
            type: BuildEnvironmentVariableType.SECRETS_MANAGER,
            value:
              'arn:aws:secretsmanager:ap-northeast-1:12345678910:secret:your-secrets-xxxxxx:SOME_SECRET::'
          }
        }
      }
    );

  }
}
```
## Construct
### CodePipeline
今回追加。
`CodeBuild`はVPCを指定してプライベートサブネットの中で実行する。
VPCを指定しないと[Docker Hub の Rate Limitに引っかかります。](https://fu3ak1.hatenablog.com/entry/2020/11/22/122241)

```lib/resources/codePipeline.ts
import { Construct } from 'constructs';
import { FargateService } from 'aws-cdk-lib/aws-ecs';
import { Pipeline, Artifact } from 'aws-cdk-lib/aws-codepipeline';
import {
  GitHubSourceAction,
  CodeBuildAction,
  EcsDeployAction
} from 'aws-cdk-lib/aws-codepipeline-actions';
import {
  PipelineProject,
  ComputeType,
  LinuxBuildImage,
  BuildEnvironmentVariable,
  BuildSpec
} from 'aws-cdk-lib/aws-codebuild';
import { SecretValue } from 'aws-cdk-lib';
import { IBucket } from 'aws-cdk-lib/aws-s3';
import { IVpc } from 'aws-cdk-lib/aws-ec2';
import { IRepository } from 'aws-cdk-lib/aws-ecr';
import { IRole } from 'aws-cdk-lib/aws-iam';

import { getEnv } from '../getENV';

export interface CodePipelineProps {
  gitHubAccountName: string;
  githubRepoName: string;
  githubBranchName: string;
  githubToken: SecretValue;
  serviceName: string;
  ecsService: FargateService;
  artifactBucket: IBucket;
  buildSpec: {
    [key: string]: any;
  };
  ecrRepo: IRepository;
  artifactFileName: string;
  codeBuildRole?: IRole;
  vpc?: IVpc;
  codebuildEnvironmentVariables?: { [name: string]: BuildEnvironmentVariable };
}

export class CustomCodePipeline extends Construct {
  constructor(scope: Construct, id: string, props: CodePipelineProps) {
    super(scope, id);

    const pipelineName = `${getEnv().envName}-pl-${props.serviceName}`;
    const pjtName = `${getEnv().envName}-pjt-${props.serviceName}`;
    const pipeline = new Pipeline(this, pipelineName, {
      crossAccountKeys: false,
      pipelineName,
      artifactBucket: props.artifactBucket
    });

    const project = new PipelineProject(this, pjtName, {
      environment: {
        buildImage: LinuxBuildImage.STANDARD_5_0,
        computeType: ComputeType.SMALL,
        privileged: true,
        environmentVariables: props.codebuildEnvironmentVariables
      },
      buildSpec: BuildSpec.fromObject(props.buildSpec),
      vpc: props.vpc,
      role: props.codeBuildRole
    });
    props.ecrRepo.grantPullPush(project);

    // connect to Github
    const sourceStage = pipeline.addStage({
      stageName: 'Source'
    });
    const sourceOutput = new Artifact('sourceOutput');
    const sourceAction = new GitHubSourceAction({
      actionName: 'GitHub_Source',
      owner: props.gitHubAccountName,
      repo: props.githubRepoName,
      oauthToken: props.githubToken,
      output: sourceOutput,
      branch: props.githubBranchName
    });
    sourceStage.addAction(sourceAction);

    // build docker image and push to ECR
    const buildStage = pipeline.addStage({
      stageName: 'Build'
    });
    const buildOutput = new Artifact('buildOutput');
    const buildAction = new CodeBuildAction({
      actionName: 'Build_And_Push_To_ECR',
      project,
      input: sourceOutput,
      outputs: [buildOutput]
    });
    buildStage.addAction(buildAction);

    // deploy to ECS
    const deployStage = pipeline.addStage({
      stageName: 'Deploy'
    });
    const deployAction = new EcsDeployAction({
      actionName: 'Deploy_To_ECS',
      imageFile: buildOutput.atPath(props.artifactFileName),
      service: props.ecsService
    });
    deployStage.addAction(deployAction);
  }
}
```

ビルド設定はこちら↓に記載する。
```lib/resources/buildspec.ts
export const buildSpec = {
  version: '0.2',
  phases: {
    pre_build: {
      commands: [
        'IMAGE_URI="${REPOSITORY_URI}:$(echo ${CODEBUILD_RESOLVED_SOURCE_VERSION} | head -c 7)"',
        'aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com'
      ]
    },
    build: {
      commands: [
        'echo Build started on $(date)',
        'docker build --tag ${IMAGE_URI} ./ --build-arg SOME_SECRET=${SOME_SECRET}',
        'docker push ${IMAGE_URI}'
      ]
    },
    post_build: {
      commands: [
        'echo Build completed on $(date)',
        'printf \'[{"name":"%s","imageUri":"%s"}]\' "${CONTAINER_NAME}" "$IMAGE_URI" > ${ARTIFACT_FILE_NAME}'
      ]
    }
  },
  artifacts: {
    files: '${ARTIFACT_FILE_NAME}'
  }
};
```


### VPC
前回同様
![スクリーンショット 2022-05-08 14.02.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/6fa3714c-e39b-2432-6242-ac3de05834e7.png)

```lib/resources/vpc.ts
import { Construct } from 'constructs';
import { Vpc, SubnetType } from 'aws-cdk-lib/aws-ec2';
import { getEnv } from '../getENV';

export interface CustomVpcProps {}

export class CustomVpc extends Construct {
  public readonly vpc: Vpc;

  constructor(scope: Construct, id: string, _props: CustomVpcProps = {}) {
    super(scope, id);

    const vpc = new Vpc(this, `${getEnv().envName}-vpc`, {
      cidr: '10.0.0.0/16',
      natGateways: getEnv().envName === 'prd' ? 2 : 1,
      maxAzs: 2,
      subnetConfiguration: [
        {
          name: 'private-subnet',
          subnetType: SubnetType.PRIVATE_WITH_NAT,
          cidrMask: 24
        },
        {
          name: 'public-subnet',
          subnetType: SubnetType.PUBLIC,
          cidrMask: 24
        },
        // DB配置用のisolated-subnet(今回は使わない)
        // { 
        //   name: 'isolated-subnet',
        //   subnetType: SubnetType.PRIVATE_ISOLATED,
        //   cidrMask: 28
        // }
      ]
    });
 
    this.vpc = vpc;
  }
}
```
### ALB(HTTPS) + Service1
前回同様
![スクリーンショット 2022-05-07 9.14.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/3bc6f42c-b2ad-9da9-9978-66b338843a7e.png)

```lib/resources/fargateServiceWithAlb.ts
import { Construct } from 'constructs';
import { ApplicationLoadBalancedFargateService } from 'aws-cdk-lib/aws-ecs-patterns';
import { ContainerImage, ICluster, FargateService } from 'aws-cdk-lib/aws-ecs';
import { Repository, IRepository } from 'aws-cdk-lib/aws-ecr';
import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
import { IHostedZone } from 'aws-cdk-lib/aws-route53';
import {
  ApplicationLoadBalancer,
  ApplicationListener
} from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { SubnetSelection } from 'aws-cdk-lib/aws-ec2';

import { getEnv } from '../getENV';

export interface FargateServiceWithSslAlbProps {
  loadBalancerName: string;
  domainName: string;
  domainZone: IHostedZone;
  serviceName: string;
  cluster: ICluster;
  certificate: ICertificate;
  taskSubnets: SubnetSelection;
  containerPort: number;
  ecrRepoName: string;
  imageTag: string;
  autoScaleMaxCount?: number;
  awsAccountNumber: string;
}

export class FargateServiceWithSslAlb extends Construct {
  public readonly loadBalancer: ApplicationLoadBalancer;
  public readonly listener: ApplicationListener;
  public readonly ecrRepo: IRepository;
  public readonly service: FargateService;

  constructor(
    scope: Construct,
    id: string,
    props: FargateServiceWithSslAlbProps
  ) {
    super(scope, id);

    const ImageArn = Repository.arnForLocalRepository(
      props.ecrRepoName,
      this,
      props.awsAccountNumber
    );
    const Image = Repository.fromRepositoryAttributes(
      this,
      `${getEnv().envName}-image-${props.ecrRepoName}-${props.imageTag}`,
      { repositoryArn: ImageArn, repositoryName: props.ecrRepoName }
    );

    const loadBalancedFargateService =
      new ApplicationLoadBalancedFargateService(
        this,
        `${getEnv().envName}-fargate-alb-${props.serviceName}`,
        {
          loadBalancerName: props.loadBalancerName,
          domainName: props.domainName,
          domainZone: props.domainZone,
          serviceName: props.serviceName,
          certificate: props.certificate,
          redirectHTTP: true,
          cluster: props.cluster,
          taskSubnets: props.taskSubnets,
          taskImageOptions: {
            containerName: props.serviceName,
            image: ContainerImage.fromEcrRepository(ecrRepo, props.imageTag),
            containerPort: props.containerPort
          }
        }
      );

    // オートスケール設定
    const scalableTarget =
      loadBalancedFargateService.service.autoScaleTaskCount({
        minCapacity: 1,
        maxCapacity: props.autoScaleMaxCount ? props.autoScaleMaxCount : 10
      });

    scalableTarget.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 50
    });

    scalableTarget.scaleOnMemoryUtilization('MemoryScaling', {
      targetUtilizationPercent: 50
    });

    this.loadBalancer = loadBalancedFargateService.loadBalancer;
    this.listener = loadBalancedFargateService.listener;
    this.service = loadBalancedFargateService.service;
    this.ecrRepo = ecrRepo;
  }
}
```
### Service2
前回同様
![スクリーンショット 2022-05-07 9.08.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/c39ae77d-f020-8847-358c-ff861c96fed0.png)

```lib/resources/fargateService.ts
import { Construct } from 'constructs';
import { FargateService, FargateTaskDefinition } from 'aws-cdk-lib/aws-ecs';
import {
  ApplicationLoadBalancer,
  ApplicationTargetGroup,
  ApplicationProtocol,
  ApplicationListener,
  ListenerCondition
} from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { ContainerImage, ICluster } from 'aws-cdk-lib/aws-ecs';
import { Repository, IRepository } from 'aws-cdk-lib/aws-ecr';
import { Port, Vpc } from 'aws-cdk-lib/aws-ec2';
import { SubnetSelection } from 'aws-cdk-lib/aws-ec2';
import {
  IHostedZone,
  RecordSet,
  RecordType,
  RecordTarget
} from 'aws-cdk-lib/aws-route53';

import { getEnv } from '../getENV';

export interface FargateServiceProps {
  domainName: string;
  domainZone: IHostedZone;
  serviceName: string;
  cluster: ICluster;
  vpc: Vpc;
  vpcSubnets: SubnetSelection;
  alb: ApplicationLoadBalancer;
  listener: ApplicationListener;
  containerPort: number;
  ecrRepoName: string;
  imageTag: string;
  autoScaleMaxCount?: number;
  listenerPriority: number;
  awsAccountNumber: string;
}

export class HostBasedRoutingFargateService extends Construct {
  public readonly service: FargateService;
  public readonly ecrRepo: IRepository;

  constructor(scope: Construct, id: string, props: FargateServiceProps) {
    super(scope, id);

    const ecrRepoArn = Repository.arnForLocalRepository(
      props.ecrRepoName,
      this,
      props.awsAccountNumber
    );
    const ecrRepo = Repository.fromRepositoryAttributes(
      this,
      `${getEnv().envName}-image-${props.ecrRepoName}-${props.imageTag}`,
      { repositoryArn: ecrRepoArn, repositoryName: props.ecrRepoName }
    );

    const taskDefinition = new FargateTaskDefinition(
      this,
      `${getEnv().envName}-taskDef-${props.serviceName}`
    );
    const container = taskDefinition.addContainer(
      `${props.serviceName}-contaier`,
      {
        image: ContainerImage.fromEcrRepository(ecrRepo, props.imageTag),
        containerName: props.serviceName,
        environment: { MY_ENVIRONMENT_VAR: 'FOO' },
        memoryLimitMiB: 512,
        cpu: 256
      }
    );
    container.addPortMappings({ containerPort: props.containerPort });

    const service = new FargateService(
      this,
      `${getEnv().envName}-${props.serviceName}`,
      {
        cluster: props.cluster,
        taskDefinition,
        vpcSubnets: props.vpcSubnets,
        serviceName: props.serviceName,
        desiredCount: 1
      }
    );
    service.connections.allowFrom(props.alb, Port.tcp(props.containerPort));

    const targetGroup = new ApplicationTargetGroup(
      this,
      `${props.serviceName}-Targetgroup`,
      {
        targets: [service],
        protocol: ApplicationProtocol.HTTP,
        port: props.containerPort,
        vpc: props.vpc
      }
    );

    props.listener.addTargetGroups('targetgroup', {
      targetGroups: [targetGroup],
      conditions: [ListenerCondition.hostHeaders([props.domainName])],
      priority: props.listenerPriority
    });

    const recordSet = new RecordSet(this, `${props.serviceName}-RecordSet`, {
      recordType: RecordType.CNAME,
      target: RecordTarget.fromValues(props.alb.loadBalancerDnsName),
      zone: props.domainZone,
      recordName: props.domainName
    });

    const scalableTarget = service.autoScaleTaskCount({
      minCapacity: 1,
      maxCapacity: props.autoScaleMaxCount ? props.autoScaleMaxCount : 10
    });

    scalableTarget.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 50
    });

    scalableTarget.scaleOnMemoryUtilization('MemoryScaling', {
      targetUtilizationPercent: 50
    });

    this.service = service;
    this.ecrRepo = ecrRepo;
  }
}

```

# 所感
- CDKでCodePipelineを使うとGithub連携で`CodeStarConnections`が使えない？
- CDKだとRoleの設定が分かりづらい
- CDKだとリソース名の設定が分かりづらい
- `CDK Pipeline`と`CDK CodePipeline`って全くの別物やないかい！
- でも型によるサジェストは最高