見習いプログラミング日記

Javaを中心に色々なことを考えてみます。目指せ本物のプログラマ。

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

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

後日追記予定

minikube startのプロキシ越え

プロキシ環境化でminikubeのセットアップにはまったのでメモ。
詳細は https://github.com/kubernetes/minikube/issues/530 を参照。

環境

プロキシ環境化でのminikube start方法

export HTTP_PROXY=http://xxx.xxx.xxx.xxx:xxxx
export HTTPS_PROXY=http://xxx.xxx.xxx.xxx:xxxx
export NO_PROXY=192.168.99.100

minikube start --vm-driver virtualbox --docker-env HTTP_PROXY=$HTTP_PROXY --docker-env HTTPS_PROXY=$HTTPS_PROXY

環境変数NO_PROXYの設定がない場合、minikube startがタイムアウトして起動しない。

Starting local Kubernetes v1.10.0 cluster...
Starting VM...
Downloading Minikube ISO
 150.53 MB / 150.53 MB [============================================] 100.00% 0s
Getting VM IP address...
Moving files into cluster...
Downloading kubeadm v1.10.0
Downloading kubelet v1.10.0
Finished Downloading kubelet v1.10.0
Finished Downloading kubeadm v1.10.0
Setting up certs...
Connecting to cluster...
Setting up kubeconfig...
Starting cluster components...
E0427 16:33:36.335447    3153 start.go:276] Error starting cluster:  timed out waiting to unmark master: getting node minikube: Get https://192.168.99.100:8443/api/v1/nodes/minikube: Service Unavailable

miniikube startして1〜2分しても応答がない場合、minikube logs -f でログを確認すると、以下のようなログ数行間隔で繰り返し出力されている。

minikube logs -f 
Apr 27 07:32:45 minikube kubelet[3541]: E0427 07:32:45.564897    3541 reflector.go:205] k8s.io/kubernetes/pkg/kubelet/kubelet.go:460: Failed to list *v1.Node: Get https://192.168.99.100:8443/api/v1/nodes?fieldSelector=metadata.name%3Dminikube&limit=500&resourceVersion=0: dial tcp 192.168.99.100:8443: getsockopt: connection refused

起動に途中で失敗した場合、環境をクリーンにした上でリトライする。

minikube delete
rm -rf ~/.minikube

Elasticsearchのインデックス開きすぎによるヒープメモリ枯渇

この記事はElastic stack Advent Calendar 2017の12/6分の記事です。

ElasticスタックによるApacheアクセスログやsar情報などのメトリクス収集を初めて導入した後の頻出トラブルとして、インデックスのオープンしすぎによるJavaヒープメモリ枯渇がある。

検索エンジン用途や、運用監視業務に組み込むような「本気の」運用では、事前にサイジングが行われる。しかし、まずはシステム状況が可視化できるかお試しで導入を始めると、とりあえず運用を始め、インデックスのクローズや削除、スナップショットの定期取得などの運用管理計画はどうしても漏れがちとなる。

では、具体的にElasticsearchはだいたい何ヶ月分のメトリクスが保存できるのが次の疑問になるが、以下のような多様な要素が作用するため、要件に合わせて実機検証が必要となる。

  • 登録するメトリクスの種類
    • Apacheアクセスログだけか、sar ALL相当も含めるか、MetricBeatなどのBeatsは使うか、Beatsを使うなら何台分のマシン情報を収集するのか など
  • Mapping設計
    • フィールド数が増えるほど、ElasticsearchのJavaヒープメモリ使用量は増える
  • Kibanaダッシュボード内容や表示期間、アクセス頻度

ここまではWeb上によくある情報だが、では実際にどの程度Elasticsearchに保持できるのか、実測してみた。

どの程度Elasticsearchに保存できるのか

以下の測定条件で実測してみた。

  • Elasticsearch 6.0.0
  • クラスタ構成。1インスタンスのみ。
  • 登録するのはApacheのCOMBINEDログのみ
  • シャード数はデフォルトの5
  • Javaヒープメモリサイズはデフォルトの1GB
    • Logstashのデフォルトに沿って、日別インデックス (logstash-%{yyyymmdd})
    • 具体的なLogstash設定は以下の通り
input { stdin {} }

filter {
  grok { match => { "message" => "%{HTTPD_COMBINEDLOG}" } }

  date {
    match => ["timestamp", "dd/MMM/yyyy:HH:mm:ss Z"]
    remove_field => ["timestamp"]
  }

  useragent {
    source => "agent"
    target => "useragent"
    remove_field => ["agent"]
  }

  mutate {
    remove_field => ["message"]
  }
}

output {
  elasticsearch {}
}
測定結果
  • 1インデックスあたり1ドキュメントの場合
    • 約1100インデックス(5500シャード)でCMS-GCループによる応答遅延発生
  • 1インデックスあたり10000ドキュメントの場合
    • 約500インデックス(2500シャード)でCMS-GCループによる応答遅延発生

Javaヒープメモリが枯渇し始め、GCのオーバヘッドが高くなると、以下のようなログがelasticsearch.logに大量に出力される。

[2017-12-05T18:02:17,978][WARN ][o.e.m.j.JvmGcMonitorService] [lvj6mYE] [gc][4474] overhead, spent [1.3s] collecting in the last [2.3s]

インデックスの大量作成を行い、Javaヒープメモリが枯渇した状態では、GCログは以下のような状態となる。水色の縦線がCMS-GCの発生を示しており、やがてヒープが完全に枯渇し始めると黒色の縦線のFull GCが連続して発生する。
f:id:n_agetsuma:20171205182809p:plain
1インデックスあたりの1ドキュメントで5500シャード作成した場合、ヒープダンプを取得してヒストグラムを取得すると、以下のようになる。5500インスタンスのクラスが複数存在し、Elasticsearchの内部実装として、シャード数ごとにインスタンス生成があり、シャードが増えるほどJavaヒープメモリ消費量が増えることが考察できる。
f:id:n_agetsuma:20171205184057p:plain

実測結果からの考察

ヒープ拡大やスケールアウトにより、より大量のインデックスをオープン状態にすることは可能なため、この結果からは確かなことは言えない。

しかし、1ノードでオープンできるシャード数は数十万などの大きなものではなく、多くても数千単位であることが考察できる。非クラスタ構成のElasticsearchでは、以下のような前提においては1〜2ヵ月分程度のインデックスが一度にオープンできてKibanaから閲覧できる量である。それより古いドキュメントはスナップショットを取得して削除するか、"Hot-Warmアーキテクチャ"に代表される、クラスタのdataノード構成を考える必要がある。

  • Apacheアクセスログ相当のフィールド数を持つメトリクスを10種類収集。
    • メトリクスごとにインデックス名を分ける。1日10インデックス。
    • シャード数はデフォルトの5
  • クラスタ構成のElasticsearch
  • 1日あたり、各メトリクスごとに10000ドキュメント

まとめ

Apacheアクセスログを対象に、Elasticsearchのデフォルト設定の場合、1ノードでどの程度のインデックスをオープンするとJavaヒープメモリ1GBが枯渇するか、実測してみました。

  • 1インデックス1ドキュメントの場合: 5500シャード
  • 1インデックス10000ドキュメントの場合: 11000シャード

環境や保存したいデータによっても結果は異なるため、あくまで上記は実測例です。

初めてElasticスタックによるメトリクス収集を導入する場合、Elasticsearchに蓄積したインデックスは定期的にクローズしないと、ディスクに余裕があっても、Javaヒープメモリが枯渇してElasticsearchが無応答になることに注意が必要です。