😀

gRPC on GraalVM

2021/04/03に公開

サンプルコード

https://github.com/fumin65/kotlin-grpc-graalvm-sample/tree/master/native-image

モチベーション

昨今、マイクロサービスやモバイルアプリへのAPI提供など、gRPCを活用するシーンが増えている。
gRPCのサンプルとしてはGo言語を使ったものが多いが、BFFをモバイルエンジニアが実装したり、Java系のエンジニアが多いことを考えると、JavaやKotlinで開発したいところである。
GraalVMを使うことでネックになっていたアプリケーションの起動速度を改善し、gRPC(protocol buffers)で高速通信を実現する。

gRPC-javaの導入

今回はシリアライズにprotocol buffersを使用し、gRPC-javaを使ってサーバー、クライアントのコードを生成する。
ちなみにgRPC-kotlinもあり、coroutineに対応したコードを生成してくれるが、ネイティブイメージを作成するのに失敗するので、今回は使用しない。

ビルドにはgradleを使用する。
まずはprotocol buffersのgradleプラグインを導入する。

buildscript {
    repositories {
        mavenLocal()
        mavenCentral()
    }
    dependencies {
        classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.15"
    }
}

plugins {
    id "com.google.protobuf"
    id "application"
    id "idea"
}

実行可能なjarを生成したり、sourceSetの設定をするために上記のプラグインも有効にしておく。

dependenciesにgRPC-javaの依存関係を追加する。

dependencies {
    implementation "io.grpc:grpc-netty-shaded:1.36.0"
    implementation "io.grpc:grpc-protobuf:1.36.0"
    implementation "io.grpc:grpc-stub:1.36.0"
}

上記でgrpc-javaで実装されているnettyが含まれているが、ネイティブイメージを作成する時に、io.nettyのクラスが足りないと言われるので、以下もdependenciesに追加する。

dependencies {
    implementation "io.netty:netty-handler:4.1.60.Final"
}

次にprotoファイルからコードの生成を行うための設定を追記する。

protobuf {
    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:1.34.1"
        }
    }
    generateProtoTasks {
        all()*.plugins {
            grpc {}
        }
    }
}

以下を追加してprotocファイルを配置するフォルダを指定する。

sourceSets {
    main {
        proto {
            srcDir "src/main/proto"
        }
    }
}

protoファイルの作成

gRPCのコードを生成するために、まずは以下のようなprotoファイルを作成する。

syntax = "proto3";

package com.example;

option java_package = "com.example";

message CreateTodoRequest {
  string title = 1;
}

message Todo {
  string id = 1;
  string title = 2;
}

service TodoService {
  rpc Create(CreateTodoRequest) returns (Todo);
}

protoファイルを作成したら、一旦ビルドし、gRPCのコードを生成させる。
(自動生成されるgRPCのコードをIntelliJに認識させるため)

./gradlew build

サービスの実装

次に自動生成されるgRPCのコードを継承して、サービスを実装していきます。
以下の実装はリクエストパラメータから適当にTodoオブジェクトを作成してレスポンスを返すだけの実装です。

src/main/kotlin/com/example/TodoService.kt
package com.example

import io.grpc.stub.StreamObserver
import java.util.*

class TodoService : TodoServiceGrpc.TodoServiceImplBase() {

    override fun create(
        request: TodoOuterClass.CreateTodoRequest,
        responseObserver: StreamObserver<TodoOuterClass.Todo>
    ) {

        val todo = TodoOuterClass.Todo
            .newBuilder()
            .setId(UUID.randomUUID().toString())
            .setTitle(request.title)
            .build()

        responseObserver.apply {
            onNext(todo)
            onCompleted()
        }

    }

}

gRPCサーバーの実装

サービスが実装できたらgRPCサーバーを実装します。
以下は9000ポートでgRPCサーバーを起動するmain関数です。

src/main/kotlin/com/example/main.kt
package com.example

import io.grpc.ServerBuilder

fun main() {
    println("start grpc server")
    val port = System.getenv("PORT") ?: 8080
    ServerBuilder.forPort(port)
        .addService(TodoService())
        .build()
        .start()
        .awaitTermination()
}

jarファイルの生成の設定

GraalVMのネイティブイメージをビルドするにはまずjarファイルを作成する必要があります。
以下をbuild.gradleに追加してjarを生成できるようにします。

application {
    mainClassName = "com.example.MainKt"
}

jar {
    archiveFileName = "native-image.jar"
    manifest {
        attributes "Main-Class": "com.example.MainKt" // main.ktはMainKtというJavaのクラスになります
    }
    from {
        configurations.runtimeClasspath.collect {
            it.isDirectory() ? it : zipTree(it)
        }
    }
}

そのままjarにすると依存関係が入らないので、fromの中で全ての依存関係がjarに含まれるように設定しています。

リフレクション設定ファイルを作成する

GraalVMでは高速起動をするためにビルド時に静的にコードをネイティブビルドします。
そのため、リフレクションなどの実行時に動的にロードをするようなコードが使用出来ません。

リフレクションなどの動的なロードを使用したい場合は、リフレクションの設定ファイルを用意して、何がロードされるのかを事前に定義しておくことでGraalVMがうまいことネイティブイメージを作れる様になります。
しかし、ライブラリなどがリフレクションを使っている場合もあり、これを一つ一つ設定ファイルに書いていくのはしんどいです。

GraalVMではjarの実行時にアプリを解析して上記の設定ファイルを自動生成してくれるagentが提供されています。

 java -agentlib:native-image-agent=config-output-dir=configs -jar xxxx.jar 

jarを起動し、リクエストを送信したりして処理を走らせて設定ファイルを書き出します。
(書き出すまでにかける時間をオプションで指定出来ます。)

以下のファイルが自動で書き出されます。

  • jni-config.json
  • proxy-config.json
  • reflect-config.json
  • resource-config.json
  • serialization-config.json

reflect-config.jsonに以下を追加します。

  ....,
  {
    "name": "io.netty.channel.socket.nio.NioServerSocketChannel",
    "methods": [
      {
        "name": "<init>",
        "parameterTypes": []
      }
    ]
  }
]

ネイティブイメージの作成

Dockerfileの作成

ネイティブイメージをビルドし、dockerイメージを作成するためにDockerfileを作成します。

FROM alpine:3.10.3

WORKDIR /protoc

# graalvmのイメージにはprotocが入っていないのでまずインストールする。
RUN apk add --no-cache unzip curl
RUN PB_REL="https://github.com/protocolbuffers/protobuf/releases" \
  && curl -LO $PB_REL/download/v3.14.0/protoc-3.14.0-linux-x86_64.zip \
  && unzip protoc-3.14.0-linux-x86_64.zip

# graalvmのイメージを使用してビルドを行う。
FROM ghcr.io/graalvm/graalvm-ce:21.0.0 as build

COPY . .
COPY --from=0 /protoc ./protoc

RUN export PATH="$PATH:/protoc/bin" \
  && gu install native-image \
  && ./gradlew build \
  && native-image --verbose --no-fallback \
    -H:ReflectionConfigurationFiles=configs/reflect-config.json \
    --allow-incomplete-classpath \
    -jar build/libs/native-image.jar
    
# 作成した実行ファイルだけを持つイメージを作成する
FROM debian:buster-slim

COPY --from=build /native-image .

CMD ["./native-image"]

docker buildでイメージを作成します。

docker build -t kotlin-grpc-native-image .

docker runで実行します。

$ docker run --rm -it -p 9000:9000 kotlin-grpc-native-image
start grpc server

クライアントの実装

gRPCサーバーにアクセスするクライアントを作成し、APIを叩いてみます。

src/main/kotlin/com/example/client.kt
package com.example

import io.grpc.ManagedChannelBuilder

fun main() {
    val channel = ManagedChannelBuilder
        .forAddress("localhost", 8080)
        .usePlaintext()
        .build()
    val client = TodoServiceGrpc.newBlockingStub(channel)
    val todo = client.create(
        TodoOuterClass.CreateTodoRequest
            .newBuilder()
            .setTitle("sample todo")
            .build()
    )
    println(todo)
}

コンテナを起動した状態で上記のプログラムを実行すると、gRPCサーバーからレスポンスが返ってきます。

id: "23610d14-0aed-4651-97ca-7b8fc2dd2dea"
title: "sample todo"

おまけ

Cloud Runへのデプロイ

Cloud RunはgRPCに対応しています。
せっかくなのでコンテナをデプロイしてみましょう。
まずはCloudRegistryにpush出来るようにdockerを構成します。

gcloud auth configure-docker

docker build時のタグを変更します。
プロジェクトIDは適宜変更してください。

docker build -t gcr.io/${project_id}/kotlin-grpc-native-image .

イメージができたらCloudRegistryにpushします。

docker push gcr.io/${project_id}/kotlin-grpc-native-image

GCPのコンソールからCloudRunのサービスを作成します。
とりあえずアクセスできるように未認証の呼び出しを許可します。

サービスが作成されたら、発行されたドメインでクライアントのコードを変更します。
また、先程のクライアントの例では認証なしアクセスでしたが、CloudRunにデプロイしたものにアクセスするためにTLSでの通信をするように変更します。

src/main/kotlin/com/example/client.kt
package com.example

import io.grpc.ManagedChannelBuilder

fun main() {
    val channel = ManagedChannelBuilder
        .forAddress("kotlin-grpc-native-image-xxxx-xx.a.run.app", 443) // portは443
        // .usePlaintext()
        .useTransportSecurity() // plaintextの代わりにこちらを使う
        .build()
    val client = TodoServiceGrpc.newBlockingStub(channel)
    val todo = client.create(
        TodoOuterClass.CreateTodoRequest
            .newBuilder()
            .setTitle("sample todo")
            .build()
    )
    println(todo)
}

上記のプログラムを実行するとCloudRunにデプロイされたサーバーからレスポンスが返る

id: "23610d14-0aed-4651-97ca-7b8fc2dd2dea"
title: "sample todo"

速度の比較

初回リクエストのレスポンス速度は以下くらい。

  • JVM版 : 2833ms
  • GraalVM版: 574ms

※ 300~1000msくらいで揺れる

2回目以降はJVMでも十分に早い

  • JVM: 322ms
  • GraalVM: 314ms

Discussion