銀の光と碧い空

クラウドなインフラとC#なアプリ開発の狭間にいるエンジニアの日々

ASP.NET Core的にAzure Web AppからAzure OpenAIへのアクセスをセキュアにする

ASP.NET Coreで作成したアプリをAzure Web Appで動かしAzure OpenAIにアクセスする場合、Microsoftのドキュメントでは認証をマネージドID認証にし、設定項目(エンドポイントなど)はKeyVaultに格納するという方法が記載されていました。ネットワーク的に制限をかける方法なども考えられますが、今回はC#のソースコードで考慮しないといけないこの2つを試してみます。

まず、マネージドID認証にする方法はこちらのドキュメントを参照します。

learn.microsoft.com

まず、記事の手順でAzure側の操作を行い、Azure Web AppsからOpenAIへのマネージド認証を有効にします。まず、Azure Web AppsのIdentityでStatusを有効にします。

この画面のAdd role assigmentからはアサイン済みのマネージドIDが確認でき、追加メニューもありますが一部のリソース種類のみでOpenAIは指定できませんが、OpenAIを含むリソースグループやサブスクリプションを指定することはできます。次の手順で設定するKeyVaultはここから追加できます。下の画面は次の手順まで実施したあとの様子です。今回はシステム割り当て IDを使っています。

さてコードについてですが、前回の記事のコードを書き換える場合を考えます。

tech.tanaka733.net

まずAzure.IdentityというNuGetライブラリを追加します。次にAPIKeyを指定する代わりに、次のようにDefaultAzureCredentialのインスタンスを指定します。別のコンストラクタを呼び出すことになります。

var credentials = new DefaultAzureCredential();
var options = sp.GetRequiredService<IOptions<AzureOpenAIOptions>>().Value;
var kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.AddAzureOpenAIChatCompletion(
    deploymentName: options.ChatDeploymentName,
    endpoint: options.Endpoint,
    credentials: credentials
);

ユーザー割り当てIDを使う場合は次のようになります。

var credentials = new DefaultAzureCredential(
        new DefaultAzureCredentialOptions
        {
            ManagedIdentityClientId = "myIdentityClientId".
        }
    );

これで認証はできましたが、このままWebAppsで動かしても前回のコードのままだとdotnet secretに設定したエンドポイントなどの情報が取得できません。そこで次の記事を参照してAzure KeyVaultに格納した情報を取得するようにします。

learn.microsoft.com

KeyVaultを作成して、WebAppsからのマネージドIDをアサインします。アサインの手順はOpenAIの時と同様です。次にdotnet secretに格納した情報をKeyVaultのsecretに保存しますが、secretの名前はSection--SecretNameという形式にします。AzureOpenAI--ChatDeploymentNameAzureOpenAI--ChatDeploymentNameという名前になります。

コード側はまずAzure.Extensions.AspNetCore.Configuration.SecretsというNuGetライブラリを追加します。上の手順で追加したAzure.Identityも必要です。そしてoptionsを取得する手前に次のコードを挿入します。

builder.Configuration.AddAzureKeyVault(
    new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/"),
    credentials);

ドキュメントではKeyVaultNameをConfigurationから取得していたので環境変数に設定しました。またif (builder.Environment.IsProduction())で括っていましたが、同じコードベースでAzure WebApps以外で動かす場合にEnvironmentを切り替えて別の認証を行うことを想定しているはずです。 最後に、前の記事ではAzureOpenAIOptionsクラスにApiKeyプロパティを用意していたので削除するか、null許容にしておきます。

以上の対応で冒頭に紹介した2つの対応が完了しました。

Semantic Kernel をASP.NET CoreのDIで利用するためのサンプルコード

SementicKernelをDIで利用するためのサンプルコード自体はこちらで公開されています。secretを取得するときのセクション名の指定がそのままだと動かないっぽいのでこちらのIssueを参照してください。

github.com

これだけでほぼ終わりなのですが、IChatCompletionService をDIせずにKernelのみをDIする方法を載せておきます。次のコードのようにKernelインスタンスを生成する際にAddAzureOpenAIChatCompletionメソッドで必要なパラメーターを与えておきます。この例では元のサンプル同様にAddOptionsメソッドを使ってsecretに設定した値を取得していますが、AddAzureOpenAIChatCompletion内に直接指定する場合はそれも不要になります。

builder.Services.AddOptions<AzureOpenAIOptions>()
                        .Bind(builder.Configuration.GetSection(AzureOpenAIOptions.SectionName))
                        .ValidateDataAnnotations()
                        .ValidateOnStart();

builder.Services.AddKeyedTransient("LabKernel", (sp, key) =>
{
    var options = sp.GetRequiredService<IOptions<AzureOpenAIOptions>>().Value;
    var kernelBuilder = Kernel.CreateBuilder();
    kernelBuilder.AddAzureOpenAIChatCompletion(
        deploymentName: options.ChatDeploymentName,
        endpoint: options.Endpoint,
        apiKey: options.ApiKey
    );
    return kernelBuilder.Build();
});

public class AzureOpenAIOptions
{
    public const string SectionName = "AzureOpenAI";

    public required string ChatDeploymentName { get; set; }

    public required string Endpoint { get; set; }

    public required string ApiKey { get; set; }

    public bool IsValid =>
        !string.IsNullOrWhiteSpace(ChatDeploymentName) &&
        !string.IsNullOrWhiteSpace(Endpoint) &&
        !string.IsNullOrWhiteSpace(ApiKey);
}

Controllerなり、RazorのModelクラスではこんな感じに利用します。

public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;
    private readonly Kernel _kernel;

    public IndexModel(ILogger<IndexModel> logger, [FromKeyedServices("LabKernel")] Kernel kernel)
    {
        _logger = logger;
        _kernel = kernel;
    }
    public void OnGet()
    {
    }

    public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        var answer = await _kernel.InvokePromptAsync(
"Why is the sky blue in one sentence?"
);

        //answerを使って何かする
        return RedirectToPage("./Index");
    }
}

GitHub ActionsでAzure Web Appsにデプロイするときに同時に環境変数を書き換えたい

とりあえず実現できたのですが、もっとうまくある方法があるのではと思っています。

GitHub Actionsを使ってAzure Web Appにデプロイしたい場合、ひとまずazure/webapps-deploy@v3を使って次のように書けます。

      - name: Deploy to Azure Web App
        id: deploy-to-webapp
        uses: azure/webapps-deploy@v3
        with:
          app-name: 'ghu-demo-webapp'
          slot-name: 'Production'
          package: .

github.com

このとき同時にWeb Appsの環境変数も書き換えたい時があります。具体的にはデプロイしたコードのコミットハッシュを記録しておきたい、といった目的です。探した限り、webapps-deployのアクションでは環境変数も更新する機能はない模様で、ひとまずAzure CLIを実行するアクションを使ってデプロイ後に次のアクションを実行するように定義しました。METADATA_COMMITという名前の環境変数にコミットハッシュを値としたものを追加(更新)します。

      - name: Update Env Vars
        id: update-envvar
        uses: Azure/cli@v2.1.0
        with:
          inlineScript: "az webapp config appsettings set -g ghu-demo -n ghu-demo-webapp --settings METADATA_COMMIT=${{ github.sha }}"

これで目的は果たせるのですが、アプリのデプロイと環境変数の更新を連続して別々に実行するのがちょっと非効率という気がしています。そもそも同時に更新する方法自体ないのかもしれないですが、そこも確認できず。 ひとまず調査した記録して残しておきました。