スラッシュコマンド(SlackAPI) ⇄ API Gateway ⇄ Lambda でEC2を再起動する

技術

こんにちは。ざわかける!のざわ(@zw_kakeru)です。
Slackでスラッシュコマンドを入力することで、SlackAPI経由でAWS API Gatewayをたたき、AWS Lambdaを実行し、EC2インスタンスに再起動をかけます。

起こったこと

SlackからAWSのEC2インスタンスに再起動をかけられたらいいなあと考えました。
軽くネットで検索してみるとこの手の記事がたくさん出てきたので早速真似してやってみました。
すると、

「今後はSlackAppじゃなくてSlackAPI側を使ってね」的なメッセージが書かれていました。
仕方ないかとSlackAPIを使って作業をしようと思いましたが、ネットでヒットするほとんどの記事ではSlackAppが使われていました。

SlackAPIを使った記事はクリティカルなものがほとんど見つからず、自分で適当にやっていて何度か落とし穴にハマったのでその部分も含めて書き記しておこうと思った次第です。

前提

次のサービスを自由に操作できる権限を持つこと。

  • SlackAPI
  • AWS API Gateway
  • AWS Lambda
  • AWS Cloud Watch(最悪なくても良い)

できるようになること

Slackでスラッシュコマンドを叩くことで、EC2インスタンスを再起動できるようになります。

/ec2-server [command]

commandは次の通りです。

  • reboot:EC2インスタンス(dpサーバー)を再起動する
  • status:EC2インスタンス(dpサーバー)の状態を取得する

SlackAPIとSlackAppの違い

SlackAppと違い、SlackAPIは(現状では)POSTリクエストしか送ることができません。
そしてその時のURLパラメータ形式のデータをAPI Gateway上で変換する手順が異なります。
具体的には後述しますが、マッピング時のContent-Typeに「application/x-www-form-urlencoded」を指定してスクリプトを書く必要があります。

手順

SlackAPIで専用アプリケーション作成

SlackAPIから自分のチャンネル専用のAppを作成します。

名前などは適当に設定してください。

Slackの設定は一旦以上(最後に戻ってきますが)ですが、閉じる前にBasic InfomationからVerification Tokenをチラ見だけしておきましょう。
後述のAWS Lambdaの環境変数設定で使うことになります。

専用IAMロールを作成

続いてAWS側の設定に移ります。
まずはこのBot専用のIAMロールを作るところからです。
ポリシー → ロールの順番で作っていきます。

IAMポリシーの作成

IAMダッシュボードを開いてからポリシータブを選択し、「ポリシーを作成」を押します。
設定するアクションは2つです。

  • サービス1
    • サービス:EC2
    • アクション
      • リスト:DescribeInstanceStatus
      • 書き込み:RebootInstances, StartVpcEndpointServicePrivateDnsVerification
    • リソース:すべてのリソース
  • サービス2
    • サービス:CloudWatch Logs
    • アクション
      • 書き込み:すべて
    • リソース:すべてのリソース

タグの設定は特にしません。
最後に名前と説明を決めてポリシーを作成して完了です。

IAMロールの作成

ポリシーが作れたら、ロールタブから「ロールを作成」を選択してください。

  • 信頼されたエンティティタイプ:AWSサービス
  • ユースケース:Lambda

ポリシーの指定では、先ほど作成したものを選択します。

スムーズに進めたら最後に、ロールの名前を設定して作成完了です。

AWS Lambdaの設定

次はLambdaを作成します。
AWS Lambdaを開いて「関数の作成」を選択してください。

関数の作成

一から作成を選んで作成します。

  • 関数名:(適当につける)
  • ランタイム:Node.js 18.x
  • アーキテクチャ:x86_64
  • アクセス権限:既存のロール(先ほど作成したロールを選択)

関数が作成できたらスクリプトを書きます。
コードソースのindex.mjsに任意のスクリプトを記述しましょう。
今回私は次のように記述しました。

'use strict';

import { EC2Client, DescribeInstanceStatusCommand, RebootInstancesCommand } from "@aws-sdk/client-ec2";
const instance_list = [process.env.EC2_INSTANCE_ID_0]

async function rebootEC2Instance(region) {
    const result = { EC2: null};
    const params = {
        InstanceIds: instance_list,
        DryRun: false,
    };
    const client = new EC2Client();
    const command = new RebootInstancesCommand(params);
    await client.send(command)
        .then(data => {
            result.EC2 = { result: 'OK', data: data };
        }).catch(err => {
            result.EC2 = { result: 'NG', data: err };
        });
    return result;
}

async function describeStatusEC2Instance() {
    const result = { EC2: null};
    const params = {
        InstanceIds: instance_list,
        DryRun: false,
    };
    const client = new EC2Client();
    const command = new DescribeInstanceStatusCommand(params);
    await client.send(command)
        .then(data => {
            result.EC2 = { result: 'OK', data: data };
        }).catch(err => {
            result.EC2 = { result: 'NG', data: err };
        });
    return result;
}

function getSuccessfulResponse(message, result) {
    return {
        "response_type": "in_channel",
        "attachments": [
            {
                "color": "#32cd32",
                "title": 'Success',
                "text": message,
            },
            {
                "title": 'Result',
                "text": '```' + JSON.stringify(result, null, 2) + '```',
            },
        ],
    };
}

function getErrorResponse(message) {
    return {
        "response_type": "ephemeral",
        "attachments": [
            {
                "color": "#ff0000",
                "title": 'Error',
                "text": message,
            },
        ],
    };
}

export const handler = async (event, context, callback) => {
    if (!event.token || event.token !== process.env.SLASH_COMMAND_TOKEN){
        callback(null, getErrorResponse('Invalid token'));
    }
    if (!event.text){
        callback(null, getErrorResponse('Parameter missing'));
    }
    if (event.text.match(/reboot/)) {
        const result = await rebootEC2Instance();
        return getSuccessfulResponse('Rebooting...', result);
    } else if (event.text.match(/status/)) {
        const result = await describeStatusEC2Instance();
        return getSuccessfulResponse('Checking statuses...', result);
    } else {
        callback(null, getErrorResponse('Unknown parameters'));
    }
};

スクリプトが完成したらDeployを押します。

環境変数の設定

続いて環境変数を設定します。
設定タブの環境変数から追加しましょう。
追加項目は次の通りです。

  • EC2_INSTANCE_ID_0:再起動したいEC2インスタンスのID
  • EC2_REGION:指定したリージョン(東京を指定したならap-northeast-1)
  • SLASH_COMMAND_TOKEN:SlackAPIのBasic Infomationで確認したVerification Token

デプロイ

ここまで完了したら、アクション > 新しいバージョンを発行でデプロイしてください。

エイリアスの設定

エイリアスのタブから、新規でエイリアスを作成して今作成したバージョンに割り当てましょう。
(エイリアス名は何でもいいですが、例えば「dev」などでしょうか。)

以上でLambdaの作成は完了です。
(追記)トリガーは後述のAPI Gatewayの設定で自動追加されるため、ここではスルーして構いません。

AWS API Gatewayの設定

Slackからコマンドを受け取り、POSTメソッドでLambda関数にリクエストをイベントとして引き渡すAPIを作成していきます。

マネジメントコンソールからAPI Gatewayを開いてAPIを作成します。
REST APIを構築します。

  • API名:(適当につける)
  • エンドポイントタイプ:リージョン

続いてPOSTメソッドを作成します。
アクション > メソッドの作成 > POSTを選択してください。「POST」の文字の右に表示されているチェックアイコンを押して設定画面へ。
Lambda関数に「先ほど作成した関数:エイリアス名(devなど)」を指定して保存します。
(:以降は補完で出なければ自分で打ち込んでください。)
「Lambda 関数に権限を追加する」ダイアログが出たらそのままOK。
これによってLambda側にトリガー設定が追加されます。

↑のような画面が表示されたら、「統合リクエスト」を選択してください。
マッピングテンプレートを選択して「テンプレートが定義されていない場合」を選択します。
Content-Typeに「application/x-www-form-urlencoded」を追加してDSL(指定ドメイン固有言語)でスクリプトを記述して保存しましょう。
今回はslackAPIから送られてくるリクエスト形式をLambdaで扱う形式に変換します。
詳細については別記事にまとめておきましたので興味がある人はのぞいてみてください。

## 全パラメータを取得
#set($rowApiData = $input.path("$"))

## &で分割
#set($apiParams = $rowApiData.split("&"))

## result用配列の初期化
#set($resultParams = [])

## token, text行のみ抽出
#foreach( $kvRaw in $apiParams )
  #set($keyValue = $kvRaw.split("="))
  #if ( ( $keyValue[0] == "token" ) || ( $keyValue[0] == "text" ) )
    #set($_ = $resultParams.add($kvRaw))
  #end
#end

## json形式に変換
{
  #foreach( $kvRaw in $resultParams )
    #set($keyValue = $kvRaw.split("="))
    "$util.urlDecode($keyValue[0])" : "$util.urlDecode($keyValue[1])" #if( $foreach.hasNext ),#end
  #end
}

デプロイ

ようやくデプロイです。
アクションから「APIのデプロイ」を選択します。

  • デプロイされるステージ:[新しいステージ]
  • ステージ名:(適当につける)

これで完了です。
呼び出し用のURLが生成されるので確認しておきましょう。

Slackアプリケーションとの連携

最後に、Slack側から今作成したAPIにリクエストを投げるよう設定しましょう。
SlackAPIで作成したアプリを開いて、「Slash Commands」からCreate New Command。
新しいコマンドを作成。

  • Command: (好きなコマンドを設定・ec2-serverとか)
  • Request URL: (先ほど作成したAPIの呼び出し用URLを設定)
  • Short Description: (適当に記述)

設定できたら、Incoming WebhooksをOnにして「Add New Webhooks to Workspace」します。

Webhookの設定画面が開けたら、ここでチャンネルの指定ができるのでBotを動かしたいチャンネルを選択してください。
これで保存したら完了です。

ちなみにこの時点で「インストールするボットユーザーがありません」などのエラーが出た場合は、先にAppHome > App Display Nameから、DisplayName(表示名)とDefaultusername(メンション指定で使う)を設定してください。
※Botの導入が初めての場合は、初期設定では名前が空欄なのでエラーが起きてしまいます。

実行

Slackからコマンドを実行してみると、正常に動くことが確認できました。

おわりに

SlackからEC2を操作する手順はネット上にたくさん載っていますがそのほとんどがSlackAppを用いるもので、SlackAPIによる手法はあまり見つからなかったのでここに書き記しておきます。
今後はSlackAPIでの実装が主流になっていくと思いますので、「だいたい同じやろ」と思ってSlackAppの記事を見ながら進めてたらハマってしまったという人が救えたらなと考えています。

分からない場所があればここのページのコメント欄に書き込むか、私のTwitterアカウントに連絡をしてください。
時間があればお返事します。
以上です。

参考

参考にしたWebページ:

タイトルとURLをコピーしました