HashiCorp Advent Calendar、15日目の記事です。
今回のテーマは Packer です。
Packer pluginとは
Packerの特徴の一つに、イメージの構築のための様々なフェーズをレイヤ分けして、それぞれでプラグインという形で分離しているところがあります。
- Builder Plugins (AWS, OpenStack, GCE, QEMUなどのプラットフォーム層)
- Provisioner Plugins (Shell, Upload, Chef/Puppet/Ansible...)
- Post-Processor Plugins (DockerやVagrant Cloud周りの操作など、イメージ作成後の挙動)
プラグイン機構を採用しているので、環境ごとに自分でプラグインを作成して拡張することも可能となっています。
筆者は以前、DHCPなしのOpenStack向けのBuilder Pluginを社内向けに作っています (参考) が、同じようにProvisioner Pluginとして packer-provisioner-serverspec というものを作っているので、それを題材にプラグインの書き方をなぞってみようと思います。
Plugin の基本構成
PackerのPluginは、最終的にGo言語で作った単一のバイナリとなります。
Go言語で書くにあたって、そのプログラムの核となる構造体を定義するわけですが、プラグインごとに特定のインタフェースを満たす必要があります。Provisionerの場合は以下のインタフェースです。
(ちなみにドキュメントが間違っていて、 Cancel()
関数も必要となっています)
import "github.com/mitchellh/packer/packer"
type Provisioner interface {
Prepare(...interface{}) error
Provision(packer.Ui, packer.Communicator) error
Cancel()
}
serverspecプラグインの場合、まずは何もしない、インタフェースを満たすだけの構造体を定義しました。
package serverspec
import (
"os"
"github.com/mitchellh/packer/packer"
)
type Provisioner struct {
}
func (p *Provisioner) Prepare(raws ...interface{}) error {
return nil
}
func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error {
ui.Say("Plugin test")
return nil
}
func (p *Provisioner) Cancel() {
os.Exit(0)
}
その後、この構造体を利用するだけのエントリポイントを、他のプラグインを参考にしつつ、 cmd/packer-provisioner-serverspec
以下に作成します。これで、ビルドができる状態になります。
package main
import (
"github.com/mitchellh/packer/packer/plugin"
"github.com/udzura/packer-provisioner-serverspec"
)
func main() {
server, err := plugin.Server()
if err != nil {
panic(err)
}
server.RegisterProvisioner(new(serverspec.Provisioner))
server.Serve()
}
ちなみに、ここまでの段階のコミットは 82d69d0ee327a71533006e8d870ee57fca827b17
です。このハッシュを直接checkoutすると、ビルド可能なことが確認できるかと思います。
カスタムプラグインを動かす
上記まで作った段階で、一度ビルドをしてみます。
go test ./...
go build ./cmd/packer-provisioner-serverspec
これで作成された packer-provisioner-serverspec
と言うバイナリを、$PATH
の通った場所にコピーします。
Packerは、 packer-{plugin type}-{plugin name}
と言う命名規約を満たしたバイナリを $PATH
(正確にはプラグイン用のディレクトリもある)から探してきて、それを利用するというルールを取っていますので、これだけでインストールが完了したことになります。
では、以下のようなJSONを作って試してみましょう...
{
"variables": {
"vagrant_ssh_port": "{{env `VAGRANT_SSH_PORT`}}",
"vagrant_privkey_file": "{{env `VAGRANT_PRIVKEY_FILE`}}"
},
"builders": [{
"type": "null",
"ssh_host": "127.0.0.1",
"ssh_username": "vagrant",
"ssh_port": "{{user `vagrant_ssh_port`}}",
"ssh_private_key_file": "{{user `vagrant_privkey_file`}}",
"ssh_pty": true
}],
"provisioners": [{
"type": "serverspec"
}]
}
テスト先としてVagrantのBoxを使いますので、環境変数 VAGRANT_SSH_PORT
, VAGRANT_PRIVKEY_FILE
は適宜 vagrant ssh-config
などして設定しておいてください。
その状態で、以下のコマンドを走らせると:
$ packer build development.json
わかりづらいですが、 null: Plugin test
と言う表示が、先程のプラグインの ui.Say("Plugin test")
の行に対応していることがわかります。
無事に動いているようですね。
それぞれのフェーズを定義する
プラグインを書いてインストールし、動かすまでの一通りの環境が整いました。あとは、中身を書いていくだけです。
Setup フェーズ
Setup のフェーズのコツとしては、プロビジョン対象のホストには 何もしない こと、純粋に設定のパースだけにとどめること、が挙げられます。
While it is not actively enforced, no side effects should occur from running the Prepare method.
公式ドキュメントより。
具体的な設定は、引数として与えられる raws ...interface{}
に含まれています。これを、 mapstructure ライブラリを用いてGoの構造体に流し込めばパースが完了します。
serverspecプラグインでは、今の所 source_path
という、アップロードの元になるディレクトリの指定しかできないのですが、それを解釈させるには以下のようにします。
まず、serverspecプラグインのためのConfig構造体を定義します。common.PackerConfig
とctx
はおまじないのようなもので、SourcePath
と言うメンバを、mapstructureの宣言とともに定義してあります。
type Config struct {
common.PackerConfig `mapstructure:",squash"`
SourcePath string `mapstructure:"source_path"`
ctx interpolate.Context
}
あとは、 github.com/mitchellh/packer/helper/config
と言うパッケージにデコーダーがあるので、それを用いてデコードすればOKです。バリデーションやMultiErrorを使うとより丁寧のようです。サンプルコードをご参照ください。
import "github.com/mitchellh/packer/helper/config"
type Provisioner struct {
config Config
destPath string
}
func (p *Provisioner) Prepare(raws ...interface{}) error {
err := config.Decode(&p.config, &config.DecodeOpts{
Interpolate: true,
InterpolateContext: &p.config.ctx,
}, raws...)
if err != nil {
return err
}
var errs *packer.MultiError
if p.config.SourcePath == "" {
errs = packer.MultiErrorAppend(errs,
errors.New("`source_path' must be specified."))
}
if errs != nil && len(errs.Errors) > 0 {
return errs
}
return nil
}
Provision フェーズ
Provisonのフェーズでは、鍵となる二つの構造体が渡されてくるので、それを上手に使うと良いでしょう。
-
ui packer.Ui
... ユーザ向けの出力をコントロールする構造体 -
comm packer.Communicator
... 操作する先のサーバへの接続情報を持った構造体- この構造体を経由することで、接続方法を抽象化している
鍵は、 packer.Communicator
を用いてサーバに接続をし、プロビジョニングを実施するところです。 packer.Communicator
では、様々な操作ができますが。serverspecプラグインでは以下のような操作を利用しています。
- ファイルのアップロード
err := comm.UploadDir(dest, p.config.SourcePath, nil)
if err != nil {
return err
}
- リモートコマンドの実行
var cmd *packer.RemoteCmd
cmd = &packer.RemoteCmd{Command: "uname -a"}
err := cmd.StartWithUi(comm, ui)
if err != nil {
return err
}
これらを組み合わせて、
- 手元のServerspecのテストケースをアップロード
- Omunibus を経由してServerspecのインストール
- テストの実行
- パッケージとファイルのクリーンアップ
を実行している、というわけです。
packer.Communicator
の詳細は、公式ドキュメントすら ソースが一番詳しいよ と言っているので、ソースを見てください ;)
Cancel フェーズ
文字通りプロビジョニングの実行がキャンセル、あるいは失敗した時に走る処理を定義するようですが、serverspecプラグインでは何もしていません。
まとめ
以上のように、Go言語の知識と、Goのソースをやっていく気持ちで読み込む能力があれば、Packerのプラグインの作成はそこまでは難しくない、ような印象を持てなくもないでしょう......
とはいえ、Packerのプラグイン機構は、Goのインターフェースを上手に使った仕組みではあるなあと思います。
是非、マニアックなプラットフォームを使っていたり、プロビジョニングで繰り返し作業がある場合には、プラグインの自作も検討に加えてみてはいかがでしょうか。
明日は、若手インフラ氏の @rrreeeyyy さんです。