Ansible Playbook の CI をまわす

この記事は、[Ansible Advent Calendar 2018](https://qiita.com/advent-calendar/2018/ansible) の 7 日目の記事です。

ソフトウェアは変更に弱く壊れやすいものです。Ansible の Playbook も例外ではありません。本記事では  Rust の生みの親である Graydon Hoare 氏の[こちらの記事](https://graydon.livejournal.com/186550.html)を参考に、Configuration as Code の開発プロセスに CI を導入した結果について述べます。

> The Not Rocket Science Rule Of Software Engineering:
> automatically maintain a repository of code that always passes all the tests

## tl;dr

- Ansible における Role のテストフレームワークである [Molecule](https://molecule.readthedocs.io/en/latest/) はローカル/CI環境問わず Playbook のテストにも有用です。
- Ansible 関連のパッケージを [Pipenv](https://pipenv-ja.readthedocs.io/ja/translate-ja/) で管理することで、チーム内の開発環境等の差異を吸収できます。
- Anisble 関連のパッケージは bot を使って定期的に更新すると Toil が減ります。
- 複数人での開発を行う場合は自動マージ Bot を導入することで、upstream の安全性を高め、Toil を減らすことが出来ます。

## Intro

アプリ開発における CI/CD の導入は"当たり前"になりつつあります。自動化による Toil の撲滅、コードの安全性向上、開発スピードの高速化など理由は様々あると思います。だったらインフラのコードでも CI/CD をまわしていきたい！という思いから、CI の部分を設計してみました。(CD は割と行われているイメージです。)

Ansible には [Check Mode](https://docs.ansible.com/ansible/2.5/user_guide/playbooks_checkmode.html) を使った Playbook の動作をシミュレートするモードがあります。しかし、実際に変更を適用する訳ではないので、実行結果に依存した条件式が組み込まれた Playbook や Check モードに非対応のモジュールを使った Playbook はテストすることができません。そのため、別途 Playbook を開発環境や検証環境に適用して動作確認する必要があります。ローカルやクラウド上に開発環境や検証環境を自前で用意するのは Toil であり、排除すべき対象です。

最近流行りの CI 環境は、使い捨てのコンテナ (一部 VM) が実行環境です。使い捨ての環境と Ansible の親和性は高く、CI の実行環境上にコンテナを構築して Playbook を適用するというアプローチを取りました。そして、実行環境上で適用対象のノードの管理からテストの実行まで一貫して行うことができる [Molecule](https://molecule.readthedocs.io/en/latest/) をテストフレームワークとして導入しました。

## What's Molecule

Ansible のロールの開発やテストを補助する目的で設計されています。コンテナ、VM、クラウドなど様々なインスタンスを構築し、実際に変更を適用することでテストします。Molecule の内部では Ansible の Playbook を使って環境を構築しているため、Ansible が公式でサポートしているプロバイダーにほぼ対応しています。元は個人プロジェクトでしたが現在は [Ansible (Red Hat) 配下のプロジェクト](https://github.com/ansible/molecule#authors)です。

今回は Docker と Vagrant ドライバーしか取り上げませんが、AWS / Azure / GCP / OpenStack に対応しているため、CD も簡単に設計できます。

Molecule を導入した Ansible 公式の [example](https://github.com/ansible/ansible-examples/tree/master/lamp_simple) は[こちら](https://github.com/toVersus/sample-playbook-for-ci-testing)にあります。Molecule を実行している様子をキャプチャした demo を用意しています。開発中のロールに Molecule を導入したサンプルは[こちら][role_sample]にあります。

以降は Molecule を導入した [example playbook][playbook_sample] を使って説明していきます。

### Overview of Testing

Molecule が行うテストの概要は以下の通りです。

- [yamllint](https://github.com/adrienverge/yamllint)
    - YAML ファイルを静的に解析します。
    - `.yamllint` で定義したルールに沿わない場合、warning/error でテストが失敗します。

- [Ansible-lint](https://github.com/ansible/ansible-lint)
    - Playbook を静的に解析し、ベストプラクティスに沿っているかを評価します。

- CCIE 上のコンテナに対する変更の適用
    - Molecule の [`test`](https://molecule.readthedocs.io/en/latest/usage.html#test) コマンドが実行され、コンテナに対して実際に変更を適用していきます。Molecule の設定ファイル ([molecule.yml](https://github.com/toVersus/sample-role-for-ci-testing/blob/master/molecule/default/molecule.yml#L15-L17)) 内で、[DIFF_ALWAYS](https://docs.ansible.com/ansible/latest/reference_appendices/config.html#diff-always) を有効にすれば、変更の差分が表示されます。
    - 同時に冪等性チェック ([`idempotence`](https://molecule.readthedocs.io/en/latest/usage.html#idempotence)) が行われ、状態が `changed` となるタスクがあればエラー終了します。

- サーバーの自動テスト
    - デフォルトは [Testinfra](https://testinfra.readthedocs.io/en/latest) ですが、[ServerSpec](https://serverspec.org/) と [InSpec](https://www.chef.io/inspec) と [Goss](https://github.com/aelsabbahy/goss) から選択できます。
    - Testinfra は [Serverspec](https://serverspec.org/) の Python 版みたいなものです。
        - [Flake8](http://flake8.pycqa.org/en/latest) による静的解析が行われるため、Python のお作法が分からなくてもリンターに怒られながらコードを書くことができます。
    - 使わない選択肢もありですが、Molecule に同梱されています。

### Testing Flow

Molecule のテストは[シナリオ](https://molecule.readthedocs.io/en/latest/configuration.html#scenario)単位で複数管理することができます。各シナリオで対象の環境や Playbook を個別に定義でき、テストの流れ (シークエンス) を設定することができます。

シークエンスは `create`、`check`、`converge`、`destroy`、`test` が用意されており、独自に実行するタスクを定義可能です。

例えば、デフォルトの `test` シークエンスは以下のタスクを順に実行していきます。

- [lint](https://molecule.readthedocs.io/en/latest/usage.html#lint)
    - デフォルトでは Yamllint, Flake8, Ansible Lint の順番でリンターが実行されます。
    - Linter はそれぞれ変更可能です。
- destroy
    - 変更を適用した環境が残っていれば破壊し、お掃除します。
- dependency
    - requirements.yml に定義された Ansible Galaxy のロールをダウンロードします。
- syntax
    - ansible-playbook コマンドを `--syntax-check` オプションで実行します。
- create
    - 変更を適用する対象の環境を構築します。docker の場合、Dockerfile.j2 に従ってDocker コンテナをビルドして起動します。(Dockerfile はカスタマイズ可能です。)
- prepare
    - converge を実行する前に環境を整えたい場合に使用します。
- converge
    - 対象の環境に Playbook を適用します。
- idempotence
    - Playbook を再度適用し、冪等性チェックを行います。
- side_effect
- verify
    - Testinfra が実行されます。
- destroy

### Directory Structure

`molecule init scenario -r <role name> [-s <scenario name>]` で既存の role に Molecule のテストシナリオを追加することができます。シナリオ名を指定しない場合は、default シナリオが作成され、以下のファイルが role 配下のディレクトリに作成されます。

```
├── .yamllint                  <- 追加された Yamllint の設定ファイル
└── molecule                   <- 追加された Molecule 関連のボイラーテンプレート
    └── default                  <- デフォルトのテストシナリオ
        ├── Dockerfile.j2          <- Dockerfile をカスタマイズする場合に使います
        ├── INSTALL.rst            <- Docker ドライバーを使用する場合のインストール方法が書かれたドキュメント
        ├── molecule.yml           <- Molecule の設定ファイル
        ├── playbook.yml           <- Molecule で実行する Playbook を記述します
        └── tests
            ├── test_default.py    <- Testinfra で使用するコードを記述します
            └── test_default.pyc   <- 上記のコードをコンパイルした結果
```

### Configuration

Molecule の設定はシナリオごとに管理します。

#### Depencency

Role の依存関係の管理に関する設定です。デフォルトの dependency マネージャは Ansible Galaxy です。その他に [Gilt](http://gilt.readthedocs.io/) と Shell を選択できます。

```yaml
dependency:
  name: galaxy
```

#### Driver

Molecule のテストを適用する対象のインスタンスの種類を指定します。コンテナ、クラウド、VM など複数の選択肢があります。詳細は[こちら](https://molecule.readthedocs.io/en/latest/configuration.html#driver)を確認して下さい。シナリオ内で複数の Driver を選択することはできません。

```yaml
driver:
  name: docker
```

#### Platforms

テストを適用する対象のインスタンスの設定です。複数のインスタンスを指定することが可能です。`name` と `groups` が Ansible における [Hosts と Groups](https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html#hosts-and-groups) に対応しています。

Driver で `docker` を指定した場合は、[docker_container](https://docs.ansible.com/ansible/latest/modules/docker_container_module.html) モジュールのオプションを指定することができます。コンテナではセキュリティ上の理由からデフォルトで init や systemd を制御することができません。`capabilities` と `/sbin/init` を指定することでサービスの状態確認/起動/停止を可能にしています。特権モードを付与したい場合は、`privileged: true` を指定すれば良いです。

```yaml
platforms:
  - name: instance-1
    groups:
      - dbservers
    image: centos:6
    capabilities:
      - SYS_ADMIN
    command: /sbin/init
  - name: instance-2
    groups:
      - webservers
    image: centos:6
    capabilities:
      - SYS_ADMIN
    command: /sbin/init
```

#### Provisioner

インスタンスに対して変更を適用するツールを指定します。指定できる Provisioner は Ansible のみです。

`inventory` を指定することで、group_vars ディレクトリをリンクさせることもできます。

```yaml
  inventory:
    links:
      group_vars: ../../group_vars
```

`config_options` を指定することで、ansible.cfg の設定を記載可能です。

```yaml
  config_options:
    defaults:
      timeout: 80
      forks: 50
      hash_behaviour: merge
      host_key_checking: false
      retry_files_enabled: false
      nocows: true
    diff:
      always: yes
```

`lint` はデフォルトで Ansible-lint が指定されています。`options:` で Ansible-lint の設定が可能です。適用ルールの表示を強制させています。

```yaml
 lint:
    name: ansible-lint
    options:
      L: true
      force-color: true
```

#### Scenario

シナリオのシークエンスを上書きすることができます。複数のシナリオを管理することはできないので注意して下さい。デフォルトのシークエンスは、[こちら](https://molecule.readthedocs.io/en/latest/configuration.html#scenario)をご確認ください。

check シークエンスはデフォルトリントチェックが走らないので設定を上書きしています。

```yaml
  check_sequence:
    - lint
    - destroy
    - dependency
    - syntax
    - create
    - prepare
    - check
    - destroy
```

#### Verifier

インフラ自動テストのツールを指定します。TestInfra がデフォルトで指定されています。インフラ自動テストを使用していないので、特に設定していません。

```yaml
verifier:
  name: testinfra
  lint:
    name: flake8
```

## Strategy for CI testing

[こちら][playbook_sample]のサンプルでは、次の CI サービスと連携しています。

- [CircleCI](https://circleci.com/)
- [AppVeyor](https://www.appveyor.com/)
- [Travis CI](https://travis-ci.org/)
- [Azure Pipelines](https://azure.microsoft.com/ja-jp/services/devops/pipelines/)
- [shippable](https://www.shippable.com/)

### CircleCI

CircleCI の設定ファイルは [.circleci/config.yml](https://github.com/toVersus/sample-playbook-for-ci-testing/blob/master/.circleci/config.yml) にあります。

#### Executor

Executor Type として [`machine`](https://circleci.com/docs/2.0/executor-types/#using-machine) を使用しています。CircleCI の `docker` 実行環境 (Executor) は特権モードでコンテナを起動できないなど制限があるからです。詳細は[こちら](https://circleci.com/docs/2.0/executor-types/#overview)を確認下さい。

```yaml
machine: true
```

- `machine` の構築には[こちら](https://github.com/circleci/image-builder/blob/picard-vm-image/provision.sh)のスクリプトが使用されています。必要なパッケージやツールがプリインストールされているかを確認しましょう。
    - スクリプトの中を見ると分かるのですが、Python の仮想環境はすでに構築されています。[`PIPENV_VENV_IN_PROJECT`](https://pipenv-ja.readthedocs.io/ja/translate-ja/advanced.html#pipenv.environments.PIPENV_VENV_IN_PROJECT) を環境変数として定義しているのは、`working_direcory` 配下に新たに `.env` というディレクトリを作成するためです。後述しますが、この `.env` をキャッシュとして保存しています。

        ```yaml
        environment:
          PIPENV_VENV_IN_PROJECT: true
        ```

#### Working Directory

[`working_directory`](https://circleci.com/docs/2.0/configuration-reference/#job_name) を指定しているのは、デフォルトだと `~/project` という名前になってしまうからです。Ansible のロールを Playbook からインポートする[こちら](https://github.com/toVersus/sample-role-for-ci-testing/blob/master/molecule/default/playbook.yml#L7)で不整合が起きないよう明示的に指定しています。

#### Caching Strategy

CircleCI のキャッシュ機能を用いることで、テストの実行時間を短縮しています。

- Python の仮想環境 .venv 配下のライブラリ等をキャッシュとして保存しています。
    - キャッシュはキーと値のペアです。
    - キャッシュは CircleCI のプロジェクト内で共有されます。
    - CircleCi ではキャッシュを手動で削除することはできません。
        - そのため、`v1` という prefix をキーの名前に付与しています。異なるキャッシュを使いたい場合は、明示的に `v2` という風にバージョンを増やして行きます。
    - Pipfile.lock のチェックサムに変更がない場合はキャッシュを保存しません。
    - キャッシュは 30 日間保管されます。

    ```yaml
        steps:
        (...)
          - save_cache:
              key: 'sample-v1-{{ checksum "Pipfile.lock" }}'
              paths:
                - .venv
    ```

- キャッシュしたディレクトリを同一のパスにリストアしています。

    ```yaml
    steps:
    (...)
      - restore_cache:
          key: 'sample-v1-{{ checksum "Pipfile.lock" }}'
    ```

#### Ansible Installation

Ansible 関連のパッケージをインストールしています。`pipenv sync` は Pipfile.lock に記載された依存関係をその通りにインストールします。Pipfile と Pipenv.lock の間に差異があったとしても Pipenv.lock に記載された依存関係に従います。

#### YAML Anchors and Aliases

YAML の機能である Anchor (`&名前`) と Alias (`*名前`) による参照を活用し、[DRY 原則](https://ja.wikipedia.org/wiki/Don%27t_repeat_yourself)に基づいて、重複した `run` を定義しています。Anchor や Alias の名前は大文字のスネークケースで表しています。

### Other CI Platforms

CircleCI の設定をできるだけ再現していますが、未完成です。

## Package Management

[Pipenv](https://pipenv-ja.readthedocs.io/ja/translate-ja/) の詳細は省きます。Pipenv を使うことで、Ansible に関連するパッケージのバージョンを管理し、チーム内のメンバーの開発環境に差異が生じないようにしています。

### Setup Automatic Updates

Pipenv ファイルに記載されたパッケージのバージョンを手作業で更新するのは Toil となるため、パッケージの更新を Bot に任せます。依存関係のパッケージやライブラリの更新サービスは、[Renovate](https://renovatebot.com/) や [Dependabot](https://dependabot.com/) などがあります。今回は Pipfile に対応している [Dependabot](https://dependabot.com/) を使います。(執筆時には対応中でしたが、Renovate も[対応完了](https://github.com/renovatebot/renovate/releases/tag/13.136.0)したようです。)

ちなみに、どちらのサービスもコア機能を OSS として公開されており、GHE や GitLab で利用するためのセルフホストが可能です。Renovate は[こちら](https://github.com/renovatebot/renovate)、Dependabot は[こちら](https://github.com/dependabot/dependabot-core)です。Dependabot の場合は、サンプルの Ruby コードが[こちら](https://github.com/dependabot/dependabot-script)にあります。

Dependabot をデプロイすることで、レポジトリ内の依存関係に更新があると自動的に Pipfile と Pipfile.lock の両方をアップデートした[こちら](https://github.com/toVersus/sample-role-for-ci-testing/pull/1)の PR を作成してくれます。

## Automate Merge Flow

PR に対するレビュアーの設定や master への安全なマージを自動化するために [bors-ng](https://bors.tech/) を導入しています。bors の歴史とセルフホスト可能な代替 bot の [popuko](https://github.com/voyagegroup/popuko) の詳細は[こちら](https://techlog.voyagegroup.com/entry/2017/02/14/080000)の記事を参照して下さい。popuko を GHE で動作させるにはコードに一部手を加える必要があります。popuko が内部で利用している [go-github](https://github.com/google/go-github) に BaseURL を設定しなければなりません。詳細は[こちら](https://github.com/google/go-github/issues/943)の Issue をご覧ください。

自動マージ bot の重要性 (マージ待ちの PR が溜まった状態の危険性) について書きたかったのですが、文量が多くなったので今回は省きます。

## Local Testing

ローカル環境でテストを実行するには、[Docker](https://www.docker.com/) と Pipenv をインストールする必要があります。

### Installation

#### macOS

```
$ brew cask install docker pipenv
```

アプリケーション から Docker を起動するのを忘れないで下さい。

#### Ubuntu 18.04

Ubuntu 16.04 -> 18.04 に上げており、Python 2.7 系が既にインストールされた状態を想定しています。

- 参考

```
$ sudo apt-get python-pip
$ pip install pipenv
$ sudo apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo apt-key fingerprint 0EBFCD88
$ sudo add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) \
    stable"
$ sudo apt-get update
$ apt-cache madison docker-ce
$ sudo apt-get install docker-ce=118.03.1~ce~3-0~ubuntu
$ sudo gpasswd -a $USER docker
$ newgrp docker
```

### Usage


ローカル環境で Vagrant + VirtualBox でテストを実行する場合は、[Vagrant](https://www.vagrantup.com/) と [VirtualBox](https://www.virtualbox.org/) をインストールし、以下のコマンドを実行して下さい。

```
$ git clone https://github.com/toVersus/sample-role-for-ci-testing.git
$ cd ./sample-role-for-ci-testing
$ pipenv sync
$ pipenv run test-local
```

コンテナ特有のエラーに苦しみたくない場合に有効です。例えば、Ubuntu ホスト上の CentOS コンテナで httpd がインストールできない[問題](https://github.com/moby/moby/issues/6980)やコンテナの IP アドレスが取得できない[問題](https://github.com/ansible/molecule/issues/836)などです。コンテナ特有の問題を Molecule でどう回避したか、需要があれば別記事に書きます。



## Conclusion

[Molecule](https://molecule.readthedocs.io/en/latest/) は CI 環境でコンテナに対して Playbook を適用することで、upstream のコードの安全性を高め、Toil を削減することができます。また、ローカル環境で VM (Vagrant + VirtualBox) に対して Playbook を適用することで、コンテナ特有の問題に依存しないテストを行なえ、心理的安全性を更に高めることができます。インフラコードにも CI/CD を導入していき、常に動作保証されたコードを本番環境へデプロイしていきたいです。 

[role_sample]: https://github.com/toVersus/sample-role-for-ci-testing
[playbook_sample]: https://github.com/toVersus/sample-playbook-for-ci-testing