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.jsonpackage-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.shpackage.jsonpackage-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 時に差分とすることができました!
hashは package-lock.json じゃなくてなぜ package.json ?
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'))
}
Lambda Function に layers を追加
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