AWS CDKでスタックの変更差分なしでも強制的にカスタムリソースを実行する方法

CDKでカスタムリソースを実行するんだな!?今…!ここで!
2022.04.18

はじめに

CX事業本部IoT事業部の佐藤智樹です。

今回は、CDKで強制的に変更差分を作りカスタムリソースを任意のタイミングで実行する方法を紹介します。通常CloudFormationから実行されるカスタムリソースは、初回のデプロイ時やスタックの変更がある場合は実行されます。しかしながら、スタックが参照するCDK外のリソースで変更があった場合はCloudFormationが変更を検知できずカスタムリソースが実行されないという微妙に悩む部分がありました。そんな問題を強制的に解決します。

CloudFormationのパラメータを故意に誤って使用しているためその点は注意してご利用ください。

2022/05/24追記 「例:デプロイしたいファイルに変更があればECRにプッシュする」のカスタムリソースを実行する実装に `onUpdate` が含まれていなかったためCloudFormation自体は再実行されるがカスタムリソースが実行されないコードになっていました。修正しましたので以前確認済の方は再度ご確認ください。

参考にしたCloudFormationの例

最初に参考にしたCloudFormationの例を紹介します。

上記の記事では、CloudFormationのパラメータへdeployコマンドから異なる値を注入することで、任意のタイミングでカスタムリソースを実行する方法が紹介されていました。こちらの方法を参考にパラメータを応用してデプロイする方法を検討しました。

CDKで強制的にカスタムリソースを実行する方法

カスタムリソースを含むスタックに対して以下のCfnParameterを追加することで実現できます。

    new cdk.CfnParameter(this, id, {
      type: "String",
      description: "強制的にStack外の変更を検知させるためのパラメータ",
      default: "HogeDefaultValue",
    });

パラメータの中身でなく、idの部分を任意のタイミングで変更することでパラメータ名を変更して強制的にカスタムリソースを実行します。例えばidの部分を特定のディレクトリ/ファイルの内容に応じてハッシュ値を生成し、ハッシュ値の変更があればカスタムリソースを実行するなどCloudFormationに頼らずに実行タイミングをコントロールできます。

実践例

上記の内容をより実践に近い内容で試してみます。CDKからECRの作成とECR上へのコンテナイメージをプッシュする操作を実装する場合を想定します。CloudFormationでECS on Fargateで構築する場合は、ECR作成、ECRへのコンテナプッシュ、ECSの作成などを同時に行いたいですがイメージのプッシュはCloudFormationから実行できないのでカスタムリソースや別のパイプラインからコンテナイメージをプッシュする必要があります。

ただカスタムリソースはデプロイ初回だけ実行され、2回目以降はコンテナイメージの元となるファイルに変更があってもCDKやCloudFormationは差分検知できず実行されません。なので、今回紹介した方法を使用しスタック変更差分を強引に作ることでコンテナイメージのプッシュを再実行します。

今回は以下のGitHubリポジトリの特定のファイルを編集して利用します。

対象のファイル:

  • usecases/guest-webapp-sample/lib/blea-ecr-stack.ts
  • usecases/guest-webapp-sample/lib/blea-build-container-stack.ts

blea-ecr-stack.tsは特に変更せず、デプロイだけ実施します。 blea-build-container-stack.tsの中に、カスタムリソースを使用してECRにイメージをプッシュする実装が含まれるのでこちらを対象とします。

例:デプロイしたいファイルに変更があればECRにプッシュする

CDKデプロイのたびにカスタムリソースが実行されると不要なタイミングでも実行される可能性があります。そこでassets/sample-appディレクトリ配下でファイルのタイムスタンプなどメタデータに変更がある際はデプロイするよう設定してみます。具体的には、blea-build-container-stack.tsを以下のように変更します。

blea-build-container-stack.ts

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { aws_ecr as ecr } from 'aws-cdk-lib';
import { aws_iam as iam } from 'aws-cdk-lib';
import { aws_codebuild as codebuild } from 'aws-cdk-lib';
import { aws_s3_assets as s3assets } from 'aws-cdk-lib';
import { custom_resources as cr } from 'aws-cdk-lib';
import * as path from 'path';

export interface BLEABuildContainerStackProps extends cdk.StackProps {
  ecrRepository: ecr.Repository;
}

export class BLEABuildContainerStack extends cdk.Stack {
  public readonly imageTag: string;

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

    const appName = 'sample-ecs-app';

    this.imageTag = appName;

    // Upload Dockerfile and buildspec.yml to s3
    const asset = new s3assets.Asset(this, 'app-asset', {
      path: path.join(__dirname, '../container/sample-ecs-app'),
    });

    // CodeBuild project
    const project = new codebuild.Project(this, `${appName}-project`, {
      source: codebuild.Source.s3({
        bucket: asset.bucket,
        path: asset.s3ObjectKey,
      }),
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_4_0,
        privileged: true,
        environmentVariables: {
          AWS_DEFAULT_REGION: {
            type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,
            value: `${this.region}`,
          },
          AWS_ACCOUNT_ID: {
            type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,
            value: `${this.account}`,
          },
          IMAGE_TAG: {
            type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,
            value: `${appName}`,
          },
          IMAGE_REPO_NAME: {
            type: codebuild.BuildEnvironmentVariableType.PLAINTEXT,
            value: props.ecrRepository.repositoryName,
          },
        },
      },
    });
    project.addToRolePolicy(
      new iam.PolicyStatement({
        resources: ['*'],
        actions: ['ecr:GetAuthorizationToken'],
      }),
    );
    project.addToRolePolicy(
      new iam.PolicyStatement({
        resources: [`arn:aws:ecr:${this.region}:${this.account}:repository/${props.ecrRepository.repositoryName}`],
        actions: [
          'ecr:BatchCheckLayerAvailability',
          'ecr:CompleteLayerUpload',
          'ecr:InitiateLayerUpload',
          'ecr:PutImage',
          'ecr:UploadLayerPart',
        ],
      }),
    );

    // CodeBuild:StartBuild
    const sdkcallForStartBuild = {
      service: 'CodeBuild',
      action: 'startBuild', // Must with a lowercase letter.
      parameters: {
        projectName: project.projectName,
      },
      physicalResourceId: cr.PhysicalResourceId.of(project.projectArn),
    };

    const assetsUpdateCheck = computeMetaHash(path.join(__dirname, "../assets/sample-app"));

    new cr.AwsCustomResource(this, `startBuild-${assetsUpdateCheck}`, {
      policy: {
        statements: [
          new iam.PolicyStatement({
            resources: [project.projectArn],
            actions: ['codebuild:StartBuild'],
          }),
        ],
      },
      onCreate: sdkcallForStartBuild,
      onUpdate: sdkcallForStartBuild,
    });
  }
}

// 参考: https://stackoverflow.com/questions/68074935/hash-of-folders-in-nodejs
const computeMetaHash = (folder: string): string => {
  const hash = crypto.createHash("sha256");
  const info = fs.readdirSync(folder, { withFileTypes: true });
  // construct a string from the modification date, the filename and the filesize
  for (let item of info) {
    const fullPath = path.join(folder, item.name);
    if (item.isFile()) {
      const statInfo = fs.statSync(fullPath);
      // compute hash string name:size:mtime
      const fileInfo = `${fullPath}:${statInfo.size}:${statInfo.mtimeMs}`;
      hash.update(fileInfo);
    } else if (item.isDirectory()) {
      // recursively walk sub-folders
      computeMetaHash(fullPath);
    }
  }
  return hash.digest().toString();
};

変更箇所はconst assetsUpdateCheck...の部分以降です。assets/sample-appディレクトリ配下のメタデータに応じてidを変更し、メタデータに差分があるときだけidが更新されます。CloudFormationはid変更をスタック更新と誤認してカスタムリソースが再実行されます。これにより初回はIaCでまとめてコンテナイメージのプッシュ/デプロイでき、2回目以降もコンテナの更新を反映させることができます。

所感

若干邪道ですが、今までやりたいと思っていて出来なかったことなので記事にしました。もし参考になる方がいれば幸いです。