AWS CDK 自由研究 - 付与必須のリソースタグをデプロイする前に検証する方法を考える

記事タイトルとURLをコピーする

こんにちは。マネージドサービス部の橋本 (@hassaku_63)です。

有益かどうかはよくわからない CDK の小ネタを紹介します。

複数のチームで共用する環境があってオーナーの所在や構築目的をハッキリしたかったり、あるいはコスト把握の目的でリソースタグの付与を必須にしたいケースはよくあります。

このようなケースにおいて、基本的には発見的統制の仕組みとして AWS Config を利用することが多いと思います。ただ、今後も継続的に新規・追加の開発が行われる環境だと、できれば追加構築するリソースに関してはデプロイする前の時点でさっさと設定ミスの芽を摘んでおきたいというモチベも出てくるでしょう。*1

CDK では「デプロイ前に検知する」ための仕組みがいくつか用意されています。それを使って、IaC でデプロイするリソースに関して今回のようなリソースタグの必須付与の要件をデプロイ前に検証できないか、考えてみます。

おことわり

この記事の内容は aws-cdk v2.147.0 のソースをベースとして参照し、調査した内容になります。

関連する CDK の実装をすべて理解して裏取りした内容ではないので、正確性を欠く可能性があります。予めご承知ください。

そして、一番重要なことですが、この記事で紹介するネタそのものはぶっちゃけ実務的にさほど役に立たないです。タイトルにも書いてるように単なる自由研究ネタとして見てください。

わざわざバリデーション機構を頑張って作らなくても、非常に簡単な方法で一括のタグ付けは実装可能です。現実的にはそれで間に合うことがほとんどだと思います。私としては、この記事で紹介する実装を採用することはあまり推奨しません。ちなみに「リソースタグの一括付与」には Aspects クラスを利用します。

docs.aws.amazon.com

「タグ付け可能なリソースに漏れなくタグ付けする」であれば、上記のドキュメントが紹介するサンプルコードの内容で充分です。Tags というクラスを利用している部分がタグ付けに該当する実装で、内部的には Aspects を利用しています。add() がおそらくよく使うであろう部分で、サンプルコードでは theBestStack という Stack に含まれるリソースのうちタグ付け可能なものすべてでタグを付与しています(その後 remove() を呼び出すことで AWS::EC2::Subnet 以外に付与したタグをキャンセルしています)

ただ、この記事のテーマである「タグの付与をデプロイ前にチェックしたい」という用事そのものが CDK ではイマイチじゃないか?というだけで、ここで紹介する Aspects と IValidation を組み合わせるアイデアそれ自体は汎用性が広い手法であろうと思っています。読者のみなさんにはそれだけ持ち帰っていただければよいと思います。

前フリ: CDK で「デプロイ前に気づく」ための仕組み

冒頭で「デプロイ前に気づける」ことに対するモチベがあることを述べました。ここに関しては多くの方が賛同するところかと思います。CDK ではこうした制御を実現する手段が提供されており、かつそれが使いやすいです。CDK を採用して最も嬉しい観点のひとつであり、推しポイントです(個人の見解です)。

CDK が提供する仕組みで「デプロイ前に気づける」ようにする手段は3つあります。1つは テストコードを記述して CI や git hook と組み合わせること、もう1つは IValidation を実装したクラスを用意してチェック対象の Construct に適用する方法、最後に Aspects を用いる方法です。*2

CDK で実装するバリデーションに関しては、AWS DevTools Hero の後藤さんという方が優れたアウトプットを数多く出しておられます。基本的な概要や実装方法の紹介は、後藤さんが執筆された以下の記事に譲ります。

aws.amazon.com

「AWS CDK のアプリケーションライフサイクル」の知識や、紹介されているそれぞれの機能や実装方法は必見です。ぜひご覧ください。

この記事では、ここで紹介する方法のいずれかを使って、付与必須なタグの検証ができないか検討してみます。

実装以前の検討事項

以降の解説パートでも述べますが、タグ情報は「遅延解決」が必要な値を含む可能性があります。CDK のバリデーション*3 は synthsize の処理よりも手前の段階で実行されるものであるため、今回の要件を完全に満たすよう実装することは原理的に不可能です。*4

こうした事情を考慮した結果、必須タグの存在をデプロイ前に確認するために取りうる方針は以下の2つと考えました。

  1. CDK のバリデーション機構で実現可能な範囲に絞った妥協実装を提供する
  2. 実際に synth した後の成果物をチェックする

今回は前者のアプローチを紹介しています。

後者は実際に synth した後の成果物をチェックすることになります。成果物というのは通常 cdk.out/ 以下に生成されるファイル群のことで、CDK 的には CloudAssembly と呼ばれるものです。ここには CloudFormation の JSON も含まれますし、アセットやメタデータも含まれます。この成果物をチェックする方向性であれば、後者の方向性である程度信頼できる「デプロイ前の検証」が提供できると思います。ただし、CloudAssembly 形式については現状資料が少なく、また私もさほど詳細を理解できているわけではないので、今回は割愛します。

ちなみに、後者の案は CDK におけるユニットテストの枠組みとはまた違った話をしています。Snapshot testing や、assertions モジュールの Template クラスを使ったような方法を指すものではないです。この方向性は却下しました。これは、実際に本番デプロイする設定そのままで synth しないとバリデーションの意味がないので、普通にテストコードを書くだけでは意味のある検証を保証しづらいから、というのが主な理由です *5

結論

デプロイ前(厳密には synth する前)に値が確定している固定文字列のタグキーのみサポートする、という前提であれば、IValicaiton と Aspects を併用することで所望のバリデーションは実現可能です。

ただし、タグキーに cdk synth の "Validate" フェーズで解決できない値(例えば、Ref や GetAtt のようにデプロイ後に解決される値)を用いている場合は対象外で、この記事で私が提案している実装ではそれらのチェックはスルーされてしまいます。

app.ts

// import ...
const app = new App();
  
const stack1 = new ExampleStack(app, 'ExampleStack1');
  
Tags.of(app).add('Project', 'ExampleProject');
Tags.of(app).add('Owner', 'Alice');
Tags.of(app).add('Environment', 'Dev');
  
// 利用例
new RequiredResourceTagCheck(app, ['Owner', 'Environment', 'Project']);

tag-validator.ts

// tag-validator.ts
import * as cdk from 'aws-cdk-lib';
import { Construct, IConstruct, IValidation } from 'constructs';
  
export class RequiredResourceTagCheck implements cdk.IAspect {
  constructor(private scope: Construct, private requiredTagKeys: string[]) {
    cdk.Aspects.of(scope).add(this);
  }
  
  public visit(node: IConstruct): void {
    // タグ付け可能な Construct であるかを判定
    const isTaggable = cdk.TagManager.isTaggable(node) || cdk.TagManager.isTaggableV2(node);
    if (!isTaggable) {
      return;
    }
  
    // タグ付け可能な Construct の場合のみ、その Construct に対してタグチェックを行う IValication を追加する
    node.node.addValidation(new Validator(node, this.requiredTagKeys));
  }
}
  
class Validator implements IValidation {
  constructor(private node: Construct, private requiredTagKeys: string[]) {}
  
  // コンストラクタで受け取った Construct リソースがタグ付け可能である前提のもと、
  // その Construct に対して遅延解決が不要なタグキーのみ対象として必須タグキーのチェックを行う
  validate(): string[] {
    let tagMgr: cdk.TagManager;
    if (cdk.TagManager.isTaggable(this.node)) {
      tagMgr = this.node.tags;
    } else if (cdk.TagManager.isTaggableV2(this.node)) {
      tagMgr = this.node.cdkTagManager;
    } else {
      throw new Error("unexpected error"); // タグ付け不可能な Construct は想定していない
    }
  
    const results: string[] = []; 
    this.requiredTagKeys.forEach((requiredTagKey) => {
      const tags = tagMgr.tagValues();
  
      // const msg = `id=${this.node.node.id}, path=${this.node.node.path}, tagValues=${JSON.stringify(tags)}`;
      // cdk.Annotations.of(this.node).addInfo(msg);
  
      if (!tags.hasOwnProperty(requiredTagKey)) {
        results.push(`Missing required tag key '${requiredTagKey}'`);
      }
    });
  
    return results;
  }
}

実装の解説

要点は2つあります。

  1. タグ付け可能な Construct を探して、その Construct にだけバリデーションを追加する
  2. タグ付け可能な Construct を1個だけ受け取ることを前提とした IValidation を用意する

Construct クラスは CDK の基本概念のひとつですが、このクラスから派生するクラス全部がタグ付け可能なわけではありません。リソースタイプによってはタグがサポートされていないケースがありますし、そもそも CloudFormation リソースではない Construct というのも存在します。そこで、Aspects を使って走査する Construct オブジェクトからタグ付け可能なものだけを探し出す必要があります。

CDK において、タグ付けが可能な Construct は ITaggable または ITaggableV2 インタフェースを実装することになっています。任意の Construct がこのインタフェースを実装しているかどうか = タグ付け可能かどうか確認するには、TagManager クラスの static メソッドである isTaggableisTaggableV2 を使います。Construct によって対応状況が異なる可能性があるため、両方の判定メソッドを併用する必要があります。

ちなみに、この V2 というのは純粋に CDK 内部の実装都合の話であって、AWS サービス的にそういう概念があるということではありません。V2 が存在するのは、過去の TagManager の仕様に問題があったから、だそうです。もし興味があれば ITaggableV2 の JSDoc を覗いてみてください。ちなみに私はまだ詳しい意味がよくわかっていないです。

github.com

以下、先ほど提示したソースのうちタグ付けの可否を判定している部分を抜粋します。

export class RequiredResourceTagCheck implements cdk.IAspect {
  // ...
  public visit(node: IConstruct): void {
    // タグ付け可能な Construct であるかを判定
    const isTaggable = cdk.TagManager.isTaggable(node) || cdk.TagManager.isTaggableV2(node);
    if (!isTaggable) {
      return;
    }
  
    // タグ付け可能な Construct の場合のみ、その Construct に対してタグチェックを行う IValication を追加する
    node.node.addValidation(new Validator(node, this.requiredTagKeys));
  }

冒頭の処理で node が ITaggable または ITaggableV2 を実装していること = タグ付け可能な Construct であることが確定するので、その node に対して addValidation を呼び出しています。 あとは追加したバリデーションが、コンストラクタで与えた Construct オブジェクトに対して正しくタグをチェックしてくれればよい、ということになります。

次に、バリデーションの中身です。

class Validator implements IValidation {
  constructor(private node: Construct, private requiredTagKeys: string[]) {}
  
  // コンストラクタで受け取った Construct リソースがタグ付け可能である前提のもと、
  // その Construct に対して遅延解決が不要なタグキーのみ対象として必須タグキーのチェックを行う
  validate(): string[] {
    let tagMgr: cdk.TagManager;
    if (cdk.TagManager.isTaggable(this.node)) {
      tagMgr = this.node.tags;
    } else if (cdk.TagManager.isTaggableV2(this.node)) {
      tagMgr = this.node.cdkTagManager;
    } else {
      throw new Error("unexpected error"); // タグ付け不可能な Construct は想定していない
    }
  
    const results: string[] = []; 
    this.requiredTagKeys.forEach((requiredTagKey) => {
      const tags = tagMgr.tagValues(); // Record<string, string> 型
  
      // const msg = `id=${this.node.node.id}, path=${this.node.node.path}, tagValues=${JSON.stringify(tags)}`;
      // cdk.Annotations.of(this.node).addInfo(msg);
  
      if (!tags.hasOwnProperty(requiredTagKey)) {
        results.push(`Missing required tag key '${requiredTagKey}'`);
      }
    });
  
    return results;
  }
}

ITaggable が V2 かどうかで、その Construct オブジェクトから TagManager を取得する方法が違います。よって、冒頭の if 文ではどちらでも対応できるような実装でその Construct から TagManager オブジェクトを取り出しています。基本的にこの Validator クラスはタグ付け可能な Construct だけ扱えるという前提で実装しているので、ITaggable でも ITaggableV2 でもないものは Unexpected error として扱っています。もうちょいしっかりやるならコンストラクタの方にも型チェックを追加してあげるとよいでしょう。

肝心のタグ情報は TagManager クラスの tagValues() メソッドで取り出しています。戻り値は Record<string, string> 型なので、このキーを走査することでタグキーのチェックが可能です。

ただし、この実装には制約があります。結論で述べた「タグキーに cdk synth の "Validate" フェーズで解決できない値(例えば、Ref や GetAtt のようにデプロイ後に解決される値)を用いている場合は対象外」がそれです。以降の文章でその内訳を説明します。

cdk synth する前に、もしくはデプロイする前の時点では具体的な値がわからない...というケースが存在します。その代表例が CloudFormation における Ref, GetAtt 関数のようなデプロイ後に初めて解決される値の存在です。CDK が IValication によってバリデーションを動かすフェーズは synthesize よりも手前であるため、こうした「後から解決される値」を cdk synth の実行時(厳密には CDK のライフサイクルにおける "Validate" フェーズ)に具体的な値として評価することは不可能です。

CDK では、こうした性質を持つ値を「遅延解決」するための仕組みとして Lazy という概念が導入されています。これに関連する概念として、「解決可能な値」を表現する IResolvable というインタフェースが存在します。ここまでが前フリです。

リソースタグの話に戻ります。タグの定義にも Ref や GetAtt を使うことができますので、CDK 的にはこうした「遅延解決」の仕組みを考慮せねばなりません。これを synth の実行時に解決することは原理的に不可能なので、それらを諦める必要があります。

タグの情報を管理している TagManager クラスでは、こうした「遅延解決」を考慮した表現として IResolvable 型を持つ renderedTags プロパティが定義されています。完全なリソースタグの情報ということならこのプロパティを触る必要がありますが、前述の通り遅延解決が必要な値をどうこうする方向性は諦めているので、今回手を出しませんでした。代えて tagValues メソッドの戻り値を使うことにしました*6。このメソッドが返す値は Record<string, string> 型です。この値は遅延解決が必要な値の情報を含まないため不完全な表現ではありますが、最初から固定値が分かっていて遅延解決が不要な値に関しては問題なく返してくれます。このメソッドを使って、抽出可能なタグ情報だけを対象としたタグキーの走査を行い、限定条件付きではあるものの必須タグキーのバリデーションを実現しました。

まとめ

自分の自由研究ネタを発表してみたかったので書きました。

前置きで述べたことの繰り返しになりますが、私としては「ここに書いてある方法で一応できそうに見えるけど、おすすめしないしあんまり意味ないと思うよ」というスタンスです。なので、そういうトリビアもあるよね〜程度に受け取ってもらえると嬉しいです。

今回のネタをやるためにちょいちょい CDK の実装を眺めてみましたが、tagValues メソッドの意義と戻り値の仕様はいまだに謎なので、実はこの方法は良くない、みたいな話は普通にありそうです。そのへんの結論を出すには、もうちょい実装の詳細を見てみないとだめそうです。

あと、解説パートで Lazy や IResolvable の話題を出しましたが、このへんの概念は正直まだ全然理解できてないです。このへんは CDK 中級者としては Token の概念と併せて押さえておきたいポイントのひとつなんですが、まだ私の実務ではさほど必要になっていない現状もあってまだ理解度は芳しくありません。本文では Ref のようにデプロイ後に解決されている値を例示しましたが、実際には「synth の時点で解決されデプロイ前には判明している値」というのもあるはずです。このあたりの具体例も出せれば良かったなと。ついでに Lazy や IResolvable の話をもうちょい深堀りして解説できたら良かったなとは思っています。

とまあ、こんな感じで私自身もさほど正確に理解できてるわけではありませんので、もしこの記事に関してもっと良いアプローチをご存じだったり、あるいは CDK 実装の詳細に関する理解の誤りなどがありましたら X で @hassaku_63 宛にフィードバックをいただけたら嬉しいです。

追記

(2024/07/16)

IPolicyValidationPluginBeta1 インタフェースを実装した自作プラグインを作ることで、synth の結果である CloudFormation テンプレートを検証する方法もあります。今回の想定ユースケースに関しては有効なアプローチであろうと思いました。手前味噌ながら個人の Zenn アカウントで記事を書きましたので併せてご紹介します。

zenn.dev

*1:何もリソースタグに限った話ではなくて、例えば Security Group のルールだとか、S3 バケットに関する設定の指定だとか、ざっくり言えば Security Hub で定義されているような類のルール全般について似たようなニーズがあろうかと思います

*2:Aspects はバリデーション専用の機能というわけではないので、そういう使い方も可能、という程度で受け取ってください

*3:ここで言う「バリデーション」とは、CDK App のライフサイクルにおける "synthesize" よりも前のフェーズで実行される任意の検証的な性質を含む処理のことを指します。なお、IValidation を実装した自前クラスによるバリデーションは "validate" フェーズで動作する処理です。このフェーズは "synthesize" フェーズの1つ手前のフェーズに該当します

*4:詳しくは CDK App のライフサイクルを参照ください。IValidation を使って実装するバリデーションはこのライフサイクルにおける "Validate" フェーズ、synth は "synthesize" フェーズで実行されます

*5:CDK のユニットテストでは App や Stack の生成はテストコード中で自分でやってしまうことが多いですが、それだと困るよねという話です。タグ付けの実装は App スコープに対して適用することもあるため、App オブジェクトの生成をテストコードの中で独自にやってしまう「普通の」テストコード実装だと本当に検証したかったはずの部分を素通りしてしまうことになります

*6:正直、tagValues メソッドの実装はまだ全然読み解けていません。どういうユースケースを想定して提供された機能なのか、具体的にどういう仕様なのか、みたいな話は私自身まだ理解しきれていないままです

橋本 拓弥(記事一覧)

マネージドサービス部

内製開発中心にやってます。普段はサーバーレス関連や CDK を触ることが多いです