RACCOON TECH BLOG

株式会社ラクーンホールディングスのエンジニア/デザイナーから技術情報をはじめ、世の中のためになることや社内のことなどを発信してます。

AWS LambdaとAPI Gatewayを利用し、PagerDutyのインシデント発生時にSlackに専用チャンネルを作成する #3

こんにちは、インフラ及びシステム運用を担当している田中です。

前回の記事ではPagerDutyのWebhookをLambda関数で受け、SlackのIncident専用のチャンネルを作成してメッセージをpostしたり、IncidentがResolvedになったら専用チャンネルをarchiveしたりするところまでご紹介しました。

大まかにまとめると、下記のシーケンス図のような仕組みになります。

sequence_1

しかし、専用チャンネルまで作成したのにpostしたメッセージは簡単なテキストだけで、見栄えもぱっとしなければ、大した情報も含まれていませんでした。

そこで今回は、Slackにpostするメッセージの見栄えを改善したり、障害対応に必要な情報をメッセージに含めるなど、前回作ったプログラムに機能を追加していきます。

また、せっかくLambdaを使用しているのに他のAWSの機能を使わないのはもったいないので、Node.jsのAWS SDKを利用してAWSの機能を利用する方法についても説明していきたいと思います。


Slackへの通知の見栄えを良くする

前回作成したプログラムではSlackのAPIであるchat.postMessageを利用して作成したチャンネルにメッセージをpostしました。

その際にAPIへ引数として渡した値は下記のようなJSONです。

{
  text: '新しいインシデントが発生しました'
}

しかし、これではあまりにシンプルすぎるので、引数として渡すJSONを工夫して見栄えを良くしていきましょう。

最終的には、下記のシーケンス図で言うと、「NEW」の所に表示されているような見た目を目指すことにします。

sequence_2

■ Message Builder について

チャンネルにpostするメッセージを色々と試す際に、その都度プログラムを書き換えて試してみるのでは効率が悪すぎます。
そこで、SlackではMessage Builderというツールで、メッセージの見た目を事前に確認できるようにしています。

slack301

「Enter your message JSON」のテキストエリアへJSONを貼り付けることにより、下の「Message Preview」が変わります。

ただし、貼り付けるJSONはフォーマットのチェックが厳密で、Key, Value共に文字列であればダブルクォートで括る必要があります。
JavaScript内で記述できるからといって、シングルクォートやクォート無しなどにするとエラーになりますので、ご注意ください。

■ メッセージのフォーマット

まずはメッセージの本文である「text」の中身をフォーマットしてみましょう。

SlackではMarkdownライクな記法での文字列修飾や絵文字が利用可能です。利用可能な記法はSlack APIのドキュメントを御覧ください。

試しに下記のJSONをMessage Builderで確認してみましょう。

{
  "text": "*Bold*\n_Italic_\n`code`\n```\ncode\nblock\n```\n:ok:"
}

すると下記のように表示されます。

slack302

これらのフォーマット記法はSlackのクライアントから入力する際にも使用可能なものになります。
ただし、\nでの改行はクライアントからの入力では効果がありません。

使用できる絵文字の一覧はこちらを御覧ください。

次に、下記のJSONを貼り付けてみてください。

{
  "text": "<mailto:hogehoge@example.com|HOGE HOGE>\n<http://www.superdelivery.com|SUPER DELIVERY>"
}

slack303

URLをSlackにpostするとURL部分が自動的にリンクになりますが、APIからメッセージをpostする場合は、リンク部分のラベルを指定できることが判ります。

■ chat.postMessageに渡せるパラメータについて

メッセージの本文である「text」内でどのようなことができるかを見てきましたが、「text」以外のオプションを使用するとどのようなことができるでしょうか。

chat.postMessage に渡せるパラメータはSlack APIのドキュメントに記載されています。

{
  "username": "ユーザー名",
  "icon_url": "http://www.superdelivery.com/img/common/footer/icon_group_sd.gif",
  "text": "https://www.youtube.com/watch?v=a7kwaIXhCO8"
}

slack304

ユーザー名やアイコンの指定ができることが判ります。

{
  "username": "ユーザー名",
  "icon_emoji": ":robot_face:",
  "text": "ここが本文です。"
}

slack305

アイコンは絵文字を使うこともできます。

その他にもいくつか表示を制御するためのオプションがありますが、Message Builderでは指定するとエラーになってしまいます。

■ attachmentsについて

chat.postMessage に渡せるオプションに「attachments」という項目があります。

他のオプションと比べて多少複雑なところがありますが、その分見た目を大きくカスタマイズすることが可能です。

少し長いですが、下記のJSONをMessage Builderで見てみましょう。

{
  "username": "PagerDuty Bot",
  "icon_emoji": ":robot_face:",
  "text": "新しいインシデントが発生しました",
  "attachments": [
    {
      "fallback": "スーパーデリバリーで致命的なエラーが発生しました - データベースエラー - 商品情報の登録に失敗しました",
      "color": "#ff9999",
      "pretext": "スーパーデリバリーで致命的なエラーが発生しました",
      "author_name": "SUPER DELIVERY",
      "author_link": "http://www.superdelivery.com/",
      "author_icon": "http://www.superdelivery.com/img/common/footer/icon_group_sd.gif",
      "title": "データベースエラー",
      "title_link": "http://www.raccoon.ne.jp/",
      "text": "商品情報の登録に失敗しました",
      "fields": [
        {
          "title": "エラーレベル",
          "value": "Emergency",
          "short": true
        },
        {
          "title": "緊急度",
          "value": "緊急",
          "short": true
        },
        {
          "title": "担当者",
          "value": "メイン担当",
          "short": true
        },
        {
          "title": "関連部署",
          "value": "技術戦略部, デザイン戦略部, SD事業推進部, 社長室",
          "short": false
        }
      ],
      "image_url": "http://www.superdelivery.com/img/common/logo/logo_header.gif",
      "thumb_url": "http://www.superdelivery.com/img/guide/about/2016/icon_01.png",
      "footer": "PagerDuty to Slack bot",
      "footer_icon": "http://factory.superdelivery.com/",
      "ts": 123456789
    },
    {
      "color": "good",
      "title": "Goodな状態です"
    },
    {
      "color": "warning",
      "title": "Warningな状態です"
    },
    {
      "color": "danger",
      "title": "Dangerな状態です"
    }
  ]
}

slack306

ほぼ想定通りの表示になっていますが、thumb_urlで指定した画像が表示されていませんね。

image_urlを消してみると表示されますので、image_urlが優先で、どちらかしか表示できない仕様のようです。

■ テストプログラムからSlackにメッセージをpostしてみる

それでは簡単なテストプログラムを書いて、実際にSlackへpostしてみましょう。

tanaka@ubuntu:~$ mkdir ~/work/test
tanaka@ubuntu:~$ cd ~/work/test
tanaka@ubuntu:~/work/test$ npm install slack-node
tanaka@ubuntu:~/work/test$ vi test.js

下記のコードをtest.jsに保存します。

【test.js】

001: 'use strict';
002:
003: var SLACK_API_TOKEN = '****-***********-***********-***********-**********';
004:
005: var Slack = require('slack-node');
006: var slack = new Slack(SLACK_API_TOKEN);
007:
008: var item = {
009:   "channel": "#general",
010:   "username": "PagerDuty Bot",
011:   "icon_emoji": ":robot_face:",
012:   "text": "新しいインシデントが発生しました",
013:   "attachments": [
014:     {
015:       "fallback": "スーパーデリバリーで致命的なエラーが発生しました - データベースエラー - 商品情報の登録に失敗しました",
016:       "color": "#ff9999",
017:       "pretext": "スーパーデリバリーで致命的なエラーが発生しました",
018:       "author_name": "SUPER DELIVERY",
019:       "author_link": "http://www.superdelivery.com/",
020:       "author_icon": "http://www.superdelivery.com/img/common/footer/icon_group_sd.gif",
021:       "title": "データベースエラー",
022:       "title_link": "http://www.raccoon.ne.jp/",
023:       "text": "商品情報の登録に失敗しました",
024:       "fields": [
025:         {
026:           "title": "エラーレベル",
027:           "value": "Emergency",
028:           "short": true
029:         },
030:         {
031:           "title": "緊急度",
032:           "value": "緊急",
033:           "short": true
034:         },
035:         {
036:           "title": "担当者",
037:           "value": "メイン担当",
038:           "short": true
039:         },
040:         {
041:           "title": "関連部署",
042:           "value": "技術戦略部, デザイン戦略部, SD事業推進部, 社長室",
043:           "short": false
044:         }
045:       ],
046:       "image_url": "http://www.superdelivery.com/img/common/logo/logo_header.gif",
047:       "footer": "PagerDuty to Slack bot",
048:       "footer_icon": "http://factory.superdelivery.com/",
049:       "ts": 123456789
050:     },
051:     {
052:       "color": "good",
053:       "title": "Goodな状態です"
054:     },
055:     {
056:       "color": "warning",
057:       "title": "Warningな状態です"
058:     },
059:     {
060:       "color": "danger",
061:       "title": "Dangerな状態です"
062:     }
063:   ]
064: };
065:
066: slack.api(
067:   'chat.postMessage',
068:   item,
069:   (err, res) => {
070:     if (!err && res.ok) {
071:       console.log('Message posted.');
072:       console.log(JSON.stringify(res, null, 2));
073:       return;
074:     }
075:     console.error(
076:       'Can not post message. ('
077:         + 'Error: ' + JSON.stringify(res, null, 2) + ')'
078:     );
079:   }
080: );

それでは実行してみましょう。

tanaka@ubuntu:~/work/test$ node test.js
Can not post message. (Error: {
  "ok": false,
  "error": "invalid_array_arg"
})

エラーになりましたね。

実はattachmentsをAPIのオプションとして渡す際には、単純に配列として渡すのではなく、配列をさらにJSON化して文字列として渡してあげる必要があります。

そのため下記のようにコードを修正すれば、想定通り動作します。

001: 'use strict';
002:
003: var SLACK_API_TOKEN = '****-***********-***********-***********-**********';
004:
005: var Slack = require('slack-node');
006: var slack = new Slack(SLACK_API_TOKEN);
007:
008: var attachments = [
009:   {
010:     "fallback": "スーパーデリバリーで致命的なエラーが発生しました - データベースエラー - 商品情報の登録に失敗しました",
011:     "color": "#ff9999",
012:     "pretext": "スーパーデリバリーで致命的なエラーが発生しました",
013:     "author_name": "SUPER DELIVERY",
014:     "author_link": "http://www.superdelivery.com/",
015:     "author_icon": "http://www.superdelivery.com/img/common/footer/icon_group_sd.gif",
016:     "title": "データベースエラー",
017:     "title_link": "http://www.raccoon.ne.jp/",
018:     "text": "商品情報の登録に失敗しました",
019:     "fields": [
020:       {
021:         "title": "エラーレベル",
022:         "value": "Emergency",
023:         "short": true
024:       },
025:       {
026:         "title": "緊急度",
027:         "value": "緊急",
028:         "short": true
029:       },
030:       {
031:         "title": "担当者",
032:         "value": "メイン担当",
033:         "short": true
034:       },
035:       {
036:         "title": "関連部署",
037:         "value": "技術戦略部, デザイン戦略部, SD事業推進部, 社長室",
038:         "short": false
039:       }
040:     ],
041:     "image_url": "http://www.superdelivery.com/img/common/logo/logo_header.gif",
042:     "footer": "PagerDuty to Slack bot",
043:     "footer_icon": "http://factory.superdelivery.com/",
044:     "ts": 123456789
045:   },
046:   {
047:     "color": "good",
048:     "title": "Goodな状態です"
049:   },
050:   {
051:     "color": "warning",
052:     "title": "Warningな状態です"
053:   },
054:   {
055:     "color": "danger",
056:     "title": "Dangerな状態です"
057:   }
058: ];
059:
060: var item = {
061:   "channel": "#general",
062:   "username": "PagerDuty Bot",
063:   "icon_emoji": ":robot_face:",
064:   "text": "新しいインシデントが発生しました",
065:   "attachments": JSON.stringify(attachments)
066: };
067:
068: slack.api(
069:   'chat.postMessage',
070:   item,
071:   (err, res) => {
072:     if (!err && res.ok) {
073:       console.log('Message posted.');
074:       console.log(JSON.stringify(res, null, 2));
075:       return;
076:     }
077:     console.error(
078:       'Can not post message. ('
079:         + 'Error: ' + JSON.stringify(res, null, 2) + ')'
080:     );
081:   }
082: );

tanaka@tanaka:~/work/test$ node test.js
Message posted.
{
  "ok": true,
  "channel": "C0Y0KV5J9",
  "ts": "1467092490.000021",
  "message": {
    "text": "新しいインシデントが発生しました",
    "username": "PagerDuty Bot",
    "icons": {
      "emoji": ":robot_face:",
      "image_64": "https://a.slack-edge.com/d4bf/img/emoji_2015_2/apple/1f916.png"
    },
    "bot_id": "B19KSQV2P",
    "attachments": [
      {
        "fallback": "スーパーデリバリーで致命的なエラーが発生しました - データベースエラー - 商品情報の登録に失敗しました",
        "image_url": "http://www.superdelivery.com/img/common/logo/logo_header.gif",
        "image_width": 111,
        "image_height": 27,
        "image_bytes": 3283,
        "author_name": "SUPER DELIVERY",
        "text": "商品情報の登録に失敗しました",
        "pretext": "スーパーデリバリーで致命的なエラーが発生しました",
        "title": "データベースエラー",
        "footer": "PagerDuty to Slack bot",
        "id": 1,
        "title_link": "http://www.raccoon.ne.jp/",
        "author_link": "http://www.superdelivery.com/",
        "author_icon": "http://www.superdelivery.com/img/common/footer/icon_group_sd.gif",
        "ts": 123456789,
        "color": "ff9999",
        "fields": [
          {
            "title": "エラーレベル",
            "value": "Emergency",
            "short": true
          },
          {
            "title": "緊急度",
            "value": "緊急",
            "short": true
          },
          {
            "title": "担当者",
            "value": "メイン担当",
            "short": true
          },
          {
            "title": "関連部署",
            "value": "技術戦略部, デザイン戦略部, SD事業推進部, 社長室",
            "short": false
          }
        ]
      },
      {
        "title": "Goodな状態です",
        "id": 2,
        "color": "36a64f",
        "fallback": "NO FALLBACK DEFINED"
      },
      {
        "title": "Warningな状態です",
        "id": 3,
        "color": "daa038",
        "fallback": "NO FALLBACK DEFINED"
      },
      {
        "title": "Dangerな状態です",
        "id": 4,
        "color": "d00000",
        "fallback": "NO FALLBACK DEFINED"
      }
    ],
    "type": "message",
    "subtype": "bot_message",
    "ts": "1467092490.000021"
  }
}

slack307

想定通りの表示になりました。

■ Interactive buttons

最近、attachmentsに「Interactive buttons」という機能が追加されました。

この機能は文字通りattachments内にボタンを配置し、ユーザーがボタンをクリックすると事前に設定したURLがコールバックされるという機能です。

「Interactive buttons」についてここで説明すると長くなりますので、いずれ改めて別記事としてpostしたいと思います。

AWSの機能を利用してみる

ここまでSlackにpostするメッセージの見た目をカスタマイズする方法を見てきましたが、ここからはAWSのサービスを使用して機能拡張を行う方法について説明していきます。

■ AWS SDKについて

前回、AWS CLIを使用してローカル環境からAWSの機能を制御する方法を説明しましたが、今回はLambda関数内からAWSの機能を制御することになるため、AWS CLIは使用できません。

そこで、node.jsからAWS SDKを利用する方法を説明します。

AWS SDKは自作のプログラム内からAWSの機能を制御するためのライブラリで、こちらに書かれているように様々な言語から利用することが可能です。

ここではnode.jsから利用することになりますので、npmコマンドを使用してインストールします。

tanaka@ubuntu:~$ cd ~/work/test/
tanaka@ubuntu:~/work/test$ npm install aws-sdk
/home/tanaka/work/test
├─┬ aws-sdk@2.4.2
│ ├── jmespath@0.15.0
│ ├── sax@1.1.5
│ ├── xml2js@0.4.15
│ └─┬ xmlbuilder@2.6.2
│   └── lodash@3.5.0
└── slack-node@0.2.0

AWS SDK for JavaScriptの詳しいAPIドキュメントはこちらにあります。

AWS CLIを使用する際に認証を行うためにIAMユーザーを作成しましたが、AWS SDKからAWSのサービスを利用する際も同様にIAMユーザーによる認証を行う必要があります。

前回作成したものを流用することも可能ではありますが、セキュリティの観点から専用のユーザーを作成することをおすすめします。

作成したユーザーの認証情報をファイルに保存しておきます。

tanaka@ubuntu:~$ cd ~/work/test/
tanaka@ubuntu:~/work/test$ cat aws_config.json
{
    "accessKeyId": "********************",
    "secretAccessKey": "****************************************",
    "region": "ap-northeast-1"
}

また、AWS CLIからもこのユーザーを利用できるように設定しておきます。

tanaka@ubuntu:~$ aws configure --profile lambda_aws_sdk
AWS Access Key ID [None]: ********************
AWS Secret Access Key [None]: ****************************************
Default region name [None]: ap-northeast-1
Default output format [None]: json

AWS CLIでは --profile オプションを使用することにより、IAMユーザーを切り替えることができます。

IAMユーザーに付与するポリシーは使用するサービスによって異なりますので、都度説明します。

■ Incidentが発生した際にS3に保存されたテキストをpostする

それでは、実際にAWSのサービスをnode.jsから呼んでみましょう。

前回作成したLambda関数では、PagerDutyでインシデントが発生したら、専用チャンネルを作成する処理を実装しました。

その後、その専用チャンネルで障害対応時のコミュニケーションをとることになるのですが、障害というのは得てして同じような現象が繰り返し発生してしまうものです。
その際に以前対応した内容を参考にして対応することも多いかと思います。

そこでインシデントが発生した際に、すでに対応したことがある内容であれば、対応方法を専用チャンネルにpostする仕組みを構築してみることにします。

下記のシーケンス図で言うと、黄色い点線で囲まれた「NEW」の部分になります。

sequence_3

具体的な実装方法としては、インシデントのSubjectから生成したハッシュ値をファイル名とみなして、S3にファイルが存在すれば、そのファイルの内容をメッセージとしてpostすることにします。

まずはIAMユーザーでS3が扱えるようにポリシーを付与しましょう。

iam301

ポリシータイプのフィルターに「s3」と入力するとS3関連のポリシーが表示されますので、その中から「AmazonS3FullAccess」を選択し、「ポリシーのアタッチ」ボタンをクリックしてください。

次に、S3にバケットを作成します。

tanaka@ubuntu:~$ aws s3 mb s3://***********-manuals/ --profile lambda_aws_sdk
make_bucket: s3://***********-manuals/

次に、S3にアップするファイルを用意します。

まずはSubjectからハッシュ値を生成します。
ここでは「test for pd2slack」というSubjectとします。

tanaka@ubuntu:~/work$ echo -n 'test for pd2slack' | sha256sum
822aac424981b32df748cac8a95b181d2ea780e7fd2ca75bfbc9b54b1d4bd9f1  -

echoコマンドに -n オプションを付けるのを忘れないでください。
忘れると最後に改行コードが付いた文字列のハッシュ値を計算してしまいます。

それから対応方法を保存するファイルを作成します。

tanaka@ubuntu:~$ mkdir ~/work/manuals
tanaka@ubuntu:~$ cat work/manuals/822aac424981b32df748cac8a95b181d2ea780e7fd2ca75bfbc9b54b1d4bd9f1
[
  {
    "pretext": "【問題点】",
    "color": "danger",
    "title": "思考回路が無限ループしています",
    "text": "目的と無関係な事項をすべて洗い出そうとしましたが、目的と無関係な事項は無限に存在しています",
    "fields": [
      {
        "title": "エラーレベル",
        "value": "Emergency",
        "short": false
      }
    ]
  },
  {
    "pretext": "【対応方法】",
    "color": "good",
    "title": "R2-D2を発明してください",
    "text": "人間のように判断することができるロボットを発明すれば解決します"
  }
]

ファイルの中身はattachmentsにそのまま入れることができるように、json形式にしておきます。

最後に、ファイルをS3にアップロードします。

tanaka@ubuntu:~$ aws s3 sync work/manuals s3://***********-manuals/ --profile lambda_aws_sdk
upload: work/manuals/822aac424981b32df748cac8a95b181d2ea780e7fd2ca75bfbc9b54b1d4bd9f1 to s3://***********-manuals/822aac424981b32df748cac8a95b181d2ea780e7fd2ca75bfbc9b54b1d4bd9f1
tanaka@ubuntu:~/work$ aws s3 ls s3://***********-manuals/
2016-06-06 12:31:22        636 822aac424981b32df748cac8a95b181d2ea780e7fd2ca75bfbc9b54b1d4bd9f1

それでは、簡単なテストプログラムでS3から読み込んだファイルの中身を元に、Slackへメッセージを書き込んでいきましょう。

【s3_test.js】

001: 'use strict';
002:
003: var SLACK_API_TOKEN = '****-***********-***********-***********-**********';
004: var SUBJECT = 'test for pd2slack';
005:
006: var Slack = require('slack-node');
007: var slack = new Slack(SLACK_API_TOKEN);
008:
009: var AWS = require('aws-sdk');
010: AWS.config.loadFromPath('aws_config.json');
011: var s3 = new AWS.S3({apiVersion: '2006-03-01'});
012:
013: var Crypto = require('crypto');
014:
015: var hash = Crypto.createHash('sha256');
016: hash.update(SUBJECT);
017: var digest = hash.digest('hex');
018:
019: var params = {
020:   Bucket: '***********-manuals',
021:   Key: digest
022: };
023:
024: s3.getObject(params).promise().then((res) => {
025:
026:   var attachments = res.Body.toString('utf8');
027:
028:   var item = {
029:     'channel': '#general',
030:     'username': 'R2-D1',
031:     'icon_emoji': ':robot_face:',
032:     'text': 'インシデントの対応マニュアルがありました',
033:     'attachments': attachments
034:   };
035:
036:   return new Promise((resolve, reject) => {
037:     slack.api(
038:       'chat.postMessage',
039:       item,
040:       (err, res) => {
041:         if (!err && res.ok) {
042:           resolve(res);
043:         }
044:         reject(new Error(JSON.stringify(res, null, 2)));
045:       }
046:     );
047:   });
048:
049: }).then((res) => {
050:   console.log('Message posted. (' + JSON.stringify(res, null, 2) + ')');
051: }).catch((err) => {
052:   if (err.code != 'NoSuchKey') {
053:     console.error('Error: ' + err);
054:   }
055: });

実行してみましょう。

tanaka@ubuntu:~/work/test$ node s3_test.js
Message posted. ({
  "ok": true,
  "channel": "*********",
  "ts": "1467181021.000016",
  "message": {
    "text": "インシデントの対応マニュアルがありました",
    "username": "R2-D1",
    "icons": {
      "emoji": ":robot_face:",
      "image_64": "https://a.slack-edge.com/d4bf/img/emoji_2015_2/apple/1f916.png"
    },
    "bot_id": "*********",
    "attachments": [
      {
        "text": "目的と無関係な事項をすべて洗い出そうとしましたが、目的と無関係な事項は無限に存在しています",
        "pretext": "【問題点】",
        "title": "思考回路が無限ループしています",
        "id": 1,
        "color": "d00000",
        "fields": [
          {
            "title": "エラーレベル",
            "value": "Emergency",
            "short": false
          }
        ],
        "fallback": "NO FALLBACK DEFINED"
      },
      {
        "text": "人間のように判断することができるロボットを発明すれば解決します",
        "pretext": "【対応方法】",
        "title": "R2-D2を発明してください",
        "id": 2,
        "color": "36a64f",
        "fallback": "NO FALLBACK DEFINED"
      }
    ],
    "type": "message",
    "subtype": "bot_message",
    "ts": "1467181021.000016"
  }
})

slack308

ちゃんと書き込むことができましたね。

■ SESでメール送ってみる

専用チャンネルを作成したのはいいのですが、まだ担当者がチャンネルにjoinしていない状態です。

適切な担当者がjoinできるように、SESを使用して専用チャンネルが作成された旨をメールで告知してみましょう。

下記のシーケンス図で言うと、黄色い点線で囲まれた「NEW」の部分になります。

sequence_4

まずはIAMユーザーでSESが扱えるようにポリシーを付与しましょう。

iam302

ポリシータイプのフィルターに「ses」と入力するとSES関連のポリシーが表示されますので、その中から「AmazonSESFullAccess」を選択し、「ポリシーのアタッチ」ボタンをクリックしてください。

SESでメールを送信するためには事前に準備が必要となります。

まず理解しておかなければいけないのは、SESを初めて使う場合、こちらに書かれているようにサンドボックス環境での動作となることです。

サンドボックス環境ではメールの送信数が厳しく制限されており、送信先のメールアドレスも先に検証を行ったものしか指定できません。

アカウントをサンドボックス外に移動するためには、AWSへ申請を行い、許可されることが必要になります。

今回は不特定多数にメールを送信するわけではありませんので、サンドボックス環境のまま実装を進めることにします。

また、送信元(From:ヘッダーに設定されるメールアドレス)はサンドボックス外であっても検証を行う必要があります。

それでは、実際にメールアドレスを検証してみましょう。

まずはAWSの管理コンソールの「アプリケーションサービス」にある「SES」をクリックしてください。

ses301

選択されているリージョンが「アジアパシフィック (東京)」などSESがサポートされていない場所であった場合、下記のページが表示されますので「米国東部 (バージニア北部)」あたりを選んでください。

ses302

ここで指定したリージョンはAWS SDKからアクセスする際に指定する必要があります。

SESの管理コンソールが表示されたら、左側のメニューで「Identity Management」の「Email Addresses」をクリックしてください。

ses303

メールアドレスの検証を行うページが表示されますので、「Verify a New Email Address」ボタンをクリックしてください。

ses304

メールアドレスを入力するダイアログが表示されますので、「Email Address:」欄に検証したいメールアドレスを入力し、「Verify This Email Address」ボタンをクリックしてください。

ses305

検証用のメールを指定したメールアドレスに送信した旨の完了ダイアログが表示されますので、「Close」ボタンをクリックしてください。

ses306

入力したメールアドレスに検証用のメールが届いていますので、メールに記載されている検証用のURLへアクセスしてください。

ses307

これでメールアドレスの検証は完了です。

ses308

これをメールの送信元や送信先に指定するメールアドレス分繰り返してください。

なお、「Identity Management」の「Domains」から特定のドメインのメールアドレスをまとめて検証することもできます。

ただし使用するドメインのDNSでTXTレコードを設定する必要がありますので、DNSの設定が行える場合に限られます。

これでSES側の準備は完了しましたので、AWS SDKでSESを利用してメールを送信してみましょう。

【ses_test.js】

001: 'use strict';
002:
003: var MAIL_FROM = '**********@**********.***';
004: var MAIL_TO = '**********@**********.***';
005: var MAIL_SUBJECT = '新しいインシデントが発生しました。'
006:
007: var AWS = require('aws-sdk');
008: AWS.config.loadFromPath('aws_config.json');
009: var ses = new AWS.SES({
010:   apiVersion: "2010-12-01",
011:   region: 'us-east-1'
012: });
013:
014: var mail_body = [
015:   '新しいインシデントが発生しました。',
016:   '',
017:   '【問題点】',
018:   '思考回路が無限ループしています',
019:   '目的と無関係な事項をすべて洗い出そうとしましたが、目的と無関係な事項は無限に存在しています',
020:   '',
021:   'エラーレベル:Emergency',
022:   '',
023:   '【専用チャンネル】',
024:   'https://raccoon-test.slack.com/messages/test0001/'
025: ].join("\n");
026:
027: var params = {
028:   'Source': MAIL_FROM,
029:   'Destination': {
030:     'ToAddresses': [MAIL_TO]
031:   },
032:   'Message': {
033:     'Subject': {
034:       'Charset': 'utf-8',
035:       'Data': MAIL_SUBJECT
036:     },
037:     'Body': {
038:       'Text': {
039:         'Charset': 'utf-8',
040:         'Data': mail_body
041:       }
042:     }
043:   }
044: }
045:
046: ses.sendEmail(params).promise().then((res) => {
047:   console.log('E-mail sent successfully. (' + JSON.stringify(res, null, 2) + ')');
048: }).catch((err) => {
049:   console.error('Error: ' + err);
050: });

このプログラムで注意しなければいけないのは、11行目でSESを使うリージョンを指定していることです。

今回使用する他のAWSサービスは東京リージョン(ap-northeast-1)で使用できますが、SESは前述の通り東京リージョンではサービスが開始されていませんので、SESの管理コンソールで指定したリージョンに合わせる必要があります。

tanaka@tanaka-ubuntu:~/work/test$ node ses_test.js
E-mail sent successfully. ({
  "ResponseMetadata": {
    "RequestId": "********-****-****-****-************"
  },
  "MessageId": "****************-********-****-****-****-************-******"
})

届いたメールを確認してみましょう。

ses309

ちゃんと送信できたようですね。

前回作成したプログラムに機能追加する

それでは、ここまで見てきた機能を前回作成したプログラムに追加していきましょう。

■ プログラムファイルが長くなって見難いので、ファイルを分割する

前回作成したプログラムも200行以上あり、そこにさらにコードを追加するということになると、流石にそろそろ1ファイルにすべてを記述するのでは見づらくなってきます。

そこで機能ごとにファイルを分け、index.jsと同じディレクトリに別のjsファイルとして保存します。

【SlackUtils.js】

0001: 'use strict';
0002:
0003: var SLACK_API_TOKEN = '****-***********-***********-***********-**********';
0004: var CHANNEL_NAME_PREFIX = 'incident_';
0005:
0006: var MESSAGE_CONFIG = {
0007:   'incident.trigger': {'color': 'danger', 'text': '新しいインシデントが発生しました'},
0008:   'incident.acknowledge': {'color': 'warning', 'text': '担当者がインシデントを確認しました'},
0009:   'incident.resolve': {'color': 'good', 'text': 'インシデントがクローズされました'},
0010:   'incident.escalate': {'color': 'danger', 'text': 'インシデントがエスカレーションされました'}
0011: };
0012:
0013: var Slack = require('slack-node');
0014: var slack = new Slack(SLACK_API_TOKEN);
0015:
0016: module.exports = {
0017:
0018:   makeChannelName: (message) => {
0019:     return CHANNEL_NAME_PREFIX + message.data.incident.incident_number;
0020:   },
0021:
0022:   makeMessageItem: (channel_id, message) => {
0023:     if (typeof MESSAGE_CONFIG[message.type] !== 'undefined') {
0024:         var fields = [{
0025:           'title': 'Service',
0026:           'value': message.data.incident.service.name,
0027:           'short': true
0028:         }];
0029:         if (message.data.incident.assigned_to_user) {
0030:           fields.push({
0031:             'title': 'Assigned to',
0032:             'value': message.data.incident.assigned_to_user.name,
0033:             'short': true
0034:           });
0035:         }
0036:         var attachments = [{
0037:           'color': MESSAGE_CONFIG[message.type].color,
0038:           'title': 'Incident #' + message.data.incident.incident_number
                            + ' (' + message.data.incident.id + ')',
0039:           'title_link': message.data.incident.html_url,
0040:           'text': message.data.incident.trigger_summary_data.subject,
0041:           'fields': fields
0042:         }];
0043:         return {
0044:           'channel': channel_id,
0045:           'username': 'PagerDuty to Slack Bot',
0046:           'icon_emoji': ':robot_face:',
0047:           'text': MESSAGE_CONFIG[message.type].text,
0048:           'attachments': JSON.stringify(attachments)
0049:         }
0050:     }
0051:     console.log('Message ignored, because message type "'
                        + message.type + '" has no need to post.');
0052:     return null;
0053:   },
0054:
0055:   makeManualItem: (manual) => {
0056:     if (manual) {
0057:       var item = {
0058:         'channel': manual.channel_id,
0059:         'username': 'PagerDuty to Slack Bot',
0060:         'icon_emoji': ':robot_face:',
0061:         'text': 'インシデントの対応マニュアルがありました',
0062:         'attachments': manual.attachments
0063:       };
0064:       return item;
0065:     }
0066:     return null;
0067:   },
0068:
0069:   createChannelIfNotExists: (channel_name) => {
0070:     return new Promise((resolve, reject) => {
0071:       slack.api(
0072:         'channels.list',
0073:         {exclude_archived: 1},
0074:         (err, res) => {
0075:           if (!err && res.ok) {
0076:             if (
0077:               !res.channels.some((channel, i, c) => {
0078:                 if (channel.name == channel_name) {
0079:                   console.log('Channel found. (' + channel_name + ')');
0080:                   resolve(channel);
0081:                   return true;
0082:                 }
0083:                 return false;
0084:               })
0085:             ) {
0086:               console.log('Channel not found. (' + channel_name + ')');
0087:               resolve(null);
0088:             }
0089:             return;
0090:           }
0091:           reject(
0092:             new Error(
0093:               'Can not get channnel list. ('
0094:                 + 'Error: ' + JSON.stringify(res, null, 2)
0095:                 + ', Response: ' + JSON.stringify(res, null, 2) + ')'
0096:             )
0097:           );
0098:         }
0099:       );
0100:     }).then((channel) => {
0101:       if (channel) {
0102:         return Promise.resolve(channel);
0103:       }
0104:       return new Promise((resolve, reject) => {
0105:         slack.api(
0106:           'channels.create',
0107:           {name: channel_name},
0108:           (err, res) => {
0109:             if (!err && res.ok) {
0110:               console.log('Channel created. (ID:' + res.channel.id
                                            + ', Name:' + channel_name + ')');
0111:               resolve(res.channel);
0112:               return;
0113:             }
0114:             reject(
0115:               new Error(
0116:                 'Can not create channnel. ('
0117:                   + 'Error: ' + JSON.stringify(res, null, 2)
0118:                   + ', Response: ' + JSON.stringify(res, null, 2) + ')'
0119:               )
0120:             );
0121:           }
0122:         );
0123:       });
0124:     }).catch((error) => {
0125:       console.error(error);
0126:     });
0127:   },
0128:
0129:   post: (item) => {
0130:     if (item) {
0131:       return new Promise((resolve, reject) => {
0132:         slack.api(
0133:           'chat.postMessage',
0134:           item,
0135:           (err, res) => {
0136:             if (!err && res.ok) {
0137:               console.log('Message posted.');
0138:               console.log(JSON.stringify(res, null, 2));
0139:               resolve();
0140:               return;
0141:             }
0142:             reject(
0143:               new Error(
0144:                 'Can not post message. ('
0145:                   + 'Error: ' + JSON.stringify(res, null, 2)
0146:                   + ', Response: ' + JSON.stringify(res, null, 2) + ')'
0147:               )
0148:             );
0149:           }
0150:         );
0151:       }).catch((error) => {
0152:         console.error(error);
0153:       });
0154:     }
0155:   },
0156:
0157:   archiveChannel: (channel_id, channel_name) => {
0158:     return new Promise((resolve, reject) => {
0159:       slack.api(
0160:         'channels.archive',
0161:         {channel: channel_id},
0162:         (err, res) => {
0163:           if (!err && res.ok) {
0164:             console.log('Channel archived. (ID:' + channel_id
                                         + ', Name:' + channel_name + ')');
0165:             resolve();
0166:           } else {
0167:             reject(
0168:               new Error(
0169:                 'Can not archive channel. ('
0170:                   + 'Error: ' + JSON.stringify(res, null, 2)
0171:                   + ', Response: ' + JSON.stringify(res, null, 2) + ')'
0172:               )
0173:             );
0174:           }
0175:         }
0176:       );
0177:     }).catch((error) => {
0178:       console.error(error);
0179:     });
0180:   }
0181:
0182: }

【AwsUtils.js】

0001: 'use strict';
0002:
0003: var MANUAL_S3_BUCKET = '***********-manuals';
0004: var MAIL_FROM = '**********@**********.***';
0005: var MAIL_TO = '**********@**********.***';
0006:
0007: var EMAIL_CONFIG = {
0008:   'incident.trigger': {'subject': '新しいインシデントが発生しました'},
0009:   'incident.acknowledge': {'subject': '担当者がインシデントを確認しました'},
0010:   'incident.resolve': {'subject': 'インシデントがクローズされました'}
0011: };
0012:
0013: var AWS = require('aws-sdk');
0014: AWS.config.loadFromPath('aws_config.json');
0015: var s3 = new AWS.S3({apiVersion: '2006-03-01'});
0016: var ses = new AWS.SES({
0017:   apiVersion: "2010-12-01",
0018:   region: 'us-east-1'
0019: });
0020:
0021: var Crypto = require('crypto');
0022:
0023: module.exports = {
0024:
0025:   fetchManual: (channel_id, subject) => {
0026:     var hash = Crypto.createHash('sha256');
0027:     hash.update(subject);
0028:     var digest = hash.digest('hex');
0029:     var params = {
0030:       Bucket: MANUAL_S3_BUCKET,
0031:       Key: digest
0032:     };
0033:     return s3.getObject(params).promise().then((res) => {
0034:       console.log('Manual found. (' + subject + ')');
0035:       return Promise.resolve({
0036:         'channel_id': channel_id,
0037:         'attachments': res.Body.toString('utf8')
0038:       });
0039:     }).catch((error) => {
0040:       if (error.code == 'NoSuchKey') {
0041:         console.log('Manual not found. (' + subject + ')');
0042:       } else {
0043:         console.error('Can not fetch manual. (' + JSON.stringify(error, null, 2) + ')');
0044:       }
0045:       return Promise.resolve(null);
0046:     });
0047:   },
0048:
0049:   makeEmail: (channel_id, message) => {
0050:     if (typeof EMAIL_CONFIG[message.type] !== 'undefined') {
0051:       var assigned_to;
0052:       if (message.data.incident.assigned_to_user) {
0053:         assigned_to = message.data.incident.assigned_to_user.name;
0054:       } else {
0055:         assigned_to = 'N/A';
0056:       }
0057:       var mail_body = [
0058:         EMAIL_CONFIG[message.type].subject,
0059:         '',
0060:         message.data.incident.trigger_summary_data.subject,
0061:         'Incident #' + message.data.incident.incident_number
                                                     + ' (' + message.data.incident.id + ')',
0062:         '',
0063:         'Service: ' + message.data.incident.service.name,
0064:         'Assigned to: ' + assigned_to,
0065:         '',
0066:         '【専用チャンネルURL】',
0067:         'https://raccoon-test.slack.com/messages/' + channel_id,
0068:         '',
0069:         '【インシデントURL】',
0070:         message.data.incident.html_url
0071:       ].join("\n");
0072:       var email = {
0073:         'Source': MAIL_FROM,
0074:         'Destination': {
0075:           'ToAddresses': [MAIL_TO]
0076:         },
0077:         'Message': {
0078:           'Subject': {
0079:             'Charset': 'utf-8',
0080:             'Data': EMAIL_CONFIG[message.type].subject
0081:           },
0082:           'Body': {
0083:             'Text': {
0084:               'Charset': 'utf-8',
0085:               'Data': mail_body
0086:             }
0087:           }
0088:         }
0089:       };
0090:       console.log(JSON.stringify(email, null, 2));
0091:       return email;
0092:     }
0093:     return null;
0094:   },
0095:
0096:   sendEmail: (email) => {
0097:     if (email) {
0098:       ses.sendEmail(email).promise().then((res) => {
0099:         console.log('E-mail sent successfully. (' + JSON.stringify(res, null, 2) + ')');
0100:       }).catch((error) => {
0101:         console.error('Can not send e-mail. (' + JSON.stringify(error, null, 2) + ')');
0102:       });
0103:     }
0104:     return Promise.resolve(null);
0105:   }
0106:
0107: }

【index.js】

0001: 'use strict';
0002: console.log('Loading function');
0003:
0004: var slack_utils = require('./SlackUtils');
0005: var aws_utils = require('./AwsUtils');
0006:
0007: exports.handler = (event, context, callback) => {
0008:
0009:   if (
0010:     typeof event.messages !== 'undefined' &&
0011:     Array.isArray(event.messages)
0012:   ) {
0013:
0014:     console.log(JSON.stringify(event, null, 2));
0015:
0016:     var channels = new Map();
0017:
0018:     console.log('Creating channels...');
0019:     Promise.all(
0020:       event.messages.map((message, i, m) => {
0021:         var channel_name = slack_utils.makeChannelName(message);
0022:         return slack_utils.createChannelIfNotExists(channel_name);
0023:       })
0024:     ).then((results) => {
0025:       console.log('done.');
0026:       results.forEach((channel, i, c) => {
0027:         if (channel && !channels.has(channel.name)) {
0028:           channels.set(channel.name, channel.id);
0029:         }
0030:       });
0031:       console.log('Posting messages...');
0032:       return Promise.all(
0033:         event.messages.map((message, i, m) => {
0034:           var channel_name = slack_utils.makeChannelName(message);
0035:           if (channels.has(channel_name)) {
0036:             var channel_id = channels.get(channel_name);
0037:             return slack_utils.post(
0038:               slack_utils.makeMessageItem(channel_id, message)
0039:             );
0040:           }
0041:           console.warn('Can not post message, because channel "'
                                     + channel_name + '" is not exists.');
0042:         })
0043:       );
0044:     }).then((results) => {
0045:       console.log('done.');
0046:       console.log('Fetching manuals...');
0047:       return Promise.all(
0048:         event.messages.map((message, i, m) => {
0049:           if (message.type == 'incident.trigger') {
0050:             var channel_name = slack_utils.makeChannelName(message);
0051:             if (channels.has(channel_name)) {
0052:               var channel_id = channels.get(channel_name);
0053:               return aws_utils.fetchManual(
0054:                 channel_id,
0055:                 message.data.incident.trigger_summary_data.subject
0056:               );
0057:             }
0058:           }
0059:         })
0060:       );
0061:     }).then((results) => {
0062:       console.log('done.');
0063:       console.log('Posting manuals...');
0064:       return Promise.all(
0065:         results.map((manual, i, m) => {
0066:           if (manual) {
0067:             return slack_utils.post(slack_utils.makeManualItem(manual));
0068:           }
0069:         })
0070:       );
0071:     }).then((results) => {
0072:       console.log('done.');
0073:       console.log('Archiving channels...');
0074:       return Promise.all(
0075:         event.messages.map((message, i, m) => {
0076:           if (message.type == 'incident.resolve') {
0077:             var channel_name = slack_utils.makeChannelName(message);
0078:             if (channels.has(channel_name)) {
0079:               var channel_id = channels.get(channel_name);
0080:               return slack_utils.archiveChannel(channel_id, channel_name);
0081:             }
0082:             console.warn('Can not archive channel, because channel "'
                                            + channel_name + '" is not exists.');
0083:           }
0084:         })
0085:       );
0086:     }).then((results) => {
0087:       console.log('done.');
0088:       console.log('Sending emails...');
0089:       return Promise.all(
0090:         event.messages.map((message, i, m) => {
0091:           var channel_name = slack_utils.makeChannelName(message);
0092:           if (channels.has(channel_name)) {
0093:             var channel_id = channels.get(channel_name);
0094:             return aws_utils.sendEmail(aws_utils.makeEmail(channel_id, message));
0095:           }
0096:           console.warn('Can not post message, because channel "'
                                     + channel_name + '" is not exists.');
0097:         })
0098:       );
0099:     }).then((results) => {
0100:       console.log('done.');
0101:       callback(null, 'done.');
0102:     });
0103:
0104:   }
0105:
0106: }

SlackUtils.jsの16行目やAwsUtils.jsの25行目でmodule.exportsへ代入することにより、index.jsの4~5行目のrequireで読み込めるようにしています。

それから、今回の機能追加でLambda外へのアクセスが増えますので、念のためタイムアウトの時間をLambdaの管理コンソールで伸ばしておきましょう。

lambda301

ここではタイムアウトの時間を10秒から30秒に増やしておきます。

変更後に左上にある「Save」ボタンをクリックするのを忘れないようにしてください。

それではPagerDutyでインシデントを作成し、Acknowledge → Resolveと処理を進めてみましょう。

lambda302

ちゃんとSlackへ書き込みができていますね。

送信したメールはどうでしょうか?

lambda303

lambda304

lambda305

ちゃんと届いていますね!

最後に

ここまで3回にわたってPagerDutyとSlackの連携を改善する方法について見てきましたが、このような手法はPagerDutyやSlackに限らず、自分ではカスタマイズをすることができないサービスに対して機能を加えることができるという意味では、様々な応用が利くと思われます。

また、今回は触れませんでしたが、Lambdaでは「Scheduled Event」という機能でcronのような定期実行を行うことができます。
Scheduled Eventを利用すれば、トリガーとなるようなイベントが無くても、異なる複数のサービスを連携させることもできるでしょう。

この記事をご覧になっている皆様も、利用中のサービスで融通が利かなかったり、痒いところに手が届かなかったりと、不便な思いをされていることも多いかと思います。
今回の連載が、そういった問題を解決するための一助になれば幸いです。

一緒にラクーンのサービスを作りませんか? 採用情報を詳しく見る

関連記事

運営会社:株式会社ラクーンホールディングス(c)2000 RACCOON HOLDINGS, Inc