はじめに
こんにちは。
今回の記事ですが過去に、同じ処理を「運用端末で手動実行したいケース」と「クラウド側で定期実行したいケース」の両方で使いたい、という要件がありました。
ただし実装を別々に持つと、仕様変更のたびに二重修正が発生し、テスト観点も分かれて保守負荷が上がります。そこで、処理本体は共通化し、実行形態だけを分ける方針を取り、「TypeScriptで作った処理を、Windows実行ファイル(exe)向けと AWS Lambda の両方で使う」構成を作る機会がありました。
実際の案件はもう少し複雑ですが、この記事では導入編として本質だけに絞って最小限の構成をご説明します。
なぜこのケースが発生したか
実務では、次のように実行場所や運用パターンが分かれることがあります。
- 通信や事前確認をしながら、担当者が端末で手動実行したい
- 深夜バッチなど、クラウドで定期実行したい
- 障害時は手動運用に切り替え、平常時は自動運用に戻したい
このとき、実装が別コードになると「仕様差分」と「修正漏れ」が起きやすくなります。そこで、計算や変換の本体ロジックだけを共通化し、入出力だけを実行環境ごとに薄く分ける構成が効きます。
どういう場合に SEA(single executable applications) + Lambda が有効か
特に次の条件がそろう場合に効果が出やすいです。
- 同じ業務ロジックを、ローカル実行とクラウド実行の両方で使いたい
- 配布先端末で Node.js 環境の準備をできるだけ減らしたい
- 本番運用は Lambda で自動化しつつ、検証や緊急時はローカルでも同じ処理を回したい
- 将来の仕様変更時に、修正箇所を最小化したい
少し前にpythonでプロダクト開発が良くされていた時期はPyinstallerでexeアプリを作るのがちょっとだけ流行っていた覚えがあります。それのTypeScriptバージョンですね。
逆に、完全にクラウド専用で端末配布が不要な場合や、ローカル実行要件がない場合は、Lambda 単体構成のほうがシンプルです。
今回のサンプルアプリのやることは次の1機能だけです。
- 入力: 数値1つ
- 出力: 2倍した値1つ
ポイントは、ビジネスロジックを1箇所に寄せることです。
- 共通ロジック:
double(input) - CLI(SEA)用の薄いラッパー
- Lambda用の薄いラッパー
これで「ロジックは同じ、実行形態だけ違う」という状態を小さく作れます。
この記事のサンプル構成
このリポジトリでは以下を sample 配下に用意しています。
-
sample/src: 共有ロジック + CLI + Lambda -
sample/cdk: Lambda をデプロイする最小 CDK -
sample/architecture.mmd: 構成図
構成図
アプリ全体の構成図
実装の要点
1. 共通ロジック
sample/src/src/core/double.ts
export function double(input: number): number {
return input * 2;
}
2. CLIラッパー
sample/src/src/cli.ts
- コマンドライン引数を受け取り
- 数値に変換
-
doubleを呼んで JSON 出力
ここでは「入力の受け取り」と「表示」に責務を限定し、計算ロジックは持たせません。
import { double } from "./core/double";
function parseInput(rawValue: string | undefined): number {
if (!rawValue) {
throw new Error("Usage: double-cli <number>");
}
const parsed = Number(rawValue);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid number: ${rawValue}`);
}
return parsed;
}
function main(): void {
try {
const input = parseInput(process.argv[2]);
const output = double(input);
console.log(JSON.stringify({ input, output }, null, 2));
} catch (error) {
console.error((error as Error).message);
process.exitCode = 1;
}
}
main();
3. Lambdaラッパー
sample/src/src/lambda.ts
-
event.valueを受け取り - 数値に変換
-
doubleを呼んでレスポンス返却
こちらも同様に、Lambda固有のI/Oだけを担当します。
import type { Handler } from "aws-lambda";
import { double } from "./core/double";
type SampleEvent = {
value: number | string;
};
type SampleResponse = {
input: number;
output: number;
};
function toNumber(value: number | string): number {
const parsed = typeof value === "number" ? value : Number(value);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid value: ${String(value)}`);
}
return parsed;
}
export const handler: Handler<SampleEvent, SampleResponse> = async (event) => {
const input = toNumber(event.value);
return {
input,
output: double(input),
};
};
4. CDK
sample/cdk/lib/sample-stack.ts
-
NodejsFunctionで../../src/src/lambda.tsを直接バンドル - Node.js 22 ランタイムで最小デプロイ
「共通ロジックは src 側、デプロイ定義は cdk 側」という切り分けにしています。
import * as cdk from "aws-cdk-lib";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
import * as path from "path";
export class SampleStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new NodejsFunction(this, "DoubleLambda", {
functionName: "sea-lambda-shared-double-lambda",
runtime: cdk.aws_lambda.Runtime.NODEJS_22_X,
entry: path.join(__dirname, "../../src/src/lambda.ts"),
projectRoot: path.join(__dirname, "../.."),
handler: "handler",
memorySize: 256,
timeout: cdk.Duration.seconds(10),
description: "Minimal shared TypeScript logic Lambda",
bundling: {
target: "node22",
},
});
}
}
動かし方
1) 共有コードをビルド
cd sample/src
npm install
npm run build
2) 通常のNode実行で確認
npm run run:cli -- 21
期待値:
{
"input": 21,
"output": 42
}
3) SEA実行ファイルを作成
npm run build:sea
./double-cli 21
期待値は同じです。
補足: SEAでは相対パスの require に制約があるため、このサンプルでは esbuild でCLIを1ファイルにバンドルしてからSEA化しています。
4) AWS CLIでLambdaを実行
aws lambda invoke \
--function-name sea-lambda-shared-double-lambda \
--cli-binary-format raw-in-base64-out \
--payload '{"value":21}' \
response.json
cat response.json
{ "input": 21, "output": 42 } が返ればOKです。
値を変える場合は、--payload の value を変更します。
aws lambda invoke \
--function-name sea-lambda-shared-double-lambda \
--cli-binary-format raw-in-base64-out \
--payload '{"value":35}' \
response.json
cat response.json
--region や --profile が必要な環境では適宜追加してください。
5) CDK Synth (デプロイ前確認)
cd ../cdk
npm install
npm run synth
必要に応じて npm run deploy でデプロイします。
この構成にしてよかった点
- ロジック重複を避けられる
- CLI と Lambda で同じ挙動を保証しやすい
- 導入時は最小構成、後から機能追加しやすい
個人的には「まず共通ロジックを1つに寄せる」だけでも、保守コストはかなり下げられると感じています。
業務向けに広げるなら
- 入出力のバリデーション強化
- 共通ロジックに単体テスト追加
- Lambda の監視(CloudWatch Logsでログ収集、必要なメトリクス/アラートを整備)
- 監視基盤を統合する場合は、うちのプロジェクトでは最終的に New Relic へ連携して可観測性を高める
- CIで
build/synth/ テスト自動化
まとめ
業務で使った「1つのTypeScriptロジックを複数の実行形態で使う」考え方は、導入編ならこのくらい小さく始められます。本格的なWindowsアプリの場合であればc#を使うのがいいですが、今回の要件やサンプルレベルであればTypeScriptでもアプリが作れます。
最初は「1入力1出力(2倍するだけ)」に寄せて、実行形態をまたいでロジックを共通化する感覚をつかむのがおすすめです。
同じように「CLIとLambdaで処理を共通化したい」という場面があれば、まずこの最小構成から試してみてください。
注意事項
本ブログに掲載している内容は、私個人の見解であり、所属する組織の立場や戦略、意見を代表するものではありません。あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。
参考リンク
-
Node.js SEA 公式ドキュメント
https://nodejs.org/api/single-executable-applications.html -
Node.js CLI オプション (
--experimental-sea-config)
https://nodejs.org/api/cli.html -
postject (SEA blob埋め込みツール)
https://github.com/nodejs/postject -
AWS CDK Developer Guide
https://docs.aws.amazon.com/cdk/v2/guide/home.html -
AWS CDK
NodejsFunction(API Reference)
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html -
AWS Lambda TypeScript ハンドラーガイド
https://docs.aws.amazon.com/lambda/latest/dg/typescript-handler.html