【CDK】サーバーレスなAPIをIACで初めて作った話


業務で、初めてLambdaとCDKを使って実装を行いました！

ずっと気になってたAWSサービスですが、ハンズオン以外使ったことありませんでした。
勉強がてら備忘録と使った感想を書いておこうと思います！

JumpStartやAWSome dayを受講して理解が深まったのもあり、頭がホットなうちにアウトプットしたいと思います。

## なぜlambdaを使ったのか

チャットボットのAPIを作る必要があり、初めてAWSのLambdaを使ってみました。  
今回の要件はこんな感じです🤖
 
- チャットボットの作成（LLMはBedrockを使用）  
- jsファイルを既存のプロダクトにiframeで埋め込む形 → Next.jsサーバーのAPIは使えない  
- Cookieベースの認証は、ドメインが異なったり、WebコンポーネントからCookieを取得できない（HttpOnly）ｎ既存のAPI認証が使えない  

認証含め、**Lambda + API Gateway**で独立したAPIをCDKで管理して作ることになりました  

## なぜCDK（IAC）でLambdaを実装したのか

サラッといきます☺️

- バージョン管理できる（Gitでコード・インフラ一元管理）  
- 再利用性が高い（コンストラクトで共通化も可）  
- 他のAWSサービスとの連携がしやすい（S3、Athena、Bedrockなど）  
- 細かい制御・権限を明示的に書ける（IAMポリシーなど）  
- デプロイの自動化がしやすい（CI/CDにも乗せやすい） 

## 基本的な設定


### Lambda

こんな感じで、lambdaリソースを定義しています。
ラムダ初心者でもコードを見れば、何をやっているのか理解できました！

```ts
const exampleHistoryLambda = new NodejsFunction(this, 'ExampleHistoryFunction', {
  runtime: lambda.Runtime.NODEJS_22_X,
  entry: './lambda/api/example-history.ts',
  timeout: Duration.seconds(5),
  memorySize: 128,
  environment: {
    TABLE_NAME: props.exampleHistoryTable.tableName,
  },
  logGroup: new cdk.aws_logs.LogGroup(this, 'ExampleHistoryLogGroup', {
    logGroupName: `/aws/lambda/${props.projectName}-${props.envName}-example-history-function`,
    retention:
      props.envName === 'prd'
        ? cdk.aws_logs.RetentionDays.ONE_YEAR
        : cdk.aws_logs.RetentionDays.THREE_DAYS,
    removalPolicy: cdk.RemovalPolicy.DESTROY,
  }),
});


```
#### 各プロパティの意味

- **runtime**  
　Lambdaで使用するNode.jsのバージョン。今回は NODEJS_22_X を指定していて、NODEJS_18_Xはそろそろ廃止になるらしい。

  
- **entry**  
　Lambdaの実装ファイルのパス。TypeScriptで書かれた .ts ファイルでも、NodejsFunction を使えば自動的にバンドル・トランスパイルしてくれるので便利です。
 
 - **logGroup**  
  LambdaのログはCloudWatchに出る。  
  デフォルトだとランダムな名前になるので、任意の名前をつけておくとデバッグがしやすい。
  retentionで環境によってログの保有時間も指定できる。
  
- **memorySize**  
  CloudWatchログで実行時の使用メモリと最大設定メモリが出る(画像)

  実行時のメモリに対してオーバースペックでないか、できるだけ小さく設定しておくとコスト削減になる。
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621281/129cffd2-922f-4cb4-8b09-d5081fd5427f.png)


- **Duration（タイムアウト）**  
  処理が長引いてタイムアウトすると再実行されてしまう。  
  無駄なリトライを防ぐためにも、適切な時間に設定しておくのが大事。
  例えば、いいね処理をするのに、60秒繰り返し処理をするのはオーバースペック。
  

- **環境変数**  
  Lambdaは独立した実行環境になる。  
  S3のバケット名など、各ラムダが外部リソースの名前を、必要なリソース分だけ環境変数で渡して管理するようにした。そうするとサービスの概要がわかりやすい。
  
#### 権限（IAMポリシー）の付与

Lambda関数内でDynamoDBなどのAWSを操作するために、必要なIAMポリシーをLambdaに直接アタッチしています。

```ts
// DynamoDBへのアクセス権限を付与
exampleHistoryLambda.addToRolePolicy(
  new iam.PolicyStatement({
    actions: ['dynamodb:UpdateItem'],
    resources: [
      `arn:aws:dynamodb:${props.env.region}:${props.env.account}:table/${props.ExampleHistories.tableName}`,
    ],
  }),
);

```

LambdaでAWS SDKを使ってDynamoDBに書き込みを行う場合、必ずその操作権限がLambdaに与えられている必要があります。
たとえば dynamodb:UpdateItem を使うなら、使う操作のみ、明示的に許可しておくのがベストプラクティスに沿っています。

（私はよくフルアクセスにしていました。。）

### APIGateWayを定義

Lambdaと連携するために、API GatewayもCDKで定義しています。

先ほど作成したlambdaリソースを起動するための、エンドポイントをAPIGateWayに作成します。
今回はチャット履歴の保存用に、/exmaple/history というエンドポイントをPOSTで作成しました。

```ts
// API Gateway の作成
const api = new apigateway.RestApi(this, 'ExampleApi', {
  restApiName: 'Example Service',
  description: 'API for example feature',
});

// チャット履歴APIリソースの作成
// path: /exmaple/history
const exampleResource = api.root.addResource('example');
const exampleHistoryResource = exampleResource.addResource('history');

// CORSの設定
exampleHistoryResource.addCorsPreflight({
  allowOrigins: apigateway.Cors.ALL_ORIGINS,
  allowMethods: ['POST', 'OPTIONS'],
  allowHeaders: apigateway.Cors.DEFAULT_HEADERS,
});

```

外部のドメイン（今回はiframeで埋め込んだjs）からリクエストを送るため、CORS（クロスオリジンリクエスト）を有効にしておく必要があります。

ALL_ORIGINS：どこからでもアクセスできる（開発中はこれでOK、本番では制限推奨）
OPTIONS：CORSのプリフライトリクエスト用メソッド
DEFAULT_HEADERS：一般的なリクエストヘッダーを許可

### Api GatewayとLambdaの連携

API Gatewayにリクエストが来たとき、どのLambdaを実行するかを紐付けます。
紐づけるためのサービスをラムダインテグレーションといいます。
かっこいい言葉ですね。

```ts
// Lambda統合の作成
const exampleHistoryIntegration = new apigateway.LambdaIntegration(exampleHistoryLambda);

// POSTメソッドの追加
exampleHistoryResource.addMethod('POST', exampleHistoryIntegration, {
  methodResponses: [
    {
      statusCode: '200',
      responseModels: {
        'application/json': apigateway.Model.EMPTY_MODEL,
      },
    },
  ],
  authorizer: props.authorizer,
  authorizationType: apigateway.AuthorizationType.CUSTOM,
});
```

実際何をやっているかはシンプルで、
あるエンドポイントにリクエストが来たとき、どのラムダを実行するか決めます。
MVCモデルのRESTのコントローラーのようなもの。

例でいうとこの統合を /example/history に対して POST メソッドで先程作ったLambdaを追加します。

メソッドの追加と認証もしています。
このLambdaを実行する前にかならずAuthlizerを設定します。
メソッドレスポンス：ステータスコードやレスポンス形式を指定

認可設定：
authorizer: Lambda Authorizerなどで定義した認証ロジックを通す
authorizationType: CUSTOM を指定（今回はJWTなどを使った認証を想定）

事前に用意しておいた認可用のラムダを、サービスとしてのラムダを実行する前に実行して、確認しています。
これにより、必ず認可を通過したユーザーだけがチャット履歴を保存できるようになります。

#### APIGateWay＋Lambdaのよくある認証設計の考え方

authorizerについてもう少し深堀りします。

インターネット上にサービスを公開する際に、限られたユーザーや特定のシステムだけがアクセスできるよう制限したいことがあります。
こうした場合には、API Gateway の認証機能を活用することで、効率的にアクセス制御を行うことができます。

今回の例でいうと、ログインしたあとの画面でチャットボットを使えるようにしたかったので、使用しております。

公式の画像を拝借しましたが、APIの認可フローとしても推奨されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3621281/0018dedf-ef32-4e8c-9d61-d8298f7f7a8a.png)

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html



## 終わりに

使ってみて感じたのは、とにかく 「APIをサクッと作れる」 ということでした。

今回は初めてLambdaとCDKを業務で触ったのですが、思った以上に直感的で、慣れていなくても進めやすかったです。
API Gatewayと組み合わせることで、独立したシンプルなAPIを最小構成で立ち上げることができ、Next.jsのAPIが使えないような制約があっても全く問題ありませんでした。

実際、パッチ処理のような「小さな処理を外出ししたい」ときや、「一部だけ切り出して別環境で運用したい」といったニーズに非常にマッチしていると感じました。
今回はBedrockとの連携を前提にしたチャットボット用のAPIでしたが、どんなAI連携でもサーバーレス構成は相性が良さそうです。

加えて、CDKによってインフラがコードで管理できるのはとても安心感があります。

もちろんデメリットも承知していて、コードで書く分、学習コストやマネコンがよしなにやってくれていたことをこちらで設定してあげないと動きません。

たがGit管理できるということは、チームでのレビューや環境差分の吸収、検証もしやすいということです。

実際にデプロイするときはチームで集まって、CDKのソースコードをベースに話し合いを進めながら、各々が開発したリソースを見ながらデバックしつつデプロイできたのは、チーム開発においてはかなり効率的に感じました。

ロールバックのメリットも大きく、変更後のデプロイの対応もスムーズです。

Lambdaを使って簡単なAPIを作っただけでしたが、総合的にAWSがついていいタスクでした☺️