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

# はじめに

この記事は、[エーピーコミュニケーションズ Advent Calendar 2018](https://qiita.com/advent-calendar/2018/ap-com) の2日目のエントリです。

最近、業務でAWS上でのサーバリプレイスを行っており、Ansibleを使った構築の自動化に取り組んでいます。
Ansible自体はそこそこ前から触っていたのですが、
仕事でガッツリ使うという機会があまりなく今回初めてその機会がありました。
しかし、いざ書いて動かしてみると思ったようには動かず、「おや？」と思うことがいくつかありました。

大抵は公式ドキュメントや書籍に書かれているような内容ですが、
読み飛ばしてしまいがちなところだったり、マイナーなケースだったりしますので、
これから使う人、久しぶりに使う人のお役に立てばと思います。

ちなみにansibleのバージョンは `2.6.3` でした。アップデート等で解消されていたらすみません。
また、質問や補足、マサカリなどあればコメント頂けると幸いです。

# 変数編

## マジック変数と同じ名前の変数を設定してしまう
Ansibleには[マジック変数](https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html#accessing-information-about-other-hosts-with-magic-variables)というものがあります。
インベントリ等に書いたホスト関連の情報が自動的に入ってくれるという便利な変数です。
マジック変数は以下の4つがあります。

- hostvars
- groups
- group_names
- inventory_hostname

リンクにあるドキュメントにも`do not set variables with these names.` と書かれていますが、
特に短い`groups`などは知らずに、あるいは無意識で使ってしまったりします。
ちなみにPlaybookや`***_vars`でマジック変数を使ってしまっても使用したタスクでエラーが出るわけではなく、
変数としてどんな値を入れていてもマジック変数の値に上書きされて実行されてしまうため、
タスクでエラーが出て止まるということがほとんどだと思いますが、最悪の場合は意図したものとは違う値でタスクが進んでしまう、ということが起こる可能性があります。

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

```yaml:play_testvars.yml
- hosts: all
  gather_facts: false
  vars:
  - groups: hogehoge
  tasks:
  - name: test vars
    debug:
      msg: "groups is {{ groups }}"
```

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

```bash
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](https://github.com/ansible/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`等に記述した場合は上記のようなエラーは発生せず、未定義の変数のように処理されます。
<details><summary>実際に実行してみると以下のようなエラーが出ます。</summary><div>

```yaml:test.yml
- hosts: all
  gather_facts: false
  tasks:
  - name: debug1
    debug:
      var: var1-var2
  - name: debug2
    debug:
      msg: "var is {{ var1-var2 }}"
```

実行してみると、

```shell:実行結果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-var2`は `VARIABLE IS NOT DEFINED!`として扱われていますが、
debug2 taskの`'var1' is undefined`というエラーメッセージが気になります。
`var1`に値を入れるとどうなるのでしょうか？

```shell:実行結果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`に値を入れると…？

```shell:実行結果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でエラーが出ました。不思議。。。
</div></details>

まあ素直にAnsibleの変数にハイフンを使うのをやめよう、ということですね。
私は実行結果1のようなエラーメッセージを見て、ハイフンの問題と気づかずに結構な時間ハマってしまいました。。

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

Ansibleでは`become`や`become_user`などのようにPlaybookにも書けるし、変数にも書ける設定があります。
Playbook毎に設定を変えたい場合もあれば、基本的には共通なので変数に書いて使いまわしたい場合もあり、
ユースケースによって様々な書き方ができるのがAnsibleの良さだと思いますが、
同じ挙動だと思って変数として書いたら若干挙動が異なるケースがあってハマりました。

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

```yaml: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`ユーザーで実行されていることがわかります。

```bash
$ 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`を変数として設定してしまうと、

```bash
$ 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](https://docs.ansible.com/ansible/latest/modules/fetch_module.html)モジュールでMemoryErrorが発生することがある

ファイル転送系のモジュールでは[copy](https://docs.ansible.com/ansible/latest/modules/copy_module.html)モジュールがよく使われますが、
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](https://docs.ansible.com/ansible/latest/modules/fetch_module.html)モジュールのドキュメントを見てみるとバッチリ書いてあり、状況も一致していました。
<details><summary>原文</summary><div>
> *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.
</div></details>

ざっくりとまとめると、
fetchモジュールでは対象ファイルのチェックサムを確認するために[slurp](https://docs.ansible.com/ansible/latest/modules/slurp_module.html)モジュールというものを使っているようですが、
このslurpモジュールはインメモリでBase64のチェックサムを取得するため、対象ファイルのファイルサイズの最低でも2倍のRAMを消費する、とのことです。
さらに`become: true` を使用していると、このRAM消費がさらに2倍になってしまう（トータル最低4倍）ようです。
それでメモリが不足して`MemoryError`が発生するケースがあるんですね。

また、転送できたとしてもかなり時間がかかります。
100MB程度のファイルを仮想マシンからrsyncで取得すると1秒程度なのですが、fetchだと分単位かかりました。
チェックサムにそれだけ時間がかかっているのでしょうか。。

### 回避策

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

* `become: false`にする
* `validate_checksum`を`false`にする
* 別のモジュールを使う

`become: false`にする方法ですが、
取得したいファイルによってはログインユーザではパーミッション不足になってしまうこともあります。
私の場合はワンタイムでしか使わないコードであまり考える時間を取りたくなかったので、
`/tmp/`配下にコピーしてfetchし、fetchが終わったら削除する、というイケてない方法を取りました。。

`validate_checksum`を`false`なのですが、ファイルの一貫性は担保できないもののコレでいいかなと思って使ってみたのですが、
何故か同様のエラーが出てしまいました。
もしかしたらオプションが認識されていない？と思い[ソース](https://github.com/ansible/ansible/blob/devel/lib/ansible/modules/files/fetch.py)を見てたのですが、
ansible-docで表示されるドキュメント情報のみで実体は別のところにあるのかわかりませんでした。。

別のモジュールとしては同じファイル操作系のモジュールである[synchronize](https://docs.ansible.com/ansible/latest/modules/synchronize_module.html) が使用できそうです。
このモジュールはcopy, fetchのようなファイル転送モジュールの一つで、
Linuxではおなじみの`rsync`のようにディレクトリを再帰的にコピーすることができるモジュールです。
このモジュールは`mode`が2通りあり、
デフォルトでは`push`モードでcopyモジュールのように`Ansible実行マシン ===> リモートマシン`にデータを転送しますが、
`pull`モードにするとfetchモジュールのように`Ansible実行マシン <=== リモートマシン`でデータを取得できます。
このpullモードにすることでfetchの代わりとして使えそうです。

```yaml
- 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](https://docs.ansible.com/ansible/latest/user_guide/become.html)として様々なものが使え、Linuxであればデフォルトの`sudo`の他に`su`を使用する方法もありますが、
`su`のみ可能で`sudo`が不可能な環境ではfetchモジュールと同様にログインユーザからアクセス可能なファイルしか取得できません。

# まとめ

Ansibleに限らずどんなツールにも言えることですが、
ツールの概要だけ読んで「こうやって書けば使えるんじゃない？」というイメージは掴めますが、
実際動かしてみると思ったとおりには動かない、なんてことは結構あるんだなーと感じました。

また、Ansibleを構成管理ツールとして見ると、
構築のためにファイルを送ることは多くても、ファイルを取ってくることは少ないので、fetchモジュールのような機能は若干マイナーな使い方なのかな？と思いましたが、
運用自動化ツールとして見る場合は十分使う機会はあると思いました。

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