AWS CDK におけるバリデーションの使い分け方を学ぶ

2024-06-04
コミュニティ通信

Author : 後藤 健太 (AWS DevTools Hero)

皆さん、こんにちは。2024 年 3 月に AWS DevTools Hero に選出いただきました、後藤と申します。普段は、AWS Cloud Development Kit (AWS CDK)  へのコントリビュート活動などを行っています。

AWS CDK において、AWS CDK でアプリケーション・インフラ環境を構築する(AWS CDK を使う) 方も、AWS CDK にコントリビュートする(AWS CDK を作る) 方も、どちらにもユーザーから受け取る値のバリデーションは欠かせません。またそのバリデーション方法は、どちらの立場においても基本的には同じ方法で実装することができます。

今回はそんな、AWS CDK を「使う側」と「作る側」、どちらにも共通するバリデーションの使い分け方についてご紹介します。

このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »

毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。 


1. バリデーションとは

バリデーションとは、データの正当性を検証する処理のことです。例えば、アプリケーションとして受け入れる値の範囲や形式をチェックし、その範囲や形式に合致しない値が入力された場合にエラーを返すなどの処理を行います。これにより、想定しない挙動などを引き起こすよりも前にエラーを発生させることで不正な値によるアプリケーションへの影響を局所化することができ、より信頼性の高いアプリケーションを作ることができます。

特に、AWS CDK においては AWS CloudFormation が内部で動作しているため、不正な値のバリデーションを行わないままデプロイを行うと、CloudFormation のスタックの作成や更新時にエラーが発生する可能性があります。AWS CDK のコードの中でバリデーションを行うことで、CloudFormation によるデプロイが実行される前にエラーによってアプリケーションを終了させることができるため、ユーザーはデプロイ時のエラーを未然に防ぐことができます。つまり、より安全に、また迅速にエラーの発見と修正を行えるようになるのです。


2. AWS CDK のアプリケーションライフサイクル

まず AWS CDK におけるバリデーション方法をご説明する前に、それぞれのバリデーション方法は、AWS CDK のアプリケーションライフサイクル (以下、ライフサイクルと表記) によって実行されるタイミングが異なります。AWS CDK のライフサイクルには、以下の 4 つのフェーズがあります。

  • Construct フェーズ
  • Prepare フェーズ
  • Validate フェーズ
  • Synthesize フェーズ

各フェーズの詳細は、AWS CDK の 開発者ガイド をご覧下さい。基本的には、ユーザーの書いた CDK コードのほとんどが一番最初の Construct フェーズで実行されます。


3. AWS CDK におけるバリデーション方法

今回ご紹介する AWS CDK におけるバリデーション方法は、以下の 4 つになります。

  • 即時スロー
  • Aspects
  • addValidation
  • Annotations

3-1. 即時スロー

こちらは AWS CDK を使う上で、一番シンプルなバリデーション方法です。なお、この「即時スロー」という命名は公式のものではなく、私が本記事のために便宜上こちらの名前をつけたものになりますので、ご了承ください。

即時スローは、コンストラクト の初期化時に第 3 引数として渡す props や、コンストラクトの持つメソッドへの引数で受け取った値を if 文などでチェックし、アプリケーションとして受け入れたくない値の場合にエラーを生成する方法になります。

またこちらは、先ほど述べた 4 つのライフサイクルのうち一番早い段階である、コンストラクトの生成処理などが行われる Construct フェーズで実行されます。そのため早期にエラーを検出することでエラーの影響を局所化できるので、基本的にはまずこちらの使用を検討するのが良いでしょう。

具体的には、以下のようにコンストラクトの props (ここでは MyConstructProps) で受け取ったパラメータに対して、アプリケーションの要件に沿って if 文などで値をチェックし、要件を満たさない場合にエラーをスローします。

import { Construct } from 'constructs';

export interface MyConstructProps {
  readonly myFlag: boolean;
  readonly myParam?: number;
}

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

    if (props.myFlag && props.myParam !== undefined) {
      throw new Error('myParam は myFlag が true の場合は指定できません');
    }
  }
}

Token

即時スローで注意する点が一つあります。それは Token です。

Token とは、ライフサイクルの後ろで解決する値を仮の値で格納しておく仕組みです。例えば、デプロイ時に値が決まる CloudFormation の !Ref で表される number 型や string 型の値などを AWS CDK で扱う際に内部で使われます。

つまりバリデーション対象のパラメータが Token である場合、このライフサイクルの段階ではまだ値が不明であるため、バリデーションをすることができません。そのため即時スローでバリデーションを行う際には、そのパラメータが Token かどうかをチェックし、Token の場合はバリデーションをスキップする必要があります。

具体的には、Tokenクラスが持つ isUnresolved メソッドを使って Token かどうかをチェックし、Token でない場合 (false を返す場合) にのみバリデーション条件をチェックするようにします。もしパラメータが Token であった場合、たとえばユーザーが実際に 100 という値を渡したとしても、-1.88815458970875e+289 のような実際の値と関係のない値が格納されています。もしこれを if (param < 0) のような条件分岐にかけてしまうと、想定外のエラーを発生させることになってしまいます。

import { Token } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export interface MyConstructProps {
  readonly myParam: number;
}

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

    // Tokenの場合はToken.isUnresolved(props.myParam)はtrueを返す
    // props.myParamがTokenの場合、-1.88815458970875e+289 のような実際の値と関係のない値が格納されている
    if (!Token.isUnresolved(props.myParam) && props.myParam < 0) {
      throw new Error('myParam は 0 以上である必要があります');
    }
  }
}

例えば、CfnParameter コンストラクトが返す valueAsString などであったり、Amazon S3 のバケットの L2 コンストラクト である Bucket コンストラクトが返す bucketName などは、実際にデプロイする際に値が解決するものになるため、CDK コード内では Token として格納されます。これらのパラメータを props を通してコンストラクトに渡すことはよくあるかと思います。しかし、props のパラメータ にどこから何が渡ってくるかはコンストラクト自身からはわからず、常に Token の可能性を秘めているため注意が必要です。

3-2. Aspects

次は、Aspects という機能を使ってバリデーションします。

Aspects とは、あるスタックやコンストラクトといった、特定のスコープ内の全ての構成に操作を適用する方法です。こちらは、ライフサイクルの 2 番目である Prepare フェーズで実行されます。

Aspects はバリデーション用の機能というわけではないのですが、スタック内の特定の種類のリソースに対して一元的にバリデーションしたいときなどに有効です。たとえば、「スタック内のすべての S3 バケットにバージョニング設定が適用されているか」などのようなバリデーションを行いたい場合に使うことができます。

import { App, Aspects, IAspect, Stack, Tokenization } from 'aws-cdk-lib';
import { CfnBucket } from 'aws-cdk-lib/aws-s3';
import { IConstruct } from 'constructs';

// AspectsはIAspectインターフェースを実装することで実現する
export class BucketVersioningChecker implements IAspect {
  // visitメソッドにバリデーションの内容を記述する
  public visit(node: IConstruct) {
    // Aspectsによって渡されたConstructの内部のリソース全てにvisitメソッドが適用されるので、CfnBucketリソースの場合にのみ実行されるよう条件分岐をする
    if (node instanceof CfnBucket) {
      // バケットのバージョニング設定が無効の場合、エラーを発生させる
      if (
        !node.versioningConfiguration ||
        (!Tokenization.isResolvable(node.versioningConfiguration) && node.versioningConfiguration.status !== 'Enabled')
      ) {
        throw new Error('バージョニングが有効になっていません');
      }
    }
  }
}

const app = new App();
const stack = new Stack(app, 'MyStack');

// MyStack内の全てのリソースに対してBucketVersioningCheckerを適用
Aspects.of(stack).add(new BucketVersioningChecker());

ちなみにこの Aspects は、実際の AWS CDK の L2 コンストラクトでは、Amazon Elastic Container Service (Amazon ECS) の Cluster コンストラクト などで使用されています。なお、こちらの例ではバリデーションとしてではなく、リソースの作成処理の一貫として使用されています。

また、Aspects に関して、AWS 公式ブログの CDK Aspects を利用してベストプラクティスに従ったインフラストラクチャを構築する からも具体的な使用方法が紹介されていますので、ぜひご覧ください。

3-3. addValidation

次は addValidation メソッドを使ってバリデーションをする方法のご紹介です。

addValidation とは、Stack や Construct が内部で変数として持つ Node クラス の持つメソッドで、ライフサイクルの 3 番目である Validate フェーズで実行されます。

addValidation の使用ケースとしては、コンストラクトの初期化時点(コンストラクトを new で生成するとき)だけでなく、コンストラクトのメソッド等が呼ばれてからパラメータの値が変わるようなケースのバリデーションをする、遅延評価として使用できます。

例えば、「props とメソッドのどちらでもセット可能な配列型のパラメータがあり、その配列の個数をチェックする」ような場合などが挙げられます。つまり、コンストラクトの生成時点では props の配列型のパラメータには何も渡さないが、その後にコンストラクトのメソッドを呼び出すことで配列に要素を追加するような場合になります。この場合、即時スローのように constructor 処理内でバリデーションをしてしまうと、メソッド呼び出しがされる前の状態のパラメータに対するチェックになってしまうため、そのようなケースで addValidation が最適です。

addValidation の実装例は以下のようになります。

export interface MyConstructProps {
  readonly myVariables: string[];
}

export class MyConstruct extends Construct {
  public readonly myVariables: string[];

  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);
    this.myVariables = props.myVariables;

    // addValidationには、validateメソッドを持つIValidation interface型のオブジェクトを渡す
    this.node.addValidation({ validate: () => this.validateVariables() });
  }

  // 可読性のため、実際のバリデーションの内容はメソッドを分けて記述している
  private validateVariables(): string[] {
    const errors: string[] = [];
    if (this.myVariables.length > 3) {
      errors.push(`myVariablesの要素は3つまでにしましょう, 現在の要素数: ${this.myVariables.length}`);
    }
    return errors;
  }

  // このメソッドによってコンストラクト生成後に配列に要素を追加できる
  public addVariable(variable: string) {
    this.myVariables.push(variable);
  }
}

addValidation メソッドの引数には、validate(): string[] メソッドを持つ IValidation interface 型のオブジェクトを渡します。validate メソッドに、実際のバリデーション内容を記述します。

interface IValidation {
  validate(): string[];
}

さらに addValidation の特徴として、エラーをスローするのではなく、string[] 型の配列にエラーメッセージを格納して返す点です。これにより複数のエラーを一度に表示することもできます。

private validateVariables(): string[] {
    const errors: string[] = [];
    if (this.myVariables.length > 3) {
      errors.push(`myVariablesの要素は3つまでにしましょう, 現在の要素数: ${this.myVariables.length}`);
    }
    return errors;
  }

このように addValidation を用いることで、以下のように props では一つも配列に要素を渡さず、コンストラクト初期化後にメソッドで要素を追加するケースでも、バリデーションが遅延実行されてエラーにすることができます。

const app = new App();
const stack = new Stack(app, 'MyStack');
const myConstruct = new MyConstruct(stack, 'MyConstruct', { myVariables: [] });

myConstruct.addVariable('variable1');
myConstruct.addVariable('variable2');
myConstruct.addVariable('variable3');
myConstruct.addVariable('variable4');
Error: Validation failed with the following errors:
  [MyStack/MyConstruct] myVariablesの要素は3つまでにしましょう, 現在の要素数: 4

ちなみにこの addValidation は、実際の AWS CDK の L2 コンストラクトでは、AWS CodePipeline の Pipeline コンストラクト などで使用されています。

3-4. Annotations

最後は Annotations を使う方法です。

Annotations は、エラーや警告をコンストラクトにアタッチするという機能です。この章は値のチェック方法というよりもエラーの出力方法にフォーカスしたものになります。またこちらは、コンストラクトで直接呼んだり、Aspects や addValidation などからでも呼び出せるため処理の実行タイミングはそれらのフェーズによるのですが、アタッチされたエラーの発火(出力)はライフサイクルの最後のフェーズである Synthesize フェーズで行われます。

例えば Aspects の中で Annotations によってエラーを発生させた場合、その処理自体は Prepare フェーズで実行されてエラーがコンストラクトにアタッチされるのですが、ユーザーへのエラーの出力処理は Synthesize フェーズで行われます。もしこの後 Validate フェーズでの addValidation によりエラーが検出された場合、Aspects でアタッチされた Annotations のエラーは発火されない、つまり出力されないので注意です。

Annotations は今までの方法とは違い、addWarningV2 メソッドを使用することで、エラーにするのではなく警告を出すことができるという特徴があります。そのため、「エラーにするほどではないが良くはない」という意思表示に有効です。たとえば、非推奨 (deprecated) パラメータが指定された際などによく使われます。

import { Annotations } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export interface MyConstructProps {
  /**
    @deprecated Use `newParam` instead
   */
  readonly oldParam?: string;
  readonly newParam?: string;
  readonly arrayParam: string[];
}

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

    // oldParamが指定された場合、警告を出す
    if (props.oldParam !== undefined) {
      Annotations.of(this).addWarningV2(
        'MyConstruct:oldParam',
        `'oldParam' パラメータは非推奨です。代わりに 'newParam' を使用してください。`,
      );
    }

    // arrayParamが空の場合、エラーを出す
    if (props.arrayParam.length === 0) {
      Annotations.of(this).addError(`'arrayParam'は空ではいけません`);
    }
  }
}

また、addError メソッドを使ってエラーをコンストラクトにアタッチできます。そして、このエラーの挙動が特徴的なのです。

Annotations では、仮に addError メソッドによってエラーがアタッチされた場合、他のバリデーションと同様にエラーが出力されますが、合成 (synthesize) 自体は成功します。合成が成功するということは、エラーが出力された場合でも、クラウドアセンブリは生成されるという特徴があります。クラウドアセンブリとは、CDK コードをもとに生成された manifest.json や AWS CloudFormation のテンプレートファイルなどのことで、CDK プロジェクトの最上階層にある cdk.out ディレクトリ内に格納されます。即時スローや addValidation では、エラー時にクラウドアセンブリは生成しません。

これがどのような挙動の違いに繋がるかというと、複数スタック構成のアプリケーションでの挙動が異なってきます。複数スタック構成のアプリケーションで、1 つのスタックが Annotations でエラーをアタッチされた場合、もちろんエラーがアタッチされたスタックに対しては、cdk synth cdk deploy は失敗します。しかし、他の正常なスタックに関しては、synth や deploy が成功するのです。

例えば、とあるアプリケーションで StackA と StackB という 2 つのスタックを持っているとします。このとき、StackA が Annotations によってエラーをアタッチされた場合、cdk synth StackA はエラーになりますが、cdk synth StackB は成功します。即時スローなどでは 1 スタックにエラーがあると他スタックも全て失敗します。

このような特徴を持つ Annotations ですが、基本的には即時スローなどの方を使っていただいた方が安全です。Annotations でエラーをアタッチするケースとしては、例えば CDK リソースの宣言としては正しいが、context メソッド を通して参照するコンテキスト値の欠落など環境的な要因でエラーが発生する場合などがあります。このような使い分け方は、AWS CDK リポジトリの DESIGN_GUIDELINES でも推奨されています。

またもちろん、エラーにするまでではないが警告を出しておきたい、というような場合にも唯一の手法になるでしょう。

addError メソッドを用いた Annotations の実際の AWS CDK の L2 コンストラクトでの使用例ですが、Amazon Elastic Compute Cloud (Amazon EC2) の Instance コンストラクト などで使用されています。また addWarningV2 メソッドの実際の L2 コンストラクトでの使用例としては、aws-ecr-assets モジュール内の DockerImageAsset コンストラクト などが挙げられます。


4. 補足: addPropertyOverride などによるテンプレートの書き換え

AWS CDK では addPropertyOverride などのようなメソッドで、Synthesize フェーズで生成される CloudFormation テンプレートの中身を直接書き換えることができます。このメソッドを使うことで、例えば S3 バケットのバージョニング設定を強制的に有効にするなどが可能です。

import { CfnBucket } from 'aws-cdk-lib/aws-s3';

declare const cfnBucket: s3.CfnBucket;

cfnBucket.addPropertyOverride('VersioningConfiguration.Status', 'NewStatus');

そこで、こちらのテンプレート書き換え処理なのですが、AWS CDK のライフサイクルの一番最後である Synthesize フェーズで実行されます。つまり、先述の各バリデーションが実行されるタイミングではまだテンプレートの書き換えは反映されていない状態になります。つまり、これらの書き換えられた内容は、どの方法でも適切なバリデーションをすることができないということになります。そのため、以下のようなケースでは、意図したものでないエラーが発生してしまうので注意が必要です。

  • S3 バケットのコンストラクトの宣言時に、バージョニング設定を指定しない
  • Aspects (Prepare フェーズ) で addPropertyOverride を呼び、バージョニング設定を enabled にする
  • addValidation (Validate フェーズ) によるバリデーションで、バージョニング設定が enabled でない S3 バケットがあった場合にエラーにする

5. まとめ

AWS CDK におけるバリデーション方法について、以下の 4 つを特徴や使い分け方法に沿ってご紹介しました。

  • 即時スロー
  • Aspects
  • addValidation
  • Annotations

これらは AWS CDK を「使う側」でも「作る側」でも共通して使えるバリデーション方法です。それぞれの特徴を理解し、適切なバリデーション方法を選択して、より安全で信頼性の高い AWS CDK のコードを書いていきましょう。


builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます

筆者プロフィール

後藤 健太 (AWS DevTools Hero)

AWS CDK のコントリビュート活動を行っており、Top Contributor や Trusted Reviewer に選定。2024 年 2 月に Open Constructs Foundation より発足された Community-Driven CDK Construct Library では、メンテナーを担っている。
また、cls3 や delstack といった自作 AWS ツールの OSS 開発も行なっている。2024 年 3 月、AWS DevTools Hero に選出。

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する