AWS Lambda + API Gateway を使って Quarkus + Scala で実装した Slack の Bot を作ってみた

本エントリは、AWS LambdaとServerless #2 Advent Calendar 2019 - Qiita の 6 日目です。

要約

  • AWS Lambda と API Gateway を使って、Slack の Bot を作ってみました。
  • Scala で書いたプログラムを GraalVM でネイティブ用のバイナリにして、高速起動を実現することを目指しました。
  • その際、Javaフレームワークである Quarkus を利用しました。
  • 主に、Scala を Quarkus で利用する上でハマった点について書きます。
    • Slack Bot の作り方一般、API GatewayAWS Lambda の詳しい設定方法については、参考資料を載せてありますので、そちらをご覧いただければと思います。

目次

なお、本記事で作成したものは GitLab のリポジトリ で公開しております。

要素技術の紹介

AWS Lambda

f:id:linkode-okazaki:20191204195931p:plain

  • 公式サイト:AWS Lambda(イベント発生時にコードを実行)| AWS
  • サーバーレスでプログラムを実行できる環境を提供するAWSのサービスの一つです。
  • 何らかのイベントなどをトリガーとして自動的にプログラムコードが実行される「FaaS(Function as a Service)」のアーキテクチャです。
    • 同様のサービスとして、Azure Functions、Google Cloud Functions、IBM Cloud Functions などがあります。
  • 利用可能な言語として、Node.js、PythonJavaRubyC#、Go があります。
    • Java がサポートされているため、Scala や Kotlin など、JVM 上で動く言語が利用可能です。
  • また、「カスタムランタイム」を利用することで、シェルスクリプトやバイナリ実行ファイルを実行することができます。つまり、バイナリにすることができれば、どんな言語でも実行可能です。
  • 今回は、Slack で投稿されたメッセージの内容を処理するために利用します。

Lambda 関数

  • AWS Lambda で実行するコードのことを「Lambda 関数」と呼びます。
  • 開発者は、コードを zip 形式にしてアップロードするか、AWS コンソール上で直接記述することで Lambda 関数をアップロード&作成が可能です。
    • 各種ライブラリを zip ファイルに含めることが可能です。
  • 言語によらず、コードにはパターンがあり、それを踏襲する必要があります。また、コードはステートレスなスタイルで書く必要があり、状態をもつ場合は他のサービスを利用するなどして工夫しなければなりません。
  • メモリ容量は 128MB から 1.5GB まで、64MB 刻みで設定可能です。また、実行時間のタイムアウトは 3 秒から 15 分迄設定可能です。
  • 今回は、Lambda 関数を Scala で実装しています。

Amazon API Gateway

f:id:linkode-okazaki:20191204195935p:plain

  • 公式サイト:Amazon API Gateway(規模に応じた API の作成、維持、保護)| AWS
  • クライアントから受け取ったリクエストを、それぞれのマイクロサービスにルーティングする仕組みです。
  • 複雑な API の処理を隱蔽化し、クライアントからは見えないようにすることが出来ます。また、多数の APIAWS コンソール上でモニタリング、認証、管理できます。
  • 今回は、Slack からの投稿イベントの送信先として利用します。

Quarkus

f:id:linkode-okazaki:20191204195940p:plain

  • 公式サイト:Quarkus - Supersonic Subatomic Java
  • RedHat が開発している、Kubernetes ネイティブな Java フレームワークです。
  • マイクロサービス・サーバーレス化が進む中、Java は他の言語に比べ起動が遅いことがデメリットです。これに対して、GraalVM を用いて作成した Linux のネイティブバイナリをコンテナ上で起動することにより、Java の起動時間を劇的に短縮することを目的に開発されています。
  • 今回は、AWS Lambda 上で動かすネイティブイメージの作成で利用しています。

GraalVM

f:id:linkode-okazaki:20191204195947p:plain

  • 公式サイト:GraalVM
  • Oracleオープンソースとして2018年4月に公開した、JavaJavaScriptRubyPythonなどの多言語を、統合された一つのランタイム上で実行させる汎用ランタイムです。
  • GraalVM の機能の 1 つとして、「ネイティブイメージの生成」があります。
    • JVM が不要となり、サイズを小さく、起動時間を短く、メモリ使用量を少なくすることが出来ます。
  • 今回は、Quarkus の中で GraalVM を使ってネイティブイメージの生成を行います。

今回作成したものの構成

  • 前述の技術を用いて今回作成した Bot の構成図は以下のようになります。
    • Bot に向けて送信されたメッセージをオウム返しします。
    • Bot から API Gateway にリクエストを送信し、Lambda 関数から HTTP 通信で Bot にメッセージ投稿のリクエストを送信します。

f:id:linkode-okazaki:20191206131309p:plain

作成手順

必ずしも下記の順番どおりでなくても良いですが、概ね、やらねばならないことは以下の通りです:

  1. 開発に必要な環境を整える:Linux or Docker、GraalVM、MavenAWS CLI
  2. Quarkus プロジェクトの作成
  3. Scala でアプリケーションを実装する
  4. Quarkus でネイティブイメージの作成を行う
  5. Lambda 関数をデプロイする
  6. API Gateway の設定を行う
  7. Slack Bot の作成を行う
  8. 動作確認

以下、順を追って説明していきます。

1. 開発に必要な環境を整える

(1) Linux 環境 or Docker

  • AWS Lambda でネイティブイメージを動かすためには、Linux 上でネイティブイメージの作成を行わねばなりません。
  • そのため、開発環境として、仮想マシンなり Docker なりで Linux の環境を用意する必要があります。
  • Linux な環境では、Quarkus の公式の Docker イメージ上でネイティブイメージの生成を行うため、Docker の環境が必要です。

(2) GraalVM

  • GraalVM の GitHub リポジトリ から Linux 向けの GraalVM をダウンロードし、適切なディレクトリに保存し、パスを通します。
    • 今回は、Java 8 ベースの graalvm-ce-linux-amd64-19.2.1.tar.gz を利用しています。
    • 2019 年 12 月現在、Quarkus は GraalVM19.2.1 までしか対応しておりません。

(3) Maven

  • Quarkus ではビルドツールとして Maven 3.5.3 以上(公式サイト:Maven – Welcome to Apache Maven)か Gradle(公式サイト:Gradle Build Tool)が利用可能です。
    • Scala 使いとしては、sbt を使いたいところですが…
  • ただし、Gradle を使うと動作が不安定(2019 年 12 月現在)という情報もありましたので、今回は Maven を利用しています。

(4) AWS CLI

2. Quarkus プロジェクトの作成

  • Maven のプロジェクトを作成する要領で、コマンドから Qauarkus のプロジェクトを作成することができます。
  • 今回は、AWS Lambda 向けのプロジェクトを作成したかったため、下記のようなコマンドを実行しました。
$ mvn archetype:generate \
   -DarchetypeGroupId=io.quarkus \
   -DarchetypeArtifactId=quarkus-amazon-lambda-archetype \
   -DarchetypeVersion=1.0.1.Final
  • この後、プロジェクト名などを入力して決定します。
  • コマンド実行後、下記のようなファイルが生成されます。
  slack-palotting-bot
  |-- create-native.sh
  |-- create.sh
  |-- delete-native.sh
  |-- delete.sh
  |-- invoke-native.sh
  |-- invoke.sh
  |-- payload.json
  |-- pom.xml
  |-- src
  |   |-- assembly
  |   |   `-- zip.xml
  |   |-- main
  |   |   |-- java
  |   |   |   `-- com
  |   |   |       `-- example
  |   |   |           `-- slack-parroting-bot
  |   |   |               |-- InputObject.java
  |   |   |               |-- OutputObject.java
  |   |   |               |-- ProcessingService.java
  |   |   |               |-- TestLambda.java
  |   |   |               `-- UnusedLambda.java
  |   |   `-- resources
  |   |       `-- application.properties
  |   `-- test
  |       `-- java
  |           `-- com
  |               `-- example
  |                   `-- slack-parroting-bot
  |-- update-native.sh
  `-- update.sh
  • 生成されたものをざっくり説明しますと、
    • シェルスクリプトAWS Lambda への Lambda 関数の作成、更新、実行を行うために利用します。
      • 「-native」と付いているのがカスタムランタイム向け、無印のものが Java ランタイム向けのものです。
      • 今回は、前者を利用します。
    • payload.jsoninvoke.shinvoke-native.sh で利用する、関数実行時に関数に渡す値を記述します。
    • pom.xmlMaven プロジェクトファイルです。ライブラリの依存関係やネイティブイメージ生成時のオプションなどを記述します。
    • src/assembly/zip.xml :生成したネイティブイメージを zip で圧縮する際に利用する設定ファイルです。理由がなければ、弄る必要はありません。
  • src/mainsrc/test はこの後、Scalaソースコードに置き換えていきます。

3. Scala でアプリケーションを実装する

(1) pom.xml の修正

  • コマンドで生成されたプロジェクトファイルだけでは、Scala を利用するには不十分な箇所がありますので、内容を変更します。
    • 変更の際には、Quarkus - Start coding with code.quarkus.io でダウンロードできる、プロジェクトのテンプレートファイルを参考にしました。
      • Scala による実装に関する設定を確認したかったため、「Alternative languages」の項にある「scala」にチェックを入れ、「Generate your application」を押下し、プロジェクトのテンプレートをダウンロードしました。

f:id:linkode-okazaki:20191204200225p:plain

  • この方法を使えば、自分の使いたい環境などに応じてある程度の設定のテンプレートは見ることが出来ます。
  • 以下、変更し終えた pom.xml の内容です。
    • ポイントは、
      • 依存関係(dependency)に Scala のライブラリを含める
      • プラグイン(plugin)に scala-maven-plugin を追加
      • sourceDirectorytestSourceDirectory をそれぞれ src/main/scalasrc/test/scala を指定
        • src/main/javasrc/test/java は消してしまって、src/main/scalasrc/test/scala にする
      • ネイティブイメージ生成時のプロパティを指定
        • quarkus.native.additional-build-args(ネイティブイメージ生成時の追加の引数)として、 --initialize-at-build-time,-H:+TraceClassInitialization を指定
        • Slack の投稿のために HTTPS 通信を行うので、quarkus.native.enable-https-url-handlerquarkus.native.enable-jni を true にする
          • それぞれ、GraalVM でのネイティブイメージ生成コマンド(natibe-image)の引数 -H:EnableURLProtocols=http,https-H:+JNI に相当します。

pom.xml

      <?xml version="1.0"?>
      <project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
               xmlns="http://maven.apache.org/POM/4.0.0"
               xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
          <modelVersion>4.0.0</modelVersion>
          <groupId>com.example.slack_parroting_bot</groupId>
          <artifactId>slack_parroting_bot</artifactId>
          <version>1.0.0</version>
          <properties>
              <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
              <surefire-plugin.version>2.22.0</surefire-plugin.version>
              <maven.compiler.parameters>true</maven.compiler.parameters>
              <quarkus.version>1.0.1.Final</quarkus.version>
              <compiler-plugin.version>3.8.1</compiler-plugin.version>
              <maven.compiler.source>1.8</maven.compiler.source>
              <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
              <maven.compiler.target>1.8</maven.compiler.target>
              <scala-maven-plugin.version>4.3.0</scala-maven-plugin.version>
              <scala.major.version>2.11</scala.major.version>
              <scala.version>${scala.major.version}.12</scala.version>
              <scalaj-http.version>2.4.2</scalaj-http.version>
              <scala-test.version>3.0.8</scala-test.version>
              <junit.version>4.12</junit.version>
              <mockito-core.version>3.2.0</mockito-core.version>
              <maven-assembly-plugin.version>3.1.0</maven-assembly-plugin.version>
          </properties>
          <dependencyManagement>
              <dependencies>
                  <dependency>
                      <groupId>io.quarkus</groupId>
                      <artifactId>quarkus-bom</artifactId>
                      <version>${quarkus.version}</version>
                      <type>pom</type>
                      <scope>import</scope>
                  </dependency>
              </dependencies>
          </dependencyManagement>
          <dependencies>
              <dependency>
                  <groupId>io.quarkus</groupId>
                  <artifactId>quarkus-amazon-lambda</artifactId>
              </dependency>
              <dependency>
                  <groupId>org.scala-lang</groupId>
                  <artifactId>scala-library</artifactId>
                  <version>${scala.version}</version>
              </dependency>
              <dependency>
                  <groupId>org.scala-lang</groupId>
                  <artifactId>scala-reflect</artifactId>
                  <version>${scala.version}</version>
              </dependency>
              <dependency>
                  <groupId>io.quarkus</groupId>
                  <artifactId>quarkus-scala</artifactId>
              </dependency>
              <dependency>
                  <groupId>org.scalaj</groupId>
                  <artifactId>scalaj-http_${scala.major.version}</artifactId>
                  <version>${scalaj-http.version}</version>
              </dependency>
              <dependency>
                  <groupId>junit</groupId>
                  <artifactId>junit</artifactId>
                  <version>${junit.version}</version>
                  <scope>test</scope>
              </dependency>
              <dependency>
                  <groupId>org.scalatest</groupId>
                  <artifactId>scalatest_${scala.major.version}</artifactId>
                  <version>${scala-test.version}</version>
                  <scope>test</scope>
              </dependency>
              <dependency>
                  <groupId>org.mockito</groupId>
                  <artifactId>mockito-core</artifactId>
                  <version>${mockito-core.version}</version>
                  <scope>test</scope>
              </dependency>
              <dependency>
                  <groupId>org.apache.maven.plugins</groupId>
                  <artifactId>maven-assembly-plugin</artifactId>
                  <version>${maven-assembly-plugin.version}</version>
              </dependency>
          </dependencies>
          <build>
              <sourceDirectory>src/main/scala</sourceDirectory>
              <testSourceDirectory>src/test/scala</testSourceDirectory>
              <plugins>
                  <plugin>
                      <groupId>io.quarkus</groupId>
                      <artifactId>quarkus-maven-plugin</artifactId>
                      <version>${quarkus.version}</version>
                      <configuration>
                          <uberJar>true</uberJar>
                      </configuration>
                      <executions>
                          <execution>
                              <goals>
                                  <goal>build</goal>
                              </goals>
                          </execution>
                      </executions>
                  </plugin>
                  <plugin>
                      <artifactId>maven-compiler-plugin</artifactId>
                      <version>3.8.1</version>
                  </plugin>
                  <plugin>
                      <artifactId>maven-surefire-plugin</artifactId>
                      <version>${surefire-plugin.version}</version>
                      <configuration>
                          <systemProperties>
                              <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
                          </systemProperties>
                      </configuration>
                  </plugin>
                  <plugin>
                      <groupId>net.alchim31.maven</groupId>
                      <artifactId>scala-maven-plugin</artifactId>
                      <version>${scala-maven-plugin.version}</version>
                      <executions>
                          <execution>
                              <id>scala-compile-first</id>
                              <phase>process-resources</phase>
                              <goals>
                                  <goal>add-source</goal>
                                  <goal>compile</goal>
                              </goals>
                          </execution>
                          <execution>
                              <id>scala-test-compile</id>
                              <phase>process-test-resources</phase>
                              <goals>
                                  <goal>add-source</goal>
                                  <goal>testCompile</goal>
                              </goals>
                          </execution>
                      </executions>
                      <configuration>
                          <scalaVersion>${scala.version}</scalaVersion>
                          <args>
                              <arg>-deprecation</arg>
                              <arg>-feature</arg>
                              <arg>-explaintypes</arg>
                              <arg>-target:jvm-1.8</arg>
                              <arg>-Ypartial-unification</arg>
                          </args>
                      </configuration>
                  </plugin>
              </plugins>
          </build>
          <profiles>
              <profile>
                  <id>native</id>
                  <properties>
                      <quarkus.package.type>native</quarkus.package.type>
                      <quarkus.native.additional-build-args>
                          --initialize-at-build-time,-H:+TraceClassInitialization
                      </quarkus.native.additional-build-args>
                      <quarkus.native.enable-https-url-handler>true</quarkus.native.enable-https-url-handler>
                      <quarkus.native.enable-jni>true</quarkus.native.enable-jni>
                  </properties>
                  <activation>
                      <property>
                          <name>native</name>
                      </property>
                  </activation>
                  <build>
                      <plugins>
                          <plugin>
                              <groupId>io.quarkus</groupId>
                              <artifactId>quarkus-maven-plugin</artifactId>
                              <version>${quarkus.version}</version>
                              <executions>
                                  <execution>
                                      <goals>
                                          <goal>native-image</goal>
                                      </goals>
                                      <configuration>
                                          <enableHttpUrlHandler>true</enableHttpUrlHandler>
                                      </configuration>
                                  </execution>
                              </executions>
                          </plugin>
                          <plugin>
                              <groupId>org.apache.maven.plugins</groupId>
                              <artifactId>maven-assembly-plugin</artifactId>
                              <version>3.1.0</version>
                              <executions>
                                  <execution>
                                      <id>zip-assembly</id>
                                      <phase>package</phase>
                                      <goals>
                                          <goal>single</goal>
                                      </goals>
                                      <configuration>
                                          <finalName>function</finalName>
                                          <descriptors>
                                              <descriptor>src/assembly/zip.xml</descriptor>
                                          </descriptors>
                                          <attach>false</attach>
                                          <appendAssemblyId>false</appendAssemblyId>
                                      </configuration>
                                  </execution>
                              </executions>
                          </plugin>
                      </plugins>
                  </build>
              </profile>
          </profiles>
      </project>

pom.xml の diff f:id:linkode-okazaki:20191204195951p:plain f:id:linkode-okazaki:20191204195927p:plain f:id:linkode-okazaki:20191204200004p:plain

(2) Scala で処理の実装

  • 今回は、Slack で Bot 宛に投げられたメッセージを単純にオウム返しするだけの簡単な Bot を作成します
    • Slack アプリを作成する際に必要な API の説明などは参考資料をご覧ください。
  • Java による Lambda 関数のビルド - AWS Lambda を参考に Java で書かれていることを Scala に変換して実装すれば良いのですが、いくつか注意点があります。
①ハンドラーの入出力に使用する POJO の実装
  • Lambda 関数を実行する際のハンドラの入出力には、POJO 型を利用します。
    • AWS Lambda としては、文字列型、整数型などのシンプルな型やストリーム型を利用することも出来ます。
    • しかしながら、Quarkus でネイティブイメージにコンパイルする際にコンパイルエラーとなってしまいました。
  • AWS Lambda の組み込み JSONリアライザーの都合上、POJOには、
    • setter/getter
    • 引数 0 個のコンストラク

    の両方が必要です。

  • そのため、POJO に相当する case class では、
    • フィールド変数に @scala.beans.BeanProperty を付け、 var 指定する
    • 何もしない引数 0 個のコンストラクタを作成する

    の 2 つの事項が必要です。

  • 以下、Slack の イベント APIURL 認証イベントの APIを受け取るための case class とアウトプットの case class の例です:

POJO の case class

  import scala.beans.BeanProperty
  
  case class MessageChannelsEvent(@BeanProperty var token: String,
                                  @BeanProperty var teamId: String,
                                  @BeanProperty var apiAppId: String,
                                  @BeanProperty var event: MessageChannelsEventType,
                                  @BeanProperty var `type`: String,
                                  @BeanProperty var authedUsers: List[String],
                                  @BeanProperty var eventId: String,
                                  @BeanProperty var eventTime: BigInt) {

    def this() = this("", "", "", new MessageChannelsEventType(), "", List(""), "", BigInt(0))
  }
  
  case class MessageChannelsEventType(@BeanProperty var `type`: String,
                                      @BeanProperty var subtype: String,
                                      @BeanProperty var channel: String,
                                      @BeanProperty var user: String,
                                      @BeanProperty var botId: String,
                                      @BeanProperty var text: String,
                                      @BeanProperty var ts: String,
                                      @BeanProperty var eventTs: String,
                                      @BeanProperty var channelType: String) {

    def this() = this("", "", "", "", "", "", "", "", "")
  }
  
  case class UrlVerificationEvent(@BeanProperty var token: String,
                                  @BeanProperty var challenge: String,
                                  @BeanProperty var eventType: String) {
  
    def this() = this("", "", "")
  }
  
  case class OutputForMain(@BeanProperty var statusCode: String,
                           @BeanProperty var message: String) {
    def this() = this("", "")
  }
  
  case class OutputForUrlVerification(@BeanProperty var challenge: String) {
    def this() = this("")
  }

②リクエストハンドラの実装
  • com.amazonaws.services.lambda.runtime.RequestHandlerhandleRequest メソッドをオーバーライドして作成します。
    • メソッドの入出力には①で作成した case class を用います
  • @javax.inject.Named でクラス名に名付けします
    • src/main/resources/application.properties で下記のように指定したクラスが Lambda 実行時に呼び出されるクラスになります。 quarkus.lambda.handler=main
      • この場合、@Names("main") と指定されたクラスのリクエストハンドラを実行します。
  • 実際に実装した例が以下です:

実装したリクエストハンドラ

  import com.amazonaws.services.lambda.runtime.{Context, RequestHandler}
  import com.example.SlackParrotingBot.{ACCESS_TOKEN, CHANNEL}
  import com.example.slack_parroting_bot.models.{MessageChannelsEvent, OutputForMain}
  import javax.inject.Named
  import scalaj.http.Http

  @Named("main")
  class MainLambda extends RequestHandler[MessageChannelsEvent, OutputForMain] {

    override def handleRequest(input: MessageChannelsEvent, context: Context): OutputForMain = {
      val message = input.event.text.replaceFirst(s"<@[a-zA-Z0-9]+>\\s*", "")
      parrot(message)
    }

    def parrot(message: String): OutputForMain = {
      val text = s"『${message}』"
      val sendData = s"token=${ACCESS_TOKEN}&channel=${CHANNEL}&text=${text}"
      val request = Http("https://slack.com/api/chat.postMessage").postData(sendData).method("POST")
      request.execute()

      OutputForMain("OK", "オウム返し成功")
    }
  }
  • また、URL 認証用に作成したクラスはこちらです。
  import com.amazonaws.services.lambda.runtime.{Context, RequestHandler}
  import com.example.slack_parroting_bot.models.{OutputForUrlVerification, UrlVerificationEvent}
  import javax.inject.Named

  @Named("test")
  class TestUrlVerificationLambda extends RequestHandler[UrlVerificationEvent, OutputForUrlVerification]{

    override def handleRequest(input: UrlVerificationEvent, context: Context): OutputForUrlVerification = {
      OutputForUrlVerification(input.challenge)
    }
  }

4. Quarkus でネイティブイメージの作成を行う

  • 下記のコマンドでネイティブイメージの生成を行います。
    • Linux 環境で行う場合:
  $ mvn clean install -Dnative
  • Linux 環境で行う場合、Quarkus 公式の Docker イメージ上で行う必要があります。Docker を起動している状態で、下記コマンドを利用します。
  $ mvn clean install -Dnative -Dnative-image.docker-build=true
  • 時間がかかるので、気長に待ちます(8分かかることはザラです)。
  • 下記のようなメッセージが現れたら成功です。
    [INFO] -------------------------------------------------------------------
    [INFO] BUILD SUCCESS
    [INFO] -------------------------------------------------------------------
    [INFO] Total time:  08:14 min
    [INFO] Finished at: 2019-12-XXTXX:XX:XX+09:00
    [INFO] -------------------------------------------------------------------

5. Lambda 関数をデプロイする

  • プロジェクト作成時に自動生成されたシェルスクリプト create-native.sh を利用します。
  • create-native.sh を下記のように書き換えます。
  zip -j -u ./target/function.zip {Path to GraalVM}/jre/lib/amd64/libsunec.so
  aws lambda create-function --function-name SlackParrotingBot_native --zip-file fileb://target/function.zip --handler any.name.not.used --runtime provided --role [Lambda 関数のロール ARM] --environment Variables="{DISABLE_SIGNAL_HANDLERS=true}"
  • 1 行目の zip コマンドでは、ネイティブイメージ生成時に作成した zip ファイルに、libsunec.so のライブラリを追加しています。
    • HTTPS 通信を行う際に必要となるものです。
    • ライブラリは、GraalVM 内のものを持ってくれば良いです。
  • 2 行目で関数の作成を行っています。
    • AWS CLI が利用できなければ実行できません。
    • --role には Lambda 関数のロール ARM を記載してください。

6. API Gateway の設定を行う

  • Lambda 関数が作成できたら、POST 通信を受け取れるように、API Gateway の設定を行います。
  • 詳しくは一番下の参考資料をご確認ください。

7. Slack Bot の作成を行う

  • Slack API: Applications | Slack から、Slack のアプリを作成します。
  • 詳しい説明は(またまた)参考資料に委ねますが、今回は、Bot に対するメンション付きのメッセージがあった場合に、API エンドポイントにメッセージ情報を投げるようにしました。

f:id:linkode-okazaki:20191204201042p:plain

8. 動作確認

  • 実際に、Slack で動作確認してみましょう。

f:id:linkode-okazaki:20191204195923p:plain

  • ちゃんと、自分に呼びかけられた意外の言葉には反応しないようになってますね。賢いです。

作成時の注意点など

  • 手順 7. の API エンドポイントの設定の際、始めはサーバーの証明のために通常の message event とは異なる形式の JSON を受け取ることになります。
    • そのため、 5. のデプロイ時はテスト用のネイティブイメージをデプロイして、OK になってから本番用のネイティブイメージをデプロイしました。
      • もうちょっとスマートな方法があるような気がします。
    • テスト用と本番用の切り替えは、src/main/resources/application.properties で設定して切り替えています。
      • 設定ファイルの内容の書き換えだけ、と言っても、再度ネイティブイメージの生成をせねばならない(≒10分弱待つ)ので、賢い方法はまた考えます。
  • 外部ライブラリを利用していると、ネイティブイメージ生成時にエラーになったり、実行時にエラーになったりすることが往々にしてあります。
    • およそ、ネイティブイメージ生成時にリフレクション周りの情報が消えてしまうことによるものが多いかと思います。
    • Quarkus - Tips for writing native applicationsを参考に修正を試みるか、別ライブラリの利用を検討するくらいしか今のところの回避策はなさそうです。
    • GraalVM のアップデートによって解消されることはあるのかもしれません。

コードサイズ、起動時間、消費メモリの比較

  • 同じ Quarkus を利用したプログラムで、Java ランタイム(jar)とカスタムランタイム(ネイティブイメージ)とでリソースの消費具合を比較しました。

コードサイズの比較

Java ランタイム カスタムランタイム
23.7 MB 9.5 MB
  • AWS Lambda のアップロード上限が 50MB ですので、jar にした場合、他にもライブラリを用いたりコードの量が増えると、上限に到達しそうです。
    • 今回は、ただ単にメッセージを返すだけでしたので、ライブラリやコードの量は少なくて済みました。
  • ネイティブイメージの場合は、JVM が無い分、サイズが小さくなっています。

起動時間の比較

Java ランタイム カスタムランタイム
1回目 11251.75 ms 1098.85 ms
2回目 143.28 ms 104.11 ms
3回目 161.20 ms 110.86 ms
4回目 131.35 ms 106.22 ms
5回目 111.99 ms 98.87 ms
6回目 144.11 ms 109.70 ms
7回目 171.29 ms 138.62 ms
8回目 162.03 ms 97.86 ms
9回目 107.35 ms 107.63 ms
10回目 117.39 ms 173.44 ms
  • Java ランタイムの場合、やはり、初回起動に明らかに時間がかかっています。
  • カスタムランタイムの場合、初回起動に時間がかかるものの、Java ランタイムほどではないです。
  • 2回目以降は両者にさほど差はないように見えます。
    • AWS Lambda の課金時間は 100 ms 単位ですので、そういった意味で両者に差はないと言えます。
  • しばらく(数分)した後に再度起動した場合、やはり Java ランタイムのほうが大幅に時間がかかりました。

最大消費メモリの比較

Java ランタイム カスタムランタイム
117 MB 77 MB
  • 最大消費メモリについては、両者とも回数による差はありませんでした。
  • 総じて、カスタムランタイムのほうが消費メモリは少なかったです。
  • Java ランタイムの方は、Lambda の基本設定でメモリの大きさを最小設定の 128 MB にした場合、OutOfMemorryError で落ちてしまいました。

比較結果について

  • 当初の予想通り、カスタムランタイムの方がリソースの消費量は少なくなりました。
  • しかしながら、これが有効な差となるかは場合によるかと思います。
    • 頻繁に呼び出される Lambda 関数なら、Java ランタイムでも問題なく使えるかと思います。
    • たまにしか呼ばれない Lambda 関数では、カスタムランタイムの方がストレスなく使えるでしょう。

まとめ

  • Javaフレームワークである Quarkus を用いて、ScalaAWS Lambda のカスタムランタイム上で動く Slack Bot を作ってみました。
  • Quarkus や GraalVM 自体がまだまだ発展途上なこともあって、ネイティブイメージ生成時には少し苦労するところも出てきます(Scala のバージョン、外部ライブラリの使用、等)
  • 実は、GraalVM を生で使って Scala のプログラムをネイティブイメージ化するのにはちょっと失敗しているのですが、Quarkus を使えば、比較的かんたんにネイティブイメージ化出来ました。
  • 消費リソースについても、期待通り、Java ランタイムよりも低く押さえられており、特に、初回起動の早さはメリットになりうるかと思います。
  • AWS Step Functionsを利用して Lambda を繋いだりしたときも、初回起動が早ければストレスなく処理ができたりするのかなぁ、と考えていたりするのですが、そのあたりの検証はまたの機会に(あれば)。
  • この記事を書いている最中、AWS re:Invent にて、Lambda の新機能 「Provisioned Concurrency の設定」が発表された模様です([速報]コールドスタート対策のLambda定期実行とサヨナラ!! LambdaにProvisioned Concurrencyの設定が追加されました  #reinvent | Developers.IO)。
    • これによると、コールドスタートの発生頻度を低く抑えることができるため、Java のランタイムでもストレスなく利用できるようになることになるかもしれません。
    • しかし、追加料金がかかる機能ではありますので、そもそもコールドスタートの問題がないに越したことはないと思います。

追記:Ver.1.1.X について

  • 2020年1月7日にバージョン 1.1.1.Final がリリースされました。本バージョンは、年末にリリースされた 1.1.0.Final のバグフィックスのみだそうです。
  • 1.1.0.Final から、AWS SAMを用いたテストがサポートされています。
  • また、1.1.0.Final から、テンプレートのプロジェクトを作成した際に生成されるシェルスクリプトの使い方が若干変わっています。
  • GraalVM 19.3.0 のサポートはまだで、1.2 以降のバージョンでサポートされる予定だとのアナウンスがあります。

参考資料

更新履歴

  • 2019/12/06 00:00:公開
  • 2019/12/06 13:15:「Lambda関数とは」と「今回作成したものの構成」を追記
  • 2020/01/08 11:00:非 Linux 環境でネイティブイメージを生成する方法を追記 &「Ver.1.1.Xについて」を追記
  • 2020/02/06 16:40:GitLab のリポジトリの URL を記事の冒頭に追記