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.jsonfeatureの項目を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でプロジェクトを作成します。
設定は以下の通り。

f:id:yuya_hirooka:20201104205319p:plain

ブログではMicroProfile Metricsじゃなくて、Micrometerの方を押してたきがしますがここではExperimentalってなってますね。
多分まだ修正されてないだけなんでしょうか。

pomにPrometheusの依存を追加しておく

MicromerterでPrometheusの形式の情報を取得するために以下の依存を追加しておく必要があります。

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

cURLでメトリクスを取得する

この状態ですでにメトリクスは取得できます。

アプリを起動して、cURLlocalhost: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にアクセスしてみるとデータが取得できているのが確認できます。

f:id:yuya_hirooka:20201104222229p:plain

Grafanaで可視化する

Grafanaはメトリクスの可視化ツールです。 Prometheusなどで収集したデータに対してよりリッチなUIを提供したりできます。
こちらもDockerでさくっと立ち上げてみます。

docker run -d -p 3000:3000 grafana/grafana

http://localhost:3000/にアクセスすると、Login画面にリダイレクトされます。
初回パスワード/ユーザ名はadmin/adminです。

f:id:yuya_hirooka:20201104224941p:plain

Prometeusに接続する

Prometeusからメトリクスを収集するようにします。
右のメニューバーの歯車を選択し、add data sourceを選択すると以下のような画面が表示されます。

f:id:yuya_hirooka:20201104225231p:plain

ここでは迷うこと無くPrometheusを選択します。
すると以下の設定画面に遷移します。

f:id:yuya_hirooka:20201104225644p:plain

いろいろ設定項目がありますが、今回はただ動かしたいだけなので、Prometheusが動いているIPとポートをURLのとろこに設定します。同じやり方をされていればホストマシンのIPをここにかけば大丈夫です。

ダッシュボードを作成する

最後にダッシュボードを作成してみます。
また、右のメニューバーから+のボタンを押して、Dashbordを選択すると以下のような画面に遷移します。

f:id:yuya_hirooka:20201104230249p:plain

あとはお好きなようにメトリクスをしたり、クエリを書いたりしてお好みのダッシュボードを作っていく感じです。 画像では単に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)

参考資料

Spring REST DocsとSpring Cloud Contractを連携して使ってみる

はじめに

Spring REST DocsはSpringのRestFullアプリケーションにおけるWebAPIのインターフェースをドキュメント化する際のサポートをしてくれます。Spring MVC Testをもとにスニペットを自動生成し、Asciidoctorの形式で出力してくれます。
また、Spring Cloud ContractはConsumer Driven Contractのサポートを提供しているプロジェクトです。Spring Cloud Contractに関しては以前にブログを書いたので良ければこちらをご覧ください。
この2つは組み合わせて利用することが可能なようなので、試してみたいと思います。

組み合わせることでなにが変わるのか?

端的に言うと、Spring Cloud Contractで書いていた契約のYamlファイルを書く必要がなくなるみたいです。その分テストを自分で書くことになるのでその面だけで考えると一長一短みたいなところはあるかもしれないです。ただ、全体として考えると、テストを記述することでCDCのサポートを受けれるようになり、かつAPIがヒューマンリーダブルな形でドキュメント化されるようになります。

やってみる

実行環境

実行環境は以下の通り

$ java --version
openjdk 15.0.1 2020-10-20
OpenJDK Runtime Environment (build 15.0.1+9-18)
OpenJDK 64-Bit Server VM (build 15.0.1+9-18, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 15.0.1, vendor: Oracle Corporation, runtime: /home/someone/.sdkman/candidates/java/15.0.1-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-52-generic", arch: "amd64", family: "unix"

プロジェクトはSpring Iniializrを使って、以下の設定で作成します

f:id:yuya_hirooka:20201101141103p:plain

また、CDCのConsumer側はSpring Cloud Contractの使い方と変わらないため、Spring Cloud ContractのProducer側のみを作成します。

pomを軽く眺めてみる

作成されたプロジェクトのpomを軽く眺めてみると必要な依存が入っているのといくつかのプラグインが設定されています。

<plugins>
    <plugin>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-contract-maven-plugin</artifactId>
        <version>2.2.4.RELEASE</version>
        <extensions>true</extensions>
        <configuration>
            <testFramework>JUNIT5</testFramework>
        </configuration>
    </plugin>
    <plugin>
        <groupId>org.asciidoctor</groupId>
        <artifactId>asciidoctor-maven-plugin</artifactId>
        <version>1.5.8</version>
        <executions>
            <execution>
                <id>generate-docs</id>
                <phase>prepare-package</phase>
                <goals>
                    <goal>process-asciidoc</goal>
                </goals>
                <configuration>
                    <backend>html</backend>
                    <doctype>book</doctype>
                </configuration>
            </execution>
        </executions>
        <dependencies>
            <dependency>
                <groupId>org.springframework.restdocs</groupId>
                <artifactId>spring-restdocs-asciidoctor</artifactId>
                <version>${spring-restdocs.version}</version>
            </dependency>
        </dependencies>
    </plugin>
    <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
</plugins>

asciidocとspring−cloud-contractのプラグインが入れられているのが確認できます。

Pom等に設定を追加する

2つを連携されるためにいくつか設定を行なう必要があります。

PomにContractのテストをスキップする設定を追加する

前述の通り、REST DocとCloud Contractを連携される場合、Yamlなどの契約を書く必要がありません。つまり、Cloud Contractが自動生成するテスト実行する必要がないので、スキップする設定を追加してやる必要があります。

ドキュメントによると、ユーザープロパティにspring.cloud.contract.verifier.skipをtrueで設定してやることでテストをスキップできるようなので設定を記述します。

<properties>
    <java.version>15</java.version>
    <spring-cloud.version>Hoxton.SR8</spring-cloud.version>
    <spring.cloud.contract.verifier.skip>true</spring.cloud.contract.verifier.skip>
</properties>

Spring Cloud Contract Pluginを削除する(追記)

このブログを投稿した際に以下のようなアドバイスをSpring Cloud Contract のAutherであるMarcin Grzejszczakさんからいただきました。
Thank you for telling me this, Mr. Marcin Grzejszczak!!

Contract とREST Docsを連携させる場合はテストもスタブも自動生成するわけで無いので、このプラグインは必要ないとのことです。なので消してしまって大丈夫です。

Stubの作成のための設定を追加する

StubのJarを作成するためにassembly pluginを入れて設定を追加してやる必要があります。まず、以下のプラグインをPomに追加します。

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <executions>
        <execution>
            <id>stub</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>single</goal>
            </goals>
            <inherited>false</inherited>
            <configuration>
                <attach>true</attach>
                <descriptors>./src/assembly/stub.xml</descriptors>
            </configuration>
        </execution>
    </executions>
</plugin>

次にサンプルを参考にstub.xmlを作成し${project_root}/src/assembly/配下に配置します。

<assembly
        xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 https://maven.apache.org/xsd/assembly-1.1.3.xsd">
    <id>stubs</id>
    <formats>
        <format>jar</format>
    </formats>
    <includeBaseDirectory>false</includeBaseDirectory>
    <fileSets>
        <fileSet>
            <directory>src/main/java</directory>
            <outputDirectory>/</outputDirectory>
            <includes>
                <include>**dev/hirooka/model/*.*</include>
            </includes>
        </fileSet>
        <fileSet>
            <directory>${project.build.directory}/classes</directory>
            <outputDirectory>/</outputDirectory>
            <includes>
                <include>**dev/hirooka/model/*.*</include>
            </includes>
        </fileSet>
        <fileSet>
            <directory>${project.build.directory}/snippets/stubs</directory>
            <outputDirectory>META-INF/${project.groupId}/${project.artifactId}/${project.version}/mappings</outputDirectory>
            <includes>
                <include>**/*</include>
            </includes>
        </fileSet>
        <fileSet>
            <directory>./target/generated-snippets/contracts</directory>
            <outputDirectory>META-INF/${project.groupId}/${project.artifactId}/${project.version}/contracts</outputDirectory>
            <includes>
                <include>**/*.groovy</include>
            </includes>
        </fileSet>
    </fileSets>
</assembly>

ここで、ポイントは<directory>./target/generated-snippets/contracts</directory>のところで、これは自動生成される契約のDSLを指定してやる必要があります。
デフォルトではtarget配下にcontractディレクトリが作成されそこにgroovyが出力されるのでそこを指定します。

<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>2.2.4.RELEASE</version>
    <extensions>true</extensions>
    <configuration>
        <testFramework>JUNIT5</testFramework>
        <packageWithBaseClasses>dev.hirooka</packageWithBaseClasses>
    </configuration>
</plugin>

spring-cloud-contract-wiremockの依存を追加する

Spring Cloud Contract Rest Docsのインテグレーションを使うためにはpomに以下の依存を追加する必要があります。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-wiremock</artifactId>
    <scope>test</scope>
</dependency>

こいつを追加することで、Rest Docからスタブを生成することができるようになります。

ベースとなるテストと実装を書く

実装に入る前に先にテストを記述しておきます。 テストはJunit5とMockMVCを使って記述します。Spring REST Docsは他にも、WebTestClientREST Assuredでも利用できます。

@SpringBootTest
class HelloControllerTest {

    private MockMvc mockMvc;

    @BeforeEach
    public void setUp(WebApplicationContext context) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
    }

    @Test
    void HelloControllerのテスト() throws Exception {
        mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.hello").value("world"));
    }
}

ここまでは一般的なテストですね次に実装を書きます。

@RestController
public class HelloController {
    @GetMapping("/")
    public Map<String, String> hello() {
        return Map.of("hello", "world");
    }
}

実装もごくごく一般的なコントローラーです。
テストを実行すると成功します。

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.282 s - in hirooka.dev.cdcanddocs.controller.HelloControllerTest

REST Docsを追加する

ベースとなる実装ができたところでテストにREST Docsの設定を入れていきます。
先程の@BeforeEachで書いたMockMvcの設定とテスト自体を少しいじります。

@SpringBootTest
@ExtendWith(RestDocumentationExtension.class)
class HelloControllerTest {

    private MockMvc mockMvc;

    @BeforeEach
    public void setUp(
            WebApplicationContext context,
            RestDocumentationContextProvider provider
    ) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(documentationConfiguration(provider))
                .build();
    }

    @Test
    void HelloControllerのテスト() throws Exception {
        mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.hello").value("world"))
                .andDo(document("index"));
    }
}

僕自身がRestDocsを使うのははじめてなので少しだけ突っ込んでまとめようと思います。

まず、最初に@ExtendWith(RestDocumentationExtension.class)を追加しています。このエクステンションを入れることで、@BforeEachの引数でRestDocumentationContextProviderを受け取ることができます。このクラスはRestDocumentationContextへのアクセスを提供します。
次に@BeforeEachにでMockMvcに設定を追加しています。 MockMvcRestDocumentationdocumentationConfiguration()スタティックメソッドを使ってMockMvcにMockMvc用のRest Docs拡張である MockMvcRestDocumentationConfigurerを登録しています。ここまでで、事前準備は完了です。

次にTestクラスに着目すると、最後のandDo()MockMvcRestDocumentationdocumennt()スタティックメソッドを呼び出しています。このスタティックメソッドは第一引数にドキュメントの識別子と第二引数に可変引数としてSnippetインターフェースの実装を受け取ります。この実装は出力ドキュメントに対して付加情報を追加する際に利用します。

mvn testを実行するとtarget/generated-snippets/index/*.adocにデフォルトでは以下の画像の6種類のasciidocが出力されます。

f:id:yuya_hirooka:20201102123335p:plain

結果の出力はMarkDownなどでも行えるようですが、ここでは深く触れないよ言うにします。

Contractと連携する。

AsciiDocの出力まで終わったところで、Spring Cloud Contractとの連携を行ってみようと思います。
テストコードを以下のように修正します。

@Test
void HelloControllerのテスト() throws Exception {
    mockMvc.perform(get("/"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.hello").value("world"))
            .andDo(document("index", SpringCloudContractRestDocs.dslContract()));
}

document()の引数にSpringCloudContractRestDocs.dslContract()を渡すことで契約のDSLとスタブが自動生成されるようになります。
mvn clean installを実行するとtarget配下が以下のように生成されることがわかります。

f:id:yuya_hirooka:20201102210440p:plain

Consumer側からのStubの利用

いくつか方法がありますが@AutoConfigureStubRunnerを使った方法をこちらにまとめてますのでそちらを参照してください。

リクエストをバリデートするような実装に変えてみる

今までは、リクエストに対して固定値を返すだけのシンプルな実装でしたが、リクエストのパターンによって返すものを変えるような実装に変えてみます。 例えば以下のようなストーリーを想定します。

  • リクエストとしてJsonを受け取り、その値によってOKとNGのステータスを返す

Rest DocsとCloud Contractの連携でリクエストのヴァリファイを行なうスタブを作成する場合、WireMockRestDocsを利用します。

    @Test
    void HelloControllerのOKテスト() throws Exception {
        mockMvc.perform(post("/")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"value\": 10}"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.status").value("OK"))
                .andDo(
                        WireMockRestDocs.verify()
                                .contentType(MediaType.APPLICATION_JSON)
                                .jsonPath("$[?(@.value >= 10)]"))
                .andDo(document("okPattern", SpringCloudContractRestDocs.dslContract()));
    }


    @Test
    void HelloControllerのNGテスト() throws Exception {
        mockMvc.perform(post("/")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"value\": 9}"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.status").value("NG"))
                .andDo(
                        WireMockRestDocs.verify()
                                .contentType(MediaType.APPLICATION_JSON)
                                .jsonPath("$[?(@.value < 10)]"))
                .andDo(document("ngPattern", SpringCloudContractRestDocs.dslContract()));
    }

同じくmvn clean installをすればstubが生成されます。

参考資料

Docker Engine APIを使ってcURLでDokcer Engineを操作する

はじめに

Docker のクライアントは公式だとGoとかPythonのものはあるようですが、UNIXドメインソケットを使ったHTTPでの操作も可能なようです。このブログではcURLを用いてDocker Engineに対するコマンドを実行してみたいと思います。

Docker Engine API

Dokcer Engine APIはDocker Engineが提供するHTTP APIです。Docker ClinentなどはこのAPIを使ってEngineとコミュニケーションをとっています。大体のコマンドははこのAPIに対応付けられているようで、HTTPを使ってDokcer Engineの操作を行なうことができます。現在の最新のバージョンはv1.40みたいです。また、Docker Engine APIのOpenAPI Specificationはここから取得できます。
前述通り、実際にこのAPIへアクセスする場合はUNIXドメインソケットを使うようです例えばcURLで起動中のコンテナ一覧を取得場合は以下のようにします。
(公式のサンプルはここからみれます)

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES


$ curl --unix-socket /var/run/docker.sock http:/v1.40/containers/json
[]

現状、動作中のコンテナは一つも無いで空のリストが返ってきています。

使ってみる

環境

$ 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

$ curl --version
curl 7.68.0 (x86_64-pc-linux-gnu) libcurl/7.68.0 OpenSSL/1.1.1f zlib/1.2.11 brotli/1.0.7 libidn2/2.2.0 libpsl/0.21.0 (+libidn2/2.2.0) libssh/0.9.3/openssl/zlib nghttp2/1.40.0 librtmp/2.3
Release-Date: 2020-01-08
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp 
Features: AsynchDNS brotli GSS-API HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM NTLM_WB PSL SPNEGO SSL TLS-SRP UnixSockets

コンテナの作成

alpineのイメージつかってコンテナを作成します。

$ curl --unix-socket /var/run/docker.sock -H "Content-Type: application/json" \
>   -d '{"Image": "alpine", "Cmd": ["echo", "hello world"]}' \
>   -X POST http:/v1.40/containers/create
{"Id":"339e9914b446b768e0e1da6b313165f9a8187793175f068884e6c1597d6a8ea3","Warnings":[]}

コンテナ作成は/containers/createに対してPOSTで、必要な情報をJson形式で送ってやることで実行することができます。

コンテナの実行

次に先程作成したコンテナを実行します。

$ curl --unix-socket /var/run/docker.sock -X POST http:/v1.40//containers/339e9914b446/start

dokcer runに相当するような操作です。 POSTのリクエストを/containers/${IMAGE_ID}/startに送ってやることで実行可能です。

インスペクト

コンテナのインスペクトの実行をしてみます。
/containers/${IMAGE_ID}/jsonにGETリクエストを送ってやることで実行可能です。

$ curl  --unix-socket /var/run/docker.sock  "http:/v1.40/containers/339e9914b446/json"  | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  4369    0  4369    0     0  4266k      0 --:--:-- --:--:-- --:--:-- 4266k
{
  "Id": "339e9914b446b768e0e1da6b313165f9a8187793175f068884e6c1597d6a8ea3",
  "Created": "2020-10-24T12:37:07.655320189Z",
  "Path": "echo",
  "Args": [
    "hello world"
  ],
  "State": {
    "Status": "exited",
    "Running": false,
    "Paused": false,
    "Restarting": false,
    "OOMKilled": false,
    "Dead": false,
    "Pid": 0,
    "ExitCode": 0,
    "Error": "",
    "StartedAt": "2020-10-24T12:39:50.497526437Z",
    "FinishedAt": "2020-10-24T12:39:50.520324354Z"
  },
  "Image": "sha256:f70734b6a266dcb5f44c383274821207885b549b75c8e119404917a61335981a",
  "ResolvConfPath": "/var/lib/docker/containers/339e9914b446b768e0e1da6b313165f9a8187793175f068884e6c1597d6a8ea3/resolv.conf",
  "HostnamePath": "/var/lib/docker/containers/339e9914b446b768e0e1da6b313165f9a8187793175f068884e6c1597d6a8ea3/hostname",
  "HostsPath": "/var/lib/docker/containers/339e9914b446b768e0e1da6b313165f9a8187793175f068884e6c1597d6a8ea3/hosts",
  "LogPath": "/var/lib/docker/containers/339e9914b446b768e0e1da6b313165f9a8187793175f068884e6c1597d6a8ea3/339e9914b446b768e0e1da6b313165f9a8187793175f068884e6c1597d6a8ea3-json.log",
  "Name": "/agitated_swirles",
  "RestartCount": 0,
  "Driver": "overlay2",
  "Platform": "linux",
  "MountLabel": "",
  "ProcessLabel": "",
  "AppArmorProfile": "docker-default",
  "ExecIDs": null,
  "HostConfig": {
    "Binds": null,
    "ContainerIDFile": "",
    "LogConfig": {
      "Type": "json-file",
      "Config": {}
    },
    "NetworkMode": "default",
    "PortBindings": null,
    "RestartPolicy": {
      "Name": "",
      "MaximumRetryCount": 0
    },
    "AutoRemove": false,
    "VolumeDriver": "",
    "VolumesFrom": null,
    "CapAdd": null,
    "CapDrop": null,
    "Capabilities": null,
    "Dns": null,
    "DnsOptions": null,
    "DnsSearch": null,
    "ExtraHosts": null,
    "GroupAdd": null,
    "IpcMode": "private",
    "Cgroup": "",
    "Links": null,
    "OomScoreAdj": 0,
    "PidMode": "",
    "Privileged": false,
    "PublishAllPorts": false,
    "ReadonlyRootfs": false,
    "SecurityOpt": null,
    "UTSMode": "",
    "UsernsMode": "",
    "ShmSize": 67108864,
    "Runtime": "runc",
    "ConsoleSize": [
      0,
      0
    ],
    "Isolation": "",
    "CpuShares": 0,
    "Memory": 0,
    "NanoCpus": 0,
    "CgroupParent": "",
    "BlkioWeight": 0,
    "BlkioWeightDevice": null,
    "BlkioDeviceReadBps": null,
    "BlkioDeviceWriteBps": null,
    "BlkioDeviceReadIOps": null,
    "BlkioDeviceWriteIOps": null,
    "CpuPeriod": 0,
    "CpuQuota": 0,
    "CpuRealtimePeriod": 0,
    "CpuRealtimeRuntime": 0,
    "CpusetCpus": "",
    "CpusetMems": "",
    "Devices": null,
    "DeviceCgroupRules": null,
    "DeviceRequests": null,
    "KernelMemory": 0,
    "KernelMemoryTCP": 0,
    "MemoryReservation": 0,
    "MemorySwap": 0,
    "MemorySwappiness": null,
    "OomKillDisable": false,
    "PidsLimit": null,
    "Ulimits": null,
    "CpuCount": 0,
    "CpuPercent": 0,
    "IOMaximumIOps": 0,
    "IOMaximumBandwidth": 0,
    "MaskedPaths": [
      "/proc/asound",
      "/proc/acpi",
      "/proc/kcore",
      "/proc/keys",
      "/proc/latency_stats",
      "/proc/timer_list",
      "/proc/timer_stats",
      "/proc/sched_debug",
      "/proc/scsi",
      "/sys/firmware"
    ],
    "ReadonlyPaths": [
      "/proc/bus",
      "/proc/fs",
      "/proc/irq",
      "/proc/sys",
      "/proc/sysrq-trigger"
    ]
  },
  "GraphDriver": {
    "Data": {
      "LowerDir": "/var/lib/docker/overlay2/136d71a0857b85896e2c74cd22c82efbccf2739862fe56c0e00427afaef50ad2-init/diff:/var/lib/docker/overlay2/98748a873a839203b278e9bf5bd0086685aaa2e9b0fae44d74393e7cdfcb1691/diff",
      "MergedDir": "/var/lib/docker/overlay2/136d71a0857b85896e2c74cd22c82efbccf2739862fe56c0e00427afaef50ad2/merged",
      "UpperDir": "/var/lib/docker/overlay2/136d71a0857b85896e2c74cd22c82efbccf2739862fe56c0e00427afaef50ad2/diff",
      "WorkDir": "/var/lib/docker/overlay2/136d71a0857b85896e2c74cd22c82efbccf2739862fe56c0e00427afaef50ad2/work"
    },
    "Name": "overlay2"
  },
  "Mounts": [],
  "Config": {
    "Hostname": "339e9914b446",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "echo",
      "hello world"
    ],
    "Image": "alpine",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": {}
  },
  "NetworkSettings": {
    "Bridge": "",
    "SandboxID": "055a82d24ed80b14fb2ff1e2d588192464199d3f51dab021efa2fcf0dc6a5df9",
    "HairpinMode": false,
    "LinkLocalIPv6Address": "",
    "LinkLocalIPv6PrefixLen": 0,
    "Ports": {},
    "SandboxKey": "/var/run/docker/netns/055a82d24ed8",
    "SecondaryIPAddresses": null,
    "SecondaryIPv6Addresses": null,
    "EndpointID": "",
    "Gateway": "",
    "GlobalIPv6Address": "",
    "GlobalIPv6PrefixLen": 0,
    "IPAddress": "",
    "IPPrefixLen": 0,
    "IPv6Gateway": "",
    "MacAddress": "",
    "Networks": {
      "bridge": {
        "IPAMConfig": null,
        "Links": null,
        "Aliases": null,
        "NetworkID": "7713a5ebd4018030b9f7c851707246986f794acfba01f7da0f3794769e712945",
        "EndpointID": "",
        "Gateway": "",
        "IPAddress": "",
        "IPPrefixLen": 0,
        "IPv6Gateway": "",
        "GlobalIPv6Address": "",
        "GlobalIPv6PrefixLen": 0,
        "MacAddress": "",
        "DriverOpts": null
      }
    }
  }
}

ログの取得

curl  --unix-socket /var/run/docker.sock  "http:/v1.40/containers/339e9914b446/logs?stdout=1" --output -

hello world

コンテナのログを取得する場合は、 POSTのリクエストを/containers/${IMAGE_ID}/logsに送ってやることで実行可能です。

最後に

このブログでは基本的にコンテナの操作だけを行いましたが基本なんの操作でもできそうです。

RouterFunctionでリクエストをインターセプトする

はじめに

Router Functionを利用しているときのサーブレットフィルターとかインターセプターみたいなのってどうやってやるんだろって言うのが気になったのでちょっと調べてみます。

やってみる

環境

環境は以下の通り

$ java --version
openjdk 15 2020-09-15
OpenJDK Runtime Environment (build 15+36-1562)
OpenJDK 64-Bit Server VM (build 15+36-1562, mixed mode, sharing)

$ mvn --version
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 15, vendor: Oracle Corporation, runtime: /home/someone/.sdkman/candidates/java/15-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-51-generic", arch: "amd64", family: "unix"

プロジェクトはSpring Initializrで作成して設定は以下のようにしました。

f:id:yuya_hirooka:20201016044037p:plain

ざっくりやるとこ

大きくは以下の2つの手順を踏むっぽいです

  • HandlerFilterFunctionを実装ししたFilter関数を作成する
  • RouterFunctionにFilterとして登録する

下準備

まずはハンドラーを作成して、Router Functionクラスに登録します。

@Component
public class HelloResource {

    public Mono<ServerResponse> greeting(ServerRequest request){
        String name = request.queryParam("name").orElse("world");
        return ServerResponse.ok().body(Mono.just(String.format("Hello, %s", name)), String.class);
    }
}
@SpringBootApplication
public class RouterFunctionFilterApplication {

    private HelloResource helloResource;

    public RouterFunctionFilterApplication(HelloResource helloResource) {
        this.helloResource = helloResource;
    }

    public static void main(String[] args) {
        SpringApplication.run(RouterFunctionFilterApplication.class, args);
    }

    @Bean
    public RouterFunction router() {
        return RouterFunctions.route(GET("/hello"), helloResource::greeting);
    }

}

ハンドラーはクエリストリングで名前を受け取って挨拶を返すだけのものです。
cURLでリクエストを送ります。

$ curl localhost:8080/hello?name=henohenomoheji
Hello, henohenomoheji

ここまでで下準備は完了です。

HandlerFilterFunctionを実装ししたFilter関数を作成する

Router Functionでリクエストをインターセプトする場合フィルターを行なうためのクラス(もしくは関数)を作成する必要があります。このFilterはHandlerFilterFunctionインターフェースを実装することでフィルター関数を作成します。
このブログではリクエストを受け取ってその情報をログに出力するだけの簡単なフィルターを作成します。
単にフィルター関数をラムダとして書いてやることもできますが、わかりやすさのために一旦フィルタークラスを作成しようと思います。

HandlerFilterFunctionのfilter(ServerRequest request, HandlerFunction<T> next)javadocをみると以下のように書かれています。

/**
* Apply this filter to the given handler function. The given
* {@linkplain HandlerFunction handler function} represents the next entity in the chain,
* and can be {@linkplain HandlerFunction#handle(ServerRequest) invoked} in order to
* proceed to this entity, or not invoked to block the chain.
* @param request the request
* @param next the next handler or filter function in the chain
* @return the filtered response
* @see ServerRequestWrapper
*/
Mono<R> filter(ServerRequest request, HandlerFunction<T> next);

引数としてリクエストと次のフィルター(もしくはハンドラー)を表すHandlerFunction受け取るみたいですね。 HandlerFunctionインターフェースはhandle(ServerRequest request)メソッドを持っていてこいつにリクエストを渡すことで次のフィルターをチェインできるっぽいです。また、javadocによるとその戻り値はresponseになるようなので、レスポンスになにか共通処理を入れたい場合はその戻り値に対してゴニョゴニョできるみたいです。
今回の場合は戻り値に特に処理はくわえないので、たんにhandle(ServerRequest request)の実行をそのままレスポンスとして返してやれば良さそうです。
具体的には以下のようなクラスを作成します。

public class RequestLoggingFilter implements HandlerFilterFunction {

    private final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class);

    @Override
    public Mono filter(ServerRequest request, HandlerFunction next) {
        logger.info(String.format("%s, %s", request, request.queryParams()));
        return next.handle(request);
    }
}

これでフィルターはできました。

RouterFunctionに作成したフィルターを登録する

作成したフィルターをRouterFunctionに登録します。

@SpringBootApplication
public class RouterFunctionFilterApplication {

    private final HelloResource helloResource;

    public RouterFunctionFilterApplication(HelloResource helloResource) {
        this.helloResource = helloResource;
    }

    public static void main(String[] args) {
        SpringApplication.run(RouterFunctionFilterApplication.class, args);
    }

    @Bean
    public RouterFunction router() {
        return RouterFunctions.route(GET("/hello"), helloResource::greeting)
                .filter(new RequestLoggingFilter());
    }

}

RouterFunctions.BuilderのfilterメソッドにHndlerFunctionの実装を渡してやると内部でRouterFunctions.FilteredRouterFunctionと呼ばれるWrapされたRouterFunctionを返してくれます。

リクエストを送ると以下のログが出力されました。

2020-10-17 11:48:37.360  INFO 20922 --- [or-http-epoll-2] d.h.r.RequestLoggingFilter               : HTTP GET /hello, {name=[henohenomoheji]}

複数フィルターを登録する

別のフィルターをラムダで作って登録します。単に、fitelr()メソッドを再度呼び出して登録してやれば良さそうです。

@Bean
    public RouterFunction router() {
        return RouterFunctions.route(GET("/hello"), helloResource::greeting)
                .filter(new RequestLoggingFilter())
                .filter((r, n)->{
                    logger.info("filtered 1");
                    return n.handle(r);
                });

    }

リクエストを送ると、ログには以下のように出力されました。

2020-10-17 11:43:20.276  INFO 20430 --- [or-http-epoll-2] d.h.r.RouterFunctionFilterApplication    : filtered 1
2020-10-17 11:43:20.277  INFO 20430 --- [or-http-epoll-2] d.h.r.RequestLoggingFilter               : HTTP GET /hello, {name=[henohenomoheji]}

フィルターは後から登録されたものが先に実行されます。
まだ、ちゃんと調べてませんが、フィルターやハンドラーはRouterFunctionのコンポジットパターンとして扱われるようで、基本的には後から登録されたものが先に実行されるような構造になっているみたいです。

QuarkusでHealth Checkのエンドポイントを作成する

はじめに

KubernetesにはLiveness ProbeをReadiness Probeといった概念があり、アプリケーションにそれぞれを確認するためのエンドポイントを作成する場合があります。
Spring Boot(2.3以上)などではそのエンドポイントが用意されていますが、Quarkusにもそれぞれのエンドポイントを作るやりたか(正確にはMicroprofileの実装であるsmallryeのQuarkus拡張)があったので試してみようかと思います。
基本的に以下のドキュメントにしたがってやる形で試してみようと思います。

やってみる

環境

実行環境は以下の通り

$ 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 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: /snap/intellij-idea-ultimate/253/plugins/maven/lib/maven3
Java version: 11.0.8, vendor: N/A, runtime: /home/yuya-hirooka/.sdkman/candidates/java/11.0.8-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-48-generic", arch: "amd64", family: "unix"

プロジェクトはQuarkus - Start coding with code.quarkus.ioで作成します。 設定は以下の通り f:id:yuya_hirooka:20201012192020p:plain

デフォルトのエンドポイントを確認する

実は特になにもせずともデフォルトでRedinessとLivenessのエンドポイントは用意されています。

  • /health/live : アプリケーション本体が立ち上がっているかどうか
  • /health/ready : DBの接続チェックなどを含めたアプリケーションがアクセスを受け入れる準備できているかどうか
  • /health : RedinessとLiveness両方の情報を返す

アプリケーションを起動し以下のエンドポイントにアクセスしてみます。

$ curl -i http://localhost:8080/health
HTTP/1.1 200 OK
content-type: application/json; charset=UTF-8
content-length: 46


{
    "status": "UP",
    "checks": [
    ]
}

$ curl -i http://localhost:8080/health/ready
HTTP/1.1 200 OK
content-type: application/json; charset=UTF-8
content-length: 46


{
    "status": "UP",
    "checks": [
    ]
}


$ curl -i http://localhost:8080/health/live 
HTTP/1.1 200 OK
content-type: application/json; charset=UTF-8
content-length: 46


{
    "status": "UP",
    "checks": [
    ]
}

ここで、UPがアプリケーションがアクティブな状態を示し、DOWNがアプリケーションがインアクティブであることを示します。

見ての通りこの状態だとアプリケーションはなにもチェックせず、自分自身が起動していることのみ確認することができます。
任意のチェックを行い結果を返す必要がある場合は、少し手をくわえてやる必要があります。

Health Cheackを作成する

例えば、Readinessのチェックではデータベースとの接続がうまく行っているか否かの確認を行いたい場合があります。そのような場合にはReadinessのHealth Checkを実装してやる必要があります。
まずは単にHealth Checkを実装します。

import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Readiness;

import javax.enterprise.context.ApplicationScoped;

@Readiness
@ApplicationScoped
public class ReadinessCheck implements HealthCheck {

    @Override
    public HealthCheckResponse call() {
        return HealthCheckResponse.up("test");
    }
}

Healthチェックを行なう際はorg.eclipse.microprofile.health.HealthCheckを実装したCDIのBeanとして作成します。この際、クラスに@Redinessをつけると/health/readyに対するHealth Cheackを実装でき、@Livenessをつけると/health/liveに対する実装を行えます。このブログでは@Redinessでの実装しか行いませんが、基本的には全く同じことが@Livenessでも行えるはずです。
UPの状態の結果を返す場合はHealthCheckResponse.up("任意の文字列")を利用します。

この状態で、cURLを叩くと以下のようなレスポンスを返します。

$ curl -i http://localhost:8080/health/ready 
HTTP/1.1 200 OK
content-type: application/json; charset=UTF-8
content-length: 121


{
    "status": "UP",
    "checks": [
        {
            "name": "test",
            "status": "UP"
        }
    ]
}

実装をデータベースへの接続を確認するように修正します。
ここでは仮想的にDBの接続が失敗するような場合における実装をしてみます。

@Readiness
@ApplicationScoped
public class ReadinessCheck implements HealthCheck {

    @Override
    public HealthCheckResponse call() {
        try {
            checkDB();
            return HealthCheckResponse.up("test");
        } catch (Exception e) {
            e.printStackTrace();
            return HealthCheckResponse.down("db connection");
        }
    }

    private void checkDB(){
       throw new RuntimeException("db connection falied");
    }
}

ここでは、checkDB()というDBの接続を確認することを想定したメソッドを作成し、失敗を模擬的に表した、RuntimeExceptionを投げています。
もう一度、cURLを叩くと以下のようなレスポンスが返ってきます。

$ curl -i http://localhost:8080/health/ready 
HTTP/1.1 503 Service Unavailable
content-type: application/json; charset=UTF-8
content-length: 134


{
    "status": "DOWN",
    "checks": [
        {
            "name": "db connection",
            "status": "DOWN"
        }
    ]
}

任意の情報を付加する

Health Checkに任意の情報を更かしたい場合はHealthCheckResponseBuilderを使います。

@Readiness
@ApplicationScoped
public class ReadinessCheck implements HealthCheck {

    @Override
    public HealthCheckResponse call() {
        try {
            checkDB();
            return HealthCheckResponse.named("db connection")
                    .up()
                    .build();
        } catch (Exception e) {
            e.printStackTrace();
            return HealthCheckResponse
                    .named("db connection")
                    .withData("foo", "bar")
                    .down().build();
        }
    }

    private void checkDB(){
       throw new RuntimeException("db connection falied");
    }
}

ここで、HealthCheckResponse.named("db connection")HealthCheckResponseBuilderを返し、そのwithData("key", "value")メソッドを使って任意のデータを返します。

cURLを叩くと以下のようなレスポンスが返ってきます。

$ curl -i http://localhost:8080/health/ready 
HTTP/1.1 503 Service Unavailable
content-type: application/json; charset=UTF-8
content-length: 200


{
    "status": "DOWN",
    "checks": [
        {
            "name": "db connection",
            "status": "DOWN",
            "data": {
                "foo": "bar"
            }
        }
    ]
}

複数の条件を確認するようなチェックを行なう

例えば、DB接続の他に依存APIも立ち上がっていることが必要な場合、複数の状態を確認してHealth Checkの結果としてUPを返す必要があります。
この場合、単にHealth Checkクラスを増やしてやるだけでOKです。
新たなHealth CheckクラスであるReadinessAPICheck.javaを作成します。

@Readiness
@ApplicationScoped
public class ReadinessAPICheck implements HealthCheck {

    @Override
    public HealthCheckResponse call() {
        try {
            chekAPI();
            return HealthCheckResponse.up("dependent api");
        } catch (Exception e) {
            e.printStackTrace();
            return HealthCheckResponse.down("dependent api");
        }
    }

    private void chekAPI(){
    }
}

cURLを叩くと以下のような結果が返ってきます。

$ curl -i http://localhost:8080/health/ready 
HTTP/1.1 503 Service Unavailable
content-type: application/json; charset=UTF-8
content-length: 209


{
    "status": "DOWN",
    "checks": [
        {
            "name": "api",
            "status": "UP"
        },
        {
            "name": "db connection",
            "status": "DOWN"
        }
    ]
}

最終的なチェック結果は各チェックのAND条件で決まります。
この場合、DB接続のほうがDOWNとなっているので最終的な結果もDOWNとなります。

Health UI

QuarkusのHealth Checkでは簡易的なUIもデフォルトで用意してくれています。http://${APP_URL}//health-ui/にアクセスすることで、そのUIをみることができます。

f:id:yuya_hirooka:20201012211846p:plain

ヘッダーの歯車ボタンを押すと設定を行なうことができます。

f:id:yuya_hirooka:20201012212016p:plain

Pollの項目ではチェック結果更新のためのポーリングの時間を決められた間隔で設定することができます。

Configuration Property

application.proptertiesには以下のような設定ポイントが用意されています。

プロパティ名 説明 デフォルト値
quarkus.health.extensions.enabled Health Checkを有効にするか否かのフラグ true (Boolean)
quarkus.smallrye-health.root-path Health Checkのルートパス /health (String)
quarkus.smallrye-health.liveness-path Helth CheckのLivenessのパス /live (String)
quarkus.smallrye-health.readiness-path Helth CheckのReadinessのパス /ready (String)
quarkus.smallrye-health.ui.root-path Health UIへアクセするためのパス /health-ui
quarkus.smallrye-health.ui.always-include Health UIをビルド常にビルドに含めるかどうかの設定。デフォルトではtestモードとdevモードのときだけHealth UIをビルドに含める false (Boolean)
quarkus.smallrye-health.ui.enable Health UIを有効にするか否かのフラグ ture (Boolean)

参考

RouterFunctionでグローバルにエラーハンドリングして任意のレスポンスを返す

はじめに

@ControllerAdvance@ExceptionHandler,を使ったグローバルなエラーハンドリングはやったことがあったのですが、そういえばRouterFanctionでやったことなかったなと思い試してみたいと思います。

やってみる

ざっくりやること

大きく2つのやることがあります。

  • ハンドルされる ResponseStatusExceptionを継承したExceptionクラスを作成する
  • AbstractErrorWebExceptionHandlerを継承したハンドラークラスを作成し、BeanとしてDIコンテナに登録する

環境

実行環境は以下の通り

$ java --version
openjdk 15 2020-09-15
OpenJDK Runtime Environment (build 15+36-1562)
OpenJDK 64-Bit Server VM (build 15+36-1562, mixed mode, shari

$ mvn --version
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 15, vendor: Oracle Corporation, runtime: /home/somenone/.sdkman/candidates/java/15-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-48-generic", arch: "amd64", family: "unix"

プロジェクトはSpring Initializrで作成し、Bootのバージョンは2.3.4.RELEASEです。

ハンドラーを作成してRouterに登録する

まずはExpectionを発生させるハンドラーや諸々を実装します。

@Component
public class ExampleResource {

    public Mono<ServerResponse> throwUnexpectedException(ServerRequest serverRequest) {
        return throwRuntimeException()
                .flatMap(s -> ServerResponse.ok().contentType(MediaType.TEXT_PLAIN).body(s, String.class));
    }

    public Mono<String> throwRuntimeException() {
        return Mono.error(new RuntimeException("something happened"));
    }

}

Routerに登録します。

@SpringBootApplication
public class ErrorHandlingWithRouterFunctionApplication {

    public static void main(String[] args) {
        SpringApplication.run(ErrorHandlingWithRouterFunctionApplication.class, args);
    }

    @Bean
    public RouterFunction<ServerResponse> route(ExampleResource exampleResource) {
        return RouterFunctions
                .route(GET("/unexpected"), exampleResource::throwUnexpectedException);
    }
}

この状態で、/unexpectedに対してリクエストを送ると以下のようなレスポンスが来ます。

$ curl localhost:8080/unexpected
{"timestamp":"2020-10-08T10:39:04.101+00:00","path":"/unexpected","status":500,"error":"Internal Server Error","message":"","requestId":"e06546f4-1"}

これはデフォルトではSpringのDefaultErrorWebExceptionHandlerがいい感じにハンドリングしてくれて値を返してくれているからです。
ちなみにこのハンドラーはAcceptリクエストヘッダーによってはHTMLのホワイトページを返してくれたりもします。
javadocによると以下のディレクトリにステータスコードの名前のHTMLを配置することで任意のエラーページを返すことも可能なようです。
例えば404のステータスコードでは以下の順序で探索が行われるようです。

'/<templates>/error/404.<ext>'
'/<static>/error/404.html'
'/<templates>/error/4xx.<ext>'
'/<static>/error/4xx.html'
'/<templates>/error/error'
'/<static>/error/error.html'

ハンドルされる ResponseStatusExceptionを継承したExceptionクラスを作成する

ハンドラーは作成できたので次にスローする任意のExpeptionクラスを作成します。
このExpectionクラスはResponseStatusExceptionを継承します。これは、特定の HTTP レスポンスステータスコードに関連付けられた例外の基本クラスで、ステータスコード、理由、原因となったException等を持つことができます。
詳細は後述しますが、グローバルでハンドリングする際のエラーの情報を持つErrorAttributesはResponseStatusExceptionで保持する情報をもとに作成されます。
それではInternalServerErrorを表すInternalServerErrorException.javaを作成してみます。

InternalServerErrorExcepiton.java

public class InternalServerErrorException extends ResponseStatusException {
    public InternalServerErrorException(String message) {
        super(HttpStatus.INTERNAL_SERVER_ERROR, message);
    }
}

ここでは、HttpStatus.INTERNAL_SERVER_ERRORは固定値にしておき、messageはExceptionが発生する際に詰め込むようにします。
今回はこのメッセージをクライアント側に返すようにします。
作成したExceptionを投げるように先程のハンドラーの実装を書き換えます。

@Component
public class ExampleResource {

    public Mono<ServerResponse> throwUnexpectedException(ServerRequest serverRequest) {
        return throwRuntimeException()
                .flatMap(s -> ServerResponse.ok().contentType(MediaType.TEXT_PLAIN).body(s, String.class))
                .onErrorResume(RuntimeException.class, e -> Mono.error(new InternalServerErrorException("something happened")));
    }

    public Mono<String> throwRuntimeException() {
        return Mono.error(new RuntimeException("something happened"));
    }
}

throwRuntimeExpception()で投げられる例外を.onErrorResume()でキャッチして作成したInternalServerErrorExceptionに詰め替え再度スローしています。

ここまで書いておいてなんですが、こいつはわざわざ実装しなくてもResponseStatusExceptionを直接使うでも大丈夫だとは思います。

AbstractErrorWebExceptionHandlerを継承したハンドラークラスを作成し、BeanとしてDIコンテナに登録する

メインコンテンツのグローバルなエラーハンドラーを実装していきます。
ハンドラーを実装するためにはAbstractErrorWebExceptionHandler を継承したクラスを作成しgetRoutingFunction(ErrorAttributes errorAttributes)をオーバライドします。

GlobalErrorWebExceptionHandler.java

@Component
@Order(-2)
public class GlobalErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {

    protected final static Logger logger = LoggerFactory.getLogger(GlobalErrorWebExceptionHandler.class);
    public GlobalErrorWebExceptionHandler(DefaultErrorAttributes g, ApplicationContext applicationContext,
                                          ServerCodecConfigurer serverCodecConfigurer) {
        super(g, new ResourceProperties(), applicationContext);
        super.setMessageWriters(serverCodecConfigurer.getWriters());
        super.setMessageReaders(serverCodecConfigurer.getReaders());
    }


    @Override
    protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
        return RouterFunctions.route(RequestPredicates.all(), r -> {
            ErrorAttributeOptions eao = ErrorAttributeOptions.defaults();

            Map<String, Object> ea = getErrorAttributes(r,
                    eao.including(ErrorAttributeOptions.Include.EXCEPTION, ErrorAttributeOptions.Include.MESSAGE)
            );
            logger.warn(ea);
            return renderJsonResponse(ea);
        });
    }

    private int getStatusCode(Map<String, Object> ea) {
        return (int) ea.get("status");
    }

    private Mono<ServerResponse> renderJsonResponse(Map<String, Object> ea) {
        ea.remove("exception");
        return ServerResponse.status(getStatusCode(ea))
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(ea));
    }
}

GlobalErrorWebExceptionHandlerではDefualtErrorAttributesに格納されたExceptionの情報をもとにログを出力することと、Mono<ServerResponse>を作成することを行っています。
DefualtErrorAttributesでは以下のような情報を持つことができます。

  • timestamp - エラーが抽出された時間
  • status - HTTPのステータスコード
  • error - エラーの理由
  • exception - ルート例外のクラス名 (設定されている場合)
  • message - 例外メッセージ (設定されている場合)
  • errors BindingResult 例外からの ObjectError (設定されている場合)
  • trace - 例外スタックトレース (設定されている場合)
  • path - 例外が発生したときの URL パス
  • requestId - リクエストのID

このなかで、 (設定されている場合)と書かれる4つの項目に関しては、ErrorAttributeOptions.Includeをオプションとして設定してgetErrorAttributesに渡してやることで取得することができます。 また、このDefualtErrorAttributesはErrorAttributesを実装したクラスをDIコンテナに登録し、エラーハンドラーでコンストラクターインジェクションを行ってセットすることでカスタマイズすることもできます。

getErrorAttributesはMap<String, Object>の形で保持する情報を返します。 この際のキーは上記のリストの英語部分のとおりです。

クラスに付与している@Order(-2)はautoconfigクラスであるErrorWebFluxAutoConfiguration@Order(-1)で設定されているためより優先度を高くする必要があるためです。

最後に、renderJsonResponseではDefualtErrorAttributeの情報をもとにMono<ServerResponse>を作成しています。この際に内部の例外の情報を返すことは好ましくないためexceptionはマップから削除しています。

アプリケーションを再起動してcurlでアクセスしてみます。

$ curl localhost:8080/unexpected
{"timestamp":"2020-10-08T12:18:07.761+00:00","path":"/unexpected","status":500,"error":"Internal Server Error","message":"something happened","requestId":"fe9c31ea-2"}y