【AWS CDK】LambdaをTypeScriptで動かす環境を構築する（その1：カスタムランタイム作成）

## はじめに  
※この記事は、私の個人ブログ（下記）と同じ内容になります。  

https://makky12.hatenablog.com/entry/2025/03/10/120500  
  
  
本ブログは、2025/02/21（金）に開催された[JAWS-UG CDK支部#19 クラスメソッドコラボ回](https://jawsug-cdk.connpass.com/event/343974/)における私のLT「CDKでカスタムランタイムを作成して、Lambdaをnode.js23+TypeScriptで動かしてみた」の詳細資料になります。
  
LTの発表資料は、下記で公開しています。（上記Connpassページにもリンクがあります）  
  
https://speakerdeck.com/smt7174/cdkdekasutamurantaimuwozuo-cheng-site-lambdawonode-dot-js23-plus-typescriptdedong-kasitemita  
  
なお今回は下記の構成で、何回かに分けて投稿します。  
  
1. カスタムランタイム作成   ※今回はこれ
1. AWS CDKでの実装＆TypeScriptで動作させる  
1.  AWSリソースにアクセスする（AWS SDK for JavaScript v3を使う）  
  
### Node.jsのTypeScriptサポート  
Node.js が ver23.6.0から、TypeScriptの直接実行を正式サポートしました。（Experimentalではなくなった） 
  
https://www.totaltypescript.com/typescript-is-coming-to-node-23
  
色々と制約はありますが、node.jsでTypeScriptの直接実行が可能になったことで、JavaScriptへのトランスパイルやそれに伴う各種設定が不要になりました。  
  
そこで「node.jsでTypeScriptが直接実行できるなら、LambdaもTypeScriptで実行できるんじゃね？」ってことで、それを検証する...というのが今回の記事になります。


### 「カスタムランタイム」とは？  
「カスタムランタイム」とは、AWSが標準で用意しているランタイム以外に、自分で作成可能なLambdaランタイムの事です。  
  
あまり使用する機会はないかもしれませんが[^1]、下記の用途で用います。  

[^1]: ((今ならコンテナLambdaで代用できますので...))
  
- Lambdaでサポートされていない言語で動かしたい（Perl, Cなど） 
- サポートが終了した過去バージョンで動かしたい（Node.js 18未満など）
  
今回の場合、AWSの標準ランタイムではNode.js ver22.xまでしかサポートされていないため、ver23.6.0を動かすにはカスタムランタイムの作成が必要となります。  
  
【参考】 [AWS Lambda 用カスタムランタイムの構築](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-custom.html)

### カスタムランタイムを作成する  
カスタムランタイムの作成方法はAWS公式サイトにドキュメントがありますので、それに沿う形で実施します。  
  
【参考】[チュートリアル: カスタムランタイムの構築](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-walkthrough.html)
  
ざっくり記載すると、こんな作業になります  
  
1. ```bootstrap``` ファイルと ```runtime.js``` ファイルを作り、コードを記載する  
1. node.js ver23.6.0のバイナリファイルを用意する
1. 上記3ファイルをzipファイルにして、```aws lambda publish-layer-version``` コマンドを実行する  
  
なお公式チュートリアルページには「関数の作成」と「レイヤーの作成」の2つがありますが、今回はLambda関数を別で用意するため、「レイヤーの作成」にてカスタムランタイムの作成のみ実施します。  
  
#### bootstrapファイルの用意  
とりあえずフォルダ（公式チュートリアルに沿い、「runtime-tutorial」とする）を作って、そのフォルダ内に```bootstrap``` ファイルを作ります。（ファイル名は ```bootstrap``` でないといけないので注意してください。）  
  
```bootstrap``` ファイルをテキストエディタで開き、下記コードを記載します。（公式チュートリアルページの内容と全然違いますが、気にしないでOKです。）  
    
```sh
#!/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 ランタイムを実行する例」として下記コードが記載されています。  
  
```bash
#!/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です）  

https://github.com/lambci/node-custom-lambda/blob/master/v12.x/bootstrap.js

なお、参考情報としてこのソースを末尾に掲載していまので、興味があればご参照ください。[^2]
  
[^2]: ソースを追ってみると、Lambdaの初期処理の内容が理解できますので、一度ソースを追ってみることをお勧めします))    
  
#### node.js ver23.6.0のバンドル
次にnode.js ver23.6.0のバンドルのために、バイナリファイルを用意します。（Lambda上に存在しないため、自分でバンドルする必要がある）  
  
これは[Node.js公式サイト](https://nodejs.org/dist/v23.6.0/)から「node-v23.6.0-linux-x64.tar.gz」（または「node-v23.6.0-linux-arm64.tar.gz」）をダウンロードした上で、解凍したフォルダを「runtime-tutorial」フォルダに保存すればOKです。  
  
### Lambdaレイヤーの作成  
最後に、カスタムランタイムのLambdaレイヤーを作成します。  
  
これは公式チュートリアルページの内容に従い、  
  
- 全ファイルに読み取り＆実行可能パーミッション（755）を付与する
- 全ファイルを圧縮して、```aws lambda publish-layer-version``` コマンドでLambdaレイヤーを作成する  
  
をすればOKです。  
  
具体的なコマンドは下記の通りです。（「runtime-tutorial」フォルダ上で実施してください）
  
```sh
# パーミッション付与
$ 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のバージョンを返すだけのシンプルなもの）  
   
```javascript
module.exports.handler = async event => {
  return process.version;
};
```  
  
【参考】[AWS LambdaのCustom Runtimeを使い、Node.js v8などEoLとなったランタイムを動かす](https://qiita.com/uhey22e/items/89a99c0cb6ad7eca567d)  
  
上記を設定後「テスト」タブからテストを作成し、テストを実行した際に「"v23.6.0"」という結果が返ってくれば、カスタムランタイムは正常に実行されています。    
  
### まとめ  
以上、カスタムランタイム作成の手順でした。  
  
正直な話、カスタムランタイム自体を使用すること自体が少ないと思いますので、あまり役立つ機会が少ないかもしれませんが、知識として持っておくと役立つかもしれません。  
  
今回はカスタムランタイムを手作業で作成しましたが、次回からはいよいよAWS CDKを用いた作業を行います。  
  
それでは、今回はこの辺で。  
  
  
### 【参考】runtime.jsの内容 
  
```javascript
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),
  }
}
```