Technology Topics by Brains

ブレインズテクノロジーの研究開発機関「未来工場」で働くエンジニアが、先端オープン技術、機械学習×データ分析(異常検知、予兆検知)に関する取組みをご紹介します。

PowerShellDSCでハマったポイント

すっかり寒くなってきました。
こんにちは、ブレインズテクノロジーの白石です。

今回、業務の中でPowerShellDSCに触れることがありましたので、それについて得た知見を書きます。
というのも、PowerShellDSCの情報ってあんまり載ってないので。。。
こういうやり方あるよ、こういうの知ってるよ、ていうのがあれば是非是非コメントお願いします。

ちなみに、使用するPowerShellのバージョンは5.0以上ですが、一部5.0未満が対象の話題もあります。
特に理由がなければ5.0に上げることをオススメします。

PowerShellDSCとは

PowerShellDSCとは、Windows専用の構成自動化ツールです。
詳しい紹介はこちら。
www.atmarkit.co.jp

Configuration, Node, Resource, ConfigurationData あたりが理解できてると以降の内容は読みやすくなると思います。

管理マシンと構成対象のマシンでPSのバージョンは揃える

そりゃそうだ、という話ですが、直面すると意外に気づきません。
バージョンが異なるとモジュールの配置場所が違ったり仕様が異なったりしていて、あるマシンではうまく動くのに別のマシンでは全く動かない、という奇妙なバグに悩まされます。(体験談)

使用してるマシンのバージョンは $PSVersionTable で確認できるので、DSCで構築を行う前に対象のマシンと管理マシンのバージョンが揃っているか確認しておきましょう。

外部のモジュールは構築対象のマシンにも入れておく

これも言われれば、そりゃそうなんやろね、という感じですが。

PowerShellを使ってると、こういう機能ないの?と色々欲が出ます。そうした場合、同じように考えた人がやはりいて、大抵の場合はライブラリとして提供されているわけです。
そういったライブラリは Install-Module xxx とかで取り込んでいくのですが、
それらは取り込んだマシンでしか使えません。つまりローカルテストでは動きますが、リモートマシンで同じスクリプトを動かそうとすると、そのマシンには対応するライブラリがインストールされてないのでエラーになります。

この辺りは、そのモジュールをインストールするPSスクリプトを書いてリモートで適用するなり、
該当するライブラリをリモートで配置するなりして対応しましょう。

リソースは一意性がある

まず、「リソース」とはなんぞや。
「リソース」とは、PowerShellDSCの用語の1つで、マシンに適用する状態や操作のことだと思ってください。
プログラムを経験してる方には関数といえば何となく伝わるでしょうか。
例えば、フォルダを作る、ファイルを消す、Zipを展開する、環境変数を定める、などなど。

で、このリソースというもの。構成の中で一意性を保証する必要があり、そのためにキーがあります。
なんのこっちゃという感じなのでとりあえず例を。

File CreateDirectory {
  Ensure='Present'
  Type='Directory'
  DestinationPath='C:\sample'
}

これはディレクトリを作るリソース(関数)で、リソース名(=CreateDirectory)がキーとなっております。
もちろん動きます。

さて、もう1個ディレクトリ作りたいなーと思い、このリソースを使い回すことを考えたとします。

File CreateDirectory {
  Ensure='Present'
  Type='Directory'
  DestinationPath='C:\sample'
}

File CreateDirectory {
  Ensure='Present'
  Type='Directory'
  DestinationPath='C:\sample2'
}

エラーになります。2回呼び出しただけなのに、どういうことなのか。
先ほど上でリソースにはリソース名がキーとなっていると申し上げました。
つまり、リソース名(例だとCreateDirectory)は適用する構成全体の中でユニークである必要があるのです。
なのに同じリソース名を使ったため、一意性が失われてエラーになりました。
(ちなみに、リソースが違えばリソース名が同じでも大丈夫です。あくまで同じ種類のリソースで一意ということです。)

なるほど。というわけで、例えば以下のようにして解決します。

$destinationArray=@('C:\sample', 'C:\sample2')
foreach($path in $destinationArray){
 File (CreateDirectory + $path) {
   Ensure='Present'
   Type='Directory'
   DestinationPath=$path
 }
}

リソース名に変数をつけてやれば、リソース名の一意性は保たれます。
foreachとかで回したい時のちょっとしたテクニックです。
関数の感覚で使うと、この仕様はちょっと面食らいます。

キーはリソース名だけじゃない

上の話題は序の口です。
今度は例として、環境変数HOGEを作って削除するという何の意味もない操作を作ることにしましょう。

Environment CreateHoge {
 Ensure='Present'
 Name='HOGE'
 Value='C:\hoge'
}
Environment RemoveHoge {
 Ensure='Absent'
 Name='HOGE'
}

リソース名は別にしたのに怒られます。なぜなのか。
操作をよく見ると、HOGEという環境変数を「存在する」「存在しない」ようにする操作が混在してますね。
このEnvironmentというリソースは、環境変数名であるNameパラメータもキーとなっているため、リソース名だけでなく、Nameもユニークである必要があるのです。
この辺りは各リソースの仕様に依存しているので、設定を見るなり、エラーにぶつかりながら学ぶしかありません。

ちなみに、これを解決するにはScriptリソースを使って無理やり書くしかありません。
もしくはConfigurationそのものを分けるか。


そもそもこういう操作をDSCに求めるのが間違っている、ということだと私は思ってます(それでもやるときはやる)。

Scriptリソース内だと変数展開のタイミングが違う(powershell 5.0未満限定)

まだあるのか!!あるんです。ただし、PowerShell 5.0未満を使っている人だけです。
ここまででリソースにはキーがあり、それはリソース名だけでなく指定するパラメータにもあることを紹介しました。
そして、その関連で最後にハマるであろう箇所がScriptリソースを使用した時です。

本題の前にScriptリソースとはなんぞやという話をしておきましょう。

通常、FileリソースやEnvironmentリソースなど、予め用意されたリソースに所定のパラメータを渡すことによって目的とする操作を行うことができます。
ただ、そうした操作が用意されていない場合、もしくは、もっと細かい操作を書きたい、となった場合、
Scriptリソースを使います。
これ、中の記述で行いたい操作のPowerShellスクリプトを直接書いてしまいます。
詰まる所Scriptリソースさえあれば力づくで何だってできるわけです。
(これをやりだすとべき等性とかどうでもよくなってくる。。。)

話を元に戻します。
Scriptを使って複数のディレクトリを作る処理を書いてみます。
(通常はFileリソース使いますが、説明のためScriptで書きます)

foreach($path in @('C:\test1', 'C:\test2')){
 Script $path {
  GetScript={
   return $null;
  }
  TestScript={
   return (Test-Path $using:path);
  }
  SetScript={
   New-Item $using:path -ItemType Directory;
  }
 }
}

$using:pathとは、Scriptの外から変数を渡す際につける接頭語です。$pathでは解釈されません。
一見うまくいきそうです。少なくとも、Fileリソースで作っていた場合は問題ありませんでした。

これを実行すると以下のようなエラーが出ます。

Add-NodeKeys : キー プロパティの組み合わせ ' (中略) ' が、ノード 'localhost' 内のリソース 'Script' のキー 'GetScript,SetScript,TestScript' で重複しています
。キー プロパティは、ノード内の各リソースで一意になるようにしてください。

なんやそれ。。。
これは、Scriptリソース内で指定した$using:変数の展開がリソースのキーチェックの後に行われるため、キーが重複したと判断されているからです。
これをどう解決すればいいのかというと、一つはPowerShellのバージョンを5.0に上げてしまうことです。
5.0ではmofファイル生成時に$usingで使用している変数について、スクリプトの先頭に代入文が挿入されて解決されるようになっています。
何かしら事情があって5.0へ上げられない人はどうするのか。
安心してほしい。実はこれを解決した先人がいるのです。
www.briantist.com

リンク先にある関数を宣言して、GetScript, TestScript, SetScriptの後ろに " | Replace-Using " と書くと、キーの評価前に変数が展開され、エラーを回避できます。素晴らしい。

コンパイル時の変数展開はコンパイルしたマシン上で行われる

話を変えて、変数の展開、特に環境変数についての話をします。
表題だけ見ると、何言うてんねん、と言いたくなりますが、私はハマってしまいました。
PowerShellスクリプトにおいて、環境変数へは$env:変数名でアクセスすることができます。

で、これは実際に私が書いたコードですが、JAVA_HOME環境変数がnullかどうかでJavaのインストールを行う処理を書いてました。
(パッケージのインストールについてはcChoco(ChocolateyのDSC版)を使うと楽なのですが、当時はライセンス明記が不明瞭だったので使用を避けてました。今はApache v2ライセンスになってるので問題なさそうです。)

if($env:JAVA_HOME -ne null){
 Script InstallJava{
  (Javaインストール処理)
 }
}

ローカルでテストしたら、うまく動いてるようです。
ですが、リモートで別のマシンに適用するとうまく動きません。
ん? そう思ったらうまく動いてる時もあります。一体何がどうなっているのか。

実は、$env:JAVA_HOMEの評価はこのスクリプトコンパイルした際のマシンの値を参照しているのです。
PowerShellDSCでConfigurationファイルをコンパイルする際、変数値は展開され、適用するマシンそれぞれに対応したmofファイルが作られます。
この時、マシン内の情報を参照するような変数を扱った場合、それらはコンパイルしたマシンの情報が適用されてしまいます。

つまり、適用したいマシンのJAVA_HOMEをチェックして書いたつもりが、実はそれはコンパイルを行なったマシンのJAVA_HOMEをチェックしているだけで、適用したいリモートマシンの情報は見れていないのです。

こういう場合、Invoke-Commandなどで事前に情報を取っておいて、
ConfigurationDataに情報を載せるのが良いと思います。
(ただし、結構コードがゴチャゴチャします。もっと楽な方法があるといいのですが・・・。)

ConfigurationDataのNode ‘*’ と、それ以外のNodeのKeyValueについて

PowerShellDSCにはConfigurationDataという、マシンの名前やパラメータの値を書いておく設定ファイルを用意できます。
この時、NodeName=‘*’ とした部分のKeyValueは全ノードに適用されます。

これは、デフォルト値としても利用出来ます。

AllNodes=@(
@{
NodeName=‘*’
Something=‘default’
},
@{
NodeName=‘localhost
Something=‘custom’
}
)

こう書けば、NodeName=‘localhost’の時のSomethingの値はcustomとなっていて、他のノードではデフォルト値としてdefaultが入ります。
ここで注意することは、これがKeyValueの上書きであるということです。
特に、構造体に対して適用する時は本当に注意しましょう。

AllNodes=@(
@{
NodeName=‘*’
Something=@{
arg0=‘sample00.txt’
arg1=‘sample01.txt'
}
},
@{
NodeName=‘localhost
Something=@{
arg0=‘extra.txt’
arg2=‘sample02.txt'
}
}
)

こう書いた場合、localhost のノードでは、Something.arg0の値は上書きされています。
そして、arg2が定義されてるのでそれにもアクセスできるようになります。

ただ、arg1は$null値となります。なぜなら、KeyValueが上書きされてarg1は未定義となるからです。
(PowerShellでは定義されていないKeyにアクセスすると $null が返されます。)
これがバグに繋がっていなければいいですが、いかんせんコンパイル時にエラーにならないのが怖いところです。

マージや継承のような感覚では書けないので注意です。

ConfigurationDataをJSONで書く

上で述べたConfigurationDataですが、
これはコード内に書いて宣言することもできますし、外部ファイルに書いておいて読み出すことも可能です。

いずれにしてもこの設定ファイル、記述がJSONによく似ています。
いっそのことJSONで管理してしまいたい。

JSONを扱うためのCmdletとして、ConvertFrom-JSONが用意されてます。
使い方は以下が詳しいです。
tech.guitarrapc.com


ただし、ConvertFrom-Jsonで読み込んだ場合、その変数の型は PSObject です。
PowerShellDSCでコンパイル時に渡しているConfigurationDataの型は HashTable なので、PSObjectからHashTableに変換する必要があります。
下記リンクを参考に関数を用意。
Topic: Configuration Data for DSC not in JSON

function ConvertPSObjectToHashtable
{
 param (
  [Parameter(ValueFromPipeline)]
  $InputObject
 )

 process
 {
  if ($null -eq $InputObject) { return $null }
  if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]){
   $collection = @(
    foreach ($object in $InputObject) { ConvertPSObjectToHashtable $object }
   )
   Write-Output -NoEnumerate $collection
  } elseif ($InputObject -is [psobject]){
   $hash = @{}

   foreach ($property in $InputObject.PSObject.Properties){
    $hash[$property.Name] = ConvertPSObjectToHashtable $property.Value
   }
   $hash
  } else {
   $InputObject
  }
 }
}

変換して実行する部分はこんな感じ。$jsonFileはもちろん読み込みたいJSONファイル名

$tmpObject = cat $jsonFile | convertFrom-Json
$hash = $tmpObject | ConvertPSObjectToHashtable

Sample -ConfigurationData $hash
Start-DscConfiguration .\Sample -Wait -Force

PowerShell5.0の便利機能

こちらが大変詳しい。
www.buildinsider.net

詳しすぎて特にここで言うこともないのですが、それでもこれだけは強調しておきます。

Get-DscConfigurationStatus超便利

上に貼ったリンク先でも特にオススメされてる機能ですが、本当に助かります。
PowerShellはうまくいかなかった時、「うまくいきませんでした。」くらいの内容しか返してくれなくて(大げさですが)、調査に時間を取られるのですが、このコマンドを打てばどのリソースが成功してどのリソースが失敗したのかが一発でわかります。

特に複数のConfigurationを適用するときは、合間にこのコマンドを打ってログとして残しておくことでどこまで進んだのかわかりますし、
条件分岐をしていた場合にもどのリソースが実行されたかが記録として残されるので、テストにもってこいです。

終わり

PowerShellDSCはあまり情報が載ってなくて、さらにはエラー内容も比較的不親切なこともあって、結構苦戦しました。
1個1個、解決した内容はメモして再発させないことを心がけると幸せになれると思います。

そして、できればその内容を共有をしていただけると、同じように悩む人も幸せになれるので、是非是非お願いいたします。