Help us understand the problem. What is going on with this article?

スマートスピーカースキルのバックエンドをAzure Functionsで作ろう!~コールドスタート問題と向き合う~

More than 1 year has passed since last update.

はじめに

本記事は スマートスピーカー 2 Advent Calendar 2018 の20日目の記事です。

みなさん、スマートスピーカーのバックエンドにサーバーレスを使っていますか?
サーバーレスのサービスを使うと、非常に簡単にバックエンドを作ることができますね。

スマートスピーカースキルのバックエンドにサーバーレスというと、おそらく(最初にAlexaが広まったところからも)AWS Lambdaの利用者が多いのではないかと思いますが、AWSの対抗馬、Microsoft Azureのサービスである「Azure Functions」も、かなり快適に使うことができます。

Azure Functionsは、Azureのもつサーバーレスのサービスで、さまざまな言語を使って、サーバーを意識することなく手軽にFunction App(関数アプリ)を作ることができます。
Azure Functionsは他のAzureサービスとの連携はもちろん、「Durable Functions」と呼ばれる、複数の関数を組み合わせた複雑・多様な処理をとても簡単に作ることができるので、スマートスピーカースキルのバックエンドとしてもかなり使い勝手のいいサービスだと思います(HTTPリクエストで処理を行ってくれるタイプの関数を作ることで実現できます)。

コールドスタート問題

しかし、Azure Functionsには現状、大きな弱点があります。それは、 コールドスタートが遅い という点です。
Azure Functions(や他のサーバーレスのサービス)のメリットのひとつとして、「実行した分だけの課金になる」というものがあります。
クラウド上にWebアプリケーションとしてバックエンドを用意すると、常にサーバーが起動しているため、かかるコストも大きくなります。
それに対し、Azure Functionsでは、関数を実行していない間はサーバーが止まっており、実行回数ごとに課金がされることになります(従量課金プラン)。
そのためコストメリットが高いのが特徴ですが、実はAzure Functionsは関数呼び出しのあと、サーバーが止まっている状態から実行可能な状態に復帰するまで(コールドスタート)にかかる時間が長いのです。
Azure Functionsも最初のころからかなり進化していて、この時間も改善してきているところではあるのですが、2018年12月現在、どうしても体感で数秒はかかってしまいます(遅いときは10秒以上)。

Alexaがスキルからの応答を待つ最大時間が約7秒。ClovaやGoogleでも数秒でタイムアウトしてしまいます。
Azure Functionsでは関数が実行された後、再びサーバーが停止状態になるまでには約20分だと言われています。
連続実行では即座にレスポンスが返ってきますが、20分以上間をあけると、タイムアウトするかしないかの微妙なラインでの応答になってしまい、エラーとなってしまうことが結構多いです。

これはわりと致命的で、たとえばClovaでタイムアウトエラーが出ると、「〇〇を起動することができませんでした。しばらくしてから再度お試しください」と言われます。
この指示に従い、本当にユーザーがしばらくまってしまうと、20分でまたサーバーが寝てしまいます。
再実行でサーバーをさらにまた起こすのですが、再びタイムアウトしてしまう可能性がある、というわけです。
使っているユーザーも目的が達成できずイライラだと思いますが、寝たり起こされたりを繰り返すサーバーのほうも、なんだかかわいそうですね。

解決アプローチ

では、この問題をどうするか。いくつか対応策があります。

① 我慢する

わりきって、タイムアウトしたらすぐに再度実行してもらう。
しかし、実際に行われる「しばらくしてから」というアナウンスをユーザーに無視させなければならないので、現実的ではないですね。
タイムアウトしないにしても、スキル起動後、かなり待たされます。
VUIでは、数秒待たされるだけでも「ちゃんと起動できたのかな」とユーザーを不安にさせてしまいます。全体的に大きくUXを損なう結果となってしまうので、許容できないでしょう。

② App Serviceプランを使う

これは現実的な解決策のひとつです。
Azure Functionsでは、実行した分だけ課金の従量課金プラン以外に、Webアプリケーションと同様の仕組みで関数を動かす「App Serviceプラン」というものがあります。
こちらは稼働時間に応じた料金体系となっており、サーバーを常時起動させておくことができます。
しかし、常時接続オンにできるのは「S1」以上の価格レベルなので月に8千円ちょっとかかってしまい、ちょっとお高めです。
サーバーレスのコストメリットの恩恵が受けられないので、なんともつらいところ。しかし、安定した環境が得られるのも事実。難しいジレンマです。

③ ゲートウェイを作る

せっかくサーバーレスを使うのだから、やっぱりコストは抑えたい。
pingを飛ばし続けてサーバーが寝ないようにするという方法もありますが…今回は、Azureの別のサービスを組み合わせてコールドスタートによるタイムアウトを回避する方法を提案してみたいと思います。
今回組み合わせるのは、同じくAzureのサービスである「Logic Apps」です。
こちらもFunctions同様サーバーレスのサービスですが、Azure Functionsがコードを書いて関数を作るのに対し、こちらはGUIのデザイナーでブロックを並べてフローを作るタイプのものです。
こちらはコールドスタートがFunctionsに比べると速く、スマートスピーカースキルのバックエンドにしてもタイムアウトしません。
Functionsのように自分でコードを書けないので、複雑な処理を作ることは難しく、単体でバックエンドにするには用途が限られてしまいます。
今回はLogic Appsのコールドスタートの速さを活かし、複雑な処理はFunctionsにまかせるため、Logic AppsはFunctionsへのつなぎ役・ ゲートウェイ として使います。

たとえばClovaで使う「適当星占い」というスキルを例に示します。
星座を言うと(適当な)占いを言ってくれるスキルです。このような流れです。

VUIのスキルで多いのは、起動後に必要な情報をユーザーに尋ねるパターンです。
実は、この「星座を教えて」という定型の文章を読み上げている間にFunctionsを起動してしまおうというアイディアです。
※Alexaでは「アレクサ、適当占いで天秤座を占って」などの「インテントありの起動」ができますが、今回のゲートウェイのパターンは適用できません。

仕組みはこうです。
スマートスピーカーの音声アシスタント(ここではClova/CEK)とつなぐのはLogic Appsで、その後ろにFunctionsがいるという構成です。

まず、起動リクエストでLogic Appsは定型の質問を返し、同時にFunctionsに空のリクエストを投げサーバーを起こします。
image.png

定型の質問とその答えを行っている間にサーバーが起動。
質問の答えを投げ、それをパラメータとした処理をFunctionsに行わせます。
image.png

ちなみにゲートウェイという名称は、文字通りAzure Functionsの「玄関」として設置するものであることから名付けました。

ゲートウェイパターンの実装

では、具体的にLoginc Appsでのゲートウェイ構成の作成手順をご紹介します。

Azure Functions(Function App)の作成

まずはAzure Functionsです。
従量課金プランでAzureにFunction Appを作成します。名前は lazy-horoscope としました。

そして、Visual StudioでAzure Functionsの新しいプロジェクト(C#)を作成(プロジェクト名は LazyHoroscope とした)、以下のような2つの関数を作ります。

まずは1つ目。インテントリクエストをさばく関数(インテントハンドラー)です。

ClovaIntentHandlerFunction.cs
using CEK.CSharp;
using CEK.CSharp.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;

namespace LazyHoroscope
{
    public static class ClovaIntentHandlerFunction
    {
        [FunctionName("ClovaIntentHandlerFunction")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            var response = new CEKResponse();

            if (req.Headers.TryGetValue("SignatureCEK", out var signature))
            {
                var client = new ClovaClient();
                var request = await client.GetRequest(signature, req.Body);

                // IntentRequestのみここにくる想定
                if (request.Request.Type == RequestType.IntentRequest)
                {
                    switch (request.Request.Intent.Name)
                    {
                        case "ZodiacIntent":
                            // 占い結果を返す。今回は適当占いなのでランダムにどちらか
                            // (本来はここでスロットの星座を受け取ってそれに基づいた処理を行う)
                            if (new System.Random().Next() % 2 == 0)
                            {
                                response.AddText("今日は絶好調!とてもよい1日になりますよ!");
                            }
                            else
                            {
                                response.AddText("今日はあまりついてないかも。転ばないように気を付けてくださいね。");
                            }
                            response.ShouldEndSession = true;
                            break;

                        case "Clova.GuideIntent":
                            // 使い方
                            response.AddText("あなたの星座を教えてください。占ってあげます。");
                            response.ShouldEndSession = false;
                            break;

                        default:
                            // その他のインテントの場合
                            response.AddText("よくわかりませんでしたが、まあまあだと思います。");
                            response.ShouldEndSession = false;
                            break;
                    }
                }
                else
                {
                    // エラー
                    response.AddText("よくわかりませんでしたが、たぶんラッキーな1日になると思いますよ。");
                    response.ShouldEndSession = true;
                }
            }
            else
            {
                // エラー
                response.AddText("よくわかりませんでしたが、きっと大丈夫ですよ。");
                response.ShouldEndSession = true;
            }
            return new OkObjectResult(response);
        }
    }
}

ポイントは、通常バックエンドの処理ではリクエストの種類を切り分ける処理を実装するのですが、今回は事前にゲートウェイを通ってFunctionsにくるのは IntentRequest のみであるという点です。そのため、インテントハンドラーとしての実装のみでよいということになります。

2つ目は、コールドスタート対策の関数です。
この関数は、Logic Appから起動時に呼び出し、寝ていたサーバーを起こすための空関数です。

KnockFunction.cs
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;

namespace LazyHoroscope
{
    public static class KnockFunction
    {
        [FunctionName("KnockFunction")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            return new OkObjectResult("Good morning");
        }
    }
}

中では、特に何もしません。起こすだけが目的なのでこれで十分です。

できあがったら、これをAzureに発行します。プロジェクトを右クリック>発行で以下のように設定し、先ほど作成したFunctions Appにデプロイします。
「パッケージファイルから実行する(推奨)」にチェックを入れるのを忘れずに。

なお、ソースコードはソリューションごとGitHubに上げました。
https://github.com/himanago/LazyHoroscope

Logic Appの作成

発行が完了したら、次はLogic Appの作成です。
さきほどと同じリソースグループ、リージョンにします。

デプロイができたら、デザイナーでロジックを編集していきます。
「HTTP要求の受信時」で編集をクリック、出てきた「サンプルのペイロードを使用してスキーマを生成する」をクリックします。

ここに、Clovaのドキュメントよりコピーしてきた以下のJSONを貼り付けます(IntentRequestの例を使用しました)。

{
  "version": "1.0",
  "session": {
    "new": false,
    "sessionAttributes": {},
    "sessionId": "a29cfead-c5ba-474d-8745-6c1a6625f0c5",
    "user": {
      "userId": "U399a1e08a8d474521fc4bbd8c7b4148f",
      "accessToken": "XHapQasdfsdfFsdfasdflQQ7"
    }
  },
  "context": {
    "System": {
      "application": {
        "applicationId": "com.example.extension.pizzabot"
      },
      "user": {
        "userId": "U399a1e08a8d474521fc4bbd8c7b4148f",
        "accessToken": "XHapQasdfsdfFsdfasdflQQ7"
      },
      "device": {
        "deviceId": "096e6b27-1717-33e9-b0a7-510a48658a9b",
        "display": {
          "size": "l100",
          "orientation": "landscape",
          "dpi": 96,
          "contentLayer": {
            "width": 640,
            "height": 360
          }
        }
      }
    }
  },
  "request": {
    "type": "IntentRequest",
    "intent": {
      "name": "OrderPizza",
      "slots": {
        "pizzaType": {
          "name": "pizzaType",
          "value": "ペパロニ"
        }
      }
    }
  }
}

するとこのようにリクエストのJSONスキーマが定義されるので、以下のロジックはこれをもとに作成していきます。
以降は、リクエストのJSONに含まれる値をロジックの条件等に使うことができるようになります。

まずは、起動リクエストかどうかの分岐です。
起動リクエストの場合、Logic Appsは定型の質問を返しつつ、Functionsを起こします。
この分岐は、typeが LaunchRequest かどうかで判定します。typeというのは、今回設定したJSONスキーマの中でリクエストの種類を示す項目です。

詳細は割愛しますが、Logic AppsではGUIのデザイナーで分岐などを簡単に作ることができます。

trueの場合(起動リクエストであるとき)は KnockFunction を呼び出し、ユーザーに星座を聞くレスポンスを返します。
ポイントは、Logic Appから直接レスポンスを返す点です。この隙に、Functionsをたたき起こすのです。

レスポンスは以下を登録します。

{
  "version": "1.0",
  "sessionAttributes": {
    "RequestedIntent": "ZodiacIntent"
  },
  "response": {
    "card": {},
    "directives": [],
    "outputSpeech": {
      "type": "SimpleSpeech",
      "values": {
        "lang": "ja",
        "type": "PlainText",
        "value": "星座を教えてください。"
      }
    },
    "shouldEndSession": false
  }
}

ブロックはこうなります。

レスポンスの処理後、 KnockFunction を呼びます。Logic Appのデザイナーから、作成済み関数が見えるので、このアクションの追加はとっても簡単です。

trueの場合は最終的にこうなります。

続いてfalseの場合です。
起動リクエスト以外の場合で、今回はインテントリクエストのみを想定した処理を作ります。

こちらは、リクエスト本文をそのままインテントハンドラーの関数に渡し、その応答をLogic Appsの応答として利用する、というフローになります。

また、リクエスト本文のほかに、ヘッダーの SignatureCEKClovaIntentHandlerFunction に渡す必要があります。
でリクエストがClovaからのものかの検証を行うことが重要なので、これもFunctionsにわたします。

SignatureCEK を渡す部分が、元のリクエストのヘッダーの中身を渡すだけなのですが、デザイナーではうまく作ることができないので、コードを直接編集します( headers の部分です)。

"else": {
    "actions": {
        "ClovaIntentHandlerFunction": {
            "inputs": {
                "body": "@triggerBody()",
                "function": {
                    "id": "/subscriptions/xxxxxxxxxxxxx/resourceGroups/lazy-horoscope/providers/Microsoft.Web/sites/lazy-horoscope/functions/ClovaIntentHandlerFunction"
                },
                "headers": {
                    "SignatureCEK": "@triggerOutputs()['headers']['SignatureCEK']"
                },
                "method": "POST"
            },
            "runAfter": {},
            "type": "Function"
        },

これでバックエンドは完成です!

対話モデルの作成など

このあと、CEK側の設定です。各入力項目や対話モデルなどはよしなに作成していきます。
間違えてはいけないのが、ExtensionサーバーのURLには、Logic AppのHTTP要求のURLを入力するという点です。その他は通常のスキルと同様に作っていきます。

実行確認

バックエンドはすでにできているので、対話モデルのビルドが完了したらあとは実行確認です。
一度実行確認し正しく動くことが確認できたら、その後20分以上間をおいて再度実行確認します。
エラーにならず、ちゃんと動いたらゲートウェイパターンの成功です!
(手元でやったら無事タイムアウトすることなくスムーズに動きました!)

クロスプラットフォーム対応にする場合

ちなみに、他のプラットフォームでも同一スキルを提供し、Azure Functionsをクロスプラットフォームなバックエンドとして利用したい場合は、このようにゲートウェイをプラットフォームごとに用意するとよいと思います(定型レスポンスを返す部分はどうしてもプラットフォーム固有の実装になるため)。

今回は(カレンダー参加が駆け込みだったので)Clovaでしか作っていませんが、時間があるときに3プラットフォーム対応版を作ってみたいと思います。

※追記
3プラットフォーム対応しました。プログラム本体のソースコードは下記です。
https://github.com/himanago/LazyHoroscope

まとめ

今回はAzure Functionsを使うために一工夫してみました。Azure Functionsは冒頭でも書きましたが素晴らしいサーバーレスサービスだと思います。
AWS Lambdaももちろん素晴らしいサービスですが、Azure Functionsにもメリットがたくさんあり、あえてこちらを選択する価値は十分にあるのではと思います。
Logic Appsとの組み合わせも、同じAzure内のサービスということもあり、慣れれば簡単に連携させることができるので、スマートスピーカースキルのデザインパターンのひとつとして、使ってみるのもよいのではないかなと思います。

私個人としては、今後のスキル開発はこのパターンで行っていきつつ、Functionsのさらなる進化(そしてコールドスタート時間の改善)を期待したいと思います。

himarin269
開発や研修・講座講師などをしています。C#/Azure/LINE API/スマートスピーカーなど。
http://himanago.hatenablog.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away