puppet bolt のディレクトリ構成

この記事は Puppet Advent Calendar 2020 16 日目 の記事です。

はじめに

まず、本記事を書こうと思ったモチベーションは

です。もし私と同様に puppet bolt を触ろうと思っている方が

って状態になっていただければ、幸いです。

ただし、前置きすると、上記に記載した通り、「まだまだ発展途上感がある」と感じているため、この記事を目にした時点で、だいぶ変わっている可能性もあると思っていますので、そこのところは、ご留意ください。

puppet bolt のディレクトリ構成

本記事で説明する puppet bolt のディレクトリ構成は以下の通りです。それぞれに関して、簡単にですが、解説して行きたいと思います。

$ tree -a -L 3
.
├── .modules
│   ├── chocolatey
│   └── ...
├── .resource_types
│   └── ...
├── Puppetfile
├── README.md
├── bolt-debug.log
├── bolt-project.yaml
├── data
│   ├── common.yaml
│   ├── local.yaml
│   ├── production.yaml
│   └── staging.yaml
├── hiera.yaml
├── inventory.yaml
├── site-modules
│   └── mymodule
│       ├── files
│       │   ├── motd.txt
│       │   └── check_server.sh
│       ├── plans
│       │   ├── planA.yaml
│       │   └── planB.yaml
│       └── templates
│           └── env.epp
└── tasks
    ├── download_from_gcs.json
    └── download_from_gcs.rb

各ディレクトリ・ファイルについて

.modules

Puppet Forgeの puppet modules が install されるディレクトリです。node.js でいうと node_modules 的なディレクトリです。基本的には、ここのディレクトリは、変更しない方針であることが前提で、gitignore しても問題ありません。もし修正した場合は、.gitignore しないで、含めた方がいいと思います。ちなみにここに関しては、puppet bolt を利用する上では、知らなくても、まぁなんとかなります。

.resource_types

Puppet Forgeの puppet modules に関連する Resource Type が install されるディレクトリ。基本的には、このディレクトリは、変更しない方針であることが前提で、gitignore しても問題ない。また、puppet bolt を利用する上では、知らなくても、基本問題ありません。 「Resource Type とはなんぞや?」というところを理解したい場合は、まずは「Resource とはなんぞや?」というところから、調べてみることをオススメします。

ref.

Puppetfile

Puppet Forgeの puppet modules を「初めて」 install した時に、作成されるファイル。基本的には、このファイルは、手で修正しない方針であることが前提(Do not edit)。このファイルをベースに、.modules を install するため、基本的には、gitignore はしない方がいいと個人的には思っています。ファイル名的には、Gemfile っぽいファイルと思いきや、Gemfile.lockのようなファイル。

# This Puppetfile is managed by Bolt. Do not edit.
# For more information, see https://pup.pt/bolt-modules

# The following directive installs modules to the managed moduledir.
moduledir '.modules'

mod "puppetlabs-chocolatey", "5.1.1"
・・・

bolt-debug.log

Bolt コマンドを実行時、デバッグレベルのログが出力されるログファイルです。bolt planなどの実行ログが書き込まれます。ログの形式は以下の通りです。特に何も指定しなければ、実行毎に recreate されるため、過去のログは残らなさそうです。基本的には、.gitignoreなどで、git からは除外することが多いと思います。

2020-12-02T18:44:46.273566 INFO   [event-publisher] [Bolt::Outputter::Logger] Starting: plan mymodule::planA
2020-12-02T18:44:46.291736 INFO   [event-publisher] [Bolt::Outputter::Logger] Finished: plan mymodule::planA in 0.02 sec

ref. The bolt-debug.log file

bolt-project.yaml

project 単位(リポジトリ単位?)で、bolt の設定を管理する時に用意するファイル。ansible でいうところの ansible.cfg みたいなもの。今までは、bolt.yamlを使っていたみたいですが、そちらは非推奨になっています。

# bolt project の作成( project_name に、ハイフンは含められない。)
$ bolt project init myproject

$ ls
bolt-project.yaml

$ cat bolt-project.yaml
---
name: myproject

ref. bolt-project.yaml options

data

bolt で利用する変数などを管理するディレクトリ。一般的には、OS や環境毎や構成管理ツール毎などの yaml ファイルを格納しておくことが多いです。後述する hiera の機能で、その変数等を読み込む優先順位を定義します。格納できるファイルは、YAML or JSON or HOCON(Human-Optimized Config Object Notation)が利用できます。ディレクトリ名は data である必要はありませんが、大体、 data とすることが多いようです。簡単な yaml の例を記載するとすると以下のような形で、普通の yaml として定義するだけです。

# staging.yaml
---
gcp_project_id: "gcp_project_staging"

# production.yaml
---
gcp_project_id: "gcp_project_production"

ref. Creating and editing data

hiera.yaml

data ディレクトリに格納した変数ファイルの読み込む優先順位などを定義するファイル。 hierarchy に、ハッシュの配列形式で読み込む順番(優先順位)を定義する。上から下に、優先順位が下がっていきます。そのため基本的には、最後に、common.yaml とかを記載することが多いです。ansible だと、group_vars とか host_vars とか暗黙的な変数定義ルールがある印象ですが、多分?、puppet bolt の変数定義ルールはここに全て記載する必要があるような気がしています。

---
version: 5 # hiera の ver 指定する。bolt は ver 5のみ対応している。
defaults:
  datadir: data # 変数ファイルを、hiera.yaml ファイルからみた相対パスで指定する
  data_hash: yaml_data # 変数ファイルの形式を指定する。e.g. yaml, json, HOCON

hierarchy:
  # bolt 実行時に、env を渡すとそれぞれの変数ファイルを読み込む
  # 上から優先順位が高い。そのため基本的には、最後に、common.yamlとかを記載することが多い。
  - name: "environment data"
    path: "%{env}.yaml"
  - name: "common data"
    path: "common.yaml"

上記 hiera.yaml を定義した場合、下記のように bolt plan 実行時に、引数にenv実行すると、各環境の変数ファイルを読み込むことが可能です。

# 引数 env=staging -> data/staging.yaml を読み込む
$ bolt plan run mymodule::planA env=staging --targets webservers

# 引数 env=production -> data/production.yaml を読み込む
$ bolt plan run mymodule::planA env=production --targets webservers

ref.

inventory.yaml

対象ホストを管理するファイルです。ansbible の inventory ファイルと同様の役割をするファイルです。Inventory filesにある、一部の例を下記に転記しました。

groups:
  # e.g. linux の webserver via ssh
  - name: ssh_nodes
    groups:
      - name: webservers
        targets:
          - 192.168.100.179
          - 192.168.100.180
      - name: memcached
        targets:
          - 192.168.101.50
        config:
          ssh:
            user: root
    config:
      transport: ssh
      ssh:
        user: centos
        private-key: ~/.ssh/id_rsa
        host-key-check: false
  # e.g. windows の webservers via winrm
  - name: win_nodes
    groups:
      - name: apiservers
        targets:
          - 192.168.110.10
      - name: testservers
        targets:
          - 172.16.219.20
    config:
      transport: winrm
      winrm:
        user: DOMAIN\opsaccount
        password: S3cretP@ssword
        ssl: true

ここの例では、まず、接続方式が、「ssh」か「winrm」かで group を分けています。その後、それぞれで、複数対象ホストが存在する場合は、さらに、groups で入れ子にして、対象ホストを指定します。config には、接続方式とその認証情報を記載します。そして、bolt コマンド実行時の --targets の引数として、groups を指定するように、下記のようなコマンドを実行します。

# ssh_nodes,win_nodes 両方に実行する場合
$ bolt command run 'echo hello' --targets ssh_nodes,win_nodes

# ssh_nodes のみに実行する場合
$ bolt command run 'echo hello' --targets ssh_nodes

# webservers,testservers のみに実行する場合
$ bolt command run 'echo hello' --targets webservers,testservers

そうすると、inventory から指定された targets への接続方法・情報を判断して、それぞれに対して、接続し、コマンドを実行します。ただし、この指定の仕方の場合、targetsに対して、IP アドレスなどを指定しなければならないため、対象ホストが増えてくると管理が煩雑になっていきます。もし設計段階で、そのような状況になりそうであれば、AWS や GCP などのクラウドを使っているのであれば、いわゆる Dynamic Inventory のような plugin 機能を利用するのは簡単にできそうなため、試してみるのが良いかなと思います。

# ref. https://forge.puppet.com/modules/puppetlabs/aws_inventory

# inventory.yaml
---
groups:
  - name: aws
    targets:
      - _plugin: aws_inventory
        profile: user1
        region: us-west-1
        filters:
          - name: tag:Owner
            values: [Devs]
          - name: instance-type
            values: [t2.micro, c5.large]
        target_mapping:
          name: public_dns_name
          uri: public_ip_address
          config:
            ssh:
              host: public_dns_name
    config:
      ssh:
        user: ec2-user
        private-key: ~/.aws/private-key.pem
        host-key-check: false

ref. Inventory files

tasks

task は、対象ホストで実行する puppet bolt の中で最もシンプルなアクションの単位です。タスクは、Bash、Python、Ruby など、対象ホスト似て、実行できる任意のプログラミング言語でタスクを記述できます。デフォルトでは、下記のようなタスクが用意されています。

$ bolt task show

apt                                 Allows you to perform apt functions
facts                               Gather system facts
http_request                        Make a HTTP or HTTPS request.
package                             Manage and inspect the state of packages
pkcs7::secret_createkeys            Create a key pair
pkcs7::secret_decrypt               Encrypt sensitive data with pkcs7
pkcs7::secret_encrypt               Encrypt sensitive data with pkcs7
puppet_agent::install               Install the Puppet agent package
puppet_agent::version               Get the version of the Puppet agent package installed. Returns nothing if none present.
puppet_conf                         Inspect puppet agent configuration settings
reboot                              Reboots a machine
reboot::last_boot_time              Gets the last boot time of a Linux or Windows system
service                             Manage and inspect the state of services
terraform::apply                    Apply an HCL manifest
terraform::destroy                  Destroy resources managed with Terraform
terraform::initialize               Initialize a Terraform project directory
terraform::output                   JSON representation of Terraform outputs

例えば、package のタスクの使い方としては、以下のコマンドを実行することで、対象ホスト(webservers)の Apache の status を確認することができます。

bolt task run package action=status name=apache2 --targets webservers

カスタムタスクの例として、ruby で記載した GCS からファイルをダウンロードするタスクを紹介します。

・・・
└── tasks
    ├── download_from_gcs.json # タスクのメタデータ
    └── download_from_gcs.rb   # タスクを定義する実行スクリプト

ファイルとしては、「タスクのメタデータ」の json と「タスクを定義する実行スクリプト」を準備します。そして、それぞれのファイルを以下のように記述します。

// download_from_gcs.json
{
  "description": "Download file from google cloud storage locally", // タスクの説明
  "input_method": "stdin", // タスクのインプット方法 e.g. environment(環境変数), stdin(標準入力), powershell(??)
  "parameters": {
    "bucket_name": {
      "description": "Bucket of google cloud storage", // パラメータの説明
      "type": "String[1]" // パラメータのデータ型の指定 e.g. 空ではない文字列
    },
    "object_path": {
      "description": "Object path of google cloud storage",
      "type": "String[1]"
    },
    "tmp_dir": {
      "description": "tmp path",
      "type": "String[1]"
    }
  },
  "files": ["ruby_task_helper/files/task_helper.rb"] // タスク実行時に利用するファイルの指定。基本、ヘルパーライブラリが多いかも。
}
#!/usr/bin/env ruby

require_relative '../../ruby_task_helper/files/task_helper'
require 'google/cloud/storage'

class DownloadFileFromGCS < TaskHelper
  # GCSの指定バケット・オブジェクトパスを指定tmpディレクトリにダウンロードするタスク
  def task(bucket_name: nil, object_path: nil, tmp_dir: nil, **_kwargs)
    storage = Google::Cloud::Storage.new
    bucket = storage.bucket bucket_name
    object = bucket.file object_path
    object.download tmp_dir + '/' + object_path

    puts "Success to download #{object.name} to #{tmp_dir}"
  end
end

DownloadFileFromGCS.run if __FILE__ == $0

上記カスタムタスクは、ruby_task_helperを利用しています。名前の通り、ruby でカスタムタスクを作成する際に、いい感じにしてくれる、ruby のヘルパーライブラリです。ざっくりいうと、メタデータにパラメータを指定して、スクリプトの方で、メソッドを定義すればよい。それだけと言えばそれだけです。

ref. 利用できるタスクのデータ型

タスクの詳細は以下のように確認することもできます。

$ bolt task show myproject::download_from_gcs

myproject::download_from_gcs - Download file from google cloud storage locally

USAGE:
bolt task run --targets <node-name> myproject::download_from_gcs bucket_name=<value> object_path=<value> tmp_dir=<value>

PARAMETERS:
- bucket_name: String[1]
    Bucket of google cloud storage
- object_path: String[1]
    Object path of google cloud storage
- tmp_dir: String[1]
    tmp path

MODULE:
/Users/jkkitakita/myproject

あとは、定義した task を直接使う、または、plan から呼び出せば ok です。

ref. Making on-demand changes with tasks

site-modules

最後に、site-modules を紹介します。site-modules は、プロジェクト毎の custom modules です。(ドキュメントがいまいちないので、合ってるか不明。私の認識です。)Puppet Forgeの puppet modules は、.modules に格納されると上述しましたが、それでは不十分である、または、プロダクト固有の modules を作成したい場合に用意します。その際には、site-modules ディレクトリを作成します。シンプルな plan を実行したいだけであれば、 bolt project のルートディレクトリに plan ディレクトリを作成するだけで十分かもしれません。が、個人的には、ちゃんと今後の保守・運用も考慮するのであれば、単純な plan を実行するだけでも site-modules ディレクトリを作成した方がいいと思います。ansible でいうと、modulesroles に読み替えると良いかもしません。Puppet ForgeAnsible Galaxy的な立ち位置なので、ansible に慣れている方は、そのイメージです。

# puppet bolt の site-modules のディレクトリ構成
...
├── site-modules
│   └── mymodule
│       ├── files
│       ├── plans
│       └── templates
...
# ref. ansible の場合の roles のディレクトリ構成
...
├── roles
│   └── myrole
│       ├── files
│       ├── tasks
│       └── templates
...

ただし、実際に使ってみた感覚から言うと、ansible の roles よりも、もう少し柔軟な(悪くいうと、フワッとした)custom modules を定義できる印象があります。具体的には

# puppet bolt の site-modules のディレクトリ構成
...
├── site-modules
│   └── mymodule
│       └── plans
│           ├── planA.yaml
│           └── planB.yaml
...

と定義した場合

# planA を実行したい場合
$ bolt plan run mymodule::planA --targets webservers
# planB を実行したい場合
$ bolt plan run mymodule::planB --targets webservers

のような形で、指定できるため、ざっくりと、サービス単位とかで、custom modules を設計することも可能です。何が言いたいかというと、ansible の場合、tasks を実行する際、 tasks/main.yaml から実行される縛りがあるのですが、その縛りはないということです。ただ、一般的に他の puppet bolt のリポジトリを見た感じだと、ansible の roles と同様に、nginxとかdeployとかの単位で分けているところが多そうには感じました。下記のリポジトリなどが参考になるかもしれません。

https://github.com/puppetlabs-seteam/control-repo

以下、site-modules 配下の filesplanstemplatesの 3 つのディレクトリに関してのみ解説しようと思います。その他に関しては、Module structureを参考にしてください。
※ ただし、上記、Module structureで紹介されているディレクトリ・ファイルが、site-modulesが対応しているかどうかは調べていません。全然 site-modules に関するドキュメントがない。。)

ref. Plan location の一部に site-modules に関して記述されています。

files

plan 実行時に、「対象ホストに、静的ファイルを upload する」「対象ホスト上で、静的スクリプトを実行する」などの場合に、その静的ファイル・スクリプトを格納するディレクトリです。ansible でも同様に files ディレクトリを使っていますが、それと使い方は同じです。実際の bolt の plan ファイルの syntax は下記のような形になります。site-modulesfilesなどは無視して、mymodule/motd.txtと指定するところが少しややこしさがあるかもしれません。が、一応、他 modules の files も指定できますよ。ってことなのかなと思っています。

steps:
  # motd.txt を対象ホストに upload する
  - upload: mymodule/motd.txt # ref. site-modules/mymodule/files/motd.txt
    destination: /etc/motd
    targets: $targets
    description: "Upload motd to the webservers"
  # check_server.sh を対象ホスト上で実行する
  # e.g. ./check_server.sh /index.html 60
  - script: mymodule/check_server.sh
    targets: $targets
    description: "Run mymodule/files/check_server.sh on the webservers"
    arguments: # Optional
      - "/index.html"
      - 60

ref.

templates

templates は、files と違い、動的に変数等を代入して、最終的にレンダリングされたファイルを生成したいファイルを管理するディレクトリです。こちらも ansible の templates と同様の用途です。ansible の場合は、python 製のため、基本的に jinja2 がテンプレートエンジンとして利用されていますが、Puppet の場合は、Embedded Puppet(EPP)、もしくは、Embedded Ruby (ERB)で記述する必要があります。以下、結論を記載します。

# mymodules/templates/env.epp
<%- | String  $env,
      String  $gcp_project_id,
| -%>
ENV=<%= $env %>
GCP_PROJECT_ID=<%= $gcp_project_id %>
# mymodules/plans/planB.pp
#
#/tmp/.env ファイルを更新する plan
# @param targets The targets to configure
# @param env The environment
# @param tmp_dir The tmp directory for download destination
plan mymodule::planB(
  TargetSpec $targets,
  String[1] $env,
  String[1] $tmp_dir = '/tmp'
) {
  apply($targets) {
    file { "${tmp_dir}/.env":
      content => epp('mymodules/env.epp', {
        'env'            => $env,
        'gcp_project_id' => lookup('gcp_project_id'), # hiera の data を取得
      }),
    }
  }
}

上記は、それぞれ mymodules/templates/env.eppmymodules/plans/planB.pp で記載した template と plan(Puppet 言語)です。これで、下記コマンドを実行してみると

bolt plan run mymodules::planB env=staging --targets webservers
centos@192.168.100.179$ cat /tmp/.env

ENV=staging
GCP_PROJECT_ID=gcp_project_staging

となります。この際、lookup('gcp_project_id')と指定して、gcp_project_stagingが代入されています。これは puppet の build-in function lookup を使うことで、hiera data として定義した変数を取得することができるためです。

ref.

plans

bolt の plan を格納するディレクトリです。plan は、複数のタスクを一まとまりにした単位のことを呼びます。実行したタスクの入力の値を計算したり、別のタスクの結果に基づいて特定のタスクを実行したりするなど、複雑なタスクを実行できます。plan は、yaml(not .yml) or puppet(pp)で記述することが可能です。それぞれ同じで、同じタスクを記述すると

# yaml の場合(site-modules/mymodule/plans/planA.yaml)
parameters:
  targets:
    type: TargetSpec
  bucket_name:
    type: String[1]
  object_path:
    type: String[1]
  tmp_dir:
    type: String[1]
    default: "/tmp"

steps:
  # motd.txt を対象ホストに upload する
  - upload: mymodule/motd.txt # ref. site-modules/mymodule/files/motd.txt
    destination: /etc/motd
    targets: $targets
    description: "Upload motd to the webservers"
  # check_server.sh を対象ホスト上で実行する
  # e.g. ./check_server.sh /index.html 60
  - script: mymodule/check_server.sh
    targets: $targets
    description: "Run mymodule/files/check_server.sh on the webservers"
    arguments: # Optional
      - "/index.html"
      - 60
  - task: myproject::download_from_gcs
    targets: $targets
    description: "Download file from gcs"
    parameters:
      bucket_name: $bucket_name
      object_path: $object_path
      tmp_dir: $tmp_dir
# puppuet の場合(site-modules/mymodule/plans/planA.pp)

# @param targets The targets to configure
# @param bucket_name The bucket name
# @param object_path The object path
# @param tmp_dir The tmp directory for download destination
plan mymodule::planA(
  TargetSpec $targets,
  String[1] $bucket_name,
  String[1] $object_path,
  String[1] $tmp_dir = '/tmp'
) {
  upload_file(
    'mymodule/motd.txt',
    '/etc/motd',
    $targets,
    "Upload motd to the webservers"
  )
  run_script(
    'mymodule/check_server.sh',
    $targets,
    "Run mymodule/files/check_server.sh on the webservers",
    {
      'arguments' => ["/index.html", 60]
    }
  )
  run_task(
    'myproject::download_from_gcs',
    $targets,
    'Download file from gcs',
    {
      'bucket_name' => $bucket_name,
      'object_path' => $object_path,
      'tmp_dir'     => $tmp_dir,
    },
  )
}

となります。上記 2 つの plan は同じ plan です。細かい箇所の説明は省きますが、上記のように記述したのち

bolt plan run mymodule::planA env=staging bucket_name=mybucket object_path=myobject.txt --targets webservers

のコマンドを実行することで、対象ホスト(targets)に対して、plan を実行することができます。ちなみに余談ですが、下記コマンドを実行すると、yaml -> pp への変換ができます。

bolt plan convert site-modules/mymodule/plans/planA.yaml

そこで次に考えるのは、yamlppどっちで書こう問題なのですが、「やっぱり yaml の方が慣れてる人多いし、puppet 独自の DSL とか学習コスト高そうだし、やっぱり yaml で書こう!」と、Puppet 言語に慣れていないほとんどの人が思うところかなと思うのですが、ここで割と大事な個人的な見解としては、「(現時点では)ちゃんと本番環境で利用とするのであれば、多少苦労してでも、Puppet言語で記述した方がいい」です。なぜかというと、yaml だと、まだ動かないこと結構多いです。最も致命的なのは、templateがいまいち使えないことです。(ref. Support Templates from yaml #2301) やはり、前身となるのが Puppet 言語なので、現段階では、「今まで、Puppet 言語で作られてきたものを YAML でも書けるようにするために色々頑張ってる」という印象が強いです。そのため現段階での個人的な見解としては、あまり yaml で記述することをお勧めしません。

さいごに

他の構成管理ツールを使ったことがある方なら、やはりディレクトリ構成も似ていたりするので、割と取っつきやすい部分もありますが、「独自言語であること」「落とし穴がある」「ドキュメント・ナレッジが少ない」の 3 つがやはりネックになってきます。上記記載したことを参考にしていただきなら、まずは、触ってみる。その中で、全体のコンセプトを掴んでもらい、設計 -> 実装とやると良いかなと思います。今後はもっと、Puppet Bolt が流行ってくることを期待したいです。

参考