TypeScriptのLambda関数をTerraformだけでデプロイする

2022.06.22

以前、「GolangのLambda関数をTerraformだけでデプロイする」というエントリを書きました。

今回はこれのTypeScript版をやっていきたいと思います。

動機としてはGolangの際と同じです。すでにTerraformでたくさんのリソースをプロビジョニングしているプロジェクトで、Lambda関数一つ追加するためだけにcdkやslsを使うよりかはTerraformにまとめてしまいたいと考えました。

やること

以下AWS公式ドキュメントにAWS CLIとesbuildでTypeScript Lambda関数をデプロイする例が載っています。これをベースにterraform applyだけでデプロイできる構成を作成します。

つまりTerraformの中でLambda関数のデプロイに加えて以下も行ないます。

  • パッケージのインストール
  • ts → jsへのトランスパイル
  • デプロイメントパッケージ(Zipファイル)の作成

全体像

コードの全体像は以下です。

.
├── lambdas
│   └── helloworld
│       ├── .gitignore
│       ├── dist
│       ├── index.ts
│       ├── package-lock.json
│       └── package.json
├── .gitignore
├── .terraform-version
├── .terraform.lock.hcl
├── lambda.tf
├── locals.tf
├── main.tf
└── providers.tf

処理の流れは以下です。

  1. Lambda関数ごとに /lambdas以下にディレクトリを作成(今回はhelloworldのみ)
  2. 上記ディレクトリ以下でコードを書く
  3. /lambda/helloworld/dist/以下にトランスパイル後のjsファイルを作成する
  4. /lambda/helloworld/dist/以下にデプロイメントパッケージ(Zipファイル)を作成する
  5. デプロイメントパッケージをS3にアップロードする
  6. アップロードしたデプロイメントパッケージを指定してLambda関数をデプロイ

3〜6までが terraform applyでできます。かつ2回目以降の実行ではコードに変更があったときだけこれら(3〜6)の処理が走ります。

動作環境

  • MacBook Pro (13-inch, M1, 2020)
  • macOs Big Sur v11.6.5
  • Terraform
    • v1.2.3
    • aws provider
      • v4.19.0
    • null provider
      • v3.1.1
  • 以下コマンドが使える
    • aws cli
    • openssl
    • zip
    • npm

GitHub

以下で中身を解説していきます。(Golang版のエントリと内容が重複する点が多々あります。)

Lambda関数コード

コードの中身自体は今回は特に重要ではないので、前述のAWS公式ドキュメントに載っていたものを使っています。

import { Context, APIGatewayProxyResult, APIGatewayEvent } from 'aws-lambda';

export const handler = async (event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> => {
  console.log(`Event: ${JSON.stringify(event, null, 2)}`);
  console.log(`Context: ${JSON.stringify(context, null, 2)}`);
  return {
      statusCode: 200,
      body: JSON.stringify({
          message: 'hello world',
      }),
   };
};

Resource: aws_lambda_function

上記コードを使うLambda関数をデプロイするリソースです。

lambda.tfの一部

resource "aws_lambda_function" "helloworld" {
  function_name    = "typescript-sample-helloworld"
  s3_bucket        = aws_s3_bucket.lambda_assets.bucket
  s3_key           = data.aws_s3_object.package.key
  role             = aws_iam_role.iam_for_lambda.arn
  handler          = "index.handler"
  source_code_hash = data.aws_s3_object.package_hash.body
  runtime          = "nodejs16.x"
  timeout          = "10"
}

デプロイメントパッケージの指定

aws_lambda_functionではデプロイメントパッケージの指定方法が2種類、ローカル上に存在するものを使う方法と、S3にアップされているものを使う方法があります。今回は後者を採用しました。

Golangの際はローカルの方法が安定しなかったのでS3の方法を採用した経緯があります。詳しくはGolang版のエントリをご覧ください。今回はローカル版の検証は行わず実績のあるS3版のみをやってみましたが、おそらく今回もローカル版だとGolangの際と同じ問題が発生するはずです。

source_code_hash

このattributeは関数の再デプロイのトリガーに使われます。この値が更新されると再デプロイされます。

Must be set to a base64-encoded SHA256 hash of the package file

という要件があります。

ローカルのデプロイメントパッケージを使う場合は filebase64sha256("file.zip") とか base64sha256(file("file.zip")) といった方法が使えます。

また、ローカルのデプロイメントパッケージを使い、さらにzip化までTerraform管理内にする場合は Data Sources: archive_file が使えて、このAttributes Referenceにoutput_base64sha256というのがあるので、 source_code_hash = data.archive_file.deploy_package.output_base64sha256 みたいな書き方が使えます。

しかしながら、もう一方の方法、S3にアップされているファイルを使う方法の場合の指定方法が調べても中々見つからず、苦労しました。。最終的にbase64-encoded SHA256 hashの結果だけを格納したファイルを作成して、それをS3にアップし参照するという方法に落ち着きました。この点は後述します。

デプロイメントパッケージアップロード用S3バケット

前述したとおりデプロイメントパッケージをS3にアップロードする必要があるので、それ用のS3バケットをプロビジョニングします。セキュリティ面の最低限の設定を加えています。

lambda.tfの一部

resource "aws_s3_bucket" "lambda_assets" {}
resource "aws_s3_bucket_acl" "lambda_assets" {
  bucket = aws_s3_bucket.lambda_assets.bucket
  acl    = "private"
}
resource "aws_s3_bucket_server_side_encryption_configuration" "lambda_assets" {
  bucket = aws_s3_bucket.lambda_assets.bucket

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
resource "aws_s3_bucket_public_access_block" "lambda_assets" {
  bucket = aws_s3_bucket.lambda_assets.bucket

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

デプロイメントパッケージを参照する Datasource: aws_s3_bucket_objects

前述のaws_lambda_function.helloworld.s3_keyで参照されているdata sourceです。

depends_on = [null_resource.lambda_build] を指定しているのがポイントです。後述しますがこのnull_resource.lambda_build内でこのdata sourceで参照しているS3オブジェクトを作成しています。ですので、null_resource.lambda_buildの実行がこのdata sourceの参照より先に行われることを保証するためにdepends_onが必要なのです。またこのdepends_onのおかげでnull_resource.lambda_buildが再実行される度にその後に参照が行われます。

lambda.tfの一部

data "aws_s3_object" "package" {
  depends_on = [null_resource.lambda_build]

  bucket = aws_s3_bucket.lambda_assets.bucket
  key    = local.helloworld_function_package_s3_key 
}

パッケージのインストール、トランスパイル、zip化、S3アップロード、全部null_resourceでやる

null_resourceはちょっと特殊なリソースで、簡単にいうと指定したコマンドを実行するリソースです。 provisioner "local-exec"というブロック内で書いたコマンドはローカルで実行され、provisioner "remote-exec"ブロック内に書いたコマンドは外部サーバー上で実行されます。(接続設定が別途必要です)

local-exec

今回以下のように3つprovisioner "local-exec"ブロックを定義しており、デプロイメントパッケージ(zipファイル)をS3バケットにアップロードするまでの作業をすべて行なっています。(local-execブロックは残り2つ、つまり計5つあるのですが、残りの2つについては別項にて後述します。)

lambda.tfの一部

  provisioner "local-exec" {
    command = "cd ${local.helloworld_function_dir_local_path} && npm install"
  }
  provisioner "local-exec" {
    command = "cd ${local.helloworld_function_dir_local_path} && npm run build"
  }
  provisioner "local-exec" {
    command = "aws s3 cp ${local.helloworld_function_package_local_path} s3://${aws_s3_bucket.lambda_assets.bucket}/${local.helloworld_function_package_s3_key}"
  }

npm run buildの中身は以下です。(前述のAWS公式ドキュメントに書かれているものと同じです)

./lambdas/helloworld/package.jsonの一部

{
  "scripts": {
    "prebuild": "rm -rf dist/",
    "build": "esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js",
    "postbuild": "cd dist && zip -r index.zip index.js*"
  }
}

prebuildpostbuildはそれぞれ buildが実行される前後に実行されます。(※ 参考:npm-scripts:pre・postプレフィックスを利用して、スクリプト実行前後に別のスクリプトも実行させる方法 - NxWorld)

AWS認証設定についての注意点

最後のS3へのアップロード処理でaws cliを使っています。もしTerraform aws providerの認証設定を例えば以下のようにprovider "aws"の中でやっている場合、

provider "aws" {
  shared_config_files      = ["/Users/tf_user/.aws/conf"]
  shared_credentials_files = ["/Users/tf_user/.aws/creds"]
  profile                  = "customprofile"
}

このクレデンシャル設定は上記 null_resource内でやっているaws cliでは使われませんのでご注意ください。provider "aws"で定義した認証設定はaws providerの各resource / data sourceとやり取りするために使われるものであり、null provider管轄のnull_resourceとは無関係です。

AWS_PROFILEAWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEYといった環境変数でprovider "aws"外で認証設定している場合は、その認証がaws providerでもnull_resource内のaws cliコマンドでも使われるでしょう。

triggers

null_resource内で定義されたコマンドが実行されるタイミングは以下2つです。

  • 初回、リソース作成時
  • triggers attribute以下のいずれかの値が更新された際

今回triggers attributeは以下のように設定しています。

lambda.tfの一部

  triggers = {
    code_diff = join("", [
      for file in fileset(local.helloworld_function_dir_local_path, "{*.ts, package*.json}")
      : filebase64("${local.helloworld_function_dir_local_path}/${file}")
    ])
  }

./lambdas/helloworld/以下に置いたtsファイルとpackage.json、package-lock.jsonからハッシュを生成しています。これらのファイルに何かしら変更があった場合このハッシュ値が変わるので、コマンドが再実行つまりパッケージのインストール、トランスパイル、zip化、S3アップロードの処理が走ります。

aws_lambda_function.source_code_hash

provisioner "local-exec"のまだ説明していない残りの2つは、aws_lambda_function.source_code_hash値を更新して、Lambda関数再デプロイを走らせるための前処理です。

lambda.tfの一部

  provisioner "local-exec" {
    command = "openssl dgst -sha256 -binary ${local.helloworld_function_package_local_path} | openssl enc -base64 | tr -d \"\n\" > ${local.helloworld_function_package_base64sha256_local_path}"
  }
  provisioner "local-exec" {
    command = "aws s3 cp ${local.helloworld_function_package_base64sha256_local_path} s3://${aws_s3_bucket.lambda_assets.bucket}/${local.helloworld_function_package_base64sha256_s3_key} --content-type \"text/plain\""
  }

source_code_hash値は「base64-encoded SHA256 hash of the package file」である必要があるとResource: aws_lambda_functionの項でご説明しました。1つ目のprovisioner "local-exec"でこのハッシュ値を作成してファイル出力しています。そして2つ目でS3にアップロードしています。

アップロードしたファイルは以下Data Sourceで参照します。これもdepends_on = [null_resource.lambda_build]を入れることで、必ず S3にファイルがアップロードされた後に参照されるようにしています。

lambda.tfの一部

data "aws_s3_object" "package_hash" {
  depends_on = [null_resource.lambda_build]

  bucket = aws_s3_bucket.lambda_assets.bucket
  key    = local.helloworld_function_package_base64sha256_s3_key
}

そして、aws_lambda_functionにて上記Data Sourceを参照します。aws_s3_object Data Sourceにはbodyというfieldがあるので、オブジェクトの中身を使えるというわけです。

lambda.tfの一部

resource "aws_lambda_function" "helloworld" {
  function_name    = "typescript-sample-helloworld"
  s3_bucket        = aws_s3_bucket.lambda_assets.bucket
  s3_key           = data.aws_s3_object.package.key
  role             = aws_iam_role.iam_for_lambda.arn
  handler          = "index.handler"
  source_code_hash = data.aws_s3_object.package_hash.body
  runtime          = "nodejs16.x"
  timeout          = "10"
}

ポイントとしては、null_resource内でaws s3 cpコマンドでS3にハッシュ文字列を格納したファイルをアップロードする際に、Content Typeを指定している点です。 aws_s3_object Data Sourceでbody値を参照するには、該当オブジェクトのContent Typeがhuman-readableなもの(text/* もしくは application/json) である必要があるためです。

Note: The content of an object (body field) is available only for objects which have a human-readable Content-Type (text/* and application/json). This is to prevent printing unsafe characters and potentially downloading large amount of data which would be thrown away in favour of metadata.

指定なしでアップロードするとContent Typeがbinary/octet-streamになってしまい、bodyフィールドはnullを返していました。

参考情報