Terraform CloudでLambdaをデプロイ時に npm install する
はじめに
Terraform Cloudを使ってLambdaを構築する際、ライブラリを npm install
するやり方を紹介します。
ローカルから terraform apply
する分には、予め npm install
を行って node_modules
を用意した状態で反映できますが、基本的に node_modules
は gitignore していると思うので、Terraform Cloudではそうはいきません。
解決方法は何個かあります。
-
CodeBuild or Lambda によるインストール
- S3に
package.json
package-lock.json
をzipでアップロード - ビルド用のCodeBuild or Lambdaでzipを取得し
npm install
を実行し、再度zipに圧縮&S3にアップロード - S3からLambdaをデプロイ
- S3に
-
Terraform Cloud上にNode.js環境を構築しインストール
-
resource "null_resource"
でNode.js環境構築&インストール -
data "archive_file"
でzipを作成し、Lambdaにデプロイ
-
など、他にもあるかもしれません。
この記事では2つ目の「Terraform Cloud上にNode.js環境を構築しインストール」する方法を解説したいと思います。
この方法だとTerraform単体で npm install
の実行とLambdaのデプロイが完了するので、ビルド用のリソースを作成する必要がなくお手軽です。
またこの記事では、npm_modules
を Lambda Layerを使って共通化します。
手っ取り早くソースが見たい方はこちら ↓
ディレクトリ・ファイル構成
この記事では以下のようなディレクトリ・ファイル構成を作ります。
- lambda/
- layers/ (Lambda Layer用)
- nodejs/
build.sh
package.json
package-lock.json
- nodejs/
- sample_function/
index.js
- layers/ (Lambda Layer用)
main.tf
-
outputs.tf
(使わないけど設置) -
variables.tf
(使わないけど設置)
サンプルの実装で見通しがしやすいようにすべて main.tf
へ記述しますが、実際にはmoduleにするなり、よしなにお願いします。
IAMロールの作成
兎にも角にも、まずはIAMロールの作成をします。
./main.tf
data "aws_iam_policy_document" "lambda_assume_role_document" {
statement {
effect = "Allow"
principals {
identifiers = [
"lambda.amazonaws.com"
]
type = "Service"
}
actions = [
"sts:AssumeRole"
]
}
}
resource "aws_iam_role" "lambda" {
name = "sample-lambda-role"
path = "/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_document.json
}
resource "aws_iam_role_policy_attachment" "aws_lambda_basic_execution_role" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
-
data "aws_iam_policy_document" "lambda_assume_role_document"
はLambdaの実行ロールです
参考) https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-intro-execution-role.html#permissions-executionrole-api -
resource "aws_iam_role_policy_attachment" "aws_lambda_basic_execution_role"
のarn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
は AWS管理ポリシーでCloud Watchへの書き込み権限が入っています。
Lambda Functionの作成
次に npm install
しない状態のLambda Functionを作成します。
JavaScriptファイルの用意
./lambda/sample_function/index.js
exports.handler = () => {
console.log('sample')
}
リソースの作成
./main.tf
locals {
lambda_root = "./lambda"
sample_function_source = "${local.lambda_root}/sample_function"
sample_function_output = "${local.lambda_root}/sample_function.zip"
}
data "archive_file" "lambda_sample_function" {
type = "zip"
source_dir = local.sample_function_source
output_path = local.sample_function_output
}
resource "aws_lambda_function" "sample_function" {
filename = data.archive_file.lambda_sample_function.output_path
source_code_hash = data.archive_file.lambda_sample_function.output_base64sha256
function_name = "sample_function"
handler = "index.handler"
role = aws_iam_role.lambda.arn
runtime = "nodejs12.x"
publish = true
memory_size = 128
timeout = 3
}
この時点では ./lambda/sample_funcion
ディレクトリをTerraformでzipファイルにしてLambdaにアップロードしています。
node_modules
が必要なければこれで十分ですね。
Lambda Layerの作成
node_modules
をアップロードする Lambda Layerを作成します。
今回は日付処理でよく使用されるMoment.jsをインストールしてみます。
package.json
package-lock.json
の生成
cd ./lambda/layers/nodejs
npm init --yes
npm i moment
./lambda/layers/nodejs
に package.json
と package-lock.json
が出来上がりました。
build.sh
の作成
buils.sh
npm install --production
Terraformから build.sh
を実行して npm install
を実行します。
--production
は dependencies
のみインストールするオプションです。
リソースの作成
main.tf
locals {
lambda_root = "./lambda"
sample_function_source = "${local.lambda_root}/sample_function"
sample_function_output = "${local.lambda_root}/sample_function.zip"
sample_layers_source = "${local.lambda_root}/layers"
sample_layers_nodejs = "${local.sample_layers_source}/nodejs"
sample_layers_package_json = "${local.sample_layers_nodejs}/package.json"
sample_layers_build_shell = "build.sh"
}
resource "null_resource" "sample_layer_source_build" {
triggers = {
layer_build = filebase64sha256(local.sample_layers_package_json)
}
provisioner "local-exec" {
working_dir = local.lambda_root
command = <<-EOF
mkdir ./node_install && \
cd ./node_install && \
curl https://nodejs.org/dist/v12.19.0/node-v12.19.0-linux-x64.tar.gz | tar xz --strip-components=1 && \
export PATH="$PWD/bin:$PATH" && \
cd ../../ && \
cd ${local.sample_layers_nodejs} && \
chmod +x ${local.sample_layers_build_shell} && \
${local.sample_layers_build_shell}
EOF
}
}
data "archive_file" "appsync_resolver_nodejs_layer" {
type = "zip"
source_dir = local.sample_layers_source
output_path = "${local.lambda_root}/layers-${filebase64sha256(local.sample_layers_package_json)}.zip"
depends_on = [null_resource.sample_layer_source_build]
}
resource "aws_lambda_layer_version" "sample_nodejs_layer" {
filename = data.archive_file.appsync_resolver_nodejs_layer.output_path
layer_name = "sample_nodejs_layer"
compatible_runtimes = ["nodejs12.x"]
}
-
resource "null_resource" "sample_layer_source_build"
でbuild.sh
を実行します。-
null_resource
はリソースを作成しないけど、なにかコマンドなどを実行したい場合に使用します。 -
triggers
はmapで定義され、source_code_hash
のように値が変更されるとnull_resource
が再実行されます。 -
provisioner "local-exec"
はローカル上で実行、つまりTerraform Cloud 上で実行を意味します。 -
command
はヒアドキュメントでNodejsのインストールとbuild.sh
の実行を行います。
参考) https://www.terraform.io/docs/provisioners/null_resource.html
-
-
data "archive_file" "appsync_resolver_nodejs_layer"
でbuild.sh
実行後のディレクトリをzipにします。- この際、
layers
ディレクトリをそのままzipにする必要があります。これはnodejs
ディレクトリがzipに含まれることでLambda LayerがNode.jsの依存関係を解決してくれるためです。
参考) https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-layers.html#configuration-layers-path -
depends_on
でnull_resource
への依存関係を示してあげる必要があります。
- この際、
-
resource "aws_lambda_layer_version" "sample_nodejs_layer"
で Lambda Layerを作成します。- source_code_hashは不要です!理由は以下で。
resource "aws_lambda_layer_version"
の source_code_hash
が不要な理由
resource "null_resource"
は terraform plan
時には実行されません。
仮に、 source_code_hash
にdata.archive_file.appsync_resolver_nodejs_layer.output_base64sha256
を代入した場合、 build.sh
が実行される前のzip でhashが作られることになります。
そして terraform apply
時には、 buils.sh
が実行されたあとにzipのhash になるので、確実に違うものになってしまい、変更がなくても意図せず次回の差分になってしまうのです。
package.jsonを filebase64sha256
で source_code_hash
に入れればよいのでは? とも考えましたが、
terraform apply
後 source_code_hash
には実際に使われたファイル、つまりbuils.sh
が実行されたあとにzipから生成されたhashになり、こちらもうまくいきませんでした。
解決策としてzipファイル名にhashを埋め込む
resource "null_resource"のtriggers
triggers = {
layer_build = filebase64sha256(local.sample_layers_package_json)
}
data "archive_file" "appsync_resolver_nodejs_layer"のoutput_path
output_path = "${local.lambda_root}/layers-${filebase64sha256(local.sample_layers_package_json)}.zip"
resource "aws_lambda_layer_version" "sample_nodejs_layer"のfilename
filename = data.archive_file.appsync_resolver_nodejs_layer.output_path
filename
が ./lambda/layers
の中身によって変化し、terraform plan
時に差分とすることができました!
package-lock.json
じゃなくてなぜ package.json
?
hashは package-lock.json
を使う方法も試したのですが、 null_resource
実行前後で内容が変わっている様で、こちらも意図せず差分がでました。
Lambda FunctionにLayerを追加
index.js
を編集
Moment.jsを使う形に index.js
を編集します。
const moment = require('moment')
exports.handler = () => {
console.log(moment().format('YYYY-MM-DD HH:mm:ss'))
}
layers
を追加
Lambda Function に resource "aws_lambda_function" "sample_function" {
filename = data.archive_file.lambda_sample_function.output_path
source_code_hash = data.archive_file.lambda_sample_function.output_base64sha256
function_name = "sample_function"
handler = "index.handler"
role = aws_iam_role.lambda.arn
runtime = "nodejs12.x"
publish = true
memory_size = 128
timeout = 3
layers = [aws_lambda_layer_version.sample_nodejs_layer.arn]
}
これで layers
が追加され node_modules
のライブラリが使用可能になります。
まとめ
最後まで読んでいただきありがとうございます。
Terraform Cloud のみで必要なライブラリを取得できるので、やれることの幅が広がりそうです。
もちろんNode.js以外でも同じように環境を作れさえすれば使えるので便利だなと思いました。
参考集
以下の記事を参考にさせていただきました。
- AWS Lambda レイヤー - AWS Lambda
- raymondbutcher/terraform-aws-lambda-builder: Terraform module to build Lambda functions in Lambda or CodeBuild
- Como deployar AWS Lambda Layers com Terraform e Node.js | by Cleber Gasparoto | Medium
- AWS Lambda Layersでnode_modulesを使う | AWSやシステム・アプリ開発の最新情報|クロスパワーブログ
- aws_lambda_function: source_code_hash argument expects a zip file to be present · Issue #6513 · hashicorp/terraform
Discussion