はじめに
こんにちは。トライベック株式会社の清水です。
今さらですが、既存の 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 を選択した理由は以下の通りです。
- AWS純正のため長期サポートが期待できる
- CloudFormationベースで、既存のAWSリソースとの親和性が高い
- CodeBuildとの連携が容易
- 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.tsでDB_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 |
オリジン更新手順
- AWSコンソール → CloudFront → ディストリビューション
- 対象のディストリビューションを選択
- オリジンタブ → API Gatewayオリジンを選択 → 編集
- オリジンドメインを新しいAPI Gateway IDに変更
# 変更前
old-api-id.execute-api.ap-northeast-1.amazonaws.com
# 変更後
new-api-id.execute-api.ap-northeast-1.amazonaws.com
- 変更を保存
- デプロイが完了するまで数分待機
動作確認(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への移行で押さえるべきポイントは以下の通りです。
-
設定ファイルの変換:
serverless.ymlの記法をSAMの記法に変換 -
環境変数の明示的な定義:
NODE_ENVやCOGNITO_REGIONなど、暗黙的に設定されていた変数を明示的に追加 -
静的ファイルのコピー:
package.includeで指定していたファイルはbuildspec.yamlで手動コピー - 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でなければもう少し楽だったかもしれません。