Ansibleのcopyモジュールはどうやって冪等性を担保しているのか

この記事は、Qiitaの Ansible Advent Calendar 2019 の 9日目の記事です。[1]

前書き

今年の PyCon JP「Ansibleを通じて「べき等性」を理解してみよう」 というトークをしました。

このトークではAnsibeの冪等性担保のアプローチを yum モジュールで理解したのですが、

「何であろうと ``yum`` コマンドを実行して、出力結果とリターンコードで判断する」

というものでした。

このときもらった質問+やり取りで、

  • Q: サーバー内でのファイルコピーとかどうしてるんでしょう?(意訳)

  • A: ローカルでファイルを保持して、 copy モジュールとか使います(意訳)

というのがありました。

そこで今回はAdventCalendarに乗じて、 「 yum ではああだったが、 copy ではどうだろう? 」 というのを検証しようと思います。

copyとyumを見比べる

ソースを読んで見る前に、ちょっと挙動をおさらいしてみます。

項目

yum

copy

何をするか
yumコマンドを操作する
ローカルからファイルを転送する
Changed条件
パッケージの有無に変化があった
or
パッケージを更新した
ファイルの有無に変化があった
or
ファイルを更新した
やってそうなこと
* インストール状態の把握
* 必要に応じてyumコマンドの実行
* 何かしらでファイルの新旧比較
* 必要に応じてファイルの転送

yum については、文字通りPyCon発表以前に想像した「やっそうなこと」で、 実際の動作は前述のスライドのとおりです。

気になるのは copy モジュールの場合このあたりの動作はどう変わってくるか。

yum と同じく「とりあえずファイルを転送」というのは考えづらいです。 というのも、

  • stat コマンドを使っても、ファイルサイズまでしかわからない

  • cp コマンドの挙動的に、

    • inodeは変化しない

    • ファイルの内容にかかわらずChangedは変化する

    • ファイルの内容変化はわからない

  • copy モジュールには backup パラメーターが存在する

となるため、「なにかしらの判定を挟まないと、OK/Changedが判定できない」と考えられそうです。

$ date > test.txt && cp test.txt var && stat var/test.txt
  File: var/test.txt
  Size: 32              Blocks: 8          IO Block: 4096   regular file
Device: 10305h/66309d   Inode: 446998103   Links: 1
Access: (0644/-rw-r--r--)  Uid: ( 1000/ attakei)   Gid: (  985/   users)
Access: 2019-12-08 23:39:01.628030154 +0900
Modify: 2019-12-08 23:42:18.812931334 +0900
Change: 2019-12-08 23:42:18.812931334 +0900
 Birth: 2019-12-08 23:39:01.628030154 +0900
$ date > test.txt && cp test.txt var && stat var/test.txt
  File: var/test.txt
  Size: 32              Blocks: 8          IO Block: 4096   regular file
Device: 10305h/66309d   Inode: 446998103   Links: 1
Access: (0644/-rw-r--r--)  Uid: ( 1000/ attakei)   Gid: (  985/   users)
Access: 2019-12-08 23:39:01.628030154 +0900
Modify: 2019-12-08 23:42:25.529653015 +0900
Change: 2019-12-08 23:42:25.529653015 +0900
 Birth: 2019-12-08 23:39:01.628030154 +0900
$ date >> test.txt && cp test.txt var && stat var/test.txt
  File: var/test.txt
  Size: 64              Blocks: 8          IO Block: 4096   regular file
Device: 10305h/66309d   Inode: 446998103   Links: 1
Access: (0644/-rw-r--r--)  Uid: ( 1000/ attakei)   Gid: (  985/   users)
Access: 2019-12-08 23:39:01.628030154 +0900
Modify: 2019-12-08 23:42:31.103031998 +0900
Change: 2019-12-08 23:42:31.103031998 +0900
 Birth: 2019-12-08 23:39:01.628030154 +0900
$ rm test.txt
$ date > test.txt && cp test.txt var && stat var/test.txt
  File: var/test.txt
  Size: 32              Blocks: 8          IO Block: 4096   regular file
Device: 10305h/66309d   Inode: 446998103   Links: 1
Access: (0644/-rw-r--r--)  Uid: ( 1000/ attakei)   Gid: (  985/   users)
Access: 2019-12-08 23:42:34.633060910 +0900
Modify: 2019-12-08 23:43:51.577024235 +0900
Change: 2019-12-08 23:43:51.577024235 +0900
 Birth: 2019-12-08 23:39:01.628030154 +0900

実際に調べてみる

というわけで、ここからはコードリーディングタイムです。 なお、今回はリリースしたばかりの v2.9.2 で挙動を確認してみます。 [2] [3]

まずはmain()から

Ansibleモジュールの比較的基本となる原則として、 「 main() を定義して、 __name__ == '__main__' で呼ぶ、実行向けの構成を取る」 というのがあります。今回もそれに当てはまるので、まずは main() 関数を眺めてみます。

チェックサムを調べている

550    checksum_dest = None
551
552    if os.path.isfile(src):
553        checksum_src = module.sha1(src)
554    else:
555        checksum_src = None
556
557    # Backwards compat only.  This will be None in FIPS mode
558    try:
559        if os.path.isfile(src):
560            md5sum_src = module.md5(src)
561        else:
562            md5sum_src = None
563    except ValueError:
564        md5sum_src = None

引数の下処理の少し後 に、 転送予定ファイルのチェックサムを取得して checksum_src に保存しいるところがあります。 すごくそれっぽいですね。

604    if os.path.exists(b_dest):
605        if os.path.islink(b_dest) and follow:
606            b_dest = os.path.realpath(b_dest)
607            dest = to_native(b_dest, errors='surrogate_or_strict')
608        if not force:
609            module.exit_json(msg="file already exists", src=src, dest=dest, changed=False)
610        if os.access(b_dest, os.R_OK) and os.path.isfile(b_dest):
611            checksum_dest = module.sha1(dest)

更に読み進めると 、 転送先予定のパスにすでにファイルがある場合に、そのファイルのチェックサムを checksum_dest に保存しています。 だんだん答えが見えてきました。

チェックサムを比較している

if checksum_src != checksum_dest or os.path.islink(b_dest):
    if not module.check_mode:
        # 状況に応じた様々な処理
        try:
            if backup:
                pass
        except (IOError, OSError):
            module.fail_json(msg="failed to copy: %s to %s" % (src, dest), traceback=traceback.format_exc())
    changed = True
else:
    changed = False

終盤に入るとchecksum_destchecksum_src の状況に応じて、条件分岐するようになっています。 上記の抜粋ではかなり省略してますが、重要なのは else の方で、 この記述を持って 「両者のチェックサムが一致した場合は、変更を加えずに changed = False とする」 実装となっているのが見て取れます。

フォルダごとの転送でも頑張る

チェックサムの確認は、いずれも os.path.isfileTrue のときのみ行われます。 src, dest どちらもがフォルダの場合は結果が None になるので、上記の転送処理に入りません。[4]

ただし、 remote_src=yes の場合に限り、後続処理が定義されています。[5] この場合は、 copy_diff_files() 関数を用いて内部で、ファイルのdiffを取って判定しています。

399def copy_diff_files(src, dest, module):
400    changed = False
401    owner = module.params['owner']
402    group = module.params['group']
403    local_follow = module.params['local_follow']
404    diff_files = filecmp.dircmp(src, dest).diff_files
405    if len(diff_files):
406        changed = True
407    if not module.check_mode:
408        for item in diff_files:
409            src_item_path = os.path.join(src, item)

関数内にて filecmp.dircmp(src, dest).diff_files が実行されて、差分が存在するファイルをリスト化を行います。 そして1ファイルでも存在すれば changed = True となるように実装されてます。 もちろん差分がなければ changed = False で何もしません。

結論

copy モジュールは、

  • ローカルtoリモートでのファイルコピー時には、sha1 でのチェックサムを比較して上書き要否を判定する

  • リモート上でのファイルコピー時には、 filecmp モジュールを利用して上書き要否を判定する

という挙動を取って冪等性を担保しているようでした。 まぁ yum モジュールよりは混みいった実装をしないと難しいようです。

余談

ちなみに、複雑度計測ツールの lizard で2個のモジュールを比較してると、

% lizard
================================================
  NLOC    CCN   token  PARAM  length  location
------------------------------------------------
       2      1     12      2       2 __init__@285-286@./ansible-copy-module.py
       7      3     87      1       8 clear_facls@293-300@./ansible-copy-module.py
      13      4    100      1      16 split_pre_existing_dir@303-318@./ansible-copy-module.py
       7      2     66      5      11 adjust_recursive_directory_permissions@321-331@./ansible-copy-module.py
      61     37    544      2      63 chown_recursive@334-396@./ansible-copy-module.py
      25      8    189      3      26 copy_diff_files@399-424@./ansible-copy-module.py
      40     24    403      3      47 copy_left_only@427-473@./ansible-copy-module.py
      14      5    133      3      16 copy_common_dirs@476-491@./ansible-copy-module.py
     225     95   2067      0     293 main@494-786@./ansible-copy-module.py
       4      1     28      2      18 __init__@376-393@./ansible-yum-module.py
      18      7    122      2      19 _enablerepos_with_error_checking@395-413@./ansible-yum-module.py
      28      8    179      1      41 is_lockfile_pid_valid@415-455@./ansible-yum-module.py
      24      8    179      1      28 yum_base@457-484@./ansible-yum-module.py
       4      2     43      2       5 po_to_envra@486-490@./ansible-yum-module.py
      17      9    144      2      23 is_group_env_installed@492-514@./ansible-yum-module.py
      45     23    426      5      55 is_installed@516-570@./ansible-yum-module.py
      28      9    238      4      36 is_available@572-607@./ansible-yum-module.py
      31     11    276      4      41 is_update@609-649@./ansible-yum-module.py
      45     13    386      4      57 what_provides@651-707@./ansible-yum-module.py
      18      9    124      2      36 transaction_exists@709-744@./ansible-yum-module.py
      17      4    103      2      20 local_envra@746-765@./ansible-yum-module.py
      36     15    285      1      40 set_env_proxy@768-807@./ansible-yum-module.py
      19      3    104      2      22 pkg_to_dict@809-830@./ansible-yum-module.py
       7      4     66      3       7 repolist@832-838@./ansible-yum-module.py
      25     20    310      3      32 list_stuff@840-871@./ansible-yum-module.py
      27     13    260      5      45 exec_install@873-917@./ansible-yum-module.py
     110     41    828      3     172 install@919-1090@./ansible-yum-module.py
      44     13    306      3      66 remove@1092-1157@./ansible-yum-module.py
       3      1     31      1       4 run_check_update@1159-1162@./ansible-yum-module.py
      18      6    195      1      45 parse_check_update@1165-1209@./ansible-yum-module.py
     156     48   1129      3     203 latest@1211-1413@./ansible-yum-module.py
      99     33    604      2     120 ensure@1415-1534@./ansible-yum-module.py
       2      1      6      0       2 has_yum@1537-1538@./ansible-yum-module.py
      72     24    507      1     102 run@1540-1641@./ansible-yum-module.py
       7      1     48      0      20 main@1644-1663@./ansible-yum-module.py
2 file analyzed.
==============================================================
NLOC    Avg.NLOC  AvgCCN  Avg.token  function_cnt    file
--------------------------------------------------------------
    662      43.8    19.9      400.1         9     ./ansible-copy-module.py
   1262      34.8    12.6      266.4        26     ./ansible-yum-module.py

こんな感じになります。意外なことに copy モジュールのほうが行数が少ないようです。

  • 意外なことに、 copy のほうが行数が少ない

  • CNN(複雑度)は、 copy のほうが高い

  • yum は処理を細かく散らしているに対して、 copy は少数に加えて main がやばめ

といった違いがあります。 用途や原作によってモジュールもずいぶん違うのだなという感想で、 この記事は終了とさせていただきます。

※脚注