PowerShell 技能连载 - Azure App Service 部署槽管理

适用于 PowerShell 7.0 及以上版本,需要 Az.Websites 模块

部署槽:零停机部署的基石

在现代云原生应用的运维实践中,零停机部署已经成为一项基本要求。传统的”停机发布”模式不仅影响用户体验,还可能导致流量丢失和请求中断。Azure App Service 提供的部署槽(Deployment Slots)功能,正是解决这一问题的利器。

部署槽本质上是应用服务内部的独立运行环境,每个槽拥有自己的主机名、应用设置和连接字符串。通过在”暂存槽(Staging Slot)”完成新版本的部署与预热验证,再将流量无缝切换到生产槽,即可实现蓝绿部署(Blue-Green Deployment)。整个过程对终端用户完全透明,不会出现任何服务中断。

本文将介绍如何使用 PowerShell 和 Az 模块,对 Azure App Service 部署槽进行全生命周期管理,涵盖槽的创建、蓝绿部署自动化、多环境配置与预热验证等核心场景。

部署槽生命周期管理

第一个示例演示部署槽的完整生命周期操作:创建新槽、从已有槽克隆配置、查看所有槽的状态,以及配置流量路由比例。

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
# 连接 Azure 账户并选择订阅
Connect-AzAccount
Set-AzContext -SubscriptionId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'

$ResourceGroup = 'rg-myapp-prod'
$AppName = 'app-myapp-prod'

# 查看当前应用的所有部署槽
Get-AzWebAppSlot -ResourceGroupName $ResourceGroup -Name $AppName |
Select-Object Name, State, DefaultHostName, TrafficManagerPolicies

# 创建新的 Staging 槽,从 Production 克隆配置
New-AzWebAppSlot `
-ResourceGroupName $ResourceGroup `
-Name $AppName `
-Slot 'staging' `
-ConfigurationSource (Get-AzWebApp -ResourceGroupName $ResourceGroup -Name $AppName)

# 为 Staging 槽单独设置应用配置(不与生产槽共享)
$StagingSlot = Get-AzWebAppSlot -ResourceGroupName $ResourceGroup -Name $AppName -Slot 'staging'
$StagingSlot.SiteConfig.AppSettings | ForEach-Object {
Write-Host " $($_.Name) = $($_.Value)"
}

# 配置流量路由:将 10% 的流量发送到 Staging 槽用于金丝雀测试
Set-AzWebAppTrafficRouting `
-ResourceGroupName $ResourceGroup `
-WebAppName $AppName `
-RoutingRule @{ ActionHostName = "$AppName-staging.azurewebsites.net"; ReroutePercentage = 10 }

# 查看当前流量路由配置
Get-AzWebAppTrafficRouting -ResourceGroupName $ResourceGroup -WebAppName $AppName

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
Name     State  DefaultHostName                        TrafficManagerPolicies
---- ----- --------------- ----------------------
Production Running app-myapp-prod.azurewebsites.net
staging Running app-myapp-prod-staging.azurewebsites.net

ASPNETCORE_ENVIRONMENT = Staging
APP_VERSION = 2.1.0
API_ENDPOINT = https://api-staging.example.com

ActionHostName ReroutePercentage
-------------- -----------------
app-myapp-prod-staging.azurewebsites.net 10

蓝绿部署自动化

蓝绿部署是部署槽最核心的使用场景。下面的脚本封装了一个完整的蓝绿部署流程:部署到 Staging 槽、执行预热验证、将流量切换到 Staging、验证生产环境稳定后删除旧的生产版本。

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
function Invoke-BlueGreenDeployment {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ResourceGroup,

[Parameter(Mandatory)]
[string]$AppName,

[Parameter(Mandatory)]
[string]$PackagePath,

[int]$WarmupTimeoutSeconds = 120,

[int]$HealthCheckRetries = 5
)

$StagingUrl = "https://$AppName-staging.azurewebsites.net"
$ProdUrl = "https://$AppName.azurewebsites.net"

# 步骤 1:部署新版本到 Staging 槽
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 正在部署到 Staging 槽..." -ForegroundColor Cyan
Publish-AzWebApp `
-ResourceGroupName $ResourceGroup `
-Name $AppName `
-Slot 'staging' `
-ArchivePath $PackagePath

# 步骤 2:预热并验证 Staging 槽健康状态
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 预热 Staging 槽..." -ForegroundColor Cyan
$Healthy = $false
for ($i = 1; $i -le $HealthCheckRetries; $i++) {
try {
$Response = Invoke-WebRequest -Uri "$StagingUrl/health" -TimeoutSec 10 -UseBasicParsing
if ($Response.StatusCode -eq 200) {
$Healthy = $true
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] Staging 槽健康检查通过 (尝试 $i/$HealthCheckRetries)" -ForegroundColor Green
break
}
} catch {
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 健康检查失败,等待重试 (尝试 $i/$HealthCheckRetries)..." -ForegroundColor Yellow
Start-Sleep -Seconds ([Math]::Min(10 * $i, 30))
}
}

if (-not $Healthy) {
throw "Staging 槽预热失败,终止部署"
}

# 步骤 3:执行槽位交换(Staging → Production)
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 正在交换槽位..." -ForegroundColor Cyan
Invoke-AzResourceAction `
-ResourceGroupName $ResourceGroup `
-ResourceType 'Microsoft.Web/sites/slots' `
-ResourceName "$AppName/staging" `
-Action 'slotsswap' `
-Parameters @{ targetSlot = 'production'; preserveVnet = $true } `
-Force

# 步骤 4:验证生产环境
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 验证生产环境..." -ForegroundColor Cyan
$ProdResponse = Invoke-WebRequest -Uri "$ProdUrl/health" -TimeoutSec 10 -UseBasicParsing
if ($ProdResponse.StatusCode -eq 200) {
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 部署完成!生产环境正常运行" -ForegroundColor Green
} else {
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] 生产环境异常,准备回滚..." -ForegroundColor Red
# 立即交换回 Staging(旧版本仍在 Staging 槽中)
Invoke-AzResourceAction `
-ResourceGroupName $ResourceGroup `
-ResourceType 'Microsoft.Web/sites/slots' `
-ResourceName "$AppName/staging" `
-Action 'slotsswap' `
-Parameters @{ targetSlot = 'production'; preserveVnet = $true } `
-Force
throw "部署后验证失败,已回滚到上一版本"
}
}

# 调用示例
Invoke-BlueGreenDeployment `
-ResourceGroup 'rg-myapp-prod' `
-AppName 'app-myapp-prod' `
-PackagePath './publish/app-myapp-2.2.0.zip' `
-HealthCheckRetries 5

执行结果示例:

1
2
3
4
5
6
7
[10:15:32] 正在部署到 Staging 槽...
[10:15:58] 预热 Staging 槽...
[10:16:05] 健康检查失败,等待重试 (尝试 1/5)...
[10:16:25] Staging 槽健康检查通过 (尝试 2/5)
[10:16:25] 正在交换槽位...
[10:16:48] 验证生产环境...
[10:16:49] 部署完成!生产环境正常运行

多环境配置与预热验证

实际生产中,不同部署槽往往需要差异化的配置。下面的脚本展示了如何为每个槽配置独立的应用设置、连接字符串,并在部署前执行完整的预热验证流程。

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
# 定义各槽的差异化配置
$SlotConfigs = @{
staging = @{
AppSettings = @{
ASPNETCORE_ENVIRONMENT = 'Staging'
APP_VERSION = '2.2.0'
API_ENDPOINT = 'https://api-staging.example.com'
LOG_LEVEL = 'Debug'
FEATURE_FLAGS = '{"NewDashboard":true,"BetaAPI":true}'
}
ConnectionStrings = @{
DefaultConnection = 'Server=tcp:staging-sql.database.windows.net,1433;Database=myapp_staging;'
RedisCache = 'staging-redis.redis.cache.windows.net:6380,abortConnect=False'
}
}
production = @{
AppSettings = @{
ASPNETCORE_ENVIRONMENT = 'Production'
APP_VERSION = '2.2.0'
API_ENDPOINT = 'https://api.example.com'
LOG_LEVEL = 'Warning'
FEATURE_FLAGS = '{"NewDashboard":false,"BetaAPI":false}'
}
ConnectionStrings = @{
DefaultConnection = 'Server=tcp:prod-sql.database.windows.net,1433;Database=myapp_prod;'
RedisCache = 'prod-redis.redis.cache.windows.net:6380,abortConnect=False'
}
}
}

$ResourceGroup = 'rg-myapp-prod'
$AppName = 'app-myapp-prod'

# 应用 Staging 槽的独立配置
$StagingSlot = Get-AzWebAppSlot -ResourceGroupName $ResourceGroup -Name $AppName -Slot 'staging'

# 设置"槽位粘性"配置(不随槽交换而移动的设置)
$SlotSettingNames = @('ASPNETCORE_ENVIRONMENT', 'API_ENDPOINT', 'LOG_LEVEL', 'FEATURE_FLAGS')
$appSettingsList = @()
foreach ($Key in $SlotConfigs.staging.AppSettings.Keys) {
$appSettingsList += @{
Name = $Key
Value = $SlotConfigs.staging.AppSettings[$Key]
}
}

Set-AzWebAppSlot `
-ResourceGroupName $ResourceGroup `
-Name $AppName `
-Slot 'staging' `
-AppSettings $appSettingsList

# 部署前预热验证:检查关键页面和 API 端点
$StagingBaseUrl = "https://$AppName-staging.azurewebsites.net"
$Endpoints = @(
@{ Name = '健康检查'; Path = '/health' },
@{ Name = '就绪检查'; Path = '/health/ready' },
@{ Name = '首页'; Path = '/' },
@{ Name = 'API 版本'; Path = '/api/version' }
)

Write-Host "`n=== Staging 槽预热验证 ===" -ForegroundColor Cyan
$AllPassed = $true
foreach ($Endpoint in $Endpoints) {
$Url = "$StagingBaseUrl$($Endpoint.Path)"
try {
$Response = Invoke-WebRequest -Uri $Url -TimeoutSec 15 -UseBasicParsing
$Status = if ($Response.StatusCode -eq 200) { 'PASS' } else { "WARN ($($Response.StatusCode))" }
$Color = if ($Response.StatusCode -eq 200) { 'Green' } else { 'Yellow' }
} catch {
$Status = "FAIL ($($_.Exception.Message.Split([Environment]::NewLine)[0]))"
$Color = 'Red'
$AllPassed = $false
}
Write-Host " [$Status] $($Endpoint.Name) ($($Endpoint.Path))" -ForegroundColor $Color
}

if ($AllPassed) {
Write-Host "`n所有端点验证通过,可以安全地进行槽位交换。" -ForegroundColor Green
} else {
Write-Host "`n存在验证失败的端点,请检查后再部署!" -ForegroundColor Red
}

执行结果示例:

1
2
3
4
5
6
7
=== Staging 槽预热验证 ===
[PASS] 健康检查 (/health)
[PASS] 就绪检查 (/health/ready)
[PASS] 首页 (/)
[PASS] API 版本 (/api/version)

所有端点验证通过,可以安全地进行槽位交换。

注意事项

  • 槽位数量限制:不同定价 tier 支持的部署槽数量不同。免费(F1)和共享(D1)层不支持部署槽;基本(B1)层最多 1 个槽;标准(S1)及以上最多 5 个槽;高级(P1v3)和隔离(I1)层最多 20 个槽。规划槽位时要提前确认 tier 配额。
  • 槽位粘性设置:标记为”槽位设置”(Slot Setting)的应用配置和连接字符串不会随槽位交换而移动。务必将环境相关的配置(如数据库连接字符串、外部 API 地址)设为槽位粘性,避免交换后生产环境连接到测试资源。
  • 预热冷却时间:槽位交换并非瞬间完成,Azure 需要执行文件同步、配置应用和实例预热。对于大型应用,整个过程可能需要 1-3 分钟。在此期间不建议执行连续多次交换。
  • 自动交换:可以通过 Set-AzWebAppSlot-AutoSwapSlotName 参数启用自动交换。当新代码推送到 Staging 槽并完成预热后,Azure 会自动将其交换到生产槽。但建议在稳定的 CI/CD 流水线中使用,手动场景下保持关闭。
  • 回滚策略:槽位交换后,旧的生产版本会保留在 Staging 槽中。如果发现问题,只需再次交换即可回滚。但如果在交换后又向 Staging 槽部署了新版本,旧版本将被覆盖,回滚窗口关闭。建议在交换后至少保留旧版本 30 分钟再进行下一次部署。
  • VNet 集成:如果应用使用了 VNet 集成,交换时务必设置 preserveVnet = $true,否则 VNet 配置会被重置,导致应用无法访问内部资源。这一点在使用私有端点(Private Endpoint)连接数据库时尤为重要。

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 技能连载 - MSI 安装包自动化部署

适用于 PowerShell 5.1 及以上版本

在企业环境中,软件部署是最常见的运维任务之一。MSI(Microsoft Installer)是 Windows 平台上标准的安装包格式,几乎所有企业级软件都提供 MSI 分发方式。手动安装不仅耗时,而且在批量部署场景下几乎不可行。通过 PowerShell 自动化 MSI 安装,可以实现静默安装、参数化配置、日志记录和错误处理,大幅提升部署效率。

随着 DevOps 和基础设施即代码(IaC)理念的普及,将软件安装纳入版本控制的自动化流程已成为标准实践。本文将介绍如何使用 PowerShell 调用 msiexec.exe 完成 MSI 包的自动化安装、卸载和状态检测。

基础:使用 msiexec 进行静默安装

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
# msiexec 是 Windows 自带的 MSI 安装引擎命令行工具
# 常用参数说明:
# /i - 安装
# /x - 卸载
# /qn - 无用户界面(静默)
# /qb - 基本界面(仅进度条)
# /l*v - 详细日志
# REBOOT= - 控制重启行为

$msiPath = 'C:\Packages\example-app-2.0.msi'
$logPath = 'C:\Logs\example-app-install.log'

# 构建安装参数
$arguments = @(
'/i', $msiPath
'/qn'
'/l*v', $logPath
'REBOOT=ReallySuppress'
'ALLUSERS=1'
)

# 启动安装进程
$process = Start-Process -FilePath 'msiexec.exe' `
-ArgumentList $arguments `
-Wait `
-PassThru

# 检查返回码
switch ($process.ExitCode) {
0 { Write-Host '安装成功' -ForegroundColor Green }
3010 { Write-Host '安装成功,需要重启' -ForegroundColor Yellow }
1602 { Write-Host '用户取消了安装' -ForegroundColor Yellow }
1603 { Write-Host '安装过程中发生严重错误' -ForegroundColor Red }
default { Write-Host "安装返回代码:$_" -ForegroundColor Red }
}

执行结果示例:

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
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
function Install-MSIPackage {
<#
.SYNOPSIS
静默安装 MSI 包
.PARAMETER Path
MSI 文件的完整路径
.PARAMETER LogPath
安装日志输出路径
.PARAMETER Properties
额外的 MSI 属性(键值对)
.PARAMETER NoRestart
是否禁止重启
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Leaf })]
[string]$Path,

[string]$LogPath,

[hashtable]$Properties,

[switch]$NoRestart
)

# 自动生成日志路径
if (-not $LogPath) {
$packageName = [System.IO.Path]::GetFileNameWithoutExtension($Path)
$logDir = 'C:\Logs\MSI'
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
$LogPath = Join-Path $logDir "$packageName-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
}

# 构建参数列表
$args = @('/i', $Path, '/qn', '/l*v', $LogPath)

if ($NoRestart) {
$args += 'REBOOT=ReallySuppress'
}

# 附加自定义属性
if ($Properties) {
foreach ($key in $Properties.Keys) {
$args += "$key=$($Properties[$key])"
}
}

Write-Verbose "正在安装:$Path"
Write-Verbose "日志文件:$LogPath"
Write-Verbose "参数:$($args -join ' ')"

$process = Start-Process -FilePath 'msiexec.exe' `
-ArgumentList $args `
-Wait `
-PassThru

# 构造返回对象
$result = [PSCustomObject]@{
MSIPath = $Path
LogPath = $LogPath
ExitCode = $process.ExitCode
Success = $process.ExitCode -in @(0, 3010)
Timestamp = Get-Date
}

return $result
}

调用封装好的函数:

1
2
3
4
5
6
7
8
9
$result = Install-MSIPackage -Path 'C:\Packages\vendor-tool-3.5.msi' `
-Properties @{
INSTALLDIR = 'C:\Program Files\VendorTool'
ADD_LOCAL = 'FeatureMain,FeatureCLI'
} `
-NoRestart `
-Verbose

$result | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
详细: 正在安装:C:\Packages\vendor-tool-3.5.msi
详细: 日志文件:C:\Logs\MSI\vendor-tool-3.5-20250818-090000.log
详细: 参数:/i C:\Packages\vendor-tool-3.5.msi /qn /l*v C:\Logs\MSI\vendor-tool-3.5-20250818-090000.log REBOOT=ReallySuppress INSTALLDIR=C:\Program Files\VendorTool ADD_LOCAL=FeatureMain,FeatureCLI

MSIPath : C:\Packages\vendor-tool-3.5.msi
LogPath : C:\Logs\MSI\vendor-tool-3.5-20250818-090000.log
ExitCode : 0
Success : True
Timestamp : 2025/8/18 09:00:15

检测已安装的 MSI 产品

在安装之前,通常需要先检查目标软件是否已经安装,避免重复部署。Windows 注册表中保存了所有已安装的 MSI 产品信息。

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 Get-InstalledMSIProduct {
<#
.SYNOPSIS
查询已安装的 MSI 产品列表
.PARAMETER Name
按产品名称过滤(支持通配符)
#>
[CmdletBinding()]
param(
[string]$Name = '*'
)

# 从注册表读取已安装产品
$uninstallPaths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)

$products = foreach ($path in $uninstallPaths) {
if (Test-Path $path) {
Get-ItemProperty $path -ErrorAction SilentlyContinue |
Where-Object {
$_.PSChildName -and
$_.WindowsInstaller -eq 1
}
}
}

$products |
Where-Object { $_.DisplayName -like $Name } |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, PSChildName |
Sort-Object DisplayName
}

# 查找特定产品
Get-InstalledMSIProduct -Name '*7-Zip*' | Format-Table -AutoSize

执行结果示例:

1
2
3
DisplayName DisplayVersion Publisher  InstallDate PSChildName
----------- -------------- --------- ----------- -----------
7-Zip 24.09 24.09 Igor Pavlov 20250115 {23170F69-40C1-2702-2409-000001000000}

判断是否需要安装

结合版本比较逻辑,判断目标软件是否需要安装或升级。

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
function Test-MSIInstallRequired {
<#
.SYNOPSIS
判断是否需要安装或升级
.PARAMETER ProductName
目标产品名称(支持通配符)
.PARAMETER MinVersion
要求的最低版本号
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ProductName,

[Version]$MinVersion
)

$installed = Get-InstalledMSIProduct -Name $ProductName

if (-not $installed) {
Write-Verbose "产品 '$ProductName' 未安装,需要安装"
return $true
}

if ($MinVersion) {
$currentVersion = [Version]::new(0, 0, 0, 0)
if ($installed.DisplayVersion) {
$currentVersion = [Version]$installed.DisplayVersion
}

if ($currentVersion -lt $MinVersion) {
Write-Verbose "当前版本 $currentVersion 低于要求版本 $MinVersion,需要升级"
return $true
}

Write-Verbose "当前版本 $currentVersion 已满足要求版本 $MinVersion,跳过安装"
return $false
}

Write-Verbose "产品 '$ProductName' 已安装,跳过"
return $false
}

# 示例:检查是否需要安装 7-Zip 24.09 及以上版本
$needInstall = Test-MSIInstallRequired -ProductName '*7-Zip*' -MinVersion '24.9' -Verbose
Write-Host "需要安装: $needInstall"

执行结果示例:

1
2
详细: 当前版本 24.09 已满足要求版本 24.9,跳过安装
需要安装: False

批量部署多个 MSI 包

将上述函数组合起来,实现批量自动部署。这在初始化新服务器或统一升级软件时非常有用。

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
# 定义部署清单
$deployList = @(
@{
Name = '*7-Zip*'
MinVersion = '24.9'
MSIPath = '\\fileserver\packages\7z2409-x64.msi'
}
@{
Name = '*Notepad++*'
MinVersion = '8.7'
MSIPath = '\\fileserver\packages\npp.8.7.Installer.x64.msi'
}
@{
Name = '*Visual Studio Code*'
MinVersion = '1.95'
MSIPath = '\\fileserver\packages\VSCodeSetup-x64-1.95.0.msi'
}
)

# 执行批量部署
$results = foreach ($item in $deployList) {
Write-Host "`n检查:$($item.Name)" -ForegroundColor Cyan

$needInstall = Test-MSIInstallRequired `
-ProductName $item.Name `
-MinVersion $item.MinVersion

if ($needInstall) {
Write-Host "开始安装:$($item.MSIPath)" -ForegroundColor Yellow
$result = Install-MSIPackage -Path $item.MSIPath -NoRestart
$result | Add-Member -NotePropertyName 'ProductName' -NotePropertyValue $item.Name -PassThru
} else {
Write-Host "已满足要求,跳过" -ForegroundColor Green
[PSCustomObject]@{
ProductName = $item.Name
MSIPath = $item.MSIPath
Success = $true
ExitCode = -1
Message = '已安装且版本满足要求'
}
}
}

# 输出部署汇总
Write-Host "`n===== 部署汇总 =====" -ForegroundColor Cyan
$results | Select-Object ProductName, Success, ExitCode | Format-Table -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
检查:*7-Zip*
已满足要求,跳过

检查:*Notepad++*
开始安装:\\fileserver\packages\npp.8.7.Installer.x64.msi

检查:*Visual Studio Code*
开始安装:\\fileserver\packages\VSCodeSetup-x64-1.95.0.msi

===== 部署汇总 =====
ProductName Success ExitCode
----------- ------- --------
*7-Zip* True -1
*Notepad++* True 0
*Visual Studio Code* True 0

MSI 卸载操作

有时需要先卸载旧版本再安装新版本,或者清理不再使用的软件。

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
function Uninstall-MSIPackage {
<#
.SYNOPSIS
静默卸载已安装的 MSI 产品
.PARAMETER ProductName
产品名称(支持通配符)
.PARAMETER LogPath
卸载日志路径
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ProductName,

[string]$LogPath
)

$product = Get-InstalledMSIProduct -Name $ProductName |
Select-Object -First 1

if (-not $product) {
Write-Warning "未找到产品 '$ProductName'"
return
}

# 自动生成日志路径
if (-not $LogPath) {
$logDir = 'C:\Logs\MSI'
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
$LogPath = Join-Path $logDir "uninstall-$($product.PSChildName)-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
}

$args = @(
'/x', $product.PSChildName
'/qn'
'/l*v', $LogPath
'REBOOT=ReallySuppress'
)

Write-Verbose "正在卸载:$($product.DisplayName) $($product.DisplayVersion)"

$process = Start-Process -FilePath 'msiexec.exe' `
-ArgumentList $args `
-Wait `
-PassThru

[PSCustomObject]@{
Product = $product.DisplayName
Version = $product.DisplayVersion
ExitCode = $process.ExitCode
Success = $process.ExitCode -in @(0, 3010)
LogPath = $LogPath
}
}

# 卸载指定产品
$uninstallResult = Uninstall-MSIPackage -ProductName '*OldTool*' -Verbose
$uninstallResult | Format-List

执行结果示例:

1
2
3
4
5
6
7
详细: 正在卸载:OldTool 1.2.3 1.2.3

Product : OldTool 1.2.3
Version : 1.2.3
ExitCode : 0
Success : True
LogPath : C:\Logs\MSI\uninstall-{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}-20250818-091500.log

解析 MSI 安装日志

安装失败时需要分析日志定位问题。以下是自动解析 MSI 日志的关键函数。

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
function Get-MSIInstallError {
<#
.SYNOPSIS
从 MSI 安装日志中提取错误信息
.PARAMETER LogPath
MSI 日志文件路径
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Leaf })]
[string]$LogPath
)

$logContent = Get-Content $LogPath -Encoding UTF8

# 提取错误行(MSI 日志中错误以 "Return value 3" 标记)
$errorLines = $logContent |
Select-String -Pattern 'Return value 3|错误|Error|ERROR' |
Select-Object -First 20

# 提取安装属性摘要
$properties = $logContent |
Select-String -Pattern '^Property\(S\):' |
Select-Object -First 10

# 提取返回码
$returnCode = $logContent |
Select-String -Pattern 'Return value [0-9]' |
Select-Object -Last 1

[PSCustomObject]@{
LogFile = $LogPath
ReturnCode = if ($returnCode) { ($returnCode.Line -replace '.*Return value (\d+).*', '$1') } else { '未知' }
Errors = $errorLines | ForEach-Object { $_.Line }
Properties = $properties | ForEach-Object { $_.Line }
}
}

# 分析最近的安装日志
$errorInfo = Get-MSIInstallError -LogPath 'C:\Logs\MSI\vendor-tool-3.5-20250818-090000.log'
$errorInfo | Format-List

执行结果示例:

1
2
3
4
LogFile    : C:\Logs\MSI\vendor-tool-3.5-20250818-090000.log
ReturnCode : 0
Errors : {}
Properties : {Property(S): ProductCode = {B1C2D3E4-F5A6-7890-BCDE-F12345678901}...}

注意事项

  • 权限要求:MSI 安装通常需要管理员权限,脚本应以提升模式运行(#Requires -RunAsAdministrator)。
  • 路径空格:MSI 文件路径中包含空格时,msiexec 可能解析失败,建议使用短路径格式(Get-ItemTarget 属性)或确保路径用引号包裹。
  • 退出码含义0 表示成功,3010 表示成功但需要重启,1603 表示严重错误。应在脚本中对常见退出码做明确处理。
  • 日志分析:生产环境中务必开启 /l*v 详细日志,安装失败时日志是排查问题的唯一依据。
  • 并发安装:Windows Installer 不支持同时安装多个 MSI 包(MSI 引擎有全局锁),批量部署时应串行执行,避免并发冲突。
  • 版本比较陷阱:注册表中的 DisplayVersion 字段格式不统一(有些是 1.2.3,有些是 1.2.3.4567),使用 [Version] 类型转换时要注意格式兼容性,建议用 TryParse 做容错处理。