はじめに
こんにちは。NTTドコモの矢吹です。
私のチームでは、ドコモの大規模データをストリーム処理しており、AWSを利用してシステムを開発しています。そのため、開発環境だけでも結構な費用になります。しかし、節約のためにサーバーやデータベースをこまめに停止したり起動するのは面倒くさいし、つい忘れてしまいがちです。そこで、Alexaを使って**「アレクサ、開発環境でデータベース停止しておいて」**という風に制御できれば何かと便利ですよね。ついでに、これで節約できれば「最近費用が予想より大幅に上振れしてる、アカン・・」と頭を抱えている上司に恩を売ることもできます。
そこで、今回はLambdaやAlexa Skills Kitなど触ったことのない素人が勉強がてら、AlexaでAWSリソースの起動・停止制御を行うスキル開発に取り組んでみます。
尚、「平日の勤務開始時間に起動して終了時間に停止するcronを書けばいいじゃん」「サーバーレスで開発しろよ」などの意見はごもっともですが、ここでは受け付けないこととします。
目標
「アレクサ、開発環境でWebサーバー起動して」
「アレクサ、開発環境でデータベース停止しておいて」
といった感じで、音声だけでAWSリソースの起動・停止制御を行う。
準備するもの
対象とする人
- Alexaスキルの開発の流れをざっくり掴みたい人
- AlexaでAWSのリソース制御 (EC2やRDSの起動・停止)を行いたい人
参考にした資料
下記の資料を大変参考にさせていただきました。
- Alexa Skills Kit(ASK) ドキュメント
- Alexa Skills Kit SDK for Python
- Amazon Echo (Alexa) のSkillの開発に必要な基本概念を押さえる
- AlexaスキルをPython/Lambdaで実装する
実装
Alexaスキルを作るには、音声入力のインターフェースの作成とリクエスト内容に応じて処理を行うバックエンドの実装が必要です。インターフェースの作成はAlexa開発者コンソールでWeb画面を操作しながら行います。バックエンドはPythonで実装し、Lambdaで実行するようにしたいと思います。
インターフェースの作成
まずは、Alexa開発者コンソールでインターフェースを作成していきます。今回は日本語のスキルでAWSアカウントのLambdaでホスティングしたいため、下図のように選択し、スキルを作成します。
以上で基本的なテンプレートが作成されるので、呼び出し名やIntent, Slotなどの設定をしていきます。
Invocation Name
Alexaが作成したスキルに応答できるようにInvocation Name(呼び出し時のキーワード)を設定します。「開発環境でWebサーバー止めて」のように呼び出したいため、「開発環境」をキーワードとして設定しました。
Intent
次にIntentを作成します。ドキュメントでは、Intentは下記のように説明されています。
少しわかりづらいですが、自分なりに触ったりして解釈した結果、ある目的の音声リクエストを認識するための機能とすると腑に落ちました。Intentが音声リクエストを正しく認識できるように発話サンプルを定義してあげる必要があります。「Webサーバー止めて」「データベース起動して」のような発話が考えられますが、全て網羅するのは大変です。なので、
{resource}を{action}して
のように発話サンプルに引数を与えることができると何かと便利です。この引数のことをSlotと呼びます。今回は下図のようにResouceControlという名前のIntentを作成しました。発話サンプルは考えられるバリエーションをたくさん作ってあげると認識率が上がります。
Slot
次に先ほど説明したSlotを作成します。まずはSlot Typeのタブに移動し、以下のようにresource
を定義します。後のバックエンドの実装でこの値とリソースIDを紐付けて制御できるようにします。
続いてaction
を定義します。今回はリソースの起動と停止を行いたいので以下のようにしました。類義語も登録するとより汎用性が高くなります。
そして、再びIntentのタブに戻り、今定義したSlot TypeとIntent Slotを紐付けます。
これでインターフェイスの実装は一通り終わりました。ページ上部にある、Save Model と Build Model のボタンを押してモデルをの保存とビルドを行います。非常に簡単ですね。
バックエンドの実装
次にバックエンドの実装を行います。今回はせっかくなので先日発表されたLambdaのコンテナイメージサポート を試してみたいと思います。
フォルダ構成は下記のようになります。
alexa/
├── Dockerfile
├── app.py
└── resource.json
AWSが提供するLambda用のPythonイメージを使用します。
Alexaスキル開発用のライブラリ(ask-sdk)のみ追加でインストールします。また、起動後はhandlerが呼ばれるようにします。
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コンソール等などから確認して入力してください。このファイルはロジック部分で読み込んで使用します。
{
"ウェブサーバー": "your_web_server_id" ,
"api サーバー": "your_api_server_id" ,
"データベース": "your_db_cluster_id"
}
次にロジック部分です。公式ドキュメント のコードをコピペしたものがベースとなっています。実装の流れとしては、LaunchRequest(呼び出し名のみのリクエスト)やIntentRequest(先ほど定義したカスタムIntentや組み込みのCancelAndStopIntentなど、Intent付きのリクエスト)、SessionEndedRequest(会話終了のリクエスト)などが呼ばれた際に行う処理やアレクサに喋らせる内容などを実装していきます。
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ロールを作成し、それを使用するようにします。
関数作成後、LambdaのARNをコピーして再びAlexa開発者コンソールに戻り、下記のようにエンドポイントの設定を行います。
Lambdaの設定画面に戻り、下記のようにトリガーを設定します。
以上で実装は完了です。Alexa開発者コンソールに戻り、作成したスキルの動作確認をしましょう。
動作確認
テキスト入力による動作確認
テストタブに移動し、下記のようにスキルの動作確認を行うことができます。正しく動作していそうですね。AWSコンソールで確認したところ、きちんとデータベースは起動されていました。
音声入力による動作確認
・・・自分が滑舌悪いこと忘れてました。
対象とする人(改)
- Alexaスキルの開発の流れをざっくり掴みたい人
- AlexaでAWSのリソース制御 (EC2やRDSの起動・停止)を行いたい人
- 滑舌が良い人
おわりに
Alexaスキルの開発を一通り体験してみましたが、IntentやSlotなどの概念さえを理解できれば意外と簡単に作れるんだなというのが作ってみての所感です。また、滑舌が悪い人には音声インターフェースは扱いづらいなということを改めて実感できました。ここまで作っといてアレですが、このスキルは使わずにシェルスクリプトを書いて実行するようにしようかなと思います。