📑

PowerShell 5.1 系で利用可能な PowerShell Workflow

2023/05/02に公開

ThreadJob module 使った方が楽かも

現時点での感覚として ThreadJob 使った方がなんかもろもろ楽な気がしています。
こちらもご覧いただければと思います。

https://zenn.dev/skmkzyk/articles/powershell-job

PowerShell 5.1 系で利用可能な PowerShell Workflow

この記事は PowerShell 5.1 系での PowerShell Workflow の話をしますので、2023 年の時点であまり参考になる人は少ないんじゃあないかとは祈っています。
とはいえ Windows Server に PowerShell 7.x 系が install できる環境も少ないかもしれないのでもしかしたら需要があるのかもしれません。

PowerShell Workflow を使ってみる

めっちゃ簡単なものだとこんな感じです。

workflow Test-Workflow {
    Get-NetAdapter
}

で、実行してみます。

> Test-Workflow

Name                      InterfaceDescription                    ifIndex Status       MacAddress             LinkSpeed
----                      --------------------                    ------- ------       ----------             ---------
Bluetooth Network Conn... Bluetooth Device (Personal Area Netw...      22 Disconnected 2C-33-58-xx-xx-xx         3 Mbps
Ethernet 3                Lenovo USB Ethernet                          21 Disconnected 48-2A-E3-xx-xx-xx          0 bps
Wi-Fi                     Intel(R) Wi-Fi 6 AX201 160MHz                11 Up           2C-33-58-xx-xx-xx       1.2 Gbps

まぁこんな感じで、関数みたいなのを定義して、それを呼べば実行できる、シンプルな感じです。

PowerShell Workflow での並列化

そもそも PowerShell Workflow を使おう、というのは並列化が目的なことが多いでしょうから、こんな感じになるわけですね。

workflow Test-Workflow {
    $NICs = Get-NetAdapter

    foreach -parallel ($NIC in $NICs) {
        Write-Output "$($NIC.Name)'s Link Speed is $($NIC.LinkSpeed)"    
    }
}

実行してみるとこんな感じです。

> Test-Workflow
Wi-Fi's Link Speed is 1.2 Gbps
Ethernet 3's Link Speed is 0 bps
Bluetooth Network Connection's Link Speed is 3 Mbps

確かに、Get-NetAdpater で取得したネットワーク インターフェース (のオブジェクト) それぞれに対して Write-Output が実行されていることがわかります。

PowerShell Workflow の定義を取得する

今実行しようとしている PowerShell Workflow の定義を確認する場合には Get-Command を使います。
Definition に定義が入っているので、それを見ればわかるという感じです。

> Get-Command -CommandType workflow -Name Test-Workflow -ShowCommandInfo

Name          : Test-Workflow
ModuleName    :
Module        : @{Name=}
CommandType   : Workflow
Definition    :
                    $NICs = Get-NetAdapter

                    foreach -parallel ($NIC in $NICs) {
                        Write-Output "$($NIC.Name)'s Link Speed is $($NIC.LinkSpeed)"
                    }

ParameterSets : {@{Name=__AllParameterSets; IsDefault=False; Parameters=System.Management.Automation.PSObject[]}}

Workflow の中で素直に使えない PowerShell module がある

現時点で法則性とかがちょっとわかっていないのですが、具体例として Import-Module -Name Az はそのままでは使えないようです。

> workflow Test-Workflow {
>>     Import-Module -Name Az
>> }
At line:2 char:5
+     Import-Module -Name Az
+     ~~~~~~~~~~~~~~~~~~~~~~
Cannot call the 'Import-Module' command. Other commands from this module have been packaged as workflow activities,
but this command was specifically excluded. This is likely because the command requires an interactive Windows
PowerShell session, or has behavior not suited for workflows. To run this command anyway, place it within an
inline-script (InlineScript { Import-Module }) where it will be invoked in isolation.
    + CategoryInfo          : ParserError: (:) [], ParseException
    + FullyQualifiedErrorId : CommandActivityExcluded

これは foreach -parallel の中に入れても同様です。

> workflow Test-Workflow {
>>     $VMs = Get-AzVM
>>
>>     ForEach -Parallel ($_VM in $VMs) {
>>         Import-Module -Name Az
>>     }
>> }
At line:5 char:9
+         Import-Module -Name Az
+         ~~~~~~~~~~~~~~~~~~~~~~
Cannot call the 'Import-Module' command. Other commands from this module have been packaged as workflow activities,
but this command was specifically excluded. This is likely because the command requires an interactive Windows
PowerShell session, or has behavior not suited for workflows. To run this command anyway, place it within an
inline-script (InlineScript { Import-Module }) where it will be invoked in isolation.
    + CategoryInfo          : ParserError: (:) [], ParseException
    + FullyQualifiedErrorId : CommandActivityExcluded

Error message に書かれているとおりですが、InlineScript で囲むと実行ができるようになります。
このままではあまり意味がないのですが、サンプルとして。。

workflow Test-Workflow {
    $VMs = Get-AzVM -ResourceGroupName "simple-windows10"

    ForEach -Parallel ($VM in $VMs) {
        InlineScript {
            Import-Module -Name Az
        }
    }
}

InlineScript の使い方に少し癖がある

で、InlineScript で囲まれた部分で、その外にある変数を使う場合には $using: を付ける必要があります。
ただ、なぜか少し深い property にアクセスしようとすると意図どおりには動いてくれません。

workflow Test-Workflow {
    $VMs = Get-AzVM

    ForEach -Parallel ($_VM in $VMs) {
        InlineScript {
            Write-Warning "`$Using:_VM.ResourceGroupName: $($Using:_VM.ResourceGroupName)"
            Write-Warning "`$Using:_VM.Name: $($Using:_VM.Name)"
            Write-Warning "`$Using:_VM.HardwareProfile.VmSize: $($Using:_VM.HardwareProfile.VmSize)"
        }
    }
}

結果は以下のとおりです。

> Test-Workflow
WARNING: [localhost]:$Using:_VM.ResourceGroupName: simple-windows10
WARNING: [localhost]:$Using:_VM.Name: vm-hub00
WARNING: [localhost]:$Using:_VM.HardwareProfile.VmSize:

なんと、$Using:_VM.HardwareProfile.VmSize では値が取れていないんですね。
ただ、$Using:_VM.ResourceGroupName$Using:_VM.Name は値が取れているので、それをもとに Get-AzVM を実行してみます。

workflow Test-Workflow {
    $VMs = Get-AzVM

    ForEach -Parallel ($_VM in $VMs) {
        InlineScript {
            $VM = Get-AzVM -ResourceGroupName $Using:_VM.ResourceGroupName -Name $Using:_VM.Name
            Write-Warning "`$VM.ResourceGroupName: $($VM.ResourceGroupName)"
            Write-Warning "`$VM.Name: $($VM.Name)"
            Write-Warning "`$VM.HardwareProfile.VmSize: $($VM.HardwareProfile.VmSize)"
        }
    }
}

結果は以下のとおりです。

> Test-Workflow
WARNING: [localhost]:$VM.ResourceGroupName: simple-windows10
WARNING: [localhost]:$VM.Name: vm-hub00
WARNING: [localhost]:$VM.HardwareProfile.VmSize: Standard_B2ms

ということで、やや冗長ではあるのですが、引数として渡されてきた $_VM を使って Get-AzVM を実行すればなんとなく用途には耐えそうです。
で、ふと、以下のような script を実行し、型がどうなっているかを確認したら理由が分かった気がします。

workflow Test-Workflow {
    $VMs = Get-AzVM

    ForEach -Parallel ($_VM in $VMs) {
        InlineScript {
            Write-Warning "`$Using:_VM: $(($Using:_VM).GetType().FullName)"

            $VM = Get-AzVM -ResourceGroupName $Using:_VM.ResourceGroupName -Name $Using:_VM.Name
            Write-Warning "`$VM: $($VM.GetType().FullName)"
        }
    }
}

結果は以下のとおりです。

> Test-Workflow
WARNING: [localhost]:$Using:_VM: System.Management.Automation.PSObject
WARNING: [localhost]:$VM: Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine

ということで、$Using:_VMSystem.Management.Automation.PSObject となってしまっており、本来の型である Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine ではない、ということが分かります。
で、何とか cast (型変換) ができないかなぁと思ったのですが、どうもうまくいかず、冗長ですが Get-AzVM を再度実行するのが一番手っ取り早いかな、という感じです。

ForEach -Parallel -ThrottleLimit での並列実行数制御

PC のスペックにもよると思いますが、ForEach -Parallel での並列実行数はデフォルトで 5 となっているようです。
どこにも明記はないのですが、いろいろ調べていると 5 以上にできないのか、などという stackoverflow などの質問が散見されるので、みんな悩んでいるんでしょう。。

で、例えばそれよりも小さくしたいという場合には -ThrottleLimit を使います。
何をやっているんだ、という意味の分からない script ですが、以下の内容を実行してみてください。

workflow Test-Workflow {
    $VMs = Get-AzVM

    ForEach -Parallel ($_VM in $VMs) {
        InlineScript {
            Write-Output "Start-Sleep -Seconds 10"
            Start-Sleep -Seconds 10
        }
    }
}

結果は静的に示しても仕方がないので省きますが、Azure VM が 10 台とかある環境で実行していただくとたぶん 5 連続で Start-Sleep -Seconds 10 が表示され、その後 10 秒待ってからまたぽつぽつと続きが表示される、、という感じになると思います。
ちなみに Task Manager で見ても、powershell.exe が全部で 6 つくらいは動いている状況が確認できるかと思います。

これに対し、以下のように -ThrottleLimit を指定すると、指定した数だけしか並列実行されないようになります。

workflow Test-Workflow {
    $VMs = Get-AzVM

    ForEach -Parallel -ThrottleLimit 2 ($_VM in $VMs) {
        InlineScript {
            Write-Output "Start-Sleep -Seconds 10"
            Start-Sleep -Seconds 10
        }
    }
}

この場合には、並列数を 2 に指定しているので、Start-Sleep -Seconds 10 が 2 つ表示され、その後 10 秒待ってからまたぽつぽつと、、という感じになると思います。
Task Manager で見ても、powershell.exe が全部で 3 つくらいは動いている状況が確認できるかと思います。

Workflow session

docs によれば、なんか Workflow session ってのがおすすめらしいですね。

https://learn.microsoft.com/powershell/module/psworkflow/about/about_workflows?view=powershell-5.1#workflow-requirements-and-configuration

ただ、localhost 宛てだとしても WinRM が必要っぽいので、エンタープライズだとたぶん無理な気がします。
もしやる場合には、Enable-PSRemoting で WinRM を有効化するんだろうけど、そもそも有効化されてるのか、を確認する方法はこちらです。

https://learn.microsoft.com/powershell/module/microsoft.wsman.management/test-wsman?view=powershell-5.1

> Test-WSMan -ComputerName localhost
Test-WSMan : <f:WSManFault xmlns:f="http://schemas.microsoft.com/wbem/wsman/1/wsmanfault" Code="2150858770"
Machine="DESKTOP-09TD89K"><f:Message>The client cannot connect to the destination specified in the request. Verify
that the service on the destination is running and is accepting requests. Consult the logs and documentation for the
WS-Management service running on the destination, most commonly IIS or WinRM. If the destination is the WinRM service,
run the following command on the destination to analyze and configure the WinRM service: "winrm quickconfig".
</f:Message></f:WSManFault>
At line:1 char:1
+ Test-WSMan -ComputerName localhost
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (localhost:String) [Test-WSMan], InvalidOperationException
    + FullyQualifiedErrorId : WsManError,Microsoft.WSMan.Management.TestWSManCommand

参考

  • 大体ここからたどっていきます

https://learn.microsoft.com/powershell/module/psworkflow/about/about_workflows?view=powershell-5.1

  • ForEach -Parallel についてはこちらです。ForEach-Object -Parallel は PowerShell 7.x 移行で使えるものでこれとは異なりますのでご注意ください

https://learn.microsoft.com/powershell/module/psworkflow/about/about_Foreach-Parallel?view=powershell-5.1

  • InlineScript についてはこちらです

https://learn.microsoft.com/powershell/module/psworkflow/about/about_InlineScript?view=powershell-5.1

Microsoft (有志)

Discussion