目的
EC2はずっと起動しているとやはりお金がかかってくるので、こまめに起動したり停止したりしたいものです。
ただAWSコンソールからとなると面倒です。
そのため今回、EC2の起動停止をSlackのスラッシュコマンドから操作してみようと思います。
サーバレスでEC2操作している記事が見つからなかったので、API GatewayとLambdaを使ってやってみました。
構成
Slackから投げたリクエストは、早くレスポンスしてやらないとタイムアウトになってしまいます。そのため、1つ目のLambdaで2つ目のLambdaを非同期でコールし、200を返しています。2つ目のLambdaがEC2の操作をした後、結果をIncoming WebhookでSlackに返します。
構築
Slackアプリケーションの作成
Slackにログインした状態で、Slack ApiからYour Apps
、Create 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ログイン用の自身のユーザと先ほど作成したロールを選択しておきます。
Lambdaの作成(API Gateway受け)
まず、API Gatewayから直接リクエストを受けるLambdaを作成します。
関数の作成
から一から作成
を選択し、情報を埋めて関数を作成します。
Pythonは3.7
、実行ロールは先ほど作成したロールを選択します。
関数の画面に遷移したとき、デザインは下記のようになっているかと思います。
[2019/04/27修正]
トリガーがまだない状態なので、トリガーの追加
から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 キー
にカスタマーマスターキーの使用
を選択し、同様にエイリアスキーを選択します。
そして、先ほど入力したキーの右にある暗号化
ボタンを押して、キーを暗号化します。
あとは保存すれば、この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_TOKEN
、B_TOKEN
、WEBHOOK_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による暗号化を施して、関数を保存します。
上記より、環境変数の箇所は下記のようになります。# 順番は気にしないでください。。
コードについて簡単に説明しますと。。。
最初にSlackから必要なコマンドが送られてきているか確認しています。
例えばSlackで/ec2 stop i-xxxx
と入力すると、command_text = event["text"]
にはstop i-xxxx
が入ります。
Command(command_text)
でインスタンスを生成するとともにコンストラクタ(33-39行目)を呼び出し、command_text
をスペースで区切り、クラス内変数のcommand
とparam
にそれぞれ代入します。
先の例でいうと、command
にstop
、param
にi-xxxx
が入ります。
クラス内変数command
がcommandList
にあるか確認を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)を利用するときに、params
にoldest
を設定すると、指定した時間以降のメッセージを取得できるそうです。
これを設定しないとチャンネルのメッセージ全てを取得してしまうので、処理時間が長くなってしまいます。
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("$")) }
を入力し、保存します。
最終的に下記のようなリソースツリーとなっているかと思います。
あとは、アクションからデプロイを選択し、ステージを選択してデプロイします。
デプロイしたときに出力された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
)を入力します。
細かい内容は割愛します。