Slack+Chalice(API Gateway+Lambda)を使ってHerokuのコマンドを実行してみた #slack #aws #chalice #lambda #heroku

chalicePython Serverless Microframework for AWS)を使って Content-Type: application/x-www-form-urlencoded のPOSTリクエストを受けるところまでは確認済みなので、今回はこれを使ってやりたかったことにチャレンジしてみました。

uchimanajet7.hatenablog.com

書いてる間にバージョンが0.3.0に上がっていました・・・試したのは0.2.0になりますので差異があったらごめんなさい

github.com

きっかけ

仕事で Heroku を使う機会が増えている今日この頃です。今までこんなに触る機会がなかったのでなかなか楽しく作業しております。

www.heroku.com

Heroku は多くのAdd-onsがあり、無償で利用できる物もあります。

elements.heroku.com

当然今のプロジェクトでもいろいろと利用しているのですが、その中でHubotの無償運用なんかでおなじみのHeroku Schedulerを利用しています

elements.heroku.com

Heroku Schedulerの説明を確認すると

devcenter.heroku.com

このような一文が・・・ Scheduler is a “best effort” service ですってよ!

Scheduler is a “best effort” service, meaning that execution is expected but not guaranteed. Scheduler is known to occasionally (but rarely) miss the execution of scheduled jobs. If scheduled jobs are a critical component of your application, it is recommended to run a custom clock process instead for more reliability, control, and visibility.

実際に約2ヶ月間で1回だけ実行されないことがあり、そういう場合に何かゆるい感じで対応できるといいなぁーと思ったのが作業のきっかけです。上記の文面をみればわかりますが、ちゃんと対応するならrun a custom clock processにしろと書いてありますしねー

ちなみに、今回実行されていないことを通知したのも無償で使えるadd−onのDead Man's Snitchになります

elements.heroku.com

チームメンバーに教えてもらったのですが、今まで全然知りませんでした。結構前からあるサービスみたいですねーいやー便利。単純に払い出されたURLにGETリクエストするとチェックイン状態になり、そのチェックインを毎時/毎日みたいなタイミングでチェックして、チェックインがなければ通知してくれるだけのシンプルなサービスです。

Herokuのadd−on経由だと1つのチェックインが無償で、チェックのインターバルと通知先に制限がある状態です。通常は有償なので制限ありでも無償で使えるのはありがたいし試しやすいですよね。

deadmanssnitch.com

インターバルの制限は今回は問題にならないのですが、通知先がmailのみとのことなので、slackのEmail integrationを利用してチームメンバーと共有可能な状態にしました

slack.com

通知はされるが

Email integrationを利用したので、何かあればslackに通知がされるようになりました。Email integrationでの通知はemailを添付ファイル扱いで通知する仕様のようです。

get.slack.help

最初に思いついたのは

  • Dead Man's Snitchからメール通知
  • slackで受信
  • slackのOutgoing WebHooksでAPI GatewayにPOST
  • Lambda で処理

みたいな流れなのですが・・・Outgoing WebHooksが添付ファイル部分には反応しない仕様のようです。

slack.com

email文中の特定のキーワードに反応してくれたら十分なんですが、残念ながら無理っぽいようです

結局

色々試した結果Outgoing WebHooksのTrigger Word(s)emailを指定してすることでemailの受信時にWebHookがかかるようになりました。これはemail受信時の投稿がemail uploaded a file:<添付ファイルのURL>のような形式となっているので、この文中のemailに反応してWebHookが発行されているようです。

注意が必要なのは今回は動作確認なのでemailという単語でWebHookを発行しています。Outgoing WebHooksをintegrationしたslackチャンネルで日常的にemailという単語が使われている場合はTrigger Word(s)を別のものにした方が良いかと思います。

f:id:uchimanajet7:20161011172539p:plain

Outgoing WebHooks の設定画面のドキュメントには以下のようなデータがPOSTされると書かれています。

token=blaNqr3fL7b8AqnwCNFW86oZ
team_id=T0001
team_domain=example
channel_id=C2147483705
channel_name=test
timestamp=1355517523.000005
user_id=U2147483697
user_name=Steve
text=googlebot: What is the air-speed velocity of an unladen swallow?
trigger_word=googlebot:

実際にはこのデータがapplication/x-www-form-urlencodedで送信されるので

token=XXXXXXXXXXXXXXXXXXXXXXXX&team_id=T0295086H&team_domain=serverworks&service_id=77430880359&channel_id=C1X85EYNR&channel_name=times-uchida&timestamp=1476076555.001561&user_id=USLACKBOT&user_name=slackbot&text=email+uploaded+a+file%3A+%3Chttps%3A%2F%2Fserverworks.slack.com%2Ffiles%2Fslackbot%2XXXXXXXXXX%2F_missing__import_data_monitoring_hasn_t_checked_in%7C%5BMISSING%5D+import+data+monitoring+hasn%27t+checked+in%3E&trigger_word=email

のような形式で送信されてきます。また、今回のように添付ファイルがある場合でもPOSTデータに添付ファイルのデータが入るようなことはないようです。残念。

Please note that the content of message attachments will not be included in the outgoing POST data.

chaliceを利用する

WebHookのPOST先を作らないといけないわけですが、あまり手間をかけずに簡単に作りたいわけですね。Heroku使ってるならそれでいいじゃん!という話もありますが、chaliceでapplication/x-www-form-urlencodedが受けられるようになったのを確認したばかりなのでchaliceを使います

github.com

chaliceそのものの使い方は簡単&他に詳しく書かれているものがたくさんあるので割愛しますが、chalice (0.2.0)以上を利用しないと上手く行かないと思うのでその点だけ確認してください

$ chalice new-projectで新規にプロジェクトを作成し、application/x-www-form-urlencodedのPOSTが受けられるよう設定します

from chalice import Chalice

app = Chalice(app_name='slack_receive')

@app.route('/slack', methods=['POST'],
           content_types=['application/x-www-form-urlencoded'])
def slack():
    body = app.current_request.raw_body

app.current_request.raw_bodyには前述の&で連結された情報が入っているので、この中から必要なものを取り出してあげます。 Pythonのデフォルトライブラリを利用してパースを行い、後で扱いやすいように辞書型にしておきます。

from urlparse import parse_qsl

body = app.current_request.raw_body
parsed = dict(parse_qsl(body))

パースした結果は以下のような状態になっていると思います

{'user_id': 'USLACKBOT', 'channel_name': 'times-uchida', 'timestamp': '1476076555.001561', 'team_id': 'T0295086H', 'trigger_word': 'email', 'channel_id': 'C1X85EYNR', 'token': 'XXXXXXXXXXXXXXXXXXXXXXXX', 'text': "email uploaded a file: <https://serverworks.slack.com/files/slackbot/XXXXXXXXX/_missing__import_data_monitoring_hasn_t_checked_in|[MISSING] import data monitoring hasn't checked in>", 'service_id': '77430880359', 'team_domain': 'serverworks', 'user_name': 'slackbot'}

必要に応じて値のチェックを行うわけですが、今回はtokentextをチェックしています。tokenに関してはLambdaのblueprintでslack関連のものを見ると、以下のようにAWS Key Management Service (KMS)を使った例がありますのでそれをそのまま利用します。

import boto3
from base64 import b64decode

ENCRYPTED_EXPECTED_TOKEN = '<kmsEncryptedToken>'  # Enter the base-64 encoded, encrypted Slack command token (CiphertextBlob)

kms = boto3.client('kms')
expected_token = kms.decrypt(CiphertextBlob=b64decode(ENCRYPTED_EXPECTED_TOKEN))['Plaintext']

aws.amazon.com

KMSを追加してchaliceにてデプロイを行うと以下のようなメッセージが表示されるかと思います

$ chalice deploy
Updating IAM policy.
Unsupported service: kms

開発中ということもありすべてのAWSサービスには対応してないようです。ポリシーを自動生成してくれるのは非常に便利なので、今後のサービス対応に期待しましょう

github.com

じゃーどうすんの?って話なんですが、ドキュメントを見ると手動で作ったポリシーを使う方法があります。chaliceのプロジェクトディレクトにある.chalice/polict.jsonを編集してkmsの復号の権限を付加します

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1443036478000",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": [
                "<your KMS key ARN>"
            ]
        }
    ]
}

これでポリシーは問題なくなりましたが、今までと同様にデプロイを実行すると以下のような確認が行われます。この確認でYesを選択すると上記で手動追加したポリシーが削除され、chaliceが自動生成するものに置き換わりますので注意が必要です。noを選択するとデプロイ自体がキャンセルされます。

$ chalice deploy
Updating IAM policy.
Unsupported service: kms

The following action will be removed from the execution policy:

kms:Decrypt

Would you like to continue?  [Y/n]: n
Error: Error when deploying:

手動で編集したポリシーを利用するためにはドキュメントにあるように--no-autogen-policyをつけてデプロイを実行します

The automatic policy generation is still in the early stages, it should be considered experimental. You can always disable policy generation with --no-autogen-policy for complete control.

これでKMSが利用できるようになったので、slackのtokenが意図したものかどうかを確認することができます。同時にtext中に意図した文字列があるかどうかも確認を行います。

Heroku APIを利用する

ここまでで、slackからのWebHookを受けて内容を確認するところまで終了しました。次はHeroku APIを利用してHerokuのコマンドを実行してみます。

devcenter.heroku.com

APIを利用するためには認証キーを取得する必要があります。Herokuのアカウントページで認証キーを確認しておいてください

Authentication is passed in the Authorization header with a value set to :{token}. You can find a token to use on the “Account” page (in the “API Key” section) on your dashboard or by running this command:

このAPI認証キーもKMSを利用して暗号化を行って利用します。コマンドを実行するためにはHeroku Platform APIDyno関連を利用します。

devcenter.heroku.com

Herokuはドキュメントが非常に丁寧に書かれているので、ドキュメントをみれば問題ないかと思います。ドキュメントではcurlの例が示されていますが、Pythonではrequestsライブラリを利用しました。

Requests: 人間のためのHTTP — requests-docs-ja 1.0.4 documentation
http://requests-docs-ja.readthedocs.io/en/latest/requests-docs-ja.readthedocs.io

Heroku APIで指定されているHTTPヘッダー項目を付けるのを忘れなければ特別なことをする必要がなくDyno Createでコマンドが実行できます

devcenter.heroku.com

このAPIでコマンドを実行した場合、レスポンスについてはHTTPステータスコード201が返却されます。

HTTP/1.1 201 Created
ETag: "0123456789abcdef0123456789abcdef"
Last-Modified: Sun, 01 Jan 2012 12:00:00 GMT
RateLimit-Remaining: 2400

HTTPステータスコード - Wikipedia
https://ja.wikipedia.org/wiki/HTTP%E3%82%B9%E3%83%86%E3%83%BC%E3%82%BF%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89ja.wikipedia.org

このレスポンスからだと実行したコマンドが正常に終了したかどうか判断することができません。APIで結果を確認するにはLog Session関連を利用するのが良さそうです

devcenter.heroku.com

Log Session CreateAPIのレスポンスで得られたlogplex_urlに対してGETを行うと実際にLogを取得することができます。

heroku[router]: at=info method=HEAD path="/login" host=hoge.herokuapp.com request_id=8184c319-ffb0-43ab-9ea7-861991176b3a fwd="XX.XXX.XXX.XX9X" dyno=web.1 connect=1ms service=7ms status=401 bytes=354\n2016-10-10T05:15:58.327710+00:00 
heroku[api]: Starting process with command `python manage.py purgerequests 4 months --noinput` by uchida@serverworks.co.jp\n']

実際にはコマンド実行後に非同期でLogを確認する必要があるので、1つのLambda関数だけでなくSQSなどを利用して他のLambda関数で確認する必要があります。また、Herokuに関して言えばAdd−onで解決してしまうのもアリだと思います。

elements.heroku.com

Papertrail のようなLogを管理できるサービスを利用すればサービス上で特定のLogを検知してslackに通知することが可能です。これを利用すればコマンド実行後の状態を検知してslackに通知してしまえば結果を確認することが可能となるため、わざわざプログラムを組む必要はなくなります。楽できる方がいいですしね。

Slackにレスポンスを戻す

ここまでで、slackからのWebHookを受けて内容を確認してHeroku APIでコマンドを実行するところまで終了しました。最後にslackにレスポンスを戻して終了です。

Outgoing Webhooks | Slack
https://api.slack.com/outgoing-webhooksapi.slack.com

ドキュメントを確認するとJSON形式textプロパティの値を設定すれば良いので、requestsのレスポンスを.json()で設定してslackに戻しています。ただし、このレスポンス中にWebHookのTrigger Word(s)が含まれていないことが前提になります。

import requests
import json

resp = requests.post('<URL>', data=json.dumps(data), headers=headers)
result = {'text': resp.json()}

return result                

まとめ

ということで、これでSlack+Chalice(API Gateway+Lambda)を使ってHerokuのコマンドを実行することができました。まー何に使うの?って話はありますが・・・

chaliceを使うと非常に簡単にAPI GatewayとLambdaを使うことができます。自動で生成されるポリシーも便利ですし今後も楽しみです。一方で複雑になったり大規模になったりした場合は大変かなぁーという印象もあります。他のLambdaを利用できるフレームワークもたくさんあるので使いやすいものを選択するのが良さそうです。

また、今後リリースされるというFlourishも楽しみですね。もうすぐre:Invent 2016が開催されますし期待しながら待っています!

dev.classmethod.jp

Appendix

利用したchaliceのコード例

import boto3
import requests
import json
import time

from chalice import Chalice
from urlparse import parse_qsl
from base64 import b64decode

app = Chalice(app_name='slack_receive')
kms_client = boto3.client('kms')

ENCRYPTED_SLACK_TOKEN = '<your own value>'
SLACK_TOKEN = kms_client.decrypt(CiphertextBlob=b64decode(ENCRYPTED_SLACK_TOKEN))['Plaintext']

ENCRYPTED_HEROKU_API = '<your own value>'
HEROKU_API = kms_client.decrypt(CiphertextBlob=b64decode(ENCRYPTED_HEROKU_API))['Plaintext']

SLACK_KEYWORDS = "<your own value>"

@app.route('/slack', methods=['POST'],
           content_types=['application/x-www-form-urlencoded'])
def slack():
    body = app.current_request.raw_body
    print('body={}'.format(body))

    result = {"text": "post OK !"}

    slack_token = parsed_l.get('token')
    if slack_token != SLACK_TOKEN:
        result = {"text": "slack token not valid!"}
        return result

    slack_text = parsed_l.get('text')
    if slack_text:
        print('slack_text={}'.format(slack_text))
        index = slack_text.find(SLACK_KEYWORDS)
        print('index={}'.format(index))

        if index != -1:
            start = slack_text.find('|')
            end = slack_text.find('>')
            sub_text = slack_text[start+1:end]
            print('sub_text={}'.format(sub_text))

            if SLACK_KEYWORDS == sub_text:
                print("--- same words!! ---")

                # call heroku API
                auth_str = 'Bearer {}'.format(HEROKU_API)
                headers = {'Accept':'application/vnd.heroku+json; version=3', 'Authorization':auth_str, 'Content-Type': 'application/json'}
                data = {'command':'<your own value>', "attach": 'false', "type": "run", "time_to_live": 1800}
                print('headers={}'.format(headers))
                print('data={}'.format(json.dumps(data)))

                resp = requests.post('https://api.heroku.com/apps/<your own value>/dynos', data=json.dumps(data), headers=headers)
                cont = {'status_code': resp.status_code, 'body': resp.json()}

                result = {'text': json.dumps(cont)}

                # log
                time.sleep(5)
                
                data2 = {}
                resp2 = requests.post('https://api.heroku.com/apps/<your own value>/log-sessions', data=json.dumps(data2), headers=headers)

                print{'resp2={}'.format(resp2.text)}

                log_url = resp2.json().get('logplex_url')

                resp3 = requests.get(log_url, headers=headers)

                print{'resp3={}'.format(resp3.text)}

    return result

こんなエラーメッセージが出たら

$ chalice deploy --no-autogen-policy
Updating IAM policy.
Updating lambda function...
Creating deployment package.
Sending changes to lambda.
Lambda deploy done.
API Gateway rest API already found.
Deleting root resource id
Done deleting existing resources.
Error: Error when deploying: An error occurred (PolicyLengthExceededException) when calling the AddPermission operation: The final policy size (20575) is bigger than the limit (20480).

issue にもありますがLambda関数自体を削除して再度デプロイを行えば問題ありませんでした。

github.com

と思ったら、冒頭にも書いた通りにバージョンが0.3.0になり、このissueの対応が行われています。0.2.0以下の人はバージョンを上げましょう

地味に困る

エラーが起きるとChaliceのエラーとしてraiseされるので、何が原因なのか特定しにくい。単純にimportを忘れた場合もchaliceのエラーとなるので他の場所かと思ってしまうと言うね・・・この辺は何か改善してほしいなぁーすでにあるなら教えてください!

ChaliceViewError: An internal server error occurred.: ChaliceViewError
Traceback (most recent call last):
File "/var/task/chalice/__init__.py", line 207, in __call__
raise ChaliceViewError("An internal server error occurred.")
ChaliceViewError: ChaliceViewError: An internal server error occurred.




以上になります