Amazon Connect で障害検知連絡をしてみた

AWS,Python,TIPSConnect,障害連絡

久々の投稿になってしまいました。
Amazon Connect を使用して Zabbix で検知した障害内容を自動で電話させる仕組みを作ってみたので概要を投稿します。

システム要件

まず最初に今回実現したかった要件です。

  • 架電先に優先順位を割り振り、先頭から順番に架電
  • 電話に出なかったら次のメンバーに架電
  • 誰も出なかったら、また先頭に戻って架電

連絡先に指定する運用メンバーは一人ではなく複数人設定するケースが多いと思うので電話に出られなければ連続して他のメンバーに電話を続けるようにします。

今回のケースでは誰も出ずに一巡した場合、先頭に戻って再度架電を再開するようにします。

Architecture

システム概要は次のようになっています。

Zabbix で障害を検知したらS3に障害情報が記載されたファイルを投げ込んでもらい、S3からはPutイベントを使ってSQSにメッセージを流し架電処理がスタートします。

実際の処理を担っているのはAmazon Connectの他に3種類のLambdaとSQSが2つ、ステータス管理用のDynamoDBという構成です。

Amazon Connect で困ったところ

基本的にAmazon Connect はインバウンドコールをベースにしたコンタクトセンターのサービスです。

・電話に応答したかどうか
・途中で顧客が通話を切ったかどうか
・顧客とAgent、どちらから通話を切ったのか

このような架電結果を確認出来るものは全てアウトバウンドでは対応していません。
LambdaからAmazon Connect を呼び出して架電させることは出来るのですがその結果は取得する術がなく、電話自体は非同期で実行されます。

よって、電話に出なかったら次のメンバーに架電を回すという要件はAmazon Connect 単体で実現することができないので、DynamoDBに応答ステータス持たせてLambdaから参照しその結果によって引き続き電話するかどうかの処理を分岐させることにしました。

応答ステータスは電話の冒頭で何かしらの数字を押すようガイダンスを流し、反応があった時はDynamoDBに結果を書き込むLambdaをAmazon Connect から呼び出します。

処理の流れ

まずは架電開始までの流れです。
前述した通り、トリガーとなるのはS3に対するファイル配置で次のように進みます。

トリガーからAmazon Connect 呼び出しまで

  1. 障害が起きたホスト名を含んだファイル配置
  2. Putイベントで処理開始用のSQSにキューイング
  3. SQSから架電開始用のLambdaを呼び出し
  4. LambdaからDynamoDBにクエリを投げて架電先一覧とその優先順位を取得
  5. 取得した架電先に対し優先順位の先頭メンバーへ架電するようAmazon Connect を呼び出し
  6. 結果確認用のLambda を実行させるためのSQSにキューイング

架電から応答結果の更新まで

  1. Lambdaから渡された連絡先に架電
  2. 電話に出たら番号を押下するようガイダンス再生
  3. 応答があればLambdaを呼び出して押された番号をDynamoDBに引き渡し
  4. Lambdaから渡された障害内容を再生

応答結果の取得から架電処理〜終了まで

  1. SQSの配信遅延で60秒 + α 待機
  2. 遅延後、応答結果を確認するLambdaを呼び出し
  3. LambdaからDynamoDBにクエリを投げ応答結果を取得
  4. 応答していれば処理終了、応答がなければ次の架電先情報をAmazon Connectへ引き渡し
    応答結果確認用のSQSに再度キューイングし終了

SQS側で60秒 + α 待機しているのは、Amazon Connectのアウトバウンドコールは架電先が電話に出なかった場合、最大60秒間コールし続ける仕様であるため59秒目に電話に出たケースを考慮しました。

サンプルコード

現状では全てを載せることが出来ないのでいくつかポイントで示します。

・連携されてくるトリガーファイル

{
  "host_name": "WEBアプリサーバ",
  "trigger_name": "CPU使用率が100%に張り付いた!",
  "event_date": "2020-07-30",
  "event_time": "17:21:44"
}

Zabbixからはこのように障害が発生したホスト名と内容、発生日時など最低限な項目のみを連携しています。

この形式であれば連携元がZabbixである必要性は全くないです。


・DynamoDB上のマスタ

{
  "Attrs": {
    "ErrorNotice": {},
    "Maintainers": [
      {
        "Name": "保守電話1",
        "PhoneNumber": "+81XXXXXXXXXX",
        "Priority": "1"
      },
      {
        "Name": "保守電話2",
        "PhoneNumber": "+81XXXXXXXXXX",
        "Priority": "2"
      },
      {
        "Name": "保守電話3",
        "PhoneNumber": "+81XXXXXXXXXX",
        "Priority": "3"
      }
    ]
  },
  "CreatedAt": "2020-08-22 15:00:00",
  "HostId": 1,
  "HostName": "WEBアプリサーバ",
  "UpdatedAt": ""
}

DynamoDBにはこのようにホスト名の他、架電先の情報を持たせ、電話を掛ける順番は Maintainers 属性の中に置いているPriorityで指定しています。

・LambdaからAmazon Conenctを呼び出す部分から抜粋

# DynamoDBへの障害情報書き込み
def update_dynamodb(contacts, info):
    [contact.setdefault("Answered", "-1") for contact in contacts]

    dynamoDB = boto3.resource("dynamodb")
    table = dynamoDB.Table(os.environ["DYNAMODB_TABLE"])
    table.update_item(
        Key={"HostName": info["host_name"]},
        UpdateExpression="set Attrs.ErrorNotice=:E",
        ExpressionAttributeValues={
            ":E": {
                "TriggerName": info["host_name"],
                "EventDate": info["event_date"],
                "EventTime": info["event_time"],
                "RecursionStatuses": contacts,
            },
        },
        ReturnValues="UPDATED_NEW",
    )

# 実際に電話を掛けさせる部分
def start_connect_flow(props: dict):
    priority_sorted = sorted(props["contacts"], key=lambda x: int(x["Priority"]))

    prompt = "<speak><p>システムエラー検知の連絡です。</p>"
    prompt += "<p>今から、10秒以内に数字の1を押してください。</p>"
    prompt += "<p>押されなかった場合、次のかたにもお電話いたします。</p></speak>"
    connect = boto3.client("connect")
    connect.start_outbound_voice_contact(
        DestinationPhoneNumber=priority_sorted[0]["PhoneNumber"],
        ContactFlowId=os.environ["CONNECT_CONTACT_FLOW_ID"],
        InstanceId=os.environ["CONNECT_INSTANCE_ID"],
        SourcePhoneNumber=os.environ["CONNECT_SOURCE_PHONE_NUMBER"],
        Attributes={
            "host": props["trigger"]["host_name"],
            "message": props["message"],
            "prompt": prompt,
            "priority": props["priority"],
        },
    )

update_dynamodb 関数で先に提示したDynamoDB上のマスタ内にある ErrorNotice を更新しています。
後述する応答結果を確認するLambdaではこの ErrorNotice の中身を参照します。

start_outbound_voice_contact ここで Attributes に引き渡した値はAmazon Connectで参照出来る他、コンタクトフローの中から他のLambdaを呼び出す際に引数で渡すことが可能です。

この仕組みを利用することで作り込む必要はありますがAmazon Connectがアウトバウンドで提供していない機能をカバーすることができます。

尚、promptの変数部分でHTMLのようなタグを使って書いている部分があります。
これはSSMLと呼ばれる、アレクサのスキルにも使われている合成音声用のマークアップ言語です。

<p>タグで囲まれた部分は段落となり区切り位置が考慮されたりします。
他には感情を込めさせたり、呼吸音を差し込むようなタグも存在していて結構面白いです。

通常の文字列で渡すことでも認識して読み上げてくれますが、サーバ名など意味を持たない英数字の羅列には弱いため変な英単語に置き換えて発声してしまうケースがありました。

試行錯誤した中でこのSSML形式にして<speak>タグで囲ってみたところ置き換えずにそのままアルファベットで発声するようになったため、こちらを採用しています。

他にも漢字やアルファベットでの発音がへんちくりんになる場面もあり、聞き取りやすい発声にするため敢えて平仮名で渡したりと細かな調整が必要でした。

・Connectから呼び出すLambdaから抜粋

input_data = event['Details']['Parameters']['inputData']
flag = '-2' if input_data == 'Timeout' else '1'
host_name = event['Details']['Parameters']['hostName']
response = table.get_item(Key={
    'HostName': host_name,
})

Amazon Connect から引数で渡した値は event['Details']['Parameters'] の中に格納されています。

尚、コンタクトフローで指定したタイムアウト値内に電話を受けたメンバーが何かしらの値を入力しなかった場合は Timeout という文字列がAWSによって埋め込まれています。

この関数では電話に出て何かしの値を入力していれば '1’ を、Timeoutであれば '-2’ をDynamoDBに書き込みます。

・結果を確認するLambdaから抜粋

def check_recursion_status(statuses, priority: str) -> bool:
    target = [x["Answered"] for x in statuses if x["Priority"] == priority][0]
    answered_flag = True if target == "1" else False

    return answered_flag


def get_next_contact(statuses, priority: str):
    priority = 1 if len(statuses) == int(priority) else int(priority) + 1
    target = [x for x in statuses if x["Priority"] == str(priority)][0]
    return target

check_recursion_status 関数ではAmazon Connect から引数で渡すPriority を使用してメンバーを特定し、電話に出たのかどうかAnswered属性を使い判定しています。

get_next_contact 関数では電話に出ていないことが判明した際、次に電話を掛けるメンバーを特定します。
Priorityがリストのサイズと一致している場合は一周したと判定し先頭のメンバーをターゲットに設定

不一致の場合は純粋に +1 したPriorityを使ってターゲット設定しています。

コストについて

Amazon Connect の課金形式は基本的に通話時間と通話回数で決まります。
今回のケースでは相手が電話に出た後の通話時間は、ガイダンスが流れた後そのまま通話終了となるためせいぜい多くて20秒程度です。

となると、あとはどれくらいの頻度で架電するか = 障害が発生するか で見積れます。
こちらのサイトさんが試算用のツールを公開してくれているので使わせてもらいました。

電話番号1つに通話時間1分、月に100回の架電としてみます。

目を疑うレベルの安さになりました…
24時間体制で人間を張るとどうしてもNode単位で月に100$近くのコストがかかったりしますが、これならNode数が幾つあっても純粋に障害発生件数で課金されるので上振れも少ないですね。

Zabbixなどのミドルウェア側で障害の規模を判定し軽めのものはSlack等に通知のみ、重度なものは通知+電話という風にコントロールすれば架電回数はより抑えられそうです。

まとめ

クラウドフォーメーションにネイティブ対応してる部分が少なかったりと痒いところに手が届かない感は多少なりともありますが、全体的にAmazon Connectは触っていてとても面白いサービスでした。

コンタクトフローにも種類がいろいろあり、試してみたくなるのですが一度作成したフローは物理削除不可というキツい制約があるので要注意です。

そのうえ、デフォルトでは単一アカウントに作成できるAmazon Connectインスタンスは2つまで。
これは申請することで緩和できるようですが、複数の部で共有しているアカウントなどの場合に留意が必要です。

AWS,Python,TIPSConnect,障害連絡

Posted by Kenny