GraalVM の概要
GraalVM をご存知でしょうか。
Java を書いたことのある人なら JVM(Java Virtual Machine)という単語をなんとなく知っていたりいなかったりするかと思いますが、その JVM に代わるものとして GraalVM が存在します。
JVM は Java コンパイラでコンパイルされた Java バイトコードを実行してくれるわけですが、 Java バイトコードを実行すると一口に言っても内部的には大きく以下の2パターンで実行されることになります。
- Java インタプリタが逐次実行
- JIT(Just In Time)コンパイラが再度コンパイルして実行
さらに、 JIT コンパイラと一口に言っても内部的には大きく2つのコンパイラが存在します。
- クライアントコンパイラ(C1)
- サーバーコンパイラ(C2)
これらのインタプリタとコンパイラを組み合わせて JVM はいい感じに動いてくれているわけですが、この中の C2 コンパイラの代替として Graal と呼ばれる新しい JIT コンパイラが爆誕し、それを利用した VM が GraalVM となっています。
なお、この Graal というコンパイラは Java で書かれているため、自分自身をコンパイルすることでより高速にコンパイルされたバージョンを作ることができるというチート性能を持ち合わせています。
JVM と比較して GraalVM には以下のような特徴があります。
- AOT コンパイラにより Native イメージを生成できる
- JVM 言語以外の言語でも実行できる
一つ目は、要はコードを丸ごと Native Image(≒機械語)に変換するため、起動も早いしメモリ効率も良い、というメリットを享受することができます。
二つ目は例えば GraalVM 上で JavaScript という Java と似たような言語 や Python, Ruby といった言語を実行することができ、Java 以外の言語で記述されたメソッドなどを利用できます。
フットプリントの軽量さはともかく起動が早いこととかましてや他言語のコードを動かせることって何の役に立つの?おいしいの?という指摘もありますが、この記事では起動の速さが最大限に活かせそうな Cloud Run にデプロイして動かすところまでを検証することにします。
GraalVM をより詳しく知りたい方は以下のリンクを参照ください。
Cloud Run について
Cloud Run は GCP イチオシのコンピューティングリソースで、オートスケールするため最小インスタンス数を 0 にしておくことでリクエストが来るまではインスタンスが立ち上がらずインフラコストを安く抑えることができます。
裏を返せば最小インスタンス数を 0 にしているとリクエストが来るまではインスタンスが立ち上がらないので、こんな時に起動時間の速さが活きることになります。
インスタンスは常時立ち上がっていないもののリクエストが来たら高速に立ち上げ必要があるとかいうユースケースがあるのかと言われると厳しいですがとにかく Cloud Run に API を立ててみます。
ともかくクラウドネイティブな時代においてコンテナが軽量でありかつ高速に立ち上がることはスケーラビリティの観点からも優位性があるので、今後 GraalVM が台頭していくことを期待したい次第です。
Quarkus について
Java で API を作るときの Web Framework のデファクトスタンダードは Spring Framework ですが、 GraalVM で Native Image を作る際にはすべて機械語に変換する都合上、 Spring 特有の Dependency Injection などの動的な機能を完璧にサポートすることが難しく、2021年12月時点でも Spring の Native 化は experimental な状態となっています。
そこで、GraalVM のために作られたといっても過言ではない Quarkus をフレームワークとして利用し API を作成します。
以下では記事のタイトルの通り GraalVM と Quarkus を使って Cloud Run で Native Image の API を動作させるところまでを目標にします。
動作環境は macOS です。
GraalVM をインストールする
基本的に GraalCM の Quick start の通りに進めればインストールできます。
が、備忘録的に実際の手順を並べておきます。まず、以下から GraalVM をダウンロードします。
2021年12月現在の最新バージョンは 21.3.0 ですが、以下では 21.2.0 で検証しています。
あとは解凍して VM として認識できるようにディレクトリを移動させます。
$ cd path/to/download/file
$ tar -xvf graalvm-ce-java11-darwin-amd64-21.2.0.tar.gz
$ cd graalvm-ce-java11-21.2.0
$ sudo mv graalvm-ce-java11-21.2.0 /Library/Java/JavaVirtualMachines
ここで .bashrc
等に graalvm へ以下のようなパスを通すとデフォルトの実行環境が GraalVM になってしまうので、jenv で環境を切り替えることをお勧めします。
# not recommended
export JAVA_HOME=/Library/Java/JavaVirtualMachines/graalvm-ce-java11-21.2.0/Contents/Home
$ jenv versions
* system (set by /Users/.../.jenv/version)
11
11.0
11.0.12
11.0.7
16
16.0
16.0.1
graalvm64-11.0.12
openjdk64-11.0.7
openjdk64-16.0.1
サンプルプログラムの作成
とりあえず以下のコマンドでプロジェクトを作成します。
$ mvn io.quarkus.platform:quarkus-maven-plugin:2.5.1.Final:create \
-DprojectGroupId=com.sample \
-DprojectArtifactId=sample-api \
-DclassName="com.sample.api.Controller" \
-Dpath="/" \
-Dextensions="resteasy-jackson"
適当に GET と POST を処理するメソッドを作成します。
package com.sample.api;
import com.sample.api.bean.Request;
import java.io.Serializable;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
@Path("/")
public class Controller implements Serializable {
@GET
@Path("/test")
public String get(@QueryParam("message") String message) {
return String.format("hello, %s", message);
}
@POST
@Consumes("application/json")
@Path("/test")
public String post(Request request) {
var message = request.getMessage();
var doubledNum = request.getNum() * 2;
return String.format("hello, %s. doubledNum: %s", message, doubledNum);
}
}
POST リクエストは自作のクラスで受けるため、 lombok を pom.xml に追加して以下のようなクラスを作成します。
package com.sample.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Request implements Serializable {
@JsonProperty("message")
private String message;
@JsonProperty("num")
private Integer num;
}
API たるもの、ヘルスチェックのエンドポイントと Swagger が無ければいけないので生やしてあげます。
lombok 含め、以下のような dependency を pom.xml へ追加します。
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
これで準備完了です。依存関係を追加するだけでエンドポイントを生やしてくれるので楽ですね。
ヘルスチェックエンドポイントと Swagger に関しては以下のドキュメントに記載があります。
ローカルでの実行(devモード)
dev モードでは起動時にユニットテストを走らせることもできるため、上記で作成したAPI に合わせてユニットテストも修正しておきましょう。
ヘルスチェックもできるようになったことなので、以下のようなユニットテストを雑に作成します。
package com.sample.api;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@QuarkusTest
public class ControllerTest {
@Test
@DisplayName("test エンドポイントがGETリクエストに対して意図したレスポンスを返す")
public void test1() {
given().when().get("/test?message=world").then().statusCode(200).body(is("hello, world"));
}
@Test
@DisplayName("test エンドポイントがPOSTリクエストに対して意図したレスポンスを返す")
public void test2() {
given()
.header("Content-type", "application/json")
.and()
.body(new Request("world", 10))
.when()
.post("/test")
.then()
.statusCode(200)
.body(is("hello, world. doubledNum: 20"));
}
@Test
@DisplayName("ヘルスチェックエンドポイントがHTTPステータス200で status=UP を返す")
public void test3() {
given().when().get("/q/health").then().statusCode(200).body("status", equalTo("UP"));
}
@Test
@DisplayName("livenessProbeエンドポイントがHTTPステータス200で status=UP を返す")
public void test4() {
given().when().get("/q/health/live").then().statusCode(200).body("status", equalTo("UP"));
}
@Test
@DisplayName("readinessProbeエンドポイントがHTTPステータス200で status=UP を返す")
public void test5() {
given().when().get("/q/health/ready").then().statusCode(200).body("status", equalTo("UP"));
}
}
dev モードは以下のコマンドで実行できます。 dev モードでの起動中にコードを変更して保存すると、その変更内容が即座にアプリケーションに反映されるため動作確認をしながらの開発が捗ります。
# dev モードで起動
$ ./mvnw compile quarkus:dev
...(中略)
Listening for transport dt_socket at address: 5005
__ ____ __ _____ ___ __ ____ ______
--/ __ \/ / / / _ | / _ \/ //_/ / / / __/
-/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2021-12-09 19:41:19,810 INFO [io.quarkus] (Quarkus Main Thread) sample-api 1.0.0-SNAPSHOT on JVM (powered by Quarkus 2.5.1.Final) started in 2.071s. Listening on: http://localhost:8080
2021-12-09 19:41:19,815 INFO [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2021-12-09 19:41:19,815 INFO [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, resteasy, resteasy-jackson, smallrye-context-propagation, smallrye-health, smallrye-openapi, swagger-ui, vertx]
--
Tests paused
Press [r] to resume testing, [o] Toggle test output, [h] for more options>
動作確認
ユニットテストと同じ内容でリクエストを投げてみて、期待した通りのレスポンスが返ってくることを確認できます。
# GET リクエスト
$ curl "http://localhost:8080/test?message=world"
hello, world%
# POST リクエスト
$ curl -H "Content-Type: application/json" "http://localhost:8080/test" -d '
{
"message": "world",
"num": "10"
}'
hello, world. doubledNum: 20%
Swagger-UI は以下の URL からアクセスできます。便利ですね。
http://localhost:8080/q/swagger-ui/
ローカルでの実行(Native モード)
Native イメージに変換して実行します。ここでは mvnw を使って jar ファイルを生成していますが、 Docker イメージにすることもできます。
ただ Docker イメージにした場合、**イメージをビルドした OS と同じ OS でなければ実行できない(同じ CPU アーキテクチャでないと実行できない)**ため、今回の実行環境(macOS)だとうまく実行できない点に注意が必要です。
また今回のようなかなりシンプルなアプリケーションでさえ、イメージのビルドにかなり時間を要する(1~2分前後)点にも要注意です。
# イメージのビルド
$ ./mvnw package -Pnative
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------< org.acme:sample-api >---------------------
[INFO] Building sample-api 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
...
# native image の起動
$ ./target/sample-api-1.0.0-SNAPSHOT-runner
__ ____ __ _____ ___ __ ____ ______
--/ __ \/ / / / _ | / _ \/ //_/ / / / __/
-/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2021-12-09 19:51:26,771 INFO [io.quarkus] (main) sample-api 1.0.0-SNAPSHOT native (powered by Quarkus 2.5.1.Final) started in 0.021s. Listening on: http://0.0.0.0:8080
2021-12-09 19:51:31,774 INFO [io.quarkus] (main) Profile prod activated.
2021-12-09 19:51:31,775 INFO [io.quarkus] (main) Installed features: [cdi, resteasy, resteasy-jackson, smallrye-context-propagation, smallrye-health, smallrye-openapi, vertx]
実際に立ち上げてみると分かりますが、尋常じゃない速さで立ち上がります。既にビルド済みのため特にメッセージ等が出力されることなく即座に立ち上がっていることが確認できます。
イメージをビルドして Cloud Run にデプロイする
ここからは以下の流れで Cloud Run にデプロイします。
- GraalVM をビルドするためのイメージを Cloud Build でビルド
- 1 でビルドしたイメージを使ってアプリケーションをビルド & Cloud Run へデプロイ
この手順については以下の記事を大いに参考にしています。 GCP 系ライブラリを API で使う場合の注意事項についても記載されているため、一読することをオススメします。
1. ビルド用の Dockerfile と cloudbuild.yaml を作成しビルド
以下の2つのファイルは builder
というディレクトリに配置しています。
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
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/vm-builder', './builder']
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'gcr.io/$PROJECT_ID/vm-builder']
images:
- gcr.io/$PROJECT_ID/vm-builder
Cloud Build でビルドします。ここは別に Cloud Build である必要は全くないので、ローカルで Docker イメージをビルドする形でも全く問題ありません。
$ gcloud config set project <your-project>
$ gcloud builds submit --config ./builder/cloudbuild.yaml
2. step1 でビルドしたイメージを使ってアプリケーションをビルド & デプロイ
イメージのビルドと Cloud Run へのデプロイを一緒に実行します。
steps:
- name: '$_BUILDER_DOCKER_IMAGE_URI'
id: 'Build:Java app native image'
args: ['mvn', 'package', '-Pnative']
- name: 'gcr.io/cloud-builders/docker'
id: 'Build:Image'
args: ['build',
'-t', '$_DOCKER_URI:latest',
'-f', 'src/main/docker/Dockerfile.native-distroless', '.'
]
- name: 'gcr.io/cloud-builders/docker'
id: 'Push:Image to GCR'
args: ['push', '$_DOCKER_URI']
- name: 'gcr.io/cloud-builders/gcloud'
id: 'Deploy'
args: ['run', 'deploy', 'sample-api',
'--image', '$_DOCKER_URI',
'--region', 'asia-northeast1',
'--platform', 'managed',
'--min-instances', '0',
'--cpu', '1',
'--memory', '1Gi',
'--no-allow-unauthenticated'
]
options:
machineType: 'E2_HIGHCPU_8'
images:
- '$_DOCKER_URI:latest'
$ gcloud builds submit \
--config ./builder/cloudbuild.yaml \
--substitutions=_BUILDER_DOCKER_IMAGE_URI="gcr.io/${PROJECT_ID}/vm-builder",_DOCKER_URI="gcr.io/${PROJECT_ID}/sample-api"
動作確認
あとはローカルと同様に叩くだけですが、Cloud Run のエンドポイントを叩くためには run.routes.invoke
という permission が必要になります。role でいうと roles/run.invoker
です。
この権限を自分自身に付与してある場合は以下で認証すれば良く、
$ gcloud auth application-default login
サービスアカウントの key ファイルを使用する場合は以下で認証します。
$ gcloud auth activate-service-account <your_service_account> --key-file=/Users/<user_name>/.config/gcloud/application_default_credentials.json
curl する際には認証情報をヘッダに含めてリクエストした結果が以下となります。
GET リクエスト
$ curl -s -H 'Content-Type: application/json' -H "Authorization: Bearer $(gcloud auth print-identity-token)" "https://<cloud_run_url>.a.run.app/test?message=world"
hello, world
POST リクエスト
$ curl -s -H 'Content-Type: application/json' -H "Authorization: Bearer $(gcloud auth print-identity-token)" "https://<cloud_run_url>.a.run.app/test" -d '{"message":"world", "num": "10"}'
hello, world. doubledNum: 20
ヘルスチェック
$ curl -s -H 'Content-Type: application/json' -H "Authorization: Bearer $(gcloud auth print-identity-token)" "https://<cloud_run_url>.a.run.app/q/health"
{
"status": "UP",
"checks": [
]
}
まとめ
見返してみると全然 GraalVM + Quarkus の良さを説明できていませんでしたが、なんとなく動くものを作ることができるという雰囲気は伝わったのではないかと思います。
最近は個人的に開発するときは積極的に GraalVM を使って API を立てているので、GraalVM の今後の開発動向についても追っていきたいと思っています。