「AWS Hands-on for Beginners Serverless #1」を参考にサーバーレスアーキテクチャ を Terraform で実装してみた

AWS

2022.6.16

Topics

はじめに

はじめまして。CloudLead チームの Cold-Airflow です。

タイトルにあるように、 AWS が公開している AWS Hands-on for Beginners の 「サーバーレスアーキテクチャで翻訳 Web API を構築する」 を Terraform を使ってデプロイしてみた内容となっております。

AWS Hands-on for Beginners とは?

AWS が様々なアーキテクチャーの構成を動画で解説してくれているので、それにそって実際に手を動かしながら AWS サービスについて学べるコンテンツです。

実際に手を動かして学ぶ!AWS Hands-on for Beginners のご紹介 | Amazon Web Services ブログ

今回使用するサーバーレスアーキテクチャのハンズオン以外にも AWS の基礎から機械学習や IaC などと盛りだくさんのコンテンツです。
ほかのハンズオンにご興味がある方は下記のリンクからご覧ください。
ハンズオン資料 | AWS クラウドサービス活用資料集

今回は数あるコンテンツの中からサーバーレスを使ったハンズオンをもとに構築を行いたいと思います。

翻訳 Web API の構築を通して、サーバーレスアーキテクチャの基本を学んでいただきます。
AWS Hands-on for Beginners | AWS

本記事について

想定読者

  • Terraform を使って何かを作りたい方
  • Terraform でサーバレスアーキテクチャを作成したい方

なお、以下については説明しておりません。

  • Terraform のインストール手順
  • 作成する AWS リソースの説明

本記事では、Terraform の内容に注力して解説を行います。
そのため使用するアーキテクチャや AWS サービスの内容は実際の AWS ハンズオンをご覧ください。

ゴール

  • AWS Lambda, Amazon API Gateway, Amazon DynamoDB サービスを組み合わせて、サーバーレスな Web API を作成する

構築環境

Terraform のバージョンは以下のとおりです。

C:\Users>terraform --version
Terraform v1.1.6
on windows_amd64

ディレクトリ構造は以下のとおりです。

Lambda のコードは、contents配下に配置します。

C:.
│  main.tf
│  variables.tf
│
└─contents
    ├─src
    │      lambda_function.py
    │
    └─zip
            lambda.zip

Terraform 構築開始

作成するリソースは下記のとおりです。

  • Amazon API Gateway
  • AWS Lambda
  • Amazon DynamoDB

ハンズオンと同じ順番で作成します。

なお、Amazon Translate はリソースを作成するものではなく、API として Lambda で呼び出しで使用します。

コード全体をご覧になりたい方は本記事末尾に飛んでください。

AWS 構成図

今回作成するハンズオンの AWS 構成図です。

翻訳 API を AWS サーバレスアーキテクチャで実装します。

handsonサーバレス構成図

API Gateway 経由で Lambda を呼び出します。
Lambda では受け取った値 Translate に投げて翻訳します。
さらに、DynamoDB に入力と出力結果を記録するアーキテクチャーとなっています。

DynamoDB の作成

まずは、データを格納する DynamoDB を作成します。
役割としては、API Gateway から送信されたデータと翻訳したデータを格納するために使用します。

resource "aws_dynamodb_table" "basic-dynamodb-table" {
  name           = "translate-history"
  billing_mode   = "PROVISIONED"
  read_capacity  = 1
  write_capacity = 1
  hash_key       = "timestamp"

  attribute {
    name = "timestamp"
    type = "S"
  }
}

Lambda の作成

次は、API のデータを処理する Lambda を作成します。
Lambda のプログラムは Python で実装しています。

コードは ZIP 形式にして Lambda を作成すると同時にアップロードしています。

variable "lambda_file_name" {
  default = "/contents/src/lambda_function.py"
}

variable "lambda_file_zip_name" {
  default = "/contents/zip/lambda.zip"
}

data "archive_file" "sample_function" {
  type        = "zip"
  source_file = "${path.module}${var.lambda_file_name}"
  output_path = "${path.module}${var.lambda_file_zip_name}"
}

data "aws_iam_policy_document" "AWSLambdaTrustPolicy" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "function_role" {
  name               = "${var.project}-function_role"
  assume_role_policy = data.aws_iam_policy_document.AWSLambdaTrustPolicy.json
}

resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.function_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy_attachment" "Translate_policy" {
  role       = aws_iam_role.function_role.name
  policy_arn = "arn:aws:iam::aws:policy/TranslateFullAccess"
}


resource "aws_lambda_function" "test_lambda" {
  filename         = data.archive_file.sample_function.output_path
  function_name    = "${var.project}-lambda-function"
  role             = aws_iam_role.function_role.arn
  handler          = var.handler_name
  publish          = true
  source_code_hash = data.archive_file.sample_function.output_base64sha256
  runtime          = "python3.9"
  memory_size      = 256
  timeout          = 10
}

resource "aws_lambda_permission" "allow_cloudwatch" {
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.test_lambda.function_name
  principal     = "events.amazonaws.com"
}

Lambda では、「翻訳のためにTranslate「データを保存するためにDynamoDBにアクセスをするため権限を Lambda に追加しています。
ハンズオンのため、アクセス権限はフルで与えてますが適宜権限を絞ってください。

  • Translate
resource "aws_iam_role_policy_attachment" "Translate_policy" {
  role       = aws_iam_role.function_role.name
  policy_arn = "arn:aws:iam::aws:policy/TranslateFullAccess"
}
  • DynamoDB
resource "aws_iam_role_policy_attachment" "dynamodb_policy" {
  role       = aws_iam_role.function_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess"
}

Point 1: ソースコードを読み込むために Data Source archive_file

Lambda のコードをアップロードする必要があります。
Terraform のベストプラクティスはarchive_fileを使うことです。
Terraform で Lambda[Python]のデプロイするときのプラクティス | DevelopersIO

data "archive_file" "sample_function" {
  type        = "zip"
  source_file = "${path.module}${var.lambda_file_name}"
  output_path = "${path.module}${var.lambda_file_zip_name}"
}

このarchive_fileはファイルを ZIP 形式に変換してくれるものです。
使い方は簡単、Lambda のプログラミングの PATH と ZIP ファイルの格納先を指定するだけです。
archive_file | Data Sources | hashicorp/archive | Terraform Registry

PATH を指定する際にも、工夫をしております。

variable "lambda_file_name" {
  default = "/contents/src/lambda_function.py"
}

variable "lambda_file_zip_name" {
  default = "/contents/zip/lambda.zip"
}

${path.module}:モジュールのディレクトリを指します。
${var.lambda_file_name}:Lambda プログラムソースファイルパス
${var.lambda_file_zip_name}:ZIP ファイルパス

Point 2: 外部ソースに Lambda Function のアクセス権限を与える aws_lambda_permission

コンソールから作成すると自動的に追加されますが、Terraform からやる場合はそうならないです。
そのため、各種リソースを連携してもアクセス権限ないため実行できないことがよくあります。

外部ソース(EventBridge Rule、SNS、S3 など)に Lambda 関数へのアクセス権限を与える。
aws_lambda_permission | Resources | hashicorp/aws | Terraform Registry

  • CloudWatch:ログを記録するため
resource "aws_lambda_permission" "allow_cloudwatch" {
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.test_lambda.function_name
  principal     = "events.amazonaws.com"
}
  • API Gateway:API の内容を処理するため
resource "aws_lambda_permission" "allow_apigateway" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.test_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.example.id}/*/${aws_api_gateway_method.example.http_method}${aws_api_gateway_resource.example.path}"
}

プログラムコード

やっていること

  1. API Gateway から値を取得
  2. Translate で受け取った値を翻訳
  3. 受け取った値と翻訳した値を DynamoDB に格納
  4. 値を整形して API Gateway に返す
import json
import boto3
import datetime

def lambda_handler(event, context):
    translate = boto3.client('translate')
    dynamodb_translate_histroy_tbl = boto3.resource(
        'dynamodb').Table("translate-history")
    input_text = event["queryStringParameters"]["input_text"]
    response = translate.translate_text(
        Text=input_text,
        SourceLanguageCode='ja',
        TargetLanguageCode='en'
    )
    output_text = response.get('TranslatedText')
    dynamodb_translate_histroy_tbl.put_item(
        Item={
            'timestamp': datetime.datetime.now().strftime("%Y%m%d%H%M%S"),
            'input_text': input_text,
            'output_text': output_text
        }
    )
    return {
        'statusCode': 200,
        'body': json.dumps({
            'output_text': output_text
        }),
        'isBase64Encoded': False,
        'headers': {}
    }

API Gateway の作成

最後に API のエンドポイントとして API Gateway を作成して Lambda と連携します。

Terraform で API Gateway を作成する場合、他のリソースと違ってかなり細かくリソースが分かれているため注意が必要です。

resource "aws_api_gateway_rest_api" "example" {
  name = "${var.project}-api-gateway"
}

resource "aws_api_gateway_resource" "example" {
  parent_id   = aws_api_gateway_rest_api.example.root_resource_id
  path_part   = "translate"
  rest_api_id = aws_api_gateway_rest_api.example.id
}

resource "aws_api_gateway_method" "example" {
  authorization = "NONE"
  http_method   = "GET"
  resource_id   = aws_api_gateway_resource.example.id
  rest_api_id   = aws_api_gateway_rest_api.example.id
  request_parameters = {
    "method.request.querystring.input_text" = true
  }
}

resource "aws_api_gateway_integration" "example" {
  http_method             = aws_api_gateway_method.example.http_method
  resource_id             = aws_api_gateway_resource.example.id
  rest_api_id             = aws_api_gateway_rest_api.example.id
  type                    = "AWS_PROXY"
  integration_http_method = "POST"
  uri                     = aws_lambda_function.test_lambda.invoke_arn
}

resource "aws_api_gateway_method_response" "response_200" {
  rest_api_id = aws_api_gateway_rest_api.example.id
  resource_id = aws_api_gateway_resource.example.id
  http_method = aws_api_gateway_method.example.http_method
  status_code = "200"
  response_models = {
    "application/json" = "Empty"
  }
}

resource "aws_api_gateway_deployment" "example" {
  rest_api_id = aws_api_gateway_rest_api.example.id

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_resource.example.id,
      aws_api_gateway_method.example.id,
      aws_api_gateway_integration.example.id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_api_gateway_stage" "example" {
  deployment_id = aws_api_gateway_deployment.example.id
  rest_api_id   = aws_api_gateway_rest_api.example.id
  stage_name    = "dev"
}


data "aws_iam_policy_document" "apigateway" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["apigateway.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "apigateway_role" {
  name               = "${var.project}-apigateway_role"
  assume_role_policy = data.aws_iam_policy_document.apigateway.json
}
resource "aws_iam_role_policy_attachment" "apigateway_policy" {
  role       = aws_iam_role.apigateway_role.name
  policy_arn = "arn:aws:iam::aws:policy/TranslateFullAccess"
}

data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

resource "aws_lambda_permission" "allow_apigateway" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.test_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.example.id}/*/${aws_api_gateway_method.example.http_method}${aws_api_gateway_resource.example.path}"
}

Point 1: Lambda との紐づけ aws_api_gateway_integration

API Gateway と Lambda はaws_api_gateway_integrationで紐づけをします。

resource "aws_api_gateway_integration" "example" {
  http_method             = aws_api_gateway_method.example.http_method
  resource_id             = aws_api_gateway_resource.example.id
  rest_api_id             = aws_api_gateway_rest_api.example.id
  type                    = "AWS_PROXY"
  integration_http_method = "POST"
  uri                     = aws_lambda_function.test_lambda.invoke_arn
}

integration_http_method
注意:ややこしいですが、Lambda との通信を行う際のメソッドタイプです。

uri
注意:Lambda の ARN について属性が違うので気をつけてください。

arn – Lambda Function を識別する Amazon Resource Name (ARN) です。
invoke_arn – Lambda Function を API Gateway から呼び出す際に使用する ARN – aws_api_gateway_integration の uri で使用します。
aws_lambda_function | Resources | hashicorp/aws | Terraform Registry

aws_lambda_function.test_lambda.arnと定義すると怒られます。

実行結果

API Gateway のエンドポイントに向けて cURL を実行すれば翻訳されます。

エンドポイントは、ステージから dev を選択したら画面上部にあります。
API Gatewayエンドポイント画像

エンドポイントに、翻訳する文章を追加してリクエストを投げます。

PS C:\Users> curl https://8zrom9un9d.execute-api.us-east-1.amazonaws.com/dev/translate?input_text=おはようございます


StatusCode        : 200
StatusDescription : OK
Content           : {"output_text": "Good morning"}
RawContent        : HTTP/1.1 200 OK
                    Connection: keep-alive
                    x-amzn-RequestId: bd91ebd1-6235-4254-8695-2b002fbe7ea1
                    x-amz-apigw-id: TowmOH7GoAMF50A=
                    X-Amzn-Trace-Id: Root=1-62a6948e-7681872b231eec422df9017b;Sampled=0
                    ...
Forms             : {}
Headers           : {[Connection, keep-alive], [x-amzn-RequestId, bd91ebd1-6235-4254-869
                    5-2b002fbe7ea1], [x-amz-apigw-id, TowmOH7GoAMF50A=], [X-Amzn-Trace-I
                    d, Root=1-62a6948e-7681872b231eec422df9017b;Sampled=0]...}
Images            : {}
InputFields       : {}
Links             : {}
ParsedHtml        : mshtml.HTMLDocumentClass
RawContentLength  : 31

さらに、AWS CLI を使ってデータが格納されたか DynamoDB のデータを確認します。

PS C:\Users> aws dynamodb scan --table-name translate-history
{
    "Items": [
        {
            "input_text": {
                "S": "おはようございます"
            },
            "output_text": {
                "S": "Good morning"
            },
            "timestamp": {
                "S": "20220613013615"
            }
        }
    ],
    "Count": 1,
    "ScannedCount": 1,
    "ConsumedCapacity": null
}

おかたづけ

使ったリソースをdestroyしておきます。

Destroy complete! Resources: 17 destroyed.

まとめ

サーバレスとしては今回の内容は簡潔でわかりやすかったのではないでしょうか。

ですが、Terraform として実装する場合には API Gateway がかなり複雑でした。

全体 Terraform コード

main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}
provider "aws" {
  region  = "us-east-1"
  profile = "default"
  default_tags {
    tags = {
      "Name" = var.tag_name_test
    }
  }
}

# Lambda

data "archive_file" "sample_function" {
  type        = "zip"
  source_file = "${path.module}${var.lambda_file_name}"
  output_path = "${path.module}${var.lambda_file_zip_name}"
}

data "aws_iam_policy_document" "AWSLambdaTrustPolicy" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "function_role" {
  name               = "${var.project}-function_role"
  assume_role_policy = data.aws_iam_policy_document.AWSLambdaTrustPolicy.json
}

resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.function_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy_attachment" "Translate_policy" {
  role       = aws_iam_role.function_role.name
  policy_arn = "arn:aws:iam::aws:policy/TranslateFullAccess"
}


resource "aws_lambda_function" "test_lambda" {
  filename         = data.archive_file.sample_function.output_path
  function_name    = "${var.project}-lambda-function"
  role             = aws_iam_role.function_role.arn
  handler          = var.handler_name
  publish          = true
  source_code_hash = data.archive_file.sample_function.output_base64sha256
  runtime          = "python3.9"
  memory_size      = 256
  timeout          = 10
}

resource "aws_lambda_permission" "allow_cloudwatch" {
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.test_lambda.function_name
  principal     = "events.amazonaws.com"
}

# API Gateway

resource "aws_api_gateway_rest_api" "example" {
  name = "${var.project}-api-gateway"
}

resource "aws_api_gateway_resource" "example" {
  parent_id   = aws_api_gateway_rest_api.example.root_resource_id
  path_part   = "translate"
  rest_api_id = aws_api_gateway_rest_api.example.id
}

resource "aws_api_gateway_method" "example" {
  authorization = "NONE"
  http_method   = "GET"
  resource_id   = aws_api_gateway_resource.example.id
  rest_api_id   = aws_api_gateway_rest_api.example.id
  request_parameters = {
    "method.request.querystring.input_text" = true
  }

}

resource "aws_api_gateway_integration" "example" {
  http_method             = aws_api_gateway_method.example.http_method
  resource_id             = aws_api_gateway_resource.example.id
  rest_api_id             = aws_api_gateway_rest_api.example.id
  type                    = "AWS_PROXY"
  integration_http_method = "POST"
  uri                     = aws_lambda_function.test_lambda.invoke_arn
}

resource "aws_api_gateway_method_response" "response_200" {
  rest_api_id = aws_api_gateway_rest_api.example.id
  resource_id = aws_api_gateway_resource.example.id
  http_method = aws_api_gateway_method.example.http_method
  status_code = "200"
  response_models = {
    "application/json" = "Empty"
  }
}

resource "aws_api_gateway_deployment" "example" {
  rest_api_id = aws_api_gateway_rest_api.example.id

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_resource.example.id,
      aws_api_gateway_method.example.id,
      aws_api_gateway_integration.example.id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_api_gateway_stage" "example" {
  deployment_id = aws_api_gateway_deployment.example.id
  rest_api_id   = aws_api_gateway_rest_api.example.id
  stage_name    = "dev"
}


data "aws_iam_policy_document" "apigateway" {
  statement {
    actions = ["sts:AssumeRole"]
    effect  = "Allow"
    principals {
      type        = "Service"
      identifiers = ["apigateway.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "apigateway_role" {
  name               = "${var.project}-apigateway_role"
  assume_role_policy = data.aws_iam_policy_document.apigateway.json
}
resource "aws_iam_role_policy_attachment" "apigateway_policy" {
  role       = aws_iam_role.apigateway_role.name
  policy_arn = "arn:aws:iam::aws:policy/TranslateFullAccess"
}

data "aws_region" "current" {}

data "aws_caller_identity" "current" {}

resource "aws_lambda_permission" "allow_apigateway" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.test_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.example.id}/*/${aws_api_gateway_method.example.http_method}${aws_api_gateway_resource.example.path}"
}

# DynamoDB

resource "aws_dynamodb_table" "basic-dynamodb-table" {
  name           = "translate-history"
  billing_mode   = "PROVISIONED"
  read_capacity  = 1
  write_capacity = 1
  hash_key       = "timestamp"

  attribute {
    name = "timestamp"
    type = "S"
  }
}
resource "aws_iam_role_policy_attachment" "dynamodb_policy" {
  role       = aws_iam_role.function_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess"
}

variables.tf

各値は各自でご自由に置き換えてください。

variable "tag_name_test" {
  type        = string
  default     = "cold-airflow-handson-serverless"
  description = "Resource Tag Name"
}

variable "project" {
  type        = string
  default     = "handson-serverless"
  description = "project name"
}
variable "lambda_file_name" {
  default = "/contents/src/lambda_function.py"
}

variable "lambda_file_zip_name" {
  default = "/contents/zip/lambda.zip"
}
variable "test_name" {
  default     = "handson-serverless"
  type        = string
  description = "instance name"
}

variable "handler_name" {
  type        = string
  description = "lambda function name"
  default     = "lambda_function.lambda_handler"
}

Lambda の Python コード

import json
import boto3
import datetime

def lambda_handler(event, context):
    translate = boto3.client('translate')
    dynamodb_translate_histroy_tbl = boto3.resource(
        'dynamodb').Table("translate-history")
    input_text = event["queryStringParameters"]["input_text"]
    response = translate.translate_text(
        Text=input_text,
        SourceLanguageCode='ja',
        TargetLanguageCode='en'
    )
    output_text = response.get('TranslatedText')
    dynamodb_translate_histroy_tbl.put_item(
        Item={
            'timestamp': datetime.datetime.now().strftime("%Y%m%d%H%M%S"),
            'input_text': input_text,
            'output_text': output_text
        }
    )
    return {
        'statusCode': 200,
        'body': json.dumps({
            'output_text': output_text
        }),
        'isBase64Encoded': False,
        'headers': {}
    }
Cold-Airflow

2021年新卒入社。インフラエンジニアです。RDBが三度の飯より好きです。 主にデータベースやAWSのサーバレスについて書く予定です。あと寒いのは苦手です。

Recommends

こちらもおすすめ

Special Topics

注目記事はこちら