朝日ネット 技術者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

VMware PowerCLI の並行実行 (RunspacePool 編)

サービス基盤部の羽賀です。

私の担当している業務のうちに VMware 社の vSphere という製品上で動作する仮想マシン(VM)の管理があります。

vSphere では管理用の製品である vCenter Server の Web インターフェースを操作することで管理を行えますが、 VMware が提供する PowerShell モジュールである PowerCLI を用いて PowerShell スクリプトで vCenter Server に対してコマンドを発行することもできます。 私の業務においては、反復して行ったり、対象が多い作業についてはこれを利用して PowerShell スクリプト化し、 Windows 10 に標準でインストールされている Windows PowerShell 5.1 で実行することで効率化を行っています。

ただ、同時に多くの対象に対して同じ作業を行いたいという場合は直列で実行すると待ち時間が長くなってしまいます。 そのような場合に、処理を並行あるいは並列で実行することで、その作業全体が終わるまでの時間を短くすることができることがあります。

PowerCLI を使った操作の並行・並列実行で利用できる手法としては以下のようなものがあります

  • 自前で PowerShell を複数起動して実行する
  • PowerShell のバックグラウンドジョブ*1でバックグラウンド実行
  • PowerShell の ThreadJob*2 で並列ジョブ実行
    • Windows 10 標準の Windows PowerShell 5.1 では追加モジュールが必要。クロスプラットフォーム版 PowerShell 6 以降は標準機能に
  • ForEach-Object -Parallel による並行・並列実行
    • クロスプラットフォーム版 PowerShell 7 からの機能*3
  • PowerShell の API から RunspacePool を利用し PowerShell スクリプトの並行・並列実行を行う
  • PowerCLI の非同期実行機能(-RunAync オプションと *-Task 系コマンドレット)を使う

PowerCLI の非同期実行機能を使うもの以外は、 PowerShell 本体の機能を使うものとなります。

今回は

  • 普段使用している Windows 10 標準の Windows PowerShell 5.1 で動作させたい
  • PowerCLI 以外でも使えそうな場面があれば適用したい
  • PowerShell の API にも興味があった

という理由から、 RunspacePool を使い PowerCLI による作業を並行実行をするという手法について調査を行いました。

コード例としては、 PowerCLI の Invoke-VMScript による VM 上で動作するゲストOSでのシェルスクリプト実行を多数の VM について行うという作業を今回の手法で実装し、直列で行う場合に比べて実行時間がどうなるかを計測し、比較しています。

仕組みについて

以下においては、API のリファレンスを見ても仕組みや使い方については公式ドキュメントでは情報が不足しているため、

https://devblogs.microsoft.com/scripting/beginning-use-of-powershell-runspaces-part-1/

から始まる一連のシリーズ上の解説を参考にしています。

PowerShell の API を使うことで PowerShell のインスタンスを作成し、そこでスクリプトを実行させるということができます。 スクリプトを実行させる際にはこの PowerShell インスタンスに Runspace という、スクリプト実行環境(変数やモジュールといった状態を持つ)を抽象化したオブジェクトを割り当てます (PowerShell インスタンスの Runspace プロパティに設定することで割り当てられる。また、明示的に割り当てなくとも、PowerShell インスタンスを作成した際に自動的に割り当てられる)。

RunspacePool は複数の Runspace をプールし、それを PowerShell インスタンスに対して割り当てる機能を持ったオブジェクトです。 実行単位となるそれぞれの PowerShell インスタンスに RunspacePool を割り当てる(PowerShell インスタンスの RunspacePool プロパティに設定する)ことで、 RunspacePool がプールしている Runspace の数だけ PowerShell インスタンスを並行・並列実行させることができます。

手順

RunspacePool を使ってスクリプトを並列実行する場合は以下のような手順になります。

  • RunspacePool を作成し Open する
  • 実行単位の情報を保存するコレクションを作成
  • 実行単位ごとに以下の操作を行う
    • PowerShell インスタンスを作成して、 Open した RunspacePool を割り当てる
    • PowerShell スクリプトを PowerShell インスタンスに割り当て(AddScript, AddParameters)
    • 非同期実行を開始(BeginInvoke)し PowerShell インスタンスとハンドルをコレクションに追加
  • コレクションを走査して実行単位の終了待ちおよび後処理と結果受け取り

RunspacePool を作成し Open する

    $rspool = [runspacefactory]::CreateRunspacePool()
    [void]$rspool.SetMaxRunspaces(5)
    $rspool.Open()

RunspaceFactory 型アクセラレータを使って CreateRunspacePool() を呼び出し、 RunspacePool を作成します。 SetMaxRunspaces() でこの RunspacePool に作成する Runspace の最大数を設定します。 この数がこの RunspacePool を使って並列実行する数となります。 最後に Open() メソッドで RunspacePool を Open 状態にします。

実行単位の情報を保存するコレクションを作成

    $jobs = [System.Collections.ArrayList]::new()

並列実行する実行単位についての情報を保存するためのコレクションを作成します。 PowerShell インスタンスを実行するにあたっては、並行・並列を実現するために非同期実行を行うことになるので、実行結果を受け取るためのハンドルを一旦保存しておく必要があります。

コレクションの実装としては、今回は ArrayList を使用しています。通常の配列でも可能ですが、こちらの方が性能的に有利とされています。*4

PowerShell インスタンスを作成して、 Open した RunspacePool を割り当てる

        $ps = [powershell]::Create()
        $ps.RunspacePool = $rspool

ここからは並行・並列実行させる実行単位ごとに行う内容となります。

まず PowerShell 型アクセラレータを使って Create() を呼び出し、 PowerShell インスタンスを作成します。 これが並行・並列実行をする実行単位となります。 そして、作成された PowerShell インスタンスの RunspacePool プロパティに Open した RunspacePool を代入することで割り当てます。 これで並行・並列実行についてはだいたいが RunspacePool の方で面倒を見てくれるようになります。

PowerShell スクリプトを PowerShell インスタンスに割り当て(AddScript, AddParameters)

        [void]$ps.AddScript({
            # コード本体
        })
        [void]$ps.AddParameters(@{
            # ハッシュテーブルでパラメータを渡す
        })

並行・並列実行させるコード本体については PowerShell インスタンスの AddScript() メソッドにスクリプトブロックとして渡すことで追加します。 また、渡したコードでは外のスコープの変数を参照できないので、 PowerShell インスタンスの AddParameters メソッドにハッシュテーブルで渡すことで、コードの方ではパラメータとして受け取れるようになります(後述のコード例を参照してください)。

非同期実行を開始(BeginInvoke)し PowerShell インスタンスとハンドルをコレクションに追加

        $handle = $ps.BeginInvoke()
        [void]$jobs.Add(@{powershell = $ps; handle = $handle})

PowerShell インスタンスの BeginInvoke() メソッドを呼び出すことで、 AddScript で 追加されているコードが実行開始されます。 このメソッドは非同期なので実行結果ではなくハンドルを返します。 PowerShell インスタンスには RunspacePool が割り当てられているため、これで RunspacePool にキューされて順次それが管理されている Runspace で並行・並列に実行されることになります。

コレクションを走査して実行単位の終了待ちおよび後処理と結果受け取り

    $return = $jobs | ForEach-Object {
        $_.powershell.EndInvoke($_.handle)
        $_.powershell.Dispose()
    }
    $jobs.clear()
    $rspool.Dispose()

全ての実行単位について上記の処理が終わったら、各実行単位について終了待ちおよび結果の取得を行います。これは PowerShell インスタンスの EndInvoke() メソッドにより行います。 その後 Dispose() メソッドによりインスタンスの破棄を行います(何も返さないのでこのように書いても結果取得に影響はない)。 コレクションが ArrayList であれば、 ForEach-Object で走査することで実行開始した順番と同じ順番で結果を取得できます。 また、 RunspacePool も使い終わっているので Dispose() メソッドで破棄しておきます。

コード例

以下にコード例を載せています。このコードでは一つの実用的な例として、 VMware ESXi ホスト上で動作する VM に対して、 PowerCLI モジュールを使って VMware vCenter 経由でシェルスクリプトを実行するという操作を今回の手法で並行・並列で行っています。 これは単純なシェルスクリプトであってもそこそこ時間がかかる操作なので、並行・並列実行の効果がはっきりと出ます。

function AddScriptBlock {
    Param (
        $PowerShell
    )
    [void]$PowerShell.AddScript({
        Param (
            $DefaultVIServer,
            $DefaultVIServers,
            $VM,
            [PSCredential]
            $GuestCredential
        )
        $global:DefaultVIServer  = $DefaultVIServer
        $global:DefaultVIServers = $DefaultVIServers
        $result = [PSCustomObject]@{
            VMName = $null
            Output = $null
            Thread = $null
            ProcessID = $null
            Error = ""
        }
        try {
            $ThreadID = [appdomain]::GetCurrentThreadId()
            $result.VMName = $VM
            $result.Thread = $ThreadID
            $result.ProcessID = $PID
            $result.Output = (Invoke-VMScript (Get-VM $VM) -ScriptText "ls" -GuestCredential $GuestCredential)
            #Get-Command Get-VM
            $result
        } catch {
            $result.Error = $_
            return $result
        } 
    })
}

function VmSer {
    Param (
        [PSCredential]
        $GuestCredential
    )
    $cred = $GuestCredential
    Get-Content ./vmname.txt | ForEach-Object {
        $vmname = $_
        $ps = [powershell]::Create()

        AddScriptBlock($ps)

        [void]$ps.AddParameters(@{
            DefaultVIServer  = $global:DefaultVIServer
            DefaultVIServers = $global:DefaultVIServers
            VM = $vmname
            GuestCredential  = $cred
        })
        $ps.Invoke()
    } | Format-Table | Out-Default
}

function VmRs {
    Param (
        [PSCredential]
        $GuestCredential
    )
    $cred = $GuestCredential
    $rspool = [runspacefactory]::CreateRunspacePool()
    [void]$rspool.SetMaxRunspaces(5)
    $rspool.Open()
    $jobs = [System.Collections.ArrayList]::new()
    Get-Content ./vmname.txt | ForEach-Object {
        $vmname = $_
        $ps = [powershell]::Create()
        $ps.RunspacePool = $rspool

        AddScriptBlock($ps)

        [void]$ps.AddParameters(@{
            DefaultVIServer  = $global:DefaultVIServer
            DefaultVIServers = $global:DefaultVIServers
            VM = $vmname
            GuestCredential  = $cred
        })
        $handle = $ps.BeginInvoke()
        [void]$jobs.Add(@{powershell = $ps; handle = $handle})
    }
    $return = $jobs | ForEach-Object {
        $_.powershell.EndInvoke($_.handle)
        $_.powershell.Dispose()
    }
    $jobs.clear()
    $rspool.Dispose()

    $return | Out-Default
    $return.Error | Out-Default

    Write-Output "Process info" | Out-Default

    $return | Group-Object ProcessID | Select-Object Count, Name | Out-Default

    Write-Output "Thread info" | Out-Default

    $return | Group-Object Thread | Select-Object Count, Name | Out-Default

    Write-Output "Thread count" | Out-Default

    ($return | Group-Object Thread).Count | Out-Default
}

$cred = Import-Clixml ./cred.xml

#<#
Measure-Command -Debug -Verbose {
    VmSer $cred
}
#>

#<#
Measure-Command -Debug -Verbose {
    VmRs $cred
}
#>

コード例への補足

  • 実行前に呼び出し元で Connect-VIServer による VCenter への接続が必要
  • カレントディレクトリにある vmname.txt からVM名の一覧を読み取り(各行にVM名を記入)、 Invoke-VMScript により ls コマンドを実行している
    • スクリプト実行用のゲストOS認証では、 Get-Credential で作成した PSCredential オブジェクトを Export-Clixml にて cred.xml という名前で出力しておき、 Impoert-Clixml で読み込むことにより使用している
  • 直列に実行する(VmSer 関数)のと今回の手法で並行・並列実行する(VmRs 関数)のとそれぞれの実行時間を Measure-Command で計測している
    • Measure-Command 内で実行しているコマンドの出力をコンソールに表示させるために明示的に Out-Host を指定している
  • 今回の並行・並列実行手法を使った VmRs 関数の方では参考にした https://devblogs.microsoft.com/scripting/beginning-use-of-powershell-runspaces-part-1/ のシリーズと同様にプロセスとスレッドの情報を取得するコードを入れている
    • 結果を見ると、それぞれの Runspace がスレッドと対応しており、実行単位が分散して割り当てられることがわかる
  • 実行単位内で発生したエラーはコンソールに表示されないので、 try-catch により明示的に取得している
  • PowerCLI のコードを実行単位内で実行するに当たって、$global:DefaultVIServer $global:DefaultVIServers を参照できるようにする必要があったので、パラメータで渡して代入している
    • 今回はこれで十分であったが、 PSDrive を参照できないように完全に Connect-VIServer を再現できているわけではない。そういうこともあって、やはり単に並行実行をしたいのであれば PowerCLI の非同期実行を使用する方が適切であろう

コード例の実行時間計測

vmname.txt に 66 のVM名が記載されている状態で、直列実行と今回の手法での並行・並列実行(Runspace 上限を5個に設定)について Measure-Command により比較しました。

直列実行 今回の手法
約 570-620 秒 約 150 秒

1/5 の時間とはいきませんが、並行・並列実行することにより相当待ち時間は短縮されています。

まとめ

Windows 10 で標準でインストールされている Windows PowerShell 5.1 で RunspacePool を用いることにより並行・並列実行を実現できることが確認できました。

冒頭でも言及しましたが、マルチプラットフォーム版として開発されている PowerShell においては、 PowerShell 7.0 において ForEach-Object -Parallel で簡単に並行・並列実行を実現できるようになっています*5。ただマルチプラットフォーム版においては workflow のように削除された機能があったり非対応のモジュールもあることから今回のように直接 API を利用するような方法が有用な場面もあるかと思います。また、最新のマルチプラットフォーム版の PowerShell においてもこのまま利用可能です。

ただ、 PowerCLI による vSphere の操作を並行化するという目的であれば、先ほども軽く言及していましたが、 PowerCLI の非同期実行機能を使うことで並行・並列については vCenter Server に任せられるので 手元の PowerShell において並列実行を管理する必要はなくなり、実行効率も上がることが期待されます。 機会があればそちらについてもこの場で検証してみたいと思います。

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。

新卒採用 キャリア採用|株式会社朝日ネット