以前、WildFly SwarmとConsulを使った、Service Discoveryを試しました。
http://d.hatena.ne.jp/Kazuhira/20170114/1484401806
今度は、WildFly Swarm+Consulの組み合わせに、さらにRibbonを足してロードバランシングまで行ってみます。
Ribbonとは?
Netflix OSSのうちのひとつで、クライアントサイドでのロードバランシングを実現してくれるものみたいです。
バックエンドにいるサーバーをどのようにして知るかは、あらかじめ列挙済みのサーバーのリストだったり、同じくNetflix OSSの
Eurekaと統合したりする方法があるようです。
今回は、WildFly SwarmのService Discoveryの仕組みと組み合わせて使うことになります。
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クラスを使用するようになり、
またバランシングのアルゴリズムはラウンドロビンとなるように設定されます。
TopologyServerListクラスは、WildFly SwarmのTopologyを使用したサーバーのアドレスとポートの組み合わせの解決をしてくれる
クラスとなり、ここでConsulが隠蔽されています(実際にはtopology-consulが頑張る、と)。
また、アプリケーションの起動クラスを作成する際にTopologyArchiveを使用してもRibbonArchiveを使用してもいいみたいなことを
書いていましたが、RibbonArchiveが指定されていなければ自動的に登録しようとするみたいです。
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を使って実現していることが確認できると思います。