CLOVER🍀

That was when it all began.

Serverless FrameworkをLocalStackで使ってみる(Amazon API Gateway+AWS Lambda)

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

LocalStackでAWS Lambda、それからAmazon API Gatewayの組み合わせでいろいろ遊んでいるのですが。

1度、Serverless Framewokにも触れた方がいいかなと思い、試してみることにしました。

LocalStackにも、Serverless Framework向けの話があるようですし。

https://localstack.cloud/docs/how-to/serverless/

Serverless Framework

まずはServerless Frameworkとは?というとこからですが。

Serverless: Develop & Monitor Apps On AWS Lambda

Serverless Frameworkは、サーバーレスアプリケーションの開発、デプロイ、トラブルシュートなどのオーバーヘッド、
コストを削減するフレームワークのようです。

Serverless Framework Documentation

OSSで公開されているCLIと、ホストされているダッシュボードで構成されています。

AWS SAMと似たような感じですが、AWS以外にもAzure Functions、Google Cloud Functionsなど
複数のクラウドプロバイダーに対応しているところがポイントです。

Serverless - Infrastructure & Compute Providers

プロジェクトを作成する際に、テンプレートを指定して複数の言語からプロジェクトの構成を選ぶことができるみたいです。

Serverless Framework Services

LocalStackとServerless Framework

次に、LocalStackとServerless Frameworkについて。

LocalStackのドキュメントに、Serverless Frameworkに向けた対応が書かれています。

https://localstack.cloud/docs/how-to/serverless/

LocalStack / Serverless Framework

具体的には、LocalStackがServerless Framework向けのプラグインを提供しているので、こちらを使えばよいみたいです。

GitHub - localstack/serverless-localstack: ⚡ Serverless plugin for running against LocalStack

一方で、Serverless Frameworkもオフラインで動作させるためのプラグインを提供しているようです。

Serverless Framework: Plugins

視点がLocalStackのようなAWSの多くのサービスをエミュレーションするものの一部として使いたいのか、
Serverless Frameworkの範囲で動作すればいいのかで選ぶんでしょうね。

では、今回はAmazon API GatewayAWS Lambdaの構成のアプリケーションを、Serverless Frameworkと
LocalStackで使ってみましょう。

環境

今回の環境は、こちら。

LocalStack。

$ localstack --version
0.12.18.3

起動。

$ LAMBDA_EXECUTOR=docker-reuse localstack start

AWS CLI

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

Serverless Frameworkを使うための、Node.js。

$ node -v
v14.18.0


$ npm -v
6.14.15

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-88-generic", arch: "amd64", family: "unix"

Serverless Frameworkをインストールする

では、Serverless Frameworkのインストールから

Setting Up Serverless Framework With AWS

インストール方法はいろいろあるのですが、LocalStackのServerless Frameworkのプラグインnpmでインストールする
形態なので、Serverless Frameworkも合わせてnpmでインストールすることにしました。

というわけで、Serverless FramewokとLocalStackのプラグインをインストール。

$ npm i -g serverless
$ npm i -D serverless-localstack

バージョン。

$ serverless --version
Framework Core: 2.62.0 (local)
Plugin: 5.4.6
SDK: 4.3.0
Components: 3.17.1

LocalStackのServerless Frameworkプラグインのバージョンは、こちら。

$ cat node_modules/serverless-localstack/package.json | jq '._id'
"serverless-localstack@0.4.35"

では、こちらのドキュメントに書かれているテンプレートを見つつ

Serverless Framework Services

プロジェクトの作成。テンプレートは、aws-java-mavenを選択。

$ serverless create --template aws-java-maven --path serverless-localstack-java

プロジェクトが作成されました。

Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "/path/to/serverless-localstack-java"
Serverless: Successfully generated boilerplate for template: "aws-java-maven"

ディレクトリ内に移動して

$ cd serverless-localstack-java

できあがったファイルを確認。

$ tree
.
├── pom.xml
├── serverless.yml
└── src
    └── main
        ├── java
        │   └── com
        │       └── serverless
        │           ├── ApiGatewayResponse.java
        │           ├── Handler.java
        │           └── Response.java
        └── resources
            └── log4j2.xml

6 directories, 6 files

pom.xml

<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.serverless</groupId>
  <artifactId>hello</artifactId>
  <packaging>jar</packaging>
  <version>dev</version>
  <name>hello</name>

  <properties>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>com.amazonaws</groupId>
      <artifactId>aws-lambda-java-log4j2</artifactId>
      <version>1.1.0</version>
    </dependency>
    <dependency>
      <groupId>org.apache.logging.log4j</groupId>
      <artifactId>log4j-core</artifactId>
      <version>2.8.2</version>
    </dependency>
    <dependency>
      <groupId>org.apache.logging.log4j</groupId>
      <artifactId>log4j-api</artifactId>
      <version>2.8.2</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-core</artifactId>
      <version>2.9.10</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.9.10</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-annotations</artifactId>
      <version>2.9.10</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <!--
        Using the Apache Maven Shade plugin to package the jar

        "This plugin provides the capability to package the artifact
        in an uber-jar, including its dependencies and to shade - i.e. rename -
        the packages of some of the dependencies."

        Link: https://maven.apache.org/plugins/maven-shade-plugin/
      -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <version>2.3</version>
        <configuration>
          <createDependencyReducedPom>false</createDependencyReducedPom>
        </configuration>
        <executions>
          <execution>
            <phase>package</phase>
            <goals>
              <goal>shade</goal>
            </goals>
            <configuration>
              <transformers>
                <transformer
                  implementation="com.github.edwgiz.mavenShadePlugin.log4j2CacheTransformer.PluginsCacheFileTransformer">
                </transformer>
              </transformers>
            </configuration>
          </execution>
        </executions>
        <dependencies>
          <dependency>
            <groupId>com.github.edwgiz</groupId>
            <artifactId>maven-shade-plugin.log4j2-cachefile-transformer</artifactId>
            <version>2.8.1</version>
          </dependency>
        </dependencies>
      </plugin>
    </plugins>
  </build>

</project>

よく見ると、プロジェクト名とは全然関係ないアーティファクト名などになっているんですね。まあ、テンプレート
そのままなんでしょう。

  <groupId>com.serverless</groupId>
  <artifactId>hello</artifactId>
  <packaging>jar</packaging>
  <version>dev</version>
  <name>hello</name>

serverless.ymlの内容。

$ grep -v '#' serverless.yml

service: serverless-localstack-java

frameworkVersion: '2'

provider:
  name: aws
  runtime: java8
  lambdaHashingVersion: 20201221




package:
  artifact: target/hello-dev.jar

functions:
  hello:
    handler: com.serverless.Handler

自動生成された、Javaソースコード

src/main/java/com/serverless/Handler.java

package com.serverless;

import java.util.Collections;
import java.util.Map;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

public class Handler implements RequestHandler<Map<String, Object>, ApiGatewayResponse> {

    private static final Logger LOG = LogManager.getLogger(Handler.class);

    @Override
    public ApiGatewayResponse handleRequest(Map<String, Object> input, Context context) {
        LOG.info("received: {}", input);
        Response responseBody = new Response("Go Serverless v1.x! Your function executed successfully!", input);
        return ApiGatewayResponse.builder()
                .setStatusCode(200)
                .setObjectBody(responseBody)
                .setHeaders(Collections.singletonMap("X-Powered-By", "AWS Lambda & serverless"))
                .build();
    }
}

ApiGatewayResponseResponseといったクラスは、Serverless Frameworkなどが提供しているものではなく、
生成されたソースコードの中に含まれているものになります。

src/main/java/com/serverless/ApiGatewayResponse.java

package com.serverless;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

public class ApiGatewayResponse {

    private final int statusCode;
    private final String body;
    private final Map<String, String> headers;
    private final boolean isBase64Encoded;

    public ApiGatewayResponse(int statusCode, String body, Map<String, String> headers, boolean isBase64Encoded) {
        this.statusCode = statusCode;
        this.body = body;
        this.headers = headers;
        this.isBase64Encoded = isBase64Encoded;
    }

    public int getStatusCode() {
        return statusCode;
    }

    public String getBody() {
        return body;
    }

    public Map<String, String> getHeaders() {
        return headers;
    }

    // API Gateway expects the property to be called "isBase64Encoded" => isIs
    public boolean isIsBase64Encoded() {
        return isBase64Encoded;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {

        private static final Logger LOG = LogManager.getLogger(ApiGatewayResponse.Builder.class);

        private static final ObjectMapper objectMapper = new ObjectMapper();

        private int statusCode = 200;
        private Map<String, String> headers = Collections.emptyMap();
        private String rawBody;
        private Object objectBody;
        private byte[] binaryBody;
        private boolean base64Encoded;

        public Builder setStatusCode(int statusCode) {
            this.statusCode = statusCode;
            return this;
        }

        public Builder setHeaders(Map<String, String> headers) {
            this.headers = headers;
            return this;
        }

        /**
        * Builds the {@link ApiGatewayResponse} using the passed raw body string.
        */
        public Builder setRawBody(String rawBody) {
            this.rawBody = rawBody;
            return this;
        }

        /**
        * Builds the {@link ApiGatewayResponse} using the passed object body
        * converted to JSON.
        */
        public Builder setObjectBody(Object objectBody) {
            this.objectBody = objectBody;
            return this;
        }

        /**
        * Builds the {@link ApiGatewayResponse} using the passed binary body
        * encoded as base64. {@link #setBase64Encoded(boolean)
        * setBase64Encoded(true)} will be in invoked automatically.
        */
        public Builder setBinaryBody(byte[] binaryBody) {
            this.binaryBody = binaryBody;
            setBase64Encoded(true);
            return this;
        }

        /**
        * A binary or rather a base64encoded responses requires
        * <ol>
        * <li>"Binary Media Types" to be configured in API Gateway
        * <li>a request with an "Accept" header set to one of the "Binary Media
        * Types"
        * </ol>
        */
        public Builder setBase64Encoded(boolean base64Encoded) {
            this.base64Encoded = base64Encoded;
            return this;
        }

        public ApiGatewayResponse build() {
            String body = null;
            if (rawBody != null) {
                body = rawBody;
            } else if (objectBody != null) {
                try {
                    body = objectMapper.writeValueAsString(objectBody);
                } catch (JsonProcessingException e) {
                    LOG.error("failed to serialize object", e);
                    throw new RuntimeException(e);
                }
            } else if (binaryBody != null) {
                body = new String(Base64.getEncoder().encode(binaryBody), StandardCharsets.UTF_8);
            }
            return new ApiGatewayResponse(statusCode, body, headers, base64Encoded);
        }
    }
}

src/main/java/com/serverless/Response.java

package com.serverless;

import java.util.Map;

public class Response {

    private final String message;
    private final Map<String, Object> input;

    public Response(String message, Map<String, Object> input) {
        this.message = message;
        this.input = input;
    }

    public String getMessage() {
        return this.message;
    }

    public Map<String, Object> getInput() {
        return this.input;
    }
}

では、こちらをLocalStackにデプロイしていきましょう。

とりあえず、プロジェクトで使うJavaが1.8になっているので、こちらを11に変更しておきましょう。

  <properties>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

serverless.ymlruntimejava11に変更しておきます。

provider:
  name: aws
  runtime: java11
  lambdaHashingVersion: 20201221

次に、LocalStackのServerless Frameworkプラグインの設定を行います。

LocalStack Serverless Plugin / Configuring / Configuration via serverless.yml

最小構成はpluginsserverless-localstackを足せばOKなようですが、なんとなくcustomの方も定義だけは
書いておきました。

frameworkVersion: '2'

plugins:
  - serverless-localstack

custom:
  localstack:

provider:
  name: aws
  runtime: java11
  lambdaHashingVersion: 20201221

あとはなにも考えずにmvn packageして

$ mvn package

デプロイ。

$ serverless deploy

LocalStackへのデプロイが始まり

Serverless: Using serverless-localstack
Serverless: Packaging service...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
........
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service hello-dev.jar file to S3 (3.38 MB)...
Serverless: Validating template...
Serverless: Skipping template validation: Unsupported in Localstack
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
........

完了。

Serverless: Stack update finished...
Service Information
service: serverless-localstack-java
stage: dev
region: us-east-1
stack: serverless-localstack-java-dev
resources: 6
api keys:
  None
endpoints:
functions:
  hello: serverless-localstack-java-dev-hello
layers:
  None

ただ、これだとAWS Lambda関数はデプロイされているのですが、Amazon API Gatewayなどは作成されません。

$ awslocal apigateway get-rest-apis
{
    "items": []
}

serverless.ymlの変更が必要そうです。

こちらのドキュメントを参照して

Serverless Framework - AWS Lambda Events - REST API (API Gateway v1)

リソース定義を変更。

functions:
  hello:
    handler: com.serverless.Handler
#    The following are a few example events you can configure
#    NOTE: Please make sure to change your handler code to work with those events
#    Check the event documentation for details
    events:
      - http:
          path: /hello
          method: get

httpをイベントとして設定。

ちなみに、最初の定義はこうでした。

functions:
  hello:
    handler: com.serverless.Handler

では、再度デプロイ。

$ serverless deploy
Serverless: Using serverless-localstack
Serverless: Packaging service...
 
 Serverless Error ----------------------------------------
 
  The serverless deployment bucket "serverless-localstack-java-de-serverlessdeploymentbuck-d23aaeee" does not exist. Create it manually if you want to reuse the CloudFormation stack "serverless-localstack-java-dev", or delete the stack if it is no longer required.
 
  Get Support --------------------------------------------
     Docs:          docs.serverless.com
     Bugs:          github.com/serverless/serverless/issues
     Issues:        forum.serverless.com
 
  Your Environment Information ---------------------------
     Operating System:          linux
     Node Version:              14.18.0
     Framework Version:         2.62.0
     Plugin Version:            5.4.6
     SDK Version:               4.3.0
     Components Version:        3.17.1
 

S3バケットがないと言われたので…表示されている名前のバケットを作成して

$ awslocal s3 mb s3://serverless-localstack-java-de-serverlessdeploymentbuck-d23aaeee
make_bucket: serverless-localstack-java-de-serverlessdeploymentbuck-d23aaeee

再度デプロイ。

$ serverless deploy

今度はうまくいきました。

Serverless: Using serverless-localstack
Serverless: Packaging service...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service hello-dev.jar file to S3 (3.38 MB)...
Serverless: Validating template...
Serverless: Skipping template validation: Unsupported in Localstack
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
.............
Serverless: Stack update finished...
Service Information
service: serverless-localstack-java
stage: dev
region: us-east-1
stack: serverless-localstack-java-dev
resources: 11
api keys:
  None
endpoints:
  http://localhost:4566/restapis/mo8mdxctbu/dev/_user_request_
functions:
  hello: serverless-localstack-java-dev-hello
layers:
  None

REST APIのIDも表示されているので、こちらを使って

$ REST_API_ID=mo8mdxctbu

LocalStackで動作しているAmazon API Gateway越しにcurlでアクセスしてみます。

$ curl http://localhost:4566/restapis/$REST_API_ID/dev/_user_request_/hello
{"message":"Go Serverless v1.x! Your function executed successfully!","input":{"path":"/hello","headers":{"remote-addr":"172.17.0.1","host":"localhost:4566","user-agent":"curl/7.68.0","accept":"*/*","x-forwarded-for":"172.17.0.1, localhost:4566, 127.0.0.1, localhost:4566","x-localstack-edge":"http://localhost:4566","authorization":"AWS4-HMAC-SHA256 Credential=__internal_call__/20160623/us-east-1/apigateway/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=1234","x-localstack-tgt-api":"apigateway","__apigw_request_region__":"us-east-1"},"multiValueHeaders":{"remote-addr":["172.17.0.1"],"host":["localhost:4566"],"user-agent":["curl/7.68.0"],"accept":["*/*"],"x-forwarded-for":["172.17.0.1, localhost:4566, 127.0.0.1, localhost:4566"],"x-localstack-edge":["http://localhost:4566"],"authorization":["AWS4-HMAC-SHA256 Credential=__internal_call__/20160623/us-east-1/apigateway/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=1234"],"x-localstack-tgt-api":["apigateway"],"__apigw_request_region__":["us-east-1"]},"body":"","isBase64Encoded":false,"httpMethod":"GET","queryStringParameters":{},"multiValueQueryStringParameters":{},"pathParameters":{},"resource":"/hello","requestContext":{"path":"/dev/hello","resourcePath":"/hello","apiId":"mo8mdxctbu","domainPrefix":"mo8mdxctbu","domainName":"mo8mdxctbu.execute-api.localhost.localstack.cloud","accountId":"000000000000","resourceId":"35mudhf87g","stage":"dev","identity":{"accountId":"000000000000","sourceIp":"127.0.0.1","userAgent":"curl/7.68.0"},"httpMethod":"GET","protocol":"HTTP/1.1","requestTime":"2021-10-12T06:40:09.696Z","requestTimeEpoch":1634020809696},"stageVariables":{}}}

OKですね。

あと、removeでリソースを削除できるはずなのですが…

$ serverless remove

こちらはうまくいかなかったので、今回は置いておくことにしました。

Serverless: Using serverless-localstack
Serverless: Getting all objects in S3 bucket...
 
 Serverless Error ----------------------------------------
 
  ServerlessError: UnknownError
      at /path/to/serverless/node_modules/serverless/lib/aws/request.js:225:11
      at processTicksAndRejections (internal/process/task_queues.js:95:5)
      at async persistentRequest (/path/to/serverless/node_modules/serverless/lib/aws/request.js:149:14)
 
     For debugging logs, run again after setting the "SLS_DEBUG=*" environment variable.
 
  Get Support --------------------------------------------
     Docs:          docs.serverless.com
     Bugs:          github.com/serverless/serverless/issues
     Issues:        forum.serverless.com
 
  Your Environment Information ---------------------------
     Operating System:          linux
     Node Version:              14.18.0
     Framework Version:         2.62.0
     Plugin Version:            5.4.6
     SDK Version:               4.3.0
     Components Version:        3.17.1
 
Serverless: Removing objects in S3 bucket...
Serverless: Removing Stack...

まとめ

Serverless FrameworkをLocalStackで使ってみました。

とはいえ、単純にデプロイしてみただけなので、Serverless Frameworkのほとんどの機能は使えていないわけですが。

ドキュメントを見ているとダッシュボード、CI/CD、モニタリング、テストなどいろいろできそうなのですが、
AWSに閉じるのならまずはAWS SAMあたりを使っておけばよいのかもな、とも。

ちょっといろいろ試しながら、考えていきましょう。