CLOVER🍀

That was when it all began.

WildFly SwarmのConsul+Ribbon IntegrationでService Discovery+Load Balancing

以前、WildFly SwarmとConsulを使った、Service Discoveryを試しました。

http://d.hatena.ne.jp/Kazuhira/20170114/1484401806

今度は、WildFly Swarm+Consulの組み合わせに、さらにRibbonを足してロードバランシングまで行ってみます。

NetflixOSS

Ribbonとは?

Netflix OSSのうちのひとつで、クライアントサイドでのロードバランシングを実現してくれるものみたいです。

GitHub - Netflix/ribbon: Ribbon is a Inter Process Communication (remote procedure calls) library with built in software load balancers. The primary usage model involves REST calls with various serialization scheme support.

バックエンドにいるサーバーをどのようにして知るかは、あらかじめ列挙済みのサーバーのリストだったり、同じくNetflix OSS
Eurekaと統合したりする方法があるようです。

今回は、WildFly SwarmのService Discoveryの仕組みと組み合わせて使うことになります。

Topology

WildFly Swarmで使えるService Discoveryには、JGroups、Consulがありますが、今回はConsulを使用します。

Topology using Hashicorp Consul

サンプルを作るにあたり、WildFly SwarmのRibbon/Consulの組み合わせのサンプルも参考にしています。

thorntail-examples/ribbon-consul at 2017.3.2 · thorntail/thorntail-examples · GitHub

構成

クライアントサイドロードバランシングを行うということでRibbonと統合するわけですが、今回は次のような構成とします。


  • Consulクラスタとして、Consul Serverをひとつ用意
  • Backendの簡単なJAX-RSアプリケーションを2インスタンス、ConsulとRibbonを導入しつつ構成
  • Ribbonによってクライアントサイドロードバランシングを行うJAX-RSアプリケーションをひとつ構成
  • アクセスはcurlで行う

サーバーの種類と名前は、こんな感じで。

  • Consul Server(consulserver)
  • Backend Server(backendserver1、backendserver2)
  • Frontend Server(frontendserver)
  • アクセス元(localhost

curlでFrontend Serverで動作しているアプリケーションを呼び出すと、BackendのServerへの呼び出しが行われることを
確認します。

実装は、さっくりとScalaで行います。

準備

今回は、Mavenのマルチプロジェクトとして構成します。

ディレクトリ構成は、こんな感じ。

pom.xml
backend/pom.xml
backend/src/main/scala
frontend/pom.xml
frontend/src/main/scala

親のpom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.littlewings</groupId>
    <artifactId>ribbon-consul-integration</artifactId>
    <packaging>pom</packaging>
    <version>0.0.1-SNAPSHOT</version>
    <modules>
        <module>frontend</module>
        <module>backend</module>
    </modules>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>

        <scala.major.version>2.12</scala.major.version>
        <scala.version>${scala.major.version}.1</scala.version>
        <scala.maven.plugin.version>3.2.2</scala.maven.plugin.version>

        <wildfly.swarm.version>2017.3.2</wildfly.swarm.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.wildfly.swarm</groupId>
                <artifactId>bom-all</artifactId>
                <version>${wildfly.swarm.version}</version>
                <scope>import</scope>
                <type>pom</type>
            </dependency>
            <dependency>
                <groupId>org.scala-lang</groupId>
                <artifactId>scala-library</artifactId>
                <version>${scala.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>net.alchim31.maven</groupId>
                    <artifactId>scala-maven-plugin</artifactId>
                    <version>${scala.maven.plugin.version}</version>
                    <executions>
                        <execution>
                            <goals>
                                <goal>compile</goal>
                                <goal>testCompile</goal>
                            </goals>
                        </execution>
                    </executions>
                    <configuration>
                        <scalaVersion>${scala.version}</scalaVersion>
                        <args>
                            <arg>-Xlint</arg>
                            <arg>-unchecked</arg>
                            <arg>-deprecation</arg>
                            <arg>-feature</arg>
                        </args>
                        <recompileMode>incremental</recompileMode>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.wildfly.swarm</groupId>
                    <artifactId>wildfly-swarm-plugin</artifactId>
                    <version>${wildfly.swarm.version}</version>
                    <executions>
                        <execution>
                            <goals>
                                <goal>package</goal>
                            </goals>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

では、各アプリケーションを作成していきます。

Backend

最初は、Backendと名づけたアプリケーションを作成してみましょう。

pom.xmlは、こんな感じ。あとで作成するmainクラスをWildFly SwarmのMavenプラグインに設定しておきます。基本的な設定は、上位のpom.xml
設定済みです。
backend/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>ribbon-consul-integration</artifactId>
        <groupId>org.littlewings</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>backend</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>org.scala-lang</groupId>
            <artifactId>scala-library</artifactId>
        </dependency>
        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>jaxrs</artifactId>
        </dependency>
        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>ribbon</artifactId>
        </dependency>
        <dependency>
          <groupId>org.wildfly.swarm</groupId>
          <artifactId>topology-consul</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>net.alchim31.maven</groupId>
                <artifactId>scala-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.wildfly.swarm</groupId>
                <artifactId>wildfly-swarm-plugin</artifactId>
                <configuration>
                    <mainClass>org.littlewings.wildflyswarm.ribbon.backend.App</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

ポイントとしては、WildFly Swarmの提供するRibbonおよびConsulへの依存関係を追加しておくことです。

        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>ribbon</artifactId>
        </dependency>
        <dependency>
          <groupId>org.wildfly.swarm</groupId>
          <artifactId>topology-consul</artifactId>
        </dependency>

まず、簡単なサンプルとして、アクセスされたら時刻とサーバー名を返すJAX-RSリソースクラスを作成します。まあ、WildFly Swarmの
サンプルに習ったものですね。
backend/src/main/scala/org/littlewings/wildflyswarm/ribbon/backend/TimeResource.scala

package org.littlewings.wildflyswarm.ribbon.backend

import java.net.InetAddress
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.ws.rs.core.MediaType
import javax.ws.rs.{GET, Path, Produces}

import scala.collection.JavaConverters._

@Path("time")
class TimeResource {
  @GET
  @Path("now")
  @Produces(Array(MediaType.APPLICATION_JSON))
  def now: java.util.Map[String, String] =
    Map("now" -> LocalDateTime.now.format(DateTimeFormatter.ISO_DATE_TIME),
      "from" -> InetAddress.getLocalHost.getHostName)
      .asJava
}

特にここまで、RibbonやConsulに依存した内容は出てきません。

最後は、アプリケーションのエントリポイント。
backend/src/main/scala/org/littlewings/wildflyswarm/ribbon/backend/App.scala

package org.littlewings.wildflyswarm.ribbon.backend

import org.jboss.shrinkwrap.api.ShrinkWrap
import org.wildfly.swarm.Swarm
import org.wildfly.swarm.jaxrs.JAXRSArchive
import org.wildfly.swarm.netflix.ribbon.RibbonArchive
import org.wildfly.swarm.topology.TopologyArchive

object App {
  def main(args: Array[String]): Unit = {
    val swarm = new Swarm(args: _*)

    val deployment = ShrinkWrap.create(classOf[JAXRSArchive])
    deployment.addResource(classOf[TimeResource])
    deployment.addAllDependencies()
    deployment.as(classOf[TopologyArchive]).advertise("backend")
    // deployment.as(classOf[RibbonArchive]).advertise("backend")  // もしくはこちら

    swarm.start().deploy(deployment)
  }
}

TopologyArchiveもしくはRibbonArchiveを使用するとともに、advertiseメソッドで名前を指定してあげる必要があります。
名前を指定しないと、WildFly Swarmが生成するWARの妙に長い名前になって、扱いづらくなります。ここでは、
「backend」と指定しました。

    deployment.as(classOf[TopologyArchive]).advertise("backend")
    // deployment.as(classOf[RibbonArchive]).advertise("backend")  // もしくはこちら

Frontend

続いて、Backendのアプリケーションを呼び出す側のアプリケーションを作成していきます。

pom.xmlは、こちら。
frontend/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>ribbon-consul-integration</artifactId>
        <groupId>org.littlewings</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>frontend</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>org.scala-lang</groupId>
            <artifactId>scala-library</artifactId>
        </dependency>
        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>jaxrs</artifactId>
        </dependency>
        <dependency>
            <groupId>org.wildfly.swarm</groupId>
            <artifactId>ribbon</artifactId>
        </dependency>
        <dependency>
          <groupId>org.wildfly.swarm</groupId>
          <artifactId>topology-consul</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>net.alchim31.maven</groupId>
                <artifactId>scala-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.wildfly.swarm</groupId>
                <artifactId>wildfly-swarm-plugin</artifactId>
                <configuration>
                    <mainClass>org.littlewings.wildflyswarm.ribbon.frontend.App</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Backendと同じく、RibbonとConsulとの依存関係を足しておきます。

続いて、Backendとの接続部分を作っていきます。

まずは、現在時刻を取得するJAX-RSリソースクラスに対するインターフェースを作成。このインターフェースを元に
Ribbonにプロキシクラスを作成してもらいます(Scalaなのでtraitですが、要求されるのはJavaのinterface相当です)
frontend/src/main/scala/org/littlewings/wildflyswarm/ribbon/frontend/TimeService.scala

package org.littlewings.wildflyswarm.ribbon.frontend

import com.netflix.ribbon.RibbonRequest
import com.netflix.ribbon.proxy.annotation.{Http, Hystrix, ResourceGroup, TemplateName}
import io.netty.buffer.ByteBuf

@ResourceGroup(name = "backend")
trait TimeService {
  @TemplateName("now")
  @Http(method = Http.HttpMethod.GET, uri = "/time/now")
  //@Hystrix(fallbackHandler = Array(classOf[TimeFallbackHandler]))
  def now: RibbonRequest[ByteBuf]
}

ポイントは、@ResourceGroupでBackendのadvertiseで指定した値を設定すること

@ResourceGroup(name = "backend")

背後のHTTP呼び出しを行うためのメソッドを定義することです。実装は、Ribbonがプロキシを生成するので不要です。
@Httpアノテーションで、GETやPOSTなどの指定、リクエスト先のURLを設定します。

  @TemplateName("now")
  @Http(method = Http.HttpMethod.GET, uri = "/time/now")
  //@Hystrix(fallbackHandler = Array(classOf[TimeFallbackHandler]))
  def now: RibbonRequest[ByteBuf]

メソッドの戻り値は、RibbonRequest<ByteBuf>である必要があるみたいですが…。

接続先のIPアドレスやポートは、WildFly Swarm側のService Discoveryに解決してもらいます。

@Hystrixのアノテーションもサンプルを参考に付けているのですが、ちょっとうまく動かなかったので今回はパス…。
実装する場合は、こちらを参考に。
https://github.com/wildfly-swarm/wildfly-swarm-examples/blob/2017.3.2/ribbon-consul/events/src/main/java/org/wildfly/swarm/examples/netflix/ribbon/events/TimeService.java#L24-L26
https://github.com/wildfly-swarm/wildfly-swarm-examples/blob/2017.3.2/ribbon-consul/events/src/main/java/org/wildfly/swarm/examples/netflix/ribbon/events/TimeFallbackHandler.java

また、今回ははしょりますが、パラメータをつける場合は@Varアノテーションを使用するようです。

  @TemplateName("echo")
  @Http(method = Http.HttpMethod.GET, uri = "/message/echo")
  def echo(@Var("message") message: String): RibbonRequest[ByteBuf]

次に、こちらのアプリケーションのエンドポイントとなるJAX-RSリソースクラスも作成。
frontend/src/main/scala/org/littlewings/wildflyswarm/ribbon/frontend/FrontResource.scala

package org.littlewings.wildflyswarm.ribbon.frontend

import java.nio.charset.StandardCharsets
import javax.ws.rs.container.{AsyncResponse, Suspended}
import javax.ws.rs.core.MediaType
import javax.ws.rs.{GET, Path, Produces, QueryParam}

import com.fasterxml.jackson.databind.ObjectMapper
import com.netflix.ribbon.Ribbon
import io.netty.buffer.ByteBufInputStream

@Path("front")
class FrontResource {
  val objectMapper: ObjectMapper = new ObjectMapper

  @GET
  @Path("get-now")
  @Produces(Array(MediaType.APPLICATION_JSON))
  def get: java.util.Map[_, _] = {
    val byteBuf = Ribbon.from(classOf[TimeService]).now.execute()

    objectMapper.readValue(new ByteBufInputStream(byteBuf), classOf[java.util.Map[_, _]])
  }

  @GET
  @Path("get-now-async")
  @Produces(Array(MediaType.APPLICATION_JSON))
  def getAsync(@Suspended asyncResponse: AsyncResponse): Unit = {
    val observable = Ribbon.from(classOf[TimeService]).now.observe

    observable.subscribe { byteBuf =>
      val now = objectMapper.readValue(new ByteBufInputStream(byteBuf), classOf[java.util.Map[_, _]])
      asyncResponse.resume(now)
    }
  }
}

通常のクラスと違うのは、Ribbonを使ってインターフェースに対するプロキシを生成し、プロキシに対してコードを書いていくことです。

  def get: java.util.Map[_, _] = {
    val byteBuf = Ribbon.from(classOf[TimeService]).now.execute()

    objectMapper.readValue(new ByteBufInputStream(byteBuf), classOf[java.util.Map[_, _]])
  }

RibbonRequest#executeを呼び出すことで、同期呼び出しとなります。

RibbonRequest#observeを呼び出すと、Observableが返ってきてNon Blockingとなるので、この場合は非同期に返すように実装します。

  def getAsync(@Suspended asyncResponse: AsyncResponse): Unit = {
    val observable = Ribbon.from(classOf[TimeService]).now.observe

    observable.subscribe { byteBuf =>
      val now = objectMapper.readValue(new ByteBufInputStream(byteBuf), classOf[java.util.Map[_, _]])
      asyncResponse.resume(now)
    }
  }

アプリケーションの起動クラスは、Backend側とそれほど変わりません。
frontend/src/main/scala/org/littlewings/wildflyswarm/ribbon/frontend/App.scala

package org.littlewings.wildflyswarm.ribbon.frontend

import org.jboss.shrinkwrap.api.ShrinkWrap
import org.wildfly.swarm.Swarm
import org.wildfly.swarm.jaxrs.JAXRSArchive
import org.wildfly.swarm.netflix.ribbon.RibbonArchive
import org.wildfly.swarm.topology.TopologyArchive

object App {
  def main(args: Array[String]): Unit = {
    val swarm = new Swarm(args: _*)

    val deployment = ShrinkWrap.create(classOf[JAXRSArchive])
    deployment.addResource(classOf[FrontResource])
    deployment.addClass(classOf[TimeService])
    deployment.addAllDependencies()
    deployment.as(classOf[TopologyArchive]).advertise("frontend")
    // deployment.as(classOf[RibbonArchive]).advertise("frontend")  // もしくはこちら

    swarm.start().deploy(deployment)
  }
}

ここまでで、アプリケーションができあがりました。

Consul Serverの起動。

Consulクラスタを構成するので、最初にConsul Serverを起動しておきます。

$ ./consul agent -server -bootstrap -client=172.17.0.2 -data-dir=/var/lib/consul/data

その他のサーバーでは、単純なConsul Agentを起動しておきます。

## backend
$ ./consul agent -join=172.17.0.2 -data-dir=/var/lib/consul/data
$ ./consul agent -join=172.17.0.2 -data-dir=/var/lib/consul/data

## frontend
$ ./consul agent -join=172.17.0.2 -data-dir=/var/lib/consul/data

Consul Server側で、こんなログが出力されればクラスタに参加できています。

    2017/03/04 12:35:43 [INFO] consul: member 'backendserver1' joined, marking health alive
    2017/03/04 12:36:01 [INFO] serf: EventMemberJoin: backendserver2 172.17.0.4
    2017/03/04 12:36:01 [INFO] consul: member 'backendserver2' joined, marking health alive
    2017/03/04 12:36:05 [INFO] serf: EventMemberJoin: frontserver 172.17.0.5
    2017/03/04 12:36:05 [INFO] consul: member 'frontserver' joined, marking health alive

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

それでは、構成したアプリケーションをパッケージング、起動して動作確認してみます。

Uber JARを作成して、各ホストにできあがったJARファイルを配り

$ mvn package

Backend側を起動。Consulと組み合わせる場合は、「-Dswarm.bind.address」でバインドするIPアドレスを指定しておくことがポイントです。

## Backend1
$ java -Dswarm.bind.address=172.17.0.3 -jar /path/to/backend-0.0.1-SNAPSHOT-swarm.jar

## Backend2
$ java -Dswarm.bind.address=172.17.0.4 -jar /path/to//backend-0.0.1-SNAPSHOT-swarm.jar

Frontend側を起動。

$ java -Dswarm.bind.address=172.17.0.5 -jar /path/to/frontend-0.0.1-SNAPSHOT-swarm.jar

確認してみます。

現在時刻の同期取得。

$ curl -i http://172.17.0.5:8080/front/get-now
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Content-Length: 57
Date: Sat, 04 Mar 2017 12:54:06 GMT

{"now":"2017-03-04T12:54:06.453","from":"backendserver2"}

取得できるんですけど、アクセスが偏ります…なんででしょう…。

ここで、Backend2を落とすと1号機が現れるので、両方認識はできているみたいです。

$ curl -i http://172.17.0.5:8080/front/get-now
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Content-Length: 57
Date: Sat, 04 Mar 2017 12:54:44 GMT

{"now":"2017-03-04T12:54:44.633","from":"backendserver1"}

で、なんでアクセス先が偏るんだろうと思ったら、前も踏んだんでした…。この構成は、Dockerで実現しています…。

https://forums.docker.com/t/consul-dns-round-robin-works-for-host-but-not-for-containers/6663=title

まあいいか…。

非同期版もOKです。

$ curl -i http://172.17.0.5:8080/front/get-now-async
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Content-Length: 57
Date: Sat, 04 Mar 2017 14:00:15 GMT

{"now":"2017-03-04T14:00:15.741","from":"backendserver2"}

とりあえず、動かせました、と。

WildFly Swarm+Ribbonの仕掛け

依存関係を追加しただけで、RibbonとConsulが連携できたように見えますが、このあたりはRibbon向けのFractionが頑張っています。

RibbonFractionを使用すると、サーバーの一覧取得処理としてWildFly Swarmが提供するTopologyServerListクラスを使用するようになり、
またバランシングのアルゴリズムラウンドロビンとなるように設定されます。

https://github.com/wildfly-swarm/wildfly-swarm/blob/2017.3.2/fractions/netflix/ribbon/src/main/java/org/wildfly/swarm/netflix/ribbon/RibbonFraction.java

TopologyServerListクラスは、WildFly SwarmのTopologyを使用したサーバーのアドレスとポートの組み合わせの解決をしてくれる
クラスとなり、ここでConsulが隠蔽されています(実際にはtopology-consulが頑張る、と)。

https://github.com/wildfly-swarm/wildfly-swarm/blob/2017.3.2/fractions/netflix/ribbon/src/main/java/org/wildfly/swarm/netflix/ribbon/runtime/TopologyServerList.java

また、アプリケーションの起動クラスを作成する際にTopologyArchiveを使用してもRibbonArchiveを使用してもいいみたいなことを
書いていましたが、RibbonArchiveが指定されていなければ自動的に登録しようとするみたいです。

https://github.com/wildfly-swarm/wildfly-swarm/blob/2017.3.2/fractions/netflix/ribbon/src/main/java/org/wildfly/swarm/netflix/ribbon/runtime/RibbonArchiveAdvertiser.java#L17-L19

Secured Ribbonを使用していると、HTTPSを使用するようです。

Ribbon自体の使い方について

今回、Ribbonを使う際には、インターフェースからプロキシを生成する方法で使いました。

ドキュメント的には、こちらの使い方ですね。

Access HTTP resource using annotations

ribbon/ribbon at v2.1.0 · Netflix/ribbon · GitHub

もうちょっと直接APIを使う方法もあるみたいです。

Access HTTP resource using template

プロキシまわりのコードはこちらにあるので、実装内容が気になる方はこちらを。

https://github.com/Netflix/ribbon/tree/v2.1.0/ribbon/src/main/java/com/netflix/ribbon/proxy

アノテーションを解析して、JDKのProxyおよびInvocationHandlerを使って実現していることが確認できると思います。

まとめ

WildFly Swarmの統合の仕組みを使いつつ、ConsulおよびRibbonを組み合わせて使ってみました。

ちょっと用意がおおがかりになるので少々大変ですが、なかなか面白かったです。

今回作成したコード(+実験の跡)はこちらに置いています。