Help us understand the problem. What is going on with this article?

SlackからEC2の起動/停止をサーバレスでしてみる

目的

EC2はずっと起動しているとやはりお金がかかってくるので、こまめに起動したり停止したりしたいものです。
ただAWSコンソールからとなると面倒です。
そのため今回、EC2の起動停止をSlackのスラッシュコマンドから操作してみようと思います。
サーバレスでEC2操作している記事が見つからなかったので、API GatewayとLambdaを使ってやってみました。

構成

3E074EE3-79A8-4A98-90A3-434FE1368B2C.jpeg
Slackから投げたリクエストは、早くレスポンスしてやらないとタイムアウトになってしまいます。そのため、1つ目のLambdaで2つ目のLambdaを非同期でコールし、200を返しています。2つ目のLambdaがEC2の操作をした後、結果をIncoming WebhookでSlackに返します。

構築

Slackアプリケーションの作成

Slackにログインした状態で、Slack ApiからYour AppsCreate New Appと進んでいけば作成できます。細かい内容は割愛します。
これでVerification Tokenが取得できます。
次に、左のリストのFeaturesからIncoming Webhooksを選択し、OnにすることでWebhook URLを取得します。

Lambda用のポリシー、ロールの作成

Lambdaで利用するためのロールを作成します。
適当な名前で、下記のポリシーを作成します。


{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:*",
                "lambda:*"
            ],
            "Resource": "*"
        }
    ]
}

次に、ロールの作成から、Lambdaを選択し、アクセス権限で先ほど作成したポリシーにチェックをつけて、ロールを作成します。

KMSの作成

Lambdaで利用するTokenなどを暗号化したいので、KMSを利用します。
カスタマー管理型のキーを適当なエイリアス名で作成します。
キーの管理アクセス許可を定義キーの使用アクセス許可を定義には、AWSログイン用の自身のユーザと先ほど作成したロールを選択しておきます。
kms-admin,user.png

Lambdaの作成(API Gateway受け)

まず、API Gatewayから直接リクエストを受けるLambdaを作成します。
関数の作成から一から作成を選択し、情報を埋めて関数を作成します。
Pythonは3.7、実行ロールは先ほど作成したロールを選択します。
lambda-make.png

関数の画面に遷移したとき、デザインは下記のようになっているかと思います。
[2019/04/27修正]
api-design.png
トリガーがまだない状態なので、トリガーの追加からAPI Gatewayを選択してください。
すると、トリガーの設定が出てくるので、下記のように設定して、右下の追加を押してください。

項目 設定値
API 新規APIの作成
セキュリティ オープン
API名 slack-ec2(任意)
デプロイされるステージ v1(任意)

この状態で、一旦保存をしておきます。

次に、DesignerからこのLambda関数を選択し、関数を設定します。(上記デザインの場合、slack-ec2)
関数はインラインだと、使えないパッケージがあったり見づらいので、zipアップロードを利用します。
コードはgithubに置いてあります。
https://github.com/hirano00o/serverlessSlacktoEC2/blob/master/lambda_function.py

使い方

$ git clone https://github.com/hirano00o/serverlessSlacktoEC2.git
$ cd serverlessSlacktoEC2
$ pip install boto3 -t .
$ zip -r lambda_function.zip *

上記で作成したlambda_function.zipをアップロードします。

次に、環境変数を設定します。コード中でSLACK_TOKENを利用していますが、これが、最初に作成したSlackのVerification Tokenとなります。

ENCRYPTED_TOKEN = os.environ['SLACK_TOKEN']

キーSLACK_TOKENにVerification Tokenを入れます。
キーの暗号化は、暗号化の設定を開き、伝送中の暗号化のためのヘルパーの有効化にチェックを付けて、先ほど作成したKMSのエイリアスを選択します。
また、保管時に暗号化する AWS KMS キーカスタマーマスターキーの使用を選択し、同様にエイリアスキーを選択します。
そして、先ほど入力したキーの右にある暗号化ボタンを押して、キーを暗号化します。
SLACK_TOKEN.png

あとは保存すれば、このLambdaに関しては終わりです。

ざっくりと、このLambdaでやっていることの概要です。

def lambda_handler(event, context):
    reqBody = event['body']
    params = urllib.parse.parse_qs(reqBody)
    token = params['token'][0]

event['body']にSlackからのリクエストボディが入っています。
これをパースして必要な情報を取得します。
今回は、自分が設定したSlackのAppからのリクエストかどうかを判断するために、トークンを取得しておきます。
このトークンと、暗号解除した環境変数に設定したトークンとが合致するかを判定します。
合致しなかったら、さようなら。

if token != DECRYPTED_TOKEN:
    logger.error("Request token (%s) does not match exptected", token)
    raise Exception("Invalid request token")

そして、プロセスIDを作成、テキストやチャンネルIDを取得して、非同期でもう1つのLambda(EC2操作)を呼び出します。

awsLambda = boto3.client('lambda')
response = awsLambda.invoke(
    FunctionName='operateEc2',
    InvocationType='Event',
    Payload= json.dumps({
        "text": commandText,
        "channel_id": channelId,
        "process_id": processId,
        "start_unix_time": startUnixTime
    })
)

FunctionNameは、呼び出すLambdaの関数名です。
InvocationTypeは、Lambdaをどう呼び出すかの設定です。Eventは非同期での呼び出しになります。
Payloadは、呼び出すLambdaに渡したいキーと値をJSONで設定します。
InvocationTypeについては、クラスメソッドさんの記事に詳しく書いてあります。

ちなみに、プロセスIDやチャンネルIDは何に利用するかというと、後ほどEC2の操作が完了した後に、Slackに先に返したメッセージ↓を削除するためです。

return {
    "text": "実行中です...\nID:" + processId,
    "response_type": "in_channel"
}

Lambdaの作成(EC2操作)

[2019/04/29修正]
基本的には先ほどと同じように作成しますが、さっきと異なる点として、トリガーは設定しません。
ちょうど、上で示したデザインの画像の通りになります。
[2019/05/12追記]
また、Lambda関数名は、API Gateway受けのLambda関数内でoperateEc2を起動するように指定しているので、operateEc2という名前で作成しない場合は、
serverlessSlacktoEC2/lambda_function.py at master · hirano00o/serverlessSlacktoEC2
operateEc2を適宜変えてアップロードし直してください。

EC2操作のLambda関数の全体のコードはこちらになります。
https://github.com/hirano00o/serverlessLambdatoEC2/blob/master/lambda_function.py

環境変数は、4つ設定します。
まずは、Incoming WebhookのURL(https://hooks.slack.com/services/Txxxxxx/Byyyyyyy/zzzzzzzzz)の、Tから始まる値、Bから始まる値、そして一番最後の値です。
これらをそれぞれ、T_TOKENB_TOKENWEBHOOK_TOKENという変数名で設定しKMSで暗号化します。(https://hooks.slack.com/services/T_TOKEN/B_TOKEN/WEBHOOK_TOKENという形になります。)

次に、Slackのメッセージを削除するために必要なLEGACY_TOKENを取得して設定します。
LEGACY_TOKENは、https://api.slack.com/custom-integrations/legacy-tokens から取得できます。
これらも先ほどと同様にKMSによる暗号化を施して、関数を保存します。

上記より、環境変数の箇所は下記のようになります。# 順番は気にしないでください。。
EC2操作.png

コードについて簡単に説明しますと。。。
最初にSlackから必要なコマンドが送られてきているか確認しています。
例えばSlackで/ec2 stop i-xxxxと入力すると、command_text = event["text"]にはstop i-xxxxが入ります。

Command(command_text)でインスタンスを生成するとともにコンストラクタ(33-39行目)を呼び出し、command_textをスペースで区切り、クラス内変数のcommandparamにそれぞれ代入します。
先の例でいうと、commandstopparami-xxxxが入ります。

クラス内変数commandcommandListにあるか確認をcommandProcess.ok_command()で行います。commandListにあれば、commandに沿った処理を行い、なければhelpメソッドを実行します。

boto3については、AWS SDK for Python | AWSを確認ください。

commandProcess = Command(command_text) # command_textをスペースで分割し、クラス内変数commandとparamを設定
if commandProcess.ok_command() == False: # クラス内変数commandがグローバル変数commandListにあるか確認
    commandProcess.help_command() # なければヘルプコマンドとしてを処理
else:
    ec2 = boto3.client('ec2')
    if commandProcess.command == 'help':
        commandProcess.help_command()
    elif commandProcess.command == 'stop':
        commandProcess.stop_command(ec2)

stop処理commandProcess.stop_command(ec2)が呼ばれた場合です。クラス内変数paramをインスタンスIDとしてEC2の停止処理ec2.stop_instances()を実行します。
停止処理が失敗した場合、except以下が実行され、ログにエラー内容を残すとともに、エラーメッセージをSlackに投稿(IncomingWebhook)します。
成功した場合は、else以下が実行され、成功時のメッセージをSlackに投稿(IncomingWebhook)します。

ec2でどのようなメソッドがあるかは、EC2 — Boto 3 Docs 1.9.137 documentationを確認ください。

def stop_command(self, ec2):
    if self.param == '':
        return 'put instance id. ex stop i-xxxxxxxxx.'
    try:
        status = ec2.stop_instances(InstanceIds=[self.param])
    except ClientError as e:
        logger.error(e.response["Error"])
        return postSlack(e.response["Error"]["Message"])
    else:
        return postSlack(self.param + " is stopped.\nstatus is " + str(status["ResponseMetadata"]["HTTPStatusCode"]))

Slackへの投稿(IncomingWebhook)はこの通りです。
気を付けないといけないのは、リクエストで送るJSONをエンコードしておかないといけないところですかね。エンコードしないとエラーとなります。

def postSlack(text):
    request = urllib.request.Request(
        WEBHOOK_URL,
        json.dumps({
            'text': text
        }).encode(),
        {
            'Content-Type': 'application/json'
        }
    )
    with urllib.request.urlopen(request) as response:
        returnData = response.read()
        logger.info(returnData)

ここまでが主な処理で、あとは最初に返したメッセージを削除deletePrePost()するだけです。

まず、チャンネルのヒストリをチャンネルヒストリAPI(channels.history method | Slack)で取得します。
そして、取得したヒストリのメッセージを1つずつ確認し、プロセスIDをもとに、Lambda(API Gateway受け)でSlackに返却したメッセージを検索します。
該当のメッセージが見つかれば、削除API(chat.delete method | Slack)を利用して、メッセージを削除します。
これらのAPIを利用するために、SlackのLEGACY_TOKENが必要になります。

チャンネルヒストリAPI(channels.history method | Slack)を利用するときに、paramsoldestを設定すると、指定した時間以降のメッセージを取得できるそうです。
これを設定しないとチャンネルのメッセージ全てを取得してしまうので、処理時間が長くなってしまいます。

params = {
    'channel': channel,
    'token': DECRYPTED_LEGACY_TOKEN,
    'oldest': startUnixTime
}

API Gatewayの作成

[2019/04/27修正]
API Gatewayは、Lamda関数でトリガーを設定したときに作成されている状態です。
しかしこのままだと、Slackからの値をLambda関数でうまく拾えないので、設定を変更していきます。

リソースのANYから、統合リクエストを選択します。
Lambda プロキシ統合の使用にチェックが入っているので、外します。このときにダイアログが2回ほど出てきますが、すべてOKで進めます。
チェックを外すと項目がいくつか増えます。一番下のマッピングテンプレートを選択し、テンプレートが定義されていない場合 (推奨)をチェックします。
マッピングテンプレートの追加から、application/x-www-form-urlencodedを入力してチェックマークで保存します。
application/x-www-form-urlencodedを選択し、{ "body": $util.urlDecode($input.json("$")) }を入力し、保存します。
mapping.png

最終的に下記のようなリソースツリーとなっているかと思います。
resource.png

あとは、アクションからデプロイを選択し、ステージを選択してデプロイします。
デプロイしたときに出力されたURLは、スラッシュコマンドのリクエストURLに設定するときに利用します。
実際に設定するURL体系は、上記例を基にするとhttps://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/[ステージ名]/slack-ec2となります。

スラッシュコマンド作成

最初のSlackアプリケーション作成でアクセスしたページのSlash CommandsからCreate New Commandで作成します。
ここのRequest URLに、先ほどのURL(https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/[ステージ名]/slack-ec2)を入力します。
細かい内容は割愛します。

完成

こんな感じになります。
stop状態でstopしても200で返ってくるそうです。
playgif.gif

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした