Alexaでサーバーやデータベースの起動・停止制御をしたい

# はじめに
こんにちは。NTTドコモの矢吹です。
私のチームでは、ドコモの大規模データをストリーム処理しており、AWSを利用してシステムを開発しています。そのため、開発環境だけでも結構な費用になります。しかし、節約のためにサーバーやデータベースをこまめに停止したり起動するのは面倒くさいし、つい忘れてしまいがちです。そこで、Alexaを使って**「アレクサ、開発環境でデータベース停止しておいて」**という風に制御できれば何かと便利ですよね。ついでに、これで節約できれば「最近費用が予想より大幅に上振れしてる、アカン・・」と頭を抱えている上司に恩を売ることもできます。
そこで、今回はLambdaやAlexa Skills Kitなど触ったことのない素人が勉強がてら、**AlexaでAWSリソースの起動・停止制御を行うスキル開発**に取り組んでみます。
尚、「平日の勤務開始時間に起動して終了時間に停止するcronを書けばいいじゃん」「サーバーレスで開発しろよ」などの意見はごもっともですが、ここでは受け付けないこととします。

## 目標
「アレクサ、開発環境でWebサーバー起動して」
「アレクサ、開発環境でデータベース停止しておいて」
といった感じで、音声だけでAWSリソースの起動・停止制御を行う。

## 準備するもの
- [Amazon開発者アカウント](https://developer.amazon.com/ja/)
- [AWSアカウント](https://aws.amazon.com/jp/#)


## 対象とする人
- Alexaスキルの開発の流れをざっくり掴みたい人
- AlexaでAWSのリソース制御 (EC2やRDSの起動・停止)を行いたい人

## 参考にした資料
下記の資料を大変参考にさせていただきました。

- [Alexa Skills Kit(ASK) ドキュメント](https://developer.amazon.com/ja-JP/docs/alexa/ask-overviews/build-skills-with-the-alexa-skills-kit.html)
- [Alexa Skills Kit SDK for Python](https://alexa-skills-kit-python-sdk.readthedocs.io/ja/latest/index.html) 
- [Amazon Echo (Alexa) のSkillの開発に必要な基本概念を押さえる](https://qiita.com/chroju/items/3f30fe424df36e4b4d80)
- [AlexaスキルをPython/Lambdaで実装する](https://qiita.com/nsuhara/items/5b19cfb5ffb897bd4cfd)

# 実装
Alexaスキルを作るには、音声入力のインターフェースの作成とリクエスト内容に応じて処理を行うバックエンドの実装が必要です。インターフェースの作成は[Alexa開発者コンソール](https://developer.amazon.com/alexa/console/ask)でWeb画面を操作しながら行います。バックエンドはPythonで実装し、Lambdaで実行するようにしたいと思います。

## インターフェースの作成
まずは、Alexa開発者コンソールでインターフェースを作成していきます。今回は日本語のスキルでAWSアカウントのLambdaでホスティングしたいため、下図のように選択し、スキルを作成します。
![スクリーンショット 2020-12-14 23.33.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/103e0478-6cf0-2e06-7c40-4d44d587ffb9.png)

テンプレートはスクラッチを選択します。
![スクリーンショット 2020-12-14 23.42.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/4e133eae-827c-2299-9709-0b8923260c14.png)

以上で基本的なテンプレートが作成されるので、呼び出し名やIntent, Slotなどの設定をしていきます。

### Invocation Name
Alexaが作成したスキルに応答できるようにInvocation Name(呼び出し時のキーワード)を設定します。「開発環境でWebサーバー止めて」のように呼び出したいため、「開発環境」をキーワードとして設定しました。
![スクリーンショット 2020-12-14 23.46.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/e7a8b9b4-19e6-a38b-76af-d588a35e4ef7.png)

### Intent
次にIntentを作成します。[ドキュメント](https://developer.amazon.com/ja-JP/docs/alexa/ask-overviews/alexa-skills-kit-glossary.html#i)では、Intentは下記のように説明されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/02b9517d-9700-2d2f-30b3-d343f63b5788.png)

少しわかりづらいですが、自分なりに触ったりして解釈した結果、**ある目的の音声リクエストを認識するための機能**とすると腑に落ちました。Intentが音声リクエストを正しく認識できるように発話サンプルを定義してあげる必要があります。「Webサーバー止めて」「データベース起動して」のような発話が考えられますが、全て網羅するのは大変です。なので、
```{resource}を{action}して```
のように発話サンプルに引数を与えることができると何かと便利です。この引数のことをSlotと呼びます。今回は下図のようにResouceControlという名前のIntentを作成しました。発話サンプルは考えられるバリエーションをたくさん作ってあげると認識率が上がります。
![スクリーンショット 2020-12-15 9.46.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/14589855-1a60-e4a9-0eaf-5972a4f2925f.png)

### Slot
次に先ほど説明したSlotを作成します。まずはSlot Typeのタブに移動し、以下のように```resource```を定義します。後のバックエンドの実装でこの値とリソースIDを紐付けて制御できるようにします。
![スクリーンショット 2020-12-15 10.15.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/5763382b-5cb9-f8da-1832-ee2eacda8833.png)

続いて```action```を定義します。今回はリソースの起動と停止を行いたいので以下のようにしました。類義語も登録するとより汎用性が高くなります。
![スクリーンショット 2020-12-15 10.16.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/497ca8e4-fa1a-3fab-dafa-2a3ab4fa5fd5.png)

そして、再びIntentのタブに戻り、今定義したSlot TypeとIntent Slotを紐付けます。
![pic7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/3d642210-c6e7-0577-0564-f8f0a1e8d1da.png)

これでインターフェイスの実装は一通り終わりました。ページ上部にある、Save Model と Build Model のボタンを押してモデルをの保存とビルドを行います。非常に簡単ですね。

## バックエンドの実装
次にバックエンドの実装を行います。今回はせっかくなので先日発表された[Lambdaのコンテナイメージサポート](https://aws.amazon.com/jp/blogs/news/new-for-aws-lambda-container-image-support/) を試してみたいと思います。
フォルダ構成は下記のようになります。

```
alexa/
├── Dockerfile
├── app.py
└── resource.json

```

AWSが提供するLambda用のPythonイメージを使用します。
Alexaスキル開発用のライブラリ(ask-sdk)のみ追加でインストールします。また、起動後はhandlerが呼ばれるようにします。

``` Dockerfile.
FROM public.ecr.aws/lambda/python:3.8

RUN pip3 install --upgrade pip && \
    pip3 install ask-sdk==1.15.0

COPY  app.py resource.json ./

CMD ["app.handler"]
```

制御したいリソースの名前とIDを下記のように記載します。各リソースのIDはAWSコンソール等などから確認して入力してください。このファイルはロジック部分で読み込んで使用します。

``` resource.json
{
  "ウェブサーバー": "your_web_server_id" ,
  "api サーバー": "your_api_server_id" ,
  "データベース": "your_db_cluster_id"
}
```

次にロジック部分です。[公式ドキュメント](https://alexa-skills-kit-python-sdk.readthedocs.io/ja/latest/DEVELOPING_YOUR_FIRST_SKILL.html) のコードをコピペしたものがベースとなっています。実装の流れとしては、LaunchRequest（呼び出し名のみのリクエスト）やIntentRequest（先ほど定義したカスタムIntentや組み込みのCancelAndStopIntentなど、Intent付きのリクエスト）、SessionEndedRequest（会話終了のリクエスト）などが呼ばれた際に行う処理やアレクサに喋らせる内容などを実装していきます。

``` app.py
import json
from ask_sdk_core.dispatch_components import AbstractRequestHandler
from ask_sdk_core.dispatch_components import AbstractExceptionHandler
from ask_sdk_core.handler_input import HandlerInput
from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_core.utils import get_slot_value_v2, is_intent_name, is_request_type
from ask_sdk_model import Response
from ask_sdk_model.ui import SimpleCard
import boto3


sb = SkillBuilder()


def get_resource_id(resource_name):
    with open('resource.json') as f:
        resource_list = json.load(f)
    return resource_list[resource_name]


class LaunchRequestHandler(AbstractRequestHandler):
    def can_handle(self, handler_input):
        # type: (HandlerInput) -> bool
        return is_request_type('LaunchRequest')(handler_input)

    def handle(self, handler_input):
        # type: (HandlerInput) -> Response
        speech_text = 'どのAWSリソースを起動/停止しますか？'

        handler_input.response_builder.speak(speech_text).set_card(
            SimpleCard('AWS', speech_text)).set_should_end_session(
            False)
        return handler_input.response_builder.response


class ResourceControlHandler(AbstractRequestHandler):
    def can_handle(self, handler_input):  # type: (HandlerInput) -> bool
        return is_intent_name('ResourceControl')(handler_input)

    def handle(self, handler_input):  # type: (HandlerInput) -> Union[None, Response]
        action = get_slot_value_v2(handler_input=handler_input, slot_name='action').value
        resource_name = get_slot_value_v2(handler_input=handler_input, slot_name='resource').value
        print(f'action: {action}')
        print(f'resource_name: {resource_name}')

        start_message = f'{resource_name}を起動しました'
        already_started_message = f'{resource_name}はすでに起動しています'
        stop_message = f'{resource_name}を停止しました'
        already_stopped_message = f'{resource_name}はすでに停止しています'
        end_session = True

        if resource_name in ['ウェブサーバー', 'api サーバー']:
            ec2 = boto3.client('ec2')
            ec2_status = ec2.describe_instances(InstanceIds=[get_resource_id(resource_name)])\
                ["Reservations"][0]["Instances"][0]['State']['Name']
            if action == '起動':
                if ec2_status == 'running' or ec2_status == 'pending':
                    speech_text = already_started_message
                else:
                    ec2.start_instances(InstanceIds=[get_resource_id(resource_name)])
                    speech_text = start_message
            elif action == '停止':
                if ec2_status == 'stopping' or ec2_status == 'stopped':
                    speech_text = already_stopped_message
                else:
                    ec2.stop_instances(InstanceIds=[get_resource_id(resource_name)])
                    speech_text = stop_message
            else:
                speech_text = f'{resource_name}をどうしますか？もう一回言ってください'
                end_session = False
        elif resource_name == 'データベース':
            rds = boto3.client('rds')
            if action == '起動':
                print('Start RDS')
                try:
                    rds.start_db_cluster(DBClusterIdentifier=get_resource_id('データベース'))
                    speech_text = start_message
                except Exception as e:
                    print(e)
                    speech_text = '起動に失敗しました。データベースはすでに起動しているかもしれません。'
            elif action == '停止':
                try:
                    rds.stop_db_cluster(DBClusterIdentifier=get_resource_id('データベース'))
                    speech_text = stop_message
                except Exception as e:
                    print(e)
                    speech_text = '停止に失敗しました。データベースはすでに停止しているかもしれません。'
            else:
                speech_text = f'{resource_name}をどうしますか？もう一回言ってください'
                end_session = False
        else:
            speech_text = 'チョットナニイッテルカワカリマセン。'
            end_session = False

        handler_input.response_builder.speak(speech_text).set_card(
            SimpleCard('Control AWS Resource', speech_text)).set_should_end_session(end_session)
        return handler_input.response_builder.response


class HelpIntentHandler(AbstractRequestHandler):
    def can_handle(self, handler_input):
        # type: (HandlerInput) -> bool
        return is_intent_name('AMAZON.HelpIntent')(handler_input)

    def handle(self, handler_input):
        # type: (HandlerInput) -> Response
        speech_text = '例えば、web サーバーを起動して、と言って見てください'

        handler_input.response_builder.speak(speech_text).ask(speech_text).set_card(
            SimpleCard('Control AWS Resource', speech_text))
        return handler_input.response_builder.response


class CancelAndStopIntentHandler(AbstractRequestHandler):
    def can_handle(self, handler_input):
        # type: (HandlerInput) -> bool
        return is_intent_name('AMAZON.CancelIntent')(handler_input) or is_intent_name('AMAZON.StopIntent')(handler_input)

    def handle(self, handler_input):
        # type: (HandlerInput) -> Response
        speech_text = 'さようなら'

        handler_input.response_builder.speak(speech_text).set_card(
            SimpleCard('Control AWS Resource', speech_text))
        return handler_input.response_builder.response


class SessionEndedRequestHandler(AbstractRequestHandler):
    def can_handle(self, handler_input):
        # type: (HandlerInput) -> bool
        return is_request_type('SessionEndedRequest')(handler_input)

    def handle(self, handler_input):
        # type: (HandlerInput) -> Response
        # クリーンアップロジックをここに追加します

        return handler_input.response_builder.response


class AllExceptionHandler(AbstractExceptionHandler):

    def can_handle(self, handler_input, exception):
        # type: (HandlerInput, Exception) -> bool
        return True

    def handle(self, handler_input, exception):
        # type: (HandlerInput, Exception) -> Response
        # CloudWatch Logsに例外を記録する
        print(exception)

        speech = 'すみません、わかりませんでした。もう一度言ってください。'
        handler_input.response_builder.speak(speech).ask(speech)
        return handler_input.response_builder.response


sb.add_request_handler(LaunchRequestHandler())
sb.add_request_handler(ResourceControlHandler())
sb.add_request_handler(HelpIntentHandler())
sb.add_request_handler(CancelAndStopIntentHandler())
sb.add_request_handler(SessionEndedRequestHandler())
sb.add_exception_handler(AllExceptionHandler())

handler = sb.lambda_handler()
```
コードを見てわかる通り、ResourceControlのIntentを処理するためのクラス(ResourceControlHandler)の実装がメインとなります（その他はほとんどコピペ）。このクラスでは、リクエストのactionとresourceのSlot値を取り出し、値に応じて処理を変えるようにしています。例えば、resourceがウェブサーバーやAPIサーバーの場合、ec2クライアントを呼び出してactionの値に応じて起動や停止の操作をします。
また、喋らせる内容はspeech_textに設定します。正常終了したため会話を終了したい、もしくはリクエストがおかしいので聞き返して会話を継続したい、などはend_sessionの値で制御します。最後にspeech_textやend_sessionなどの値でレスポンス内容を組み立ててアレクサに喋らせるための値を返します。こちらも簡単ですね。
実装が完了したら、コンテナイメージをビルドしてECRにプッシュします。（割愛）

## Lambdaの設定
次にLambda関数を作っていきます。今回はランタイムとしてコンテナを利用するため、コンテナイメージを選択し、関数名と先ほどECRにプッシュしたイメージのURIを指定します。アクセス権限はLambdaがEC2やRDSなどのリソースを操作できるように適切なIAMロールを作成し、それを使用するようにします。
![pic9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/0c87e7d1-c1ba-4113-b3f9-ca76b8bdcd03.png)

関数作成後、LambdaのARNをコピーして再びAlexa開発者コンソールに戻り、下記のようにエンドポイントの設定を行います。
![pic10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/25156757-765b-f360-3e0c-6d57b256909a.png)

Lambdaの設定画面に戻り、下記のようにトリガーを設定します。
![pic11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/5f026cf3-2249-d987-fb42-0bca5d037dbf.png)

以上で実装は完了です。Alexa開発者コンソールに戻り、作成したスキルの動作確認をしましょう。

# 動作確認

## テキスト入力による動作確認
テストタブに移動し、下記のようにスキルの動作確認を行うことができます。正しく動作していそうですね。AWSコンソールで確認したところ、きちんとデータベースは起動されていました。

![スクリーンショット 2020-12-16 12.58.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/345abb31-bce5-0397-27fd-b5376c1e47b1.png)

## 音声入力による動作確認
「開発環境でAPIサーバー停止して」と言ってみます。
![スクリーンショット 2020-12-16 13.13.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552099/a53ea390-1bc6-0de7-baa5-d885295cdd73.png)

・・・自分が滑舌悪いこと忘れてました。

## 対象とする人（改）
- Alexaスキルの開発の流れをざっくり掴みたい人
- AlexaでAWSのリソース制御 (EC2やRDSの起動・停止)を行いたい人
- <font color="Red">滑舌が良い人</font>

# おわりに
Alexaスキルの開発を一通り体験してみましたが、IntentやSlotなどの概念さえを理解できれば意外と簡単に作れるんだなというのが作ってみての所感です。また、滑舌が悪い人には音声インターフェースは扱いづらいなということを改めて実感できました。ここまで作っといてアレですが、このスキルは使わずにシェルスクリプトを書いて実行するようにしようかなと思います。