Quarkus + GraalVM (native-image)でGCP系ライブラリを利用する
はじめに
最近は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
]を使えば大丈夫 - サンプルプロジェクトはこちら。
とりあえずプロジェクトを作る
まずは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
を追加します。といっても、基本的には以下の公式サイトを参考にすれば大丈夫ですけれど。
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を作成します。
とりあえず同フォルダに以下のようにディレクトリとファイルを作成します。
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!
Discussion