mazyu36の日記

某SIer所属のクラウドエンジニアのブログ

AWS CDKのチーム開発でCDK Pipelinesの導入を諦めた話

概要

※本記事はAWS CDK Advent Calendar 2022の11日目の記事になります。

qiita.com

AWS CDKのチーム開発でCDK Pipelinesを導入しようと思ったが、諦めてCI/CDパイプラインを自作した話をまとめる。

背景

CDK開発にCI/CDパイプラインを導入するモチベーションは色々あるが、個人的には「事故を防ぐ」という点が一番大きいと思う。

CDKのデプロイとしては主に「コマンドを手動実行して直接デプロイ」か「CI/CDパイプラインによるデプロイ」のどちらかを取ることが多いと思う。

しかし、前者の手動実行だとデプロイ時に少なからず事故が起きる。

  • 開発者がコマンドを打ち間違えて意図しない環境にdeployしてしまった
  • 開発者が手元のブランチから開発環境に直接deployしてしまった。しかし実はmainが進んでおり開発環境にデプロイされていたため、deploy時に一部のリソースが先祖返りする
  • テストが通っていない(テストを書いていない)のに不十分なものをデプロイをしてしまう
  • 開発者の権限が強くなりがちなので、マネコン操作時にオペミスでリソースを消してしまう ...などなど。

全員ちゃんとテストを書いて、mainにマージ後最新化して、適用先の環境を間違えないようにコマンドを打ってデプロイすれば問題ない...が間違えることもあるじゃない。人間だもの。

開発環境ならまだしも、本番環境で事故った日には目も当てられない

CI/CDパイプラインのメリット

上記を解決するため、CDK開発においてCI/CDパイプラインの導入を検討した。

導入すると特定環境へのデプロイをCI/CDパイプライン経由に限定でき、以下のようなメリットがある。

  • 手動デプロイを抑えられるのでオペミスが防止できる
  • テストステージ、承認ステージを設けることで、不適切なデプロイを防止できる
  • 開発者の権限を弱くできることでマネコン上でのオペミスも防止できる。

CDK Pipelinesの導入を検討した...が断念

CDKにおけるCI/CDパイプラインの最初の選択肢としてCDK Pipelinesがある。

qiita.com

最初はCDK Pipelinesの導入を検討したが以下の3つが理由で諦めた。

  1. テンプレートのサイズが一定以上、かつ日本語(マルチバイト文字)が含まれているとエラーになる
  2. cdk diffの結果を見てからcdk deployしたい
  3. self-mutation がいまいち受け付けない

1. テンプレートのサイズが一定以上、かつ日本語(マルチバイト文字)が含まれているとエラーになる

正直これが断念した最大の理由である。

当初はCDK Pipelinesを利用しようと検証していたところデプロイ時に以下のエラーとなった。

Template format error: YAML not well-formed. (line xxxx, column xx) (Service: AmazonCloudFormation; Status Code: 400; Error Code: ValidationError; Request ID: ....)

CDK Pipelinesで生成されるCloudFormation テンプレートにおいて、サイズが一定以上かつ日本語が含まれているとエラーになるというものである。 2022/12現在未解決である。

github.com

日本語はあまり使わないものの、例えばメール発出をする文言の部分など、一部で使わざるを得ないケースもあった。

エラーにならないようにするため、日本語が含まれる部分のみスタックを切り出すことも考えたが、導入検討時には既に開発がかなり進んでいた段階であったためコスト的に難しかった。 また、スタックを分けると弊害も多いので、個人的には分けたくなかったというのもある。

※スタック分割については以下の記事がよくまとまっていた。 tmokmss.hatenablog.com

2.cdk diffの結果を見てからcdk deployしたい

承認ステージで承認するとき、cdk diffの結果を見て、問題なければ承認という風にしたい。

こうすることで承認者が変更差分を確認してから承認が行える、また変更履歴がCodeBuildのログに残るため後で追跡調査する際に使用できるというメリットがある。

以下のissueの図に書いてあるようなことをまさにしたかった(図はissueから引用)。

github.com

Source
|
Build
|
Diff
|-- optional manual approval
|
Update Pipeline
|
Application Stage

しかし上記のissueがある通りCDK Pipelinesにおいては手動承認前のdiff取得は現状非対応である。

3. self-mutationがいまいち受け付けない

self-mutationはコードが変更されたときにCDK Pipelines自体も更新が行われるというものである。仕組みは理解できるものの、このself-mutationがいまいちしっくりこなかった(感覚的な話になってしまうが)。まあオフにもできるけど。

self-mutationは気をつけないと先祖返りを起こす可能性がある。後はCDK開発を行うときにチームメンバも経験が浅いことが多いので、あまり小難しい仕組みを入れたくなかったというのもある。

代替案の内容

CDK Pipelinesの導入は諦め、代替案として以下2つを検討した。

  1. GitLab CI, Github Actions等外部ツールを活用する。
  2. AWSのCodeシリーズを使用して自前で作る。

外部ツールに精通しているメンバが多ければ1でも良いかと思ったが、検討時はそうではなかったので2とした。CDKを使用するようなメンバーであれば、AWSのCodeシリーズなら少なからず馴染みがあるだろうという判断。

全体像

前置きが長くなったが今回検討した今回構築した仕組みは以下である。

やっていることは「手動デプロイする時のコマンド実行を、CodeBuild上で実行させ自動化しているだけ」というシンプル構成である。 あまり複雑なことをやっても今後維持が難しくなると考えたので、小難しいことは一切排除した。

その他ポイントとしては以下3点である。

  • 単一リポジトリで複数環境(dev, stg, prdなど)のパイプラインを制御する。コード変更を検知して各環境に対するパラメータを注入してデプロイを行う。ブランチを環境ごとに分けるべきか、という議論もあるがそれはまた別の機会で。
  • パイプラインではCodeBuildを使用して、テスト実行(+cdk diff)とデプロイのステージを用意している。
  • またテスト実行とデプロイの間に手動承認のステージを挟んで、意図しないデプロイによる事故防止としている。

具体的な実装内容

github.com

プロジェクト構成

パイプラインのAppとCI/CD対象のAppのプロジェクトを、別とするか同一とするか悩んだが、分けるほどでもないと考え同一にすることとした。具体的には以下の構成である。

.
├── README.md
├── bin
│   ├── app.ts
│   ├── config
│   └── pipeline.ts  # pipeline構築用のapp
├── cdk.context.json
├── cdk.json
├── jest.config.js
├── lib
├── package-lock.json
├── package.json
├── pipeline # pipeline関連のstack定義
│   ├── config
│   └── pipelineStack.ts # pipeline用のStack
├── test
└── tsconfig.json

ポイントとしては以下二点である。

  1. /bin配下で、パイプラインのtsを作り追加し、Appを分離。これにより、以下のようにcdk deployでAppを指定した際のみ、パイプライン自体のAppがデプロイされる(App未指定の場合は、 CI/CD対象のAppとなる)。
  2. パイプライン関係のスタック定義は/pipeline配下に格納(libとは分離)。

※Appを指定してcdk deployを行うときは以下のようにする。

cdk deploy --app "npx ts-node --prefer-ts-exts bin/pipeline.ts" -c env=dev

上記によりチーム開発の流れとしては以下となる。

  • どこかのタイミングで管理者が各環境のパイプラインのApp(/bin/pipeline.ts)を手動でデプロイしておく(Appを指定して実行)
  • 各開発者はそれぞれ開発を進める。この時はCI/CDパイプラインの実行対象ではない、別の検証環境にデプロイなどをしながら実装を進める。
  • 開発が進み各環境にデプロイしたいタイミングになったら、main等にマージする。そうするとCI/CD対象環境のパイプラインが自動的に動作する。

なおパイプラインのApp含めプロジェクトを1つにしたことで、パイプラインのAppのコードのみを変更した場合も、CI/CDが動作してしまう(空振りのCI/CDが発動してしまう)。 ただ、頻度は少ないと考え許容することにした。

/bin/pipeline.ts

パイプラインのAppを定義するクラス。デプロイ時の対象環境のみcontextから取得し、設定値を取得した上でスタックに引回す。

const app = new cdk.App();

// デプロイ先の環境はcontextから取得する
const envType = app.node.tryGetContext('env');

// contextで`env`が指定されていない場合はエラーにする
if (envType === undefined)
  throw new Error(`Please specify environment with context option. ex) cdk deploy -c env=dev`);

// contextを元に環境設定(デプロイ先のアカウントID、リージョン)を取得する
const envConfig: EnvConfig = getEnvConfig(envType)

// スタックを作成
new CdkCodePipelineStack(app, 'CDKPipelinesStack', {
  stackName: `${envType}-CDK-CodePipeline-Stack`,  // Stack名に環境名を含め重複防止
  env: envConfig,  // アカウントID、リージョンを設定
  terminationProtection: true,  // Stackの削除保護を有効化
  envType: envType  // contextをStackに引き継ぐ
})

// タグに環境名を付与
const envTagName = 'Environment';
cdk.Tags.of(app).add(envTagName, envType);

環境ごとの設定値はconfigファイル(/bin/config/envConfig.ts)において、contextのenvを渡すと対応するものを取得できる関数を実装している。

なお手動実行時にミスなどで意図しないcontext値を指定した場合は、エラーとなる分岐を用意することで事故を防止している。

    // 環境によりアカウントIDを分岐
    switch (envType) {
        case 'dev':
            account = "xxxxxxxxxxx";
            break
        case 'stg':
            account = "xxxxxxxxxxx";
            break
        case 'prd':
            account = "xxxxxxxxxxx";
            break;
        case 'infraA':
            account = "xxxxxxxxxxx";
            break
        // contextで指定した値に対応する環境定義が存在しない場合はエラー
        default:
            throw new Error(
                `Env config in "${envType}" environment are not exist.`
            )
    }

/pipeline/pipelineStack.ts

パイプラインの詳細を定義する箇所。いくつかポイントを記載する。

CodeCommitリポジトリ

CDKのコードを格納するリポジトリはライフサイクルを分離したかったため、手動で作成する形とした。

    //--------------CodeCommit----------------
    // TODO CodeCommitリポジトリは事前に手動で作成しておく
    const cdkRepository = codecommit.Repository.fromRepositoryName(this, 'CDKRepository', 'cdk-pipeline-test')

CodeBuildの権限

CodeBuildでテスト実行やデプロイを行うための権限を付与したIAMロールを作成。ここではAdministratorAccessを付与する。

特にデプロイを行う際は強い権限が必要になるが、CodeBuildに付与しデプロイを実行させることで、開発者自身の権限を弱くすることができる。

    //--------------CodeBuild用のIAMロール作成----------------
    // AdministratorAccessを付与
    const codeBuildRole = new iam.Role(this, 'CDKPipelinesCodeBuildDeployRole', {
      assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'),
      managedPolicies: [
        {
          managedPolicyArn: 'arn:aws:iam::aws:policy/AdministratorAccess',
        },
      ],
    });

CodeBuild(テスト)

CodeBuildの一段目として、テスト実行とdiff取得を行うCodeBuildプロジェクトを生成していく。

BuildSpec自体は別ファイルに定義した関数に環境種別(context)を渡すことで取得し、その上でfromObjectで渡している。

    //--------------CodeBuild(テスト)----------------
    const cdkBuildSpecTestConfig = getCdkCodeBuildSpecTestConfig(props.envType)

    const cdkTestProject = new codebuild.PipelineProject(this, 'CDKTestProject', {
      projectName: `${props.envType}-CDK-Test-Project`,
      buildSpec: codebuild.BuildSpec.fromObject(cdkBuildSpecTestConfig),
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
        privileged: true
      },
      cache: codebuild.Cache.local(codebuild.LocalCacheMode.DOCKER_LAYER, codebuild.LocalCacheMode.CUSTOM),
      role: codeBuildRole
    });

BuildSpecを取得する関数は/pipeline/config/pipelineConfig.tsに定義している。

内容としては以下。

export function getCdkCodeBuildSpecTestConfig(envType: string) {

  const buildSpecConfig = {
    version: 0.2,
    env: {
      variables: {
        'ENV_TYPE': envType,
      }
    },
    phases: {
      install: {
        commands: [
          "n stable",
          "node -v",
          "npm update npm"
        ]
      },
      build: {
        commands: [
          "echo \"node: $(node --version)\" ",
          "echo \"npm: $(npm --version)\" ",
          "npm ci",
          "npm audit",
          "npm run build",
          "npm run test",
        ]
      },
      post_build: {
        commands: [
          "npx cdk ls -c env=${ENV_TYPE}",
          "npx cdk diff --all -c env=${ENV_TYPE}",
        ]
      }
    }
  }
  return buildSpecConfig;

}

引数で与えたenvTypeをBuildSpecの環境変数に設定する形とすることで、CodeBuild上で環境に応じたcontextを指定してcdkコマンドを実行している。cdkコマンドでAppを指定しないことで、パイプラインではなくCI/CD対象のAppに対して実行される。

CDKだとfromObjectを使用したBuildSpecの環境ごとの切り替えが便利なので結構重宝している(ECSのパイプライン等でもよく使う)。

実行しているコマンド自体はCDKのテスト実行後にdiffを取得しているのみである。

手動承認

テストが通り、diffまで取得した段階で手動承認のステージに移る。 ここではメール通知も行うようSNSトピック、サブスクリプションも作成してみた。

    //--------------Approval----------------
    //Stageを定義
    const cdkApprovalStage = cdkCodePipeline.addStage({
      stageName: 'Approval'
    });

    //トピックとサブスクリプションを作成
    const cdkSnsTopic = new sns.Topic(this, "CdkSnsTopic", {
      displayName: `${props.envType}-cdk-sns-topic`,
      topicName: `${props.envType}-cdk-sns-topic`,
    });
    new sns.Subscription(this, 'CdkSnsSubscription', {
      topic: cdkSnsTopic,
      endpoint: cdkCodePipelineConfig.notifyEmail,
      protocol: sns.SubscriptionProtocol.EMAIL,
    });

    //Actionを定義
    const cdkApprovalAction = new codepipeline_actions.ManualApprovalAction({
      actionName: 'ApprovalAction',
      notificationTopic: cdkSnsTopic
    });

    // StageにActionを追加
    cdkApprovalStage.addAction(cdkApprovalAction)

CodeBuild(デプロイ)

手動承認を行なった後はデプロイのステージに移る。CodeBuild上でcdk deployを実行する形となる。

CodeBuild(テスト)と同様にビルドスペックは設定ファイルから取得する形である。

    //--------------CodeBuild(デプロイ)----------------
    const cdkCodeBuildSpecDeployConfig = getCdkCodeBuildSpecDeployConfig(props.envType)

    const cdkDeployProject = new codebuild.PipelineProject(this, 'CDKDeployProject', {
      projectName: `${props.envType}-CDK-Deploy-Project`,
      buildSpec: codebuild.BuildSpec.fromObject(cdkCodeBuildSpecDeployConfig),
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
        privileged: true
      },
      cache: codebuild.Cache.local(codebuild.LocalCacheMode.DOCKER_LAYER, codebuild.LocalCacheMode.CUSTOM),
      role: codeBuildRole
    });

BuildSpecを取得する関数についても同様に/pipeline/config/pipelineConfig.tsに定義している。

// 対応する環境のBuildSpec(cdk deploy)を取得する関数
export function getCdkCodeBuildSpecDeployConfig(envType: string) {

  const buildSpecConfig = {
    version: 0.2,
    env: {
      variables: {
        'ENV_TYPE': envType,
      }
    },
    phases: {
      install: {
        commands: [
          "n stable",
          "node -v",
          "npm update npm"
        ]
      },
      build: {
        commands: [
          "echo \"node: $(node --version)\" ",
          "echo \"npm: $(npm --version)\" ",
          "npm ci",
        ]
      },
      post_build: {
        commands: [
          "npx cdk deploy --all  -c env=${ENV_TYPE} --require-approval never"
        ]
      }
    }
  }
  return buildSpecConfig;

}

こちらもenvTypeで指定した環境に対してcdk deployを実行するBuildSpecを生成するのみである。cdk deployにおいて--require-approval neverをつけないと、確認ダイアログが出てデプロイが止まってしまうケースがあるので注意。

※CodeBuild(テスト)とCodeBuild(デプロイ)でそれぞれnpm ciしたり等いまいちな点があるが、一旦はシンプルさ重視で実装。

実装結果

これによりやりたかった以下のパイプラインが構築できた。

Source
|
Build
|
Diff
|-- optional manual approval
|
Update Pipeline
|
Application Stage

実際にコードをpushすると、手動承認前のCodeBuildにおいてcdk diffの結果が見れるようになっている。

終わりに

一応できたとはいえ、やはり力づく感が否めないので、CDK Pipelinesの不具合等が解消されたら将来的にはそちらを使いたい。