Azure DevOpsを使ってOpenAIモデルをデプロイする

## 概要
Azure OpenAIを使うとき、Azure AI FoundryのGUIからの操作が多いのではないかなと思います。
今回はAzure OpenAIをDevOps構成管理に加えたときのDevOpsパイプライン構成を記します。

## 環境
- Azure DevOps
    デプロイにはMSホステッドのエージェントを使用。
    リソースはGitリポジトリで管理。
- Azure
    東日本リージョン

## 手順
1. 開発環境のAzureにてAzureポータルを使ってリソースを作成
1. ①で作成したリソースのARMテンプレートをエクスポート
1. ②のテンプレートを整形、DevOpsパイプラインを作成する
1. 本番環境へデプロイ

### 開発環境のAzureにてAzureポータルを使ってリソースを作成
Azure PortalでAzure AI Servicesを検索します。
Azure AI ServicesはAI関連のリソースをまとめて管理できるサービスです。もともと別のサービスとして存在していたOpenAIを始めとした様々なサービスが集約されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3634883/a8de2ead-d13e-47f0-0c1a-52df79142c20.png)
作成ボタンを押して必要な情報をポチポチすると作ることができます。
Azure OpenAIのリソースができたら、モデルをデプロイします。
デプロイするにはAzure AI Foundryに移動します。
「共有リソース＞デプロイ」から色々なモデルを選択できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3634883/9a996568-ec27-a6ed-83ba-56f3c4fc8fcb.png)

今回はtext-embedding-3-largeをデプロイしました。
デプロイする際に名前を入力します。Azure OpenAIのAPIを使用するときはこのデプロイ名を使用します。特にこだわりが無ければ、モデルと同じ名前にすると良いと思います。MSのドキュメントでもデプロイ名とモデル名を同じにしていることが多いようです。

### 作成したリソースのARMテンプレートをエクスポート
Azureポータルに戻り、Azure OpenAIのページからARMテンプレートをエクスポートします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3634883/9938105a-2d95-82f1-c94e-a560c4e06e05.png)
そして出来上がったものがこちらです。
```json
{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "accounts_name": {
            "defaultValue": "<openAIのリソース名>",
            "type": "String"
        },
        "virtualNetworks_externalid": {
            "defaultValue": "<vNet-resourceId>",
            "type": "String"
        }
    },
    "variables": {},
    "resources": [
        {
            "type": "Microsoft.CognitiveServices/accounts",
            "apiVersion": "2024-06-01-preview",
            "name": "[parameters('accounts_name')]",
            "location": "japaneast",
            "sku": {
                "name": "S0"
            },
            "kind": "OpenAI",
            "properties": {
                "apiProperties": {},
                "customSubDomainName": "[parameters('accounts_name')]",
                "networkAcls": {
                    "bypass": "AzureServices",
                    "defaultAction": "Deny",
                    "virtualNetworkRules": [
                        {
                            "id": "[concat(parameters('virtualNetworks_externalid'), '/subnets/default')]",
                            "ignoreMissingVnetServiceEndpoint": false
                        }
                    ],
                    "ipRules": []
                },
                "publicNetworkAccess": "Enabled"
            }
        },
        {
            "type": "Microsoft.CognitiveServices/accounts/defenderForAISettings",
            "apiVersion": "2024-06-01-preview",
            "name": "[concat(parameters('accounts_name'), '/Default')]",
            "dependsOn": [
                "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accounts_name'))]"
            ],
            "properties": {
                "state": "Disabled"
            }
        },
        {
            "type": "Microsoft.CognitiveServices/accounts/deployments",
            "apiVersion": "2024-06-01-preview",
            "name": "[concat(parameters('accounts_name'), '/text-embedding-3-large')]",
            "dependsOn": [
                "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accounts_name'))]"
            ],
            "sku": {
                "name": "Standard",
                "capacity": 120
            },
            "properties": {
                "model": {
                    "format": "OpenAI",
                    "name": "text-embedding-3-large",
                    "version": "1"
                },
                "versionUpgradeOption": "OnceNewDefaultVersionAvailable",
                "currentCapacity": 120,
                "raiPolicyName": "Microsoft.DefaultV2"
            }
        },
        {
            "type": "Microsoft.CognitiveServices/accounts/raiPolicies",
            "apiVersion": "2024-06-01-preview",
            "name": "[concat(parameters('accounts_name'), '/Microsoft.DefaultV2')]",
            "dependsOn": [
                "[resourceId('Microsoft.CognitiveServices/accounts', parameters('accounts_name'))]"
            ],
            "properties": {
                "mode": "Blocking",
                "contentFilters": [
                    {
                        "name": "Hate",
                        "severityThreshold": "Medium",
                        "blocking": true,
                        "enabled": true,
                        "source": "Prompt"
                    },
                    {
                        "name": "Hate",
                        "severityThreshold": "Medium",
                        "blocking": true,
                        "enabled": true,
                        "source": "Completion"
                    },
                    {
                        "name": "Sexual",
                        "severityThreshold": "Medium",
                        "blocking": true,
                        "enabled": true,
                        "source": "Prompt"
                    },
                    {
                        "name": "Sexual",
                        "severityThreshold": "Medium",
                        "blocking": true,
                        "enabled": true,
                        "source": "Completion"
                    },
                    {
                        "name": "Violence",
                        "severityThreshold": "Medium",
                        "blocking": true,
                        "enabled": true,
                        "source": "Prompt"
                    },
                    {
                        "name": "Violence",
                        "severityThreshold": "Medium",
                        "blocking": true,
                        "enabled": true,
                        "source": "Completion"
                    },
                    {
                        "name": "Selfharm",
                        "severityThreshold": "Medium",
                        "blocking": true,
                        "enabled": true,
                        "source": "Prompt"
                    },
                    {
                        "name": "Selfharm",
                        "severityThreshold": "Medium",
                        "blocking": true,
                        "enabled": true,
                        "source": "Completion"
                    },
                    {
                        "name": "Jailbreak",
                        "blocking": true,
                        "enabled": true,
                        "source": "Prompt"
                    },
                    {
                        "name": "Protected Material Text",
                        "blocking": true,
                        "enabled": true,
                        "source": "Completion"
                    },
                    {
                        "name": "Protected Material Code",
                        "blocking": false,
                        "enabled": true,
                        "source": "Completion"
                    }
                ]
            }
        }
        // 以下省略、他にもraiPolicyが記載される可能性があります。
    ]
}
```
指定したvirtual Networkからのみアクセス可能にしているため、その設定が記載されています。
このテンプレートのうち、`Microsoft.CognitiveServices/accounts/defenderForAISettings`と`Microsoft.CognitiveServices/accounts/raiPolicies`は自分で作成したポリシーでなければ不要です。

:::note alert
raiPoliciesのMicrosoft.DefaultやMicrosoft.DefaultV2といったリソース名はデプロイできません。
自分で作成したポリシーでなければ削除してください。
:::
Microsoftさん、なぜデプロイできない名前のテンプレートを出力するんですか……
ARMテンプレートに記載が無くても、DefaultやDefaultV2といったポリシーは適用可能です。

### テンプレートを整形、DevOpsパイプラインを作成する
ということで上記のjsonから`raiPolicies`と`defenderForAISettings`を削除します。
そしてDevOpsパイプライン用yamlがこちらです。デプロイは特に変わったことはしません。

```yaml
trigger:
  branches:
    include:
      - develop
      - main
  paths:
    include:
      - ARM_templates/aoai_resource_template.json # テンプレートの相対パス

variables:
# 詳細は割愛しますが、DevOpsの変数グループの設定です。
  - ${{ if eq(variables['Build.SourceBranchName'], 'develop') }}:
    - group: 'DEV_Environment'
  - ${{ if eq(variables['Build.SourceBranchName'], 'main') }}:
    - group: 'PRD_Environment'

stages:
  - stage: PublishArtifact
    displayName: "PublishArtifact"
    jobs: 
      - job: PublishArtifact
        displayName: "PublishArtifact"
        pool: 
          vmImage: "ubuntu-latest"
        steps:
          - task: CopyFiles@2
            inputs:
              SourceFolder: 'iac/ARM_templates' #ARMテンプレートを格納しているディレクトリ
              Contents: 'aoai_*' # デプロイ対象となるテンプレートの名称
              TargetFolder: '$(Build.ArtifactStagingDirectory)'
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)'
              artifact: 'ARM_DROP'
              publishLocation: 'pipeline'

  - stage: deployResources
    displayName: "deployResources"
    jobs:
    - deployment: deployResources
      displayName: "deployResources"
      pool: 
        vmImage: "ubuntu-latest"
      environment: $(environment) #変数グループから取得しています
      strategy:
        runOnce:
          deploy:
            steps:
              - task: AzureResourceManagerTemplateDeployment@3
                displayName: "deploy openAI models"
                inputs:
                  deploymentScope: 'Resource Group'
                  azureResourceManagerConnection: $(azure_resource_manager_connection_name) #変数グループから取得しています
                  subscriptionId: $(subscription_id) #変数グループから取得しています
                  action: 'Create Or Update Resource Group'
                  resourceGroupName: '$(resource_group_name)' #変数グループから取得しています
                  location: 'Japan East'
                  templateLocation: 'Linked artifact'
                  csmFile: '$(Pipeline.Workspace)/ARM_DROP/aoai_resource_template.json'
                  deploymentMode: 'Incremental'
                  overrideParameters: |
                    #オーバーライドしたい場合はこちらに記載...

```

フォルダ構成はこんな感じです。
iac
┣━ ARM_templates
┃　┗━ aoai_resource_template.json
┗━ deploy_aoai.yaml

このパイプラインを本番環境向けに動かすことで、Azure OpenAIリソースのデプロイができました。
細かい設定などを省いているので、ユースケースに合わせて微調整してください。

### おまけ
Azure OpenAIのAPIを使用するときはエンドポイントとAPIキーを取得する必要があります。
私はこのエンドポイントとAPIキーをJSONにまとめて、Azure Key Vaultに置いています。DevOpsパイプラインの動作時にこのシークレットを作成するようにジョブを追加しました。
```yaml
  - stage: UpdateSecretInAKV
    displayName: "create secret of API key of azure openAI"
    jobs:
      - job: createSecretinAKV
        pool: 
          vmImage: "windows-latest"
        steps:
          - task: AzureCLI@2
            displayName: "Fetch API Key from Azure OpenAI Account"
            inputs:
              azureSubscription: $(azure_resource_manager_connection_name) #変数グループから取得しています
              scriptType: 'bash'
              scriptLocation: 'inlineScript'
              inlineScript: |
                API_KEY=$(az cognitiveservices account keys list --name "<OpenAIのデプロイ名>" --resource-group "$(resource_group_name)" \
                  | jq -r .key1)
                ENDPOINT=$(az cognitiveservices account show --name "<OpenAIのデプロイ名>" --resource-group "$(resource_group_name)" \
                  | jq -r .properties.endpoint)
                FULL_ENDPOINT="${ENDPOINT}openai/deployments/text-embedding-3-large/embeddings?api-version=2023-05-15"
                JSON_SECRET=$(jq -nc --arg key $API_KEY --arg endpoint $FULL_ENDPOINT \
                  '{
                    "apikey": $key,
                    "endpoint": $endpoint
                  }'
                )
                echo "##vso[task.setvariable variable=JSON_SECRET]$JSON_SECRET"
            
          - task: AzureCLI@2
            displayName: "Update Key Vault Secret"
            inputs:
              azureSubscription: $(azure_resource_manager_connection_name)
              scriptType: 'bash'
              scriptLocation: 'inlineScript'
              inlineScript: |
                activate_date=$(date -u -d "10 minutes ago" +"%Y-%m-%dT%H:%M:%SZ")
                az keyvault secret set --vault-name "<key_vault名>" --name "<シークレット名>" --value "$(echo $JSON_SECRET | jq -c .)" --output "none" --not-before "$activate_date"
```
bashでjsonを操作できるjqコマンドですが、-nc引数を使えば新しいjsonを作成することができます。
このままだとちょっとハードコーディング気味ですので、適宜修正してお使いくださいm(__)m