今回は、Serverless Framework の料金改定(V4)に伴い、「既存の Serverless Framework 環境を AWS CDK (TypeScript) に移行する」という課題をいただいたので、取り組んでみました。

「NestJS を Lambda で動かす」というアプリケーションの構成自体は一切変えず、インフラ管理(IaC)の部分だけを CDK ベースに置き換えるのがゴールです。

本稿では、その移行プロセスと、作業中に遭遇した問題(特にビルド周り)について共有します。

目次

  1. なぜ移行するのか?
  2. アプリ概要
  3. 移行後(Before/After)
  4. 移行・セットアップ手順
  5. 詰まったポイント
  6. まとめ

なぜ移行するのか

理由:Serverless Framework V4 の料金体系変更

2023年10月、Serverless Framework V4 からの有償化(一定規模以上の組織利用等)が発表されました。 V4 を利用する場合、以下のようなコスト課題が発生します。詳しくはこちら

  • ライセンス費用: 商用利用や組織規模に応じたライセンス料が発生。
  • 複雑な「Credits」計算: 課金単位となる Credits は region × stage × service の掛け算で計算されるため、開発環境や検証環境が増えるほどコストが嵩み、見積もりが難しくなる。

これに対し、AWS CDK は AWS 公式ツールであり無償で利用可能です。「コスト最適化」と「ベンダーロックインの回避」の観点で、CDK が有力な選択肢となりました。

*Serverless Frameworkとは

YAMLファイルによる設定だけで、AWS Lambdaなどのサーバーレスアプリケーションを簡単に構築・デプロイできる、マルチクラウド対応のオープンソースフレームワーク。
サーバーレス(FaaS)に特化しており、簡単なAPIやバッチ処理を素早く立ち上げるのに最適。

*AWS CDKとは

TypeScriptやPythonなどの一般的なプログラミング言語を使って、AWS上のインフラストラクチャをコードとして定義・構築できる、AWS公式のIaC(Infrastructure as Code)フレームワーク。
言語の機能(ループ、条件分岐、型補完など)を活用してインフラを記述し、最終的にCloudFormationテンプレートに変換される。

アプリ概要(ざっくり前提)

ファイル構成

api/
 ├── src/
 │  ├── app.controller.ts
 │  ├── app.module.ts
 │  └── index.ts(handler)
 ├── serverless.yml(元)
 ├── package.json
 │
 ├── cdk/(新規追加)
 │  ├── bin
 │  │    └──cdk.ts
 │  ├── lib
 │  │    └──cdk-stack.ts
 │  └── layers
 │       └──nodejs
 │           └──package.json
  • 一般的なNestJSの構成
  • 今回はcdkディレクトリーを追加

使用リソース

  • API Gateway と Lambda
    • API Gateway からのリクエストをすべて1つのLambdaで受ける構成(Monolithic)
    • そこから serverless-express(NestJS Adapter) でアプリ内部の router を動かします
  • DynamoDB / S3
    • 当アプリの場合は DynamoDB / S3 を 参照のみで運用 していました(必須構成ではなく必要に応じて追加・変更可能)
    • 今回は移行テストのため、動作確認用に CDK で作成しました
  • その他(外部連携)
    • 複数の外部APIやOpenSearchなど(※今回は割愛)

移行後:Before & After

Before(serverless.yml)

service: nestjs-api
useDotenv: true
frameworkVersion: '3'

provider:
  name: aws
  stage: dev
  runtime: nodejs20.x
  region: us-west-2
  timeout: 29
  versionFunctions: false
  layers:
    - { Ref: NodeModulesLambdaLayer }

iamRoleStatements:
  - Effect: Allow
    Action:
      - "dynamodb:*"
      - "s3:*"
    Resource: "*"

package:
  individually: true
  patterns:
    - "!**"
    - "dist/**/*.js"
    - "dist/**/*.json"

layers:
  nodeModules:
    path: .tmp/modules
    compatibleRuntimes:
      - nodejs20.x
    name: node-modules-layer
    package:
      patterns:
        - "nodejs/node_modules/**"

functions:
  index:
    handler: dist/index.api
    environment:
      ENV: ${file(./.env.yml):ENV}
      UPLOAD_BUCKET: ${file(./.env.yml):UPLOAD_BUCKET}
      # その他 環境変数参照

After (cdk-stack.ts)

※CDK では、TypeScript のクラスやメソッドを使ってリソースを定義します。 今回のアプリでは DynamoDB と S3 を使用しているため、それらを定義する場合は以下のように記述します。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as s3 from 'aws-cdk-lib/aws-s3'; 
import * as iam from 'aws-cdk-lib/aws-iam';
import * as dotenv from 'dotenv';

dotenv.config({ path: path.join(__dirname, '../../.env') });

export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Layer(node_modules を配置)
    const layer = new lambda.LayerVersion(this, 'NodeModulesLayer', {
      code: lambda.Code.fromAsset('../layers'),
      compatibleRuntimes: [lambda.Runtime.NODEJS_22_X],
    });

    // IAM Role
    const lambdaRole = new iam.Role(this, 'LambdaExecutionRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
      ],
    });

    lambdaRole.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ['lambda:*','dynamodb:*','s3:*'],
      resources: ['*'],
    }));

    // DynamoDB Tables       
    const sampleTable = new dynamodb.Table(this, 'SampleTable', {
        tableName: 'sample-table',
        partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
        sortKey: { name: 'createdAt', type: dynamodb.AttributeType.STRING },
        billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
    });
    
    // インデックス(GSI/LSI)の追加例
    sampleTable.addLocalSecondaryIndex({
        indexName: 'category-index',
        sortKey: { name: 'category', type: dynamodb.AttributeType.STRING },
    });


    // S3 Bucket
    const sampleBucket = new s3.Bucket(this, 'SampleBucket', {
        bucketName: "sample-app-bucket",
        autoDeleteObjects: true,
    });

    // Lambda 関数にビルド済みの dist フォルダを渡す
    const apiFunction = new lambda.Function(this, 'ApiHandler', {
      runtime: lambda.Runtime.NODEJS_22_X,
      timeout: cdk.Duration.seconds(29),
      handler: 'index.api',
      role: lambdaRole,
      layers: [layer],
      code: lambda.Code.fromAsset('../../dist'),
      environment: {
        ENV: process.env.ENV ?? '',
        UPLOAD_BUCKET: sampleBucket.bucketName,
        //その他 環境変数参照
      },
    });

    // API Gateway で Proxy
    new apigateway.LambdaRestApi(this, 'ApiGatewayProxy', {
      handler: apiFunction,
      proxy: true,
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: apigateway.Cors.ALL_METHODS,
      },
    });
  }
}
  • CDK では dist フォルダをまるっと Lambda に渡すだけ
  • node_modules は、 軽量化のため Lambda Layer に分離

移行&セットアップ手順

① まずアプリ側でビルドする

cd api
npm install
npm run build   #dist フォルダが生成される

② CDK プロジェクトを初期化

cd api/cdk
cdk init app --language=typescript

③ 依存をLayerへ持って行く

  • api/package.json の内容を Layer の package.json にコピー
  • その後 不要な devDependencies などを削除
  • Layer フォルダで:
cd api/cdk/layers/nodejs
npm install   #layer用 node_modules生成

④ CDKデプロイ

cd api/cdk
cdk deploy
  • これだけでインフラ側は cdk-stack.ts に書いた通りデプロイされます ✅

詰まったポイント

  • 今回の移行で最も躓いたのが、「どうやって TypeScript コードを Lambda に上げるか」 という点です。
  • 当初は NodejsFunction という便利なコンストラクト使って、「CDK 側でよしなにビルドしてもらう」構成で進めていました。
const apiFunction = new lambdaNodejs.NodejsFunction(this, 'ApiHandler', {
  entry: path.join(__dirname, '../../src/index.ts'), // NestJS のエントリポイント
  handler: 'api',
  runtime: lambdaNodejs.Runtime.NODEJS_22_X,
  bundling: {
    minify: false,
    sourceMap: true,
    externalModules: ['aws-sdk'],
  },
});

*NodejsFunction / esbuild とは?

AWS Lambda関数をデプロイするためのコンストラクト。
esbuildというバンドラーを使用して、TypeScriptやJavaScriptで記述されたソースコードをAWS Lambda実行環境向けに最適化。
esbuildは、非常に高速なJavaScriptおよびTypeScript向けのバンドラー(複数のソースファイルを一つにまとめたり、最適化したりするツール) 

しかし、、、

TypeError: Cannot read properties of undefined
  • デプロイ後、Lambda 実行時に上記のエラーが
  • 調べていくと、NestJS の runtime 起動時に必要な module が循環参照の影響で undefined になっていました。
    ※循環参照:(例 ModuleAModuleBModuleA の相互 import)

原因(おそらく)

  • Gemini先生に力を借りながら、こう解釈しました。
    • esbuild は「ソースを 1 つの JS ファイルにまとめる(バンドル)」 = 依存解決の順序が変わる
    • このアプリはモジュールが複雑で循環参照(A → B → Aみたいな依存)がある
    • NestJS はデコレータやリフレクション(reflect-metadata)を多用するため、バンドルによるクラスの読み込み順序変更やファイル結合に敏感である
    • 結果、esbuildのバンドル順序では解決できず、クラスが定義される前に参照されて undefined

解決策

↓これがわかりやすく、無事動きました。

  • CDK 側で TypeScript を bundle するのをやめる
  • NestJS標準のビルド(tsc)で dist を作る
  • CDK はビルド済みの dist を zip にしてAWSに送るだけ
  • 大きい依存は Lambda Layer に逃がす

まとめ

今回は、NestJS の「API Gateway と Lambda (Proxy)」構成を変えず、インフラ管理だけを Serverless Framework から AWS CDK へ移行しました。

移行手順そのものはシンプルで、同じ方針であれば再現できると思います。参考になりましたら幸いです ✨