2023年のAnsibleとわたし

みなさんメリークリスマス。Red Hatのさいとうです。例年になく静かなクリスマスを過しながら、このAnsible Advent Calendar最終日12月25日の記事を書いています。

2022年もAnsible界隈では大きな動きがいくつかありました。個人的にはコンテナ実行環境とPrivate AutomationHubにやられた注力した1年でした。

毎年、Advent Calendarの最終日には、来年に向けて僕が「来そう」だと予想している技術をご紹介しているのですが、来年、2023年に来そうなのは「Ansible SDK」です。

Ansible SDK

Ansible SDKは、2022年に本格的な活動が始まった最新のプロジェクトの中の1つです。ソースコードの最初のコミットは2022年7月26日ですね。

commit 318bde98585223f00842a8a1605f0f1d6089dd21
Author: Abhijeet Kasurde <akasurde@redhat.com>
Date:   Tue Jul 26 19:14:22 2022 +0530

    Initial commit

CI/CDなどのシステムからAnsibleを利用しようとすると、これまではansible-playbookコマンドを実行し、その実行結果をハンドリングするというのが一般的な利用方法でした。Ansibleはシンプルなコマンドラインツールですから、すでに稼動しているJenkinsなどのシステムに組み込むには、このansible-playbookコマンドを実行する仕組み開発し、プラグインとして組み込むというのが一般的な利用方法ではないでしょうか。

Ansible SDKは、このようなコマンド実行という仕組みではなく、よりプログラマブルでキメ細かい制御を実現したいというパワーユーザ向けのSDKです。Ansibleを外部システムから便利に利用するため機能、たとえば実行環境(ネイティブ環境、コンテナ環境)の切り替えといった機能をまとめてSDKとして提供しています(していく予定だと思います)。

これまでのAnsible Python API

Ansibleは、ansible-playbookやansible-galaxyなどのシンプルなコマンドラインツール群で構成されていますが、それらのコマンドラインツールが内部で利用しているPython APIを、外部プログラムから利用できるようPython APIも提供しています。誰も使ってないけれど。

例えば、pingモジュールを2回(うち1回はdata='crash'で失敗させる)をlocalhostとserver00(こっちは未到達)に実行するようなPlaybookを、Python APIを利用して書くと以下のようになります。この例では、既存のPlaybookのロードなどの処理は書いておらず、必要最低限の処理としてcallback程度しか書いていませんが、これは...わざわざPython APIを使用するよりも、普通にansible-playbookコマンドで叩いたほうが確かに良さそうです。

#!/usr/bin/env python

import json
import shutil

import ansible.constants as C
from ansible.executor.task_queue_manager import TaskQueueManager
from ansible.module_utils.common.collections import ImmutableDict
from ansible.inventory.manager import InventoryManager
from ansible.parsing.dataloader import DataLoader
from ansible.playbook.play import Play
from ansible.plugins.callback import CallbackBase
from ansible.vars.manager import VariableManager
from ansible import context


class ResultsCollectorJSONCallback(CallbackBase):
    def __init__(self, *args, **kwargs):
        super(ResultsCollectorJSONCallback, self).__init__(*args, **kwargs)
        self.host_ok = {}
        self.host_unreachable = {}
        self.host_failed = {}

    def v2_runner_on_unreachable(self, result):
        host = result._host
        self.host_unreachable[host.get_name()] = result

    def v2_runner_on_ok(self, result, *args, **kwargs):
        host = result._host
        self.host_ok[host.get_name()] = result
        print(json.dumps({host.name: result._result}, indent=4))

    def v2_runner_on_failed(self, result, *args, **kwargs):
        host = result._host
        self.host_failed[host.get_name()] = result


def main():
    host_list = ['localhost', 'server00']
    context.CLIARGS = ImmutableDict(connection='smart')
    sources = ','.join(host_list)
    if len(host_list) == 1:
        sources += ','

    loader = DataLoader()
    passwords = dict()
    results_callback = ResultsCollectorJSONCallback()

    inventory = InventoryManager(loader=loader, sources=sources)

    variable_manager = VariableManager(loader=loader, inventory=inventory)

    tqm = TaskQueueManager(
        inventory=inventory,
        variable_manager=variable_manager,
        loader=loader,
        passwords=passwords,
        stdout_callback=results_callback,
    )

    play_source = dict(
        name="Ansible Play",
        hosts=host_list,
        gather_facts='no',
        tasks=[
            dict(action=dict(module='ping')),
            dict(action=dict(module='ping', args=dict(data='crash'))),
        ]
    )

    play = Play().load(play_source, variable_manager=variable_manager, loader=loader)

    try:
        result = tqm.run(play)
    finally:
        tqm.cleanup()
        if loader:
            loader.cleanup_all_tmp_files()

    shutil.rmtree(C.DEFAULT_LOCAL_TMP, True)

    print("UP ***********")
    for host, result in results_callback.host_ok.items():
        print('{0} >>> {1}'.format(host, result._result['ping']))

    print("FAILED *******")
    for host, result in results_callback.host_failed.items():
        print('{0} >>> {1}'.format(host, result._result['msg']))
        print('{0} >>> STDOUT:{1}'.format(host, result._result['module_stdout']))
        print('{0} >>> STDERR:{1}'.format(host, result._result['module_stderr']))

    print("DOWN *********")
    for host, result in results_callback.host_unreachable.items():
        print('{0} >>> {1}'.format(host, result._result['msg']))


if __name__ == '__main__':
    main()

実行してみると、成功、失敗、未到達といった状況もハンドリングでき、実用としては十分ではありますが、やはりちょっと手間がかかりますね。

$ python example_api.py
{
    "localhost": {
        "ping": "pong",
        "invocation": {
            "module_args": {
                "data": "pong"
            }
        },
        "ansible_facts": {
            "discovered_interpreter_python": "/usr/bin/python3"
        },
        "warnings": [
            "Platform darwin on host localhost is using the discovered Python interpreter at /usr/bin/python3, but future installation of another Python interpreter could change the meaning of that path. See https://docs.ansible.com/ansible-core/2.14/reference_appendices/interpreter_discovery.html for more information."
        ],
        "_ansible_no_log": null,
        "changed": false
    }
}
UP ***********
localhost >>> pong
FAILED *******
localhost >>> MODULE FAILURE
See stdout/stderr for the exact error
localhost >>> STDOUT:Traceback (most recent call last):
  File "/Users/hsaito/.ansible/tmp/ansible-tmp-1671962136.749241-66036-54422040219731/AnsiballZ_ping.py", line 107, in <module>
    _ansiballz_main()
  File "/Users/hsaito/.ansible/tmp/ansible-tmp-1671962136.749241-66036-54422040219731/AnsiballZ_ping.py", line 99, in _ansiballz_main
    invoke_module(zipped_mod, temp_path, ANSIBALLZ_PARAMS)
  File "/Users/hsaito/.ansible/tmp/ansible-tmp-1671962136.749241-66036-54422040219731/AnsiballZ_ping.py", line 47, in invoke_module
    runpy.run_module(mod_name='ansible.modules.ping', init_globals=dict(_module_fqn='ansible.modules.ping', _modlib_path=modlib_path),
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/runpy.py", line 210, in run_module
    return _run_module_code(code, init_globals, run_name, mod_spec)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/runpy.py", line 97, in _run_module_code
    _run_code(code, mod_globals, init_globals,
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/var/folders/kb/tk3jg2mn4m1535zd5zr4lnkc0000gn/T/ansible_ping_payload_4qlm0wra/ansible_ping_payload.zip/ansible/modules/ping.py", line 89, in <module>
  File "/var/folders/kb/tk3jg2mn4m1535zd5zr4lnkc0000gn/T/ansible_ping_payload_4qlm0wra/ansible_ping_payload.zip/ansible/modules/ping.py", line 79, in main
Exception: boom

localhost >>> STDERR:Shared connection to localhost closed.

DOWN *********
server00 >>> Failed to connect to the host via ssh: ssh: connect to host server00 port 22: Network is unreachable

Ansible SDKの良いところ

従来のPython APIは、Ansible Coreが提供するAPIを利用しています。つまり、Ansible Runnerが提供するような、Playbook実行環境の切り替え(ネイティブ実行環境とコンテナ実行環境の切り替え)や、Receptorを経由したPlaybook実行環境の延伸といった、エンタープライズ環境で求められるような魅力的な機能を利用することができませんでした。

Ansible SDKでは、自動化フレームワークの中でAnsible Runnerを利用しています。また、実行環境の延伸手段としてReceptorを利用することができます。これは、Red Hatが提供しているAnsible automation controllerと同じ仕組みであるため、「コンテナ環境でPlaybookを実行したい」、「遠隔地にある実行環境を利用してPlaybookを実行したい」といったリクエスにも対応することができます(仮に今はできなくても近い将来可能となるはずです)。

Ansible SDKによるPlaybookの実行

Ansible SDKを利用するには、以下のコンポーネントが必要です。あらかじめインストールしておきましょう。SDKは、ソースコードリポジトリをcloneして、最新版を利用します。

$ python -m venv venv/ansible-sdk
$ source venv/ansible-sdk/bin/activate
(ansible-sdk)$ pip install -U pip
(ansible-sdk)$ pip install ansible-core
(ansible-sdk)$ pip install ansible-runner
(ansible-sdk)$ pip install receptorctl
(ansible-sdk)$ git clone git://github.com/ansible/ansible-sdk
(ansible-sdk)$ cd ansible-sdk
(ansible-sdk)$ pip install -e .

Ansible SDKでは、Ansible Runnerを利用してPlaybookを実行するためのフレームワークを提供します。では、pingモジュールを実行してみましょう。以下のコードをexample00.pyとして保存します。

#!/usr/bin/env python

import asyncio
from tempfile import mkdtemp

from ansible_sdk import AnsibleJobDef
from ansible_sdk.executors import AnsibleSubprocessJobExecutor, AnsibleSubprocessJobOptions


async def main():
    executor = AnsibleSubprocessJobExecutor()
    executor_options = AnsibleSubprocessJobOptions()

    tmp_data_dir = mkdtemp()

    jobdef = AnsibleJobDef(
            data_dir=tmp_data_dir,
            playbook=None,
            module='ping',
            host_pattern='localhost')

    job_status = await executor.submit_job(jobdef, executor_options)

    async for line in job_status.stdout_lines:
        print(line)

    await job_status


if __name__ == '__main__':
    asyncio.run(main())

実行してみると、たしかにad-hocコマンド(ansible localhost -m ping)と同様の結果が得られました。前述の通りAnsible SDKは、自動化のフレームワークとしてAnsible Runnerを使用しているので、Runnerの流儀にしたがって/tmp/に、data_dir(今回は空)とartifactsが保存されるワーキングディレクトリが作成されているはずです。

(ansible-sdk)$ python example00.py
[WARNING]: No inventory was parsed, only implicit localhost is available
localhost | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
(ansible-sdk)$ ansible localhost -m ping
[WARNING]: No inventory was parsed, only implicit localhost is available
localhost | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
(ansible-sdk)$ find /tmp/tmp*
/tmp/tmp64v1c63m
/tmp/tmpl3r13t33
/tmp/tmpl3r13t33/artifacts
/tmp/tmpl3r13t33/artifacts/110fb364-db2b-480e-8225-ff32838abd86
/tmp/tmpl3r13t33/artifacts/110fb364-db2b-480e-8225-ff32838abd86/rc
/tmp/tmpl3r13t33/artifacts/110fb364-db2b-480e-8225-ff32838abd86/status
/tmp/tmpl3r13t33/artifacts/110fb364-db2b-480e-8225-ff32838abd86/fact_cache
/tmp/tmpl3r13t33/artifacts/110fb364-db2b-480e-8225-ff32838abd86/stderr
/tmp/tmpl3r13t33/artifacts/110fb364-db2b-480e-8225-ff32838abd86/stdout
/tmp/tmpl3r13t33/artifacts/110fb364-db2b-480e-8225-ff32838abd86/command
/tmp/tmpl3r13t33/artifacts/110fb364-db2b-480e-8225-ff32838abd86/job_events

Ansible SDKからコンテナ実行環境を利用する

Ansible Runnerを利用しているということは、前述のad-hocコマンドやPlaybookの実行時にDocker/podmanを利用したコンテナベースの実行環境が利用できるということです。そこで、アップストリームにあるサンプルコードを参考に、コンテナ実行環境でad-hocコマンドを実行するコードを書いてexample01.pyとして保存します。

#!/usr/bin/env python

import asyncio
from tempfile import mkdtemp

from ansible_sdk import AnsibleJobDef
from ansible_sdk.executors import AnsiblePodmanJobExecutor, AnsiblePodmanJobOptions


async def main():
    executor = AnsiblePodmanJobExecutor()
    executor_options = AnsiblePodmanJobOptions(container_image_ref='quay.io/ansible/ansible-runner:devel')

    tmp_data_dir = mkdtemp()

    jobdef = AnsibleJobDef(
            data_dir=tmp_data_dir,
            playbook=None,
            module='pause',
            module_args='seconds=180',
            host_pattern='localhost')

    job_status = await executor.submit_job(jobdef, executor_options)

    async for line in job_status.stdout_lines:
        print(line)

    await job_status


if __name__ == '__main__':
    asyncio.run(main())

example01.pyでは、ansible-runnerコンテナ(quay.io/ansible/ansible-runner:devel)を利用して、ad-hocコマンドを実行しています。awx-eeイメージが存在しないことを確認してから、example01.pyを実行してみましょう。

(ansible-sdk)$ podman images
REPOSITORY  TAG         IMAGE ID    CREATED     SIZE
(ansible-sdk)$ python example01.py
[WARNING]: You are running the development version of Ansible. You should only
run Ansible from "devel" if you are modifying the Ansible engine, or trying out
features under development. This is a rapidly changing source of code and can
become unstable at any point.
[WARNING]: Unable to parse /runner/inventory/hosts as an inventory source
[WARNING]: No inventory was parsed, only implicit localhost is available
Pausing for 180 seconds
(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort)
localhost | SUCCESS => {
    "changed": false,
    "delta": 180,
    "echo": true,
    "rc": 0,
    "start": "2022-12-25 09:04:33.396725",
    "stderr": "",
    "stdout": "Paused for 180.0 seconds",
    "stop": "2022-12-25 09:07:33.397015",
    "user_input": ""
}

pauseモジュールの実行中に、別ターミナルをオープンしてコンテナの実行状況を確認してると、コンテナ実行環境を利用してad-hocコマンドとしてpauseモジュールが実行されているのがわかります。

$ podman ps
CONTAINER ID  IMAGE                                 COMMAND               CREATED             STATUS                 PORTS       NAMES
076655c80b00  quay.io/ansible/ansible-runner:devel  ansible -i /runne...  About a minute ago  Up About a minute ago              ansible_runner_3cff6702-dab0-47ce-9879-748d950e1442
$ podman inspect 076655c80b00
[
     {
          "Id": "076655c80b00fdb9348be9bfb61d6193bfd8504e5e006bd0f935ad7dddc11944",
          "Created": "2022-12-25T09:04:31.419783404Z",
          "Path": "entrypoint",
          "Args": [
               "ansible",
               "-i",
               "/runner/inventory/hosts",
               "-m",
               "pause",
               "-a",
               "seconds=180",
               "localhost"
          ],
...省略...
               "Cmd": [
                    "ansible",
                    "-i",
                    "/runner/inventory/hosts",
                    "-m",
                    "pause",
                    "-a",
                    "seconds=180",
                    "localhost"
               ],
               "Image": "quay.io/ansible/ansible-runner:devel",
 ...省略...
               "CreateCommand": [
                    "/usr/bin/podman",
                    "run",
                    "--rm",
                    "--tty",
                    "--interactive",
                    "--workdir",
                    "/runner/project",
                    "-v",
                    "/tmp/tmpu4bv0901/:/runner/:Z",
                    "--env-file",
                    "/tmp/tmpu4bv0901/artifacts/3cff6702-dab0-47ce-9879-748d950e1442/env.list",
                    "--quiet",
                    "--name",
                    "ansible_runner_3cff6702-dab0-47ce-9879-748d950e1442",
                    "quay.io/ansible/ansible-runner:devel",
                    "ansible",
                    "-i",
                    "/runner/inventory/hosts",
                    "-m",
                    "pause",
                    "-a",
                    "seconds=180",
                    "localhost"
               ],
...以下略...

2023年のAnsibleとわたし

2023年、オペレーションの自動化を提供するエコシステムの一部としてAnsibleを利用しており、かつRed Hat Ansible automation controllerやAWXのような本格的な自動化フレームワークを必要としないCI/CDなどのシステムは、徐々にこのSDKを利用したものに移行しはじめるのではないかと予想しています。

2022年のFestやContributor Meetupで紹介されて、2023年に大きく飛躍しそうなプロジェクトは他にもありますが、このような新しいプロジェクトには、我々のようなコントリビューターが開発に参加するチャンスが多くあります。Ansible SDKをみなさんのエコシステムを支える自動化基盤技術の1つとして検討してみてください。

それでは、みなさんメリークリスマス & ハッピーオートメーション! :)

* 各記事は著者の見解によるものでありその所属組織を代表する公式なものではありません。その内容については非公式見解を含みます。