KINTO Tech Blog
General

Lambda + TypeScript + Express.jsでAPIをサクサク開発して低コストで運用する話

Cover Image for Lambda + TypeScript + Express.jsでAPIをサクサク開発して低コストで運用する話

はじめに

こんにちは!
KINTOテクノロジーズでWebエンジニアをしている亀山です。
現在はマーケティングプロダクト開発グループという部署に所属しています。

今回はサーバレス構築についてお話しします。

ECSなどのコンテナが常時起動しているアプリケーションは、稼働している間、たとえリクエストが来なくても起動している時間分のリソース(CPUやメモリ)に対して課金が発生します。そのため、PoC開発や極端にリクエストが少ないプロダクトにおいては特に「使っていないのにお金がかかる」状態になりがちです。
そのようなプロダクトに対しては、使っている間だけサーバーが立ち上がり、一定時間処理が行われないと自動で停止するような従量課金型のサーバレスアーキテクチャによってランニングコストを大幅に抑えることが可能になります。

そこで私たちは下記の要点でLambdaアプリケーションを構築しました。

  • AWSのAPI Gateway + Lambdaでサーバレス開発
  • TypeScript + Expressでシンプルかつ汎用的なAPI開発

サーバレスについて

AWSのサーバレス構成として広く用いられているLambdaを採用しましたLambdaはサーバレスアーキテクチャであるため、先にも述べましたが、サーバーの起動・停止やスケーリングを自動で行ってくれ、使った分だけ課金される従量課金制でコストを最小限に抑えられます。

一方でこのようなサーバレスAPIのデメリットとしては、コールドスタートによる応答遅延が発生する点があります。特にリクエスト数の少ない環境や、一定時間アクセスがないときにLambdaがスリープ状態になり、再度リクエストが来た際にコンテナの起動時間(実測値として応答時間約1秒)がかかるという特性があります。

まとめると、とにかくサクッとプロトタイプを構築したいであったり、応答遅延を許容できるようなユーザー(例えば社内メンバー)向けのツールを構築したいという方には特におすすめなインフラ構成になります。

Lambdaでどれくらい費用が安くなるのか?

常時実行コンテナのFargateと今回使用するサーバレス型のLambdaで料金比較をしてみます。

Fargate

AWS Fargateの料金について

0.5 vCPU でメモリ2GBを想定して1時間あたりの1タスク稼働費用は

  • vCPU料金: 0.5 vCPU * $0.04048 / vCPU-時間 = $0.02024 / 時間
  • メモリ料金: 2GB * $0.004445 / GB-時間 = $0.00889 / 時間

という計算から1時間あたり合計費用は $0.02024 + $0.00889 = $0.02913 となり、
そのため1ヶ月720時間稼働するとすると$0.02913 * 720時間 = $20.9736 が1タスク月当たりかかります。(ただし、夜間停止やvCPUスペックダウンによって節約すること可能)

これは1環境あたりの費用なので本番環境、開発環境など複数の環境が必要であればその数だけ掛け算されます。

Lambda

AWS Lambdaの料金について
一方でLambdaはリクエスト件数とリクエストによって一時的に起動するコンテナの計算時間(コンピューティング時間)によって計算されます。

  • GB-秒あたり0.00001667 USD
  • 1,000,000 件のリクエストあたり0.20 USD

Fargateと同様の2GBで、1リクエストあたり0.5秒の計算時間、さらに1ヶ月あたり10万件のリクエストであると想定すると月当たりの合計費用は$0.02 (リクエスト費用) + $1.6667 (計算費用) = 約 $1.69 が月当たりかかります。
さらに良いことに、環境を増やしたり、1環境あたりのLambda数を増やしたとしても、合計リクエスト数が同じであれば上記と同じ金額になります。

これらの計算シミュレーションからLambdaのコスト面での優位性がわかると思います。

特に売上の出ないかつトラフィックの少ない社内向けツールであったり、PoCのプロダクトであればこのような費用削減は特にメリットが大きくコスト面でのハードルを下げてくれることが期待できます。

Expressについて

サーバサイドJSのフレームワークとしてExpressを採用しました。

ルーティングやミドルウェアの概念が直感的に理解できるように設計されており、初めて Node.js でサーバサイド開発を行う開発者にとっても扱いやすい構成となっています。小規模なAPIから中〜大規模なアプリケーションまで、スムーズに拡張できる設計が可能です。また、ルーティングの記述も簡潔です。

app.get('/users/:id', (req, res) => {
  res.send(`User: ${req.params.id}`);
});

ログ出力には morgan、認証処理には passport、入力バリデーションには express-validator など、用途に応じて豊富なミドルウェアのライブラリを簡単に組み込むことができます。これにより、アプリケーションの機能追加や保守が容易になります。

AWS公式で配布されているLambdaライブラリを用いてエンドポイントを構築することも可能ですが、汎用ライブラリであるExpressを用いて構築しておけばアプリケーションの規模が拡大したタイミングでECSやApp Runnerに転用する際にエンドポイント以降のコードを転用することもLamda特化のライブラリを使用するより容易になります。

開発方針

本記事では、1つのLambda関数に複数の API エンドポイントをまとめて搭載する構成を採用しています。

これは、Lambdaの特性である「ホットスタート」を活用するためです。

Lambda関数は一度起動されると、一定時間はメモリ上に残り続ける「ホットスタート」の状態になります。そのため、1つのAPIがリクエストされて Lambdaが起動されたあとは、同じ関数内の他のAPIに対するリクエストも高速に処理できるようになります。

この性質を活かすことで、操作時のパフォーマンスの向上が期待できます。

ただし、Lambdaにはデプロイ可能なパッケージサイズに制限(zip圧縮時で50MB以内かつ展開後で250MB以内)があるため、アプリケーション内すべてのAPIを1つの関数に詰め込むと最終的にこの上限に到達するため、現実的ではありません。

そのため、画面単位や機能単位で関連のあるAPIを同一の Lambda関数にまとめる構成とし、最終的には1つのリポジトリで複数の Lambda関数を管理していくmonorepo構成を前提で進めていきます。

また本記事ではSAMによるローカル実行できるようにするまでをゴールとし、AWSコンソールの設定やデプロイ以降のお話は割愛させていただきます。

環境構築(コーディングまでの準備)

本記事では複数の Lambda関数や共有コードを管理しやすいpnpmにAWS SAM を組み合わせた環境構築方法を説明します。

プロジェクト全体を pnpm のワークスペースとして管理し、各 Lambda関数や共通ライブラリを独立したワークスペースとして扱います。デプロイツールにはAWS SAM(Serverless Application Model)を利用します。

主に以下のツールが必要になります。

  • Node.js
  • pnpm
  • AWS CLI
  • AWS SAM CLI
  • Git (バージョン管理)

Gitのインストールは割愛します。

必要なツールのインストール

Node.js

これまでと同様に Node.js が必要です。公式サイトから LTS 版をインストールしてください。

Node.js公式サイト

インストール後、以下のコマンドでバージョンが表示されることを確認します。

node -v
npm -v # pnpmをインストールするために使用する

pnpm

依存ライブラリの管理には pnpm を使用します。pnpm は特に複数のモジュール(Lambda function)を1つのリポジトリで管理するようなmonorepo構成での依存解決やディスク使用効率に優れています。

以下の方法でインストールします。

npm install -g pnpm

または、curl などを使用する方法は pnpm の公式サイトを参照してください。 pnpm インストールガイド

インストール後、以下のコマンドでバージョンを確認します。

pnpm -v

AWS CLI

これまでと同様に AWS との連携に必要です。インストールおよび aws configure による認証情報の設定を行ってください。

AWS CLIインストールガイド

AWS SAM CLI

今回はデプロイツールとして AWS SAM (Serverless Application Model) を使用します。AWS SAM はサーバーレスアプリケーションのための IaC (Infrastructure as Code) フレームワークであり、SAM CLI はローカルでのビルド、テスト、デプロイをサポートします。

以下の公式サイトを参考に、お使いのOSに合わせた方法でインストールしてください。

AWS SAM CLIインストールガイド

インストール後、以下のコマンドでバージョンを確認します。

sam --version

プロジェクト構造とワークスペース設定

プロジェクトのルートディレクトリには、monorepo全体の設定ファイルや、開発時に共通で使うツール(例: esbuild)の依存関係を定義するpackage.jsonを置きます。各 Lambda関数や共通ライブラリは、例えばfunctionsディレクトリの中にそれぞれ独立したサブディレクトリとして作成し、これらをpnpmのワークスペースとして定義します。

提供いただいた構造を参考に、基本的な構造と設定ファイルを説明します。

sample-app/             # (ルートディレクトリ)
├── functions/
│   ├── common/         # 共通コード用ワークスペース
│   │   ├── package.json
│   │   ├── src/
│   │   └── tsconfig.json
│   ├── function-1/     # Lambda関数1用ワークスペース
│   │   ├── package.json
│   │   ├── src/        # Expressアプリやハンドラコード
│   │   └── tsconfig.json
│   └── function-2/     # Lambda関数2用ワークスペース
│       ├── package.json
│       ├── src/
│       └── tsconfig.json
├── node_modules/       # pnpmによって管理される依存ライブラリ
├── package.json        # ルートのpackage.json
├── pnpm-lock.yaml      # ルートのロックファイル
├── pnpm-workspace.yaml # ワークスペース定義ファイル
├── samconfig.toml      # SAM デプロイ設定ファイル (初回デプロイで生成)
└── template.yaml       # AWS SAM テンプレートファイル

ルートのpackage.json

リポジトリ全体で共有するスクリプトや開発ツール(esbuild など)を定義します。

package.json

{
  "name": "sample-lambda-app-root", // プロジェクト全体を表す名前
  "version": "1.0.0",
  "description": "Serverless Express Monorepo with SAM and pnpm",
  "main": "index.js",
  "private": true, // ルートパッケージは公開しない設定
  "workspaces": [
    "functions/*" // ワークスペースとなるディレクトリを指定
  ],
  "scripts": {
    "build": "pnpm -r build", // 全ワークスペースの build スクリプトを実行
    "sam:build": "sam build", // SAMでのビルド (後述)
    "sam:local": "sam local start-api", // SAMでのローカル実行 (後述)
    "sam:deploy": "sam deploy" // SAMでのデプロイ (後述)
  },
  "devDependencies": {
    "esbuild": "^0.25.3" // 各ワークスペースのビルドで使う esbuild をルートで管理
    // 他、monorepo全体で使う開発ツールがあればここに追加
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

pnpm-workspace.yaml

どのディレクトリをワークスペースとして扱うかを定義します。

pnpm-workspace.yaml

packages:
  - 'functions/*' # `functions` ディレクトリ内の全てのサブディレクトリをワークスペースとする
  # - 'packages/*' # 別のワークスペースグループがあれば追加

依存ライブラリ管理 (pnpm ワークスペース)

各Lambda関数や共通ライブラリに必要な依存ライブラリは、それぞれのワークスペース内の package.json に記述します。

例: functions/function-1/package.json

{
  "name": "function-1", // ワークスペースの名前
  "version": "1.0.0",
  "description": "Lambda Function 1 with Express",
  "scripts": {
    "build": "esbuild src/app.ts --bundle --minify --sourcemap --platform=node --outfile=dist/app.js", // esbuildでビルド
    "start:dev": "nodemon --watch src -e ts --exec \"node dist/app.js\"", // ローカルテスト用のスクリプト (SAM Localとは別に用意しても良い)
    "tsc": "tsc" // 型チェック用
  },
  "dependencies": {
    "@codegenie/serverless-express": "^4.16.0", // Lambdaアダプター
    "express": "^5.1.0",
    "@sample-lambda-app/common": "workspace:*" // 共通ライブラリへの依存
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.138", // Lambdaの型定義
    "@types/express": "^4.17.21",
    "nodemon": "^3.1.0",
    "typescript": "^5.4.5"
    // esbuild はルートの devDependencies にあるのでここでは不要
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
  • @sample-lambda-app/common: functions/commonワークスペースを指します。"workspace:*"とすることで、ローカルのcommonワークスペースを参照するようになります。commonワークスペース側のpackage.json"name": "@sample-lambda-app/common", と定義している必要があります。

  • scripts.build:esbuildを使用して、TypeScript コードと依存ライブラリをまとめて1つの JavaScript ファイル (dist/app.js) にバンドルする例です。Lambdaにデプロイするパッケージサイズを削減するために重要なステップです。

依存ライブラリのインストールは、プロジェクトのルートディレクトリで 一度だけ pnpm install を実行します。pnpmがpnpm-workspace.yamlを見て、各ワークスペースのpackage.jsonに記述された依存関係を解決し、効率的にnode_modulesを構成します。

pnpm install

特定のワークスペースにライブラリを追加したい場合は、ルートから以下のコマンドを実行します。

pnpm add <package-name> -w <workspace-name> # 例: pnpm add axios -w functions/function-1
pnpm add -D <dev-package-name> -w <workspace-name> # 開発依存の場合

実際にサンプルコードを書いてみる

先ほど説明したディレクトリ構成は、複数function構成になるようにfunction-1function-2の2つのfunctionモジュールを用意し、それらのfunctionが共用部品として利用できるためにcommonというモジュールを用意しています。

具体的にコードを書いていきます。

commonのコード

まず共通部品であるcommonにサンプルとしてミドルウェア関数を1つ書いてみます。

functions/common/src/middlewares/hello.ts

import { Request, Response, NextFunction } from 'express';
/**
 * サンプル共通ミドルウェア
 * リクエストログを出力し、カスタムヘッダーを追加します。
 */
export const helloMiddleware = (req: Request, res: Response, next: NextFunction) => {
  console.log(`[Common Middleware] Received request: ${req.method} ${req.path}`);
  // レスポンスにカスタムヘッダーを追加
  res.setHeader('X-Sample-Common-Middleware', 'Applied');
  // 次のミドルウェアまたはルートハンドラに進む
  next();
};
続いて、middlewares/内のエクスポートを追加します。

functions/common/src/middlewares/index.ts


export * from './hello';
// middlewares内に他のミドルウェアがあればここに追加していく
さらにワークスペースのトップレベルのsrc/でもエクスポートしてあげる必要があります。

functions/common/src/index.ts


export * from './middlewares';

// middlewaresのような共通処理が他にあればここに追加していく(utilsとか)

funciton-1側のコード

次にfunction-1側のコードを書いていきます。

functions/function-1/src/app.ts

import express from 'express';
import serverlessExpress from '@codegenie/serverless-express';
import { helloMiddleware, errorHandler } from '@sample-lambda-app/common'; // 共通ミドルウェア、エラーハンドラをインポート
// apiRouter のインポートは不要になりました
// import apiRouter from './routes/api';
// import cookieParser from 'cookie-parser'; // 必要に応じてインストール・インポート
const app = express();
// express標準ミドルウェアの適用
app.use(express.json()); // JSONボディのパースを有効化
// app.use(cookieParser()); // クッキーパースが必要な場合このように追加する
// 共通ミドルウェアの適用
app.use(helloMiddleware);
app.get('/hello', (req, res) => {
  console.log('[Function 1 App] Handling GET /hello');
  res.json({ message: 'Hello from Function 1 /hello (Simplified)!' });
});
app.post('/users', (req, res) => {
  console.log('[Function 1 App] Handling POST /users');
  console.log('Request Body:', req.body); // JSONボディをログ出力
  res.status(201).json({ received: req.body, status: 'User created (sample)' });
});
// common等にエラーハンドラミドルウェアを作成し、使用する場合は全てのミドルウェアとルート定義の後に配置する。
// app.use(errorHandler); // 本記事では作成していない
// ハンドラのエクスポート
export const handler = serverlessExpress({ app });

補足:このあと行うtemplate.yamlでのAPI Gatewayの設定で、/function1 を取り除いたパスが渡されるようになるので、ここに定義するルートは、API Gateway のベースパスからの相対パスになります。
例えば、API Gatewayへのリクエストが /function1/hello なら、ここに定義する /hello にマッチします。

function-2側のコード

functions/function-2/src/app.ts

import express from 'express';
import serverlessExpress from '@codegenie/serverless-express'; // ★アダプターをインポート★
import { helloMiddleware, errorHandler } from '@sample-lambda-app/common'; // 共通ミドルウェア、エラーハンドラをインポート
// ルーターファイルは使用しないためインポート不要
// import apiRouter from './routes/api';
// import cookieParser from 'cookie-parser'; // 必要に応じてインストール・インポート
const app = express();
// express標準ミドルウェアの適用
app.use(express.json()); // JSONボディのパースを有効化
// app.use(cookieParser()); // クッキーパースが必要な場合このように追加する
// 共通ミドルウェアの適用
app.use(helloMiddleware);
// ルートをごとに処理を定義
app.get('/bye', (req, res) => {
  console.log('[Function 2 App] Handling GET /bye');
  res.json({ message: 'Goodbye from Function 2 /bye!' });
});
app.post('/items', (req, res) => {
  console.log('[Function 2 App] Handling POST /items');
  console.log('Request Body:', req.body); // JSONボディをログ出力
  res.status(201).json({ received: req.body, status: 'Item created (sample)' });
});
app.get('/status', (req, res) => {
    console.log('[Function 2 App] Handling GET /status');
    res.json({ status: 'OK', function: 'Function 2 is running (Simplified)' });
});
// common等にエラーハンドラミドルウェアを作成し、使用する場合は全てのミドルウェアとルート定義の後に配置する。
// app.use(errorHandler); // 本記事では作成していない
// ハンドラのエクスポート
export const handler = serverlessExpress({ app });

今回サンプルなのでルート内の処理を全てアロー関数で書いていますが、実際の開発では処理が複雑になったら別のtsファイルに処理をまとめた方が良いと思います。

また、開発していく中でルートごとに使用するミドルウェアを使い分けたい場面などが出てくると思います。その際はexpressのRouterのライブラリを用いてAPIルーターを作成するとより柔軟に作成できますので調べて試してみてください。(参考: https://expressjs.com/ja/guide/routing.html

SAMローカル実行するための準備

AWS SAM テンプレート (template.yaml)

プロジェクトのルートにtemplate.yamlファイルを作成し、デプロイするAWSリソースを定義します。Lambda関数、API Gateway、必要な IAMロールなどを記述します。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Sample Serverless Application
Globals:
  # Functions 全体に適用する共通設定 (メモリサイズやタイムアウトなど)
  Function:
    Timeout: 30
    MemorySize: 256 # 適宜調整する
    Runtime: nodejs20.x
    Architectures:
      - x86_64
    Environment:
      Variables:
        NODE_ENV: production
Resources:
  # function-1 ワークスペースに対応するLambda関数リソース定義
  Function1:
    Type: AWS::Serverless::Function # AWS SAMで定義するサーバーレス関数
    Properties:
      FunctionName: sample-express-function-1 # AWSコンソールに表示されるLambda関数名 (任意)
      Description: Express App for Function 1 (Simplified)
      # CodeUri は SAM がコードをパッケージングする際のソースディレクトリを指す。
      # ここには、sam build 前のソースコードがあるディレクトリを指定。
      CodeUri: functions/function-1/
      # Handler は、sam build によって生成された成果物の中でのエントリーポイントを指す。
      # esbuild が src/app.ts を dist/handler.js にバンドルし、
      # その中で 'export const handler = ...' を CommonJS の 'exports.handler = ...' に変換するため、
      # 'ファイル名(拡張子なし).エクスポート名' と記述する。
      Handler: handler.handler
      Events:
        # API Gateway からのトリガー設定
        Function1Api:
          Type: Api # API Gateway REST APIをトリガーとする
          Properties:
            Path: /function1/{proxy+}
            # 許可するHTTPメソッド (ANYは全てのメソッドを許可)
            Method: ANY
  # function-2 ワークスペースに対応するLambda関数リソース定義
  Function2:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: sample-express-function-2 # AWSコンソールに表示されるLambda関数名 (任意)
      Description: Express App for Function 2 (Simplified)
      # CodeUri は function-2 ワークスペースのソースディレクトリを指す
      CodeUri: functions/function-2/
      # Handler は function-2 のビルド成果物の中でのエントリーポイントを指す
      Handler: handler.handler
      Events:
        # API Gateway からのトリガー設定 (function-2用)
        Function2Api:
          Type: Api
          Properties:
            # Function 2 が処理するAPI Gatewayパス
            Path: /function2/{proxy+}
            Method: ANY
  • Transform: AWS::Serverless-2016-10-31: SAM テンプレートであることを示します。
  • Resources: デプロイする AWS リソースを定義します。
    • Type:AWS::Serverless::Function: Lambda関数リソースです。
    • CodeUri: Lambda関数としてデプロイするコードがあるディレクトリを指定します。functions/function-1/distのように、各ワークスペースのビルド成果物の場所を指定します。
    • Handler: Lambda関数が実行されるときに最初に呼び出されるコード内の関数名を指定します。バンドル後のファイル (dist/app.js) の中でエクスポートされる関数名になります。
    • Events: その Lambda関数をトリガーするイベントを設定します。Type: Apiは API Gateway からのHTTPリクエストをトリガーとする設定です。PathMethodで特定のエンドポイントに紐づけます。/{proxy+} は、そのパス以下の全てのリクエストをキャッチするための記法です。

ローカルでの開発とテスト (AWS SAM CLI)

AWS SAM CLI を使うと、ローカル環境で Lambda関数と API Gateway をエミュレートしてテストできます。

  1. 各ワークスペースのビルド: まず、各ワークスペースのソースコードを JavaScript にビルドします。ルートディレクトリで定義したスクリプトを利用できます。

    pnpm run build # functions/* 以下のそれぞれの build スクリプトが実行される
    

    これにより、例えばfunctions/function-1/dist/app.jsのようなビルド成果物が生成されます。

  2. SAMビルド: 次に、AWS SAM がデプロイ用のパッケージを作成するためのビルドを実行します。

    sam build
    

    このコマンドはtemplate.yamlを読み込み、CodeUri:で指定された場所にあるビルド成果物を (.aws-sam/buildディレクトリ以下に) コピーし、Lambdaが必要とする形式に整えます。

  3. ローカルAPI起動: SAM CLI が提供するローカル API 機能を使って、API Gatewayをエミュレートし、ローカルで Lambdaコードを実行できるようにします。

    sam local start-api
    

    コマンド実行後、http://127.0.0.1:3000 のようなURLでローカルサーバーが起動します。ブラウザやPostman/curl などで、template.yaml で定義したパス(例: /function1/hello)にアクセスすると、ローカルで Lambda関数が実行されます。

ローカル開発中は、ソースコードを変更したら pnpm run build → sam build → sam local start-api を再実行するか、sam local start-api --watch オプションを使ってコード変更を監視させるなどの方法があります。(--watch オプションはビルドやエミュレーションの再起動を自動で行ってくれますが、実際の環境構成によっては少し工夫が必要な場合もあります)

終わりに

今回はTypeScriptのサーバレスをLambdaとExpressを用いて、ローカル実行するまでの方法ご紹介しました。実際にプロダクトをリリースするにあたり、AWSインフラを構築し、適切な設定をするなどの作業が必要になります。

特にExpressであったりmonorepo構成については初めての試みであったため躓くことがあり、備忘録も兼ねて細かくご説明したため、少々長くなってしまったかもしれません。
同じように困っている人にとって少しでもお役に立てていただければ幸いです。

Facebook

関連記事 | Related Posts

We are hiring!

【フロントエンドエンジニア(コンテンツ開発)】新車サブスク開発G/大阪・福岡

新車サブスク開発グループについてTOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 』のWebサイトの開発、運用をしています。​業務内容トヨタグループの金融、モビリティサービスの内製開発組織である同社にて、自社サービスである、クルマのサブスクリプションサービス『KINTO ONE』のWebサイトコンテンツの開発・運用業務を担っていただきます。

【クラウドエンジニア】Cloud Infrastructure G/東京・大阪

KINTO Tech BlogWantedlyストーリーCloud InfrastructureグループについてAWSを主としたクラウドインフラの設計、構築、運用を主に担当しています。

イベント情報

Cloud Security Night #2
製造業でも生成AI活用したい!名古屋LLM MeetUp#6
Mobility Night #3 - マップビジュアライゼーション -