なぜモジュールが先か、そしてシェルを使わざるを得ないとき
一言でいうと
ansible.builtin.commandはシェルを経由せず、ansible.builtin.shellは経由します。その1行の違いが、リダイレクト・パイプ・グロブが使えるか使えないかを分け、シェルに出た瞬間に、冪等性・報告・失敗判定のすべてを、人が直接責任を持たなければならなくなるという意味です。
なぜ必要なのか
Ansibleを初めて使う人のプレイブックは、ほとんどいつも同じ姿をしています。これまでやっていたことをそのまま移すため、タスクがすべてshell:になっています。
- name: 설정 배포
ansible.builtin.shell: |
mkdir -p /etc/myapp
echo "env=prod" > /etc/myapp/app.conf
chmod 640 /etc/myapp/app.conf
動くことは動きます。問題は、このタスクが何を尋ねても答えられないことです。今すでにその状態なのか。わかりません。今回の実行で何が変わったのか。わかりません。変更する前に、何が変わるかを見せられるか。できません。失敗したのか成功したのか。最後のコマンドの終了コードが0なら成功だと言うだけです。そのため、このタスクは実行されるたびにchangedとして報告され、20台のうち1台で静かに異なる結果が出ても、誰も気づきません。
同じことをモジュールで書くと、次のようになります。
- name: 설정 자리를 만든다
ansible.builtin.file:
path: /etc/myapp
state: directory
mode: "0755"
- name: 설정을 쓴다
ansible.builtin.copy:
dest: /etc/myapp/app.conf
content: "env=prod\n"
mode: "0640"
行数は似ていますが、性質が異なります。モジュールは、対象の現在の状態を先に読み取り、すでにその状態なら何もせず、changed: falseを返します。チェックモードでは、変更する代わりに、変更する内容を報告します。結果は、文字列ではなくキーのあるJSONなので、後ろのタスクが.mode・.checksumをそのまま取り出して使えます。この3つ、状態の判定・事前の予告・構造化された戻り値が、「モジュールが先」である理由のすべてです。
どう動くのか
commandとshellの本当の違いは、シェルの有無の1つだけです。commandは、受け取った文字列を単語に分割して、そのまま実行ファイルに渡します。途中に/bin/shがありません。shellは、文字列をまるごとシェルに渡します。そのため、シェルがやってくれていたことが、すべて分かれます。
| 渡すもの | command |
shell |
|---|---|---|
ls /srv/app/*.conf |
グロブが展開されず、*.confという名前のファイルを探します |
シェルが展開してくれます |
echo a b c 뒤에 파이프와 wc -w(韓国語の部分は「の後にパイプと」という意味です) |
パイプ記号以降がすべて、echoの引数になります | 実際にパイプがつながります |
echo x > /tmp/f |
不等号も引数になります。ファイルはできません | リダイレクトされます |
; 와 &&(韓国語の1文字は「と」を意味します) |
引数です | シェルの演算子です |
ここで、多くの人が混同する点が1つあります。環境変数は、commandでも展開されます。ansible-core 2.16から、commandモジュールにexpand_argument_varsオプションが追加され、デフォルト値が真なので、$HOMEは、シェルなしでもモジュール自身が展開します。無効にしたいなら、expand_argument_vars: falseを指定します。したがって、「シェルを経由しなければ、何も展開されない」ではなく、シェルの文法が使えないが、正確な文です。
commandが静かに間違える方式も、知っておく価値があります。グロブが展開されなければ、コマンドが終了コード2で失敗するので、すぐに目に付きます。ところが、パイプは違います。echo one two three | wc -wをcommandに渡すと、echoがone two three | wc -wをそのまま出力し、終了コード0で成功します。タスクは緑色で、結果だけが間違っています。失敗よりも悪い成功です。
シェルを使う必要があるなら、3つのことを自分で決めます。
1つ目は、changed_whenです。command・shellは、自分が何を変更したのかを知る方法がないので、無条件にchangedとして報告します。取得だけを行うタスクには、changed_when: falseを付ける必要があります。付けないと、何も変更しないプレイブックが毎日changedを積み上げ、その数字が意味を失った瞬間、本当の変更も目立たなくなります。
2つ目は、failed_whenです。デフォルトの判定は、「終了コードが0でなければ失敗」です。ところが、grepは、見つからなければ1を返し、それはエラーではなく答えです。このようなときは、failed_when: result.rc not in [0, 1]のように、失敗の定義を直接書きます。
3つ目は、パイプの終了コードです。シェルでのパイプラインの終了コードは、最後のコマンドのものです。cat 없는파일 | wc -l(韓国語の部分は「存在しないファイル」という意味です)は、catが失敗しても、wcが0で終わるので、全体が0です。タスクは成功で、結果は0です。防ぐには、bashのset -o pipefailを先頭に付け、executable: /bin/bashを一緒に指定します(デフォルトのシェルがpipefailを知らない場合があります)。ansible-lintのrisky-shell-pipeルールが、まさにこの部分を捉えます。
- name: 로그에서 오류 줄을 센다
ansible.builtin.shell:
cmd: set -o pipefail; grep ERROR /var/log/app.log | wc -l
executable: /bin/bash
register: errors
changed_when: false
failed_when: errors.rc not in [0, 1]
モジュールが返すのは、JSONです。registerで受け取ると、rc・stdout・stdout_lines・stderr・changed・failed・cmdが入っています。stdout_linesがすでに行のリストなので、split('\n')を自分で行う理由がなく、cmdには、実際に実行された引数のリストが残り、事故の調査に使われます。モジュールごとに返すキーが異なり、そのリストは、ansible-docのRETURNセクションに書かれています。
ansible-docは、検索エンジンではなく、インストールされているものの一覧です。ansible-doc -lは、今このマシンが実際に使えるモジュールをすべて出力します。-sを指定すると、プレイブックに貼り付けられる骨組みが出力されます。-tで、プラグインの種類(callback・filter・lookup・connection)を選べます。「この作業を行うモジュールがあるか」は、インターネットではなく、まずここで探します。
rawは、例外のための道具です。commandでもshellでも、対象にPythonがなければ動きません。モジュールのコードがPythonだからです。Pythonがまだないマシン(インストールしたばかりのサーバー、ネットワーク機器、Pythonを抜いたコンテナ)には、rawを使います。rawは、SSHで文字列をそのまま投げ、出てきたものをそのまま受け取ります。安価な代わりに、何もしてくれません。冪等性も、戻り値の構造も、行末の整理もないので、受け取った文字列にCRが付いてくることがよくあります。ブートストラップにだけ使い、Pythonがインストールされたあとは使いません。
FQCNを使う理由です。copy:のように短く書いても、今は動きます。ところが、コレクションが複数インストールされたマシンでは、同じ名前のモジュールが2つ以上ある場合があり、そのとき何が選ばれるかは、検索パスが決めます。ansible.builtin.copyと最後まで書けば、その曖昧さがなくなります。読む人も、「これはcoreのものだ」ということが、ひと目でわかります。ansible-lintのfqcnルールが、これを要求します。
現場での姿
1つ目は、shellで始めたプレイブックは、シェルスクリプトに戻ってしまうことです。1つのタスクがshellだと、次のタスクもshellになりやすいです。半年もすれば、プレイブックはSSHで実行されるシェルスクリプトになり、Ansibleを使う理由が残りません。そのため、レビューで最初に確認する質問は、「このshellの代わりになるモジュールは、本当にないのか」です。
2つ目は、事故の調査で、changedが嘘をつくことです。取得のタスクにchanged_when: falseを付けていないチームは、毎日changedが数十件出ます。本当の変更がその中に混ざっていても、誰も見つけられません。逆に、changedを正直に管理しているチームは、「昨日は何も変わっていない」ことを、証拠として示せます。
3つ目は、パイプの終了コードの事故は静かだということです。バックアップの検証タスクがtar -tzf backup.tar.gz | wc -lで、ファイルが壊れた日にtarは失敗しましたが、wcが0を返して、タスクは成功しました。バックアップが壊れていたことは、復旧が必要な日にわかります。その間にあるのが、set -o pipefailの1行です。
4つ目は、このラボ環境の正直な限界です。ラボのPodにはcapabilityがないので、systemctl・mount・sysctl -wが動作しません。そのため、「サービスの再起動をシェルで行っていたものを、ansible.builtin.serviceに移す」ような例は、この環境では判定できないので、ラボから外しました。代わりに、ファイル・ディレクトリ・取得コマンドのように、このPodで本当に動くものだけを扱います。原理は同じです。
参考ドキュメント
- ansible.builtin.commandモジュール: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/command_module.html
- ansible.builtin.shellモジュール: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/shell_module.html
- ansible.builtin.rawモジュール: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/raw_module.html
- ansible-docコマンド: https://docs.ansible.com/ansible/latest/cli/ansible-doc.html
- エラー処理(failed_when・changed_when): https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_error_handling.html
次のラボですること
同じコマンドを、commandとshellのそれぞれで実行して、グロブとパイプで何が分かれるかを自分で計測し、その数字をファイルに残します。registerで受け取ったJSONから、rc・stdout・changed・cmdを取り出してみて、取得のタスクにchanged_when: falseを、終了コード1が正常であるタスクにfailed_whenを付けて、基準を正します。pipefailのないパイプが失敗を飲み込むことを数字で確認してから直し、シェルの3行をfile・copy・statモジュールに移して、構造化された戻り値だけを残します。最後に、rawでPythonを探してみて、プレイブック全体をFQCNで整理したあと、ガードなしでシェルに出るタスクを見つけ出す監査スクリプトを自分で作成します。