Help us understand the problem. What is going on with this article?

QuarkusとCloud RunではじめるServerless JavaEE

はじめに

先日のGoogle Cloud Next 19でCloud Runが公開されましたね。
これはHTTPで起動するDockerコンテナをk8s上でオートスケールさせるKnativeをGoogleがGCP上でフルマネージドで提供するものです。
AWSのFargateHeroku, あるいは僕たちが本当に欲しかったGAE Flexible Environmentと言ったところでしょうか。

今回は、こちらにGraalでJavaをバイナリにコンパイルできる超音速/超軽量を謡うJavaEEコンテナのQuarkusを使うことで高速なServerless JavaEEアプリを作ってみます。
もう「Javaだからスピンアップが遅い」とか言わせない!

TL;DR

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.sqltrueにしたので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によるドキュメントにアクセスできます。
004.png

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.shcloud_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"]

続いて.dockerignoreDockerfile.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!

参考

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away