Azure OpenAIを例にAKSのワークロードID環境をterraformで構築・設定する

# はじめに

皆さまAKS上で動作させるアプリケーションと各Azureサービスとの認証はどうされていますでしょうか？
特にGitOpsを構成していたりすると、キーやパスワードを利用すると漏洩リスクが高くなるので、厄介だと思います。  
AzureではAKS(Azure Kubernetes Service)について、23年4月にこれを解決するため「ワークロードID (workload identity)」がGAされました。

そこで今回、このワークロードIDを利用するAzure環境をIaCツールである`terraform`を用いて構築したいと思います。
主には以下のMicrosoftドキュメントの手順について、`terraform`を用いて実装したのものになります。

- [ワークロード ID を使用して Azure Kubernetes Service (AKS) クラスターをデプロイして構成する - Azure Kubernetes Service](https://learn.microsoft.com/ja-jp/azure/aks/workload-identity-deploy-cluster)

またワークロードIDの特徴については、以下のGA時の記事をご参照ください。

- [一般提供開始: AKS での Azure Active Directory ワークロード ID | Azure の更新情報 | Microsoft Azure](https://azure.microsoft.com/ja-jp/updates/ga-azure-active-directory-workload-identity-with-aks-2/)

## 構築する環境

今回は以下のような簡単な環境を`terraform`を用いて構築します。  
デプロイするリソースの名称は、後程掲載するterraformのローカル変数を修正ください。  
詳細については「認証フローについて」にて記載いたします。

![デプロイリソース](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695104/a9d5bf7b-6f16-c44f-fa08-8af578718d3a.png)


## 環境構築手順

ここではAzure CloudShell(bash)を利用します。CloudShellにはデフォルトで`terraform`と`kubectl`がインストールされています。  
CloudShellで以下に記載の通りコマンドを実行し、terraformコードについてもコピーしてください。
またコマンド中のコメントに記載の通り、1度目の`terraform apply`は失敗するので、再度実行し、AKSにマニフェストを反映させてください。

``` bash
# terraformフォルダを作成し移動
mkdir terraform && cd terraform

# terraformの内容を貼り付けて保存
vi aks-workload-id.tf

# terraformを初期化し、デプロイ実行
terraform init && terraform apply

# 1回目ではKubernetes Configが存在せず失敗するので再実行
terraform apply
```

コピーする`terraform`コードは以下の通りです。

``` terraform:aks-workload-id.tf
# 必要な外部プロバイダーの定義 ※今回はAzApiを利用します。
terraform {
  required_providers {
    azapi = {
      source = "azure/azapi"
    }
  }
}
# 各プロバイダーの設定
provider "azurerm" {
  features {}
}
provider "kubernetes" {
  config_path = "~/.kube/config"
}
provider "azapi" {
}

# ワークロードID関連で利用するローカル変数を定義
locals {
  resource_group_name                = "example"
  resource_group_locaiton            = "Japan East"

  aks_name                           = "example-aks"
  aks_dns_prefix                     = "exampleaks"

  service_account_name               = "workload-identity-sa"
  service_account_namespace          = "default"
  user_assigned_identity_name        = "myIdentity"
  federated_identity_credential_name = "myFedIdentity"

  kubernetes_pod_name                = "test-pod"
  kubernetes_container_name          = "test"
  
  openai_account_name                = "example-openai"
  gpt_35_turbo_deploy_name           = "chatgpt"
  gpt_35_turbo_model_version         = "0613"
}

# リソースグループを作成
resource "azurerm_resource_group" "example" {
  name     = local.resource_group_name
  location = local.resource_group_locaiton
}

# AKSの作成
resource "azurerm_kubernetes_cluster" "example" {
  name                      = local.aks_name
  location                  = azurerm_resource_group.example.location
  resource_group_name       = azurerm_resource_group.example.name
  dns_prefix                = local.aks_dns_prefix
  oidc_issuer_enabled       = true # OIDC issuerを有効化 
  workload_identity_enabled = true # ワークロードIDを有効化

  default_node_pool {
    name       = "default"
    node_count = 1
    vm_size    = "Standard_D2_v2"
  }

  identity {
    type = "SystemAssigned"
  }

  # デプロイ後、Kubernetes Configを作成
  provisioner "local-exec" {
    command = "az aks get-credentials -n ${self.name} -g ${azurerm_resource_group.example.name}"
  }
}

# ユーザ割当マネージドIDの作成
resource "azurerm_user_assigned_identity" "example" {
  name                = local.user_assigned_identity_name
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
}

# Kubernetesのサービスアカウントを作成
resource "kubernetes_service_account" "example" {
  depends_on = [azurerm_kubernetes_cluster.example]

  metadata {
    name      = local.service_account_name
    namespace = local.service_account_namespace
    annotations = {
      "azure.workload.identity/client-id" = azurerm_user_assigned_identity.example.client_id
    }
  }
}

# フェデレーション ID 資格情報を設定
resource "azurerm_federated_identity_credential" "example" {
  depends_on = [kubernetes_service_account.example]

  name                = local.federated_identity_credential_name
  parent_id           = azurerm_user_assigned_identity.example.id
  resource_group_name = azurerm_resource_group.example.name
  audience            = ["api://AzureADTokenExchange"]
  issuer              = azurerm_kubernetes_cluster.example.oidc_issuer_url
  subject             = "system:serviceaccount:${local.service_account_namespace}:${local.service_account_name}"
}

# テスト用のKubernetes Podを作成
resource "kubernetes_pod" "example" {
  depends_on = [azurerm_federated_identity_credential.example]

  metadata {
    name      = local.kubernetes_pod_name
    namespace = local.service_account_namespace
    labels = {
      "azure.workload.identity/use" = "true"
    }
  }

  spec {
    service_account_name = local.service_account_name
    container {
      name    = local.kubernetes_container_name
      image   = "python:3.11"
      command = ["sleep", "3600"]
    }
  }
}

# OpenAIアカウントを作成
resource "azurerm_cognitive_account" "example" {
  name                  = local.openai_account_name
  location              = azurerm_resource_group.example.location
  resource_group_name   = azurerm_resource_group.example.name
  custom_subdomain_name = local.openai_account_name

  kind     = "OpenAI"
  sku_name = "S0"

  identity {
    type = "SystemAssigned"
  }
}

# gpt-35-turbo(ChatGPT)モデルをデプロイ
resource "azapi_resource" "chatgpt" {
  type      = "Microsoft.CognitiveServices/accounts/deployments@2023-05-01"
  name      = local.gpt_35_turbo_deploy_name
  parent_id = azurerm_cognitive_account.example.id

  body = jsonencode({
    properties = {
      model = {
        format  = "OpenAI"
        name    = "gpt-35-turbo"
        version = local.gpt_35_turbo_model_version
      }
      versionUpgradeOption = "NoAutoUpgrade"
    }
    sku = {
      capacity = 120
      name     = "Standard"
    }
  })
}

# ユーザ割当マネージドIDにアクセス権を付与
resource "azurerm_role_assignment" "openai_role_user" {
  scope                = azurerm_cognitive_account.example.id
  role_definition_name = "Cognitive Services OpenAI User"
  principal_id         = azurerm_user_assigned_identity.example.principal_id
}
```

## ワークロードIDに関する設定について

### Azure上の設定

terraformでワークロードIDを有効化するには`azurerm_kubernetes_cluster`の`oidc_issuer_enabled`と`workload_identity_enabled`を`true`に設定してください。  
次に必要な設定としては、フェデレーションID資格情報`azurerm_federated_identity_credential`になります。  
この設定により、ユーザ割当マネージドIDとAKS、サービスアカウントの紐づけがされます。

### Kubernetesリソースの設定

Kubernetesリソースとしてはサービスアカウント(sa)にユーザ割当マネージドIDのクライアントIDを設定する点と、そのsaとPodを紐づけ、
ラベル`azure.workload.identity/use`に`true`を設定することが必要です。  
これによりPodデプロイ時にワークロードIDを用いた認証に必要な設定がInjectされます。
詳細については後述の「認証フローについて」を参照ください。

## サンプルアプリによるワークロードIDでの認証例

次にAzure CloudShellで以下のコマンドを実行し、Podに入ってください。

``` bash
kubectl exec -it test-pod -- /bin/bash
```

Podに入った後は、`pip`を用いて必要なライブラリと、`apt`でエディタ(`vi`等)をインストールしてください。

``` bash
pip install azure-identity openai
apt install vi
```

次にインストールしたエディタで、以下のPythonを保存してください。
この際、terraformでOpenAIのリソース名を変更した場合は、上から6行目の`example-openai`を変更したリソース名に、OpenAIのデプロイ名を変更した場合は12行目について`engine="<デプロイ名>"`に修正してください。
このPythonを実行すると、自動的にワークロードIDを用いて認証され、Azure OpenAI Serviceから応答が返ってくるかと思います。  
見て頂けたら分かる通り、特にコード上ではワークロードIDに関する記述はなく、全て`DefaultAzureCredential`が吸収してくれています。  

``` python
import openai
from azure.identity import DefaultAzureCredential

default_credential = DefaultAzureCredential()

openai.api_base = "https://example-openai.openai.azure.com/"
openai.api_version = "2023-05-15"
openai.api_type = "azure_ad"
openai.api_key = default_credential.get_token("https://cognitiveservices.azure.com/.default").token

response = openai.ChatCompletion.create(
    engine="chatgpt",
    messages=[
        {"role": "user", "content": "こんにちは！"},
    ],
)

print(response.choices[0].message.content)
```

## 認証フローについて

ワークロードID利用時の認証の流れは以下の通りです。
前述の`oidc_issuer_enabled`を`true`にすると、OIDC Provider URLが公開され、AzureADと連携できるようになります。  
OIDC Issuerは、一度有効化すると無効化することはできません。無効化するにはクラスタを再作成する必要があります。
また`workload_identity_enabled`を`true`にすると、ラベルに`azure.workload.identity/use`が`true`となっているPodに対して、ワークロードIDに必要な設定がInjectされます。
InjectはPodの作成時にのみ行われるようなので、修正するにはPod自身を再作成してください。

![ワークロードID 認証概要](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695104/dfbb845f-dd41-8bb6-27ed-6d1c0515029b.png)


認証フローの詳細については、以下の公式ドキュメントを参照ください。

- [Azure Kubernetes Service (AKS) で Azure AD ワークロード ID を使用する - Azure Kubernetes Service](https://learn.microsoft.com/ja-jp/azure/aks/workload-identity-overview?tabs=dotnet)

また上記の図を作成するにあたり、以下のサイトも参照しています。

- [Lab - Workload Identity](https://azure.github.io/AKS-DevSecOps-Workshop/modules/Module1/lab-workloadidentity.html)
- [Connect your Kubernetes application to your database without any credentials (and securely) - Alexis Plantin - Blog](https://alexisplantin.fr/workload-identity-federation/)

## 終わりに

如何でしたでしょうか。公式ドキュメントだと認証フローから記載されているため取っ付きにくいと思いますが、一度設定が分かってしまえば、そこまで難しくないと思います。
また認証についてもAzure Identityの`DefaultAzureCredential`を利用していれば、既存コードから変更することなくワークロードIDに移行できることが分かったかと思います。
特に冒頭にも記載さセていただいた通り、日増しにキーやパスワードの漏洩リスクが高まってきているのを感じているため、もしキーやパスワードを利用されている場合は是非移行についてご検討ください。

## 参考記事

- [ワークロード ID を使用して Azure Kubernetes Service (AKS) クラスターをデプロイして構成する - Azure Kubernetes Service](https://learn.microsoft.com/ja-jp/azure/aks/workload-identity-deploy-cluster)
- [Azure Kubernetes Service でAzure AD ワークロード IDを使ってみた - APC 技術ブログ](https://techblog.ap-com.co.jp/entry/2022/12/02/113000)