gRPC on GraalVM

샘플 코드


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

모티프


최근 마이크로 서비스나 모바일 애플리케이션에 API를 제공하는 등 gRPC를 활용하는 장면이 늘었다.
gRPC의 샘플로서 Go 언어를 사용하는 것이 비교적 많지만 BFF가 모바일 엔지니어가 실시하고 자바 시스템의 엔지니어가 많은 것을 감안하여 자바와 Kotlin으로 개발하고 싶습니다.
GraalVM을 사용하여 병목이 된 애플리케이션의 시작 속도를 개선하고 gRPC(protocol buffers)를 통해 고속 통신을 실현한다.

gRPC-java 가져오기


이번에protocol buffers를 사용하여 직렬화하고 gRPC-java를 사용하여 서버, 클라이언트 코드를 생성합니다.
그나저나 gRPC-kotlin은 코로틴에 대응하는 코드를 만들 수 있지만 로컬 이미지 생성 실패 때문에 이번에는 사용하지 않습니다.
구축할 때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를 포함하지만 로컬 이미지를 제작할 때.넷티의 반이 부족하다고 하니 아래도dependencies에 추가합니다.
dependencies {
    implementation "io.netty:netty-handler:4.1.60.Final"
}
이어서 포토 파일에서 코드를 생성하는 데 사용되는 설정을 보충한다.
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"
        }
    }
}

소개 파일 만들기


gRPC 코드를 생성하기 위해서는 먼저 다음과 같은 프로토 파일을 만듭니다.
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);
}
protoo 파일을 만들면 먼저 구축하고 gRPC 코드를 생성합니다.
(IntelliJ에서 자동으로 생성된 gRPC의 코드를 식별하기 위해)
./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를 생성하기 위해gradle에 추가합니다.
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가 실행될 때 프로그램을 분석하고 상기 설정 파일을 자동으로 생성하는 에이전트를 제공합니다.
 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": []
          }
        ]
      }
    ]
    

    로컬 이미지 만들기


    Docker file 만들기


    로컬 이미지, Docker 이미지를 만듭니다.
    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 서비스를 만듭니다.
    우선 접근할 수 있도록 인증되지 않은 호출을 허용합니다.

    서비스가 생성되면 발행된 영역에서 클라이언트 코드를 변경합니다.
    또한 아까 클라이언트 예시에서 인증된 접근은 없지만 클라우드런의 프로그램에 접근하기 위해 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 Edition: 2833ms
  • GraalVM Edition: 574ms
  • ※ 300~100ms 정도 흔들림
    두 번째 이후에도 JVM이 빠릅니다.
  • JVM: 322ms
  • GraalVM: 314ms
  • 좋은 웹페이지 즐겨찾기