はじめに
普段、Serverspec を利用してサーバー構築後のテストを行っているのですが、対象サーバー台数やテスト項目が肥大化して、テスト実行時間が1時間くらい掛かるようになり、代替手段を探したところ、 Goss という Go製のテストフレームワークを見つけました。
そこで、 Goss のインストール方法、使いかたについて検討することにしました。
インストール方法
Goss公式サイトにあるように、 curl | sh
で簡単にインストールできます。
curl -fsSL https://goss.rocks/install | sh
環境によってはインターネットからダウンロードができないシーンもあるので、バイナリファイルをあらかじめダウンロードしておいて、運用端末から配布してもよさそうです。
使いかた
ヘルプを見てみます。
$ goss --help
NAME:
goss - Quick and Easy server validation
USAGE:
goss [global options] command [command options] [arguments...]
VERSION:
v0.3.16
COMMANDS:
validate, v Validate system
serve, s Serve a health endpoint
render, r render gossfile after imports
autoadd, aa automatically add all matching resource to the test suite
add, a add a resource to the test suite
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--gossfile value, -g value Goss file to read from / write to (default: "./goss.yaml") [$GOSS_FILE]
--vars value json/yaml file containing variables for template [$GOSS_VARS]
--vars-inline value json/yaml string containing variables for template (overwrites vars) [$GOSS_VARS_INLINE]
--package value Package type to use [apk, dpkg, pacman, rpm]
--help, -h show help
--version, -v print the version
テストシナリオの作りかた
Serverspec にはない特徴的な機能として、 autoadd
, add
があります。これらは、今ある状態を正として自動的にテストシナリオを生成するものです。
autoadd
file
, group
, package
, port
, process
, service
, user
を探索し、マッチするものをもとにテストシナリオを生成します。
例えば、 sshd
を指定します。
$ goss autoadd sshd
goss.yaml
には以下のようなテストシナリオが生成されます。(すでにファイルが存在する場合は追記されます)
port:
tcp:22:
listening: true
ip:
- 0.0.0.0
tcp6:22:
listening: true
ip:
- '::'
service:
sshd:
enabled: true
running: true
user:
sshd:
exists: true
uid: 74
gid: 74
groups:
- sshd
home: /var/empty/sshd
shell: /sbin/nologin
group:
sshd:
exists: true
gid: 74
process:
sshd:
running: true
-g
オプションで出力するファイルを指定することができます。
add
autoadd
では対応できないテスト(addr
, command
, dns
, http
, interface
, kernel-param
, mount
)は add
を使って生成します。
$ goss add command "cat /etc/passwd | wc -l"
Adding Command to './goss.yaml':
cat /etc/passwd | wc -l:
exit-status: 0
stdout:
- "32"
stderr: []
timeout: 10000
手動編集
YAMLファイルを手動で編集します。使用可能なリソースタイプは以下の通りです。詳しくは goss manual を参照してください。
リソースタイプ | 説明 |
---|---|
addr | リモートの address:port に接続できるかを評価する |
command | コマンドを実行し、 exit status や出力を評価する |
dns | アドレスの名前解決ができるかを評価する |
file | ファイル、ディレクトリ、シンボリックリンクの状態を評価する |
gossfile | gossfile をインポートする |
group | グループの状態を評価する |
http | HTTPレスポンスステータスコードやコンテンツを評価する |
interface | ネットワークインターフェースの値を評価する |
kernel-param | カーネルパラメータ(sysctl) の値を評価する |
mount | マウントポイントの attribute を評価する |
matching | matcher に対して特定のコンテンツを評価する |
package | パッケージの状態を評価する |
port | ローカルポートの状態を評価する |
process | プロセスが起動しているかを評価する |
service | サービスの状態を評価する |
user | ユーザーの状態を評価する |
注意点として、同じファイルに同じ内容のテスト項目を書くことはできません。(片方が無視されます)
file:
/etc/ssh/sshd_config:
exists: true
file:
/etc/httpd/conf/httpd.conf:
exists: true
file:
/etc/ssh/sshd_config:
exists: true
/etc/httpd/conf/httpd.conf:
exists: true
同じファイルでなければよいので、別々のファイルに書いておいて、 gossfile
でインポートすると気にしなくてもよさそうです。
応用的な書きかた
変数とループ
変数ファイルを用意しておき、ループで呼び出すことができます。
変数 | 説明 |
---|---|
{{.Env}} |
環境変数 |
{{.Vars}} |
--vars で指定したファイルで定義した変数 |
例1. Vars
users:
- user1
- user2
user:
{{range .Vars.users}}
{{.}}:
exists: true
groups:
- {{.}}
home: /home/{{.}}
shell: /bin/bash
{{end}}
例2. Vars, Env
centos:
packages:
kernel:
- "4.9.11-centos"
- "4.9.11-centos2"
debian:
packages:
kernel:
- "4.9.11-debian"
- "4.9.11-debian2"
package:
# Looping over a variables defined in a vars.yaml using $OS environment variable as a lookup key
{{range $name, $vers := index .Vars .Env.OS "packages"}}
{{$name}}:
installed: true
versions:
{{range $vers}}
- {{.}}
{{end}}
{{end}}
例3. if
package:
{{if eq .Env.OS "centos"}}
# This test is only when $OS environment variable is set to "centos"
libselinux:
installed: true
{{end}}
パターン
file
, command
, output
で使えます。
パターン | 説明 |
---|---|
"string" |
string を含むか |
"!string" |
string を含まないか |
"\\!string" |
!string を含むか |
"/regex/" |
正規表現にマッチするか |
"!/regex/" |
正規表現にマッチしないか |
file:
/etc/ssh/sshd_config:
exists: true
contains:
- "/^PermitRootLogin no/"
- "/^UseDNS no/"
実行例は以下の通りです:
$ goss --gossfile sshd_config.yaml validate --format tap
1..2
ok 1 - File: /etc/ssh/sshd_config: exists: matches expectation: [true]
ok 2 - File: /etc/ssh/sshd_config: contains: all expectations found: [/^PermitRootLogin no/, /^UseDNS no/]
マッチしないと以下のような結果になります。
$ goss --gossfile sshd_config.yaml validate --format tap
1..2
ok 1 - File: /etc/ssh/sshd_config: exists: matches expectation: [true]
not ok 2 - File: /etc/ssh/sshd_config: contains: patterns not found: [/^UseDNS yes/]
command
exit-status
, stdout
, stderr
で評価します。
exit-status
は必須のようです。定義しないとエラーになりました。
command:
number_of_users:
exec: "cat /etc/passwd | wc -l"
exit-status: 0
stdout:
- 32
$ goss --gossfile passwd.yaml validate --format tap
1..2
ok 1 - Command: number_of_users: exit-status: matches expectation: [0]
ok 2 - Command: number_of_users: stdout: all expectations found: [32]
gossfile
複数のファイルをインクルードできます。
gossfile:
include/common/*.yaml: {}
include/target/*.yaml: {}
テストシナリオ作成時の Tips
render
は すべての gossfile をインポートしたあとの gossfile を生成する機能ですが、バリデーションチェックとしても使えます。
エラー発生時の実行例は以下の通りです:
$ goss --gossfile ss.yaml --vars vars.yaml render
could not unmarshal "command:\n \n - version:\n exec: \"ss -tuna | grep ':22 ' | grep LISTEN\"\n exit-status: 0\n \n - version:\n exec: \"ss -tuna | grep ':80 ' | grep LISTEN\"\n exit-status: 0\n \n - version:\n exec: \"ss -tuna | grep ':443 ' | grep LISTEN\"\n exit-status: 0\n \n" as YAML data: yaml: unmarshal errors:
line 3: cannot unmarshal !!seq into map[string]map[string]interface {}
実践投入に向けて
Ansible で以下の手順で実行することにします。
- goss およびテストコードをターゲットノードに送る
- テストを実行する
- 結果をファイルに出力する
※必要に応じてターゲットノードの資材を削除してもよいでしょう
PoC
以下のようなディレクトリ・ファイル構成とします。
.
├── files
│ └── goss ............ ターゲットノードに送るテスト環境一式
│ ├── bin
│ │ └── goss .... goss本体
│ └── goss.yaml ... テストシナリオ
├── goss_local.yml ...... Ansible Playbook
├── library
│ └── goss.py ......... goss 呼び出し用ライブラリ
└── log ................. テスト実行結果格納用
goss.py
は ansible-goss を利用させていただきました。
Ansible Playbook は以下の通りです。 PoC なので、ローカル実行かつ1ファイルなのはご容赦ください。また、実行環境が少々古いので、モジュール名がFQCNではありません。適宜 ansible.builtin.copy
などと読み替えてください。
---
- hosts: localhost
connection: local
become: yes
gather_facts: no
tasks:
- name: copy goss files
copy:
src: goss
dest: /tmp
- name: delegate the right to execute
file:
path: /tmp/goss/bin/goss
owner: root
group: root
mode: 0755
- name: execute goss
goss:
path: /tmp/goss/goss.yaml
format: tap
output_file: "/tmp/goss/goss_{{ inventory_hostname }}.log"
goss_path: /tmp/goss/bin/goss
ignore_errors: yes
- name: fetch log
fetch:
src: "/tmp/goss/goss_{{ inventory_hostname }}.log"
dest: "./log/goss_{{ inventory_hostname }}.log"
flat: yes
実行します。
$ ansible-playbook goss_local.yml --diff -v
No config file found; using defaults
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
PLAY [localhost] ****************************************************************************************************************************************************************************************************************************
TASK [copy goss files] **********************************************************************************************************************************************************************************************************************
changed: [localhost] => {"changed": true, "dest": "/tmp/", "src": "/opt/ansible/goss/files/goss"}
TASK [delegate the right to execute] ********************************************************************************************************************************************************************************************************
ok: [localhost] => {"changed": false, "gid": 0, "group": "root", "mode": "0755", "owner": "root", "path": "/tmp/goss/bin/goss", "secontext": "unconfined_u:object_r:user_home_t:s0", "size": 12414976, "state": "file", "uid": 0}
TASK [execute goss] *************************************************************************************************************************************************************************************************************************
ok: [localhost] => {"changed": false, "stdout": "1..11\nok 1 - Process: sshd: running: matches expectation: [true]\nok 2 - User: sshd: exists: matches expectation: [true]\nok 3 - User: sshd: uid: matches expectation: [74]\nok 4 - User: sshd: gid: matches expectation: [74]\nok 5 - User: sshd: home: matches expectation: [\"/var/empty/sshd\"]\nok 6 - User: sshd: groups: matches expectation: [[\"sshd\"]]\nok 7 - User: sshd: shell: matches expectation: [\"/sbin/nologin\"]\nok 8 - Group: sshd: exists: matches expectation: [true]\nok 9 - Group: sshd: gid: matches expectation: [74]\nok 10 - Service: sshd: enabled: matches expectation: [true]\nok 11 - Service: sshd: running: matches expectation: [true]\n", "stdout_lines": ["1..11", "ok 1 - Process: sshd: running: matches expectation: [true]", "ok 2 - User: sshd: exists: matches expectation: [true]", "ok 3 - User: sshd: uid: matches expectation: [74]", "ok 4 - User: sshd: gid: matches expectation: [74]", "ok 5 - User: sshd: home: matches expectation: [\"/var/empty/sshd\"]", "ok 6 - User: sshd: groups: matches expectation: [[\"sshd\"]]", "ok 7 - User: sshd: shell: matches expectation: [\"/sbin/nologin\"]", "ok 8 - Group: sshd: exists: matches expectation: [true]", "ok 9 - Group: sshd: gid: matches expectation: [74]", "ok 10 - Service: sshd: enabled: matches expectation: [true]", "ok 11 - Service: sshd: running: matches expectation: [true]"]}
TASK [fetch log] ****************************************************************************************************************************************************************************************************************************
changed: [localhost] => {"changed": true, "checksum": "1592662d878a81087e426b4bf250e155d3485674", "dest": "/opt/ansible/goss/log/goss_localhost.log", "md5sum": "663ccd7670bc02ce7da6910c6a2383bc", "remote_checksum": "1592662d878a81087e426b4bf250e155d3485674", "remote_md5sum": null}
PLAY RECAP **********************************************************************************************************************************************************************************************************************************
localhost : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
$ cat log/goss_localhost.log
1..11
ok 1 - Process: sshd: running: matches expectation: [true]
ok 2 - User: sshd: exists: matches expectation: [true]
ok 3 - User: sshd: uid: matches expectation: [74]
ok 4 - User: sshd: gid: matches expectation: [74]
ok 5 - User: sshd: home: matches expectation: ["/var/empty/sshd"]
ok 6 - User: sshd: groups: matches expectation: [["sshd"]]
ok 7 - User: sshd: shell: matches expectation: ["/sbin/nologin"]
ok 8 - Group: sshd: exists: matches expectation: [true]
ok 9 - Group: sshd: gid: matches expectation: [74]
ok 10 - Service: sshd: enabled: matches expectation: [true]
ok 11 - Service: sshd: running: matches expectation: [true]
無事に実行結果をファイルに出力することができました。
Ansible から実行することで、ターゲットノード数が増えても並列実行ができるのではないかと思います。
感想
最後に、ここまで検討した感想を述べます:
- インストールは簡単。12MBの単一実行ファイルだけで実行できる
- validate は爆速。Ansible の力を借りれば並列実行できるのでさらに実行速度が上がる
- validate のフォーマットは個人的にはtapがシンプルで読みやすい
- goss.yaml に書いた順番通りにテストしてくれないので、テスト項目ごとにファイルを分けたほうがよさそう
- リソースタイプは Serverspec と比較してとても少ないので、できる範囲で移行する、という割り切りが必要(とはいえ、ほとんどのものは移行できそう)