CLOVER🍀

That was when it all began.

Terraformで、LocalStackにAmazon API Gateway+AWS Lambdaの環境を構成してみる

これは、なにをしたくて書いたもの?

以前に、LocalStackでAmazon API GatewayAWS Lambdaを動かしてみるエントリを書きました。

LocalStackでAmazon API Gateway+AWS Lambdaを動かしてみる - CLOVER🍀

今回は、こちらをTerraformで実現できないかなと思いまして。

LocalStackに対してTerraformを使うことを試す、という意味でもやってみることにしました。

LocalStackに対して、Terraformでリソースを作成する

LocalStackはあくまでローカルでAWSのサービスをエミュレーションするだけなので、過度な期待をするものでは
ないと思いますが。

TerraformのドキュメントにもLocalStack向けの設定が書いてあり、せっかくなら試してみようかなと。

Custom Service Endpoint Configuration / Connecting to Local AWS Compatible SolutionsLocalStack

リソース定義は、aws_api_gateway_integrationの記載のものを少しカスタマイズする感じにします。

Resource: aws_api_gateway_integration / Lambda integration

どうカスタマイズするかというと、こちらのエントリと似たような感じにします。

LocalStackでAmazon API Gateway+AWS Lambdaを動かしてみる - CLOVER🍀

ステージ名は前のエントリではtestでしたが、今回はProdにしておきます。

環境

今回の環境は、こちら。

LocalStack。

$ localstack --version
0.12.18.1


$ LAMBDA_EXECUTOR=docker-reuse localstack start

AWS CLIAWS CLI Local。

$ awslocal --version
aws-cli/2.2.43 Python/3.8.8 Linux/5.4.0-88-generic exe/x86_64.ubuntu.20 prompt/off

Terraform。

$ terraform version
Terraform v1.0.8
on linux_amd64
+ provider registry.terraform.io/hashicorp/archive v2.2.0
+ provider registry.terraform.io/hashicorp/aws v3.61.0

構成

最終的にできあがった構成は、こちら。

$ tree
.
├── lambda_app
│   └── function.py
└── main.tf

1 directory, 2 files

AWS Lambda関数としてのソースコードが1ファイル、Terraformでのリソース定義をまとめたものが1ファイルです。

AWS Lambda関数

デプロイするAWS Lambda関数の定義は、こちら。

lambda_app/function.py

import json

def handler(event, context):

    print(f'event = {event}')

    path = event['path']
    http_method = event['httpMethod']

    word = None

    if 'queryStringParameters' in event:
       if 'word' in event['queryStringParameters']:
           word = event['queryStringParameters']['word']

    if 'body' in event and len(event['body']) != 0:
        body = json.loads(event['body'])
        if 'word' in body:
            word = body['word']

    if word is None:
        word = 'World'

    return {
        'statusCode': 200,
        'body': {
            'message': f'Hello {word}!!',
            'path': path,
            'http_method': http_method
        }
    }

元ネタになったブログエントリ、そのままです。

LocalStackでAmazon API Gateway+AWS Lambdaを動かしてみる - CLOVER🍀

Terraformリソース定義

Terraformのリソース定義はひとつのmain.tfファイルにまとめていますが、いくつかに区切って書くことにしましょう。

Terraformのバージョン制約と、Providerの定義。

terraform {
  required_version = "1.0.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.61.0"
    }

    archive = {
      source  = "hashicorp/archive"
      version = "2.2.0"
    }
  }
}

provider "aws" {
  access_key                  = "mock_access_key"
  region                      = "us-east-1"
  s3_force_path_style         = true
  secret_key                  = "mock_secret_key"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    apigateway     = "http://localhost:4566"
    cloudformation = "http://localhost:4566"
    cloudwatch     = "http://localhost:4566"
    dynamodb       = "http://localhost:4566"
    es             = "http://localhost:4566"
    firehose       = "http://localhost:4566"
    iam            = "http://localhost:4566"
    kinesis        = "http://localhost:4566"
    lambda         = "http://localhost:4566"
    route53        = "http://localhost:4566"
    redshift       = "http://localhost:4566"
    s3             = "http://localhost:4566"
    secretsmanager = "http://localhost:4566"
    ses            = "http://localhost:4566"
    sns            = "http://localhost:4566"
    sqs            = "http://localhost:4566"
    ssm            = "http://localhost:4566"
    stepfunctions  = "http://localhost:4566"
    sts            = "http://localhost:4566"
  }
}

provider "archive" {}

AWS Providerは、LocalStack向けの設定にしています。内容的には、ドキュメントの内容をそのまま使っています。

Custom Service Endpoint Configuration / Connecting to Local AWS Compatible SolutionsLocalStack

Amazon API Gatewayに関するリソース定義。

## API Gateway
resource "aws_api_gateway_rest_api" "rest_api" {
  name = "my-rest-api"
}

resource "aws_api_gateway_resource" "proxy_resource" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  parent_id   = aws_api_gateway_rest_api.rest_api.root_resource_id
  path_part   = "{proxy+}"
}

resource "aws_api_gateway_method" "method" {
  rest_api_id   = aws_api_gateway_rest_api.rest_api.id
  authorization = "NONE"
  http_method   = "ANY"
  resource_id   = aws_api_gateway_resource.proxy_resource.id
}

resource "aws_api_gateway_integration" "integration" {
  rest_api_id             = aws_api_gateway_rest_api.rest_api.id
  resource_id             = aws_api_gateway_resource.proxy_resource.id
  http_method             = aws_api_gateway_method.method.http_method
  integration_http_method = "ANY"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.lambda.invoke_arn
}

resource "aws_api_gateway_deployment" "deployment" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_rest_api.rest_api.body,
      aws_api_gateway_resource.proxy_resource.id,
      aws_api_gateway_method.method.id,
      aws_api_gateway_integration.integration.id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }

  depends_on = [aws_api_gateway_integration.integration]
}

resource "aws_api_gateway_stage" "stage" {
  deployment_id = aws_api_gateway_deployment.deployment.id
  rest_api_id   = aws_api_gateway_rest_api.rest_api.id
  stage_name    = "Prod"
}

基本的には、こちらのドキュメントの例に習ったものです。

Resource: aws_api_gateway_integration / Lambda integration

Resource: aws_api_gateway_deployment / Example Usage / Terraform Resources

aws_api_gateway_deploymentについては、先にaws_api_gateway_integrationができている必要があり、LocalStackだと
実行が速すぎてエラーになるのでdepends_onを入れました…。

resource "aws_api_gateway_deployment" "deployment" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_rest_api.rest_api.body,
      aws_api_gateway_resource.proxy_resource.id,
      aws_api_gateway_method.method.id,
      aws_api_gateway_integration.integration.id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }

  depends_on = [aws_api_gateway_integration.integration]
}

AWS Lambdaに関するリソース定義は、こちら。

## Lambda
data "aws_caller_identity" "current" {}

data "aws_region" "current" {}

resource "aws_lambda_permission" "lambda" {
  statement_id  = "AllowExecutionFromApiGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.lambda.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "arn:aws:execute-api:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.rest_api.id}/*/${aws_api_gateway_method.method.http_method}${aws_api_gateway_resource.proxy_resource.path}"
}

data "archive_file" "lambda_source" {
  type        = "zip"
  source_dir  = "lambda_app"
  output_path = "archive/my_lambda.zip"
}

resource "aws_lambda_function" "lambda" {
  filename      = data.archive_file.lambda_source.output_path
  function_name = "my_lambda"
  role          = aws_iam_role.lambda_role.arn
  handler       = "function.handler"
  runtime       = "python3.8"

  source_code_hash = data.archive_file.lambda_source.output_base64sha256
}

# IAM
resource "aws_iam_role" "lambda_role" {
  name               = "MyLambdaRole"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

作成したAWS Lambda関数のソースコードをzipにして、関連するIAMロール、パーミッションを作成してデプロイする
リソース定義です。

リソースを作成する

では、必要なファイルは作成できたので、実際のリソース作成に移ります。

Terraformの初期化を行い

$ terraform init

デプロイ。

$ terraform apply -auto-approve

すごくあっさり完了します。

Plan: 9 to add, 0 to change, 0 to destroy.
aws_api_gateway_rest_api.rest_api: Creating...
aws_iam_role.lambda_role: Creating...
aws_api_gateway_rest_api.rest_api: Creation complete after 0s [id=1iwuj3c56v]
aws_iam_role.lambda_role: Creation complete after 0s [id=MyLambdaRole]
aws_api_gateway_resource.proxy_resource: Creating...
aws_lambda_function.lambda: Creating...
aws_api_gateway_resource.proxy_resource: Creation complete after 0s [id=qxag9cz9k5]
aws_api_gateway_method.method: Creating...
aws_api_gateway_method.method: Creation complete after 0s [id=agm-1iwuj3c56v-qxag9cz9k5-ANY]
aws_lambda_function.lambda: Creation complete after 6s [id=my_lambda]
aws_lambda_permission.lambda: Creating...
aws_api_gateway_integration.integration: Creating...
aws_api_gateway_integration.integration: Creation complete after 0s [id=agi-1iwuj3c56v-qxag9cz9k5-ANY]
aws_api_gateway_deployment.deployment: Creating...
aws_api_gateway_deployment.deployment: Creation complete after 0s [id=kyylnunoct]
aws_api_gateway_stage.stage: Creating...
aws_lambda_permission.lambda: Creation complete after 0s [id=AllowExecutionFromApiGateway]
aws_api_gateway_stage.stage: Creation complete after 0s [id=ags-1iwuj3c56v-Prod]

Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

デプロイできたら、REST APIのIDを取得して

$ REST_API_ID=`awslocal apigateway get-rest-apis --query 'items[0].id' --output text`

動作確認。

## GET
$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/request_from_query?word=WordFromQuery
{"message": "Hello WordFromQuery!!", "path": "/request_from_query", "http_method": "GET"}


## POST
$ curl -XPOST http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/request_post/json -d '{"word": "Lambda"}'
{"message": "Hello Lambda!!", "path": "/request_post/json", "http_method": "POST"}

OKですね。

困ったこと

リソース定義がうまくできさえすればあっさりと動くのですが、そこまでにいろいろハマりまして。

実行が速すぎて、aws_api_gateway_integrationへのdepends_onを書かないとaws_api_gateway_deployment
作成に失敗したり

resource "aws_api_gateway_deployment" "deployment" {

  ...

  depends_on = [aws_api_gateway_integration.integration]
}

aws_api_gateway_resourceparent_idに間違った値を書いてしまったら応答が返ってこなくなったり

resource "aws_api_gateway_resource" "proxy_resource" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  parent_id   = aws_api_gateway_rest_api.rest_api.root_resource_id
  path_part   = "{proxy+}"
}

Amazon CloudWatch Logsロググループを作成しようとしたらクレデンシャルでエラーになったり。

最終的には、今回書いた定義でなんとかなりました…。

オマケ

最後に、Terraformのリソース構成ファイル全体を載せておきます。

main.tf

terraform {
  required_version = "1.0.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.61.0"
    }

    archive = {
      source  = "hashicorp/archive"
      version = "2.2.0"
    }
  }
}

provider "aws" {
  access_key                  = "mock_access_key"
  region                      = "us-east-1"
  s3_force_path_style         = true
  secret_key                  = "mock_secret_key"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    apigateway     = "http://localhost:4566"
    cloudformation = "http://localhost:4566"
    cloudwatch     = "http://localhost:4566"
    dynamodb       = "http://localhost:4566"
    es             = "http://localhost:4566"
    firehose       = "http://localhost:4566"
    iam            = "http://localhost:4566"
    kinesis        = "http://localhost:4566"
    lambda         = "http://localhost:4566"
    route53        = "http://localhost:4566"
    redshift       = "http://localhost:4566"
    s3             = "http://localhost:4566"
    secretsmanager = "http://localhost:4566"
    ses            = "http://localhost:4566"
    sns            = "http://localhost:4566"
    sqs            = "http://localhost:4566"
    ssm            = "http://localhost:4566"
    stepfunctions  = "http://localhost:4566"
    sts            = "http://localhost:4566"
  }
}

provider "archive" {}

## API Gateway
resource "aws_api_gateway_rest_api" "rest_api" {
  name = "my-rest-api"
}

resource "aws_api_gateway_resource" "proxy_resource" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id
  parent_id   = aws_api_gateway_rest_api.rest_api.root_resource_id
  path_part   = "{proxy+}"
}

resource "aws_api_gateway_method" "method" {
  rest_api_id   = aws_api_gateway_rest_api.rest_api.id
  authorization = "NONE"
  http_method   = "ANY"
  resource_id   = aws_api_gateway_resource.proxy_resource.id
}

resource "aws_api_gateway_integration" "integration" {
  rest_api_id             = aws_api_gateway_rest_api.rest_api.id
  resource_id             = aws_api_gateway_resource.proxy_resource.id
  http_method             = aws_api_gateway_method.method.http_method
  integration_http_method = "ANY"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.lambda.invoke_arn
}

resource "aws_api_gateway_deployment" "deployment" {
  rest_api_id = aws_api_gateway_rest_api.rest_api.id

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_rest_api.rest_api.body,
      aws_api_gateway_resource.proxy_resource.id,
      aws_api_gateway_method.method.id,
      aws_api_gateway_integration.integration.id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }

  depends_on = [aws_api_gateway_integration.integration]
}

resource "aws_api_gateway_stage" "stage" {
  deployment_id = aws_api_gateway_deployment.deployment.id
  rest_api_id   = aws_api_gateway_rest_api.rest_api.id
  stage_name    = "Prod"
}

## Lambda
data "aws_caller_identity" "current" {}

data "aws_region" "current" {}

resource "aws_lambda_permission" "lambda" {
  statement_id  = "AllowExecutionFromApiGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.lambda.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "arn:aws:execute-api:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.rest_api.id}/*/${aws_api_gateway_method.method.http_method}${aws_api_gateway_resource.proxy_resource.path}"
}

data "archive_file" "lambda_source" {
  type        = "zip"
  source_dir  = "lambda_app"
  output_path = "archive/my_lambda.zip"
}

resource "aws_lambda_function" "lambda" {
  filename      = data.archive_file.lambda_source.output_path
  function_name = "my_lambda"
  role          = aws_iam_role.lambda_role.arn
  handler       = "function.handler"
  runtime       = "python3.8"

  source_code_hash = data.archive_file.lambda_source.output_base64sha256
}

# IAM
resource "aws_iam_role" "lambda_role" {
  name               = "MyLambdaRole"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}