Ansibleを実際使ってみてハマったハマりポイント


はじめに

この記事は、エーピーコミュニケーションズ Advent Calendar 2018 の2日目のエントリです。

最近、業務でAWS上でのサーバリプレイスを行っており、Ansibleを使った構築の自動化に取り組んでいます。

Ansible自体はそこそこ前から触っていたのですが、

仕事でガッツリ使うという機会があまりなく今回初めてその機会がありました。

しかし、いざ書いて動かしてみると思ったようには動かず、「おや?」と思うことがいくつかありました。

大抵は公式ドキュメントや書籍に書かれているような内容ですが、

読み飛ばしてしまいがちなところだったり、マイナーなケースだったりしますので、

これから使う人、久しぶりに使う人のお役に立てばと思います。

ちなみにansibleのバージョンは 2.6.3 でした。アップデート等で解消されていたらすみません。

また、質問や補足、マサカリなどあればコメント頂けると幸いです。


変数編


マジック変数と同じ名前の変数を設定してしまう

Ansibleにはマジック変数というものがあります。

インベントリ等に書いたホスト関連の情報が自動的に入ってくれるという便利な変数です。

マジック変数は以下の4つがあります。


  • hostvars

  • groups

  • group_names

  • inventory_hostname

リンクにあるドキュメントにもdo not set variables with these names. と書かれていますが、

特に短いgroupsなどは知らずに、あるいは無意識で使ってしまったりします。

ちなみにPlaybookや***_varsでマジック変数を使ってしまっても使用したタスクでエラーが出るわけではなく、

変数としてどんな値を入れていてもマジック変数の値に上書きされて実行されてしまうため、

タスクでエラーが出て止まるということがほとんどだと思いますが、最悪の場合は意図したものとは違う値でタスクが進んでしまう、ということが起こる可能性があります。

例えばこんなPlaybookを書いたとき、


play_testvars.yml

- hosts: all

gather_facts: false
vars:
- groups: hogehoge
tasks:
- name: test vars
debug:
msg: "groups is {{ groups }}"

意図している値は groups is hogehoge ですが…

PLAY [all] *************************************************************************************************************

TASK [test vars] *******************************************************************************************************
ok: [192.168.33.100] => {
"msg": "groups is {'all': ['192.168.33.100'], 'ungrouped': [], 'vagrant': ['192.168.33.100']}"
}

PLAY RECAP *************************************************************************************************************
192.168.33.100 : ok=1 changed=0 unreachable=0 failed=0

groups=hogehoge という値に、マジック変数が上書きされて処理されてしまいます。


回避策

変数を設定する場合はマジック変数を避ける必要があるので、マジック変数に何があるかをドキュメント等で知っておく必要があります。

この事象に限ったことではありませんが、レビューで実行前にチェックしたり、テスト環境で動作確認をするのが良さそうです。

ちなみにansible-lintでは特にエラー等は検出できませんでした。


2018/12/2 追記

同僚の @akira6592 さんに補足情報をいただきました。

記事内ではgroupsを始めとした4つのマジック変数を紹介しましたが、

ドキュメントの以下のページにマジック変数の一覧が記載されています。

https://docs.ansible.com/ansible/latest/reference_appendices/special_variables.html

Ansible2.7版のドキュメントから新規で追加されたページのようです。

思った以上にたくさんあるんですね!

今回の書き方は注意喚起の意味合いが強いですが、本来的には便利なものなのでドンドン活用していきましょう。


変数に使用できない記号(ハイフンなど)を使用してしまう

Ansibleで変数に使用できる文字・記号は以下の3種類です。


  • アルファベット(大文字・小文字可) ※1文字目はアルファベットのみ可

  • 数字

  • アンダースコア _

このようにAnsibleの変数名には記号はアンダースコアのみが使用可能で、ハイフン - は使用できません。


回避策

こちらも変数名からハイフンを除外するしかないようです。

幸いアンダースコアは使用できるので、var1_var2のようなスネークケースに置き換えたり、

大文字・小文字を別々に認識するようなのでVar1Var2のようなキャメルケースなどで書き換えることは容易だと思います。


ちなみに

ハイフン混じりの変数(例:var1-var2)を設定した場合、

Playbook内であれば以下のようなエラーが表示されます。

ERROR! Invalid variable name in vars specified for Play: 'var1-var2' is not a valid variable name

一方、Playbook外のhost_vars, group_vars等に記述した場合は上記のようなエラーは発生せず、未定義の変数のように処理されます。

実際に実行してみると以下のようなエラーが出ます。


test.yml

- hosts: all

gather_facts: false
tasks:
- name: debug1
debug:
var: var1-var2
- name: debug2
debug:
msg: "var is {{ var1-var2 }}"

実行してみると、


実行結果1

$ ansible-playbook -i inventory.ini test.yml

PLAY [all] *************************************************************************************************************

TASK [debug1] **********************************************************************************************************
ok: [192.168.33.100] => {
"var1-var2": "VARIABLE IS NOT DEFINED!"
}

TASK [debug2] **********************************************************************************************************
fatal: [192.168.33.100]: FAILED! => {"msg": "The task includes an option with an undefined variable. The error was: 'var1' is undefined\n\nThe error appears to have been in '/path-to-playbook/test.yml': line 7, column 5, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n var: var1-var2\n - name: debug2\n ^ here\n"}

PLAY RECAP *************************************************************************************************************
192.168.33.100 : ok=1 changed=0 unreachable=0 failed=1


debug1 taskでは変数var1-var2VARIABLE IS NOT DEFINED!として扱われていますが、

debug2 taskの'var1' is undefinedというエラーメッセージが気になります。

var1に値を入れるとどうなるのでしょうか?


実行結果2

$ ansible-playbook -i inventory.ini test.yml -e var1=test1

PLAY [all] *************************************************************************************************************

TASK [debug1] **********************************************************************************************************
ok: [192.168.33.100] => {
"var1-var2": "VARIABLE IS NOT DEFINED!"
}

TASK [debug2] **********************************************************************************************************
fatal: [192.168.33.100]: FAILED! => {"msg": "The task includes an option with an undefined variable. The error was: 'var2' is undefined\n\nThe error appears to have been in '/path-to-playbook/test.yml': line 7, column 5, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n var: var1-var2\n - name: debug2\n ^ here\n"}

PLAY RECAP *************************************************************************************************************
192.168.33.100 : ok=1 changed=0 unreachable=0 failed=1


今度はvar2が未定義になりました。var2に値を入れると…?


実行結果3

$ ansible-playbook -i inventory.ini test.yml -e var1=test1 -e var2=test2

PLAY [all] *************************************************************************************************************

TASK [debug1] **********************************************************************************************************
fatal: [192.168.33.100]: FAILED! => {"msg": "Unexpected templating type error occurred on ({{var1-var2}}): unsupported operand type(s) for -: 'str' and 'str'"}

PLAY RECAP *************************************************************************************************************
192.168.33.100 : ok=0 changed=0 unreachable=0 failed=1


今度は実行結果1,2では未定義としてですが通っていたdebug1 taskでエラーが出ました。不思議。。。


まあ素直にAnsibleの変数にハイフンを使うのをやめよう、ということですね。

私は実行結果1のようなエラーメッセージを見て、ハイフンの問題と気づかずに結構な時間ハマってしまいました。。


変数としてansible_become: trueにするとログインユーザーに戻れない

Ansibleではbecomebecome_userなどのようにPlaybookにも書けるし、変数にも書ける設定があります。

Playbook毎に設定を変えたい場合もあれば、基本的には共通なので変数に書いて使いまわしたい場合もあり、

ユースケースによって様々な書き方ができるのがAnsibleの良さだと思いますが、

同じ挙動だと思って変数として書いたら若干挙動が異なるケースがあってハマりました。

例えば以下のようなPlaybookを書いた場合、


whoami.yml

- hosts: all

gather_facts: false
become: true
tasks:
- name: root user
command: whoami
- name: login user
command: whoami
become: false

実行すると意図したように2つ目のタスクlogin userではログインしたvagrantユーザーで実行されていることがわかります。

$ ansible-playbook -i inventory.ini whoami.yml -v

Using /etc/ansible/ansible.cfg as config file

PLAY [all] *************************************************************************************************************

TASK [root user] *******************************************************************************************************
changed: [192.168.33.100] => {"changed": true, "cmd": ["whoami"], "delta": "0:00:00.004528", "end": "2018-12-01 04:57:34.454376", "rc": 0, "start": "2018-12-01 04:57:34.449848", "stderr": "", "stderr_lines": [], "stdout": "root", "stdout_lines": ["root"]}

TASK [login user] ******************************************************************************************************
changed: [192.168.33.100] => {"changed": true, "cmd": ["whoami"], "delta": "0:00:00.004071", "end": "2018-12-01 04:57:35.172791", "rc": 0, "start": "2018-12-01 04:57:35.168720", "stderr": "", "stderr_lines": [], "stdout": "vagrant", "stdout_lines": ["vagrant"]}

PLAY RECAP *************************************************************************************************************
192.168.33.100 : ok=2 changed=2 unreachable=0 failed=0

しかし、ansible_become: trueを変数として設定してしまうと、

$ ansible-playbook -i inventory.ini whoami.yml -v -e ansible_become=true

Using /etc/ansible/ansible.cfg as config file

PLAY [all] *************************************************************************************************************

TASK [root user] *******************************************************************************************************
changed: [192.168.33.100] => {"changed": true, "cmd": ["whoami"], "delta": "0:00:00.013255", "end": "2018-12-01 04:57:49.792209", "rc": 0, "start": "2018-12-01 04:57:49.778954", "stderr": "", "stderr_lines": [], "stdout": "root", "stdout_lines": ["root"]}

TASK [login user] ******************************************************************************************************
changed: [192.168.33.100] => {"changed": true, "cmd": ["whoami"], "delta": "0:00:00.005359", "end": "2018-12-01 04:57:50.544723", "rc": 0, "start": "2018-12-01 04:57:50.539364", "stderr": "", "stderr_lines": [], "stdout": "root", "stdout_lines": ["root"]}

PLAY RECAP *************************************************************************************************************
192.168.33.100 : ok=2 changed=2 unreachable=0 failed=0

2つ目のタスクlogin userに記載したbecome: falseが効かずにrootユーザーで実行されてしまいました。


回避策

全てのタスクをbecome_user(デフォルトではroot)で実行することが決まっていない限りは、

変数としてansible_become: trueは持たせずに、Playbookの記述で制御するのが良さそうです。

以下に続くモジュール系ではbecomeしているかどうかが重要な意味を持っていたため、

本件のように変数に書いたbecomeが解除されずにハマってしまいました。


モジュール編


fetchモジュールでMemoryErrorが発生することがある

ファイル転送系のモジュールではcopyモジュールがよく使われますが、

copyモジュールは Ansible実行マシン ===> リモートマシン のようにファイルを転送するのに対し、

fetchモジュールはその逆でAnsible実行マシン <=== リモートマシンのようにリモートマシン上のファイルを取得します。

しかし、以下のようなエラーが発生するケースがしばしば起こりました。

長々と書かれていますが、最後にあるMemoryErrorが発生していることがわかります。

TASK [test fetch by root] **********************************************************************************************

fatal: [192.168.33.100]: FAILED! => {"changed": false, "module_stderr": "Shared connection to 192.168.33.100 closed.\r\n", "module_stdout": "Traceback (most recent call last):\r\n File \"/tmp/ansible_U2m0xP/ansible_module_slurp.py\", line 87, in <module>\r\n main()\r\n File \"/tmp/ansible_U2m0xP/ansible_module_slurp.py\", line 83, in main\r\n module.exit_json(content=data, source=source, encoding='base64')\r\n File \"/tmp/ansible_U2m0xP/ansible_modlib.zip/ansible/module_utils/basic.py\", line 2364, in exit_json\r\n File \"/tmp/ansible_U2m0xP/ansible_modlib.zip/ansible/module_utils/basic.py\", line 2358, in _return_formatted\r\n File \"/tmp/ansible_U2m0xP/ansible_modlib.zip/ansible/module_utils/basic.py\", line 2312, in jsonify\r\n File \"/tmp/ansible_U2m0xP/ansible_modlib.zip/ansible/module_utils/basic.py\", line 795, in jsonify\r\n File \"/usr/lib64/python2.7/json/__init__.py\", line 250, in dumps\r\n sort_keys=sort_keys, **kw).encode(obj)\r\n File \"/usr/lib64/python2.7/json/encoder.py\", line 210, in encode\r\n return ''.join(chunks)\r\nMemoryError\r\n", "msg": "MODULE FAILURE", "rc": 1}

fetchモジュールのドキュメントを見てみるとバッチリ書いてあり、状況も一致していました。

原文


*When running fetch with become, the slurp module will also be used to fetch the contents of the file for determining the remote checksum. This effectively doubles the transfer size, and depending on the file size can consume all available memory on the remote or local hosts causing a MemoryError. Due to this it is advisable to run this module without become whenever possible.



ざっくりとまとめると、

fetchモジュールでは対象ファイルのチェックサムを確認するためにslurpモジュールというものを使っているようですが、

このslurpモジュールはインメモリでBase64のチェックサムを取得するため、対象ファイルのファイルサイズの最低でも2倍のRAMを消費する、とのことです。

さらにbecome: true を使用していると、このRAM消費がさらに2倍になってしまう(トータル最低4倍)ようです。

それでメモリが不足してMemoryErrorが発生するケースがあるんですね。

また、転送できたとしてもかなり時間がかかります。

100MB程度のファイルを仮想マシンからrsyncで取得すると1秒程度なのですが、fetchだと分単位かかりました。

チェックサムにそれだけ時間がかかっているのでしょうか。。


回避策

回避策はいくつか考えられます



  • become: falseにする


  • validate_checksumfalseにする

  • 別のモジュールを使う

become: falseにする方法ですが、

取得したいファイルによってはログインユーザではパーミッション不足になってしまうこともあります。

私の場合はワンタイムでしか使わないコードであまり考える時間を取りたくなかったので、

/tmp/配下にコピーしてfetchし、fetchが終わったら削除する、というイケてない方法を取りました。。

validate_checksumfalseなのですが、ファイルの一貫性は担保できないもののコレでいいかなと思って使ってみたのですが、

何故か同様のエラーが出てしまいました。

もしかしたらオプションが認識されていない?と思いソースを見てたのですが、

ansible-docで表示されるドキュメント情報のみで実体は別のところにあるのかわかりませんでした。。

別のモジュールとしては同じファイル操作系のモジュールであるsynchronize が使用できそうです。

このモジュールはcopy, fetchのようなファイル転送モジュールの一つで、

Linuxではおなじみのrsyncのようにディレクトリを再帰的にコピーすることができるモジュールです。

このモジュールはmodeが2通りあり、

デフォルトではpushモードでcopyモジュールのようにAnsible実行マシン ===> リモートマシンにデータを転送しますが、

pullモードにするとfetchモジュールのようにAnsible実行マシン <=== リモートマシンでデータを取得できます。

このpullモードにすることでfetchの代わりとして使えそうです。

- hosts: all

gather_facts: false
become: true
tasks:
- name: sync
synchronize:
mode: pull
src: /etc/sysconfig/ # リモートファイルパス
dest: files # 格納先(相対パスでも絶対パスでも可)

しかしsynchronizeモジュールもどんな状況でも使用できるわけではなく、


Currently, synchronize is limited to elevating permissions via passwordless sudo. This is because rsync itself is connecting to the remote machine and rsync doesn’t give us a way to pass sudo credentials in.


とあるように、becomeの方法としてsudoのみでパスワード入力不要なものに限られます。

Ansibleではbecome_methodとして様々なものが使え、Linuxであればデフォルトのsudoの他にsuを使用する方法もありますが、

suのみ可能でsudoが不可能な環境ではfetchモジュールと同様にログインユーザからアクセス可能なファイルしか取得できません。


まとめ

Ansibleに限らずどんなツールにも言えることですが、

ツールの概要だけ読んで「こうやって書けば使えるんじゃない?」というイメージは掴めますが、

実際動かしてみると思ったとおりには動かない、なんてことは結構あるんだなーと感じました。

また、Ansibleを構成管理ツールとして見ると、

構築のためにファイルを送ることは多くても、ファイルを取ってくることは少ないので、fetchモジュールのような機能は若干マイナーな使い方なのかな?と思いましたが、

運用自動化ツールとして見る場合は十分使う機会はあると思いました。

自分のハマりどころが少しでも他のユーザーの方々のお役に立てば嬉しいです。