YarpでAzure Open AIの負荷分散用Reverse Proxyを作る

# なぜAzure Open AIはリバースプロキシが必要なのか
## 1. 冗長性
デプロイの種類がグローバルだろうと、データゾーンだろうと、可用性的には冗長化はされない。そのため自分で複数インスタンスをデプロイしてフェールオーバーを組む必要がある。

https://learn.microsoft.com/ja-jp/azure/architecture/ai-ml/guide/azure-openai-gateway-guide

> AOAIではサービスの可用性に基づく複数の Azure OpenAI インスタンス間のフェールオーバーは、構成とカスタム ロジックを使用して制御する必要があるクライアントの責任です。
> グローバル、標準またはプロビジョニング済み、および データ ゾーン(標準またはプロビジョニング) は、リージョンエンドポイントの可用性の観点から Azure OpenAI サービスの可用性に影響しません。 フェールオーバー ロジックを自分で実装する必要があります。

https://learn.microsoft.com/ja-jp/azure/well-architected/service-guides/azure-openai

> 冗長性: Azure OpenAI デプロイの前に適切なゲートウェイを追加します。 このゲートウェイには、一時的な障害に耐え、複数の Azure OpenAI インスタンスへのルーティングも行うスロットリングなどの機能を持たせる必要があります。 リージョンの冗長性を構築するには、異なるリージョン内のインスタンスへのルーティングを検討してください。

## 2. スケーリング
AOAIではスケーリングも自動では行われず、かつリクエスト数（RPM）やトークン数（TPM）のクォータ上限はテナント毎に決定される。複数デプロイメントを作成しても、上限はテナントで決まる。そのため、上限を超えて利用する場合、複数テナントで分けてAOAIをデプロイする必要がある。

https://learn.microsoft.com/ja-jp/azure/ai-services/openai/quotas-limits?tabs=REST

>  顧客の使用量はモデルごとに定義され、この量は、特定のテナントのすべてのリージョンのすべてのサブスクリプションのすべてのデプロイで使用されるトークンの合計です。

→と思ったら自動スケーリング機能がプレビューですが提供されてた。

https://learn.microsoft.com/ja-jp/azure/ai-services/openai/how-to/dynamic-quota

## 3. ヘルスチェック
AOAIにはヘルスチェックエンドポイントがない。ということは通信先のAOAIが生きているかどうかを確実に知るためには、実際に通信を行う必要があるということだ。

こちらの記事を書いているときに以下のようなブログ記事を見つけたが、Microsoftサポートに確認したところ、このAOAIのヘルスチェックのためにAPI Managementの/status-0123456789abcdefからのレスポンスを見るというのは推奨されないらしい。公式に提供されているものではないため、いつでも変わりうるという回答だった。かつ実際はAOAIで障害が発生していても、APIMが生きていればOKとなってしまうため、正直使えない。

https://techcommunity.microsoft.com/blog/azure-ai-services-blog/azure-openai-architecture-patterns-and-implementation-steps/3979934

なので、以下の記事にあるようなApplication GatewayやFrontdoorでの単純なヘルスチェックは本番環境では推奨されないようだ。

https://qiita.com/Isato-Hiyama/items/237cb5b0b420d4239a77

## Microsoftの公式ソリューションはAPIMだが、、、
さて、では以上を踏まえたうえでMicrosoftの推奨ソリューションは何かというと、以下の二種類になる。

https://learn.microsoft.com/ja-jp/azure/architecture/ai-ml/guide/azure-openai-gateway-multi-backend#introduce-a-gateway-for-multiple-instances-in-multiple-regions

1. API Management
1. カスタムアプリによるリバースプロキシ

基本API Managementだし世の中の資料でもそれがよく取り上げられているのだが、AOAIの負荷分散だけを目的に使うとなると、高機能だし高価すぎると感じる。API ManagementはPremiumでないと可用性ゾーンがない。せっかくAOAIをリージョンレベルで冗長化したのに、Gatewayが落ちたら元も子もない。

https://learn.microsoft.com/ja-jp/azure/api-management/api-management-features

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/654538/49d61a97-63c8-44a3-a3ac-c02c4845a743.png)

だがAPIMで可用性ゾーンをサポートしようと思うと月4000ドルを軽く超えてしまう。やってられないのである。（正直APIMは高すぎて自己学習すら躊躇する。。。）

https://azure.microsoft.com/ja-jp/pricing/details/api-management/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/654538/fedd0df7-4113-451c-8fb7-f33108282663.png)

そういう人にYarpはちょうど隙間を埋めてくれるソリューションになりえる。

# Yarpとは
YarpとはYet Another Reverse Proxyの略。もともとMicrosoft内部で複数のプロジェクトで毎回リバプロを作っていたが、それなら汎用的に使えるものをとMicrosoftが作ったのがきっかけ。基本はASP.Net Coreで動いているプロジェクトにライブラリという形で追加される。

公式サイトはこちら。

https://learn.microsoft.com/ja-jp/aspnet/core/fundamentals/servers/yarp/getting-started?view=aspnetcore-9.0

https://learn.microsoft.com/ja-jp/shows/on-dotnet/yarp-the-net-reverse-proxy

Layer 7プロキシで、以下のような機能がデフォルトで提供されている。
- 認証
- パスルーティング
- TLSターミネーション
- キャッシング
- セッションアフィニティ
- ヘルスチェック
- レート制限

これによってバックエンド側で個別に処理を実装するよりリバースプロキシで処理を一元化して、バックエンドの処理を省略できるというBackend For Frontend的な利点が生まれる。

もちろんASP.Netベースなのでカスタム処理もミドルウェアという形で追加できる。

## 基本
まずはYarpの使い勝手を見てみよう。
簡単なルーティングだけをしたい場合、Program.csではAddReverseProxy()とapp.MapReverseProxy()を実行して、プロキシの設定を読み込む。
```Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

app.MapReverseProxy();

app.Run();
```
ルーティングの設定自体はappsettings.jsonで行う。以下の例では、*/somethingとそれ以外のパターンでパスベースルーティングをしている。設定ファイルだけで可能な項目もかなり豊富なので、シンプルなプロキシやロードバランサであれば、これだけで事足りるかもしれない。

https://learn.microsoft.com/ja-jp/aspnet/core/fundamentals/servers/yarp/config-files?view=aspnetcore-9.0#all-config-properties

```appsettings.json
  "ReverseProxy": {
    "Routes": {
      "minimumroute": {
        "ClusterId": "minimumcluster",
        "Match": {
          "Path": "{**catch-all}"
        }
      },
      "route2": {
        "ClusterId": "cluster2",
        "Match": {
          "Path": "/something/{*any}"
        }
      }
    },
    "Clusters": {
      "minimumcluster": {
        "Destinations": {
          "example.com": {
            "Address": "http://www.example.com/"
          }
        }
      },
      "cluster2": {
        "Destinations": {
          "first_destination": {
            "Address": "https://contoso.com"
          },
          "another_destination": {
            "Address": "https://bing.com"
          }
        },
        "LoadBalancingPolicy": "PowerOfTwoChoices"
      }
    }
  }
```

## コードでルーティング

これと同じことはもちろんコードでも実装することも可能。

https://github.com/dotnet/yarp/blob/main/samples/ReverseProxy.Code.Sample/Program.cs

```Program.cs
builder.Services.AddReverseProxy()
    .LoadFromMemory(GetRoutes(), GetClusters());

app.MapReverseProxy();

RouteConfig[] GetRoutes()
{
    return
    [
        new RouteConfig()
        {
            RouteId = "route1",
            ClusterId = "cluster1",
            Match = new RouteMatch
            {
                Path = "{**catch-all}"
            }
        }
    ];
}

ClusterConfig[] GetClusters()
{
    return
    [
        new ClusterConfig()
        {
            ClusterId = "cluster1",
            Destinations = new Dictionary<string, DestinationConfig>(StringComparer.OrdinalIgnoreCase)
            {
                { "destination1", new DestinationConfig() { Address = "https://example.com" },
                { "destination2", new DestinationConfig() { Address = "https://example2.com" }
            },
            LoadBalancingPolicy: "PowerOfTwoChoices"
        }
    ];
}
```

# YarpでスマートAOAIロードバランシング
なんとなく使い勝手が分かったところで、これをどうAOAIのロードバランシングに使うかであるが、実はMicrosoftはYarpを利用してAOAI用にロードバランシングするコードをサンプルとしてすでに提供している。

https://github.com/Azure-Samples/openai-aca-lb

## どのように実装されているのか
正直ReadMeがとても丁寧に書かれているので、それを読めば大体の挙動はつかめるのだが、せっかくなので実装を少し見てみる。（一部コードは省略してます。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/654538/9c915826-9485-41e3-946b-2129b9637df6.png)

まずセットアップを見てみると主に以下のような機能が提供されているのがわかる。
- ヘルスチェック
- リトライ処理
- TransformRequest（リクエスト時のカスタム処理）
```Program.cs
public static void Main(string[] args)
{
    var builder = WebApplication.CreateBuilder(args);

    var backendConfiguration = BackendConfig.LoadConfig(builder.Configuration);
    var yarpConfiguration = new YarpConfiguration(backendConfiguration);
    builder.Services.AddSingleton<IPassiveHealthCheckPolicy, ThrottlingHealthPolicy>();
    builder.Services.AddReverseProxy().AddTransforms(m =>
    {
        m.AddRequestTransform(yarpConfiguration.TransformRequest());
        m.AddResponseTransform(yarpConfiguration.TransformResponse());
    }).LoadFromMemory(yarpConfiguration.GetRoutes(), yarpConfiguration.GetClusters());

    builder.Services.AddHealthChecks();
    var app = builder.Build();

    app.MapHealthChecks("/healthz");
    app.MapReverseProxy(m =>
    {
        m.UseMiddleware<RetryMiddleware>(backendConfiguration);
        m.UsePassiveHealthChecks();
    });

    app.Run();
}
```

設定ファイルには環境変数としてAOAIの宛先等が定義できる。
```launchSettings.json
{
  "profiles": {
    "https": {
      "environmentVariables": {
        "BACKEND_1_URL": "https://andre-openai-eastus.openai.azure.com",
        "BACKEND_1_PRIORITY": "1",
        "BACKEND_1_APIKEY": "your-api-key",
        "BACKEND_2_URL": "https://andre-openai-eastus-2.openai.azure.com",
        "BACKEND_2_PRIORITY": "1",
        "BACKEND_2_APIKEY": "your-api-key",
      },
    }
  },
}
```

### ヘルスチェック

https://learn.microsoft.com/ja-jp/aspnet/core/fundamentals/servers/yarp/dests-health-checks?view=aspnetcore-8.0

YarpにはActiveHealthCheckとPassiveHealthCheckの2種類が提供されている。
- Active
定期的にロードバランサからPingのようなものを打って生存確認をする。ヘルスチェックといわれたら普通に思い浮かべるやつ。

- Passive
今回使われているのはこっち。実際に顧客からのAOAIへリクエストが行われた段階で、もし通信が失敗したら宛先を"Unhealthy"としてタグ付けし一定時間振り分け対象から除外する。一定時間が経過したらまた振り分け対象に戻るようになる。
コードを見てみると、レスポンスコードが429（TooManyRequests）、もしくは5XXだった場合に該当AOAIをUnhealtyとして設定し、10秒間振り分け対象から除外している。

```YarpConfiguration.cs
public class ThrottlingHealthPolicy : IPassiveHealthCheckPolicy
{
    public static string ThrottlingPolicyName = "ThrottlingPolicy";
    private readonly IDestinationHealthUpdater _healthUpdater;

    public ThrottlingHealthPolicy(IDestinationHealthUpdater healthUpdater)
    {
        _healthUpdater = healthUpdater;
    }

    public string Name => ThrottlingPolicyName;

    public void RequestProxied(HttpContext context, ClusterState cluster, DestinationState destination)
    {
        var headers = context.Response.Headers;
        if (context.Response.StatusCode is 429 or >= 500)
        {
            var retryAfterSeconds = 10;
            _healthUpdater.SetPassive(cluster, destination, DestinationHealth.Unhealthy, TimeSpan.FromSeconds(retryAfterSeconds));
        }
    }
}
```

### リトライ処理
通信が失敗したらPassiveHealthCheckにより振り分け対象から該当のAOAIが除外される。そのうえでまだ生存している（実際には通信が行われるまでそのAOAIが生きているかわからないので"Unknown"と呼ばれているが）AOAIがある場合はリトライ処理を行う。

ちなみに全部のAOAIが全てダウンしている場合はとりあえずランダムに一つ選んで通信をするという感じになっているっぽい。

```RetryMiddleware.cs
public class RetryMiddleware
{
    public async Task InvokeAsync(HttpContext context)
    {
        context.Request.EnableBuffering();

        var shouldRetry = true;
        var retryCount = 0;

        while (shouldRetry)
        {
            var reverseProxyFeature = context.GetReverseProxyFeature();
            var destination = PickOneDestination(context);

            reverseProxyFeature.AvailableDestinations = new List<DestinationState>(destination);

            await _next(context);

            var statusCode = context.Response.StatusCode;
            var atLeastOneBackendHealthy = GetNumberHealthyEndpoints(context) > 0;
            retryCount++;

            shouldRetry = (statusCode is 429 or >= 500) && atLeastOneBackendHealthy;
        }
    }
}
```

### RequestTransform（認証）
ここではAOAIへの認証をしている。もし設定ファイルにAPIキーが定義されている場合はそれを利用するし、含まれていない場合はYarp側でManagedIDを取得して、AOAIにアクセスするようになっている。
```YarpConfiguration.cs
    internal Func<RequestTransformContext, ValueTask> TransformRequest()
    {
        return async context =>
        {
            var proxyHeaders = context.ProxyRequest.Headers;
            var reverseProxyFeature = context.HttpContext.GetReverseProxyFeature();

            var backendConfig = backends[reverseProxyFeature.AvailableDestinations[0].DestinationId];

            if (!string.IsNullOrEmpty(backendConfig.ApiKey))
            {
                proxyHeaders.Remove("api-key");
                proxyHeaders.Add("api-key", backendConfig.ApiKey);
            }
            else
            {
                AccessToken accessToken = await new DefaultAzureCredential().GetTokenAsync(new TokenRequestContext(scopes: ["https://cognitiveservices.azure.com/.default"]));
                proxyHeaders.Remove("Authorization");
                proxyHeaders.Add("Authorization", "Bearer " + accessToken.Token);
            }
        };
    }
```

ちなみに通信元が別のApp Serviceなどで、リバースプロキシにManaged IDのトークンが渡すようになっている場合は、elseの部分をコメントアウトすればそのままAOAIにManaged IDトークンを渡せるようになる。

## 挙動の確認
まずは設定ファイルをテスト用の宛先に振ってみる。
```
{
  "profiles": {
    "https": {
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "BACKEND_1_URL": "https://aoai-dev-sima001.openai.azure.com/",
        "BACKEND_1_PRIORITY": "1",
        "BACKEND_1_APIKEY": "key",
        "BACKEND_2_URL": "https://oai-sima01.openai.azure.com/",
        "BACKEND_2_PRIORITY": "1",
        "BACKEND_2_APIKEY": "key"
      },
    }
  },
}
```

Visual Studioからローカル実行して、そこに対して通信してみると、うまく応答が返ってきている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/654538/841cd4c8-516c-421f-b6d2-3cd2be346871.png)

ログを見てみるとデフォルトでYarpのログが出力され、今回はoai-sima01.openai.azure.comというところに通信されている。
```
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7151
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\Work\CSharp\aoailoadbalance\openai-aca-lb\src
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[9]
      Proxying to https://oai-sima01.openai.azure.com//openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview HTTP/2 RequestVersionOrLower
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[56]
      Received HTTP/2.0 response 200.
```

それでは今度は片方を通信できないようにURLをいじってみる。
```
{
  "profiles": {
    "https": {
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "BACKEND_1_URL": "https://aoai-dev-sima001.openai.azure.com/",
        "BACKEND_1_PRIORITY": "1",
        "BACKEND_1_APIKEY": "key",
        "BACKEND_2_URL": "https://testfail-oai-sima01.openai.azure.com/",
        "BACKEND_2_PRIORITY": "1",
        "BACKEND_2_APIKEY": "key"
      },
    }
  },
}
```

testfailのほうに繋がった場合、エラーが発生している。その際以下のメッセージが出力され10秒間Unhealthyとしてマークされる。
```
warn: Yarp.ReverseProxy.Health.DestinationHealthUpdater[19]
Destination `BACKEND_2` marked as 'Unhealthy` by the passive health check is scheduled for a reactivation in `00:00:10`.
```
その10秒間はずっと振り分けられず、10秒間後に以下のメッセージが出力され、振り分け対象に再度組み入れられているのがわかる。
```
info: Yarp.ReverseProxy.Health.DestinationHealthUpdater[20]
      Passive health state of the destination `BACKEND_2` is reset to 'Unknown`.
```
通しで見るとこんな感じ。
```
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[9]
      Proxying to https://testfail-oai-sima01.openai.azure.com//openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview HTTP/2 RequestVersionOrLower
warn: Yarp.ReverseProxy.Forwarder.HttpForwarder[48]
      Request: An error was encountered before receiving a response.
      System.Net.Http.HttpRequestException: そのようなホストは不明です。 (testfail-oai-sima01.openai.azure.com:443)
       ---> System.Net.Sockets.SocketException (11001): そのようなホストは不明です。
         at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
         at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
         at System.Net.Sockets.Socket.<ConnectAsync>g__WaitForConnectWithCancellation|285_0(AwaitableSocketAsyncEventArgs saea, ValueTask connectTask, CancellationToken cancellationToken)
         at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken)
         --- End of inner exception stack trace ---
         at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken)
         at System.Net.Http.HttpConnectionPool.ConnectAsync(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
         at System.Net.Http.HttpConnectionPool.AddHttp2ConnectionAsync(QueueItem queueItem)
         at System.Threading.Tasks.TaskCompletionSourceWithCancellation`1.WaitWithCancellationAsync(CancellationToken cancellationToken)
         at System.Net.Http.HttpConnectionPool.HttpConnectionWaiter`1.WaitForConnectionWithTelemetryAsync(HttpRequestMessage request, HttpConnectionPool pool, Boolean async, CancellationToken requestCancellationToken)
         at System.Net.Http.HttpConnectionPool.SendWithVersionDetectionAndRetryAsync(HttpRequestMessage request, Boolean async, Boolean doRequestAuth, CancellationToken cancellationToken)
         at System.Net.Http.DiagnosticsHandler.SendAsyncCore(HttpRequestMessage request, Boolean async, CancellationToken cancellationToken)
         at System.Net.Http.HttpMessageInvoker.<SendAsync>g__SendAsyncWithTelemetry|6_0(HttpMessageHandler handler, HttpRequestMessage request, CancellationToken cancellationToken)
         at Yarp.ReverseProxy.Forwarder.HttpForwarder.SendAsync(HttpContext context, String destinationPrefix, HttpMessageInvoker httpClient, ForwarderRequestConfig requestConfig, HttpTransformer transformer, CancellationToken cancellationToken)
warn: Yarp.ReverseProxy.Health.DestinationHealthUpdater[19]
      Destination `BACKEND_2` marked as 'Unhealthy` by the passive health check is scheduled for a reactivation in `00:00:10`.
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[9]
      Proxying to https://aoai-dev-sima001.openai.azure.com//openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview HTTP/2 RequestVersionOrLower
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[56]
      Received HTTP/2.0 response 200.
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[9]
      Proxying to https://aoai-dev-sima001.openai.azure.com//openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview HTTP/2 RequestVersionOrLower
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[56]
      Received HTTP/2.0 response 200.
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[9]
      Proxying to https://aoai-dev-sima001.openai.azure.com//openai/deployments/gpt-4o/chat/completions?api-version=2025-01-01-preview HTTP/2 RequestVersionOrLower
info: Yarp.ReverseProxy.Forwarder.HttpForwarder[56]
      Received HTTP/2.0 response 200.
info: Yarp.ReverseProxy.Health.DestinationHealthUpdater[20]
      Passive health state of the destination `BACKEND_2` is reset to 'Unknown`.
```

あとはこのコード自体をAppServiceなどにデプロイすればいい感じにAOAIとの通信を担ってくれる。