はじめに
先日のGoogle Cloud Next 19でCloud Runが公開されましたね。
これはHTTPで起動するDockerコンテナをk8s上でオートスケールさせるKnativeをGoogleがGCP上でフルマネージドで提供するものです。
AWSのFargateやHeroku, あるいは僕たちが本当に欲しかったGAE Flexible Environmentと言ったところでしょうか。
今回は、こちらにGraalでJavaをバイナリにコンパイルできる超音速/超軽量を謡うJavaEEコンテナのQuarkusを使うことで高速なServerless JavaEEアプリを作ってみます。
もう**「Javaだからスピンアップが遅い」**とか言わせない!
TL;DR
- こちらのできたものをclone
-
Dockerfile.gcp
をビルドしてCloud Runにデプロイ - Quarkus速い! Cloud Run簡単!
Quarkusでサンプルアプリケーションを開発
What is Quarkus?
QuarkusはRedhatによって作られた次世代JavaEEコンテナで「Supersonic Subatomic Java」とまで言うだけあって10msと言う異次元の速度で起動すると言う他者の追従を許さない特徴を持っています。
主な特徴は以下の通り。
- MicroProfileをサポートしているためJAX-RSやCDI/JPAと言った基本的なJavaEEの機能が利用できる
- GraalVMのnative-imageを使ってJVMではなくLinuxのバイナリとして実行しているためGo言語とかと同等の起動速度
- One configuration, Hot Reload, OpenAPI, Docker/Maven/Gradleのサポートなど単純にJavaEE環境としても開発もしやすい
詳しくは長くなったので別記事にまとめました。興味がある人はこちらも読んでみてください。
「ブログなんだよもん: Serverless時代のJavaEEコンテナ - Quarkus」
サンプルアプリの仕様
では、サンプルアプリケーションを作って行きます。なんでも良いのですが「銀行API」を今回は作ってみることにします。
機能としては以下とします。
- 口座を作成できる
- 口座を一覧を確認できる
- 口座に入金できる
- 口座から出金できる
なお、簡略化のためにユーザ管理はしないので誰でも入出金できますw
また口座情報はデータベースに格納することにします。
プロジェクトの作成
プロジェクトはmavenまたはgradleで作成できます。今回はmavenを使用します。
% mvn io.quarkus:quarkus-maven-plugin:create
...
Set the project groupId [org.acme.quarkus.sample]: cn.orz.pascal.mybank
Set the project artifactId [my-quarkus-project]: my-bank
Set the project version [1.0-SNAPSHOT]:
Do you want to create a REST resource? (y/n) [no]: y
Set the resource classname [cn.orz.pascal.mybank.HelloResource]:
Set the resource path [/hello]:
...
[INFO] Finished at: 2019-04-14T17:51:48-07:00
[INFO] ------------------------------------------------------------------------
サンプルのエンドポイントとして/hello
を追加しました。JAX-RSのコードは以下のようになっています。
@Path("/hello")
public class HelloResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
return "hello";
}
}
なんの変哲も無いJAX-RSのコードですね。では実行してみましょう。開発モードで起動します。
$ mvn compile quarkus:dev
...
2019-04-14 17:52:13,685 INFO [io.quarkus] (main) Quarkus 0.13.1 started in 2.042s. Listening on: http://[::]:8080
2019-04-14 17:52:13,686 INFO [io.quarkus] (main) Installed features: [cdi, resteasy]
curlでアクセスしてみます。
$ curl http://localhost:8080/hello
hello
無事、アクセスが確認できましたね。
DB環境の準備
続いて口座を表すAccountテーブルの作成をします。
まずはDBを準備する必要があるのでDockerでpostgresを起動します。
$ docker run -it -p 5432:5432 -e POSTGRES_PASSWORD=mysecretpassword postgres
...
2019-04-15 01:29:51.370 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432
2019-04-15 01:29:51.370 UTC [1] LOG: listening on IPv6 address "::", port 5432
2019-04-15 01:29:51.374 UTC [1] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2019-04-15 01:29:51.394 UTC [50] LOG: database system was shut down at 2019-04-15 01:29:51 UTC
2019-04-15 01:29:51.404 UTC [1] LOG: database system is ready to accept connections
JPAの設定
続いてJPAの設定をします。まずは依存の追加。
# Extention名の確認
$ mvn quarkus:list-extensions|grep jdbc
[INFO] * JDBC Driver - H2 (io.quarkus:quarkus-jdbc-h2)
[INFO] * JDBC Driver - MariaDB (io.quarkus:quarkus-jdbc-mariadb)
[INFO] * JDBC Driver - PostgreSQL (io.quarkus:quarkus-jdbc-postgresql)
$ mvn quarkus:list-extensions|grep hibernate
[INFO] * Hibernate ORM (io.quarkus:quarkus-hibernate-orm)
[INFO] * Hibernate ORM with Panache (io.quarkus:quarkus-hibernate-orm-panache)
[INFO] * Hibernate Validator (io.quarkus:quarkus-hibernate-validator)
# Extentionの追加
$ mvn quarkus:add-extension -Dextensions="io.quarkus:quarkus-jdbc-postgresql"
$ mvn quarkus:add-extension -Dextensions="io.quarkus:quarkus-hibernate-orm"
次はsrc/main/resources/application.properties
にDB設定を記載します。基本的にはQuarkusではpersitance.xmlやlog4j.xmlなど別の設定ファイルを定義せずに全てこのapplication.properties
に記載することが推奨されています。
また、このファイルはmicroprofile-configが使われて居るので環境変数や引数で上書き可能です。
つまり、開発環境とSTGやProdとの差分はk8sのyaml側で定義することができるので、環境ごとにProfileで設定を切り替える、などのビルド手法が不要になり運用がシンプルにできます。
これは起動時/デプロイ時に環境変数を指定するDokcerやCloud RunあるいはHerokuのようなTwelve-Factorを満たした環境に非常にマッチしています。
application.properties
に追加する設定内容はこちら。見ての通りのDB設定です。
# datasource account
quarkus.datasource.url: jdbc:postgresql://localhost:5432/postgres
quarkus.datasource.driver: org.postgresql.Driver
quarkus.datasource.username: postgres
quarkus.datasource.password: mysecretpassword
# database optional
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql: true
口座作成用のEntity/Service/Resourceの追加
続いて口座に相当するAccount
のEntityとServiceを作ります。
まずはEntity。当然ですが普通にJPAのEntityです。個人的な趣味でPKはUUIDを使っていますが別にシーケンスを使うことも問題なく可能です。
@Entity
public class Account implements Serializable {
private UUID id;
private Long amount;
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public Long getAmount() {
return amount;
}
public void setAmount(Long amount) {
this.amount = amount;
}
}
続いてEntityを操作するServiceです。
@ApplicationScoped
public class AccountService {
@Inject
EntityManager em;
@Transactional
public void create(long amount) {
Account account = new Account();
account.setAmount(amount);
em.persist(account);
}
}
最後にJAX-RSを記述するResourceです。
@Path("/account")
public class AccountResource {
@Inject
AccountService accountService;
@POST
@Produces(MediaType.APPLICATION_JSON)
@Path("/create")
public void create() {
accountService.create(0);
}
}
では/account/create
を叩いてみましょう。
$ curl -i -X POST -H "Content-Type: application/json" http://localhost:8080/account/create
HTTP/1.1 204 No Content
Date: Mon, 15 Apr 2019 02:56:30 GMT
正常終了しました。
また、サーバの標準出力に下記の通り実行したSQLが出ているかと思います。
quarkus.hibernate-orm.log.sql
をtrue
にしたのでSQLログが確認できます。本番では忘れずfalse
にしましょう。
Hibernate:
insert
into
Account
(amount, id)
values
(?, ?)
残りの口座作成用のAPIの追加
ではサクサクと残りのAPIを追加して行きましょう。AccountService.java
に一覧と入出金の機能をつけます。
@ApplicationScoped
public class AccountService {
@Inject
EntityManager em;
@Transactional
public void create(long amount) {
Account account = new Account();
account.setAmount(amount);
em.persist(account);
}
@Transactional
public List<Account> findAll() {
return em.createQuery("SELECT a FROM Account a", Account.class)
.setMaxResults(3)
.getResultList();
}
@Transactional
public Account deposit(UUID id, long amount) {
em.createQuery("UPDATE Account SET amount = amount + :amount WHERE id=:id")
.setParameter("id", id)
.setParameter("amount", amount)
.executeUpdate();
return em.createQuery("SELECT a FROM Account a WHERE id=:id", Account.class)
.setParameter("id", id)
.getSingleResult();
}
@Transactional
public Account withdraw(UUID id, long amount) {
em.createQuery("UPDATE Account SET amount = amount - :amount WHERE id=:id")
.setParameter("id", id)
.setParameter("amount", amount)
.executeUpdate();
return em.createQuery("SELECT a FROM Account a WHERE id=:id", Account.class)
.setParameter("id", id)
.getSingleResult();
}
}
続いてAccountResource.java
を以下のように修正します。
@Path("/account")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AccountResource {
@Inject
AccountService accountService;
@POST
public void create() {
accountService.create(0);
}
@GET
public List<Account> list() {
return accountService.findAll();
}
@POST
@Path("/deposit/{id}/{amount}")
public Account deposit(@PathParam("id") UUID id, @PathParam("amount") long amount) {
System.out.println(id + ":" + amount);
return accountService.deposit(id, amount);
}
@POST
@Path("/withdraw/{id}/{amount}")
public Account withdraw(@PathParam("id") UUID id, @PathParam("amount") long amount) {
System.out.println(id + ":" + amount);
return accountService.withdraw(id, amount);
}
}
こちらを実行してみます。
$ curl -X POST -H "Content-Type: application/json" http://localhost:8080/account
$ curl -X GET -H "Content-Type: application/json" http://localhost:8080/account
[{"amount":0,"id":"0687662d-5ac7-4951-bb11-c9ced6558a40"}]
$ curl -X POST -H "Content-Type: application/json" http://localhost:8080/account/deposit/0687662d-5ac7-4951-bb11-c9ced6558a40/100
{"amount":100,"id":"0687662d-5ac7-4951-bb11-c9ced6558a40"}
$ curl -X POST -H "Content-Type: application/json" http://localhost:8080/account/deposit/0687662d-5ac7-4951-bb11-c9ced6558a40/100
{"amount":200,"id":"0687662d-5ac7-4951-bb11-c9ced6558a40"}
$ curl -X POST -H "Content-Type: application/json" http://localhost:8080/account/deposit/0687662d-5ac7-4951-bb11-c9ced6558a40/100
{"amount":300,"id":"0687662d-5ac7-4951-bb11-c9ced6558a40"}
$ curl -X POST -H "Content-Type: application/json" http://localhost:8080/account/withdraw/0687662d-5ac7-4951-bb11-c9ced6558a40/200
{"amount":100,"id":"0687662d-5ac7-4951-bb11-c9ced6558a40"}
無事、入出金の機能も実装できました。
ドキュメンテーション
さて、アプリ機能が出来たら次はドキュメンテーションですね。と言ってもQuarkusはOpenAPIとSwagger UIに対応してるのでサクりと出来ます。
まずはExtentionの追加。
$ mvn quarkus:list-extensions|grep openapi
[INFO] * SmallRye OpenAPI (io.quarkus:quarkus-smallrye-openapi)
$ mvn quarkus:add-extension -Dextensions="io.quarkus:quarkus-smallrye-openapi"
Extentionを追加したらquarkus:dev
を再実行します。そしてhttp://localhost:8080/openapi
にアクセスします。すると以下のようにJAX-RSから生成されたOpenAPIの定義ファイルが取得できます。
openapi: 3.0.1
info:
title: Generated API
version: "1.0"
paths:
/account:
get:
responses:
200:
description: OK
content:
application/json: {}
post:
...
また、同じくhttp://localhost:8080/swagger-ui/
にアクセスすれば以下のようなSwagger UIによるドキュメントにアクセスできます。
Cloud Runにデプロイする
Quarkusによる爆速JavaEEアプリが出来たので、次はServerless環境であるCloud Runにデプロイしてみましょう。
冒頭でも書きましたがCloud RunはKnativeをベースにしたGCPによるCaaS(Containers as a Service)環境です。
自前のGKE環境にデプロイする方法と手軽なGCPフルマネージド環境がありますが、今回は後者を使います。
Cloud SQLの作成
今回はPostgreSQLを利用してるのでGCPにもRDBが必要です。というわけでCloud SQLを使ってマネージドなDBを作成します。
$ gcloud sql instances create myinstance --region us-central1 --cpu=2 --memory=7680MiB --database-version=POSTGRES_9_6
$ gcloud sql users set-password postgres --instance=myinstance --prompt-for-password
これでmyinstance
という名前のでDBを作成することができました。gcloud sql instances list
で動作を確認することができます。
$ gcloud sql instances list
NAME DATABASE_VERSION LOCATION TIER PRIMARY_ADDRESS PRIVATE_ADDRESS STATUS
myinstance POSTGRES_9_6 us-central1-b db-custom-2-7680 xxx.xxx.xxx.xxx - RUNNABLE
Cloud Run向けのDockerイメージの作成
続いてCloud Run向けのDockerイメージの作成をします。
本来的にCloud Run
として特殊な設定はいりません。Quarkusプロジェクトに含まれるsrc/main/docker/Dockerfile.native|jvm
のいずれでビルドしたイメージもそのまま動きます。
ただし、GCPマネージドのCloud RunはVPCの中に入れる事ができません(Cloud Run on GKEは出来ます)。そのため、Cloud RunからCloud SQLへの接続はACLで直接接続するのでは無くCloud SQL Proxy
が基本となります。
エンドポイントスクリプトを作成
以下のようなProxyとQuarkusアプリの両方を動かすsrc/main/script/run.sh
スクリプトを作成します。
#!/bin/sh
# Start the proxy
/usr/local/bin/cloud_sql_proxy -instances=$CLOUDSQL_INSTANCE=tcp:5432 -credential_file=$CLOUDSQL_CREDENTIALS &
# wait for the proxy to spin up
sleep 10
# Start the server
./application -Dquarkus.http.host=0.0.0.0 -Dquarkus.http.port=$PORT
Proxyの起動には多少の時間が必要なので10秒ほどSleepを入れています。当たり前ですがスピンアップに最低10秒かかるのでここはなんとかしたいところ...
Dockerfileの作成とビルド
続いてDockerfileの作成をします。基本はDockerfile.nativeと同じですがrun.sh
やcloud_sql_proxy
を含んだsrc/main/docker/Dockerfile.gcp
を作成します。
FROM registry.fedoraproject.org/fedora-minimal
WORKDIR /work/
COPY target/*-runner /work/application
RUN chmod 775 /work
ADD https://dl.google.com/cloudsql/cloud_sql_proxy.linux.amd64 /usr/local/bin/cloud_sql_proxy
RUN chmod +x /usr/local/bin/cloud_sql_proxy
COPY src/main/script/run.sh /work/run.sh
RUN chmod +x /work/run.sh
EXPOSE $PORT
CMD ["./run.sh"]
続いて.dockerignore
にDockerfile.gcp
を追加します。Quarkusプロジェクトでは特定のファイル以外はビルド時に見えなくしてあるので、改修しないとdocker build
時に対象ファイルがないとしてエラーになります。
改修差分は以下の通り。
$ diff .dockerignore.bak .dockerignore
4a5
> !src/main/script/run.s
いよいよ準備ができたのでビルドを行います。
フルマネージド版のCloud Run
ではデプロイ対象はCloud Registryに配置してある必要があるようなので、タグをgcr.io/プロジェクト名/イメージ名
にしておきます。
$ export PRJ_NAME="ここにプロジェクト名"
$ ./mvnw clean package -Pnative -DskipTests=true -Dnative-image.container-runtime=docker
$ docker build -f src/main/docker/Dockerfile.gcp -t gcr.io/${PRJ_NAME}/mybank .
GraalVMのnative-imageのビルドは依存関係を確認してネイティブイメージに変換するためとても重いです。
ザクっと5分から10分かかるので懐かしのコーヒータイムを楽しんでください。
ローカルでの動作確認
ローカルでの動作確認には「IAMと管理」よりSQLクライアントにアクセスできるアカウントを作成しJSON形式の鍵を作成します。
こちらをcredentials.json
という名前で適当なローカルの場所に配置してボリュームでコンテナの見えるところに置いた上でファイルのパスを環境変数CLOUDSQL_CREDENTIALS
に指定してローカルでの動作確認が出来ます。
$ export SQL_CONNECTION_NAME=$(gcloud sql instances describe myinstance|grep connectionName|cut -d" " -f2)
$ docker run -it -p 8080:8080 -v `pwd`:/key/ \
-e CLOUDSQL_INSTANCE=${SQL_CONNECTION_NAME} \
-e CLOUDSQL_CREDENTIALS=/key/credentials.json \
-e QUARKUS_DATASOURCE_URL=jdbc:postgresql://localhost:5432/postgres \
-e QUARKUS_DATASOURCE_PASSWORD="ここにパスワード" \
gcr.io/${PRJ_NAME}/mybank
Cloud RunのサービスアカウントにSQLクライアント権限を追加
ローカルでの動作確認が出来たのでCloud Run
向けの設定をします。
鍵ファイルをコンテナに入れれば上記のままでも動きますがそれはセキュリティ的に微妙なので、Cloud RunのサービスアカウントGoogle Cloud Run Service Agent(xxxx@serverless-robot-prod.iam.gserviceaccount.com)
にSQLクライアント権限を追加します。
「IAMと管理」 -> 「IAM」で上記のCloud Run Service Agentを指定してCloud SQLクライアント
の役割を追加すればOKです。
これでCloud Runからは鍵ファイルなしで接続が可能になります。
Cloud Runへのデプロイ
それではいよいよCloud Runにデプロイを行います。
まずは先ほど作成したイメージをCloud RegistryにPushします。
docker push gcr.io/${PRJ_NAME}/mybank
続いてデプロイ。
$ export SQL_CONNECTION_NAME=$(gcloud sql instances describe myinstance|grep connectionName|cut -d" " -f2)
$ gcloud beta run deploy mybank \
--image gcr.io/${PRJ_NAME}/mybank \
--set-env-vars \
QUARKUS_DATASOURCE_URL=jdbc:postgresql://localhost:5432/postgres,\
QUARKUS_DATASOURCE_PASSWORD={DBパスワード},\
CLOUDSQL_INSTANCE=$SQL_CONNECTION_NAME
set-env-vars
で環境変数を指定できます。必要に応じてモジュールに手を加える事なくログレベルやDB設定の変更ができるのが便利ですね。
$ gcloud beta run services list
SERVICE REGION LATEST REVISION SERVING REVISION LAST DEPLOYED BY LAST DEPLOYED AT
✔ mybank us-central1 mybank-00017 mybank-00017 xxxx@gmail.com 2019-04-25T08:50:28.872Z
デプロイできたのが確認できるかと思います。接続先のURLを確認してみましょう。
$ gcloud beta run services describe mybank|grep hostname
hostname: https://mybank-xxx-uc.a.run.app
curlで動作確認をしてみます。
[$ curl -X POST -H "Content-Type: application/json" https://mybank-xxx-uc.a.run.app/account
$ curl https://mybank-xxx-uc.a.run.app/account
[{"amount":0,"id":"b9efbb84-3b4d-4152-be6b-2cc68bfcbe71"}]
初回のスピンアップは10秒程度かかりますが、それ以降は数百ms前後で動作してる事がわかります。DBアクセスもバッチリですね!
まとめ
QuarkusとCloud Runを使ってServerlessなJavaEE環境を作ってみました。
GAEはロックインするしJavaEEだとスピンアップがなー、という所にちょうど良い感じのプロダクトが揃ってきましたが如何でしょうか?
普通にJAX-RS/CDIやJPAといったJavaEEお馴染みの書き方をしつつ、msオーダーで起動するのは面白いですよね?
この特徴は「リクエスト毎にプロセスを立ち上げる」というServerlessのアーキテクチャにもとても合っています。
Fast CGIに舞い戻ったのが不思議な感じがしますが長時間稼働も原則しないのでJavaで頭を悩ませるGCも大きな影響がないのは面白い特徴だと思います。
どちらも出たばかりで不安定ですし、ブレイクスルーなので運用面をどうしていくかは考える必要がありますが今後も追っていきたいと思います。
まずはDB周りをなんとかしないとせっかくmsオーダーで動くのにDB接続のためにスピンアップが重い...
それではHappy Hacking!