PrometheusからPodのリソース使用量のデータをPythonでcsvにエクスポートする

IBM Cloud Privateに同梱のPrometheusから、データをPythonでcsvにエクスポートしてみたメモ。
古き良きシステムで、Pod毎のCPU使用率などの性能情報レポートを、エクセルで作成して報告したい場合を想定。

ICP v3.1.0で確認。Macでコマンドを実行しているため、Linuxでは`curl`や`date`などの挙動が若干違うかもしれない。

## Prometheusクエリー

この記事ではPrometheusクエリーについては触れないが、以下のリンクを見ておくことがおすすめ。

- [Prometheusクエリ道場](https://qiita.com/t_nakayama0714/items/1231751e72804d52c20a)

例えば、NamespaceのPod毎のCPU使用率を取得するには以下のようなクエリーが使われる。

```
sum(rate(container_cpu_usage_seconds_total{namespace="default"}[5m])) by (pod_name) * 100
```

このクエリーは順を追ってみていくと以下のようになっている。[データの型](https://prometheus.io/docs/prometheus/latest/querying/basics/#expression-language-data-types)と[rate()関数](https://prometheus.io/docs/prometheus/latest/querying/functions/#rate)がわかりにくいけれどもポイント。

- `container_cpu_usage_seconds_total`はコンテナが使用したCPU時間の積算値で、このクエリーは複数のコンテナのあるタイムスタンプにおけるデータが返ってくる（Instant vector型のデータ）
- `container_cpu_usage_seconds_total{namespace="default"}`のように、ラベルでデータを絞ることができる（Instant vector型のデータ）
- `container_cpu_usage_seconds_total{namespace="default"}[5m]`とすることで、データの期間を指定していて、複数のコンテナの複数のタイムスタンプに渡るデータが返ってくる（Range vector型のデータ）
- `rate(container_cpu_usage_seconds_total{namespace="default"}[5m])`とすることで、指定期間の1秒当たりの平均の増分を出している（Instant vector型のデータ）
  - 1秒当たりCPU使用時間が0.5秒増えていたとすると、CPU使用率は0.5（50%）ということになる
- `sum(rate(container_cpu_usage_seconds_total{namespace="default"}[5m])) by (pod_name)`で複数のデータをPod毎に合計している（Instant vector型のデータ）
  - Pod内には複数のコンテナがいる場合があるので、Pod毎にこのような合計をしている

## Grafanaからのエクスポート

Grafanaダッシュボードから、パネル単位でのcsvエクスポートがGUIで可能。ダッシュボードで、データの期間と、ダッシュボードによっては設定されている変数（以下の例ではintervalとNamespace）を指定して欲しいデータが表示されている状態にした後、パネルのタイトル部分をクリックすることで、csvでのエクスポートができる。すでにブラウザにダウンロード済みのjsonデータをブラウザ上のJavaScriptがcsvに変換してくれているっぽい。

<img width="1349" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/86449/93f1a025-44dc-1b73-04de-b232c868945b.png">

エクスポート時にはいくつかの設定も可能。

<img width="496" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/86449/1857dc5d-32b3-3b75-de4b-dfeb16e02cd9.png">

## curl

続いて`curl`でデータを取得してみる。

### 認証

Prometheus APIにアクセスするにはトークンが必要となる。

- [コンポーネント API コマンドまたは管理 API コマンドを実行するための準備](https://www.ibm.com/support/knowledgecenter/ja/SSBS6K_3.1.0/apis/access_api.html)
- [Prometheus API](https://www.ibm.com/support/knowledgecenter/ja/SSBS6K_3.1.0/apis/prometheus.html)

IDトークンが必要と思いきや、Prometheus APIへのアクセスに必要なのはIDトークンではなくアクセス・トークンのほうなので注意。

トークンはローカルで`cloudctl login`済みであれば`cloudctl tokens`で取得できる。

```shell
ACCESS_TOKEN=$(LANG=C cloudctl tokens | grep "Access token:" | awk '{print ($4)}')
```

またはユーザーとパスワードを渡してAPIから取得することもできる。

```shell
USERNAME="admin"
PASSWORD="admin"
ACCESS_TOKEN=$(curl -s -k -H "Content-Type: application/x-www-form-urlencoded;charset=UTF-8" \
  -d "grant_type=password&username=${USERNAME}&password=${PASSWORD}&scope=openid" \
  https://mycluster.icp:8443/idprovider/v1/auth/identitytoken | jq -r '.access_token')
```

### クエリーの確認

GrafanaのパネルのタイトルでEditをクリックすることで、どのようなPrometheusクエリーが使われているかが確認できる。`$interval`と`$namespace`はダッシュボードに定義している変数の部品から渡される。

<img width="1361" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/86449/010607e2-e35b-31fc-90ca-6eb2232d59cc.png">

また、Query Inspectorを開くと、どのようなHTTPリクエストなのかも確認できる。

<img width="1359" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/86449/f137f520-3ef1-7572-3412-08891035026e.png">

GETリクエストのパラメータには`query`、`start`、`end`、`step`を渡す必要がある。`start`と`end`はエポックタイムで、`step`はデータポイントの間隔。

### curlの実行

`curl`でのリクエストは以下のように行う。`-H`オプションで認証のためのヘッダーをつける。`--data-urlencolde`でデータをエンコードさせ、`-G`オプションをつけることでPOSTではなくGETさせる。

```shell
curl -k -s -G -H "Authorization:Bearer $ACCESS_TOKEN" \
  https://mycluster.icp:8443/prometheus/api/v1/query_range \
  --data-urlencode 'query=sum(rate(container_cpu_usage_seconds_total{namespace="default"}[5m])) by (pod_name) * 100' \
  --data-urlencode "start=1547517120" \
  --data-urlencode "end=1547527950" \
  --data-urlencode "step=30" | jq .
```

パラメータを変数にして前に出すと以下。

```shell
NAMESPACE="default"
INTERVAL="5m"
QUERY="sum(rate(container_cpu_usage_seconds_total{namespace=\"${NAMESPACE}\"}[${INTERVAL}])) by (pod_name) * 100"
START=$(date -v -1d +%s)  # 1日前の時刻をエポックタイムで取得
END=$(date +%s)           # 今の時刻をエポックタイムで取得
STEP=30
curl -k -s -G -H "Authorization:Bearer $ACCESS_TOKEN" \
  https://mycluster.icp:8443/prometheus/api/v1/query_range \
  --data-urlencode "query=${QUERY}" \
  --data-urlencode "start=${START}" \
  --data-urlencode "end=${END}" \
  --data-urlencode "step=${STEP}" | jq .
```

実行すると以下のようにjsonで返ってくる。

```shell-session
$ curl -k -s -G -H "Authorization:Bearer $ACCESS_TOKEN" \
>   https://mycluster.icp:8443/prometheus/api/v1/query_range \
>   --data-urlencode "query=${QUERY}" \
>   --data-urlencode "start=${START}" \
>   --data-urlencode "end=${END}" \
>   --data-urlencode "step=${STEP}" | jq .
{
  "status": "success",
  "data": {
    "resultType": "matrix",
    "result": [
      {
        "metric": {
          "pod_name": "infra-test-nodeport-cust-0"
        },
        "values": [
          [
            1547537972,
            "2.5491993487228775"
          ],
          [
            1547538002,
            "2.300661640484626"
          ],
（省略）
```

## Python

jsonをcsvにするのはシェルスクリプトではちょっと大変なので、Pythonを使ってやってみる。

### コード作成

以下のような手続き的なコードを作成。よいコードの書き方はまだわからないので「[退屈なことはPythonにやらせよう](https://amzn.to/2CnZ55G)」な思想。

```python:pod_cpu_exporter.py
import argparse
import collections
import csv
import datetime
import logging
import re
import subprocess

import pprint
import requests
import urllib3


formatter = '%(asctime)s %(name)-12s %(levelname)-8s %(message)s'
logging.basicConfig(level=logging.WARNING, format=formatter)
logger = logging.getLogger(__name__)


# 警告を非表示にする
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


# コマンド引数の処理
parser = argparse.ArgumentParser(description='PodのCPU使用率をcsvに出力します。')
parser.add_argument('-f', '--filename',
                    action='store',
                    type=str,
                    help='出力先のファイル名を指定します')
parser.add_argument('-n', '--namespace',
                    action='store',
                    type=str,
                    default='default',
                    help='Namespaceを指定します')
parser.add_argument('--interval',
                    action='store',
                    type=str,
                    default='5m',
                    help='CPU使用率計算に使用するデータの間隔を指定します（例）1h、5m')
parser.add_argument('--start',
                    action='store',
                    type=str,
                    help='データの開始時間を指定します（例）20190101-1000')
parser.add_argument('--end',
                    action='store',
                    type=str,
                    help='データの終了時間を指定します（例）20190102-1000')
parser.add_argument('--step',
                    action='store',
                    type=int,
                    help='データポイントの間隔（秒）を指定します')
args = parser.parse_args()

filepath = args.filename
namespace = args.namespace
interval = args.interval
step = args.step
logger.debug('filepath: {}'.format(filepath))
logger.debug('interval: {}'.format(interval))
logger.debug('step: {}'.format(step))

# 引数の開始時間と終了時間をUNIX時刻に変換
start_str = args.start
end_str = args.end
start_dt = datetime.datetime.strptime(start_str, '%Y%m%d-%H%M')
end_dt = datetime.datetime.strptime(end_str, '%Y%m%d-%H%M')
start_unix = start_dt.timestamp()
end_unix = end_dt.timestamp()
logger.debug('start_dt: {}'.format(start_dt))
logger.debug('end_dt: {}'.format(end_dt))
logger.debug('start_unix: {}'.format(start_unix))
logger.debug('end_unix: {}'.format(end_unix))

# サブプロセスでコマンドを実行し、結果からアクセストークンを抽出
completed_process = subprocess.run(['cloudctl', 'tokens'], stdout=subprocess.PIPE)
result_str = completed_process.stdout.decode('utf-8')
match = re.search(r'(.*)\s+Bearer\s+(.*)', result_str)
access_token = (match.group(2))
logger.debug('access_token: {}'.format(access_token))

# Prometheusクエリー
# 指定のNamespaceの、指定のintervalで算出したPod毎のCPU使用率を取得する
# sum(rate(container_cpu_usage_seconds_total{namespace="$namespace"}[$interval])) by (pod_name) * 100
query = 'sum(rate(container_cpu_usage_seconds_total{{namespace="{}"}}[{}])) ' \
        'by (pod_name) * 100'.format(namespace, interval)
logger.debug('query: {}'.format(query))

# リクエスト
url = 'https://mycluster.icp:8443/prometheus/api/v1/query_range'
headers = {'Authorization': 'Bearer {}'.format(access_token)}
params = {'query': query,
          'start': start_unix,
          'end': end_unix,
          'step': step}
logger.debug('url: {}'.format(url))
logger.debug('headers: {}'.format(headers))
logger.debug('params: {}'.format(params))


# リクエストを実行
response = requests.get(url, verify=False, headers=headers, params=params)
response.raise_for_status()
logger.debug('response: {}'.format(response))

# レスポンスは以下のようなデータ
# pprint.pprint(response.json())
# {'data': {'result': [{'metric': {'pod_name': 'infra-test-nodeport-cust-0'},
#                       'values': [[1547528400, '2.64939279124293'],
#                                  [1547532000, '2.5820633706497045'],
#                                  [1547535600, '2.562417181158173'],
#                                  [1547539200, '2.4563804665536724'],

# 意味のない部分を取り除いて中のリストを取り出す
results = response.json()['data']['result']

# 取り出したのは以下のようなデータ
# pprint.pprint(results)
# [{'metric': {'pod_name': 'infra-test-nodeport-cust-0'},
#   'values': [[1547528400, '2.64939279124293'],
#              [1547532000, '2.5820633706497045'],
#              [1547535600, '2.562417181158173'],
#              [1547539200, '2.4563804665536724'],
#
# このデータを時刻をキーにして以下のような辞書にまとめる
#
# {1547464889.632: {'infra-test-nodeport-cust-0': '3.1518179124293577',
#                   'infra-test-nodeport-cust-1': '1.530811175762711',
#                   'infra-test-nodeport2-cus-0': '3.0063879859887037',
#                   'infra-test-nodeport2-cus-1': '1.5241500936723127'},
#  1547468489.632: {'infra-test-nodeport-cust-0': '3.161739384943495',
#                   'infra-test-nodeport-cust-1': '1.5393470943785368',
#                   'infra-test-nodeport2-cus-0': '2.8831145322598943',
#                   'infra-test-nodeport2-cus-1': '1.578976048757047'},

# 時刻毎のデータの辞書を用意する
time_series = collections.defaultdict(dict)

# Pod名のSetを用意する
pod_names = set()

for result in results:
    # Pod名を取り出してSetに入れておく
    pod_name = result['metric']['pod_name']
    pod_names.add(pod_name)
    for value in result['values']:
        # timestampを辞書のキーにすることで同じtimestampのデータをまとめる
        # defaultdictを使うことでキーがなくてもKeyErrorにならない
        time_series[value[0]][pod_name] = value[1]

# pprint.pprint(time_series)

# csvのヘッダーは時刻とPod名にする
fieldnames = ['timestamp']
fieldnames.extend(pod_names)

# csvファイルに保存する
with open(filepath, 'w') as csv_file:

    writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
    writer.writeheader()

    # 辞書から時間毎のデータを取り出してループする
    for timestamp, values in time_series.items():
        # 行に時間の列を追加
        row = {'timestamp': datetime.datetime.fromtimestamp(timestamp)}
        # valuesは以下のような辞書
        # {'infra-test-nodeport-cust-0': '2.467225521553685',
        #  'infra-test-nodeport-cust-1': '1.5932590068361583',
        #  'infra-test-nodeport2-cus-0': '2.2811341803954917',
        #  'infra-test-nodeport2-cus-1': '1.6517850743220521'},
        # 事前に格納したPod名のリストの方でループする
        for pod_name in pod_names:
            try:
                row[pod_name] = values[pod_name]
            except KeyError:
                # valuesにこのPodのデータがないときはKeyErrorが発生するので空データを入れる
                row[pod_name] = ''
        writer.writerow(row)
```

### 実行例

`request`モジュールが必要。

```
pip install requests
```

サブプロセスで`cloudctl token`を実行してトークンを取得しているので、`cloudctl`コマンドが必要で、事前にログインしておく必要がある。

実行すると以下のようにcsvが取得できる。

```shell-session
$ python export_pod_cpu.py --help
usage: export_pod_cpu.py [-h] [-f FILENAME] [-n NAMESPACE]
                         [--interval INTERVAL] [--start START] [--end END]
                         [--step STEP]

PodのCPU使用率をcsvに出力します。

optional arguments:
  -h, --help            show this help message and exit
  -f FILENAME, --filename FILENAME
                        出力先のファイル名を指定します
  -n NAMESPACE, --namespace NAMESPACE
                        Namespaceを指定します
  --interval INTERVAL   CPU使用率計算に使用するデータの間隔を指定します（例）1h、5m
  --start START         データの開始時間を指定します（例）20190101-1000
  --end END             データの終了時間を指定します（例）20190102-1000
  --step STEP           データポイントの間隔（秒）を指定します
$ python export_pod_cpu.py -f test.csv -n default --interval 5m --start 20190115-1600 --end 20190116-1600 --step 600
$ cat test.csv
timestamp,infra-test-nodeport-cust-1,infra-test-nodeport2-cus-1,infra-test-nodeport2-cus-0,infra-test-nodeport-cust-0
2019-01-15 16:00:00,1.7765650066665255,1.5227466104164478,2.4642478804166026,2.7171226995831903
2019-01-15 16:10:00,1.4900129837500724,1.4548672362502657,2.3264455262498513,2.7998607379167124
2019-01-15 16:20:00,1.7629794254168016,1.6523557370834396,2.231762218333415,2.040916205000182
2019-01-15 16:30:00,1.87828389291667,1.8159218674998103,1.7364743683333472,2.4042730716670726
2019-01-15 16:40:00,1.6692312762499266,1.7964510670833533,2.264148609108464,2.300661640484626
2019-01-15 16:50:00,1.7293341049999826,1.7319731500000066,1.8862790512499334,2.091235875833111

（省略）

2019-01-16 15:00:00,1.4205530700000204,1.450829464166835,2.229421241249838,2.6200906879167483
2019-01-16 15:10:00,1.207286078750182,1.4032802437501837,1.8004293182339135,2.120460568511992
2019-01-16 15:20:00,1.2547518466667875,1.5286131908334255,1.8340893729164995,2.258688518749826
2019-01-16 15:30:00,1.2122690649999868,1.3929129920832868,2.2814286763049063,2.8766292608645045
2019-01-16 15:40:00,1.4557575473232511,1.2478161367336864,1.8957547083334705,2.535229864166316
2019-01-16 15:50:00,1.0475173487499962,1.2800040254167773,1.750940527916403,2.7946370483334704
2019-01-16 16:00:00,1.4323884024997824,1.3961431675001752,2.2708808758333516,2.626497514166885
$
```

### TODO

- 日時をローカル時刻で指定してるが、UTCで指定できる方が便利かもしれない
- CPUしかやってないのでメモリー使用量も必要
- `openpyxl`モジュールとかを使ってエクセルにするところまでやってもよさそう