Datadogを使ってサーバのリソーストレンドを分析する

この記事は [リクルートライフスタイルアドベントカレンダー2017](https://qiita.com/advent-calendar/2017/recruitlifestyle) の8日目です。
ホットペッパービューティーでインフラみてます。[@_tacmac](https://twitter.com/_tacmac)です。

ホットペッパービューティーも時代の変化に伴い、多くの人がサービスを使うようになってくれ、ますますサーバのスケールアップ・スケールアウト戦略が重要になってきました。サービスが急成長する際には、「いつ・どのスペックのサーバにスケールアップ・アウトするか?」という観点を、インフラ屋は気にしたいところです。
これまでのインフラ運用では、定常監視を人の手で行ない、リソーストレンドを分析していました。この記事は、人の手で行なっていた確認作業を、ほとんど自動化・対策が必要なタイミングだけ把握できる状態にしたよ、という旨の内容になります。

## どんなことをしたのか?
前提として「手っ取り早く、あんまり難しいことをせずに良い感じのトレンドを知ること」をゴールとし、主務の片手間で実装を始めました。
ざっくりまとめると、こんな感じのことをしました。

1. API越しに各サーバのリソース情報を時系列で取得
2. 取得したデータを加工・分析し、以下のトレンド値を求める
    - 1週間後の特定のリソース項目の予想値を導く
    - 予想値をもとに、3ヶ月後にはスケールアップなりの対策が必要なサーバを特定
3. 週次で上記トレンド値をSlackに報告してくれるBotの作成

では、順を追って、みていきましょう。

### 1. API越しに各サーバのリソース情報を時系列で取得
監視基盤にDatadogを利用しているのですが、この犬の良いところは、API越しにリソース値を細かく取得できるところです。
今回は、Slack連携用Hubotも用意しようと思っていたので、Datadogのリソースを取得するためのSDKであるdogapiを用いて、JavaScriptでリソース値の取得・分析するツールを設計・実装しました。
実装したものの全体像はこんな感じです。
<img width="1492" alt="構成図" src="https://qiita-image-store.s3.amazonaws.com/0/34144/46a1ea13-09da-e6c5-4b33-474334cfec38.png">


※  当記事ではHubotやdogapiの詳細解説はしておりません。別途ぐぐっていただけると 🙇

### 2. 取得したデータを加工・分析し、トレンド値を求める
全体像がほんわかできたので、次は分析アプリケーションの実装です。具体的には、Hubot部分と、分析アプリケーション本体に分けて実装しました。なぜそうしたかというと、今後以下のような要望が出てくると考えたためです。

- 主要項目（CPU使用率やメモリ使用率）以外のサイト要望に沿ったリソースの分析
- Slack以外からの利用

そして、要となる分析ロジックですが、加工手順は以下のようにしました。
リソース値は好きなように選ぶことができますが、具体例としてCPU使用率を使います。

1. リソース値の取得（dogapiからAPI投げると、指定した期間のリソース値を返してくれる）
   - ex) 12週間分のCPU使用率（過去2016回刻みのCPU使用率）を取得
2. 後続の計算を楽にするために、1日ごとのデータとして数値をまるめる（中央値にする）
   - ex) 2016回刻みのデータを 12刻みにまるめる。すると3ヶ月分の週次平均値になる
3. 指数移動平均法（EMA）を使って、12地点のCPU使用率から13地点目（来週）の予想値を作成

本体コードはこんな感じです。
どうしてもデータ取得時の構造が複雑であれがあれでつらかったです。

```js:analyze_client.js
const dogapi = require('dogapi');
import EMA from './ema.js';
import ss from 'simple-statistics';

export default class AnalyzeStorageClient {

  constructor(options) {
    this.term = 3; // 対象期間（単位：月）
    this.now = parseInt(new Date().getTime() / 1000);
    this.then = this.now - (60 * 60 * 24 * 7 * 4 * this.term);
    if(Object.values(options).includes(null || undefined)) {
      throw new Error("DataDog APIに必要な情報が揃っていません");
    } else {
      dogapi.initialize({
        api_key: options['apiKey'],
        app_key: options['appKey']
      });
    };
  };

  // IN: 特定ホスト名
  // OUT: 特定ホストCPU使用率のフル稼働状態になる時期の予測値(Promise)
  getCpuUsageBusyTime(host) {
    const query = `system.processes.cpu.pct{host:${host}}`;
    return new Promise((resolve) => {
      dogapi.metric.query(this.then, this.now, query, (err, res) => {
        let result = processLogic(host, res, this.term);
        resolve(result);
      });
    });
  };

// IN: DataDogAPI Queryの結果, term
// OUT: [ホスト名, 予想枯渇時期, 次週予測値]
function processLogic(host, res, term) {
  let dataList = normalizeData(res.series[0].pointlist);
  let convertedDataList = convertDataAsPercent(dataList);
  let compressedList = compressDataList(convertedDataList, term);
  let dataMetrics = extractMetrics(compressedList);
  let predictedValue = dataMetrics[1].toFixed(1);
  let deadline = calcDryUpTime(dataMetrics);
  let result = [host, deadline, predictedValue];
  return result;
};

// IN:  DataDogから取得した一定期間分のリソース値
// OUT: 取得したリソース値の最新・最古のデータを配列として返す
function extractMetrics(dataList) {
  let ema = new EMA(dataList);
  let emaResult = ema.calcPredictedAllVal();
  let predictedValue = emaResult[emaResult.length - 1];
  let result = [dataList[dataList.length - 1], predictedValue];
  return result;
};

// IN: DataDogAPIから受け取ったデータリスト
// OUT: JSON.parseの結果
function normalizeData(dataList) {
  let ary = [];
  for(var i = 0; i < dataList.length; i++) {
    ary.push(JSON.parse(dataList[i][1]));
  };
  return ary;
};

// IN: 実値データのリスト
// OUT: パーセントに変換したデータリスト
function convertDataAsPercent(dataList) {
  return dataList.map(data => {
    return data * 100;
  });
};

// IN:  最新の実値・予測値のデータの配列
// OUT: 最新の実値・予測値から差分を取り、計算し、枯渇までのおおよその時間を返す
// REMARK: 傾きがマイナスの場合、枯渇する見込みがないため、Infinityを返す
function calcDryUpTime(dataMetrics) {
  if (dataMetrics[0] < dataMetrics[1]) {
    let diff = dataMetrics[1] - dataMetrics[0];
    let dryUpTime = Math.floor(100/diff);
    return dryUpTime;
  };
  return Infinity;
};

function compressDataList(dataList, term) {
  let dividedList = divideDataList(dataList, term);
  return dividedList.map((ary, idx) => {
    return ss.median(ary);
  });
};

function divideDataList(dataList, term) {
  let weeksCount = term * 4;
  let dataListSize = dataList.length;
  let unit = Math.ceil(dataListSize / weeksCount);
  let dividedList = [];
  for(let i = 0; i < Math.ceil(dataListSize / unit); i++ ) {
    let k = i * unit;
    let ary = dataList.slice(k, k + unit);
    dividedList.push(ary);
  };
  return dividedList;
};
```

EMA自体は簡単な数式でしかないので、データをぶっこめば良い感じに計算してくれるクラスを自作。

``` js:EMA.js
export default class EMA {
  constructor(dataList) {
    // 実データリスト
    this.dataList= dataList;
    // 予測値リスト
    this.predictedDataList= [0, dataList[0]];
    // 平滑化係数
    this.smoothingFactor = 0.8;
  };

  calcPredictedAllVal() {
    for(let i = 1; i < this.dataList.length; i++) {
      let actualVal = this.dataList[i]
      let prevPredictedVal = this.predictedDataList[i];
      let predictedVal = calcPredictedVal(actualVal, prevPredictedVal, this.smoothingFactor);
      this.predictedDataList.push(predictedVal);
    };
    return this.predictedDataList;
  };
};

function calcPredictedVal(actualVal, prevPredictedVal, smoothingFactor) {
 return  prevPredictedVal + smoothingFactor*(actualVal - prevPredictedVal);
};
```

今回、主要な移動平均法（単純・加重・指数）のどれを使うかを比較しましたが、重み付けコントロールがしやすいEMAを選択しました。詳しくは [移動平均](https://ja.wikipedia.org/wiki/%E7%A7%BB%E5%8B%95%E5%B9%B3%E5%9D%87) をご覧ください。

予想値がつくれましたら、あとは現時点のリソース値と予想値の傾きを求めることができますので、その傾きのままリソースが使われると、あとどの程度で閾値に達するか?を概算します。

### 3. 週次で上記トレンド値をSlackに報告してくれるBotの作成
さて、ようやく分析APPが作れたので、最後に通知用のBotを作ります。
今回は、毎週、定時で分析・報告してくれるだけの簡単なBotを作りました。Datadogにちなんで、犬っぽい名前のBotにしたいなぁと思って、コリーと名付けました 🐶

コリーのやることを再確認すると、

- 分析Appに分析してもらえるよう命令して予想値とそろそろヤバそうなホストを取得
  - 来週の予想リソース値をとりあえず報告
  - やばそうなホストもあるならそれも報告（ないなら報告はしない） 
- 指定したSlackのチャンネルに定時で上記処理を実行


以下にhubotの実装部分を一部展開します。

```js:app.js
import AnalyzeClient from './lib/analyze_client';
const hosts = require('./hosts.json'); // 監視するホストのFQDNリスト
const cron = require('cron').CronJob;
const apiKey = process.env.DATADOG_API_KEY; // Datadog連携に使うKEY
const appKey = process.env.DATADOG_APP_KEY; // Datadog連携に使うKEY
const channel = { room: "xxx.xxx.xxx" };

module.exports = (robot => {
  new cron('00 01 10 * * 2', () => {
    const WARNTIME = 3;
    robot.send(channel, 'CPU使用率の傾向から、スケールアップ・アウト時期を分析いたします。');
    let client = genAnalyzeClient();
    let result = client.getAllDryUpTime(hosts, 'cpu').then((result) => {
      for(let resource of result) {
        let hostName = resource[0];
        let calclatedWarnTime = resource[1];
        if(calclatedWarnTime < WARNTIME) {
          robot.send(channel, `${hostName}のCPUはあと${calclatedWarnTime}ヶ月で定常的にBUSY状態になります.`);
        };
      };
      robot.send(channel, '各ホストの来週のCPU使用率予測値は以下のようになっております。');
      for(let resource of result) {
        let hostName = resource[0];
        let predictedValue = resource[2];
        robot.send(channel, `${hostName}： ${predictedValue}%. `);
      };
    });
  }, null, true, 'Asia/Tokyo');
});

function genAnalyzeClient() {
  try {
    var client = new AnalyzeClient({
      apiKey: apiKey,
      appKey: appKey
    });
    return client;
  } catch (e) {
    console.log(e);
    return false;
  };
};
```

#### 完成したのがこいつ
簡易ですが、知りたい情報を労せず得られ、かつ対策を講じるタイミングもこちらの都合で対応しやすくなりました。これでホットペッパービューティーのインフラの運用もだいぶやりやすくなりました。
<img width="700" alt="コリーの様子" src="https://qiita-image-store.s3.amazonaws.com/0/34144/143efa32-f5fd-415b-2af2-a0c9f7a25843.png">


## さいごに
こんな感じのことをそれとなくやったので、心の中でドヤっていたのですが、2017年12月に本家でまさに理想としていたトレンド分析機能が提供されはじめ、このアプリケーションが不要な時代が到来してしまいました。残念ながら近いうちにコリーは殺処分されることでしょう。さようならコリー... 😭
というわけで、実質コリーの供養記事となりました。なむなむ。

※ Datadogで最近提供されはじめたトレンド分析機能はこちら。
[Introducing metric forecasts for predictive monitoring in Datadog](https://www.datadoghq.com/blog/forecasts-datadog/)