しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Azure App Service に pnpm を利用した Node.js アプリケーションをデプロイする

最近は Node.js のアプリケーションを Azure App Service にデプロイする機会がかなり多いので、大体のユースケースには対応できると思っていたのですが、少し前に pnpm を使っているアプリケーションのデプロイが上手くいかないケースに遭遇したのでまとめておきます。

そもそも pnpm について詳しくはなかったのですが、確かにインストールは高速かつディスク容量をあまり食わないので良い感じだと思いました。モノリポだと特に輝く感じですね。

詳細は後述しますが、pnpm はシンボリックリンクを多用しているため Web App へのデプロイ時には工夫が必要でした。Web App には pnpm がデフォルトで入っていないという点も考慮する必要があります。

今回は検証用に以下のようなモノリポの構成で、フロントエンドとバックエンドのプロジェクトをそれぞれ用意しました。シンプルですが良くある構成だと思います。

フロントエンドは SPA なので Static Web App にデプロイする想定で、バックエンドは Web App にデプロイするように作っていきます。フロントエンドは SPA なので特に何も考える必要が無いですが、バックエンドは pnpm でインストールした node_modules を正しく持っていく必要があります。

Web App に Node.js のアプリケーションをデプロイする方法としては、Docker Image を利用する方法と Run From Package を利用する方法の 2 種類があるので、それぞれで試していきます。

Docker Image を利用したデプロイ

Docker Image をビルドする方法は pnpm の公式ドキュメントに載っているので難しいことはないのですが、モノリポのサンプルではビルドした結果が含まれないなど問題があったのでカスタマイズします。

今回の構成で Docker Image が必要なのはバックエンドだけなので、その部分に特化して Dockerfile を書いています。本来なら pnpm の特徴を生かすためにフロントエンドのビルドもマルチステージで行うべきです。

基本的な流れは公式のサンプルと同じですが、ビルド結果をコピーしている点が大きく異なっています。

FROM node:20-slim AS base
WORKDIR /app

FROM base AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY . /app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run -r build
RUN pnpm deploy --filter=api --prod /prod/api

FROM base AS api
COPY --from=build /prod/api/node_modules /app/node_modules
COPY --from=build /app/apps/api/dist /app/dist
COPY --from=build /app/apps/api/package.json /app/package.json
EXPOSE 3000
CMD [ "npm", "run", "start:prod" ]

最終成果物となる Docker Image には pnpm をあえて含めずに npm 経由で実行するようにしています。そして node_modules については pnpm deploy コマンドを使って開発用の依存関係を除いたデプロイ用に最適化したものを出力しています。

この Docker Image を Web App にデプロイするとバックエンドが正しく動作することが確認できます。

Run From Package を利用したデプロイ

App Service では Docker Image を使わない場合は Run From Package を使うことが強く推奨されているので、こちらの方法でも検証を行っておきます。

ビルド時に pnpm deploy を実行しても node_modules 以下は .pnpm というディレクトリの実体を参照するようになっています。pnpm を使っている場合はシンボリックリンクを維持したままデプロイする必要があるので、これを理解していないと失敗します。

検証用に作成した GitHub Actions のワークフロー定義は以下のようになりました。基本的な流れは Docker Image の作成と同じですが成果物を zip にする点が異なっています。

Docker Image を利用したケースでは pnpm deploy の実行で済みましたが、pnpm がシンボリックリンクを多用しているので、単純に Run From Package 向けに zip を作るだけだと正しく動作しませんでした。これは zip コマンドで圧縮する際に -y オプションを付けてシンボリックリンク維持することで回避できます。

name: Deploy apps

on:
  push:
    branches: [ master ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - uses: pnpm/action-setup@v3
      with:
        version: 8.15.6

    - name: Use Node.js v20
      uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: pnpm

    - name: Build apps
      run: |
        pnpm install --frozen-lockfile
        pnpm -r build

    - name: Create api package
      run: |
        pnpm --filter api --prod deploy prod/api
        mkdir -p output/api && cp -r apps/api/dist prod/api/node_modules prod/api/package.json output/api
        cd output/api && zip -ryq ../../api.zip .

    - name: Deploy web
      run: |
        npm i -g @azure/static-web-apps-cli
        swa deploy ./apps/web/dist --env production --deployment-token ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}

    - name: Deploy api
      uses: azure/webapps-deploy@v3
      with:
        app-name: ***
        publish-profile: ${{ secrets.AZURE_WEB_APP_PUBLISH_PROFILE }}
        package: api.zip

このワークフロー定義で作成された zip ファイルは .pnpm へのシンボリックリンクが維持されているので、Run From Package でデプロイすると正しく node_modules 以下のファイルを参照出来るようになります。

実際に Run From Package デプロイされた状態の wwwroot 以下を確認すると、node_modules 以下のディレクトリが .pnpm 以下にリンクされていることが確認出来ます。

Node.js アプリケーションを Run From Package でデプロイした場合は、Azure Portal から Startup Command を指定する必要があるので、今回は Docker Image と同じく pnpm ではなく npm を使って npm run start:prod を実行するように設定します。

ここまでの設定で pnpm を使ったアプリケーションが Web App でも正しく動作するようになります。

ちなみに App Service は Run From Package ではなく zip デプロイも利用可能ですが、現状は内部でのデプロイに使われている KuduSync がシンボリックリンクに対応していないため正しく動作しません。

従って pnpm を使ったアプリケーションや将来的に npm でシンボリックリンクが使われるようになった際には、今回のように Docker Image か Run From Package を使う必要があります。

非推奨となった Terraform リソースからのマイグレーションを安全に行いたい

Azure Provider に限った話ではないと思うのですが、正直 Azure Provider はリソースの非推奨化が多い気がするので書いています。きっかけとしては直近の Azure Provider の更新で azurerm_static_site は非推奨になって azurerm_static_web_app が追加されたことにあります。

追加された時から正直命名をミスっているなと思っていましたが、このバージョンから他の App Service に合わせる形で新しい名前のリソースが追加されました。機能としては変わっていないので、今回の場合は本当に名前が変わっただけです。

これまでにも Azure Provider では同じようなリソース変更があり、代表的かつ影響範囲の広いものとして App Service / Azure Functions があります。v3 のリリースタイミングで OS で別々のリソースが用意されるようになり、これまでのリソースは v4 で削除されるためマイグレーションが必須です。

しかし、そのリソースを移行すると言っても Terraform 側でのサポートがあるわけではなく、単純にリソースを変更すると再作成が走ってしまうため tfstate から削除してインポートし直す必要があります。具体的な手順は以下のマイグレーションのドキュメントにありますが、本番環境の tfstate を CLI から触るというのは危険な作業には変わりありません。

折角 Terraform の運用を GitHub Actions や Terraform Cloud で自動化していても、たまにあるマイグレーションで手動作業になってしまうのは避けたいので、CI/CD を生かしたまま実行したいところです。

そこで最新の Terraform では removed ブロックと import ブロックが使えるようになっているので、これを使ってマイグレーションを簡単に行えないか実際のリソースで試しました。Terraform CLI を使った作業であれば tfstate から削除してインポートし直すという 2 つの手順が必要ですが、この removedimport を使うと理屈上は 1 回で終わるはずです。

実際に azurerm_static_web_app へのマイグレーションを行った時の Terraform 定義は以下のようになります。元々あった azurerm_static_site は削除しつつ同時に removed を追加して、該当リソースを tfstate から削除します。そして azurerm_static_web_appimport を追加することで新しいリソースとしてインポートするようにしています。

この時 removed には明示的に destroy = false を追加してリソース自体は削除しないようにします。

#resource "azurerm_static_site" "default" {
#  name                = "stapp-xxxxxx"
#  location            = var.location
#  resource_group_name = var.resource_group_name
#}

removed {
  from = azurerm_static_site.default

  lifecycle {
    destroy = false
  }
}

resource "azurerm_static_web_app" "default" {
  name                = "stapp-xxxxxx"
  location            = var.location
  resource_group_name = var.resource_group_name
}

import {
  id = "/subscriptions/.../resourceGroups/.../providers/Microsoft.Web/staticSites/..."
  to = azurerm_static_web_app.default
}

本来であれば import で指定する id を既存のリソースから取得したいのですが、removed ブロックを使う場合はリソース定義は削除する必要があるので無理でした。更に import ブロックはモジュールの中では書けないようなので、ルートで指定する必要があるのも面倒でした。将来的には改善して欲しいです。

このように書くことで古いリソースの tfstate からの削除と、新しいリソースのインポートが 1 回で完了します。実際に稼働しているアプリケーションで移行を行った Pull Request が以下のようになります。

カスタムドメインも移行しているので 2 つ分 removedimport を書いて対応しています。現在の Azure Provider バージョンではカスタムドメインのインポートに失敗している気がするので、再作成した方が結果として安定する予感です。

Terraform Cloud で運用しているので plan 結果を確認すると tfstate からの削除とインポートが行われていることが分かります。カスタムドメインは若干この時点でも挙動がおかしいです。

この Pull Request をマージすると apply が走って新しいリソースへのマイグレーションが完了します。Terraform CLI で tfstate を直接触るよりも安全かつ効率的に行えました。

最後に追加した removed ブロックと import ブロックを削除する Pull Request を作成します。

この Pull Request の plan を確認すると変更なし扱いとなるので、今回の removedimport を使ったマイグレーションが正しく行われていることが確認出来ました。

最新の Terraform を使っておく必要がありますが、Terraform CLI を使って手動で作業するよりも格段に安全なので、今後も Azure Provider でリソースの非推奨化が行われても安心して作業が出来そうです。

Azure Functions の Node.js v4 に追加された HTTP Streaming サポートを深掘りする

Azure Functions の Node.js や Python といった言語ワーカーが動作するタイプでは、これまで HTTP のリクエストとレスポンスを Stream として扱うことが出来なかったので、大容量ファイルのダウンロードやアップロードが効率的に行えないという制約がありました。

この制約は In-Process で動作する C# のみ適用されず、同じ C# でも .NET Isolated を使っている場合は Node.js や Python と同様の制約を受けていましたが、ASP.NET Core Integration の追加によって解消されました。仕組みとしては HTTP だけは Azure Functions Runtime が裏側の言語ワーカー対して、HTTP リクエストをそのままフォワーディングすることで実現しています。

以前の仕組みでは HTTP も一旦は gRPC に変換していたので、どう頑張っても Stream として扱うことが出来ないという事情がありました。ちなみに HTTP フォワーディングには YARP が使われているので信頼性とパフォーマンスの面でも安心です。

この新しい仕組みによって Azure Functions の言語ワーカーでも効率的に HTTP が扱えるようになりましたが、この度 Node.js でも同じ仕組みがサポートされるようになったという話です。現在はプレビュー中ですが、仕組みとしてはシンプルなので GA もそう遠くないでしょう。

C# では ASP.NET Core Integration という形で導入されたので、HTTP リクエストとレスポンスを表す型が変更され移行が必要になりましたが、Node.js の HTTP Streaming サポートでは Node.js の Stream への対応に必要な最小限の変更だけが行われたので、オプションを有効化して Stream を返すだけで使えます。

早速複数のシナリオで試していくわけですが、まず HTTP Streaming を有効化するには app.setup を呼び出して enableHttpStreamtrue に設定する必要があります。

import { app } from "@azure/functions";

app.setup({ enableHttpStream: true });

ちなみに 1 回だけ呼び出せばよいので setup.ts のようなファイルを作成して、そこに上のコードを追加することで対応しました。これでグローバルに有効化されます。

ここから先に紹介するサンプルコードの注意点として、私自身が Node.js の Stream API に詳しいわけではないので効率の悪い実装になっている可能性はあります。

Blob を Streaming でダウンロード・アップロードする

まずはよくありそうなシナリオとして 1,2 位を争いそうな、Azure Storage の Blob に対して Stream でのダウンロードとアップロードを行ってみます。Blob SDK 自体は Node.js の Stream に対応しているので、HTTP Streaming を組み合わせると効率よく扱えるはずです。

用意したサンプルコードは以下のようになりました。HttpResponseInitbody に直接 readableStreamBody を渡すだけで良いと思っていましたが、TypeScript の型エラーが出てしまったので Readable.from を使って Readable に変換することで対応しました。

正直なところ Readable / ReadStream / ReadableStream の違いはよく分かっていません。

import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import { BlobServiceClient } from "@azure/storage-blob";
import { Readable } from "stream";

const blobServiceClient = BlobServiceClient.fromConnectionString(process.env.AZURE_STORAGE_CONNECTION_STRING);

export async function httpTrigger1(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    context.log(`Http function processed request for url "${request.url}"`);

    const containerClient = blobServiceClient.getContainerClient('streaming');
    const blobClient = containerClient.getBlockBlobClient('download.jpg');

    const response = await blobClient.download();
    const stream = Readable.from(response.readableStreamBody);

    return {
        body: stream,
        headers: { 'Content-Type': 'image/jpg' }
    };
};

app.http('httpTrigger1', {
    methods: ['GET'],
    authLevel: 'anonymous',
    handler: httpTrigger1
});

これまでのように arrayBufferblob として一度メモリに読み込んでから返すのと違い、 Stream はすぐに応答を返し始めるので TTFB が短縮されます。更にメモリに全データを読み込まないので、消費するメモリ量は非常に少なくなります。

次はアップロードを行っていきますが、Blob SDK では Stream 用の API が用意されているので、こちらもダウンロードと同様にサクッと対応可能です。

現在の HTTP Streaming 実装はリクエストのペイロードしか対応していないので、マルチパートで送信されたファイルでは HTTP Streaming が使えない点に注意が必要です。アップロードでも uploadStream に直接 request.body を渡すことが出来なかったので、一度 Readable.from を経由して解決しています。

import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import { BlobServiceClient } from "@azure/storage-blob";
import { Readable } from "stream";

const blobServiceClient = BlobServiceClient.fromConnectionString(process.env.AZURE_STORAGE_CONNECTION_STRING);

export async function httpTrigger2(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    context.log(`Http function processed request for url "${request.url}"`);

    const containerClient = blobServiceClient.getContainerClient("streaming");
    const blobClient = containerClient.getBlockBlobClient("upload.jpg");

    await blobClient.uploadStream(Readable.from(request.body));

    return { body: 'Done' };
};

app.http('httpTrigger2', {
    methods: ['POST'],
    authLevel: 'anonymous',
    handler: httpTrigger2
});

ダウンロードと同様にアップロードされたファイルが Stream で扱われるので、実行中の消費メモリ量が非常に少なく済むことを確認出来ました。パフォーマンスとスケーラビリティに大きく影響する部分なので、今後は可能な限り Stream を使っていきたいです。

Server-Sent Events を実装する

HTTP Streaming をサポートしたということは、ファイルを効率よく扱うだけではなく、Azure Functions でも Server-Sent Events が実装できるようになったことを意味します。公式ブログでも Azure OpenAI の Streaming が用途として挙げられていたように、処理中に順次応答を返すことが出来るようになりました。

WebSocket でも同じことは実現出来ますが、Azure Functions Runtime では有効化されていないようです。YARP 自体は対応しているので実装の都合という感じはあります。

しかしサーバーからクライアントに順次データを返すだけであれば Server-Sent Events が一番簡単です。

応答として Node.js の Stream を返せばよいだけなので、実現方法はいくつも考えられると思いますが、以下のサンプルコードでは Generator を作成して Readable.from で変換するようにして対応しました。

import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import { Readable } from "stream";

const sleep = (timeout: number) => new Promise(resolve => setTimeout(resolve, timeout))

export async function httpTrigger3(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    context.log(`Http function processed request for url "${request.url}"`);

    const generator = async function* (count: number) {
        for (var i = 0; i < count; i++) {
            yield `event: ping\ndata: ${new Date().toISOString()}\n\n`
            await sleep(1000)
        }
    };

    const stream = Readable.from(generator(10));

    return {
        body: stream,
        headers: {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-store'
        }
    };
};

app.http('httpTrigger3', {
    methods: ['GET'],
    authLevel: 'anonymous',
    handler: httpTrigger3
});

このサンプルコードを EventSource を使って動かすと、以下のようにイベントが一定間隔でサーバーから送信されていることが確認出来ます。Generator が完了すると接続が切れて再接続が走るため、エラー発生時には明示的に close するコードを入れています。

C# で言うところの CancellationToken に相当するものがなさそうなので、現時点の実装では SSE の送信中に接続が切れたことを判別する手段が無さそうです。なのでかなり長い期間接続を保持して、ダラダラとイベントを送信するようなケースでは対応が難しそうです。

Azure OpenAI の Streaming API と組み合わせる

ここまでの確認で準備が整っているので、最後は Azure OpenAI の Streaming API を使って Chat Completions の実行結果を Streaming でクライアントに返す実装を試してみます。Azure OpenAI のドキュメントでも最適化として Streaming が推奨されているので、Azure Functions でサクッと実装できると嬉しい部分です。

コードとしては Server-Sent Events を実装した時とほぼ同じで、違いとしては Generator の実装が EventStream<ChatCompletions> を引数で受け取って for await で回すようにしているだけです。

import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import { OpenAIClient, AzureKeyCredential, ChatCompletions, EventStream } from "@azure/openai";
import { Readable } from "stream";

export async function httpTrigger4(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    context.log(`Http function processed request for url "${request.url}"`);

    const client = new OpenAIClient(process.env.AZURE_OPENAI_ENDPOINT, new AzureKeyCredential(process.env.AZURE_OPENAI_KEY))

    const events = await client.streamChatCompletions(
        'gpt-4',
        [
            { role: 'system', content: 'You are an AI assistant that helps people find information.' },
            { role: 'user', content: 'Azure Functions について分かりやすく説明してください' }
        ],
        { maxTokens: 1024 }
    );

    const generator = async function* (events: EventStream<ChatCompletions>) {
        for await (const event of events) {
            for (const choice of event.choices) {
                if (choice.delta?.content === undefined) {
                    continue;
                }
                console.log(choice.delta?.content);
                yield `event: text\ndata: ${choice.delta?.content}\n\n`
            }
        }
    };

    const stream = Readable.from(generator(events));

    return {
        body: stream,
        headers: {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-store'
        }
    };
};

app.http('httpTrigger4', {
    methods: ['GET'],
    authLevel: 'anonymous',
    handler: httpTrigger4
});

このコードを実行してみると、応答が開始されるまでは少し時間がかかりますが、その後は Server-Sent Events として GPT-4 の応答が Streaming で流れていることが確認出来ます。Azure Functions をベースとしているので必要なコードが少なく実現出来ているのは大きなメリットです。

特に Azure OpenAI を利用したアプリケーションは多くの API が必要となるケースが少ないため、Azure Functions の HttpTrigger を使ってサクッと用意するのが手っ取り早いです。

今回は Azure にデプロイまでは試していませんが、ほぼ全てのリージョンで Azure Functions Runtime の 4.30.0 以上がデプロイ済みなのですんなりと動作するはずです。あまり長い接続を作ろうとすると Azure Load Balancer のタイムアウトに引っかかる可能性はあるので、その辺りは注意が必要になります。

ちなみに Static Web Apps の Managed Functions にも 4.30.0 以上がデプロイされているはずなので、SWA でも利用できる可能性があります。Azure OpenAI と SWA の相性は良いと思っているので、組み合わせて使っていきたいです。

独自のバックエンドとリンクした Azure Static Web Apps のプレビュー環境を自動で作成する

Azure Static Web Apps で何も考えずに GitHub で Pull Request を作成すると、標準で提供されているプレビュー環境が自動的に作成されるので便利です。Managed Functions も SWA とセットでデプロイされるので、フロントエンドだけではなくバックエンドも含めてプレビュー出来るのが特徴です。

簡単なアプリケーションであれば Managed Functions で十分なケースも多いはずですが、Web API の数が多い場合には Azure Functions ではなく App Service や API Management を API リンクで利用するケースがあります。SWA の Standard Plan が必要になりますが、App Service / API Management / Container Apps などをバックエンドに利用できるので SWA の利用範囲が広がります。

SWA へのバックエンドのリンク機能については以下のエントリでまとめていますので、使ったことがない方はこちらも参照してください。

しかし、バックエンドに独自の App Service などで実装した API をリンクすると、SWA のプレビュー環境がフロントエンド側でしか動作しなくなるため、実質使い物にならなくなります。ドキュメントにもバックエンドのリンクを利用すると Pull Request でのプレビュー環境が使えなくなると明記されています。

Backend integration is not supported on Static Web Apps pull request environments.

API support in Azure Static Web Apps with Azure App Service | Microsoft Learn

こればかりはバックエンドがどのようにホストされているかに依存するので、SWA 側ではどうしようもない部分です。SWA で独自のバックエンドを利用する場合の制約となるのですが、非常に便利な機能なのでやはり活用したいです。

ドキュメントには Pull Request のプレビュー環境はバックエンドのリンクを利用すると使えないと書いてありますが、実は名前付きプレビュー環境ではバックエンドのリンクが利用できるので、それを利用して構築を行なっていきます。

App Service や Azure Functions には Deployment Slot が用意されているので、SWA 側のプレビュー環境と App Service の Deployment Slot を一対一に紐づけると綺麗に解決できます。以前に App Service で Pull Request プレビュー環境を作成する方法を書いているので、こちらも参考にしてください。

組み込みでのサポートは用意されていないので、基本的に GitHub Actions と Azure CLI を利用して実現していきますが、以下の 3 ステップで済むため意外に簡単です。

  1. API デプロイ用の Web Apps の Deployment Slot を作成
  2. Static Web Apps のプレビュー環境を作成
  3. Static Web Apps の API リンクで Web Apps を関連付け

SWA 側で App Service をリンクする際には Deployment Slot 名を指定できるので、そういう意味では想定された使い方かもしれません。この方針で Azure CLI を使ってプレビュー環境を構築していきます。

Web Apps でプレビュー環境を作成

まずは Web Apps に対して新しい Deployment Slot を作成して、そこにプレビュー表示したいブランチのバックエンドをデプロイします。デプロイの処理自体は変わらないので省略します。

Azure CLI を使って Deployment Slot を作成するには az webapp deployment slot create を使います。

Deployment Slot を作成するときのオプションとして、コピー元の Slot の指定があるので常に Production Slot の設定をコピーして作るようにします。引数としては --configuration-source が該当しますが、Production Slot を指定する際には Web App 名を指定します。

az webapp deployment slot create -n ${{ env.BACKEND_APP_NAME }} -g ${{ env.RESOURCE_GROUP_NAME }} -s ${{ env.PREVIEW_ENV_NAME }} --configuration-source ${{ env.BACKEND_APP_NAME }}

App Service 名などは他の部分でも必要となるので環境変数して追加しておくと楽です。このコマンドが成功すると新しい名前で Deployment Slot が作成されます。

ちなみにこのコマンドは既に同名の Deployment Slot が存在している場合でも実行でき、更にその時点でのコピー元の最新の設定を適用してくれるので、多少実行に時間はかかりますがアプリケーションデプロイの度に毎回呼び出したほうが安全です。

Static Web Apps でプレビュー環境を作成

次は Static Web Apps 側のプレビュー環境を作成します。SWA のプレビュー環境は Azure CLI でも作成可能ですが、アプリケーションデプロイの際になければ自動で作ってくれるのでそれを利用します。

ただし公式の SWA デプロイ Action を使うと Pull Request 上の場合に特殊処理が走ってしまうので、デプロイには SWA CLI を使うようにして対応します。SWA CLI を使ったデプロイ方法は以下のエントリで紹介しているので、こちらも参照してください。

SWA CLI を使ったデプロイの際にプレビュー環境名を指定すれば、名前付きプレビュー環境が作成されます。App Service のように Deployment Slot とデプロイを個別に行う必要がないためシンプルです。

swa deploy ./dist -n ${{ env.FRONTEND_APP_NAME}} --env ${{ env.PREVIEW_ENV_NAME}} --no-use-keychain

これで SWA 側に指定した名前でプレビュー環境が作成されます。この方法で作成したプレビュー環境には独自のバックエンドをリンク出来るので、後は Azure CLI を使ってリンクを行うだけとなります。

バックエンドのリンク

最後は作成した Web App と Static Web App をリンクするのですが、これも Azure CLI を使って行えるので GitHub Actions に載せることが容易です。ただし渡すパラメータに癖があるので注意が必要です。

Azure CLI で SWA へのバックエンドのリンクを行う機会は正直ほぼ無いと思いますし、そもそも SWA を Azure CLI で管理する機会もほぼ無いとは思うので az staticwebapp というコマンド自体があまり知られていない気がしますが、意外に多くのコマンドが用意されています。

バックエンドのリンク自体はコマンド 1 つで終わるのですが、コマンドのパラメータとしてリンク先のリソース ID とリージョン名が必要となるのが最大のネックです。リソース ID はともかく、リージョン名が必要になるのは謎です。

バックエンドのリソース ID とリージョン名を取得するために、今回は az webapp show コマンドを使うことにして、以下のような処理でバックエンドのリンクを実現しました。

read -d "" backendId location <<< $(az webapp show -n ${{ env.BACKEND_APP_NAME }} -g ${{ env.RESOURCE_GROUP_NAME }} -s ${{ env.PREVIEW_ENV_NAME }} --query "[id, location]" -o tsv) || true
az staticwebapp backends link -n ${{ env.FRONTEND_APP_NAME }} -g ${{ env.RESOURCE_GROUP_NAME }} --environment-name ${{ env.PREVIEW_ENV_NAME }} --backend-resource-id "$backendId" --backend-region "$location"

read -d を使った呪文のようなコマンドは、az webapp show が返した複数の値を変数に格納するために使っています。以下のドキュメントで紹介されていた処理を流用しているだけですが、今回のように複数の値が必要な場合に時間のかかる ARM REST API の呼び出しを最小限にできます。

これでプレビュー環境同士をリンク出来ました。Azure Portal で確認するとプレビュー環境にバックエンドがリンクされているのが確認出来ます。

必要なくなったプレビュー環境を削除

プレビュー環境の作成はここまでの処理で完成したので、最後に Pull Request が閉じられた際にプレビュー環境も削除する処理を追加する必要があります。削除は非常にシンプルで Web App と Static Web App のそれぞれで削除コマンドを実行するだけです。

厳密にはどちらのコマンドが失敗した場合のリカバリについても考慮する必要がありますが、削除が失敗することは可能性としては低いため以下のように単純なコマンドで実現します。

az webapp deployment slot delete -n ${{ env.BACKEND_APP_NAME }} -g ${{ env.RESOURCE_GROUP_NAME }} -s ${{ env.PREVIEW_ENV_NAME }}
az staticwebapp environment delete -n ${{ env.FRONTEND_APP_NAME }} -g ${{ env.RESOURCE_GROUP_NAME }} --environment-name ${{ env.PREVIEW_ENV_NAME }} --yes

このコマンドを Pull Request が閉じられた時に実行するようにすると、プレビュー環境と Pull Requst のライフサイクルを一致させることができます。

実際の動作例

ここまでのプレビュー環境の自動作成とデプロイを組み合わせたサンプルリポジトリを作成して、実際に動作確認を行いましたが Pull Request を作成すると、自動でバックエンド付きのプレビュー環境が作成され、素早く動作確認が行えるようになりました。

作成したサンプルでは GitHub の Environment も利用しているので、以下のように Pull Request からプレビュー用の URL に素早く飛べるようになっています。

実際にバックエンドに新しい API を追加して、プレビュー環境上で動作確認を行いましたが問題なく動作しました。実際のプロダクト開発にこの仕組みを実装して、しばらく運用していますが非常にスムーズにレビューが行えるのでかなり便利に使っています。

プレビュー環境で問題となりがちな認証については、今回は Entra ID のワイルドカードを利用してプレビュー環境に対しても本番と同等の Entra ID 認証を追加することで綺麗に解決することが出来ています。

Entra ID 認証がワイルドカードで利用出来ることが確認できたのが、今回の独自のバックエンドをリンクした SWA でプレビュー環境を構築するモチベーションになっています。

実際のプロダクト開発に組み込んだ話を含めて、どこかで喋る機会があれば共有したいなと思っています。

Azure Static Web Apps のデプロイに Static Web Apps CLI を利用する

Azure Static Web Apps へアプリケーションをデプロイする際には GitHub Actions を使うことが多いはずですが、公式で用意されている Action はビルドまで行うように設計されているため、ビルドは自前で行ってデプロイだけを行いたいケースでは若干使いにくいです。

それ以外のケースでも、ちょっとした検証で GitHub からデプロイするように構成するのは非常に面倒なので、最近は Static Web Apps CLI を使ってデプロイをすることが多くなりました。

Static Web Apps CLI はローカルでの開発をサポートする機能だけではなく、そのまま Azure にデプロイする機能まで備えているため扱いやすいものとなっています。最近は個人で開発しているアプリで Static Web Apps へのデプロイは、あえて GitHub Actions 上で Static Web Apps CLI を使うようにしています。

公式の Action ではデプロイの認証情報として Deployment Token のみ利用可能ですが、Static Web Apps CLI では Deployment Token だけではなく Service Principal も利用可能になっているため、他の Azure リソースへのデプロイと認証情報を共有できるメリットもあります。

単純に Static Web Apps CLI のデプロイと言ってもパターンがあるので簡単にまとめておきます。

Deployment Token を利用したデプロイ

一番シンプルなデプロイ方法は Deployment Token を利用するものです。SWA CLI では --deployment-token パラメータか SWA_CLI_DEPLOYMENT_TOKEN 環境変数を使って渡すようになっています。Deployment Token の値でどの Static Web Apps にデプロイするか特定されるため、名前を指定する必要がありません。

swa deploy ./dist --env production --deployment-token ***

Azure Portal から GitHub を選択すると Deployment Token は自動で Secret に登録されますが、SWA CLI を使う場合は Azure Portal から Deployment Token をコピーする必要があります。

ローカルでの swa deploy コマンドの実行時に毎回 Deployment Token を指定するのは手間なので、環境変数に登録してから実行する方法が扱いやすいです。

対話型ログインを利用したデプロイ

Deployment Token を利用したデプロイはシンプルで扱いやすいですが、Deployment Token 自体の管理が非常に手間かつリスクになります。SWA CLI は Azure にログインして Deployment Token を管理する必要のないデプロイに対応しているため、更に扱いやすいものです。

最低限 swa deploy に渡す必要があるのは Static Web Apps の名前だけです。

swa deploy ./dist --env production --app-name ***

後は必要に応じて SWA CLI が Azure へのログインを行い、テナントやサブスクリプションの選択まで促してくれます。swa deploy に渡すパラメータで明示的にテナントとサブスクリプションを指定することも出来ますが、以下のように対話型で行った方が便利です。

最初にログインとサブスクリプションの選択を行うと、その情報は .env ファイルに保存されるため、次からはサブスクリプションを選択する必要なくデプロイが行えます。

Service Principal を利用したデプロイ

公式の Action でも要望が多く寄せられていた Service Principal を利用したデプロイも、SWA CLI は標準で対応しているので Static Web Apps のデプロイとバックエンドの App Service などのデプロイを同じ認証情報で行えるようになっています。

Service Principal を使う場合は swa deploy に渡すパラメータは一番多くなり、Client Secret の管理が少しネックになりますが、他と認証情報を共有できる強みは大きいです。

swa deploy ./dist --env production --app-name *** --tenant-id *** --subscription-id *** --client-id *** --client-secret ***

Deployment Token の時と同様に Service Principal の値は以下の環境変数を経由して渡すことも出来ます。

  • AZURE_TENANT_ID
  • AZURE_SUBSCRIPTION_ID
  • AZURE_CLIENT_ID
  • AZURE_CLIENT_SECRET

この環境変数は SWA CLI が内部で使っている Azure Identity が要求するものとなっています。なので他のアプリケーションでも使われている可能性があるのでバッティングには注意が必要です。

環境変数を設定するとデプロイは Static Web Apps の名前だけで済むようになるので楽です。

swa deploy ./dist --env production --app-name ***

Service Principal を使ったデプロイでは Static Web Apps への Contributor 権限が必要になります。App Service のようにリソース固有のロールは用意されていないので、強めの権限を与える必要があります。

GitHub Actions で SWA CLI を使ったデプロイフローを組む

Static Web Apps CLI を利用するとデプロイのみを簡単に実行できるので、GitHub Actions でのデプロイ時に利用するとビルドとデプロイを分離できるため、Oryx に依存しないアプリケーションに最適な方法でビルドを行えるようになります。

以下は Deployment Token を利用したシンプルなデプロイフローの例です。SWA CLI をインストールする手間は発生しますが、ビルドとデプロイを分離することで見通しも良くなっています。

name: Azure Static Web Apps CI/CD

on:
  push:
    branches: [ master ]

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Use Node.js 20
      uses: actions/setup-node@v4
      with:
        node-version: 20

    - name: npm ci and build
      run: |
        npm ci --prefix frontend
        npm run build --prefix frontend

    - name: Deploy to Static Web App
      run: |
        npm i -g @azure/static-web-apps-cli
        swa deploy ./frontend/dist/ --env production --deployment-token ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}

実際に運用しているアプリケーションでも SWA CLI を使ったデプロイを既に組んであるので、こちらのワークフローも参考にしてください。それぞれの処理を分けておくとデプロイに失敗した場合に、失敗した処理だけリトライ出来るため効率的です。

そして Service Principal を利用したデプロイフローのサンプルは以下の通りになります。基本的には Deployment Token を利用する場合と同じですが、swa deploy に渡すパラメータを減らすため環境変数に Secrets の値を設定している点が異なっています。

name: Azure Static Web Apps CI/CD

on:
  push:
    branches: [ master ]

env:
  AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
  AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
  AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
  AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Use Node.js 20
      uses: actions/setup-node@v4
      with:
        node-version: 20

    - name: npm ci and build
      run: |
        npm ci --prefix frontend
        npm run build --prefix frontend

    - name: Deploy to Static Web App
      run: |
        npm i -g @azure/static-web-apps-cli
        swa deploy ./frontend/dist/ -n <SWA NAME> --env production --no-use-keychain

これまで説明していない --no-use-keychain についてですが、GitHub Actions 上ではキーチェーンが使えず問題となるため指定しています。

実際にデプロイフローを動かしてみると、以下のように Service Principal を使って Azure にログインを行い、Static Web Apps へのデプロイまで完了していることが確認出来ます。

Service Principal は権限がある Static Web Apps であれば自由にデプロイできるので、複数の Static Web Apps へのデプロイも 1 つの認証情報で実現出来ます。これは管理面からみると大きなメリットとなります。

Federated Credentials (OIDC) には未対応

既に GitHub Actions を使って Azure へのデプロイを組んだことのある人なら、絶対に Federated Credentials (OIDC) を使いたくなると思うのですが、残念ながら現時点の SWA CLI では対応していないため Client Secret の管理から逃げられません。

Federated Credentials については今回は説明しませんので、以下のエントリを参照してください。

SWA CLI は内部的に Azure Identity ライブラリを使用している関係上、Azure CLI から認証情報を取得出来れば綺麗に解決可能です。現在は Federated Credentials への対応のため Pull Request を作成してレビューを待っている状態です。

この Pull Request がマージされると、ローカルや GitHub Actions 上で Azure CLI が持っている認証情報でデプロイ可能となるため、より安全なデプロイフローを実現できます。

既に GitHub に関連付いたものを SWA CLI に変更する

新規に Static Web Apps を作成した時に GitHub を関連付けて作成した場合は、SWA CLI からデプロイしようとするとエラーとなるケースがあります。解消するには Azure CLI を使って az staticwebapp disconnect を実行して関連付けを解除する必要があります。

Azure CLI を利用すると再び GitHub 関連付けることも可能なはずですが、試したところ謎のエラーが出て動作しませんでした。一応 az staticwebapp update を使うと GitHub の設定以外は行えるので、後は手動で対応する形になりそうです。

Azure Static Web Apps のプレビュー環境で Microsoft Entra ID 認証を有効化する

Azure Static Web Apps のプレビュー環境は、GitHub を利用した開発中に Pull Request を作成したタイミングで自動的にデプロイされるので、変更点の動作確認やレビューが行いやすくなる便利な機能なのですが、カスタム認証を使ったアプリケーションでは利用が難しいものでした。

カスタム認証を使うとプレビュー環境を使うのが難しい理由は、URL が別で発行されるので OpenID Connect のようにコールバック URL を登録する必要がある場合に、何らかの方法で登録する必要があるためです。

以下のドキュメントでも Microsoft Entra ID を使った認証では、/.auth/login/aad/callback というパスの絶対 URL を Entra ID 側に登録する必要があると記載されています。

実際に Entra ID でのログインを Static Web Apps に組み込む際には、Entra ID アプリケーション側で以下のように Static Web Apps へのコールバック URL を登録する必要があります。

Static Web Apps の本番向け URL はリソースを再作成しない限り固定されるので、シンプルに設定できます。

本番向けだけであれば、後は staticwebapp.config.json に必要な設定を追加すれば、Entra ID を使ったログインを実装出来ます。画像だと説明が難しいですが、Entra ID を使ったログインを問題なく行えています。

この状態で Pull Request などをトリガーに作成されるプレビュー環境を使って試してみます。プレビュー環境の URL は Pull Request の場合は ID とデプロイ時に指定したリージョン名が追加で含まれます。

本来なら GitHub と連携させてデプロイをさせることでプレビュー環境を作るのですが、動作検証中に毎回 GitHub と連携するのは手間なので Static Web Apps CLI を使ってローカルからデプロイして検証しました。

この辺りは別途ブログに書こうと思っていますが、Static Web Apps CLI には swa deploy コマンドが用意されていて、簡単に任意のディレクトリをデプロイできるため非常に便利です。

Azure Portal から Deployment Token をコピーしてくれば、以下のようなコマンドでサクッとデプロイ可能です。--env を指定することで任意の名前のプレビュー環境へのデプロイも可能です。

swa deploy ./dist --env staging --deployment-token ***

これで作成したプレビュー環境に対してアクセスを行ってみると、Entra ID 側に登録されているコールバック URL が一致しないというエラーが発生して、ログインに失敗することが確認出来ます。このコールバック URL は Entra ID アプリケーション側に登録をしていないので、エラーが発生するのは当然です。

Static Web Apps がリリースされた当初から URL が Pull Request 毎に変わる結果、Entra ID のように外部に URL を登録するサービスが使いにくいと言われていて、その対応策としてプレビュー環境の拡張としてブランチ環境や名前付き環境が導入されました。

詳細は以下のドキュメントを参照していただくとして、このブランチ環境や名前付き環境を使うと、URL の固定化が可能となるためコールバック URL を登録するのは容易になります。

これでステージング環境のように予め決められたプレビュー環境であれば、Entra ID 認証を有効化出来るようになりましたが、やはり Pull Request 単位でのプレビュー環境の需要は多いので実現したいです。SPA のレビューの度にローカルで実行するのは正直厳しいです。

今回の場合は Entra ID アプリケーションのリダイレクト URL を可変に出来れば解決するので調べると、なんと Entra ID アプリケーションのリダイレクト URL は条件付きながらワイルドカードが利用できることが分かりました。これは完全に求めていた機能でした。

サブドメイン部分をワイルドカードで登録できるので、今回のプレビュー環境であれば問題なく利用できるはずです。但しドキュメントにもあるようにワイルドカードは推奨されておらず、絶対 URL が強く推奨されている点は頭に入れておく必要があります。

Though it's possible to set a redirect URI with a wildcard by using the manifest editor, we strongly recommend you adhere to section 3.1.2 of RFC 6749. and use only absolute URIs.

Redirect URI (reply URL) restrictions - Microsoft identity platform | Microsoft Learn

そしてワイルドカードが利用できるのは組織向けのアプリケーションだけとなっているため、ユースケースとしては今回のように開発中に利用することが想定されていると考えられます。

全く推奨されてはいない関係上、通常の UI ではワイルドカードを含む URL は登録出来ません。ドキュメントにもあるようにマニフェストを直接編集することで対応します。

注意点としてはワイルドカードはサブドメインに対して指定できるため、挙動としてはワイルドカード証明書と同じ仕様になっているようです。つまり https://*.example.com は指定できますが https://test-*.example.com のような指定は出来ませんし、ワイルドカードの対象となるのはその階層だけです。

従って Static Web Apps のプレビュー環境向けにワイルドカード設定を追加する際には、以下のようにプレビュー環境向けのコールバック URL を追加する形で対応します。

これで本番向けとプレビュー環境向けの両方に対応した Entra ID アプリケーションの設定が出来ました。

早速、先ほどはコールバック URL のミスマッチエラーが発生したプレビュー環境にアクセスすると、今度は問題なく Entra ID 認証に成功してページが表示されることが確認出来ました。

GitHub Actions を利用したデプロイの設定を行って、実際に Pull Request を作成したタイミングで作成されるプレビュー環境にアクセスしてみましたが、こちらも問題なく Entra ID 認証が行われてページが表示できています。ワイルドカードなので Pull Request が複数作られても問題なく動作します。

これで Managed Functions を利用した Static Web Apps 向けのアプリケーションであれば、問題なく Entra ID 認証付きで Pull Request の変更内容をプレビュー出来るようになりました。

最後に本題とは関係ないですが、GitHub Actions で Static Web Apps に Pull Request のプレビュー環境を作成した際に、完了のコメントが書き込まれないケースがあるようです。これは GitHub Actions の権限のデフォルト値が変更され、Pull Request への書き込みの権限が外れたことが原因です。

以下の Issue にある通り、permissions で Pull Request への書き込み権限を付与すると解決します。

自動生成された GitHub Actions の Workflow ファイルには permissions が定義されていないようなので、デフォルト値が変更されたことを知らないとはまりそうです。

Azure Functions の Python V2 と Node.js V4 プログラミングモデルを利用する上での注意点

去年に GA してから時間が経過しましたが、Azure Functions の新しいプログラミングモデル Python V2 と Node.js V4 を使う機会が増えてきて、以前書いたエントリからの更新点も増えたので簡単にまとめます。

プレビュー中に書いたエントリは以下になりますが、基本的な部分は変わっていないはずです。

新しいプログラミングモデルは非常に扱いやすく、これまでのように function.json を手動で書く必要がないのでミスを減らすことが出来ますが、内部的には function.json と同様の仕組みが使われているのでエラーメッセージなどで混乱するかもしれません。実行時エラーで function.json に関するメッセージだった場合にはバインディングとトリガー周りの設定ミスと考えてください。

Python V2 と Node.js V4 について GA してドキュメントとツールのサポートがしっかりと整備されたので、まずはその辺りについて触れていきます。その後に注意点をいくつか紹介します。

Python V2 プログラミングモデル

Python V2 は今年の 5 月に GA していて、既に十分安定していると言っても良いレベルになっていますが、一部のトリガーのテンプレートやドキュメントは追従できていない部分があるため、V1 と V2 どちらのプログラミングモデル向けなのかを確認する必要があります。

Azure Functions を Python で作成する際には VS Code を使うことが大半だと思います。最新の Azure Functions 拡張を入れておくと以下のように新規作成時に Model V2 を選択できるようになっていますが、まだテンプレートは全てのトリガー向けに用意されていません。Azure Functions Core Tools を使っても同じことが可能になっていますが、こちらの方がテンプレートの種類が多い印象です。

VS Code にテンプレートが用意されていないバインディングやトリガーは、今のところはドキュメントを確認して手動で追加する形になると思います。特によく使いそうな Cosmos DB Trigger はまだテンプレートが用意されていないので面倒です。

ドキュメントも V1 と V2 でタブが用意されているので、確実に V2 が選択されていることを確認してから参照してください。書き方がかなり異なっているので間違えることは少ないと思いますが、検索で辿り着くと V1 向けで表示されることもあるので注意です。

Python については Blueprint という概念が追加されていて、少し他の言語とは毛色が異なっている部分もあるので、一度は実際に触って書き方を試しておくのが良いかと思います。

Node.js V4 プログラミングモデル

Python V2 に比べるとリリースが遅れた Node.js V4 ですが、こちらもプレビューを長い期間取っていたので、既にかなり安定していると感じています。書き味としては Express のようなスタイルなので、Node.js を使った開発に慣れている方ならスムーズに利用できると思います。

Node.js V4 も Python V2 と同様に VS Code と最新の Azure Functions 拡張を入れておけば、新規作成時に Model V4 を選ぶことが出来るので簡単に始めることが出来ます。

テンプレートについては Python V2 よりも Node.vs V4 の方が数が多いので、これまで V3 で開発していた人も困ることはないと思います。ドキュメントも V4 がデフォルトで提供されているので、こちらもバージョンを間違えないように注意して読んでください。

ちなみに Node.js V4 についてはマイグレーションのドキュメントが公開されているので、既存の Node.js V3 の Function があれば早めに Node.js V4 へ移行しておいた方が開発が楽になります。

ドキュメントでは V3 と V4 の例が紹介されているので、どのように書き換えればよいのか分かりやすくなっています。そして V4 でどれだけシンプルに書けるようになったかも一目瞭然なので、Azure Functions を Node.js で開発している方は必読だと思います。

最新の Azure Functions Runtime では AzureWebJobsFeatureFlags が不要に

これまで function.json というファイルを手書きする必要があったのを、実行時に各言語ワーカーから受け取る仕組みが追加されたことで、Python と Node.js の開発体験が劇的に向上したのですが、その仕組みがデフォルトで有効化されていないため AzureWebJobsFeatureFlags という設定を追加する必要がありました。

しかし、少し前のコミットでデフォルト有効化するコードが追加され、その実装はバージョン 4.28.0 に含まれているため、このバージョンより新しい Azure Functions Runtime では設定が不要になっています。

自分が確認した限り、大半のリージョンでは 4.28.3 がデプロイされているので設定なしで動作するようになっていますが、一部のリージョンでは 4.27.5 が残っているのと Azure Functions Core Tools が 4.27.5 ベースになっているので、暫くは AzureWebJobsFeatureFlags を明示的に指定しておいた方が安全です。

恐らく今月中には全てのリージョンに 4.28.3 がデプロイされるのではないかと思っています。

Cosmos DB Extension v3 と v4 の混在に注意

これまで Azure Functions ではバインディングやトリガーで使われている SDK のアップデートはあっても、破壊的変更は行われてこなかったのですが Cosmos DB Extension については SDK 側で破壊的変更が多かったため、Azure Functions 側でも Cosmos DB Extension の v3 と v4 の両方が扱えるように実装されました。

C# であれば以前ブログに書いた通り NuGet パッケージを更新して、コンパイルが通らなくなったプロパティ名を修正するぐらいで済むのですが、Python や Node.js では少し罠があります。

具体的に見ていくと、Python V2 の場合はこれまでのバインディングとトリガーでは Cosmos DB Extension の v4 向けの設定となっていて、新しく v3 向けにバインディングとトリガーを追加することでユーザー側がアップデートのタイミングをコントロールできるようになっています。

参考情報になりますが、この実装が追加された PR は以下になります。プロパティ名は Cosmos DB Extension v4 準拠になっているため、Collection が Container になっているなどいくつか変更されています。

Python V2 と同じように Node.js V4 でも Cosmos DB Extension v3 と v4 をユーザー側で選べるようになっていますが、実装が Union 型を使っているためプロパティ名によって型推論の結果が変化し、Cosmos DB Extension v3 と v4 が切り替わるようになっています。

入力補完の結果としては最初は同じようなプロパティが出てきて混乱しますが、最初に connectionconnectionStringSetting を指定するようにすると分かりやすくなります。具体的には前者を使用すると v4 になり後者を使用すると v3 になります。

この辺りの型宣言は以下のファイルで定義されています。この辺りは TypeScript の機能を使って上手く実装した感がありますが、評価が分かれそうな予感です。

こちらも Collection が Container に変更されているので、利用する際にはプロパティ名を間違えないように注意してください。TypeScript ではチェックが走るはずですが JavaScript では素通りになるはずです。

Cosmos DB Extension v4 への Python V2 / Node.js V4 側での対応は以上になりますが、v3 と v4 の切り替えには Azure Functions の Extension Bundle も深く関わっています。Function 実装側で Cosmos DB Extension v4 を使うようにしても、Extension Bundle が古いままでは実行時エラーとなり動作しません。v4 を使う際には Extension Bundle のバージョンも v4 以上に更新する必要があります。

{
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[4.*, 5.0.0)"
  }
}

一部の言語向けではテンプレートから作成すると Extension Bundle v3 が使われる設定になっているため、Cosmos DB Extension v4 向けの Function を追加すると実行時エラーとなります。

Extension Bundle に含まれている拡張機能のバージョンは GitHub のリリースから確認出来ます。Cosmos DB Extension v3 と v4 が共存できないため Extension Bundle のバージョンで切り替えを行う必要があります。

C# で開発している場合には意識することは無いので忘れがちですが、Python や Node.js では Extension Bundle がかなり重要なのでバージョンには気を付ける必要があります。

Static Web Apps の Managed Functions 上での利用について

通常の Azure Functions であれば Consumption Plan であっても、設定だけ追加してしまえば問題なく Python V2 / Node.js V4 で実装したアプリケーションをデプロイして実行できますが、Static Web Apps に付属している Managed Functions では AzureWebJobs から始まるキーの設定は禁止されているので、これまでは以下の Issue にもあるようにどう頑張っても実行できませんでした。

しかし前述した通り、Azure Function Runtime 側でデフォルト有効になるように変更されたため、特定のバージョン以降がデプロイされたリージョンでは Python V2 / Node.js V4 の実行をサポートしています。

確認した限りでは East Asia では 4.28.3.21820 がデプロイされているので、以下のような Node.js V4 を使った Function を Managed Functions として実行出来ます。

実際に上の Function をデプロイしてみましたが、以下のように問題なくデプロイが行えて Azure Portal からも認識されています。Azure Functions Runtime のバージョンが古かった時はデプロイは出来ても実行は出来ず、Azure Portal からもデプロイした Function が認識されないという状態でした。

全リージョンに展開されているのかは確認していませんが、恐らく一部のリージョンでは古いバージョンが残っていると思われるので注意は必要ですが、よく使われるであろう East Asia では問題なく使えそうです。

Azure OpenAI Service の GPT-4 Turbo with Vision と Azure AI Service Enhancement を試した

OpenAI 本家の提供から数週間遅れましたが、Azure OpenAI Service でも GPT-4 Turbo with Vision が一部リージョンで利用できるようになりました。本家とは異なり Azure AI Service Enhancement という Azure OpenAI 専用の機能も同時にリリースされています。

詳細は後述しますが、Azure AI Service Enhancement は現時点では若干挙動がおかしい部分があるので、利用には少し注意が必要になっています。

現時点では Japan East で利用できないため West US で利用するケースが多いと思います。レイテンシ的には West US の方が Australia East より有利な可能性があり、実際にネットワークのレイテンシは West US の方が Australia East より小さいです。

GPT-4 Turbo with Vision は GPT-4 Turbo が持っている機能全てがサポートされているわけではないので、ユースケースによっては追加のプロンプトを書く必要も出てきます。

実際に Azure OpenAI Service で GPT-4 Turbo with Vision を使う方法は以下のドキュメントに全てまとまっています。画像の入力方法は HTTP/HTTPS 指定あるいは Data URL を利用した Base64 での指定が必要です。

モデルのデプロイは vision-preview バージョンを指定して gpt-4 をデプロイします。vision-preview は通常のバージョンと独立してデプロイできるようになっています。

GPT-4 Turbo with Vision と Azure AI Service Enhancement は Azure OpenAI Studio を使うとサクッと試すことが出来ますが、最終的にはアプリケーションに組み込むので SDK から利用してみます。

C# SDK から GPT-4 Turbo with Vision を利用する

最新の Azure OpenAI SDK for .NET では GPT-4 Turbo with Vision の早期サポートが追加されているため、以下のパッケージをインストールあるいは更新すると利用できます。使い方もこれまでとほぼ同じなので経験があれば悩むことはないでしょう。

サンプルコードを載せておきますが、基本的にはテキストベースの時と変わっていません。テキストの代わりに画像の URL を指定する点が唯一の違いとも言えます。

using Azure;
using Azure.AI.OpenAI;

var openAIClient = new OpenAIClient(new Uri("https://****.openai.azure.com/"), new AzureKeyCredential("****"));

var response = await openAIClient.GetChatCompletionsAsync(new ChatCompletionsOptions
{
    DeploymentName = "gpt-4v",
    Messages =
    {
        new ChatRequestSystemMessage("You are an AI assistant that helps people find information."),
        new ChatRequestUserMessage(
            new ChatMessageTextContentItem("この画像を日本語で説明してください。"),
            new ChatMessageImageContentItem(new Uri("https://***.blob.core.windows.net/kazuakix.jpg")))
    },
    MaxTokens = 1024
});

Console.WriteLine(response.Value.Choices[0].Message.Content);

当然ながら URL で指定した画像はパブリックからのアクセスが可能である必要があります。

このコードを実行すると、指定した URL の画像を利用して結果が生成されます。非常に高い精度で画像の説明が生成されていることに驚きますね。

先ほどのサンプルではパブリックアクセス可能な Blob を用意して URL を指定しましたが、 実際の要件としては画像を外部からアクセス可能な状態にはしたくないケースの方が多いはずです。一応 SAS 付きの Blob URL を指定することでアクセス制限された Blob を使って応答を生成させる事は出来ましたが、ネットワーク的な制限がかかっている場合には対応出来ません。

そのようなケースでは Base64 化した画像をリクエストに含めて送信するのが解決策となりそうです。Data URL を生成する便利メソッドは用意されていないので、手動で組み立てる形になります。

using Azure;
using Azure.AI.OpenAI;

Uri ToDataUrl(string filePath, string contentType) => new($"data:{contentType};base64,{Convert.ToBase64String(File.ReadAllBytes(filePath))}");

var openAIClient = new OpenAIClient(new Uri("https://****.openai.azure.com/"), new AzureKeyCredential("****"));

var response = await openAIClient.GetChatCompletionsAsync(new ChatCompletionsOptions
{
    DeploymentName = "gpt-4v",
    Messages =
    {
        new ChatRequestSystemMessage("You are an AI assistant that helps people find information."),
        new ChatRequestUserMessage(
            new ChatMessageTextContentItem("この画像を日本語で説明してください。"),
            new ChatMessageImageContentItem(ToDataUrl(@".\kazuakix.jpg", "image/jpeg")))
    },
    MaxTokens = 1024
});

Console.WriteLine(response.Value.Choices[0].Message.Content);

Uri クラスで扱う URL は 65519 バイト以下である必要があるので、大きな画像は Uri クラスを利用している限り扱えません。この辺りは C# SDK の制約という感じです。

OpenAI としては長い会話の場合は URL を指定するように推奨しているのと、アップロード可能な POST サイズには制限がありそうなので、可能な限り URL で渡した方が効率的だと考えられます。

Vision Enhancement (OCR / Grounding) を利用する

基本的な GPT-4 Turbo with Vision の利用が出来たので、次は Azure AI Service Enhancement を試していきます。多少名称がブレている気がしますが、Azure AI Service Enhancement は Vision Enhancement とも呼ばれているようなので、どちらも正解だと思っています。

GPT-4 Turbo with Vision は画像内のテキストも割と認識してくれるので意外かもしれませんが、基本的にテキストを認識する機能は持ち合わせていないようです。特に日本語などは苦手としているので、その部分を Azure AI Service で補うのが Vision Enhancement となります。

今回はサンプルとして Azure OpenAI Service のモデル一覧のテーブルを Markdown のテーブルに変換する処理をやらせてみます。まずは Azure OpenAI Studio 上で Vision Enhancement を無効化した状態で試してみると、以下のように画像の解析は出来ないと断られてしまいました。

何回か試すと頑張ってくれるケースもあるのですが、実利用するには不安定なので難しいです。このあたりの挙動を Azure AI Vision (Azure Computer Vision) を利用することで改善可能です。

実際に画像に対して Vision Enhancement を有効化するサンプルコードは以下のようになります。少し OpenAI とは関係のない余計な実装が付いているのは後述します。

using Azure;
using Azure.AI.OpenAI;

using Mono.Reflection;

Uri ToDataUrl(string filePath, string contentType) => new($"data:{contentType};base64,{Convert.ToBase64String(File.ReadAllBytes(filePath))}");

AzureChatExtensionsOptions CreateExtensionsOptions(AzureChatEnhancementConfiguration enhancementConfiguration)
{
    var extensionOptions = new AzureChatExtensionsOptions();

    var property = typeof(AzureChatExtensionsOptions).GetProperty(nameof(AzureChatExtensionsOptions.EnhancementOptions));
    var field = property.GetBackingField();

    field.SetValue(extensionOptions, enhancementConfiguration);

    return extensionOptions;
}

var openAIClient = new OpenAIClient(new Uri("https://****.openai.azure.com/"), new AzureKeyCredential("****"));

var options = new ChatCompletionsOptions
{
    DeploymentName = "gpt-4v",
    AzureExtensionsOptions = CreateExtensionsOptions(new AzureChatEnhancementConfiguration
    {
        Grounding = new AzureChatGroundingEnhancementConfiguration(true),
        Ocr = new AzureChatOCREnhancementConfiguration(true)
    }),
    Messages =
    {
        new ChatRequestSystemMessage("You are an AI assistant that helps people find information."),
        new ChatRequestUserMessage(
            new ChatMessageTextContentItem("この画像に含まれているテーブルを Markdown に変換してください。"),
            new ChatMessageImageContentItem(ToDataUrl(@"***.png", "image/png")))
    },
    MaxTokens = 1024
};

var response = await openAIClient.GetChatCompletionsAsync(options);

Console.WriteLine(response.Value.Choices[0].Message.Content);

先程のコードとの違いは AzureExtensionsOptions を追加している部分だけです。余計なコードが付いているのは、現状の Azure OpenAI SDK に不具合があるため、その部分をリフレクションで無理やり回避しているためです。Issue は作成済みなので次のバージョンで直ることを期待しています。

サンプルコードの実行結果は以下となりますが、ちゃんとテキストを認識して Markdown として出力されていることが確認できます。テーブル構造の認識に失敗していますが、結合されたセルの扱いは苦手のようです。

次は日本語も含んだ大きなテーブルを試してみます。例として App Service の料金テーブルを画像として保存して、同じように Markdown に変換させてみた結果が以下の通りです。

問題なく日本語も認識していますが、空のセルがある場合に正しく認識されていないことが分かります。Azure AI Vision によって OCR された結果が悪いのか、OpenAI に渡すタイミングで構造が失われているのかは分かっていませんが、配置などの情報は渡されていない予感です。

ここまで Vision Enhancement を試してきましたが、実は Azure AI Vision の利用に必要な設定を追加していないことに気が付かれた方もいるかもしません。

Azure OpenAI Studio 上ではトグルスイッチをオンにして、Azure OpenAI Service と同じリージョンにデプロイした Azure AI Vision を指定していたように、SDK から利用する場合も同じ手順が必要になるはずですが、現状は指定せずとも動作しています。

ドキュメント上も Azure AI Vision が必要と書かれていますが、未指定で OCR などの設定を有効化するだけで動作しています。Azure OpenAI Service だけでは不可能な Grounding も動作していることを確認しましたので、Azure AI Vison が裏側では使われているようです。

To use Vision enhancement, you need a Computer Vision resource, and it must be in the same Azure region as your GPT-4 Turbo with Vision resource.

How to use the GPT-4 Turbo with Vision model - Azure OpenAI Service | Microsoft Learn

逆に Azure AI Vision を利用する設定を追加して、リクエストを投げてみましたがメトリックを見る限り呼び出されている形跡がありませんでした。ドキュメントとは異なる挙動なので不具合なのだと思いますが、正直なところ別リソース用意するのではなく、今のように内部でよしなにやっていてほしいです。

特にネットワーク制限が必要な環境の場合は、Azure OpenAI Service から Azure AI Vision へのアクセスが問題となるケースも発生するため、内部で閉じておいて貰った方が扱いやすくなります。

REST API を直接利用する際の注意点

通常の GPT-4 Turbo with Vision を利用する際にはこれまで通りの REST API エンドポイントで問題なく動作するのですが、Azure AI Service Enhancement を利用する際にはエンドポイントが変わっているので、間違うと常にバリデーションエラーが出ることになります。

ドキュメントのエンドポイントを注意深く見るとパスに extensions が追加されたものになっています。

Azure AI Service Enhancement 無し
https://{RESOURCE_NAME}.openai.azure.com/openai/deployments/{DEPLOYMENT_NAME}/chat/completions?api-version=2023-12-01-preview

Azure AI Service Enhancement 有り
https://{RESOURCE_NAME}.openai.azure.com/openai/deployments/{DEPLOYMENT_NAME}/extensions/chat/completions?api-version=2023-12-01-preview

2 つのエンドポイントがあることはドキュメントからは読み取りにくいため、バリデーションエラーが出た場合はエンドポイントのミスを疑ってみてください。

Azure App Service の OS が Windows Server 2022 にアップデートされ始めているので確認した

先日の Ignite 2023 では App Service の TLS 1.3 対応が発表されて、一部のリージョンでは既に有効化されていることを以下のエントリで触れましたが、TLS 1.3 対応と同時に App Service の OS が Windows Server 2022 へとアップデートされ始めています。

App Service のマルチテナント版だけではなく、App Service Environment v3 でも TLS 1.3 対応がデプロイされるようです。流石にメンテナンス通知は来ると信じています。

先日の GitHub Universe 2023 と Microsoft Ignite 2023 のキャッチアップイベントでも少し話しましたので、興味があれば以下のアーカイブを参照してください。

6 年振りに App Service の Windows Server がアップデートされるので、Windows Server 2016 へのアップデートが行われた時のエントリを拾ってきました。2016 へのアップデートは HTTP/2 の有効化が目玉と言えますが、今回の 2022 では TLS 1.3 の有効化が目玉だと言えます。

HTTP/2 は明示的に有効化しない限り、自動的に有効にはなりませんでしたが、TLS 1.3 についてはクライアント側が対応していれば自動的に使われるようです。設定としては最低の TLS バージョンしか用意されないので、TLS 1.3 の無効化は出来ないと考えています。

今回のアップデートも完全にインプレースかつ Scale unit 単位で段階的にで実行されるようなので、気が付いたら稼働中の App Service の OS が 2022 になっているはずです。このあたりのアップデートについてはドキュメントが少しだけあるので、興味があれば参照してください。

ちなみに既に Web App for Container (Windows) は Windows Server 2022 にアップデートされていました。しかし Frontend や Web Worker は Windows Server 2016 のままだったので、その部分が今回アップデートされようとしているようです。

App Service の Frontend は Windows / Linux / Container で全て共通なので、今回のアップデートで App Service for Linux などでも TLS 1.3 が自動的に有効になるはずです。Windows を使っていなくても影響のある部分なので少し気を付けたいです。

意外に App Service が使われているサービスは多いので影響範囲は広そうです。Azure Functions や Logic Apps で使われていることは広く知られていると思いますが、API Management の Consumption や Basic v2 / Standard v2 でも App Service が使われているので、自動的に TLS 1.3 が有効化されると考えています。

互換性への影響

インプレースでアップデートされるので、既に App Service で本番環境を運用している場合には互換性が心配だと思いますが、結論から言うとほぼ気にする必要はないと考えています。Windows Server 2019 / 2022 でのアップデート内容は以下のドキュメントで公開されていますが、App Service で使われている機能に対しての大きな変更などはありません。

互換性に一番影響しそうな .NET Framework のバージョンについても、これまで通りの 4.8 となっていましたのでアップデートされたことに気が付かないまま Windows Server 2022 になっていそうです。

但し Windows Server 2022 は .NET Framework 4.8.1 のインストールに対応しているため、将来的に .NET Framework 4.8.1 にアップデートされる可能性はあると考えています。とはいえ互換性に影響しそうなアップデートはないので、ほぼ気にする必要はなさそうです。

正直なところ一番互換性に影響しそうなのは、TLS 1.3 が自動的に有効化される点でしょう。通常の環境ではまず影響しないはずですが、謎のプロキシがある場合には TLS 1.3 が通らないというケースもありそうです。

以下は Free プランで動かしている App Service ですが、TLS 1.3 が有効化されていることを確認しました。

何も設定をしていなくても、App Service の Frontend がアップデートされたタイミングで TLS 1.3 が有効になるのは、頭の片隅にでも入れておいた方が良さそうです。

クライアント証明書を有効化している場合、TLS 1.3 では利用できない機能があるらしく TLS 1.2 になるという話が公式ブログで紹介されていました。利用している場合には早めに検証しておいた方が良さそうです。

現在の Windows Server バージョンを確認

最後に App Service の Windows Server バージョンを確認する方法を何種類か紹介しておきます。一番簡単な方法は Azure Portal の Console から ver コマンドを実行する方法です。

定番の方法としては Kudu の Environment にある System info から OS Version を確認するというものもあります。この表示は C# の Environment.OSVersion を使っているので若干大雑把な表示です。

最後は Kudu が提供する Runtime API を利用する方法です。呼び出すと以下のように OS バージョンが細かいレベルまで返ってきますが、何故か OS 名は Windows Server 2016 のままになるので、ビルド番号を確認するのが正確な方法になります。

6 年振りの Windows Server バージョンのアップデートなので経験したことが無い方も多いと思いますが、基本は内部のテスト用環境から段階的にロールアウトされていくので、OS バージョンに依存した処理を書いていない限りは問題ないので安心してください。

日本リージョンにデプロイされるのは恐らく来年になると思うので、年内は US リージョンでテストされて問題があらかた潰れたタイミングでアップデートされることになるでしょう。

Ignite 2023 で発表された Azure Cosmos DB のアップデート

先日シアトルで開催された Microsoft Ignite 2023 では Azure Cosmos DB のアップデートも Build 2023 程ではありませんが多く発表されました。例によって AI を意識したものが大きく扱われていますが、スケーラビリティの改善に繋がる機能もしっかり追加されています。

発表については公式ブログでまとまっているので、基本はこちらを参照すれば問題ないです。

ちなみに Build 2023 で発表された Cosmos DB のアップデートは以下の通りなので、興味があれば参照してください。Build 2023 で Private Preview だった機能が Ignite 2023 で Public Preview になったものもあります。

例によって個人的に気になっている機能について簡単にまとめます。一部の機能については詳細な検証が必要だと感じたら、別途検証結果をブログに書く予定です。

MongoDB vCore と Vector Search が GA

昨今の AI ブームと共に到来した Vector Search ブームに乗って、Cosmos DB の MongoDB vCore と Vector Search が GA しました。これまで MongoDB vCore がプレビューだったことを認識していませんでした。

MongoDB vCore の Vector Search については紹介記事が多いはずなので特に深堀りはしませんが、個人的には Ignite のセッションで発表された NoSQL API での Vector Search サポートの方を注目しています。

恐らくは Function として実装されると考えていますが、この辺りは Preview が始まるのを待ちたいと思います。少し不安なのは 1536 次元の配列を読み書きするのに必要な RU が、思っているよりも大きくなりそうな点です。Cosmos DB の仕組み上は Native 実装が追加されても RU が減ることはないはずなので、コスト面は注意したいと考えています。

Dynamic Scaling Per Region and Per Partition が Public Preview

今回のアップデートの中で個人的に一番注目しているのがこの Dynamic Scaling Per Region and Per Partition です。基本は Autoscale を有効化しているコンテナーに対しての機能で、マルチリージョンや 50GB 以上のコンテナーを運用している場合には有効化するだけで、全体的なコスト最適化が期待できます。

マルチリージョンで Write と Read を分けているケースは分かりやすいのですが、物理パーティションが 2 つ以上存在するケースでは理解が難しいかも知れません。具体的な例はドキュメントに記載されているので、基本はこちらを読み込んでおけば問題ありません。

Cosmos DB は設定した最大 RU は物理パーティションの数だけ均等に分割され、一番多く RU を消費したパーティションの値を使って全体で消費した RU の計算が行われますが、Dynamic Scaling が有効な場合は物理パーティション毎の最大値を使った集計が行われるという仕組みです。従って物理パーティションが常に全て同じ RU を消費しているというケース以外では確実にコストダウンが見込めます。

Autoscale を利用している場合には確実に有効化しておきたい機能ではありますが、現時点では 11/15 以降に作成した Cosmos DB アカウントでのみ有効化が可能です。あくまでも Preview 中の制約だと考えていますので、GA 時には既存のアカウントでも有効化出来るようになると思います。

Cosmos DB を活用する上で RU からは逃げられないため、Autoscale と Burst Capacity そして今回の Dynamic Scaling を活用してコストを最適化しつつパフォーマンスとスケーラビリティを高めましょう。

Priority-based execution が Public Preview

Priority-based execution は Build 2023 で発表された時は Private Preview 扱いで、有効化するためにチームとのやり取りが必要だったのですが Ignite 2023 のタイミングで Public Preview に移行しました。

Cosmos DB では設定した RU 以上を必要とすると 429 を返してリトライという流れになりますが、Priority-based execution を使うとリクエスト単位で優先度を指定できるので、ユーザー操作に紐づくリクエスト以外は優先度を下げることで、応答性を改善するといった実装が可能になります。

デフォルトでは全てのリクエストは優先度 High に設定されていますが、このデフォルト値は Azure CLI などを使うと変更可能なので、デフォルトを Low にしつつ必要なリクエストだけ High にするというのも手です。

利用するには最新のプレビュー版 SDK のインストールが必要になるので、Azure Functions の Cosmos DB 拡張などを使っている場合は利用できません。

この機能は既存のアカウントでも有効化出来るようなので、比較的気軽に試せるものとなっています。但し Serverless には対応していないので Provisioned Throughput で作る必要があります。

Priority-based execution を有効化すると、Azure Portal にある Data Explorer からの操作がデフォルトで Low になるのが地味に便利そうです。本番向けに調査用のクエリを投げたい場合には、Data Explorer のせいでアプリ側で 429 が多発することがありました。

設定で変更できますが、基本は Low のままで問題ないはずです。正式版がリリースされた際には Azure Functions の CosmosDB Trigger を Low にして実行させる検証を行いたいと思っています。Change Feed は Checkpoint を持っているため 429 が発生した際のリトライが行いやすく、処理内容としても Priority-based execution と相性が良いはずです。

Cross-account container copy が Public Preview

これまで同一アカウント内でのコンテナーのコピーは Preview で公開されていましたが、アカウント間でのコピーも Public Preview となりました。これまで Data Factory を使うことが多かった Cosmos DB のコピーですが、Azure CLI だけで完結するようになりました。

Azure Portal からもコピー出来るようになって欲しいですが、同一アカウント内であってもサポートされていないため時間がかかりそうです。少し権限周りの割り当てが手間になりそうですが、Change Feed を使ってコピーするため安心感があります。

Change Feed を使って読み取りを行うということは、アプリケーションが稼働していて書き込みが発生するとデータの一貫性が損なわれる可能性があるということなので、オフラインにしてから処理を行う必要がある点には注意しておきたいですね。

Microsoft Copilot for Azure in Azure Cosmos DB

Ignite では Microsoft Copilot for Azure が発表されましたが、Cosmos DB についても Data Explorer 上でのクエリ作成を支援してくれる Copilot が追加されました。名前が異常に長いのが気になります。

Cosmos DB の SQL は T-SQL とも異なっているので多少癖がありますが、その辺りを上手く Copilot で書いてくれると楽は出来そうな気がします。しかし最近はクエリ作成に Copilot が必要となるようなデータ構造を設計しないので、個人的にはあまり使わない予感です。