Quarkus + GraalVM (native-image)でGCP系ライブラリを利用する

12 min read読了の目安(約11200字

はじめに

最近はWebアプリは専らQuarkusで書いているのですがGCPのCloud Runと組み合わせたときに少し問題があります。というのもQuarkusは結構起動が早いのでローカルだと1秒程度で起動するのですが、Cloud Runに乗せると10秒近く掛かかることもあります。これでは初回アクセスのUXも悪いですし、APIだとタイムアウトの可能性も出てきます。

であればせっかくQuarkusを使っているのだから、その謳い文句であるGraalVMのnative-imageを使ったSupersonicを実現すれば良いのですが、実は公式のモジュールはDataStoreやGCS、PubSubなどGCPのライブラリに非対応のためコンパイルエラーになります。

ただ、まだ本家には取り込まれていませんがGCPのGithubで開発されているgoogle-cloud-graalvm-supportを使うことで解決できるので利用方法を紹介します。

TL;DR

  • DataStoreとかGCSをGraalVMのnative-imageでビルドするとビルドエラーになる
  • [google-cloud-graalvm-support]を使えば大丈夫
  • サンプルプロジェクトはこちら。

https://github.com/koduki/example-quarkus-gcp-with-graal

とりあえずプロジェクトを作る

まずはMavenコマンドで適当なQuarkusプロジェクトを作ります。

$ mvn io.quarkus:quarkus-maven-plugin:1.13.2.Final:create \

-DprojectGroupId=dev.nklav.examples \
    -DprojectArtifactId=example-gcp-graal2 

つづいて、GCPのライブラリを使うようにコードを修正します。とりあえず今回はDataStoreに読み書きするシンプルなプログラム。

@Path("/hello")
public class HelloResource {

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    @Path("say")
    public String say() {
        var datastore = DatastoreOptions.getDefaultInstance().getService();
        var key = datastore.newKeyFactory().setKind("Sandbox").newKey();
        var task = Entity.newBuilder(key).set("title", "test - " + new Date()).build();
        var entity = datastore.put(task);

        return String.format("Project: %s, key: %d", datastore.getOptions().getProjectId(), entity.getKey().getId());
    }

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    @Path("listen")
    public String listen() {
        var datastore = DatastoreOptions.getDefaultInstance().getService();
        var gql = "SELECT * FROM Sandbox";
        var query = Query.newGqlQueryBuilder(Query.ResultType.ENTITY, gql).setAllowLiteral(true)
                .build();

        var result = new ArrayList<Map<String, Object>>();
        var rs = datastore.run(query);
        var title = "";
        var cnt = 0;
        while (rs.hasNext()) {
            var record = rs.next();
            cnt += 1;
            title = record.getString("title");
        }

        return String.format("title: %s, count: %d", title, cnt);
    }
}

続いてpom.xmlに依存ライブラリを追加します。差分はこんな感じ。

     <groupId>dev.nklav.examples</groupId>
     <artifactId>example-gcp-graal</artifactId>
@@ -17,6 +17,7 @@
         <quarkus.platform.group-id>io.quarkus</quarkus.platform.group-id>
         <quarkus.platform.version>1.13.2.Final</quarkus.platform.version>
         <surefire-plugin.version>3.0.0-M5</surefire-plugin.version>
+        <libraries.bom.version>20.1.0</libraries.bom.version>
     </properties>
     <dependencyManagement>
         <dependencies>
@@ -27,6 +28,13 @@
                 <type>pom</type>
                 <scope>import</scope>
             </dependency>
+            <dependency>
+                <groupId>com.google.cloud</groupId>
+                <artifactId>libraries-bom</artifactId>
+                <version>${libraries.bom.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
         </dependencies>
     </dependencyManagement>
     <dependencies>
@@ -38,6 +46,10 @@
             <groupId>io.quarkus</groupId>
             <artifactId>quarkus-resteasy</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.google.cloud</groupId>
+            <artifactId>google-cloud-datastore</artifactId>
+        </dependency>
         <dependency>
             <groupId>io.quarkus</groupId>
             <artifactId>quarkus-junit5</artifactId>

こちらをJVMで普通に実行してみます。GOOGLE_APPLICATION_CREDENTIALSにDataStoreにアクセス権のあるサービスアカウントを指定してください。

$ GOOGLE_APPLICATION_CREDENTIALS=~/.seacret/key.json mvn quarkus:dev
Listening for transport dt_socket at address: 5005
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2021-04-28 00:21:16,301 INFO  [io.quarkus] (Quarkus Main Thread) example-gcp-graal 1.0.0-SNAPSHOT on JVM (powered by Quarkus 1.13.2.Final) started in 1.071s. Listening on: http://localhost:8080
2021-04-28 00:21:16,303 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2021-04-28 00:21:16,303 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, resteasy]

以下のcurlコマンドで動作確認ができます。

$ curl http://localhost:8080/hello/say
Project: xxx, key: 5679858948505600                                             
$ curl http://localhost:8080/hello/listen
title: test - Wed Apr 28 06:31:53 GMT 2021, count: 10 

Java版としてはこれで問題なくビルド&実行ができている事が分かります。

いざ、GraalVMでnative-imageへ

それではとりあえずネイティブへの変換を試みてみます。
下記のコマンド

$ mvn clean package -P native

または

$ ./mvnw package -Pnative -Dquarkus.native.container-build=true

で、本来はビルドできますが、以下のエラーになります。

[example-gcp-graal2-1.0.0-SNAPSHOT-runner:25]   (features):     649.93 ms,  2.01 GB
[example-gcp-graal2-1.0.0-SNAPSHOT-runner:25]     analysis:  35,566.78 ms,  2.01 GB
Error: com.oracle.graal.pointsto.constraints.UnresolvedElementException: Discovered unresolved type during parsing: com.google.appengine.api.urlfetch.HTTPMethod. To diagnose the issue you can use the --allow-incomplete-classpath option. The missing type is then reported at run time when it is accessed the first time.
Detailed message:
Trace:
        at parsing com.google.api.client.extensions.appengine.http.UrlFetchTransport.buildRequest(UrlFetchTransport.java:116)
Call path from entry point to com.google.api.client.extensions.appengine.http.UrlFetchTransport.buildRequest(String, String):
        at com.google.api.client.extensions.appengine.http.UrlFetchTransport.buildRequest(UrlFetchTransport.java:113)
        at com.google.api.client.extensions.appengine.http.UrlFetchTransport.buildRequest(UrlFetchTransport.java:46)
        at com.google.api.client.http.HttpRequest.execute(HttpRequest.java:889)
...

native-imageでビルド出来ないライブラリが偶にありますね。HTTP周りの独自実装が悪さをしてるみたいでgRPC系も同じ理由で死にます。このあたりをnative-imageに影響されないライブラリに差し替える等をしないといけないのですが、そのあたりをgoogle-cloud-graalvm-supportがやってくれます。

google-cloud-graalvm-supportを追加する

ではgoogle-cloud-graalvm-supportを追加します。といっても、基本的には以下の公式サイトを参考にすれば大丈夫ですけれど。

https://github.com/GoogleCloudPlatform/google-cloud-graalvm-support

pom.xmlを以下のように修正します。

@@ -18,6 +18,7 @@
         <quarkus.platform.version>1.13.2.Final</quarkus.platform.version>
         <surefire-plugin.version>3.0.0-M5</surefire-plugin.version>
         <libraries.bom.version>20.1.0</libraries.bom.version>
+        <gcp-graal.version>0.4.0</gcp-graal.version>
     </properties>
     <dependencyManagement>
         <dependencies>
@@ -46,6 +47,15 @@
             <groupId>io.quarkus</groupId>
             <artifactId>quarkus-resteasy</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.google.cloud</groupId>
+            <artifactId>google-cloud-graalvm-support</artifactId>
+            <version>${gcp-graal.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.quarkus</groupId>
+            <artifactId>quarkus-grpc</artifactId>
+        </dependency>
         <dependency>
             <groupId>com.google.cloud</groupId>
             <artifactId>google-cloud-datastore</artifactId>

google-cloud-graalvm-supportだけではなくquarkus-grpcも無いとビルドエラーになるので注意してください。
再びビルドします。

$ mvn clean package -P native
...
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  02:28 min
[INFO] Finished at: 2021-04-28T00:39:04-07:00
[INFO] ------------------------------------------------------------------------

無事コンパイルが完了しました。それでは動作確認をしましょう。
アプリケーションは以下のように起動します。サービスアカウントはDataStoreにアクセスできる適当なものを作って指定します。

GOOGLE_APPLICATION_CREDENTIALS=~/.seacret/key.json ./target/example-gcp-graal2-1.0.0-SNAPSHOT-runner

それでは先ほどのJVM版と同様に実行してみましょう。

$ curl http://localhost:8080/hello/say
Project: xxx, key: 5760334488928256                                             
$ curl http://localhost:8080/hello/listen
title: test - Wed Apr 28 00:49:50 PDT 2021, count: 11

無事、GraalVMで生成したネイティブイメージでDataStoreに書き込めたのが確認できました。

Cloud Runにデプロイする

さて、それではCloud Runにデプロイしていきたいと思います。ローカルでビルドしてデプロイする事も出来ますが、今回はCloud Buildを使ってデプロイをしたいと思います。

Cloud Build向けのGraalVM-bulderを作る

まずCloud Build向けにGraalVM-bulderを作ります。というのもCloud Build自体がビルド時にコンテナを利用しているため-Dquarkus.native.container-build=trueを使うとnested containerになってしまいビルドエラーになります。
そのためGraalVMがインストールされたビルダーを使う必要があるのですが、標準では存在しません。なので、以下を参考にbuilderを作成します。

https://cloud.google.com/build/docs/create-custom-build-steps?hl=ja

とりあえず同フォルダに以下のようにディレクトリとファイルを作成します。

builder
├── Dockerfile
└── cloudbuild.yaml

builder用のDockerfileは以下のようになります。GraalVMのイメージをベースにnative-imageとMavenのインストールをしています。

FROM ghcr.io/graalvm/graalvm-ce

RUN gu install native-image

RUN curl https://downloads.apache.org/maven/maven-3/3.8.1/binaries/apache-maven-3.8.1-bin.tar.gz | tar zx -C /opt && \
ln -s /opt/apache-maven-3.8.1/bin/mvn /usr/bin/mvn

cloudbuild.yamlは単純にPushするだけです。Builderは単なるコンテナなので作るのが簡単で良いですね。

steps:
- name: 'gcr.io/cloud-builders/docker'
  args: ['build', '-t', 'gcr.io/$PROJECT_ID/graalvm-builder', './']
- name: 'gcr.io/cloud-builders/docker'
  args: ['push', 'gcr.io/$PROJECT_ID/graalvm-builder']
images:
- gcr.io/$PROJECT_ID/graalvm-builder

Cloud RunにCloud Buildからデプロイする

最後にCloud BuildからCloud Runにデプロイします。Cloud Runにデプロイするためのcloudbuild.yamlは以下のようになります。

steps:
- name: 'gcr.io/$PROJECT_ID/graalvm-builder'
  id: Build:Java
  args: ['mvn', 'package', '-Pnative']
- name: 'gcr.io/cloud-builders/docker'
  id: Build:Image
  args: ['build', '-t', 'gcr.io/$PROJECT_ID/example-gcp-graal', '-f', 'src/main/docker/Dockerfile.native-distroless', '.']
- name: 'gcr.io/cloud-builders/docker'
  id: Ship:Push
  args: ['push', 'gcr.io/$PROJECT_ID/example-gcp-graal']
- name: 'gcr.io/cloud-builders/gcloud'
  id: Ship:Deploy
  args: ['run', 'deploy', 'example-gcp-graal', 
  '--image', 'gcr.io/$PROJECT_ID/example-gcp-graal', 
  '--region', 'us-central1', 
  '--platform', 'managed', 
  '--allow-unauthenticated',
  '--service-account', '${_SRV_ACC}'
  ]
images:
- gcr.io/$PROJECT_ID/example-gcp-graal
options:
 machineType: 'E2_HIGHCPU_8'

ポイントは3つ。まずは先ほど作ったgcr.io/$PROJECT_ID/graalvm-builderを使うこと。これに関しては特に説明は不要ですね。次に_SRV_ACCでサービスアカウントを指定すること。DataStoreにアクセスできる権限を使わないと権限エラーになるので適切なサービスアカウントを指定してください。最後にmachineTypeの指定です。というのもデフォルトではn1-standard-1なのでメモリが3.5GBしかありません。native-imageへのビルドはかなりリソースを使うのでデフォルトのままではタイムアウトの10分を超えてしまいます。そのため、E2_HIGHCPU_8を使うことでタイムアウトより前に終わらせる事が出来ます。

実際のビルドは以下の通り。$SRV_ACCに自分のサービスアカウントを指定してください。

$ gcloud builds submit --substitutions=_SRV_ACC=$SRV_ACC

以下のコマンドで動作を確認できます。

$ curl https://example-gcp-graal-xxx-uc.a.run.app/hello/say
Project: xxx, key: 5746523350499328%

まとめ

Quarkusやあるいはmicronautはnative-imageに出来るのでJavaでCloud Runにするなら最適なのですがGCPのライブラリが使えなくてちょっと困っていました。
今回、このライブラリで出来る用になったので早く公式にも取り込まれると良いですね。Javaが捗って良き。。。
まあ、コンパイル時間が長いからJava使わずに書くのももちろん手なのですが。。。

それではHappy Hacking!