Serverless Framework で SNS + Lambda の Slack 通知を簡単に作る

任意のメッセージを Slack の特定のチャネルに通知する仕組みを AWS SNS + Lambda で作ります。

- アプリケーションから任意のメッセージを通知したい
- 複数の通知先がある（Slackとメーリングリストなど）
- しかし、通知先の設定などはなるべくアプリケーションで管理したくない

こういうときに役に立つと思います


# Slack APP の登録とチャネルの Webhook URL の生成（手動）

https://api.slack.com/apps?new_app=1 にアクセスして、 Workspace に Slack APP を作成します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/751eb35a-813f-4f67-ec23-05580b893431.png)

`Incoming Webhooks` を選択します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/6cb56274-ce0c-6f76-ef79-33bc5d39e1cf.png)

初期状態で Incoming Webhooks は有効になってないので、 ON にします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/5cec9aa9-ab41-d8ee-159d-69f11840acff.png)

ON にすると Webhook を追加できるようになります
`Add New Webhook to Workspace` を選択します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/8fcab963-36b4-07b4-ffaa-2b511df153b0.png)

OAuth で権限をリクエストする画面に遷移します
Webhook でメッセージを投稿するさきのチャンネルを選択して許可します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/978fd007-c703-b2fd-971d-51ba74881769.png)

Webhook URL が生成されました[^do-not-publish-webhook-url]
これは後ほど、 Serverless Framework で利用します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/aba585e2-796a-6226-228c-4fd7eab6a558.png)

Sample curl request も生成されています
指定したチャネルへ Webhook でメッセージ送信できるか、事前にテストしておくこともできます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/ee224813-0f27-fc93-b526-60d60ffeb21e.png)

```bash
$ curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX
ok%
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/e8f09e6d-93ae-cb80-5c0c-9bd6c86afb10.png)


# Serverless Framework で SNS + Lambda を作成する

実際にこの手順どおりに実装したリポジトリを参考に置いておきます

https://github.com/ikasam/sls-sns-lambda-to-slack

## Serverless Framework のインストールとプロジェクト作成

[Get started][sls-getting-started] や [CLI reference][sls-cli-ref] を見てもらうのが一番良いですが、必要なステップだけ抜粋しておきます
環境は MacOS/Linux で、言語は python3 にします
他の環境へのインストール方法や他の言語のテンプレートはドキュメントを参照してください


```bash
$ curl -o- -L https://slss.io/install | bash
$ sls create --template aws-python3
Serverless: Generating boilerplate...
 _______                             __
|   _   .-----.----.--.--.-----.----|  .-----.-----.-----.
|   |___|  -__|   _|  |  |  -__|   _|  |  -__|__ --|__ --|
|____   |_____|__|  \___/|_____|__| |__|_____|_____|_____|
|   |   |             The Serverless Application Framework
|       |                           serverless.com, v2.6.0
 -------'

Serverless: Successfully generated boilerplate for template: "aws-python3"
Serverless: NOTE: Please update the "service" property in serverless.yml with your service name
```

以下のファイルが生成されます

```bash
$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   .gitignore
        new file:   handler.py
        new file:   serverless.yml
```

`handler.py` の中身を見てもらえればわかりますが、固定の文字列と Lambda event を返す関数が定義されています

```python:handler.py
import json


def hello(event, context):
    body = {
        "message": "Go Serverless v1.0! Your function executed successfully!",
        "input": event
    }

    response = {
        "statusCode": 200,
        "body": json.dumps(body)
    }

    return response

    # Use this code if you don't use the http event with the LAMBDA-PROXY
    # integration
    """
    return {
        "message": "Go Serverless v1.0! Your function executed successfully!",
        "event": event
    }
    """
```

このままデプロイしてみましょう


```bash
$ sls deploy -v
Serverless: Packaging service...

...(snip)...

Serverless: Stack update finished...
Service Information
service: sls-sns-lambda-to-slack
stage: dev
region: us-east-1
stack: sls-sns-lambda-to-slack-dev
resources: 6
api keys:
  None
endpoints:
  None
functions:
  hello: sls-sns-lambda-to-slack-dev-hello
layers:
  None

Stack Outputs
HelloLambdaFunctionQualifiedArn: arn:aws:lambda:us-east-1:000123456789:function:sls-sns-lambda-to-slack-dev-hello:1
ServerlessDeploymentBucketName: sls-sns-lambda-to-slack-serverlessdeploymentbuck-1drc5f5y43luq


**************************************************************************************************************************************
Serverless: Announcing Metrics, CI/CD, Secrets and more built into Serverless Framework. Run "serverless login" to activate for free..
**************************************************************************************************************************************
```

動作確認もしてみます

```bash
$ sls invoke -f hello -d "{\"message\": \"This is input message.\"}"
{
    "statusCode": 200,
    "body": "{\"message\": \"Go Serverless v1.0! Your function executed successfully!\", \"input\": {\"message\": \"This is input message.\"}}"
}
```

[ここまでのコミット](https://github.com/ikasam/sls-sns-lambda-to-slack/commit/4f3f03f3088e89c7c274ae8b6eb00e6a242760ed)

## Slack 通知する Lambda の実装

### `handler.py` の実装

- 受け取ったメッセージを Slack API の [chat.postMessage][slack-api-chat-post-message] payload に変換します
- payload を先程作成した Webhook に POST すれば良いです

```python3:handler.py
import json
import urllib3
import os

http = urllib3.PoolManager()


def invoke(event, context):
    message = event['message']
    payload = json.dumps({'text': message})
    url = os.environ["WEBHOOK_URL"]
    resp = http.request('POST', url, body=payload)
    print({
        "url": url,
        "payload": payload,
        "status_code": resp.status,
        "response": resp.data
    })

    return payload
```

- `serverless.yml` の修正も必要です
  - `handler.py` のエントリポイントの関数名を `hello` から `invoke` に変更したので
- ついでに Lambda の関数名もいい感じの名前 ( `post-to-slack` ) に変えておきましょう

```diff:serverless.yml
-  hello:
-    handler: handler.hello
+  post-to-slack:
+    handler: handler.invoke
```

### 環境変数の設定

- Webhook を環境変数として渡しますが、リポジトリには commit したくありません [^do-not-publish-webhook-url]
- `.gitignore` したファイルで読ませることにします

```diff:serverless.yml
- #  environment:
- #    variable1: value1
+   environment:
+     WEBHOOK_URL: ${file(./config.json):WEBHOOK_URL}
```

```json:config.json
{
  "WEBHOOK_URL": "https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX"
}
```

### デプロイと動作確認

- ここまでの内容をデプロイして動作確認してみましょう
- Lambda へ渡す payload は `{"message": "Slack に投稿されるメッセージ"}` という形式です
  - Lambda が `event['message']` で Slack に投稿するメッセージを受け取っています

```bash
$ sls deploy -v
$ sls invoke -f post-to-slack -d "{\"message\": \"Slack に投稿されるメッセージ\"}"
"{\"text\": \"Slack \\u306b\\u6295\\u7a3f\\u3055\\u308c\\u308b\\u30e1\\u30c3\\u30bb\\u30fc\\u30b8\"}"
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/61144665-14f5-b2cf-45b2-80d0468de364.png)

[ここまでのコミット](https://github.com/ikasam/sls-sns-lambda-to-slack/commit/7427f965ced3bc2fae596970b8fd78a8b9d65d00)

## SNS Topic 作成と Lambda Subscription の設定

`serverless.yml` を少し修正するだけで、 SNS Topic と Lambda Subscription を設定できます

### SNS Topic のリソースを定義

```yaml:serverless.yml
resources:
  Resources:
    snsTopic:
      Type: AWS::SNS::Topic
  Outputs:
    snsTopicArn:
      Description: "ARN of SNS Topic"
      Value: !Ref snsTopic
```

### Lambda Subscription の設定

関数にイベントとして SNS Topic を紐付けます

```diff:serverless.yml
  functions:
    post-to-slack:
      handler: handler.invoke
+     events:
+       - sns:
+           arn: !Ref snsTopic
+           topicName: snsTopic
```

たったこれだけです
デプロイして SNS Topic と Lambda Subscription が作成されたことを確認しましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/62a61dcc-7dde-e129-fdbe-cfd9ffd62dc9.png)

[ここまでのコミット](https://github.com/ikasam/sls-sns-lambda-to-slack/commit/bf4e842f255d6f532a0c28f41dc6d1f2d577a0d2)

## SNS Topic からのメッセージを処理するよう、 Lambda 関数を修正

- SNS Topic はできましたが、まだ Lambda 関数が SNS Topic から送られてくる payload を解釈できていません
- AWS のコンソールなどから SNS Topic にメッセージを発行できますが、これまで使ってきた payload の形式で送ってもうまくいきません

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/01708192-f2ab-64f2-2d27-ab4d90d06ac4.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/7211b2c7-06d2-8c45-4153-037ecece84c5.png)

```python
[ERROR] KeyError: 'message'
Traceback (most recent call last):
  File "/var/task/handler.py", line 9, in invoke
    message = event['message']
```

- SNS から送られてくる payload がどういう形で `event` に格納されているか、知る必要があります
- とりあえず、関数の冒頭で `print(event)` してログを見るとわかりやすいです

```diff:handler.py
  def invoke(event, context):
+     print(event)
      message = event['message']
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/be1852de-b8fe-50ff-f690-5e2e0409f8bc.png)

`Records.0.Sns.Message` に SNS Topic 経由で送信されたメッセージの内容が格納されていることがわかりました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/54cc8772-71a0-a868-fc82-6190020d4f5f.png)

Lambda 関数のコードを修正してデプロイしましょう

```diff:handler.py
 def invoke(event, context):
-    message = event['message']
+    message = event['Records'][0]['Sns']['Message']
     payload = json.dumps({'text': message})
     url = os.environ["WEBHOOK_URL"]
     resp = http.request('POST', url, body=payload)
```

- AWS コンソールから SNS 経由でメッセージを送ってみます
- JSON payload にする必要がないので、メッセージ本文をそのまま submit します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/a48b97e6-1876-1c4c-53fe-1d95a3148d8d.png)

届きましたね

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/5b4105d3-1eac-ba7b-6db0-8faa6f3f6c98.png)

[ここまでのコミット](https://github.com/ikasam/sls-sns-lambda-to-slack/commit/7a51ddb94550cbb1663974043fd1cacaedb12f90)

## アプリケーションから SNS Topic にメッセージを publish する

- あとは、アプリケーションから AWS SDK で SNS Topic にメッセージを publish するだけです
- 例として、 PHP/Laravel [^aws-sdk] で実装するサンプルはこんな感じです

```php
$client = App::make('aws')->createClient('sns');
$client->publish([
    'Message' => 'アプリケーションから送信するメッセージ', // REQUIRED
    'TopicArn' => 'arn:aws:sns:us-east-1:000123456789:sls-sns-lambda-to-slack-dev-snsTopic-1OUEKFS94QV16'
]);
```

# 応用編: 通知の環境分離

- 環境ごとに通知先を分けたいニーズはよくあると思います
- Serverless Framework は環境の概念を持っていて、環境ごとに独立したリソースをデプロイできます

## 通知先チャネルごとの Webhook URL の作成

冒頭で実施した Webhook URL 生成と同じ手順で、Slack App に Webhook URL を追加しましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/8147fffc-7e0f-58dd-b413-cfbb5278a2b6.png)

## `serverless.yml` と `config.json` の修正

環境ごとに読み込む `config.json` を分けることで、環境変数の Webhook URL を切り替えられるようにします

```diff:serverless.yml
   environment:
-    WEBHOOK_URL: ${file(./config.json):WEBHOOK_URL}
+    WEBHOOK_URL: ${file(./config.${opt:stage, self:provider.stage, 'dev'}.json):WEBHOOK_URL}
```

```bash:config.*.json
$ ls -l config*
-rw-r--r-- 1 m_kanno AD\Domain Users 103 Dec  4 19:58 config.dev.json
-rw-r--r-- 1 m_kanno AD\Domain Users 103 Dec  4 19:58 config.stg.json
```

commit したくないファイルの名前が変わるので、 `.gitignore` の修正も忘れずに

```diff:.gitignore
 # config
-config.json
+config.*.json
```

## 環境ごとにデプロイする

- `--stage` オプションでデプロイ先の環境を指定できます
- `--stage` オプションを指定しなかった場合、デフォルトで `dev` 環境にデプロイされます
    - これまでは `dev` 環境にデプロイさていました
    - デフォルトの環境は `serverless.yml` で上書きすることも可能です

```yaml:serverless.yml
# you can overwrite defaults here
#  stage: dev
#  region: us-east-1
```

```bash
$ sls deploy -v --stage stg
```

stg 環境の SNS Topic などが作成されました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/097acc29-fccd-ce40-e12b-d74ba4e8d753.png)

メッセージを送ってみます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/0be0f6ca-3dfc-37b5-37a8-223e13d77ce2.png)

別の Webhook URL で指定したチャネルにメッセージが届きました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62103/cf6a8828-06bb-212e-bd9a-3d477a4040e3.png)

[ここまでのコミット](https://github.com/ikasam/sls-sns-lambda-to-slack/commit/000666218efcd1ffdb9dc69bd96df78ffea7d780)

# リソースの削除

- 必要なくなったら、 `sls remove` でリソースを削除できます
- これも `--stage` オプションで環境ごとに削除できます

```bash
$ sls remove
$ sls remove --stage stg
```

Slack App は手動で削除してください

# まとめ

- Serverless Framework を使って、Slack 通知する SNS Topic + Lambda を簡単に作れます
    - 慣れればリポジトリ作成から30分くらいでできるようになります
- Seerverless Framework は関連するリソースや IAM ポリシーもざっと作ってくれるのでアプリケーション開発に集中できます

# 参考

- [Serverless Getting Started Guid][sls-getting-started]
- [Serverless - AWS Lambda - CLI Reference][sls-cli-ref]
- [chat.postMessage method | Slack][slack-api-chat-post-message]

[^do-not-publish-webhook-url]: Webhook URL を公開してしまわないように注意しましょう。 Webhook URL を知っていると、その Webhook に紐付いているチャンネルにメッセージを投稿し放題になってしまいます。
[^aws-sdk]: サンプルコードは [`aws/aws-sdk-php-laravel`](https://github.com/aws/aws-sdk-php-laravel) を利用する前提です

[sls-getting-started]: https://www.serverless.com/framework/docs/getting-started/
[sls-cli-ref]: https://www.serverless.com/framework/docs/providers/aws/cli-reference/
[slack-api-chat-post-message]: https://api.slack.com/methods/chat.postMessage