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

クラウド/Webサービス

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

第8回 コンテキストの基本
オージス総研
樋口 匡俊
2024年8月27日

コンテキストは使い始めるのは簡単ですが、使いこなそうとするとあれこれ判断に迷う機能です。筆者も試行錯誤をくり返しているのですが、使いこなすにはまず核となる基礎知識の整理が必要だろうと常々思っていました。そこで今回はコンテキストの基本的な特徴や仕組みにしぼって、cdk コマンドや cx-api など関連するツールやモジュールとともに説明したいと思います。また、コンテキストの利用例として機能フラグをとりあげ、その概要と課題を説明します。

コンストラクトの機能としてのコンテキスト

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

コンテキスト (context) は、その汎用的なコンストラクトの機能であり、AWS CDK とは関係なく使えます。もちろん AWS CDK でも使えるのですが、コンテキストを使いこなすにはまず AWS CDK と切り離して、コンストラクトの機能としてとらえる視点が必要だと思います。

コンストラクトツリーにおけるコンテキスト

コンテキストは、コンストラクトツリーの外部から内部へと、なんらかのデータを渡すために用いられます。

データ構造としてのコンテキストは、キーと値の組合せにすぎません。以下のコンストラクト (の Node クラス) のメソッドを用いると、そのコンストラクトに対して任意の値を設定したり、キーに対応する値を取得したりできます。

public getContext(key: string): any
public setContext(key: string, value: any): void

ふつう、コンテキストはコンストラクトツリーのルートノードにのみ設定します。コンテキストは、親ノードから子ノードへと引き継がれるため、ルートに設定したコンテキストはツリー内のどのノードからも取得できます。その上で、必要に応じて子ノードで新たなコンテキストを追加したり、親から引き継いだコンテキストの値を変更したりもできます。

例を見てみましょう。

import { Construct } from 'constructs'

// ルートノードに Root-context を設定する
const root = new Construct(undefined as any, 'Root')
root.node.setContext('Root-context', 'set by Root')

// ノード A で追加した A-context は A の子ノード B に引き継がれる
const a = new Construct(root, 'A')
a.node.setContext('A-context', 'set by A')
const b = new Construct(a, 'B')

// ノード X で変更した Root-context は X の子ノード Y に引き継がれる
const x = new Construct(root, 'X')
x.node.setContext('Root-context', 'set by X')
const y = new Construct(x, 'Y')

いずれの setContext メソッドも、子ノードを追加する前に呼び出しています。これは子ノードを追加すると、コンテキストを追加したり変更したりできなくなるためです。

このコードを実行すると、次の図のようなコンストラクトツリーが生成されます。

コンストラクトツリーに設定したコンテキスト

ルートに設定したコンテキストを基本として、サブツリーごとにコンテキストをカスタマイズできることが見てとれると思います。「A」→「B」のサブツリーでは「Root-context」はそのままですが、新たに「A-context」を追加しています。「X」→「Y」のサブツリーでは「Root-context」を書き換えています。

確認のために、各ノードのコンテキストをログに出力してみましょう。

console.log('Root-context of Root', '=', root.node.getContext('Root-context'))
console.log('Root-context of A', '=', a.node.getContext('Root-context'))
console.log('Root-context of B', '=', b.node.getContext('Root-context'))
console.log('Root-context of X', '=', x.node.getContext('Root-context'))
console.log('Root-context of Y', '=', y.node.getContext('Root-context'))

console.log('A-context of A', '=', a.node.getContext('A-context'))
console.log('A-context of B', '=', b.node.getContext('A-context'))

出力結果は以下のようになります。

Root-context of Root = set by Root
Root-context of A = set by Root
Root-context of B = set by Root
Root-context of X = set by X
Root-context of Y = set by X

A-context of A = set by A
A-context of B = set by A

AWS CDK におけるコンテキスト

コンテキストは AWS CDK アプリにおいても、アプリの外部から内部へなんらかのデータを渡すために用いられます。渡されたデータは、アプリの内部でさまざまな処理に利用できます。

上で見たコンストラクトの機能としてのコンテキストは、もちろん AWS CDK アプリでも使えます。次の例では、おなじみの Stack コンストラクトに setContext メソッドでコンテキストを設定しています。

const stack = new Stack(app)
stack.node.setContext('Stack-context', 'set by Stack')

ただ、このように直接 setContext メソッドを呼び出してコンテキストを設定することはあまりないでしょう。AWS CDK アプリでは、ふつうは cdk コマンドのオプションやファイルを用いてコンテキストを設定します。以下、それらの設定方法を詳しく見ていきましょう。

--context オプションによるコンテキストの設定

一つ目は cdk コマンドの --context オプションを用いる方法です。--contextsynthdeploy など各 cdk コマンドで使える global option の一種です。

--context オプションの引数には キー=値 の形式でコンテキストを指定します。コンテキストが複数ある場合は --context も複数指定します。以下はアプリをシンセサイズするときにコンテキストを指定する例です。

cdk synth --context key1=value1
cdk synth --context key1=value1 --context key2=value2

cdk コマンドを実行すると --context で指定したコンテキストはあらかじめ決められた場所に格納されます。そこはいわば AWS CDK アプリにコンテキストを渡すための中継所です。

中継所は、具体的には環境変数一時ファイルなのですが、普段は特に気にする必要はありません。cdk コマンドと AWS CDK アプリが、見えないところで適切に処理してくれるからです。

cdk コマンドにより起動された AWS CDK アプリの App コンストラクトは、その中継所からコンテキストを取得し自身に設定します。App はツリーのルートですので、設定したコンテキストはツリー全体で使えます。App は子ノードとなる Stack や L1/L2/L3 コンストラクトのためにコンテキストを設定する特別な役割を担っているのです。

cdk コマンドによるコンテキストの設定

cx-api モジュール

第1回で、ふつう AWS CDK アプリは直接実行せず、cdk コマンドを用いて実行すると説明しました。cdk コマンドとアプリが協調して動作できるのは、コンテキストの中継所など、あらかじめ決められた前提条件や約束事に cdk コマンドもアプリも従っているからです。

それら前提条件や約束事が明確に定義されている公式ドキュメントがほしいところですが、筆者が知る限りそのようなものはありません。かわりにソースコードや GitHub のコメントなどから推測することになるのですが、その中でも重要なのが cx-api というモジュールです。

cx-api は cdk コマンドとアプリが協調して動作するための共通のクラスやインターフェース、定数等をまとめたモジュールです。cdk コマンドのパッケージ aws-cdk と AWS CDK コンストラクトライブラリのパッケージ aws-cdk-lib は、どちらも cx-api を用いて開発されています。

コンテキストの中継所である環境変数の名前も cx-api の定数 CONTEXT_ENV として定義されています。確かめたければ、適当な AWS CDK アプリに次のようなログを出力するコードを追加してみましょう。

import * as cxapi from 'aws-cdk-lib/cx-api'
console.log(cxapi.CONTEXT_ENV, '=', process.env[cxapi.CONTEXT_ENV])

そうしてコンテキストを --context key1=value1 のように指定して cdk コマンドを実行してみましょう。次のように、指定したコンテキストが CDK_CONTEXT_JSON という環境変数に含まれていることがわかるはずです。

CDK_CONTEXT_JSON = {...,"key1":"value1",...}

ちなみに cdk コマンドは「CDK Toolkit」とも呼ばれますが、初期の CHANGELOG によると AWS CDK が公開される前に「cx Toolkit」と呼ばれていた時期があり、コマンド名も「cdk」ではなく「cx」だったようです。cx-api モジュールと cdk コマンドは、それぞれ API とそのクライアントツールとして密接な関係のもとに構想されたのでしょう。

cdk.json ファイルによるコンテキストの設定

cdk コマンドでコンテキストを設定するもう一つの方法は、ファイルを使うことです。使えるファイルはいくつかありますが、今回はもっとも基本的な cdk.json ファイルについて説明します。

AWS CDK アプリには定番と言える各種ファイルやディレクトリがあります。それらは一つ一つ開発者が作るのではなく、cdk init コマンドを実行して生成するのが一般的であり、その一つが cdk.json です。

cdk.json を読みとり利用するのは cdk コマンドです。cdk.json の各キーと値は、cdk コマンドのオプションとデフォルト値に相当します。あらかじめ cdk.json を書いておくと、cdk コマンドを実行するたびにオプションを付けなくてよいので便利というわけです。1

以下は cdk init コマンドを実行して出力される TypeScript 用の cdk.json の例です。

{
  "app": "npx ts-node --prefer-ts-exts bin/app.ts",
  ...
}

cdk コマンドの --app オプションには、AWS CDK アプリを実行するためのコマンドを指定します。この例では npx ts-node によりアプリのファイル bin/app.ts を実行しています。--app はアプリを実行するには必須のオプションですが、ふだんは cdk コマンドに直接付けることはありません。このように cdk.json にデフォルトの値が設定されているからです。

同じように --context オプションもあらかじめ cdk.json に書いておけます。

{
  "app": "npx ts-node --prefer-ts-exts bin/app.ts",
  "context": {
    "key1": "value1",
    "key2": "value2"
  },
  ...
}

cdk synth コマンドを実行するときに、この cdk.json と同じオプションを付けると以下のようになります。

cdk synth \
  --app 'npx ts-node --prefer-ts-exts bin/app.ts' \
  --context key1=value1 \
  --context key2=value2

cdk コマンドに付けたオプションと cdk.json に書いたオプションが重複した場合、cdk コマンドが優先されます。cdk.json はあくまで cdk コマンドで何も指定しないときのデフォルト値です。

cdk.json は cdk コマンドのファイル

このように cdk.json は cdk コマンドが読み取って利用するファイルです。先ほども紹介した初期の CHANGELOG によると、もともとは cx.json という名前であり、cx コマンドを cdk コマンドに改名するとき一緒に cdk.json に改名したようです。そのことからも cdk.json が cdk コマンドのファイルであることは明らかでしょう。

しかし、名前からなんとなく AWS CDK アプリのファイルのようにみなしてしまう人も多いと思います。筆者も Jest でユニットテストをしていて、cdk.json に書いたはずのコンテキストがアプリで取得できず悩んだことがあります。GitHub にも同様のイシューが上がっており、わりとよくあることなのではと思います。

けれども先ほどの説明を踏まえれば、cdk コマンドを使わないユニットテストで cdk.json が読み込まれないのは当然ですね。ユニットテストでコンテキストを設定したければ、setContext メソッドや、App コンストラクトの context/postCliContext プロパティを使うとよいでしょう。

コンテキストの利用例:機能フラグ

さてここからは AWS CDK の機能フラグ (feature flag) を題材に、コンテキストが具体的にどのように利用されるのか見ていきましょう。

一般に機能フラグとは、コードを書き換えることなく特定の機能を有効にしたり無効にしたりできる仕組みと言えます。AWS CDK の機能フラグは、破壊的変更を新しい AWS CDK のバージョンに取り込むために用いられます。破壊的変更とは、既存の AWS CDK アプリが期待通りに動作しなくなるような新機能やコード修正のことです。

機能フラグはコンテキストとして設定します。AWS CDK の内部では、機能フラグのコンテキスト値をもとに、破壊的変更かそれ以外の処理に分岐します。

機能フラグのコンテキストは、ふつうは以下のように cdk.json に書いておきます。

"context": {
  "@aws-cdk/core:checkSecretUsage": true
}

checkSecretUsage は対象となる機能の名前で、aws-cdk/core はそのモジュールです。true ですのでこの機能は有効です。

破壊的変更の機能フラグは、デフォルトではすべて false つまり無効になっています。そのおかげで、既存のアプリは破壊的変更による影響を受けずに AWS CDK のバージョンを上げられます。

いっぽう新規にアプリを開発する際、cdk init を実行すると cdk.json には上のような true の機能フラグが多数出力されます。AWS CDK は、新規のアプリでは破壊的変更であってもなるべく使ってもらおうとするのです。

機能フラグの課題

機能フラグは、コンテキストとして値を設定するだけの単純なものです。一見それで問題なさそうですが、まだ検討段階だった 2019 年末当時から、機能フラグにはいくつかの課題が指摘されていました。

それらは機能フラグに限らず、コンテキストを利用するときの一般的な注意としても参考になると思います。以下、機能フラグに関する AWS CDK RFC 0055 や GitHub のコメントに書き残されているそれらの課題を、筆者の私見をまじえて紹介します。

課題1. 見分けにくい

たとえば、以下のような cdk.json ファイルがあるとします。

"context": {
  "@aws-cdk/core:target-partitions": [
    "aws",
    "aws-cn"
  ],
  "@aws-cdk/core:permissionsBoundary": {
    "name": "cdk-PermissionsBoundary",
  },
  "@aws-cdk/core:checkSecretUsagee": true
}

このうち、機能フラグは一つ目のコンテキスト @aws-cdk/core:target-partitions だけです。この機能フラグを有効にするには、珍しく true ではなくこのように文字列の配列を指定します。

二つ目の @aws-cdk/core:permissionsBoundary は、デフォルトの permissions boundary を適用するための機能です。2022 年末に追加された機能ですが、破壊的変更とはみなされておらず、機能フラグではありません。

三つ目の @aws-cdk/core:checkSecretUsagee は、先ほどの例の機能フラグをわざと書き間違えて、末尾を ee にしたものです。機能フラグではありませんが、コンテキストとしては正しいので特にエラーは発生しません。静かにアプリに渡されるだけで、どこでも使われることはないでしょう。

このように機能フラグは単なるコンテキストであり、ながめているだけでは他のコンテキストと区別がつきません。どれが機能フラグであり、どんな効果があり、どう使ったらよいかを、FEATURE_FLAGS.md というドキュメントで調べて、注意深く設定しなければならないのです。

課題2. 乱用されやすい

FEATURE_FLAGS.md は、すべての機能フラグについて詳しく説明しているドキュメントです。それをもとに AWS CDK v2 の機能フラグを数えてみると、現在 40 個以上あることが分かります。

いっぽう機能フラグの RFC 0055 には、一つのメジャーバージョンで 20 個を超えたら多すぎだろうと書かれています。それと合わせて、機能フラグは後方互換性を保てない場合の最終手段であり乱用 (abuse) しないよう注意を促しているのですが、結局当初の目安の倍以上に増えてしまったのです。

増えすぎた一因は、コンテキストの手軽さにあるように思います。破壊的変更を避けて、後方互換性を保てる機能を設計・実装するよりも、機能フラグを追加することは簡単です。キーと値の組合せにすぎないコンテキストを追加し、その値によって処理を分岐させればよいからです。

課題3. 機能を追加しにくい

この課題はひょっとしたらスーパーエンジニアによってあっさり解決されてしまうかもしれませんが、現在の機能フラグを改善・強化するにはかなりの労力が必要なように思います。機能フラグには、これといった抽象化も、保守性や拡張性を高める設計もなく、キーと値の組合せというむき出しのコンテキストだからです。

GitHub には 機能フラグをもっと使いやすい形に再設計しようというイシューが上がっていますが未解決のままです。RFC 0055 や GitHub のコメントを見ると、2019 年末の検討段階では、複数のフラグをグループとして管理する機能や、AWS CDK のバージョンに合わせて管理する機能などが議論されていたようです。それらは機能フラグが増えるなど必要になったときに実装すればよいとされましたが、数年経って機能フラグが増えすぎた今でも実装されていません。

おわりに

今回はコンテキストの特徴や仕組みについて、基本的なものにしぼって説明しました。

コンテキストは、まずコンストラクトの機能としてとらえる視点が必要です。データ構造としてのコンテキストはキーと値の組合せにすぎませんが、設定・取得する対象はコンストラクトツリーです。

コンテキストを AWS CDK アプリに設定する際、コンストラクトの機能である setContext メソッドを直接呼び出すことはあまりないでしょう。かわりに、ふつうは cdk コマンドのオプションやファイルを用いて設定します。

コンテキストの利用例としては、シンプルな機能フラグをとり上げました。この他にもやや込み入った利用例や関連するコンセプトがコンテキストにはありますが、それらは次回以降に説明できればと思います。


参考資料

本文ではふれませんでしたが、以下の公式ガイドがあります。


  1. cdk コマンドの各オプションは yargs の機能を利用して環境変数でも指定できるようになっています。現在どの環境変数が指定されているかは cdk doctor コマンドで確認できます。これらは初期からある機能なのですが、あまり知られていない隠し機能のようになっています。