はじめに
本ブログは、2025/02/21(金)に開催された「JAWS-UG CDK支部#19 クラスメソッドコラボ回」における私のLT「CDKでカスタムランタイムを作成して、Lambdaをnode.js23+TypeScriptで動かしてみた」の詳細資料になります。
LTの発表資料は、下記で公開しています。(上記Connpassページにもリンクがあります)
なお今回は下記の構成で、何回かに分けて投稿します。
- カスタムランタイム作成 ※今回はこれ
- AWS CDKでの実装&TypeScriptで動作させる
- AWSリソースにアクセスする(AWS SDK for JavaScript v3を使う)
Node.jsのTypeScriptサポート
Node.js が ver23.6.0から、TypeScriptの直接実行を正式サポートしました。(Experimentalではなくなった)
Node.js Now Supports TypeScript By Default | Total TypeScript
色々と制約はありますが、node.jsでTypeScriptの直接実行が可能になったことで、JavaScriptへのトランスパイルやそれに伴う各種設定が不要になりました。
そこで「node.jsでTypeScriptが直接実行できるなら、LambdaもTypeScriptで実行できるんじゃね?」ってことで、それを検証する...というのが今回の記事になります。
「カスタムランタイム」とは?
「カスタムランタイム」とは、AWSが標準で用意しているランタイム以外に、自分で作成可能なLambdaランタイムの事です。
あまり使用する機会はないかもしれませんが*1、下記の用途で用います。
- Lambdaでサポートされていない言語で動かしたい(Perl, Cなど)
- サポートが終了した過去バージョンで動かしたい(Node.js 18未満など)
今回の場合、AWSの標準ランタイムではNode.js ver22.xまでしかサポートされていないため、ver23.6.0を動かすにはカスタムランタイムの作成が必要となります。
カスタムランタイムを作成する
カスタムランタイムの作成方法はAWS公式サイトにドキュメントがありますので、それに沿う形で実施します。
ざっくり記載すると、こんな作業になります
bootstrapファイルとruntime.jsファイルを作り、コードを記載する- node.js ver23.6.0のバイナリファイルを用意する
- 上記3ファイルをzipファイルにして、
aws lambda publish-layer-versionコマンドを実行する
なお上記公式チュートリアルページには「関数の作成」と「レイヤーの作成」の2つがありますが、今回はLambda関数を別で用意するため、「レイヤーの作成」にてカスタムランタイムの作成のみ実施します。
bootstrapファイルの用意
とりあえずフォルダ(公式チュートリアルに沿い、「runtime-tutorial」とする)を作って、そのフォルダ内にbootstrap ファイルを作ります。(ファイル名は bootstrap でないといけないので注意してください。)
bootstrap ファイルをテキストエディタで開き、下記コードを記載します。(公式チュートリアルページの内容と全然違いますが、気にしないでOKです。)
#!/bin/sh ## カレントフォルダはデフォルトでLAMBDA_TASK_ROOT(/var/task)になっているが、 ## 不安なら明示的に指定しておく cd $LAMBDA_TASK_ROOT ## これはこの後説明します。 /opt/node-v23.6.0-linux-x64/bin/node /opt/runtime.js
runtime.jsファイル(JavaScript ランタイム)の用意
次に「runtime-tutorial」フォルダに runtime.js ファイルを作ります。(ファイル名は何でもよいです)
これはJavaScript ランタイムファイルで、先述の「AWS Lambda 用カスタムランタイムの構築」ページにも「バンドルされた Node.js バージョンを使用して、runtime.js という別のファイルで JavaScript ランタイムを実行する例」として下記コードが記載されています。
#!/bin/sh cd $LAMBDA_TASK_ROOT # node-v11.1.0は過去にリリースされていたのでAWS上にあるが、 # ver23.6.0は過去にリリースされていないので自分でバンドルする必要がある ./node-v11.1.0-linux-x64/bin/node runtime.js
今回もそれに沿って、JavaScript ランタイムファイル runtime.js を作成します。(Node.js ver23.6.0のバンドルは後で行います)
runtime.js のソースコードについては、「node-custom-lambda」 というNode.js 10.x および 12.x 用のカスタムランタイム作成用のリポジトリに bootstrap.js というファイルがあるので、そのソースコードをそのままコピぺします。(ファイル名は違いますが、ソースはそのままコピペでOKです)
なお、参考情報としてこのソースを末尾に掲載していまので、興味があればご参照ください。*2
node.js ver23.6.0のバンドル
次にnode.js ver23.6.0のバンドルのために、バイナリファイルを用意します。(Lambda上に存在しないため、自分でバンドルする必要がある)
これはNode.js公式サイトの下記ページから「node-v23.6.0-linux-x64.tar.gz」(または「node-v23.6.0-linux-arm64.tar.gz」)をダウンロードした上で、解凍したフォルダを「runtime-tutorial」フォルダに保存すればOKです。
https://nodejs.org/dist/v23.6.0/
Lambdaレイヤーの作成
最後に、カスタムランタイムのLambdaレイヤーを作成します。
これは公式チュートリアルページの内容に従い、
- 全ファイルに読み取り&実行可能パーミッション(755)を付与する
- 全ファイルを圧縮して、
aws lambda publish-layer-versionコマンドでLambdaレイヤーを作成する
をすればOKです。
具体的なコマンドは下記の通りです。(「runtime-tutorial」フォルダ上で実施してください)
# パーミッション付与 $ chmod 755 -R runtime.js bootstrap node-v23.6.0-linux-x64 # 圧縮 zip -r runtime.zip runtime.js bootstrap node-v23.6.0-linux-x64 # Lambdaレイヤー作成 aws lambda publish-layer-version --layer-name <任意のLambdaレイヤー名> --zip-file fileb://runtime.zip
動作確認(Lambda関数作成)
最後に、Lambda関数を動かして動作確認します。
マネジメントコンソール上で、新規にLambda関数を作成します。(名前は何でもいいです)
ポイントとしては、
- 「基本的な情報」-「ランタイム」に「Amazon Linux 2023」を指定する(「アーキテクチャ」はとりあえず「x86_64」を指定)
- 関数作成後、「コード」タブで下記設定をする
- 「レイヤー」に先程作成したカスタムランタイムのレイヤーARNを指定する
- 「コードソース」で「hello.js」ファイルを作成する(ファイル名は「ランタイム設定」-「ハンドラ」のファイル名に合わせる。デフォルトでは「hello.handler」になっているはず)
- 「設定」タブの「一般設定」でタイムアウト時間を「15分」など、ある程度長い時間に延長する。
- 最初はカスタムランタイムの読み込みにある程度時間がかかるので
ソースコードは下記の通りにします。(使用しているNode.jsのバージョンを返すだけのシンプルなもの)
module.exports.handler = async event => { return process.version; };
【参考】AWS LambdaのCustom Runtimeを使い、Node.js v8などEoLとなったランタイムを動かす
上記を設定後「テスト」タブからテストを作成し、テストを実行した際に「"v23.6.0"」という結果が返ってくれば、カスタムランタイムは正常に実行されています。
まとめ
以上、カスタムランタイム作成の手順でした。
正直な話、カスタムランタイム自体を使用すること自体が少ないと思いますので、あまり役立つ機会が少ないかもしれませんが、知識として持っておくと役立つかもしれません。
今回はカスタムランタイムを手作業で作成しましたが、次回からはいよいよAWS CDKを用いた作業を行います。
それでは、今回はこの辺で。
【参考】runtime.jsの内容
const http = require('http') const RUNTIME_PATH = '/2018-06-01/runtime' const CALLBACK_USED = Symbol('CALLBACK_USED') const { AWS_LAMBDA_FUNCTION_NAME, AWS_LAMBDA_FUNCTION_VERSION, AWS_LAMBDA_FUNCTION_MEMORY_SIZE, AWS_LAMBDA_LOG_GROUP_NAME, AWS_LAMBDA_LOG_STREAM_NAME, LAMBDA_TASK_ROOT, _HANDLER, AWS_LAMBDA_RUNTIME_API, } = process.env const [HOST, PORT] = AWS_LAMBDA_RUNTIME_API.split(':') start() async function start() { let handler try { handler = getHandler() } catch (e) { await initError(e) return process.exit(1) } tryProcessEvents(handler) } async function tryProcessEvents(handler) { try { await processEvents(handler) } catch (e) { console.error(e) return process.exit(1) } } async function processEvents(handler) { while (true) { const { event, context } = await nextInvocation() let result try { result = await handler(event, context) } catch (e) { await invokeError(e, context) continue } const callbackUsed = context[CALLBACK_USED] await invokeResponse(result, context) if (callbackUsed && context.callbackWaitsForEmptyEventLoop) { return process.prependOnceListener('beforeExit', () => tryProcessEvents(handler)) } } } function initError(err) { return postError(`${RUNTIME_PATH}/init/error`, err) } async function nextInvocation() { const res = await request({ path: `${RUNTIME_PATH}/invocation/next` }) if (res.statusCode !== 200) { throw new Error(`Unexpected /invocation/next response: ${JSON.stringify(res)}`) } if (res.headers['lambda-runtime-trace-id']) { process.env._X_AMZN_TRACE_ID = res.headers['lambda-runtime-trace-id'] } else { delete process.env._X_AMZN_TRACE_ID } const deadlineMs = +res.headers['lambda-runtime-deadline-ms'] let context = { awsRequestId: res.headers['lambda-runtime-aws-request-id'], invokedFunctionArn: res.headers['lambda-runtime-invoked-function-arn'], logGroupName: AWS_LAMBDA_LOG_GROUP_NAME, logStreamName: AWS_LAMBDA_LOG_STREAM_NAME, functionName: AWS_LAMBDA_FUNCTION_NAME, functionVersion: AWS_LAMBDA_FUNCTION_VERSION, memoryLimitInMB: AWS_LAMBDA_FUNCTION_MEMORY_SIZE, getRemainingTimeInMillis: () => deadlineMs - Date.now(), callbackWaitsForEmptyEventLoop: true, } if (res.headers['lambda-runtime-client-context']) { context.clientContext = JSON.parse(res.headers['lambda-runtime-client-context']) } if (res.headers['lambda-runtime-cognito-identity']) { context.identity = JSON.parse(res.headers['lambda-runtime-cognito-identity']) } const event = JSON.parse(res.body) return { event, context } } async function invokeResponse(result, context) { const res = await request({ method: 'POST', path: `${RUNTIME_PATH}/invocation/${context.awsRequestId}/response`, body: JSON.stringify(result === undefined ? null : result), }) if (res.statusCode !== 202) { throw new Error(`Unexpected /invocation/response response: ${JSON.stringify(res)}`) } } function invokeError(err, context) { return postError(`${RUNTIME_PATH}/invocation/${context.awsRequestId}/error`, err) } async function postError(path, err) { const lambdaErr = toLambdaErr(err) const res = await request({ method: 'POST', path, headers: { 'Content-Type': 'application/json', 'Lambda-Runtime-Function-Error-Type': lambdaErr.errorType, }, body: JSON.stringify(lambdaErr), }) if (res.statusCode !== 202) { throw new Error(`Unexpected ${path} response: ${JSON.stringify(res)}`) } } function getHandler() { const appParts = _HANDLER.split('.') if (appParts.length !== 2) { throw new Error(`Bad handler ${_HANDLER}`) } const [modulePath, handlerName] = appParts // Let any errors here be thrown as-is to aid debugging const app = require(LAMBDA_TASK_ROOT + '/' + modulePath) const userHandler = app[handlerName] if (userHandler == null) { throw new Error(`Handler '${handlerName}' missing on module '${modulePath}'`) } else if (typeof userHandler !== 'function') { throw new Error(`Handler '${handlerName}' from '${modulePath}' is not a function`) } return (event, context) => new Promise((resolve, reject) => { context.succeed = resolve context.fail = reject context.done = (err, data) => err ? reject(err) : resolve(data) const callback = (err, data) => { context[CALLBACK_USED] = true context.done(err, data) } let result try { result = userHandler(event, context, callback) } catch (e) { return reject(e) } if (result != null && typeof result.then === 'function') { result.then(resolve, reject) } }) } function request(options) { options.host = HOST options.port = PORT return new Promise((resolve, reject) => { let req = http.request(options, res => { let bufs = [] res.on('data', data => bufs.push(data)) res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: Buffer.concat(bufs).toString(), })) res.on('error', reject) }) req.on('error', reject) req.end(options.body) }) } function toLambdaErr(err) { const { name, message, stack } = err return { errorType: name || typeof err, errorMessage: message || ('' + err), stackTrace: (stack || '').split('\n').slice(1), } }