Quarkusのテンプレートエンジン "qute"

この記事は赤帽エンジニア Advent Calendar 2019の12/11分の記事です。

今日は日本JBossユーザグループでQuarkus入門について話しましたが、スライドはこちらです。今日の記事では、スライドに入りきらなかった qute について紹介します。


背景

今年の4月にGlassFishユーザ会に向けてQuarkus0.14.0を触りながら、実際の開発で使うならば欲しいと思った機能が2つありました。

  • Hibernate以外のDBアクセスの選択できること
    • スキーマが複雑になるほどSQLを直接書きたいユーザは多い
  • Thymeleafのようなサーバサイドで使えるテンプレートエンジン

DBアクセスについては、Quarkus自体のアーキテクチャがVert.xベースになったことにより、スライドでも言及している Quarkus - Reactive SQL Clients が導入され、元々はノンブロッキングなDBアクセスを目的とする機能ですが、APIがJdbcTemplateに似てるのでこれで良いなと。

もう一つのテンプレートエンジンは、近年はフロントエンドは別に作り込まれる方が一般的になってきている一方で、エンタープライズなアプリではサーバサイドテンプレートでも十分機能するのでは、ちょっとした社内システムにそこまでフロントエンド必要なのかなと思っていま..(ごめんなさいクライアントサイドも勉強します)

テンプレートエンジン "qute"

quteはQuarkus向けに新たに開発された、Graal VMのnative-imageコマンドによるネイティブビルドに対応したテンプレートエンジンです。先日、Quarkusのmasterブランチにマージされましたが、まだ最新のリリース版であるQuarkus1.0.1には含まれていません。

シンプルな機能のみを提供しており { name } のように {} で囲まれた部分をテンプレートとして置き換えます。 以降にサンプルを紹介します。

qute Hello, world!

以下はテンプレート部分です。ソースコードsrc/main/resources/templates に以下のようなhello.htmlを配置します。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Hello Quarkus Qute template engine</title>
</head>
<body>
    <h1>Hello Quarkus Qute template engine</h1>
    <p>Hello, {name}!</p>
</body>
</html>

JAX-RSのエンドポイント実装からテンプレートを解決したhtmlを応答したい場合は、以下のように書きます。

import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;

import javax.inject.Inject;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;

@Path("hello")
public class HelloResource {

    @Inject
    Template hello;   // src/main/resources/templates/hello.html の拡張子を除いた hello に合わせる

    @GET
    @Produces(MediaType.TEXT_HTML)
    public TemplateInstance get(@QueryParam("name") String name) {
        return hello.data("name", name);  // 第1引数はテンプレートにかかれたキー name, 第2引数はテンプレートに適用する値
    }
}

コードの説明はコメントの通りで、シンプルに扱えることがわかります。

繰り返し each / 条件分岐 if

以下の例のように繰り返しやifなどの最低限の機能は満たされています。

<p>List of items over 100 yen</p>
<ul>
    {#each items}
        {#if it.price > 100}
            <li>name: {it.name} , price: {it.price}</li>
        {/if}
    {/each}
</ul>

{#each}には、Iterable、MapのentrySet、Streamインスタンスが設定できるようになっています。it.nameのitはループ中の現在の要素を示しており、it.<プロパティ名>でItemクラスのnameプロパティにアクセスしています。

java側のコードは以下の通りです。

public class Item {
    private String name;
    private int price;
    // getter & setter 省略
}

import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;

import javax.inject.Inject;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import java.util.Arrays;
import java.util.List;

@Path("items")
public class ItemResource {

    @Inject
    Template items;

    @GET
    @Path("/all")
    @Produces(MediaType.TEXT_HTML)
    public TemplateInstance getAll() {
        return items.data("items", getSnacks());
    }

    private List<Item> getSnacks() {
        Item ice = Item.of("ice cream", 120);
        Item cookie = Item.of("cookie", 80);
        Item cake = Item.of("cake", 200);
        Item chocolate = Item.of("chocolate", 90);
        return Arrays.asList(ice, cookie, cake, chocolate);
    }
}

GitHub上に上記のコードを置いています quarkus-qute-helloworld
本日時点はまだないですが、正式版がリリースされているときには quarkus-quickstarts にサンプルコードが他の機能と同様に用意されるはずです。

"qute" を実際に試す

quteはまだリリースされていないため、試すためにはquarkusのmasterブランチを自分でビルドします。テストをスキップするとGraalVMを用意しなくてもOpenJDK8でビルド可能です。

git clone https://github.com/quarkusio/quarkus.git
cd quarkus
mvnw clean package -DskipTests=true

自分でビルドしたmasterブランチのQuarkus(バージョン999-SNAPSHOT)をアプリケーションで利用するときに少し注意が必要です。
mvn io.quarkus:quarkus-maven-plugin:1.0.1.Final:createで生成したひな型プロジェクトでは、pom.xmlにおいてquarkus-universe-bomがbomが参照されていますが、前述ののビルドではquarkus-universe-bomはローカル上の~/.m2生成されていません。自分でビルドしたQuarkusでは、ビルド時に生成されるquarkus-bomに書き換えます。

<?xml version="1.0"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
        ...
    <properties>
        ...
        <quarkus-plugin.version>999-SNAPSHOT</quarkus-plugin.version>        <!-- バージョンを999-SNAPSHOTに書き換える -->
        <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
        <quarkus.platform.group-id>io.quarkus</quarkus.platform.group-id>         <!-- quarkus-universe-bom から書き換える -->
        <quarkus.platform.version>999-SNAPSHOT</quarkus.platform.version>  <!-- バージョンを999-SNAPSHOTに書き換える -->
       ....

上記については公式ガイドに言及があります。

quarkus-qute-helloworldに置いたコードでは、pom.xmlの修正は既に盛り込まれています。これでローカル環境でquarkus-qute-helloworldmvn clean package でビルドして試せるようになりました。

まとめ

  • quteはQuarkusにこれから盛り込まれる予定のネイティブコンパイルに対応したテンプレートエンジンです
  • { }で囲った部分をテンプレートして置換します
  • Quarkus1.0おめでとう!

Galleonを使ってWildFlyのイメージサイズを小さくする

GalleonはWildFly16から対応した、WildFlyのバイナリを作るためのプロビジョニングツールです。詳細についてはWildFly News - WildFly 16 and Galleon, towards a cloud native EE application serverにまとめられていますが、Galleonにより以下が実現できます。

  • JAX-RS/CDI/JPAなど、よく使うAPIだけ含んだサイズの小さなWildFlyサーバを作る
  • WildFlyから使わない機能を削ってDockerイメージサイズを小さくする
  • Galleonで作った小さなWildFlyは従来のWildFlyと同じようにAPサーバとしての使い勝手で使える。ThorntailのようなUber-jarではない。

Galleonを使う

実際に使ってみる方がイメージが湧きやすいので、使い方から紹介します。

GalleonはWildFlyコミュニティで開発されているツールですが、WildFlyにはバンドルされておらず、GitHubGalloenのリポジトリからダウンロードします。このブログを書いた時点の最新バージョンは4.0.3.Finalです。

ダウンロードしたツールを任意のディレクトリに展開します

$ mkdir work
$ cp ~/Downloads/galleon-4.0.3.Final.zip .
$ unzip galleon-4.0.3.Final.zip
Archive:  galleon-4.0.3.Final.zip
   creating: galleon-4.0.3.Final/
   creating: galleon-4.0.3.Final/bin/
  inflating: galleon-4.0.3.Final/LICENSE
  inflating: galleon-4.0.3.Final/bin/galleon.sh
  inflating: galleon-4.0.3.Final/bin/galleon-cli-logging.properties
  inflating: galleon-4.0.3.Final/bin/galleon-cli.jar
  inflating: galleon-4.0.3.Final/bin/galleon.bat

まずは早速GalleonでWildFlyサーバを作成してみます。対象のWildFlyのバージョンはこの記事を書いている時点で最新の17.0.1.Final、--layersにはサーバに含める機能を指定するオプションでcloud-profileとはJAX-RS/CDI/JPA/JTAなどのよく使われるAPI実装に加えて、WildFly自体の動作に必要なサーバのコア機能のみ持つことを示します。--dirは作成したWildFlyサーバの出力先です。

Maven経由で必要なサブシステムをダウンロードしてWildFlyを組み立てるため、初回実行時は数分かかる場合もあります。

$ cd galleon-4.0.3.Final
$ bin/galleon.sh install wildfly:current#17.0.1.Final --layers=cloud-profile --dir=cloud-profile
Feature-packs resolved.
Feature-packs resolved.
Packages installed.
JBoss modules installed.
Configurations generated.
Feature pack installed.
======= ============ ==============
Product Build        Update Channel
======= ============ ==============
wildfly 17.0.1.Final current

Galleonによって生成された機能がそぎ落とされたWildFlyサーバの構成を確認すると一見して同じようなディレクトリ構造に見えます。GalleonはThorntailのように、Uber-jarに必要な機能をまとめるのではなく、通常のWildFlyと同じ構成を保ったまま、サブシステムを削ることで小サイズ化を実現しています。

$ cd cloud-profile
$ ls
LICENSE.txt		bin			jboss-modules.jar	standalone
README.txt		copyright.txt		modules

$WILDFLY_HOME/bin の中身を見ると、WildFlyを使ったことがある人なら違和感を感じると思います。jboss-cli.sh、add-user.shや、LinuxのOS起動時にWildFlyを起動させるinit.dスクリプトjboss-client.jarが含まれていません。

これらの$WILDFLY_HOME/binに含まれていたスクリプトを含んだWildFlyサーバを生成したい場合は、--layersにcore-toolsを追加します。

$ bin/galleon.sh install wildfly:current#17.0.1.Final --layers=cloud-profile,core-tools --dir=cloud-profile-with-tools

他にもどんなlayerがあるかは、WildFly Admin Guideの12.4. WildFly Galleon layersに解説があります。

ユースケース例として、WildFlySpring Frameworkを使ったアプリケーションをデプロイしたいので、GalleonによってEJBやremotingサブシステム(JMSサーバ接続やリモートEJBに使われるWildFly固有の機能)を削って、欲しい機能だけ持つWildFlyを作ることが考えられます。

Galleonが生まれた背景

コンテナのイメージサイズの削減がGalleonの目的です。

WildFlyには、元々@Statelessが付与されたコードがデプロイされてからEJBサブシステムをクラスロードするなど、アプリケーションで使われている機能をデプロイ処理で検知して必要な機能だけロードして余分なMetaspaceを消費しないようにする仕組みが備わっています。このため、Galleonによって使われないサブシステムを削除しても、メモリ削減にはあまり効果は期待できません。

前述のcloud-profileの場合、通常のWildFlyと比較しても起動直後のJavaヒープメモリのフットプリント削減の効果は手元で計測する限りでは10MB程度です。

APサーバとJDK、S2Iツール群など含んだJBossのコンテナイメージはどうしてもサイズが大きくなります。JBoss EAP7の場合、最新のイメージでは940MBと大きなイメージです。

$ docker pull registry.redhat.io/jboss-eap-7/eap72-openjdk11-openshift-rhel8:1.0-4
$ docker images
REPOSITORY                                                       TAG                  IMAGE ID            CREATED             SIZE
registry.redhat.io/jboss-eap-7/eap72-openjdk11-openshift-rhel8   1.0-4                cbc9919e54f5        3 weeks ago         940MB

Dockerイメージのアプリのレイヤだけ別で、全て最新の同一バージョンのJBoss EAPが同一のワーカーノードで起動している場合はDockerによるイメージレイヤの共有が期待できます。しかし、別バージョンのJBoss EAPが複数起動している場合はディスクサイズをそれなりに消費します。

Web管理コンソールやリモートJMXサーバなど、EAP自体にもコンテナ環境ではあまり使わない機能が含まれています。このモノリスなAPサーバから使わない機能を削ってイメージサイズをなるべく小さくすることがGalleonの目的です。

必要な機能だけアーカイブに含める考え方はThorntailでも実現できていましたが、Quarkusの登場に伴い、Thorntailは今後メジャーバージョンアップせずにメンテナンスモードとなる*1ため、WildFlyとしてもイメージサイズ削減に手を打っています。

OpenShiftにGalleonで作ったWildFlyをデプロイする

Galloenを使ったWildFlyコンテナをデプロイするためのテンプレートやImageStreamはwildfly-s2iリポジトリで配布されています。テンプレートの使い方などの詳しいドキュメントは READMEに書かれています。

Galleonには前述のCLIの他にもMavenプラグインのgalleon-maven-pluginがあり、WildFlyのS2Iビルダイメージでは、BuildConfigによるイメージのビルド時にMavenプラグインのGalleonを動かして小さなWildFlyバイナリを生成します。

単純にS2Iビルド時にGalleonでWildFlyをビルドしても、元々のコンテナイメージのレイヤのサイズは小さくならず、ただレイヤを重ねてビルダイメージよりも大きくなるだけです。このためS2Iのツール群やJDKWildFly本体を含んだビルダイメージquay.io/jfdenise/wildfly-centos7:latestと、WildFly本体やS2Iツール群を削ってJDKと便利スクリプトのみ含められたランタイムイメージquay.io/jfdenise/wildfly-runtime-centos7:latestの2種類のコンテナイメージが配布されています。

テンプレートには2つのBuildConfigが含まれています。1つはWildFlyにビルダイメージとソースコードをインプットに従来と同等のアプリケーションイメージを作成するBuildConfigと、生成されたアプリケーションイメージからGalleonで生成したディレクトリを抜き取って、ランタイムイメージにマージしたサイズの小さなアプリケーションイメージを生成するBuildConfigです。絵にすると以下のようなイメージです。

f:id:n_agetsuma:20190725210525p:plain

"抜き取る"とは、具体的には以下のようにBuildConfigを定義して、アプリケーションイメージの /s2i-output/server/ の内容をランタイムイメージにコピーしています。

- apiVersion: build.openshift.io/v1
  kind: BuildConfig
  ...
  spec:
    output:
      ...
    source:
      dockerfile: |-
        FROM wildfly-runtime-centos7:latest
        COPY /server $JBOSS_HOME
        USER root
        RUN chown -R jboss:root $JBOSS_HOME && chmod -R ug+rwX $JBOSS_HOME
        RUN ln -s $JBOSS_HOME /wildfly 
        USER jboss
        CMD $JBOSS_HOME/bin/openshift-launch.sh
      images:
        - from: 
            kind: ImageStreamTag
            name: ${APPLICATION_NAME}-build-artifacts:latest
          paths: 
          - sourcePath: /s2i-output/server/
            destinationDir: "."

minishiftで実際に試してみます。

# プロジェクトの作成
$ oc new-project wildfly-galleon

# ImageStreamとTemplateのロード
$ oc create -f https://raw.githubusercontent.com/wildfly/wildfly-s2i/wf-17.0/templates/wildfly-builder-imagestream.yml
$ oc create -f https://raw.githubusercontent.com/wildfly/wildfly-s2i/wf-17.0/templates/wildfly-runtime-imagestream.yml
$ oc create -f https://raw.githubusercontent.com/wildfly/wildfly-s2i/wf-17.0/templates/wildfly-s2i-chained-build-template.yml

# テンプレートwildfly-s2i-chained-build-templateによるイメージのビルド
$ oc new-app --template=wildfly-s2i-chained-build-template -p APPLICATION_NAME=cloudprofile-app -p GIT_REPO=https://github.com/nagetsum/eap-debug.git -p GIT_CONTEXT_DIR=cloudprofile-app -p IMAGE_STREAM_NAMESPACE=wildfly-galleon -p GALLEON_PROVISION_SERVER=cloud-profile-h2

テンプレートのパラメータGALLEON_PROVISION_SERVERがGalleonに渡すパラメータで、この例ではJAX-RS/CDI/JPA + H2インメモリDBを示すcloud-profile-h2を指定します。その他のパラメータは前述のREADMEに言及があります。

イメージのサイズは従来のアプリケーションイメージが1.63GBに対し、小さいなアプリケーションイメージは568MBと約3分の1となりました。

$ minishift ssh
[docker@minishift ~]$ docker images
REPOSITORY                                                         TAG                 IMAGE ID            CREATED             SIZE
172.30.1.1:5000/wildfly-galleon/cloudprofile-app                   latest              43b73a025880        28 minutes ago      568 MB
172.30.1.1:5000/wildfly-galleon/cloudprofile-app-build-artifacts   latest              a18f05787add        29 minutes ago      1.63 GB
quay.io/jfdenise/wildfly-centos7                                   <none>              6983f22f7f32        6 weeks ago         1.22 GB
quay.io/jfdenise/wildfly-runtime-centos7                           <none>              c449eca1cc04        6 weeks ago         436 MB

テンプレートを実行しただけではイメージがビルドされるだけなので、作ったイメージをデプロイします。

$ oc new-app cloudprofile-app:latest
--> Found image 43b73a0 (37 minutes old) in image stream "wildfly-galleon/cloudprofile-app" under tag "latest" for "cloudprofile-app:latest"

    Tags: wildfly, wildfly17

    * This image will be deployed in deployment config "cloudprofile-app"
    * Ports 8080/tcp, 8778/tcp will be load balanced by service "cloudprofile-app"
      * Other containers can access this service through the hostname "cloudprofile-app"

--> Creating resources ...
    deploymentconfig.apps.openshift.io "cloudprofile-app" created
    service "cloudprofile-app" created
--> Success
    Application is not exposed. You can expose services to the outside world by executing one or more of the commands below:
     'oc expose svc/cloudprofile-app'
    Run 'oc status' to view your app.

$ oc expose svc/cloudprofile-app
route.route.openshift.io/cloudprofile-app exposed

このアプリケーションではJAX-RSのエンドポイントにアクセスすると、JPAを使ってインメモリのH2データベースから本情報を取り出しています。

$ oc get route
NAME               HOST/PORT                                              PATH      SERVICES           PORT       TERMINATION   WILDCARD
cloudprofile-app   cloudprofile-app-wildfly-galleon.192.168.64.7.nip.io             cloudprofile-app   8080-tcp                 None

$ curl cloudprofile-app-wildfly-galleon.192.168.64.7.nip.io/cloudprofile-app/api/books/1; echo
{"author":"Joshua Bloch","id":1,"title":"Effective Java"}

JBoss EAPではまだGalleonに対応していませんが、今後のバージョンで対応するように検討が行われています。

まとめ

  • Galleonは欲しい機能だけを含んだWildFlyを生成するプロビジョニングツールです
  • uber-jarではなく、従来のWildFlyディレクトリ構成や使い勝手を維持しながらスリム化します
  • Galleonの目的はコンテナイメージサイズの削減です

RESTEasy BeanValidationエラーメッセージのカスタマイズ

JAX-RS Specでは、BeanValidationのバリデーションエラー時には、エラー原因を返すことのみが規定されており、メッセージのフォーマット自体は実装依存です。

7.6 Validation and Error Reporting
In all cases, JAX-RS implementations SHOULD include a response entity describing the source of the error; however, the exact content and format of this entity is beyond the scope of this specification.

この記事ではRESTEasyの場合にどのようにメッセージフォーマットをカスタマイズするか言及します。

RESTEasyデフォルトの振る舞い

例として以下のバリデーションを実行を考えます。

public class Customer {
    @NotBlank(message = "Customer id is required.")
    private String id;

    @NotBlank(message = "Customer name is required.")
    private String name;

    @NotBlank(message = "Customer location is required.")
    private String location;
    ...
}

@Path("/customer")
public class CustomerResource {
    @POST
    @Produces(MediaType.APPLICATION_JSON)
    @Consumes(MediaType.APPLICATION_JSON)
    public Customer post(@Valid Customer cu) {
        ...
    }
}

WildFlyやQuarkusが内蔵するJAX-RS実装のRESTEasyの場合、バリデーションエラー時にはHTTPレスポンスヘッダにvalidation-exception:
true
を含むと共に、以下のようなフォーマットのメッセージを返します。各プロパティはバリデーションの種類を示しています。例の場合はメソッドパラメータでバリデーションしているため、parameterViolationsにメッセージが含まれています。

// 見やすいように改行を含めていますが、実際には改行は含まれません
{
   "exception":null,
   "fieldViolations":[],
   "propertyViolations":[],
   "classViolations":[],
   "parameterViolations":[
      {
         "constraintType":"PARAMETER",
         "path":"post.arg0.name",
         "message":"Customer name is required.",
         "value":""
      },
      {
         "constraintType":"PARAMETER",
         "path":"post.arg0.id",
         "message":"Customer id is required.",
         "value":""
      }
   ],
   "returnValueViolations":[]
}

エラーメッセージのカスタマイズ

もっとシンプルに以下のようなエラーメッセージのリストを返すことを考えます。

{"errors":["Customer id is required.","Customer name is required."]}

org.jboss.resteasy.api.validation.ResteasyViolationExceptionMapperクラスをオーバーライドするとメッセージフォーマットのカスタマイズができます。

コード例は以下の通りです。ResteasyViolationExceptionMapperクラスでは後で拡張できるように、いくつかのメソッドのスコープがprotectedに設定されています。WildFly17で動作します。Quarkus0.16.1では原因は詳しくデバッグしていませんが動作しません。

package sample;

import org.jboss.resteasy.api.validation.ResteasyViolationException;
import org.jboss.resteasy.api.validation.ResteasyViolationExceptionMapper;
import org.jboss.resteasy.api.validation.Validation;

import javax.json.Json;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.ext.Provider;
import java.util.List;
import java.util.stream.Collectors;

@Provider
public class ViolationExceptionMapper extends ResteasyViolationExceptionMapper {

    @Override
    protected Response buildViolationReportResponse(ResteasyViolationException exception, Response.Status status) {
        ResponseBuilder builder = Response.status(status);
        builder.header(Validation.VALIDATION_HEADER, "true");

        if (isAcceptJson(exception.getAccept())) {
            builder.type(MediaType.APPLICATION_JSON);

            List<String> errors = exception.getViolations()
                    .stream()
                    .map(violation -> violation.getMessage())
                    .collect(Collectors.toList());

            builder.entity(Json.createObjectBuilder()
                    .add("errors", Json.createArrayBuilder(errors).build())
                    .build());
            return builder.build();
        }

        // Default media type.
        builder.type(MediaType.TEXT_PLAIN);
        builder.entity(exception.toString());
        return builder.build();

    }

    private boolean isAcceptJson(List<MediaType> accepts) {
        return accepts.stream()
                .anyMatch(mt -> MediaType.APPLICATION_JSON_TYPE.getType().equals(mt.getType())
                        && MediaType.APPLICATION_JSON_TYPE.getSubtype().equals(mt.getSubtype()));
    }
}

org.jboss.resteasy.api.validationパッケージにクラスパスが通るように、pom.xmldependencyresteasy-jaxrsを追加します。

    <dependencies>
        <dependency>
            <groupId>org.jboss.spec</groupId>
            <artifactId>jboss-javaee-web-8.0</artifactId>
            <version>1.0.3.Final</version>
            <type>pom</type>
        </dependency>
        <dependency>
            <groupId>org.jboss.resteasy</groupId>
            <artifactId>resteasy-jaxrs</artifactId>
            <version>3.7.0.Final</version>
            <scope>provided</scope>
        </dependency>
  </dependencies>

まとめ

  • JAX-RS仕様ではBeanValidationエラー時のメッセージフォーマットは規定されておらず実装依存
  • RESTEasyの場合、エラー時にはHTTPレスポンスにvalidation-exception : trueを含み、独自フォーマットのメッセージを返す
  • メッセージフォーマットのカスタマイズには org.jboss.resteasy.api.validation.ResteasyViolationExceptionMapper の継承クラスを作る
  • 参考 RESTEasyドキュメント Chapter 55. Validation

Quarkusのスレッドプール設定

Quarkusで実行されるブロッキングタスク(JAX-RS/Servletなど)は、従来のAPサーバと同様にランタイム内部で管理されるスレッドプールにより実行されます。

WildFly/JBoss EAPでtask-max-threadsに相当するパラメータは、ソースコード中のapplication.propertiesにおいてquarkus.thread-pool.max-threadsとして設定します。

src/main/resources/application.properties

# スレッドプールのアイドルスレッド数。未設定時のデフォルトは1。
quarkus.thread-pool.core-threads=32

# スレッドプールの最大数。未設定時のデフォルトは論理プロセッサ数x8。
quarkus.thread-pool.max-threads=64

このスレッドの実態はスレッドダンプを取得すると executor-thread-nとして確認できます。

kill -3 <pid>
"executor-thread-1" #24 daemon prio=5 os_prio=31 tid=0x00007f966b09b000 nid=0x5c03 waiting on condition [0x00007000112da000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
        at java.lang.Thread.sleep(Native Method)
        at sample.quarkus.jpajaxrscdijta.Sleeper.sleep(Sleeper.java:7)

スレッドプールのチューニングはQuarkus0.16.1の時点では公式ドキュメントに言及がないですが、ブロッキングタスクを動かすならばチューニングした方が良いです。

  • デフォルト値が小さい。WildFlyではtask-max-threadsは論理プロセッサ数 x 16ですが、Quarkus0.16.1では論理プロセッサ数 x 8です。JDBCによるブロッキングI/O待ちですぐに枯渇します。
  • dockerの--cpuset-cpus=0など、コンテナレイヤで論理プロセッサ数を絞っている場合は、絞られたCPU数 x 8になる。
    • Quarkusの実装ではLinux環境の場合はコア数算出にRuntime.availableProcessor()依存せず、/proc/self/statusのCpus_allowedの値からコア数算出するorg.wildfly.common.cpu.ProcessorInfoクラスを使っているため、Java8のマイナーバージョンに関わらずcgroupsが反映されます。

Quarkus入門

GlassFish Users Group Japan 勉強会 2019 SpringQuarkusについて紹介しました。話そうと思っていたけれどもスライドに入らなかったことをブログで補足してみます。

Quarkusとは何か

とにかく起動が高速な Java EE / MicroProfile ベースのAPIでコードが書けるフレームワークです。スライド中でも言及していますが、GraalVMのnative-imageコマンドによってネイティブコンパイルすると100ミリを切るスピードでJAX-RS/CDI/JPAで構成されるCRUDアプリケーションが起動します。

Javaは起動が遅い、Goのようなシングルバイナリによる起動の方が便利でJavaは手間が掛かると言われる課題を解決しています。

続きについてはスライドを参照してください。ここからは、スライドに入らなかった内容や、当日話そうと思っていたけど忘れていた内容をまとめます。

Quarkusがビルド時に自動生成するクラス

mvn clean packageすると、target/wiring-classesにQuarkusがビルド時にクラスファイルを展開してuber-jarの中に含めるような動きをします。JAX-RS/CDI/JPAのサンプルアプリケーションの場合、アプリケーションのパッケージsample.quarkus.jpajaxrscdijtaだけでなく、ランタイム自体のクラスファイルが生成されている様子がわかります。

$ tree target/wiring-classes
target/wiring-classes
├── META-INF
│   ├── build-config.properties
│   ├── quarkus-default-config.properties
│   └── services
│       ├── io.quarkus.arc.ComponentsProvider
│       └── io.quarkus.arc.ResourceReferenceProvider
├── io
│   └── quarkus
│       ├── agroal
│       │   └── runtime
│       │       ├── DataSourceProducer.class
│       │       ├── DataSourceProducer_Bean$$function$$6.class
│       │       ├── DataSourceProducer_Bean.class
│       │       ├── DataSourceProducer_ClientProxy.class
│       │       └── DataSourceProducer_ProducerMethod_createDefaultDataSource_7c487e3ef869f878aa871e917c94f4d26d5d5c56_Bean.class
│       ├── arc
│       │   ├── ActivateRequestContextInterceptor_Bean.class
│       │   ├── runtime
│       │   │   └── LifecycleEventRunner_Bean.class
│       │   ├── runtimebean
│       │   │   └── RuntimeBeanProducers.class
│       │   └── setup
│       │       └── Default_ComponentsProvider.class
│       ├── deployment
│       │   └── steps
│       │       ├── AgroalProcessor$build3.class
│       │       ├── AgroalProcessor$configureRuntimeProperties6.class
│       │       ├── ArcAnnotationProcessor$build10.class
│       │       ├── ConfigBuildStep$validateConfigProperties11.class
│       │       ├── HibernateOrmProcessor$build8.class
│       │       ├── HibernateOrmProcessor$build9.class
│       │       ├── HibernateOrmProcessor$startPersistenceUnits14.class
│       │       ├── LifecycleEventsBuildStep$startupEvent17.class
│       │       ├── LoggingResourceProcessor$setupLoggingRuntimeInit5.class
│       │       ├── LoggingResourceProcessor$setupLoggingStaticInit1.class
│       │       ├── NarayanaJtaProcessor$build4.class
│       │       ├── ResteasyScanningProcessor$setupInjection12.class
│       │       ├── RuntimeBeanProcessor$build2.class
│       │       ├── ThreadPoolSetup$createExecutor7.class
│       │       ├── UndertowArcIntegrationBuildStep$integrateRequestContext13.class
│       │       ├── UndertowBuildStep$boot16.class
│       │       └── UndertowBuildStep$build15.class
│       ├── hibernate
│       │   └── orm
│       │       ├── panache
│       │       │   └── PanacheEntity.class
│       │       └── runtime
│       │           ├── DefaultEntityManagerProducer_Bean.class
│       │           ├── DefaultEntityManagerProducer_ProducerField_entityManager_Bean.class
│       │           ├── JPAConfig_Bean.class
│       │           ├── RequestScopedEntityManagerHolder_Bean$$function$$7.class
│       │           ├── RequestScopedEntityManagerHolder_Bean.class
│       │           ├── RequestScopedEntityManagerHolder_ClientProxy.class
│       │           └── TransactionEntityManagers_Bean.class
│       ├── narayana
│       │   └── jta
│       │       └── runtime
│       │           ├── NarayanaJtaProducers_Bean.class
│       │           ├── NarayanaJtaProducers_ProducerMethod_transactionManager_9989455b3b53ac81c17ca945c636473b7202fe4e_Bean$$function$$8.class
│       │           ├── NarayanaJtaProducers_ProducerMethod_transactionManager_9989455b3b53ac81c17ca945c636473b7202fe4e_Bean.class
│       │           ├── NarayanaJtaProducers_ProducerMethod_transactionManager_9989455b3b53ac81c17ca945c636473b7202fe4e_ClientProxy.class
│       │           ├── NarayanaJtaProducers_ProducerMethod_transactionSynchronizationRegistry_ad29dd72d7aa0c9be8f98e90052c29fc262ea31a_Bean$$function$$9.class
│       │           ├── NarayanaJtaProducers_ProducerMethod_transactionSynchronizationRegistry_ad29dd72d7aa0c9be8f98e90052c29fc262ea31a_Bean.class
│       │           ├── NarayanaJtaProducers_ProducerMethod_transactionSynchronizationRegistry_ad29dd72d7aa0c9be8f98e90052c29fc262ea31a_ClientProxy.class
│       │           └── interceptor
│       │               ├── TransactionalInterceptorMandatory_Bean.class
│       │               ├── TransactionalInterceptorNever_Bean.class
│       │               ├── TransactionalInterceptorNotSupported_Bean.class
│       │               ├── TransactionalInterceptorRequired_Bean.class
│       │               ├── TransactionalInterceptorRequiresNew_Bean.class
│       │               └── TransactionalInterceptorSupports_Bean.class
│       ├── runner
│       │   ├── ApplicationImpl1.class
│       │   ├── AutoFeature.class
│       │   └── GeneratedMain.class
│       └── runtime
│           └── generated
│               ├── BuildTimeConfig.class
│               ├── BuildTimeConfigRoot.class
│               ├── RunTimeConfig.class
│               ├── RunTimeConfigRoot.class
│               └── RunTimeDefaultConfigSource.class
├── javax
│   ├── enterprise
│   │   ├── context
│   │   │   └── control
│   │   │       └── ActivateRequestContext_Shared_AnnotationLiteral.class
│   │   └── inject
│   │       └── Produces_Shared_AnnotationLiteral.class
│   ├── persistence
│   │   └── PersistenceContext_Shared_AnnotationLiteral.class
│   └── transaction
│       └── Transactional_Shared_AnnotationLiteral.class
└── sample
    └── quarkus
        └── jpajaxrscdijta
            ├── EmployeeResource_Bean.class
            ├── EmployeeService_Bean$$function$$1.class
            ├── EmployeeService_Bean.class
            ├── EmployeeService_ClientProxy.class
            ├── EmployeeService_Subclass$$function$$2.class
            ├── EmployeeService_Subclass$$function$$3.class
            ├── EmployeeService_Subclass$$function$$4.class
            ├── EmployeeService_Subclass$$function$$5.class
            └── EmployeeService_Subclass.class

スライド中で言及している以下のようなコードの@Injectの解決の場合、

package sample.quarkus. jpajaxrscdijta;

@Path("/")
public class EmployeeResource {

    @Inject
    private EmployeeService service;

wiring-classes/sample/quarkus/jpajaxrscdijta/EmployeeResource_Bean.classにインジェクション実装が含まれており、putfield命令でシンプルにフィールド名serviceに値を設定するクラスファイルが自動生成されている様子が確認できます。

javap -v wiring-classes/sample/quarkus/jpajaxrscdijta/EmployeeResource_Bean.class
...
  public sample.quarkus.jpajaxrscdijta.EmployeeResource create(javax.enterprise.context.spi.CreationalContext);
    descriptor: (Ljavax/enterprise/context/spi/CreationalContext;)Lsample/quarkus/jpajaxrscdijta/EmployeeResource;
    flags: ACC_PUBLIC
        ...
        30: aload_3
        31: checkcast     #57                 // class sample/quarkus/jpajaxrscdijta/EmployeeService
        34: putfield      #61                 // Field sample/quarkus/jpajaxrscdijta/EmployeeResource.service:Lsample/quarkus/jpajaxrscdijta/EmployeeService;

これらのクラスをデプロイしてから動的バイトコード生成で行うのではなく、事前に可能な処理はビルド時に実行するため、GraalVMによってネイティブバイナリを生成しなくても、java -jarでも従来のThorntail/WildFlyと比較して高速に起動しています。

Quarkusでスレッドダンプ

GraalVM Community Editionのnative-imageコマンドで生成された実行バイナリでは、kill -3 (SIGQUIT) が投げられるとHotSpotJVMと異なりプロセスが終了します。

Quarkusではシグナルハンドラを独自に実装しており、kill -3を実行プロセスに投げるとコンソールにスレッドダンプを出力します。内部的にはsun.misc.SignalHandlerを使って実装しています。

2019-05-21T14:22:58.915Z
Thread dump follows:

"XNIO-1 Accept" #22 prio=5 tid=0x7f6cb77e9700
   java.lang.thread.State: RUNNABLE
	at com.oracle.svm.core.posix.headers.linux.LinuxEPoll.epoll_wait(LinuxEPoll.java)
	at sun.nio.ch.EPollArrayWrapper.epollWait(EPollArrayWrapper.java:326)
	at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:269)
	at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:93)
	at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
	at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
	at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:101)
	at org.xnio.nio.WorkerThread.run(WorkerThread.java:532)
	at com.oracle.svm.core.thread.JavaThreads.threadStartRoutine(JavaThreads.java:473)
	at com.oracle.svm.core.posix.thread.PosixJavaThreads.pthreadStartRoutine(PosixJavaThreads.java:193)
...

ヒープダンプはGraalVM Enterprise EditionによりGraalVMレイヤで実現されています。無償で利用可能なGraalVM Community Editionでは現状ヒープダンプを取得することはできません。

まとめ

QuarkusはMicroProfileベースのAPIでコードが書ける、高速起動のフレームワークです。JBoss系コミュニティ由来との製品としては、Java EEからJakarta EEへの移管に伴い標準仕様の観点では変化がゆっくりになっていましたが、MicroProfile仕様の一部をWildFlyで実装したり、Quarkusのような新しい試みが行われています。ぜひ試してみるのはいかがでしょうか。

OpenShiftでJBossクラスタを構成する仕組み

この記事は赤帽エンジニア Advent Calendar 2018の12/4分の記事です。
Red HatJBoss EAPのサポートエンジニアをしています。自由なAdvent Calendarなので、自由に勉強メモを書いてみます。


JBossのようなAPサーバはクラスタメンバの探索に従来マルチキャストを使ってきましたが、OpenShift*1k8sの環境が一般的になると、マルチキャストが使えないネットワークの方が多いです。

Red Hat Container Catalogで公開されているOpenShift向けのJBossコンテナイメージJBoss EAP CD for OpenShiftがどのようにクラスタを組んでいるかメモします。

EAP CDってなんぞやから、テストアプリケーションのデプロイ、クラスタディスカバリの仕組みDNS_PINGについて順を追って紹介します。

JBoss EAP CD for OpenShift

JBoss EAP Continuous Delivery for OpenShiftとは、2018年4月から3〜4ヶ月単位でリリースされている、OpenShift環境向けのEAPのバイナリです。今のところはTechnology Preview*2で、今後導入される予定のEAP新機能を先行して試す検証用を目的としています。EAP CDの3〜4つのリリースを組み合わせて、従来の同様にフルサポートされるEAPがリリースされるモデルとなっています。

従来のEAPと異なり、zip版はリリースされておらず、Red Hat Container Catalogを通じて、コンテナイメージをダウンロードする方式のみ提供されています。

デフォルトのプロファイルは standalone-openshift.xml であり、コンテナ起動時に /opt/eap/bin/launch に含まれるスクリプト類でstandalone-openshift.xmlsedで色々置換して設定を作成する仕組みになっています。

OpenShiftがなくても、手元のDockerで起動してイメージの中身を確認できます。イメージにはbashが入っているので、通常のEAPとの差分については /opt/eap を見てみてください。

$ docker run --name eap-cd -p 8080:8080 registry.access.redhat.com/jboss-eap-7-tech-preview/eap-cd-openshift:14.0-4
$ docker exec -it eap-cd /bin/bash
cd /opt/eap

テストアプリケーションのデプロイ

OpenShiftの環境があることを前提に、テストアプリケーションのデプロイをします。
eap-debug/session-test at master · n-agetsu/eap-debug · GitHubにある、簡単なセッションレプリケーションの動作確認用アプリです。

EAP CD向けのImageStreamとTemplateの登録

はじめにEAP CD向けの最新のImageStreamとTemplateをプロジェクト名 openshift に登録します。12/4時点ではEAP CD 14です。

$ oc project openshift
$ for resource in eap-cd-image-stream.json   eap-cd-amq-persistent-s2i.json   eap-cd-amq-s2i.json   eap-cd-basic-s2i.json   eap-cd-https-s2i.json   eap-cd-mongodb-persistent-s2i.json   eap-cd-mongodb-s2i.json   eap-cd-mysql-persistent-s2i.json   eap-cd-mysql-s2i.json   eap-cd-postgresql-persistent-s2i.json   eap-cd-postgresql-s2i.json   eap-cd-sso-s2i.json   eap-cd-third-party-db-s2i.json   eap-cd-tx-recovery-s2i.json; do   oc replace --force -f https://raw.githubusercontent.com/jboss-container-images/jboss-eap-7-openshift-image/eap-cd/templates/${resource}; done

テンプレートによるアプリケーションのデプロイ

テンプレートeap-cd-basic-s2i を使ってセッションレプリケーションテスト用のアプリケーションをデプロイします。

$ oc new-app --template=eap-cd-basic-s2i -p IMAGE_STREAM_NAMESPACE=openshift -p SOURCE_REPOSITORY_URL="https://github.com/n-agetsu/eap-debug" -p SOURCE_REPOSITORY_REF="master" -p CONTEXT_DIR="session-test" -p APPLICATION_NAME="session-test"

テンプレートを実行すると、以下のような処理が行われます。

  1. https://github.com/n-agetsu/eap-debugをgit cloneして、eap-cd-openshiftイメージにソースコードを転送した上で、eap-cd-openshiftイメージに入っているMavenを利用してwarファイルをビルド。
  2. ビルドしたwarファイルを、eap-cd-openshiftイメージの /opt/eap/standalone/deployments に含めた、アプリケーションイメージを作成し、OpenShiftの内部レジストリにpush。eap-cd-openshiftのようにEAPのランタイムとMavenのようなビルドツールが入っているコンテナイメージを、OpenShiftではBuilder Imageと呼んでいて、Red Hat Container Catalogでは色々なBuilder Imageが公開されています。一方でBuilder Imageにビルド済みのwarファイルを追加して、Podとして起動するイメージをApplication Imageと呼んでいます。
  3. アプリケーションイメージのビルドが完了した後、DeploymentConfigの設定に沿ってアプリケーションイメージをPodとして起動。

アプリケーションイメージのPod起動が完了すると、以下のリソースが展開されます。ServiceやRouteなど、アプリケーションの動作に必要なリリースを個別に設定する必要はなく、テンプレートによって展開されます。

$ oc get all
NAME                       READY     STATUS      RESTARTS   AGE
pod/session-test-1-build   0/1       Completed   0          31m
pod/session-test-1-vldx2   1/1       Running     0          29m

NAME                                   DESIRED   CURRENT   READY     AGE
replicationcontroller/session-test-1   1         1         1         29m

NAME                        TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
service/session-test        ClusterIP   172.30.133.229   <none>        8080/TCP   31m
service/session-test-ping   ClusterIP   None             <none>        8888/TCP   31m

NAME                                              REVISION   DESIRED   CURRENT   TRIGGERED BY
deploymentconfig.apps.openshift.io/session-test   1          1         1         config,image(session-test:latest)

NAME                                          TYPE      FROM         LATEST
buildconfig.build.openshift.io/session-test   Source    Git@master   1

NAME                                      TYPE      FROM          STATUS     STARTED          DURATION
build.build.openshift.io/session-test-1   Source    Git@5b856c7   Complete   31 minutes ago   1m53s

NAME                                          DOCKER REPO                                                  TAGS      UPDATED
imagestream.image.openshift.io/session-test   docker-registry.default.svc:5000/cluster-test/session-test   latest    29 minutes ago

NAME                                    HOST/PORT                                         PATH      SERVICES       PORT      TERMINATION     WILDCARD
route.route.openshift.io/session-test   session-test-cluster-test.cloudapps.example.com             session-test   <all>     edge/Redirect   None
||< 


テンプレートの中身やパラメータの一覧を確認したい場合は、以下のコマンドで確認してください。
>|sh|
$ oc get template eap-cd-basic-s2i -o yaml -n openshift

アプリケーションの動作確認

テストアプリケーションでは、アクセスカウンタと共に、アクセスされたホスト名を返しています。eap-cd-basic-s2i ではPodのレプリカ数は1で起動するため、毎回同じホスト名が返されます。

$ curl --insecure -c cookie.txt https://session-test-cluster-test.cloudapps.example.com/session-test/count
count: 1, host: session-test-1-vldx2
$ curl --insecure -b cookie.txt https://session-test-cluster-test.cloudapps.example.com/session-test/count
count: 2, host: session-test-1-vldx2
$ curl --insecure -b cookie.txt https://session-test-cluster-test.cloudapps.example.com/session-test/count
count: 3, host: session-test-1-vldx2

Podのレプリカを2に増やしても、OpenShiftのRouterはデフォルトでソースIPベースのスティッキーセッションが有効化されているため、同じPodにアクセスし続けます。これではセッションレプリケーションが動いているかわからないため、レプリカ数を2に増やした後、元々アクセスしていたPodを削除してみます。

$ oc scale dc session-test --replicas=2
$ oc delete pod session-test-1-vldx2

$ curl --insecure -b cookie.txt https://session-test-cluster-test.cloudapps.example.com/session-test/count
count: 4, host: session-test-1-fhvjs

別のホストにアクセスしましたが、HTTPセッションは継続しています。セッションレプリケーションがなければ、Podをdeleteした時点でセッションは失われていたでしょう。

OpenShiftのデフォルトではマルチキャストは飛びません。テンプレートの中ではStatefulSetを使ってホスト名を固定し、クラスタメンバを静的に定義することもしていません。テンプレートでデプロイすると、jgroupsのDNS_PINGの仕組みを使ってクラスタメンバの探索を行なっています。

JGroupsのDNS_PING機能

JGroupsにはk8s向けのクラスタディスカバリの実装として、KUBE_PINGDNS_PINGの2種類の機能があります。コンテナイメージとしてはKUBE_PINGがデフォルトでstandalone-openshift.xmlに設定されていますが、テンプレートを利用するとコンテナ起動時にデフォルトでDNS_PINGに上書きします。

/opt/eap/standalone/configuration/standalone-openshift.xmlには、以下のようにJGroupsサブシステムの設定にopenshift.DNS_PINGが含まれます。

<subsystem xmlns="urn:jboss:domain:jgroups:6.0">
    <channels default="ee">
        <channel name="ee" stack="tcp"/>
    </channels>
    ...
    <stack name="tcp">
        <transport type="TCP" socket-binding="jgroups-tcp">
            <property name="use_ip_addrs">true</property>
        </transport>
        <protocol type="openshift.DNS_PING" ></protocol>
        <protocol type="MERGE3"/>

KUBE_PINGはざっくり言うと、k8sAPIサーバに同じラベルを持つPodのリストを問い合わせてクラスタメンバを見つけています。OpenShift上でKUBE_PINGを動作させようとすると、 アプリケーションからk8sREST APIへのアクセスを許可する設定が必要ですが、DNS_PINGではOpenShift側の設定は不要なためシンプルに使えます。

DNS_PINGの仕組み

ここまで長くなりましたが、ここからが今日のタイトルの話です。

DNS_PINGはOpenShift上のDNSサーバのSRVレコードの仕組みを使って、クラスタメンバを見つけています。実際にレコードを内容を見てみると想像が付きやすいので、OpenShift上の任意のノードにSSHログインしてnslookupしてみます。

$ nslookup -type=SRV _tcp.session-test-ping.cluster-test.svc.cluster.local
Server:		192.168.122.2
Address:	192.168.122.2#53

_tcp.session-test-ping.cluster-test.svc.cluster.local	service = 10 50 8888 fd149e20.session-test-ping.cluster-test.svc.cluster.local.
_tcp.session-test-ping.cluster-test.svc.cluster.local	service = 10 50 8888 14a2d9.session-test-ping.cluster-test.svc.cluster.local.

ホスト名のようなものが出てくるため、pingしてみます。

$ ping fd149e20.session-test-ping.cluster-test.svc.cluster.local.
PING fd149e20.session-test-ping.cluster-test.svc.cluster.local (10.130.0.11) 56(84) bytes of data.
64 bytes from 10.130.0.11 (10.130.0.11): icmp_seq=1 ttl=64 time=1.51 ms

oc get pod -o wideで確認すると、SRVレコードで取得できたFQDNfd149e20.session-test-ping.cluster-test.svc.cluster.local.はPodのIPアドレス10.130.0.11とわかります。

$ oc get pod -o wide
NAME                   READY     STATUS      RESTARTS   AGE       IP            NODE                           NOMINATED NODE
session-test-1-build   0/1       Completed   0          1h        10.130.0.9    node01.openshift.example.com   <none>
session-test-1-vldx2   1/1       Running     0          1h        10.130.0.11   node01.openshift.example.com   <none>
session-test-1-xmqpc   1/1       Running     0          41m       10.130.0.12   node01.openshift.example.com   <none>

k8sの機能として、ロードバランシング用のCluster IPを持たない(clusterIP: none) Headless Serviceがデプロイされた場合、Serviceに関連づけられたPodのホスト名とServiceが公開しているポート番号を、k8s内部のDNSSRVレコードに登録する仕組み*3があります。

OpenShiftのテンプレートでは、EAP-CD(eap-cd-basic-s2i)向けだけに止まらず、EAP64やEAP71のイメージにおいても、HTTPアクセスのためのServiceとは別に、以下のようなクラスタディスカバリ向けのServiceを登録しています。

$ oc get svc session-test-ping -o yaml
apiVersion: v1
kind: Service
metadata:
  ...
spec:
  clusterIP: None
  ports:
  - name: ping
    port: 8888
    protocol: TCP
    targetPort: 8888
  selector:
    deploymentConfig: session-test
  sessionAffinity: None
  type: ClusterIP

もう一度nslookupの例に戻ります。

$ nslookup -type=SRV _tcp.session-test-ping.cluster-test.svc.cluster.local
  ...
_tcp.session-test-ping.cluster-test.svc.cluster.local	service = 10 50 8888 fd149e20.session-test-ping.cluster-test.svc.cluster.local.
_tcp.session-test-ping.cluster-test.svc.cluster.local	service = 10 50 8888 14a2d9.session-test-ping.cluster-test.svc.cluster.local.

nslookupのクエリ対象である、_tcp.session-test-ping.cluster-test.svc.cluster.localは、前述のService session-test-ping の追加によって登録されたDNSレコードです。k8sのServiceを追加すると、今のような命名規則SRVレコードが追加されます。

<Serviceのprotocol>.<Service名>.<プロジェクト名(namespace)>.svc.cluster.local

SRVレコードは以下のような情報を含んでいます。PriorityとWeightはSRVレコードを読み取るクライアントが、負荷分散の重み付けのために使う情報です。DNS_PINGは負荷分散ではなく単純にクラスタディスカバリにしかSRVを使っていないため、現時点での実装では重み付け情報は使っていません。SRVレコードのポート番号も現時点での実装では使っていません。クラスタ間通信にはデフォルトで7600ポート(TCP)が使われます。

service = 10 50 8888 fd149e20.session-test-ping.cluster-test.svc.cluster.local.
              = (Priority) (Weight) (ポート番号) (ホスト名)

このようにJGroupsはSRVレコードからクラスタメンバの情報を取得しています。一度メンバの探索ができたら、その後FDとFD_ALLで定期的にヘルスチェックを行い障害検知を行うのは、従来のTCPユニキャストベースのクラスタと同じです。

このDNS_PINGソースコードは以下にあります。
GitHub - jboss-openshift/openshift-ping: JGroups PING protocols for OpenShift

今回のテンプレートeap-cd-basic-s2iのように、テンプレートでは必要な関連リソースを一緒にデプロイしてくれるため、慣れないと中身で何やっているかわからない不安はありますが、テンプレートを使う方がハマりにくいです。

おそらく初めてEAPをOpenShiftにデプロイするときに、テンプレートなしでJGroups向けのHeadless Serviceのデプロイに思い当たる人はなかなかいないと思います。プロジェクト固有のテンプレートが必要な場合、まずは既存のテンプレートで慣れてから、テンプレート作成に着手した方が近道だと思います。

まとめ

  • JBoss EAP CD for OpenShiftは従来のEAPと比較してインクリメンタルなモデルでリリースしている、コンテナイメージ形式リリースのバイナリです。今のところTechnology Previewのため評価検証向けです。
  • JBoss EAP CD for OpenShiftの最新版をOpenShiftで利用するためには前述の通りImageStreamとTemplateの更新をします
  • マルチキャストが使えない環境のために、DNS_PINGと呼ばれる、Headless ServiceによるSRVレコードの参照により、クラスタを構成しています
  • 慣れないうちはテンプレートを使うのがおすすめです

明日は@orimanabuさんです、お楽しみに!

OpenShift Origin 3.9のインストール

OpenShift Origin 3.9のセットアップ手順メモ。

前提条件は以下の通り。

  • OpenShift用DNSサーバ(dnsmasq)、OpenShift Master 1台、 Node 1台
  • VirtualBoxのブリッジネットワーク上に構成
  • ゲストマシンのOSは全てCentOS7

DNSサーバのセットアップ

・dnsmasqのインストール

# yum install -y dnsmasq

・/etc/hostsのの追記
dnsmasqは/etc/hostsを設定ファイルとして読み込み、名前解決を行う。

192.168.11.10 dns dns.openshift.example.com
192.168.11.11 master master.openshift.example.com
192.168.11.12 node01 node01.openshift.example.com

・/etc/dnsmasq.confの編集
DNSクエリ要求に応答するドメインの設定(local=)、Route用のワイルドカードDNS(address=)、DNSリクエスト待ち受けのネットワークインタフェース(interface=)、DHCPサーバ機能の無効化(no-dhcp-interface=)を設定。

# diff -u /etc/dnsmasq.conf.org /etc/dnsmasq.conf

--- /etc/dnsmasq.conf.org       2018-05-26 12:08:03.447080188 +0900
+++ /etc/dnsmasq.conf   2018-05-26 15:02:48.609040878 +0900
@@ -72,11 +72,13 @@
 # Add local-only domains here, queries in these domains are answered
 # from /etc/hosts or DHCP only.
 #local=/localnet/
+local=/openshift.example.com/

 # Add domains which you want to force to an IP address here.
 # The example below send any host in double-click.net to a local
 # web-server.
 #address=/double-click.net/127.0.0.1
+address=/cloudapps.example.com/192.168.11.11

 # --address (and --server) work with IPv6 addresses too.
 #address=/www.thekelleys.org.uk/fe80::20d:60ff:fe36:f83
@@ -103,7 +105,7 @@
 # specified interfaces (and the loopback) give the name of the
 # interface (eg eth0) here.
 # Repeat the line for more than one interface.
-#interface=
+interface=enp0s3
 # Or you can specify which interface _not_ to listen on
 #except-interface=
 # Or which to listen on by address (remember to include 127.0.0.1 if
@@ -112,7 +114,7 @@
 # If you want dnsmasq to provide only DNS service on an interface,
 # configure it as shown above, and then use the following line to
 # disable DHCP and TFTP on it.
-#no-dhcp-interface=
+no-dhcp-interface=enp0s3

 # On systems which support it, dnsmasq binds the wildcard address,
 # even when it is listening on only some interfaces. It then discards

DNSサーバの起動
ここではあくまでローカルの実験用環境のため、DNSリクエストのポート53へのアクセス許可はfirewalldを切って対処。

# systemctl stop firewalld
# systemctl disable firewalld
# systemctl enable dnsmasq
# systemctl start dnsmasq

DNSサーバのネットワーク設定
DNS1はOpenShift用に起動した内部ネットワーク用のdnsmasqサーバ。DNS2はWebアクセス用のDNSサーバ。

# vim /etc/sysconfig/network-scripts/ifcfg-enp0s3
TYPE=Ethernet
BOOTPROTO=none
NAME=enp0s3
UUID=4590c759-b0cd-4dc2-9f43-779201c6593c
DEVICE=enp0s3
ONBOOT=yes
IPADDR=192.168.11.10
PREFIX=24
GATEWAY=192.168.1.1
DNS1=127.0.0.1
DNS2=192.168.11.1

# hostnamectl set-hostname dns.openshift.example.com
# systemctl restart network

Masterのセットアップ

・Masterサーバのネットワーク設定

# vim /etc/sysconfig/network-scripts/ifcfg-enp0s3
TYPE=Ethernet
BOOTPROTO=none
NAME=enp0s3
UUID=4590c759-b0cd-4dc2-9f43-779201c6593c
DEVICE=enp0s3
ONBOOT=yes
IPADDR=192.168.11.11
PREFIX=24
GATEWAY=192.168.11.1
DNS1=192.168.11.10

# hostnamectl set-hostname master.openshift.example.com
# systemctl restart network

・必要パッケージのインストール

# yum install -y wget git net-tools bind-utils yum-utils iptables-services bridge-utils bash-completion kexec-tools sos psacct unzip
# yum update
# systemctl reboot

OpenShift公式ドキュメントのHost Preparation Installing Base Packagesの通りにインストールすると、後述のAnsibleによるインストール時に以下のエラーが発生したため、unzipのインストールを追加している。

TASK [etcd : Unarchive cert tarball] **************************************************************************************
fatal: [master.openshift.example.com]: FAILED! => {"changed": false, "msg": "Failed to find handler for \"~None/.ansible/tmp/ansible-tmp-1527315221.2-254636300880053/source\". Make sure the required command to extract the file is installed. Command \"unzip\" not found. Command \"/usr/bin/gtar\" could not handle archive."}

・Advanced Instllationに必要なパッケージのインストール

# yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
# sed -i -e "s/^enabled=1/enabled=0/" /etc/yum.repos.d/epel.repo
# yum -y --enablerepo=epel install ansible pyOpenSSL

・OpenShift3.9のダウンロード

# cd ~
# git clone https://github.com/openshift/openshift-ansible
# cd openshift-ansible
# git checkout release-3.9

・Dockerのインストール

# yum install -y docker-1.13.1
# systemctl enable docker
# systemctl start docker

Nodeのセットアップ

・Nodeサーバのネットワーク設定

# vim /etc/sysconfig/network-scripts/ifcfg-enp0s3
TYPE=Ethernet
BOOTPROTO=none
NAME=enp0s3
UUID=4590c759-b0cd-4dc2-9f43-779201c6593c
DEVICE=enp0s3
ONBOOT=yes
IPADDR=192.168.11.12
PREFIX=24
GATEWAY=192.168.11.1
DNS1=192.168.11.10

# hostnamectl set-hostname node01.openshift.example.com
# systemctl restart network

・必要パッケージのインストール
Masterと同じパッケージをインストール

# yum install -y wget git net-tools bind-utils yum-utils iptables-services bridge-utils bash-completion kexec-tools sos psacct unzip
# yum update
# systemctl reboot

・Dockerのインストール

# yum install -y docker-1.13.1
# systemctl enable docker
# systemctl start docker

OpenShift Originのインストール

Masterサーバ上で以下を実行する。

SSHログイン時の対話型パスワード入力の抑止

# ssh-keygen
# for host in master.openshift.example.com node01.openshift.example.com; do ssh-copy-id -i ~/.ssh/id_rsa.pub $host; done

・/etc/ansible/hostsの設定

[OSEv3:children]
masters
nodes
etcd

[OSEv3:vars]
ansible_ssh_user=root
openshift_deployment_type=origin
openshift_master_default_subdomain=cloudapps.example.com
openshift_disable_check=memory_availability,disk_availability

[masters]
master.openshift.example.com

[etcd]
master.openshift.example.com

[nodes]
master.openshift.example.com openshift_node_labels="{'region': 'infra', 'zone': 'default'}"
node01.openshift.example.com openshift_node_labels="{'region': 'primary', 'zone': 'default'}"

・OpenShiftインストールの実行

# ansible-playbook -i /etc/ansible/hosts ~/openshift-ansible/playbooks/prerequisites.yml
# ansible-playbook -i /etc/ansible/hosts ~/openshift-ansible/playbooks/deploy_cluster.yml

インストール後の初期セットアップ

後日追記予定