Azure サービス プリンシパルの作成方法

Service Principal (SP) は、主にプログラムからの Azure リソース使用 (Azure PowerShell、Azure REST API など) や、Auzre DevOps との連携などで使用する(通常のアカウントも使えるけど)。

用途としては、アクセス制限を行った、プログラム専用のアカウントみたいな感じ。 通常の Azure アカウントで認証した方が手軽だったりするが、セキュリティ的にあまりよろしくなかったり、会社の場合は当該アカウントが削除された場合に、使用しているプログラムも同時に動かなくなる可能性があると言うこともあり、プログラムからのアクセスは SP による認証が推奨されているらしい。

なお、Azure AD で認証用のアカウントを作るということもあり、SP アカウントを作成するには管理者レベルの権限が必要になるはずなので、その点は注意。

作成手順

  1. Azure ポータルで [Azure Active Directory] - [アプリの登録] - [+新規登録] の順にクリック。いきなり分かりづらいが、ここでの「アプリの登録」が「サービス プリンシパルの登録」を意味していると思って OK

    f:id:poke_dev:20191124201815p:plain

  2. 名前にサービスプリンシパル名(アカウント名みたいなもの)を入力し、他は既定の設定で [登録] をクリック

    f:id:poke_dev:20191124202107p:plain

  3. 作成成功したら、概要画面で「アプリケーション (クライアント) ID」と「ディレクトリ (テナント) ID」をメモしておく

    f:id:poke_dev:20191124211908p:plain

  4. [証明書とシークレット] - [+新しいクライアント シークレット] の順にクリック

    f:id:poke_dev:20191124202805p:plain

  5. クライアント シークレットの追加ダイアログが表示されたら、「有効期限」で「なし」を選択し、[追加] をクリック。なお、ここでは無期限で登録しているが、定期的にパスワード変更をしたい場合は、適宜その期限を選択する

    f:id:poke_dev:20191124202937p:plain

  6. クライアント シークレット(パスワードみたいなもの)の値は、この時点でメモしておく(この後、参照不可になるため)

    f:id:poke_dev:20191124203444p:plain

  7. [サブスクリプション] で、サブスクリプション名とサブスクリプション ID をメモする

    f:id:poke_dev:20191124204044p:plain

  8. この SP を使用してアクセスしたいリソースを選択し(ここではサブスクリプションを選択)、[アクセス制御 (IAM)] - [+追加] - [ロールの割り当ての追加] の順にクリック

    f:id:poke_dev:20191124204553p:plain

  9. 「役割」は、必要最小限の権限を選択するのが望ましいが、よく分からない場合は取り敢えず「共同作成者 (= Contributor)」を選んでおけばたぶん大丈夫(操作、閲覧など、権限付与以外の全ての権限がある)。
    「選択」テキストボックスに先程作成した SP 名を入れて(ここで手入力しないと結果一覧に出てこないみたい)、そのすぐ下に検索結果が出てくるので先程作成した SP を選択し、[保存] をクリック

    f:id:poke_dev:20191124205301p:plain

以上で SP の作成と設定が完了。プログラムからはこの SP (とメモした情報) を使用して認証を行うことで、役割を設定した特定リソースへのアクセスのみが可能となる。

なお、ドキュメントによって各項目の呼び方が微妙に違っていたりして少し分かりづらいが、基本的には、「アプリケーション (クライアント) ID」がユーザー名みたいな扱いで、「クライアント シークレット」がパスワードみたいな扱いと考えれば問題ない。

注意点としては、追加した役割のみ使用可能だということ。SP 作成しただけではこの SP を使っても何も操作できないので、そこは注意。

後、メモした情報のうち、「クライアント シークレット (= パスワード)」についてはこの後はもう表示することできなくなるので、ちゃんとどこかに保存しておくこと。 もし忘れた場合は、クライアント シークレットを作り直す。

作成したサービスプリンシパルを使用して、Az モジュールでログインする場合は、以下も参照。

Azure Functions の host.json 設定変更時の注意点

Azure Functions では Functions 自体の動作設定を一部 host.json ファイルで定義している。 例えば代表的な設定で言うと、functionTimeout による最大実行時間の定義がある。

この設定を変更することで、関数の最大実行時間を 30 分などに伸ばしたりすることが可能だが、host.json ファイルの変更にあたっては、一つ注意が必要になる点がある。 host.json はプロジェクトに含まれるファイルの一つであるため、アプリ設定と異なり、dev/stg/prd ごとに設定を変更することが出来ない。

例えば、dev 環境のみ従量課金プランを使用していて、stg/prd は App Service プランを使用しているような状況の場合、dev 環境では functionTimeout の定義は最大 10 分までしか認められないが、これを stg/prd での実行のみにフォーカスして host.json の設定を 20 分とかに変更してしまうと、そのプロジェクトを dev 環境にデプロイした場合に、dev 環境では動作しなくなってしまう。

一応、デプロイ後に KUDU 上で強制的に設定変更することで対応可能ではあるが、それだと自動デプロイなどが実質使えない状態になってしまうので、基本的には、どの環境でもそのまま動作可能な範囲の設定を行うのが良いかと思われる。

Azure Functions 1.x の host.json のリファレンス | Microsoft Docs

Azure Functions タイマートリガーの強制終了について

Azure Functions では一回の実行の最大実行時間に制限があり、従量課金プランだと最大で 10 分間実行可能となる。App Service プランであればこの制限はない。 そのため、タイマートリガーでのバッチ実行なども、10 分までであればプランによらず可能だし、App Service プランであれば数時間の実行も一応可能。

なのだが、注意しなければいけない点として、Functions の関数は複数の外部要因(アプリ設定変更によるプロセス再起動、VM メンテナンス、などなど)により実行中に強制終了する可能性がある。 またこの時、プログラム側では例外スローされず、連携している Application Insights 側にもエラーのテレメトリが飛ばない可能性がある。

特に、長時間(ここでは数分以上を長時間と定義)の実行時は必然的に強制終了に遭遇する確率が上がるため、基本的には数分、出来れば 30 秒以内に収まる設計にするのが望ましいと思われる。 もしくは、処理中に強制終了しても問題ない設計であれば、本問題を気にする必要はない。

WebJobs · projectkudu/kudu Wiki · GitHub

また、例えば従量課金プランの Functions では関数が実行されるインスタンスが毎回変わる可能性があるが、この時、複数インスタンスでの同時実行を防ぐファイルロックの仕組みにより、同じ Functions アプリでも、タイマートリガー実行時の排他ロックで失敗する可能性がある。この状況を軽減するため、同一 Functions のタイマートリガーのスケジュールは、基本的には実行時間が被らないようにしておくのが無難。

強制シャットダウンについてはキャンセルトークンで検知できるかもしれないが、動作未確認。

-> v2 であれば、検知できるらしい (Azure Functions v2 の Graceful Shutdown の挙動を試した - しばやん雑記)

Azure リソースで指定が必要となる BLOB アカウントの乱立予防

Azure BLOB Storage は、アプリのファイル入出力先に使う以外にも、Functions の制御ファイル置き場に必要となったり、各種 Azure リソースのログ出力先としても使用されることが多く、それぞれについて単純に毎回新しい BLOB アカウントを用意すると、そのうちストレージアカウントだらけになってしまう。

Azure リソースが必要とする類の BLOB (ログ出力先や Functions 用制御ファイルなど) は、基本的に対象リソース名などをフォルダ名にしてファイルが格納されるため、同じストレージアカウントを指定しても、コンフリクトすることはない。 なので、dev/stg/prd ぐらいの粒度で共通のストレージアカウントを一つ用意し、各種リソースで BLOB が必要になった際は、この共通 BLOB を使うようにすることで、ストレージアカウントの乱立を防ぐことが出来る。

Azure Web Apps の App Service ログについては、ストレージアカウント名だけでなく格納先のコンテナ名まで指定する必要があるが、こちらも対象コンテナの下にリソース名 (Web App 名) のフォルダが作成されるため、webapplogswebserverlogs などの単位で上述した共通 BLOB ストレージにコンテナを作成し、複数 Web Apps で共通使用する形で問題ない。

なお、この共通ストレージアカウント方式で開発を進める場合、当初実験的に作成してその後不要になったリソースが出力したファイルも、ストレージアカウントの一覧を見ただけでは判断することができず、それは不便だが、dev 用のストレージアカウントはそういうゴミファイルが残りやすいものとして扱い、定期的に手動削除するなどの方針で対処出来るかと思われる。

Azure Web Apps + ASP.NET MVC のアプリログ出力

Azure Web Apps + ASP.NET MVC でアプリケーションログを出力する場合、log4net などのログライブラリを使用するのも可能ではあるが、Azure のビルトインとして用意されている App Service ログを使用した方が、ちゃんとログ出力されるかなど余計な心配をしなくて済む(ファイル名はインスタンス ID + PID で生成される)。

Azure ポータルでの設定

f:id:poke_dev:20191124111934p:plain

Storage Explorer でログファイルを確認

f:id:poke_dev:20191124112359p:plain

出力されたログファイルの中身

f:id:poke_dev:20191124112652p:plain

複数インスタンス、ワーカープロセスで同時に実行されていても問題ない

f:id:poke_dev:20191124112743p:plain

但し、以下のようにデメリットもある。

  • ログ出力のフォーマットがカスタマイズできない。開発者が出力内容として手を出せるのは Message プロパティのみ
  • 古いログをどこまで残すかの設定は、日数によるリテンション期間の指定のみ
  • ログローテーションの種別は、日時のみ。時間単位での出力のみとなるため、一日通してのログを確認したい時などは、やや手間

上記のような制限はあるものの、自分のプログラム側での仕掛けが一切不要であることや Azure Web Apps 環境で安定してログ出力できることと天秤にかけた場合、受け入れ可能な範囲だと思う。

但し、ASP.NET MVC アプリとしてログ出力する場合、上記とは別に、一つ大きな問題が発生する可能性がある。 通常、Web アプリでログを出力する場合、リクエストを跨いだ同一ユーザーの一連の流れを追うため、セッション ID のような同一セッションを追跡できる情報を補足情報として出力する必要があるが、App Service ログで使用される Trace クラスにはそれに適したプロパティなどはないため、プログラム側でどうにかして、Message プロパティに含める必要がある。

一応、App Service ログ出力項目の一つである ActivityId がトレース用の情報として使えそうではあるが、リクエスト単位で変わったり、非同期メソッド呼び出し時に切り替わったり、そもそも空で出力される挙動などがあるため、セッション追跡用の情報としてはやはり使えない。

対応方法

結論としては、アプリで Trace 出力する Message に Session.SessionID を常に含めれば良いわけだが、Session クラスは ASP.NET のレイヤー上にあり、アプリ全体でログ出力が必要になることを考えると、そのまま使うわけにはいかない。

この問題に対処するため、CallContext を使用する。リクエスト受信時、セッションが使用可能になったタイミング (Application_PostAcquireRequestState) で CallContext に SessionID を格納し、ログ出力時は CallContext から SessionID を取り出して使用する。

Application_PostAcquireRequestState イベントより前のメソッドについてはセッション ID が使えないので、ActivityId で追跡する。 なお、上述したように空になる可能性があるため、これを緩和するために BeginRequest で初期化を行う。

Global.asax.cs

protected void Application_BeginRequest(object sender, EventArgs e)
{
    // empty になる場合があるので、強制的に初期化する
    Trace.CorrelationManager.ActivityId = Guid.NewGuid();

    Trace.WriteLine("Application_BeginRequest start");
}

protected void Application_PostAcquireRequestState(object sender, EventArgs e)
{
    if (Context.Handler is IRequiresSessionState)
    {
        CallContext.LogicalSetData("SID", Session.SessionID);
    }

    // 以降は、以下のように CallContext から SessionID を取得して、使用する
    var sessionId = CallContext.LogicalGetData("SID");
    Trace.WriteLine(string.Format("[SID: {0}] Application_PostAcquireRequestState start", sessionId));
}

続、問題 (CallContext に格納した情報が null になる)

基本的には上記の対応で、トレース用の SessionID が App Service ログに出力されるようになるはずなのだが、まだ問題がある。

CallContext に格納した情報が、なぜか null になってしまうタイミングが、自分が確認する限りあった。 原因の詳細は不明だが、自分が確認する限り、ライフサイクルの中で、ページの処理に入ったタイミングと出るタイミングでの発生を確認した(この場合も必ず発生するわけではない)。

原因が不明なので少し気持ちは悪いが、上記のタイミングにフォーカスして問題を緩和するため、以下のようにページの処理に入った直後と出た直後のタイミングでそれぞれ CallContext の状態を確認し、null なら再設定を行うことで基本的にはセッション情報が出力されるようになるのを確認した。

Controller

public class HomeController : Controller
{
    protected override void OnAuthorization(AuthorizationContext filterContext)
    {
        base.OnAuthorization(filterContext);

        try
        {
            var sessionId = CallContext.LogicalGetData("SID");

            if (sessionId == null)
            {
                CallContext.LogicalSetData("SID", Session.SessionID);
            }
        }
        catch
        {
            // 問題発生しても何もしない
        }
    }

Global.asax.cs

protected void Application_PostRequestHandlerExecute(object sender, EventArgs e)
{
    try
    {
        var sessionId = CallContext.LogicalGetData("SID");

        if (sessionId == null)
        {
            CallContext.LogicalSetData("SID", Session.SessionID);
        }
    }
    catch
    {
        // 問題発生しても何もしない
    }
}
参考

Surface Capabilities example | Actions on Google

Reference:

Example
Capability Smart Phone Smart Display Wear OS Speaker Speaker(?)
actions.capability.WEB_BROWSER x
actions.capability.SCREEN_OUTPUT x x x
actions.capability.MEDIA_RESPONSE_AUDIO x x x
actions.capability.AUDIO_OUTPUT x x x x x
actions.capability.ACCOUNT_LINKING x x x x x

Actions on Google のスマートディスプレイ使用について

Actions on Googleスマートディスプレイで使用する場合の考慮事項について、確認して記載する。ここでは Rich response にフォーカスして確認する。

参考: Responses  |  Conversational Actions  |  Google Developers

基本事項

  • 使用する要素によらず、Web ページへのリンクは使用不可。例えば、Basic card の Link button で Web ページへのリンクを行おうとしても、スマートディスプレイでは Link button 要素そのものが表示されない
  • シミュレータと実機では表示のされ方や要素の解釈のされ方が違うようなので、注意。そのため、基本実機で確認し、シミュレータは簡易的なチェックに留めたほうが無難

マークダウン

  • シミュレータと実機で、マークダウンの解釈が同じとは限らないみたい。例えば、改行 1 つのみだと、どちらでも改行と見做されない (恐らく半角スペースとして出力される)。改行を 2 つ連続で入れるだけだと、シミュレータでは p タグで表現しているような改行のされかたをするが、実機だと改行にならない。[スペース 1] + 改行だと、どちらでも改行と認識されない。[スペース 2] + 改行だと、どちらでも改行として認識される。

    br タグは、シミュレータだと改行として出力されるが、実機だとそのまま文字で出力される。[スペース 2] + 改行を複数回連続して入れると、どちらでもやや改行の高さが増加する感じがあるが、正直この挙動にはあまり頼らない方が良いように思える。

  • 結果として、シミュレータでも実機でもどちらでも同じように表現できる改行は、[スペース 2] + 改行のみ (今の所)

Basic card

  • Image について
    • width, height 指定は変化が見いだせなかった
    • ImageDisplayOptions 指定については、WHITE or CROPPED を指定した場合、スマホの時だけ余白の色がグレーから白に変わった
    • サイズについては後述するように、200px ~ 400px ぐらいで、正方形に近い形が恐らくうまい感じで表示できるかと思われる
    • スマートディスプレイ実機 (Google Nest Hub)
      • 余白の色は白固定。ImageDisplayOptions 指定は何を指定しても変わらない様子
      • 配置開始位置は左上
      • コンテナ横幅は、400px 弱ぐらい?
      • イメージ横幅がコンテナ横幅に届くまでは、拡大縮小なしで表示される
      • イメージ横幅がコンテナ横幅を超えた場合、コンテナ横幅最大に収まるように縮小処理が行われる
      • 縦サイズは特に制限ないようで、画面に収まらない場合はスクロールして表示できる
      • イメージの左上、右上、左下、右下が、少しラウンドにトリミングされる
    • スマホ実機 (Nova lite)
      • 余白の色は、既定はグレー。ImageDisplayOptions 指定で WHITE or CROPPED を指定した場合、余白が白になる
      • 配置開始場所は中央
      • 常に、コンテナの高さ、もしくは幅のどちらかいっぱいになるまで拡大・縮小が行われる。そのため、あまりに小さかったり、大きかったり、もしくは縦横の比率が違いすぎると、見えづらい表示となるかも。200px 以上かつ正方形に近い比率だと良い感じになるかも
  • formattedText プロパティについては、一度に表示できる行数を超えると、シミュレータでは右にスクロールバーが出てスクロールしての表示になるが、実機では画面全体でのスクロールになる様子
  • 表示対象がスマホの場合、シミュレータだと上部に simpleResponse が表示されるが、実機では表示されない。正確には、実機でも表示はされているが、自動的に表示されない位置までスクロールされる様子。なので、上にスワイプすると実機でも simpleResponse が出てくる。なお、スマホの場合は自動スクロールされないこともあるっぽい
  • Suggestion chips は、スマートディスプレイ実機では、画面にタッチしない間 Basic card にオーバーラップする形で画面下部に表示される。そのため、一番下までスクロールしても Suggestion chips が表示されると Basic card の内容が隠される場合がある。スマホ実機では Basic card とは独立して画面下部に常に表示されているため、Basic card をスクロールしても Suggestion chips は表示されたままとなる

  • 画面表示例

  "richResponse": {
    "items": [
      {
        "simpleResponse": {
          "textToSpeech": "test",
          "displayText": "test"
        }
      },
      {
        "basicCard": {
          "title": "東京地方",
          "subtitle": "今日 19 日 (日) の天気",
          "formattedText": "**天気:** くもり  \n**降水確率:** 午前 100% 日中 100% 夜 100%  \n**最高気温:** 23°(-2°)  \n**最低気温:** 17°(+1°)  \n**天気概況:** 小笠原諸島では、強風や濃霧による視程障害に注意してください。伊豆諸島、小笠原諸島では、高波に注意してください。",
          "image": {
            "url": "https://example.com/200x200.png",
            "accessibilityText": "東京"
          }
        }
      }
    ],
    "suggestions": [
      {
        "title": "天気図"
      }
    ]
  }
  • シミュレータ (Smart Display)

    f:id:poke_dev:20191026230651p:plain

  • 本文を下までスクロールした状態

    f:id:poke_dev:20191026231145p:plain

  • 実機 (Google Nest Hub)

    f:id:poke_dev:20191026233132p:plain

  • 下までスクロールした状態

    f:id:poke_dev:20191026233452p:plain

  • シミュレータ (Phone)

    f:id:poke_dev:20191026230949p:plain

  • 実機 (Nova lite 1920x1080)

    f:id:poke_dev:20191026234012p:plain

  • 本文を下までスクロールした状態

    f:id:poke_dev:20191026233912p:plain

  • イメージが 100px x 100px の場合

  • シミュレータ (Smart Display)

    f:id:poke_dev:20191027005931p:plain

  • 実機 (Google Nest Hub)

    f:id:poke_dev:20191027012450p:plain

  • シミュレータ (Phone)

    f:id:poke_dev:20191027005845p:plain

  • 実機 (Nova lite 1920x1080)

    f:id:poke_dev:20191027012812p:plain

  • イメージが 400px x 400px の場合

  • シミュレータ (Smart Display)

    f:id:poke_dev:20191027010917p:plain

  • 実機 (Google Nest Hub)

    f:id:poke_dev:20191027012559p:plain

  • シミュレータ (Phone)

    f:id:poke_dev:20191027011016p:plain

  • 実機 (Nova lite 1920x1080)

    f:id:poke_dev:20191027012849p:plain

  • イメージが 600px x 600px の場合

  • シミュレータ (Smart Display)

    f:id:poke_dev:20191027011728p:plain

  • 実機 (Google Nest Hub)

    f:id:poke_dev:20191027012701p:plain

  • シミュレータ (Phone)

    f:id:poke_dev:20191027011635p:plain

  • 実機 (Nova lite 1920x1080)

    f:id:poke_dev:20191027012924p:plain

  • イメージが 600px x 100px の場合

  • シミュレータ (Smart Display)

    f:id:poke_dev:20191027014537p:plain

  • 実機 (Google Nest Hub)

    f:id:poke_dev:20191027015523p:plain

  • シミュレータ (Phone)

    f:id:poke_dev:20191027014640p:plain

  • 実機 (Nova lite 1920x1080)

    f:id:poke_dev:20191027015657p:plain

  • イメージが 100px x 600px の場合

  • シミュレータ (Smart Display)

    f:id:poke_dev:20191027015017p:plain

  • 実機 (Google Nest Hub)

    f:id:poke_dev:20191027015621p:plain

  • シミュレータ (Phone)

    f:id:poke_dev:20191027015111p:plain

  • 実機 (Nova lite 1920x1080)

    f:id:poke_dev:20191027015722p:plain

  • imageDisplayOptions に WHITE or CROPPED を指定した場合

    f:id:poke_dev:20191027023603p:plain