可易

用 Terraform 部署 Microsoft Foundry 托管智能体

可易团队阅读约 14 分钟

把 Foundry 平台资源与智能体发布分开,用镜像摘要、AzAPI 和就绪脚本部署托管智能体,失败时保留上一版本回滚。

目录

#用 Terraform 部署 Microsoft Foundry 托管智能体

使用 Terraform 和 AzAPI 提供程序,在 Microsoft Foundry 中部署由容器支持的托管智能体(hosted agent)。Foundry 在托管计算上运行你的智能体代码。你把逻辑智能体的配置与基础设施即代码(infrastructure as code,IaC)一起放在源代码管理中管理。

把共享基础设施与智能体发布分开:置备 Foundry 资源,构建并推送镜像,然后应用智能体定义并验证就绪。本文聚焦智能体部署配置,并把已有的平台资源当作输入。

此工作流不需要 Azure Developer CLI(azd)。若要使用由 azd 管理智能体部署的 Terraform 基础设施,请参阅用 azd 设置 CI/CD。若要上传源代码而不是容器镜像,请参阅从源代码部署托管智能体。

先决条件

  • Terraform 1.4 或更高版本,其中包含 terraform_data。
  • AzAPI 提供程序 2.9.0 或更高版本。提交经过评审的提供程序锁文件,以便团队使用同一提供程序版本。
  • 在受支持区域中已有 Foundry 项目和模型部署,且配额充足。要用 Terraform 创建配套基础设施,请参阅用 Terraform 管理 Foundry 资源。
  • Foundry 账户上已有护栏(负责任 AI 策略,RAI policy)及其完整的 Azure 资源管理器(Azure Resource Manager)资源 ID。请参阅为托管智能体添加护栏。
  • 一个 Foundry 运行时可以访问的 Azure 容器注册表(Azure Container Registry)实例,并已配置所需的项目连接和拉取权限。
  • 托管智能体代码和 Dockerfile,或一个以其不可变摘要寻址的现有容器镜像。镜像必须面向 linux/amd64,满足运行时契约,并支持容器协议 2.0.0。
  • 如果在本地构建镜像,需要 Docker。使用 Arm 处理器的工作站(包括 Apple Silicon)还需要 Docker 的跨平台模拟,以构建 linux/amd64。
  • 在项目中创建和管理智能体的权限,以及所配置身份拉取镜像的权限。请参阅托管智能体权限和部署托管智能体。
  • PowerShell 7.4 或更高版本,以及用于就绪脚本的已认证 Azure CLI。本地开发时,在应用配置之前用 az login 登录。
  • 该具名智能体有单一部署所有者。协调 Terraform、门户、SDK 和其他流水线写入方,使它们不要并发更新该智能体。

把基础设施与智能体部署分开

平台团队和应用团队可以管理各自的部署生命周期。平台团队可以拥有 Foundry 账户、项目、模型部署、护栏策略、连接和监控。应用团队可以拥有智能体代码、容器镜像和逻辑智能体定义。

这些资源使用不同的 API 表面:

资源API 表面部署方式
Foundry 项目(Microsoft.CognitiveServices/accounts/projects)Azure 资源管理器控制平面Terraform 或 Bicep。
项目连接(Microsoft.CognitiveServices/accounts/projects/connections)Azure 资源管理器控制平面Terraform 或 Bicep;在部署智能体之前配置注册表访问。
逻辑托管智能体(Microsoft.Foundry/agents@v1)Foundry 项目数据平面Terraform azapi_data_plane_resource、SDK 或 REST API。

项目的数据平面端点是逻辑智能体的父级。Foundry 管理配套计算;该智能体不是作为 Azure 资源管理器中的 App Service 或 Azure Container Apps 资源来部署的。关于 AzAPI 如何指向该端点,请参阅了解 AzAPI 数据平面框架。

使用类型为 Microsoft.Foundry/agents@v1 的 azapi_data_plane_resource 来管理具名智能体。这种方法不会为每个版本创建一个独立管理的 Terraform 资源。

定义发生变化可以创建一个新版本,Foundry 在逻辑智能体之下管理版本历史。一次没有变化的 apply 不会提供新的发布标识符。

基本示例从新的智能体名称开始。对于已有的生产智能体,在应用候选定义之前,先把生产固定到其当前版本。在接管另一个工具已经管理的智能体之前,先审查 Terraform 所有权和导入要求。

规划部署阶段

对于初次部署,按此顺序完成各阶段:

  • 置备或复用基础基础设施:Foundry 账户和项目、模型部署、护栏策略、注册表连接、所需身份和角色分配,以及监控。
  • 构建智能体的 linux/amd64 镜像,并推送到 Azure 容器注册表。
  • 用项目端点、镜像摘要、模型部署名称和完整的 RAI 策略资源 ID,应用托管智能体的 Terraform 配置。
  • 等待所得版本变为 active,然后调用并验证该版本。

Azure 容器注册表可以与 Foundry 资源有各自的生命周期。当组织在应用团队之间共享一个注册表时,复用集中管理的注册表。

对于后续的智能体发布,除非基础基础设施的配置需要变更,否则复用它。构建并推送新镜像,应用更新后的智能体定义,并重复就绪检查和应用检查。在候选通过这些检查之前,保持生产固定。

端到端示例见社区维护的 simple-hosted-agent-deploy-azapi 示例。它把 foundry_base Terraform 配置、镜像构建和 hosted_agent Terraform 配置分开,并包含一个可复用的托管智能体模块。按其 README 进行开发容器设置、部署命令和清理。

该示例默认使用本地 Terraform 状态。在把它改用于共享环境之前,对照当前平台要求审查其设置,并配置安全的远程状态和发布控制。

构建并推送容器镜像

如果镜像流水线已经提供兼容的镜像摘要,就跳过构建。否则,在包含智能体 Dockerfile 的目录中运行这些 Bash 命令。使用唯一的发布标签,并且不要在部署期间覆盖它。

set -euo pipefail

ACR_NAME="<registry-name>"
IMAGE_NAME="<image-name>"
IMAGE_TAG="<unique-release-tag>"
LOGIN_SERVER=$(az acr show --name "$ACR_NAME" \
  --query loginServer --output tsv)

az acr login --name "$ACR_NAME"
docker build --platform linux/amd64 \
  --tag "$LOGIN_SERVER/$IMAGE_NAME:$IMAGE_TAG" .
docker push "$LOGIN_SERVER/$IMAGE_NAME:$IMAGE_TAG"

DIGEST=$(az acr repository show --name "$ACR_NAME" \
  --image "$IMAGE_NAME:$IMAGE_TAG" --query digest --output tsv)
if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
  echo "The registry didn't return a valid image digest." >&2
  exit 1
fi

printf 'image_uri = "%s/%s@%s"\n' "$LOGIN_SERVER" "$IMAGE_NAME" "$DIGEST"

把打印出的 image_uri 赋值复制到下一节的 terraform.tfvars 中。固定摘要使本次发布独立于之后的标签变化。在智能体版本或回滚要求仍依赖该镜像时,保持镜像可用。

推送镜像的权限并不授予 Foundry 运行时拉取它的权限。请单独配置注册表访问。请参阅托管智能体权限。

参考:容器要求、az acr login,以及 az acr repository show。

配置提供程序与输入

在单独的 Terraform 配置目录中创建以下文件。把共享平台的项目端点、模型部署名称和护栏资源 ID 作为输入传入;此配置不会重建那些资源。你可以从经过评审的平台输出或流水线配置提供这些值。

创建 providers.tf:

terraform {
  required_version = ">= 1.4.0"

  required_providers {
    azapi = {
      source  = "Azure/azapi"
      version = ">= 2.9.0, < 3.0.0"
    }
  }
}

provider "azapi" {}

对于本地开发,提供程序可以使用你的 Azure CLI 登录。在 CI 中,分别配置提供程序身份验证和 Azure CLI 登录。用 OIDC 认证提供程序,并不会自动认证 waiter 所使用的 CLI。

创建 variables.tf:

variable "project_endpoint" {
  type        = string
  description = "HTTPS Foundry project data-plane endpoint."

  validation {
    condition = can(regex(
      "^https://[^/?#]+/api/projects/[^/?#]+/?$",
      var.project_endpoint
    ))
    error_message = "Use an HTTPS project endpoint without a query."
  }
}

variable "agent_name" {
  type        = string
  description = "Name of the agent managed by this configuration."
}

variable "image_uri" {
  type        = string
  description = "Container image URI including its sha256 digest."

  validation {
    condition     = can(regex("@sha256:[0-9a-f]{64}$", var.image_uri))
    error_message = "Use an immutable image URI ending in @sha256:digest."
  }
}

variable "model_deployment_name" {
  type        = string
  description = "Existing model deployment used by the container."
}

variable "cpu" {
  type        = string
  description = "CPU cores for the hosted container."
  default     = "0.5"
  nullable    = false
}

variable "memory" {
  type        = string
  description = "Memory for the hosted container."
  default     = "1Gi"
  nullable    = false
}

variable "environment_variables" {
  type        = map(string)
  description = "Additional nonsecret container environment variables."
  default     = {}
}

variable "rai_policy_id" {
  type        = string
  description = "Full ARM resource ID of an existing RAI policy."
  nullable    = false

  validation {
    condition = can(regex(join("", [
      "(?i)^/subscriptions/[^/]+/resourceGroups/[^/]+",
      "/providers/Microsoft\\.CognitiveServices/accounts/[^/]+",
      "/raiPolicies/[^/]+$"
    ]), var.rai_policy_id))
    error_message = "Provide the full account-scoped RAI policy ARM ID."
  }
}

用你自己的值创建 terraform.tfvars:

project_endpoint = "https://<account>.services.ai.azure.com/api/projects/<project>"
agent_name       = "<agent-name>"
image_uri        = "<registry>/<repository>@sha256:<64-character-digest>"

model_deployment_name = "<existing-model-deployment>"
rai_policy_id         = "<full-rai-policy-arm-resource-id>"

cpu    = "0.5"
memory = "1Gi"

替换每一个占位符。使用你的 Foundry 项目能够拉取的镜像;摘要本身不会配置注册表权限。从平台配置或策略资源取得完整的 rai_policy_id:

/subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.CognitiveServices/accounts/<account>/raiPolicies/<policy-name>

供团队使用时,在运行部署之前配置安全的远程后端。为各环境使用分开的状态键,并限制对状态和已保存计划的访问。请参阅在 Azure 存储中存放 Terraform 状态。

定义托管智能体

创建 main.tf。parent_id 是去掉 https:// 的项目端点,而不是项目的 Azure 资源管理器 ID。

locals {
  project_endpoint = trimsuffix(var.project_endpoint, "/")

  agent_definition = {
    kind = "hosted"
    container_configuration = {
      image = var.image_uri
    }
    cpu    = var.cpu
    memory = var.memory
    protocol_versions = [
      {
        protocol = "responses"
        version  = "2.0.0"
      }
    ]
    environment_variables = merge(var.environment_variables, {
      AZURE_AI_MODEL_DEPLOYMENT_NAME = var.model_deployment_name
    })
    rai_config = {
      rai_policy_name = var.rai_policy_id
    }
  }
}

resource "azapi_data_plane_resource" "hosted_agent" {
  type      = "Microsoft.Foundry/agents@v1"
  name      = var.agent_name
  parent_id = trimprefix(local.project_endpoint, "https://")

  body = {
    name       = var.agent_name
    definition = local.agent_definition
  }

  response_export_values = ["versions.latest.version"]

  lifecycle {
    prevent_destroy = true

    precondition {
      condition = contains(
        ["0.5:1Gi", "1:2Gi", "2:4Gi"], "${var.cpu}:${var.memory}"
      )
      error_message = "Choose a supported CPU and memory pair."
    }
  }
}

默认输入使用文档中的沙箱规格。把 cpu 和 memory 一起改成另一组受支持的配对。如果容器期望不同的环境变量名,就更改 AZURE_AI_MODEL_DEPLOYMENT_NAME。该变量选择一个已有的模型部署;它不会创建一个。

这些字段使用 Foundry REST 架构,而不是 azure.yaml 架构。尽管名称如此,托管智能体字段 rai_policy_name 取的是 RAI 策略的完整 Azure 资源管理器资源 ID,而不是裸策略名。示例要求 rai_policy_id,并把它传给该字段。

确认该策略存在于账户上,并测试内容安全过滤。即使无效的策略引用阻止了过滤,版本仍可能报告 active。就绪并不能证明护栏有效。

不要把机密直接放进环境变量映射。Terraform 状态可能保留配置值。关于运行时解析机密,请参阅在环境变量中引用项目连接。

显式的响应导出使版本可通过 output.versions.latest.version 取得。如果没有它,不要假定提供程序会导出智能体响应。

增加就绪门禁

资源写入成功并不能证明托管版本处于活动状态。请单独检查该版本的状态。

提供程序会重新读取具名智能体以填充其输出。它的 latest 值并不是本次部署所创建版本的原子回执。保持独占的部署所有权,只捕获该值一次,并在把它用于测试或提升之前验证候选。

创建 scripts 目录,并把以下内容保存为 scripts/Get-HostedAgentStatus.ps1:

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

function Get-RequiredEnvironmentValue([string] $Name) {
    $value = [Environment]::GetEnvironmentVariable($Name)
    if ([string]::IsNullOrWhiteSpace($value)) {
        throw "Missing required environment variable: $Name."
    }
    return $value
}

function Get-PositiveInteger([string] $Name) {
    $value = Get-RequiredEnvironmentValue $Name
    $number = 0
    if (-not [int]::TryParse($value, [ref] $number) -or $number -le 0) {
        throw "$Name must be a positive integer."
    }
    return $number
}

$endpoint = Get-RequiredEnvironmentValue "PROJECT_ENDPOINT"
$name = Get-RequiredEnvironmentValue "AGENT_NAME"
$version = Get-RequiredEnvironmentValue "AGENT_VERSION"
$expectedImage = Get-RequiredEnvironmentValue "EXPECTED_IMAGE_URI"
$timeout = Get-PositiveInteger "TIMEOUT_SECONDS"
$interval = Get-PositiveInteger "POLL_INTERVAL_SECONDS"

$projectUri = $null
if (-not [Uri]::TryCreate($endpoint, [UriKind]::Absolute,
        [ref] $projectUri) -or
    $projectUri.Scheme -ne "https" -or
    $projectUri.UserInfo -or $projectUri.Query -or $projectUri.Fragment) {
    throw "PROJECT_ENDPOINT must be an HTTPS endpoint without credentials."
}

$agentPath = [Uri]::EscapeDataString($name)
$versionPath = [Uri]::EscapeDataString($version)
$url = $endpoint.TrimEnd("/") +
    "/agents/$agentPath/versions/${versionPath}?api-version=v1"

$tokenOutput = & az account get-access-token `
    --resource https://ai.azure.com --query accessToken `
    --output tsv --only-show-errors 2>$null
if ($LASTEXITCODE -ne 0) {
    throw "Azure CLI token acquisition failed. Check the runner sign-in."
}
$token = ($tokenOutput -join "").Trim()
if ([string]::IsNullOrWhiteSpace($token)) {
    throw "Azure CLI returned an empty access token."
}
$headers = @{ Authorization = "Bearer $token" }
$clock = [Diagnostics.Stopwatch]::StartNew()

while ($clock.Elapsed.TotalSeconds -lt $timeout) {
    $remaining = $timeout - $clock.Elapsed.TotalSeconds
    $requestTimeout = [int][Math]::Min(30, [Math]::Ceiling($remaining))
    $response = $null
    $delay = [double] $interval

    try {
        $response = Invoke-WebRequest -Method Get -Uri $url `
            -Headers $headers -TimeoutSec $requestTimeout `
            -MaximumRedirection 0 -SkipHttpErrorCheck
    }
    catch [System.Net.Http.HttpRequestException],
          [System.OperationCanceledException] {
        Write-Host "Transient status-request failure; retrying."
    }

    if ($clock.Elapsed.TotalSeconds -ge $timeout) {
        break
    }

    if ($null -ne $response) {
        $httpStatus = [int] $response.StatusCode
        if ($httpStatus -eq 200) {
            try {
                $body = ConvertFrom-Json -InputObject $response.Content `
                    -AsHashtable
            }
            catch [System.ArgumentException] {
                throw "The version endpoint returned invalid JSON."
            }
            if ($body -isnot [System.Collections.IDictionary] -or
                -not $body.Contains("status") -or
                $body["status"] -isnot [string]) {
                throw "The version response is missing a valid status."
            }

            $status = $body["status"]
            switch -CaseSensitive ($status) {
                "active" {
                    $image = $body.definition.container_configuration.image
                    if ($image -cne $expectedImage) {
                        throw "Candidate image doesn't match the release."
                    }
                    Write-Host "Candidate version is active; image matches."
                    exit 0
                }
                "creating" { Write-Host "Candidate is creating." }
                "failed" { throw "Candidate provisioning failed." }
                "deleting" { throw "Candidate is being deleted." }
                "deleted" { throw "Candidate has been deleted." }
                default { throw "Unrecognized version status." }
            }
        }
        elseif ($httpStatus -in @(408, 429, 500, 502, 503, 504)) {
            Write-Host "Transient HTTP $httpStatus; retrying."
            $retryAfter = [string] $response.Headers["Retry-After"]
            $retrySeconds = 0
            $retryDate = [DateTimeOffset]::MinValue
            if ([int]::TryParse($retryAfter, [ref] $retrySeconds)) {
                $delay = [Math]::Max($delay, $retrySeconds)
            }
            elseif ([DateTimeOffset]::TryParse($retryAfter,
                    [ref] $retryDate)) {
                $seconds = ($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds
                $delay = [Math]::Max($delay, $seconds)
            }
        }
        else {
            throw "Version lookup failed with HTTP $httpStatus."
        }
    }

    $remaining = $timeout - $clock.Elapsed.TotalSeconds
    if ($remaining -gt 0) {
        Start-Sleep -Seconds ([Math]::Min($delay, $remaining))
    }
}

throw "Timed out waiting for the candidate version to become active."

该脚本在开始有界的状态轮询之前取得令牌。它对选定的瞬时错误重试,拒绝永久性 HTTP 失败,并在失败、删除、畸形响应或超时退出为不成功。它不打印令牌或响应体。

镜像检查是额外的保障,不能代替独占所有权,也不能代替检查候选定义的其余部分。

把门禁加到 main.tf:

resource "terraform_data" "hosted_agent_status" {
  triggers_replace = {
    endpoint = local.project_endpoint
    name     = var.agent_name
    version = tostring(
      azapi_data_plane_resource.hosted_agent.output.versions.latest.version
    )
  }

  provisioner "local-exec" {
    working_dir = path.module
    interpreter = ["pwsh", "-NoProfile", "-NonInteractive", "-Command"]
    command     = "& './scripts/Get-HostedAgentStatus.ps1'"

    environment = {
      PROJECT_ENDPOINT = local.project_endpoint
      AGENT_NAME       = var.agent_name
      AGENT_VERSION = tostring(
        azapi_data_plane_resource.hosted_agent.output.versions.latest.version
      )
      EXPECTED_IMAGE_URI    = var.image_uri
      TIMEOUT_SECONDS       = "600"
      POLL_INTERVAL_SECONDS = "10"
    }
  }
}

按你的流水线调整超时和轮询间隔。该超时是一个有界示例,不是服务就绪保证。

terraform_data 在其触发器变化时运行这个门禁。它不是持续的健康监控。当你需要在批准等待之后重新检查候选时,把同一脚本作为流水线检查再运行一次。

创建 outputs.tf:

output "agent_version" {
  description = "Captured version from this serialized deployment."
  value = tostring(
    azapi_data_plane_resource.hosted_agent.output.versions.latest.version
  )
  depends_on = [terraform_data.hosted_agent_status]
}

参考:Terraform data 资源、local-exec 预配器,以及托管版本状态。

应用并验证部署

只有在审查了其资源所有权,并且对于已有生产智能体已把生产版本固定之后,才应用此配置。

初始化并验证完整配置:

terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplan

审查计划。如果它替换已有智能体,或更改预期发布之外的资源,就停止。

应用经过审查的计划:

terraform apply tfplan
terraform output -raw agent_version

一次成功的 apply 包含针对新候选的就绪门禁。把返回的版本保存为该发布的候选标识符。保护已保存的计划,不要把它放进源代码管理。

检查那一个确切版本,并确认其镜像和配置与预期发布一致。在另一个写入方已经能够访问该智能体之后,不要再去重新发现 latest。

创建一个固定到候选版本的新会话。使用返回的会话 ID 调用它,并断言协议成功完成以及响应内容符合预期。

确认该版本引用了预期的完整护栏策略 ID,并测试其过滤行为。

就绪确认版本处于活动状态。应用级冒烟测试检查它是否执行预期任务。如果就绪、应用或护栏检查失败,不要提升候选。

在 CI/CD 中使用该配置

把流水线分成基础设施与定义应用、候选测试、批准和提升。把捕获的候选标识符和预期镜像摘要一起作为发布记录保存。

如果平台团队和应用团队使用分开的 Terraform 配置,也要把它们的状态和权限分开。把经过评审的平台输出传入智能体配置。仅应用的发布不需要重新应用未变化的共享基础设施。

对于无人值守执行:

  • 在运行器上安装经过评审的 Terraform 版本、PowerShell 和 Azure CLI。
  • 为提供程序、后端和 CLI 身份验证配置 OIDC 或其他已批准的工作负载身份。不要依赖开发人员缓存的登录。
  • 使用安全的远程状态,并在所有写入方之间串行化整个发布,而不只是 apply 步骤。
  • 在批准之后重新检查候选就绪和生产选择器。
  • 显式提升已测试的版本,并保留前一版本以便回滚。

上面的 Terraform 配置拥有智能体定义。端点提升的所有权要单独协调,并验证随后的 refresh 和 apply 不会把选择器改回去。不要让两套独立配置管理相同字段。

排查与清理

使用状态和 HTTP 诊断,不要把完整定义或凭据打印到 CI 日志中。

症状动作
缺少版本输出确认 response_export_values 包含 versions.latest.version。
令牌获取失败在与预配器相同的运行器上下文中认证 Azure CLI。
HTTP 401 或 403检查令牌受众以及部署身份的数据平面角色。
候选达到 failed检查该特定版本和可用诊断。检查镜像访问、运行时兼容性和配置。
候选镜像不匹配停止发布,并调查其他写入方或错误输入。不要提升所观察到的最新版本。
智能体处于活动状态但过滤不工作验证策略存在,在 rai_policy_name 中使用其完整 Azure 资源管理器资源 ID,并与就绪分开测试过滤。
就绪超时在重试之前检查该版本。不要把 Terraform 资源创建成功当作就绪。
销毁被阻止prevent_destroy 有意阻止删除或替换智能体。在更改它之前审查所有权和保留要求。

删除具名智能体会删除其版本,并且可能被活动会话阻止。这不是回滚。对于可丢弃的部署,审查智能体删除过程,解决会话依赖,并且只有在批准该删除之后才移除 prevent_destroy。不要把强制删除生产会话当作常规发布的一部分。 PRIMARY_SOURCE: https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent-terraform

  • #Foundry
  • #Terraform
  • #托管智能体
  • #AzAPI
  • #就绪门禁