iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
IT Operation

系統工程師的 30 天自動化維運實戰:PowerShell × AD × Windows Server系列 第 23 篇

Day 23|PowerShell 狀態追蹤:不要每天重複告警,只通知「新異常」與「恢復」

  • 分享至 

  • xImage
  •  

Day 22 我們已經讓 PowerShell 可以做到:
Server Health Check
↓
判斷 Warning / Critical / Unknown
↓
產生 HTML Report
↓
主動寄 Email

但昨天最後留下一個很現實的問題。
假設:
SERVER03
Disk = Critical

今天:
10/01
Critical
↓
寄信

明天問題還沒有修:
10/02
Critical
↓
又寄信

後天:
10/03
Critical
↓
又寄信

連續一個禮拜:
Critical
Critical
Critical
Critical
Critical

每天都收到一模一樣的 Email。
久了最可能發生的事情不是:
工程師處理得更快。

而是:
工程師開始無視這個寄件者。

這就是 Day 22 提到的:
Alert Fatigue
所以今天我們要讓 PowerShell 第一次開始具備:
記憶上一次狀態的能力。

流程從:
Current Status
↓
有問題?
↓
Notify

升級成:
Previous Status
+
Current Status
↓
比較
↓
發生什麼改變?
↓
決定要不要 Notify

也就是開始進入:
Stateful Automation
Stateless 與 Stateful 差在哪裡?
前面寫的 Script 基本上都是:
執行
↓
取得目前資料
↓
產生結果
↓
結束

下一次再執行時:
它不知道昨天發生過什麼。

這就是:
Stateless

例如今天:
SERVER01 = Critical

Script 只知道:
現在 Critical。

它不知道昨天:
SERVER01 = Healthy

還是:
SERVER01 = Critical

但這兩種情況意義差非常多。
情境一:Healthy → Critical
昨天
Healthy

今天
Critical

代表:
新發生的異常。

我們應該:
New Alert
↓
Notify

情境二:Critical → Critical
昨天
Critical

今天
Critical

問題還存在。
但它不是新的。
所以可以:
Existing Issue
↓
保留在 Dashboard
↓
不需要每次重新寄相同 Email

情境三:Critical → Healthy
昨天
Critical

今天
Healthy

這其實也是非常重要的事件:
Recovery
代表:
問題已經恢復。

所以最好:
[RECOVERY]
SERVER03 returned to Healthy

讓維運人員知道:
昨天收到的問題現在已經好了。

情境四:Warning → Critical
昨天
Warning

今天
Critical

問題不但沒有改善。
而且:
變嚴重了。

這種應該:
Escalated
↓
Notify

我們先定義 State Transition
今天先使用下面這套規則:
Previous Current Event Notify
無資料 Healthy InitialHealthy No
無資料 Warning / Critical / Unknown InitialIssue Yes
Healthy Healthy NoChange No
Healthy Warning / Critical NewAlert Yes
Healthy Unknown VisibilityLost Yes
Warning Warning ExistingIssue No
Warning Critical Escalated Yes
Warning Healthy Recovery Yes
Critical Critical ExistingIssue No
Critical Warning Improved No
Critical Healthy Recovery Yes
Critical Unknown VisibilityLost Yes
Unknown Healthy Recovery Yes
Unknown Warning / Critical StateRestoredWithIssue Yes

這裡特別把:
Unknown

獨立處理。
Unknown 不能單純當 Severity 數字
我們可能會想:
Healthy = 0
Warning = 1
Unknown = 2
Critical = 3

然後比較大小。
但有一個問題。
假設:
Critical
↓
Unknown

如果只是數字比較:
3 → 2

好像:
變好了。

但其實:
Critical
↓
現在監控不到

並不能稱為改善。
所以:
Unknown 不是單純 Severity,它代表 Visibility Loss。

這也是為什麼今天不單純使用數字排序判斷所有狀態。
那 Previous State 要存在哪?
最簡單的方法之一:
JSON
例如建立:
C:\Automation\State\ServerHealthState.json

內容:
[
{
"ComputerName": "SERVER01",
"OverallStatus": "Healthy",
"CheckTime": "2026-09-30 06:00:00"
},
{
"ComputerName": "SERVER02",
"OverallStatus": "Warning",
"CheckTime": "2026-09-30 06:00:00"
},
{
"ComputerName": "SERVER03",
"OverallStatus": "Critical",
"CheckTime": "2026-09-30 06:00:00"
}
]

隔天 Script 就可以:
讀取 JSON
↓
取得 Previous State
↓
執行今天 Health Check
↓
取得 Current State
↓
比較

為什麼使用 JSON?
其實也可以使用:
CSV
XML
SQLite
Database

但目前我們只需要保存:
Server
Status
CheckTime

JSON 已經足夠。
而且 PowerShell 本身就有:
ConvertTo-Json
ConvertFrom-Json

不用另外裝東西。
專案結構再多一個 State
現在:
Automation/
│
├── Config/
│ └── Servers.csv
│
├── Reports/
│
├── Logs/
│
├── State/
│ └── ServerHealthState.json
│
├── Collect-ServerHealth.ps1
├── New-DailyHtmlReport.ps1
└── Send-HealthAlert.ps1

這個:
State/

代表:
Automation 自己需要保存、供下一次執行使用的資料。

先建立 State Folder
$StateFolder =
Join-Path $PSScriptRoot
"State"

if (-not (Test-Path $StateFolder)) {

New-Item `
    -Path $StateFolder `
    -ItemType Directory |
    Out-Null

}

$StateFile =
Join-Path $StateFolder
"ServerHealthState.json"

讀取 Previous State
首先判斷檔案存在嗎。
if (Test-Path $StateFile) {

$PreviousState = @(
    Get-Content `
        -Path $StateFile `
        -Raw |
    ConvertFrom-Json
)

}
else {

$PreviousState = @()

}

如果是第一次跑:
ServerHealthState.json
不存在

那:
$PreviousState = @()

代表:
沒有歷史資料。

為什麼 Get-Content 要加 -Raw?
如果直接:
Get-Content ServerHealthState.json

PowerShell 可能是一行一行讀。
JSON 通常希望整份內容當成:
一個完整 String

所以:
Get-Content -Path $StateFile
-Raw

再:
ConvertFrom-Json

會比較清楚。
State File 壞掉怎麼辦?
不要看到 JSON 讀不到,就直接:
Previous State = Empty

然後覆蓋掉。
因為可能只是:
檔案損毀
寫入中斷
人工修改錯誤
權限問題

如果直接當第一次執行,可能產生大量錯誤告警。
比較安全:
try {

if (Test-Path $StateFile) {

    $PreviousState = @(
        Get-Content `
            -Path $StateFile `
            -Raw `
            -ErrorAction Stop |
        ConvertFrom-Json `
            -ErrorAction Stop
    )

}
else {

    $PreviousState = @()
}

}
catch {

Write-Log `
    -Message "State file load failed: $($_.Exception.Message)" `
    -Level "ERROR"

exit 1

}

也就是:
State 壞了是一個真正的 Automation Error。

而不是偷偷假裝:
昨天什麼都沒有。

今天的 Current State 從哪來?
假設 Day 20~22 的 Server Health Check 已經產生:
$Results

例如:
ComputerName OverallStatus


SERVER01 Healthy
SERVER02 Critical
SERVER03 Healthy

建立:
$CurrentState =
$Results |
Select-Object ComputerName, OverallStatus, @{ Name = "CheckTime" Expression = { Get-Date
-Format "yyyy-MM-dd HH:mm:ss"
}
}

現在:
$CurrentState

就是今天的狀態。
Previous State 怎麼快速找 Server?
最直覺可以:
$Previous =
$PreviousState |
Where-Object {
$_.ComputerName -eq
$Current.ComputerName
}

如果只有 10 台沒有差。
但如果:
500 Servers

每一台都把 PreviousState 掃一次,就不是最漂亮的方式。
我們可以建立:
Hashtable
建立 Previous State Map
$PreviousMap = @{}

然後:
foreach ($Item in $PreviousState) {

$PreviousMap[
    $Item.ComputerName
] = $Item

}

現在可以:
$PreviousMap["SERVER01"]

直接取得 SERVER01。
概念:
ServerName
↓
Hashtable Key
↓
Previous State

比每次:
Where-Object

重新搜尋更適合大量資料。
開始寫 State Transition Function
今天最重要的一個 Function:
function Get-HealthEventType {

param (

    [AllowNull()]
    [string]
    $PreviousStatus,

    [Parameter(Mandatory)]
    [string]
    $CurrentStatus
)


# ======================================
# First Run
# ======================================

if (
    [string]::IsNullOrWhiteSpace(
        $PreviousStatus
    )
) {

    if (
        $CurrentStatus -eq "Healthy"
    ) {

        return "InitialHealthy"

    }
    else {

        return "InitialIssue"
    }
}


# ======================================
# Same State
# ======================================

if (
    $PreviousStatus -eq
    $CurrentStatus
) {

    if (
        $CurrentStatus -eq
        "Healthy"
    ) {

        return "NoChange"

    }
    else {

        return "ExistingIssue"
    }
}


# ======================================
# Visibility Lost
# ======================================

if (
    $CurrentStatus -eq "Unknown"
) {

    return "VisibilityLost"
}


# ======================================
# Previous Was Unknown
# ======================================

if (
    $PreviousStatus -eq "Unknown"
) {

    if (
        $CurrentStatus -eq "Healthy"
    ) {

        return "Recovery"

    }
    else {

        return "StateRestoredWithIssue"
    }
}


# ======================================
# New Problem
# ======================================

if (
    $PreviousStatus -eq "Healthy"
) {

    return "NewAlert"
}


# ======================================
# Recovery
# ======================================

if (
    $CurrentStatus -eq "Healthy"
) {

    return "Recovery"
}


# ======================================
# Escalated
# ======================================

if (
    $PreviousStatus -eq "Warning" -and
    $CurrentStatus -eq "Critical"
) {

    return "Escalated"
}


# ======================================
# Improved
# ======================================

if (
    $PreviousStatus -eq "Critical" -and
    $CurrentStatus -eq "Warning"
) {

    return "Improved"
}


return "Changed"

}

現在真正的監控邏輯已經跟:
CPU 怎麼抓

分離了。
這個 Function 只負責:
狀態改變代表什麼?

開始比較 Previous vs Current
建立:
$Events = @()

然後:
foreach ($Current in $CurrentState) {

$PreviousStatus = $null


if (
    $PreviousMap.ContainsKey(
        $Current.ComputerName
    )
) {

    $PreviousStatus =
        $PreviousMap[
            $Current.ComputerName
        ].OverallStatus
}


$EventType =
    Get-HealthEventType `
        -PreviousStatus $PreviousStatus `
        -CurrentStatus $Current.OverallStatus


$Events +=
    [PSCustomObject]@{

        ComputerName =
            $Current.ComputerName

        PreviousStatus =
            if ($PreviousStatus) {
                $PreviousStatus
            }
            else {
                "None"
            }

        CurrentStatus =
            $Current.OverallStatus

        EventType =
            $EventType

        CheckTime =
            $Current.CheckTime
    }

}

假設昨天是這樣
SERVER01 Healthy
SERVER02 Warning
SERVER03 Critical
SERVER04 Unknown

今天:
SERVER01 Critical
SERVER02 Critical
SERVER03 Critical
SERVER04 Healthy

結果:
SERVER01
Healthy → Critical
NewAlert

SERVER02
Warning → Critical
Escalated

SERVER03
Critical → Critical
ExistingIssue

SERVER04
Unknown → Healthy
Recovery

這就開始非常接近真正的 Monitoring Event。
哪些 Event 要 Notify?
我們可以明確定義:
$NotifyEventTypes = @(
"InitialIssue"
"NewAlert"
"Escalated"
"Recovery"
"VisibilityLost"
"StateRestoredWithIssue"
)

然後:
$NotifyEvents = @(
$Events |
Where-Object {
$_.EventType -in
$NotifyEventTypes
}
)

注意:
ExistingIssue

不在裡面。
所以:
Critical → Critical

仍然會:
出現在 HTML Dashboard
出現在 CSV
出現在 Log

但是:
不會每天寄同樣的 Alert。

Improved 要不要通知?
例如:
Critical
↓
Warning

這是一個改善。
但還沒有完全恢復。
第一版我會:
Event = Improved
Log = Yes
Dashboard = Yes
Email = No

因為它還不是:
Recovery

當然企業也可以決定:
Critical → Warning

也要通知。
這就是 Notification Policy。
把 Event Report 存下來
例如:
$EventReport =
Join-Path $ReportFolder
"StateChanges_$Date.csv"

輸出:
$Events |
Export-Csv -Path $EventReport
-NoTypeInformation `
-Encoding UTF8

這樣我們不只有:
現在狀態

還開始保存:
狀態變化紀錄。

事件紀錄比 Snapshot 更有價值
Snapshot:
SERVER03 Critical

只能告訴我:
現在 Critical。

Event:
2026-10-01
SERVER03
Warning → Critical
Escalated

可以告訴我:
發生了什麼事情。

這兩種資料用途不同:
State
→ 現在是什麼狀態?

Event
→ 狀態怎麼變成現在這樣?

Email 內容改成「變化」而不是「全部異常」
Day 22 Email 是:
目前有哪些 Critical / Warning?

Day 23 改成:
這次有哪些新的變化?

例如:
Infrastructure State Changes

SERVER01
Healthy → Critical
NewAlert

SERVER02
Warning → Critical
Escalated

SERVER04
Unknown → Healthy
Recovery

SERVER03:
Critical → Critical

就不需要再寄。
建立 Notification Table
$NotificationTable =
$NotifyEvents |
Select-Object ComputerName, PreviousStatus, CurrentStatus, EventType, CheckTime | ConvertTo-Html
-Fragment

Subject 可以:
$NewAlertCount =
@(
$NotifyEvents |
Where-Object {
$_.EventType -eq "NewAlert"
}
).Count

$EscalatedCount =
@(
$NotifyEvents |
Where-Object {
$_.EventType -eq "Escalated"
}
).Count

$RecoveryCount =
@(
$NotifyEvents |
Where-Object {
$_.EventType -eq "Recovery"
}
).Count

決定 Email Subject
如果目前有新的 Critical:
$NewCriticalCount =
@(
$NotifyEvents |
Where-Object {

        $_.CurrentStatus -eq
        "Critical"

    }
).Count

可以:
if ($NewCriticalCount -gt 0) {

$Subject =
    "[CRITICAL] Infrastructure State Change"

}
elseif (
@(
$NotifyEvents |
Where-Object {
$_.EventType -eq
"VisibilityLost"
}
).Count -gt 0
) {

$Subject =
    "[UNKNOWN] Infrastructure Visibility Lost"

}
elseif (
$RecoveryCount -eq
$NotifyEvents.Count
) {

$Subject =
    "[RECOVERY] Infrastructure Health"

}
else {

$Subject =
    "[WARNING] Infrastructure State Change"

}

現在最重要的一個陷阱來了
假設今天:
SERVER01
Healthy → Critical

我們:
Compare
↓
NewAlert

然後立刻把:
Current State

寫進:
ServerHealthState.json

接著寄 Email。
但是 Email 寄送失敗。
結果:
State File
SERVER01 = Critical

下一次執行:
Previous = Critical
Current = Critical

Script 會判斷:
ExistingIssue

然後:
不再通知。

這代表真正的 Critical Alert:
可能永遠沒有人收到。

State 不能太早 Commit
所以今天很重要的流程應該是:
Load Previous State
↓
Collect Current State
↓
Compare
↓
Generate Events
↓
需要通知?
↓
Send Notification
↓
通知成功?
↓
Save Current State

而不是:
Compare
↓
Save State
↓
Send Mail

如果 Notification 失敗
我會讓:
Previous State

保持不變。
這樣下一次:
Healthy → Critical

仍然會判定:
NewAlert

然後再次嘗試通知。
這是一個非常重要的 Automation Design。
建立 CanCommitState
例如:
$CanCommitState =
$false

如果根本沒有需要通知的 Event:
if ($NotifyEvents.Count -eq 0) {

$CanCommitState =
    $true

}

如果需要通知:
先寄

寄送成功:
$CanCommitState =
$true

失敗:
$CanCommitState =
$false

這樣才能避免 Alert Lost。
Save State Function
先做:
function Save-HealthState {

param (

    [Parameter(Mandatory)]
    [array]
    $State,

    [Parameter(Mandatory)]
    [string]
    $Path
)


$TempPath =
    "$Path.tmp"


$Json =
    ConvertTo-Json `
        -InputObject @($State) `
        -Depth 4


Set-Content `
    -Path $TempPath `
    -Value $Json `
    -Encoding UTF8 `
    -ErrorAction Stop


Move-Item `
    -Path $TempPath `
    -Destination $Path `
    -Force `
    -ErrorAction Stop

}

這裡先寫:
.tmp

再取代正式 State。
可以降低:
Script 在寫入一半時中斷,直接把原 State 寫壞

的風險。
Notification 成功後再 Save
if ($CanCommitState) {

try {

    Save-HealthState `
        -State $CurrentState `
        -Path $StateFile


    Write-Log `
        "Current state saved."

}
catch {

    Write-Log `
        -Message "State save failed: $($_.Exception.Message)" `
        -Level "ERROR"

    $HasPartialError =
        $true
}

}
else {

Write-Log `
    -Message "State was not updated because notification failed." `
    -Level "WARNING"

}

如果 State Save 失敗呢?
可能造成:
Email 已成功
State 沒更新

下一次又會寄一次。
也就是:
Duplicate Notification

但這跟:
Critical 發生
Email 沒成功
State 卻更新
從此不再通知

相比,我會寧願:
偶爾重複,也不要把真正的 Alert 吃掉。

這是一種可靠性上的取捨。
First Run 怎麼辦?
第一次執行:
State File 不存在

我們不知道:
昨天是什麼狀態

所以:
Healthy
→ InitialHealthy

Warning / Critical / Unknown
→ InitialIssue

我會選擇:
InitialHealthy
→ 不寄

InitialIssue
→ 寄一次

因為如果第一次部署時:
SERVER03 已經 Critical

不能因為:
沒有 Previous State

就完全不通知。
Current Results 一定要包含失敗的 Server
這裡也很重要。
假設 Config:
SERVER01
SERVER02
SERVER03

今天 SERVER02 WinRM 失敗。
不要讓:
$Results

變成只有:
SERVER01
SERVER03

因為這樣 State Engine 甚至不知道:
SERVER02 今天怎麼了。

比較好的 Collector 應該回:
SERVER02

Connection = Failed
OverallStatus = Unknown

也就是:
失敗本身也應該產生 Result Object。

這樣 State Compare 才能:
SERVER02
Healthy → Unknown
VisibilityLost

然後通知。
No Data 不能靠「沒有這筆 Object」表示
Monitoring 裡我比較喜歡:
SERVER02
Status = Unknown

而不是:
SERVER02
完全消失

因為:
Explicit Unknown

比:
Missing Data

更容易處理。
Persistent Issue 還是可能需要 Reminder
現在我們做:
Critical → Critical

ExistingIssue

No Email

這解決 Alert Fatigue。
但也產生另一個問題:
如果 Critical 放了 7 天都沒人處理怎麼辦?

成熟一點可以加入:
Reminder Interval

例如:
New Critical
→ 馬上寄

仍 Critical 24 小時
→ Reminder

仍 Critical 72 小時
→ Escalation

那 State 裡就可以增加:
FirstSeenTime
LastChangeTime
LastNotificationTime

例如:
{
"ComputerName": "SERVER03",
"OverallStatus": "Critical",
"FirstSeenTime": "2026-09-30 06:00:00",
"LastChangeTime": "2026-09-30 06:00:00",
"LastNotificationTime": "2026-09-30 06:01:00"
}

今天先不做到這麼複雜。
先完成:
Change-based Notification。

今天完整 State Tracking Script
下面假設:
$Results

已經由前面的 Server Health Check 產生。
並沿用 Day 22 的:
Write-Log
Send-MailMessage

作為 Lab Notification。

==========================================

Infrastructure State Tracking

Day 23

==========================================

==========================================

Paths

==========================================

$StateFolder =
Join-Path $PSScriptRoot
"State"

$ReportFolder =
Join-Path $PSScriptRoot
"Reports"

if (-not (Test-Path $StateFolder)) {

New-Item `
    -Path $StateFolder `
    -ItemType Directory |
    Out-Null

}

if (-not (Test-Path $ReportFolder)) {

New-Item `
    -Path $ReportFolder `
    -ItemType Directory |
    Out-Null

}

$StateFile =
Join-Path $StateFolder
"ServerHealthState.json"

$Date =
Get-Date `
-Format "yyyyMMdd"

==========================================

Functions

==========================================

function Get-HealthEventType {

param (

    [AllowNull()]
    [string]
    $PreviousStatus,

    [Parameter(Mandatory)]
    [string]
    $CurrentStatus
)


if (
    [string]::IsNullOrWhiteSpace(
        $PreviousStatus
    )
) {

    if ($CurrentStatus -eq "Healthy") {

        return "InitialHealthy"
    }

    return "InitialIssue"
}


if (
    $PreviousStatus -eq
    $CurrentStatus
) {

    if ($CurrentStatus -eq "Healthy") {

        return "NoChange"
    }

    return "ExistingIssue"
}


if ($CurrentStatus -eq "Unknown") {

    return "VisibilityLost"
}


if ($PreviousStatus -eq "Unknown") {

    if ($CurrentStatus -eq "Healthy") {

        return "Recovery"
    }

    return "StateRestoredWithIssue"
}


if ($PreviousStatus -eq "Healthy") {

    return "NewAlert"
}


if ($CurrentStatus -eq "Healthy") {

    return "Recovery"
}


if (
    $PreviousStatus -eq "Warning" -and
    $CurrentStatus -eq "Critical"
) {

    return "Escalated"
}


if (
    $PreviousStatus -eq "Critical" -and
    $CurrentStatus -eq "Warning"
) {

    return "Improved"
}


return "Changed"

}

function Save-HealthState {

param (

    [Parameter(Mandatory)]
    [array]
    $State,

    [Parameter(Mandatory)]
    [string]
    $Path
)


$TempPath =
    "$Path.tmp"


$Json =
    ConvertTo-Json `
        -InputObject @($State) `
        -Depth 4


Set-Content `
    -Path $TempPath `
    -Value $Json `
    -Encoding UTF8 `
    -ErrorAction Stop


Move-Item `
    -Path $TempPath `
    -Destination $Path `
    -Force `
    -ErrorAction Stop

}

==========================================

Load Previous State

==========================================

try {

if (Test-Path $StateFile) {

    $PreviousState = @(
        Get-Content `
            -Path $StateFile `
            -Raw `
            -ErrorAction Stop |
        ConvertFrom-Json `
            -ErrorAction Stop
    )

}
else {

    $PreviousState = @()
}

}
catch {

Write-Log `
    -Message "Previous state load failed: $($_.Exception.Message)" `
    -Level "ERROR"

exit 1

}

==========================================

Previous State Map

==========================================

$PreviousMap = @{}

foreach ($Item in $PreviousState) {

$PreviousMap[
    $Item.ComputerName
] = $Item

}

==========================================

Build Current State

==========================================

$AllowedStatuses = @(
"Healthy"
"Warning"
"Critical"
"Unknown"
)

$CurrentState = @()

foreach ($Result in $Results) {

$Status =
    $Result.OverallStatus


if (
    $Status -notin
    $AllowedStatuses
) {

    $Status =
        "Unknown"
}


$CurrentState +=
    [PSCustomObject]@{

        ComputerName =
            $Result.ComputerName

        OverallStatus =
            $Status

        CheckTime =
            Get-Date `
                -Format "yyyy-MM-dd HH:mm:ss"
    }

}

==========================================

Compare State

==========================================

$Events = @()

foreach ($Current in $CurrentState) {

$PreviousStatus =
    $null


if (
    $PreviousMap.ContainsKey(
        $Current.ComputerName
    )
) {

    $PreviousStatus =
        $PreviousMap[
            $Current.ComputerName
        ].OverallStatus
}


$EventType =
    Get-HealthEventType `
        -PreviousStatus $PreviousStatus `
        -CurrentStatus $Current.OverallStatus


$Events +=
    [PSCustomObject]@{

        ComputerName =
            $Current.ComputerName

        PreviousStatus =
            if ($PreviousStatus) {
                $PreviousStatus
            }
            else {
                "None"
            }

        CurrentStatus =
            $Current.OverallStatus

        EventType =
            $EventType

        CheckTime =
            $Current.CheckTime
    }

}

==========================================

Export Event Report

==========================================

$EventReport =
Join-Path $ReportFolder
"StateChanges_$Date.csv"

$Events |
Export-Csv -Path $EventReport
-NoTypeInformation `
-Encoding UTF8

==========================================

Notification Events

==========================================

$NotifyEventTypes = @(
"InitialIssue"
"NewAlert"
"Escalated"
"Recovery"
"VisibilityLost"
"StateRestoredWithIssue"
)

$NotifyEvents = @(
$Events |
Where-Object {
$_.EventType -in
$NotifyEventTypes
}
)

==========================================

Notification

==========================================

$NotificationStatus =
"NotRequired"

$CanCommitState =
$false

if ($NotifyEvents.Count -eq 0) {

Write-Log `
    "No new state changes requiring notification."


$CanCommitState =
    $true

}
else {

$NotificationStatus =
    "Pending"


# ======================================
# Determine Subject
# ======================================

$NewCriticalCount = @(
    $NotifyEvents |
    Where-Object {
        $_.CurrentStatus -eq "Critical"
    }
).Count


$VisibilityLostCount = @(
    $NotifyEvents |
    Where-Object {
        $_.EventType -eq "VisibilityLost"
    }
).Count


$RecoveryCount = @(
    $NotifyEvents |
    Where-Object {
        $_.EventType -eq "Recovery"
    }
).Count


if ($NewCriticalCount -gt 0) {

    $Subject =
        "[CRITICAL] Infrastructure State Change"

}
elseif ($VisibilityLostCount -gt 0) {

    $Subject =
        "[UNKNOWN] Infrastructure Visibility Lost"

}
elseif (
    $RecoveryCount -eq
    $NotifyEvents.Count
) {

    $Subject =
        "[RECOVERY] Infrastructure Health"

}
else {

    $Subject =
        "[WARNING] Infrastructure State Change"
}


# ======================================
# HTML Body
# ======================================

$ChangeTable =
    $NotifyEvents |
    Select-Object `
        ComputerName,
        PreviousStatus,
        CurrentStatus,
        EventType,
        CheckTime |
    ConvertTo-Html `
        -Fragment


$EmailBody = @"

$ChangeTable

"@

# ======================================
# Send
# ======================================

try {

    Send-MailMessage `
        -From "automation@contoso.com" `
        -To "it-ops@contoso.com" `
        -Subject $Subject `
        -Body $EmailBody `
        -BodyAsHtml `
        -SmtpServer "smtp-relay.contoso.com" `
        -ErrorAction Stop


    $NotificationStatus =
        "Success"


    $CanCommitState =
        $true


    Write-Log `
        "State change notification sent."

}
catch {

    $NotificationStatus =
        "Failed"


    $CanCommitState =
        $false


    Write-Log `
        -Message "Notification failed: $($_.Exception.Message)" `
        -Level "ERROR"
}

}

==========================================

Commit State

==========================================

if ($CanCommitState) {

try {

    Save-HealthState `
        -State $CurrentState `
        -Path $StateFile


    Write-Log `
        "Current health state committed."

}
catch {

    Write-Log `
        -Message "State commit failed: $($_.Exception.Message)" `
        -Level "ERROR"

    exit 2
}

}
else {

Write-Log `
    -Message "State was not committed. Previous state preserved for notification retry." `
    -Level "WARNING"

exit 2

}

==========================================

Complete

==========================================

Write-Log `
"State tracking completed."

exit 0

第一次執行
假設完全沒有:
ServerHealthState.json

目前:
SERVER01 Healthy
SERVER02 Healthy
SERVER03 Critical

Event:
SERVER01
None → Healthy
InitialHealthy

SERVER02
None → Healthy
InitialHealthy

SERVER03
None → Critical
InitialIssue

只會通知:
SERVER03

然後保存:
[
{
"ComputerName": "SERVER01",
"OverallStatus": "Healthy"
},
{
"ComputerName": "SERVER02",
"OverallStatus": "Healthy"
},
{
"ComputerName": "SERVER03",
"OverallStatus": "Critical"
}
]

第二次執行
如果還是:
SERVER01 Healthy
SERVER02 Healthy
SERVER03 Critical

結果:
SERVER01
Healthy → Healthy
NoChange

SERVER02
Healthy → Healthy
NoChange

SERVER03
Critical → Critical
ExistingIssue

所以:
NotifyEvents = 0

不寄信。
但 SERVER03 還是:
Critical

仍然會出現在:
HTML Dashboard
CSV
Event / Health Report

只是不重複轟炸收件者。
第三次執行
假設:
SERVER03
Critical → Healthy

結果:
Recovery

這次寄:
[RECOVERY]
SERVER03
Critical → Healthy

然後更新 State:
SERVER03 = Healthy

整個:
Alert
↓
Existing Issue
↓
Recovery

流程就完整了。
現在已經開始有「事件」概念
以前:
SERVER03 Critical

只是:
State
現在:
SERVER03
Healthy → Critical
NewAlert

變成:
Event
下一次:
Critical → Critical
ExistingIssue

再下一次:
Critical → Healthy
Recovery

這其實已經是很多 Monitoring System 核心概念的簡化版本。
今天真正重要的不是 JSON
表面上 Day 23 學的是:
ConvertTo-Json
ConvertFrom-Json

但今天真正重要的是:
開始思考「目前狀態」跟「狀態改變」的差別。

以前:
Current

現在:
Previous
+
Current
↓
Transition
↓
Event

這是一個非常大的變化。
Automation 開始有 Memory
現在整個系統已經從:
Stateless

執行完
↓
全部忘記

變成:
Stateful

執行
↓
讀取上次狀態
↓
取得現在狀態
↓
比較
↓
處理事件
↓
保存新狀態

也就是:
Automation
開始知道
「上次發生過什麼」

Day 23 小結
今天解決的是 Day 22 最容易造成 Alert Fatigue 的問題:
Critical
↓
每天寄
↓
每天寄
↓
每天寄
↓
最後沒人看

現在改成:
Healthy → Critical
NewAlert
→ Notify

Critical → Critical
ExistingIssue
→ 不重複 Notify

Critical → Healthy
Recovery
→ Notify

另外還加入:
Warning → Critical
Escalated

Healthy → Unknown
VisibilityLost

Unknown → Healthy
Recovery

讓 Alert 不只是:
有沒有 Error?

而開始理解:
狀態到底發生什麼改變?

今天也建立了第一個:
State/
└── ServerHealthState.json

以及:
ConvertTo-Json
ConvertFrom-Json

讓 Script 可以跨不同執行週期保存資訊。
最重要的設計則是:
不要在 Notification 成功以前就 Commit 新 State。

否則:
New Critical
↓
State 先更新
↓
Email 失敗
↓
下次變 ExistingIssue
↓
真正告警永遠沒送出去

我們今天改成:
Compare
↓
Notify
↓
成功
↓
Commit State

如果 Notification Failed:
Previous State 保留
↓
下一次再次嘗試

這已經不只是 PowerShell 語法問題。
而是在處理:
Automation Reliability。

到 Day 23,目前整個流程已經變成:
Task Scheduler
↓
Collect
↓
Analyze
↓
Current State
↓
Previous State
↓
Compare
↓
Event
↓
Report
↓
Notification
↓
Commit State

從最開始 Day 1 的:
Get-Service

一路走到現在,已經慢慢有一個小型維運平台的味道了。
Day 24 預告
Day 24|建立自己的 PowerShell Module:把散落的 Function 整理成真正的維運工具箱
目前我們已經寫了很多 Function:
Write-Log

Get-CPUHealth

Get-MemoryHealth

Get-DiskHealth

Get-ServiceHealth

Get-ServerHealth

Get-HealthEventType

Save-HealthState

但如果每一支:
HealthCheck.ps1
ADReport.ps1
StateTracking.ps1
Notification.ps1

都複製一份 Write-Log:
改 Bug
↓
要改 5 份

增加功能
↓
又要改 5 份

很快又會回到:
大型 Script 難以維護的問題。

所以 Day 24 我們會正式建立:
SysAdminToolkit/
│
├── SysAdminToolkit.psd1
└── SysAdminToolkit.psm1

最後可以直接:
Import-Module SysAdminToolkit

然後:
Get-ServerHealth

Write-Log

Get-HealthEventType

不需要每支 Script 重複定義。
Day 24 會正式把前 23 天累積的 Function,從:
零散 PowerShell Code

整理成:
自己的 PowerShell 維運模組。


上一篇
Day 22|PowerShell 自動通知:巡檢出現 Warning / Critical 時主動發送 Email
系列文
系統工程師的 30 天自動化維運實戰:PowerShell × AD × Windows Server 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言