サーバレスアーキテクチャーを AWS CDK で作ってみた #AppSync #StepFunctions
こんにちは!コンサル部のテウです。
先週末に韓国のAWSイベント AWS Community Day Online 2020 に登壇しました。
セッションの内容は、今年の3月にブログ登壇させて頂きました JAWS DAYS 2020 での以下の記事とほぼ一緒ですが、
API Gateway をメインAPIエンドポイントとして利用する既存のアーキテクチャーに変化をかけ、GraphQL ベースの AWS AppSync をメインAPIエンドポイントとして利用することにしました。なお、全ての AWS リソースを AWS CDK でデプロイすることで、サーバーレスアーキテクチャー開発時に参考になれる AWS CDK デモコードを作成し、公開しました。
本記事では、そのデモコードを共有させて頂きつつ、ポイントになる部分については説明して行きたいと思います。
それでは、始めますー!:D
Github レポジトリー
(Github) twkiiim/aws-community-day-2020-demo
README は日本語版で書いたものがありますので、日本語版のREADMEをご覧ください。
ポイント説明
基本的な内容は JAWS DAYS 2020 で登壇しました内容と同じですので、上記の JAWS DAYS 2020 での登壇ブログをご覧頂ければと思います。
本記事では、幾つかの補足説明や AWS CDK 実装でのポイント説明をさせて頂きます。
Distributed Sagas (分散トランザクション)の具体的なポイント説明
Distributed Sagas は Caitie さんにて紹介された代表的な分散トランザクションアルゴリズムです。詳しくは以下のYoutube動画をご覧ください。
上記の JAWS DAYS 2020 登壇ブログでも説明しましたが、もっと詳しく説明させて頂きたいと思いました。なので、本記事では、分散トランザクションについて知っておけば役に立つポイントを説明します。
Two Phase Commit (2PC) の限界
分散トランザクションのアルゴリズムとして、Two Phase Commit (2PC) や Distributed Sagas の名前がよく出てくると思いますが、実際は 2PC はスケーラブルではないため、ほぼ使えないアルゴリズムになっております。2PC はトランザクションに参加するリソースやサービスをホールディングするので、トランザクション中の他のサービスからのアクセスができなくなります。例えば、旅行系のサービスで、ホテル、飛行機、レンタカーなどを一緒に予約するトランザクションがあったとすると、2PC の実行の流れとしては、
これから飛行機予約するけど、他のサービスにも聞いてみて来るからちょっと待ってねー
これからレンタカー予約するけど、他のサービスにも聞いてみて来るからちょっと待ってねー
これからホテル予約するけど、他のサービスにも聞いてみて来るからちょっと待ってねー
...... (待機中)......
他のサービスから全部 OK になったよー!飛行機予約進めてくれ!
他のサービスから全部 OK になったよー!レンタカー予約進めてくれ!
他のサービスから全部 OK になったよー!ホテル予約進めてくれ!
といった流れとなります。なので、トランザクション実行中の際は、他のリクエストの受付ができなくなります。なので、大規模サービスではスケールさせることが厳しくなります。そのため、マイクロサービスアーキテクチャー(MSA)では、2PC は使いにくいアルゴリズムになってしまいます。
それは、トランザクションが原子的(Atomic)で動作し、同時に、サービスやリソースを孤立(Isolation)させるため発生する特徴となります。
これから説明する Distributed Sagas は非原子的(Non Atomic)トランザクションを保証し、非孤立性(No Isolation)も保証できるアルゴリズム(プロトコル)なため、MSA などの大規模でも使えるプロトコル(アルゴリズム)です。
Distributed Sagas のポイント説明
シングルデータベースで、長い時間で処理されるトランザクションを処理する方法を Sagas と言いますが、Distributed Sagas は、Sagas を MSA に適用させるために改善した分散トランザクションプロトコルです。
Distributed Saga は, 一つのビジネスレベルでのアクション(トランザクション)を意味し、 "リクエスト" と "補償リクエスト" のコレクションとして定義される と Caitie さんは説明しています。
ここで重要なのは、この リクエスト(requests) と 補償リクエスト(compensating requests) の特徴なのですが、
リクエストの場合,
- 冪等性 (Idempotent)
- 失敗可能 (Can Abort)
という条件を満たせる必要があり、補償リクエストの場合、
- 冪等性 (Idempotent)
- 順番交換可能 (Commutative)
- 失敗不可能 (Can Not Abort)
の条件を満たせる必要があります。これが何を意味するのか、これから説明して行きます。
先ず、リクエストの「冪等性」は、「同じインプットに対し、何回実行させても結果の状態は一回実行させた時と一緒であること」を意味します。例えば、ホテルを予約するシナリオを考えてみると、「Tomさんは 1234号室を 2020/10/17 に泊まります」というリクエストに対し、1回処理しても、100回処理しても結果としては同じ状態になるのでしょう。はい、当たり前ですね。
ですが、そもそもこんな性質はなぜ必要になるのでしょうか。リクエストしたクライアント側からは、もし、タイムアウトなどのイベントが発生した際にリクエストが成功されたのか、失敗されたのかわかる方法がないので、もう1回リクエストしなければならないです。その際に、この冪等性が保証されたら、安心して何回もリクエストすることが可能になるからです。
次は、リクエストの「失敗可能」です。これはどういう意味でしょう。「Tomさんは 1234 号室を 2020/10/17 に泊まります」というリクエストに対し、既にこの日程の号室の予約が存在する場合は、そのリクエストを失敗させることが可能、ということです。これも当たり前ですね。
Distributed Sagas の場合、リクエストを処理するコードはその2つの性質を満足させなければならないです。
今回は補償リクエストについて説明します。
補償リクエストについても「冪等性」は成立しなければならないです。「Tomさんが 2020/10/17 に予約した 1234号室をキャンセルします」というリクエストについては、何回実行させても1回実行させた時と一緒の結果になるはずです。
次は、「順番交換可能」という性質ですが、これは「ホテルを予約してからキャンセルするか、キャンセルしてから予約するか」の順番に関係なく、結果としては予約キャンセル状態になる、という意味です。例えば、ホテル予約リクエストを処理する際に、なんらかの理由で処理が遅れている際に、他のサービスからあるリクエストが失敗され、全てのトランザクションの補償リクエストを実行することになったとします。この時、補償リクエストであるホテル予約キャンセルが先に実行され、その後、予約リクエストが実行されたとしたら、ホテルを予約してしまうことになるのでしょう。
なので、予約リクエストと予約キャンセルリクエスト(補償リクエスト)は同じ日程と同じ号室と同じ人からのリクエストであれば、順番に関係なく、結果としては予約キャンセル状態にならなければいけないですね。要約すると、トランザクションをロールバックする際に補償リクエストを一般リクエストより先に処理させることがあるとしても、「リクエスト → 補償リクエスト」の順番でも、「補償リクエスト → リクエスト」の順番でも、結果は一緒である性質を「順番交換可能」と言います。
特に MSA では、非同期でタスクが処理されることを前提にしている場合が一般的なため、このような非同期タスクが処理される順番に注意する必要があります。Distributed Sagas の補償リクエストは、実行の順番とは関係なく、マッピングされたリクエスト・補償リクエストが両方実行されたら、意味上 、リクエストが実行される前の状態に復旧されることが保証できます。ここで、意味上 という表現を使いましたが、これについてはちょっと後でまた説明します。
最後に、「失敗不可能」です。どんな場合でも、必ず補償リクエストは成功すべきです。すなわち、補償リクエストを成功させるまで補償リクエストを実行させるメカニズムが必要とのことです。
全てについて、直感的に理解できましたのでしょうか?
それでは、先の「 意味上 、結果は元の状態に復旧されること」という表現はどういう意味でしょうか。
補償リクエストを処理した後は、リクエストを処理させる前の状態に戻らなければならない、という当たり前な前提がありますが、この際、元の状態のデータを完璧に復旧するというよりは、意味的に元の状態に戻らせる、ということです。
これは一体どういう意味でしょう。
例えば、決済リクエストを実行させてから、払い戻しの補償リクエストを実行したとすると、意味的には 決済する前と残高は元の残高に復旧されましたが、決済と払い戻しのデータが記録され、データ上に残ってしまいます。ですが、意味上、元の状態に復旧されたため、問題にはならないはずですよね。
もう一つの例として、お客様にメールを送ってから、これを引っ繰り返すメールを送りましたとすると、お客様には最初のメールを無効にするメールを発送したため、意味上元の状態に戻らせる情報伝達をしたと言えるのでしょう。
このように、補償リクエストの結果は 意味上 リクエストを処理させる前の状態に復旧するという観点から見るべきです。
ここまで、Distributed Sagas のリクエストと補償リクエストについて説明しました。
今回私が公開したデモコードは、あくまで AWS 上のサーバーレスアーキテクチャーを実装するためのコードとなり、上記の Distributed Sagas の リクエストや補償リクエストの全ての性質を満足させているとは言えないです。なので、実際に Distributed Sagas を本番サービスに適用させようとする方々は、必ず上記の性質について正しく理解し、可能なシナリオを分析してからトランザクションコードを作成することを推奨します。
AWS CDK プロジェクト上でのポイント
今回はデモコードでのポイントになるところについて説明して行きます。
AWS Lambda は make でパッケージング
AWS CDK は AWS Lambda 関数を簡単にデプロイすることが可能ですが、サードパーティなど依存関係のライブラリーは自分から zip ファイルにパッケージングしておく必要があります。 Shell スクリプトを作成したり、Docker を使ったり、Lambda Layer を使ったりする方法もあると思いますが、今回のデモコードではほとんどの開発者の馴染みのある make コマンドにてパッケージングしました。
backend/ (CDKプロジェクトのルートディレクトリー)での Makefile では以下を作成しておきます。
SUBDIRS := $(wildcard ./handlers/source/*) # SUBDIRS := $(filter-out ./handlers/source/shared, $(SUBDIRS)) all clean : $(SUBDIRS) $(SUBDIRS) : $(MAKE) -C $@ $(MAKECMDGOALS) .PHONY: all clean $(SUBDIRS)
handlers/source/ 配下の全てのディレクトリーの Makefile に対し、make するという意味ですね。
あと、handlers/source/ 配下の Makefile の例としては、以下のようにコードを作成します。
FUNC_NAME=ここに AWS Lambda 関数名を入力します(例:requestDelivery) PROJ_ROOT_DIR=../../../ SOURCE_DIR=$(PROJ_ROOT_DIR)/handlers/source/$(FUNC_NAME) BUILD_DIR=$(PROJ_ROOT_DIR)/handlers/build/$(FUNC_NAME) PACKAGED_DIR=$(PROJ_ROOT_DIR)/handlers/packaged build: mkdir -p $(BUILD_DIR) mkdir -p $(PACKAGED_DIR) pip3 install -r ${SOURCE_DIR}/requirements.txt --target=$(BUILD_DIR) cp $(SOURCE_DIR)/*.py $(BUILD_DIR)/ cd $(BUILD_DIR) && zip -rq $(FUNC_NAME).zip * cp $(BUILD_DIR)/$(FUNC_NAME).zip $(PACKAGED_DIR)/
はい、理解しやすいコードなので、読めばすぐ分かると思いますが、handlers/source/ から handlers/build/ に全てのファイルをコピーし、handlers/build/ の中の requirements.txt に基づき、必要なライブラリーをインストールし、全ての zip して handlers/packaged/ に置くコードです。 AWS CDK コードからは、その handlers/packaged/ 配下の zip ファイルを以下のように読み出すだけです。
registerTasks(taskNames: string[]) { let taskMap = new Map<string, lambda.Function>(); for(const taskName of taskNames) { const task = new lambda.Function(this, `task-${taskName}-${this.deployEnv}`, { functionName: `${StackConfig.PROJ_PREF}-${taskName}-${this.deployEnv}`, runtime: lambda.Runtime.PYTHON_3_7, code: lambda.Code.fromAsset(`handlers/packaged/${taskName}.zip`), handler: `${taskName}.handler`, }); taskMap.set(taskName, task); } return taskMap; }
なので、Lambda 関数のコードが変更されたら
- make
- cdk deploy
のコマンドをセットで実行するということです。
AWS Lambda 関数のデプロイコード作成が楽になる
AWS CDK はコードの書き方により、完全に違う形として IaC (Infrastructure as Code) が実現できます。
ここで、「完全に違う形」という意味は、デプロイ方法が異なるとかではなく、リソース管理のための工数やリソース管理の考え方、リソース管理のユニットなどがコードの書き方によって完全に違う形になるという意味です。
例えば、私の場合、AWS Lambda 関数を定義するために以下のコードを書きました。
enum LambdaFunctions { stripeWebhook = 'stripeWebhook', requestDelivery = 'requestDelivery', cancelDelivery = 'cancelDelivery', cancelOrder = 'cancelOrder', cancelPayment = 'cancelPayment', confirmOrder = 'confirmOrder', orderTransactionDone = 'orderTransactionDone', } ... registerTasks(taskNames: string[]) { let taskMap = new Map<string, lambda.Function>(); for(const taskName of taskNames) { const task = new lambda.Function(this, `task-${taskName}-${this.deployEnv}`, { functionName: `${StackConfig.PROJ_PREF}-${taskName}-${this.deployEnv}`, runtime: lambda.Runtime.PYTHON_3_7, code: lambda.Code.fromAsset(`handlers/packaged/${taskName}.zip`), handler: `${taskName}.handler`, }); taskMap.set(taskName, task); } return taskMap; }
Lambda 関数の一覧みたいな enum を定義しておいて、Lambda 関数は for loop にて生成する、という形をしています。こうすると、AWS Lambda 関数が増えても、実行環境が同様であれば (今回は python 3.7)、enum タイプに Lambda 関数名を追加するだけで簡単に Lambda 関数が追加生成されます。
あと、以下のように、string を enum化させて使っているため、Type型の言語の特徴と合わせて 誤字 (typo)なども compile 時に気づくことができます。
// set abbreviations const lf = LambdaFunctions; const ts = taskStates; // success flow ts.get(lf.stripeWebhook).next( startOrderTransaction .branch( ts.get(lf.confirmOrder), ts.get(lf.requestDelivery), ) .next(ts.get(lf.orderTransactionDone)) );
他にもリソース管理方法はいろいろあると思いますので、皆さんの好きな方法で試してみませんか?!
Step Functions の定義が分かりやすい
AWS CDK を使って、本当に良かったと思ったことの一つですが、Step Functions の定義と、このフローがとても分かりやすくなります。 success flow と failure flow をみてみてください。開発時に実行のフローが一目ですぐ分かるでしょう。
// set abbreviations const lf = LambdaFunctions; const ts = taskStates; // success flow ts.get(lf.stripeWebhook).next( startOrderTransaction .branch( ts.get(lf.confirmOrder), ts.get(lf.requestDelivery), ) .next(ts.get(lf.orderTransactionDone)) ); // failure flow - stripeWebhook to orderTransactionDone ts.get(lf.stripeWebhook).addCatch(compensateOrderTransaction, { resultPath: '$.error' }) // failure flow - startOrderTransaction to orderTransactionDone startOrderTransaction.addCatch(compensateOrderTransaction, { resultPath: '$.error' }); compensateOrderTransaction.branch( ts.get(lf.cancelPayment), ts.get(lf.cancelDelivery), ts.get(lf.cancelOrder), ).next(ts.get(lf.orderTransactionDone)) // set up Step Functions State Machine const stateMachine = new stepf.StateMachine(this, `${StackConfig.PROJ_PREF}-stepf-statemachine-${this.deployEnv}`, { stateMachineName: `${StackConfig.PROJ_PREF}-stepf-statemachine-${this.deployEnv}`, definition: ts.get(lf.stripeWebhook) });
ちなみに、このステートマシーンは以下の図で表現されます。
あと、ステートマシーンの定義はちょっとだけ違いますが、YAML 形式で作成しますと、以下のような感じになります。 (JSON 形式は..... もっと複雑で分かりづらいと簡単に想像できますよね...)
stepFunctions: stateMachines: postOrderStateMachine: name: jawsdays2020-demo-postOrderStateMachine events: - http: path: stripe-webhook method: post cors: true definition: StartAt: StripeWebhook States: StripeWebhook: Type: Task Resource: Fn::GetAtt: [stripeWebhook, Arn] Next: PaymentSucceeded Catch: - ErrorEquals: ["States.ALL"] Next: PaymentFailed PaymentSucceeded: Type: Pass Next: StartOrderTransaction PaymentFailed: Type: Pass End: true StartOrderTransaction: Type: Parallel Next: OrderSucceeded ResultPath: null Branches: - StartAt: ConfirmOrder States: ConfirmOrder: Type: Task Resource: Fn::GetAtt: [confirmOrder, Arn] End: true - StartAt: RequestDelivery States: RequestDelivery: Type: Task Resource: Fn::GetAtt: [requestDelivery, Arn] End: true Catch: - ErrorEquals: ["States.ALL"] Next: OrderTransactionFailed ResultPath: null OrderTransactionFailed: Type: Pass Next: CompensateOrderTransaction CompensateOrderTransaction: Type: Parallel Next: OrderFailed ResultPath: null Branches: - StartAt: CancelPayment States: CancelPayment: Type: Task Resource: Fn::GetAtt: [cancelPayment, Arn] Retry: - ErrorEquals: [ "States.ALL" ] IntervalSeconds: 1 MaxAttempts: 3 BackoffRate: 2 End: true - StartAt: CancelDelivery States: CancelDelivery: Type: Task Resource: Fn::GetAtt: [cancelDelivery, Arn] Retry: - ErrorEquals: [ "States.ALL" ] IntervalSeconds: 1 MaxAttempts: 3 BackoffRate: 2 End: true - StartAt: CancelOrder States: CancelOrder: Type: Task Resource: Fn::GetAtt: [cancelOrder, Arn] Retry: - ErrorEquals: [ "States.ALL" ] IntervalSeconds: 1 MaxAttempts: 3 BackoffRate: 2 End: true OrderFailed: Type: Task Resource: Fn::GetAtt: [orderFailed, Arn] Retry: - ErrorEquals: [ "States.ALL" ] IntervalSeconds: 1 MaxAttempts: 3 BackoffRate: 2 End: true OrderSucceeded: Type: Task Next: SendOrderConfirmEvent ResultPath: null Resource: Fn::GetAtt: [orderSucceeded, Arn] Retry: - ErrorEquals: [ "States.ALL" ] IntervalSeconds: 1 MaxAttempts: 3 BackoffRate: 2 SendOrderConfirmEvent: Type: Task Resource: Fn::GetAtt: [sendOrderConfirmEvent, Arn] End: true
AppSync デプロイが楽になる
最近(?) AWS AppSync の Direct Lambda Resolver 機能がリリースされましたが、ご存知でしたか?
VTL というテンプレートファイル無しで、GraphQL の resolver を Lambda 関数ですぐ利用できるという機能です。それによって、AWS CDK で AppSync を管理する際のコードもとても楽になるのです。
enum AppSyncQuery { getOrderStatus = 'getOrderStatus', } enum AppSyncMutation { createOrderReservation = 'createOrderReservation', confirmOrderReservation = 'confirmOrderReservation', cancelOrderReservation = 'cancelOrderReservation', finalizeOrder = 'finalizeOrder', } ... // set the new Lambda function as a data source for the AppSync API const lambdaDs = api.addLambdaDataSource('appsyncLambdaResolverDS', appsyncLambdaResolver); // set Queries and Mutations for AppSync API const queries = Object.values(AppSyncQuery); const mutations = Object.values(AppSyncMutation); for(const query of queries) { lambdaDs.createResolver({ typeName: 'Query', fieldName: query }); } for(const mutation of mutations) { lambdaDs.createResolver({ typeName: 'Mutation', fieldName: mutation }); }
はい、AWS Lambda 関数と同様で、for loop で Query と Mutation を定義しました。今回はマニュアルで直接 resolver や Lambda 関数名などを入力しましたが、プロジェクトの構造を定形化し、resolver や Lambda 関数名を自動で読み込む機能を追加すると、リソース管理がさらになるになるかもしれません。(CDKってここまで考えてみると、サーバーレス開発時には本当に最高だと思っちゃいますね!)
スタック設定ファイル
今回のデモコードでは、project-config.ts というファイルで、以下の変数を管理しています。
/* * ------ CAUTIONS -------- * * If you modify the value in this file, it might occur a lot of unexpected resource deletion in the entire stacks. * You're definitely aware of what you're doing. * * ------------------------ */ export const StackConfig = { PROJ_PREF: 'aws-commday-2020', DEPLOY_ENV: process.env.DEPLOY_ENV || 'dev', }
特に DEPLOY_ENV ですが、同じアカウント、同じリージョンで、複数の環境を構築したい場合は有効活用できるオプションです。
bin/backend.ts ファイルで、そのデプロイ変数を読み込んだり、
... const app = new cdk.App(); let stackName = ''; const deployEnv = StackConfig.DEPLOY_ENV; ...
各スタックから読み込んだり、
... export class StepFunctionsStack extends cdk.Stack { deployEnv: string = StackConfig.DEPLOY_ENV; constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps, refStack?: AppsyncStack) { super(scope, id, props); ...
することが出来ます。なので、DEPLOY_ENV の値を変更することで、全く異なるスタックとリソース名を管理することが出来るのです。
スタック間 Output 変数参照
これも方法としてはいろいろあると思いますが、一番簡単に使える方法としては、bin/backend.ts から、他のスタック自体をオブジェクトとして渡す方法です。以下のコードをみてみましょう。
stackName = 'appsync-stack'; const appsyncStack = new AppsyncStack(app, stackName, generateStackProps(stackName)); stackName = 'stepf-stack'; const stepfunctionsStack = new StepFunctionsStack(app, stackName, generateStackProps(stackName), appsyncStack);
一番下のラインで、appsyncStack を4つ目のパラメータとして渡しています。StepFunctionsStack からは、このパラメータを以下のように使っています。
... constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps, refStack?: AppsyncStack) { super(scope, id, props); const lambdaFuncNames = Object.values(LambdaFunctions); const lambdaFuncMap = this.registerTasks(lambdaFuncNames); lambdaFuncMap.get(LambdaFunctions.confirmOrder)!.addEnvironment('APPSYNC_API_ENDPOINT_URL', refStack!.APPSYNC_API_ENDPOINT_URL); lambdaFuncMap.get(LambdaFunctions.confirmOrder)!.addEnvironment('APPSYNC_API_KEY', refStack!.APPSYNC_API_KEY); lambdaFuncMap.get(LambdaFunctions.cancelOrder)!.addEnvironment('APPSYNC_API_ENDPOINT_URL', refStack!.APPSYNC_API_ENDPOINT_URL); lambdaFuncMap.get(LambdaFunctions.cancelOrder)!.addEnvironment('APPSYNC_API_KEY', refStack!.APPSYNC_API_KEY); lambdaFuncMap.get(LambdaFunctions.orderTransactionDone)!.addEnvironment('APPSYNC_API_ENDPOINT_URL', refStack!.APPSYNC_API_ENDPOINT_URL); lambdaFuncMap.get(LambdaFunctions.orderTransactionDone)!.addEnvironment('APPSYNC_API_KEY', refStack!.APPSYNC_API_KEY); ...
はい、理解しやすいですね。スタック間のOutput参照も本当に直感的になりました。
ここからさらに他のアイディアも思い付いたりしませんか?ワクワクしませんか?
はい、AWS CDK はこんなものです。
AWS Amplify は amplify codegen だけでも良い
AWS Amplifyって何?という方もいらっしゃるかもしれませんが、Amplify を既にある程度使っている方も、よく知らない機能があります。それは、amplify init 無しで、 amplify codegen を使うことが可能なことです。
amplify codegen は AWS AppSyncのスキーマファイルを読み込んで、クライアントから必要な API ファイルを自動で生成してくれる本当に良いやつです。スキーマが複雑になればなるほど助けてくれる機能です。スキーマが 200 ラインを超えたりすると、自動で生成してくれるコードは 10万ライン以上になったりします。このコードは、GraphQL オペレーションである Query だったり、 Mutation だったり、もしくは Subscription だったりします。
この有用な機能ですが、amplify init をしなくても利用可能(Amplify プロジェクトじゃないプロジェクトにも使える)なこと、ご存知でしたか?
これは、以下の2つのコマンドで実現可能になります。
$ aws appsync get-introspection-schema --api-id ${APPSYNC_APP_ID} --format JSON schema.json $ amplify codegen add
こうすると、Amplify から生成していない(Amplify と全く関係ない)AppSync からのスキーマより、クライアントコードが自動で作成されます。
もちろん、GraphQL API Key や Endpoint URLなどの設定は必要になるため、クライアントコード内で初期化設定をする必要がありますが、これも本当に簡単なので、admin/src/main.ts を見ていただければと思います。
最後に
いかがでしょうか? AWS CDK の魅力を伝えるために頑張ってデモコードまで作成しましたが、AWS CDK の魅力について感じられましたでしょうか?(笑
私は今回のデモプロジェクト作成をしながら AWS CDK の無限な可能性を気付きました。AWSが好きな開発者の皆さんなら、上記コードを見てワクワクするはずだと私は勝手に思っちゃいますw
今後も引き続き AWS CDK の魅力を発見し、皆さんに共有したいと思います!
以上、コンサル部のテウでした!