What's this?

これは次の Ansible Advent Calendar 2020 に参加して書いている記事となります。 他の方の記事については下記のリンクからたどれますので是非あわせてご参照下さい。

前置き

今日の記事は Ansible とも関連する Python の仮想環境管理支援ツール tox についてとなります。 『Ansible と関係あるの?』という疑問もあるかと思いますが、tox がどういったものでなぜ必要なのか順番に説明しますので少々辛抱を。

Tox って何?

Tox は主に Python 開発の中でテストや開発環境を用意するのに使われる Python 製のツールです。

Tox を使うと、異なる Python バージョンやライブラリを組み合わせた環境を素早く作り、その環境内で 開発中の Python コードのテストを実行するなどといったことが簡単にできるようになります。

なぜ Tox が必要?

閉じた python 仮想環境の作成、利用

Python で開発しているコードをテストするためには、そのコードが必要とする他のライブラリなども追加でインストールする必要がある場合があります。 そのようなライブラリがごく小数か開発している OS 環境に既にインストール済みであれば良いのですが、大抵の場合はどうにかして新たに追加インストールしなければならないことが多いです。

この問題を解決するために Python では virtualenv などのツールが用意されています。

virtualenv は指定した特定ディレクトリ下を閉じた環境として利用するようにできます。 virtualenv などを使うことで、その閉じた環境の中に追加で必要なライブラリなどをインストールしたり、その中で開発コードを実行したりできます。

ただ virtualenv などを使う場合もその閉じた環境での何らかの実行の度に次のように一連の準備作業などが必要となります。

  • 指定ディレクトリ下を初期化: 例. virtualenv <閉じた環境を作るディレクトリのパス>
  • 指定ディレクトリ内に移動
  • 指定ディレクトリ下の閉じた環境のライブラリやコマンドを使えるように有効化: 例. source bin/activate
  • ... (何かその環境内での一連のテスト実行などの操作) ...
  • 閉じた環境から出る: 例. deactivate

例えば virtualenv を使って ansible 最新版をインストール、試してみるとしましょう。 おそらく次のような手順となるはずです。

[ssato@localhost ~]$ virtualenv /tmp/py38
Using base prefix '/usr'
New python executable in /tmp/py38/bin/python3
Also creating executable in /tmp/py38/bin/python
Installing setuptools, pip, wheel...
done.
[ssato@localhost py38]$ cd /tmp/py38
[ssato@localhost py38]$ source ./bin/activate
(py38) [ssato@localhost py38]$ pip install ansible
... (snip) ...
Installing collected packages: pyparsing, packaging, MarkupSafe, jinja2, pycparser, cffi, six, cryptography, PyYAML, ansible-base, ansible
Successfully installed MarkupSafe-1.1.1 PyYAML-5.3.1 ansible-2.10.3 ... (snip) ...
(py38) [ssato@localhost py38]$ ansible -m ping localhost
localhost | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}
(py38) [ssato@localhost py38]$ deactivate
[ssato@localhost py38]$ cd -
~
[ssato@localhost ~]$

virtualenv 等で作成して利用する Python 仮想環境が一個だけであればまだ何とかなります。 しかし、もし必要な環境が一つではないとしたらどうでしょう? 例えば Python 2 と 3 両方で動くコードの開発で両方テストするとしたら? virtualenv 等の Python 仮想環境の管理ツールは非常に便利ではあるのですが依然煩雑さは解決されず、残っているわです。

tox による Python 仮想環境の管理

tox を使うと先にふれたような virtualenv 等による一連の作業手順を意識することなく行うことができます。 tox は virtualenv のラッパーとして働き、煩雑な部分を隠蔽し、よりずっと簡単にやりたいこと (Python 仮想環境の中でテストを実行したい、など) を実行してくれます。

例えば、次のようにすれば先の例と同様に閉じた仮想環境内で ansible 最新版をインストール、試してみることができます。

[ssato@localhost 00]$ cat << EOF > tox.ini
> [tox]
> envlist = py36
> skipsdist = true
>
> [testenv]
> deps =
>   ansible
> commands =
>   ansible -m ping localhost
>
> EOF
[ssato@localhost 00]$ tox
py36 create: /tmp/0/00/.tox/py36
py36 installdeps: ansible
py36 installed: ansible==2.10.3, ... (snip) ...
py36 run-test-pre: PYTHONHASHSEED='950942320'
py36 run-test: commands[0] | ansible -m ping localhost
localhost | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}
_______________________________ summary _____________________________
  py36: commands succeeded
  congratulations :)
[ssato@localhost 00]$

またこの例では実行時の最新の Ansible (2.10.3) をインストール、実行していますが、 Ansible Playbook を実行している環境ではより古いバージョンを利用しているということもあるかもしれません。 そこでより古い Ansible 2.9.x でも同じように実行してみることとしましょう。

次のように tox の設定ファイルをすこしだけ変更しするだけで Ansible 2.9 を使うことができます [1]

[ssato@localhost 00]$ sed -i.save 's/ansible$/& == 2.9/' tox.ini
[ssato@localhost 00]$ diff -u tox.ini{.save,}
--- tox.ini.save        2020-11-30 02:09:42.614733037 +0900
+++ tox.ini     2020-11-30 02:20:28.380544560 +0900
@@ -4,7 +4,7 @@

 [testenv]
 deps =
-  ansible
+  ansible == 2.9
 commands =
   ansible -m ping localhost

[ssato@localhost 00]$ rm -rf .tox/
[ssato@localhost 00]$ tox
py36 create: /tmp/0/00/.tox/py36
py36 installdeps: ansible == 2.9
py36 installed: ansible==2.9.0, ... (snip) ...
py36 run-test-pre: PYTHONHASHSEED='2272734381'
py36 run-test: commands[0] | ansible -m ping localhost
localhost | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}
_______________________________ summary _____________________________
  py36: commands succeeded
  congratulations :)
[ssato@localhost 00]$
[1]各環境毎に .tox/ 下にインストールパスが用意されています。先のデータが残っているのでまっさらな状態からやり直すために、一旦削除して初期化しています。

先の例と違って今度は ansible の少し古いバージョン 2.9.0 がインストール、使われているのがわかります。

なぜ Ansible Playbook 開発で tox が必要?

Ansible はもう大分成熟してはいますがまだ開発は活発に続いています。 Ansible Playbook のより良い書き方や構文などが Ansible のバージョンに依存する可能性は程度の差こそあれ大いにありえますし、実際過去にありました。 また Ansible Playbook の実行には外部のライブラリなどを必要とする場合も多く、その場合はそれらのバージョンに依存して挙動が変るかもしれません。

依存する外部ライブラリも OS パッケージが用意されていれば簡単ですがそうではない場合、追加インストールが必要となります [2] 。 したがって追加インストールは大抵の場合は pip install で行うことになりますが OS 組込のパッケージ管理システムとの使い分けも煩雑ですし、 pip はそれらと比較するとシンプルなパッケージ管理システムですので将来も一貫して安定的に管理することは難しいと思います。

Ansible や依存する外部ライブラリのバージョンが上るとすぐに動いていた Playbook が動かなくなるということは実際にはあまりありません。 しかし Ansible Playbook も IaC 化された『コード』である以上、他のプログラミング言語での開発と同様に Ansible や他の依存ライブラリの様々なバージョンの組み合わせで動作するように恒常的にテストしておく、CI しておくことが必須になってきます。

以上をふまえると Ansible Playbook 開発においても次のようなことを簡単に実現するために tox を使う必要があるわけです。

  • 閉じた python 仮想環境内でテスト: 開発環境の OS にライブラリ等を追加インストールしなくてよい、など
  • ansible や python の複数のバージョンの組み合わせに対してテスト: 個々の環境を細かく virtualenv で管理しなくてよい、など

実際に、先の例の tox.ini を拡張して ansible 最新版と ansible 2.9.x の両方で実行してみましょう。

ssato@localhost% rm -rf .tox
ssato@localhost% cat requirements.txt
ansible
ssato@localhost% diff -u tox.ini{.save,}
--- tox.ini.save        2020-11-30 02:09:42.614733037 +0900
+++ tox.ini     2020-11-30 13:17:16.294919336 +0900
@@ -1,10 +1,14 @@
 [tox]
-envlist = py36
+envlist = py36{,-ansible29}
 skipsdist = true

 [testenv]
 deps =
-  ansible
+    -r {toxinidir}/requirements.txt
 commands =
-  ansible -m ping localhost
+    ansible --version
+    ansible -m ping localhost

+[testenv:py36-ansible29]
+deps =
+  ansible == 2.9
ssato@localhost% cat tox.ini
[tox]
envlist = py36{,-ansible29}
skipsdist = true

[testenv]
deps =
    -r {toxinidir}/requirements.txt
commands =
    ansible --version
    ansible -m ping localhost

[testenv:py36-ansible29]
deps =
  ansible == 2.9
ssato@localhost% tox
py36 create: /tmp/0/00/.tox/py36
py36 installdeps: -r/tmp/0/00/requirements.txt
py36 installed: ansible==2.10.3,ansible-base==2.10.3, ... (snip) ...
py36 run-test-pre: PYTHONHASHSEED='2963978821'
py36 run-test: commands[0] | ansible --version
ansible 2.10.3
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/ssato/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /tmp/0/00/.tox/py36/lib/python3.6/site-packages/ansible
  executable location = /tmp/0/00/.tox/py36/bin/ansible
  python version = 3.6.12 (default, Aug 19 2020, 00:00:00) [GCC 10.2.1 20200723 (Red Hat 10.2.1-1)]
py36 run-test: commands[1] | ansible -m ping localhost
localhost | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}
py36-ansible29 create: /tmp/0/00/.tox/py36-ansible29
py36-ansible29 installdeps: ansible == 2.9
py36-ansible29 installed: ansible==2.9.0, ... (snip) ...
py36-ansible29 run-test-pre: PYTHONHASHSEED='2963978821'
py36-ansible29 run-test: commands[0] | ansible --version
ansible 2.9.0
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/ssato/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /tmp/0/00/.tox/py36-ansible29/lib/python3.6/site-packages/ansible
  executable location = /tmp/0/00/.tox/py36-ansible29/bin/ansible
  python version = 3.6.12 (default, Aug 19 2020, 00:00:00) [GCC 10.2.1 20200723 (Red Hat 10.2.1-1)]
py36-ansible29 run-test: commands[1] | ansible -m ping localhost
localhost | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}
______________________________________________ summary ________________________________________
  py36: commands succeeded
  py36-ansible29: commands succeeded
  congratulations :)
ssato@localhost%
[2]完全に余談ですがシステムグローバルにインストールが必要な場合、パッケージ管理ツールをまぜると危険なので、筆者は必ず自分の環境 (Fedora) にあわせて OS native のパッケージ (RPM) を自身で作成、管理していて pip install 等で直接インストールすることはほぼありません。RPM 作成については来年別にどこかで記事化する予定です。

Tox + CI サービス

最近では GitLab 組込の CI サービス (GitLab CI) や GitHub と組み合わせて使える Travis-CI や GitHub Actions などの CI サービスを使うことも増えてきました。

これらの CI サービスではそれぞれ何らかの独自の書式の設定ファイルを用意することが多いようです。 一つ一つ書式を理解してテスト手順をサービス毎に設定を用意するのは非常に面倒な作業となります。 またこの面倒さ故に CI サービスにロックインされてしまう可能性も高まります。

そこで tox を使ってテスト手順を隠蔽し CI サービス側の設定は tox 呼出しだけにしてしまいましょう。 tox を使って設定をシンプルにできるだけではなく、さらに幸運なことに、いくつかのサービスでは tox のそのサービス対応の plugin が利用できる場合もあり、pluing を使うことでますます便利になったりします。

ここでは Ansilbe Role を CI サービスを使って CI (lint, unit + integration tests) する例をあげておきます。

いずれの場合も CI で実行する処理内容 (lint, unit + integration tests など) については tox の設定で行っている [3] ため、 CI サービスによって処理内容は変らず、全く同じ内容と手順で実行されます。

[3]正確には lint, unit tests など一連の処理の大部分は molecule を使って行っているため tox.ini では molecule を呼出しているだけとなります。

Tox を使ってみよう

Tox のインストール

Tox は Python 開発では非常に有名でほぼ必須に近いツールなので、各種 OS や Linux ディストリビューションでは最初からパッケージが用意されているか、オプションのリポジトリなどを利用、また OS 標準形式のパッケージがない場合も pip install tox とすれば簡単に追加インストールできるはずです。

例えば RHEL 8 または CentOS 8 をご利用の場合は EPEL リポジトリを有効化 [4] した上で、Fedora をご利用の場合はそのまま、いずれの場合も次のコマンドを実行すればインストールできるでしょう。

$ sudo dnf install -y python3-tox
[4]RHEL 8 等で EPEL リポジトリを利用する方法については https://fedoraproject.org/wiki/EPEL#Quickstart などをご参照下さい。

Tox をさわってみる

tox の実行には設定ファイルが必ず必要となります。次のような内容の tox.ini というファイルを tox を実行する場所に用意します。

[tox]
envlist = py36
skipsdist = true

[testenv]
deps =
  ansible
commands =
  ansible --version

簡単に各々の設定項目についてふれると、

  • tox セクション ([tox] から次の [...] の手前まで):
    • envlist: 何かを実行する (Python) 環境のリストをカンマ (,) でつなげて列挙します。pyNM (py36 = python 3.6 の環境) といった指定をします。どういった値を指定できるかは https://tox.readthedocs.io/en/latest/config.html#tox-environments などもあわせてご参照下さい。
    • skipsdist: 元々 tox は python の setuptools によるパッケージの開発に使うものなので setup.py などそれ用のファイルがないと実行できませんが、この指定はそれをなしでも tox を使えるようにするおまじないです。
  • testenv セクション ([testenv] から次の [...] またファイル末尾の手前まで):
    • deps: 依存関係から追加インストールが必要となる Python パッケージのリストを列挙します。pip install 以降に指定できる文字列を記述できます。必要なものすべてを列挙しても良いのですが、おすすめは先程の例にあげた requirements.txt という別ファイルを用意してそちらに列挙、こちらの tox の設定では requirements.txt を参照する( -r {toxinidir}/requirements.txt と指定) ようにする方法です。
    • commands: 各環境で実行するコマンドを列挙します。

試しに先の内容の tox.ini を用意して tox を実行すると次のようになります。

ssato@localhost% cat tox.ini
[tox]
envlist = py36
skipsdist = true

[testenv]
deps =
    ansible
commands =
    ansible --version
ssato@localhost% tox
py36 create: /tmp/0/01/.tox/py36
py36 installdeps: ansible
py36 installed: ansible==2.10.3,ansible-base==2.10.3,cffi==1.14.4, ... (snip) ...
py36 run-test-pre: PYTHONHASHSEED='2209576110'
py36 run-test: commands[0] | ansible --version
ansible 2.10.3
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/ssato/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /tmp/0/01/.tox/py36/lib/python3.6/site-packages/ansible
  executable location = /tmp/0/01/.tox/py36/bin/ansible
  python version = 3.6.12 (default, Aug 19 2020, 00:00:00) [GCC 10.2.1 20200723 (Red Hat 10.2.1-1)]
______________________________________________ summary ___________________________________
  py36: commands succeeded
  congratulations :)
ssato@localhost%

ここでは簡単な説明と例、さらに tox.ini のいくつかのより実践的な tox.ini の設定例を示すにとどめておきます。 さらに詳しく使い方を知りたい方は冒頭でふれた tox の公式ホームからたどれる文書などもあわせてご参照下さい。

次回予告

次回は Ansible Lint と yamllint をどう使っていくのか実例を示しながら簡単に紹介する予定です。