Prometheus 2.0 のストレージ (TSDB) の構造

Prometheus 2.0 の目玉であるリライトされたストレージ [prometheus/tsdb](https://github.com/prometheus/tsdb) の構造と何が変わったかを説明します。

## 要約

Prometheus 2.0 ではストレージが完全に書き直され、今までの課題について改善され大きくパフォーマンスが向上しました :tada: 

- 大量のファイルができることによるパフォーマンスの問題
  - 時間の範囲ごとに block という単位でまとめて管理されるようになった
- メモリ管理の問題
  - mmap によってカーネル側のキャッシュ管理になった
- 歯抜データによるインデックスの問題
  - 転置インデックスが導入された

Prometheus v1.6.3 と v2.0.0 を 24 時間動かしたデータを比較したところ、[公式アナウンス](https://prometheus.io/blog/2017/11/08/announcing-prometheus-2-0/)の通り、CPU・メモリ共にパフォーマンスが大きく向上していることが確認できました。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/100377/608a5038-c580-2fd1-40f0-47776785e2c8.png)


## 今まで (Storage v2) の課題

今までの Prometheus のストレージは内部的には Storage v2 と呼ばれており、Time Series ごとにファイルを作り、Level DB で index を管理する仕組みでした。Time Series はユニークなラベルの組み合わせごとに作られ、例えば `node_cpu{instance="x", cpu="cpu0"}` と `node_cpu{instance="x", cpu="cpu1"}` は別の Time Series となります。(この場合cpu ラベルが違う)

データファイルの構成は以下のようになっています。

```bash
.
├── 00 # time series の fingerprint (SHA 256) の先頭 2 文字がディレクトリ名
│   ├── 000011da572e51.db # time series ごとのデータファイル。ファイル名は fingerprint の先頭 2 文字目移行
│   ├── 00002c17915cc5.db
│   ├── 0000b1da572e51.db
│   ├── ....
│   └── fffffd7fd348a0.db
├── 01
├── .....
├── ff # 00 ~ ff までのディレクトリが必要に応じて作られる
│   ├── ....
│   └── ffff6136313c3f.db
├── archived_fingerprint_to_timerange # level DB の index
├── archived_fingerprint_to_metric    # level DB の index
├── labelname_to_labelvalues          # level DB の index
├── labelpair_to_fingerprints         # level DB の index
└── heads.db # head データ
```


Storage v2 には下記のような課題がありました。

- Time Series ごとにファイルができるため大量の小さいファイルができる
  - inode の限界の問題
  - SSD の Write amplification によるパフォーマンス低下
  - retention 期間を過ぎたデータの削除の CPU 負荷が高い
- インデックスの問題
  - Kubernetes のような環境では Pod が頻繁に再作成され非アクティブな time series が多くできてしまう
  - この問題は Churn と呼ばれている
  - 参考: https://qiita.com/kkohtaka/items/7e99e9eaadbbad6cbb2b
- メモリ管理の問題
  - チャンクをオブジェクトとして管理しているので GC のような処理が必要

## 新しい prometheus/tsdb の構成

新しい prometheus/tsdb では一定の時間ごとの block という区切りで Time Series をまとめてて扱うようになりました。block の中の `chunks` というディレクトリ以下のファイル (例 `000001`) が実際に Time Series のデータが入っているファイルとなり、512 MB ごとにファイルが分割され管理されています。

<img src="https://qiita-image-store.s3.amazonaws.com/0/100377/179872b7-651b-64c3-61d7-dde6b8e86a0c.png" width="1382">


### ブロック

ブロックはディレクトリとして管理され一定の時間枠の Time Series を保持しています。時間枠ごとに複数のディレクトリができていき、後述のコンパクションという処理で一定期間で大きなブロックに自動的にまとめられています。ブロックごとにインデックスやメタデータを持っているのでブロックはそれぞれが小さなデータベースと考えられます。ディレクトリ名は [ULID](https://github.com/oklog/ulid) (Universally Unique Lexicographically Sortable Identifier) というソート可能な ID となっています。下記のファイルで構成されています。

ファイル | 用途
--- | ---
chunks/ | Time Series が保存されているディレクトリ。512 MB ごとに分割。参考: [Chunks Disk Format](https://github.com/prometheus/tsdb/blob/master/Documentation/format/chunks.md)
index | ラベルと Time Series の転置インデックス。参考: [Index Disk Format](https://github.com/prometheus/tsdb/blob/master/Documentation/format/index.md)
meta.json | メタデータ。ブロックの保持データの開始時間、終了時間などが記録されている。
tombstones | 削除済みデータの情報。参考: [Tombstones Disk Format](https://github.com/prometheus/tsdb/blob/master/Documentation/format/tombstones.md)

```meta.json
{
    "version": 1,
    "ulid": "01BZ033BRP0EQBFW51QKD1TT9Y",
    "minTime": 1510718400000, # 保持データの開始時間
    "maxTime": 1510747200000, # 保持データの終了時間
    "stats": {
        "numSamples": 21396291,
        "numSeries": 176339,
        "numChunks": 325936
    },
    "compaction": {
        "level": 3, # コンパクションのレベル
        "sources": [ # コンパクションした元のディレクトリ
            "01BYZ7MCSVJHG5D17M4YZF0JD1",
            "01BYZEG4434G74SCPWJGQ2TDEQ",
            "01BYZNBVBAJ34NWT3CWRFNB43Z",
            "01BYZW7JJ2YQVKM1WX08FDQ7ZN"
        ]
    }
}
```


### wal (Write Ahead Log)

最新のデータだけは書き込みがされるため、[head](https://github.com/prometheus/tsdb/blob/master/head.go) として特別な扱いがされていて、`wal` (Write Ahead Log) というディレクトリで定期的に永続化されています。

一定時間([2.0.0 時点での実装では 3時間](https://github.com/prometheus/tsdb/blob/a00d700d48ec14d46c270eff928dfe17187d3b57/db.go#L334))が経過すると block に変換されます。

## mmap によるキャッシュ管理

新しい TSDB では Time Series のデータファイルは mmap システムコールを使って[メモリ上の Byte Slice にマッピングされています](https://github.com/prometheus/tsdb/blob/a00d700d48ec14d46c270eff928dfe17187d3b57/block.go#L396)。mmap でマッピングされているため、ファイルのキャッシュ管理はカーネルを通して透過的に行われます。カーネル側で必要に応じてキャッシュは解放されるため、今までのヒープメモリによる管理のように prometheus がキャッシュのためにメモリを専有することがなくなりました。これにより `storage.local.target-heap-size` によるメモリサイズのチューニングも不要になりました。

インデックスはラベルと chunk reference の転置インデックスとなっており、chunk reference は in-file offset(32 bits) + sequence (32 bits) で構成されるためメモリからダイレクトにアクセスできるようになっています。

参考: [Chunk のフォーマット](https://github.com/prometheus/tsdb/blob/master/Documentation/format/chunks.md)

```
┌────────────────────────────────────────┬──────────────────────┐
│ magic(0x85BD40DD) <4 byte>             │ version(1) <1 byte>  │
├────────────────────────────────────────┴──────────────────────┤
│ ┌───────────────┬───────────────────┬──────┬────────────────┐ │
│ │ len <uvarint> │ encoding <1 byte> │ data │ CRC32 <4 byte> │ │
│ └───────────────┴───────────────────┴──────┴────────────────┘ │
└───────────────────────────────────────────────────────────────┘
```

下記は Prometheus v1.6.3 と v2.0.0 を 24 時間のメモリの CPU と メモリ (RSS)を比較した結果になります。共に Kubernetes 上で動かしており、ターゲットなどの設定は同一になります。ともに大きくパフォーマンスが向上しています。v1.6.3 はヒープメモリでキャッシュを管理しているため、メモリ使用量 (RSS) が増加していくのに比べ、v2.0.0 では mmap によるカーネル側の管理のためメモリのサイズはほぼ一定であることがわかります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/100377/608a5038-c580-2fd1-40f0-47776785e2c8.png)


## データのコンパクト化 (compaction)

クエリを投げる際の効率化のため、block のデータは一定の期間ごとにコンパクションという処理で大きなサイズの block にまとまられます。コンパクションのレベルは内部的には TSDB のオプションとして配列で渡されており、[Prometheus 2.0.0 の実装](https://github.com/prometheus/prometheus/blob/a51c500e30e96df4026282c8a4f743cf6a8827f1/storage/tsdb/tsdb.go#L134)ではデフォルト 2時間で 3倍ずつ増加していく実装になっています。この初期値(2時間)は `--storage.tsdb.max-block-duration` で指定可能です。最大の時間は retention period の 10% がデフォルトで `--storage.tsdb.max-block-duration`で指定可能です。

```
# 3 倍ずつ 10 段階増えていく
rngs := tsdb.ExponentialBlockRanges(int64(time.Duration(opts.MinBlockDuration).Seconds()*1000), 10, 3)

# 秒単位の結果
[7200000 21600000 64800000 194400000 583200000 1749600000 5248800000 15746400000 47239200000 141717600000]

# 時間に直したもの
[2h, 6h, 18h, 2d6h, 6d18h 20d6h, 60d18h, 182d6h, 546d18h, 1640d6h]
```

<img src="https://qiita-image-store.s3.amazonaws.com/0/100377/10145394-7bd0-f46c-dba3-b3f63265bc1a.png" width="960">


イメージ的には上記図のような処理になります。コンパクションの次のレベルの時間枠(2h, 6h, 18h と指数的に増えていく)でブロックをグルーピングしてコンパクト化されます。

Prometheus 2.0.0 時点での実装ではコンパクションの際に[時間枠の倍数でアライン](https://github.com/prometheus/tsdb/blob/master/compact.go#L237)してからその時間枠をグルーピングする関係で単純に 2h * 3 ブロックが 6h にコンパクト化されるだけでなく、複数の組み合わせのグルーピングがありえます。コンパクション処理のテストコード [compact_test.go](https://github.com/prometheus/tsdb/blob/a00d700d48ec14d46c270eff928dfe17187d3b57/compact_test.go#L150) を参考にテストケースを書くと以下のようになります。

```go
compactor, err := NewLeveledCompactor(nil, nil, []int64{
    7200000,  // デフォルトの時間枠のブロックを 3 レベル分を設定
    21600000,
    64800000,
}, nil)

// 中略

{
    metas: []dirMeta{ // ブロックのメタ情報の配列
        //             開始時間        終了時間 (2時間後)
        metaRange("1", 1510704000000, 1510711200000, nil), // 開始時間が時間枠 21600000 (6h) の倍数
        metaRange("2", 1510711200000, 1510718400000, nil),
        metaRange("3", 1510718400000, 1510725600000, nil),
        metaRange("4", 1510725600000, 1510732800000, nil),
    },
    // コンパクト化される結果
    expected: []string{"1", "2", "3"}, // 時間枠の倍数ピッタリの場合だけ 1, 2, 3 がコンパクト化される。
},
{
    metas: []dirMeta{
        metaRange("1", 1510704000001, 1510711200001, nil), // 時間枠 21600000 の倍数を超えている場合
        metaRange("2", 1510711200001, 1510718400001, nil),
        metaRange("3", 1510718400001, 1510725600001, nil),
        metaRange("4", 1510725600001, 1510732800001, nil),
    },
    expected: []string{"1", "2"}, // 3 は時間枠をはみ出すため、1, 2 だけがコンパクト化される。
},
{
    metas: []dirMeta{
        metaRange("1", 1510703999999, 1510711199999, nil), // 時間枠 21600000 の倍数を下回る場合
        metaRange("2", 1510711199999, 1510718399999, nil),
        metaRange("3", 1510718399999, 1510725599999, nil),
        metaRange("4", 1510725599999, 1510732799999, nil),
    },
    expected: []string{"2", "3"}, // 1 が時間枠で連続しないため、2, 3 がコンパクト化される
},

{
    metas: []dirMeta{
        metaRange("1", 1510718400000, 1510725600000, nil), // 開始時間が時間枠の倍数でない
        metaRange("2", 1510725600000, 1510732800000, nil),
        metaRange("3", 1510732800000, 1510740000000, nil),
        metaRange("4", 1510740000000, 1510747200000, nil),
    },
    expected: []string{"2", "3", "4"},  // アラインの関係で 1 はコンパクト化されない
},
```



## 参考
- https://fabxc.org/blog/2017-04-10-writing-a-tsdb/
- https://github.com/prometheus/tsdb/tree/master/Documentation/format
- https://www.percona.com/live/e17/sites/default/files/slides/Evolution%20of%20the%20Prometheus%20TSDB%20-%20FileId%20-%20115511.pdf