TL; DR
監視において様々な通知方法が求められると思いますが、影響度の高いものに関しては電話連絡が必要だったりすると思います。
今回はその要件がありDatadogとAmazon Connectを用いて実装しました。
思った以上に簡単に実装できたので備忘録も兼ねて書き起こしていきます。
では早速やっていきます。
Amazon Connect
Amazon Connectはコールセンターを容易に作成できるという触れ込みでGAされたと記憶してましたので、正直結構限定的なのかなーと思っていて今まで触ってきませんでした。
しかし今回のような単純に電話かけたいみたいな要件でも簡単に利用することができます。
インスタンス作成
まずはAmazon Connectインスタンスを作成します。
赤枠のインスタンスを追加するをクリックします。
作成後アクセスできる設定用の画面があるのですが、おそらくそれとConnectの中身が動くためのターゲットグループ的なのを作るのでしょう。
Connect用コンソールのURLを決めます
管理者を決めますが、ここで作らなくてもあとから作成できるのでスキップしても大丈夫です。
個人的にはここで作っといた方がいいと思います。
ここはそのままで次へ
ログなどはS3バケットに保存されるのですがデフォルトは新規で作成されますが、設定カスタマイズからバケットを指定することも可能です。
設定を確認をして、インスタンスの作成を押下すると数分で作成されます。
作成後はアクセスURLからアクセスが可能です。
電話番号取得
上記のURLへアクセスするとダッシュボードが表示されます。
左ペインから電話番号を選択します
電話番号の取得を押下します。
すると画面に遷移するのですが、日本を選択すると注意画面がでてきます。
最初反社チェック的なものかと思ったのですが、一旦クリックするとその後保存ボタンがアクティブになりました。
問い合わせたところバグらしくすみません…とおっしゃってたので仕様だと思ってぽちぽちしてくださいw
無事に電話番号が取得できました。
問い合わせフローの設定
次にこの番号がどのようなアクション(問い合わせフロー)をするのかをGUIで作成していきます。
左ペインから問い合わせフローを選択
問い合わせフローの作成をポチします。
こんな感じで作成してください。大事なのは赤枠です。
プロンプトの再生をクリックすると右側ににょっと出てくるのでこんな感じで設定してください。赤枠は任意で大丈夫ですが、Lambdaで利用するものなのでお好みで設定してください。
設定が終わったら保存と公開を押しましょう。後ほどLambdaで利用するのでURLを控えておきましょう。
電話番号との紐付け
先ほど作成した電話番号の画面に行き、問い合わせフローを作成したものに変更します。
変更後保存を押下します。
こちらも後ほどLambdaで利用するので電話番号を控えておきましょう。
これでAmazon Connect側の設定は完了です。
AWSリソース
必要なAWSリソースを作成していきます。
Lambda
実際にAmazon Connectを介して電話するLambda関数を作成していきます。
と言ってもConnect側は非常にシンプルです。
今回はPythonで実装したのでboto3のConnect Clientでstart_outbound_voice_contactを利用します。
コードはこんな感じです。
# coding=utf-8
import logging
import json
import os
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
headquarters = os.environ['INCIDENT_MANAGEMENT_HEADQUARTERS']
def lambda_handler(event, context):
logger.info("Event: " + str(event))
message = event['Records'][0]['Sns']
connect = boto3.client('connect')
ssm = boto3.client('ssm')
alertMessage = message['Subject']
message = alertMessage + 'です。確認してください。'
logger.info("Message: " + str(message))
for boss in headquarters.split(","):
destination = ssm.get_parameter(
Name=boss,
WithDecryption=True
)
phoneNumber = destination['Parameter']['Value']
connect.start_outbound_voice_contact(
DestinationPhoneNumber=phoneNumber,
ContactFlowId='1234567a-zyxw-9876-12ab-098765def432',
InstanceId='987abc12-098v-gh56-78ij-4567klmn12op',
SourcePhoneNumber='+811234567890',
Attributes={
'alarm': message + message
}
)
先ほど控えておいたURLと電話番号をここで使います。
InstanceId
/ ContactFlowId
は下記のようになっていますので、ここからとりましょう。
SourcePhoneNumber
には取得した電話番号を入れます。
https://[アクセスURL].awsapps.com/connect/contact-flows/edit?id=arn:aws:connect:ap-northeast-1:12345678901:instance/[InstanceId]/contact-flow/[ContactFlowId]
これでOKです。
後ほど設定するDatadogから受け取るペイロードはこんな感じでした。
{
"Records": [
{
"EventSource": "aws:sns",
"EventVersion": "1.0",
"EventSubscriptionArn": "arn:aws:sns:ap-northeast-1:123456789012:test-connect:12345678-asnc-0987-zyxw",
"Sns": {
"Type": "Notification",
"MessageId": "0987654-abcd-1234-zyxw-12345abcde",
"TopicArn": "arn:aws:sns:ap-northeast-1:123456789012:test-connect",
"Subject": "[Recovered] [Synthetics] アプリケーションサイト正常性確認",
"Message": "@sns-test-connect\n\nMonitor Status: https://app.datadoghq.com/monitors#0987654?group=total · Edit Monitor: https://app.datadoghq.com/monitors#0987654/edit · Event URL: https://app.datadoghq.com/event/event?id=1234567890123456789",
"Timestamp": "2020-04-07T10:28:12.148Z",
"SignatureVersion": "1",
"Signature": "hogehogefugafuga==",
"SigningCertUrl": "https://sns.ap-northeast-1.amazonaws.com/SimpleNotificationService-abcdefghijklmnopqrstu.pem",
"UnsubscribeUrl": "https://sns.ap-northeast-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-northeast-1:123456789012:test-connect:12345678-asnc-0987-zyxw",
"MessageAttributes": {}
}
}
]
}
SNS
特段難しいことはないのでトピックを作成してサブスクリプションをLambdaで作成しましょう。
$ aws sns create-topic \
--name test-connect
$ aws sns subscribe \
--topic-arn arn:aws:sns:ap-northeast-1:123456789012:test-connect \
--protocol lambda \
--notification-endpoint arn:aws:lambda:ap-northeast-1:123456789012:function:test-connect
これでOKです。
Dadadog Synthetics
DatadogとAWSの連携はよしなにやってください。
Datadogでの外形監視にはSyntheticsというものが利用できます。
設定は非常に簡単でぽちぽちと入力するだけで利用が可能です。無料トライアルもありますし、10,000リクエスト/月まで$5で利用可能なので既に利用されている方はおすすめです。
APIのテストやブラウザテストなど幅広く実施できますが、今回は単純にサイトのダウンを検知したかったのでシンプルに設定していきます。
こんな感じでURLを指定して閾値などを設定していきます。
通知設定はこんな感じです。
{{#is_alert}} [サービスサイト異常検知](https://hogehoge.jp/) @sns-dd-2-connect @slack-alert-test {{/is_alert}}
{{#is_recovery}} [サービスサイト](https://hogehoge.jp/) 回復 @slack-alert-test {{/is_recovery}}
あとはアラートを設定して適当にやるだけです。
Slack通知もちゃんときてます。
電話もちゃんときてます。
ばっちりですね。
所感
24/7で頑張って対応する!みたいなのが少しでも減らせたら嬉しいですね。
ただほんとにクリティカルなものってユーザーからの指摘で気付くみたいなこともあると思うので、システム側で全て拾いきるにはまだまだ難しいですね。
Appendix
コマンドでちまちま作るのめんどくさいのでTerraformでざばっとAWSリソースは作ったので一応
リソースざばっと作るコード
provider "archive" {
version = "~> 1.3"
}
provider "aws" {
region = "ap-northeast-1"
version = "~> 2.50.0"
}
locals {
name = test-connect
}
variable "params" {
hoge = "+811234567890"
fuga = "+810987654321"
}
resource "aws_ssm_parameter" "params" {
for_each = var.params
name = each.key
type = "SecureString"
key_id = "alias/aws/ssm"
value = each.value
}
resource "aws_sns_topic" "topic" {
name = local.name
}
resource "aws_sns_topic_subscription" "subscription" {
topic_arn = aws_sns_topic.topic.arn
protocol = "lambda"
endpoint = aws_lambda_function.function.arn
}
resource "aws_cloudwatch_log_group" "log_group" {
name = "/aws/lambda/${local.name}"
retention_in_days = 14
}
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "lambda_basic" {
statement {
sid = "putLog"
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = "${aws_cloudwatch_log_group.log_group.arn}:*"
}
}
data "aws_iam_policy_document" "lambda_get_params" {
statement {
sid = "getParams"
effect = "Allow"
actions = [
"ssm:GetParameters",
"ssm:GetParameter"
]
resources = [for params in aws_ssm_parameter.params : params.arn]
}
}
resource "aws_iam_role" "lambda_basic" {
name = "lambda_basic_${local.name}"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
resource "aws_iam_policy" "lambda_basic" {
name = "lambda_basic_${local.name}"
policy = data.aws_iam_policy_document.lambda_basic.json
}
resource "aws_iam_policy" "lambda_get_params" {
name = "lambda_${local.name}_get_params"
policy = data.aws_iam_policy_document.lambda_get_params.json
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda_basic.name
policy_arn = aws_iam_policy.lambda_basic.arn
}
resource "aws_iam_role_policy_attachment" "lambda_get_params" {
role = aws_iam_role.lambda_basic.name
policy_arn = aws_iam_policy.lambda_get_params.arn
}
resource "aws_iam_role_policy_attachment" "lambda_connect_fullaccess" {
role = aws_iam_role.lambda_basic.name
policy_arn = "arn:aws:iam::aws:policy/AmazonConnectFullAccess"
}
data "archive_file" "function" {
type = "zip"
source_file = "${local.name}.py"
output_path = "${local.name}.lambda.zip"
}
resource "aws_lambda_function" "function" {
function_name = local.name
filename = data.archive_file.function.output_path
runtime = "python3.7"
handler = "${local.name}.lambda_handler"
source_code_hash = data.archive_file.function.output_base64sha256
role = aws_iam_role.lambda_basic.arn
environment {
INCIDENT_MANAGEMENT_HEADQUARTERS = "hoge,fuga"
}
lifecycle {
ignore_changes = [
last_modified,
source_code_hash,
]
}
}
resource "aws_lambda_permission" "sns" {
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.function.function_name
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.topic.arn
}