Prometheus: アラートルールのユニットテスト

Prometheus に追加された[ルールのユニットテストの機能](https://github.com/prometheus/prometheus/blob/master/docs/configuration/unit_testing_rules.md)でアラートルールとレコーディングルールのテストを試してみました。この記事の執筆時点の最新バージョン [Prometheus v2.4.3](https://github.com/prometheus/prometheus/releases/tag/v2.4.3) にはまだこの機能が入っていないので、master ブランチの Docker イメージを利用しています。リリース前なので仕様変更が入る可能性があるのでご注意ください。


## 背景
以前より Prometheus のアラートルールのユニットテストは issue ([#1695](https://github.com/prometheus/prometheus/issues/1695))で要望があがっていました。[Google Summer of Code 2018 向けのプロジェクトアイデア](https://github.com/cncf/soc/blob/master/2018.md#prometheus)としても挙げられており、[Ganesh Vernekar](https://github.com/codesome)さんがこの実装を行いました。

- 実装の PR: [Unit testing for rules by codesome · Pull Request #4350 · prometheus/prometheus · GitHub](https://github.com/prometheus/prometheus/pull/4350)


##  `promtool test` コマンド
アラートルールのユニットテストは `promtool` という Prometheus のユーティリティコマンドに `test rules` サブコマンドとして実装されています。

promtool は [Promethus の Docker イメージ](https://hub.docker.com/r/prom/prometheus/)に含まれているので、それを使ってコマンドを確認してみます。デフォルトの Entrypoint が `prometheus` バイナリになっているので、`/bin/sh` に変更してシェルを実行しその中で確認しています。ユニットテストの定義ファイルはローカルのものを使いたいのでカレントディレクトリをマウントしています。

```bash
# master の最新バージョンを pull する
$ docker pull prom/prometheus:master

# Prometheus のイメージでシェルを実行
$ docker run -it -v $PWD:/work -w /work --entrypoint /bin/sh prom/prometheus:master
```

以下の操作はコンテナ内のシェルでの実行になります。基本的に `promtool test rules` にユニットテストの定義を書いた yaml ファイルを引数で渡すだけです。ここではテスト定義のファイルは別途カレントディレクトリに用意しています。


### テスト実行(成功)
test.yaml はユニットテストの定義ファイルです(後述)。テストに成功すると SUCCESS と表示され戻り値が 0 で返ります。

```bash
/work $ promtool test rules test.yaml 
Unit Testing:  test.yaml
  SUCCESS

# 戻り値は 0
/work $ echo $?
0
```


### テスト実行(失敗)
テストに失敗した場合は FAILED と表示され、期待値(exp) と実際の値(got) が表示されます。戻り値は 1 になります。

```bash
/work $ promtool test rules failed.yaml
Unit Testing:  failed.yaml
  FAILED:
    alertname:NodeNotReady, time:10m0s, 
        exp:"[Labels:{alertname=\"NodeNotReady\", condition=\"Ready\", node=\"mynode\", severity=\"notice\", status=\"true\"} Annotations:{text=\"node mynode is not ready for more than 10 minutes.\"}]", 
        got:"[Labels:{alertname=\"NodeNotReady\", condition=\"Ready\", node=\"othernode\", severity=\"notice\", status=\"true\"} Annotations:{text=\"node othernode is not ready for more than 10 minutes.\"}]"

# 戻り値は 1
/work $ echo $?
1
```

### ヘルプ

```bash
# promtool test のヘルプを実行
/work $ promtool test --help
usage: promtool test <command> [<args> ...]

Unit testing.

Flags:
  -h, --help     Show context-sensitive help (also try --help-long and --help-man).
      --version  Show application version.

Subcommands:
  test rules <test-rule-file>...
    Unit tests for rules.
```


## アラートルールのテスト定義

ユニットテストを定義を行ってみます。まずユニットテストの対象となるアラートルールのファイルを用意します。ここでは、Kubernetes でノードが 10分間 Ready にならない場合のアラートルール (`KubeNotReady`) を例にテストしてみます。ファイル名は `alerts.yaml` としています。

```yaml:alerts.yaml
groups:
- name: kube-state-metrics
  rules:
  - alert: NodeNotReady
    expr: kube_node_status_condition{condition="Ready",status="true"} == 0
    for: 10m
    labels:
      severity: notice
    annotations:
      text: node {{ $labels.node }} is not ready for more than 10 minutes.
```

次にユニットテストの定義を記載します。想定する timeseries の入力 (`input_series`)には特殊な展開記法を使うことができます。(後述)

```yaml:test.yaml
# テスト対象のルールファイルを指定します
rule_files:
- alerts.yaml

# テストケースを記載します
tests:
- interval: 1m                # input series の values の timeseries を記録する間隔

  # 想定する入力の timeseries を定義します
  input_series:
  - series: 'kube_node_status_condition{condition="Ready",status="true", node="mynode"}'
    values: 0+0x15            # 0 の値を 15 回 interval の間隔で繰り返す

  # テストケースを定義します
  alert_rule_test:
  - eval_time: 10m            # 評価するタイミング (テスト起動時を起点とした時間)
    alertname: NodeNotReady   # アラート名
    exp_alerts:               # 期待するアラートの状態
    - exp_labels:             # 期待するアラートのラベル。すべて記載する
        severity: notice
        node: mynode
        condition: Ready
        status: true
      exp_annotations:        # 期待するアラートのアノテーション
        text: "node mynode is not ready for more than 10 minutes."
```

コンテナ内でテストを実行してみます。テストが通ったことが確認できます

```bash
/work $ promtool test rules test.yaml
Unit Testing:  test.yaml
  SUCCESS
```

試しに input_series の value を `1+0x15` に変えて、テストが通らなくなることを確認します。

```bash
# アラートの条件を満たさないのでアラートが上がらない
/work $ promtool test rules test.yaml 
Unit Testing:  test.yaml
  FAILED:
    alertname:NodeNotReady, time:10m0s, 
        exp:"[Labels:{alertname=\"NodeNotReady\", condition=\"Ready\", node=\"mynode\", severity=\"notice\", status=\"true\"} Annotations:{text=\"node mynode is not ready for more than 10 minutes.\"}]", 
        got:"[]"
```

### 試してみての感想

- いくつか既存のアラートのテストを書いてみたところ、複雑な PromQL はやはりテストがあると安心できました
  - 特に他人が書いたアラートだとテストを書くことで意味が理解しやすくなりました
- アラートが上がらない境界値のテストをどうやるか
  - 現状 `exp_alerts: []` とするとアラートが上がらない場合と一致しますが仕様なのかは不明でした

## レコーディングルールのテスト定義

アラートルールと同じような方法でレコーディングもテストできます。ここではノードの idle でない CPU 時間を集計するレコーディングルールを例にテストしてみます。ファイル名は `recording.yaml` としています。

```yaml:recording.yaml
groups:
- name: example-node-exporter-rules
  rules:
  # CPU in use by CPU.
  - record: instance_cpu:node_cpu_seconds_not_idle:rate5m
    expr: sum(rate(node_cpu_seconds_total{mode!="idle"}[5m])) without (mode)
```

アラートルールでは `alert_rule_test` でアラートの期待値を定義しましたが、レコーディングルールでは `promql_expr_test` で PromQL を使ってテストします。

```yaml:recording-test.yaml
rule_files:
- recording.yaml # レコーディングルールのファイル

tests:
- interval: 1m

  # 想定する入力の timeseries を定義します
  input_series:
  - series: 'node_cpu_seconds_total{mode="system", cpu="0"}'
    values: 0+20x10
  - series: 'node_cpu_seconds_total{mode="user", cpu="0"}'
    values: 0+40x10
  - series: 'node_cpu_seconds_total{mode="system", cpu="1"}'
    values: 0+10x10
  - series: 'node_cpu_seconds_total{mode="user", cpu="1"}'
    values: 0+20x10

  # PromQL で値をテストします
  promql_expr_test:
  - eval_time: 10m
    expr: 'instance_cpu:node_cpu_seconds_not_idle:rate5m' # PromQL
    exp_samples: # 期待するする結果
    - labels: 'instance_cpu:node_cpu_seconds_not_idle:rate5m{cpu="0"}'
      value: 1
    - labels: 'instance_cpu:node_cpu_seconds_not_idle:rate5m{cpu="1"}'
      value: 0.5
```

テストが通ることを確認します。

```
/work/recoding $ promtool test rules recording-test.yaml 
Unit Testing:  recording-test.yaml
  SUCCESS
```

試しに input_series の value を変えて、テストが通らなくなることを確認します。

```bash
/work/recoding $ promtool test rules recording-test.yaml 
Unit Testing:  recording-test.yaml
  FAILED:
    expr:'instance_cpu:node_cpu_seconds_not_idle:rate5m', time:10m0s, 
        exp:"{__name__=\"instance_cpu:node_cpu_seconds_not_idle:rate5m\", cpu=\"0\"} 1E+00, {__name__=\"instance_cpu:node_cpu_seconds_not_idle:rate5m\", cpu=\"0\"} 1E+00, {__name__=\"instance_cpu:node_cpu_seconds_not_idle:rate5m\", cpu=\"1\"} 5E-01", 
        got:"{__name__=\"instance_cpu:node_cpu_seconds_not_idle:rate5m\", cpu=\"0\"} 2.3333333333333335E+00, {__name__=\"instance_cpu:node_cpu_seconds_not_idle:rate5m\", cpu=\"0\"} 2.3333333333333335E+00, {__name__=\"instance_cpu:node_cpu_seconds_not_idle:rate5m\", cpu=\"1\"} 5E-01"
```

### 試してみての感想

- レコーディングルールの場合、どのように記録されるかがテストの期待値として記載できわかりやすかったです

## input_series の表記方法

`input_series` の `values` は値を直接記載する表記と、展開記法を使う表記の 2つの表記方法があります。

### 直接表記

下記のように values に数値を並べる方式です。数値は interval の間隔で timeseries が記録されます (scrape 間隔に近いイメージ)。下記の例だと、最初は 1 分間隔で `0` が 5回 、その後 1分間隔で `1` が 5回と 10 分間のメトリクスの値となります。

```yaml
- interval: 1m

  input_series:
  - series: 'kube_node_status_condition{condition="Ready",status="true", node="mynode"}'
    values: 0 0 0 0 0 1 1 1 1 1
```

### 展開記法
同じ値もしくは一定の値で増加していく場合の値を展開記法で表記できます。前述の値は次のように `0+0x5` と `1+0x5` の 2つで表現することができます。

```yaml
- interval: 1m

  input_series:
  - series: 'kube_node_status_condition{condition="Ready",status="true", node="mynode"}'
    values: 0+0x5 1+0x5
```

[ドキュメント](https://github.com/prometheus/prometheus/blob/master/docs/configuration/unit_testing_rules.md#series) に記載がありますが、下記のような表記になっています。増加値は counter タイプのメトリクスに便利です。

```
a = 初期値、b = 増加値、 c = 回数 とすると下記のように展開されます (x は繰り返しの意味)

'a+bxc' : 'a a+b a+(2*b) a+(3*b) … a+(c*b)'
'a-bxc' : 'a a-b a-(2*b) a-(3*b) … a-(c*b)'

例:
'-2+4x3' : '-2 2 6 10'
'1-2x4'  : '1 -1 -3 -5 -7'

0+0x5 の場合増加値が 0 なので: '0 0 0 0 0' と 0 が5回になります
```

## 参考: PromQL のテスト用 DSL

実装の PR [Unit testing for rules by codesome · Pull Request #4350 · prometheus/prometheus · GitHub](https://github.com/prometheus/prometheus/pull/4350) を見る、ユニットテストの機能はコア部分の `unittest.go` が 500 行以下とコンパクトに実装されています。

これは Prometheus の内部で使われている PromQL 用 DSL の仕組み ([promql/test.go](https://github.com/prometheus/prometheus/blob/master/promql/test.go)) を使っているからです。`1+0x10` などの独特の記法もこの DSL の表記の仕方に由来しています。[Prometheus で使われている テスト用 DSL]( https://github.com/prometheus/prometheus/tree/master/promql/testdata)は PromQL のテストの仕方の参考になるかと思います。

PromQL のテスト用 DSL の例 (`increase()`関数のテストの一部)

```
# Tests for increase().
load 5m
	http_requests{path="/foo"}	0+10x10
	http_requests{path="/bar"}	0+10x5 0+10x5

# Tests for increase().
eval instant at 50m increase(http_requests[50m])
	{path="/foo"} 100
	{path="/bar"}  90
```

## まとめ

- `promtool test rules` でアラートルール、レコーディングルールのテストが実行できるようになりました
- PromQL のテスト用 DSL がベースとなっています
- メトリクスの想定入力値を書いて、それに対して期待するアラートもしくは PromQL の結果をテストできます
- 複雑なアラートやレコーディングルールはテストを書いてみると安心感があります


## 参考
- [prometheus/unit_testing_rules.md at master · prometheus/prometheus · GitHub](https://github.com/prometheus/prometheus/blob/master/docs/configuration/unit_testing_rules.md)