顧客フロントSEのIT勉強ブログ

2022 Japan AWS Top Engineer / 2022-23 Japan AWS Certifications Engineer。AWS認定12冠、情報処理試験全冠。顧客フロントSEがなるべく手を動かしながらIT技術を学んでいくブログです。

【ChatOps事始め】SlackからEC2/RDSの起動・停止やステータス確認をしてみる

業務でチャットを使うシーンが増えたので、システム運用もチャットを通じてやりたいなぁと思うことが増えました。ChatOps事始めとして、SlackからEC2/RDSを操作する仕組みを実装してみます。

テレワークがきっかけでチャットツールを業務で使うことが増えましたが、こうなるとチャットを通じて普段のシステム運用も回したくなります。チャットでシステム運用を行うことを「ChatOps」と呼びますが、ここ1~2年で耳にする機会も増えました。

ChatOpsの第一歩はシステムアラートをチャットで受けることかと思いますが、それだけで「ChatOpsしてるぜ!」というのはちょっと微妙。システム→人の運用だけでなく、人→システムの運用も出来て初めてChatOpsなのかなと個人的には思います。

ということで“ChatOps事始め“として、SlackからEC2/RDSインスタンスの起動・停止を行ったり、インスタンスのステータス確認が出来るようにしてみました。

処理方式

全体の処理方式は次のとおりです。開発環境など頻繁にEC2/RDSの起動・停止を行う運用で役に立つかなと思います。

Slackからのメッセージを受け付けるのはAWS ChatbotAWSにおけるChatOpsを司るサービスです。EC2/RDS起動・停止など実処理はLambdaで実装し、ChatbotがLambda関数を起動する仕組みです。

またEC2/RDSインスタンスのステータス(Running、Stoopedなど)をわざわさAWSコンソールを立ち上げなくてもSlackで確認できるようにしていますが、LambdaからSlackへのメッセージ送信をSlackのIncoming Webhook機能で実装しました。

実装方法

1.AWS Chatbotのセットアップ

まずはAWS Chatbotのサービス画面から「新しいクライアントを設定」します。
AWS ChimeとSlackが選べますが、今回はSlackを選択します。SlackワークスペースのURLを入力すると、権限リクエスト画面に移るので「許可する」をクリックします。

(出典)AWS builders.flash「SlackとAWS ChatbotでChatOpsをやってみよう」

次は連携したいSlackチャネルを設定します。AWS Chatbotが連動するチャネルの選択や権限設定を行えますが、今回はbulk_operationというチャネルを利用します。IAMロールはテンプレートから自動生成とし、Lambda関数が実行できるようテンプレートを選択しておきます。

これでChatbotのセットアップは完了です。

2.Slackのセットアップ

先ほど設定したslackチャネルに@awsユーザを招待します。

/invite @aws

これでSlackを通じてAWS Chatbotを使う準備は完了なのですが、今回はSlackのIncoming Webhook機能を使いたいので、Slackアプリの設定も併せて実施します。
詳細手順は下記ページに任せますが「https://hooks.slack.com/services/xxxx」形式のWebhook URLが発行されたらOKです。後程LambdaからこのURLにメッセージ送信します。

3.Lambda関数の作成

今回は「EC2/RDSインスタンスを起動・停止する関数」と「EC2/RDSインスタンスのステータスを取得する関数」の2つ用意します。ランタイムはPython 3.9を使用します。

まず1つめの「EC2/RDSインスタンスを起動・停止する関数」です。

import json
import boto3
import traceback

def lambda_handler(event, context):
    try:
        region = event['Region']
        action = event['Action']
        type = event['Type']
        
        #EC2インスタンスの起動・停止
        ec2_client = boto3.client('ec2', region)
        if type == 'auto':
            ec2_ids = ec2_client.describe_instances(Filters=[{'Name': 'tag:AutoStop', "Values": ['TRUE']}])
        elif type == 'bulk':
            ec2_ids = ec2_client.describe_instances(Filters=[{'Name': 'tag:BulkStartStop', "Values": ['TRUE']}])

        target_instance_ids = []
        for reservation in ec2_ids['Reservations']:
            for instance in reservation['Instances']:
                target_instance_ids.append(instance['InstanceId'])

        print(target_instance_ids)

        if not target_instance_ids:
            print('There are no EC2 instances subject to automatic start / stop.')
        else:
            if action == 'start':
                ec2_client.start_instances(InstanceIds=target_instance_ids)
                print('started instances.')
            elif action == 'stop':
                ec2_client.stop_instances(InstanceIds=target_instance_ids)
                print('stopped instances.')
            else:
                print('Invalid action.')
                
        #RDSインスタンスの起動・停止
        rds_client = boto3.client('rds', region)
        rds_ids = rds_client.describe_db_instances()

        for db in rds_ids['DBInstances']:
            db_tags = get_tags_for_db(db)
            if type == 'auto':
                tag = next(iter(filter(lambda tag: tag['Key'] == 'AutoStop' and tag['Value'] == 'TRUE', db_tags)), None)
            elif type == 'bulk':
                tag = next(iter(filter(lambda tag: tag['Key'] == 'BulkStartStop' and tag['Value'] == 'TRUE', db_tags)), None)
            else:
                print('Invalid type')
                
            if tag and action == 'start' and db['DBInstanceStatus'] == 'stopped':
                response = rds_client.start_db_instance(DBInstanceIdentifier=db["DBInstanceIdentifier"])
                print('started db instances.[ID: {}]'.format(db["DBInstanceIdentifier"]))
            elif tag and action == 'stop' and db['DBInstanceStatus'] == 'available':
                response = rds_client.stop_db_instance(DBInstanceIdentifier=db["DBInstanceIdentifier"])
                print('stopped db instances.[ID: {}]'.format(db["DBInstanceIdentifier"]))
                
        return {
            "statusCode": 200,
            "message": 'Finished automatic start / stop EC2 and RDS instances process. [Region: {}, Action: {}, Type: {}]'.format(event['Region'], event['Action'], event['Type'])
        }
    except:
        print(traceback.format_exc())
        return {
            "statusCode": 500,
            "message": 'An error occured at automatic start / stop EC2 and RDS instances process.'
        }
        
def get_tags_for_db(db):
    instance_arn = db['DBInstanceArn']
    instance_tags =  boto3.client('rds').list_tags_for_resource(ResourceName=instance_arn)
    return instance_tags['TagList']

この関数のイベント(引数)は次の形式です。

{"Region": "ap-northeast-1", "Action": "start", "Type": "bulk"}

Actionが"start"ならEC2/RDSインスタンスを起動、"stop"なら停止します。Typeは"bulk"を指定してSlackから実行します(Typeが"auto"の処理は、EventBridgeで毎日定時にインスタンス停止・起動をさせようと思って実装した機能なのですが、この記事の本題ではないので割愛します)。

これでBulkStartStop : TRUEのタグが登録されているEC2/RDSインスタンスを起動・停止することができます。

ーーーー

ここで少し本題から外れますが、boto3の話を少しだけ。
boto3はPythonAWSを操作するSDKの呼称ですが、botoってアマゾン川にいるイルカの名前だそうです。アマゾン(AWS)のエコシステムをナビゲートしてくれる、という意味を込めたそうで。他言語のSDKにはこんな呼称は無いのでPythonだけ特別。
あとboto3でEC2、RDSを操作するときに両者の癖があるんですよね・・・EC2はインスタンスIDを配列でstart_instancesメソッドに渡せば複数インスタンスを一度に起動できるけど、RDSのstart_db_instanceは1つずつだったり、EC2はstopped状態のインスタンスにstop_instancesメソッドを実行してもエラーにならないが、RDSだとエラーになっちゃうなど。サボらずboto3ドキュメントを読みながら実装しないといけませんね!!

次が2つめの「EC2/RDSインスタンスのステータスを取得する関数」です。

import boto3
import json
import urllib.request
import traceback

def lambda_handler(event, context):
    try:
        region = event['Region']
        ec2_client = boto3.client('ec2', region)
        rds_client = boto3.client('rds', region)
        ec2_instances = ec2_client.describe_instances()
        rds_instances = rds_client.describe_db_instances()
        slack_message = ""

        # EC2の情報を抜粋
        slack_message += '<EC2インスタンス>\n'
        for reservation in ec2_instances['Reservations']:
            for instance in reservation['Instances']:
            # インスタンスのNameタグとステータスを取得
                ec2_tags = dict([(tag['Key'], tag['Value']) for tag in instance['Tags']])
                ec2_instance_state = instance['State']['Name']
                slack_message += ec2_tags['Name'] + ' : ' + ec2_instance_state + '\n'

        # RDSの情報を抜粋
        slack_message += ' \n<RDSインスタンス>\n'
        for db in rds_instances['DBInstances']:
            # インスタンスの識別子とステータスを取得
            rds_id = db["DBInstanceIdentifier"]
            rds_instance_state = db["DBInstanceStatus"]
            slack_message += rds_id + ' : ' + rds_instance_state + '\n'
                
        post_slack(slack_message)
    
        return {
            "statusCode": 200,
            "message": 'Finished discribe EC2 and RDS instances process.  [Region: {}]'.format(event['Region'])
        }
    except:
        print(traceback.format_exc())
        return {
            "statusCode": 500,
            "message": 'An error occured at describe EC2 and RDSinstances process.'
        }

# slackのincoming_webhookを使ってメッセージ送信            
def post_slack(message):
    send_data = {
        "username": "AWS_Bot",
        "text":message
    }
    send_text = "payload=" + json.dumps(send_data)
    request = urllib.request.Request(
        "https://hooks.slack.com/services/xxxx/xxxx/xxxx",
        data=send_text.encode('utf-8'),
        method="POST"
    )
    
    with urllib.request.urlopen(request) as response:
        response_body = response.read().decode('utf-8')

この関数で想定しているイベント(引数)は次の形式です。

{"Region": "ap-northeast-1"}

post_slackメソッドがwebhook機能を使ったメッセージ送信部分で、途中のURLには、Slackセットアップ時に取得したWebhook URLを記載してください。

これら2つの関数はEC2・RDS・CloudWatchLogsへの操作が含まれているため、
EC2はDescribeInstances/StartInstances/StopInstances、
RDSはDescribeDBInstances/ListTagsForResources/StartDbInstance/StopDbInstance、
CloudWatchLogsはCreateLogStream/PutLogEventsのポリシーを付与
します。

またEC2/RDS操作にやや時間がかかるのかタイムアウトを起こすことがあるので、タイムアウト値をデフォルト(3秒)から少し伸ばしたほうがよいかもしれません。

ChatOpsを実行してみる

ではSlackから実行してみます。
まずEC2/RDSインスタンスを起動する場合、以下のメッセージを打ちます。

@aws lambda invoke —function-name StartStopInstances —region ap-northeast-1 —payload {“Region”: “ap-northeast-1”, “Action”: “start”, “Type”: “bulk”}

実行するか聞かれるので「[RUN] command」を押します。

上手く動いたようです!ステータスコードが200で返ってきました。

本当に起動できたかAWSコンソールから確認しても良いのですが、せっかくなのでSlackからインスタンスのステータスを確認してみます。一度Lambdaを実行すると --regionオプションを指定しなくてもよくなるようで、下記で実行できました。

@aws lambda invoke —function-name DescribeInstances —payload {“Region”: “ap-northeast-1”}

今回EC2は「踏み台端末#1」と「Webサーバ#1」、RDSは「spf-d-rds」のみAutoStartStopタグをつけて起動・停止の対象にしたのですが、無事running(RDSはまだstarting状態ですが)になっていますね。

まとめ

簡単にChatOpsを実装することが出来ました。「こんなことやりたいな」と思ってから実装にかかったのは、おおよそ3時間くらいだったかと思います。
これでパソコンを閉じた後に「インスタンス停止するの忘れてた!」と気づいて渋々デスクに戻る必要が無くなりました。

ちなみに、毎回コマンドを打つのは面倒、と思って調べたのですが、Slackのワークフロー機能を使えば定型文を登録できるようなので、クリック操作だけでLambda実行できるかもしれません(有料プランでしか使えず、無料ユーザの私は試せませんでしたが・・)