BitZenyマイニングプールの死活監視をするTwitter Bot

この記事は、[暗号通貨 Advent Calendar 2017](https://qiita.com/advent-calendar/2017/cryptocurrencies)の24日目の記事です。
昨日の記事は、@redshogaさんの[まだ急変動で消耗してるの？ 暗号通貨(仮想通過)の急変動をつぶやくbotを作ったお話](http://redshoga.hatenablog.com/entry/2017/12/16/%E3%81%BE%E3%81%A0%E6%80%A5%E5%A4%89%E5%8B%95%E3%81%A7%E6%B6%88%E8%80%97%E3%81%97%E3%81%A6%E3%82%8B%E3%81%AE%EF%BC%9F_%E6%9A%97%E5%8F%B7%E9%80%9A%E8%B2%A8%28%E4%BB%AE%E6%83%B3%E9%80%9A%E9%81%8E%29)でした。なんと2日連続でBotの話題ですね。

---

## 作ったもの
[**BitZenyプール稼働状況Bot**](https://twitter.com/bitzenypoolbot)というのを作りました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">【定期】プール稼働状況<br>LA Pool ✅<br>MDpool ✅<br>MDpool(new) ✅<br>うさぎコイン ✅<br>みそスープール ✅<br>大人の自由研究 ✅<br>寛永通宝 ✅<br>選民プール ✅<br>wmapool ✅<br>みずたまり ✅<br>Knyacki ✅<br>コインラッシュ ✅<br>とある鯖管 ✅<br>銭プール ✅<br>Hoge Pool ✅<br>powerpool ✅<br>(2017/12/24 20:00:00 JST)<a href="https://twitter.com/hashtag/BitZeny?src=hash&amp;ref_src=twsrc%5Etfw">#BitZeny</a></p>&mdash; BitZenyプール稼働状況Bot (@bitzenypoolbot) <a href="https://twitter.com/bitzenypoolbot/status/944885364723916806?ref_src=twsrc%5Etfw">December 24, 2017</a></blockquote> 

こんな感じで、BitZenyという通貨のマイニングプールがきちんと動いているかどうかを定期的に呟いてくれます。

また、数分(現状、5分)間隔でプールを巡回し、障害が発生していたら警告をツイートします。

## 環境
- Node.js v8.2.1
- AWS EC2 t2.micro(無料期間)

## 経緯
### BitZenyマイニングをやってみた
2017年12月6日あたりに、BitZenyの価格が20円ほどまで急騰して話題になりました。
それだけならこれまで散々起きた一過性の祭りとなんら変わりませんが、BitZenyには「**日本中心の活発なコミュニティの存在**」「**CPUで手軽にマイニングできる**」という特徴があったので興味を持ちました。

丁度自宅PC(自作機)のグラフィックボードを更新して、マイニングを始めてみようと掘る通貨を探していたこともあり、プールに登録して掘り始めました。
CPUでBitZeny、グラボでMonacoinを掘っているのですが、PCのサイドパネルを開けておくとそこそこ部屋が暖まっていい感じです。

~~夏になったらやめそうとか言わない~~

### マイニングプールの多くが不安定に
当たり前ですが、私が考え付くようなことは皆さん同じように考えるわけで、BitZenyのマイニングを協同で行う**マイニングプール**では日々ユーザが増えていきました。

特に、解説記事で紹介されたプールにマイナーの大部分が集中し、土日のマイナー激増を経て負荷分散が呼びかけられる事態に発展。しかし、分散先のプールにも受け入れの余力があるとは限らず、移行先を選んだはいいが**通信ができず採掘できていない**こともありました。

### Botの制作
プールと接続できない時は、言及したツイートがないか検索するのですが、すぐ気付く人がいるとは限りません。どのプール落ちているのか、即座に分かるような仕組みがなかったため自作することにしました。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">bitZenyのプール、今後もちょくちょく落ちるようなら疎通確認するBotの需要あるんかな</p>&mdash; 絶望の吉祥 (@kn1cht) <a href="https://twitter.com/kn1cht/status/940235333173592064?ref_src=twsrc%5Etfw">2017年12月11日</a></blockquote>

自分が欲しいから作るにせよ、折角なので公開の場で動かそうということで**TwitterのBot**として開発することにしました。
専用サイトと違い鯖落ち情報がすぐに共有できますし、利用者がアクセスするのはTwitterなので負荷を考える必要がない利点がありますね。

## 障害発生の判定
プールを使う際、マイナーが接続する先は二種類あります。

1. **Webのダッシュボード画面**
    - プールのWebサイトで、ログインして統計閲覧や各種設定が可能
1. **Stratumポート**
    - 採掘ソフトが接続し、演算結果を送信する
    - マイニング専用のプロトコルである`Stratum`を使用

このいずれかだけが停止するということも当然起こり得ます。
例えば、Web側がエラーを返していても、**Stratumの方は動作していて正常に採掘できている**こともあるのです。従って、この両方をチェックする必要があります。

### Webダッシュボード
ほとんどのマイニングプールが`MPOS`というシステムを使っており、APIで各種情報を取得できます。

- [APIリファレンス](https://github.com/MPOS/php-mpos/wiki/API-Reference)

リファレンスを読むと、登録ユーザに発行される**APIキー**が必要なメソッドがほとんどですが、`public`だけは認証不要となっています。プールウェブサイトの`index.php?page=api&action=public`にアクセスするだけで、以下のようにJSONでデータを得られます。

```bash
$ curl 'https://lapool.me/bitzeny/index.php?page=api&action=public' | python -mjson.tool  
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   149    0   149    0     0     49      0 --:--:--  0:00:02 --:--:--    50
{
    "hashrate": 17313.810700595,
    "last_block": 1083778,
    "network_hashrate": 26499835,
    "pool_name": "LA Bitzeny Pool",
    "shares_this_round": 119133,
    "workers": 6284
}
```

巡回リストのプールそれぞれについて`public`メソッドを呼び出し、

- エラーなくデータを取得できること
- 受信したデータがJSONとしてパースできること

を満たす場合に障害なしと判断することにします。

### Stratumポート
マイニングソフトウェアがプールと通信する際は、Stratumという専用のプロトコルを使用します。TCPでデータをやり取りするので、プールが指定した**ポートとハンドシェイクできれば正常**と判定できます。

例えば、[LA Bitzeny Pool](https://lapool.me/bitzeny/)のBitZeny用アドレスの一つに`stratum+tcp://jp.lapool.me:3014`があります。jp.lapool.meの3014番ポートを使っていることがわかるので、`nc`コマンドなどで接続することができます。

```bash
$ nc -z jp.lapool.me 3014
Connection to jp.lapool.me port 3014 [tcp/broker_service] succeeded!
```

## 実装
慣れているNode.jsで実装しました。
リポジトリは[こちら](https://github.com/kn1cht/bitzeny-pool-checker)です。

### APIのチェック
```main.js
/*** check MPOS API reachability ***/
const checkAPI = async(uri) => {
  const data = await new Promise((resolve) => {
    request.get(uri, { timeout : config.timeout.api || 1000 }, (error, response, body) => {
      if(error) { resolve({ error }); }
      else { resolve({ body }); }
    });
  });
  if(!data.error) {
    try {
      data.json = JSON.parse(data.body);
    } catch(err) {
      if(err.name === 'SyntaxError') { data.error = err.name; }
      else { console.error(err); }
    }
  }
  if(data.error) { return false; }
  return true;
};
```

少しごちゃごちゃしていますが、`request`でAPIを見に行って、エラーが起きたら`data.error`に入れています。取得したデータをJSONパーサに入力し、文法エラー(=MPOS APIが正常でない)になったらやはり`data.error`に書きます。
最後に`data.error`の有無を確認して判定完了です。

### Stratumのチェック
```main.js
/*** check Stratum Port reachability ***/
const checkStratum = async(host, port) => {
  const portStatus = await portscanner.checkPortStatus(port, {
    host,
    timeout : config.timeout.stratum || 1000
  });
  return portStatus === 'open' ? true : false;
};
```

こちらの方がシンプルです。[`portscanner`](https://github.com/baalexander/node-portscanner)という`nc -z`と同じことをしてくれるモジュールがあるので導入しました。
ホスト、ポートを指定して`checkPortStatus()`を呼ぶだけでチェックが完了します。

ちなみに、用途別に複数のポートを持つプールも存在しますが、落ちるときは一緒に落ちるだろうという雑な推論により適当に一つを選んでチェックしています。

### 巡回先プールの設定
各プールについて、**名称やURL、ホスト、Stratumのポート**などを設定しておく必要があります。
[`node-config`](https://github.com/lorenwest/node-config)を使って`config/`以下の設定ファイル(YAML形式)を読み込んで使います。

まとめツイート用の短縮名や、鯖落ちや復帰をツイートするかの設定なども含めて以下のような具合です。

```config/default.yaml
apipath: /index.php?page=api&action=public
timeout:
  api: 10000
  stratum: 1000
pools:
  -
    name: LA Bitzeny Pool
    shortname: LA Pool
    id: lapool
    url: https://lapool.me/bitzeny
    alert_enabled: true
    stratum:
      host: jp.lapool.me
      port: 3014
...
```

プログラム側で`config`をrequireするとconfigに上記の設定を入れてくれます。
新たに巡回先を増やしたい場合や、タイムアウトの待ち時間を変えたい場合でも設定ファイルを編集するだけでよく、メンテナンスの負担がかなり減りました。

なお、Twitter APIのトークンなど隠したい設定は、別途[`dotenv`](https://github.com/motdotla/dotenv)で管理し、コミット対象から除外しています。

### プールの状況チェック(checkCurrentStatus)
```main.js
const checkCurrentStatus = async() => {
  for(const pool of config.pools) {
    console.info(`[${new Date()}] Checking ${pool.name}...`);
    const previous = previousStatus[pool.id];
    const current = { api : false, stratum : false };
    for(let retry = 0; retry < MAX_RETRY; ++retry) {
      current.api = await checkAPI(pool.url + config.apipath);
      if(current.api) { break; }
    }
    for(let retry = 0; retry < MAX_RETRY; ++retry) {
      current.stratum = await checkStratum(pool.stratum.host, pool.stratum.port);
      if(current.stratum) { break; }
    }

    if(previous.api !== current.api || previous.stratum !== current.stratum) {
      previousStatus[pool.id] = { api : current.api, stratum : current.stratum };
// ...(ツイート本文を作成してツイート)
    }
  }
};
```

各プールに対してWeb・Stratumのチェックをします。それぞれにループがあるのは、指定したリトライ回数(現在は3回)までは接続しようとするためです。
**短時間繋がりにくいだけの状態を停止と判定しない**ための措置です。

`previousStatus`に各プールの前回の状態が入っているので、**状態が変化(停止or復旧)した時だけ**ツイートを行います。

### 定期まとめツイート(tweetAllStatus)
こちらは、`previousStatus`の中身に応じ、`config/default.yaml`で指定したプール全ての状況をツイートします。

### ツイート
[`node-twitter`](https://github.com/desmondmorris/node-twitter)でTwitterのAPIを叩きます。
手元でちょっと試すだけのときにはツイートして欲しくないので、`DEBUG=1`として実行するとスキップするようにしました。

```main.js
const postTweet = (status) => {
  if(process.env.DEBUG) { return; }
  bot.post('statuses/update', { status }, (err/*, tweet, response*/) => {
    if (err) { console.error(err); }
  });
};
```

### 定期実行とデーモン化
[`node-cron`](https://github.com/kelektiv/node-cron)と[`forever`](https://github.com/foreverjs/forever)です。どちらもド定番だと思うので説明は控えます。

```main.js
cron.schedule('*/3 * * * *', () => {
  checkCurrentStatus();
});

cron.schedule('0 * * * *', async() => {
  tweetAllStatus();
});
```

```bash:常駐させる
$ forever start main.js
warn:    --minUptime not set. Defaulting to: 1000ms
warn:    --spinSleepTime not set. Your script will exit if it does not stay up for at least 1000ms
info:    Forever processing file: main.js
```

## 反響と今後
思いつきで作ってとりあえずリリースしたようなBotですが、お陰さまで現在250人程度の方にフォロー頂いています。プールの利用者の方はもちろん、管理者の方も参考にしてくださっているようで嬉しい限りです。

最低限の障害警告ツイート機能だけで公開したのが12月12日頃になります。これは時期が悪く、どのプールも負荷激増で頻繁にタイムアウトするため、**数分おきに大量の障害ツイート**が流れてしまいました。
作ったばかりのアカウントで似た内容を大量に呟いているということで、**即刻スパム判定され一時アカウントをロックされました。**ロックは認証により解除されましたが、その後しばらく検索結果に表示して貰えませんでした :sweat_smile: 

その後、

- ハッシュタグをつけるツイートを減らす
- 接続チェックする頻度を減らしてツイート頻度を抑制する
- 接続できなくてもすぐに異常と判定せず、3回まではリトライする

などの対策によりツイート頻度は徐々に落ち着いて(もちろん**中の人達の努力によりプール自体が安定した**のも大きいです！)いきます。
コミュニティーの方々もリツイートなどで広めてくださり、検索に出ずとも皆さんに知って頂けるようになりました。

今後も、新たなプールを追加していきたいと思っています。ただ、毎時のまとめツイートが既に140字ギリギリなので、分割ツイートする機能をつけなければなりません。
また、MPOSを使用しないプールもあり、APIの動作が異なるので別途対応することになりそうです。

---

明日最終日は、@you21979@githubさんです。