TypeScript × express APIをAWS Lambdaにデプロイする際の勘所
はじめに
TypeScriptとexpressで書いたAPIを、AWS Lambdaにデプロイしたい、と考えたこと、ありますよね。私は昨日考えていました。
ローカルでは ts-node などで手軽に動作確認できるものの、いざLambdaにデプロイしようとすると、TypeScriptはそのままではLambdaで動かない(トランスパイルが必要)し、node_modules が肥大化してLambdaのサイズ制限に引っかかるし、なんだかんだハマることが多いです。しかも今回はいろんな事情でECR on Lambda(Dockerイメージを使う方法)が選択できないとか、serverless frameworkで楽ができないとかいう制約があったせいで余計悩みました。
この記事では、同じような状況で困っている方のために、私がどのようにしてこれらの課題を解決したかを記録としてまとめます。
今回の構成
前提となる環境とディレクトリ構成は以下の通りです。
- 言語・フレームワーク: TypeScript, Node.js (v22), express
- AWS SDKバージョン: AWS SDK v3
- デプロイ: GitHub Actions
ルートフォルダ/
├─ package-lock.json
├─ package.json
├─ tsconfig.json
├─ dist/
│ └─ handler.js
├─ node_modules/
└─ src/
├─ handler.ts
└─ app/
├─ index.ts (expressアプリケーション本体)
└─ ...
ローカルでの開発中は src/app/index.ts を実行して動作確認し、最終的に src/handler.ts をLambdaのハンドラーとしてデプロイすることを目指します。
Lambdaのサイズ制限について
まず、Lambdaにデプロイする上で避けて通れないのがサイズ制限です。主な制限は以下の通りです。
-
zip圧縮後のサイズ: 50MB
- これを超えると、S3経由でのアップロードが必要になります。
-
展開後のサイズ: 250MB
- これが最も重要な制限で、関数本体、すべてのLambdaレイヤー、カスタムランタイムを合計したサイズです。
node_modules を含めると、この250MBの壁は意外と簡単に超えてしまいます。
ちなみに、DockerイメージをLambdaで動かすECR on Lambdaであれば、この上限は10GBまで緩和されます。だから普段は意識せずに済んでたのか。
課題と対応方法
冒頭で挙げた2つの課題について、どのように対応したかを解説します。
課題1: TypeScriptをLambdaにデプロイする
TypeScriptのコードは、JavaScriptにトランスパイル(変換)しないとNode.jsランタイムでは実行できません。
デプロイの方法はいくつか考えられます。
-
CDK (Cloud Development Kit) を使う
AWSが提供するIaCツールで、だいぶコードっぽくインフラを記載できます。aws-lambda-nodejsモジュールを使えば、Lambdaへのデプロイ時に自動でTypeScriptのトランスパイルとバンドルを行ってくれるのであんまり考えなくてよくなる。 -
Serverless Frameworkを使う
サーバーレスアプリケーション専門のTerraform的なやつです。serverless.ymlという設定ファイルにインフラ情報を記述し、serverless-esbuildなどのプラグインを導入することで、TypeScriptのビルドからデプロイまでを自動化できます。 -
GitHub Actionsでビルド&デプロイする
CI/CDツール上でビルドプロセスを組み、生成されたzipファイルをAWS CLIでアップロードする方法です。要は自力でゴリゴリするパターン。
今回は、インフラ構成を別チームがTerraformで管理していた背景もあり、アプリケーションのコードだけをシンプルにデプロイできる 3. GitHub Actions を採用しました。
やったこと
1. esbuildでトランスパイル
package.json の scripts に、esbuild を使ってトランスパイルとバンドルを行うコマンドを追加します。esbuild は非常に高速なのが特徴です。
{
"scripts": {
"build": "esbuild src/handler.ts --bundle --platform=node --target=node22 --format=cjs --minify --outdir=dist"
}
}
-
--bundle: 依存関係をすべて1つのファイルにまとめます。 -
--platform=node --target=node22: Node.js v22環境で実行することを指定します。 -
--format=cjs: CommonJS形式で出力します。LambdaのNode.jsランタイムはこちらを想定しています。 -
--minify: コードを圧縮してファイルサイズを削減します。 -
--outdir=dist:distディレクトリに成果物を出力します。
2. GitHub Actionsでビルド&デプロイ
ワークフローの中で、ビルド、node_modules の整理、zip化、そしてAWS CLIでのデプロイを実行します。
- name: Build and Deploy
run: |
npm ci
npm run build
npm prune --omit=dev
zip -r ../sample.zip dist node_modules
- name: Deploy to Lambda
run: |
aws lambda update-function-code \
--function-name MyLambdaFunction \
--zip-file fileb://../sample.zip
esbuild などのビルドツールは devDependencies に含まれるため、npm ci の後にビルドを実行し、その後で本番に不要な devDependencies をnpm prune --omit=devで削除しています。
3. Lambdaハンドラーの作成
Lambdaのエントリーポイントとなる handler.ts は以下のように記述します。
import serverless from "serverless-http";
import app from "./app"; // expressアプリケーション本体
export const handler = serverless(app);
課題2: Lambdaのサイズを削減する
node_modules が250MBの上限を超えてしまう問題への対応です。
-
ECR on Lambdaを使い、Dockerイメージでデプロイする
前述の通り、この方法なら上限が10GBまで緩和されるため、サイズ問題の根本的な解決になります。 -
node_modulesの中身を減らす
依存関係を見直し、不要なパッケージを徹底的に削る地道な方法です。
今回はDockerを使わないため、2の方法で対応しました。
やったこと
-
devDependenciesへの移行:-
@types/*: 型定義ファイルは実行時には不要です。 -
dotenv: 環境変数を.envファイルで管理するライブラリですが、Lambdaでは実行環境の環境変数設定を使うため不要です。 - その他、テストやLinter関連のライブラリも
devDependenciesに移動させます。
-
-
AWS SDKの依存関係を絞る:
- AWS SDK v3はサービスごとにクライアントが分かれています。
@aws-sdk/client-s3や@aws-sdk/client-dynamodbのように、実際に使うクライアントだけをインストールし、@aws-sdk/*のようなワイルドカードでの一括追加は避けます。 - また、クライアントライブラリが内部で依存している
@aws-crypto/*や@aws-sdk/credential-provider-nodeなどは、直接インストールする必要はありません。
- AWS SDK v3はサービスごとにクライアントが分かれています。
-
tree-shakingを活用する:
-
esbuildはデフォルトでtree-shaking(不要なコードを削除する機能)が有効です。 -
import * as AWS from 'aws-sdk'のような包括的なインポートを避け、import { S3Client } from '@aws-sdk/client-s3'のように、必要な機能だけを具体的にインポートすることで、tree-shakingがより効果的に機能します。
-
まとめ
TypeScriptとexpressを使ったAPIを、Dockerを使わずにLambdaにデプロイする方法を紹介しました。esbuild によるビルドと、GitHub Actionsによるデプロイフローを組み合わせることで、比較的シンプルに実現できたと感じています。TypeScriptのまま1コマンドでデプロイできるようになればいいのにな。
ちなみに、数年前からずっとLambdaにデプロイする記事を書いている気がしますね。
Discussion