iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
IT Operation

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

Day 24|建立自己的 PowerShell Module:把散落的 Function 整理成真正的維運工具箱

  • 分享至 

  • xImage
  •  

前面 23 天,我們已經累積了不少 Function。
像是:
Write-Log

Get-CPUHealth

Get-MemoryHealth

Get-DiskHealth

Get-ServiceHealth

Get-ServerHealth

Get-HealthEventType

Save-HealthState

一開始把 Function 直接放在:
ServerHealthCheck.ps1

沒有問題。
但後來又有:
Collect-ServerHealth.ps1
Collect-ADAudit.ps1
New-DailyHtmlReport.ps1
Send-HealthAlert.ps1
StateTracking.ps1

這時候很容易開始出現:
ServerHealthCheck.ps1
└── Write-Log

ADAudit.ps1
└── Write-Log

Send-Alert.ps1
└── Write-Log

StateTracking.ps1
└── Write-Log

也就是:
同一個 Function 被複製到很多 Script。

今天如果 Write-Log 有 Bug:
要改 4 份

明天想新增:
DEBUG Level

又要:
改 4 份

過幾個月就可能變成:
Write-Log Version A
Write-Log Version B
Write-Log Version C

誰都不知道哪一份才是最新版。
所以 Day 24 要正式把前面累積的 Function 整理成:
PowerShell Module
最後我們希望可以直接:
Import-Module SysAdminToolkit

然後:
Get-ServerHealth

Write-Log

Get-HealthEventType

就像使用:
Import-Module ActiveDirectory

一樣。
Script 跟 Module 有什麼不同?
目前我們主要使用:
.ps1

例如:
ServerHealthCheck.ps1

它比較像:
我要執行的一個工作流程。

例如:
載入 Config
↓
巡檢 Server
↓
輸出 CSV
↓
寄信
↓
Exit

而 Module 比較像:
可以被其他 Script 重複使用的工具集合。

例如:
SysAdminToolkit
│
├── Write-Log
├── Get-CPUHealth
├── Get-MemoryHealth
├── Get-DiskHealth
├── Get-ServerHealth
├── Get-HealthEventType
└── Save-HealthState

所以可以簡單分成:
Module
→ 提供 Tool

Script
→ 使用 Tool 完成 Workflow

以前的架構
可能:
ServerHealthCheck.ps1
│
├── function Write-Log
├── function Get-CPUHealth
├── function Get-MemoryHealth
├── function Get-DiskHealth
├── function Get-ServerHealth
│
└── Main

其他 Script 又重新複製。
今天要變成
SysAdminToolkit
↓
提供共用 Functions
↓
┌───────────────┬────────────────┐
↓ ↓ ↓
HealthCheck ADAudit StateTracking
.ps1 .ps1 .ps1

所有 Script 都:
Import-Module SysAdminToolkit

共用同一套 Function。
PowerShell Module 最基本的兩個檔案
我們會建立:
SysAdminToolkit/
│
├── SysAdminToolkit.psm1
└── SysAdminToolkit.psd1

其中:
.psm1
Script Module。
主要放:
Function
Logic

.psd1
Module Manifest。
主要描述:
Module 名稱
版本
作者
PowerShell Version
Root Module
Export 哪些 Function

可以把它想成:
.psm1

程式本體

.psd1

Module 的說明書與設定

先建立 Module 資料夾
例如:
$ModuleRoot =
"C:\Automation\Modules\SysAdminToolkit"

New-Item -Path $ModuleRoot
-ItemType Directory `
-Force

建立:
C:\Automation
└── Modules
└── SysAdminToolkit

建立第一個 SysAdminToolkit.psm1
先不要一次搬全部。
第一版我們放三個 Function:
Write-Log
Get-HealthEventType
Get-ServerHealth

建立:
C:\Automation\Modules\SysAdminToolkit
└── SysAdminToolkit.psm1

內容:

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

SysAdminToolkit.psm1

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

function Write-Log {

[CmdletBinding()]

param (

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

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

    [ValidateSet(
        "INFO",
        "WARNING",
        "ERROR"
    )]
    [string]
    $Level = "INFO"
)


$Folder =
    Split-Path `
        -Path $Path `
        -Parent


if (
    $Folder -and
    -not (Test-Path $Folder)
) {

    New-Item `
        -Path $Folder `
        -ItemType Directory `
        -Force |
        Out-Null
}


$Time =
    Get-Date `
        -Format "yyyy-MM-dd HH:mm:ss"


$LogMessage =
    "$Time [$Level] $Message"


Add-Content `
    -Path $Path `
    -Value $LogMessage `
    -Encoding UTF8

}

function Get-HealthEventType {

[CmdletBinding()]

param (

    [AllowNull()]
    [string]
    $PreviousStatus,

    [Parameter(Mandatory)]
    [ValidateSet(
        "Healthy",
        "Warning",
        "Critical",
        "Unknown"
    )]
    [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 Get-ServerHealth {

[CmdletBinding()]

param (

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

    [int]
    $CPUWarning = 80,

    [int]
    $CPUCritical = 90,

    [int]
    $MemoryWarning = 80,

    [int]
    $MemoryCritical = 90
)


try {

    Write-Verbose `
        "Testing WinRM: $ComputerName"


    Test-WSMan `
        -ComputerName $ComputerName `
        -ErrorAction Stop |
        Out-Null


    $Result =
        Invoke-Command `
            -ComputerName $ComputerName `
            -ErrorAction Stop `
            -ScriptBlock {

                param (
                    $CPUWarning,
                    $CPUCritical,
                    $MemoryWarning,
                    $MemoryCritical
                )


                # ======================
                # CPU
                # ======================

                $CPUUsage = [math]::Round(
                    (
                        Get-CimInstance `
                            Win32_Processor |
                        Measure-Object `
                            -Property LoadPercentage `
                            -Average
                    ).Average,
                    2
                )


                if (
                    $CPUUsage -ge
                    $CPUCritical
                ) {

                    $CPUStatus =
                        "Critical"

                }
                elseif (
                    $CPUUsage -ge
                    $CPUWarning
                ) {

                    $CPUStatus =
                        "Warning"

                }
                else {

                    $CPUStatus =
                        "Normal"
                }


                # ======================
                # Memory
                # ======================

                $OS =
                    Get-CimInstance `
                        Win32_OperatingSystem


                $TotalMemory =
                    $OS.TotalVisibleMemorySize


                $FreeMemory =
                    $OS.FreePhysicalMemory


                $UsedMemory =
                    $TotalMemory -
                    $FreeMemory


                $MemoryUsage =
                    [math]::Round(
                        (
                            $UsedMemory /
                            $TotalMemory
                        ) * 100,
                        2
                    )


                if (
                    $MemoryUsage -ge
                    $MemoryCritical
                ) {

                    $MemoryStatus =
                        "Critical"

                }
                elseif (
                    $MemoryUsage -ge
                    $MemoryWarning
                ) {

                    $MemoryStatus =
                        "Warning"

                }
                else {

                    $MemoryStatus =
                        "Normal"
                }


                # ======================
                # Uptime
                # ======================

                $LastBootTime =
                    $OS.LastBootUpTime


                $UptimeDays =
                    [math]::Floor(
                        (
                            (Get-Date) -
                            $LastBootTime
                        ).TotalDays
                    )


                # ======================
                # Overall
                # ======================

                if (
                    $CPUStatus -eq "Critical" -or
                    $MemoryStatus -eq "Critical"
                ) {

                    $OverallStatus =
                        "Critical"

                }
                elseif (
                    $CPUStatus -eq "Warning" -or
                    $MemoryStatus -eq "Warning"
                ) {

                    $OverallStatus =
                        "Warning"

                }
                else {

                    $OverallStatus =
                        "Healthy"
                }


                [PSCustomObject]@{

                    ComputerName =
                        $env:COMPUTERNAME

                    Connection =
                        "Success"

                    CPUUsage =
                        $CPUUsage

                    CPUStatus =
                        $CPUStatus

                    MemoryUsage =
                        $MemoryUsage

                    MemoryStatus =
                        $MemoryStatus

                    LastBootTime =
                        $LastBootTime

                    UptimeDays =
                        $UptimeDays

                    OverallStatus =
                        $OverallStatus

                    CheckTime =
                        Get-Date
                }

            } `
            -ArgumentList `
                $CPUWarning,
                $CPUCritical,
                $MemoryWarning,
                $MemoryCritical


    return $Result

}
catch {

    return [PSCustomObject]@{

        ComputerName =
            $ComputerName

        Connection =
            "Failed"

        CPUUsage =
            $null

        CPUStatus =
            "Unknown"

        MemoryUsage =
            $null

        MemoryStatus =
            "Unknown"

        LastBootTime =
            $null

        UptimeDays =
            $null

        OverallStatus =
            "Unknown"

        CheckTime =
            Get-Date

        ErrorMessage =
            $_.Exception.Message
    }
}

}

為什麼 Write-Log 改成要求 Path?
前面的 Script 裡,我們常寫:
$LogFile =
"C:\Automation\Logs\Test.log"

然後:
function Write-Log {

Add-Content `
    -Path $LogFile

}

在同一個 .ps1 裡沒什麼問題。
但 Function 搬進 Module 後,開始牽涉:
Scope
Module 有自己的:
Module Scope

不要假設:
$LogFile

在呼叫 Script 裡存在,Module 就一定應該偷偷使用它。
比較清楚的是:
Write-Log -Path $LogFile
-Message "Health Check started."

也就是:
Function 需要什麼,就明確傳進去。

這樣 Function 比較容易:
重複使用
測試
閱讀
除錯

[CmdletBinding()] 是什麼?
今天 Function 開始多:
[CmdletBinding()]

例如:
function Get-ServerHealth {

[CmdletBinding()]

param (...)

}

它會讓 Function 更接近 PowerShell 原生 Cmdlet 的行為。
例如可以使用 Common Parameters:
-Verbose
-Debug
-ErrorAction
-WarningAction

所以我們可以:
Get-ServerHealth -ComputerName SERVER01
-Verbose

然後 Function 裡:
Write-Verbose `
"Testing WinRM: SERVER01"

只有使用:
-Verbose

時才會顯示。
這比 Script 裡到處:
Write-Host

更適合做正式工具。
Module 裡的 Remoting 還有一個坑
Day 11 提過:
本機定義的 Function 不會自動出現在 Remote Session 裡。

今天變 Module 之後仍然一樣。
假設 Module 裡有:
Get-CPUHealth

然後:
Invoke-Command -ComputerName SERVER01
-ScriptBlock {

    Get-CPUHealth

}

Remote SERVER01 不會因為:
管理機有 Import SysAdminToolkit

就自動認識:
Get-CPUHealth

所以今天範例的:
Get-ServerHealth

把真正需要 Remote 執行的 Query 放在:
-ScriptBlock {

Get-CimInstance ...

}

裡面。
也就是:
Local Module
↓
Get-ServerHealth
↓
Invoke-Command
↓
Remote ScriptBlock
↓
Remote Server 自己執行 Query

不要假設 Module Function 會自動穿越 WinRM。
現在 Import 這個 Module
目前只有:
C:\Automation\Modules
└── SysAdminToolkit
└── SysAdminToolkit.psm1

可以直接:
Import-Module `
"C:\Automation\Modules\SysAdminToolkit\SysAdminToolkit.psm1"

然後:
Get-Command `
-Module SysAdminToolkit

可能看到:
CommandType Name


Function Get-HealthEventType
Function Get-ServerHealth
Function Write-Log

現在就可以:
Get-ServerHealth `
-ComputerName SERVER01

但這樣還少了一個 Manifest
目前只是:
Script Module

接著建立:
SysAdminToolkit.psd1

也就是:
Module Manifest

使用 New-ModuleManifest
例如:
$ManifestPath =
"C:\Automation\Modules\SysAdminToolkit\SysAdminToolkit.psd1"

然後:
New-ModuleManifest -Path $ManifestPath
-RootModule "SysAdminToolkit.psm1" -ModuleVersion "1.0.0"
-Author "IT Operations" -CompanyName "Internal"
-Description "PowerShell toolkit for Windows Server and Active Directory operations." -PowerShellVersion "5.1"
-FunctionsToExport @(
"Write-Log"
"Get-HealthEventType"
"Get-ServerHealth"
)

PowerShell 會產生:
SysAdminToolkit.psd1

Module Manifest 裡有什麼?
大概會看到:
@{

RootModule =
    'SysAdminToolkit.psm1'

ModuleVersion =
    '1.0.0'

GUID =
    '...'

Author =
    'IT Operations'

CompanyName =
    'Internal'

Description =
    'PowerShell toolkit for Windows Server and Active Directory operations.'

PowerShellVersion =
    '5.1'

FunctionsToExport = @(
    'Write-Log'
    'Get-HealthEventType'
    'Get-ServerHealth'
)

}

這些資訊可以讓使用者知道:
這個 Module 是什麼?
Version?
適用哪個 PowerShell?
提供哪些 Function?

為什麼 Module 要有 Version?
假設今天:
SysAdminToolkit 1.0.0

下個月新增:
Get-DiskHealth
Get-ServiceHealth

可以變:
1.1.0

如果做了不相容的大修改:
2.0.0

未來某天排查問題就可以知道:
SERVER01 Automation
使用 Module 1.0.0

SERVER02 Automation
使用 Module 1.2.0

這比:
我也不知道哪份 ps1 比較新

好很多。
驗證 Manifest
建立完不要直接假設沒問題。
可以:
Test-ModuleManifest `
-Path $ManifestPath

正常會回傳 Module 資訊。
如果:
RootModule 不存在
Manifest 語法錯誤
版本設定不正確

這時候就會比較容易發現。
改成從 psd1 Import
現在可以:
Import-Module "C:\Automation\Modules\SysAdminToolkit\SysAdminToolkit.psd1"
-Force

這時候 PowerShell 會透過 Manifest 知道:
RootModule 是誰
Version 是多少
Export 哪些 Function

-Force 什麼時候用?
開發 Module 時:
Import-Module SysAdminToolkit -Force

很好用。
因為:
修改 psm1
↓
重新 Import

可以載入新的版本。
不然目前 PowerShell Session 可能還在使用舊的 Module。
正式 Script 一般不需要每次都:
-Force

Export-ModuleMember
目前 Module 裡的 Function 可能預設會被 Export。
但我比較喜歡明確控制。
在:
SysAdminToolkit.psm1

最後加入:
Export-ModuleMember -Function
Write-Log,
Get-HealthEventType,
Get-ServerHealth

意思就是:
Module 外面只公開這三個 Function。

這是一個很重要的概念:
Public Interface
Module 裡面可能有:
10 個 Helper Function

但使用者真正需要看到的只有:
3 個

那就只 Export 3 個。
Public 跟 Private
當 Module 開始變大,我不建議:
SysAdminToolkit.psm1

最後變成:
3000 行

否則只是:
把 3000 行 .ps1 換成 3000 行 .psm1。

問題沒有真的解決。
比較好的結構是:
SysAdminToolkit/
│
├── SysAdminToolkit.psd1
├── SysAdminToolkit.psm1
│
├── Public/
│ ├── Write-Log.ps1
│ ├── Get-ServerHealth.ps1
│ └── Get-HealthEventType.ps1
│
└── Private/
├── Get-OverallStatus.ps1
└── Test-ValidHealthStatus.ps1

Public 是什麼?
就是使用 Module 的人可以直接呼叫:
Get-ServerHealth

例如:
Public/
├── Get-ServerHealth.ps1
├── Write-Log.ps1
└── Get-HealthEventType.ps1

Private 是什麼?
例如:
Get-OverallStatus

只是:
Get-ServerHealth

內部需要使用。
我不希望外部 Script 直接:
Get-OverallStatus

所以放:
Private/

它屬於:
Module Internal Helper。

psm1 負責載入 Function
可以把:
SysAdminToolkit.psm1

改成:

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

SysAdminToolkit.psm1

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

$PublicPath =
Join-Path $PSScriptRoot
"Public"

$PrivatePath =
Join-Path $PSScriptRoot
"Private"

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

Load Private Functions

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

Get-ChildItem -Path $PrivatePath
-Filter "*.ps1" `
-ErrorAction SilentlyContinue |
ForEach-Object {

. $_.FullName

}

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

Load Public Functions

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

$PublicFunctions =
Get-ChildItem -Path $PublicPath
-Filter "*.ps1" `
-ErrorAction Stop

foreach ($File in $PublicFunctions) {

. $File.FullName

}

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

Export Public Functions

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

Export-ModuleMember -Function
$PublicFunctions.BaseName

這裡出現一個新的技巧:
. $File.FullName

這叫:
Dot Sourcing
Dot Sourcing
一般:
.\Get-ServerHealth.ps1

代表:
執行這個 Script。

但:
. .\Get-ServerHealth.ps1

前面多一個:
.

代表:
把這個 Script 裡定義的內容載入目前 Scope。

因此 Module 可以:
讀 Public/*.ps1
↓
Dot Source
↓
Functions 載入 Module Scope

現在每個 Function 可以獨立一個檔案
例如:
Public/
└── Write-Log.ps1

內容就只有:
function Write-Log {

[CmdletBinding()]

param (

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

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

    [ValidateSet(
        "INFO",
        "WARNING",
        "ERROR"
    )]
    [string]
    $Level = "INFO"
)


$Time =
    Get-Date `
        -Format "yyyy-MM-dd HH:mm:ss"


Add-Content `
    -Path $Path `
    -Value "$Time [$Level] $Message" `
    -Encoding UTF8

}

如果今天只修改:
Write-Log

就只改:
Write-Log.ps1

不用在 20 個 Script 找 Function。
把以前的 Functions 慢慢搬進來
最終可能:
SysAdminToolkit/
│
├── SysAdminToolkit.psd1
├── SysAdminToolkit.psm1
│
├── Public/
│ ├── Write-Log.ps1
│ ├── Get-CPUHealth.ps1
│ ├── Get-MemoryHealth.ps1
│ ├── Get-DiskHealth.ps1
│ ├── Get-ServiceHealth.ps1
│ ├── Get-ServerHealth.ps1
│ ├── Get-HealthEventType.ps1
│ └── Save-HealthState.ps1
│
└── Private/
├── Get-OverallStatus.ps1
└── Test-HealthStatus.ps1

這就開始真的像一個:
SysAdmin Toolkit。

Main Script 會變得非常乾淨
以前:
ServerHealthCheck.ps1

500 行

現在 Main 可以:
Import-Module `
"$PSScriptRoot\Modules\SysAdminToolkit\SysAdminToolkit.psd1"

$Servers =
Import-Csv `
"$PSScriptRoot\Config\Servers.csv"

$Results = foreach (
$Server in $Servers
) {

Get-ServerHealth `
    -ComputerName $Server.ComputerName `
    -CPUWarning 80 `
    -CPUCritical 90 `
    -MemoryWarning 80 `
    -MemoryCritical 90

}

$Results |
Export-Csv "$PSScriptRoot\Reports\ServerHealth.csv"
-NoTypeInformation `
-Encoding UTF8

這支 Script 現在只描述:
我要做什麼。

而不是塞滿:
每一個 Function 是怎麼實作。

這是很重要的抽象化
以前:
Main Script
│
├── CPU 計算
├── Memory 計算
├── WinRM
├── Log
├── State
├── HTML
└── Notification

現在:
Main Script
│
├── Get-ServerHealth
├── Write-Log
├── Get-HealthEventType
└── Save-HealthState

也就是:
Main Script 開始描述 Workflow。

細節交給 Module。
Module 可以放進 PSModulePath
目前每次:
Import-Module `
"C:\Automation\Modules\SysAdminToolkit\SysAdminToolkit.psd1"

路徑還是寫很長。
PowerShell 本身有:
$env:PSModulePath

查看:
$env:PSModulePath `
-split ";"

可能看到:
C:\Users\User\Documents\WindowsPowerShell\Modules

C:\Program Files\WindowsPowerShell\Modules

C:\Windows\System32\WindowsPowerShell\v1.0\Modules

PowerShell 會從這些地方找 Module。
Windows PowerShell 5.1 的 User Module Path
常見:
C:\Users<User>\Documents\WindowsPowerShell\Modules

如果放:
...\Modules
└── SysAdminToolkit
├── SysAdminToolkit.psd1
└── SysAdminToolkit.psm1

就可以:
Import-Module SysAdminToolkit

不需要寫完整路徑。
不要直接硬背 Module Path
不同 PowerShell 版本與環境可能有差異。
比較好的方式是:
$env:PSModulePath -split ";"

直接看:
目前這台機器 PowerShell 到底去哪裡找 Module。

找 Module
例如:
Get-Module -ListAvailable
SysAdminToolkit

可能:
ModuleType Version Name


Script 1.0.0 SysAdminToolkit

表示:
PowerShell 找得到它。

Import
Import-Module `
SysAdminToolkit

查看目前載入:
Get-Module `
SysAdminToolkit

看有哪些 Commands
Get-Command `
-Module SysAdminToolkit

例如:
Get-HealthEventType
Get-ServerHealth
Write-Log

這就是使用者真正看到的 Module Interface。
Remove Module
開發時可以:
Remove-Module `
SysAdminToolkit

再:
Import-Module `
SysAdminToolkit

或者:
Import-Module SysAdminToolkit
-Force

方便測試修改。
Function 最好加 Help
既然開始做 Module,就可以讓:
Get-Help Get-ServerHealth

真的有內容。
例如:
function Get-ServerHealth {

<#
.SYNOPSIS
Checks basic Windows Server health.

.DESCRIPTION
Uses PowerShell Remoting to collect
CPU, memory and uptime information
from a remote Windows Server.

.PARAMETER ComputerName
Target Windows Server name.

.PARAMETER CPUWarning
CPU warning threshold.

.PARAMETER CPUCritical
CPU critical threshold.

.EXAMPLE
Get-ServerHealth -ComputerName SERVER01

.EXAMPLE
Get-ServerHealth -ComputerName SERVER01
-CPUWarning 75 `
-CPUCritical 90

#>

[CmdletBinding()]

param (
    ...
)

...

}

這叫:
Comment-Based Help
然後:
Get-Help Get-ServerHealth
-Full

或者:
Get-Help Get-ServerHealth
-Examples

現在自己的 Function 開始真的很像原生 Cmdlet。
Naming 也開始變重要
Module 裡不要:
ServerCheck
DoCheck
CheckCPU
Log
RunThing

PowerShell 比較建議:
Verb-Noun

所以:
Get-ServerHealth

Write-Log

Save-HealthState

如果不知道有哪些標準 Verb:
Get-Verb

例如:
Get
Set
New
Remove
Test
Write
Save
Import
Export

Module 越大,命名規則越重要。
不要所有 Function 都 Export
假設有:
function ConvertTo-InternalHealthCode {
...
}

只是:
Get-ServerHealth

內部使用。
就不要 Export。
使用者只需要看到:
Get-ServerHealth

而不是看到:
30 個內部 Helper

這跟寫 API 很像:
Public
→ 別人應該使用的介面

Private
→ 內部實作

Module Version 更新
假設:
1.0.0

加入:
Save-HealthState

可能更新:
1.1.0

修小 Bug:
1.1.1

大幅修改 Function 行為:
2.0.0

可以用:
Major.Minor.Patch

的概念。
目前不用做得像正式開源專案那麼複雜。
但至少:
不要 Module 永遠叫 Final-New-v2-ReallyFinal。

Automation Script 也可以檢查 Module Version
例如:
$Module =
Get-Module `
SysAdminToolkit

Write-Log -Path $LogFile
-Message "SysAdminToolkit version: $($Module.Version)"

Log:
2026-10-02 06:00:00
[INFO]
SysAdminToolkit version: 1.0.0

之後 Troubleshooting 非常有價值。
Task Scheduler 執行時也要注意 Module
你手動:
Import-Module SysAdminToolkit

成功。
Task Scheduler:
Module not found

還是可能發生。
因為 Day 20 講過:
Execution Context 不同。

假設 Module 安裝在:
C:\Users\Ted\Documents\WindowsPowerShell\Modules

但 Task Scheduler 使用:
CONTOSO\svc-automation

那 Service Account 不一定看到 Ted 的個人 Module Path。
Automation 共用 Module 適合放哪裡?
如果是該 Server 上多個帳號都需要使用,
可以依公司的權限與部署方式考慮放到:
C:\Program Files\WindowsPowerShell\Modules

之下。
例如:
C:\Program Files\WindowsPowerShell\Modules
└── SysAdminToolkit

但要注意:
誰可以修改 Module?

因為如果 Task Scheduler 用高權限帳號執行 Module,而任何一般使用者都可以修改:
SysAdminToolkit.psm1

那會形成嚴重的安全問題。
所以 Module Folder 本身也應該有適當:
NTFS Permission

Automation Code 本身也是需要保護的資產
以前我們可能只關心:
Server Password
AD Permission

但現在 Script 越來越重要。
假設:
svc-automation

可以:
Query AD
Remote Server
產生 Report

而 Module:
SysAdminToolkit.psm1

卻所有人都可以修改。
那別人只要改:
Get-ServerHealth

就有可能讓高權限 Scheduled Task 執行他的內容。
所以:
Automation Code Permission 本身也是 Security Control。

Module 不要存 Secret
Module 裡同樣不要:
$Password =
"Password123!"

也不要:
$ApiToken =
"abc..."

Module 應該放:
Logic
Function
Validation

而不是:
Secret
Credential

Secrets 應由適當的 Credential / Secret Management 機制處理。
建立一個真正的 Toolkit 專案
到 Day 24,我會把整個專案整理成:
SysAdmin-Automation/
│
├── Modules/
│ │
│ └── SysAdminToolkit/
│ │
│ ├── SysAdminToolkit.psd1
│ ├── SysAdminToolkit.psm1
│ │
│ ├── Public/
│ │ ├── Write-Log.ps1
│ │ ├── Get-ServerHealth.ps1
│ │ ├── Get-HealthEventType.ps1
│ │ └── Save-HealthState.ps1
│ │
│ └── Private/
│ ├── Get-OverallStatus.ps1
│ └── Test-HealthStatus.ps1
│
├── Config/
│ ├── Servers.csv
│ └── NotificationConfig.csv
│
├── Input/
│ ├── NewUsers.csv
│ └── Offboarding.csv
│
├── Scripts/
│ ├── Collect-ServerHealth.ps1
│ ├── Collect-ADAudit.ps1
│ ├── New-DailyHtmlReport.ps1
│ ├── Send-HealthAlert.ps1
│ ├── Preview-Onboarding.ps1
│ └── Preview-Offboarding.ps1
│
├── Reports/
│
├── Logs/
│
└── State/

這時候已經開始有一個真正專案的樣子。
Scripts 跟 Modules 的責任開始清楚
Modules
負責:
How

例如:
CPU 怎麼查?

State 怎麼比較?

Log 怎麼寫?

Scripts
負責:
What / Workflow

例如:
先巡檢 Server。

再產 HTML。

有問題就通知。

Config
負責:
Environment Differences

例如:
Server 清單
Threshold
Group 名稱
Email Recipient

State
負責:
Previous Execution Memory

Reports
負責:
Output

Logs
負責:
Execution History

這就是前 23 天慢慢累積起來的架構。
今天做一支簡化 Main Script
現在真正的 Daily Script 可以變成:

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

Daily Infrastructure Check

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

$ModulePath =
Join-Path $PSScriptRoot
"..\Modules\SysAdminToolkit\SysAdminToolkit.psd1"

Import-Module $ModulePath
-ErrorAction Stop

$LogFile =
Join-Path $PSScriptRoot
"..\Logs\DailyCheck.log"

Write-Log -Path $LogFile
-Message "Daily infrastructure check started."

$ConfigFile =
Join-Path $PSScriptRoot
"..\Config\Servers.csv"

$Servers =
Import-Csv `
$ConfigFile

$Results =
foreach ($Server in $Servers) {

    Write-Log `
        -Path $LogFile `
        -Message "Checking $($Server.ComputerName)"


    Get-ServerHealth `
        -ComputerName $Server.ComputerName `
        -CPUWarning ([int]$Server.CPUWarning) `
        -CPUCritical ([int]$Server.CPUCritical) `
        -MemoryWarning ([int]$Server.MemoryWarning) `
        -MemoryCritical ([int]$Server.MemoryCritical)
}

$ReportFile =
Join-Path $PSScriptRoot
"..\Reports\ServerHealth.csv"

$Results |
Export-Csv -Path $ReportFile
-NoTypeInformation `
-Encoding UTF8

Write-Log -Path $LogFile
-Message "Daily infrastructure check completed."

你會發現:
Main Script 變得非常容易閱讀。

以前看 Script
可能:
第 1~100 行
Function

第 101~300 行
Function

第 301~450 行
Function

第 451 行
Main 開始

現在:
Import Module
↓
Import Config
↓
foreach Server
↓
Get-ServerHealth
↓
Export Report

第一次打開 Script 的工程師也比較容易知道:
這支程式到底要幹嘛。

這其實跟軟體開發概念很像
一開始:
全部寫在一起

快速。
後來功能增加:
Function

再增加:
不同 Script

再增加:
Shared Module

這就是程式慢慢從:
Script

走向:
Maintainable Tooling

的過程。
所以系統工程師學 PowerShell,不只是學:
Get-Service

更重要的是開始理解:
Reuse
Separation of Concerns
Configuration
State
Interface
Version
Maintainability

這些概念。
Day 24 小結
今天沒有新增新的:
CPU Check
AD Query
Event Log

而是處理一個隨著 Automation 越做越大,一定會遇到的問題:
Code 要怎麼維護?

我們把前 23 天零散的:
Write-Log

Get-ServerHealth

Get-HealthEventType

Save-HealthState

開始整理進:
SysAdminToolkit

並認識:
.psm1
→ Script Module

.psd1
→ Module Manifest

最後可以:
Import-Module SysAdminToolkit

使用:
Get-ServerHealth

而不是每一支 Script 都重新 Copy Function。
今天也開始建立:
Public
Private

概念:
Public
→ 使用者應該呼叫的 Function

Private
→ Module 內部實作

再搭配:
Export-ModuleMember

控制 Module 對外提供什麼。
另外我們開始加入:
[CmdletBinding()]

以及:
Comment-Based Help

讓自己的 Function 更接近真正 PowerShell Cmdlet。
最終目前架構已經變成:
Config
│
▼
PowerShell Scripts
│
▼
SysAdminToolkit
PowerShell Module
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Windows Server AD State / Log
│ │ │
└─────────────┼─────────────┘
▼
Report
│
▼
Notification

也就是我們已經從 Day 1:
Get-Service

一路走到現在:
Reusable
Versioned
Modular
SysAdmin Automation Toolkit

這是從:
「我會寫 PowerShell Script」

走向:
「我開始建立自己的維運工具」

很重要的一步。
Day 25 預告
Day 25|PowerShell 測試與防呆:怎麼知道修改 Module 後沒有把原本功能弄壞?
現在我們遇到下一個問題。
假設:
SysAdminToolkit 1.0.0

目前正常。
今天我修改:
Get-HealthEventType

想增加:
Unknown
Recovery

結果不小心讓:
Warning → Critical

原本應該:
Escalated

卻變成:
Changed

如果我們只能:
修改
↓
丟到 Production
↓
隔天才知道壞掉

那 Module 化還是不夠。
所以 Day 25 我們會開始碰:
PowerShell Testing
包括最基本的:
Function Input
↓
Expected Result
↓
Actual Result
↓
Pass / Fail

例如:
Previous = Healthy
Current = Critical

Expected = NewAlert

以及:
Previous = Critical
Current = Healthy

Expected = Recovery

並開始介紹 Pester 的思考方式,把:
「我覺得這個 Function 應該沒問題」

慢慢變成:
「我有測試可以證明主要邏輯仍然符合預期。」

Day 25 會是我們從 Automation Tooling 再往 可測試、可維護的工程化工具 前進的一步。


上一篇
Day 23|PowerShell 狀態追蹤:不要每天重複告警,只通知「新異常」與「恢復」
系列文
系統工程師的 30 天自動化維運實戰:PowerShell × AD × Windows Server 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言