用 Terraform 部署 Microsoft Foundry 托管智能体
把 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
- #就绪门禁


