gRPC on GraalVM
サンプルコード
モチベーション
昨今、マイクロサービスやモバイルアプリへの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オブジェクトを作成してレスポンスを返すだけの実装です。
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関数です。
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 /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 /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を叩いてみます。
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での通信をするように変更します。
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