PowerShellスクリプトの署名と実行

EXCEL

PowerShellスクリプトの署名と並列実行

PowerShellスクリプトの署名と実行は、セキュリティポリシー遵守と運用の効率化に不可欠である。本稿では、署名手順、Runspaceを用いた並列実行、エラー処理、ロギング、性能計測を含む包括的なアプローチを示す。

目的と前提 / 設計方針(同期/非同期、可観測性)

目的は、セキュアなスクリプト実行環境を構築し、複数のWindowsホストに対する処理を効率的かつ堅牢に自動化することにある。前提として、スクリプトを実行するクライアントとターゲットホスト間でネットワーク接続が可能であり、適切な権限(リモート処理にはWinRM、スクリプト署名には署名証明書)が与えられていることとする。

設計方針として、複数のホストに対する操作は非同期の並列処理を採用し、全体の実行時間を短縮する。処理の可観測性を確保するため、各ホストでの処理状況、成功/失敗、実行時間を構造化ログとして記録する。失敗時には再試行メカニズムを組み込み、全体の信頼性を向上させる。

graph TD
    A["スクリプト開発"] --> B("証明書取得/生成");
    B --> C{"スクリプト署名"};
    C --> D["実行ポリシー設定"];
    D --> E("ターゲットホストリスト取得");
    E --> F{"Runspaceプール初期化"};
    F --> G["各ホストで並列処理"];
    G -- 成功時 --> H["結果記録"];
    G -- 失敗時 --> I["エラーハンドリング/再試行"];
    I -- 成功時または最大再試行回数超過 --> H;
    H --> J("Runspaceプール終了");
    J --> K["全体性能計測/ログ集計"];
    K --> L["運用レポート/監視"];

コア実装(並列/キューイング/キャンセル)

ここでは、スクリプトの署名方法と、複数のリモートホストに対して並列で処理を実行するスクリプトの実装を示す。並列処理にはRunspacePoolを使用し、各Runspace内で個別のスクリプトブロックを実行する。再試行とタイムアウトもこのスクリプトブロック内で実装する。

スクリプト署名

まず、スクリプトに署名するための証明書が必要となる。ここではテスト用に自己署名証明書を生成するが、本番環境ではCAが発行したコード署名証明書を使用することが推奨される。

# 1. 自己署名証明書の生成 (テスト用途)
# 既存の証明書がある場合はこのステップはスキップ
# $cert = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
# 上記で証明書が見つからない場合、または新規作成する場合
if (-not (Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object {$_.Subject -eq "CN=PowerShell Signing Test" -and $_.KeyUsage -like "*DigitalSignature*"})) {
    Write-Host "自己署名証明書を生成します..." -ForegroundColor Yellow
    $cert = New-SelfSignedCertificate -Subject "CN=PowerShell Signing Test" -Type CodeSigningCert -CertStoreLocation Cert:\CurrentUser\My -KeyDescription "PowerShell Code Signing" -KeyUsage DigitalSignature -NotAfter (Get-Date).AddYears(5)
    Write-Host "証明書が生成されました: $($cert.Thumbprint)" -ForegroundColor Green
} else {
    $cert = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object {$_.Subject -eq "CN=PowerShell Signing Test" -and $_.KeyUsage -like "*DigitalSignature*"} | Select-Object -First 1
    Write-Host "既存の自己署名証明書を使用します: $($cert.Thumbprint)" -ForegroundColor Green
}

# 2. 署名対象のスクリプトファイルパス
$scriptToSign = "C:\Temp\MySignedScript.ps1"

# 3. 署名対象のスクリプトを作成 (テスト用)
@"
param([string]\$ComputerName = \$env:COMPUTERNAME)
try {
    Write-Output "[$(Get-Date -Format 'HH:mm:ss')] $($ComputerName): サービス状態取得を開始..."
    \$services = Get-Service -ComputerName \$ComputerName | Select-Object Name, Status, DisplayName -ErrorAction Stop
    Write-Output "[$(Get-Date -Format 'HH:mm:ss')] $($ComputerName): サービス状態取得が完了しました。"
    return \$services
}
catch {
    Write-Error "[$(Get-Date -Format 'HH:mm:ss')] $($ComputerName): エラー発生 - \$(\$_.Exception.Message)"
    throw \$exception # 親Runspaceに例外を再スロー
}
"@ | Set-Content -Path $scriptToSign -Encoding UTF8 -Force

Write-Host "スクリプトファイルが作成されました: $scriptToSign" -ForegroundColor Green

# 4. スクリプトに署名
try {
    Set-AuthenticodeSignature -FilePath $scriptToSign -Certificate $cert -Force
    Write-Host "スクリプト '$scriptToSign' に署名が完了しました。" -ForegroundColor Green
    Get-AuthenticodeSignature -FilePath $scriptToSign | Format-List
}
catch {
    Write-Error "スクリプト署名中にエラーが発生しました: $($_.Exception.Message)" -ErrorAction Stop
}

# 5. 実行ポリシーの設定 (必要に応じて)
# 署名済みスクリプトを実行するには、RemoteSigned, AllSigned, または Bypass のいずれかである必要がある
# Get-ExecutionPolicy # 現在のポリシーを確認
# Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force # 例: カレントユーザーのみRemoteSignedに設定

並列実行スクリプト

次に、署名済みスクリプトを複数のターゲットホストで並列実行するスクリプトを実装する。ここでは、最大同時実行数、再試行回数、タイムアウトを設定し、結果を構造化ログとして出力する。

# 実行ポリシーをBypassに設定することで、テスト時に署名が不要になるが、本番ではAllSignedなどを推奨
# Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force

$scriptToExecute = "C:\Temp\MySignedScript.ps1" # 先ほど署名したスクリプト
$targetComputers = @(
    "localhost", # 自身のコンピュータ
    "NonExistentHost1", # 存在しないホスト (エラーテスト用)
    "NonExistentHost2"  # 存在しないホスト (エラーテスト用)
    # 必要に応じて他のホスト名を追加
)
$maxConcurrentRunspaces = 5 # 最大同時実行Runspace数
$maxRetries = 3             # 失敗時の最大再試行回数
$retryDelaySeconds = 5      # 再試行までの待機時間 (秒)
$commandTimeoutSeconds = 30 # 各ホストでのコマンド実行タイムアウト (秒)
$logFilePath = "C:\Temp\ExecutionLog_$(Get-Date -Format 'yyyyMMdd_HHmmss').json"
$transcriptPath = "C:\Temp\Transcript_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"

# トランスクリプトを開始 (監査証跡用)
Start-Transcript -Path $transcriptPath -Append -Force

Write-Host "===============================================" -ForegroundColor Cyan
Write-Host " PowerShellスクリプト並列実行開始" -ForegroundColor Cyan
Write-Host " ターゲットホスト数: $($targetComputers.Count)" -ForegroundColor Cyan
Write-Host " 最大同時実行数: $maxConcurrentRunspaces" -ForegroundColor Cyan
Write-Host " ログファイル: $logFilePath" -ForegroundColor Cyan
Write-Host "===============================================" -ForegroundColor Cyan

# RunspacePoolの準備
$runspacePool = [runspacefactory]::CreateRunspacePool(1, $maxConcurrentRunspaces)
$runspacePool.Open()

# PowerShellオブジェクトのリスト
$powershells = @()
$results = [System.Collections.Generic.List[object]]::new()

# 各ターゲットホストに対する処理をRunspaceに登録
foreach ($computer in $targetComputers) {
    $powershell = [powershell]::Create()
    $powershell.RunspacePool = $runspacePool

    # 外部変数をRunspaceスクリプトブロックに渡す
    $powershell.AddScript({
        param(
            [string]$ComputerName,
            [string]$ScriptPath,
            [int]$MaxRetries,
            [int]$RetryDelaySeconds,
            [int]$CommandTimeoutSeconds
        )

        $attempt = 0
        $success = $false
        $output = $null
        $errorMessage = $null
        $startTime = Get-Date

        do {
            $attempt++
            Write-Output "[$(Get-Date -Format 'HH:mm:ss')] $($ComputerName): 試行 #$attempt"

            try {
                # スクリプトファイルの内容を読み込み、Invoke-Expressionで実行
                # AddScript().Invoke()も可能だが、スクリプトパスと引数渡しを簡潔にするためInvoke-Expression
                $scriptContent = Get-Content -Path $ScriptPath -Raw -Encoding UTF8

                # CancellationTokenSource for custom timeout
                $cts = New-Object System.Threading.CancellationTokenSource
                $task = [System.Threading.Tasks.Task]::Run({
                    param($ScriptContent, $ComputerName, $CTS)
                    $scriptBlock = [ScriptBlock]::Create($ScriptContent)
                    # Invoke-Command -ScriptBlock $scriptBlock -ArgumentList $ComputerName # リモートホストでローカルスクリプト実行の場合
                    # ここではRunspace内部で対象ホストへのリモート処理を想定し、スクリプト内でComputerNameを引数に直接指定
                    try {
                        Invoke-Expression -Command ($scriptBlock.ToString() -replace 'param\(\[string\]\\$ComputerName = \$env:COMPUTERNAME\)', "param(\[string]\$ComputerName = '$ComputerName')") # 引数をスクリプト内容に埋め込み
                        # もしくは、スクリプトがparam($ComputerName)を受け取るように記述し、直接引数として渡す
                        # .$scriptBlock -ComputerName $ComputerName
                    } catch {
                        throw # スクリプト内のエラーを再スロー
                    }
                }, $cts.Token)

                $timeoutTask = [System.Threading.Tasks.Task]::Delay($CommandTimeoutSeconds * 1000)
                $completedTask = [System.Threading.Tasks.Task]::WaitAny($task, $timeoutTask)

                if ($completedTask -eq $timeoutTask.Id) {
                    $cts.Cancel() # キャンセル要求
                    throw "コマンドがタイムアウトしました (${CommandTimeoutSeconds}秒)。"
                }

                if ($task.IsFaulted) {
                    throw $task.Exception.InnerExceptions[0]
                }

                $output = $task.Result # Invoke-Expressionの結果を取得
                $success = $true
            }
            catch {
                $errorMessage = $_.Exception.Message
                Write-Error "[$(Get-Date -Format 'HH:mm:ss')] $($ComputerName): エラー発生 - $errorMessage"
                if ($attempt -lt $MaxRetries) {
                    Write-Warning "[$(Get-Date -Format 'HH:mm:ss')] $($ComputerName): $RetryDelaySeconds 秒待機して再試行します..."
                    Start-Sleep -Seconds $RetryDelaySeconds
                }
            }
        } while (-not $success -and $attempt -lt $MaxRetries)

        $endTime = Get-Date
        [pscustomobject]@{
            ComputerName = $ComputerName
            ScriptPath   = $ScriptPath
            Status       = if ($success) {"Success"} else {"Failed"}
            Attempts     = $attempt
            ErrorMessage = $errorMessage
            StartTime    = $startTime.ToString("yyyy-MM-dd HH:mm:ss")
            EndTime      = $endTime.ToString("yyyy-MM-dd HH:mm:ss")
            DurationMs   = ($endTime - $startTime).TotalMilliseconds
            Output       = $output # 成功時の出力を格納
        }
    }).AddParameter('ComputerName', $computer).AddParameter('ScriptPath', $scriptToExecute).AddParameter('MaxRetries', $maxRetries).AddParameter('RetryDelaySeconds', $retryDelaySeconds).AddParameter('CommandTimeoutSeconds', $commandTimeoutSeconds)

    $powershells += @{
        ComputerName = $computer
        PowerShell   = $powershell
        AsyncResult  = $powershell.BeginInvoke()
    }
}

Write-Host "すべてのRunspaceが開始されました。結果を待機中..." -ForegroundColor Yellow

# すべてのRunspaceの結果を収集
$measuredTime = Measure-Command {
    while ($powershells.Where({-not $_.AsyncResult.IsCompleted}).Count -gt 0) {
        Start-Sleep -Milliseconds 100
        foreach ($psInfo in $powershells.Where({-not $_.AsyncResult.IsCompleted})) {
            # 個別のRunspaceタイムアウト処理もここで監視可能だが、今回はスクリプトブロック内で実装済み
        }
    }

    foreach ($psInfo in $powershells) {
        try {
            $runspaceResult = $psInfo.PowerShell.EndInvoke($psInfo.AsyncResult)
            $results.Add($runspaceResult)
        }
        catch {
            # Runspace自体がエラーになった場合の処理 (通常はスクリプトブロック内で捕捉される)
            $results.Add([pscustomobject]@{
                ComputerName = $psInfo.ComputerName
                ScriptPath   = $scriptToExecute
                Status       = "RunspaceFailed"
                Attempts     = 1
                ErrorMessage = $_.Exception.Message
                StartTime    = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
                EndTime      = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
                DurationMs   = 0
                Output       = $null
            })
        }
        finally {
            $psInfo.PowerShell.Dispose()
        }
    }
}

# RunspacePoolのクリーンアップ
$runspacePool.Close()
$runspacePool.Dispose()

Write-Host "すべてのRunspaceが完了しました。" -ForegroundColor Green
Write-Host "総実行時間: $($measuredTime.TotalSeconds)秒" -ForegroundColor Green

# 結果の表示とログへの出力
$results | Format-Table -AutoSize
$results | ConvertTo-Json -Depth 5 | Set-Content -Path $logFilePath -Encoding UTF8 -Force

Write-Host "結果が '$logFilePath' に出力されました。" -ForegroundColor Green
Write-Host "===============================================" -ForegroundColor Cyan

# トランスクリプトを停止
Stop-Transcript

検証(性能・正しさ)と計測スクリプト

上記の並列実行スクリプトは、Measure-Commandを使用して全体の実行時間を計測している。個々のRunspace内での処理時間もDurationMsプロパティとして記録されるため、ボトルネックの特定が可能である。

正しさの検証: – 成功ホストからの期待されるサービス情報が取得されているか。 – 存在しないホストに対してエラーが適切に捕捉され、再試行後にFailedステータスになっているか。 – タイムアウトが発生した場合、その旨がログに記録されているか。 – logFilePathに出力されたJSONログが適切に構造化され、すべての情報が含まれているか。

計測スクリプト: 上記のコード例で$measuredTime = Measure-Command { ... }によって既に計測が行われている。この$measuredTime.TotalSecondsが全体のスループットを示す指標となる。RunspaceごとのDurationMsを平均することで、個々のホストに対する処理性能を評価できる。

運用:ログローテーション/失敗時再実行/権限

ロギング戦略とローテーション

前述のスクリプトでは、構造化ログ(JSON)とトランスクリプト(テキスト)を出力している。 – 構造化ログ: 各ホストの処理結果を機械可読形式で保存する。これにより、BIツールや他のスクリプトで容易に解析できる。 – トランスクリプト: スクリプト実行中の標準出力、エラー、警告をすべて記録し、監査証跡として利用できる。

ログローテーションの例: 日次や週次でログを自動的にアーカイブする。

function Rotate-Log {
    param(
        [string]$LogPath,
        [int]$KeepDays = 30
    )

    $logDir = Split-Path -Path $LogPath -Parent
    $logName = Split-Path -Path $LogPath -Leaf -ExcludeExtension
    $logExt = [System.IO.Path]::GetExtension($LogPath)

    # 既存のログファイルをアーカイブ
    Get-ChildItem -Path $logDir -Filter "$logName*$logExt" | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) -and $_.Name -notmatch "$logName`_$(Get-Date -Format 'yyyyMMdd_HHmmss')"} | ForEach-Object {
        $archivePath = Join-Path -Path $logDir -ChildPath ("Archive_" + $_.Name)
        Move-Item -Path $_.FullName -Destination $archivePath -Force
        Write-Host "ログファイルをアーカイブしました: $($_.Name) -> $(Split-Path -Path $archivePath -Leaf)" -ForegroundColor DarkGray
    }

    # 古いアーカイブを削除
    Get-ChildItem -Path $logDir -Filter "Archive_$logName*$logExt" | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$KeepDays) } | Remove-Item -Force -Confirm:$false
}

# 実行例 (上記スクリプト実行後)
# Rotate-Log -LogPath $logFilePath -KeepDays 90
# Rotate-Log -LogPath $transcriptPath -KeepDays 90

失敗時再実行

構造化ログには失敗したホストの情報が含まれる。この情報を使って、失敗したホストのみを対象にスクリプトを再実行できる。

function Invoke-FailedHostRetry {
    param(
        [string]$PreviousLogPath,
        [string]$ScriptToExecute,
        [int]$MaxConcurrentRunspaces = 5,
        [int]$MaxRetries = 3,
        [int]$RetryDelaySeconds = 5,
        [int]$CommandTimeoutSeconds = 30
    )

    if (-not (Test-Path $PreviousLogPath)) {
        Write-Error "前回のログファイルが見つかりません: $PreviousLogPath"
        return
    }

    $previousResults = Get-Content -Path $PreviousLogPath | ConvertFrom-Json
    $failedComputers = $previousResults | Where-Object {$_.Status -ne "Success"} | Select-Object -ExpandProperty ComputerName -Unique

    if (-not $failedComputers) {
        Write-Host "再実行が必要な失敗ホストは見つかりませんでした。" -ForegroundColor Green
        return
    }

    Write-Host "以下のホストで再実行を開始します: $($failedComputers -join ', ')" -ForegroundColor Yellow
    # ここに前述の並列実行スクリプトのロジックを再利用する
    # 例: Invoke-ParallelScript -TargetComputers $failedComputers ...
    # 実際には、上記の並列実行スクリプトを関数化して呼び出すのが良い
}

# 実行例
# Invoke-FailedHostRetry -PreviousLogPath $logFilePath -ScriptToExecute $scriptToExecute

権限と安全対策

  • Just Enough Administration (JEA): PowerShell JEAを用いることで、管理者が特定のタスクを実行するために必要な最小限の権限のみを持つセッションエンドポイントを作成できる。これにより、リモートホストでのスクリプト実行権限を細かく制御し、過剰な権限付与を防ぐ。
  • SecretManagementモジュール: 認証情報やAPIキーなどの機密情報を安全に取り扱うために、SecretManagementモジュールと対応する拡張ボールト(例: Microsoft.PowerShell.SecretStore)を使用する。スクリプト内にハードコードされた資格情報を含めることは避ける。
  • 実行ポリシー: AllSignedまたはRemoteSignedポリシーを適用し、未署名または信頼できない発行元によって署名されたスクリプトの実行を制限する。本番環境ではAllSignedが最も厳格で推奨される。

落とし穴(例:PowerShell 5 vs 7の差、スレッド安全性、UTF-8問題)

  • PowerShell 5.1 vs 7.xの差:
    • ForEach-Object -Parallel: PowerShell 7.xで導入された機能であり、5.1では利用できない。5.1で並列処理を行うには、本稿で示したRunspacePoolを使用する必要がある。
    • デフォルトエンコーディング: PowerShell 5.1のSet-ContentはデフォルトでASCIIやUTF-16LE(BOM付き)を使用する場合がある。PowerShell 7.xではデフォルトがUTF-8(BOMなし)に統一された。スクリプトやデータファイルの保存・読み込み時にエンコーディングを明示的に指定しないと、文字化けや予期せぬスクリプトエラーが発生する可能性がある(例:Set-Content -Encoding UTF8)。
  • スレッド安全性:
    • RunspaceThreadJobで並列処理を行う際、複数のスレッドから共有変数にアクセスすると競合状態が発生する可能性がある。グローバル変数($global:)やスクリプト変数($script:)への書き込みは特に注意が必要。必要に応じてロック機構(例:[System.Threading.Monitor]::Enter($lockObject))を実装するか、各Runspaceに変数をコピーして独立させる設計を検討する。
  • リモート処理時の認証情報:
    • Get-Credentialで取得した資格情報をInvoke-Command -Credentialで渡すことは可能だが、ループ内で何度も資格情報を渡すのは非効率で、セキュリティ上の懸念もある。CredSSPKerberosの二重ホップ問題を解決し、Implicit RemotingやJEAを組み合わせることが推奨される。
  • タイムアウトの実装:
    • PowerShellのInvoke-Commandには-SessionOptionOperationTimeoutSecを指定できるが、これは接続と最初のコマンド実行に対するタイムアウトであり、スクリプト全体の実行時間ではない。より粒度の細かいタイムアウト制御には、本稿で示したSystem.Threading.Tasks.TaskCancellationTokenSourceを組み合わせたカスタム実装が必要となる。
  • エラーアクション設定:
    • $ErrorActionPreference = 'Stop'を設定すると、エラー発生時にスクリプトが即座に停止する。並列処理では、特定のRunspaceでのエラーが他のRunspaceに影響を与えないよう、try/catchブロック内で-ErrorAction Stopを個別に指定し、Runspaceレベルでエラーを捕捉・処理する。

まとめ

PowerShellスクリプトの署名と実行ポリシーの適用は、セキュリティ基盤の要となる。また、RunspacePoolを用いた並列処理は、複数のWindowsホストに対する運用タスクの効率を飛躍的に向上させる。本稿で示したように、堅牢なエラーハンドリング、再試行ロジック、タイムアウト制御、構造化されたロギングを組み合わせることで、大規模な環境においても信頼性の高い自動化を実現できる。JEAやSecretManagementなどの安全対策も講じ、セキュアで効率的なWindows運用を目指すべきである。

ライセンス:本記事のテキスト/コードは特記なき限り CC BY 4.0 です。引用の際は出典URL(本ページ)を明記してください。
利用ポリシー もご参照ください。

コメント

タイトルとURLをコピーしました