0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Node.js 22 対応を機に Serverless Framework から AWS SAM へ移行した話

Last updated at Posted at 2025-12-24

はじめに

こんにちは。トライベック株式会社の清水です。
今さらですが、既存の Serverless Framework v3 を利用した Lambda / API Gateway 構成の社内開発ツールを AWS SAM + CodeBuild に移行しました。

Node.js 18のサポート期限は2025年4月30日で終了しており、現在、2025年12月23日時点でLambda ランタイムは非推奨となっております。非推奨化後のランタイムの使用について

選択肢 廃止日 関数の作成をブロックする 関数の更新をブロックする
Node.js 18 2025年9月1日 2026年2月3日 2026年3月9日
Node.js 20 2026年4月30日 2026年6月1日 2026年7月1日
Node.js 22 2027年4月30日 2027年6月1日 2027年7月1日

Node.js 22へのバージョンアップ対応と合わせてAWS SAMに移行しましたので、その内容の記事となります。

本記事では、

  • なぜ AWS SAM を選択したか
  • CodeBuild から sam deploy する際の構成
  • IAM / Stage 管理 / 環境変数の整理方法
  • デプロイ後に API が正常応答するまでの確認手順

を、まとめておきます。

対象読者

  • Serverless Framework から AWS SAM への移行を検討している方
  • CI/CD(CodeBuild)経由で SAM デプロイを行いたい方
  • Lambda / API Gateway / IAM を明示的に管理したい方

移行方針

前提構成

  • ランタイム: Node.js 18.x ⇒ Node.js 22.x
  • 認証: Amazon Cognito
  • CDN: Amazon CloudFront
  • CI/CD: AWS CodeBuild

なぜ AWS SAM を選択したか

Serverless Framework v3 から移行先を検討する際、以下の選択肢がありました。

選択肢 メリット デメリット
Serverless Framework v4 移行コストが低い ライセンス体系の変更(有償化)
AWS SAM AWS純正、CloudFormationベース 学習コストあり
AWS CDK 柔軟性が高い、TypeScriptで記述可能 学習コストがSAMより高い
Terraform マルチクラウド対応 Lambda特化ではない

最終的に AWS SAM を選択した理由は以下の通りです。

  1. AWS純正のため長期サポートが期待できる
  2. CloudFormationベースで、既存のAWSリソースとの親和性が高い
  3. CodeBuildとの連携が容易
  4. serverless.ymlからの変換が比較的シンプル

CDKでも良かったのですが、開発規模が小さかったのとあまり時間をかけたくなかったので、キャッチアップ優先でSAMにしました。


移行手順

Step 1: template.yaml の作成

Serverless Frameworkのserverless.ymlをSAMのtemplate.yamlに変換します。
Runtimeをnodejs22.xに変更しました。

変換の対応表

serverless.yml template.yaml
service Description
custom.environment Mappings
${self:custom...} !FindInMap
${opt:stage} !Ref StageName
provider.vpc VpcConfig
provider.iam.role AWS::IAM::Role
functions.handler Handler
functions.events.http Events.Type: Api

serverless.yml(移行前)

service: my-api-server
frameworkVersion: '3'

plugins:
  - serverless-plugin-typescript
  - serverless-offline

custom:
  defaultStage: local
  environment:
    local:
      RDB_NAME: local_db
      COGNITO_POOL_ID: ap-northeast-1_XXXXXXXX
      COGNITO_CLIENT_ID: xxxxxxxxxxxxxxxxxx
    test:
      RDB_NAME: test_db
      COGNITO_POOL_ID: ap-northeast-1_YYYYYYYY
      COGNITO_CLIENT_ID: yyyyyyyyyyyyyyyyyy
    prod:
      RDB_NAME: prod_db
      COGNITO_POOL_ID: ap-northeast-1_XXXXXXXX
      COGNITO_CLIENT_ID: xxxxxxxxxxxxxxxxxx

package:
  include:
    - src/config/AmazonRootCA1.pem

provider:
  name: aws
  runtime: nodejs18.x
  stage: ${opt:stage, self:custom.defaultStage}
  region: ap-northeast-1
  endpointType: REGIONAL
  vpc:
    securityGroupIds:
      - sg-xxxxxxxxx
    subnetIds:
      - subnet-xxxxxxxxx
      - subnet-yyyyyyyyy
  iam:
    role: LambdaExecutionRole

functions:
  index:
    handler: index.handler
    timeout: 29
    memorySize: 512
    environment:
      TZ: 'Asia/Tokyo'
      RDS_PROXY_PORT: 3306
      RDS_PROXY_ENDPOINT: my-rds-proxy.proxy-xxxx.ap-northeast-1.rds.amazonaws.com
      RDS_SECRET_NAME: my-secret-name
      RDB_NAME: ${self:custom.environment.${self:provider.stage}.RDB_NAME}
      COGNITO_REGION: ap-northeast-1
      COGNITO_POOL_ID: ${self:custom.environment.${self:provider.stage}.COGNITO_POOL_ID}
      COGNITO_CLIENT_ID: ${self:custom.environment.${self:provider.stage}.COGNITO_CLIENT_ID}
    events:
      - http:
          path: /top
          method: get
      - http:
          path: /login/getCognitoUserId
          method: post
      - http:
          path: /tables/getTablesList
          method: post

resources:
  Resources:
    LambdaExecutionRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: my-LambdaExecutionRole-${self:provider.stage}
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: 'Allow'
              Principal:
                Service:
                  - 'lambda.amazonaws.com'
              Action:
                - 'sts:AssumeRole'
        ManagedPolicyArns:
          - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole

template.yaml(移行後)

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: my-api-server

Parameters:
  StageName:
    Type: String
    Default: local
    AllowedValues:
      - local
      - test
      - prod

Mappings:
  StageConfig:
    local:
      RdbName: local_db
      CognitoPoolId: ap-northeast-1_XXXXXXXX
      CognitoClientId: xxxxxxxxxxxxxxxxxx
    test:
      RdbName: test_db
      CognitoPoolId: ap-northeast-1_YYYYYYYY
      CognitoClientId: yyyyyyyyyyyyyyyyyy
    prod:
      RdbName: prod_db
      CognitoPoolId: ap-northeast-1_XXXXXXXX
      CognitoClientId: xxxxxxxxxxxxxxxxxx

Globals:
  Function:
    Runtime: nodejs22.x
    Timeout: 29
    MemorySize: 512
    Environment:
      Variables:
        TZ: Asia/Tokyo
        NODE_ENV: !Ref StageName
        RDS_PROXY_PORT: 3306
        RDS_PROXY_ENDPOINT: my-rds-proxy.proxy-xxxx.ap-northeast-1.rds.amazonaws.com
        RDS_SECRET_NAME: my-secret-name
        RDB_NAME: !FindInMap [StageConfig, !Ref StageName, RdbName]
        COGNITO_REGION: ap-northeast-1
        COGNITO_POOL_ID: !FindInMap [StageConfig, !Ref StageName, CognitoPoolId]
        COGNITO_CLIENT_ID: !FindInMap [StageConfig, !Ref StageName, CognitoClientId]

Resources:
  ########################################
  # API Gateway (REGIONAL)
  ########################################
  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      Name: my-api-server
      StageName: !Ref StageName
      EndpointConfiguration: REGIONAL

  ########################################
  # Lambda Function
  ########################################
  IndexFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub 'my-api-server-${StageName}'
      Handler: dist/index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      VpcConfig:
        SecurityGroupIds:
          - sg-xxxxxxxxx
        SubnetIds:
          - subnet-xxxxxxxxx
          - subnet-yyyyyyyyy
      Events:
        Top:
          Type: Api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /top
            Method: get
        GetCognitoUserId:
          Type: Api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /login/getCognitoUserId
            Method: post
        GetTablesList:
          Type: Api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /tables/getTablesList
            Method: post

  ########################################
  # IAM Role
  ########################################
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 'my-LambdaExecutionRole-${StageName}'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
      Policies:
        - PolicyName: my-LambdaExecutionPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:CreateLogGroup
                  - logs:TagResource
                Resource:
                  - !Sub 'arn:aws:logs:ap-northeast-1:${AWS::AccountId}:log-group:/aws/lambda/my-api-server-${StageName}*:*'
              - Effect: Allow
                Action:
                  - logs:PutLogEvents
                Resource:
                  - !Sub 'arn:aws:logs:ap-northeast-1:${AWS::AccountId}:log-group:/aws/lambda/my-api-server-${StageName}*:*:*'

Outputs:
  ApiUrl:
    Description: API Gateway endpoint
    Value: !Sub 'https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${StageName}/'


Step 2: buildspec.yaml の作成

CodeBuildでSAMをビルド・デプロイするための設定ファイルを作成します。

version: 0.2

env:
  variables:
    STAGE_NAME: 'test'
    SAM_CLI_TELEMETRY: '0'

phases:
  install:
    runtime-versions:
      nodejs: 22
    commands:
      - echo "Node version:" && node -v
      - echo "NPM version:" && npm -v
      - echo "Checking SAM CLI"
      - |
        if ! command -v sam >/dev/null 2>&1; then
          echo "SAM not found. Installing via pip..."
          python3 --version
          python3 -m pip --version
          python3 -m pip install --upgrade aws-sam-cli
        fi
      - sam --version
      - echo "Installing dependencies"
      - npm ci

  pre_build:
    commands:
      - echo "Clean SAM build artifacts"
      - rm -rf .aws-sam
      - echo "Building TypeScript"
      - npm run build

  build:
    commands:
      - echo "Running SAM build"
      - sam build --cached --parallel

  post_build:
    commands:
      # SSL証明書をコピー(RDS Proxy接続用)
      - echo "Copying SSL certificate for RDS Proxy"
      - mkdir -p .aws-sam/build/IndexFunction/dist/config
      - cp src/config/AmazonRootCA1.pem .aws-sam/build/IndexFunction/dist/config/
      - echo "Verifying certificate copied"
      - ls -la .aws-sam/build/IndexFunction/dist/config/
      
      # デプロイ
      - echo "Deploying with SAM"
      - |
        sam deploy \
          --no-confirm-changeset \
          --no-fail-on-empty-changeset \
          --resolve-s3 \
          --stack-name "my-api-server-${STAGE_NAME}" \
          --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \
          --parameter-overrides StageName="${STAGE_NAME}"

cache:
  paths:
    - node_modules/**/*
    - .aws-sam/cache/**/*

ポイント: 静的ファイルのコピー

Serverless Frameworkではpackage.includeで指定していた静的ファイル(SSL証明書など)は、SAMではbuildspec.yamlで手動コピーする必要がありました。
デプロイが通って安心したのも束の間、502 Bad Gateway エラーになって気が付きました。

# serverless.yml(自動でパッケージング)
package:
  include:
    - src/config/AmazonRootCA1.pem

# buildspec.yaml(手動でコピー)
- mkdir -p .aws-sam/build/IndexFunction/dist/config
- cp src/config/AmazonRootCA1.pem .aws-sam/build/IndexFunction/dist/config/

コピー先のパスは、コード内で参照しているパスに合わせる必要があります。
ここもエラーが解消しなかったので、再度見直しして気づきました。

// dbConfig.ts
const caFilePath = path.join(__dirname, 'AmazonRootCA1.pem');
// __dirname = /var/task/dist/config/ となるため、そこに証明書をコピー

Step 3: 環境変数の整理

SAMではServerless Frameworkで暗黙的に設定されていた環境変数を明示的に定義する必要があります。

dotenvConfig の確認

以下のような設定をしていたのをすっかり忘れていました。

// dotenvConfig.ts
const nodeEnv = process.env.NODE_ENV || 'local';
dotenv.config({ path: `.env.${nodeEnv}` });

NODE_ENVが未設定の場合、ローカル環境用の設定を使用するようにしていたので、template.yamlにもNODE_ENVの設定が必要です。

追加が必要な環境変数

変数名 説明 設定値
NODE_ENV 実行環境の識別 !Ref StageName
COGNITO_REGION Cognitoリージョン ap-northeast-1
# template.yaml
Globals:
  Function:
    Environment:
      Variables:
        NODE_ENV: !Ref StageName        # 必須
        COGNITO_REGION: ap-northeast-1  # 必須

設定を忘れていて、ここでも502 Bad Gateway エラーに嵌りました。
COGNITO_REGIONも、ついでに記載が漏れていたので追記しました。


Step 4: ローカル環境でのテスト

Expressを使用しているため、ローカル環境ではExpressサーバーを直接起動にしました。
元々serverless-offlineとExpressと両方起動できる構成だったので、SAM Localも導入したいと思ったのですが、一旦移行作業を優先したかったので、今回は入れていません。

ローカルサーバーの構成

Lambda関数のエントリーポイントとは別に、ローカル開発用のサーバーファイルを用意しました。

// src/local-server.ts
import app from './app';

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});
// src/app.ts
import express from 'express';
import router from './router';

const app = express();
app.use(express.json());
app.use('/', router);

export default app;
// src/index.ts(Lambda用エントリーポイント)
import serverlessExpress from '@codegenie/serverless-express';
import app from './app';

export const handler = serverlessExpress({ app });

package.json のスクリプト設定

{
  "scripts": {
    "dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts",
    "build": "tsc",
    "start:local": "node dist/local-server.js",
    "sam:build": "sam build"
  }
}

環境変数ファイル(.env.local)

ローカル用の環境変数は.env.localに定義します。

# .env.local
TZ=Asia/Tokyo
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=password
DB_NAME=local_db
COGNITO_REGION=ap-northeast-1
COGNITO_POOL_ID=ap-northeast-1_XXXXXXXX
COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxx

ローカルサーバーの起動

node_modulesを入れ替えて、sam buildでdistフォルダができることを確認してから起動します。

# node_modulesのinstall
npm install

# ビルド
sam build

# ローカル起動
npm run start:local

動作確認

curl http://localhost:3000/top
# → {"message":"top"}

ローカルとAWS環境の切り替え

dbConfig.tsDB_HOSTの有無により接続先を切り替えています。

// dbConfig.ts
const loadDBConfig = async () => {
  if (process.env.DB_HOST) {
    // ローカル環境: 直接DB接続
    return {
      host: process.env.DB_HOST,
      port: Number(process.env.DB_PORT),
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
    };
  } else {
    // AWS環境: RDS Proxy経由 + SSL
    const secret = await getSecret();
    const caFilePath = path.join(__dirname, 'AmazonRootCA1.pem');
    return {
      host: process.env.RDS_PROXY_ENDPOINT!,
      port: Number(process.env.RDS_PROXY_PORT!),
      user: secret.username,
      password: secret.password,
      database: process.env.RDB_NAME!,
      ssl: { ca: fs.readFileSync(caFilePath) },
    };
  }
};

ローカルではExpressサーバーとして動作AWS上ではLambda関数として動作するようにしています。


Step 5: デプロイと動作確認

1. CodeBuildでデプロイ

CodeBuildを実行し、デプロイが成功することを確認します。ビルドログで以下の出力を確認します。

Successfully created/updated stack - my-api-server-test in ap-northeast-1

2. API Gateway IDの確認

デプロイログに新しいAPI Gateway IDが出力されます。

CloudFormation outputs from deployed stack
-------------------------------------------------------------------------------------------------
Key                 ApiUrl
Description         API Gateway endpoint
Value               https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/test/
-------------------------------------------------------------------------------------------------

3. 動作確認(API Gateway直接)

API Gatewayに直接アクセスして、Lambda関数が正常に動作することを確認します。

# GETリクエスト
curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/test/top
# → {"message":"top"}

# POSTリクエスト
curl -X POST https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/test/login/getCognitoUserId \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com"}'

Step 6: CloudFront のオリジン更新

SAMでデプロイすると 新しいAPI Gateway が作成されます。既存のCloudFrontを使用している場合、オリジン設定の更新が必要でした。

なぜAPI Gateway IDが変わるのか

Serverless FrameworkとSAMは別々のCloudFormationスタックを作成するため、API Gatewayも新規に作成されます。

デプロイ方法 スタック名 API Gateway ID
Serverless Framework serverless-my-api-test old-api-id
SAM my-api-server-test new-api-id

オリジン更新手順

  1. AWSコンソール → CloudFront → ディストリビューション
  2. 対象のディストリビューションを選択
  3. オリジンタブ → API Gatewayオリジンを選択 → 編集
  4. オリジンドメインを新しいAPI Gateway IDに変更
# 変更前
old-api-id.execute-api.ap-northeast-1.amazonaws.com

# 変更後
new-api-id.execute-api.ap-northeast-1.amazonaws.com
  1. 変更を保存
  2. デプロイが完了するまで数分待機

動作確認(CloudFront経由)

curl https://your-domain.com/test/top
# → {"message":"top"}

何度かトライ&エラーを繰り返しましたが、ここでようやく導通ができてホッとしました。


トラブルシューティング

502 Bad Gateway エラーが発生した場合

CloudWatch Logsでエラー内容を確認して、以下の対処を行ったのでまとめておきます。

エラー内容 原因 対処方法
ECONNREFUSED 127.0.0.1:3306 NODE_ENV未設定で.env.localが読み込まれた template.yamlにNODE_ENVを追加
ENOENT: .../AmazonRootCA1.pem SSL証明書がパッケージに含まれていない buildspec.yamlで正しいパスにコピー
CloudFront origin error CloudFrontが古いAPI Gatewayを指している オリジンドメインを更新
トークンの検証に失敗 COGNITO_REGIONが未設定 template.yamlにCOGNITO_REGIONを追加

まとめ

Serverless FrameworkからAWS SAMへの移行で押さえるべきポイントは以下の通りです。

  1. 設定ファイルの変換: serverless.ymlの記法をSAMの記法に変換
  2. 環境変数の明示的な定義: NODE_ENVCOGNITO_REGIONなど、暗黙的に設定されていた変数を明示的に追加
  3. 静的ファイルのコピー: package.includeで指定していたファイルはbuildspec.yamlで手動コピー
  4. CloudFrontの更新: 新しいAPI Gateway IDへのオリジン更新が必要

SAMのキャッチアップに多少時間はかかりましたが、移行作業自体は半日〜1日程度で完了しました。
デプロイ後の動作確認で問題が発生したので、事前に確認ポイントを整理しておくことをおすすめします。


補足: class-validator の修正

Node.js 18 から Node.js 22 へのバージョンアップに伴い、class-validatorの import 方法を修正する必要がありました。

問題

// ❌ Node.js 22 + ESM で動作しない
import * as validator from 'class-validator';

export class SampleDto {
  @validator.IsString()
  @validator.IsNotEmpty()
  name: string;
}

名前空間インポート(import * as)でデコレータを使用すると、Node.js 22 の ESM 環境で正常に動作しません。

解決方法

// ✅ 名前付きインポートに変更
import { IsString, IsNotEmpty } from 'class-validator';

export class SampleDto {
  @IsString()
  @IsNotEmpty()
  name: string;
}

名前付きインポートに変更することで、Node.js 22 環境でも正常に動作しました。
class-validatorでなければもう少し楽だったかもしれません。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?