PowerShell 技能连载 - ARM 模板部署

适用于 PowerShell 5.1 及以上版本

Azure Resource Manager(ARM)模板是微软 Azure 平台原生的基础设施即代码(Infrastructure as Code,IaC)解决方案。通过 JSON 格式声明式地定义云资源,团队可以在版本控制系统中追踪每一次基础设施变更,实现与应用代码同等严谨的发布流程。

在实际运维中,手动点击 Azure 门户创建资源既容易出错,也难以在多环境间保持一致。ARM 模板配合 PowerShell 的 Az 模块,能够一键完成从资源组、虚拟网络到虚拟机的完整环境搭建,并且天然支持幂等部署——无论执行多少次,最终状态始终一致。

本文将从零开始演示如何用 PowerShell 编写、参数化和部署 ARM 模板,涵盖模板验证、增量部署以及多环境参数管理等常见场景。

基础:部署一个简单的 ARM 模板

我们先从最简单的存储账户部署开始。ARM 模板是一个 JSON 文件,包含 $schemacontentVersionresources 等固定节。下面是一个最小化的存储账户模板和对应的参数文件。

首先创建模板文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
$templateJson = @'
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"storageAccountName": {
"type": "string",
"metadata": {
"description": "存储账户名称,全局唯一"
}
},
"location": {
"type": "string",
"defaultValue": "[resourceGroup().location]",
"metadata": {
"description": "资源位置,默认为资源组所在区域"
}
}
},
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-05-01",
"name": "[parameters('storageAccountName')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard_LRS"
},
"kind": "StorageV2"
}
]
}
'@

$templateJson | Set-Content -Path '.\storage-template.json' -Encoding UTF8

然后创建参数文件,为不同环境提供不同的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
$parametersJson = @'
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"storageAccountName": {
"value": "stpowershelldemo001"
}
}
}
'@

$parametersJson | Set-Content -Path '.\storage-parameters.json' -Encoding UTF8

执行部署:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 连接到 Azure(如果尚未登录)
Connect-AzAccount

# 选择目标订阅
$context = Get-AzSubscription | Where-Object { $_.Name -eq 'MySubscription' }
Set-AzContext -SubscriptionId $context.Id

# 创建资源组
New-AzResourceGroup -Name 'rg-demo' -Location 'eastasia' -Force

# 部署 ARM 模板
$deployment = New-AzResourceGroupDeployment `
-ResourceGroupName 'rg-demo' `
-TemplateFile '.\storage-template.json' `
-TemplateParameterFile '.\storage-parameters.json' `
-Mode Incremental

$deployment

执行结果示例:

1
2
3
4
5
6
7
8
9
10
DeploymentName          : storage-template
ResourceGroupName : rg-demo
ProvisioningState : Succeeded
Timestamp : 2025-11-27 08:15:32
Mode : Incremental
TemplateParameterString :
Name Type Value
==================== ====== ==========
storageAccountName String stpowershelldemo001
location String eastasia

ProvisioningStateSucceeded 表示部署成功。-Mode Incremental 意味着模板中定义的资源会被创建或更新,但不会删除资源组中已有的其他资源。

进阶:参数化多环境部署

在企业实践中,开发、测试、生产三套环境的配置各不相同。与其维护多份参数文件,不如用 PowerShell 的哈希表动态生成参数,实现一套模板走天下。

下面我们构建一个包含虚拟网络和子网的模板,并用 PowerShell 哈希表为不同环境注入参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 定义环境配置
$environments = @{
dev = @{
ResourceGroupName = 'rg-dev-network'
Location = 'eastasia'
VnetName = 'vnet-dev'
AddressPrefix = '10.0.0.0/16'
SubnetName = 'snet-dev-default'
SubnetPrefix = '10.0.1.0/24'
}
staging = @{
ResourceGroupName = 'rg-staging-network'
Location = 'eastasia'
VnetName = 'vnet-staging'
AddressPrefix = '10.1.0.0/16'
SubnetName = 'snet-staging-default'
SubnetPrefix = '10.1.1.0/24'
}
prod = @{
ResourceGroupName = 'rg-prod-network'
Location = 'eastasia'
VnetName = 'vnet-prod'
AddressPrefix = '10.2.0.0/16'
SubnetName = 'snet-prod-default'
SubnetPrefix = '10.2.1.0/24'
}
}

# 目标环境
$envName = 'dev'
$envConfig = $environments[$envName]

# 构建模板参数哈希表
$templateParams = @{
vnetName = $envConfig.VnetName
addressPrefix = $envConfig.AddressPrefix
subnetName = $envConfig.SubnetName
subnetPrefix = $envConfig.SubnetPrefix
location = $envConfig.Location
}

Write-Host "正在部署 [$envName] 环境的网络资源..." -ForegroundColor Cyan
Write-Host " 资源组: $($envConfig.ResourceGroupName)"
Write-Host " VNet: $($envConfig.VnetName) ($($envConfig.AddressPrefix))"
Write-Host " Subnet: $($envConfig.SubnetName) ($($envConfig.SubnetPrefix))"

# 确保资源组存在
New-AzResourceGroup -Name $envConfig.ResourceGroupName `
-Location $envConfig.Location -Force | Out-Null

# 使用哈希表参数直接部署(无需参数文件)
$result = New-AzResourceGroupDeployment `
-ResourceGroupName $envConfig.ResourceGroupName `
-TemplateFile '.\vnet-template.json' `
@templateParams `
-Mode Incremental

Write-Host "部署状态: $($result.ProvisioningState)" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
正在部署 [dev] 环境的网络资源...
资源组: rg-dev-network
VNet: vnet-dev (10.0.0.0/16)
Subnet: snet-dev-default (10.0.1.0/24)
部署状态: Succeeded

这种方式的妙处在于切换环境只需修改 $envName 变量,所有配置自动跟随变化。配合 CI/CD 管道中的环境变量,可以轻松实现自动化多环境发布。

高级:模板验证与批量部署

在真正执行部署之前,先用 Test-AzResourceGroupDeployment 进行干跑验证,能够在不创建任何资源的情况下检查模板语法和参数是否正确。这在批量部署多个关联模板时尤其重要,可以提前发现错误,避免半途而废。

下面展示一个完整的批量部署流程,包含预验证、逐模板部署和结果汇总:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# 定义需要部署的模板列表
$templates = @(
@{
Name = '网络基础'
Template = '.\templates\vnet.json'
Params = '.\parameters\vnet.dev.json'
}
@{
Name = '存储账户'
Template = '.\templates\storage.json'
Params = '.\parameters\storage.dev.json'
}
@{
Name = '虚拟机'
Template = '.\templates\vm.json'
Params = '.\parameters\vm.dev.json'
}
)

$resourceGroupName = 'rg-dev-infra'
$deploymentResults = @()

# 第一步:批量验证所有模板
Write-Host '=' * 60 -ForegroundColor DarkGray
Write-Host '第一阶段:模板验证' -ForegroundColor Cyan
Write-Host '=' * 60 -ForegroundColor DarkGray

$allValid = $true

foreach ($item in $templates) {
Write-Host "`n验证: $($item.Name)..." -NoNewline

$errorMessages = @()
$testResult = Test-AzResourceGroupDeployment `
-ResourceGroupName $resourceGroupName `
-TemplateFile $item.Template `
-TemplateParameterFile $item.Params `
-ErrorAction SilentlyContinue `
-ErrorVariable errorMessages

if ($errorMessages.Count -eq 0) {
Write-Host ' 通过' -ForegroundColor Green
} else {
Write-Host ' 失败' -ForegroundColor Red
foreach ($msg in $errorMessages) {
Write-Host " 错误: $msg" -ForegroundColor Red
}
$allValid = $false
}
}

# 第二步:如果全部通过,执行批量部署
if ($allValid) {
Write-Host "`n$('=' * 60)" -ForegroundColor DarkGray
Write-Host '第二阶段:执行部署' -ForegroundColor Cyan
Write-Host '=' * 60 -ForegroundColor DarkGray

foreach ($item in $templates) {
Write-Host "`n部署: $($item.Name)..." -ForegroundColor Yellow

$deployResult = New-AzResourceGroupDeployment `
-ResourceGroupName $resourceGroupName `
-TemplateFile $item.Template `
-TemplateParameterFile $item.Params `
-Mode Incremental

$deploymentResults += [PSCustomObject]@{
Name = $item.Name
Status = $deployResult.ProvisioningState
Timestamp = $deployResult.Timestamp
Template = Split-Path $item.Template -Leaf
}

if ($deployResult.ProvisioningState -eq 'Succeeded') {
Write-Host " 完成 ($($deployResult.Timestamp))" -ForegroundColor Green
} else {
Write-Host " 失败" -ForegroundColor Red
break
}
}
} else {
Write-Host "`n验证未全部通过,终止部署。请修复上述错误后重试。" -ForegroundColor Red
}

# 汇总输出
Write-Host "`n$('=' * 60)" -ForegroundColor DarkGray
Write-Host '部署结果汇总' -ForegroundColor Cyan
Write-Host '=' * 60 -ForegroundColor DarkGray

$deploymentResults | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
============================================================
第一阶段:模板验证
============================================================

验证: 网络基础... 通过
验证: 存储账户... 通过
验证: 虚拟机... 通过

============================================================
第二阶段:执行部署
============================================================

部署: 网络基础...
完成 (2025-11-27 08:22:15)

部署: 存储账户...
完成 (2025-11-27 08:22:48)

部署: 虚拟机...
完成 (2025-11-27 08:24:03)

============================================================
部署结果汇总
============================================================

Name Status Timestamp Template
---- ------ --------- --------
网络基础 Succeeded 2025-11-27 08:22:15 vnet.json
存储账户 Succeeded 2025-11-27 08:22:48 storage.json
虚拟机 Succeeded 2025-11-27 08:24:03 vm.json

这个脚本有两个关键设计:第一,Test-AzResourceGroupDeployment 在验证阶段不会创建任何真实资源,可以在安全的环境中提前发现问题;第二,部署阶段使用 foreach 遍历模板列表,一旦某个模板部署失败立即 break 退出,避免在错误的基础上继续部署后续资源。

注意事项

  1. 模板语法检查先行:在提交代码前,始终使用 Test-AzResourceGroupDeployment 进行验证。模板 JSON 的语法错误(如缺少逗号、引号不匹配)会导致整个部署失败,而这类错误在验证阶段就能被捕获。

  2. 资源命名规则:Azure 对资源名称有严格限制,例如存储账户名只能包含小写字母和数字,长度 3-24 个字符。建议在参数文件中使用命名前缀 + 环境缩写 + 序号的规则(如 st + dev + 001),并在 PowerShell 中用 -match 正则表达式做前置校验。

  3. 幂等性依赖模板设计:ARM 模板本身支持幂等部署,但前提是模板中完整定义了资源的所有关键属性。如果只定义了 namelocation 而省略了 sku,多次部署可能不会报错,但资源配置可能不是预期的最终状态。

  4. 增量模式与完整模式的区别-Mode Incremental 是安全的默认选择,它只处理模板中声明的资源。-Mode Complete 会删除资源组中所有未在模板中声明的资源,生产环境慎用。建议在脚本中显式指定 -Mode,不要依赖默认值。

  5. 大模板拆分为链接模板:当模板超过 200 行或包含 10 个以上资源时,建议使用链接模板(Linked Templates)将不同层(网络、存储、计算)拆分为独立文件,由主模板统一编排。这样既降低单文件复杂度,也便于团队分工维护。

  6. 敏感参数使用 Key Vault 引用:虚拟机密码、数据库连接字符串等敏感信息不要明文写在参数文件中。在参数文件的 value 字段中使用 Key Vault 引用格式("reference" + "keyVault" + "secretName"),部署时 PowerShell 会自动从 Azure Key Vault 安全读取。

PowerShell 技能连载 - Terraform 集成

适用于 PowerShell 7.0 及以上版本(跨平台)

为什么要用 PowerShell 桥接 Terraform

Terraform 是目前最主流的基础设施即代码(IaC)工具,它通过声明式的 HCL 语言定义云资源,并用 terraform planterraform apply 实现可重复、可审计的部署流程。但 Terraform 本身是一个命令行工具,它不擅长处理动态逻辑、条件判断和外部系统集成。当部署流程涉及读取配置中心、调用审批 API、解析 CSV 数据源或发送通知等操作时,纯 HCL 的实现会变得笨重且难以维护。

PowerShell 作为跨平台的任务自动化框架,恰好弥补了这一空白。通过将 PowerShell 与 Terraform CLI 组合使用,可以用 PowerShell 编排整个部署生命周期:动态生成 tfvars 文件、在 apply 前插入审批检查、解析 plan 输出并生成可读报告、甚至实现漂移检测与自动修复。本文将围绕三个典型场景,演示如何用 PowerShell 脚本高效集成 Terraform。

动态生成 Terraform 配置并执行部署

在实际项目中,环境参数(实例规格、区域、节点数量等)通常存储在外部系统中,例如 JSON 配置文件、Consul KV 或者是 REST API 返回的结果。下面的脚本演示如何从 JSON 配置文件读取环境参数,动态生成 Terraform 变量文件,然后自动执行完整的 initplanapply 流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# 环境配置文件路径
$configPath = Join-Path $PSScriptRoot "environments.json"
$tfWorkDir = Join-Path $PSScriptRoot "terraform"

# 读取环境配置
$environments = Get-Content -Path $configPath -Raw | ConvertFrom-Json

# 选择目标环境
$targetEnv = "staging"
$envConfig = $environments.$targetEnv

if (-not $envConfig) {
throw "未找到环境配置: $targetEnv"
}

Write-Host "目标环境: $targetEnv" -ForegroundColor Cyan
Write-Host "区域: $($envConfig.location) | 实例规格: $($envConfig.vm_size) | 节点数: $($envConfig.node_count)"

# 动态生成 terraform.tfvars.json
$tfVars = @{
environment = $targetEnv
location = $envConfig.location
vm_size = $envConfig.vm_size
node_count = $envConfig.node_count
tags = @{
ManagedBy = "PowerShell"
CreatedAt = (Get-Date -Format "yyyy-MM-dd")
CostCenter = $envConfig.cost_center
}
}

$tfVarsPath = Join-Path $tfWorkDir "terraform.tfvars.json"
$tfVars | ConvertTo-Json -Depth 5 | Set-Content -Path $tfVarsPath -Encoding UTF8
Write-Host "[OK] 已生成变量文件: $tfVarsPath" -ForegroundColor Green

# 执行 Terraform 初始化
Write-Host "`n>>> terraform init" -ForegroundColor Yellow
$initResult = terraform -chdir=$tfWorkDir init -input=false -no-color 2>&1
$initExit = $LASTEXITCODE
Write-Host $initResult

if ($initExit -ne 0) {
throw "Terraform init 失败,退出码: $initExit"
}

# 执行 Plan
Write-Host "`n>>> terraform plan" -ForegroundColor Yellow
$planFile = Join-Path $tfWorkDir "current.tfplan"
$planResult = terraform -chdir=$tfWorkDir plan -out=$planFile -var-file="terraform.tfvars.json" -no-color 2>&1
$planExit = $LASTEXITCODE
Write-Host $planResult

if ($planExit -ne 0) {
throw "Terraform plan 失败,退出码: $planExit"
}

# 解析 Plan 摘要
$addCount = if ($planResult -match '(\d+) to add') { $Matches[1] } else { "0" }
$changeCount = if ($planResult -match '(\d+) to change') { $Matches[1] } else { "0" }
$destroyCount = if ($planResult -match '(\d+) to destroy') { $Matches[1] } else { "0" }

Write-Host "`nPlan 摘要: +$addCount ~$changeCount -$destroyCount" -ForegroundColor Cyan

# 安全检查:如果存在销毁操作,要求人工确认
if ($destroyCount -gt 0) {
$confirm = Read-Host "检测到 $destroyCount 个资源将被销毁,是否继续?(yes/no)"
if ($confirm -ne "yes") {
Write-Host "已取消部署" -ForegroundColor Yellow
return
}
}

# 执行 Apply
Write-Host "`n>>> terraform apply" -ForegroundColor Yellow
$applyResult = terraform -chdir=$tfWorkDir apply -auto-approve $planFile -no-color 2>&1
$applyExit = $LASTEXITCODE
Write-Host $applyResult

if ($applyExit -ne 0) {
throw "Terraform apply 失败,退出码: $applyExit"
}

Write-Host "`n[OK] 部署完成!" -ForegroundColor Green
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
目标环境: staging
区域: eastasia | 实例规格: Standard_D2s_v3 | 节点数: 3
[OK] 已生成变量文件: /deploy/terraform/terraform.tfvars.json

>>> terraform init
Initializing the backend...
Initializing provider plugins...
Terraform has been successfully initialized!

>>> terraform plan
Terraform will perform the following actions:
# module.vm.azurerm_linux_virtual_machine.main[0] will be created
# module.vm.azurerm_linux_virtual_machine.main[1] will be created
# module.vm.azurerm_linux_virtual_machine.main[2] will be created
Plan: 3 to add, 0 to change, 0 to destroy.

Plan 摘要: +3 ~0 -0

>>> terraform apply
module.vm.azurerm_linux_virtual_machine.main[0]: Creating...
module.vm.azurerm_linux_virtual_machine.main[0]: Creation complete after 2m35s
module.vm.azurerm_linux_virtual_machine.main[1]: Creating...
module.vm.azurerm_linux_virtual_machine.main[1]: Creation complete after 2m40s
module.vm.azurerm_linux_virtual_machine.main[2]: Creating...
module.vm.azurerm_linux_virtual_machine.main[2]: Creation complete after 2m38s

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

[OK] 部署完成!

解析 Terraform State 生成资源清单

Terraform 的状态文件(terraform.tfstate)记录了所有已部署资源的详细属性。在多环境、多项目的运维场景中,定期导出资源清单有助于成本审计、安全合规检查和资产盘点。下面的脚本演示如何通过 terraform show -json 将状态输出为结构化数据,并用 PowerShell 提取关键字段生成 CSV 报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
function Get-TerraformResourceInventory {
<#
.SYNOPSIS
从 Terraform 状态中提取资源清单并导出报告
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$TerraformDir,

[string]$OutputPath = "./resource-inventory-$(Get-Date -Format 'yyyyMMdd').csv"
)

# 验证 Terraform 工作目录
if (-not (Test-Path (Join-Path $TerraformDir ".terraform"))) {
throw "目录未初始化: $TerraformDir - 请先运行 terraform init"
}

# 获取 JSON 格式的状态
Write-Host "正在读取 Terraform 状态..." -ForegroundColor Cyan
$stateJson = terraform -chdir=$TerraformDir show -json 2>$null

if ($LASTEXITCODE -ne 0) {
throw "无法读取 Terraform 状态。请确认已成功执行过 terraform apply"
}

$state = $stateJson | ConvertFrom-Json

# 提取资源信息
$inventory = @()

foreach ($resource in $state.values.root_module.resources) {
# 根据资源类型提取不同的关键属性
$keyAttrs = switch -Regex ($resource.type) {
'azurerm_linux_virtual_machine|azurerm_windows_virtual_machine' {
@{
'VM 名称' = $resource.values.name
'VM 规格' = $resource.values.size
'区域' = $resource.values.location
'资源组' = $resource.values.resource_group_name
}
}
'azurerm_storage_account' {
@{
'存储账户' = $resource.values.name
'SKU' = $resource.values.account_tier
'复制策略' = $resource.values.account_replication_type
'区域' = $resource.values.location
}
}
'azurerm_virtual_network' {
@{
'VNet 名称' = $resource.values.name
'地址空间' = ($resource.values.address_space -join ', ')
'区域' = $resource.values.location
}
}
default {
@{
'名称' = $resource.values.name
'属性' = "N/A"
}
}
}

$row = [ordered]@{
资源类型 = $resource.type
资源名称 = $resource.name
模块路径 = if ($resource.module) { $resource.module } else { "root" }
提供者 = ($resource.provider_name -split '::')[-1]
依赖数量 = if ($resource.depends_on) { $resource.depends_on.Count } else { 0 }
模式 = $resource.mode
}

# 合并类型特定的属性
foreach ($key in $keyAttrs.Keys) {
$row[$key] = $keyAttrs[$key]
}

$inventory += [PSCustomObject]$row
}

# 处理子模块中的资源
if ($state.values.root_module.child_modules) {
foreach ($child in $state.values.root_module.child_modules) {
foreach ($resource in $child.resources) {
$row = [ordered]@{
资源类型 = $resource.type
资源名称 = $resource.name
模块路径 = $child.address
提供者 = ($resource.provider_name -split '::')[-1]
依赖数量 = if ($resource.depends_on) { $resource.depends_on.Count } else { 0 }
模式 = $resource.mode
}
$inventory += [PSCustomObject]$row
}
}
}

# 输出统计信息
$typeSummary = $inventory | Group-Object 资源类型 | Sort-Object Count -Descending

Write-Host "`n资源统计:" -ForegroundColor Cyan
foreach ($group in $typeSummary) {
Write-Host (" {0,-45} {1,3} 个" -f $group.Name, $group.Count)
}
Write-Host (" {0,-45} {1,3} 个" -f "总计", $inventory.Count)

# 导出到 CSV
$inventory | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Host "`n[OK] 资源清单已导出: $OutputPath" -ForegroundColor Green

return $inventory
}

# 执行资源盘点
$inventory = Get-TerraformResourceInventory -TerraformDir "./terraform" -Verbose

# 筛选需要关注的资源(如公网 IP、未加密存储等)
$publicResources = $inventory | Where-Object {
$_.资源类型 -match 'public_ip|lb$'
}

if ($publicResources.Count -gt 0) {
Write-Host "`n[WARN] 发现 $($publicResources.Count) 个公网暴露资源,请检查安全策略:" -ForegroundColor Yellow
foreach ($pr in $publicResources) {
Write-Host " - $($pr.资源类型): $($pr.资源名称)" -ForegroundColor Yellow
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
正在读取 Terraform 状态...

资源统计:
azurerm_linux_virtual_machine 3 个
azurerm_network_interface 3 个
azurerm_virtual_network 1 个
azurerm_public_ip 3 个
azurerm_network_security_group 1 个
azurerm_storage_account 1 个
总计 12 个

[OK] 资源清单已导出: ./resource-inventory-20251013.csv

[WARN] 发现 3 个公网暴露资源,请检查安全策略:
- azurerm_public_ip: main_public_ip[0]
- azurerm_public_ip: main_public_ip[1]
- azurerm_public_ip: main_public_ip[2]

配置漂移检测与自动通知

基础设施配置漂移是指实际运行资源的状态与 Terraform 状态文件中记录的期望状态不一致的情况。漂移可能由手动操作、自动化脚本的副作用或外部系统变更引起。下面的脚本将漂移检测封装为一个可定期执行的巡检任务,在发现漂移时自动通过 Webhook 发送告警通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
function Test-TerraformDrift {
<#
.SYNOPSIS
检测 Terraform 管理的基础设施是否存在配置漂移
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]]$WorkDirs,

[string]$WebhookUrl,

[int]$WarningThreshold = 5
)

$allResults = @()

foreach ($dir in $WorkDirs) {
$dirName = Split-Path $dir -Leaf
Write-Host "`n检查工作目录: $dirName" -ForegroundColor Cyan

# 确保 Terraform 已初始化
$initCheck = terraform -chdir=$dir init -backend=false -input=false -no-color 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host " [SKIP] 初始化失败,跳过此目录" -ForegroundColor Red
continue
}

# 执行 Plan(不保存计划文件,仅检测差异)
$planOutput = terraform -chdir=$dir plan -detailed-exitcode -input=false -no-color 2>&1
$exitCode = $LASTEXITCODE

# 详细退出码:0=无变化 1=错误 2=有变化
$driftStatus = switch ($exitCode) {
0 { "无漂移" }
1 { "检测失败" }
2 { "存在漂移" }
default { "未知 ($exitCode)" }
}

# 解析变化数量
$changes = @{
Add = if ($planOutput -match '(\d+) to add') { [int]$Matches[1] } else { 0 }
Change = if ($planOutput -match '(\d+) to change') { [int]$Matches[1] } else { 0 }
Destroy = if ($planOutput -match '(\d+) to destroy'){ [int]$Matches[1] } else { 0 }
}
$totalChanges = $changes.Add + $changes.Change + $changes.Destroy

# 提取具体变化的资源列表
$changedResources = @()
$planLines = $planOutput -split "`n"
foreach ($line in $planLines) {
if ($line -match '#\s+(\S+)\s+will be\s+(created|updated|deleted|replaced)') {
$changedResources += [PSCustomObject]@{
Resource = $Matches[1]
Action = $Matches[2]
}
}
}

$result = [PSCustomObject]@{
工作目录 = $dirName
漂移状态 = $driftStatus
新增 = $changes.Add
变更 = $changes.Change
销毁 = $changes.Destroy
总变化数 = $totalChanges
检测时间 = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
变化资源 = $changedResources
}

$allResults += $result

# 控制台输出
$color = if ($totalChanges -eq 0) { "Green" } elseif ($totalChanges -lt $WarningThreshold) { "Yellow" } else { "Red" }
Write-Host " 状态: $driftStatus | 变化: +$($changes.Add) ~$($changes.Change) -$($changes.Destroy)" -ForegroundColor $color
}

# 汇总报告
$driftCount = ($allResults | Where-Object { $_.漂移状态 -eq "存在漂移" }).Count
$summary = [PSCustomObject]@{
检测目录数 = $WorkDirs.Count
正常目录数 = $WorkDirs.Count - $driftCount
漂移目录数 = $driftCount
总变化资源 = ($allResults | Measure-Object -Property 总变化数 -Sum).Sum
检测完成时间 = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}

Write-Host "`n========== 漂移检测汇总 ==========" -ForegroundColor Cyan
Write-Host " 检测目录: $($summary.检测目录数) 个"
Write-Host " 正常: $($summary.正常目录数) | 漂移: $($summary.漂移目录数)"
Write-Host " 总变化资源数: $($summary.总变化资源)"

# 发送 Webhook 通知
if ($WebhookUrl -and $driftCount -gt 0) {
$severity = if ($summary.总变化资源 -ge $WarningThreshold) { "HIGH" } else { "MEDIUM" }

$payload = @{
text = "[Terraform 漂移告告警] 级别: $severity | 漂移目录: $driftCount/$($WorkDirs.Count) | 变化资源: $($summary.总变化资源) 个"
sections = @()
}

foreach ($res in ($allResults | Where-Object { $_.漂移状态 -eq "存在漂移" })) {
$payload.sections += @{
activityTitle = "目录: $($res.工作目录)"
facts = @(
@{ name = "新增"; value = $res.新增 },
@{ name = "变更"; value = $res.变更 },
@{ name = "销毁"; value = $res.销毁 }
)
}
}

$body = $payload | ConvertTo-Json -Depth 5 -Compress
try {
Invoke-RestMethod -Uri $WebhookUrl -Method Post -Body $body -ContentType "application/json" -ErrorAction Stop
Write-Host "[OK] Webhook 通知已发送" -ForegroundColor Green
}
catch {
Write-Host "[WARN] Webhook 发送失败: $($_.Exception.Message)" -ForegroundColor Yellow
}
}

return $allResults
}

# 定期巡检:检查多个 Terraform 工作目录
$workDirs = @(
"/infra/terraform/networking"
"/infra/terraform/compute"
"/infra/terraform/storage"
)

$driftReport = Test-TerraformDrift `
-WorkDirs $workDirs `
-WebhookUrl "https://hooks.example.com/infra-alerts" `
-WarningThreshold 5

# 导出历史记录
$historyPath = "./drift-history-$(Get-Date -Format 'yyyyMMdd').json"
$driftReport | ConvertTo-Json -Depth 3 | Set-Content -Path $historyPath -Encoding UTF8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
检查工作目录: networking
状态: 无漂移 | 变化: +0 ~0 -0

检查工作目录: compute
状态: 存在漂移 | 变化: +0 ~2 -1

检查工作目录: storage
状态: 无漂移 | 变化: +0 ~0 -0

========== 漂移检测汇总 ==========
检测目录: 3 个
正常: 2 | 漂移: 1
总变化资源数: 3
[OK] Webhook 通知已发送

注意事项

  1. Terraform CLI 依赖:本文所有脚本都依赖本地安装的 Terraform CLI。在 CI/CD 管道中建议使用 tfenvmise 管理版本,确保团队使用相同的 Terraform 版本。版本不一致可能导致状态文件不兼容或 Provider 下载失败。

  2. 状态文件安全terraform show -json 输出的状态数据可能包含敏感信息(数据库密码、连接字符串等)。在生成报告时应避免将敏感字段写入明文 CSV,可通过 -sensitive=false 参数或在 PowerShell 中过滤 sensitive = true 的属性来降低泄露风险。生产环境务必使用远程状态后端(如 Azure Storage、S3)并启用加密。

  3. plan 退出码语义terraform plan -detailed-exitcode 返回三种退出码:0 表示无变化、1 表示执行出错、2 表示存在变更。漂移检测脚本必须区分这三种状态,不能简单地将非零退出码都视为错误,否则会误报正常的 plan 结果。

  4. 跨平台路径处理:PowerShell 7 支持在 Linux 和 macOS 上运行,但 Terraform 的 -chdir 参数在不同操作系统上对路径分隔符有严格要求。统一使用 Join-Path 拼接路径(而非硬编码 /\),可以保证脚本在 Windows 和 Linux 上行为一致。

  5. 并发执行与状态锁:如果同时对多个工作目录执行 terraform apply,且这些目录共享同一个远程状态后端,可能会触发状态锁定冲突。建议使用 -lock-timeout 参数设置合理的等待时间(如 terraform apply -lock-timeout=60s),或通过 PowerShell 的串行控制逻辑确保同一时间只有一个操作访问共享状态。

  6. 错误处理与回滚策略terraform apply 失败时 Terraform 会自动标记 tainted 资源并在下次 apply 时重建。但 PowerShell 脚本层面的错误处理同样重要:应在每次 Terraform CLI 调用后立即检查 $LASTEXITCODE,并在脚本顶层使用 try/catch/finally 确保临时文件(如 .tfplan)被清理,避免残留的计划文件影响下次执行。

PowerShell 技能连载 - Desired State Configuration 实战

适用于 PowerShell 5.1 及以上版本(Windows),DSC 需要 Windows 管理框架

Desired State Configuration(DSC)是 PowerShell 的声明式配置管理框架——你定义系统”应该是什么状态”,DSC 负责将系统调整到目标状态。与命令式脚本(”执行这些步骤”)不同,DSC 声明期望结果(”安装这些角色、创建这些文件、启动这些服务”),引擎自动判断需要做什么。这使得配置管理具有幂等性和可审计性。

本文将讲解 DSC 的核心概念、配置编写,以及实用的服务器配置场景。

DSC 配置基础

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# DSC 配置块
Configuration WebServerSetup {
param([string[]]$MachineName = "localhost")

Node $MachineName {
# 安装 IIS 角色
WindowsFeature IIS {
Ensure = "Present"
Name = "Web-Server"
IncludeAllSubFeature = $true
}

# 确保 Web 管理工具安装
WindowsFeature WebMgmtTools {
Ensure = "Present"
Name = "Web-Mgmt-Tools"
DependsOn = "[WindowsFeature]IIS"
}

# 创建网站目录
File WebsiteRoot {
Ensure = "Present"
DestinationPath = "C:\inetpub\MyApp"
Type = "Directory"
}

# 复制默认页面
File DefaultPage {
Ensure = "Present"
DestinationPath = "C:\inetpub\MyApp\default.htm"
Contents = @"
<!DOCTYPE html>
<html>
<head><title>MyApp</title></head>
<body><h1>MyApp Running</h1><p>DSC Configured</p></body>
</html>
"@
DependsOn = "[File]WebsiteRoot"
}

# 启动 W3SVC 服务
Service W3SVC {
Name = "W3SVC"
State = "Running"
StartupType = "Automatic"
DependsOn = "[WindowsFeature]IIS"
}
}
}

# 编译配置(生成 MOF 文件)
WebServerSetup -OutputPath "C:\DSC\WebServer"

Write-Host "配置已编译,MOF 文件位于 C:\DSC\WebServer" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
配置已编译,MOF 文件位于 C:\DSC\WebServer
Directory: C:\DSC\WebServer

Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 2025-07-24 08:30:15 3456 localhost.mof

应用与测试配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 应用 DSC 配置(推送模式)
Start-DscConfiguration -Path "C:\DSC\WebServer" -Wait -Verbose -Force

# 检查配置状态
Test-DscConfiguration -Path "C:\DSC\WebServer"

# 查看当前配置状态详情
Get-DscConfigurationStatus |
Select-Object Status, StartDate, Type, Mode |
Format-List

# 获取当前 DSC 配置的实际值
Get-DscConfiguration |
Select-Object ConfigurationName, ResourceInstanceName, ResourceModuleName |
Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
PSComputerName  ResourceName  InDesiredState
localhost WindowsFeature True
localhost WindowsFeature True
localhost File True
localhost File True
localhost Service True

Status : Success
StartDate : 2025-07-24 08:30:15
Type : Initial
Mode : Push

完整服务器配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# 综合服务器配置 DSC
Configuration ProductionWebServer {
param(
[string[]]$NodeName = "localhost",
[string]$WebsitePath = "C:\inetpub\MyApp",
[string]$LogPath = "C:\Logs\MyApp"
)

Import-DscResource -ModuleName PSDesiredStateConfiguration
Import-DscResource -ModuleName NetworkingDsc
Import-DscResource -ModuleName xWebAdministration

Node $NodeName {
# Windows 功能
WindowsFeature WebServer {
Ensure = "Present"
Name = "Web-Server"
}

WindowsFeature NetFramework {
Ensure = "Present"
Name = "NET-Framework-45-Core"
}

# 目录结构
File WebsiteDir {
Ensure = "Present"
DestinationPath = $WebsitePath
Type = "Directory"
}

File LogDir {
Ensure = "Present"
DestinationPath = $LogPath
Type = "Directory"
}

# 注册表设置
Registry MaxUrlLength {
Ensure = "Present"
Key = "HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\HTTP\Parameters"
ValueName = "UrlSegmentMaxLength"
ValueData = "0"
ValueType = "Dword"
}

# Windows 防火墙规则
Script FirewallRuleHTTP {
GetScript = { @{ Result = (Get-NetFirewallRule -DisplayName "HTTP" -ErrorAction SilentlyContinue).DisplayName } }
SetScript = { New-NetFirewallRule -DisplayName "HTTP" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow }
TestScript = { (Get-NetFirewallRule -DisplayName "HTTP" -ErrorAction SilentlyContinue) -ne $null }
}

# 服务配置
Service W3SVC {
Name = "W3SVC"
State = "Running"
StartupType = "Automatic"
}

Service WAS {
Name = "WAS"
State = "Running"
StartupType = "Automatic"
}

# 本地配置管理器设置
LocalConfigurationManager {
ConfigurationMode = "ApplyAndAutoCorrect"
ConfigurationModeFrequencyMins = 30
RefreshMode = "Push"
RebootNodeIfNeeded = $true
}
}
}

# 编译
ProductionWebServer -OutputPath "C:\DSC\ProductionWeb" -NodeName "SRV-WEB-01"

# 应用
Start-DscConfiguration -Path "C:\DSC\ProductionWeb" -Wait -Force

Write-Host "生产服务器配置已应用" -ForegroundColor Green

执行结果示例:

1
生产服务器配置已应用

配置漂移检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 定期检查配置漂移
function Test-DscDrift {
param([string]$ComputerName = "localhost")

$result = Test-DscConfiguration -ComputerName $ComputerName -Detailed

$inDesired = ($result.ResourcesInDesiredState | Measure-Object).Count
$notInDesired = ($result.ResourcesNotInDesiredState | Measure-Object).Count

$report = [PSCustomObject]@{
Computer = $ComputerName
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
InDesired = $inDesired
NotInDesired = $notInDesired
Compliant = $notInDesired -eq 0
}

if ($notInDesired -gt 0) {
Write-Host "[$($report.Timestamp)] $ComputerName : 漂移!$notInDesired 项不符合" -ForegroundColor Red
$result.ResourcesNotInDesiredState | ForEach-Object {
Write-Host " - $($_.ResourceId)" -ForegroundColor Yellow
}
} else {
Write-Host "[$($report.Timestamp)] $ComputerName : 合规($inDesired 项)" -ForegroundColor Green
}

return $report
}

# 批量检查
$servers = @("SRV-WEB-01", "SRV-WEB-02", "SRV-WEB-03")
$reports = foreach ($server in $servers) {
Test-DscDrift -ComputerName $server
}

$reports | Format-Table Computer, InDesired, NotInDesired, Compliant -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
[2025-07-24 09:00:00] SRV-WEB-01 : 合规(8 项)
[2025-07-24 09:00:02] SRV-WEB-02 : 漂移!2 项不符合
- [Service]W3SVC
- [File]DefaultPage
[2025-07-24 09:00:04] SRV-WEB-03 : 合规(8 项)

Computer InDesired NotInDesired Compliant
-------- --------- ------------ ---------
SRV-WEB-01 8 0 True
SRV-WEB-02 6 2 False
SRV-WEB-03 8 0 True

注意事项

  1. 推送 vs 拉取:推送模式(Push)适合小规模环境,拉取模式(Pull)适合大规模环境
  2. 幂等性:DSC 配置应该可以重复执行而不产生副作用,使用 Test 脚本检测当前状态
  3. MOF 文件:MOF 文件可能包含敏感信息(密码),注意安全存储和传输
  4. 依赖关系:使用 DependsOn 控制资源配置顺序,避免依赖循环
  5. 自定义资源:内置资源不满足需求时,可以创建自定义 DSC 资源模块
  6. AutoCorrect 模式ApplyAndAutoCorrect 模式会自动修复漂移,ApplyOnly 模式只检测不修复