オウチーノ開発者ブログ

「株式会社オウチーノ」の社員によるブログです。

GrafanaとPrometheusとTwilioを使ってサーバー監視システムを構築する

オウチーノのSREチームの尾形です。

今回はオウチーノのサーバー監視の仕組みについてご紹介したいと思います。

経営方針が変わる前のオウチーノのサーバー監視は全て外部会社にお願いしていまして、その時は個別の依頼ベースで監視対象を追加・削除してもらっていました。自分達で管理していない状態なので、監視項目に漏れがあったり柔軟な設定が出来ない、Opsへの認識が薄くなるなどの問題がありました。そこで自分達のシステムは自分達で面倒を見ようということで去年から監視をする仕組みを構築し始めました。外部会社ではZabbixを利用していたので、その設定をそのまま頂くことも出来たのですが秘伝のタレを頂いても管理が出来ないということでゼロから構築することを決めました。
SREチーム以外にも見てもらえるグラフを表示出来るツールということでGrafana+Prometheusで構築しました。オウチーノではAWSを利用しているため、AWS CloudWatchで取得できる情報はAWS CloudWatchに任せたりしています。

Grafana + Prometheusの構築方法については今回割愛します。

監視の仕組み

GrafanaにDashboardを表示し、オウチーノドメインのGoogleアカウントを持っている人なら誰でも見ることの出来る状態にしています

CloudWatchMetrics

こんな感じにDashboardを用意し、開発者に気軽に見てもらえるようにしています。もちろん開発者がDashboardやグラフを作ることも可能です。
各グラフにアラートの閾値を設定し、閾値を超えたものに関してグラフ付きでSlackに通知します。

SlackNotification

PrometheusではEC2上に乗っているMySQLのメトリクスやヘルスチェック、EC2のCPUやメモリなど様々な値を収集しています。
現在はまだ出来ていませんが、コンテナ単位のメトリクス取得もする予定です。

Prometheusのデータ収集は各種Exporterを利用しています。監視をOFFにしたい時もあるので、その場合はEC2のTagで制御しています。
prometheus.ymlに以下のような設定値を追加し、PrometheusNodeExporterタグがenabledのものだけを収集するようにしています。

  - job_name: ec2-node-exporter
    ec2_sd_configs:
      - region: ap-northeast-1
        refresh_interval: 60s
        port: 9100
    relabel_configs:
      - source_labels: [__meta_ec2_tag_PrometheusNodeExporter]
        regex: (enabled)
        action: keep
      - source_labels: [__meta_ec2_tag_Name]
        target_label: name

サーバーの追加

Itamaeを利用してサーバーを構築しています。
(ItamaeはChefを簡潔に記載出来るように作られたOSSです。)
共通化したレシピを用意してあるので、各サーバーのレシピを書く際に単純にincludeすればexporterが入ります。

e.g.

include_recipe "../../cookbooks/prometheus-ec2-exporter/default.rb" # この1行を追加するだけ
include_recipe "../../cookbooks/timezone/default.rb"
include_recipe "../../cookbooks/hostname/default.rb"

execute "apt-get update"

...

構築後expoterが起動すると設定に則りメトリクスが収集されます。共通化されたレシピを利用している場合は既にGrafana上にグラフがあるので、特別な設定をしなくてもグラフ上に構築したサーバーが表示され、アラート監視の管理下に入ります。
ItamaeのレシピはGit管理下にあるため、GitHubのPR時のレビューで監視の漏れがないかをチェック出来、1度レシピを作ればSREチームでなくとも簡単にサーバーを構築・監視下に置くことが可能です。

オンコールの仕組み

Slack通知だけでは夜間・休日に気づくことが難しくなります。なのでSlack通知の他に電話通知もしています。
電話発信にはTwilioという音声通話をAPIでコントロールできるサービスを利用し実現しています。
大まかな流れは以下のようになります。

  • Googleカレンダーに予め第1窓口、第2窓口の人を週替わりで登録しておく
  • Prometheusがアラートを検知
  • Googleカレンダーに登録されている第1窓口の人に電話
    • 電話を受けた第1窓口の人はチャットボットに対応しますコマンドを投げる
  • 約10分第1窓口の人から応答がなければ第2窓口の人に電話

ここからは実現方法を簡単に説明していきたいと思います。

Googleカレンダーに予め第1窓口、第2窓口の人を週替わりで登録しておく

Google Spread Sheetsにマスターを登録して、日々スクリプトでカレンダーを更新しています。
ここではオンコールだけでなく、トイラーも登録しています。トイラーについては前回のブログのCTOのスライドをご参照ください。

OncallMasterSheets OncallCalendar

スケジュールを更新するスクリプトはGoogle Apps Scriptを利用していて、Google Apps Scriptのスケジューラ機能で日々Google Spread Sheetsのマスターベースに最新のカレンダーに更新します。順番を変更したい場合はGoogle Spread Sheetsをいじってもらえれば次の日には次週以降の情報が更新されます。
(スクリプトの内容については目を瞑って頂けると٩( ᐛ )و

var spreadSheetUrl = 'https://docs.google.com/spreadsheets/xxxx';
var calendarId = 'XXXX'

var columnDefinitions = { 'オンコール': 1, 'トイラー': 2 }

function users(target) {
  var column = columnDefinitions[target];
  var spreadSheet = SpreadsheetApp.openByUrl(this.spreadSheetUrl);
  var sheets = spreadSheet.getSheets();
  var sheet = spreadSheet.getSheetByName('シート1')
  
  var row = 2; // 1行目は列タイトルなので2行目から
  
  var targetUsers = [];
  while(true) {
    var user = sheet.getRange(row, column).getValue();
    if(user == "") {
      break
    }
    targetUsers.push(user);
    row++;
  }
  return targetUsers;
}

function previousMonday() {
  var today = new Date();
  return new Date(today.getFullYear(), today.getMonth(), today.getDate() - today.getDay() + 1);
}

function calenderEvents(seachQuery) {
  var startTime = previousMonday(); // 月曜始まり
  var endTime = new Date(startTime.getFullYear(), startTime.getMonth() + 4, startTime.getDate());
  return CalendarApp.getCalendarById(calendarId).getEvents(startTime, endTime, {search: seachQuery});
}

function removeAfterNextWeekEvents(target) {
  var events = calenderEvents(target);
  if(events.lentgh == 0) {
    return;
  }
  events.slice(1, events.length).forEach(function(element) {
    element.deleteEvent();
  });
}

function addCalender(target) {
  var oncaller = users(target);
  var primaries = oncaller.slice();
  var secondaries = oncaller.slice();
  var findIndex = 0;
  var endTime = previousMonday(); // 月曜始まり
  
  var events = calenderEvents(target);
  if(events.length != 0) {
    var lastEvent = events.pop();
    findIndex = oncaller.indexOf(lastEvent.getTitle().match(/Primary: (.*), Secondary: (.*)/)[2]);
    endTime = lastEvent.getEndTime();
  }
  
  // rotate
  for(var i = 0; i < oncaller.length; i++) {
    if(i < findIndex) {
      primaries.push(primaries.shift());
    }
    if(i < findIndex + 1) {
      secondaries.push(secondaries.shift());
    }
  }
  var calendar = CalendarApp.getCalendarById(calendarId);
  
  for(var i = 0; i < primaries.length; i++) {
    calendar.createEvent('【' + target +  '】 Primary: ' + primaries[i] + ', Secondary: ' + secondaries[i],
                    new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate() + 7 * i),
      new Date(endTime.getFullYear(), endTime.getMonth(), endTime.getDate() + 7 * (i + 1)));
  }
}

function upsertOncaller() {
  removeAfterNextWeekEvents('オンコール');
  addCalender('オンコール');
}

function upsertToiler() {
  removeAfterNextWeekEvents('トイラー');
  addCalender('トイラー');
}

function upsert() {
  upsertOncaller();
  upsertToiler();
}

Prometheusがアラートを検知

Prometheusにアラートを設定して、アラート発生時のアクションにWebhookを登録することで電話通知サービス(Sinatra製サービス)をキックします。
これらの設定はGrafanaのGUIから設定しています。

http://docs.grafana.org/alerting/notifications/ NewNotificationChannel

http://docs.grafana.org/alerting/rules/#notifications SetAlertNotification

Googleカレンダーに登録されている第1窓口の人に電話

キックされた電話通知サービスがGoogleカレンダーから窓口の人の名前を取得しに行き、名前から電話番号を引いてTwilioのAPIをコールします。
電話発信をした後、第1窓口の人が未対応という状態を保存します。

Googleカレンダーの取得

GoogleカレンダーをAPIで取得するにはOAuth2の認証が必要なので、電話通知サービスのデプロイ時に手動で認可しています。
(ここが自動化のネックになっているのでどうにかしたい・・・ナイスな案をお持ちの方は是非教えてください!)
アラートが全く鳴らない幸せな世界が続いた場合にリフレッシュトークンが切れるので何かしらのリカバリー手段が必要なのですが、幸い(?)アラートは少なくとも週1で発生しているのでまだ問題に直面していません _:(´ཀ`」 ∠):...

電話の発信

TwilioのAPIをコールしています。

TwilioのAPIは公式ドキュメントが充実しているので、そちらを参照して頂くのが良いと思います。
SDKのサポートしている言語の種類も豊富で困ることは少ないと思います(201809時点ではGoが非サポートです)。

公式の電話発信のRubyサンプルは以下のようになっています。

# Get twilio-ruby from twilio.com/docs/ruby/install
require 'twilio-ruby'

# Get your Account SID and Auth Token from twilio.com/console
account_sid = 'ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
auth_token = 'your_auth_token'

# Initialize Twilio Client
@client = Twilio::REST::Client.new(account_sid, auth_token)

@call = @client.calls.create(
  url: 'http://demo.twilio.com/docs/voice.xml',
  to: '+14155551212',
  from: '+15017122661'
)

puts @call.sid

オウチーノでは電話でアラートに気づけばいいよねということでサンプルボイスをそのまま流していますが、自前の音声ファイルを流したりテキストを読ませるということもTwilioではサポートしています。電話に出るのが楽しくなるようなアイデアを募集中です!

状態の保存

サービスのサーバのローカルファイルに状態を記載しています。
DBなどを利用するのがいいのでしょうが、手抜きしました v^^v

電話を受けた第1窓口はチャットボットに対応しますコマンドを投げる

チャットボット経由で未対応状態を解消します。

e.g. @ruboty ack

これはチャットボットがack(acknowledge)コマンドを受けたら電話通知サービスに対して状態削除APIをコールしています。
これは発信したユーザーを特に見ていないので、アラートに気づいて対応できる人がいたら代わりに投げてくれたりします。

ちなみにオウチーノではチャットボットにrubotyを利用しています。

約10分第1窓口の人から応答がなければ第2窓口の人に電話

cronで定期的に状態を確認し、アラートが発生してから10分間応答がない場合に第2窓口の人に電話します。
電話をかける方法は第1窓口の人に電話をした時と同じです。

今後の課題

途中にも書きましたがGoogleのAPIを呼ぶ周りをもう少し整理したいのと、現状アラートアクションのトリガーとなっているPrometheusと電話発信をするサービスの冗長性が皆無なので、どちらかが死ぬと悲劇が起きるため、何かしら対策を打ちたいと考えています。

最後に

モニタリングにオーナーシップを持つようになった結果、トラブルの予兆を見つけて事前に対策したり、リソースが余っている場合にはスケールダウンしてコスト削減したりといったことが柔軟にできるようになりました。
とはいえ手動な部分が多く残っているので、これからも改善を続けていきたいと思います。

オウチーノでは一緒に働くメンバーを募集中です