負荷テストツールK6を試す
はじめに
負荷テストのツールを何かしら勉強したいなと思って、K6というツールがあるというのを知って良さそうに感じたのでとりあえず動かしてみるところまでやってみようと思います。
K6とは
K6はLoad Impactという負荷テストのサービスを作っていた会社が、その経験を活かして作ったOSSみたいです。その機能に以下のようなものがあります。
- ES6でのスクリプティング
- テストも設定オプションもJSのコードで記述できる
- CIでの自動化サポート(アサーションや成功/失敗を判定するしきい値の設定が可能)
- HTTP/1.1、2、WebSocket、gRPCのサポート
- TLS機能のサポート
- ビルドインのHARコンバーター
- InfluxDB (+Grafana)やJson等を利用したメトリクスの公開
- その他様々な機能
- クッキー
- 暗号化
- メトリクスのカスタマイズ
- エンコーディング
- HTMLフォーム
Virtual User
K6にはVirtual Users(VUs)という概念があります。
Virtual Usersはそれぞれが分けられた環境で、並行でテストスクリプトを実行してくれます。
また、Virtual Userはリアルユーザの真似をするようなリクエストを送ることも可能みたいです。
使ってみる
早速使ってみたいと思います。
K6を実行するためにはそのツールセットをインストールする必要がありますが今回はDockerを利用してK6を実行したいと思います。
環境
実行環境は以下の通りです。
$ uname -srvmpio Linux 5.4.0-58-generic #64-Ubuntu SMP Wed Dec 9 08:16:25 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux $ lsb_release -a LSB Version: core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch Distributor ID: Ubuntu Description: Ubuntu 20.04.1 LTS Release: 20.04 Codename: focal $ docker version Client: Docker Engine - Community Version: 19.03.12 API version: 1.40 Go version: go1.13.10 Git commit: 48a66213fe Built: Mon Jun 22 15:45:36 2020 OS/Arch: linux/amd64 Experimental: false Server: Docker Engine - Community Engine: Version: 19.03.12 API version: 1.40 (minimum version 1.12) Go version: go1.13.10 Git commit: 48a66213fe Built: Mon Jun 22 15:44:07 2020 OS/Arch: linux/amd64 Experimental: true containerd: Version: 1.2.13 GitCommit: 7ad184331fa3e55e52b890ea95e65ba581ae3429 runc: Version: 1.0.0-rc10 GitCommit: dc9208a3303feef5b3839f4323d9beb36df0a9dd docker-init: Version: 0.18.0 GitCommit: fec3683
事前準備
インストール
前述の通り、K6を実行するにはそのツールセットをインストールする必要があります。様々な環境でインストール、動作させることが可能です。
Linux(Ubuntu/Debian)
DabianベースのLinuxの場合はプライベートのdebリポジトリからインストールすることができます。
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 379CE192D401AB61 echo "deb https://dl.bintray.com/loadimpact/deb stable main" | sudo tee -a /etc/apt/sources.list sudo apt-get update sudo apt-get install k6
Docker
Dockerイメージloadimpact/k6が公開されており、そのイメージを用いたテストの実行も可能です。
docker pull loadimpact/k6
Mac
Macではbrewを使ってインストールすることができるみたいです。
brew install k6
バイナリでのインストール
GitHubのページからバイナリを取得することも可能です。
リンクからバイナリをダウンロードしてPATHを通して通してください。
テストするアプリを用意する
インストールが終わったところで、テスト対象のアプリを準備しておきます。
今回は環境が用意できればなんでもよいので個人的に1番使い慣れてるSpringを使ってアプリを作ろうと思います。
Spring Initializrを使ってアプリを作成します。
設定は以下のように
出来上がったプロジェクトをエディタ等で開き作成されているApplication
クラスを以下のように修正します。
@SpringBootApplication @RestController public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } @GetMapping("/hello") public String hello(){ return "hello"; } }
アプリを起動してcURLでエンドポイントへアクセスするとHelloという文字列が返ってきます。
$ curl localhost:8080/hello hello
テストコードを書く
それでは準備ができたところで実際にテストスクリプトを書いて行きたいと思います。 K6でスクリプトを記述する場合基本的なスクリプトの構成は以下のようになります。
// init code export default function() { // vu code }
K6ではdefalt
functionを定義してやる必要があり、これが一般的に言うメイン関数のようなテストコードのエントリーポイントとなります。
defalt
functionの中のコードのことをVU code
と呼び、外側のコードのことをinit code
と呼びます。
ここで、Virtual Usersを利用して、並行化されるのはVU code
であり、init code
は一回のみ実行されます。VU code
の中ではHTTPなどのリクエストを送信しそのメトリクスを計測することは可能ですが、ローカルのファイルシステムを読み込んだり、他のモジュールを呼んだりすることはできません。それらはinit code
で実行することが必要です。
以上のことを踏まえて、リクエストを一回実行して1秒だけまつスクリプトを記述します。
k6script.js
import http from 'k6/http'; import { sleep } from 'k6'; export default function () { http.get('http://${HOST_IP}:8080/hello'); sleep(1); }
テストコードが記述できたので早速実行してみます。 実行は以下のようにコマンドを叩きます。
$ docker run -i loadimpact/k6 run - < ${SCRIPT_NAME}.js
今回の場合は次のようになります。
$ docker run -i loadimpact/k6 run - < k6script.js /\ |‾‾| /‾‾/ /‾‾/ /\ / \ | |/ / / / / \/ \ | ( / ‾‾\ / \ | |\ \ | (‾) | / __________ \ |__| \__\ \_____/ .io execution: local script: - output: - scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop): * default: 1 iterations for each of 1 VUs (maxDuration: 10m0s, gracefulStop: 30s) running (00m01.0s), 1/1 VUs, 0 complete and 0 interrupted iterations default [ 0% ] 1 VUs 00m01.0s/10m0s 0/1 iters, 1 per VU running (00m01.0s), 0/1 VUs, 1 complete and 0 interrupted iterations default ✓ [ 100% ] 1 VUs 00m01.0s/10m0s 1/1 iters, 1 per VU data_received..............: 118 B 114 B/s data_sent..................: 89 B 86 B/s http_req_blocked...........: avg=1.67ms min=1.67ms med=1.67ms max=1.67ms p(90)=1.67ms p(95)=1.67ms http_req_connecting........: avg=1.61ms min=1.61ms med=1.61ms max=1.61ms p(90)=1.61ms p(95)=1.61ms http_req_duration..........: avg=2ms min=2ms med=2ms max=2ms p(90)=2ms p(95)=2ms http_req_receiving.........: avg=83.92µs min=83.92µs med=83.92µs max=83.92µs p(90)=83.92µs p(95)=83.92µs http_req_sending...........: avg=72.25µs min=72.25µs med=72.25µs max=72.25µs p(90)=72.25µs p(95)=72.25µs http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s http_req_waiting...........: avg=1.84ms min=1.84ms med=1.84ms max=1.84ms p(90)=1.84ms p(95)=1.84ms http_reqs..................: 1 0.96781/s iteration_duration.........: avg=1s min=1s med=1s max=1s p(90)=1s p(95)=1s iterations.................: 1 0.96781/s vus........................: 1 min=1 max=1 vus_max....................: 1 min=1 max=1
コンソールに実行結果が出力されているのがわかります。
ちょっとだけ細かく見てみると、送信したデータ量(B/s)、受信したデータ量(B/s)、リクエストでブロックされた時間、接続にかかった時間、等々の平均値やパーセントタイルの値などが表示されていますね。
VirtualUsersを追加して、テストの実行時間を変更する
次に、Virtual Usersを追加してリクエストを並行で送ってみるようにしてみたいと思います。 方法は以下の4つほどあります
- テスト実行時オプション(
--vus
and-duration
)として渡す - 環境変数として定義しておく
.json
の設定ファイルを作成し、実行時に読み込ませるinit call
にoptions
のJsonオブジェクトを定義する
今回は4つ目のJsonオブジェクトを定義する方でやろうと思います。
具体的には、「10 users で10秒間リクエストを送る
」ように、先程のコードを以下のように書き換えます。
import http from 'k6/http'; import { sleep } from 'k6'; export let options = { vus: 10, duration: '10s', }; export default function () { http.get('http://${HOST_IP}:8080/hello'); sleep(1); }
option.vus
とoption.duration
をそれぞれinit code
の領域で設定しています。
テストを実行すると先ほどと違って、10並行で、10秒間リクエストが送られていることがわかります。
$ docker run -i loadimpact/k6 run - < k6script.js /\ |‾‾| /‾‾/ /‾‾/ /\ / \ | |/ / / / / \/ \ | ( / ‾‾\ / \ | |\ \ | (‾) | / __________ \ |__| \__\ \_____/ .io execution: local script: - output: - scenarios: (100.00%) 1 scenario, 10 max VUs, 40s max duration (incl. graceful stop): * default: 10 looping VUs for 10s (gracefulStop: 30s) running (01.0s), 10/10 VUs, 0 complete and 0 interrupted iterations default [ 9% ] 10 VUs 00.9s/10s running (02.0s), 10/10 VUs, 10 complete and 0 interrupted iterations default [ 19% ] 10 VUs 01.9s/10s (略) default [ 99% ] 10 VUs 09.9s/10s running (10.1s), 00/10 VUs, 100 complete and 0 interrupted iterations default ✓ [ 100% ] 10 VUs 10s data_received..............: 12 kB 1.2 kB/s data_sent..................: 8.9 kB 880 B/s http_req_blocked...........: avg=32.42µs min=2.11µs med=11.88µs max=855.09µs p(90)=49.84µs p(95)=172.34µs http_req_connecting........: avg=19.37µs min=0s med=0s max=814.16µs p(90)=4.29µs p(95)=144.09µs http_req_duration..........: avg=3.67ms min=704.32µs med=3.94ms max=8.48ms p(90)=6.71ms p(95)=7.35ms http_req_receiving.........: avg=99.37µs min=16.55µs med=103.15µs max=223.89µs p(90)=173.99µs p(95)=182.34µs http_req_sending...........: avg=49.94µs min=9.15µs med=47.83µs max=334.41µs p(90)=70.58µs p(95)=87.42µs http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s http_req_waiting...........: avg=3.52ms min=658.68µs med=3.75ms max=8.3ms p(90)=6.52ms p(95)=6.92ms http_reqs..................: 100 9.895886/s iteration_duration.........: avg=1s min=1s med=1s max=1s p(90)=1s p(95)=1s iterations.................: 100 9.895886/s vus........................: 10 min=10 max=10
ここまでで、ざっくりとK6の利用方法は把握できた気がします。
テスト結果の出力
テスト結果はデフォルトではコンソールに出力されますが、以下のような出力先を変更するプラグインがいくつか用意されています。
この他にもNew Relicなどもあります。
オプション
最後に、その他にどんなことができるのかざっくり把握するためオプションで気になったところをいくつかまとめたいと思います。
全量はここで確認することができます。
Duration
Durationでは、テストが実行される時間を設定することができます。
実行時間中はそれぞれのVUsでスクリプトがループして呼ばれ続けます。
例えば以下のような設定の場合3分間vu code
がループで実行され続けます。
export let options = { duration: '3m', };
Iterations
IterationsオプションはVUsがそれぞれ何度スクリプトを実行するかを指定することができます。
この値は、VUsで単純に割り算され、例えば以下のような設定である場合10Vusがそれぞれ10回のリクエストを実行します。
export let options = { vus: 10, iterations: 100, };
No VU Connection Reuse
No VU Connection ReuseではTCPをVUsの実行の中でTCPコネクションを再利用するかをbooleanで設定できます。 設定は以下のように行います。
export let options = { noVUConnectionReuse: true, };
RPS
RPSではVUsをまたいだ秒間での最大リクエスト数を設定することができます。
export let options = { rps: 500, };
Scenarios
Scenariosでは、1つ以上の実行パターンを定義することができます。シナリオは以下のような特徴があります。
- 同じスクリプトに対して複数のシナリオを定義できる
- それぞれのシナリオは個別のVUsやスケジュールパターンを持つことができる
- それぞれのシナリオは直列でも並行でも実行することができる
- シナリオごとに違った環境変数やメトリクスタグをセットすることができる
より詳細な情報はこちらを参考にしてください。
利用イメージは以下のようになります。
export let options = { scenarios: { my_api_scenario: { // arbitrary scenario name executor: 'ramping-vus', startVUs: 0, stages: [ { duration: '5s', target: 100 }, { duration: '5s', target: 0 }, ], gracefulRampDown: '10s', env: { MYVAR: 'example' }, tags: { my_tag: 'example' }, }, }, };
Stages
StagesではVUsの上昇や下降を制御することができます。
例えば以下のような設定の場合、最初の3分間でVUsを1から10まで上昇させ5分間そのままリクエストを続けその他とに10から35ユーザ10分間で増やしていき、最後の3分でVUsを0まで減らします。
export let options = { stages: [ { duration: '3m', target: 10 }, { duration: '5m', target: 10 }, { duration: '10m', target: 35 }, { duration: '3m', target: 0 }, ], };
参考資料
Nettyの主要コンポーネントを整理する
はじめに
Nettyを勉強していると、EventLoopやHandlerなど様々なコンポーネントがでてきて、混乱したのでちょっと図でまとめてみようと思います。このブログでは以下のようなNettyの主要コンポーネント(クラス or インターフェース)の役割をまとめそれぞれの関係を図で整理しようと思います。
- EventLoop、EventLoopGroupe
- Channel
- ChannelHandler、ChannelPipeline
Netty In Actionなどの書籍を参考にしていますが、あくまで自分の理解をまとめますので、正しい理解を行いたい場合は書籍やドキュメント実装を当たるようにしてください。
非同期I/O? 同期I/O?
前提としてJavaのネットワーク通信における、同期I/Oと非同期I/Oについてちょっと整理してみようと思います。
Javaにおけるネットワーキングプログラムは、はじめ、ネイティブのソケットライブラリーを使った同期的なAPI(java.net
)が用意されていました。その、同期的なAPIを使った複数通信を扱う際のイメージは以下のようになります。
このような同期I/Oでは、ソケットごとにスレッドが作成され、それらのスレッドはI/Oオペレーションを行っている間はスレッドは休眠状態に入ります。そうなるとリソースの無駄が発生してしまいます。更に、それぞれのスレッドはOSによって決められたサイズのメモリを確保しますのでそうったオーバーヘッドも発生してしまいます。
上記のような同期I/Oに対して、Java 1.4らNIO(java.nio
)が導入されました。NIOでは、システムのイベント通知APIを用いて、データの読み取りや書き取りが行われたことを通知を受けることで、非同期的な通信を可能としています。
NIOを用いたJavaにおけるネットワーク通信は以下のようになります。
class java.nio.channels.Selector
は複数の非同期ソケットが処理可能な状態にあるかのイベント通知を受け、ハンドリングします。同期IOに比べソケットに対して、1つだけののスレッドが割り当てられているため、コンテクストスイッチなどにおける、CPUリソースの無駄遣いの削減や、メモリ消費の削減に繋がり、また、スレッドも休眠せずI/O待ちの際に別のタスクを消費することができます。
Nettyとは
NettyはJavaのNIOの上に作られるネットワークアプリケーションを作るためのフレームワークです。NIOを直接使うよりも、より簡単にすばやくアプリケーションの開発を行なうことができます。また、プーリングと再利用によってJavaのAPIを直接使うよりも低いレイテンシ、高スループットを実現してします。
NettyはNIO以外のライブラリー等には依存しておらず、3.x系ならJava 5、4.x系ならJava 6以上(一部、オプションの機能を利用する場合は7以上が求められます)で動きます。
ちなみにNIOに対して、同期的I/OをNettyではOIOと呼んでいるようです。
Nettyのコンポーネントと全体像
Nettyの主要コンポーネントを一枚の図のような関係になります。 以降でそれぞれのコンポーネントについて見ていきたいと思います。
Channel
まず、NettyのChannelについてですが、これは、Socketを直接使う場合の複雑さを削減するためのコンポーネントで、 Reed、Write、Connect、BindなどのI/Oオペレーションを提供しています。
NettyではChannelインターフェースを用意しており、以下のような実装が存在します。
- NioSocketChannel
- NIO selectorベースの実装
- OioSocketChannel
- 古いブロッキングI/Oを利用した実装
EventLoop、EventLoopGroupe
EventLoopGoupeは1つ以上のEventLoopを管理し、新しく作られたChannelにアロケートする役割を持っています。1つのChannelは1つのEventLoopが割り当てられ、そのライフサイクルの中で登録されたChannelのI/Oオペレーションをハンドルします。通常、1つのEventLoopに複数のChannelが登録されますが、実装によってはEventLoopとChannelが一体一になるものもあります。
EventLoopの実装には以下のようなものがあります。
- NioEventLoop
- ChannelをSelectorに登録して、それらの多重化を行います
- ThreadPerChannelEventLoop
- ChannelごとにEventLoopが割り当てられる同期OIOチャンネルをハンドリングします
ChannelHandler、ChannelPipeline
ChannelHandlerはChannelの状態の変化、データの取得などのネットワークイベントによって、フックされるメソッドを持ったコンポーネントです。Nettyでは、アプリケーションのロジックはこのHandlerに記述されます。 ソケットを外としたときの、データフローの方向によってInboundとOutboundそれぞれのハンドラーを作成することが可能で、 ChannelHandlerはChannelPipelineに登録され、Piplineの中で各方向のHandlerがチェインされていきます。このときHandlerはPiplelineに登録された順番でチェインされます。
通常、Nettyをクライアントとして利用した場合はOutboundのデータフローを通り、サーバとして利用した場合はInboundのデータフローを通ることになります。
同じ、Pipeline内でInboundとOutbound両方のHandlerが登録されていた場合でも基本的にはそれぞれが交わってチェインされることがありません。ただし、InboundHanderでも、ChannelHandlerContext(後述)からChannelを取り出して直接ソケットへの書き込みを行った際にはOutboundのハンドラーがチェインされます。また、ChannelPilpelineへのハンドラーは動的に削除や追加を行なうことも可能です。
ChannelHandlerとChannelPipelineの説明をする上で、もう一つ欠かせないのが、ChannelHandlerContextです。ChannelHandlerContextは同じ方向のデータフローのHanlder群とChannelPipelineとのひも付きや、Channelそのものを持つコンテクストです。また、アプリケーションはこのコンテクストを通してHandlerはデータの送信を行ったり、他のハンドラーを呼び出したりすることが可能となります。Nettyでは2種類のデータ送信方法があり、前述したように直接Channelに書き込む方法と、このCannelHandlerContextを通して送信する方法があります。前者の場合はOutboundのHandlerがフックされ後者の場合はされません。
次の図はHandler群とChannelPipeline、そして、その関係性を示しています。
図に書いてあるとおり、HandlerはChannelHandlerContextを通して、次のハンドラーへのチェインを行います。
終わりに
細かいところで言うと、Nettyをクライアントとして利用する場合とサーバとして利用する場合でEventLoopの数が違ったりはするですが、ざっとまとめた、Nettyの全体像はこんな感じになっていると理解しています。
参考資料
NettyのChannelInboundHandlerとChannelOutboundHandleについてまとめる
はじめに
Netty In Actionを読んでいて、それぞれがのハンドラーがどのどのタイミングで実行されるかが分かりづらかったので、自身の頭の中の整理を目的に自分の理解をまとめて見ようと思います。
Inbound? OutBound?
ChannelPipelineでは、ソケットとアプリケーションを両端として、それぞれの方向に流れるデータフローをInboundとOutBoundと呼んでいます。
- ソケット⇨アプリケーション : Inbound
- アプリケーション⇨ソケット: Outbound
また、それぞれのデータフローのイベントに対して、ChannelPipelineには別の種類のハンドラーを登録することが可能で、そのハンドラーを実装するためのインターフェースが、ChannelInboundHandler
とChannelOutboundHandler
です。その名の通りそれぞれがソケットに空のInboundとOutboundのイベントで発火されます。
それぞれの方向で基本的に、Hndler間での関係性は無く、ChannelHandlertContextを通してお互いに相互作用はしません。
ChannelInboundHandler
ChannelInboundHandler
のデータに対するイベントで発火されるメソッドが定義されたInterfaceです。具体的には以下のようなものが定義されています。
メソッド名 | 説明 |
---|---|
channelRegistered | EventLoopとChannelHandlertContextがChannelに登録され、何かのI/Oのハンドルが可能となった際に実行される |
channelUnregistered | EventLoopとChannelHandlertContextがChannelから解除され、なにかのI/Oのハンドルが不可能となった際に実行される |
channelActive | ChannelのChannelHndlerContextがアクティブ、チャンネルが接続もしくは、バインドされ準備ができた状態になった際に実行される |
channelInactive | ChannelがActive状態ではなくなった際際に実行される。リモートの通信先と接続がキレている |
channelReadComplete | readオペレーションが終わった際に実行される |
channelRead | データがChannelから読み出された際に実行される |
channelWritabilityChanged | ChannelのWritabilityの状態が変わった際に呼び出される。ユーザは書き込みがが終わっていないことを確認できたり、書き込み可能になった際に書き込みを行なうようにすることができる。その確認を行なうために、Channelのメソッドである、isWritable() メソッドを利用することができる。 |
userEventTriggered | ChannelnboundHandler.fireUserEventTriggered()が呼ばれた際に実行される。 |
exceptionCaught | 例外が投げられた際に実行される。 |
例えば以下のようなハンドラーをChannelPipelineに登録します。
@ChannelHandler.Sharable public class EchoInboundHandler extends ChannelInboundHandlerAdapter { @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { System.out.println("channelRegistered inbound handler called"); super.channelRegistered(ctx); } @Override public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { System.out.println("channelUnregistered inbound handler called"); super.channelUnregistered(ctx); } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { System.out.println("channelActive inbound handler called"); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { System.out.println("channelInactive inbound handler called"); super.channelInactive(ctx); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { System.out.println("channelRead inbound handler called"); //送信されたデータをコンソールに出力してそのまま返す。 ByteBuf input = (ByteBuf) msg; System.out.println(input.toString(CharsetUtil.UTF_8)); ctx.write(input); } @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { System.out.println("channelReadComplete inbound handler called"); //読み込みが終了の際にコネクションをクローズする ctx.writeAndFlush(Unpooled.EMPTY_BUFFER) .addListener(ChannelFutureListener.CLOSE); } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { super.userEventTriggered(ctx, evt); } @Override public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception { super.channelWritabilityChanged(ctx); } }
TCPソケットの通信でaaa
というデータを送った際には以下のような出力が標準出力にされます。channelActive
までが、コネクションを確立されるまでに出力され、
channelRegistered inbound handler called channelActive inbound handler called channelRead inbound handler called aaa channelReadComplete inbound handler called channelInactive inbound handler called channelUnregistered inbound handler called
userEventTriggered
やchannelWritabilityChanged
などは、前述の表のようなタイミングで実行されるため、今回のケースでは実行されないようです。
ChannelOutboundHandler
Inboundに対して、ChannelOutboundHandler
に関しては、以下のようなメソッドが定義されています。
メソッド名 | 説明 |
---|---|
bind(ChannelHandlerContext, SocketAddress,ChannelPromise) | ローカルアドレスにバインドがリクエストされた際に実行される |
connect(ChannelHandlerContext, SocketAddress,SocketAddress,ChannelPromise) | リモートの通信先にChannelと接続のリクエストの際に実行される |
disconnect(ChannelHandlerContext, ChannelPromise) | Channelのリモートの通信先とのコネクションを切断するリクエストを送る際に実行されます |
close(ChannelHandlerContext,ChannelPromise) | Channelのクローズのリクエストの際に |
disconnect(ChannelHandlerContext, ChannelPromise) | EventLoopからChannelの登録解除がリクエストされた際に実行されます。 |
read(ChannelHandlerContext) | Channelのリードがリクエストされた際に実行されます |
flush(ChannelHandlerContext) | Channelのキューに溜まったデータをフラッシュのリクエストの際に実行されます |
write(ChannelHandlerContext,Object, ChannelPromise | Channelを通して書き込みがリクエストされる際に実行されます |
例えば以下の2つのようなハンドラーをChannelPipelineに登録します。
@ChannelHandler.Sharable public class EchoOutBoundHandler extends ChannelOutboundHandlerAdapter { @Override public void bind(ChannelHandlerContext ctx, SocketAddress localAddress, ChannelPromise promise) throws Exception { System.out.println("bind outbound handler called"); super.bind(ctx, localAddress, promise); } @Override public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) throws Exception { System.out.println("connect outbound handler called"); super.connect(ctx, remoteAddress, localAddress, promise); } @Override public void disconnect(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { System.out.println("disconnect outbound handler called"); super.disconnect(ctx, promise); } @Override public void close(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { System.out.println("close outbound handler called"); super.close(ctx, promise); } @Override public void deregister(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception { System.out.println("deregister outbound handler called"); super.deregister(ctx, promise); } @Override public void read(ChannelHandlerContext ctx) throws Exception { System.out.println("read outbound handler called"); super.read(ctx); } @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { System.out.println("write outbound handler called"); super.write(ctx, msg, promise); } @Override public void flush(ChannelHandlerContext ctx) throws Exception { System.out.println("flush outbound handler called"); super.flush(ctx); } }
public class EchoClient extends SimpleChannelInboundHandler<ByteBuf> { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // 通信先にhelloのメッセージを送信 ctx.writeAndFlush(Unpooled.copiedBuffer("hello", CharsetUtil.UTF_8)); super.channelActive(ctx); } @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception { // 通信先からのレスポンスを表示 System.out.println(msg.toString(CharsetUtil.UTF_8)); } }
その状態でNettyをクライアントとして実行し、通信を行なうとコンソールに以下のような出力が行われます。
connect outbound handler called write outbound handler called flush outbound handler called read outbound handler called hello read outbound handler called read outbound handler called
Nettyでエコーサーバーを書く
はじめに
最近、Netty in Actionを読んでいるのですが、生のNettyを今まで触ったことがなかったのでHello, Worldとして、Echoサーバを書いてみたいと思います。あまり、Netty全体の要素に対して深堀すると言うよりはひとまず動かすのを目指し、その中で必要なものを少し深堀する感じでやろうと思います。
書いてみる
環境
実行環境は以下のとおり
$ uname -srvmpio Linux 5.4.0-54-generic #60-Ubuntu SMP Fri Nov 6 10:37:59 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux $ lsb_release -a LSB Version: core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch Distributor ID: Ubuntu Description: Ubuntu 20.04.1 LTS Release: 20.04 Codename: focal $ java --version openjdk 11.0.8 2020-07-14 OpenJDK Runtime Environment 18.9 (build 11.0.8+10) OpenJDK 64-Bit Server VM 18.9 (build 11.0.8+10, mixed mode) $ mvn -v Apache Maven 3.6.3 Maven home: /usr/share/maven Java version: 11.0.8, vendor: N/A, runtime: /home/someone/.sdkman/candidates/java/11.0.8-open Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.4.0-54-generic", arch: "amd64", family: "unix"
プロジェクトを準備する
適当にMavenプロジェクトを作成し(私はIntelliJを使ってプロジェクトを作成しました)、Pomを以下のように書きます。
<?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.example</groupId> <artifactId>netty-http-client-example</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.0.51.Final</version> <scope>compile</scope> </dependency> </dependencies> </project>
準備としては以上です。
やること
Nettyでサーバを書く際には大きく以下の2つをする必要があります。
- ChannelHanlderの実装を書く
- HanlderをChannelPipelineに登録し、起動の処理を記述する
ChannelHanlderの実装を書く
ChannelHandler
はネットワークイベントをトリガーとして実行されるメソッドを持ったインターフェースです。Nettyでは、このインターフェースを実装し、メソッドに対してアプリケーションロジックを記述することになります。
なお、このHanlderは、HandlerのコンテナであるChannelPileline
に登録され(実際の登録は事項で行いが)、Handlerが複数登録されている場合は処理がチェインされます。チェインの順番はPipelineへの登録順となります。
Hanlderには大きく分けて、Inbound用とOutbound用のものがありますがここでは深く触れません。
簡単にHanlderの説明をおえたので実際に受け取ったデータをそのまま返すだけのハンドラーを記述してみたいと思います。
import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.util.CharsetUtil; @ChannelHandler.Sharable public class EchoHandler extends ChannelInboundHandlerAdapter { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { ByteBuf input = (ByteBuf) msg; System.out.println(input.toString(CharsetUtil.UTF_8)); ctx.write(input); } @Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { ctx.writeAndFlush(Unpooled.EMPTY_BUFFER) .addListener(ChannelFutureListener.CLOSE); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }
今回はChannelInboundHandler
のベースクラスであるChannelInboundHandlerAdapter
を用いてハンドラーを記述しています。Nettyではこのようなインターフェースのデフォルト実装を行ってくれている〇〇Adapterと名の付くベースクラスが複数用意されているので、必要なメソッドだけをオーバーライドすればよいようになっています。これらのベースクラスは単に次のハンドラーにイベントをパスするような実装になっているようです。
各メソッドで、受け取っているChannelHandlerContext
はChannelPipeline
や、他のハンドラーと相互作用を行なうためのコンテクストクラスです。
実装しているメソッドの簡単な説明を以下にまとめます。
- channelRead(ChannelHandlerContext ctx, Object msg)
- メッセージの入力があるたびに呼び出されます。上記の実装では受け取ったメッセージを標準出力に出力し、そのままChannelに同じ値を書き込んでいます。
- channelReadComplete(ChannelHandlerContext ctx)
- 読み込みオペレーションの最後に呼び出されます。
writeAndFlush()
では、書き込んだデータをすべてクライアント側に送信しています。その後、ChannelFutureListener.CLOSE
でChannelをクローズしています。
- 読み込みオペレーションの最後に呼び出されます。
- exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
- 読み込みオペレーション時に例外がスローされた際に呼び出されます。スタックトレースを行い、Channelをクローズしています。
ちなみに、ここでは深く調べませんが、ChannelはScoketと直接コミュニケーションをとる役割をもつコンポーネントです。Channelに対して、1つのChannelPipelineが割り当てられて、ハンドラーの処理がチェインされていくイメージです。
HanlderをChannelPipelineに登録し、起動の処理を記述する
ここまでで、Handlerがかけたので、次にこのハンドラーをChannelPipelineに登録し、サーバを起動する処理を書きたいと思います。
import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import java.net.InetSocketAddress; public class Server { public static void main(String[] args) throws Exception { Server server = new Server(); server.run(); } public void run() throws Exception { final EchoHandler echoHandler = new EchoHandler(); EventLoopGroup group = new NioEventLoopGroup(); try { ServerBootstrap boot = new ServerBootstrap(); boot.group(group).channel(NioServerSocketChannel.class) .localAddress(new InetSocketAddress(8080)) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel socketChannel) throws Exception { socketChannel.pipeline() .addLast(echoHandler); } }); ChannelFuture cf = boot.bind().sync(); cf.channel().closeFuture().sync(); } finally { group.shutdownGracefully(); } } }
メインスレッドでは自分自身のrun()
を呼び出しています。その中を上から見ていくと、まずはハンドラーをインスタンス化しています。これは後の登録のところで使います。
次にEventLoopGroup
をインスタンス化しています。まず、EventLoop
はコネクションのライフライムで発生するイベントをハンドリングする役割を持っています。EventLoop
、1つに対して、1つのスレッドが割り当てられます。また、Channel
は1つのEventLoop
に割り当てられるのですが、その関係は多対一になり、1つのチャンネルは 1つのEventLoop
に割り当てられ、1つのEventLoop
は複数のチャンネルを割りてられる可能性があります。
EventLoopGroup
の実装は、NioEventLoopGroup
を利用しており、これは非同I/OのSelectorを使ったEventLoopGroupとなります。EventLoopGroup
はEventLoop
を管理するコンテナとなります、1つ以上のEventLoop
を保持します。
try
句の中では、まずServerBootstrap
をインスタンス化しています。Nettyには2つのタイプのBootstrapの機構があり、ServerBooytstrap
はNettyをクライアントではなくサーバーとして利用する際に利用する機構です。
インスタンス化したServerBootstrapに対して、上から順に以下のようなを設定していきます。
group()
でEventLoopGroupeの登録channel()
で、Channel作る際に利用するChannelクラスの登録。今回はNIOのSelecter用いた非同期IOを行なうNioServerSocketChannel
を利用localAddress()
でポートをバインドchildHandler()
でハンドラーを登録。ChannelInitilizer
はカスタムなChannelHandler
のセットをセットアップするためのHanlderで、チャンネルのHandlerを登録し終わったあとはChannelPipelineから削除されます。ここではEchoHandler
を登録しています。
ServerBootstrap
の設定が終わったので、boot.bind()
を呼び出して、サーバーをスタートさせます。
Telnetでつないでみる
telnetコマンドを使ってデータを送信します。アプリを起動します。私はinttelliJからメインメソッドを実行しましたが、以下のような感じでも起動できます。
$ mvn exec:java -Dexec.mainClass=server.Server
$ telnet localhost 8080 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. aaa aaa Connection closed by foreign host.
1つ目のaaa
はこちらが送信したもので、2つめのaaa
がサーバから返されるものです。
これで、Echoサーバができました。
Spring Boot 2.4.0についてメモ
はじめに
先週、(2020/11/12)にSpring Boot 2.4が出ましたが、どんなのが出たのかまとめて、なんとなく違いを把握おこうと思います。
基本的にはリリースブログとRelease Notesの内容を個人的に気になったところを少し深ぼって、自分の理解をまとめようと思うので、正確な情報は本家のブログやそれに付随する記事等をご参照ください(なるべく、情報ソースのリンクはつけます)。
ちなみに、ドキュメントに書かれた変更点を自分の理解でまとめたものなので、基本的に機能に対しては動作検証などは行っておりません。
Spring Boot 2.4.0変更点
このブログでは以下のような変更点についてまとめます。
- バージョニングスキーマの変更
- 設定ファイルのプロセッサ変更
- Volume Mounted Config Directory Trees
- Startupエンドポイントのサポート
- Docker/Buildpack Support
spring-boot-starter-test
からJUnit 5’s Vintage Engineを削除- Java 15のサポート
この他、すべての変更点に関してはRelease Notesを参照してください。
バージョニングスキーマの変更
まず、今回のバージョンから新しいバージョンスキーマを利用するようです。
なので、今までは、2.3.4.RELEASE
などをPom等に記載していたと思いますが、今回のリリースからは2.4.0
と記載するようになってます。
Spring Projectのバージョニング変更の詳細はこちらをご確認ください。
設定ファイルのプロセッサの変更
2.4よりapplication.poperties
やapplication.yml
の設定ファイルのプロセッシングロジックの見直しが行われているようです。新しいものはよりシンプルで、外部からの設定をより合理的な方法でロードするようになっています。
基本的には今までの設定ファイルを使えるようですが、例えば以下のような複雑な設定を行っている場合記述方法が変わっていたりするので注意が必要です。
spring.profiles<.*>
のプロパティを利用しているMuti-document YAML Ordering
を利用している
少しだけ注意点をまとめると、
プロファイルを利用している場合も注意が必要で、Jarの外部からapplication.poperties
を読み込む場合Jar内部のapplication-<profile>.poperties
を上書かないようになっているので気をつける必要があります。
また、Muti-document YAML Ordering
を利用している場合(Yamlファイルを---
セパレータで区切っている場合)その設定の反映順序が変わっています。2.3以前はプロファイルのアクティベーションの順序だったのが、Yaml内で宣言された順序で反映されるようになります。
詳細な、マイグレーションガイドについてはこちらをご覧ください。
ちなみに2.3以前のプロセッサで動かすことも可能で、その場合場spring.config.use-legacy-processing=true
を設定する必要があるようです。
Volume Mounted Config Directory Trees
spring.config.import
プロバティが新しく追加され、Kubernetesなどの環境でVolumeマウントされたディレクトリ等からの設定をインポートすることが可能となりました。
spring.config.import
を使った設定ファイルの読み込みは以下の2つのパターンで行なうことができます。
- すべての設定をコンテンツとして含む単一な(もしくは複数の)設定ファイルの読み込み
- ディレクトリーツリーに配置された、ファイル名が
key
でコンテンツがvalue
となるような設定の読み込み
前者については、例えば下記のように設定を行った場合、
spring.config.import=optional:file:./dev.properties
カレントディレクトリにdev.properties
が存在すれば、その設定が既存の設定の上書きで反映されるようになります。このファイルは、複数指定することが可能で記述された順番で設定が読み込まれます。同じ設定を複数のファイルで行っている場合はあと勝ちになるようです。
ちなみにoptional:
をつけることでファイルが存在しない場合になにもせずにスキップされるようになります。
後者についてはconfigtree:
という表現を用いることでインポートが可能となります。
例えば、以下のようなディレクトリーツリーがあったとします。
etc/ config/ myapp/ username password
ファイルのusername
とpassword
の中身が読み込みたい値です。
上記のディレクトリツリーに対して、以下のように設定を記述してやると値を読み込むことができます。
spring.config.import=optional:configtree:/etc/config/
この場合では、myapp.username
とmyapp.password
と言う設定をインジェクト(もしくはアサート)するようになります。
また、複数のディレクトリツリーを1つの親ディレクトリから読み込みたい場合などに置いてワイルドカードショートカットを利用して以下のように設定することができます。
spring.config.import=optional:configtree:/etc/config/*/
この場合、ディレクトリツリーが以下のようになっていると想定すると、
etc/ config/ dbconfig/ db/ username password mqconfig/ mq/ username password
それぞれ、db.username
、db.password
、mq.username
、mq.password
のプロパティが追加されるようになります。
この機能は、主にKubernetes環境に置いての利用を想定されているようです。ComfigMap等の書き換えが行われたらその設定が反映されるようになると思われます。 (後からご指摘頂いたのですが、こちら間違えでした。書き換えが行われたとしても設定が動的に反映されなかったことを手元の環境で確認してます。)
ActuatorのStartupエンドポイントのサポート
Kubernetes 1.18以降では Start Up Probeという機能がベータから正式リリースされています。
これは、Liveness Probe
やReadiness Probe
と並んで追加されたもので、コンテナ起動時にのみ使用されるProbeです。
自分の理解のために、少し本筋から外れて説明すると、起動の遅いアプリケーションナなどに対して、コンテナのデットロックに対する迅速な反応を損なわずにLiveness Probeを設定するのが難しい場合があります。起動が遅いアプリケーションに対応してLiveness Probeを長く設定してしまうと、その分コンテナの再起動が遅れてしまうからです。これに対して、Startup Probeはコンテナ起動時のみに利用されるLiveness Probeの機能を提供することによって既存の問題に対してより簡単な解決方法を提供しています。
このStartup Probeに対して、Spring Actuatorでは専用のエンドポイントが追加されています。
(こちらもあとから指摘いただいたのですが、Startup Probeとは関係が無かったよです。)
アプリケーションの起動時の情報を取得するためのエンドポイントが新たに追加されています。
このエンドポイントでは、予想よりも起動が遅いBeanの情報などのスタートアップに関する情報を取得することができます。
取得できる情報の詳細に関してはこちらをご覧ください。
Docker/Buildpack Support
イメージの公開
Mavenプラグインのspring-boot:build-image
ゴールやbootBuildImage
タスクを用いて、Dockerレジストリにイメージをこう買うすることが可能になったようです。
例えば、Mavenでは以下のような設定を追加してレジストリを登録しておきます。
<project> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <image> <name>docker.example.com/library/${project.artifactId}</name> <publish>true</publish> </image> <docker> <publishRegistry> <username>user</username> <password>secret</password> <url>https://docker.example.com/v1/</url> <email>user@example.com</email> </publishRegistry> </docker> </configuration> </plugin> </plugins> </build> </project>
ちなみにレジストリへの公開は以下のようなコマンドを用いても行なうことが可能です。
$ mvn spring-boot:build-image -Dspring-boot.build-image.imageName=docker.example.com/library/my-app:v1 -Dspring-boot.build-image.publish=true
認証
Spring BootのビルドパックサポートにおいてプライベートDockerレジストリに対する、username/password
認証とトークン認証のどちらもサポートされるようになりました。
詳細に関してはこちらを参考にしてください。
spring-boot-starter-test
からJUnit 5’s Vintage Engineを削除
spring-boot-starter-test
からJUnit 5’s Vintage Engine(Junit4の記述で、Junit5のエンジンを利用する際に使われるエンジン)が削除されています。このため、アップグレードを行った際に、org.junit.Test
がエラーで落ちるようになります。
もし、2.4以降でも、Vintage Engineを利用したい場合は以下のような依存を追加してやる必要があります。
<dependency> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-core</artifactId> </exclusion> </exclusions> </dependency>
Java 15のサポート
言葉通りです。
Java 8と11にも互換性があります。
依存のアップグレード
依存がアップグレードされています。詳細はこちらを確認ください。
参考資料
Docker BuildKitを使う
はじめに
Dockerの 18.09 以降にはBuildKitという機能が存在しますが、今までなんとなく使ってただけなのでちゃんとまとめてある程度理解しておきたいなと
Docker BuildKitとは
そもそもBuildKitそのものはDocker Engineとは別でMoby Projectで開発されていたものです。 キャッシュや処理の並列化、アーキテクチャの見直しによってソースコードをビルドアーティファクトへ変換する処理をより効率よく行なうことができるようになったり、Dockerfileの拡張文法を提供していたりします。Dockerの 18.09 以降はDocker Engineに拡張として取り込まれ、特に特別なインストールなどはなしに利用することができます。
何ができるん?
つまるところ、BuidKitはどういうことを可能にしてくれるのかということを以下にまとめます。
- ビルドの高速化される
- ビルドの標準出力に各ステップの実行時間が出力される
- Dockerfileの拡張文法が利用可能になる(後述)
使ってみる
環境
実行環境は以下の通り
$ docker version Client: Docker Engine - Community Version: 19.03.12 API version: 1.40 Go version: go1.13.10 Git commit: 48a66213fe Built: Mon Jun 22 15:45:36 2020 OS/Arch: linux/amd64 Experimental: false Server: Docker Engine - Community Engine: Version: 19.03.12 API version: 1.40 (minimum version 1.12) Go version: go1.13.10 Git commit: 48a66213fe Built: Mon Jun 22 15:44:07 2020 OS/Arch: linux/amd64 Experimental: false containerd: Version: 1.2.13 GitCommit: 7ad184331fa3e55e52b890ea95e65ba581ae3429 runc: Version: 1.0.0-rc10 GitCommit: dc9208a3303feef5b3839f4323d9beb36df0a9dd docker-init: Version: 0.18.0 GitCommit: fec3683 $ lsb_release -a LSB Version: core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch Distributor ID: Ubuntu Description: Ubuntu 20.04.1 LTS Release: 20.04 Codename: focal $ uname -srvmpio Linux 5.4.0-53-generic #59-Ubuntu SMP Wed Oct 21 09:38:44 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
Dockerfileを用意しておく
Docker BuildKitの機能を試すためにいかのDockerfileをベースにします。
特に明示してなければ以下のDcokerfileを利用してビルドを行っています。
FROM centos:7 RUN yum install -y java
BuildKit有効化
環境変数での有効化
BuildKitは環境変数にDOCKER_BUILDKIT=1
と設定しておくと有効化されます。例えば単発のビルド時に有効化したい場合は以下のようにします。
$DOCKER_BUILDKIT=1 docker build .
デフォルトでの有効化
デフォルトで有効化するためには/etc/docker/daemon.json
のfeature
の項目をtrue
に設定します。
{ "features": { "buildkit": true } }
今回はデフォルトとの対比を行いたいのでこちらは使わず環境変数の方で指定して、BuildKitを使いたいと思います。
出力結果のが変わる
まずは普通にdocker build
した場合は以下のような出力になります。
$ docker build -t java-centos . Sending build context to Docker daemon 2.048kB Step 1/2 : FROM centos:7 ---> 7e6257c9f8d8 Step 2/2 : RUN yum install -y java ---> Using cache ---> 68dff4f570e9 Successfully built 68dff4f570e9 Successfully tagged java-centos:latest
次にBuildKitを有効化してDockerBuildを実行します。
$ DOCKER_BUILDKIT=1 docker build -t java-centos . [+] Building 0.0s (6/6) FINISHED => [internal] load .dockerignore 0.0s => => transferring context: 2B 0.0s => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 37B 0.0s => [internal] load metadata for docker.io/library/centos:7 0.0s => [1/2] FROM docker.io/library/centos:7 0.0s => CACHED [2/2] RUN yum install -y java 0.0s => exporting to image 0.0s => => exporting layers 0.0s => => writing image sha256:fc5361ee029af85ff2d83403afe003214d2dfcb539216b3fcfa1349852ed614a 0.0s => => naming to docker.io/library/java-centos
出力する項目がすこし変わっているのと、各工程にどのくらいの時間がかかったのかの計測結果が出力されるようになります。
また、--progress=plain
を付け加えることで出力形式をプレーン?なものに変えることもできるみたいです。
$ DOCKER_BUILDKIT=1 docker build --progress=plain -t java-centos . #2 [internal] load build definition from Dockerfile #2 transferring dockerfile: 37B done #2 DONE 0.0s #1 [internal] load .dockerignore #1 transferring context: 2B done #1 DONE 0.0s #3 [internal] load metadata for docker.io/library/centos:7 #3 DONE 0.0s #4 [1/2] FROM docker.io/library/centos:7 #4 DONE 0.0s #5 [2/2] RUN yum install -y java #5 CACHED #6 exporting to image #6 exporting layers done #6 writing image sha256:fc5361ee029af85ff2d83403afe003214d2dfcb539216b3fcfa1349852ed614a done #6 naming to docker.io/library/java-centos done #6 DONE 0.0s
(いまのところこいつの利点があんまりわかってないが、余計な=>
がなくなってるだけのかな...?)
BuildKitのフロンドエンドの置き換え
ここで言うフロントエンドとはBuildKitの中で動くビルドディフィニションをLLB(BuildKitが生成する中間バイナリフォーマット)に変換するコンポーネントのことを指します。Docker目線でいえばDockerfileをバイナリに変換するためのコンポーネントのことを指します。
Dcokerfileの先頭行に以下のような形式のコメントを追加することでこのフロントエンドを置き換えることができます。
# syntax = <frontend image>
現状Dockerfileのフロントエンドのイメージは以下の2つが用意されているようです。
後述しますが、フロントエンドを置き換えることによってDockerfile内でエクスペリメンタルな文法を利用することが可能となります。
フロントエンドを置き換えてエクスペリメンタルの機能を利用する(実験機能)
BuildKitを使ったDockerfileのビルドにおいて別フロントエンドを使うことで拡張の機能を利用することができます。
フロントエンドの置き換え
フロントエンドの置き換えに関しては前述していますが、今回使いたい機能を利用するために、Dockerfileの先頭にコメントを追加します。
# syntax=docker/dockerfile:experimental FROM centos:7 RUN yum install -y java
これで拡張文法とその機能を利用することが可能です。
拡張文法
置き換えたフロントエンドで便利そうな機能をいくつかまとめます。
RUN --mount=type=bind
--mount=type=bind
はビルドコンテクスト(Dockerfileが置かれているワーキングディレクトリ)、もしくは、ビルドされるイメージコンテナのディレクトリをバインドすることができます。バインドされるディレクトリはデフォルトはリードオンリーです。
下記のような項目を,
でつないで利用することができます。
項目 | 説明 |
---|---|
target(必須) | Dockerコンテナ内のマウント先のパス |
from | ビルドストレージ、もしくはソースのルート。デフォルトはビルドのコンテクストにバインドされます |
source | from の中のソースパス、デフォルトのソースパスはfrom のルートディレクトリ |
rw,readwrite | マウントされたディレクトリに書き込むことを許可します。ただし、書き込んだデータは後に削除されます |
例えば下記のDockerfileではビルドコンテキストにあるDockerfileの内容をtext.txt
に書き写しています。
# syntax=docker/dockerfile:experimental FROM busybox RUN --mount=type=bind,target=tmp cat tmp/Dockerfile > text.txt
下記のように実行するとビルドコンテキストがDokcerコンテナ内の/tmp
にバインディングされ、Dockerfileの内容がtext.txt
に書き移すことができます。
$ DOCKER_BUILDKIT=1 docker build -t busybox-tmp . [+] Building 2.1s (9/9) FINISHED => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 37B 0.0s => [internal] load .dockerignore 0.0s => => transferring context: 2B 0.0s => resolve image config for docker.io/docker/dockerfile:experimental 1.8s => CACHED docker-image://docker.io/docker/dockerfile:experimental@sha256:de85b2f3a3e8a2f7fe48e8e84a65f6fdd5cd5183afa6412fff9caa6871649c44 0.0s => [internal] load metadata for docker.io/library/busybox:latest 0.0s => [internal] load build context 0.0s => => transferring context: 31B 0.0s => [1/2] FROM docker.io/library/busybox 0.0s => CACHED [2/2] RUN --mount=type=bind,target=tmp cat tmp/Dockerfile > text.txt 0.0s => exporting to image 0.0s => => exporting layers 0.0s => => writing image sha256:d75bb6670538d7af739b6f201d170a5fa38128e7cbbcf56ef9448682695d0b36 0.0s => => naming to docker.io/library/busybox-tmp $ docker run busybox-tmp cat text.txt # syntax=docker/dockerfile:experimental FROM busybox RUN --mount=type=bind,target=tmp cat tmp/Dockerfile > text.txt
この機能のユースケースとしてはDockerコンテナ内に必要は無いけど実行はしたいスクリプトがあるディレクトリをバインドしたりすることが考えられます。
ちなみにですが、target
にコンテナ内に元来存在しないディレクトリを指定すると勝手にディレクトリを作成しそこに対してビルドコンテキスト(や指定されたビルドストレージ、もしくはイメージのディレクトリ)をバインドしてくれます。
RUN --mount=type=cache
コンパイラーやパッケージマネージャーのキャッシュディレクトリをバインドすることができます。
下記のような項目を,
でつないで利用することができます。
項目 | 説明 |
---|---|
id | キャッシュを識別するためのID |
target(必須) | Dockerコンテナ内のマウント先のパス |
ro,readonly | セットされていればキャッシュがリードオンリーになります |
from | キャッシュマウントを行なうビルドストレージ。デフォルトは空のディレクトリ |
source | マウントを行なうfrom の中のサブパス。デフォルトはfrom のルート |
mode | 新しいキャッシュディレクトリのファイルモード、デフォルトは0755 |
uid | 新しいキャッシュディレクトリのUserID。デフォルトは0 |
gid | 新しいキャッシュディレクトリのグループID。デフォルトは0 |
例えば下記のDockerfileはmavenのm2ディレクトリをキャッシュして、2回目以降は.m2のキャッシュが/root/.m2
にマウントされるようになります。
# syntax = docker/dockerfile:1.1-experimental FROM maven:3.6.2-jdk-8-slim AS build-env ADD . /javasoerceroot WORKDIR /javasorceroot RUN --mount=type=cache,target=/root/.m2 mvn clean package
aptパッケージをキャッシュする場合以下のようにします。
# syntax = docker/dockerfile:experimental FROM ubuntu RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \ apt update && apt-get --no-install-recommends install -y gcc
RUN --mount=type=tmpfs
tmpfsをコンテナへバインドすることができます。
下記のような項目を,
でつないで利用することができます。
項目 | 説明 |
---|---|
target(必須) | Dockerコンテナ内のマウント先のパス |
RUN --mount=type=secret
秘密鍵のファイルようなクレデンシャルファイルに対して、をイメージの中に残すこと無くバインドすることができます。
項目 | 説明 |
---|---|
id | シークレットのID。ターゲットパスのデフォルトベース名として利用されます。 |
target | Dockerコンテナ内のマウント先のパス。デフォルトでは/run/secrets/ + id となります。 |
required | true でセットすると、シークレットが有効で無かった場合に命令がエラーとして取り扱われるようになります。デフォルトはfalse です |
mode | シークレットのファイルモードデフォルトは0400 |
uid | シークレットのユーザID。デフォルトは0 |
gid | シークレットのグループID。デフォルトは0 |
例えばAWSのS3へアクセスしたいようなユースケースでは以下のようにします。
# syntax = docker/dockerfile:experimental FROM python:3 RUN pip install awscli RUN --mount=type=secret,id=aws,target=/root/.aws/credentials aws s3 cp s3://... ...
docker build時に--secret
フラグを用いてバインドするクレデンシャルなファイルを渡します。
$ docker build --secret id=aws,src=$HOME/.aws/credentials .
その他機能はこちらをご覧ください。
参考資料
QuarkusアプリのメトリクスをMicrometer+Prometeus+Grafanaで可視化する
はじめに
Quarkus 1.9のリリースブログをなんとなく眺めていると以下のようなことが書かれていました。
Micrometer extension maturing
1.8 introduced a new Micrometer extension. During the 1.9 development cycle, it matured a lot and is now the recommended way to collect metrics in Quarkus.
原文リンク
どうやら1.9以降はMicrometerがメトリクスの収集方法としておすすめされるようなので、使ってみてPromethus+Grafanaで可視化するのをやってみようかと思います。
PrometeusとGrafanaはDocker使って立ち上げて、ホストにポートフォワードします。
あと、ついでにQuarkusで設定できるMicrometerの設定をまとめておこうと思います。
やってみる
環境
動作環境は以下の通り
$ java --version openjdk 11.0.8 2020-07-14 OpenJDK Runtime Environment 18.9 (build 11.0.8+10) OpenJDK 64-Bit Server VM 18.9 (build 11.0.8+10, mixed mode) $ mvn --version Apache Maven 3.6.3 Maven home: /usr/share/maven Java version: 11.0.8, vendor: N/A, runtime: /home/someone/.sdkman/candidates/java/11.0.8-open Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.4.0-52-generic", arch: "amd64", family: "unix" $ uname -srvmpio Linux 5.4.0-52-generic #57-Ubuntu SMP Thu Oct 15 10:57:00 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux $ lsb_release -a LSB Version: core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch Distributor ID: Ubuntu Description: Ubuntu 20.04.1 LTS Release: 20.04 Codename: focal $ docker version Client: Docker Engine - Community Version: 19.03.12 API version: 1.40 Go version: go1.13.10 Git commit: 48a66213fe Built: Mon Jun 22 15:45:36 2020 OS/Arch: linux/amd64 Experimental: false Server: Docker Engine - Community Engine: Version: 19.03.12 API version: 1.40 (minimum version 1.12) Go version: go1.13.10 Git commit: 48a66213fe Built: Mon Jun 22 15:44:07 2020 OS/Arch: linux/amd64 Experimental: false containerd: Version: 1.2.13 GitCommit: 7ad184331fa3e55e52b890ea95e65ba581ae3429 runc: Version: 1.0.0-rc10 GitCommit: dc9208a3303feef5b3839f4323d9beb36df0a9dd docker-init: Version: 0.18.0
プロジェクトを作成する
Quarkus - Start coding with code.quarkus.ioでプロジェクトを作成します。
設定は以下の通り。
ブログではMicroProfile Metricsじゃなくて、Micrometerの方を押してたきがしますがここではExperimentalってなってますね。
多分まだ修正されてないだけなんでしょうか。
pomにPrometheusの依存を追加しておく
MicromerterでPrometheusの形式の情報を取得するために以下の依存を追加しておく必要があります。
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>
cURLでメトリクスを取得する
この状態ですでにメトリクスは取得できます。
アプリを起動して、cURLでlocalhost:8080/metrics/
を叩いてみます。
$ ./mvnw clean compile quarkus:dev $ curl localhost:8080/metrics/ # HELP jvm_threads_states_threads The current number of threads having NEW state # TYPE jvm_threads_states_threads gauge jvm_threads_states_threads{state="runnable",} 20.0 jvm_threads_states_threads{state="blocked",} 0.0 jvm_threads_states_threads{state="waiting",} 10.0 jvm_threads_states_threads{state="timed-waiting",} 5.0 jvm_threads_states_threads{state="new",} 0.0 jvm_threads_states_threads{state="terminated",} 0.0 # HELP jvm_gc_memory_promoted_bytes_total Count of positive increases in the size of the old generation memory pool before GC to after GC # TYPE jvm_gc_memory_promoted_bytes_total counter jvm_gc_memory_promoted_bytes_total 0.0 # HELP jvm_gc_memory_allocated_bytes_total Incremented for an increase in the size of the young generation memory pool after one GC to before the next # TYPE jvm_gc_memory_allocated_bytes_total counter jvm_gc_memory_allocated_bytes_total 5.8720256E7 # HELP jvm_gc_overhead_percent An approximation of the percent of CPU time used by GC activities over the last lookback period or since monitoring began, whichever is shorter, in the range [0..1] # TYPE jvm_gc_overhead_percent gauge jvm_gc_overhead_percent 0.0014644940855901103 # HELP process_cpu_usage The "recent cpu usage" for the Java Virtual Machine process # TYPE process_cpu_usage gauge process_cpu_usage 1.0287248248851947E-4 # HELP system_cpu_usage The "recent cpu usage" for the whole system # TYPE system_cpu_usage gauge system_cpu_usage 0.13014392884149495 # HELP process_uptime_seconds The uptime of the Java virtual machine # TYPE process_uptime_seconds gauge process_uptime_seconds 7.019 # HELP process_start_time_seconds Start time of the process since unix epoch. # TYPE process_start_time_seconds gauge process_start_time_seconds 1.604492368312E9 # HELP jvm_info_total JVM version info # TYPE jvm_info_total counter jvm_info_total{runtime="OpenJDK Runtime Environment",vendor="Oracle Corporation",version="11.0.8+10",} 1.0 # HELP jvm_buffer_count_buffers An estimate of the number of buffers in the pool # TYPE jvm_buffer_count_buffers gauge jvm_buffer_count_buffers{id="mapped",} 0.0 jvm_buffer_count_buffers{id="direct",} 11.0 # HELP jvm_buffer_memory_used_bytes An estimate of the memory that the Java virtual machine is using for this buffer pool # TYPE jvm_buffer_memory_used_bytes gauge jvm_buffer_memory_used_bytes{id="mapped",} 0.0 jvm_buffer_memory_used_bytes{id="direct",} 21370.0 # HELP process_files_max_files The maximum file descriptor count # TYPE process_files_max_files gauge process_files_max_files 1048576.0 # HELP jvm_gc_pause_seconds Time spent in GC pause # TYPE jvm_gc_pause_seconds summary jvm_gc_pause_seconds_count{action="end of minor GC",cause="Metadata GC Threshold",} 1.0 jvm_gc_pause_seconds_sum{action="end of minor GC",cause="Metadata GC Threshold",} 0.007 # HELP jvm_gc_pause_seconds_max Time spent in GC pause # TYPE jvm_gc_pause_seconds_max gauge jvm_gc_pause_seconds_max{action="end of minor GC",cause="Metadata GC Threshold",} 0.007 # HELP jvm_memory_committed_bytes The amount of memory in bytes that is committed for the Java virtual machine to use # TYPE jvm_memory_committed_bytes gauge jvm_memory_committed_bytes{area="heap",id="G1 Survivor Space",} 8388608.0 jvm_memory_committed_bytes{area="heap",id="G1 Old Gen",} 2.07618048E8 jvm_memory_committed_bytes{area="nonheap",id="Metaspace",} 3.9010304E7 jvm_memory_committed_bytes{area="nonheap",id="CodeHeap 'non-nmethods'",} 2555904.0 jvm_memory_committed_bytes{area="heap",id="G1 Eden Space",} 3.0408704E8 jvm_memory_committed_bytes{area="nonheap",id="Compressed Class Space",} 4980736.0 jvm_memory_committed_bytes{area="nonheap",id="CodeHeap 'non-profiled nmethods'",} 8716288.0 # HELP system_load_average_1m The sum of the number of runnable entities queued to available processors and the number of runnable entities running on the available processors averaged over a period of time # TYPE system_load_average_1m gauge system_load_average_1m 1.11 # HELP jvm_gc_live_data_size_bytes Size of old generation memory pool after a full GC # TYPE jvm_gc_live_data_size_bytes gauge jvm_gc_live_data_size_bytes 1.2500864E7 # HELP jvm_memory_used_bytes The amount of used memory # TYPE jvm_memory_used_bytes gauge jvm_memory_used_bytes{area="heap",id="G1 Survivor Space",} 8388608.0 jvm_memory_used_bytes{area="heap",id="G1 Old Gen",} 1.0403712E7 jvm_memory_used_bytes{area="nonheap",id="Metaspace",} 3.7437216E7 jvm_memory_used_bytes{area="nonheap",id="CodeHeap 'non-nmethods'",} 1258624.0 jvm_memory_used_bytes{area="heap",id="G1 Eden Space",} 6291456.0 jvm_memory_used_bytes{area="nonheap",id="Compressed Class Space",} 4416816.0 jvm_memory_used_bytes{area="nonheap",id="CodeHeap 'non-profiled nmethods'",} 8708736.0 # HELP jvm_classes_loaded_classes The number of classes that are currently loaded in the Java virtual machine # TYPE jvm_classes_loaded_classes gauge jvm_classes_loaded_classes 6538.0 # HELP jvm_classes_unloaded_classes_total The total number of classes unloaded since the Java virtual machine has started execution # TYPE jvm_classes_unloaded_classes_total counter jvm_classes_unloaded_classes_total 0.0 # HELP http_server_connections_seconds_max # TYPE http_server_connections_seconds_max gauge http_server_connections_seconds_max 0.016392673 # HELP http_server_connections_seconds # TYPE http_server_connections_seconds summary http_server_connections_seconds_active_count 1.0 http_server_connections_seconds_duration_sum 0.016127428 # HELP jvm_gc_max_data_size_bytes Max size of old generation memory pool # TYPE jvm_gc_max_data_size_bytes gauge jvm_gc_max_data_size_bytes 8.321499136E9 # HELP jvm_buffer_total_capacity_bytes An estimate of the total capacity of the buffers in this pool # TYPE jvm_buffer_total_capacity_bytes gauge jvm_buffer_total_capacity_bytes{id="mapped",} 0.0 jvm_buffer_total_capacity_bytes{id="direct",} 21368.0 # HELP jvm_memory_max_bytes The maximum amount of memory in bytes that can be used for memory management # TYPE jvm_memory_max_bytes gauge jvm_memory_max_bytes{area="heap",id="G1 Survivor Space",} -1.0 jvm_memory_max_bytes{area="heap",id="G1 Old Gen",} 8.321499136E9 jvm_memory_max_bytes{area="nonheap",id="Metaspace",} -1.0 jvm_memory_max_bytes{area="nonheap",id="CodeHeap 'non-nmethods'",} 7553024.0 jvm_memory_max_bytes{area="heap",id="G1 Eden Space",} -1.0 jvm_memory_max_bytes{area="nonheap",id="Compressed Class Space",} 1.073741824E9 jvm_memory_max_bytes{area="nonheap",id="CodeHeap 'non-profiled nmethods'",} 2.44105216E8 # HELP process_files_open_files The open file descriptor count # TYPE process_files_open_files gauge process_files_open_files 212.0 # HELP jvm_threads_daemon_threads The current number of live daemon threads # TYPE jvm_threads_daemon_threads gauge jvm_threads_daemon_threads 13.0 # 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 42.0 # HELP jvm_threads_live_threads The current number of live threads including both daemon and non-daemon threads # TYPE jvm_threads_live_threads gauge jvm_threads_live_threads 35.0 # HELP http_server_bytes_read # TYPE http_server_bytes_read summary http_server_bytes_read_count 1.0 http_server_bytes_read_sum 0.0 # HELP http_server_bytes_read_max # TYPE http_server_bytes_read_max gauge http_server_bytes_read_max 0.0 # HELP jvm_memory_usage_after_gc_percent The percentage of old gen heap used after the last GC event, in the range [0..1] # TYPE jvm_memory_usage_after_gc_percent gauge jvm_memory_usage_after_gc_percent{area="heap",generation="old",} 0.0015022370123094128 # HELP system_cpu_count The number of processors available to the Java virtual machine # TYPE system_cpu_count gauge system_cpu_count 8.0
Prometeusでデータを取得する
Prometeus Pull型のモニタリングとアラートを行なうためのツールキットです。 Dockerで準備して、アプリのメトリクスを取得してみます。
prometheus.ymlを準備しておく
ここを参考にPrometheusの設定を記述します。
Dockerでボリュームマウントできれば、設定はどこでも良いのですが一旦/tmp/etc/prometheus.yaml
に作成しました。
global: scrape_interval: 15s evaluation_interval: 15s rule_files: scrape_configs: - job_name: prometheus static_configs: - targets: ['${IP_OF_YOUR_APP}:8080']
注目すべきは-targets
のところで、ここにQuarkusあぷりのホストとポートを指定します。
アプリは/metrics
で情報の公開を行っていますが、Prometheusはデフォルトで、/metrics
に情報を取得しに行くためPathなどの指定は必要ありません。
docker runでPrometeusを起動する
以下のコマンドでPrometeusを起動します。
ボリュームのマウントはホスト側のyamlファイルを置いた場所に指定します。
docker run \ -p 9090:9090 \ -v /tmp/etc/prometheus.yaml:/etc/prometheus/prometheus.yml \ prom/prometheus
PrometheusのUIを確認
起動してブラウザーから9090にアクセスしてみるとデータが取得できているのが確認できます。
Grafanaで可視化する
Grafanaはメトリクスの可視化ツールです。
Prometheusなどで収集したデータに対してよりリッチなUIを提供したりできます。
こちらもDockerでさくっと立ち上げてみます。
docker run -d -p 3000:3000 grafana/grafana
http://localhost:3000/
にアクセスすると、Login画面にリダイレクトされます。
初回パスワード/ユーザ名はadmin/admin
です。
Prometeusに接続する
Prometeusからメトリクスを収集するようにします。
右のメニューバーの歯車を選択し、add data source
を選択すると以下のような画面が表示されます。
ここでは迷うこと無くPrometheusを選択します。
すると以下の設定画面に遷移します。
いろいろ設定項目がありますが、今回はただ動かしたいだけなので、Prometheusが動いているIPとポートをURL
のとろこに設定します。同じやり方をされていればホストマシンのIPをここにかけば大丈夫です。
ダッシュボードを作成する
最後にダッシュボードを作成してみます。
また、右のメニューバーから+のボタンを押して、Dashbord
を選択すると以下のような画面に遷移します。
あとはお好きなようにメトリクスをしたり、クエリを書いたりしてお好みのダッシュボードを作っていく感じです。
画像では単にMetrics
のところでjvm_gc_max_data_size_bytes
を選択しただけです。
設定項目一覧
上記までで可視化は完了しましたが、Quarkusで設定できるMicrometerのプロパティをいくつかまとめておこうと思います。
すべてのプロパティを見たい方はこちらを参照ください(気が向いたら追記していくかもです)
プロパティ | 説明 | 型(デフォルト値) |
---|---|---|
quarkus.micrometer.enabled | Micromerterのサポートを有効化するフラグ | boolean(true) |
quarkus.micrometer.registry-enabled-default | クラスパス内にMeterRegistryの実装を見つけた場合に自動的に認識するか否かを決めるためのフラグ。デフォルトでは自動的に認識する。 | boolean(true) |
quarkus.micrometer.binder-enabled-default | クラスパス内にMeterBinderの実装を見つけた場合に自動的に認識するか否かを決めるためのフラグ。デフォルトでは自動的に認識する。 | boolean(true) |
quarkus.micrometer.binder.vertx.enabled | Vert.xのメトリクスサポートの有効化フラグマイクロメータのサポートが有効化されている場合、Vert.xのMetricsOpitongaクラスパスにある場合、このプロパティにtrueが指定されている場合、quarkus.micrometer.binder-enabled-default がtrueの場合にtrueになる |
boolean |
quarkus.micrometer.binder.mp-metrics.enabled | MicroProfile Metricsのサポート有効化フラグ、 | boolean |
quarkus.micrometer.binder.jvm | jvmのメトリクスサポートの有効化フラグ | boolean(true) |
quarkus.micrometer.binder.system | システムのメトリクスサポート有効化フラグ | boolean |
quarkus.micrometer.export.prometheus.path | メトリクスを公開するパスを指定するためのプロパティ | string(/metrics) |
quarkus.micrometer.export.json.enabled | Jsonフォーマットでのメトリクス公開を行なうか否かのフラグ | boolean(false) |