インフラブログ

とあるWEBサイトのインフラを構築運用するメモ

メール以外の方法でもアラートの通知を行う(Twilio)

アラート通知の続きです。

サービスダウン検知

MSPに運用を手伝ってもらっていればサービスダウンが発生した場合でも電話で障害連絡してくれたり一次対応をやってくれたりするのですが、今回はスモールスタートでの運用ということもありMSPを使うことを勘定にいれなかったので、なんとか自力で対応できるようにします。

監視検知としてはMuninを使ってWEBサイトのヘルスチェックを行うのですが、サイトダウン検出時にアラートメール送ってもすぐ誰かに見てもらえると限らず、社内で警告音鳴らしたとしても社内に誰もいなければ気づいてもらえません。

いざという時はやはり電話で担当者に連絡したいと思っていたのですが、Twilio参考になる記事という電話周りのAPIが提供されているようなので、試しに使ってみてます。 なお、携帯に電話をかけると1分20円ほど課金が発生します。(電話に出なければ発生しないのかな?)

どんなものかというと、Twilioが監視サーバと電話の中間に入ってやりとりを代行してくれます。Twilioと監視サーバの間でhttpでのやりとりが発生するので、監視サーバ上にWEBサーバ等が必要です。今回は監視サーバ上にSinatraで簡単なサイトを作って、架電開始のリクエストや、Twilioからのコールバックの対応を行います。

f:id:ls-la:20140130150509j:plain

サンプルを見ながら作ってみたのですが、ざっくり流れは以下の感じです。

アラート発生時にTwilioのAPIを使って担当者に電話する。

  • アラート検知のスクリプトなどから、架電スタートのリクエスト/startを叩く。wget http://watch.example.com:4567/start?secret=xxx&message=アラートです
  • /startからstart_callが呼び出され、Twilioが担当の携帯に架電する。

担当者が電話にでない、出ても自動留守電メッセージだったという場合は、次の担当者に架電する。

  • Twilioの架電結果が/callbackにコールバックリクエストとして来るのでパラメータの中のCallStatus:no-answerで電話に出てないと判断して 再度start_callを呼んで次の電話番号に架電する。次の番号がない場合は架電を停止する。

担当者が電話に出たら、文章を読み上げ始める。

  • 担当が電話を取ると、/answerで設定した文面をTwilioがゆっくりさんみたいな音声で読み上げる。

担当者が内容を聞いたら確認のボタンを押す。

  • 読み上げの他にキー入力の制御も設定しているので、担当のキー入力待ち状態となる。

確認のボタンを押したのが検知できたらそこで架電をやめる。

  • 担当が1桁のキーを押すか、10秒何もしていない時点でTwilioは/completeにアクセスくる。
  • キーが押されていれば架電停止のフラグをたて、「確認しました」の応答を返す。
  • 押されていなければ「確認できませんでした」を返す。
  • この後もTwilioから/callbackにコールバックが返ってくるので、架電停止フラグをみて停止でなければ次の電話番号に電話をかける。

sinatraのコード

rubyはこれから勉強します。。

require 'sinatra/base'
require 'twilio-ruby' 
require 'uri'
require 'redis-objects'
require 'json'

FROM = '+81xxx' #電話する際の電話番号
ACCOUNT_SID = 'xxx' #twilioにサインアップしたときにもらうID
AUTH_TOKEN = 'xxx' #twilioにサインアップしたときにもらうToken
NUMBERS = ["+81xxx","+81xxx","+81xxx"] #電話番号リスト
SECRET = "xxx" #だれでも実行できないようにするための簡単な合言葉
HOST = "http://watch.example.com:4567" #このアプリが動くURL

CALLBACK_URL = "#{HOST}/callback?secret=#{SECRET}" #架電後に結果をtwilioが送ってくる際の受取先              
ANSWER_URL = "#{HOST}/answer" #電話がつながった後にtwilioにやってもらいたいことをxmlで教える
COMPLETE_URL = "#{HOST}/complete" #電話中の担当者が番号入力を行ったあとにtwilioにやってもらいたいことをxmlで教える


#アラートメッセージの文面、今何番目に電話してるとか各種状態をredisに保存する。
class State
  include Redis::Objects
  value :processing
  list :numbers
  counter :number_index
  value :message

  def id
    1
  end

  def number_shuffle
    numbers.clear
    NUMBERS.shuffle.each{|n| numbers << n }
  end

  def processing?
    processing == "0" ? false : true 
  end

  def new_number
    if number_index.to_i < NUMBERS.size
      number = numbers.at(number_index)
      number_index.increment
      number
    else
      nil
    end
  end

  def stop
    processing.value = "0"
  end

  def start(text)
    processing.value = "1"
    number_shuffle
    message.value = text
    number_index.reset
  end

  def dump 
    "processing: #{processing.value},numbers: #{numbers.each{|n| n} },number_index: #{number_index},message: #{message.value}"
  end

end

State.redis = Redis.new(:host => '127.0.0.1')

def log text
  `echo \`date\` >> /var/tmp/twilio`
  `echo #{State.new.dump} >> /var/tmp/twilio`
  `echo #{text} >> /var/tmp/twilio`
end

#電話をかける
def start_call
  callback = "#{HOST}/callback?secret=#{SECRET}"
  url = "#{HOST}/answer"
  @state = State.new
  if number = @state.new_number
    @client = Twilio::REST::Client.new ACCOUNT_SID, AUTH_TOKEN
    @client.account.calls.create({
      :to => number,
      :from => FROM,
      :url => URI.encode(url),
      :method => 'GET',
      :status_callback => URI.encode(callback),
      :status_callback_method => 'GET',
      :timeout => '30',
      :record => 'false',
      'IfMachine' => 'hangup' #電話に出た相手が留守電メッセージのようなマシンだった場合電話を切る(US向けの機能らしいです)
    }) 
  else
    @state.stop
  end
end

class Server < Sinatra::Base

  get '/'do
    ""
  end

  get '/stop' do
    State.new.stop
    log "stop"
    "stop"
  end

  #
  get '/start' do
    if params[:secret] == SECRET
      State.new.start(params[:message])
      log "start #{params.to_json}"
      start_call
    end
    "start"
  end

  get '/callback' do
    if params[:secret]
      log "callback #{params.to_json}"
      if State.new.processing?
        start_call
      else
        content_type "text/xml"
        "<Response><Say language='ja-jp'>completed</Say></Response>"
      end
    end
  end
  
  get '/answer' do
    log "answer #{params.to_json}"
    content_type "text/xml" 
    "<Response><Gather timeout='10' numDigits='1' action=\"#{COMPLETE_URL}\" method='GET'><Say language='ja-jp'>#{State.new.message.value}。確認のため、何か数字を押して下さい。</Say></Gather><Say>確認できませんでした。次に電話します。</Say></Response>"
  end

  get '/complete' do
    log "complete #{params.to_json}"
    if params[:Digits]
      State.new.stop
      content_type "text/xml"
      "<Response><Say language='ja-jp'>確認しました。アラートを停止します</Say></Response>"
    else
      content_type "text/xml"
      "<Response><Say language='ja-jp'>確認できませんでした。次に電話します。</Say></Response>"
    end
  end
end