ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

Azure Functions におけるシークレットの管理方法を改めて考えてみた

昨年 .NET 6 に対応した Azure Functions v4 がリリースされたので、Azure Functions におけるシークレットの管理方法を改めて考えてみました。
データベースの接続文字列や API Key などのセンシティブな情報は、アプリケーションのリポジトリ内では管理したくありません。
アプリケーションとセンシティブな情報を分離させる構成を実現する方法にはいつくかありますが、現時点では次の方針がシンプルでいいのではと考えています。

  • Azure の本番環境では、Azure Key Vault を使って Managed Service Identity(MSI)で認証する
  • ローカルの開発環境では、ASP.NET Core の Secret Manger で管理する
  • アプリケーションに関する設定は、appsettings.json で管理する
  • Function のバインディング用の接続文字列は、local.settings.json で管理する

上記の方針に従って、実際に構成を作って試してみます。

Azure Functions に appsettings.json を追加する

Azure Functions のプロジェクトには local.settings.json が用意されていますが、JSON の配列を定義できないなど ASP.NET Core の appsettings.json と異なる部分が使いづらいので、Azure Functions においても appsettings.json を導入します。
まずは、前準備として Azure Functions のプロジェクトに拡張の NuGet パッケージをインストールし、Startup クラスを実装します。

PM> Install-Package Microsoft.Azure.Functions.Extensions -Version 1.1.0
[assembly: FunctionsStartup(typeof(FunctionApp.Startup))]

namespace FunctionApp
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
        }
    }
}

appsettings.json と appsettings.Development.json のファイルを追加し、.csproj ファイルと Startup クラスに構成と読み取りを実装します。

<ItemGroup>
    <None Update="appsettings.json">
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="appsettings.Development.json">
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
</ItemGroup>
public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
{
    var context = builder.GetContext();

    builder.ConfigurationBuilder
        .AddJsonFile(Path.Combine(context.ApplicationRootPath, "appsettings.json"), optional: true, reloadOnChange: false)
        .AddJsonFile(Path.Combine(context.ApplicationRootPath, $"appsettings.{context.EnvironmentName}.json"), optional: true, reloadOnChange: false)
        .AddEnvironmentVariables();
}

appsettings.json では、接続する Key Vault の URL を定義しておきます。

{
  "Function": {
    "KeyVaultUrl": "https://xxx.vault.azure.net/"
  }
}

Azure Key Vault にデータベースの接続文字列を登録する

Azure Functions からアクセスする SQL Database の接続文字列を登録するシナリオを例に説明していきます。
Key Vault の Secret に Function--SqlConnection という名前で登録します。

f:id:TonyTonyKun:20220114152545p:plain

名前に Function のプレフィックスを付けた理由は、1つの Key Vault で複数の Azure Functions を構成を管理する想定のためです。
JSON の階層構造で管理したいので -- で区切っています。Key Vault の名前には : を使うことができないで注意してください。

Azure Functions で Azure Key Vault を参照する

Azure Key Vault を参照するための NuGet パッケージをインストールします。

PM> Install-Package Azure.Identity -Version 1.5.0
PM> Install-Package Azure.Extensions.AspNetCore.Configuration.Secrets -Version 1.2.1

開発環境以外では MSI を使って Key Vault に接続するように構成します。

public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
{
    var context = builder.GetContext();

    builder.ConfigurationBuilder
        .AddJsonFile(Path.Combine(context.ApplicationRootPath, "appsettings.json"), optional: true, reloadOnChange: false)
        .AddJsonFile(Path.Combine(context.ApplicationRootPath, $"appsettings.{context.EnvironmentName}.json"), optional: true, reloadOnChange: false)
        .AddEnvironmentVariables();

    if (context.EnvironmentName != "Development")
    {
        var config = builder.ConfigurationBuilder.Build();
        builder.ConfigurationBuilder
            .AddAzureKeyVault(new Uri(config["Function:KeyVaultUrl"]), new DefaultAzureCredential());
    }
}

アプリケーションで SQL Database の接続文字列を扱いやすくするため、POCO クラスにマッピングします。

public class MySettings
{
    public string SqlConnection { get; set; }
}

Key Vault の Secret から自分のアプリケーション情報だけを取得したいので、Function のプレフィックスを指定しています。

public override void Configure(IFunctionsHostBuilder builder)
{
    var context = builder.GetContext();
    builder.Services.Configure<MySettings>(context.Configuration.GetSection("Function"));
}

Function では、このような実装で SQL Database の接続文字列を取得します。

public class HttpFunction
{
    private readonly MySettings _settings;

    public HttpFunction(IOptions<MySettings> optionsAccessor)
    {
        _settings = optionsAccessor.Value;
    }

    [FunctionName(nameof(HttpFunction))]
    public IActionResult Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        log.LogInformation("C# HTTP trigger function processed a request.");

        var responseMessage = $"SqlConnection : {_settings.SqlConnection}";

        return new OkObjectResult(responseMessage);
    }
}

Azure 本番環境

Azure Functions の MSI を有効化し、Key Vault の Access policies を設定します。

$ az functionapp identity assign --name "<Function Apps Name>" --resource-group "<Resource Group Name>"
{
  "principalId": "xxx",
  "tenantId": "yyy",
  "type": "SystemAssigned",
  "userAssignedIdentities": null
}

$ az keyvault set-policy --name "<Key Vault Name>" --object-id "xxx" --secret-permissions get list

これで Azure Functions は、MSI で Key Vault に認証して SQL Database の接続文字列を取得できます。

ローカル開発環境

Visual Studio の機能でローカル開発環境から Key Vault に接続することはできますが、環境によって上手く認証できなかったり、デバッグ実行が遅くなったりするので、今回は使いません。
ASP.NET Core の Secret Manger を使って、プログラマーが開発用の構成情報を管理してコードを書いていくようにします。
docs.microsoft.com

Visual Studio のプロジェクトを右クリックしたユーザーシークレットの管理から secrets.json ファイルを追加し、このように SQL Database の接続文字列を定義します。

{
  "Function": {
    "SqlConnection": "SQL Connection for Secret Manager."
  }
}

これでローカル環境では、secrets.json から取得した SQL Database の接続文字列を使って開発できます。

local.settings.json でバインディングの接続文字列を管理する

Storage や Cosmos DB のバインディングを使う Function の場合、バインディング用の接続文字列を設定する必要があります。
バインディング用の接続文字列を Key Vault や Secret Manager で管理すると、Functions のスケーラーが値を読み取れません。ローカルのデバッグ実行でも警告が発生しますし、Azure Functions にデプロイするとエラーが発生します。

The Functions scale controller may not scale the following functions correctly because some configuration values were modified in an external startup class.

f:id:TonyTonyKun:20220114182646p:plain

そのため、バインディング用の接続文字列は local.settings.json で管理します。

{
    "IsEncrypted": false,
    "Values": {
      "AzureWebJobsStorage": "UseDevelopmentStorage=true",
      "FUNCTIONS_WORKER_RUNTIME": "dotnet",
      "StorageBindingConnection": "UseDevelopmentStorage=true"
    }
}
public class QueueFunction
{
    [FunctionName(nameof(QueueFunction))]
    public void Run([QueueTrigger("myqueue-items", Connection = "StorageBindingConnection")] string myQueueItem, ILogger log)
    {
        log.LogInformation($"C# Queue trigger function processed: {myQueueItem}");
    }
}

Azure 本番環境では、Application settings に接続文字列をベタ書きではなく Key Vault 参照を使うことで、シークレットを Key Vault で一元管理するようにします。

@Microsoft.KeyVault(SecretUri=https://xxx.vault.azure.net/secrets/Function--StorageConnection/)

docs.microsoft.com

まとめ

Azure Functions におけるシークレットの管理方法を改めて考えてみました。
セキュリティという観点では、Key Vault 自体を閉域網構成で管理したい話もあるので、そちらは追加で検討が必要です。

ASP.NET Core アプリケーションについては、こちらの記事を参照してください。
gooner.hateblo.jp

2021 年振り返り

今年も、しばやんさんが作った 2021 年の人気記事ランキング生成 を使わせてもらい、1年を振り返ってみます。

  1. HttpClient を使って同期で通信する
  2. ASP.NET Web API で multipart / form-data を使ってファイルをアップロードする
  3. ASP.NET でクライアントの IP アドレスを取得する
  4. プロキシ環境下で Web API を呼び出す
  5. Azure Synapse Analytics を試してみた
  6. ユーザー委任 SAS を使った AzCopy で Azure Blob Storage にファイルをアップロードする
  7. ASP.NET MVC の Ajax 通信で例外を処理する
  8. ASP.NET MVC で JSON の一部として PartialView を返す方法
  9. Azure App Service の Easy Auth を有効にした Web Apps から Functions にアクセスする
  10. Azure Cognitive Search への接続で TLS1.2 エラーが発生していた件

例年通り、ASP.NET 関連の記事が多く読まれているなかで、Azure の記事が増えてきたのは嬉しいところ。今年はブログを書き始めた 2014 年に並ぶ最多 37 本の記事を書きました。来年もこのくらいペースを継続していきたいです。

イベント登壇

クラウドデベロッパーちゃんねるで、ちょまどさんと Azure Kubernetes Service のお話をしてきました。今見たら再生回数が 1900 回を超えてたので、思いのほかたくさんの方々に観てもらえているようです。
gooner.hateblo.jp

先月は Hack Azure で Azure Container Apps の使いドコロを話してきました。ワイワイ喋れて楽しかったので、今後もキャッチアップしつつ、またどこかで話せればと。

www.youtube.com

JASUG は 11周年総会での LT のみでした。Azure Synapse Analytics を初心者向けに説明する内容になっています。
gooner.hateblo.jp

お仕事の絡みで、企業向けコミュニティの Azure Council Experts にも 2 回登壇しました。運営なども含めて諸々を見直しているので、なんとか改善していきたい。

コミュニティ運営

JAZUG の運営は足利さんたちにお任せしてしまっていて、ほとんど何もしていません。セッションや LT に登壇される方は増えてきたので、来年はイベント振り返りとか特定のサービスとかをテーマにしたディスカッション(雑談)などを企画できるといいなと考えています。
jazug.connpass.com

あとは、今年 4 月から Azureもくもく会@新宿をリモートで再開しました。月一で開催しているので、よかったらご参加ください。
azure-mokumoku.connpass.com

Microsoft MVP

3 月には、4 回目の Microsoft MVP Global Summit に参加しました。例年はシアトルの Microsoft 本社に行って参加していましたが、今年もオンライン開催となりました。
深夜に連日のセッション視聴は家族にも迷惑がかかりますし、仕事はリモートワークなので、京都のホテルに滞在しながら参加しました。
gooner.hateblo.jp
今年も Microsoft MVP for Microsoft Azure を無事に更新できて、5 年目の受賞で青い 5 Years のリングが貰えたので嬉しかったです。来年のグロサミはどこで参加ようかな。

仕事

ネクストスケープに転職して、1年ちょっと経ちました。
今年は案件をバリバリこなすというよりも、その前準備としてセールスやマーケティングに取り組みました。ネクストスケープの得意な領域を改めて定義したり、売っていきたいサービスをメニュー化したりしました。
www.nextscape.net

あとは、社員が書いた技術ブログのハブサイトなんかも作ったりしました。Azure Static Web Apps を使っているのですが、サイトをサクッと作れる良いサービスです。
tech-blog-hub.nextscape.net

今年も社内の仲間たちとアドベントカレンダーを書いて、なんとか最後までバトンを繋げることができました。
qiita.com

まとめ

昨年に引き続きフルリモートで引きこもりな生活スタイルでしたが、それなりに充実した1年でした。早く安心してみんなで集まれるようになってほしいですね。
本年もお世話になりました。よい年をお迎えください。

おまけ

2021 年自分が選ぶ今年の4枚。

f:id:TonyTonyKun:20210408201417j:plain
清水寺の夜桜
f:id:TonyTonyKun:20210616204856j:plain
明月院の紫陽花
f:id:TonyTonyKun:20211017150750j:plain
浩庵キャンプ場からの富士山
f:id:TonyTonyKun:20211204201102j:plain
瑠璃光院の紅葉

Azure Pipelines で Azure Load Testing のパイプラインを構築する

Azure Load Testing は、Apache JMeter ベースのマネージドな負荷テストサービスです。
docs.microsoft.com
過去に Azure DevOps で提供されていた負荷テスト機能がなくなって以来、ようやく Azure でマネージドなサービスとして提供されました。
今回は、Azure Pipelines を使って負荷テストを自動化してみます。

Azure Load Testing を作成する

公式ドキュメントのクイックスタートに従って、Azure ポータルから作ります。
docs.microsoft.com

注意点は、リソースを作成した後にアクセスコントロールを設定する必要がある部分です。Load Test Contributor もしくは Load Test Owner のロールをアサインしておきましょう。
また、負荷テストの対象として、Azure Web Apps も作成しておきました。

パイプラインを作成する

公式ドキュメントのチュートリアルに従って、Azure DevOps から作ります。
docs.microsoft.com

Azure DevOps Marketplace から Azure Load Testing task extension のインストールと、アクセスコントロールの設定を忘れずに行いましょう。
再利用性を考慮し、下記のようなディレクトリ構成にしました。

f:id:TonyTonyKun:20211219133547p:plain

Azure Load Testing のタスクを実行する load-test-pipelines.yml です。

parameters:
  imageName: ''
  azureSubscription: ''
  loadTestConfigFile: ''
  resourceGroup: ''
  loadTestResource: ''

jobs:
- job: LoadTest
  displayName: 'Load Test'
  pool:
    vmImage: ${{parameters.imageName}}
  steps:
  - task: AzureLoadTest@1
    displayName: 'Run Azure Load Testing'
    inputs:
      azureSubscription: ${{parameters.azureSubscription}}
      loadTestConfigFile: ${{parameters.loadTestConfigFile}}
      resourceGroup: ${{parameters.resourceGroup}}
      loadTestResource: ${{parameters.loadTestResource}}
  - publish: $(System.DefaultWorkingDirectory)/loadTest
    artifact: results

Azure Load Testing の YAML を呼び出す azure-pipelines.yml です。負荷テストの前にビルドやデプロイが必要な場合は、Stage を追加することを想定しています。
トリガーは、スケジュール実行などユースケースに合わせて変更してください。

trigger:
- main

variables:
- name: imageName
  value: 'ubuntu-latest'
- name: azureSubscription
  value: 'azure'
- name: loadTestConfigFile
  value: 'tests/load-test.yml'
- name: resourceGroup
  value: '<Resource Group Name>'
- name: loadTestResource
  value: '<Azure Load Testing Name>'

stages:
- stage: LoadTest
  jobs:
  - template: pipelines/load-test-pipelines.yml
    parameters:
      imageName: $(imageName)
      azureSubscription: $(azureSubscription)
      loadTestConfigFile: $(loadTestConfigFile)
      resourceGroup: $(resourceGroup)
      loadTestResource: $(loadTestResource)

Azure Load Testing の構成ファイルの load-test.yml です。engineInstances が JMeter スクリプトを実行するので、この数を増やすことで負荷テストをスケールアウトできます。1000 ユーザーをシミュレーションしたい場合、4つのエンジンインスタンス(4 x 250 スレッド)を構成することになります。
failureCriteria には、負荷テスト結果の判定基準として総平均応答時間とエラーのパーセンテージを指定しています。

version: v0.1
testName: sample-app-test
testPlan: test-plan.jmx
description: 'Run Sample App Test'
engineInstances: 1
failureCriteria: 
    - avg(response_time_ms) > 1000
    - percentage(error) > 20
env:
  - name: webapp
    value: <Web Apps Name>.azurewebsites.net

JMeter スクリプトの test-plan.jmx です。テスト対象の URL を環境変数 webapp から取得しています。

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.4.1">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Test Plan" enabled="true">
      <stringProp name="TestPlan.comments"></stringProp>
      <boolProp name="TestPlan.functional_mode">false</boolProp>
      <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
      <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
      <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
      <stringProp name="TestPlan.user_define_classpath"></stringProp>
    </TestPlan>
    <hashTree>
      <kg.apc.jmeter.threads.UltimateThreadGroup guiclass="kg.apc.jmeter.threads.UltimateThreadGroupGui" testclass="kg.apc.jmeter.threads.UltimateThreadGroup" testname="jp@gc - Ultimate Thread Group" enabled="true">
        <collectionProp name="ultimatethreadgroupdata">
          <collectionProp name="1400604752">
            <stringProp name="1567">5</stringProp>
            <stringProp name="0">0</stringProp>
            <stringProp name="48873">30</stringProp>
            <stringProp name="49710">60</stringProp>
            <stringProp name="10">10</stringProp>
          </collectionProp>
        </collectionProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <intProp name="LoopController.loops">-1</intProp>
        </elementProp>
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
      </kg.apc.jmeter.threads.UltimateThreadGroup>
      <hashTree>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="homepage" enabled="true">
          <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
            <collectionProp name="Arguments.arguments"/>
          </elementProp>
          <stringProp name="HTTPSampler.domain">${__BeanShell( System.getenv("webapp") )}</stringProp>
          <stringProp name="HTTPSampler.port"></stringProp>
          <stringProp name="HTTPSampler.protocol">https</stringProp>
          <stringProp name="HTTPSampler.contentEncoding"></stringProp>
          <stringProp name="HTTPSampler.path"></stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
          <boolProp name="HTTPSampler.follow_redirects">true</boolProp>
          <boolProp name="HTTPSampler.auto_redirects">false</boolProp>
          <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
          <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
          <stringProp name="HTTPSampler.embedded_url_re"></stringProp>
          <stringProp name="HTTPSampler.implementation">HttpClient4</stringProp>
          <stringProp name="HTTPSampler.connect_timeout">60000</stringProp>
          <stringProp name="HTTPSampler.response_timeout">60000</stringProp>
        </HTTPSamplerProxy>
        <hashTree/>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

実行結果

Azure Pipelines を実行すると負荷テストが行われ、テスト結果を確認できます。

f:id:TonyTonyKun:20211219140607p:plain

テスト結果を artifact に発行しているので、zip ファイルをダウンロードすれば JMeter のツールで可視化することもできます。
将来的には、Azure DevOps でも可視化できるプラグインが提供されると思います。

まとめ

Azure Pipelines で Azure Load Testing のパイプラインを構築して、負荷テストを自動化してみました。
JMeter スクリプトさえ作れば、フルマネージドでお手軽に負荷テストできるのは便利なので、積極的に活用していきたいと思います。
JMeter プラグインが使えるようになれば、さらに用途が広がりそうです。
こちらから、パイプライン YAML の全体を確認できます。
github.com

未解決:Azure Pipelines から環境変数を渡せない

Azure Load Testing の構成ファイルを部品化するために、Azure Pipelines から環境変数を渡したかったのですが、上手く動きませんでした。
docs.microsoft.com

このドキュメントを参考に、下記のように試してみましたが InvalidJSON エラーとなってしまい、未解決です。

f:id:TonyTonyKun:20211219142446p:plain

- task: AzureLoadTest@1
  inputs:
    azureSubscription: 'MyAzureLoadTestingRG'
    loadTestConfigFile: 'SampleApp.yaml'
    loadTestResource: 'MyTest'
    resourceGroup: 'loadtests-rg'
    env: |
      [
          {
              "name": "webapp",
              "value": "myapplication.contoso.com",
          }
      ]