CloudWatchの通知をSlackに流す

こんにちは。AWS担当のwakです。今回はAWS監視の話をします。

f:id:nurenezumi:20150327133924j:plain

本文とは特に関係のないかわいい猫

CloudWatchのおさらい

AWSにはCloudWatchという監視サービスが用意されています。CPU使用率やDBコネクション数といった監視条件を事前に設定しておくと、EC2やRDSといったサービスが

  • この条件を満たしたとき(ALARM)
  • この条件を満たさなくなったとき (OK)
  • 判定する情報が不足しているとき (INSUFFICIENT_DATA)

のタイミングで何らかの通知を行うことができます。また、アプリケーションのログ、top dfなどの結果をCloudWatchに送信して、監視・通知を代行してもらうこともできます(カスタムメトリクス)。

SNS……CloudWatchの通知先として

ではその通知はどこに行くのでしょうか。それを知るにはまずAmazon SNSについての説明が必要です。

AWSには、SNS (Simple Notification Service)というメッセージングサービスがあります。

f:id:nurenezumi:20150324185138p:plain

誰かがボタンを押すと(このボタン1個をトピックと呼びます)、あらかじめ登録してあった通知先にメッセージが送信されるだけのシンプルなサービスです。送信先にはEメールアドレス、WebサービスのエンドポイントのURL、モバイルプッシュ通知*1、そして後述のSQSが指定できます。

CloudWatchの通知先にはこのSNSを指定します。すると、

  1. EC2で障害が発生する
  2. CloudWatchが障害を検出して、指定されたトピックを叩く
  3. トピックは事前に登録されていたメールアドレスに通知を送信する
  4. 担当者がメールを受け取って復旧作業を行う

といった自動通知処理が実現できるわけです。

SQS……SNSの通知先として

AWSにはSQS (Simple Queue Service) というサービスが用意されています。これは名前の通り、メッセージを入れたり出したりするためのキューを提供します。キューは単独で使うこともできて、たとえばシナリオはこんな風になります。

  1. データを吐くシステムAと、そのデータを処理するシステムBがある
  2. システムAは不定期にデータを出力し、そのデータを書いたメッセージをキューに格納する
  3. システムBは暇になったときキューからメッセージを取得し、そこに書いてあるデータを処理する(処理したらメッセージを削除する)
  4. AとBは直接メッセージのやり取りはしないが、協調して非同期に動作することができる

「メッセージ」はプレーンテキストで記述された任意のデータです。最大サイズは256KBまで。多くの用途には十分なサイズです。

Slack APIと繋げると

弊社で利用しているチャットシステム・SlackAPIが充実しており、HTTP POSTで送信した内容をそのままチャットでbotに発言させるためのAPIがあります。この仕組みを使うと、簡単なバッチを走らせるだけでCloudWatchのアラートをSlackに送信することができます。

f:id:nurenezumi:20150327132345p:plain

  1. CloudWatchは警告発生時にSNSのトピックへメッセージを送信する
  2. そのトピックはSQSのキューにメッセージを格納する(ここまではAWSのコンソールから設定可能)
  3. EC2上に小さなバッチを用意する。このバッチは無限ループを回り、キューを監視する
  4. キューにメッセージが入ってくると、バッチはSlackにその旨を通知する

実にシンプルですね。

やってみる

というわけで、簡単に手順を書いてみます。今回はバッチにrubyを使いますが、PHPでもPowerShell(!!)でも手順はあまり変わりません。大まかな手順は次の通りです。 1. AWS APIを叩くためのユーザーと認証用キーを用意する 1. SQSのキューを作成する 1. SNSのトピックを作成してSQSとつなげる 1. CloundWatchのメトリクスを作成してSNSとつなげる 1. SQSを監視するスクリプトを用意する

API用のIAM Userを用意する

存在しなければ新しく作りましょう。ユーザー詳細画面のPermissionsタブの中にInline Policiesメニューがあるので、Policy Generatorで権限を作成します。Amazon SQSの下にある「Delete Message」「ReceiveMessage」だけ選択しておけば良いでしょう。

f:id:nurenezumi:20150324185425p:plain

Add Statementボタンを押すのを忘れないように

SQSを用意する

SQSで新しいキューを作成します。Create New Queueを選び、設定(まあデフォルトで良いでしょう)を決めたらすぐに作成されます。

f:id:nurenezumi:20150324185448p:plain

この時点でエンドポイントとARNが決まります

キューを作成すると、コンソールからテストメッセージを(手書きで)SQSへ送信したり、キューに入っているメッセージを確認・削除したりできます。右クリックメニューのSend a Messageを選択するとメッセージ送信ウィンドウが開きます。これをrubyで取得してみましょう。

gem install aws-sdkSDKがインストールできるので、その後は次のようなコードを実行するだけです。ロングポーリングを勝手にやってくれるので、自分でウェイトをかけたりする必要はありません。

require 'aws-sdk'
# 認証用キーのデフォルト値を設定する。今後APIを叩くたび毎回この値が送信される
Aws.config = {:access_key_id => '<アクセスキー>', :secret_access_key => '<シークレットキー>', :region => 'ap-northeast-1'}
# キューにアクセスするためのインスタンスを取得
poller = Aws::SQS::QueuePoller.new('https://sqs.ap-northeast-1.amazonaws.com/999999999999/HelloSQS')
poller.poll(:wait_time_seconds => 20, :idle_timeout => 5) {|msg| p msg}

実行結果はこんな感じでした。「HELLO WORLD!」がコンソールから入力した値です。またpollメソッドの戻り値として、メッセージの取得件数、最終メッセージのタイムスタンプなどが取れます。また、pollで受信したメッセージは自動的に削除されるため、明示的な削除は不明です(この動作は:skip_deleteにfalseを指定してスキップできます)。

irb(main):013:0> poller.poll(:wait_time_seconds => 20, :idle_timeout => 5) {|msg| p msg}
#<struct message_id="……", receipt_handle="……", md5_of_body="……", body="HELLO WORLD!", attributes={"SenderId"=>"……", "ApproximateFirstReceiveTimestamp"=>"1427414334046", "ApproximateReceiveCount"=>"1", "SentTimestamp"=>"1427414334044"}, md5_of_message_attributes=nil, message_attributes={}>

キューにパーミッションを追加する

現時点ではこのキューにメッセージを送ることができるのは自分だけです。SNSからメッセージを送信することができるように、Add a Permissionメニューからパーミッションを追加しておきましょう。

f:id:nurenezumi:20150324185543p:plain

エンドポイントを知っていればどこからでもこのキューにメッセージを投げ込むことができるようになる

 SNSを用意する

次はSNSです。Create New Topicでトピックを作成したら、Create Subscriptionで購読設定の追加を行います。ついでに自分のメールアドレスを追加しても結構です。(メールアドレスの確認作業が必要になるため手順は省略します)

これでSNSとSQSがつながったことになります。SNSのトップに戻り、Publish to topicをクリックしてテストメッセージを送信してみましょう。

f:id:nurenezumi:20150324190129p:plain

これをさっきのrubyのコードで取得してみます。結果のうち、bodyプロパティの値を示します。JSONエンコードされているのでちょっと整形しました。

{
    "Type" : "Notification",
    "MessageId" : "77c5f3c4-679b-5931-aed7-be4f5619a1d2",
    "TopicArn" : "arn:aws:sns:ap-northeast-1:999999999999:HelloSNS",
    "Subject" : "HELLO SNS!",
    "Message" : "HELLO I AM NEKO",
    "Timestamp" : "2015-03-24T09:32:50.814Z",
    "SignatureVersion" : "1",
    "Signature" : "wZMZ....g==",
    "SigningCertURL" : "https://....amazonaws.com/SimpleNotificationService-....pem",
    "UnsubscribeURL" : "https://....amazonaws.com/?Action=Unsubscribe&SubscriptionArn=...",
    "MessageAttributes" : {
      "AWS.SNS.MOBILE.MPNS.Type" : {"Type":"String","Value":"token"},
      "AWS.SNS.MOBILE.WNS.Type" : {"Type":"String","Value":"wns/badge"},
      "AWS.SNS.MOBILE.MPNS.NotificationClass" : {"Type":"String","Value":"realtime"}
    }
}

CloudWatchを用意する

AWS側はこれで最後です。CloudWatchのメトリクスを作成します。

f:id:nurenezumi:20150327132133p:plain

Descriptionは後から好きに変更できますが、Nameは一度作成したら変えられません。また、現状ではメトリクスにタグを付けることもできないので、監視内容のレベルに応じて「WARN」や「ERROR」といったプレフィックスを付けておくと良いかもしれません。図ではステータスがALARMになったときのみ通知を行っていますが、OKになったときにも通知を行うこともできます。

作成したらテストをしてみます。次のコードで、作成したメトリクスのコードをむりやりALARMにセットしてみます(これはテスト用コマンドなので、現在の値(正常値)にあわせて勝手に元に戻ります)。

cloudwatch = Aws::CloudWatch::Client.new(region: 'ap-northeast-1')
cloudwatch.set_alarm_state(alarm_name: "WARN_HelloCloudWatch", state_value: "ALARM", state_reason: "THIS IS NOT A DRILL")

こういう1回限りの操作はPowerShellでやってもいいです。

PS C:\>Initialize-AWSDefaults -accessKey '<アクセスキー>' -secretKey '<シークレットキー>' -Region ap-northeast-1
PS C:\>Set-CWAlarmState -AlarmName WARN_HelloCloudWatch -StateValue ALARM -StateReason "THIS IS NOT A DRILL"

メトリクスのステータスがALARMに変更され、SNS→SQSとメッセージが送信されてゆきます。

Slack botの準備をする

Slackへ何か送信するためには、事前にIncoming Webhookの設定画面でもらえるトークンが必要です。トークンがあれば、次の簡単なコードで送信ができます。

def saySomething(channel, text, username = nil, emoji = nil)
  token = '<slackのトークン>'
  uri = 'https://your-slack-domain-name.slack.com/services/hooks/incoming-webhook'

  params = {:channel => channel, :text => text, :username => 'SlackBot'}
  params[:username] = username if username
  params[:icon_emoji] = emoji if emoji

  query = {'payload' => params.to_json}.map{|k, v| "#{k}=#{v}"}.join('&')
  uri_parsed = URI.parse(uri)
  http = Net::HTTP.new(uri_parsed.host, 443)
  http.use_ssl = true

  http.post(uri_parsed.path + '?token=' + token, URI.escape(query)).body
end
  • textは発言内容です。改行は\r\nを使ってください。
  • channelは送信先チャンネル名で、'chanelName', '#privateGroupName', '@userName'のように指定します。スペルミスがあるとそのメッセージは闇に飲まれて消えてしまいます。
  • emojibotのアイコンを指定するための値で、':heart:'のようにコロンでくくった形で渡します。

つなげる

後は無限ループの中でキューをポーリングし、何かメッセージがあったらSlackにそのまま送信するだけです。

require 'aws-sdk'
Aws.config = {:access_key_id => '<アクセスキー>', :secret_access_key => '<シークレットキー>', :region => 'ap-northeast-1'}
poller = Aws::SQS::QueuePoller.new('https://sqs.ap-northeast-1.amazonaws.com/999999999999/HelloSQS')
channelName = 'your_channel_name'

while true
  poller.poll(:wait_time_seconds => 20, :idle_timeout => 5) {|msg|
    begin
      body = JSON.parse(msg.body)
      message = JSON.parse(body['Message'])
    rescue => e
      saySomething(channelName, "電文のパースに失敗しました: " + msg.body)
      return false
    end

    if message == nil || message['NewStateValue'] == nil || message['AlarmDescription'] == nil
      saySomething(channelName, "なんか変なメッセージが来てます: " + msg.body)
      return false
    end

    text = ''
    if message['NewStateValue'] == 'OK'
      saySomething(channelName,
        "[復帰] CloudWatchのアラートが解消しました。\r\n" +
        "Name : " + message['AlarmName'] + "\r\n" +
        "Descrpition : " + message['AlarmDescription'] + "\r\n" +
        "Reason : " + message['NewStateReason'],
        "よかったね",
        ":heart:")
    else
      saySomething(channelName,
        "[警告] CloudWatchのアラートが発生しました。\r\n" +
        "Name : " + message['AlarmName'] + "\r\n" +
        "Descrpition : " + message['AlarmDescription'] + "\r\n" +
        "Reason : " + message['NewStateReason'],
        "たいへんです",
        ":broken_heart:")
    end
  }
end

Slackにこうして通知が来ます。

f:id:nurenezumi:20150327132223p:plain

まとめ

エラー通知は大切ですが、受け取る側が気付かないのでは意味がありません。このSlack連携を導入して以来、エラー発生から対応までの時間が激減(多くの場合は即時)しました。 これだけ簡単な設定とバッチで通知システムができるのは楽しいですし、(得られる価値÷書いたコードの行数)は自分の仕事人生の中でも最大値を記録しました。

今回はSlackに通知をするようにしましたが、重要度に応じて携帯電話に音声発信をするTwillioAPIを呼べば電話通知もできるようになります。 これからもなるべく手抜きをして最大の成果が得られるように頑張っていこうと思います。

*1:要するにスマホに通知が出ます