AWS CDKのbundlingオプションを使ってLambdaへのデプロイ前処理もCDKで管理する方法

この記事はAWS LambdaとServerless Advent Calendar 2020の13日目の記事です。LambdaとSeverlessということで大好きなAWS CDKを使えばLambdaへのデプロイ前処理まで管理できるよ!という内容をお伝えします。
2020.12.13

はじめに

おはようございます、加藤です。この記事は AWS LambdaとServerless Advent Calendar 2020 の13日目の記事です。LambdaとSeverlessということで大好きなAWS CDKを使えばLambdaへのデプロイ前処理まで管理できるよ!という内容をお伝えします。

本記事は今までLambdaを使ってWebアプリケーションやバッチ処理などを作成した経験のあり、少なくとも CDK Workshop はやったことがある、同等以上にCDKを使った経験がある方を想定しています。

記事を書く際に検証したサンプルコードをこちらのリポジトリで公開しています、合わせてご確認ください。

https://github.com/intercept6/sample-cdk-lambda-bundling

Lambdaのバンドル

Lambda関数をデプロイするにはスクリプトまたはコンパイルされたプログラムおよび依存関係を1つのZIPアーカイブにまとめる必要があります。このまとめるまでの一連の処理を本記事ではバンドルすると呼称します。

バンドル処理はプログラミング言語によって必要なステップが異なります。TypeScript - Node.jsの場合は、トランスコンパイルしてからnode_moduleと一緒にまとめる必要がありますが、Goの場合はコンパイルすれば依存関係が解決されたシングルバイナリが手に入ります。

デプロイパッケージに関してはこちらの記事内でもう少し説明しています。

AWS Lambdaのデプロイパッケージをどの範囲で構築すべきか

この記事の目的はCDKでLambda関数およびLambdaレイヤーをバンドルする方法をお伝えすることです。

Node.jsとPythonの場合

CDKにはaws-lambda-node.jsaws-lambda-pythonというaws-lambdaパッケージを更にそれぞれの言語向けに特化させたパッケージが存在します。これらのパッケージによってランタイムにNode.js(JavaScript、TypeScript)かPythonを使用する場合は他のランタイムと比べてより快適にバンドルが行えます。

aws-lambda-node.js

このパッケージを使うとentryで指定したJavaScript、TypeScriptファイルをesbuildを使ってトランスコンパイルおよびバンドルしてくれます。このバンドルとは前述したLambdaのバンドルではなく依存関係を単一のJavaScriptファイルにまとめるという意味のバンドルです。

handlerにはLambda関数でハンドラとして使用したい関数名を指定します。

new lambda.NodejsFunction(this, 'MyFunction', {
  entry: '/path/to/my/file.ts', // accepts .js, .jsx, .ts and .tsx files
  handler: 'myExportedFunc'
});

esbuildはDockerコンテナ上で実行されますが、ローカルにesbuildがインストールされている場合はそれを識別してローカルで実行します。しかし、Lambda関数はLinux環境で動くのでプロダクションに乗せる際はデプロイパイプラインを構築し、その環境で直接もしくはDocker上で実行するのが好ましいでしょう。ローカルにesbuildが存在するがコンテナ上でバンドルを行わせたい場合は forceDockerBundling というオプションがあるのでこれを true に設定すれば、文字通りコンテナ上でのバンドルを強制できます。

前述の通りこのパッケージはバンドルを行うので、基本的にはLambdaレイヤーと併用することはないでしょう。

aws-lambda-python

このパッケージを使うとentryで指定したディレクトリにある、indexで指定したPythonファイルをバンドルします。指定されたディレクトリにrequirements.txtもしくはPipfileが存在する、つまりパッケージの管理が行われていれば、これに従って依存関係をまとめてバンドルします。パッケージのバージョンはロックファイル(Pipfile.lock)が存在すればそれに従います。

new PythonFunction(this, 'MyFunction', {
  entry: '/path/to/my/function', // required
  index: 'my_index.py', // optional, defaults to 'index.py'
  handler: 'my_exported_func', // optional, defaults to 'handler'
  runtime: lambda.Runtime.PYTHON_3_6 // optional, defaults to lambda.Runtime.PYTHON_3_7
});

PythonLamberVersionを使えばLambdaレイヤーを作成することができます。entryで指定したディレクトリに存在するrequirements.txtもしくはPipfileをLambdaレイヤーとしてバンドルします。

依存関係をLambda関数ごとではなくリポジトリ全体で管理したい場合にaws-lambdaパッケージのFunctionと組み合わせると便利です。

    // ルートディレクトリを直接していするとHost→Containerのファイル同期で時間がかかって辛かったのでロックファイルを専用ディレクトリに複製し、そこをPythonLayerVersionで参照する。
   copyFileSync('Pipfile', 'src/lambda-layer/Pipfile')
    copyFileSync('Pipfile.lock', 'src/lambda-layer/Pipfile.lock')

    const pythonLayer = new PythonLayerVersion(this, 'python-layer', {
      entry: 'src/lambda-layer',
      compatibleRuntimes: [Runtime.PYTHON_3_8],
    })
    new Function(this, 'fizzbuzz-python-with-layer-func', {
      handler: 'fizzbuzz.handler',
      code: Code.fromAsset(
        resolve(__dirname, '..', 'src', 'lambda', 'python-with-layer')
      ),
      runtime: Runtime.PYTHON_3_8,
      layers: [pythonLayer],
    })

その他のランタイムの場合

aws-lambda パッケージのCode.fromAssetは実は第2引数でオプションが設定でき、bundlingというオプションが存在します。これを使うことで任意のバンドル処理を定義することができます。/asset-outputにデプロイしたいスクリプトまたはバイナリを配置します、それがそのままのディレクトリ構造でバンドルされます。

処理はコンテナ内で行われますが、codeオプションで指定したディレクトリはコピーではなくマウントされているので処理はローカルにも影響を及ぼします。これを避けたい場合はコンテナ上に作業ディレクトリを作って必要なファイルをコピーし、そのディレクトリに移動してから処理してください。

new Function(this, 'fizzbuzz-go-func', {
      handler: 'main',
      runtime: Runtime.GO_1_X,
      code: Code.fromAsset(resolve(__dirname, '..', 'src', 'lambda', 'go'), {
        assetHashType: AssetHashType.OUTPUT,
        bundling: {
          image: Runtime.GO_1_X.bundlingDockerImage,
          command: [
            'bash',
            '-c',
            'GOOS=linux GOARCH=amd64 go build -o /asset-output/main',
          ],
          user: 'root',
        },
      }),
    })

JavascriptまたはTypeScriptを使う際にesbuildを使ってバンドルをしたくない場合はbundlingオプションをこのように使うことでパッケージのLambdaレイヤーとLambda関数を作成することができます。

const nodejsLayer = new LayerVersion(this, 'NodejsLayer', {
      code: Code.fromAsset(resolve(__dirname, '..'), {
        assetHashType: AssetHashType.OUTPUT,
        bundling: {
          image: Runtime.NODEJS_12_X.bundlingDockerImage,
          command: [
            'bash',
            '-c',
            [
              'npm install -sg yarn',
              'mkdir -p /asset-output/nodejs/',
              'cp package.json yarn.lock /asset-output/nodejs/',
              'yarn install --force --silent --production --cwd /asset-output/nodejs/',
            ].join(' && '),
          ],
          user: 'root',
        },
      }),
      compatibleRuntimes: [Runtime.NODEJS_12_X],
    })

    new Function(this, 'fizzbuzz-nodejs-with-layer-func', {
      handler: 'src/lambda/node/fizzbuzz.handler',
      runtime: Runtime.NODEJS_12_X,
      code: Code.fromAsset(resolve(__dirname, '..'), {
        assetHashType: AssetHashType.OUTPUT,
        bundling: {
          image: Runtime.NODEJS_12_X.bundlingDockerImage,
          command: [
            'bash',
            '-c',
            [
              'npm install -sg yarn',
              'cp -au src tsconfig.json package.json yarn.lock /tmp',
              'cd /tmp',
              'yarn install --force --silent',
              'yarn ts-build --outDir /asset-output',
            ].join(' && '),
          ],
          user: 'root',
        },
      }),
      layers: [nodejsLayer],
    })

あとがき

Lambda関数およびLambdaレイヤーにデプロイするには、パッケージのダウンロードやコンパイルなどの処理が必要ですが、これは従来の一般的なInfrastructure as Codeツールではサポートされず独自でスクリプトを作成して行っていました。CDKにおいてもbundlingオプションが登場する前はnpm scriptsを使いBashで処理または、Entryファイルの./bin/***.tsに処理を書く必要がありました。これで困ってはいませんでしたが個々のエンジニアの好みで実装されるため、他の人が作ったCDKを最初に読むときの詰まるポイントの1つでした。今後このbundlingオプションを使うことが標準化されていくと私的には嬉しいです。

見るのと体感するのでは全然異なるので、CDKでLambdaを使っている方やLambdaを使っているがCDKは使ったことが無かった方にはぜひ試して頂きたいです。

以上です。