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

前回の記事に引き続き、AWSのAPI Gateway経由でLambdaファンクションを呼び出し、Slackの
APIを叩いて専用チャンネルを作成する手順を説明していきます。

前回の記事ではPagerDutyのWebhookをLambda関数で受け取るところまでご紹介しました。

これからLambda関数内でSlackのAPIをコールするための処理を記述することになります。

簡単なLambda関数を書くだけであれば特に開発環境が必要になるわけではないのですが、少し複雑な処理を行おうとすると外部モジュールが必要になってきたりしますので、まずは開発環境を作ることから始めましょう。

また、開発したコードのアップロードも管理コンソールからのアップロードは面倒ですし、ファイルサイズ等の制限もあります。
そこで、AWS CLIを利用して開発したコードをS3経由でデプロイするようにします。

本格的に開発に入る前に開発環境を用意する

以下、ローカル環境のOSとしてUbuntuが動作していることを前提に環境構築の説明を進めていきますが、Linux系のOSであれば似たような手順で環境構築が可能なはずです。

■ Node.jsでLambda関数を開発する環境を用意する

Lambda関数は前述の通りJavaやPythonでも作成できますが、今回はNode.jsを使用します。
Node.jsについてはこちら等を御覧ください。

今回はOS標準のパッケージを利用せず、NVM(Node Version Manager)を利用してインストールを行います。

まずはローカル環境でコンパイルが行える必要がありますので、必要なパッケージをインストールします。

tanaka@ubuntu:~$ sudo apt-get update
tanaka@ubuntu:~$ sudo apt-get install build-essential libssl-dev

次にNVMをインストールします。
v0.31.0は記事執筆時点での最新版です。実行される際に最新版をこちらでご確認ください。

tanaka@ubuntu:~$ curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.0/install.sh | bash
tanaka@ubuntu:~$ . ~/.nvm/nvm.sh

tanaka@ubuntu:~$ nvm --version
0.31.0

NVMのインストールが完了しましたので、NVMを使用してNode.js及びnpmをインストールします。
バージョンを指定してインストールすることもできますが、とりあえずは最新版をインストールします。

tanaka@ubuntu:~$ nvm install node

tanaka@ubuntu:~$ which node
/home/tanaka/.nvm/versions/node/v5.10.1/bin/node
tanaka@ubuntu:~$ node --version
v5.10.1

tanaka@ubuntu:~$ which npm
/home/tanaka/.nvm/versions/node/v5.10.1/bin/npm
tanaka@ubuntu:~$ npm --version
3.8.3

これでNode.jsの開発環境の準備は完了しました。

■ AWS CLIをインストールする

次に、AWS CLIをインストールしていきます。

AWS CLIはPythonで書かれたプログラムで、Pythonのパッケージマネージャーであるpipを利用してインストールすることになります。

まずはOSのパッケージマネージャーでPythonとpipをインストールしましょう。

tanaka@ubuntu:~$ sudo apt-get install python-pip python-setuptools

tanaka@ubuntu:~$ pip --version
pip 8.1.1 from /usr/lib/python2.7/dist-packages (python 2.7)

次にpipを利用してAWS CLIをインストールします。

tanaka@ubuntu:~$ sudo pip install awscli

tanaka@ubuntu:~$ aws --version
aws-cli/1.10.19 Python/2.7.11+ Linux/4.4.0-17-generic botocore/1.4.10

これでAWS CLIのインストールはできましたが、インストールしただけではAWS CLIは使用できません。

■ IAMユーザーを作成する

AWS CLIを使用するためには、AWSの管理コンソールでIAMユーザーを作成し、そのユーザーの認証情報をAWS CLIへ設定する必要があります。

まずはAWSの管理コンソールでIAMユーザーを作成してみましょう。
IAMユーザーは管理コンソールの「セキュリティ&アイデンティティ」にある「Identity & Access Management」で作成できます。

aws201

「Identity & Access Management」の管理コンソールで左のメニューから「ユーザー」をクリックしてください。

aws202-1

「新規ユーザーの作成」ボタンをクリックします。

aws202-2

ユーザー名の入力を求められますので、適切な名前を付け「作成」ボタンをクリックしてください。

aws203

完了画面では「ユーザーセキュリティ認証情報をダウンロードできる最後の機会です」と言われますが、認証情報は再作成できますのでご安心ください。
「認証情報のダウンロード」ボタンをクリックすると、認証情報が記載されたcsvファイルをダウンロードすることができます。

この認証情報を知っているとAWS CLIで何でもできてしまうため、厳重に保管するようにしてください。

「閉じる」をクリックするとユーザーの一覧に戻ります。

aws204

ユーザー一覧で先ほど作成したユーザーをクリックするとユーザーの詳細ページへ遷移します。

このページで「アクセスキーの作成」ボタンをクリックすると認証情報の再作成が可能です。

ここでは「アクセス許可」のタブをクリックしてください。

最初はアクセスが何も許可されていない状態ですね。

管理ポリシーのところにある「ポリシーのアタッチ」ボタンをクリックしてください。

aws205

ポリシーが大量にあって迷いますが、とりあえずはCLIから色々なことができるように「AdministratorAccess」権限を付与しましょう。
慣れてきたら、必要な権限のみ付与するようにした方がいいですね。

aws206

これでIAMユーザーの設定は完了です。

■ AWS CLIのCredential設定を行う

それでは、先ほど作成したIAMユーザーでAWS CLIが使えるように設定してみましょう。

設定はaws configureコマンドで行います。

AWS Access Key IDとAWS Secret Access Keyは先程ダウンロードしたcsvファイルに記載されています。

Default region nameはLambda関数を作成したリージョンを指定してください。
アジアパシフィック(東京)の場合は「ap-northeast-1」です。

Default output formatは出力結果のフォーマットを指定します。
json, text, tableが指定できますが、jsonにしておくと出力結果を加工しやすいのでおすすめです。

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

これでCredentialの設定ができたはずです。

tanaka@ubuntu:~$ cat ~/.aws/credentials 
[default]
aws_access_key_id = ********************
aws_secret_access_key = ****************************************

tanaka@ubuntu:~$ cat ~/.aws/config
[default]
output = json
region = ap-northeast-1

ちゃんと設定できたかどうか試すため、現在作成されているLambda関数の一覧を見てみましょう。

tanaka@ubuntu:~$ aws lambda list-functions
{
    "Functions": [
        {
            "Version": "$LATEST", 
            "CodeSha256": "********************************************", 
            "FunctionName": "test", 
            "VpcConfig": {
                "SubnetIds": [], 
                "SecurityGroupIds": []
            }, 
            "MemorySize": 128, 
            "CodeSize": 273, 
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:************:function:test", 
            "Handler": "index.handler", 
            "Role": "arn:aws:iam::************:role/lambda_basic_execution", 
            "Timeout": 10, 
            "LastModified": "2016-04-06T06:10:09.685+0000", 
            "Runtime": "nodejs4.3", 
            "Description": "test"
        }
    ]
}

ちゃんと設定できてますね!

これで開発環境の準備は完了です。

なお、AWS CLIは途中までコマンドを打ち、最後にhelpを付け加えるとマニュアルを見ることができます。

tanaka@ubuntu:~$ aws lambda help

ローカル開発環境からLambda関数をデプロイしてみる

それではローカル環境でLambda関数を開発していきましょう。

■ ソースコードをローカル環境に準備する

まずは作業用のディレクトリとLambda関数用のファイルを作成します。

tanaka@ubuntu:~$ mkdir -p ~/work/pd2slack
tanaka@ubuntu:~$ touch ~/work/pd2slack/index.js

前回説明した通り、Node.jsの場合にHandlerをindex.handlerにしておくと、index.jsというファイルのexport.handlerに設定されたfunctionがコールされます。

それではindex.jsをお好きなエディタで編集し、とりあえず前回と同一のソースコードにしましょう。

tanaka@ubuntu:~$ cat ~/work/pd2slack/index.js
'use strict';
console.log('Loading function');

exports.handler = (event, context, callback) => {

  console.log('Received event:', JSON.stringify(event, null, 2));

  callback(null, 'success!');

};

これでソースコードの準備は完了です。

■ S3のバケットを用意し、ソースコードをzipファイルに圧縮してアップロードする

次に、ソースコードアップロードするためのS3のバケットを作成します。
ここでは***********-pd2slackというバケットを作成しています。

tanaka@ubuntu:~$ aws s3 mb s3://***********-pd2slack
make_bucket: s3://***********-pd2slack/

できたかどうか確認してみましょう。

tanaka@ubuntu:~$ aws s3 ls
2016-04-10 18:48:46 ***********-pd2slack

ちゃんとできてますね。

作成したバケットにソースコードをアップロードするのですが、zipファイルにしなければいけません。

tanaka@ubuntu:~$ cd ~/work/pd2slack/
tanaka@ubuntu:~/work/pd2slack$ zip -r ~/work/pd2slack.zip ./*
  adding: index.js (deflated 25%)

作成したzipファイルをS3へアップロードします。

tanaka@ubuntu:~/work/pd2slack$ aws s3 cp ~/work/pd2slack.zip s3://***********-pd2slack/
upload: ../pd2slack.zip to s3://***********-pd2slack/pd2slack.zip

確認してみましょう。

tanaka@ubuntu:~/work/pd2slack$ aws s3 ls s3://***********-pd2slack/
2016-04-10 19:45:47        319 pd2slack.zip

ちゃんとアップロードできましたね。

■ アップロードしたzipファイルをソースコードに指定して、Lambda関数を作成する

それでは、Lambda関数を先ほどアップロードしたzipファイルをソースコードに指定して作成してみましょう。
--role の指定は先程確認したLambda関数の情報からコピーします。

tanaka@ubuntu:~/work/pd2slack$ aws lambda create-function --function-name 'pd2slack' \
  --runtime 'nodejs4.3' --role 'arn:aws:iam::************:role/lambda_basic_execution' \
  --handler 'index.handler' --code 'S3Bucket=***********-pd2slack,S3Key=pd2slack.zip' \
  --description 'Post message from PagerDuty to Slack.' --timeout 10 --memory 128 --publish
{
    "CodeSha256": "********************************************", 
    "FunctionName": "pd2slack", 
    "CodeSize": 319, 
    "MemorySize": 128, 
    "FunctionArn": "arn:aws:lambda:ap-northeast-1:************:function:pd2slack", 
    "Version": "1", 
    "Role": "arn:aws:iam::************:role/lambda_basic_execution", 
    "Timeout": 10, 
    "LastModified": "2016-04-10T11:03:33.533+0000", 
    "Handler": "index.handler", 
    "Runtime": "nodejs4.3", 
    "Description": "Post message from PagerDuty to Slack."
}

念のため確認もしましょう。

tanaka@ubuntu:~$ aws lambda list-functions
{
    "Functions": [
        {
            "Version": "$LATEST", 
            "CodeSha256": "********************************************", 
            "FunctionName": "pd2slack", 
            "MemorySize": 128, 
            "CodeSize": 319, 
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:************:function:pd2slack", 
            "Handler": "index.handler", 
            "Role": "arn:aws:iam::************:role/lambda_basic_execution", 
            "Timeout": 10, 
            "LastModified": "2016-04-10T11:03:33.533+0000", 
            "Runtime": "nodejs4.3", 
            "Description": "Post message from PagerDuty to Slack."
        }, 
        {
            "Version": "$LATEST", 
            "CodeSha256": "********************************************", 
            "FunctionName": "test", 
            "VpcConfig": {
                "SubnetIds": [], 
                "SecurityGroupIds": []
            }, 
            "MemorySize": 128, 
            "CodeSize": 282, 
            "FunctionArn": "arn:aws:lambda:ap-northeast-1:************:function:test", 
            "Handler": "index.handler", 
            "Role": "arn:aws:iam::************:role/lambda_basic_execution", 
            "Timeout": 10, 
            "LastModified": "2016-04-10T10:27:39.225+0000", 
            "Runtime": "nodejs4.3", 
            "Description": "test"
        }
    ]
}

ちゃんと追加されていますね。

■ API Gatewayで作成したLambda関数を公開する

前回とは異なりCLIからLambda関数を作成したため、それだけではAPI Gateway経由でアクセスすることができません。

そこでAPI GatewayとLambda関数を結びつける設定を入れていくことになりますが、この作業はCLIで行うと大変な作業になるため、管理コンソールから作業します。

まずは作成したLambda関数の設定ページへアクセスし、「API endpoints」のタブを開いてください。

lambda201

「+ Add API endpoint」をクリックすると、まずはエンドポイントのタイプを聞かれますが、API Gatewayしか選択肢がありません。

lambda202

API Gatewayを選択すると前回と同じような設定画面になりますので、同じように設定しましょう。

lambda203

「Submit」ボタンをクリックして設定完了です。

それでは、前回作成したPagerDutyのWebhookのURLを今回作成したLambda関数のものに変更し、Incidentを発生させてみましょう。

CloudWatchのログが確認できれば成功です。

clowdwatch201

SlackのAPIをLambda関数からコールし、Incident専用チャンネルを作成する

SlackのAPIを呼び出す側の準備はすべて完了しましたので、今度は呼び出されるSlack側の準備を行います。

■ API Tokenを用意する

通常、SlackのAPIを呼び出すBotのようなプログラムを作成する場合、Bot用のAPI Tokenを発行してもらい、Slack Real Time Messaging APIにそのTokenを渡すことで処理を行います。

しかし、Bot用のTokenでは一部のメソッドしか使用することができず、特にチャンネルを作成するメソッドが使用できないため、今回の要件を満たすことができません。

Slackが用意しているメソッドをすべて使用するためには、OAuth 2.0で認証を行ってTokenを取得する必要がありますが、今回のように汎用的にサービスとして提供するような機能ではない場合、Tokenの取得のためだけにOAuthの実装まで行うのは面倒です。

そこで、Slackが用意している開発・テスト用のTokenジェネレーターを利用してTokenを発行してもらうことにします。

このツールで発行されるTokenはSlackのログインユーザーの権限がすべて付与されているため、基本的にログインしてできることはほぼAPI経由で実行可能ですので、他人に知られないよう気をつけてください。

いつも使用しているアカウントではなく、API専用のユーザーを作成し、そのユーザーでTokenを発行するようにした方が良いでしょう。

以下の例では「@r2d1」というAPI専用のユーザーでTokenを発行します。

それではTokenを発行するユーザーでSlackにログインした後に、下記のURLにアクセスしてください。


「Create token」をクリックしてください。

slack201

Tokenが発行され、Slack側の準備も完了しました。

slack202

■ Slack-node npmについて

SlackのAPIはHTTPSでHTTP RPCスタイルのメソッドをコールするだけですので、HTTPSのクライアントライブラリがあれば呼び出すことは可能です。

しかしSlackのAPIのようにメジャーなAPIでは、専用のクライアントライブラリが用意されていることが多いです。

今回はNode.jsで実装しますので、slack-nodeというnpmモジュールを利用します。

slack-nodeはAPIコール部分をラッピングしてくれるだけなので、呼び出すメソッドの詳細はメソッド一覧を参照してください。

それでは、ローカル開発環境でslack-nodeをインストールしてみましょう。

tanaka@ubuntu:~$ cd ~/work/pd2slack/
tanaka@ubuntu:~/work/pd2slack$ npm install slack-node
/home/tanaka/work/pd2slack
└─┬ slack-node@0.2.0 
    └─┬ requestretry@1.6.0 
        ├─┬ fg-lodash@0.0.2 
        │  ├── lodash@2.4.2 
        │  └── underscore.string@2.3.3 
        ├─┬ request@2.70.0 
        │  ├── aws-sign2@0.6.0 
        │  ├─┬ aws4@1.3.2 
        │  │  └─┬ lru-cache@4.0.1 
        │  │      ├── pseudomap@1.0.2 
        │  │      └── yallist@2.0.0 
        │  ├─┬ bl@1.1.2 
        │  │  └─┬ readable-stream@2.0.6 
        │  │      ├── core-util-is@1.0.2 
        │  │      ├── inherits@2.0.1 
        │  │      ├── isarray@1.0.0 
        │  │      ├── process-nextick-args@1.0.6 
        │  │      ├── string_decoder@0.10.31 
        │  │      └── util-deprecate@1.0.2 
        │  ├── caseless@0.11.0 
        │  ├─┬ combined-stream@1.0.5 
        │  │  └── delayed-stream@1.0.0 
        │  ├── extend@3.0.0 
        │  ├── forever-agent@0.6.1 
        │  ├─┬ form-data@1.0.0-rc4 
        │  │  └── async@1.5.2 
        │  ├─┬ har-validator@2.0.6 
        │  │  ├─┬ chalk@1.1.3 
        │  │  │  ├── ansi-styles@2.2.1 
        │  │  │  ├── escape-string-regexp@1.0.5 
        │  │  │  ├─┬ has-ansi@2.0.0 
        │  │  │  │  └── ansi-regex@2.0.0 
        │  │  │  ├── strip-ansi@3.0.1 
        │  │  │  └── supports-color@2.0.0 
        │  │  ├─┬ commander@2.9.0 
        │  │  │  └── graceful-readlink@1.0.1 
        │  │  ├─┬ is-my-json-valid@2.13.1 
        │  │  │  ├── generate-function@2.0.0 
        │  │  │  ├─┬ generate-object-property@1.2.0 
        │  │  │  │  └── is-property@1.0.2 
        │  │  │  ├── jsonpointer@2.0.0 
        │  │  │  └── xtend@4.0.1 
        │  │  └─┬ pinkie-promise@2.0.1 
        │  │      └── pinkie@2.0.4 
        │  ├─┬ hawk@3.1.3 
        │  │  ├── boom@2.10.1 
        │  │  ├── cryptiles@2.0.5 
        │  │  ├── hoek@2.16.3 
        │  │  └── sntp@1.0.9 
        │  ├─┬ http-signature@1.1.1 
        │  │  ├── assert-plus@0.2.0 
        │  │  ├─┬ jsprim@1.2.2 
        │  │  │  ├── extsprintf@1.0.2 
        │  │  │  ├── json-schema@0.2.2 
        │  │  │  └── verror@1.3.6 
        │  │  └─┬ sshpk@1.7.4 
        │  │      ├── asn1@0.2.3 
        │  │      ├─┬ dashdash@1.13.0 
        │  │      │  └── assert-plus@1.0.0 
        │  │      ├── ecc-jsbn@0.1.1 
        │  │      ├── jodid25519@1.0.2 
        │  │      ├── jsbn@0.1.0 
        │  │      └── tweetnacl@0.14.3 
        │  ├── is-typedarray@1.0.0 
        │  ├── isstream@0.1.2 
        │  ├── json-stringify-safe@5.0.1 
        │  ├─┬ mime-types@2.1.10 
        │  │  └── mime-db@1.22.0 
        │  ├── node-uuid@1.4.7 
        │  ├── oauth-sign@0.8.1 
        │  ├── qs@6.1.0 
        │  ├── stringstream@0.0.5 
        │  ├── tough-cookie@2.2.2 
        │  └── tunnel-agent@0.4.2 
        └── when@3.7.7 

びっくりするほど大量のパッケージが依存関係でインストールされていますね…。

■ Node.jsでLambda関数を開発する環境を用意する

それではindex.jsへチャンネルを作成するコードを実装してみましょう。

001: 'use strict';
002: console.log('Loading function');
003: 
004: var SLACK_API_TOKEN = '****-***********-***********-***********-**********';
005: var CHANNEL_NAME_PREFIX = 'incident_';
006: 
007: var Slack = require('slack-node');
008: var slack = new Slack(SLACK_API_TOKEN);
009: 
010: var SlackUtil = {
011:   makeChannelName: (message) => {
012:     return CHANNEL_NAME_PREFIX + message.data.incident.incident_number;
013:   },
014:   createChannelIfNotExists: (channel_name) => {
015:     return new Promise((resolve, reject) => {
016:       slack.api(
017:         'channels.list',
018:         {exclude_archived: 1},
019:         (err, res) => {
020:           if (!err && res.ok) {
021:             if (
022:               !res.channels.some((channel, i, c) => {
023:                 if (channel.name == channel_name) {
024:                   console.log('Channel found. (' + channel_name + ')');
025:                   resolve(channel);
026:                   return true;
027:                 }
028:                 return false;
029:               })
030:             ) {
031:               console.log('Channel not found. (' + channel_name + ')');
032:               resolve(null);
033:             }
034:             return;
035:           }
036:           reject(
037:             new Error(
038:               'Can not get channnel list. ('
039:                 + 'Error: ' + JSON.stringify(res, null, 2)
040:                 + ', Response: ' + JSON.stringify(res, null, 2) + ')'
041:             )
042:           );
043:         }
044:       );
045:     }).then((channel) => {
046:       if (channel) {
047:         return Promise.resolve(channel);
048:       }
049:       return new Promise((resolve, reject) => {
050:         slack.api(
051:           'channels.create',
052:           {name: channel_name},
053:           (err, res) => {
054:             if (!err && res.ok) {
055:               console.log('Channel created. (ID:' + res.channel.id
                                        + ', Name:' + channel_name + ')');
056:               resolve(res.channel);
057:               return;
058:             }
059:             reject(
060:               new Error(
061:                 'Can not create channnel. ('
062:                   + 'Error: ' + JSON.stringify(res, null, 2)
063:                   + ', Response: ' + JSON.stringify(res, null, 2) + ')'
064:               )
065:             );
066:           }
067:         );
068:       });
069:     }).catch((error) => {
070:       console.error(error);
071:     });
072:   }
073: }
074: 
075: exports.handler = (event, context, callback) => {
076: 
077:   if (
078:     typeof event.messages !== 'undefined' &&
079:     Array.isArray(event.messages)
080:   ) {
081: 
082:     console.log(JSON.stringify(event, null, 2));
083: 
084:     var channels = new Map();
085: 
086:     console.log('Creating channels...');
087:     Promise.all(
088:       event.messages.map((message, i, m) => {
089:         var channel_name = SlackUtil.makeChannelName(message);
090:         return SlackUtil.createChannelIfNotExists(channel_name);
091:       })
092:     }).then((results) => {
093:       console.log('done.');
094:       callback(null, 'done.');
095:     });
096: 
097:   }
098: 
099: }

いきなり長いソースコードになってしまいましたが、見た目よりはシンプルなプログラムです。

ただし、Promiseとアロー関数の書式を知らないと解りづらいと思いますので、ソースコードの説明を読む前に下記参考サイトにも目を通してみてください。


exports.handlerに設定されたfunctionがコールされますので、Lambda関数の本体は75行目~99行目ということになります。

先ほど確認した通り、PagerDutyからPOSTされた情報は第一引数のeventに格納されています。
PagerDutyからPOSTされる情報については、こちらを御覧ください。

ここで重要なのは、最上位の要素であるevent.messagesが配列であることです。
従って、一度のWebhookで複数のメッセージがPOSTされてくることが想定されます。

そのため、88行目~91行目までevent.messagesをArray.prototype.map()によるループで処理しています。

まず、SlackUtil.makeChannelName()へ送られてきたメッセージを1件渡し、メッセージの情報から作成するチャンネル名を生成します。
その後、SlackUtil.createChannelIfNotExists()へチャンネル名を渡し、戻り値をreturnしています。

SlackUtil.createChannelIfNotExists()の戻り値は、引数で渡されたチャンネル名のチャンネルが存在しなければ、チャンネルを作成するPromiseオブジェクトです。
存在すれば、Promise.resolve()によって何もしないPromiseオブジェクトが戻り値になります。

Array.prototype.map()のコールバック関数内ですので、戻り値は配列に格納され、Promise.all()に渡されます。

Promise.all()は引数で渡された配列内のPromiseオブジェクトがすべてresolveされると次のthen()を呼び出しますので、すべてのチャンネルの作成が完了するとthen()内のcallback()が実行されてLambda関数の実行が終了します。

実際にSlackのAPIを呼んでチャンネルを作っているのはSlackUtil.createChannelIfNotExists()の戻り値であるPromiseオブジェクトなので、次は14行目~71行目を見ていきましょう。

最初に呼ばれているのはSlackのAPI、channels.listです。
オプションで{exclude_archived: 1}を渡しているので、アーカイブされていないチャンネルの一覧のみ取得しています。

チャンネルの取得が終わるとコールバック関数が実行され、正常に取得できていれば22行目~29行目でチャンネルの一覧をループで確認し、引数で渡されたチャンネル名と同一のチャンネルが存在すれば、そのchannelオブジェクトをresolve()で返します。
引数で渡されたチャンネル名と同一のチャンネルが存在しなければ、resolve()でnullを返します。

resolve()で返された値は45行目からのthen()に指定されたコールバック関数の引数として渡されます。
チャンネルが存在していた場合はこれ以上何もする必要がありませんので、Promise.resolve()でチャンネル名を返すだけのPromiseオブジェクトを返します。
しかしチャンネルが存在しなかった場合はチャンネルを作成する必要がありますので、SlackのAPI、channels.createでチャンネルの作成するためのPromiseオブジェクトを返します。
その結果、チャンネルが作成できれば、作成したchannelをresolve()で返します。

一連の流れの中でエラーが発生した場合はreject()でErrorオブジェクトを返しており、最後のcatchで一括してエラーログを出力しています。

■ 作成したコードをLambdaにデプロイしテストする

それでは作成したコードをLambdaにデプロイしてみましょう。

デプロイは初回と同様にS3にファイルをアップロードしますが、Lambda側はcreate-functionではなくupdate-function-codeする必要があります。
デプロイの一連の流れは毎回同じことをすることになりますので、シェルスクリプトにまとめておきます。

#! /bin/sh

rm -f ~/work/pd2slack.zip
rm -f /tmp/pd2slack_test_results.txt

cd ~/work/pd2slack/

zip -qr ~/work/pd2slack.zip ./*

aws s3 cp ~/work/pd2slack.zip s3://***********-pd2slack/

aws lambda update-function-code --function-name 'pd2slack' \
    --s3-bucket '***********-pd2slack' --s3-key 'pd2slack.zip' --publish

また、デプロイ後にはちゃんと動作するかどうかテストを行うことになりますが、こちらもシェルスクリプトにしておきます。

#! /bin/sh

aws lambda invoke --function-name 'pd2slack' --log-type Tail \
    --payload file://~/work/pd2slack_input.txt /tmp/pd2slack_test_results.txt \
        | jq -r .LogResult | base64 --decode

テストする際には、PagerDutyから渡されるメッセージ(Lambda関数内ではevent.messages)を渡してあげる必要がありますので、CloudWatchのログからeventオブジェクトをconsole.log()で出力したものを拾ってきて、--payloadオプションで指定するファイルに保存しておきます。

それではデプロイ後にテストを実行してみましょう。

2016-04-13T04:04:05.809Z  *  Creating channels...
2016-04-13T04:04:07.068Z  *  Channel not found. (incident_131)
2016-04-13T04:04:07.469Z  *  Channel created. (ID:C105L7XEX, Name:incident_131)
2016-04-13T04:04:07.470Z  *  done.

チャンネルの作成ができたようなので、Slackにログインして確認してみましょう。

slack203

ちゃんと専用のチャンネルができましたね。

もう一度テストを実行してみるとどうなるでしょうか?

2016-04-13T04:11:08.140Z  *  Creating channels...
2016-04-13T04:11:08.340Z  *  Channel found. (incident_131)
2016-04-13T04:11:08.340Z  *  done.

ロジック通り、channels.listでチャンネルを発見してchannels.createは呼ばれなかったようですね。

なお、channelをarchiveしても同名のチャンネルを再度作成することはできません。
アーカイブされたチャンネルを削除すれば同名のチャンネルを再度作成できますが、APIでチャンネルの削除を行うことはできないようですので、手動でチャンネルの削除を行ってください。

■ 作成したインシデント専用チャンネルに対して処理を行う

ようやくインシデント専用チャンネルを作成することができましたが、まだチャンネルが作成できただけです。

作成した専用チャンネルへメッセージをpostしたり、インシデントがResolvedになったらチャンネルをarchiveしたりする処理を追加してみましょう。

001: 'use strict';
002: console.log('Loading function');
003: 
004: var SLACK_API_TOKEN = '****-***********-***********-***********-**********';
005: var CHANNEL_NAME_PREFIX = 'incident_';
006: 
007: var Slack = require('slack-node');
008: var slack = new Slack(SLACK_API_TOKEN);
009: 
010: var SlackUtil = {
011:   makeChannelName: (message) => {
012:     return CHANNEL_NAME_PREFIX + message.data.incident.incident_number;
013:   },
014:   createChannelIfNotExists: (channel_name) => {
015:     return new Promise((resolve, reject) => {
016:       slack.api(
017:         'channels.list',
018:         {exclude_archived: 1},
019:         (err, res) => {
020:           if (!err && res.ok) {
021:             if (
022:               !res.channels.some((channel, i, c) => {
023:                 if (channel.name == channel_name) {
024:                   console.log('Channel found. (' + channel_name + ')');
025:                   resolve(channel);
026:                   return true;
027:                 }
028:                 return false;
029:               })
030:             ) {
031:               console.log('Channel not found. (' + channel_name + ')');
032:               resolve(null);
033:             }
034:             return;
035:           }
036:           reject(
037:             new Error(
038:               'Can not get channnel list. ('
039:                 + 'Error: ' + JSON.stringify(res, null, 2)
040:                 + ', Response: ' + JSON.stringify(res, null, 2) + ')'
041:             )
042:           );
043:         }
044:       );
045:     }).then((channel) => {
046:       if (channel) {
047:         return Promise.resolve(channel);
048:       }
049:       return new Promise((resolve, reject) => {
050:         slack.api(
051:           'channels.create',
052:           {name: channel_name},
053:           (err, res) => {
054:             if (!err && res.ok) {
055:               console.log('Channel created. (ID:' + res.channel.id
                                        + ', Name:' + channel_name + ')');
056:               resolve(res.channel);
057:               return;
058:             }
059:             reject(
060:               new Error(
061:                 'Can not create channnel. ('
062:                   + 'Error: ' + JSON.stringify(res, null, 2)
063:                   + ', Response: ' + JSON.stringify(res, null, 2) + ')'
064:               )
065:             );
066:           }
067:         );
068:       });
069:     }).catch((error) => {
070:       console.error(error);
071:     });
072:   },
073:   post: (channel, message) => {
074:     var item = null;
075:     switch (message.type) {
076:       case 'incident.trigger':
077:         item = {
078:           text: '新しいインシデントが発生しました'
079:         }
080:         break;
081:       case 'incident.acknowledge':
082:         item = {
083:           text: '担当者がインシデントを確認しました'
084:         }
085:         break;
086:       case 'incident.resolve':
087:         item = {
088:           text: 'インシデントがクローズされました'
089:         }
090:         break;
091:       default:
092:         console.log('Message ignored, because message type "'
                            + message.type + '" has no need to post.');
093:     }
094:     if (item) {
095:       item.channel = channel;
096:       return new Promise((resolve, reject) => {
097:         slack.api(
098:           'chat.postMessage',
099:           item,
100:           (err, res) => {
101:             if (!err && res.ok) {
102:               console.log('Message posted.');
103:               console.log(JSON.stringify(res, null, 2));
104:               resolve();
105:               return;
106:             }
107:             reject(
108:               new Error(
109:                 'Can not post message. ('
110:                   + 'Error: ' + JSON.stringify(res, null, 2)
111:                   + ', Response: ' + JSON.stringify(res, null, 2) + ')'
112:               )
113:             );
114:           }
115:         );
116:       }).catch((error) => {
117:         console.error(error);
118:       });
119:     }
120:     return Promise.resolve();
121:   },
122:   archiveChannel: (channel_id, channel_name) => {
123:     return new Promise((resolve, reject) => {
124:       slack.api(
125:         'channels.archive',
126:         {channel: channel_id},
127:         (err, res) => {
128:           if (!err && res.ok) {
129:             console.log('Channel archived. (ID:' + channel_id
                                        + ', Name:' + channel_name + ')');
130:             resolve();
131:             return;
132:           }
133:           reject(
134:             new Error(
135:               'Can not archive channel. ('
136:                 + 'Error: ' + JSON.stringify(res, null, 2)
137:                 + ', Response: ' + JSON.stringify(res, null, 2) + ')'
138:             )
139:           );
140:         }
141:       );
142:     }).catch((error) => {
143:       console.error(error);
144:     });
145:   }
146: }
147: 
148: exports.handler = (event, context, callback) => {
149: 
150:   if (
151:     typeof event.messages !== 'undefined' &&
152:     Array.isArray(event.messages)
153:   ) {
154: 
155:     console.log(JSON.stringify(event, null, 2));
156: 
157:     var channels = new Map();
158: 
159:     console.log('Creating channels...');
160:     Promise.all(
161:       event.messages.map((message, i, m) => {
162:         var channel_name = SlackUtil.makeChannelName(message);
163:         return SlackUtil.createChannelIfNotExists(channel_name);
164:       })
165:     ).then((results) => {
166:       console.log('done.');
167:       results.forEach((channel, i, c) => {
168:         if (channel && !channels.has(channel.name)) {
169:           channels.set(channel.name, channel.id);
170:         }
171:       });
172:       console.log('Posting messages...');
173:       return Promise.all(
174:         event.messages.map((message, i, m) => {
175:           var channel_name = SlackUtil.makeChannelName(message);
176:           if (channels.has(channel_name)) {
177:             return SlackUtil.post(
178:               channels.get(channel_name),
179:               message
180:             );
181:           }
182:           console.warn('Can not post message, because channel "'
                                    + channel_name + '" is not exists.');
183:           return Promise.resolve();
184:         })
185:       );
186:     }).then((results) => {
187:       console.log('done.');
188:       console.log('Archiving channels...');
189:       return Promise.all(
190:         event.messages.map((message, i, m) => {
191:           if (message.type == 'incident.resolve') {
192:             var channel_name = SlackUtil.makeChannelName(message);
193:             if (channels.has(channel_name)) {
194:               return SlackUtil.archiveChannel(
195:                 channels.get(channel_name),
196:                 channel_name
197:               );
198:             }
199:             console.warn('Can not archive channel, because channel "'
                                        + channel_name + '" is not exists.');
200:           }
201:           return Promise.resolve();
202:         })
203:       );
204:     }).then((results) => {
205:       console.log('done.');
206:       callback(null, 'done.');
207:     });
208: 
209:   }
210: 
211: }

先ほどのソースコードと比べると、73行目~143行目にSlackUtil.post()とSlackUtil.archiveChannel()が増えており、165行目~203行目にそれぞれを呼び出すコードが増えていることがわかります。

SlackUtil側のコードは特に説明することもありませんが、呼び出す側のコードについてはいくつか説明する必要があります。

まず、最初のthen()に設定されたコールバック関数の引数がresultsとなっています。
これはall()に渡された配列内のPromiseオブジェクトからresolve()の引数の形で返された値の配列になっています。

SlackUtil.createChannelIfNotExists()から返されるPromiseオブジェクトは、チャンネルが見つかったか作成できた場合にchannelのオブジェクトをresolve()の引数にしていますので、resultsはchannelを値として持つ配列になっています。

174行目~184行目でこの配列をチェックし、チャンネル名がキーでチャンネルIDが値のMapを作成しています。
このMapはこの後でチャンネルにメッセージをpostする際や、チャンネルのアーカイブを行う際に、対象のチャンネルが存在しているかどうかのチェックに使用しています。

次に、173行目~185行目でPromise.all()の戻り値(Promiseオブジェクト)をreturnしていますが、then()内のコールバック関数からPromiseオブジェクトがreturnされた場合、returnされたPromiseオブジェクトでresolve()されると、then()に対してメソッドチェーンされたthen()がコールされます。

つまり、then()内に定義されたコールバック関数内でPromiseオブジェクトをreturnし、then()をメソッドチェーンすることにより、並列処理ではなく逐次処理を行うことができるわけです。

下記のコードを実行すると、必ず1, 2, 3の順でログが出力されます。

Promise.resolve().then(() => {
	console.log('1');
	return Promise.resolve();
}).then(() => {
	console.log('2');
	return Promise.resolve();
}).then(() => {
	console.log('3');
	return Promise.resolve();
});

then()内に定義されたコールバック関数は本来Promiseオブジェクトを返すのですが、Promiseオブジェクト以外のものが返された場合や何も返されなかった場合は自動的にPromiseオブジェクトに変換されますので、下記のコードでも同様に逐次処理になります。

Promise.resolve().then(() => {
	console.log('1');
}).then(() => {
	console.log('2');
}).then(() => {
	console.log('3');
});

また今回の例のように、returnするPromiseオブジェクトをnew Promise(callback)やPromise.resolve()ではなく、Promise.all(Promiseオブジェクトの配列)とすることにより、下記の例のように逐次処理の途中に並列処理を挟んだりすることもできます。

Promise.resolve().then(() => {
	// ここで job_A を実行する
}).then(() => {
	return Promise.all(
		// ここで並列処理 jobs_B を行うPromiseオブジェクトの配列を生成する
	);
}).then(() => {
	// ここで job_C を実行する
});

この場合はまず job_A が実行され、完了したら jobs_B が並列実行され、jobs_Bがすべて完了したら job_C が実行されます。
もちろん、job_A や job_C の部分は処理を行うPromiseオブジェクトを生成してreturnしても構いません。

ただし、Promiseオブジェクトはインスタンスが生成されたタイミングでコールバック関数の実行が開始されてしまうので、注意が必要です。

たとえば、下記のようなコードを考えてみましょう。

Promise.all(
	makeParallelJobs(); // 並列処理したいPromiseオブジェクトの配列を生成する関数
).then(() => {
	console.log('done.');
});

このコードは下記のように書き換えてもほぼ同じように処理されます。

var parallel_jobs = makeParallelJobs();

Promise.all(parallel_jobs).then(() => {
	console.log('done.');
});

それでは下記のようなコードだとどうなるでしょうか?

Promise.resolve().then(() => {
	// ここで job_A を実行する
}).then(() => {
	return Promise.all(makeParallelJobs());
}).then(() => {
	console.log('done.');
});

このコードはまず job_A を実行し、それが完了すると makeParallelJobs()が返すPromiseオブジェクトの配列を並列実行します。

それでは、下記のように書き換えてしまうとどうなるでしょうか?

var parallel_jobs = makeParallelJobs();

Promise.resolve().then(() => {
	// ここで job_A を実行する
}).then(() => {
	return Promise.all(parallel_jobs);
}).then(() => {
	console.log('done.');
});

この場合、並列実行したいPromiseオブジェクトはmakeParallelJobs()が呼ばれた時点で生成されてしまい、すぐに実行が開始されます。
job_A が開始される前に parallel_jobs に含まれるPromiseオブジェクトの処理が開始されてしまうわけです。

従って、逐次処理の途中に並列処理を挟むような場合などには、必ず1つ前の処理が終わった後にPromiseオブジェクトを生成する必要があります。

then()に指定したコールバック関数はその前のPromiseオブジェクトの処理が終わった後に呼ばれることが保証されていますので、その中で次の処理用のPromiseオブジェクトを生成するようにすれば良いでしょう。

以上のことを踏まえて先ほどのソースコードを見ると、SlackUtil.createChannelIfNotExists()の戻り値のPromiseオブジェクトがすべてresolve()された後に、163行目からのthen()に指定されたコールバック関数が実行されて、SlackUtil.post()の戻り値のPromiseオブジェクトが処理を開始します。

それもすべてresolve()されるとSlackUtil.archiveChannel()の戻り値のPromiseオブジェクトが処理を開始し、それもすべてresolve()されるとcallback()が実行されてLambda関数の実行が終了するという流れであることがわかります。

チャンネルが作成されないとチャンネルにメッセージをpostすることはできませんし、チャンネルがアーカイブされてもpostすることはできません。
そのため、チャンネル作成 → メッセージpost → チャンネルアーカイブの流れで逐次処理が行われるようにしているわけです。

それでは、チャンネルのアーカイブまで行われるように、テストの際に--payloadオプションで指定するファイルのmessage.typeをincident.resolveに変更し、テストしてみましょう。

一度チャンネルが作成されている場合、チャンネルを削除するのを忘れないようにしてください。

2016-04-13T05:08:01.955Z  *  Creating channels...
2016-04-13T05:08:02.185Z  *  Channel not found. (incident_131)
2016-04-13T05:08:02.481Z  *  Channel created. (ID:*********, Name:incident_131)
2016-04-13T05:08:02.482Z  *  done.
2016-04-13T05:08:02.482Z  *  Posting messages...
2016-04-13T05:08:03.205Z  *  Message posted.
2016-04-13T05:08:03.205Z  *  {
  "ok": true,
  "channel": "*********",
  "ts": "1460524083.000003",
  "message": {
    "text": "インシデントがクローズされました",
    "username": "bot",
    "type": "message",
    "subtype": "bot_message",
    "ts": "1460524083.000003"
  }
}
2016-04-13T05:08:03.205Z  *  done.
2016-04-13T05:08:03.205Z  *  Archiving channels...
2016-04-13T05:08:03.450Z  *  Channel archived. (ID:*********, Name:incident_131)
2016-04-13T05:08:03.450Z  *  done.

想定通り、チャンネル作成 → メッセージpost → チャンネルアーカイブの流れで処理されました!

次回予告

次回はSlackの通知の見栄えを良くしてみたり、せっかくLambdaを経由しているので、AWSの機能を利用して機能を追加していく予定です。