Header image

クラッソーネの開発者がエンジニアリングに関することもそうでないことも綴っています!

AWS LambdaをTerraformで管理する

AWS LambdaをTerraformで管理する

こんにちは!
SREチームの宮原(@TakashiMiyahara)です🙋

本日は、AWS LambdaをTerraformを利用して管理するよ!というお話しです。

💡 きっかけ

クラッソーネのAWS上のリソースは、Terraformを利用してコード化されています。
しかしながら、既存のLambda関数は、AWS サーバーレスアプリケーションモデル (SAM、Serverless Application Model)を利用して、開発・運用されています。
新しいLambda関数を作成するときに、SAMを利用するか、Terraformを利用するかで悩みました。

Terraformを利用して、Lambda関数を開発・運用する知見を得たので、紹介してみたいと思います。

Lambda関数で実行する関数の作成

今回は、Pythonを利用したいと思います。

Pipfileの作成

Pipenvを利用して関数を作っていきます。
Pipenvを利用することで、プロジェクトや関数ごとの依存関係を、仮想環境に閉じ込めることができ、ローカルの開発環境を汚さずに開発が行え、とても開発体験が良いです✨

Pipfileに、pytestなど利用するパッケージや、テストやフォーマット用のスクリプトを記述します。

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]

[dev-packages]
pytest = "*"
pytest-cov = "*"
pytest-mock = "*"
moto = "*"
autopep8 = "*"

[requires]
python_version = "3.10"

[scripts]
test = "bash -c \"PYTHONPATH=src python -m pytest --cov --cov-branch --cov-report=html -v\""
format = "autopep8 -ivr ."

下記コマンドを利用して、必要なパッケージをインストールし、Pipfile.lockファイルを作成します。

pipenv install -d

Lambda関数で実行したい関数の作成

次に lambda_handler関数を作成します。
今回は、sampleという文字列を返すだけにしてみます。

def lambda_handler():
    return 'sample'

次にテストコードを作成します。

import importlib
import pytest


@pytest.fixture
def app():
    return importlib.import_module('src.app')


def test_lambda_handler(app):
    ret = app.lambda_handler()

    assert ret == 'sample'

runコマンドからテストを実行します。

pipenv run test

最終的なディレクトリ構造は、以下のようになります。

.
├── Pipfile
├── Pipfile.lock
├── src
│   └── app.py
└── tests
    └── test_app.py

AWSのリソースの作成

次に、Lambda関数を実行するために必要なリソースを定義していきましょう。

ロググループの作成

Lambda関数を実行したときのログを残せるように、ロググループを作成しましょう。
今回は、14日間分のログを維持するようにしています。
こちらは、それぞれのプロジェクトやチームのルールに合わせて設定してください。

resource "aws_cloudwatch_log_group" "sample_app" {
  name              = "/aws/lambda/sample-app"
  retention_in_days = 14
}

IAM Policy と IAM Roleの作成

Lambda関数を実行したときに、ログストリームへログを出力する権限を設定します。

resource "aws_iam_policy" "sample_app" {
  name        = "sample-app-logging-policy"
  path        = "/"
  description = "IAM policy for logging from a sample_app"

  policy = jsonencode(
    {
      "Statement" : [
        {
          "Action" : "logs:CreateLogGroup",
          "Effect" : "Allow",
          "Resource" : "arn:aws:logs:ap-northeast-1:AWSアカウントのID:*"
        },
        {
          "Action" : [
            "logs:CreateLogStream",
            "logs:PutLogEvents"
          ],
          "Effect" : "Allow",
          "Resource" : [
            "arn:aws:logs:ap-northeast-1:AWSアカウントのID:log-group:/aws/lambda/sample-app:*"
          ]
        }
      ],
      "Version" : "2012-10-17"
    }
  )
}

作成した aws_iam_policy のARNを、aws_iam_rolemanaged_policy_arns に設定します。
こちらのRoleをLambda関数の実行ロールとして設定しましょう。

resource "aws_iam_role" "sample_app" {
  name = "sample-app-execution-role"
  assume_role_policy = jsonencode(
    {
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Action" : "sts:AssumeRole",
          "Principal" : {
            "Service" : "lambda.amazonaws.com"
          },
          "Effect" : "Allow",
          "Sid" : ""
        }
      ]
    }
  )

  managed_policy_arns = [
    aws_iam_policy.sample_app.arn,
  ]
}

Lambda関数の作成

lambda_functions/sample_app/src/app.pyに、Lambda関数で実行したい処理を実装しています。
このファイルをLambda関数で実行したいので、データリソースのarchive_fileを利用します。
terraform plan実行時に、指定したディレクトリのZipファイルを作成してくれます。

data "archive_file" "sample_app" {
  type        = "zip"
  source_dir  = "lambda_functions/sample_app/src"
  output_path = "lambda_functions/sample_app/src/app.zip"
}

aws_lambda_functionを定義していきます。
function_nameは、プロジェクトやチームの命名ルールに合わせてみてくださいね。
roleには、先ほど作成したIAM RoleのARNを渡します。
handlerには、app.pylambda_handler()を呼び出したいので、app.lambda_handlerと設定します。
source_code_hashには、データリソースの出力ファイルをBase64でエンコードした際のSHA256チェックサムを渡します。
source_code_hashは、Lambda関数の更新のトリガーに使用できます。app.pyの変更をトリガーに、Lambda関数をTerraformからデプロイできるようになります。

resource "aws_lambda_function" "sample_app" {
  filename         = data.archive_file.sample_app.output_path
  function_name    = "sample-app-function"
  role             = aws_iam_role.sample_app.arn
  handler          = "app.lambda_handler"
  runtime          = "python3.9"
  source_code_hash = data.archive_file.sample_app.output_base64sha256
}

すでにLambda関数がデプロイ済みでlambda_functions/sample_app/src/app.pyを編集し、terraform planを実行すると以下のような出力を得られます。
このことから、app.pyの変更によりLambda関数自体にも、変更が入ることがわかります。

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # aws_lambda_function.sample_app will be updated in-place
  ~ resource "aws_lambda_function" "sample_app" {
        id                             = "sample-app-function"
      ~ last_modified                  = "2023-01-06T00:00:00.000+0000" -> (known after apply)
      ~ source_code_hash               = "****************************=" -> ""****************************=""
        tags                           = {}
        # (19 unchanged attributes hidden)

        # (2 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

source_code_hashの値は、マスキングしています。

EventBridgeのルールとトリガーを作成する

作成したLambda関数を、指定したスケジュールで定期的に呼び出したいですよね。
EventBridgeのルールとトリガーを作成し、Lambda関数を紐づけてあげることで、設定が行えます。

以下は、毎時0分に実行するルールです。

resource "aws_cloudwatch_event_rule" "sample_app" {
  name                = "sample-app-function-rule"
  description         = "Rule to run sample_app hourly."
  schedule_expression = "cron(0 * * * ? *)"
  is_enabled          = true
}
resource "aws_cloudwatch_event_target" "sample_app" {
  rule      = aws_cloudwatch_event_rule.sample_app.name
  target_id = aws_cloudwatch_event_rule.sample_app.name
  arn       = aws_lambda_function.sample_app.arn
}

aws_lambda_permissionを利用して、aws_cloudwatch_event_ruleaws_lambda_functionを紐づけます。

resource "aws_lambda_permission" "sample_app" {
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.sample_app.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.sample_app.arn
}

最終的な構成

ディレクトリ構造

pythonのソースコードやTerraformのリソース定義を、1つのRepositoryに閉じ込める構成にしてみました。
必要なリソースやLambda関数で実行したい処理だけでを、新規に作成したり、改修したりすることが行いやすくなったかと思います。

.
├── README.md
├── backend.tf
├── cloudwatch_event_rule.tf
├── cloudwatch_event_target.tf
├── cloudwatch_log_group.tf
├── iam_policy.tf
├── iam_role.tf
├── lambda_functions
│   ├── sample_app
│   │   ├── Pipfile
│   │   ├── Pipfile.lock
│   │   ├── README.md
│   │   ├── src
│   │   │   └── app.py
│   │   └── tests
│   │       └── test_app.py
├── lambda.tf
├── lambda_permission.tf
├── provider.tf
└── versions.tf

デプロイの流れ

Pull RequestにApproveが付きマージされると、Terraform Cloudを利用してAWS環境へデプロイされていきます。
Terraformに不慣れなメンバーが多い場合は、Auto applyは許可せず、Manual applyに制限し、詳しいメンバーが最終的な反映をするようにしておくと安全です。

終わりに

今回は、Terraformを利用してLambda関数を作成する構成について紹介してみました。
実行する関数の中身や、関数に必要なリソースをひとまとめにして管理し、デプロイまで行えることがわかりました。
SAMも非常に便利なツールだと思うのですが、他のリソースをTerraformで管理している場合は、Terraformに寄せてしまう方が使い方で悩むことが少なくなるのかなと思いました。

クラッソーネでは、プロダクトとチームの双方をより良く改善していけるエンジニアを募集中です!
ご興味ある方はぜひ採用サイトをご覧ください!

https://www.crassone.co.jp/recruit/engineer/


RubyやTerraformが好きで、メンバーが楽になる仕組みを考えるのが好きなエンジニア