5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS CDKAdvent Calendar 2024

Day 20

AWS CDKでCI/CDパイプラインのスクリプトを効率化!2年間の経験から学んだ7つのプラクティス

Posted at

はじめに

AWS CDK で CI/CD パイプラインを構築する際に、スクリプトをどのように管理するか悩んだことはありませんか?私は AWS CDK で複数のパイプラインを構築・運用しており、これまでに開発現場でいくつかの悩みがありました。例えば、スクリプトの更新方法や条件分岐の扱いなどです。

本記事では CDK における CI/CD パイプラインのスクリプトについて、効率性とメンテナンス性を高めるためのプラクティスを 7 つご紹介します。

想定読者

  • AWS CDK を使って CI/CD パイプラインを構築しているエンジニア
  • CI/CD パイプラインでスクリプトの構築・管理方法に課題を感じているエンジニア
  • CDK Pipelines の効率化、メンテナンス性向上に興味のあるエンジニア

用語の定義

この記事では、用語を以下の通り定義します。

  • CDK で記述する CI/CD パイプラインのコマンド群を「スクリプト」と表記
  • xx.sh 形式のファイルについては「シェルスクリプト」と表記
  • AWS CDK は、「CDK」と省略表記

実行環境

# ライブラリ バージョン
1 aws-cdk 2.171.1
2 typescript 5.7.2
3 jest 29.7.0

7 つのプラクティス

  1. CDK Pipelines でスクリプトを自動更新する
  2. CDK Pipelines ではビルドステージ後にスクリプトが更新される点に注意する
  3. スクリプトは CDK のプログラミング言語で記述する
  4. 単純な条件分岐は CDK で記述する
  5. スナップショットテストで安全にリファクタリングする
  6. CDK Pipelines のスナップショットテストでアセットのハッシュ値を置換する
  7. CI/CD パイプライン起動前にスナップショットテストを自動実行する

1. CDK Pipelines でスクリプトを自動更新する

AWS CDK には、CDK Pipelines という CI/CD パイプラインを構築するためのライブラリがあります。CDK Pipelines は、CDK で CI/CD パイプラインを構築する際に便利な機能が多数備わっています。代表的な機能が Self Mutation です。CI/CD パイプラインの定義を変更した場合、パイプライン自身が自動的に更新されます。最初にパイプラインスタックのデプロイをするだけで、その後のビルドスクリプトも自動更新されるため、便利です。

Self Mutation の仕組み

以下図の Update Pipeline が Self Mutation を実行するステージです。Build ステージの後に Self Mutation が実行され、パイプライン自身が自動更新されます。

CDK Pipelines Self Mutation

上記の通り、Self Mutation ではパイプラインスタックのデプロイが実行されますが、CDK ユーザーが意識する必要はありません。なお、CDK Pipelines の活用事例は以下で詳しく紹介しています。

通常の CI/CD パイプラインでは、スクリプトの更新が発生する度にパイプライン自体も更新する必要があります。Git リポジトリへの変更をトリガーに自動デプロイを組んでいるにも関わらず、パイプラインを手動更新しなければならないのは手間です。CDK プロジェクトでの CI/CD では、是非 CDK Pipelines の活用を検討してみてください。

2. CDK Pipelines ではビルドステージ後にスクリプトが更新される点に注意する

CDK Pipelines の Self Mutation は非常に便利ですが、更新タイミングに注意する必要があります。先ほどの図で示した通り、Self Mutation はビルドステージの後に実行されます。そのため、CDK Pipelines のスクリプトを修正したとしても、一度ビルド工程が成功するまでは反映されません。一方、ソースコードはビルドに必要なパッケージがインストールされている前提となっているため、ビルドが失敗します。

具体例として、静的サイトジェネレーターの MkDocs で本事象を説明します。例えば、MkDocs に拡張機能を追加する場合、mkdocs.yaml の更新が必要です。

mkdocs.yaml
markdown_extensions:
+ - mdx_truly_sane_lists: # 追加した拡張機能
+     nested_indent: 2
# 以下省略

次に、パイプラインのビルドステージにインストールコマンドを追加します。

lib/pipelineStack.ts
// CDK Pipelines の定義
const cdkPipeline = new CodePipeline(this, "Default", {
  codePipeline: codePipeline,
  synth: new CodeBuildStep("BuildStep", {
    input: repo,
    installCommands: [
      "pip install mkdocs",
+     "pip install mdx_truly_sane_lists", // 拡張機能をインストール
      // ...
      "npm ci",
    ],
    commands: [
      "mkdocs build", // MkDocs のビルド
      // ...
    ],
    // ...
  }),
  selfMutation: true, // Self Mutation を有効化
});

上記の変更をコミットして、Git リポジトリにマージします。すると、以下の流れでエラーが発生します。

  1. Git リポジトリのマージをトリガーにパイプラインが起動
  2. ソースコードを取得
  3. インストールコマンドを実行
    (この時点では pip install mdx_truly_sane_lists は CI/CD パイプラインに反映されておらず、実行されない)
  4. MkDocs のビルドを実行
    mkdocs.yaml に定義された mdx_truly_sane_lists がインストールされていないため、ビルドが失敗
  5. 以降のビルドや Self Mutation は実行されず、パイプラインがエラー終了

シーケンス図で表すと、以下のイメージです。

Self Mutation がビルドステージ後に実行されるため、ビルド時点で必要なパッケージをインストールしていないことが原因です。対策としては、以下があります。

① 先にインストールコマンドのみ修正して、Git リポジトリにマージする
② CDK Pipelines のスタックを手動更新する (cdk deploy)

社内ルールなどにより、パイプライン自体を更新しにくい場合は ① を採用するのがよいでしょう。とはいえ、CDK Pipelines に合わせてコミットとマージを工夫するのは、開発者体験が悪いと感じます。やや手間ではありますが、私のチームでは ② を採用しています。② も作りこめば自動化できると思いますが、事象の発生頻度が低いため手動更新で問題ないと判断しました。

3. スクリプトは CDK のプログラミング言語で記述する

パイプラインから .sh などのシェルスクリプトを実行できますが、スクリプトも CDK のプログラミング言語を使って記述することをおすすめします。CDK のコードでスクリプトを記述することにより、スクリプトの更新や検証が容易になります。スモールスタートでシンプルなスクリプトを構築する際、特におすすめです。それぞれの記述方法について実際のコード例を提示しながら、解説します。

まずは、パイプラインにおけるシェルスクリプトの使用例をみてみましょう。

シェルスクリプトを使用する例

以下 2 つのファイルが Git リポジトリ内に存在するとします。

ディレクトリ構成
lib/
├── shell/
│   └── sample.sh     <!-- シェルスクリプト -->
│── pipelineStack.ts  <!-- パイプラインの定義 -->
└──  <!-- その他のファイル -->

上記のソースコード例は以下の通りです。

lib/pipelineStack.ts
new codebuild.PipelineProject(this, "SampleProject", {
  buildSpec: codebuild.BuildSpec.fromObject({
    phases: {
      build: {
        commands: [
          "set -e",  // エラーが発生した場合にスクリプトを停止
          "chmod +x ./shell/sample.sh",  // スクリプトに実行権限を付与
          "./shell/sample.sh",  // スクリプトを実行
        ],
      },
    },
  }),
});
lib/shell/sample.sh
#!/bin/bash
set -e

echo "スクリプトが実行されました"
echo "現在のディレクトリは $(pwd) です"

シェルスクリプトを、Git リポジトリに置くことで簡単に CI/CD パイプラインへ組み込むことができます。また、IDE が .sh 形式に最適化されたシンタックスハイライトを提供するため、スクリプトの視認性がよい利点もあります。ネストが深くなる複雑なスクリプトを実行する場合は、シェルスクリプトの方が管理しやすそうです。しかし、パイプラインでシェルスクリプトを用いると、以下の問題があります。

スクリプトを更新して CI/CD パイプラインを再実行したい場合、ソースステージからやり直す必要がある。
CI/CD パイプラインの実行が失敗した際に、少しだけスクリプトを修正して該当ステージを再実行したいケースがあります。シェルスクリプトを Git 管理していると、ソースステージからシェルスクリプトを取得し直す必要があります。シェルスクリプトを S3 バケットなどで管理していても同様です。シェルスクリプトのアップロードなど、アセットの更新が必要となります。

Jest のスナップショットテストをスクリプトに活かせない。
スナップショットテストを活用することで、意図しない変更が入っていないか簡単に差分確認できます。また、安心してリファクタリングできます。CDK プロジェクトであれば、デフォルトで Jest のスナップショットテストを利用できます。しかし、Jest のスナップショットテストでは、シェルスクリプトの中身を確認しません。シェルスクリプトの確認については、個別の仕組みを用意する必要があります1

CDK で直接スクリプトを記述すれば、これらの問題は解消されます。コード例は以下の通りです。

CDK でスクリプトを直接記述する例

lib/pipelineStack.ts
import { SampleScript } from "./shell/sample";
// ...
new codebuild.PipelineProject(this, "SampleProject", {
  buildSpec: codebuild.BuildSpec.fromObject({
    phases: {
      build: {
        commands: [
          "set -e",  // エラーが発生した場合にスクリプトを停止
          SampleScript,  // TypeScript の定数としてスクリプトを実行
          /* または以下のように直接記述 */
          // `echo "スクリプトが実行されました"`,
          // `echo "現在のディレクトリは $(pwd) です"`,
        ],
      },
    },
  }),
});

sample.sh は利用せず、代わりに CDK のプログラミング言語で記述します。

lib/shell/sample.ts
// プログラミング言語の変数にスクリプトを記述
export const SampleScript = 
`echo "スクリプトが実行されました"
echo "現在のディレクトリは $(pwd) です"`;

スクリプトを CDK で直接記述することにより、CI/CD パイプラインスタックを cdk deploy するだけでスクリプトも更新できます。ソースステージでシェルスクリプトの再取得をする必要がなく、スクリプト更新前に失敗したステージの再実行も容易です。開発中の一時的な修正時で buildspec.yaml に慣れている人であれば、マネジメントコンソールから直接 YAML のスクリプトを修正してもよいでしょう。

また、スクリプトのリファクタリングも容易になります。詳細は後のセクションで説明します。繰り返しになりますが、ネストが深くなるような複雑なスクリプトを実行する場合は、シェルスクリプトでの管理も検討してください。

4. 単純な条件分岐は CDK で記述する

追加処理など、スクリプトの単純な条件分岐は CDK で記述するのがシンプルです。

背景として、金融機関では本番・開発環境の分離が厳格化されています。パイプライン自体も本番と開発環境で分離することが一般的です2。一方、デプロイ環境ごとに異なるスクリプトを実行したい場合があります。このような場合、CDK で条件分岐を記述することで、スクリプトがシンプルで静的になります

例えば、commands へ渡すスクリプトの配列を以下のように変えるという方法があります。

(例)スクリプト配列の追加
const preBuildCommand = (): string[] => {
  // 共通のコマンドを定義
  const commands: string[] = [
    "echo 'Initial processing'"
    // ...
  ];
  // deployEnvはデプロイ環境(例: 'prod' や 'dev')を表す文字列変数
  if (deployEnv === "prod") {
    // 本番環境の場合、追加処理を実行
    commands.push("echo 'Additional processing for production'");
  }

  return commands;
};

new codebuild.PipelineProject(this, "SampleProject", {
  buildSpec: codebuild.BuildSpec.fromObject({
    phases: {
      pre_build: {
        commands: preBuildCommand(), // スクリプトの参照
      },
    },
  }),
});

一方、以下のようにステージを追加する方法もあります。マネジメントコンソールで明確に CI/CD パイプラインのステージが分かれて表示されるため、視認性がよいです。また、ステージが分かれていると、ステージ単位でスクリプトの再実行できます。スクリプトの内容によっては、このアプローチもご検討ください。

(例)ステージ追加
// 本番環境の場合、ステージを追加
if (deployEnv === "prod") {
  const sampleProject = new codebuild.PipelineProject(this, "SampleProject", {
    // ここに追加のスクリプトなどを記述
  });
  const sampleAction = new action.CodeBuildAction({
    actionName: "",
    project: sampleProject, 
    input: sourceArtifact,
  });
  // パイプラインにステージを追加
  pipeline.addStage({
    stageName: "LaunchProduct",
    actions: [ sampleAction ],
  });
}

その他、ステージにアクションを追加する方法(addPost など)もあります。詳しくは AWS CDK Reference を参照してください。

CI/CD パイプラインのステージとアクション

本プラクティスにより、パイプラインのスクリプトが静的になります。そのため、エラー時にスクリプトの内容を確認しやすくローカルでコマンドを試す際にも便利です。ただし、処理が複雑な場合は、スクリプト側で条件分岐することも検討してください。

5. スナップショットテストで安全にリファクタリングする

スナップショットテストは、リファクタリングを安全に進める上で頼もしいツールです。スクリプトのリファクタリングにも活用でき、スクリプトが膨らんでくると特に有用です。

なぜスクリプトのリファクタリングが必要か、簡単に紹介します。以下は「プログラマが知るべき97のこと」の一節で、綺麗なスクリプトの重要さが紹介されています。

ビルドは開発プロセスの中でも特に重要な部分と言えるでしょう。ビルドプロセス次第で、コードをシンプルにし、コーディング作業にかかる労力を減らすこともできるのです。

ビルドスクリプトは、不適切な書き方をしてしまうと保守が困難になる上、後で改善することも容易ではなくなります。そういうビルドスクリプトをどうすれば良いものに変えられるかを、時間を多少かけてでも是非学ぶべきでしょう。

ビルドをおろそかにしない | プログラマが知るべき97のこと

この引用が示すように、ビルドスクリプトの品質は開発プロセス全体に大きな影響を与えます。スクリプトをリファクタリングすることで保守性が向上し、将来的な変更にも柔軟に対応できるようになります。

そこで、スクリプトのリファクタリングにスナップショットテストを活用できます。スナップショットテストではスクリプトの内容が意図せず変更されていないことを保証し、安心してリファクタリングすることができます。以下の通り、簡単に実装できるので是非お試しください。

test/pipelineStack.test.ts
import { Template } from "aws-cdk-lib/assertions";
import * as cdk from "aws-cdk-lib";
import { PipelineStack } from "../lib/pipelineStack";

describe("CI/CDパイプライン", () => {
  test("開発環境のスナップショットテスト", () => {
    const stack = new PipelineStack(new cdk.App(), "DevPipeline",{
      branch: "develop",
      targetAccountId: "012345678901",
      connectionArn:
        "arn:aws:codestar-connections:ap-northeast-1:x:connection/x",
    });
    const template = Template.fromStack(stack);
    expect(template.toJSON()).toMatchSnapshot();
  });
  test.todo("本番環境のスナップショットテスト");
});

以下のコマンドでスナップショットテストを実行できます。

テスト実行のコマンド
$ npm test

> xx-repository@0.1.0 test
> jest test/pipelineStack.test.ts

 PASS  test/pipelineStack.test.ts
  CI/CDパイプライン
    ✓ 開発環境のスナップショットテスト (374 ms)
    ✎ todo 本番環境のスナップショットテスト

Test Suites: 1 passed, 1 total
Tests:       1 todo, 1 passed, 2 total
Snapshots:   1 passed, 1 total
Time:        0.592 s, estimated 1 s
Ran all test suites matching /test\/pipelineStack.test.ts/i.

スナップショットの出力イメージは以下の通りです。

test/__snapshots__/pipelineStack.test.ts.snap(抜粋)
  "phases": {
    "build": {
      "commands": [
        "set -e",
        "echo \\"スクリプトが実行されました\\"",
        "echo \\"現在のディレクトリは $(pwd) です\\""
      ]
    }
  }

なお、プログラミング言語でリファクタリングを進めると、分岐処理や共通化で処理順を追いづらくなることがあります3。そのよう場合でもスナップショットを参照することで、上記のように CDK で出力されたスクリプトを確認できます。ただし、複雑なスクリプトの場合は、シェルスクリプトで実行結果の期待値をチェックする方が効果的です。

6. CDK Pipelines のスナップショットテストでアセットのハッシュ値を置換する

アセットのハッシュ値に関する問題は CDK 全般で発生しますが、CDK Pipelines のスナップショットテストでは注意が必要です。

アセットは、AWS CDK ライブラリやアプリケーションにバンドルできるローカルファイル、ディレクトリ、または Docker イメージです。たとえば、アセットは、AWS Lambda 関数のハンドラーコードを含むディレクトリである場合があります。

出典: アセットと AWS CDK - AWS Cloud Development Kit (AWS CDK) v2

CDK Pipelines は アセットのアップロードも自動実行します。CDK Pipelines の CloudFormation テンプレートには、ソース修正の度に変化するアセットのハッシュ値が含まれます。以下は、スナップショットテストの出力例です。

スナップショットの出力例(抜粋)
  "phases": {
    "install": {
      "commands": [
        "npm install -g cdk-assets@latest"
      ]
    },
    "build": {
      "commands": [
        "cdk-assets --path \\"assembly-DevPipeline-DeployCdkStage/xxxxx.assets.json\\" --verbose publish \\"a1b2c3d4e5f678901234567890abcdef1234567890abcdef1234567890abcdef:123456789012-us-east-1\\"" // アセットのハッシュ値
      ]
    }
  }

ソースコード修正後、アセットのハッシュ値によりスナップショットテストが毎回失敗する可能性があります。重要でない部分の差分はノイズなので、スナップショットテストで出力しないようにしたいです。そこで、以下記事を参考にさせていただきました。

上記のサンプルを参考にして、CDK Pipelines 用のハッシュ値置換ロジックを作成します。

test/snapshotSerializer.ts
module.exports = {
  test: (val: unknown) => typeof val === "string",
  print: (val: unknown) => {
    // S3 アセットなど、CDK でみられる一般的なハッシュ値を置換
    let newVal = (val as string).replace(
      /([A-Fa-f0-9]{64})(\.zip)/,
      "[HASH REMOVED]",
    );

    // CDK Pipelines の出力形式に合わせて、アセットのハッシュ値を置換
    newVal = newVal.replace(
      /(--verbose publish \\")([0-9a-f]{64})(:[^"]+\\")/g,
      "$1[HASH REMOVED]$3",
    );

    return `"${newVal}"`;
  },
};

上記を実行できるようにするため、jest.config.cjs に追記します。

jest.config.cjs
module.exports = {
  testEnvironment: "node",
  roots: ["<rootDir>/test"],
  testMatch: ["**/*.test.ts"],
  transform: {
    "^.+\\.tsx?$": "jest-esbuild",
  },
+ snapshotSerializers: ["<rootDir>/test/snapshotSerializer.ts"],
  // ...
};

この状態でスナップショットテスト(npm test)を実行すると、スナップショットのアセット部分が [HASH REMOVED] に置換されます。

スナップショットの出力例(抜粋)
  "phases": {
    "install": {
      "commands": [
        "npm install -g cdk-assets@latest"
      ]
    },
    "build": {
      "commands": [
        "cdk-assets --path \\"assembly-DevPipeline-DeployCdkStage/xxxxx.assets.json\\" --verbose publish \\"[HASH REMOVED]:123456789012-us-east-1\\"" // アセットのハッシュ値が置換されている
      ]
    }
  }

上記により、スナップショットテストでハッシュ値のノイズがなくなり、スクリプトやパイプライン設定の変更を追いやすくなります。通常の CI/CD パイプラインでもアセットを扱う場合は、同様の対応が必要になると思いますのでご留意ください。

7. CI/CD パイプライン起動前にスナップショットテストを自動実行する

パイプラインのビルドステージで、単体テストを実行することは一般的です。CDK Immersion Day Workshop のサンプルコードでも、以下のようにビルドステージでテストコマンドが明示されています。

パイプラインのサンプルコード
const pipeline = new CodePipeline(this, "Pipeline", {
  pipelineName: "WorkshopPipeline",
  synth: new CodeBuildStep("SynthStep", {
    input: CodePipelineSource.codeCommit(repo, "main"),
    commands: ["npm ci", "npm run build", "npx cdk synth"], // テストコマンドが明示されている
  }),
});

出典:CDK Immersion Day Workshop

しかし、スナップショットの更新漏れがあると パイプラインの実行が失敗します。パイプラインの無駄な起動を減らすためにも、プルリクエスト作成時やマージ前にスナップショットテストを自動実行しましょう。

私のチームでは GitHub を採用しているため、GitHub Actions を利用してスナップショットテストを自動実行しています。以下は、GitHub Actions の設定例です。

.github/workflows/pull_request_validation.yml
name: Pull Request Validation

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install dependencies
        run: npm ci
        # 中略
      - name: AWS CDK tests
        run: npm test # スナップショットテストを実行

以下は、GitHub のプルリクエストで自動テストが実行される例です。

Pull Request Validation

Pull Request Validation / test (pull_request) では、一般的な静的解析に加えてスナップショットテストも自動実行されます。

おわりに

CDK で CI/CD パイプラインのスクリプトを効率化する 7 つのプラクティスはいかがでしたでしょうか?これらのプラクティスは、日々の開発現場で直面する課題を解決するヒントになるはずです。ぜひ、ご自身のプロジェクトに適用し、効果を実感してみてください。また、記事の内容に関するご意見やご質問、皆さんのスクリプト管理 Tips をコメントで共有いただけると嬉しいです。

本記事がどなたかの参考になれば幸いです。

参考資料

  1. スクリプトの実行結果をテストする方がスナップショットテストよりも効果的だと思います。しかし、CI/CD パイプラインのスクリプトでは API コールや環境変数の使用が多くなりがちで、テストの実装コストがかかります。テストの手間を考慮し、私のチームでは CI/CD パイプラインを手動実行して直接確認することが多いです。

  2. パイプラインの環境分離については金融サービス向けに理想のCI/CDを追い求めたお話で詳しく紹介されています。非常に分かりやすく、金融業界に従事する方は是非ご覧ください。

  3. リファクタリングでは、可読性・保守性のバランスが重要です。過度なリファクタリングを推奨しているわけではないので、ご注意ください。

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?