CLOVER🍀

That was when it all began.

Spring Cloud Function AWS AdapterとLocalStackを使って、Amazon API Gateway+AWS Lambdaを構成してみる

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

前にSpring Cloud Function AWS Adapterを使って、AWS Lambda関数をLocalStackにデプロイしてみました。

Spring Cloud Function AWS AdapterでAWS Lambda関数を作成して、LocalStackにデプロイしてみる - CLOVER🍀

Spring Cloud Function AWS Adapterには、Amazon API Gatewayと組み合わせるための機能もあるみたいなので、
こちらを試してみることにしました。

SpringBootApiGatewayRequestHandler

Spring Cloud Function AWS Adapterのドキュメントには、プラットフォーム固有の機能に関する記述があります。

Spring Cloud Function Reference Documentation / AWS Adapter / Platform Specific Features

Amazon API Gatewayとのプロキシ統合も、そのひとつです。
org.springframework.cloud.function.adapter.aws.SpringBootApiGatewayRequestHandlerというクラスが
提供されています。

Spring Cloud Function Reference Documentation / AWS Adapter / HTTP and API Gateway

なんですけど、よく見るとdeprecatedなんですけどね。

https://github.com/spring-cloud/spring-cloud-function/blob/v3.1.4/spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/main/java/org/springframework/cloud/function/adapter/aws/SpringBootApiGatewayRequestHandler.java

まあ、今回はドキュメントに載っている情報をそのまま使うとしましょう。

環境

今回の環境は、こちら。

LocalStack。

$ localstack --version
0.12.19.1

起動。

$ LAMBDA_EXECUTOR=docker-reuse localstack start

アプリケーションを作成するJava環境。

$ java --version
openjdk 11.0.11 2021-04-20
OpenJDK Runtime Environment (build 11.0.11+9-Ubuntu-0ubuntu2.20.04)
OpenJDK 64-Bit Server VM (build 11.0.11+9-Ubuntu-0ubuntu2.20.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.3 (ff8e977a158738155dc465c6a97ffaf31982d739)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.11, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-89-generic", arch: "amd64", family: "unix"

Spring Cloud Functionを使ったプロジェクトそのものは、JARファイルを作ることしかしないので、LocalStack側に
Amazon API Gateway等の構築が必要です。

これには、Terraformを使うことにします。

$ terraform version
Terraform v1.0.9
on linux_amd64

合わせて、AWS Lambda関数のデプロイもTerraformで行います。

プロジェクトを作成する

まずは、Spring Cloud Functionを使うプロジェクトを作成します。

$ curl -s https://start.spring.io/starter.tgz \
  -d dependencies=cloud-function,webflux \
  -d groupId=org.littlewings \
  -d artifactId=hello-spring-cloud-function-api-gateway-with-lambda \
  -d packageName=org.littlewings.spring.cloudfuntion.aws \
  -d bootVersion=2.5.6 \
  -d javaVersion=11 \
  -d type=maven-project \
  -d baseDir=hello-spring-cloud-function-api-gateway-with-lambda | tar zxvf -
$ cd hello-spring-cloud-function-api-gateway-with-lambda

含まれるソースコードは、いったん削除。

$ rm src/main/java/org/littlewings/spring/cloudfuntion/aws/DemoApplication.java src/test/java/org/littlewings/spring/cloudfuntion/aws/DemoApplicationTests.java

依存関係とMavenプラグインの設定を、このあたりの情報を見て調整します。

Spring Cloud Function Reference Documentation / AWS Adapter / Build file setup

Spring Cloud Function Reference Documentation / AWS Adapter / Notes on JAR Layout

https://github.com/spring-cloud/spring-cloud-function/blob/v3.1.4/spring-cloud-function-samples/function-sample-aws/pom.xml

こんな感じになりました。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.6</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>org.littlewings</groupId>
    <artifactId>hello-spring-cloud-function-api-gateway-with-lambda</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>11</java.version>
        <spring-cloud.version>2020.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-function-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-function-adapter-aws</artifactId>
        </dependency>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-events</artifactId>
            <version>3.10.0</version>
        </dependency>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-core</artifactId>
            <version>1.2.1</version>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <dependencies>
                    <dependency>
                        <groupId>org.springframework.boot.experimental</groupId>
                        <artifactId>spring-boot-thin-layout</artifactId>
                        <version>1.0.27.RELEASE</version>
                    </dependency>
                </dependencies>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.2.4</version>
                <configuration>
                    <createDependencyReducedPom>false</createDependencyReducedPom>
                    <shadedArtifactAttached>true</shadedArtifactAttached>
                    <shadedClassifierName>aws</shadedClassifierName>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

ポイントは、この依存関係角追加と

     <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-function-adapter-aws</artifactId>
        </dependency>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-events</artifactId>
            <version>3.10.0</version>
        </dependency>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-core</artifactId>
            <version>1.2.1</version>
            <scope>provided</scope>
        </dependency>

Mavenプラグインの設定調整、追加ですね。

     <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <dependencies>
                    <dependency>
                        <groupId>org.springframework.boot.experimental</groupId>
                        <artifactId>spring-boot-thin-layout</artifactId>
                        <version>1.0.27.RELEASE</version>
                    </dependency>
                </dependencies>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.2.4</version>
                <configuration>
                    <createDependencyReducedPom>false</createDependencyReducedPom>
                    <shadedArtifactAttached>true</shadedArtifactAttached>
                    <shadedClassifierName>aws</shadedClassifierName>
                </configuration>
            </plugin>
        </plugins>

AWS Lambda関数を作成して、LocalStackにデプロイする

次に、AWS Lambda関数に相当するクラスを作成します。

src/main/java/org/littlewings/spring/cloudfuntion/aws/App.java

package org.littlewings.spring.cloudfuntion.aws;

import java.util.Map;
import java.util.function.Function;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import reactor.core.publisher.Mono;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }

    // @Bean
    // public Function<Mono<APIGatewayProxyRequestEvent>, Mono<APIGatewayProxyResponseEvent> upper(ObjectMapper mapper) {

    @Bean
    public Function<Mono<Map<String, Object>>, Mono<Map<String, Object>>> upper() {
        return request ->
                request.map(r -> Map.of("result", r.get("message").toString().toUpperCase()));
    }
}

関数は、AWS Lambdaとして単純にデプロイした時と同様、messageキーに対応するメッセージを大文字にして
resultとして返すだけにしています。

あと、こちらを見てちょっと勘違いしていたのですが

Spring Cloud Function Reference Documentation / AWS Adapter / HTTP and API Gateway

コメントアウトしていますが、作成する関数自体のリクエスト、レスポンスはAPIGatewayProxyRequestEvent
APIGatewayProxyResponseEventである必要はないみたいです。

    // @Bean
    // public Function<Mono<APIGatewayProxyRequestEvent>, Mono<APIGatewayProxyResponseEvent> upper(ObjectMapper mapper) {

むしろ、そう書いていたらうまくいかなかったです…。

パッケージング。

$ mvn package

-aws.jarとなっているJARファイルをデプロイします。

$ ll -h target
合計 21M
drwxrwxr-x 8 xxxxx xxxxx 4.0K 10月 25 01:25 ./
drwxr-xr-x 7 xxxxx xxxxx 4.0K 10月 25 01:25 ../
drwxrwxr-x 3 xxxxx xxxxx 4.0K 10月 25 01:25 classes/
drwxrwxr-x 3 xxxxx xxxxx 4.0K 10月 25 01:25 generated-sources/
drwxrwxr-x 3 xxxxx xxxxx 4.0K 10月 25 01:25 generated-test-sources/
-rw-rw-r-- 1 xxxxx xxxxx  21M 10月 25 01:25 hello-spring-cloud-function-api-gateway-with-lambda-0.0.1-SNAPSHOT-aws.jar
-rw-rw-r-- 1 xxxxx xxxxx  12K 10月 25 01:25 hello-spring-cloud-function-api-gateway-with-lambda-0.0.1-SNAPSHOT.jar
-rw-rw-r-- 1 xxxxx xxxxx 4.2K 10月 25 01:25 hello-spring-cloud-function-api-gateway-with-lambda-0.0.1-SNAPSHOT.jar.original
drwxrwxr-x 2 xxxxx xxxxx 4.0K 10月 25 01:25 maven-archiver/
drwxrwxr-x 3 xxxxx xxxxx 4.0K 10月 25 01:25 maven-status/
drwxrwxr-x 2 xxxxx xxxxx 4.0K 10月 25 01:25 test-classes/

デプロイおよびAmazon API Gatewayの構築などは、Terraformで行います。

terraform/main.tfというファイルに各種リソースの定義を行いますが、こちらは最後にまた載せます。

概要としては、Amazon API GatewayREST APIとして作成し、AWS Lambda関数とプロキシ統合します。
path-partは、リクエストされた内容をすべてAWS Lambda関数に転送するように設定します。

前に書いた、こちらの内容をほぼ使う感じですね。

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

注意点としては、タイムアウトtimeout)を伸ばしておかないと、起動しきれずにタイムアウトしてしまいます。

resource "aws_lambda_function" "lambda" {
  filename      = "../target/hello-spring-cloud-function-api-gateway-with-lambda-0.0.1-SNAPSHOT-aws.jar"
  function_name = "hello_spring_cloud_function_api_gateway_with_lambda"
  role          = aws_iam_role.lambda_role.arn
  handler       = "org.springframework.cloud.function.adapter.aws.SpringBootApiGatewayRequestHandler"
  runtime       = "java11"
  timeout       = 30
  memory_size   = 1024
}

では、initしてapply

$ terraform init
$ terraform apply -auto-approve

REST APIのIDを取得して

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

動作確認。

$ curl -XPOST -H 'Content-Type: application/json' http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/upper -d '{"message": "Hello World"}'
{"result":"HELLO WORLD"}

OKですね。パスについては、どのリクエストも同じAWS Lambda関数に転送するようになっているので、
なにを指定しても大丈夫です。

これで、確認したいことは動作確認できました、と。

まとめ

Spring Cloud Function AWS Adapterを使って、AWS Lambda関数とAmazon API Gatewayのプロキシ統合を構成して
みました。

今回は全リクエストを単一のAWS Lambda関数に転送しているのですが、これをSpring Web MVCのような
ルーティングをするのは、いろいろ調べましたがちょっとできなさそうでした。
※HTTPヘッダーなどでのルーティングはできそうですが

他の手段もありそうなので、そのうち試してみるとしましょう。

オマケ

省略しておいた、Terraformのリソース定義全体を載せておきます。

terraform/main.tf

terraform {
  required_version = "1.0.9"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.63.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"
  }
}

## 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}"
}

resource "aws_lambda_function" "lambda" {
  filename      = "../target/hello-spring-cloud-function-api-gateway-with-lambda-0.0.1-SNAPSHOT-aws.jar"
  function_name = "hello_spring_cloud_function_api_gateway_with_lambda"
  role          = aws_iam_role.lambda_role.arn
  handler       = "org.springframework.cloud.function.adapter.aws.SpringBootApiGatewayRequestHandler"
  runtime       = "java11"
  timeout       = 30
  memory_size   = 1024
}

# 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"]
    }
  }
}