ROMANCE DAWN for the new world

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

Azure Container Apps で KEDA を使って Azure Queue Storage のバックグラウンド処理を構築する

Microsoft Build 2022 で Azure Container Apps が GA されました。東日本リージョンでも使えるようになりましたので、KEDA を使って Azure Queue Storage のバックグラウンド処理を構築してみました。

Azure Container Apps の KEDA サポート

Container Apps では、コンテナのインスタンス(Replicas)の水平オートスケーリングに対応しており、4種類のスケーリング トリガーがあります。

  • HTTP Request
  • Event-driven
  • CPU
  • Memory

Event-driven については、KEDA でサポートされているすべてのイベントに対応しています。今回は、Azure Queue Storage のスケーリング トリガーを試してみます。
Replicas をゼロにスケーリングすると課金されることはありませんが、CPU と Memory はゼロにスケーリングできないので注意が必要です。
docs.microsoft.com

バックグラウンド処理を作成する

バックグラウンド処理を C# で作る場合、2つの方法があります。

  • Azure Functions を使う
  • BackgroundService クラスを使って実装する

今回は、Azure Functions の Queue Trigger テンプレートをそのまま使います。

public class Function
{
    [FunctionName(nameof(Function))]
    public void Run([QueueTrigger("myqueue-items", Connection = "AzureWebJobsStorage")]string myQueueItem, ILogger log)
    {
        log.LogInformation($"C# Queue trigger function processed: {myQueueItem}");
    }
}

Azure Functions Core Tools で func init コマンドに --docker-only を指定すると、既存のプロジェクトに Dockerfile を追加できます。

$ func init --docker-only --dotnet
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS installer-env

# Build requires 3.1 SDK
COPY --from=mcr.microsoft.com/dotnet/core/sdk:3.1 /usr/share/dotnet /usr/share/dotnet

COPY . /src/dotnet-function-app
RUN cd /src/dotnet-function-app && \
    mkdir -p /home/site/wwwroot && \
    dotnet publish *.csproj --output /home/site/wwwroot

# To enable ssh & remote debugging on app service change the base image to the one below
# FROM mcr.microsoft.com/azure-functions/dotnet:4-appservice
FROM mcr.microsoft.com/azure-functions/dotnet:4
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
    AzureFunctionsJobHost__Logging__Console__IsEnabled=true

COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"]

Dockerfile が追加されたので、Ducker Hub に Image をプッシュしておきます。

Container Apps をデプロイする

Bicep を使って、Container Apps をデプロイします。
environment.bicep では、Environment、LogAnalytics、Application Insights のリソースを定義します。

param environmentName string
param logAnalyticsWorkspaceName string = 'logs-${environmentName}'
param appInsightsName string = 'appins-${environmentName}'
param location string = resourceGroup().location

resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
      legacy: 0
      enableLogAccessUsingOnlyResourcePermissions: true
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}

resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: { 
    Application_Type: 'web'
    WorkspaceResourceId:logAnalyticsWorkspace.id
  }
}

resource environment 'Microsoft.App/managedEnvironments@2022-03-01' = {
  name: environmentName
  location: location
  properties: {
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: logAnalyticsWorkspace.properties.customerId
        sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
      }
    }
    daprAIInstrumentationKey: appInsights.properties.InstrumentationKey
    zoneRedundant: false
  }
}

output location string = location
output environmentId string = environment.id

storage.bicep では、Storage Accounts のリソースを定義します。ConnectionString を作っている部分がポイントです。

param storageAccountName string
param location string = resourceGroup().location

var strageSku = 'Standard_LRS'

resource stg 'Microsoft.Storage/storageAccounts@2019-06-01' = {
  name: storageAccountName
  location: location
  kind: 'Storage'
  sku:{
    name: strageSku
  }
}

output storageId string = stg.id
output storageConnectionString string = 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${listKeys(stg.id, stg.apiVersion).keys[0].value}'

containerapp.bicep では、バックグラウンド処理を実行する Functions の Docker Image を指定して Container Apps のリソースを定義します。
バックグラウンド処理なので Ingress は有効化せず、Functions の構成に必要となる環境変数とシークレットに Storage の ConnectionString を指定します。
myqueue-items の Queue 内のメッセージが 3 個を超えると新しい Replicas を作成して、最大 10 個までオートスケーリングさせます。

param containerAppName string
param location string = resourceGroup().location
param environmentId string
param containerImage string
param revisionSuffix string
@allowed([
  'multiple'
  'single'
])
param revisionMode string = 'single'
@secure()
param storageConnectionString string

resource containerApp 'Microsoft.App/containerApps@2022-03-01' = {
  name: containerAppName
  location: location
  properties: {
    managedEnvironmentId: environmentId
    configuration: {
      activeRevisionsMode: revisionMode
      dapr:{
        enabled:false
      }
      secrets: [
        {
          name: 'storage-connection'
          value: storageConnectionString
        }
      ]   
    }
    template: {
      revisionSuffix: revisionSuffix
      containers: [
        {
          image: containerImage
          name: containerAppName
          resources: {
            cpu: '0.25'
            memory: '0.5Gi'
          }
          env: [
            {
              name: 'AzureWebJobsStorage'
              secretref: 'storage-connection'
            }
            {
              name: 'FUNCTIONS_WORKER_RUNTIME'
              value: 'dotnet'
            }
          ]
        }
      ]
      scale: {
        minReplicas: 0
        maxReplicas: 10
        rules: [
          {
            name: 'queue-scaling-rule'
            azureQueue: {
              queueName: 'myqueue-items'
              queueLength: 3
              auth: [
                {
                  secretRef: 'storage-connection'
                  triggerParameter: 'connection'
                }
              ]
            }
          }
        ]
      }
    }
  }
}

main.bicep では、それぞれの bicep ファイルをモジュールとして定義します。

param environmentName string = 'env-${resourceGroup().name}'
param storageAccountName string = resourceGroup().name
param location string = resourceGroup().location
param containerAppName string = 'queue-reader-function'
param containerImage string = 'thara0402/queue-reader-function:0.1.0'
param revisionSuffix string = ''
@allowed([
  'multiple'
  'single'
])
param revisionMode string = 'single'


module environment 'environment.bicep' = {
  name: 'container-app-environment'
  params: {
    environmentName: environmentName
    location: location
  }
}

module storage 'storage.bicep' = {
  name: 'storage'
  params: {
    storageAccountName: storageAccountName
    location: location
  }
}

module containerapp 'containerapp.bicep' = {
  name: 'container-app'
  params: {
    environmentId: environment.outputs.environmentId
    containerAppName: containerAppName
    containerImage: containerImage
    revisionSuffix: revisionSuffix
    revisionMode: revisionMode
    storageConnectionString: storage.outputs.storageConnectionString
    location: location
  }
}

Azure CLI を使って、デプロイします。

$ az group create -n <ResourceGroup Name> -l japaneast
$ az deployment group create -f ./deploy/main.bicep -g <ResourceGroup Name>

作られたリソースは、こんな感じです。

Container Apps の Revision を確認すると、オートスケーリングが設定されたことが分かります。


結果確認

Azure CLI を使って、Revision の Replicas 数を確認しておきます。

$ az containerapp revision show --revision <Revision Name> -n queue-reader-function -g <ResourceGroup Name> -o table

サーバーレスの設定を行ったので、Replicas 数が 0 であることを確認できます。

myqueue-items の Queue を作ってメッセージを 10 個ほど送信すると、Replicas 数が 3 に増えたことを確認できます。

Log Analytics でクエリを実行すると、Functions が実行されたログを確認できます。

ContainerAppConsoleLogs_CL |
project TimeGenerated, ContainerAppName_s, Log_s |
where ContainerAppName_s contains "queue-reader-function" |
order by TimeGenerated desc 


まとめ

Azure Container Apps で、KEDA を使って Azure Queue Storage のバックグラウンド処理を構築してみました。
今回のように C# で Queue Trigger のケースは素直に Azure Functions を使ったほうがいいですが、従量課金プランにおける 1 回の実行が 10 分までなどの制限が要件にマッチしないケースでは Container Apps が選択肢になると思います。
Azure Functions Core Tools には func kubernetes install コマンドがあるので、いずれ Container Apps にも対応する可能性があるので、さらに簡単にデプロイできそうです。

今回のソースコードは、こちらのリポジトリで公開しています。
github.com

キャンプ黄金崎に行ってきました

梅雨入り間近ではありましたが、ゆるキャン△の聖地であるキャンプ黄金崎に行ってきました。
前回は、本栖湖の畔にある浩庵キャンプ場に行きました。
gooner.hateblo.jp

沼津港

黄金崎の近くには大きなスーパーがないので、キャンプの食材を買い出しするため、沼津に立ち寄りました。


沼津港では、新鮮な地魚を使った食事を楽しめますが、キャンプ飯に備えて軽めの朝食として、生しらす丼を食べました。


買い出しは、フードストアあおきの沼津店に行きました。キャンプ向けの食材が充実しているという感じではないので、普通に肉や野菜、お酒などを買いました。

黄金崎公園

沼津から1時間ちょっとで、黄金崎公園に到着しました。キャンプ場のチェックインは 13 時からで少し早く着いたので、馬ロックに行きました。

見事に馬でしたね。そして、海も綺麗でした。




キャンプ黄金崎

キャンプ黄金崎は、テントサイトの事前予約が必要です。海側と山側のサイトがありますが、今回は海側のテントサイトAを予約しました。
前回は富士山の見えるキャンプ場でしたが、海の見える景色もいいですね。

2~3人用のテントを2つ張りたかったので A-2 と A-3 のサイトを予約しましたが、A-3 だけでも大丈夫な広さでした。
地面は固めでペグを打つのに苦戦したので、ペグハンマーが欲しくなった。

ゆるキャン△にも出てきた見覚えのある風景です。駐車場は少し離れていますが、他キャンパーが少なかったのでテントサイトの横に車を停めさせてくれました。


キャンプ飯の写真は、結局1枚しか撮らなかったですが、肉、椎茸、ハマグリ、アスパラなどを焼きました。パイナップルは美味しかった。フライパンでアヒージョも作りました。
伊豆キャン△だし干物とかも焼きたかったけど、メタル賽銭箱なのでサイズ的に厳しそうなので断念。

日が暮れるとこんな感じ。なでしこと恵那が夜眠れなくて会話していた場所ですね。ゆるキャン△のなかでも、あのシーンはけっこう好きです。

この日は夜中から雨の予報だったので、デイキャンプに変更して空いていた2~3人用のコテージに宿泊しました。強い雨と風を凌ぐことができて良かったです。


禅の湯

天気が良ければ、堂ヶ島のトンボロや海の見える露天風呂のある温泉とかに行きたかったですが、翌日も雨だったのでとある目的のため河津方面に向かいました。

河津に向かう途中で立ち寄り温泉の禅の湯に行きました。露天風呂や岩盤浴などもあって、くつろげる温泉でした。
改装のため休業中だったことを知らずに訪れたにもかかわらず、私たちのために開けてくれて、ありがとうございました。また河津に来たら、必ず立ち寄ります。

zen-no-yu.com

わさび園 かどや

とある目的というは、河津にある「わさび園 かどや」でわさび丼を食べることです。孤独のグルメに2回も登場したお店です。
tabelog.com

わさび丼を注文すると、わさびとおろし板を渡されるので自分ですります。

おろしたわさびをごはんの真ん中にのせ、まわりの鰹節に醤油をかけて、少しずつわさびを崩しながら食べます。わさびの風味が消えてしまうので、わさびに直接醤油をかけてはいけません。

こんなにシンプルな組み合わせなのに、めちゃくちゃ旨い。混み合うので追加注文ができなかったので、次回は最初から2杯注文したい。

Azure Container Apps の Revision Label を使って Staging の URL を固定する

Azure Container Apps の Revision は、Web Apps の Deployment Slot とは異なり、新しい Docker Image をデプロイするたびに Revision が追加されます。
Revision Label を使うことで、Staging の URL を固定できるかどうか試してみました。
docs.microsoft.com

Container Apps をデプロイする

前回の記事と同様の手順で、Bicep で Container Apps をデプロイします。
gooner.hateblo.jp

Staging へのデプロイを想定し、新しいバージョンをデプロイしました。dapr-frontend--v2 という Revision が追加されました。

新しい Revision には、Container Apps とは別の Revison URL が割り当てられるので、リリース前のテストができます。

この Revision 毎に割り当てられる URL を固定させるため、Revison Label に staging をセットします。

Revision URL とは別に、Label URL が割り当てられます。


Revision を Swap する

Revision の traffic パーセンテージを Swap します。

$ az containerapp ingress traffic set -n dapr-frontend -g <ResourceGroup Name> \
     --revision-weight dapr-frontend--v1=0 latest=100

Swap が完了したら、Revison Label を dapr-frontend--v2 から dapr-frontend--v1 に付け替えます。

最後に、旧バージョンの Revision をシャットダウンします。

$ az containerapp revision deactivate -n dapr-frontend -g <ResourceGroup Name> --revision dapr-frontend--v1

あとは、この繰り返しで。

まとめ

Azure Container Apps の Revision Label を使って Staging の URL を固定してみました。
ひと手間かかりますが、Web Apps の Deployment Slot に近い運用を実現できそうです。

本当は、Azure CLI で Revision Label を追加削除したかったのですが、挙動が怪しかったので Azure Portal を使いました。
具体的には、traffic パーセンテージが 0% の Revison に対して、Revison Label を追加できません。

$ az containerapp revision label add -n dapr-frontend -g <ResourceGroup Name> --label staging --revision dapr-frontend--v2
Please specify a revision name with an associated traffic weight.

GitHub の Issues に報告したところ、次のリリースで修正されるようです。
github.com

// 追記:2022/06/15
Azure CLI の Ver.0.3.6 で修正が完了しました。--no-prompt オプションを指定することで、Revision Label を削除追加せずとも、強制的に付け替えできます。

// Revision Label(staging) を新バージョンにセット
az containerapp revision label add -n dapr-frontend -g <ResourceGroup Name> --label staging --revision dapr-frontend--v2

// スワップ
az containerapp ingress traffic set -n dapr-frontend -g <ResourceGroup Name> --revision-weight dapr-frontend--v1=0 latest=100

// Revision Label(staging) を付け替え
az containerapp revision label add -n dapr-frontend -g <ResourceGroup Name> --label staging --revision dapr-frontend--v1 --no-prompt