オブジェクトの広場はオージス総研グループのエンジニアによる技術発表サイトです

クラウド/Webサービス

もっとじっくり AWS CDK のコンセプト

第2回 L1 コンストラクトとテンプレート生成
オージス総研
樋口 匡俊
2023年9月21日

AWS コンストラクトライブラリは AWS CDK アプリを開発するためのさまざまなコンストラクトを提供しています。アプリの開発者にとって最も重要なのは、L1, L2, L3 という三種類のコンストラクトです。その中から今回は AWS CloudFormation テンプレートを生成する仕組みの一部である L1 コンストラクトについて説明します。

ふたたび、シンプルなアイデア

前回説明したとおり、AWS CDK の基本的なアイデアは CloudFormation のテンプレートを書くかわりにソースコードを書くことです。

そのシンプルなアイデアをこえた抽象化こそが AWS CDK の真価ではあります。 しかし AWS CDK アプリをシンセサイズして出力されるクラウドアセンブリには、テンプレートが含まれています。 抽象化の裏では、コードからテンプレートを生成する仕組みがはたらいているのです。

今回とりあげる L1 コンストラクトは、そんなテンプレート生成の仕組みの一部をなすものです。

L1 と継承、is-a 関係

テンプレート生成の仕組みは、オブジェクト指向におけるクラスの継承を活用して設計されています。 継承を用いると、クラスとクラスの関係を端的に表現することができます。

ふつう、あるクラス X から継承したクラスは X(の一種)であると解釈されます。 「… は … である」を英語にすると「… is a …」ですので、これを is-a 関係 といいます。

L1 コンストラクトは、Construct というクラスを何度か継承した先にあります。 「L1 はコンストラクトである」という is-a 関係が、継承を用いて表現されているわけです。

このような話になじみのないかた、某情報処理の試験でしか見たことがないというかたは、AWS CDK を手がかりに色々調べてみるとよいでしょう。 AWS CDK を開発したチームが求めたものは、JSON や YAML には無いこうしたオブジェクト指向の表現力であり、それらを読みとり自分でも表現することは、AWS CDK を使いこなすことにつながっていくからです。

L1 の継承しているクラスを知ることは、L1 の理解を深めることにつながります。 これから L1 の親クラスからそのまた親クラスへと Construct クラスまでたどりながら、L1 とは何であるか、L1 を含むテンプレート生成の仕組みはどのようなものか、見ていくことにしましょう。

L1 コンストラクトとは

L1 コンストラクトとは、CloudFormation テンプレートの個々のリソースを表すクラス群のことです。 別名として CloudFormation リソースコンストラクトとも呼ばれます。

“L1” というのは “Layer 1” の略で、一番下のレイヤー、層という意味です。 よく “Level 1” の略ともいわれますが1、意味するところは同じです。 L2 や L3 とくらべて抽象化の度合いがもっとも低く、AWS CDK アプリの基礎となるコンストラクトということです。

テンプレートは JSON または YAML 形式のファイルです。 次のように、いくつかのセクション (section) というまとまりに分けて記述します。

{
  "AWSTemplateFormatVersion" : "...",
  "Description" : "...",
  "Metadata" : { ... },
  "Parameters" : { ... },
  "Rules" : { ... },
  "Mappings" : { ... },
  "Conditions" : { ... },
  "Transform" : { ... },
  "Resources" : { ... },
  "Outputs" : { ... }
}

これらのうち Resources セクションには、EC2 インスタンスや S3 バケットなど CloudFormation で作成したいリソースを定義します。 L1 が表すリソースとは、具体的にはこの Resources セクションに書く JSON の断片のことです。

L1 のクラスとインターフェース

Resources セクションでは、次のような形式でリソースを定義します。 L1 が表す断片というのも、これと同じ形式です。

"Resources" : {
  "XXXX" : {
    "Type" : "XXXX::XXXX::XXXX",
    "Properties" : { ... }
  }
}

Type はリソースタイプという、リソース一つ一つに定められた文字列です。 それぞれのリソースタイプには、プロパティという設定項目が定められており、それらは Properties で指定できます。

リソースタイプとプロパティの仕様は公開され、日々更新されています。 人間が読みやすいリファレンスとしてはもちろん、プログラムで処理しやすい JSON 形式のファイルとしても公開されています。

その仕様とツールを用いて L1 は自動生成されます。 生成されるのは、リソースタイプ一つにつきクラスが一つと、そのプロパティを表すインターフェースが一つです。

命名規則があり、L1 のクラス名は CfnXXXX のように先頭に Cfn を付けることになっています。 XXXX の部分はリソースタイプの末尾からとってくるリソース名です。 プロパティを表すインターフェース名は、CfnXXXXProps のように末尾に Props を付けます。

たとえば S3 バケットのリソースタイプ AWS::S3::Bucket に対応する L1 は CfnBucket クラスであり、プロパティを表すインターフェースは CfnBucketProps です。

L1 の例:CfnBucket

例としてその CfnBucket コンストラクトの使い方を見てみましょう。

import * as core from 'aws-cdk-lib/core'
import * as s3 from 'aws-cdk-lib/aws-s3'

const app = new core.App()
const stack = new core.Stack(app)

new s3.CfnBucket(stack, 'Bucket', {
  bucketName: 'SampleBucket'
})

前回のおさらいになりますが、これは基本的な AWS CDK アプリのコードですね。 コアフレームワークの AppStack を使って、コンストラクトツリーを伸ばしています。 Stack は CloudFormation のスタックを表すコンストラクトです。

Stack の下には CfnBucket が伸びています。 bucketName というのは、S3 バケットの名前を指定するプロパティです。 使っているインターフェースは CfnBucketProps ですが、ふつうはこんなふうにインターフェース名は書かずにすませます。

このアプリをシンセサイズすると、次のような断片を含む CloudFormation テンプレートが出力されます2

"Resources": {
  "Bucket": {
    "Type": "AWS::S3::Bucket",
    "Properties": {
      "BucketName": "SampleBucket"
    }
  }
}

CfnBucket に対応するリソースタイプ AWS::S3::Bucket がきちんと出力されていますね。 プロパティ名 BucketName の先頭が大文字ですが、先ほどの bucketName との違いはそれくらいです。

このように L1 でコードを書くことは、Resources セクションを書くこととあまり違いはありません。 TypeScript としてのエラーチェックやコード補完はききますが、リソースタイプのかわりのクラス名といい、一つ一つ書かなければならないプロパティといい、テンプレートさながらです。

CfnResource コンストラクト

L1 は個々のリソースごとにクラスが分かれていますが、それらはひとくくりに、Resources セクションに定義するリソースの一種であるとも言えます。 そのことを表すのが CfnResource コンストラクトです。

CfnResourceResources セクションのリソース全般を表すクラスです。 以下の図のように、すべての L1 は CfnResource を継承 (TypeScript の extends) しており、is-a 関係を表現しています。

CfnElement クラスからの継承

CfnResource の書き方は、L1 よりもさらに Resources セクションの書き方に近いです。 次の例は、先ほどと同様の S3 バケットを定義するコードです。

new CfnResource(stack, 'Bucket', {
  type: 'AWS::S3::Bucket',
  properties: {
    BucketName: 'SampleBucket'
  }
})

type はリソースタイプの文字列そのものですね。 string 型であればなんでも書けます。 properties{ [name: string]: any } というインデックス型です。 よってこれもなんでも書けます。

このなんでも書ける性質は、L1 が存在しないときには便利です。 リソースの仕様は公開されているのにまだ L1 は自動生成されていないときや、仕様にはないリソースを自作したときなどです。

なんでも書けるなら L1 は要らないかというと、そうではありません。 CfnResource には、L1 のようなエラーチェックやコード補完がほとんどききません。 そのためふつうは L1 のほうが効率よく品質の高いコードを書けるはずです。

テンプレートのコンストラクト

L1 と CfnResource コンストラクトで Resources セクションは書けるとして、AWS CDK の基本的なアイデアを実現するには他のセクションも書けなければなりません。

AWS CDK は、テンプレートの各セクションに対応するコンストラクトを提供しています。 以下はその一覧です。

セクション コンストラクト
AWSTemplateFormatVersion Stack (templateOptions)
Description Stack (templateOptions)
Metadata Stack (templateOptions)
Parameters CfnParameter
Rules CfnRule
Mappings CfnMapping
Conditions CfnCondition
Transform Stack (templateOptions)
Resources CfnResource
Outputs CfnOutput

これらを使うと、各セクションの細かいところまで自由に書くことができます。 具体的なコードの書き方は、公式リファレンスを参照してください。

CfnElement コンストラクト

上の一覧の Stack 以外のコンストラクトは、L1 のようにあたまに Cfn が付いていますが L1 ではありません。 どれもテンプレートの要素を表す CfnElement コンストラクトを継承したクラスです3

CfnElement クラスからの継承

テンプレートの要素を表すとはいえ、CfnElement は TypeScript の抽象クラスです。 JSON 形式のテンプレートにするには、なんらかの変換処理が必要になります。 以下はそのための抽象メソッドです。

public abstract _toCloudFormation(): object;

この _toCloudFormation は開発者からは見えない (@internal) 、AWS CDK の内部で呼びだされるメソッドです。 戻り値としてはテンプレートの断片となるオブジェクトを返します。 具体的にどのような断片を返すかは、CfnResource など継承先のクラスで実装されます。

テンプレート生成の仕組み

CfnElement コンストラクトは is-a 関係を表現するだけでなく、実際に CloudFormation テンプレートを生成するときにも重要な役割をはたします。 その仕組みは、次のような図で表すことができます。

テンプレート生成の仕組み

この図は前回概要を説明した AWS CDK アプリのコンストラクトツリーですね。 Stack は二つあり、それらの下にいくつか CfnElement が伸びています。 一つだけ Construct がありますが、これはその下の二つの CfnElement をまとめるコンストラクトです。

シンセサイズすると、各 Stack は自分の下のツリーをさがして、CfnElement を継承しているコンストラクトを集めます。 つまり、テンプレートの断片を出力できるコンストラクト全般を集めます。 そうして集めた断片を整理し、テンプレートとして出力します。

このとき CfnResource や L1 の CfnBucket など、細かい具体的なクラスを気にする必要はありません。 CfnElement を継承したクラスであれば、_toCloudFormation メソッドで断片を返す決まりになっているからです。

この仕組みのおかげで、アプリの開発者は柔軟にツリーを組むことができます。 意識が必要なのは、Stack ごとにテンプレートが出力されることくらいです。 あとは適宜まとめ用の Construct を使うなどして、あつかいやすい形のツリーを作り、そこに L1 や CfnResource を含めておけばよいのです。

Construct クラス

L1 など、コンストラクトと呼ばれるものはすべて Construct クラスを継承しています。 上のようなツリーが作れるのも、ツリーからクラスを集めてまわれるのも、すべてコンストラクトの一種として処理できるからです。

Construct クラスからの継承

Construct は AWS に限定されない汎用的なクラスです。 もともと AWS CDK に含まれていましたが、CDK8s など AWS CDK 以外でも利用できるよう、v1 の途中から単独の constructs パッケージとして配布されるようになりました。

コンストラクトツリーの作り方は簡単で、次のようにインスタンスを生成するときにスコープ (scope) と ID を指定するだけです。

new Construct(scope: Construct, id: string)

scope には、new するコンストラクトの親にしたいコンストラクトを指定します。 scope の下に子をくっつけてツリーを伸ばしていくわけです。

id には、同じ scope でユニークな文字列を指定します。 scope が異なれば、同じ id を何度でも使えます。

第三引数に XXXXProps のようなプロパティがありませんね。 プロパティは L1, L2, L3 など AWS CDK のコンストラクトにはたいていありますが、あくまで Construct クラスを拡張して追加したものであり、コンストラクトに必須のものではありません4

コンストラクトツリーの例

コードの例を見てみましょう。

import { Construct } from 'constructs'

const root = new Construct(undefined as any, 'Root')
const a = new Construct(root, 'A')
const b = new Construct(root, 'B')
const a_a = new Construct(a, 'A')
const b_a = new Construct(b, 'A')

ツリーのルートのスコープには undefined を指定することになっています。 ID はなんでもよいので 'Root' にしています。

ルートの子コンストラクトは二つで、それぞれ ID は 'A''B' です。 どちらもスコープは root で同じなので、同じ ID を使ってはいけません。

ID が 'A' のコンストラクトが三つありますが、問題ありません。 それぞれスコープが root, a, b で異なるからです。

図にすると次のようになります。

コンストラクトツリーの例

Node クラス

ツリーの中において、各コンストラクトはノード (node) と呼ばれます。 呼び方だけでなく、実際に Construct クラスのインスタンスを生成すると、内部では Node というクラスのインスタンスが一つ生成されます。

Construct はツリーを作る処理をこの Node クラスにお任せしています。 また、Node はツリーに関するさまざまな情報をもっており、Constructnode プロパティから取得できます。

たとえば、コンストラクト a の子ノードたちは a.node.children で取得できます。 自身からルートまでのすべてのスコープは a.node.scopes で取得できます。

つまり Node により、ツリーの上から下まですべての情報にアクセスできるわけです。 先ほど見たテンプレート生成の仕組みにおいて、ツリーからコンストラクトをさがし出せるのも Node のおかげなのです。

おわりに

今回は L1 が継承しているクラスを上へ上へと Construct までたどりながら、L1 とテンプレート生成の仕組みについて見てきました。

この仕組みはあくまでも、テンプレートのかわりにコードを書くという AWS CDK の基本的なアイデアを実現したものです。 それをこえた抽象化にこそ、AWS CDK の真価はあります。

よって、ここからさらに L1 を継承するわけにはいきません。 L1 は CfnElementCfnResource を継承しており、テンプレートの要素の一種です。 どこまで継承しても、その枠をのりこえることはできません。

そこで L2 や L3 では、継承とは異なるアプローチがとられるのですが、その説明は次回としたいと思います。


参考資料

公式ドキュメントの L1 コンストラクトの説明は『Constructs』に短くまとめられています。 とりあえず L1 をちょっと使ってみるだけならこれで十分でしょう。

テンプレート生成の仕組みや L1 の継承しているクラスの役割については、特に解説が見当たらなかったので、主に aws-cdk-lib/core モジュールのソースコードを参照しました。

L1 の詳細を知りたい場合は、L1 のコードを見るのもよいですが、L1 の自動生成ツール spec2cdk も参考になります。 以前は cfn2ts という別のツールが使われていましたが、今年 7 月リリースの v2.88.0 から置きかえられました。


  1. 公式ドキュメント『Constructs』に「L1, short for “layer 1"」と明記されているのですが、筆者の感覚では "Level” の “L” だと思っている人の方が多いと感じます。そんなふうに AWS CDK の用語や概念には、はっきりしないものがけっこうあります。 

  2. 細かいことをいうと Metadata も出力されるのですが、ここでは省略しています。 

  3. 実は間に CfnRefElement というクラスが入るのですが、今回は省略します。このクラスは使われ方とコメントに合わない点があり、設計を見直す必要があると思われます。 

  4. AwsCliLayer コンストラクトにはプロパティがありません。