Ruby
Arduino
AWS
電子工作
監視
OriginalAkatsukiDay 20

サーバ監視デバイスを作ってみた。

背景と目的

サーバの監視には色々なツールやサービスがあります。AWS CloudWatch、New Relic、DataDog、Mackerel、などなど。
こうしたサービスは主にエンジニア向けにデザインされており、様々な指標を見ることができます。一方、エンジニア以外の職種のチームメンバーも、サーバの負荷が気になるケースがあります。例えば、何らかのイベントで急激に負荷が上昇することが予想される場合が挙げられます。また、サービスにアクセスしてみたらちょっと遅いような気がする、といった時に、いちいちエンジニアに聞かなくても今アクセスが多いのかをパッと知れる方法があると良いですよね。

元々、監視用のモニターを用意してはいたのですが、これには色々なメトリクスのグラフがたくさん表示されており、情報量が多すぎてエンジニア以外にはよくわからない、という感じでした。
そこで、簡易的にサーバ負荷状況をいつでもモニタリングできる手段を用意することにしました。

作ったもの

ledmatrix.jpg

いわゆる電光掲示板です。マグネット(と補強用にテープ)でホワイトボードにくっ付けてあります。

上の数値はサービスの分間リクエスト数、下部のグラフ(長さが変わる)と数値はフロントサーバの平均CPU使用率(%)を表示しています。(なお写真の数値はテスト用のダミーです。)
情報ソースはCloudWatchで、直近1分間の状況を1分ごとに更新するようになっています。

遠くからでも一目瞭然。リクエスト数に応じて数値の色が変わったり、さりげなくグラフがカラフルにアニメーションしたりといった、見ていて飽きない(?)遊びごころも加えてみました。
ちなみに右端に出ているのは r/m (request/min) と書いたつもりですが、ピクセル数が足りずちょっと苦しい感じ。

構成

ざっくりした構成はこんな感じ。

Untitled Diagram.png

元々、負荷監視用にラップトップが用意されていたので、そこでCloudWatchから情報取得してArduinoにシリアルで送り込むスクリプトを走らせています。(ラップトップが使えなければ、Lambda等で情報取得用のエンドポイントを用意し、そこにESP-32等のマイコンからアクセスすることを考えていました。)

使用パーツ

LEDマトリクスパネルはAdafruitのRGB LEDが16x32並んだものです。

420-07.jpg
Medium 16x32 RGB LED matrix panel

国内では以下などから3,000円台で購入でき、安価といえます。
https://www.switch-science.com/catalog/924/
https://strawberry-linux.com/catalog/items?code=18252
http://akizukidenshi.com/catalog/g/gM-07764/

数年前に発売された商品ですが、AdafruitやSparkfunではOut of stockとなっており、そろそろ入手できなくなってくるかもしれません。
ロットによって背面のコネクタ位置や表示が異なることがありますが、仕様はいずれも一緒のようです。

これ単体は、LEDとトランジスタアレイ、シフトレジスタが乗っているだけで、PWMやダイナミック点灯制御などの機能は搭載されていないため、外部からマイコンやFPGA等でドライブしてあげる必要があります。
幸い、Arduino Uno用のサンプルブログラムとライブラリが提供されており、これを使えば簡単に開発ができます。

ただしこのライブラリは、各列をスキャンしながら階調に合わせて自力でPWM制御するというリアルタイム性の高い制御が必要なので、Arduinoのタイマー割込みやI/Oレジスタにがっつり依存しており、他のマイコン等に移植するのは結構大変な作業となります。(Raspberry Piで動かしている方もいるようです。)

今回は素直にArduino Unoを使用しました。ただでかいのでArduino Pro Miniの方が良かったかも。

Arduino_Uno_-_R3.jpg

電源はUSBからダイレクトにとっています。全LEDを同時点灯させると5V2Aくらいの電源を用意する必要がありますが、数値を表示するだけなら電流は200mAくらいに十分収まり、USB給電でも大丈夫でした。

また、剥き出しだとLEDの光が結構眩しいので、スモークの塩ビ板を同じサイズに切って2枚重ねで貼り付けてあります。

51EX6j7bVFL._SL1000_.jpg

配線はジャンパー線を駆使すれば、半田付けなしでも動かせます。Adafuitのチュートリアルでもジャンパー線で頑張ってますw
led_matrix_jumpers.jpg

プログラム

Arduino側

USBシリアルから上部に表示する内容と下部の数値(%)を受け取り、それをLEDマトリクスに表示します。
(正確には、受け取ったものは随時上部に表示、'\n'を受け取るとクリア、#に続いて3桁の数値で色指定、下部は%に続いて2桁の数値を受け取って表示、というデータ形式となっています。)

描画はAdafruit GFX Libraryを利用しています。
GitHub上のコードをコンパイルすると fontconvert というツールができ、これにフォント名とサイズを渡すとC++のヘッダファイルができます。これをincludeしてフォント名を指定するとテキスト表示に利用できます。

描画関数を呼び出すとメモリ上にビットマップが作られます。色指定はRGB各3bitで行います。
実際のパネルへの描画は、タイマー割り込みハンドラでライブラリが行ってくれます。ビットマップに応じてパネルのLEDを1列ずつダイナミック点灯させているのですが、この時に25%の輝度を指定した場合は8回のスキャン中2回だけ該当LEDを点灯させるという方式でPWMを行うことで、階調表示が実現されています。

#include <Adafruit_GFX.h>   // Core graphics library
#include <RGBmatrixPanel.h> // Hardware-specific library
#include <Fonts/Arial4pt7b.h>
#include <Fonts/Arial7pt7b.h>

#define CLK 8  // MUST be on PORTB! (Use pin 11 on Mega)
#define LAT A3
#define OE  9
#define A   A0
#define B   A1
#define C   A2
RGBmatrixPanel matrix(A, B, C, CLK, LAT, OE, true);

void setup() {
  Serial.begin(115200);

  matrix.begin();
  draw();
}

int level = 99;
uint16_t text_col = matrix.Color333(1,1,0);
char msg[32] = "ready";
int msg_len = -1;

void loop() {
  if (Serial.available()) {
    int c = Serial.read();
    if (c == '\n') {
      msg_len = -1;
    }
    else if (c == '#') {
      while(!Serial.available());
      int r = Serial.read() - '0';
      while(!Serial.available());
      int g = Serial.read() - '0';
      while(!Serial.available());
      int b = Serial.read() - '0';
      text_col = matrix.Color333(r,g,b);
    }
    else if (c == '%') {
      while(!Serial.available());
      level = Serial.read() - '0';
      while(!Serial.available());
      level = level * 10 + Serial.read() - '0';
    }
    else if (c == '~') {
      memset(msg, 0, 32);
      msg_len = 0;
      do {
        while (!Serial.available());
        c = Serial.read();
        if (c == '\n')
          break;
        if (c == '\r')
          continue;
        msg[msg_len++] = c;
      } while (msg_len < 31);
      scrollMsg();
      memset(msg, 0, 32);
      msg_len = 0;
    }
    else if (msg_len < 32) {
      if (msg_len == -1) {
        memset(msg, 0, 32);
        msg_len = 0;
      }
      msg[msg_len++] = c;
    }
  }
  draw();
}

void draw()
{
  static int offset;

  matrix.fillScreen(0);

  matrix.setFont(&Arial_Narrow7pt7b);
  matrix.setCursor(0, 9);
  matrix.setTextSize(1);

  matrix.setTextColor(text_col);
  matrix.print(msg);

  uint16_t col = matrix.Color333(1, 1, 1);
  matrix.drawPixel(29, 0, col);
  matrix.drawPixel(29, 1, col);
  matrix.drawPixel(29, 2, col);
  matrix.drawPixel(30, 1, col);
  matrix.drawPixel(31, 0, col);

  matrix.drawPixel(29, 5, col);
  matrix.drawPixel(30, 4, col);
  matrix.drawPixel(31, 3, col);

  matrix.drawPixel(29, 7, col);
  matrix.drawPixel(29, 8, col);
  matrix.drawPixel(29, 9, col);
  matrix.drawPixel(30, 8, col);
  matrix.drawPixel(31, 7, col);
  matrix.drawPixel(31, 8, col);
  matrix.drawPixel(31, 9, col);

  int x;
  for (x = -1; x < 32*level/100; x++) {
    matrix.drawLine(x, 12, x+1, 15, matrix.ColorHSV(1536/96*x + offset/4, 255, 160, true));
  }
  matrix.drawLine(x, 12, x, 13, matrix.ColorHSV(1536/96*x + offset/4, 255, 160, true));
  offset = (offset + 1) % (1536*4);

  matrix.setFont(&Arial_Narrow4pt7b);
  matrix.setTextColor(matrix.ColorHSV(1536/96*50 + offset, 128, 160, true));
  matrix.setCursor(24, 15);
  matrix.print(level);

  matrix.swapBuffers(false);
}

void scrollMsg() {
  matrix.setFont(&Arial_Narrow7pt7b);
  matrix.setTextSize(1);
  matrix.setTextColor(text_col);
  matrix.setTextWrap(false);

  for (int x = 32; matrix.getCursorX() > 0; x--) {
    matrix.fillScreen(0);
    matrix.setCursor(x, 10);
    matrix.print(msg);
    matrix.swapBuffers(false);
    delay(100);
  }
}

Laptop側

rubyでCloudWatchにアクセスし、前の分のELB Request Countを取得、USBシリアルで送出します。
毎分0秒だとまだデータが乗っていなかったり小さい数値が返ってきてしまうので、適度に遅らせて取得する必要があります。
下記だと15秒遅れで取得していますが、実際にはもっと待ったほうが確実でしょう。

#!/usr/bin/env ruby
require 'time'
require 'serialport'
require 'aws-sdk'

ELB_NAME = -'name_of_load_balancer'
EC2_INSTANCE_CLASS = -'c4.4xlarge'
WAIT = 15

client = Aws::CloudWatch::Client.new(profile: 'prod')

serial = Dir.glob('/dev/cu.usbmodem*')[0]
sp = SerialPort.new(serial, 115200, 8, 1, SerialPort::NONE)
sleep 5 # wait for device initialization

loop do
  begin
    time = Time.now - WAIT
    time -= time.sec

    req = client.get_metric_statistics(namespace:   'AWS/ELB',
                                       metric_name: 'RequestCount',
                                       dimensions: [
                                         {
                                           name:  'LoadBalancerName',
                                           value: ELB_NAME
                                         }
                                       ],
                                       start_time:  time - 60,
                                       end_time:    time,
                                       period:      60,
                                       statistics:  ['Sum'])
    req_count = req.datapoints[0].sum

    cpu = client.get_metric_statistics(namespace:   'AWS/EC2',
                                       metric_name: 'CPUUtilization',
                                       dimensions: [
                                         {
                                           name:  'InstanceType',
                                           value: EC2_INSTANCE_CLASS
                                         }
                                       ],
                                       start_time:  time - 60,
                                       end_time:    time,
                                       period:      60,
                                       statistics:  ['Average'])
    cpu_usage = cpu.datapoints[0].average

    puts "#{time}\t#{req_count} rpm\t#{cpu_usage} %"

    col = if req_count < 1_000_000 # 閾値
            "003"                  # RGB 各3bit(0〜7)で色指定
          else
            "300"                  # 一定値を超えたら赤くする
          end
    msg = "\##{col}#{(req_count/1000).round}k%#{"%02d" % cpu_usage.to_i}\n"
    sp.write msg
  rescue => e
    p e
  end
  sleep(60 - (Time.now.to_i - WAIT) % 60)
end

作ってみて

今回はエンジニア以外でも直感的にイメージしやすい指標として、リクエスト数とCPU使用率を選びました。
誰でもすぐに負荷が確認できるようになっただけでなく、サービスの施策を考えるときにもサーバ負荷を少し意識して設計してもらえることも多くなったような気がします。

ただ、何を表示するべきかは色々と議論の余地があると感じました。
用途やサービスによって何を見るべきか、どう見るべきかは変わってきます。例えばレイテンシ一つ取っても、平均でいいのか? メジアン? p95? などなど、状況によって何を知りたいかは様々です。この辺りはSRE本でも詳しく解説されていますね。

51Ybz+6kIsL._SX389_BO1,204,203,200_.jpg
https://www.amazon.co.jp/dp/4873117917