GenUを通してAWS CDKに入門してみた

## はじめに

### この記事でやること

GenUのソースコードをベースにAWS CDKでのインフラ構築方法について理解していきたいと思います。
といいつつほぼAWS CDKのチュートリアルをなぞっただけになってしまいましたが、GenUのカスタマイズの第一歩として、ざっくりどんな構成になっているか理解することをゴールにしたいと思います。

### GenUとは

AWS公式がOSSで提供している、生成AIのWEBアプリがわずか数コマンドで簡単に構築できるデモアプリです。

https://github.com/aws-samples/generative-ai-use-cases-jp

アプリインフラは全てAWSのサービスで完結しており、生成AIサービスは[Amazon Bedrock]を利用します。
AWSアカウントを持っており、Nodejsを使えるPCがあれば10分とかからずにこんなリッチなUIのWEBアプリが作れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649149/aff3a1ef-48bb-2733-4a9c-48edabe1adef.png)


### AWS CDKとは

AWS CDK (Cloud Development Kit)とは、TypescriptやPythonなどのプログラミング言語を使って[CloudFormation]のテンプレートが作成できる、AWS公式のIaCツールです。

https://docs.aws.amazon.com/cdk/v2/guide/home.html

プログラミング言語なのでif文やfor文が使え、より柔軟にインフラの定義ができます。
IaCツールとしてよく使われる[Terraform]との比較は[こちら](https://qiita.com/luton-mr/items/afe70781807bf3b5016a)の記事がとてもわかりやすかったです。

## とりあえずアプリのデプロイ

とりあえずGenUのアプリをデプロイしてみます
前提として以下の準備が必要です。

- AWSアカウント
- AWS CLIのインストール
- Nodejsのインストール

[GenU公式リポジトリの手順](https://github.com/aws-samples/generative-ai-use-cases-jp?tab=readme-ov-file#%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4)通りにコマンドを叩くだけでアプリが立ち上がります。
詳しくデプロイの手順が見たい場合はワークショップも用意されています。

https://catalog.workshops.aws/generative-ai-use-cases-jp/ja-JP

## AWS CDKのコードはどこ？

このリポジトリにはAWSインフラ用のCDKのコードと、WEBアプリなのでWEB画面用のReactコードが入っています。
CDKナニモワカラナイの状態なのでまずはCDKのコードがどこにあるか探します。

ということでAWS CDKがどのような構成になっているのか理解するために、公式チュートリアルをなぞってみることにしました。

https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html

### CDKの実行環境準備

チュートリアル通りに進めます。
ステップ1〜3は実施済みの前提で本記事では飛ばします。

> ステップ4: Node.jsとプログラミング言語の前提条件をインストールする

言語は業務で一番使い慣れているPythonにしてみました（GenUはTypescript）。

```bash
$ node --version
v22.4.1
$ python --version
Python 3.12.4
```

> ステップ5: AWS CDK CLIをインストールする

AWS CDK CLIをインストールします。

```bash
npm install -g aws-cdk
```

> ステップ6: CDK CLIのセキュリティ認証情報を設定する

AWSの認証情報はAWS_DEFAULT_PROFILE環境変数に設定します。

```bash
export AWS_DEFAULT_PROFILE=<プロファイル名>
```

> ステップ7: AWS環境をブートストラップする

さて、単純に`cdk bootstrap`コマンドを実行すれば対話形式で進むのかと思いきや、実行すると次のエラーが出ました。

```bash
Specify an environment name like 'aws://123456789012/us-east-1', or run in a directory with 'cdk.json'.
```

ふむふむアカウント名とリージョンからなる環境名を指定するか、`cdk.json`があるディレクトリで実行しろとのことです。
`cdk.json`とやらがCDKの設定ファイルっぽいので、GenUのソースでも探してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649149/b0e4b44c-3a91-111a-be19-e87fffac1135.png)

`generative-ai-use-cases-jp/packages/cdk/cdk.json`にありました。
この`cdk`フォルダにおそらくGenUのCDK周りのソースがあるのでしょう。

中身を見ても今のところわからないので、とりあえず公式チュートリアルの[環境をブートストラップする方法](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping-env.html#bootstrapping-howto)を見ます。

環境名を指定する方法でブートストラップします。

```bash
# リージョンは任意で
cdk bootstrap aws://<アカウント名>/ap-northeast-1
```

実行結果

```bash
✅  Environment aws://<アカウント名>/ap-northeast-1 bootstrapped.
```

ここで`cdk.json`が出来上がるのかと思いきや、なにも生成されませんでした。
とりあえず次の手順に行きます。

### CDKアプリを作成する

https://docs.aws.amazon.com/cdk/v2/guide/hello_world.html

`Hello World!`を返すLambdaを作るサンプルアプリを作っていきます。

> ステップ1: CDKプロジェクトを作成する

ディレクトリ作成

```bash
mkdir hello-cdk && cd hello-cdk
```

CDKプロジェクトの初期化

```bash
cdk init app --language python
```

ここで諸々ベースの構成ファイルが出来上がりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649149/83398709-a996-7147-d56d-75007ee1e329.png)

続いてCDK実行のための仮想環境に入り、依存関係をインストール

```bash
source .venv/bin/activate # On Windows, run `.\venv\Scripts\activate` instead
python -m pip install -r requirements.txt
```

プロジェクトディレクトリ直下にある`app.py`の中身がこちら

```app.py
#!/usr/bin/env python3
import os

import aws_cdk as cdk

from hello_cdk.hello_cdk_stack import HelloCdkStack


app = cdk.App()
HelloCdkStack(app, "HelloCdkStack",
    # If you don't specify 'env', this stack will be environment-agnostic.
    # Account/Region-dependent features and context lookups will not work,
    # but a single synthesized template can be deployed anywhere.

    # Uncomment the next line to specialize this stack for the AWS Account
    # and Region that are implied by the current CLI configuration.

    #env=cdk.Environment(account=os.getenv('CDK_DEFAULT_ACCOUNT'), region=os.getenv('CDK_DEFAULT_REGION')),

    # Uncomment the next line if you know exactly what Account and Region you
    # want to deploy the stack to. */

    #env=cdk.Environment(account='123456789012', region='us-east-1'),

    # For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html
    )

app.synth()
```

`hello_cdk/hello_cdk_stack.py`の中身

```hello_cdk_stack.py
from aws_cdk import (
    # Duration,
    Stack,
    # aws_sqs as sqs,
)
from constructs import Construct

class HelloCdkStack(Stack):

    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # The code that defines your stack goes here

        # example resource
        # queue = sqs.Queue(
        #     self, "HelloCdkQueue",
        #     visibility_timeout=Duration.seconds(300),
        # )

```

`hello_cdk_stack.py` > `class HelloCdkStack(Stack)` クラスとしてCloudFormationのスタックを定義するようです。

> ステップ4: Lambda関数を定義する

`hello_cdk_stack.py`を以下のように変更します。

```hello_cdk_stack.py
from aws_cdk import (
  Stack,
  aws_lambda as _lambda, # Import the Lambda module
)
from constructs import Construct

class HelloCdkStack(Stack):

  def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
    super().__init__(scope, construct_id, **kwargs)

    # Define the Lambda function resource
    my_function = _lambda.Function(
      self, "HelloWorldFunction", 
      runtime = _lambda.Runtime.NODEJS_20_X, # Provide any supported Node.js runtime
      handler = "index.handler",
      code = _lambda.Code.from_inline(
        """
        exports.handler = async function(event) {
          return {
            statusCode: 200,
            body: JSON.stringify('Hello World!'),
          };
        };
        """
      ),
    )
```

ふむふむ`aws_lambda`の`Fucntion`クラスでLambda関数を定義するようです。
AWS SAMの`template.yaml`でいうこの辺の設定

```template.yaml
Function:
    Type: AWS::Serverless::Functio
    Properties:
      FunctionName: ***
      Role: ***
      CodeUri: ***
      Handler: ***
```

`code`はチュートリアルだとインラインで書いているので、後で別ファイルを参照できるように変えたいと思います。ランタイムもpythonに変えたい。とりあえずこのまま進みます。

> ステップ5: Lambda関数のURLを定義する

```hello_cdk_stack.py
from aws_cdk import CfnOutput, Stack
from aws_cdk import aws_lambda as _lambda
from constructs import Construct


class HelloCdkStack(Stack):

    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        # Define the Lambda function resource
        my_function = _lambda.Function(
            self,
            "HelloWorldFunction",
            runtime=_lambda.Runtime.NODEJS_20_X,  # Provide any supported Node.js runtime
            handler="index.handler",
            code=_lambda.Code.from_inline(
                """
        exports.handler = async function(event) {
          return {
            statusCode: 200,
            body: JSON.stringify('Hello World!'),
          };
        };
        """
            ),
        )

        # Define the Lambda function URL resource
        my_function_url = my_function.add_function_url(
            auth_type=_lambda.FunctionUrlAuthType.NONE,
        )

        # Define a CloudFormation output for your URL
        CfnOutput(self, "myFunctionUrlOutput", value=my_function_url.url)

```

Lambda呼び出し用のURLの定義のようです。
`CfnOutput`の部分がおそらく`teplate.yaml`でいう`Outputs`ブロック

```teplate.yaml
Outputs:
  ApiUrl:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${StageParameter}/"
```

> ステップ6: CloudFormationテンプレートを合成する

これまでに作成したPythonコードからCloudFormationテンプレートを作成します。

```bash
cdk synth
```

実行するとコンソール上にCloudFormationテンプレートが出力されました（長くなるので中身は省略。チュートリアルの出力例を見てください）。

ファイルに出力して欲しいのでオプションを探す

```bash
$ cdk synth --help

-o, --output             Emits the synthesized cloud assembly into a directory
                           (default: cdk.out)                           [string]
```

ありました

```bash
cdk synth --output <任意のフォルダ名>
```

jsonで作成予定のスタックの設定ファイルが出力されました。

> ステップ7: (オプション) 環境をブートストラップする

デフォルトは実行環境の準備でブートストラップした環境にデプロイされるのですが、コード内でアカウントやリージョンを指定することもできる模様。今回はやりません。

> ステップ8: CDKスタックをデプロイする

いよいよデプロイします。

```bash
cdk deploy
```

実行結果

```bash
 ✅  HelloCdkStack

✨  Deployment time: 38.05s

Outputs:
HelloCdkStack.myFunctionUrlOutput = ***
Stack ARN:***

✨  Total time: 45.5s
```

Lambda関数1つだけなので1分も経たずに出来上がりました。

出力されたURLにアクセスします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649149/e8e1ff36-5199-818e-c5b4-222e0a45aa65.png)


おおーちゃんとデプロイされていますね。

### アプリの変更

チュートリアルで作成したLambda関数はCDKのコード内にインラインで書かれていました。
実運用ではそんなことありえないので、別ファイルを参照するように変更します。
ランタイムもPythonにします。

ランタイムの指定はこちらから調べました。

https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_lambda/Runtime.html

まずは`hello_cdk_stack.py`と同じディレクトリに`hello`フォルダを作成、その中に`lambda-handler.py`を作成します。

```app.py
import json

def handler(event, context):
    return {"statusCode": 200, "body": json.dumps("Hello!!!!!")}
```

元気なハローを返すだけの関数です。

変更の差分を確認します。

```bash
$ cdk diff

Stack HelloCdkStack
Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
Resources
[~] AWS::Lambda::Function HelloWorldFunction HelloWorldFunctionB2AB6E79 
 ├─ [~] Code
 │   ├─ [+] Added: .S3Bucket
 │   ├─ [+] Added: .S3Key
 │   └─ [-] Removed: .ZipFile
 ├─ [~] Handler
 │   ├─ [-] index.handler
 │   └─ [+] lambda-handler.handler
 └─ [~] Runtime
     ├─ [-] nodejs20.x
     └─ [+] python3.12
```

変更をデプロイします。

```bash
cdk deploy
```

デプロイが終わったら出力されるURLにアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1649149/5ac17609-2a41-532d-3a59-360b27a88109.png)

元気なハローが返ってきました。

### `cdk.json`はなに？

CDKのプロジェクトの設定をしているファイルですね。`package.json`的なやつでしょう。
プロジェクトのエントリーファイルはappブロックで定義するみたいです。

```cdk.json
{
  "app": "python3 app.py",
}
```

## GenUのコードを見てみる

さてさて、AWS CDKのチュートリアルを実践したことでCDKのプロジェクト構成が理解できました。
本題のGenUのソースコードを追ってみます。

`cdk.json`が`./packages/cdk`下にあるので、ここがCDKプロジェクトのルートディレクトリですね。
まずGenUはTypescriptでCDKのコードが定義されているので、`cdk.json`のappブロックは以下のような記述です。

```cdk.json
{
    "app": "npx ts-node --prefer-ts-exts bin/generative-ai-use-cases.ts"
}
```

またチュートリアルとは違い`context`ブロックに次のような定義があり、CDKコード内で使われているのではと推測。

```json
"ragKnowledgeBaseEnabled": false
```

探してみる

```bin/generative-ai-use-cases.ts
// 省略
const ragKnowledgeBaseEnabled =
  app.node.tryGetContext('ragKnowledgeBaseEnabled') || false;
const ragKnowledgeBaseStack = ragKnowledgeBaseEnabled
  ? new RagKnowledgeBaseStack(app, 'RagKnowledgeBaseStack', {
      env: {
        account: process.env.CDK_DEFAULT_ACCOUNT,
        region: modelRegion,
      },
      crossRegionReferences: true,
    })
  : null;
// 省略
```

ありました。`tryGetContext`でコンテキストを参照し、値によってスタックを作成するか分岐しています。柔軟ですね。

Lambda以外のリソースについても`aws-cdk`に定義されているクラスを使用して定義していけば良さそうです。

コードの詳細までは追えてませんが、ざっとどんな感じでGenUが構成されているか理解できました。

## おわりに

普段TerraformやAWS SAMを使用していますが、`if`や`for`で作成するリソースを柔軟に定義できるのは良いですね。
Terraformでも同じようなことはできますが、他のリソースから参照する場合などにコードが煩雑になってしまうのがモヤポイントでした。
また、LambdaのデプロイにローカルマシンでDockerを使用しないで良い点も嬉しいです。
社内LANがProxyを使用している関係で、環境構築に手間取るケースが多く。。。

システムの規模によって使い分けていこうと思います。

[Amazon Bedrock]: https://aws.amazon.com/jp/bedrock/
[CloudFormation]: https://aws.amazon.com/jp/cloudformation/
[Terraform]: https://www.terraform.io/