avatar
tkat0.dev
Published on

タスクランナーをmakeからcargo-makeへ移行

cargo-make は Rust で書かれたタスクランナー。make をよりリッチにしたようで使い勝手が良かった。 今まで Makefile や各種スクリプト言語で書いていたようなタスクをこれに移行している。

https://twitter.com/_tkato_/status/1143394139569975296

cargo-make | Rust task runner and build tool.

Rust じゃないプロジェクトでも普通に使えるので、Python をメインでつかう機械学習エンジニアの人でも使うと便利になると思う。

何ができるかというと、

まずインストール。後述するが Rust がない環境向けにバイナリも配布されてる。

cargo install --force cargo-make

以下のように、タスクを Makefile.toml に書いて、

[tasks.BUILD]
description = "Build hoge"
script = ['''
#!/usr/bin/env bash
echo "build ${@}..."
''']

[tasks.TEST]
description = "Test hoge"
script = ['''
#!/usr/bin/env python3
print("test ...")
''']
dependencies = ["BUILD"]

cargo make TEST あるいは makers TEST で実行。後者のが短いので好き。 これは BUILD(bash)->TEST(python) と依存するスクリプトを流した例。 以下のように、タスクが実行されログが出る。

$ makers TEST -- --option-a --option-b
[cargo-make] INFO - makers 0.20.0
[cargo-make] INFO - Using Build File: Makefile.toml
[cargo-make] INFO - Task: TEST
[cargo-make] INFO - Profile: development
[cargo-make] INFO - Running Task: init
[cargo-make] INFO - Running Task: BUILD
[cargo-make] INFO - Execute Command: "/usr/bin/env" "bash" "/var/folders/wc/k31v1cd10pg3l46x7q7kc1wjjk8c7c/T/cargo-make/x9KmMmJyej.sh" "--option-a" "--option-b"
build --option-a --option-b...
[cargo-make] INFO - Running Task: TEST
[cargo-make] INFO - Execute Command: "/usr/bin/env" "python3" "/var/folders/wc/k31v1cd10pg3l46x7q7kc1wjjk8c7c/T/cargo-make/LwmKbxQaWP.sh" "--option-a" "--option-b"
test ...
[cargo-make] INFO - Running Task: end
[cargo-make] INFO - Build Done  in 0 seconds.

やりたかったことや、既存のツールでの課題

それなりのプロジェクトで作業していると、メインのソフトウェアを書く以外にも以下のようなタスクが発生する。

  • CI でのビルドやテスト、デプロイ
  • データのダウンロード
  • ドキュメントのビルド
  • 開発ツールの起動

こうしたタスクを簡単に実行できるようにするために、Makefile を書いて make build test deploy とかで実行できるようにしておくと便利だ。 Makefile をタスクランナーとして上記のようなことをする例としては、 cookiecutter-docker-science の Makefileを見ると雰囲気がわかる。 これは当時かなりお世話になったツールです。

しかし、Makefile は書きにくい。凝ったことは難しいのでシェルスクリプト別に書いてそれを Makefile から呼んで…と、 いつの間にか CI 用のオレオレフレームワークができてしまってメンテナンス辛い、みたいな感じになると思う。 この課題感に共感できる方はご飯食べに行きましょう(愚痴りましょう)。

特に、リリースビルドやデバッグビルドなどのオプションを実行時に与えたいときは make で実現するのは面倒だ。 とはいえ、Airflow や Digdag、Luigi などワークフローエンジンを覚えたり開発環境に入れたりして使うほどでもないし、何より複雑にしたくない。1

使ったことないチームメンバでも、ぱっとみて、あーなるほどとなり、公式のドキュメントを見れば 1 時間もあればそれなりに使いこなせて、make とワークフローエンジンの中間にあるくらいのものが欲しかった。

cargo-make

前置きが長くなったが、上記を解決してくれるなと思ったのが cargo-cmake だった。 cargo-make は、Make の上位互換のような印象。 依存関係をもつタスクを簡単に定義できるのはもちろん、Makefile.toml の継承や Python スクリプトやシェルスクリプトの直書きなど、 タスクランナーとしてやりたいことはたいていカバーされていて便利だった。なにより記法や概念がシンプルでわかりやすかった。

Rust じゃないプロジェクトでも簡単に使えるのがポイント。 各プラットフォーム向けにビルド済みのバイナリが GitHub でリリースされているので、Rust の環境がないところでもうごくはず。

以降は、特に自分がよく使う例を書いておく。調べながら書いていたら長くなってしまった…

より詳細は本家 README 参照。

シェルスクリプトを直接書く

" で囲ったりせず普通に書けるので書きやすい

[tasks.bash]
script = [
'''
#!/usr/bin/env bash
echo Hello, World!
'''
]

Python スクリプトを直接書く

https://github.com/sagiegurari/cargo-make#other-programming-languages

ディレクトリ走査や Json などのコンフィグからなにかする時、Python で書くと楽に書けるのでタスクを Python で直接記述できるのは嬉しい。

テンポラリファイルとして Python スクリプトを作成してそれを python3 で呼び出してるみたい。

[tasks.python]
script_runner = "python"
script_extension = "py"
script = [
'''
print("Hello, World!")
'''
]

ただし、shebang でも動くので以下のほうがシンプルに書ける。

[tasks.shebang-py]
script = [
'''
#!/usr/bin/env python3
print("Hello, World!")
'''
]

同様にして任意のスクリプト言語を埋め込める。

コマンドライン引数の読み取り

実行時に引数を渡したいケースはよくある。

これも、スクリプト実行時に普通に渡されてくるので、シェルスクリプトなら@@や1 とかで取ればいい。

[tasks.BUILD]
description = "Build hoge"
script = ['''
#!/usr/bin/env bash
echo "build ${@}..."
''']

ただし実行時は注意。--option のような dash ではじまるオプションは cargo-make 自体のオプションだと解釈されてしまうので、--を挟む。

- cargo make BUILD --option-a  # NG
+ cargo make BUILD -- --option-a  # OK

Extend

https://github.com/sagiegurari/cargo-make#default-tasks-and-extending

複数のリポジトリやディレクトリからなるプロジェクトの場合、 プロジェクト全体で共通のタスクをデフォルトの Makefile.toml に切り出し、各リポジトリ独自の部分は差分として書くようにしておくとメンテナンスが楽。

こんなイメージ。

extend = "../../Makefile.default.toml"

[tasks.repository-specific]
...

同じ名前のタスクを定義すると、 定義した属性(script, command, args, …)のみ 上書き/新規追加する。 上書き対象を一旦初期化したい場合は clear = true する。

たとえば、tasks.build を継承する場合、clear = true せずに script を定義すると、script と command が両方定義された情報になり実行時エラーになるので clear は必要。 こうしたときのデバッグ手段として cargo make --print-steps とすると、タスクの詳細をダンプできるので、期待する toml がかけているかを確認できる。ただしエラーメッセージを見ればそこまでしなくてもわかる。

[tasks.sometask]
clear = true
command = "echo"
args = [
    "extended task"
]

特に Rust のプロジェクトで workspace を使う場合、workspace 内のすべての crate の toml に前記の extend を書かなくても workspace 共通の toml に CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = “true”を書いとけば自動で extend される機能があるみたい。他にも、Rust 開発向けの特有の機能は色々あった。

また、extend で指定できるのはローカルの toml のみだが、リモートからのダウンロードもサポート。例えば、共通のコンフィグレーションをまとめた GitHub のリポジトリから、最新の toml をダウンロードして使いたいケースは、extend より先にダウンロードするために load_script が使える。

[config]
load_script = ["git clone git@mygitserver:user/project.git /home/myuser/common"]

ちなみに、cargo-cmake のビルトインのデフォルトタスクはの定義はここ。 何も書いてなくても cargo make build-release などが動くのはこのため。 他にも、カバレッジや lint など、CI で実行する想定のタスクがいくつかあるので参考になる。

その他の機能

その他、こんなこともできるよ集。書ききれないくらい機能がある。

  • コマンド一覧をみたい
    • makers --list-all-steps でカテゴリ毎に説明がでる
    • これそのまま README に貼れば、リポジトリの使い方がわかっていいかも
    • ただ cargo-cmake のデフォルトのタスクもすべてダンプされるのでなあ
  • toml 間じゃなくて、タスク間でも extend できる
  • CI 実行時は stdout をカラーでなくする
    • makers --no-color xxx
  • 同じコマンドでも OS 毎に実行内容を切り替える機能
  • 環境変数や OS などで、タスクの実行可否を指定する
  • あるコマンドの後に他のコマンドを実行したい
    • run_task をつかう。逆に、あるコマンドの前に実行してほしいのは dependencies に書く
  • Rust のスクリプトを toml の中に書きたい
  • dev と prod で環境変数のファイルをわけたい
    • makers --env-file xxx.env
    • toml に書いた環境変数を profile で分ける機能もある
      • [env.development] [env.production] をそれぞれ書いて、makers --profile production みたいな
  • 実行時に環境変数をわたしたい
    • makers --env A=xxx
  • rust の workspace の扱い
    • デフォルトだと、workspace 以下のすべての crate に対して指定したタスクを実行してしまう
    • makers --no-workspace xxx にすれば解決
  • makers task-a task-b task-c のように複数指定はできないみたい
    • これは make と違うので注意。task-a 以降は引数として処理される
    • dependencies で task-c -> task-b, task-a として、 makers task-c とすればいい話

感想

自分は Python, C/C++, Rust などを書くプロジェクトをやることが多いのだけど、それらのタスクランナーとして cargo-make は使えると思った。

これでタスクを定義しておくと、自分自身書きやすいし、他の人が書いたのもわかりやすいなあと思う。

例えば、ML で言えばモデルの学習とかも、個人でやる分にはいいんじゃないだろうか。複数の python スクリプトの組み合わせをタスクとして定義すると楽になりそう。例えば、

# 学習→デプロイ
$ makers train-deploy -- --config 001.yaml
# 評価単体
$ makers evaluate -- --config 001.yaml --threshold 1
# traing-loopのデバッグ
$ makers run-test -- --config 001.yaml --data-slice 10

すくなくとも開発時に使うコマンドはエイリアスとして toml に書いちゃって、各種 CI の設定ファイルからも makers ci-build とかさせるように、cargo-make を中心に設計しちゃうのがわかりやすくていいんじゃないかと思ってる。とはいえ、docker のビルドは docker-compose build hoge で十分なので、わざわざ cargo-make にタスク化しなくてもいいよな、みたいな気持ちもある。

ただ、docker わからないユーザーに対して処理を隠蔽するために、cookiecutter-docker-science のようにするのはありかも。

$ makers init-docker

作者の方のブログを読むと、使い所がわかってくる。 GitHub - sagiegurari/cargo-make: Rust task runner and build tool.

Rust 開発時に便利な機能は、また別途。

また、以下は対応指定なさそう。ちゃんと調べていないけど。間違ってたら追記します。

  • タスクの時間計測は?
  • コマンドラインのオートコンプリートは?
  • タスクの依存関係をグラフで可視化できる?

Footnotes

  1. ちなみに私は @toyama0919 さんに教えてもらって以来、ワークフローエンジンは digdag を好んで使ってる。