LayerX エンジニアブログ

LayerX の エンジニアブログです。

Taskfile.devでシンプルにタスクを管理する

こんにちは。バクラク事業部 機械学習・データ部 データグループの@civitaspoです。最近、アルコール度数の低いお酒にハマっています。飲んでも意識がハッキリしているので、趣味の時間を長く確保できるようになってハッピーになれます。僕的には「正気のサタン」がおすすめです。

yonasato.com

さて本記事では、最近バクラクのデータ基盤管理リポジトリで導入した Taskfile.dev を紹介しようと思います。

これまでバクラクのデータ基盤では、非エンジニア職のPC環境でも問題なく動作するように、可能な限りMac標準でインストールされているコマンドのみでスクリプトを書いていました。しかし、aquaを導入したことにより、非エンジニア職のPC環境にも「容易に」「セキュアに」指定バージョンのバイナリを配れるようになったため、利用ツール群の刷新を進めています。Taskfile.devはその刷新の中で出会った便利なツールの1つです。

aquaに関しては弊社@upamuneが以前書いた記事や公式ドキュメントをご覧ください。

tech.layerx.co.jp aquaproj.github.io

それでは、始めていきます。

Taskfile.dev とは?

taskfile.dev

Taskfile.devは、Golang製のタスクランナーです。GNU Makeなどの従来のタスクランナーと比べ、シンプルさと容易さに重点を置いて設計されています。

タスク定義は、独自文法ではなくYAMLで記述するため、YAMLの基本的な知識があれば特別な学習コストをかけずに利用を始められます。タスク定義やタスク間の依存関係の定義に必要なYAML上の記法も直感的で、学習コストが低いのが特徴です。実際、導入を決めてからわずか1日で、ほぼ全てのスクリプトをTaskfile.devに置き換えることができました。

また、有名なリポジトリでTaskfile.devが採用されていることも注目すべきポイントです。データ基盤でdbtを利用している人にとっては非常に馴染みのある、dbt-labs/jaffle-shopz3z1ma/dbt-osmosisといったリポジトリでも採用されています。

github.com github.com

ちなみに、公式ドキュメントによると、ツール名は「Task」です。しかし、あまりにも汎用的すぎる単語なので、私はTaskfile.devと呼んでいます。本記事でも、タスクランナーの文脈における一般的な意味の「タスク」との混同を避けるため、ツール名としてTaskfile.devという名称を使用します。

Taskfile.devにおけるタスク定義の基本

Taskfile.devでは、 先ほども記載した通り、以下のようなYAMLでタスクを定義します。 task-hello に記述している内容が1つのタスクです。実際に実行されるコマンドは cmds に配列で記述します。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  task-hello:
    desc: hello 3 times
    cmds:
      - echo hello 1
      - echo hello 2
      - echo hello 3

このタスクの実行結果は以下のようになります。 cmds に記述されたコマンドが実行されていることが分かります。 --silent をオプション指定することでTaskfile.devが出すログを抑制できます。

$ task task-hello                                                                                          
task: [task-hello] echo hello 1
hello 1
task: [task-hello] echo hello 2
hello 2
task: [task-hello] echo hello 3
hello 3

$ task --silent task-hello
hello 1
hello 2
hello 3

タスク同士の依存関係は deps キーワードで管理します。たとえば、以下のような例であれば「 task-hello-1task-hello-2task-hello-3 に依存している」という定義となります。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  task-hello-1:
    deps:
      - task-hello-2
      - task-hello-3
    desc: hello 1
    cmds:
      - echo hello 1
  task-hello-2:
    desc: hello 2
    cmds:
      - echo hello 2
  task-hello-3:
    desc: hello 3
    cmds:
      - echo hello 3

task-hello-1 を実行すると、 task-hello-2task-hello-3 が実行されてから task-hello-1 の結果が出力されていることが分かります。

$ task --silent task-hello-1
hello 2
hello 3
hello 1

なお、この deps は可能な限り並列に実行されます。そのため、シリアルに実行したい場合は実行順序を定義するタスクを定義する必要があります。たとえば、先ほどの例で task-hello-3 を実行してから task-hello-2 を実行する必要がある場合は以下のように記述します。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  task-hello-1:
    deps:
      - task-hello-ordered
    desc: hello 1
    cmds:
      - echo hello 1
  task-hello-2:
    desc: hello 2
    cmds:
      - echo hello 2
  task-hello-3:
    desc: hello 3
    cmds:
      - echo hello 3
  task-hello-ordered:
    desc: hello ordered
    cmds:
      - task: task-hello-3
      - task: task-hello-2

task-hello-orderedtask-hello-3 の実行後に task-hello-2 を実行するタスクを追加しました。この例のように cmds には単なるコマンドだけでなく、他のタスクを定義することもできます。

この定義でtask-hello-1 を実行すると、task-hello-3task-hello-2task-hello-1 の順でタスクが実行されることが分かります。

$ task --silent task-hello-1
hello 3
hello 2
hello 1

コマンドライン引数を CLI_ARGS という変数で受け取ることもできます。Taskfile.devではGo Templateを使用でき、 CLI_ARGS はGo Templateの変数として使用できます。

taskfile.dev

以下のように CLI_ARGS をコマンドに埋め込んだものを定義します。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  task-hello:
    cmds:
      - echo hello {{ .CLI_ARGS }}

CLI_ARGS には、コマンドライン引数で-- のあとに定義されたものが格納されます。

$ task task-hello --silent -- civitaspo
hello civitaspo

Taskfile.devの高度な機能

ここからは、Taskfile.devが持つ高度な機能について書いていきます。

不必要なタスク実行の抑制

Taskfile.devは、不必要なタスク実行を防ぐために、 sourcesgenerates といった機能を持っています。この機能は sources に定義されたファイル群のハッシュ値を計算し、入力ファイルに変更がない場合はタスクの実行をスキップしてくれるものです。

以下の例は、Pythonのパッケージマネージャーであるastral-sh/uvを使ってパッケージをインストールするタスクです。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  uv-sync:
    cmds:
      - uv sync
    sources:
      - pyproject.toml
      - uv.lock
    generates:
      - .venv/**/*

sources に定義されたファイル群に変更がない限り、以下の実行結果のように task: Task "${タスク名}" is up to date というメッセージとともに実行がスキップされます。

$ task uv-sync
task: Task "uv-sync" is up to date

この機能により、タスク間の依存関係定義はそのままに、不必要なタスク実行を抑制できるため、シンプルにタスクの依存関係を定義することができます。

by-fingerprinting-locally-generated-files-and-their-sourcestaskfile.dev

タスクの重複実行数制御

Taskfile.devは、タスクの重複実行を上手く制御する仕組みを持っています。タスクの依存関係を解決したときに、重複するタスクが存在した場合、そのタスクに run: once の記述があれば、そのタスクは1度しか実行されません。

以下の例では、 run-once-tasktask-a, task-b, task-ab のそれぞれから依存されており、 task-abtask-a, task-b に依存しています。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  run-once-task:
    run: once
    cmds:
      - echo executed!!
  task-a:
    deps:
      - task: run-once-task
    cmds:
      - echo task-a
  task-b:
    deps:
      - task: run-once-task
    cmds:
      - echo task-b
  task-ab:
    deps:
      - task: task-a
      - task: task-b
      - task: run-once-task
    cmds:
      - echo task-ab

このとき、task-abを実行すると run-once-task は3回実行されることになります。しかし、run-once-task には run: once が定義されているため一度しか実行されません。run: onceの定義有無で結果がどう変わるか見てみましょう。

# `run: once` 定義なし
$ task --silent task-ab
executed!!
executed!!
executed!!
task-a
task-b
task-ab

# `run: once` 定義あり
$ task --silent task-ab
executed!!
task-a
task-a
task-ab

この機能も、不必要な重複実行を避けるのに役立ちます。我々の運用では、先ほど紹介した uv sync の例と併せて以下のように依存パッケージをインストールできるようにしています。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  aqua-install:
    run: once
    desc: Install CLI dependencies
    cmds:
      - aqua install --only-link

  uv-sync:
    run: once
    desc: Sync python dependencies
    deps:
      - aqua-install
    cmds:
      - uv sync
    sources:
      - pyproject.toml
      - uv.lock
    generates:
      - .venv/**/*

run キーワードは他にも run: when_changed という定義も可能です。これは、タスクが使用する変数セットごとに1度しか実行しない設定になります。

limiting-when-tasks-runtaskfile.dev

動的なタスク定義

Taskfile.devは、動的なタスク定義もできます。タスク名にワイルドカード(*)を使用することで、ワイルドカードに入ってくる任意の文字列をキャプチャーすることができます。

wildcard-argumentstaskfile.dev

たとえば、弊社では以下のように dbt のサブコマンドを動的に切り替えられるようなタスクを定義しています。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  dbt-*:
    deps:
      - uv-sync
      - dbt-deps
    vars:
      dbt_target: '{{ default "dev-via-sso" .dbt_target }}'
      subcommand: '{{ index .MATCH 0 }}'
    cmds:
      - |
        uv run dbt {{ .subcommand }} \
          --project-dir . \
          --profiles-dir . \
          --target "{{ .dbt_target }}"

このタスクは task dbt-compile を実行すると dbt compile が、 task dbt-build を実行すると dbt build が実行されます。

処理の遅延実行

Taskfile.devは、Golangではおなじみの defer を定義することができます。タスクが終了するタイミングで呼び出される処理を定義することができます。

以下の例では処理中だけ存在する一時ディレクトリを作る例です。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  task-defer:
    vars:
      temp_dir:
        sh: |
          mktemp -d
    cmds:
      - defer: rm -rf {{ .temp_dir }}
      - echo {{ .temp_dir }}
      - echo a > {{ .temp_dir }}/a
      - ls {{ .temp_dir }}

このタスクを実行すると以下のようになります。

$ ❯ task task-defer       
task: [task-defer] echo /var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.07b9cg1TGj
/var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.07b9cg1TGj
task: [task-defer] echo a > /var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.07b9cg1TGj/a
task: [task-defer] ls /var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.07b9cg1TGj
a
task: [task-defer] rm -rf /var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.07b9cg1TGj

$ ls /var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.07b9cg1TGj              
"/var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.07b9cg1TGj": No such file or directory (os error 2)zsh: exit 2     /nix/store/227mmgh7srvyv91ihin08g4af3pfm2li-eza-0.18.16/bin/eza 

タスク終了のタイミングで一時ディレクトリが削除されているのが分かります。

doing-task-cleanup-with-defertaskfile.dev

タスク定義ファイルの分割、及び、定義ファイルを跨いだタスク間の依存定義

モノレポでコード管理している場合はタスク定義ファイルの分割を行いたいケースがあると思います。Taskfile.devはタスク定義ファイルの分割、及び、定義ファイルを跨いだタスク間の依存定義もサポートしているので、モノレポでも有効に利用できます。

以下のように、親となるタスク定義ファイルから、子プロジェクトのタスク定義ファイルを参照し、子プロジェクトのタスクに依存したタスク定義を書くことができます。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
includes:
  child-project:
    taskfile: ./child-project/Taskfile.yml
tasks:
  task-ab:
    deps:
      - task: child-project:task-a
      - task: child-project:task-b
    cmds:
      - echo task-ab

この他にも watch というファイル変更を検知して実行する機能やマトリックスビルド機能があります。

taskfile.dev

Taskfile.devで少し困ったこと

Taskfile.devを使っていて、少し困ったことがあったのでいくつか紹介します。

タスクの処理結果をパイプで別のタスクに渡せない

Taskfile.devには、タスクの処理結果を別のタスクにストリームで渡す機能がありません。たとえばJSONで結果を返すタスクと、JSONのデータを処理するタスクがあった時、シェルのようにパイプを使ってデータを受け渡ししたくなりますが、Taskfile.devでは工夫が必要です。

以下の例では task-output-json というJSONを出力するタスクと、 task-parse-json というJSONをパースするタスクを定義しています。そして、task-output-json の出力をファイル経由で task-parse-json に渡しています。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  task-output-json:
    requires:
      vars:
        - output_file
    cmds:
      - |
        echo '{"foo": "bar"}' > {{ .output_file }}
  task-parse-json:
    requires:
      vars:
        - input_file
    cmds:
      - cat {{ .input_file }} | jq -r '.foo'
  task-main:
    vars:
      temp_file:
        sh: |
          mktemp
    cmds:
      - defer: rm -rf {{ .temp_file }}
      - task: task-output-json
        vars:
          output_file: '{{ .temp_file }}'
      - task: task-parse-json
        vars:
          input_file: '{{ .temp_file }}'

実行結果は以下のとおりです。

$ task task-main       
task: [task-output-json] echo '{"foo": "bar"}' > /var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.am4rbmvr5E

task: [task-parse-json] cat /var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.am4rbmvr5E | jq -r '.foo'
bar
task: [task-main] rm -rf /var/folders/7z/1vd96nyd48x4dkpf9vwv35s80000gn/T/tmp.am4rbmvr5E

このように、パイプでデータをつなぎたい場合はファイルを経由させる工夫が必要です。

run: once が効かない使い方がある

先ほど run: once を説明した例を少し変更して、 run: once が効かない使い方の説明をします。 task-abtask-atask-b を呼び出しています。一見、この書き方は問題なさそうに見えますが、 run-once-taskrun: once が効きません。

# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: '3'
tasks:
  run-once-task:
    run: once
    cmds:
      - echo executed!!
  task-a:
    deps:
      - task: run-once-task
    cmds:
      - echo task-a
  task-b:
    deps:
      - task: run-once-task
    cmds:
      - echo task-b
  task-ab:
    cmds:
      - task task-a
      - task task-b

処理結果は以下のようになります。

$ task task-ab
task: [task-ab] task task-a
task: [run-once-task] echo executed!!
executed!!
task: [task-a] echo task-a
task-a
task: [task-ab] task task-b
task: [run-once-task] echo executed!!
executed!!
task: [task-b] echo task-b
task-b

run-once-task が2回呼び出されていることが分かります。この問題はTaskfile.devがbuilt-inで持つ他タスクをcallする方法ではなく、コマンドとしての task を使用しているために発生しています。他タスクを呼び出す場合は以下のようにbuilt-inの機能で呼び出すようにしましょう。

  task-ab:
    cmds:
      - task: task-a
      - task: task-b

おわり

本記事では、バクラクのデータ基盤で導入したTaskfile.devというタスクランナーを紹介しました。私の経験の中でも、学習コストの低さ・シンプルさの観点で、抜群に導入しやすいタスクランナーだったのでおすすめです。本記事が、タスクランナーで困っている人たちに届けばいいなと願っています。

LayerXでは一緒にデータ基盤を作ってくれる仲間を募集しています。ちょっとでも興味のある方は一度ぜひお話しましょう!

jobs.layerx.co.jp