谷本 心 in せろ部屋

はてなダイアリーから引っ越してきました

Dapr Advent Calendar 22日目 - Dapr vs Spring Cloud

こんにちは Dapr Advent Calendar 22日目です。おとといから合宿をしていたため今日は更新が遅くなってしまいました。今回はDaprをSpring Cloudと比べてみたいと思います。

Spring Cloud vs Dapr

僕はもともとSpring BootとSpring Cloudを使って、いわゆるマイクロサービスを開発してました。

Spring CloudはいわゆるCloud Nativeな分散アプリケーションに必要となる要素をフルスタックで提供するプロダクトで、サービスディスカバリー、メッセージング/ストリーミング処理、分散トレーシング、サーキットブレイカーなどの機能をSpring Bootから扱いやすい形で提供しています。こんな機能をSpring Cloud以上に使いやすく提供しているプロダクトは、おそらく今のところ他にはないでしょう*1

一時期、僕はJava以外の言語、たとえばGoなどでマイクロサービスの開発をしようかと考えたこともあったのですが、結局はSpring Cloudを使いたいからSpring Boot、そのためにJavaJVM言語)を使うという選択をしていました。そうやって言語選択に影響するくらい、Spring Cloudは分散アプリケーション開発における重要なプロダクトでした。

そんなSpring Cloudと、Daprを、次の3つの機能で比べたいと思います。

  • サービス呼び出し
  • 非同期メッセージング
  • 分散トレーシング

この3つが、分散アプリケーションの柱ですよね。

1. サービス呼び出し

まずはアプリケーションから別アプリケーションを呼び出す機能を比較します。

DaprのInvoke API

Daprのサービス呼び出しはInvoke APIを利用します。このAdvent Calendarでも何度となく触れてきました。

cero-t.hatenadiary.jp

ソースコードは次のようになります。

@Value("${baseUrl}")
private String baseUrl;

@GetMapping("/invokeHello")
public Map<String, ?> invokeHello() {
    Map<?, ?> result = restTemplate.getForObject(baseUrl + "/hello", Map.class);
    return Map.of("baseUrl", baseUrl, "remoteMessage", result);
}

baseUrl の値に http://localhost:${DAPR_HTTP_PORT}/v1.0/invoke/hello-app/method というDaprのInvoke APIを指定することで、Dapr経由で対象のアプリケーションを呼ぶことができます。

Daprはアプリケーション名から対象サービスが動いているホストを発見するために、ローカル環境ではmDNS(マルチキャストDNS)を利用し、k8sではk8s自身が持つ名前解決機能を利用します。いずれも利用できない環境では、現時点ではConsulを利用する必要があります。

たとえば、Amazon EC2上でもConsulを利用すればDaprを動かすことができます。

cero-t.hatenadiary.jp

そのような場合を除けば、Daprは基本的にはサービスレジストリなしで運用できるところが強みですね。

Spring Cloud Service Discovery

Spring Cloudのサービス呼び出しは、名前解決のためにNetflix Eurekaを利用します。

spring.pleiades.io

名前解決のためのサーバとなるEureka Server(上で言うConsulに相当します)を立て、それぞれのアプリケーションではEureka Discovery Clientを利用して自分自身をEureka Serverに登録したり、他のアプリケーションの情報をEureka Serverから取得してアクセスするという仕組みになっています。

サーバ側であるEureka Serverのコードは次のようになります。

@EnableEurekaServer
@SpringBootApplication
public class ServiceRegistrationAndDiscoveryServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(ServiceRegistrationAndDiscoveryServiceApplication.class, args);
    }
}

@EnableEurekaServer アノテーションをつけたアプリケーションを起動するだけで、難しいことはありません。もちろん設定ファイルを追加して細かな設定をしたり、クラスタを組んだりすることができます。

クライアント側のソースコードは次のようになります。

@Value("${baseUrl}")
private String baseUrl;

@GetMapping("/invokeHello")
public Map<String, ?> invokeHello() {
    Map<?, ?> result = restTemplate.getForObject(baseUrl + "/hello", Map.class);
    return Map.of("baseUrl", baseUrl, "remoteMessage", result);
}

Dapr側のソースコードと同じです。

baseUrlhttp://hello-app というアプリケーション名さえ指定すれば、RestTemplateが自動的にEureka Discovery Clientを利用して対象のアプリケーションにアクセスします。URLのシンプルさで言えば、Daprを使うよりも分かりやすいですね。

どちらが良いか?

サービス呼び出しについて、DaprとSpring Cloud、どちらが良いでしょうか。

DNSを別に立てる必要がないDaprのほうが使いやすいですが、Spring Cloudはローカル環境でもEureka Serverを立てる必要があります。ただ、立てることは大して難しくないので、そこまでのデメリットとも言えません。

また、呼び出す際のURLはSpring Cloudのほうが分かりやすく、DaprのInvoke APIはどうしても長くなってしまいます。しかし、これもそこまで大きなデメリットと言えません。

判定としては「引き分け」というか、大差ないというのが正味の所ですね。

2. 非同期メッセージング

続いて、非同期メッセージングについて比較します。

DaprのPus/sub APIとSubscription

DaprではメッセージングのPub/sub APIを用いてメッセージを送信し、Subscriptionの設定ファイルとWeb APIを作るだけでメッセージを受信できます。

cero-t.hatenadiary.jp

メッセージを送る側のソースコードは次のようになります。

@Value("${pubsubUrl}")
private String pubsubUrl;

@PostMapping("/publish")
public void publish(@RequestBody MyMessage message) {
    restTemplate.postForObject(pubsubUrl, message, Void.class);
}

pubsubUrlhttp://localhost:${DAPR_HTTP_PORT}/v1.0/publish/rabbitmq-pubsub/my-message というDaprのPub/sub APIを指定して、メッセージブローカーにメッセージを送ることができます。

続いて、メッセージを受け取る側のソースコードです。次のようになります。

@PostMapping("/subscribe")
public void subscribe(@RequestBody CloudEvent<MyMessage> cloudEventMessage) {
    System.out.println("subscriber is called");
    System.out.println(message);
}

メッセージを受け取るWeb APIを作成するだけです。

このWeb APIを利用するために、次のような設定ファイルを作成します。

apiVersion: dapr.io/v1alpha1
kind: Subscription
metadata:
  name: subscription
spec:
  pubsubname: rabbitmq-pubsub
  topic: my-message
  route: /subscribe
scopes:
  - subscribe-app

詳細な説明は割愛しますが、メッセージブローカーからメッセージを受け取ると /subscribe という上で作ったWeb APIにアクセスするという設定を書いています。

シンプルに、やりたいことを実現できる感じです。

Spring Cloud Stream

Spring Cloudでメッセージングを行うためには、Spring Cloud Streamを利用します。

spring.pleiades.io

Spring Cloud Stream 2.xから3.xの間でAPIに大きな変化があり、よりストリーム処理に特化したような形になりました。ここでは3.xの記法で紹介します。

メッセージを送る側は次のようなソースコードになります。

@PostMapping("/publish")
public void publish(@RequestBody MyMessage message) {
    streamBridge.send("my-message-0", message);
}

StreamBridge クラスを使って、メッセージを送ります。

また、ソースコード内で指定したメッセージのキーに対して、どのメッセージブローカーに送るかを設定ファイルに書きます。

spring.cloud.stream.bindings.my-message-0.destination=my-message

ここで指定した値がRabbitMQのExchangeの名前として使われます。

続いて、メッセージを受け取る側のソースコードです。

@Bean
public Consumer<MyMessage> subscribe() {
    return (map) -> {
        System.out.println("subscriber is called");
        System.out.println(map);
    };
}

DaprのようなWeb APIではなく、java.util.funciton パッケージの ConsumerFunction などを使って実装します。

そして、メッセージを受け取った際にこのメソッド(Bean)を呼び出すよう設定ファイルを作成します。

spring.cloud.stream.bindings.subscribe-in-0.destination=my-message
spring.cloud.stream.bindings.subscribe-in-0.group=my-message-subscribe

上の値がExchangeの名前、下の値がQueueの名前として使われます。設定に若干クセがあって微妙に分かりづらいですね。

ただ、subscribe側を「API」などではなく「Function」と捉えて実装させるというのは納得感もあります。このバージョンのSpring Cloud Streamのソースコードを読んだことはないので推測になりますが、おそらくこの前段の処理は、メッセージブローカーからメッセージを1つずつ受け取って処理するのではなく、WebFluxを使ってノンブロッキングに複数の呼び出しをまとめているのでしょう。その方が性能的なメリットがありますからね。

どちらが良いか?

サービス呼び出しについては、DaprとSpring Cloud、どちらが良いでしょうか。

設定ファイルも含めた構成としては、Daprの方が分かりやすいです。しかし性能やメモリ使用量の少なさに関しては(計測したことはないですが)Spring Cloud Streamの方が上になると推測できます。特に大量のメッセージを同時に捌くような必要がある際には差が出そうです。

またDaprのほうはHTTPを使ってメッセージングをしていますが、Spring Cloud Streamでは独自クラスを使ってメッセージングをしています。そのためDaprの方がテスト時に別の処理に置き換えたり、curlコマンドでのテストがやりやすい一方で、HTTPを経由することのオーバーヘッドがあるとも言えます。

取り回しやすさではDaprに軍配が上がり、性能についてはSpring Cloudに軍配があがる、と言えるでしょうか。

3. 分散トレーシング

最後に、分散トレーシングについて比較します。

Daprの分散トレーシング対応

Daprでは設定ファイルを書くだけで分散トレーシングが有効になります。

cero-t.hatenadiary.jp

設定ファイルは次のような内容です。

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: daprConfig
spec:
  tracing:
    samplingRate: "1"
    zipkin:
      endpointAddress: http://localhost:9411/api/v2/spans

分散トレーシングのサンプリングレートやzipkinサーバのアドレスを指定するだけで、分散トレーシングが有効になります。

ただしトレースIDを自分で伝播させる必要があるため、次のようなコードを書く必要があります。

@GetMapping("/invokeHello")
public Map<String, ?> invokeHello(@RequestHeader("traceparent") String traceparent) {
    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.set("traceparent", traceparent);
    HttpEntity<?> request = new HttpEntity<>(httpHeaders);

    Map<?, ?> result = restTemplate.exchange(helloUrl + "/hello", HttpMethod.GET, request, Map.class).getBody();
    return Map.of("baseUrl", helloUrl, "remoteMessage", result);
}

HTTPヘッダで受け取った traceparent というヘッダの値を、次のリクエストのHTTPヘッダに渡しています。これをすべてのエンドポイントの処理に書くことは現実的ではないですから、何か共通的な処理にする必要があるでしょう。その辺りは自分で用意しなければなりません。

Spring Cloud Sleuth

Spring Cloudで分散トレーシングを行うには、Spring Cloud Sleuthを利用します。

spring.pleiades.io

Spring Cloud Sleuthをdependenciesに追加して次のように設定ファイルを作成します。

spring.sleuth.sampler.rate=100
spring.zipkin.sender.type=web
spring.zipkin.baseUrl=http://localhost:9411

このように設定すれば、分散トレーシングが有効になりZipkinにトレース情報が送られます。

分散トレーシングは、RestTemplateやWebClientによるHTTP通信や、Spring Cloud Streamによるメッセージングなどすべてが対象になりますし、Daprのほうで問題となったトレースIDの伝播も自動的に行われます。

それだけでなく、トレースIDなどの情報がロガーのMDC(Mapped Diagnostic Context)に保存されるため、ログの設定を書けばログにトレースIDなどを出力することができ、トレースIDを起点としたログ検索がやりやすくなります。

このような事が何の実装もなく実現でき、トレースIDの伝播も自動で行われるのは、Spring Cloudがフルスタックで提供されていることが引き出すメリットだと言えるでしょうね。

どちらが良いか?

分散トレーシングについては、トレースIDの伝播も自動的に行うSpring Cloudの方が便利だと言えます。

また、Spring Cloud Sleuthではキュー経由でトレース情報を送ることでトレーシングの余計な待ち時間を減らしたり、Zipkin以外の様々な分散トレーシングツールと連携したりすることも可能です。

さすがは5年以上の歴史あるプロダクトだと言ったところでしょうか。僕が分散トレーシングという言葉を知ったのもこのSpring Cloud Sleuthがキッカケでしたし、他のプロダクトに先駆けてフルスタックで機能を提供してきた筋の良いプロダクトだと思います。

総合的な比較

ここまでDaprとSpring Cloudの3機能を比べてきましたが、総合的に見て、どちらの方が良いのでしょうか。

分かりやすさやHTTPによる疎結合という点ではDaprの方が上ですが、非同期メッセージングの性能や分散トレーシングの機能はSpring Cloudに強みがあります。歴史の浅いプロダクトと、歴史あるプロダクトの差、と言い換えることができるかも知れません。またこの3機能だけではなく他の機能を含めて、あるいは世の中にある情報量の差なども考えれば、Spring Cloudの方が上だと言えるでしょう。

バージョンアップに伴う苦痛

それではなぜ、僕はSpring CloudをやめてDaprを選んだのでしょうか。一言でいうと「バージョンの互換性やバージョンアップに伴う問題」が要因でした。

たとえば古いバージョンのJavaとSpring Bootで運用しているシステムがあり、新システムではより新しいバージョンのJavaとSpring Bootで開発しようとした場合、それぞれのシステムでSpring Cloudを使おうとすると、それぞれのSpring Bootに対応したSpring Cloudのバージョンが異なってしまうため、そこで互換性が失われていることがありました。たとえばSpring Cloudのバージョンアップに伴って、内部で使っているEurekaのバージョンが上がった際にプロトコルが変わってしまったことがありましたし、また上にも述べたとおりSpring Cloud Streamは2.xと3.xで大きくAPIの形が変わっています。

であれば常にJava、Spring Boot、Spring Cloudを最新バージョンに更新し続ければ良いのかも知れません。ただ、Spring Cloudはバージョンアップに伴う作業が大がかりになりがちでした。これはCloud Nativeという分野が比較的新しいため、対応するプロダクトやトレンドが変わり、それにSpring Cloudが追従しようとした際に互換性を失わざるを得ないという面があったのだと思います。

またもう少しマイナーな問題としては、Spring Bootの提供スピードとSpring Cloudの提供スピードの違いや依存性の複雑さなどにより、Spring Bootのバージョンを上げようとすると、Spring Cloudがまだ対応していないとか、参照しているライブラリのバージョンが異なってエラーになることがありました。

その辺りの痛みを経験したことで、Spring Cloudが悪いというよりは、「Web APIを提供するアプリケーションと、その下支えをするインフラとの境界になるレイヤーは、もっと疎結合にして、それぞれ個別にバージョンアップできるようにしたほうが良い」と考えるようになったのです。

それがSpring CloudではなくDaprに使うに至った経緯です。

まとめ

  • サービス呼び出しについては、DaprもSpring Cloudもあまり変わらない
  • メッセージングについては、Daprの方がシンプルだがSpring Cloudのほうがおそらく性能が良い
  • 分散トレーシングについては、DaprよりもSpring Cloudの方が高機能だし使いやすい
  • Spring Cloudはバージョンアップに伴う苦痛が大きかった

ところで、最近しばらくSpring Cloudをあまり追っていなかったためドキュメントなどを見直しながら今回のブログを書いたのですが、Spring Cloudのドキュメントはどこに何があるのか少し分かりづらくなっていて、どうしちゃったのかなという気持ちになりました。歴史あるプロダクトに様々な機能がついて複雑になっていく中で、ドキュメントも複雑になっていくということがあるのかも知れません。もちろん、その辺りを補うようなガイドや、ブログ、発表資料なども多いのが、Springの良いところなのですけどね。

いずれにせよ、SpringはJavaのCloud Nativeアプリケーションの開発を牽引する立場として、今後も応援したいと思います。

それでは、また明日!

*1:もしあればこっそり教えてください