🕺

Terraform CloudでLambdaをデプロイ時に npm install する

2020/10/14に公開

はじめに

Terraform Cloudを使ってLambdaを構築する際、ライブラリを npm install するやり方を紹介します。

ローカルから terraform apply する分には、予め npm install を行って node_modules を用意した状態で反映できますが、基本的に node_modules は gitignore していると思うので、Terraform Cloudではそうはいきません。

解決方法は何個かあります。

  • CodeBuild or Lambda によるインストール
    1. S3に package.json package-lock.json をzipでアップロード
    2. ビルド用のCodeBuild or Lambdaでzipを取得し npm install を実行し、再度zipに圧縮&S3にアップロード
    3. S3からLambdaをデプロイ
  • Terraform Cloud上にNode.js環境を構築しインストール
    1. resource "null_resource" でNode.js環境構築&インストール
    2. data "archive_file" でzipを作成し、Lambdaにデプロイ

など、他にもあるかもしれません。

この記事では2つ目の「Terraform Cloud上にNode.js環境を構築しインストール」する方法を解説したいと思います。
この方法だとTerraform単体で npm install の実行とLambdaのデプロイが完了するので、ビルド用のリソースを作成する必要がなくお手軽です。

またこの記事では、npm_modules を Lambda Layerを使って共通化します。

手っ取り早くソースが見たい方はこちら ↓

https://github.com/RikutoYamaguchi/terraform-cloud-lambda-npm-install

ディレクトリ・ファイル構成

この記事では以下のようなディレクトリ・ファイル構成を作ります。

  • lambda/
    • layers/ (Lambda Layer用)
      • nodejs/
        • build.sh
        • package.json
        • package-lock.json
    • sample_function/
      • index.js
  • 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"
}

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/nodejspackage.jsonpackage-lock.json が出来上がりました。

build.sh の作成

buils.sh

npm install --production

Terraformから build.sh を実行して npm install を実行します。
--productiondependencies のみインストールするオプションです。

リソースの作成

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"]
}
  1. 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
  2. data "archive_file" "appsync_resolver_nodejs_layer"build.sh 実行後のディレクトリをzipにします。
  3. 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_hashdata.archive_file.appsync_resolver_nodejs_layer.output_base64sha256 を代入した場合、 build.shが実行される前のzip でhashが作られることになります。
そして terraform apply 時には、 buils.sh が実行されたあとにzipのhash になるので、確実に違うものになってしまい、変更がなくても意図せず次回の差分になってしまうのです。

package.jsonを filebase64sha256source_code_hash に入れればよいのでは? とも考えましたが、
terraform applysource_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以外でも同じように環境を作れさえすれば使えるので便利だなと思いました。

参考集

以下の記事を参考にさせていただきました。

Discussion