谷本 心 in せろ部屋

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

MacのDockerに別マシンからアクセスする。

Dockerでk8sやらミドルウェアやらを色々動かしているとマシンのリソースをそこそこ消費するので、NAS代わりにしているIntel Mac miniをDockerのサーバにできないかなと思って試行錯誤しました。

結論

まず、DockerのホストにしたいMac側で次のコマンドを打つ。

docker run \
    -it \
    --rm \
    --name=api-forwarder \
    -p 2375:2375 \
    -v /var/run/docker.sock:/var/run/docker.sock \
    alpine sh -c 'apk add --no-cache socat && socat TCP-LISTEN:2375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock'

そしてクライアントにしたいマシンで export DOCKER_HOST=tcp://(ホストのIP):2375 コマンドを打つ。.zshrc にでも書いておけば良い。

色々やった上でこの結論に至ったのですが、その経緯も書いておきます。

Macにsocatを入れると挙動が怪しかった

やりたいことでググるとすぐにこのエントリーが出てきました。 hawksnowlog.blogspot.com socatを使って2375ポートへのアクセスを /var/run/docker.sock のソケットに転送するようです。

まずMacにsocatをインストールして(brew install socat)次のコマンドを打ちます。

socat -d TCP-LISTEN:2375,range=127.0.0.1/32,reuseaddr,fork UNIX:/var/run/docker.sock

そしてクライアント側で export DOCKER_HOST=tcp://(ホストのIP):2375 をすれば、docker ps などのコマンドでホスト側のdockerにアクセスできるようになりました。

ただ動きはするものの、コマンドを打つたびにホスト側のMac(Dockerデーモンとsocatを動かしている方)にエラーが出ていました。

2023/01/22 00:51:18 socat[83752] E write(6, 0x7f7c29009000, 5): Broken pipe
2023/01/22 00:51:19 socat[83753] E write(6, 0x7f7c2a009000, 5): Broken pipe
2023/01/22 00:51:28 socat[83756] E write(6, 0x7f7c2a809000, 5): Broken pipe
2023/01/22 00:54:05 socat[83892] E write(6, 0x7f7c2a809000, 5): Broken pipe
2023/01/22 00:54:06 socat[83893] E write(6, 0x7f7c29009000, 5): Broken pipe
2023/01/22 00:54:15 socat[83894] E write(6, 0x7f7c2a809000, 5): Broken pipe

何これ。socatコマンドに慣れ親しんでないので原因がよく分かりません。どうあれ気持ち悪いので、別の方法を模索します。

Macでは daemon.js のhostsを書いても効果がない

令和5年においては、困りごとはChatGPT先生に聞いてみるというスタイルが主流です*1

ChatGPT先生との会話

どちらの回答も微妙でしたが、デーモンの設定に tcp://0.0.0.0:2375 を入れれば良いことが分かりました。なんだ、そんな簡単な方法があるなら早く言ってくれよ。

Docker DesktopのPreferencesにあるDocker Daemonというメニューでdaemon.jsonを編集できます。デフォルトはこんな感じ。

{
  "builder": {
    "gc": {
      "defaultKeepStorage": "20GB",
      "enabled": true
    }
  },
  "experimental": false,
  "features": {
    "buildkit": true
  }
}

ここにhostsを追加しました。

{
  "builder": {
    "gc": {
      "defaultKeepStorage": "20GB",
      "enabled": true
    }
  },
  "experimental": false,
  "features": {
    "buildkit": true
  },
  "hosts": [
    "tcp://0.0.0.0:2375",
    "unix:///var/run/docker.sock"
  ]
}

これで再起動してみたのですが・・・netstatしてみても、2375ポートがLISTENされている様子がありません。

ちなみにDocker Desktopの設定を有効にして再起動した時点で tcp://0.0.0.0:2375tcp://127.0.0.1:2375 に書き換えられてしまうので、そこも対応しなきゃなと思っていたのですが、どうあれ2375ポートがLISTENされないことには話が始まりません。なぜMacだとこの設定は無視されるのか、引き続き調べてみます。

MacのDockerはVM内で動いている

mac ignore docker hosts daemon.json あたりでググってみると、そのままの質問が見つかりました。 github.com そこに回答もありました。

I suspect the issue in that case is that while the daemon may be listening on those IP-addresses and ports, it's doing so within the VM that it's running in. Those ports are not mapped / forwarded to the macOS host, so won't be accessible there.

英語だとよく分からないので日本語に訳すと「デーモンはたぶんそのアドレスとポートでLISTENしてるけど、VMの中で動いていて、そのポートがmacOSホストにマッピングされてないから効果がないんじゃね?」ということです。日本語にしてもよく分かりません。

あ、そういえばMacのDockerはVMの中で動いているのでした。

LinuxのDocker構成
OS XのDocker構成
(出典:Mac OS X — Docker-docs-ja 1.13.RC ドキュメント

たしかにこの構成だと、Dockerデーモンの設定をいじって2375ポートで待ち受けてもLinux VMが2375ポートで待ち受けるだけになり、Macの2375ポートでは待ち受けされませんね。

Linuxでsocatを使う

上のフォーラムの回答の続きに、DockerでAlpine Linuxを起動してsocatを動かす方法が紹介されていました。

docker run \
    -it \
    --rm \
    --name=api-forwarder \
    -p 2375:2375 \
    -v /var/run/docker.sock:/var/run/docker.sock \
    alpine sh -c 'apk add --no-cache socat && socat TCP-LISTEN:2375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock'

Mac/var/run/docker.sock をDockerで起動したAlpine Linux/var/run/docker.sock にマウントして、socatを使って2375ポートをDockerのソケットに流すようにして、Alpine Linuxの2375ポートとMacの2375ポートをマッピングするという形です。

Alpine Linux上のsocatを経由する形
これだとMacにsocatを入れた時のようなエラーメッセージも出ないようですし、Macにsocatを入れなくて済むので、この方法を使うことにしました。

所感

そんなわけで、無事にMacをDockerのサーバとして利用できるようにしたので、メインマシンのリソースを消費しなくて済むようになりました。またM1/M2のMacamd64イメージを実行せざるを得ない時にも(Rosetta2対応により高速化されたとはいえ)Intel Mac miniで実行できますね。

ただこんな一手間を掛けてMacをDockerサーバにするよりも、メモリをたくさん積んだLinuxサーバを1台用意しても良いかも知れませんね。最近vscodeのRemote ContainerやJetBrainsのRemote Developmentのように開発環境をリモートに置くことも流行りつつあるようですから、またちょっと考えたいと思います。

*1:諸説ある

GrafanaスタックによるSpring Bootアプリケーション監視の詳細(その3 Grafana + Tempo編)

Grafanaスタックによるアプリケーション監視の第3回、今回はGrafana + Tempoです。ローカル環境(+ Docker)でGrafanaとTempoを使ってトレースの可視化を行います。

ソースコードなどはすべてGitHubに置いています。

https://github.com/cero-t/spring-store-2022

トレース収集の構造

マイクロサービスにおいては特に「分散トレーシング」と呼ばれています。分散アプリケーションに対するトレーシングという意味であり、この図のように呼び出し階層や、それぞれの処理に掛かった時間などを可視化するために利用されます。

可視化された分散トレーシング

このような呼び出しの関係を作るためには、それぞれのアプリケーションが「誰から呼ばれたのか?」ということを認識する必要があります。そのために用いるのが「トレースID」です。

分散トレーシングにおいては、このトレースIDをアプリケーション間で伝搬させること、そしてそれぞれのアプリケーションからトレースストレージ(TempoやZipkinなど)にトレース情報を送ることの2つが不可欠です。

アプリケーション間のトレースIDの伝播

Spring Bootのアプリケーション間通信ではHTTPやAMQPなどが利用されます。この通信の際にトレースIDをHTTPヘッダやAMQPヘッダに追加することで伝播させます。

送信側のアプリケーションがHTTPヘッダにトレースIDを付与し、受信側のアプリケーションでそれを受け取り、またそこから別アプリケーションを呼び出す際にはHTTPヘッダにトレースIDを乗せるという形です。

トレースIDの伝播

このヘッダは以前はライブラリごとに独自だったのですが、近年は traceparent というヘッダが W3C Trace-Context で規定され、これを使うライブラリやフレームワークがほとんどです。ただtraceparentヘッダの形式はライブラリごとに異なっていることもあり、完全に相互運用できるわけではないという現状です。

Spring Bootでは micrometer-tracing-bridge-otelmicrometer-tracing-bridge-brave というライブラリを利用することで、送信と受信ができるようになります。

micrometer-tracingを使ったトレースIDの伝播
Spring Web、Spring Cloud Stream、Spring AMQPなどがこのトレースIDの伝播に標準で対応しており、ライブラリをdependencyに入れればアプリケーションのロジック側でヘッダを設定したり取得したりする必要なく、トレースIDの伝播ができるようになっています。

トレース情報の送信

このようなトレース情報はアプリケーションが一時的に保持したうえで、トレースストレージ(TempoやZipkin、Jaegerなど)にHTTPやgRPCなどのプロトコルを用いて送信して集約します。

トレース情報の送信

Spring Bootでは opentelemetry-exporter-zipkinzipkin-reporter-brave を使うことでこのトレース情報を送信できるようになります。

opentelemetry-exporter-zipkinを用いたトレース情報の送信

ライブラリ名が「zipkin」なのにTempoに送れるのかと不思議に思うかも知れませんが、Tempoは(他のトレースストレージも同様ですが)Zipkin互換のAPIを備えており、Zipkin用のリクエストを受け取れるようになっています。

メトリクスやログに比べて少し構造が見づらくなりましたが、ひとまずは「トレースID伝播するためのライブラリと、トレース情報をストレージに送るためのライブラリは別なんだ」と理解してもらえれば問題ありません。

分散トレーシングについては、Elastic社のこの記事がとても分かりやすかったのでオススメです。

www.elastic.co

Tempoでトレースを収集する

それではTempoを使ってトレースを収集する手順を説明します。

1. Spring Bootアプリケーションからトレース情報を送る

まずはSpring Bootアプリケーションからトレース情報を送れるように関連ライブラリの追加などを行います。

トレースIDを伝播させる

まずはSpring Bootアプリケーション間でトレースIDを伝播できるようにします。

Spring Bootアプリケーションのdependencymicrometer-tracing-bridge-otel を追加します。

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
    <scope>runtime</scope>
</dependency>

(View in GitHub)

このライブラリを入れれば、RestTemplateなど(後述)で通信した際にOpenTelemetryのライブラリ群を使ってトレースIDの伝播が行われます。

MicrometerではOpenTelemetryのライブラリ以外にもBraveのライブラリを使った micrometer-tracing-bridge-brave というライブラリがあり、Spring Bootではいずれも利用することができるとドキュメントに記載があります。

docs.spring.io

両方試してみたところ特に何も違いはありませんでした。というか受信側と送信側でOpenTelemetryとBraveを混在させても良いくらいには互換性が保たれていました。

トレース情報を送信する

次にdependencyopentelemetry-exporter-zipkin を追加します。

<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-zipkin</artifactId>
    <scope>runtime</scope>
</dependency>

(View in GitHub)

これでアプリケーションがZipkin(Tempo)にトレース情報を送ることができるようになります。

ここでは代わりに zipkin-reporter-brave というBraveを利用してZipkinに送るライブラリも利用できます。他にも opentelemetry-exporter-otlpopentelemetry-exporter-jaeger などがあるのですが、上で示したドキュメントに記載されていないライブラリは使えないようです。

正直、ライブラリの選択肢があることでちょっと混乱してしまいますね。Micrometer Tracingの前身であるSpring Cloud Sleuthの時も同じくちょっと混乱を招きがちだったので、ここは仕方ないところでしょうか。

トレース情報の送信割合を指定する

さらに、applicaiton.properties に設定を追加します。

management.tracing.sampling.probability=1.0

(View in GitHub)

トレース情報をどれくらいの割合でサーバに送るかというものです。トレース情報はそれなりにボリュームがあるためデフォルトでは10%(0.1F)だけ送るようになっています。それだと手元で試すときには不便なので、開発中やデモの時には100%(1.0)にするのが王道です。

ちなみに実際に分散トレーシングを導入しているプロジェクトでは「エラーが起きた時に分散トレーシングを見て発生箇所を知りたい」という理由から、運用中でも送信割合を100%(1.0)にしています。エラーや、発生率の低い性能問題が起きた時に、そのトレース情報が収集されていないと意味がないですからね。僕も100%派です。

RestTemplateやWebClientのBeanを作る

Spring Webで RestTemplate を利用する場合は、RestTemplateBuilderをAutowiredさせてインスタンスを生成します。

@Bean
RestTemplate restTemplate(RestTemplateBuilder builder) {
    return builder.build();
}

(View in GitHub)

ここで単に return new RestTemplate(); としてしまうとOpenTelemetryのライブラリなどが利用されない素のRestTemplateとなってしまうためです。

WebClientについても同様です。

@Bean
WebClient webClient(WebClient.Builder builder) {
    return builder.build();
}

(View in GitHub)

Spring AMQPの場合はsetObservationEnabledをtrueにする

Spring WebやSpring Cloud Streamでは分散トレーシングが標準でできるようになっているのですが、Spring AMQPにおいては分散トレーシングの設定がデフォルトで無効化されています。RabbitTemplateとSimpleRabbitListenerContainerFactory(AbstractRabbitListenerContainerFactory)に setObservationEnabled というメソッドがあり、これを true にすることで有効化できます。

そこで BeanPostProcessor などの仕組みを用いてこの設定を有効化します。

@Configuration
public class MessagingConfig implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
        if (bean instanceof RabbitTemplate template) {
            template.setObservationEnabled(true);
        } else if (bean instanceof SimpleRabbitListenerContainerFactory factory) {
            factory.setObservationEnabled(true);
        }

        return bean;
    }
}

(View in GitHub)

この設定が必要なことがドキュメントには記載されていなかったので、見つけて設定をtrueにするまでエラく難儀しました。

issueを立てて開発者に聞いたところ、性能に対する懸念があるためデフォルトでfalseにしているとのことでした。その設計思想自体はわかるものの、このような設定が必要になるのはあまりに不便なので、できればSpring Boot全体で有効化/無効化できるような設定が欲しいところですね。

Spring AMQPプロジェクトは過去にもSpring Cloud Sleuth対応がしばらくされなかったこともあり、もしかしたら分散トレーシングが好きではないのかも知れませんね。

2. Tempoを構築する

続いてTempoを構築します。Tempoはdocker-composeを使って起動します。

docker-compose.ymlのうち、Tempoに関する部分が次の箇所です。

services:
  tempo:
    image: grafana/tempo
    extra_hosts: ['host.docker.internal:host-gateway']
    command: [ "-config.file=/etc/tempo.yaml" ]
    volumes:
      - ./config/tempo-local.yaml:/etc/tempo.yaml:ro
    ports:
      - "14268"
      - "9411:9411"

(View in GitHub)

設定ファイルとして ./config/tempo-local.yaml を使えるようマウントし、それを -config.file=/etc/tempo.yaml で利用していますね。

ポートは 142689411 の2つを利用しており、14268 はJaeger互換のAPIが動いているポートで、前回のLokiの構築のところで少し触れた通りLokiは自身のトレースをJaeger(実体はTempo)のエンドポイントに送るため、そのポートとして利用されます。いずれLokiはTempoのAPIでトレースを送るようになると思いますけどね。

また 9411 の方はZipkinのポートであり、TempoがこのポートでZipkin互換のAPIを提供します。アプリケーションからこのポートに向けてトレース情報を送るため、Docker外部にもポートを公開しています。

続いて tempo-local.yaml の内容について説明します。

server:
  http_listen_port: 3200

distributor:
  receivers:
    zipkin:

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/blocks

search_enabled: true

(View in GitHub)

上から順に「Tempoをポート3200として起動する」「Zipkin互換のAPIを動かす」「トレース情報をローカルストレージに保存する」「検索を有効にする」の4つが設定されています。

空っぽの設定のように見えるこの部分ですが

distributor:
  receivers:
    zipkin:

この設定を入れておかないとZipkin互換のAPIが動かないため、必ず入れるようにしてください。

また search_enabledtrue にすることで、Tempoに対して条件を指定した検索が有効になります。これを設定しなければトレースIDによる検索しか行えません。デフォルトで有効化されてても良いと思うんですけどね。負荷やパフォーマンスに影響があるんでしょうかね。

3. GrafanaのデータソースにTempoを追加

過去に説明してきたPrometheusやLokiと同様に、GrafanaのデータソースにTempoを追加してGrafanaからTempoを参照できるようにします。

Grafanaのdatasources.ymlで設定しています。

datasources:
  - name: Tempo
    type: tempo
    access: proxy
    orgId: 1
    url: http://tempo:3200
    basicAuth: false
    isDefault: true
    version: 1
    editable: false
    apiVersion: 1
    uid: tempo
    jsonData:
      httpMethod: GET
      tracesToLogs:
        datasourceUid: 'loki'
      nodeGraph:
        enabled: true
      serviceMap:
        datasourceUid: 'prometheus'

(View in GitHub)

データソースとしてTempoを指定し、url にTempoのアドレスを指定します。

それ以外の設定では、Grafanaのトレースからログにジャンプする設定や、アプリケーション同士の関連を可視化する設定を入れているのですが、バージョンの問題なのか何なのかうまく動いていないため(少し古いバージョンを使うと動くものもある)いったん説明は割愛します。

メトリクス・ログ・トレースの相互参照は開発が活発なところで、設定や挙動に変化があるため何か影響を受けているのかも知れません。

4. Grafanaでトレース情報を見る

ここまでの設定を終えてアプリケーションとGrafanaスタックを起動し、Grafanaにアクセスします。

http://localhost:3000

ExploreでTempoを選択し、「Search」タブで「Service Name」でいずれかのサービスを選択して右上の「Run Query」ボタンを押すとトレースを検索することができます。

トレース一覧の表示

prometheusに対するアクセスばっかり出てきますね。これを送らないようにする方法も調べなきゃですね。

もう少し意味のあるトレースを見たいので、アプリケーション側で少し操作したうえで、「Span Name」で有効なURLを選択して検索しました。

絞り込んだトレースの表示

そしてTraceIDを選択すると、右側にトレースが表示されます。

選択したトレースの表示

処理の呼び出し階層や、それぞれの処理に掛かった時間の詳細などを確認することができます。特にログなどを出していなくても、この情報を確認できるというのは便利なものですね。

また前回のLokiのところでも少し触れましたが、LokiとTempoの両方が設定されていると、LokiのログからTempoにジャンプすることもできるようになります。

LokiにあるTempoボタンを押して

Tempoでトレースを確認

特にエラー発生時に出力するログにトレースIDが入っていれば、サービス呼び出しのどこで問題が起きたか捉えやすくなるので良いでしょう。

まとめ

  • Spring Bootアプリケーションに micrometer-tracing-bridge-otelmicrometer-tracing-bridge-brave を入れればトレースIDの伝播ができる
  • Spring Bootアプリケーションに opentelemetry-exporter-zipkinzipkin-reporter-brave を入れればZipkinのエンドポイントにトレース情報を送るようになる
  • Spring Web、Spring Cloud StreamはデフォルトでトレースIDの伝播やトレース情報の送信が有効になっているが、Spring AMQPではコードで設定を有効化する必要がある
  • GrafanaとTempoはdockerで簡単に利用できる
  • Tempoの条件指定検索は設定で有効化する必要がある
  • Grafana上でLokiのログからTempoのトレースにジャンプできる

ということで、3回に分けてGrafana、Prometheus、Loki、Tempoの設定や、Spring Bootアプリケーション側の対応について説明してきました。特に分散トレーシングについてはSpring Boot 3.0とMicrometer Tracingのおかげで必要な設定やライブラリが減っており、過去に比べて導入しやすくなったという印象です。

一部うまく動かない部分については、Grafana側のバージョンアップを確認しながら設定などを更新して対応したいと思います。ちょっと宿題として残ってしまいましたね。

さて次回は、これまで作った環境をk8s上に持っていきたいと思います。この連載は、もうちょっとだけ続くんじゃ。

GrafanaスタックによるSpring Bootアプリケーション監視の詳細(その2 Grafana + Loki編)

第2回目はGrafana + Lokiです。Lokiと言えば、時間移動して悪さをしたせいでタイムパトロールに捕まるという 大長編ドラえもんの悪役みたいな スピンオフドラマシリーズがありましたが、皆さんご覧になったでしょうか。あ、ディズニープラス限定配信のドラマなんてほとんどの人が観ないか、そっか。なかなかの野心作で面白かったですよ。展開はイマイチでしたけど。

そんなわけで今回は、ローカル環境(+ Docker)でGrafanaとLokiを使ってログの可視化を行います。

ソースコードなどはすべてGitHubに置いています。

https://github.com/cero-t/spring-store-2022

ログ収集の構造

ログ収集と可視化の構造は次のようになります。

ログ収集の構造

ログをファイルに出力し、それをログ収集エージェントが読み取ってログストレージに送ると、いう流れがログ収集の構造としては一般的です。ログ収集エージェントは、ここではPromtailを選んでいますが、FluentdやFilebeat、Logstashなどがよく使われています。

ただコンテナ環境においては、ログを標準出力に出力すれば、それをログエージェントが読み取ってログストレージに転送するような仕組みが一般的であるため「ログファイル」というものをあまり考えなくなります。

コンテナ環境でのログ収集の構造

ずっとコンテナ環境を使っているとむしろ「ログファイルなどという前時代的なものに縛られるとは人類はいつまで経っても愚かなものよ」などと高尚な神の視点で物事を考えるようになってしまいがちになり、ログをファイル出力したくなくなってきます。

そこで今回は(参考にしたGrzejszczakさんのサンプルがそうであったように)Loki4Jの loki-logback-appender を利用することにしました。

loki-logback-appenderを使ったログ収集の構造

loki-logback-appenderは、ログを直接Lokiに転送するというものです。微妙に賛否ありそうな構造ですが、ひとまずは「Lokiにログを集めたい」という事が主目的なので、手段は簡易なものを選ぶことにしました。

ちなみにspring-store-2022では、k8s上ではPromtailを使うようにしているので、その辺りは別途ブログを書きます。

Lokiでログを収集する

それではLokiを使ってログを収集する手順を説明します。

1. Spring Bootアプリケーションからログを送る

まずはSpring BootアプリケーションからLokiにログを転送できるようにします。

Spring Bootアプリケーションにloki-logback-appenderを追加

Spring Bootアプリケーションのdependencyloki-logback-appender を追加します。

<dependency>
    <groupId>com.github.loki4j</groupId>
    <artifactId>loki-logback-appender</artifactId>
    <scope>runtime</scope>
</dependency>

(View in GitHub)

ただし loki-logback-appenderspring-boot-dependencies に含まれていないため、バージョンを自分で指定する必要があります。spring-store-2022では親のpom.xmlで指定するようにしています。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.github.loki4j</groupId>
            <artifactId>loki-logback-appender</artifactId>
            <version>1.3.2</version>
        </dependency>
    </dependencies>
</dependencyManagement>

(View in GitHub)

logback.xmlを作る

続いて、このloki-logback-appenderを使うようLogbackの設定を書きます。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/base.xml" />
    <springProperty scope="context" name="appName" source="spring.application.name"/>

    <appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
        <http>
            <url>http://localhost:3100/loki/api/v1/push</url>
        </http>
        <format>
            <label>
                <pattern>app=${appName},host=${HOSTNAME},traceID=%X{traceId:-NONE},level=%level</pattern>
            </label>
            <message>
                <pattern>${FILE_LOG_PATTERN}</pattern>
            </message>
            <sortByTime>true</sortByTime>
        </format>
    </appender>

    <root level="INFO">
        <appender-ref ref="LOKI"/>
    </root>

(View in GitHub)

まず urlhttp://localhost:3100/loki/api/v1/push を指定して、送り先のLokiのアドレスを記載します。

またタグとして app host traceId level を追加しています。このタグを使ってLoki上で検索することができるようになります。

ちなみにこのファイルを logback-spring.xml という名前にしておけば無設定で読み込まれるのですが、なんとなくこれを標準にすることに抵抗があったため、logback-loki.xml という名前にしたうえで、application.properties でファイル名を指定ししました。

logging.pattern.level=%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]
logging.config=classpath:logback-loki.xml

(View in GitHub)

なんとなく、Appenderで送ることを標準として良いのかどうなのか、まだ悩んでるんですよね。まぁその辺りの悩みはブログの最後に書きます。

また logging.pattern.level でログレベルを出す箇所に、ログレベルの後ろにアプリケーション名、トレースID、スパンIDを出しています。タグがなく、ただログを見ただけでもトレースIDなどを確認できるようにするためです。

これでSpring Bootアプリケーション側の設定は完了です。

2. Lokiを構築する

続いて、Lokiの構築を行います。Lokiはdocker-composeを使って起動します。

docker-compose.ymlのうち、Lokiの起動に関する部分のみピックアップします。

services:
  loki:
    image: grafana/loki
    extra_hosts: ['host.docker.internal:host-gateway']
    command: [ "-config.file=/etc/loki/local-config.yaml" ]
    ports:
      - "3100:3100"                                   # loki needs to be exposed so it receives logs
    environment:
      - JAEGER_AGENT_HOST=tempo
      - JAEGER_ENDPOINT=http://tempo:14268/api/traces # send traces to Tempo
      - JAEGER_SAMPLER_TYPE=const
      - JAEGER_SAMPLER_PARAM=1

(View in GitHub)

environment でLoki自身のトレース情報をJaeger(実体はTempo)に送るような設定が入っているのですが、これは別になくても構いません。元のサンプルに入っていたので、そのままにしています。

また ports3100 番ポートを外部公開していますが、アプリケーションからAppenderを使ってLokiにログを転送するという構造上、LokiのポートはDockerの外に公開する必要があるのです。

設定はこれくらいで、特に追加の設定ファイルなどは使わずにデフォルト設定で動かしています。

3. GrafanaからLokiにアクセス

最後に、GrafanaでLokiに収集したログを表示できるようにするために、GrafanaのデータソースにLokiを追加します。これはGrafanaの datasources.yml で設定します(Grafanaの設定画面からも追加できます)

datasources:
- name: Loki
    type: loki
    uid: loki
    access: proxy
    orgId: 1
    url: http://loki:3100
    basicAuth: false
    isDefault: false
    version: 1
    editable: false
    apiVersion: 1
    jsonData:
      derivedFields:
        - datasourceUid: tempo
          matcherRegex: \[.+?,(.+?),
          name: TraceID
          url: $${__value.raw}

(View in GitHub)

データソースとしてLokiを指定し、url にLokiのアドレスを指定します。

jsonData.derivedFields に少し設定が入っているのですが、これを設定することで、GrafanaでLokiのログに含まれるTraceIDを使ってTempo(トレース情報)のUIを開くことができるようになります。ログを正規表現\[.+?,(.+?), でマッチングさせて TraceID を抽出し、その値を使ってTempoを開くという設定になっています。

以前のエントリー でLokiからTempoに移動できることを紹介した時の画像を再掲します。

Grafana上でLokiのログに表示されるTempoボタン

Tempoボタンを押すとTempoでトレースが表示される

これを実現するための設定になります(Tempoの詳細については次のブログエントリーで紹介します)

画像内に「Detected fields」という項目がありますが、ここにGrafana自身が自動検出したフィールドと、derivedFieldsで設定したフィールドが列挙され、ここに「TraceID」がされるという形です。

この処理はLoki側ではなくGrafana側で表示する際に行っているということになります。逆に言えば、ここで抽出されている「Detected fields」はLoki側ではindexingされていないので、高速に検索できるわけではないので注意が必要です。Elasticsearch + Kibanaも同じ感じでしたけど、最初は混乱するんですよね、このUI側でのフィールド認識。

どうあれこうやって手軽にログとトレースを行き来できるというのは、GrafanaがLokiとTempoに標準対応しているという強みですね。同じことをKibanaとZipkinで実現している人もいるのですが、ナカナカにやりこみが必要です。

4. GrafanaでLokiのログを見る

ここまでの設定をすべて終えたあと、アプリケーションとGrafanaスタックを起動してGrafanaにアクセスします。

http://localhost:3000

ExploreでLokiを選択し、Label filtersで絞り込み条件(たとえば app = bff)を指定すると、条件に合致したログが表示されます。

GrafanaからLokiのログを絞り込んで表示

Lokiは起動に少し時間が掛かったり、アプリケーションからLokiにログを送るまでタイムラグがあるため、操作してから1〜2分くらい待たないとログが表示されないとか、Label filtersの絞り項目が表示されないことがあり、その時は少し待ってから改めて行うと良いでしょう。

ちなみにLokiはElasticsearchのようにログを単語分割したり、全項目にindexを張ったりしていないので、大量のログに対して文字列一致させるような検索はあまり早くありません。そのため、実運用で利用する際にはあるていど時間帯や対象を限定して検索することになると思いますが、問題発生時にその時間帯のログを確認したいなどという用途においてはあまり困らないと思います。

まとめ

  • Javaアプリケーションに loki-logback-appender と設定ファイルを追加すればLokiにログを送れる
  • GrafanaとLokiはdockerで簡単に利用できる
  • Grafana上でLokiのログからTempoのトレースにジャンプするような設定ができる(Tempoの詳細は次回)
  • Elasticsearchほど高機能じゃないけど軽くて手軽だよ

ところで。

ログをLokiに送る方法として、Prometailのようなエージェントを使うのが良いのか、Appenderで直接転送するのが良いのか、というのはわりと悩ましい問題です。

信頼性という意味ではログエージェントに収集させた方が確実だと思いますし、ログエージェントならログのパースや加工などのパイプライン処理ができるため、よりログを扱いやすい形に加工してLokiに送ることもできます。

一方で、Zipkinなどのトレース情報はアプリケーションから直接送るのが普通ですし、ログだって同じような形で送っても悪くないように思います。

ログについては「絶対に抜けてはいけない。信頼性が重要だ」という意見もあり、そのために信頼性の高いログエージェントに任せるべきだという意見もあるとは思いますが、実際には本当に重要な情報と、さほど重要でない情報が雑多に混ざった状態です。真に重要な情報はログではなくRDBMSなどに書くよう丁寧に設計して管理し、ログはもっと「あると嬉しい」情報に倒した方が楽だと思っています。

そんなわけでなかなかどっちが良いか決めきれないログの出力方式なのですが、いったんブログを一通り書いたあとで、改めてログ収集のあるべき形について考えたいなと思います。

GrafanaスタックによるSpring Bootアプリケーション監視の詳細(その1 Grafana + Prometheus編)

前回のエントリー では動かし方のみ説明し、GrafanaスタックやMicrometerがどのように動いているのかについて触れていなかったので、これから何度かに分けて説明していきます。

第1回目はGrafana + Prometheusです。

Grafanaスタックの各プロダクトについて

説明に入る前に、Grafanaスタックになじみがない方(1ヶ月前の僕とか)も多いと思いますので、まずは簡単に各プロダクトのことを説明しておきます。

Grafana

https://grafana.com/oss/grafana/

GrafanaはGrafana Labsが開発している監視用のダッシュボードやアラート機能などを提供するUIです。Elastic Stackになじみ深い方にとっては「要するにkibana」と言うと説明が早いでしょうか。

GrafanaはPrometheus、Loki、Tempo、Elasticsearch、Zipkin、Jaegerなど多くのモニタリング系データストアの可視化に対応しています。ダッシュボードのカスタマイズ性などはKibanaの方が高機能なのですが、KibanaはElasticsearchにしか対応していないという点で違いがあります。

Prometheus

https://prometheus.io/

PrometheusはCPU使用率やメモリ消費量などのメトリクスを収集するデータストアです。多くのメトリクス用データストアと同様に、Prometheusもいわゆる時系列データストア(Time series datastore)で、タイムスタンプと共に数値やタグを保持することに特化しています。

ただ、多くのメトリクス用データストアはPush型である一方、PrometheusはPull型を採用しています。よくあるPush型のデータストアはクライアント側にデータ送信用のエージェント(CloudWatchやDataDogのエージェント、Metricbeatなど)がいてサーバにメトリクス情報を送るという流れですが、Prometheusは逆にPrometheus側がクライアントに情報を取りに行く流れになります。

Push型の監視に慣れ親しんだ僕としては、ハァ? Pull型? 逆に面倒くさくね? ってかサーバがクライアントを意識するとかあり得なくね? と思っていたのですが、今回使ってみて特にk8sで運用する際などには「なるほどpull型も良いもんだな」と考えが変わりました。その辺りは後で説明します。

Grafana LabsはPrometheusの開発を支援しており、Promethusの可視化では標準的にGrafanaが使われています。

https://grafana.com/oss/prometheus/

Loki

https://grafana.com/oss/loki/

LokiはGrafana Labsが開発するログ収集用のデータストアです。ログ収集用のデータストアと言えばElasticsearchの一強で、CloudWatch LogsやDataDogなども追従してきましたが、ローカル環境にでもデプロイできるOSSプロダクトとして、ようやくElasticsearchのライバルが現れたのかなという印象です。

Elasticsearchが「全カラムをindexingする」というわりと富豪的なアプローチを採る一方で、Lokiは「少ないカラムだけindexingする」というアプローチなので、Elasticsearchの方が使い勝手や機能は上ですが、Lokiの方が少ないリソースで動かせるという点で分かれています。

Tempo

https://grafana.com/oss/tempo/

TempoはGrafana Labsが開発する分散トレーシング用のデータストアです。分散トレーシングと言えばZipkinが元祖であり一強で、Jaegerがそれに追従している状況です。

正直、分散トレーシングについてはZipkinが実現したアイデア自体が素晴らしいのであって、後発プロダクトも含めてあまり大きな優劣がないように思います(僕の分散トレーシングプロダクトに対する解像度が足りないだけかも知れませんが)

TempoはGrafana Labsが開発していてGrafanaとの親和性が高そうなので今回はこれを選びました。ちなみにGrafanaの説明で述べた通り、ZipkinやJaegerを使ってGrafanaで可視化することもできます。

Promtail

https://grafana.com/docs/loki/latest/clients/promtail/

PromtailはGrafana Labsが開発するLokiのためのログ収集エージェントです。Prometheusの発想を真似たPull型のエージェントであり、設定などもPrometheusとよく似ています。

ログ収集と言えばfluentdが君臨し、Elasticsearch用にはFilebeat(収集)とLogstash(加工)がよく使われていますが、Lokiと組み合わせて軽量に使うならPromtailが標準のようだったのでこれを使うことにしました。

Grafana Cloud

https://grafana.com/products/cloud/features/

Grafana CloudはモGrafanaを使ったニタリング環境をまとめて提供するSaaSで、Grafana、Prometheus、Loki、Tempoが利用できます。またログを送るためのGrafana AgentにはPromtailが含まれています。そういう点でも、今回選んだプロダクト群がGrafanaを利用する際の標準的なものだと考えて差し支えないと思います。

ちなみにAWSのマネージドサービスにもGrafanaとPrometheusはあるのですが、後発のLokiとTempoはありませんでした。現時点でマネージドサービスとしてGrafanaスタックを使いたいのであれば、Grafana Cloudを使うほうが良さそうです。

GrafanaスタックでSpring Bootアプリケーションを監視する

それでは実際にローカル環境(+ Docker)でGrafanaスタックを使ってSpring Bootアプリケーションのモニタリングを行う方法を説明します。

対象は前回のエントリーで紹介した spring-store-2022 です。

https://github.com/cero-t/spring-store-2022

メトリクス監視の構造は次の図のようになります。

GrafanaとPrometheusを用いたメトリクス監視の構造
Spring BootアプリケーションにActuatorとMicrometerを追加することでアプリケーションにPrometheus用のエンドポイント /actuator/prometheus が追加され、そこにPrometheusが定期的にアクセスしてメトリクスを収集し、Grafanaでそのメトリクスを可視化するという流れです。

1. Grafanaの構築

Grafanaの各スタックはdocker-composeを使って起動します。

spring-store-2022/docker/docker-compose.yml のGrafanaの起動に関する部分は次の通りです。

services:
  grafana:
    image: grafana/grafana
    extra_hosts: ['host.docker.internal:host-gateway']
    volumes:
      - ./config/grafana/datasources:/etc/grafana/provisioning/datasources:ro
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_AUTH_DISABLE_LOGIN_FORM=true
    ports:
      - "3000:3000"

(View in GitHub)

まず environment に次のような設定が入っていることに気づきます。

      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_AUTH_DISABLE_LOGIN_FORM=true

これはGrafanaにアクセスした際のログイン画面をスキップする設定です。ローカル環境でモニタリングする程度なら認証は要りませんから、これは入れておくと便利です。

そして ./config/grafana/datasources/etc/grafana/provisioning/datasources にマウントしています。この datasources ディレクトリには datasource.yml があり、PrometheusやLokiなどをデータソースとして使う設定が記載されています。それぞれの設定の詳細については後ほど紹介します。

どうあれこれくらいでGrafanaは起動できます。

2. Spring BootアプリケーションにPrometheusのエンドポイント追加

続いて、Spring BootアプリケーションでActuatorとMicrometerを用いてPrometheus用のエンドポイントを作成します。

pom.xmlにActuatorとMicrometerを追加

Spring Bootアプリケーションのdependencyspring-boot-starter-actuatormicrometer-registry-prometheus を追加します。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
    <scope>runtime</scope>
</dependency>

(View in GitHub)

これだけで、actuatorのエンドポイントに /actuator/prometheus が追加されます。

ちなみにPrometheus用のMicrometerを追加したのでactuatorにエンドポイントが追加されましたが、ここで代わりに micrometer-registry-elastic を追加すると、actuatorのエンドポイントが追加される代わりに、Micrometerのメトリクス情報をElasticsearchに定期的に送るようになります。その辺りがpull型とpush型の違いですね。

application.propertiesに設定を追加

Spring Bootの application.properties にMicrometerの設定を少し追加します。

management.endpoints.web.exposure.include=*
management.metrics.distribution.percentiles-histogram.http.server.requests=true
management.metrics.tags.application=${spring.application.name}

(View in GitHub)

management.endpoints.web.exposure.include=* は、actuatorのうち外部からアクセスできるエンドポイントを指定するものです。開発用なので全てのエンドポイントにアクセスできるようにしていますが、Prometheusを利用するだけなら management.endpoints.web.exposure.include=prometheus という指定だけで構いません。

management.metrics.distribution.percentiles-histogram.http.server.requests=true は、Micrometerのメトリクスにエンドポイントごとのレイテンシ(処理時間)を追加するものです。便利なメトリクスとしてよく使われているようです。

management.metrics.tags.application=${spring.application.name} はメトリクスを送るときにタグとして application にSpring Bootアプリケーションの名前(spring.application.name の値)を指定しています。メトリクスはアプリケーションごとに確認したいでしょうからほぼ必須のタグです。というかこれはデフォルトで送られるようになってても良いんじゃないかなと思うのですが。

アプリケーション側の設定はこれくらいです。

追加されたメトリクス

ここまでの設定をしてアプリケーションを起動した後、ブラウザやcurlコマンドなどで http://(アプリケーションのアドレス)/actuator/prometheus にアクセスしてみると、わりと凄い量のメトリクスが表示されます。

冒頭のみ抜粋しますが、このような形です。

# HELP process_files_max_files The maximum file descriptor count
# TYPE process_files_max_files gauge
process_files_max_files{application="bff",} 10240.0
# HELP process_uptime_seconds The uptime of the Java virtual machine
# TYPE process_uptime_seconds gauge
process_uptime_seconds{application="bff",} 15057.411
# HELP jvm_threads_peak_threads The peak live thread count since the Java virtual machine started or peak was reset
# TYPE jvm_threads_peak_threads gauge
jvm_threads_peak_threads{application="bff",} 43.0
# HELP jvm_threads_states_threads The current number of threads
# TYPE jvm_threads_states_threads gauge
jvm_threads_states_threads{application="bff",state="new",} 0.0
jvm_threads_states_threads{application="bff",state="runnable",} 11.0
jvm_threads_states_threads{application="bff",state="terminated",} 0.0
jvm_threads_states_threads{application="bff",state="waiting",} 12.0
jvm_threads_states_threads{application="bff",state="timed-waiting",} 9.0
jvm_threads_states_threads{application="bff",state="blocked",} 0.0
...

CPU使用率や、JavaVMのスレッド情報、ヒープ使用量、GCの詳細、HTTPエンドポイントへのリクエスト数など多岐にわたるメトリクスが表示されていて、これを毎秒収集してるとなるとデータサイズがとんでもないことになりそうだなと思ったのですが、いったん考えないことにしました。ハハハ。

3. Prometheusの構築

続いて、Prometheusの構築を行います。

docker-composeを用いたPrometheusの構築

Prometheusはdocker-composeを使って起動します。

docker-compose.ymlのうち、Prometheusの起動に関する部分のみピックアップします。

prometheus:
  image: prom/prometheus
  extra_hosts: ['host.docker.internal:host-gateway']
  command:
    - --enable-feature=exemplar-storage
    - --config.file=/etc/prometheus/prometheus.yml
  volumes:
    - ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro
  ports:
    - "9090:9090"

(View in GitHub)

ポイントは ./config/prometheus.yml/etc/prometheus/prometheus.yml としてマウントして設定ファイルとして利用しているところくらいでしょうか。

--enable-feature=exemplar-storage はトレース情報のサンプルを保存するために必要な設定ですが、いったん今回は注目していないので説明を割愛します。

利用しているprometheus.ymlの内容は次のようになっています。

global:
  scrape_interval: 2s
  evaluation_interval: 2s

scrape_configs:
  - job_name: "prometheus"
    static_configs:
      - targets: ["host.docker.internal:9090"]
  - job_name: "apps"
    metrics_path: "/actuator/prometheus"
    static_configs:
      - targets:
          [
            "host.docker.internal:9000",
            "host.docker.internal:9001",
            "host.docker.internal:9002",
            "host.docker.internal:9003",
            "host.docker.internal:9004",
            "host.docker.internal:9005",
            "host.docker.internal:9006",
            "host.docker.internal:9010"
          ]

(View in GitHub)

見て分かる通り、監視対象となるアプリケーションのアドレスとポートの一覧を列挙しています。ちょっとこれはどうなんでしょうか、こんなpull型エージェントを好きな人っているんでしょうか。

これでどうやってスケールアウト/スケールインするようなマイクロサービスを監視するんだよと思ったのですが、そういう環境ではそういう環境なりの設定方法があるので、また別に紹介します。

ここではひとまず「pull型エージェントはこういう風になるんだな」と思ってもらって構いません。

ここまで設定してPrometheusを起動すれば、Prometheusにメトリクスが収集されるようになります。

Prometheusの動作確認

アプリケーションのメトリクスをPrometheusが正常に収集しているかどうかを確認するためは、Prometheusの /targets というエンドポイントにアクセスするのが良いでしょう。

http://localhost:9090/targets

ここにアクセスすると、Prometheusが収集している対象の一覧が表示されます。

PrometheusのTargets
ここで正常にアプリケーションが表示されない場合は、Prometheusの設定などに誤りがある可能性があります。

また、Graphビューで process_cpu_usage などを検索すると、収集したCPU使用率をグラフで表示できます。

PrometheusのGraphビュー
このようなグラフ(Tableビューならテーブル)が表示されれば、正常に収集できていると言えます。

4. GrafanaからPrometheusにアクセス

最後に、GrafanaからPrometheusを利用できるようにします。

GrafanaのデータソースにPrometheusを指定

GrafanaでPrometheusを可視化するためには、Grafana側でPrometheusをdatasourceとして設定する必要があります。Grafanaの構築のところで説明した通り datasources.yml にその設定があります。

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://host.docker.internal:9090
    editable: false
    jsonData:
      httpMethod: POST
      exemplarTraceIdDestinations:
        - name: trace_id
          datasourceUid: tempo

(View in GitHub)

データソースとしてPrometheusを指定し、docker内の9090番ポートにアクセスするように記載しています。これでGrafanaがPrometheusを認識するようになります。

トレース情報のサンプルを tempo に送信するような設定も入っているのですが、これも今回は紹介しないので説明は割愛します。

ちなみにデータソースは datasources.yml で静的に設定するだけでなく、Grafanaの設定画面からでも追加できます。色々と試したい時は設定画面から追加した方が楽でしょう。

これでGrafanaのExploreでPrometheusを指定すると、Grafana上でPrometheusのメトリクスを検索、表示できるようになります。

http://localhost:3000

GrafanaでPrometheusのメトリクスを表示

Grafanaにダッシュボードを追加

ここまでの手順で、Spring Boot 3.0で作ったアプリケーションをGrafana + Prometheusでメトリクスを可視化できるようになりました。

ただここから自分で必要なデータを選んでダッシュボードを作っていくというのは、監視環境を構築した経験がないとなかなか難しいものです。そのため、Grafanaではコミュニティで作成したダッシュボードがたくさん提供されています。

grafana.com

ここでダッシュボードの一覧を「Spring Boot」で絞り込むと、2023年1月現在で52個のダッシュボードがヒットします。

https://grafana.com/grafana/dashboards/?search=Spring+Boot

その中でも最もよく使われているのが「JVM (Micrometer)」というものです。 grafana.com

導入はとても簡単で、まずダッシュボードのサイトでダッシュボードのIDを確認します。上の「JVM (Micrometer)」はIDが「4701」となっています。

それを確認したら、自分の環境に構築したGrafanaで左メニューのDashboardsにある「+Import」ボタンを押します。

DashboardsのImportボタン

「Import via grafana.com」の欄にIDを入力して右側にある「Load」ボタンを押します。

4701と入力して右のLoadボタンを押す

そしてPrometheusのデータソースとして、既に設定済みのPrometheusを指定して、「Import」ボタンを押します。

Prometheusを選んでImportボタンを押す

それだけで、カッコイイ感じのダッシュボードがインポートできました。

インポートされたダッシュボード

他にも様々なダッシュボードがあるため、好きなモノを探すもよし、自分でカスタマイズするのも良いでしょう。

まとめ

  • Spring Bootアプリケーションに spring-boot-starter-actuator と micrometer-registry-prometheus を追加するとPrometheus用のエンドポイントが追加される
  • GrafanaとPrometheusはdockerで簡単に利用できる
  • GrafanaのデータソースとしてPrometheusを指定する必要がある
  • コミュニティが作成したGrafana用のダッシュボードを利用できる

ぜひ、Grafanaで良い感じのダッシュボードを作ってみてください!

Spring Boot 3.0アプリケーションをGrafanaスタックで可視化してみた。

Spring Boot 3.0でMicrometer対応が強化されたとか、トレース情報を収集するSpring Cloud SleuthがMicrometerに入ったと聞き、この辺りはしばらく追いかけてなかったので、この機会にまとめて学び直すことにしました。

今回作ったものはGitHubに置いてあります。 github.com もう2023年になってしまいましたが、作り始めたのが2022年なので spring-store-2022 となっています。

イキってREADMEを英語で書く習慣がついてしまったので、代わりにこのブログで日本語の説明を書きたいと思います。工夫したところやハマったポイントなどの話はまた別途ブログを書くとして、今回はこのアプリケーションの概要と動かすところまで説明します。

1. ゴール

マイクロサービスのアプリケーションをこんな風に可視化するところがゴールです。

Grafanaでマイクロサービスのトレースを表示

2. サンプルアプリケーションの概要

サンプルアプリケーションは、いわゆるECサイト的なマイクロサービスを想定したものです。商品一覧を見る、カートに入れる、購入するなどができ、購入すると非同期で手配やクレジットカードの決済が行われるという動きを模倣しています。

サービスの構成は次の図の通りです。

マイクロサービスの構成
見やすさのため一部の処理を割愛していますが、いずれにせよBFF(Backend for Frontend)を介して各サービスを利用する形です。

なお今回作るのはサーバサイドのみです。フロントエンドは2019年にvueで作ったものを再利用したかったのですが、残念ながらすでにコンパイルが通らない状況になってしまっていたので諦めてしまいました。誰か助けて。

利用する技術スタック

  • Java 17
  • Spring Boot 3.0 (without Spring Cloud)
  • Grafana、Loki、Tempo、Prometheus、Promtail
  • Kubernetesk8sがなくても動く)

可視化はGrafanaスタックのみでメトリクス、ログ、トレースを見られるようになったので、これに一本化することにしました。

Grafanaスタックを使ったモニタリングのインフラ

また令和のマイクロサービスアプリケーションは一般的にコンテナとしてデプロイされるものであり *1 もはやデファクトスタンダードな基礎技術となっているk8s *2 にもデプロイできるようにしておきました。

3. アプリケーションをローカルで実行する

ローカル環境ではDockerでミドルウェアを立ち上げ、各マイクロサービスアプリケーションをIDEから起動する形で実行します。

(0) 必要なもの

ソースコードhttps://github.com/cero-t/spring-store-2022 からダウンロードするなりcloneするなりしてください。

(1) ミドルウェア(RabbitMQとGrafanaスタック)の起動

spring-store-2022/docker ディレクトリに移動します

cd docker

docker-composeでミドルウェアを起動します

docker-compose up -d

(2) アプリケーションの起動

IDEを使ってアプリケーションを起動してください。

Mavenspring-boot:run コマンドはプロジェクトの構成上、使えません。僕これSpring Maven Pluginのバグだと思ってるんですけどね。

(参考) cero-t.hatenadiary.jp

(3) アプリケーションへのアクセス

BFFでSwagger UIを使えるようにしているため、これを利用します。

http://localhost:9000/swagger-ui.html

  1. catalog-controller: GET /catalog
    • 商品一覧の名前や値段、画像などを取得できます
  2. cart-controller: POST /cart
    • カートを作成して cartId を取得できます
  3. cart-controller: POST /cart/{cartId}
    • カートに商品を追加します
    • 追加する itemId/catalog で取得できる商品のIDを指定してください
  4. cart-controller: GET /cart/{cartId}
    • カートに入った商品の詳細と合計金額を確認できます
  5. order-controller: POST /order
    • 商品を購入できます。と言ってもメールが送られたりクレジットカードの決済が発生することはありません
    • cardExpireMM/yy 形式で年月を入れてください
    • cartId は POST /cart で取得したIDを取得してください

/order にPOSTするボディの例

{
  "name": "Shin Tanimoto",
  "address": "Tokyo",
  "telephone": "0123456789",
  "mailAddress": "hello@example.com",
  "cardNumber": "0000111122223333",
  "cardExpire": "12/24",
  "cardName": "Shin Tanimoto",
  "cartId": "1"
}

これでエラーが出なければ、アプリケーションの操作は完了です。

(4) Grafanaのダッシュボードを確認

アプリケーションがどう動いたのかをGrafanaで確認します。

http://localhost:3000/

左メニューのコンパスのアイコン(Explore)をクリックします

GrafanaのExplore

上のドロップダウンリストから Loki を選択します

Lokiを選択

Label filters に app = bff を指定して右上の Run query ボタンを押します。アプリからLokiにログが送られるまで1分ほど掛かるので、選択できない場合やログが出ない場合は少し待ってから実行してください。

Lokiのクエリを実行

おそらく一番上にある CHECKOUT が含まれるログをクリックします

CHECKOUTのログ

TraceID のところに Tempo のボタンがあるのでこれをクリックします

TraceIdの隣にあるTempoボタン

トレースが表示されましたね!

トレースが右側に出た!

これで分散トレーシングの情報を可視化することができました。他にもPrometheusを使ったメトリクスの可視化などもできますが、説明はいったん割愛します。

(5) アプリケーションを終了させる

それぞれのアプリケーションをIDEで停止させてください

続いて、ミドルウェアを停止させます

docker-compose down

これで環境が綺麗になりました。

4. アプリケーションをKubernetes上で実行する

続いて、k8s上でも実行してみましょう。アプリケーションや設定ファイルは何も変更することなく、k8sマニフェスト環境変数を使って設定を上書きする形で起動します。

k8sにデプロイする際に application-k8s.properties などを別に作ってプロファイルを切り替える方法のもよく使われていと思いますが、僕としては「アプリケーションが特定のインフラを意識しない」というか、「k8s → アプリケーションの参照は良いけど、アプリケーション → k8sに相互参照させない」ということを意識しているので、k8sマニフェストで上書きすることにしています。

(0) 必要なもの

(1) k8sを起動する(minikubeの場合のみ。Docker Desktopの場合はk8sを有効にするだけでOK)

minikubeを十分なリソースで起動します

minikube start --cpus=4 --memory=4096

環境変数を設定して、minikubeのイメージリポジトリが利用されるようにします

eval $(minikube docker-env)

(2) アプリケーションのイメージをビルドする

spring-store-2022/services ディレクトリに移動します

cd services

アプリケーションイメージを作成します

sh build-image.sh

(3) k8sミドルウェアをインストールする

spring-store-2022/k8s ディレクトリに移動します

cd k8s

ネームスペースを作成します(必須ではありません。他のコンテナなどもk8s上に置いていて環境を分けたい場合のみ作成してください)

kubectl create ns spring-store-2022

Helmのリポジトリを追加します

helm repo add grafana https://grafana.github.io/helm-charts

Tempoをインストールします(ネームスペースを使う場合は -n spring-store-2022 を引数に追加してください)

helm install tempo grafana/tempo -f helm/tempo-config.yml

loki-stackをインストールします。これはGrafana、Prometheus、Loki、Promtailをまとめてインストールできるものです(ネームスペースを使う場合は -n spring-store-2022 を引数に追加してください)

helm install loki-stack grafana/loki-stack -f helm/loki-stack-config.yml

Lokiは起動に1〜2分くらい掛かるようで、それまではコネクションエラーなどが起きることがありますが、しばらく待てば正常に動き始めます。

(4) アプリケーションをk8sにデプロイする

アプリケーションをデプロイします(何度も言いますが、ネームスペースを使う場合は -n spring-store-2022 を引数に追加してください)

kubectl apply -f ./deploy

これでまとめてアプリケーションとRabbitMQがk8s上にデプロイされます。

(5) ポートフォワードを設定する

ローカル環境からアクセスできるよう、ポートフォワードを設定します。ネームスペースを使っている場合は -n spring-store-2022 を引数に追加してください。

Grafanaへのポートフォワードを設定します

kubectl port-forward svc/loki-stack-grafana 3000:80

minikubeを使っている場合は、BFFアプリケーションにポートフォワードを設定します。Docker Desktopの場合はこの手順をスキップしてもアクセスできるようになっています。

kubectl port-forward svc/bff-svc 9000:9000

これでk8s上でもローカル環境と変わらずアプリケーションを利用でき、モニタリングされていることを確認できるはずです。

アプリケーションの使い方やGrafanaの使い方は上で説明した通りですので割愛します。

(6) アプリケーションを終了させる

アプリケーションをk8sから削除します

kubectl delete -f ./deploy

ミドルウェアをアンインストールします

helm uninstall loki-stack
helm uninstall tempo

ネームスペースを作成していた場合はネームスペースを削除します

kubectl delete ns spring-store-2022

これで環境が綺麗になりました。

5. 所感

Grafanaでメトリクス、ログ、トレースのすべての可視化ができるようになっており、またSpring Bootからも簡単に情報が送れるようになっていることはなかなか体験が良く、今後はこれを自分の中での標準技術にしていきたいという気持ちです。

ただ今回、Spring BootのMicrometer対応や、Grafanaスタック、あとHelmも初めて使ってみたわけですが、思った以上に細かいところでハマってしまい、トータル2週間くらい格闘し続けた形になりました。

特にGrafanaスタックはアップデートが早くてHelm Chartがそれに追従していないとか、Helm Chartの設定に問題があって回避しなければいけないなどがあり、「コマンド一発で動くようになる」という状況ではありませんでした。デバッグに近いことをやり続けたおかげで、ずいぶんと知識はついたのですが、良くも悪くもbreaking changeが発生するくらい活発に開発中ということなんですね。

冒頭にも書きましたが、その辺りの細かい話や工夫したところは、また改めてブログを書きたいと思います。

(余談)過去に作っていたアプリケーション

ちなみにこれまで同じアプリケーションをJava 8や11でも作っていました。

2016年バージョン(Java 8 + Spring Boot 1.x + Spring Cloud Stream、Eureka、Hystrix、Sleuth + Zipkin) github.com

2019年バージョン(Vue.js + Java 11 + Spring Boot 2.x + Spring Cloud Stream、Eureka、Sleuth + Elasticsearch + Kibana + Elastic APM + Zipkin) github.com

その当時に僕が興味を持っていた技術がそのまんまスタックに現れてくるのがちょっと面白いですね。昨年、Dapr版を作ればよかったですね。

参考サイト

今回のアプリケーションを作成するに辺り、Grzejszczakさんのブログエントリーを参考にさせていただきました。 spring.io

GrzejszczakさんはSpringのObservability関連のプロダクトを開発されている方です。今後も活動を応援したいと思います!

*1:個人の感想です

*2:あくまでも個人の感想です

Spring Bootのマルチモジュール構成でmvn spring-boot:runできなくて困った話。

こんにちは、絶対にmvn installしたくないマンのcero_tです。しばらくブログはお休みです的なことを言ってたのに、今日も長文を書いてしまいました。

背景

Mavenで(Gradleでも)開発する時にはだいたいマルチモジュール構成にすると思うのですが、Spring Bootでマルチモジュール構成にした時に mvn spring-boot:runmvn spring-boot:build-image をしようとするとエラーになってハマってしまったというお話です。

というか、いつも大体ハマってしまって解決できなくて、先にサブモジュールを mvn install を実行してから mvn spring-boot:build-image するなどして回避することが常なのですが、僕はこの mvn install が嫌いでして。

コンテナで動くCI環境ならまだしも、ローカル環境で mvn install を実行すると環境が汚れてしまう *1 し、そこに残ったゴミのせいで思わぬ挙動になってハマることもあるので、開発効率よりもハマらないことのほうが重要 と考える僕はこのコマンドが嫌いなのです。

そんなわけで、今回は mvn install をせずに mvn spring-boot:runmvn spring-boot:build-image をするというお話です。

1. プロジェクトの構成

まずはサンプルにしたプロジェクトについて簡単に説明します。Spring Bootで開発している人にとっては「いつものやつ」なので読み飛ばしてもらっても大丈夫です。

1-1. ディレクトリ構造

親となる multi-module-example の下に my-library というライブラリのモジュールと my-service というSpring Bootアプリケーションのサービスがいるという構成です。

multi-module-example
 ├── .mvn
 ├── mvnw
 ├── pom.xml
 ├── my-library
 │    ├── pom.xml
 │    └── src
 └── my-service
      ├── pom.xml
      └── src

親子それぞれにpom.xmlを置いてモジュールとして定義しています。

ビルドは mvnw を使っておこないます。

1-2. 親pomの設定

multi-module-example のpom.xmlはこんな感じ。

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.0.0</version>
    <relativePath/>
</parent>

<groupId>ninja.cero.example.multimodule</groupId>
<artifactId>multi-module-example</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>

<name>multi-module-example</name>
<description>multi-module-example</description>

<modules>
    <module>my-library</module>
    <module>my-service</module>
</modules>

parentを spring-boot-starter-parent にして、子モジュールとして my-servicemy-library を指定しています。マルチモジュール構成の時は、大体こうしますよね。

1-3. ライブラリモジュールのpomの設定

my-library のpom.xmlはこんな感じ。

<artifactId>my-library</artifactId>
<packaging>jar</packaging>

<name>my-library</name>
<description>my-library</description>

<parent>
    <groupId>ninja.cero.example.multimodule</groupId>
    <artifactId>multi-module-example</artifactId>
    <version>1.0.0</version>
    <relativePath>../pom.xml</relativePath>
</parent>

parentを multi-module-example にします。親側で依存するライブラリとかを指定したいので、だいたいこういう相互参照にしますよね。

ちなみにライブラリの実装として、文字列を返すstaticメソッドを書いています。

public class MyLibrary {
    public static String hello() {
        return "Hello!";
    }
}

特に実装について説明することはありません。

1-4. サービスモジュールのpomの設定

my-service のpom.xmlはこんな感じ。

<artifactId>my-service</artifactId>
<packaging>jar</packaging>

<name>my-service</name>
<description>my-service</description>

<parent>
    <groupId>ninja.cero.example.multimodule</groupId>
    <artifactId>multi-module-example</artifactId>
    <version>1.0.0</version>
    <relativePath>../pom.xml</relativePath>
</parent>

<dependencies>
    <dependency>
        <groupId>ninja.cero.example.multimodule</groupId>
        <artifactId>my-library</artifactId>
        <version>1.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

やはりparentを multi-module-example にして、Spring Bootやライブラリのモジュールをdependencyに入れます。またSpring Bootアプリケーションとして起動するために、spring-boot-maven-plugin をビルドプラグインとして指定します。

またサービスの実装として、ライブラリを呼ぶようなWeb APIのエンドポイントを一つ設けています。

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

    @GetMapping("/")
    String hello() {
        return MyLibrary.hello();
    }
}

手抜きおぶ手抜きですが、ここは主題じゃないので。

1-5. IDEでの起動は問題なくできる

この構成で、IntelliJなどからMyServiceを実行すればアプリケーションが実行できます。

% curl localhost:8080
Hello!

はい、ここまでは当たり前です。

1-6. package もできる

この構成で、Executable JARを作って実行することもできます。

$ ./mvnw clean package
[INFO] multi-module-example ............................... SUCCESS [  0.057 s]
[INFO] my-library ......................................... SUCCESS [  0.527 s]
[INFO] my-service ......................................... SUCCESS [  0.427 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  1.179 s
[INFO] Finished at: 2022-12-28T09:25:39+09:00
[INFO] ------------------------------------------------------------------------

ビルドに成功したので、起動します。

$ java -jar my-service/target/my-service-1.0.0.jar
2022-12-28T09:27:39.691+09:00  INFO 64023 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2022-12-28T09:27:39.699+09:00  INFO 64023 --- [           main] n.cero.example.multi_module.MyService    : Started MyService in 1.155 seconds (process running for 1.384)

問題なく起動しました。

% curl localhost:8080
Hello!

もちろんアクセスすれば正常応答が返ります。

こんな風にふつうにビルドできて動くので、この時点ではあまり疑問を持つことはありません。

2. どんな問題が起きるのか

それでは、問題を再現させます。

2-1. spring-boot:run できない

Mavenコマンドを使って直接Spring Bootのアプリケーションを起動するには spring-boot:run を使います。ただ親子構造を持っている場合には、親側のディレクトリで -pl (--projects) オプションをつけて実行します。

% ./mvnw spring-boot:run -pl my-service
[INFO] -------------< ninja.cero.example.multimodule:my-service >--------------
[INFO] Building my-service 1.0.0
[INFO] --------------------------------[ jar ]---------------------------------
[WARNING] The POM for ninja.cero.example.multimodule:my-library:jar:1.0.0 is missing, no dependency information available
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  0.194 s
[INFO] Finished at: 2022-12-28T09:36:03+09:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal on project my-service: Could not resolve dependencies for project ninja.cero.example.multimodule:my-service:jar:1.0.0: ninja.cero.example.multimodule:my-library:jar:1.0.0 was not found in https://repo.maven.apache.org/maven2 during a previous attempt. This failure was cached in the local repository and resolution is not reattempted until the update interval of central has elapsed or updates are forced -> [Help 1]

はい失敗しました。依存している my-library:jar:1.0.0 がないぞと。親子構造で指定しているのに、気が利かないわね。

・・・なんて言わず -pl オプションは大体 -am (--also-make) オプションと一緒に実行します。依存しているモジュールがあればそれも一緒にビルドするというオプションです。

% ./mvnw clean spring-boot:run -am -pl my-service
[INFO] multi-module-example ............................... FAILURE [  0.199 s]
[INFO] my-library ......................................... SKIPPED
[INFO] my-service ......................................... SKIPPED
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  0.482 s
[INFO] Finished at: 2022-12-28T09:38:29+09:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:3.0.0:run (default-cli) on project multi-module-example: Unable to find a suitable main class, please add a 'mainClass' property -> [Help 1]

はい失敗しました。親pomである multi-module-example に、Spring Bootアプリケーションを起動するための main クラスがないぞと。

あるわけないやろ! 親pomやぞ、っていうか <packaging>pom</packaging> やぞ、良い感じにスキップしてくれや!!

というかこの挙動って前からでしたっけ? ライブラリ側にmainクラスがないと怒られるならまだしも、親pomにないと言われるのはちょっと解せないですね。

解決は後ほど試みるとして、一旦、もう一つコマンドを試してみます。

2-2. spring-boot:build-image できない

Mavenコマンドを使ってSpring Bootのアプリケーションのコンテナイメージを作るために spring-boot:build-image を使います。フットプリントが小さく、オプションなども良い感じにつけてくれると評判のbuildpacksを使ったイメージ作成コマンドです。

先ほどと同じように -pl (--projects)-am (--also-make) オプションをつけて実行します。

% ./mvnw clean spring-boot:build-image -am -pl my-service
[INFO] multi-module-example ............................... SUCCESS [  0.205 s]
[INFO] my-library ......................................... FAILURE [  4.931 s]
[INFO] my-service ......................................... SKIPPED
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  5.406 s
[INFO] Finished at: 2022-12-28T09:53:05+09:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:3.0.0:build-image (default-cli) on project my-library: Execution default-cli of goal org.springframework.boot:spring-boot-maven-plugin:3.0.0:build-image failed: Error packaging archive for image: Unable to find main class -> [Help 1]

はい失敗しました。ライブラリ my-librarymain クラスがないぞと。

いや、そうなるからライブラリ側には spring-boot-maven-plugin を指定してないんですけど…という気持ちでいっぱいになるのですが、mvn spring-boot:build-image を実行したからには -am で伴ってビルドされるモジュール側もアプリケーションのコンテナイメージを作られてしまう(そして失敗してエラーになる)みたいです。

3. 解決のためのトライ&エラー

それでは仮説を立てつつトライ&エラーをしながら問題の解決に向かいます。

まずはより重要な spring-boot:build-image の方から取り組みます。

3-1. ライブラリの spring-boot-maven-plugin をスキップする

ライブラリ側のビルドイメージを作ろうとしてエラーが出ているのですが、それなら spring-boot-maven-plugin の実行をスキップしてしまえば良いのではないかと考えました。

my-libary のpom.xmlに次のような設定を追加します。

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <skip>true</skip>
            </configuration>
        </plugin>
    </plugins>
</build>

これで spring-boot-maven-plugin の実行はスキップされます。

ところが、ところがですよ?

% ./mvnw clean spring-boot:build-image -am -pl my-service
[INFO] --- maven-jar-plugin:3.3.0:jar (default-jar) @ my-library ---
[INFO] Building jar: /Users/shin/GitHub/multi-module-example/my-library/target/my-library-1.0.0.jar
[INFO] 
[INFO] --- spring-boot-maven-plugin:3.0.0:repackage (repackage) @ my-library ---
[INFO] 
[INFO] <<< spring-boot-maven-plugin:3.0.0:build-image (default-cli) < package @ my-library <<<
[INFO] 
[INFO] 
[INFO] --- spring-boot-maven-plugin:3.0.0:build-image (default-cli) @ my-library ---
[INFO] 
(略)
[INFO] multi-module-example ............................... SUCCESS [  0.227 s]
[INFO] my-library ......................................... SUCCESS [  0.441 s]
[INFO] my-service ......................................... FAILURE [  0.715 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  1.671 s
[INFO] Finished at: 2022-12-28T10:11:51+09:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal on project my-service: Could not resolve dependencies for project ninja.cero.example.multimodule:my-service:jar:1.0.0: Could not find artifact ninja.cero.example.multimodule:my-library:jar:1.0.0 in central (https://repo.maven.apache.org/maven2) -> [Help 1]

my-library のビルドには成功したようなのに、my-service のビルド時に my-library が見つからないというエラーが発生します。

ビルド自体がスキップされてしまったのかと思って念のため確認しましたが my-library/target/ にはきちんと my-library-1.0.0.jar ができあがっています。それなのにそれなのに、なぜか参照されないのです。

ライブラリ側で spring-boot-maven-plugin の実行をスキップした場合、たとえビルドに成功していても、spring-boot-maven-plugin のコンテキストでは成果物を認識できず spring-boot:build-image では成果物がないかのように扱われている、と考えれば良いのでしょうかね。

3-2. ライブラリから親pomへの参照をやめる

それならばと、ライブラリ側から親pomへの参照をやめることにしました。ライブラリは別にSpringに関連する処理を書いているわけではないので、特に親pom、さらにはその親の spring-boot-starter-parent を参照する必要もありません。

my-library のpom.xmlを次のように書き換えました。

<groupId>ninja.cero.example.multimodule</groupId>
<artifactId>my-library</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>

<name>my-library</name>
<description>my-library</description>

<properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
</properties>

親pomへの参照を削除しました。

この状態で ./mvnw clean package すると、正常にビルドが通ることは確認できています。

ところが、ところがです。

% ./mvnw clean spring-boot:build-image -am -pl my-service
[INFO] my-library ......................................... SKIPPED
[INFO] multi-module-example ............................... SKIPPED
[INFO] my-service ......................................... SKIPPED
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  0.994 s
[INFO] Finished at: 2022-12-28T10:26:00+09:00
[INFO] ------------------------------------------------------------------------
[ERROR] No plugin found for prefix 'spring-boot' in the current project and in the plugin groups [org.apache.maven.plugins, org.codehaus.mojo] available from the repositories [local (/Users/shin/.m2/repository), central (https://repo.maven.apache.org/maven2)] -> [Help 1]

spring-boot: できるプラグインが見つからないというのです。なぜでしょうか。仮説をいくつか立ててみました。

  1. 何らかの理由で親pomが spring-boot: を実行するためのプラグインを見失った
  2. 親子構造の場合は、親と子は相互参照しないといけない
  3. ビルド対象はすべて spring-boot-starter-parent (の中にある何か)に依存しなければならない

ひとつずつ検証していきましょう。

3-2 (1) 親pomが spring-boot: を実行するためのプラグインを見失った?

親pom側で spring-boot-maven-plugin を入れてみました。

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

これでも結果は全く変わりません。別に親pomが spring-boot: プラグインを参照できていないことが理由というわけではなさそうです。

3-2 (2) 親子構造の場合は、親と子は相互参照しないといけない?

マルチモジュール構成では、親と子は相互に参照しなければならないのでしょうか。

ただそれならば ./mvnw clean package コマンドも通らないはずです。実際にはビルドに成功して実行可能な成果物ができていました。

つまり、親子は相互参照にしなければならないわけではありません。

3-2 (3) ビルド対象はすべて spring-boot-starter-parent に依存しなければならない?

親子関係がなくとも、親が spring-boot-starter-parent であれば良いのでしょうか。

my-library のpom.xmlを次のように書き換えてみます。

<groupId>ninja.cero.example.multimodule</groupId>
<artifactId>my-library</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>

<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.0</version>
        <relativePath/>
</parent>

<name>my-library</name>
<description>my-library</description>

これでビルドしてみましょう。

% ./mvnw clean spring-boot:build-image -am -pl my-service
[INFO] my-library ......................................... FAILURE [  0.421 s]
[INFO] multi-module-example ............................... SKIPPED
[INFO] my-service ......................................... SKIPPED
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  0.682 s
[INFO] Finished at: 2022-12-28T10:37:48+09:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:3.0.0:run (default-cli) on project my-library: Unable to find a suitable main class, please add a 'mainClass' property -> [Help 1]

エラーが変わって my-librarymain クラスがないというエラーになりました。

そうすると、すべてのビルド対象が spring-boot-starter-parent に依存しなければいけないという仮説は、的外れではなさそうです。*2

4. 解決編

さて、紳士淑女の皆さま、お待たせしました。ここからは解決編です。すべての手がかりは提示されました。

エラーは自明。ただし、私はこう問いかけましょう。はたして、あなたは私の解決策を推理することができますか?

要点は3つ。勘のいい皆さんはもうおわかりですね?

  1. ライブラリ側の spring-boot-maven-plugin が有効な状態では spring-boot:build-image でライブラリのアプリケーションイメージを作ろうとしてしまい、必ず失敗する

  2. ライブラリ側のビルドで spring-boot-maven-plugin をスキップすると、spring-boot:build-image では成果物はなかったと見なされてしまい、後続のサービス側のビルド時にライブラリの成果物を見つけることができない

  3. すべてのビルド対象が spring-boot-starter-parent に依存しなければいけない

これらの問題を解決する方法は何なのか。ヒントは mvn package は正常に動くということ…

@cero_t でした。

4-1. spring-boot:build-image を解決する

あまり視聴率の高くないドラマのセリフをパクっても、まるで滑ったかのような感じになるので難しいですね。

そんなわけで解決策ですが、まずは spring-boot:build-image の方から解決します。

ライブラリ側 my-library のpom.xmlをこのような形にします。

<artifactId>my-library</artifactId>
<packaging>jar</packaging>

<name>my-library</name>
<description>my-library</description>

<parent>
    <groupId>ninja.cero.example.multimodule</groupId>
    <artifactId>multi-module-example</artifactId>
    <version>1.0.0</version>
    <relativePath>../pom.xml</relativePath>
</parent>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <skip>true</skip>
            </configuration>
        </plugin>
    </plugins>
</build>

spring-boot-starter-parent を使う必要がある以上、spring-boot-maven-plugin はスキップする設定を入れるしかありません。parentは親pomでも spring-boot-starter-parent でもどちらでも構わないのですが、利便性を考えると親pomを参照する方が好きです(この件は後述します)

そして実行コマンドは次のようになります。

./mvnw clean package spring-boot:build-image -am -pl my-service

spring-boot:build-image の前に package を入れています。

こうすれば package でライブラリとサービスのビルドをおこなって必要な成果物が揃い、続いてspring-boot:build-image でビルド済みの成果物を使ってイメージ作成のみが行われるわけです。

ちなみに1コマンドで済ませることが必要であり、次のように2コマンドに分けると動きません。

./mvnw clean package -am -pl my-service
./mvnw spring-boot:build-image -am -pl my-service

この場合も spring-boot:build-image でサービス側をビルドしようとした時に「ライブラリ側の成果物はなかった」と認識してしまうようです。2つめのコマンドの -am オプションを外しても変わりません。

spring-boot: の挙動に少し怪しいところを感じなくはないのですが、どうあれ1コマンドで package でビルドまで済ませてから spring-boot:build-image でイメージを作るところまでやりきれば良いということです。

4-2. spring-boot:run を解決する

仕組みがおおむね把握できたところで、続けて spring-boot:run の解決もしましょう。

こちらは親pomの main クラスを探そうとしてしまうことが問題になります。

ここまで見てきた挙動を踏まえると、解決策は2つあります。

4-2 (1) 親pomの spring-boot-maven-plugin をスキップする

一つ目は、親側のpomで spring-boot-maven-plugin をスキップすることです。次のような設定になります。

 <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.0</version>
        <relativePath/>
    </parent>

    <groupId>ninja.cero.example.multimodule</groupId>
    <artifactId>multi-module-example</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <name>multi-module-example</name>
    <description>multi-module-example</description>

    <modules>
        <module>my-library</module>
        <module>my-service</module>
    </modules>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <skip>true</skip>
                </configuration>
            </plugin>
        </plugins>
    </build>

ただし親pomでこのように定義すると、子側のライブラリ2つにも同じ設定が引き継がれることになります。そのため先ほど spring-boot:build-image のために my-library に入れた同様の設定は必要なくなります。

逆にサービス側 my-serivce では次のように設定を上書きする必要があります。

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <skip>false</skip>
            </configuration>
        </plugin>
    </plugins>
</build>

skipがfalseだからスキップしない、というのは二重否定のようでちょっと分かりづらい気もしますが、この方針での回避策としてはこの形になります。

4-2 (2) 子側から親pomへの参照をやめる

もう一つは、子側、つまりライブラリ側とサービス側の両方について、<parent> を親pomではなく spring-boot-starter-parent にしてしまうことです。そうすれば子から親への参照はなくなるわけですから spring-boot:run する時に、参照先の親側で spring-boot:run することもなくなります。

その場合、親側のpomで <dependencyManagement> を使って依存するモジュールのバージョンを一括で指定することはできなくなります。もし依存するモジュールのバージョンを指定したい場合には、依存を管理する専用のモジュールを別途作る必要があります。

具体的にはこのような設定のモジュールを作ります。

<groupId>ninja.cero.example.multimodule</groupId>
<artifactId>dependency-management</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>

<name>dependency-management</name>
<description>dependency-management</description>

<dependencyManagement>
        <dependencies>
                <dependency>
                        <groupId>com.github.loki4j</groupId>
                        <artifactId>loki-logback-appender</artifactId>
                        <version>1.3.2</version>
                </dependency>
        </dependencies>
</dependencyManagement>

そして、それをアプリケーション側の <dependencyManagement> で利用する形になります。

 <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.loki4j</groupId>
            <artifactId>loki-logback-appender</artifactId>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>ninja.cero.example.multimodule</groupId>
                <artifactId>dependency-management</artifactId>
                <version>1.0.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

こちらの方が作るファイルが増えますが、Spring BootやSpring Cloudでも採られている手法ですし、どちらかと言えばこちらの方が真っ当な方法ですよね。

まとめ

  • 4-2 の (1) か (2) の好きな方を選んでください

雑なまとめてですが、楽だからと思って親側の <dependencyManagement> にたくさんバージョンを書いて、子側からそれを参照してきたのですが、その辺りも重なって mvn spring-boot: 系のコマンドが上手く動かなかっただけ、という話でした。

これだけ長文でツラツラ書いてきましたけど、最初から 4-2 (2) のように親は <module> と、真に共通の設定だけ列挙して、依存管理などは別のモジュールでやるようにするという真っ当な方法でビルドしていれば起きなかった問題だということになりますね。

私ったらウッカリさん、てへぺろ

そんなわけで、このブログが子pomから親pomを参照している人たちに届くことを祈っております。

*1:./m2 ディレクトリにビルド成果物がコピーされます

*2:spring-boot-starter-parentのどの部分の設定が効いたのかを追う根気はありませんでした

Spring Data JDBCを拡張してみる その2 - アノテーションでクエリを受け取る

毎日のようにブログを書いていると、昨年の Dapr Advent Calendar を思い出しますね。とは言え、そろそろブログを毎日書くのもいったんキリがつきそうです。

やりたいこと

前回も書きましたが、ゴールを再確認しておきます。

  • Spring Data JDBCに機能追加して query(String query, Object... args) メソッドを追加できること
  • Spring Data JDBCを使ったうえで @SqlFile アノテーションで指定したSQLファイルを読み込んで実行できること

今回は2つ目を説明します。

具体的には次のような実装ができるようになるイメージです。

@Repository
public interface EmpRepository extends SqlRepository<Emp, Long> {
    @SqlFile("/sql/selectOdd.sql")
    List<Emp> selectOdd();
}

自作Spring Dataの方でも実装したので同じ感じでいけるでしょうね。

1. 独自アノテーションを読んで処理する部分を作る

独自アノテーションを作る

まずはSQLファイル名を指定するためのアノテーションを作成します。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface SqlFile {
    String value() default "";
}

(in GitHub)

これはアプリケーション側のRepositoryで使ってもらうものです。

RepositoryQuery の実装

続いて、そのアノテーションを使ってクエリを構築する RepositoryQuery を作成します。

public class SqlFileQuery implements RepositoryQuery {
    private SqlFile annotation;

    private JdbcOperations jdbcOperations;

    private RowMapper<?> rowMapper;

    private QueryMethod queryMethod;

    Lazy<String> query = Lazy.of(this::getQuery);

    public SqlFileQuery(SqlFile annotation, JdbcOperations jdbcOperations, RowMapper<?> rowMapper, QueryMethod queryMethod) {
        this.annotation = annotation;
        this.jdbcOperations = jdbcOperations;
        this.rowMapper = rowMapper;
        this.queryMethod = queryMethod;
    }

    @Override
    public Object execute(Object[] parameters) {
        return jdbcOperations.query(query.get(), rowMapper);
    }

    @Override
    public QueryMethod getQueryMethod() {
        return queryMethod;
    }

    private String getQuery() {
        String fileName = annotation.value();
        URL resource = getClass().getResource(fileName);

        if (resource == null) {
            throw new RuntimeException("SQL file cannot be read: " + fileName);
        }

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.openStream()))) {
            return reader.lines().collect(Collectors.joining());
        } catch (IOException e) {
            throw new UncheckedIOException("SQL file cannot be read: " + fileName, e);
        }
    }
}

(in GitHub)

中核となる部分は execute メソッドです。

@Override
public Object execute(Object[] parameters) {
    return jdbcOperations.query(query.get(), rowMapper);
}

(in GitHub)

JdbcOperations に処理を流すだけですが、この処理を行うために必要なインスタンスをコンストラクタで受け取ってフィールドとして保持しています。

また実行するSQLについては spring-data-jdbc を真似して Lazy を使って遅延ローディングしています。

Lazy<String> query = Lazy.of(this::getQuery);

private String getQuery() {
    String fileName = annotation.value();
    URL resource = getClass().getResource(fileName);

    if (resource == null) {
        throw new RuntimeException("SQL file not found: " + fileName);
    }

    try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.openStream()))) {
        return reader.lines().collect(Collectors.joining());
    } catch (IOException e) {
        throw new UncheckedIOException("SQL file cannot be read: " + fileName, e);
    }
}

(in GitHub)

動作検証のためだけなら特に遅延ローディングする必要もなかったのですが、カッコイイので真似してみた感じです。

QueryLookupStrategy の実装

さらに上で作った SqlFileQuery (implements RepositoryQuery)インスタンスを生成するための QueryLookupStrategy を作成します。

QueryLookupStrategyRepositoryQuery を返す resolveQuery を実装するだけで構いません。

public class JdbcExtQueryLookupStrategy implements QueryLookupStrategy {
    JdbcOperations jdbcOperations;
    RelationalMappingContext context;
    JdbcConverter converter;
    QueryLookupStrategy originalQueryLookupStrategy;

    public JdbcExtQueryLookupStrategy(JdbcOperations jdbcOperations, RelationalMappingContext context, JdbcConverter converter, QueryLookupStrategy originalQueryLookupStrategy) {
        this.jdbcOperations = jdbcOperations;
        this.context = context;
        this.converter = converter;
        this.originalQueryLookupStrategy = originalQueryLookupStrategy;
    }

    @Override
    public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, NamedQueries namedQueries) {
        QueryMethod queryMethod = new QueryMethod(method, metadata, factory);
        EntityRowMapper<?> rowMapper = new EntityRowMapper<>(context.getRequiredPersistentEntity(metadata.getDomainType()), converter);

        SqlFile annotation = method.getAnnotation(SqlFile.class);
        if (annotation != null) {
            return new SqlFileQuery(annotation, jdbcOperations, rowMapper, queryMethod);
        }

        return originalQueryLookupStrategy.resolveQuery(method, metadata, factory, namedQueries);
    }
}

(in GitHub)

メインとなる部分は、SqlFileQueryインスタンスを生成して返す部分ですね。

return new SqlFileQuery(annotation, jdbcOperations, rowMapper, queryMethod);

(in GitHub)

このために必要なインスタンスをコンストラクタで受け取って、フィールドとして保持しています。

ただここで、@SqlFile アノテーションのついたメソッドを処理する分には問題ありませんが、それ以外のアノテーションがついている場合などにはSpring Data JDBCの標準機能で処理すべきです。そのために、コンストラクタで QueryLookupStrategy originalQueryLookupStrategy というSpring Data JDBCが作成する QueryLookupStrategy を受け取ってフィールドとして保持しておいて、@SqlFile アノテーションがついていないメソッドの場合場合には、その originalQueryLookupStrategy を使って RepositoryQuery をルックアップするようにしました。

独自拡張あるあるな実装ですね。

JdbcExtRepositoryFactory の実装修正

さらにこの JdbcExtQueryLookupStrategy を使えるよう、前回作成した JdbcExtRepositoryFactorygetQueryLookupStrategy メソッドを追加します。具体的には次のメソッドを追加しました。

public class JdbcExtRepositoryFactory extends JdbcRepositoryFactory {
    // (略)
    @Override
    protected Optional<QueryLookupStrategy> getQueryLookupStrategy(QueryLookupStrategy.Key key,
                                                                   QueryMethodEvaluationContextProvider evaluationContextProvider) {
        Optional<QueryLookupStrategy> original = super.getQueryLookupStrategy(key, evaluationContextProvider);
        return Optional.of(new JdbcExtQueryLookupStrategy(operations.getJdbcOperations(), context, converter, original.orElseThrow()));
    }
    // (略)
}

(in GitHub)

ここで先に super.getQueryLookupStrategy を使ってSpring Data JDBC標準の QueryLookupStrategy を取得しておき、今回作った JdbcExtQueryLookupStrategy のコンストラクタに渡すようにしました。理由は上に書いたとおり、今回作った @SqlFile アノテーションがついていないメソッドを処理する際には、Spring Data JDBC標準通りの動きをさせるためです。

これで実装は完了です。

2. サンプルアプリケーションを修正する

続いて、サンプルアプリケーション側も修正します。

Repositoryにメソッド追加

まずはRepositoryクラスに、今回作った @SqlFile アノテーションをつけたメソッドを作成します。

@Repository
public interface EmpRepository extends JdbcExtRepository<Emp, Long> {
    @SqlFile("/sql/selectOdd.sql")
    List<Emp> selectOdd();

    @Query("select * from emp where id in (2, 4, 6)")
    List<Emp> selectEven();
}

(in GitHub)

既存のアノテーションが動くことも確認するためにSpring Data JDBC@Query アノテーションを利用したメソッドも追加しています。

また /src/main/resources/sql/selectOdd.sql ファイルの中には次のようなクエリを書きました。

select * from emp where id in (1, 3, 5)

(in GitHub)

selectOddid が奇数のレコードを返す、selectEvenid が偶数のレコードを返すというクエリを実行します。

Controllerにメソッド追加

続いて、コントローラー側にも処理を追加します。

public class EmpController {
    // (略)
    @GetMapping("/odd")
    List<Emp> odd() {
        return empRepository.selectOdd();
    }

    @GetMapping("/even")
    List<Emp> even() {
        return empRepository.selectEven();
    }
    // (略)
}

(in GitHub)

/odd/even というエンドポイントが、それぞれリポジトリselectOdd selectEven を呼び出すという簡単なものです。

これでサンプルアプリケーション側の実装も完了です。

実際に動かしてみる

それではアプリケーションを起動して、エンドポイントを叩いてみましょう。

% curl localhost:8080/odd                   
[{"id":1,"name":"Nakamoto"},{"id":3,"name":"Mizuno"},{"id":5,"name":"Fujihira"}]
% curl localhost:8080/even
[{"id":2,"name":"Kikuchi"},{"id":4,"name":"Sayashi"},{"id":6,"name":"Okazaki"}]

/odd /even とも期待通りの結果となりました。

前回のエントリーでカスタマイズできる仕組みまで作っており、今回はそれに乗っかって機能を追加しただけですので、簡単でしたね。

まとめ

今回作ったものはGitHubに置いています。

github.com

これまで5回に分けてSpring Dataを自作する方法とSpring Data JDBCを拡張する方法を説明してきました。最初は入口すら分からなくて難儀しましたが、構造や挙動が分かってしまえば後はサクッと実装してしまえるくらいの難易度でした。

実際にやってみた感想として、RDBMSにアクセスするためのSpring Dataを自作するのはコストとメリットが合わないので、Spring Data JDBCを拡張して好きなようにアノテーションやメソッドを追加する方が良さそうだなと思いました。Spring Dataの自作を考えるのは、RDBMS以外の新しいNoSQLにSpring Dataでアクセスしたいと思った時くらいでしょう。

そんなわけで長文を書き続けてきましたが、今回の取り組みはいったんここで区切りをつけます。これからしばらくは、どんな風にSpring Dataを拡張すると良いか考えて実装したいと思います。また形になってきたらブログで報告します。

それでは、Enjoy Spring Data!