AnsibleのPlaybookで管理対象としているファイルをバックアップする

# はじめに
この記事は、Ansible Blogger Advent Calendar 2018 の18日目の記事です。
お題は「あなたが好きなAnsible Module」ということで、普段あまり目立つことのないけど実は力強い`fetch`モジュールにスポットライトを当てていこうと思います。基本的な使い方から、「Playbookで管理対象としているファイルをバックアップ」という応用技まで記載していきます！

# 前提条件
この記事はイメージをしやすいように、NginxがインストールされたWebサーバが2台ある環境をモデルとします。

#### クライアント
- MacOS Movaje
- Python 3.7.1 (pyenv)
- Ansible 2.7.5

#### サーバ側
- Ubuntu 18.04.1 LTS

[モデルのVagrantファイルはこちら] (https://github.com/narutay/ansiblebackup/blob/master/examples/Vagrantfile)

インベントリファイルはこんな感じです。

```ini:hosts.ini
[webservers]
web[01:02]

[all:vars]
ansible_python_interpreter=/usr/bin/python3
```

蛇足ですが、sshの接続に関わる設定（ホスト名、ポート、秘密鍵等）はssh_configに集約することをおすすめしています。ProxyCommand等を使ってカスタマイズがしやすいからです。Vagrantの場合、`vagrant ssh-config`コマンドでssh_configの設定値を出力できるため、そのまま`~/.ssh/config`に貼り付けるだけでOKです。

# fetchモジュールの基本
さて、ここからが本題です。
fetchモジュールとは、管理対象ホストからファイルを取得することができるモジュールです。copyモジュールの逆と表現した方がわかりやすいかもしれません。`/etc/hosts`ファイルをバックアップする例を見てみましょう。

```bash:hostsファイルバックアップ
$ ansible -i hosts all -m fetch -a 'src=/etc/hosts  dest=backup'
```
* `src`　取得対象のファイルパス
* `dest`　バックアップ先のディレクトリ

この例の場合`./backup/<インベントリ名>/etc/hosts`のように、自動的に対象ホストのインベントリ名ごとのディレクトリを作成してバックアップされます。

実際のコマンドの実行結果がこちらです。

```bash:hostファイルの収集
$ ansible -i hosts all -m fetch -a 'src=/etc/hosts  dest=backup'
web01 | CHANGED => {
    "changed": true,
    "checksum": "0b4d5dbdb419df735bc4b111e1633d0132ab36b3",
    "dest": "/Users/Shared/ansiblebackup/examples/backup/web01/etc/hosts",
    "md5sum": "f542f9cd86bbc4cd168fc54dbc725bc9",
    "remote_checksum": "0b4d5dbdb419df735bc4b111e1633d0132ab36b3",
    "remote_md5sum": null
}
web02 | CHANGED => {
    "changed": true,
    "checksum": "0b4d5dbdb419df735bc4b111e1633d0132ab36b3",
    "dest": "/Users/Shared/ansiblebackup/examples/backup/web02/etc/hosts",
    "md5sum": "f542f9cd86bbc4cd168fc54dbc725bc9",
    "remote_checksum": "0b4d5dbdb419df735bc4b111e1633d0132ab36b3",
    "remote_md5sum": null
}
```
```bash:収集ファイルの確認
$ tree backup/
backup/
├── web01
│   └── etc
│       └── hosts
└── web02
    └── etc
        └── hosts
```

ドキュメントに明記されていませんが、事前にリモートとローカルのファイルの`sha1sum`の結果を比較して、異なった場合のみファイルを取得する実装になっています。`dest`に指定するディレクトリが同じである場合、2回目以降ファイルを実際に取得することはありません。大きなサイズのファイルを取得する場合には便利です。

fetchモジュールはこの2つの必須オプション以外に`fail_on_missing`、`flat `、`validate_checksum`オプションがありますが、あまり利用することはないかと思いますので割愛します。

#### ポイント①　srcオプションは単一ファイルのみ指定可能

`src`は基本的には単一のファイルのみが指定可能です。ディレクトリ名や`/etc/nginx/*`のようなパスのワイルドカードの利用はできません。そのため、複数ファイルを取得するには、with_items等で繰り替えし指定する必要があります。

```yaml:multiple_fetch.yml
---
- name: Nginxの設定ファイル取得(fetch)
  hosts: webservers
  become: true
  tasks:
    - shell: (cd /etc/nginx; find . -maxdepth 3 -type f)
      register: files_to_copy
    
    - fetch:
        src: /etc/nginx/{{ item }}
        dest: ./backup/
      with_items: "{{ files_to_copy.stdout_lines }}"
```

ただし、fetchモジュールはwith_items句に対応していない（処理をまとめてくれない）ため、このようなPlaybookでの取得はお勧めしません。処理がかなり遅いです。
特定のディレクトリ配下のファイルを一括バックアップしたい場合は`synchronize`モジュールを活用しましょう。

```yaml:multiple_rsync.yml
---
- name: Nginxの設定ファイル取得(synchronize)
  hosts: webservers
  become: true
  tasks:
    - synchronize:
        src: /etc/nginx
        dest: backup/{{ inventory_hostname }}
        mode: pull
```

`synchronize`モジュールはrsyncコマンドのラッパーなので、オプションが豊富です。シンボリックリンクの取得方法など細かく指定することができます。

#### ポイント②　シンボリックリンクファイルはリンク先を取得する

`src`で指定したファイルがシンボリックリンクであった場合、そのリンク先を取得します。ハッシュ値の計算もリンク先でチェックされます。

#### ポイント③　ファイル転送方式

fetchモジュールは`connection`プラグインの`fetch_file()`関数を利用しています。そのためプラグインによって動作が異なります。Linuxの場合`openssh`がControlPersistに対応している場合はssh、対応していない場合paramikoプラグインが利用されるようです。
sshプラグインの場合、`sftp`、`scp`、`piped`のいずれかのモードでファイルの取得をします。`sftp`と`scp`は見ての通りそれぞれのコマンドですが、`piped`はddコマンドの結果をssh越しのパイプで渡してファイルに書き込む実装になっています。
取得モードはansible変数の`ansible_ssh_transfer_method`もしくは`DEFAULT_SCP_IF_SSH`で指定することができ、デフォルトでは`smart`という設定値になっています。`smart`モードは`sftp`→`scp`→`pipd`の順で成功するまで試行する汎用的なモードとなっています。
何かしらの理由でsftpコマンドが利用できない環境であれば、明示的に`scp`を指定すると、処理が高速になるかもしれません。このパラメータは`connection`プラグインを利用している他のモジュールにも影響を与えるため注意してください。

# 【応用】Playbookで管理対象としているファイルをバックアップする
fetchモジュールに着目した唯一のモチベーションは「Playbookで管理対象としているファイルを全てローカルにバックアップしたい」です。
手元にあるPlaybookのtemplateファイルを見ても最終的にどんなファイルがサーバに配置されているかわかりません。各サーバにログインて確認したり、マネジメントツールを使って参照しても良いのですが、やはりローカルにファイルとしてバックアップされている状態が一番見やすいです。

バックアップ対象一覧を作って`with_items`で順に取得するという方法もありますが、それだとスマートではないので、Playbookをインプットとして、管理対象としているファイルを自動的に判定、バックアップすることができないか試行錯誤しました。

### できたアプリケーション

ansible-playbookをベースにコマンドラインで実行可能なアプリケーションを作成してみました。
https://github.com/narutay/ansiblebackup

### バックアップ対象モジュール
バックアップ対象と判定するモジュールはFile系のモジュールを中心に抽出しています。

* `file`：オプション`path` `dest` `name`
* `copy`：オプション`dest`
* `template`：オプション `dest`
* `ini_file`：オプション`path` `dest`

これらのオプションで指定されているファイルをローカルにバックアップすることができます。ただしfechモジュールの制約上、ディレクトリやワイルドカードが指定されている場合はエラーとなってしまいます。

他のモジュールも簡単に対象に追加できるような作りになっています。

### アプローチ

基本的にはPython APIを用いた開発となるのですが、`ansible-playbook`と同じオプションを利用できるようにしたかったため、`PlaybookExecutor`を継承して組み立てていきました。
実は、同様のツールをAnsible 1.xの頃に作成していたので、2.0でも同様にサクッと作れるかなと考えていたのですが、1.xと2.xのAnsibleの作りが違いすぎることと、2.xはクラスやロジック、処理の流れがかなり複雑になってしまったため、ハックするのにかなり苦戦しました。

2.xではPlaybook実行時に`PlaybookExecutor`、`TaskQueueManager`、`PlayIterator`、`VariableManager`、`DataLoader`、`Strategy`これらのクラスが複雑に絡み合って動作します。
それぞれの役割や動作について説明するとものすごい量になってしまうため、別の記事としてまとめたいと思います。

今回は`Play`オブジェクトの`compile()`関数をオーバーライドして、PlaybookからTaskオブジェクトを抽出しきった後に、copyモジュール等の対象モジュールを抽出し、fetchモジュール用のTaskに置換するというアプローチをしてみました。`PlaybookExecutor`が`Playbook`オブジェクトを`TaskQueueManager`に渡す前にクラスを差し替えています。

`Strategy`プラグインで実行前に置換することや、`DataLoader`で読み込み時に置換するアプローチもをしてみましたがうまく行かず、最終的にここに落ち着きました。もっと良いハックの仕方を知っている方がいれば教えてほしいです！

### インストール

Githubからpipでインストールできます。現時点ではかなり粗い作りになっているので、使ってみたい方はpyenv等で分離された環境を利用してください。

```bash:インストール
pip install https://github.com/narutay/ansiblebackup/archive/0.0.1.tar.gz
```

#### 動作確認
ansible-galaxyから`geerlingguy.nginx`をインストールして以下のようなPlaybookを作成てみました。2台で異なる設定ファイルとなるよう、``{{ inventory_host }}``を少し無理やり使用しています。この差分は後で利用します。

```yaml:playbook.yml
---
- name: Nginxのインストール
  hosts: webservers
  become: true
  vars:
    nginx_vhosts:
      - listen: "80"
        server_name: "www.example.com"
        return: "301 https://www.example.com$request_uri"
        filename: "example.com.80.conf"
      - listen: "8080"
        server_name: "www2.{{ inventory_hostname }}.com"
        return: "301 https://www.{{ inventory_hostname }}.com$request_uri"
        filename: "example.com.8080.conf"
  roles:
    - { role: geerlingguy.nginx }
```

Playbookの実行コマンドを実行するとNginxのインストールや`nginx.conf`、`sites-enabled/*.conf`などを作成、操作します。

```bash:playbook実行
$ ansible-playbook -i hosts playbook.yml
(結果は略)
```

実際にansible-backupしてみましょう。実行する際のオプションはansible-playbookと全く同じです。試してないですが、おそらくtag等も使えます。

```bash:ansible-backupの実行
ansible-backup -i hosts playbook.yml 

PLAY [Nginxのインストール] ******************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ***************************************************************************************************************************************************************************************************************
ok: [web02]
ok: [web01]

TASK [geerlingguy.nginx : Include OS-specific variables.] ****************************************************************************************************************************************************************************
ok: [web01]
ok: [web02]

TASK [geerlingguy.nginx : Define nginx_user.] ****************************************************************************************************************************************************************************************
ok: [web01]
ok: [web02]

TASK [geerlingguy.nginx : <fetch|template> Add managed vhost config files.] **********************************************************************************************************************************************************
changed: [web02] => (item={'listen': '80', 'server_name': 'www.example.com', 'return': '301 https://www.example.com$request_uri', 'filename': 'example.com.80.conf'})
changed: [web01] => (item={'listen': '80', 'server_name': 'www.example.com', 'return': '301 https://www.example.com$request_uri', 'filename': 'example.com.80.conf'})
changed: [web02] => (item={'listen': '8080', 'server_name': 'www2.web02.com', 'return': '301 https://www.web02.com$request_uri', 'filename': 'example.com.8080.conf'})
changed: [web01] => (item={'listen': '8080', 'server_name': 'www2.web01.com', 'return': '301 https://www.web01.com$request_uri', 'filename': 'example.com.8080.conf'})

TASK [geerlingguy.nginx : <fetch|template> Copy nginx configuration in place.] *******************************************************************************************************************************************************
changed: [web01]
changed: [web02]

PLAY RECAP ***************************************************************************************************************************************************************************************************************************
web01                      : ok=5    changed=2    unreachable=0    failed=0   
web02                      : ok=5    changed=2    unreachable=0    failed=0   
```

完了すると、CLIの実行ディレクトリ直下に`backup/YYYYMMDDhhmmss`が作成され、ホストごとのファイルが格納されます。

```bash:取得結果
$ tree backup/20181218083248
backup/20181218083248
├── web01
│   └── etc
│       └── nginx
│           ├── nginx.conf
│           └── sites-enabled
│               ├── example.com.80.conf
│               └── example.com.8080.conf
└── web02
    └── etc
        └── nginx
            ├── nginx.conf
            └── sites-enabled
                ├── example.com.80.conf
                └── example.com.8080.conf

8 directories, 6 files
```

全てローカルにあるので、ホストごとの差分を抽出することも簡単にできます。

```diff:diff
$ diff -r backup/20181218083248/web{01,02}
diff -r backup/20181218083248/web01/etc/nginx/sites-enabled/example.com.8080.conf backup/20181218083248/web02/etc/nginx/sites-enabled/example.com.8080.conf
6c6
<     server_name www2.web01.com;
---
>     server_name www2.web02.com;
12c12
<     return 301 https://www.web01.com$request_uri;
---
>     return 301 https://www.web02.com$request_uri;
```

`{{ inventory_hostname }}`でホストごとに異なる値を設定している箇所が抽出できていますね。便利！

こんな感じで、ファイルの状態の確認に利用しても良いですし、バックアップ用途として利用しても良いと思います。過去には定期的にGitにコミットして、差分のトレースなどをしたりもしていました。Ansible管理外で直接ファイルを触ってしまった場合などに、変化を検知することができるようになります。

#### 制約
`include_module`で読み込むPlaybookでcopyモジュール等を利用している場合は、バックアップ対象とすることができません。これは、`import_module`はPlaybook実行前に解決されるのに対し、`include_module`はタスク実行時に初めて解決されるためです。

# 最後に
想定以上にPlaybookのハックに時間がかかってしまい、納得がいく作りにはできませんでしたが、ギリギリ動きました（今日までに動くところまでいけないと思っていました・・・）。今回やってみてPython APIを用いたAnsibleの活用方法の記事や情報が少ないことを実感しました。おおよその構成は理解できたので、今後はPlaybookの仕組みの解説など情報発信していこうと思います！