For Your ISHIO Blog

データ分析や機械学習やスクラムや組織とか、色々つぶやくブログです。

SlackのSlash CommandでサーバレスにEC2インスタンスを起動する

ChatOpsとは「Chat」と「Ops」を掛けあわせた造語で、Chatをベースにシステム運用(Ops)を行うことを指します。日々のチームコミュニケーションで利用しているSlackのSlashコマンドを利用して、AWSのEC2インスタンスの起動・停止を行えるようにしたので、その手順を記載していきます。

使ったサービスは、AWS LambdaAmazon API Gateway、そしてSlackです。

課題意識

弊チームでは、社内の検証用/分析用途で、AWS上にEC2インスタンスでClouderaのCDH(Cloudera Distribution Including Apache Hadoop)を構築しています。それなりにハイスペックなインスタンスを使っているため、利用していない時は停止してコストを抑える運用をしています。

最近では下記のような課題が出てきました。

かかわる人が増えてきた

Hadoop環境を触れる人が増えてきました。その結果、「誰が立ち上げたのかわからない。使用中なのかわからない」自体が発生するようになりました。誰が使っているのか可視化したい、また新しいメンバーも簡単に操作できた方が楽だなと思いました。

オペレーションミスを防ぐ

MasterやWorker、Gatewayノード、CDHの場合がCloudera ManagerやCloudera Atlas Directorといった関連EC2インスタンスが多く存在しました。間違って他のインスタンスを立ち上げてしまったり、一部のインスタンスを停止し忘れることがでてきました。

AWSのマネジメントコンソールへのアクセス面倒

そもそも、AWSのマネジメントコンソールに都度アクセスして、6つか7つのEC2インスタンスをチェックして起動する、という操作は面倒でした。

最終ゴール

Slackのチャネル上で、つぎのSlash Command (スラッシュコマンド) を実行し、Hadoopクラスタ関連の複数インスタンスを一度に操作(起動・停止)できるようにします。Slash Commandは、Slack のメッセージ入力欄に / からはじまるコマンドを入力することでAPIを実行できます。私の場合では、以下のようにコマンドを入力することで、EC2インスタンスを操作できるようにします。

/cdh-dev start
/cdh-dev stop

f:id:ishitonton:20201115163814p:plain

全体構成

今回はAWSの次のサービスを利用します。

AWS Lambdaは、イベントが発生した際にスクリプトを実行できる、サーバレスコンピューティングサービスです。リクエストが飛んできたときだけ、EC2インスタンスを起動/停止するスクリプトを実行してくれます。

Amazon API Gatewayは、AWS上で簡単にAPIの作成ができるサービスです。SlackからGETリクエストを取得して、それをLambdaに渡す役割を果たします。

f:id:ishitonton:20201115134616p:plain

参考

今回私は、次のブログを参考にさせて頂きました。

Slack の Slash Command で AWS の EC2 と RDS の起動と停止を実現してみた (1) 導入

ロールの作成

まず最初に、EC2の起動・停止ができるポリシーとロールを作成します。AWSマネジメントコンソールより、IAMにアクセスし、ロールの画面に飛びます。

f:id:ishitonton:20201115140203p:plain

f:id:ishitonton:20201115140309p:plain

ロールの作成をクリックし、新しいロールを作成していきます。 ロールの作成画面では、

  • 信頼されたエンティティの種類を選択:AWSサービス
  • ユースケースの選択:Lambda

をクリックし、次のステップをクリックします

f:id:ishitonton:20201115140412p:plain

ポリシーを選択する画面になりますので、ここではEC2Controlという新しいポリシーを作成しましょう。ポリシーを作成をクリックします f:id:ishitonton:20201115140745p:plain

ビジュアルエディタで、次の設定をしてください。

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

設定が完了したら、ポリシーの確認をクリックします。

f:id:ishitonton:20201115141327p:plain

ポリシーの名前をEC2Controlと入力しポリシーの作成をクリックします。これでポリシーが作成できました。 f:id:ishitonton:20201115141451p:plain

先程のロールの作成画面に戻り、ポリシー名を検索すると、今作成したポリシーが確認できます。EC2Controlにチェックを入れて、次のステップに進みます。

f:id:ishitonton:20201115141651p:plain

ロールの作成の確認画面に進みますので、ロール名をLambdaEC2Controlとしてロールの作成をクリックして完了です。 f:id:ishitonton:20201115142106p:plain

AWS Lambdaの設定

次にLambdaの設定を行っていきます。ここでは、イベント発生時に実行するスクリプトを登録します。 AWS マネジメントコンソールより、AWS Lambdaにアクセスし、右上の関数の作成をクリックします。

f:id:ishitonton:20201115142624p:plain

関数の作成では、一から作成をクリックし、関数名、ランタイム、アクセス権限を指定していきます。

  • 関数名:slack-cdh-dev(※私の場合、Dev環境にあるCDHのEC2インスタンスをSlackから操作するため、このような名前にしました)
  • ランタイム:Node.js 12.x
  • アクセス権限
    • 既存のロールを使用するをクリックし、先程作成したLambdaEC2Controlを指定します。

設定が完了したら、右下の関数の作成をクリックします f:id:ishitonton:20201115143133p:plain

作成したLambda関数の具体的な設定を行っていきます

f:id:ishitonton:20201115143334p:plain

関数コード

実行するスクリプトを登録します。私の用途としては、Hadoopクラスタに関連するインスタンスを一度に起動・停止するユースケースを想定していたため、インスタンスIDリストで全てハードコードしました。この部分は、実際は、のちに説明する環境変数に登録した方が良い情報かもしれません。

またスクリプト内では、イベント発生時にevent.tokenとevent.textをAPI Gatewayから受け取り、処理を行います。event.tokenはSlackから受け取ったtoken情報です。event.textはSlashコマンドの後ろに続くテキスト情報であり、私の場合が/cdh-dev [start/stop]のstartやstopなどの文字列を表しています。その文字列によって、Node.jsスクリプト内の異なる関数を呼び出し、各APIを実行しています。

'use strict';

const AWS = require('aws-sdk');

// 操作したいEC2インスタンスIDを入力する。※各自が入力する
const instance_list = ["i-xxxxxxxxxxxxxxxxx", "i-xxxxxxxxxxxxxxxxx"]

// EC2 インスタンスを起動する
function startEC2Instance(region, instanceId) {
    const ec2 = new AWS.EC2({ region: region });
    const params = {
        InstanceIds: instance_list,
        DryRun: false,
    };
    return new Promise((resolve, reject) => {
        ec2.startInstances(params, (err, data) => {
            if (err) reject(err);
            else     resolve(data);
        }); 
    });
}

// EC2 インスタンスを停止する
function stopEC2Instance(region, instanceId) {
    const ec2 = new AWS.EC2({ region: region });
    const params = {
        InstanceIds: instance_list,
        DryRun: false,
    };
    return new Promise((resolve, reject) => {
        ec2.stopInstances(params, (err, data) => {
            if (err) reject(err);
            else     resolve(data);
        }); 
    });
}

// EC2 インスタンスのステータスを確認する
function describeStatusEC2Instance(region, instanceId) {
    const ec2 = new AWS.EC2({ region: region });
    const params = {
        InstanceIds: instance_list,
        DryRun: false,
    };
    return new Promise((resolve, reject) => {
        ec2.describeInstanceStatus(params, (err, data) => {
            if (err) reject(err);
            else     resolve(data);
        }); 
    });
}

// 関数指定してインスタンスを制御します。
function executeControl(ec2Function) {
    const result = { EC2: null};
    const a = ec2Function(process.env.EC2_REGION, process.env.EC2_INSTANCE_ID)
        .then(data => {
            result.EC2 = { result: 'OK', data: data };
        }).catch(err => {
            result.EC2 = { result: 'NG', data: err };
        });
    const b = null
    return Promise.all([a, b]).then(() => 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,
            },
        ],
    };
}

exports.handler = (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(/start/)) {
        executeControl(startEC2Instance)
        .then(result => { callback(null, getSuccessfulResponse('Starting...', result)); });
    } else if (event.text.match(/stop/)) {
        executeControl(stopEC2Instance)
        .then(result => { callback(null, getSuccessfulResponse('Stopping...', result)); });
    } else if (event.text.match(/status/)) {
        executeControl(describeStatusEC2Instance)
        .then(result => { callback(null, getSuccessfulResponse('Checking statuses...', result)); });
    } else {
        callback(null, getErrorResponse('Unknown parameters'));
    }
};

コードの作成が完了したらDeployボタンをクリックして、スクリプトを保存します。

環境変数

次の環境変数を入力し保存してください。各環境変数は、上のスクリプト内で利用します。なお、SLASH_COMMAND_TOKENについては、後々Slack側でSlash Commandの設定をする際に発行されるので、現在がひとまずhogehogeなどと入力しておきます。後ほど更新します。

キー
EC2_REGION ap-northeast-1
SLASH_COMMAND_TOKEN hogehoge

API Gatewayの設定

次に、AWSのマネジメントコンソールから、Amazon API Gatewayの画面に進んでください。このサービスでは簡単にAPIを作成することができます。ここでのAPI Gatewayの役割は、Slackからコマンドを受け取り、GETメソッドでLambda関数にリクエストをイベントとして引き渡すことです。APIを作成ボタンをクリックしてください。

f:id:ishitonton:20201115145451p:plain

REST APIを構築する選択に進むと、新しいAPIの設定が行えます。

  • API名:slack-cdh-dev ※Lambda関数と同じ
  • エンドポイントタイプ:リージョン

とし、APIの作成をクリックします。s f:id:ishitonton:20201115153052p:plain

まだ、何もAPIメソッドが登録されていないので、GETメソッドを作成します。アクション >> メソッドの作成をクリックし、GETを選択します。右側の✔ボタンをクリックすると、GETメソッドのセットアップに進みます。

f:id:ishitonton:20201115153637p:plain f:id:ishitonton:20201115154107p:plain

Lambda関数には、先程作成した関数名を入力してください。私の場合はslack-cdh-devです。その他の設定がそのままで保存します。Lambda 関数に権限を追加するというダイアログが出ますので、そのままOKをクリックします。

f:id:ishitonton:20201115154317p:plain

次に、メソッドリクエストと統合リクエストの設定を行っていきます。

f:id:ishitonton:20201115154609p:plain

メソッドリクエス

画面のメソッドリクエスをクリックしてください。ここでは、Slackから受信可能なパラメータの情報を指定します。

まず、リクエストの検証をクリックし、クエリ文字列パラメータおよびヘッダーの検証を選択してください。次に、URL クエリ文字列パラメータのタブを開くと、Slackから受け取るパラメータを指定できます。クエリ文字列の追加をクリックし、以下の情報を追加してください。

名前 必須
text 必須✔
token 必須✔

textは、Slackで/cdh-dev startと入力した際のstart部分の情報が受信できます。tokenは、Slackから受け取るトークン情報です。

統合リクエス

次に一画面前に戻り、統合リクエスをクリックします。ここでは、メソッドリクエストで取得したtextとtokenの情報をlambda関数(のevent引数)に渡す役目を果たします。一番下のマッピングテンプレートをクリックします。

f:id:ishitonton:20201115155651p:plain リクエスト本文のパススルーの設定では、 テンプレートが定義されていない場合 (推奨)を選択し、Content Typeとしてapplication/jsonと入力します。表示されたテンプレート部分に、次のコードを入力して保存してください。この指定で、URL パラメーターの text と token がそれぞれ Lambda 関数側にevent引数のtextとtokenとして渡されます。

{
   "token": "$input.params('token')",
   "text": "$input.params('text')"
}

f:id:ishitonton:20201115160210p:plain

これでAPIの設定は完了です。

APIのデプロイ

作成したAPIをデプロイします。アクション >> APIのデプロイを選択します。

f:id:ishitonton:20201115165950p:plain

APIのデプロイダイアログが表示されます。ここでは、ステージの設定を行えますので、dev環境へのデプロイを行いましょう。

  • デプロイされるステージ:[新しいステージ]
  • ステージ名:dev

そのままデプロイをクリックしてください。

f:id:ishitonton:20201115160850p:plain

次の画面のように、APIのエンドポイントURLが発行されます。これでAPI Gatewayの設定は完了です。 f:id:ishitonton:20201115161029p:plain

Slackの設定

Slack側で、Slash Commandの登録を行っていきます。Slackの管理画面より、その他管理項目 >> アプリを管理するに進みます。検索窓でSlash Commandと検索してもらうと、Slash Commandのアプリが表示されるはずです。Slackに追加をクリックしてください。

f:id:ishitonton:20201115161623p:plain

コマンドを選択するの項目では、実際に利用するSlash Commandを入力します。執筆者の場合には、/cdh-devと入力します。Slash Commandは、その名のとおり、スラッシュ/から始まるコマンドです。入力が終わったらスラッシュコマンドインテグレーションを追加するを押下してください。

f:id:ishitonton:20201115161854p:plain

最後にコマンドの各設定を行っていきます。

  • URL
    • API Gatewayで発行されたURLを入力します。
  • メソッド
    • GET(取得する)メソッドを指定します。
  • トーク

f:id:ishitonton:20201115162425p:plain

一番下のインテグレーションの保存をクリックすれば、Slash Commandは完成です。

lambda関数のバージョンの発行

最後に、残っていることが2つあります。1つはlambda関数の環境変数SLASH_COMMAND_TOKENの編集です。現在hogehoeという値になっていますので、Slash Commandの設定で取得したトークン情報に書き換えてください。

f:id:ishitonton:20201115162640p:plain

2つ目に、Lambda関数の発行を行います。Lambda関数の設定画面上段のアクション >> 新しいバージョンを発行をクリックしてください。これで、最新のlambda関数が利用できるようになりました。

f:id:ishitonton:20201115162912p:plain

Slackから試してみる

任意のチャネルで、/cdh-dev startと入力してください。(※このコマンドは私が指定したコマンドです)。レスポンスとして、Successというメッセージと、各インスタンスの起動状態がjson形式で返ってきます。/cdh-dev stopと入力してあげれば、同様にインスタンスを停止してあげることができます。

f:id:ishitonton:20201115163814p:plain