Claude Media
Claude CodeでKubeflow Pipelinesの失敗を追う — kfp compileとローカル実行

Claude CodeでKubeflow Pipelinesの失敗を追う — kfp compileとローカル実行

kfp dsl compileは通るのに実行で落ちる。型の食い違いはcompileで、hermetic違反はローカル実行で拾い、残りをClaude Codeにログから絞らせる三段の手順をまとめます。

Kubeflow Pipelines(KFP)のコンポーネントを書き換えたあと、kfp dsl compileは成功したのにクラスタ上のrunが失敗する。この食い違いは、KFPの仕組みから説明がつきます。compileが見るのは型の整合などの静的な部分だけで、コンポーネントの中身は実行して初めて評価されるからです。

Claude Codeに原因調査を任せるなら、失敗を「compileで落ちる」「ローカル実行で落ちる」「クラスタでだけ落ちる」の3段に分け、手前の段で拾えるものを手前で拾わせるのが近道です。この記事では、kfp 2.18.0(Python 3.12)で実際に動かした出力を使い、各段で何が見えるかと、Claude Codeに渡す権限・hook・CLAUDE.mdの書き方をまとめます。

compileが通っても実行で落ちる理由

公式ドキュメントによると、@dsl.componentで作るLightweight Python Componentは、関数のコードが取り出されてコンテナ内で実行されます。関数は「hermetic」であること、つまり本体の外で定義されたシンボルを参照しないことが条件です。ドキュメントには、外側の定数を参照する例が「実行時に失敗する」例として載っています。

compileは関数のシグネチャから入出力の型を読み取ってYAMLを作る工程です。関数本体の変数参照が解決できるかは見ません。だからこの種のミスはcompileを素通りします。

切り分けの段は次のとおりです。

段使うもの拾える失敗拾えない失敗
1. compile使うものkfp dsl compile拾える失敗型の不一致、必須引数の欠落拾えない失敗関数本体の実行時エラー
2. ローカル実行使うものkfp.local拾える失敗hermetic違反、ロジックの例外拾えない失敗認証、リソース指定、クラスタ固有の挙動
3. クラスタ使うものkfp run ほか拾える失敗上記すべて拾えない失敗—

Claude Codeには「段1から順に通す」ことを手順として渡します。クラスタのrunを直接見に行かせると、手前で再現できた失敗にも時間とトークンを使うことになります。

検証用のパイプラインを用意する

挙動を確かめるため、意図的に1つミスを入れた小さなパイプラインを使います。prepareが0以上1未満の数のリストを作り、filter_rowsがしきい値以上の件数を返します。

from kfp import dsl
 
THRESHOLD = 0.5
 
@dsl.component
def prepare(n: int) -> list:
    return [i / n for i in range(n)]
 
@dsl.component
def filter_rows(rows: list) -> int:
    return len([r for r in rows if r >= THRESHOLD])
 
@dsl.pipeline(name="demo-pipeline", description="filter demo")
def demo(n: int = 10) -> int:
    p = prepare(n=n)
    f = filter_rows(rows=p.output)
    return f.output

THRESHOLDが関数の外で定義されている点が、先ほどのhermetic違反にあたります。

段1: kfp dsl compileで型の食い違いを拾う

CLIのkfp dsl compileは、--pyと--outputが必須です。同じファイルに複数のパイプラインがあるときは--functionで名前を指定します。

kfp dsl compile --py pipeline.py --output pipeline.yaml

上のパイプラインは、このコマンドが成功します。THRESHOLDの誤りには気づきません。生成されたYAMLの先頭には、パイプラインの要約がコメントで入ります。

# PIPELINE DEFINITION
# Name: demo-pipeline
# Description: filter demo
# Inputs:
#    n: int [Default: 10.0]
# Outputs:
#    Output: int

公式ドキュメントにも、YAML全体は人間が読むためのものではなく、冒頭のコメントが要約だと書かれています。Claude Codeにはこのコメントだけを読ませれば、入出力の型が意図どおりか確認できます。整数のデフォルト値10はコメントでは10.0と表示されますが、これは表示用の要約です。型はInputs行のintで判定できます。

型の食い違いはcompileで止まります。strを返すコンポーネントの出力をintの入力につなぐと、次のエラーで終了コード1になり、YAMLは作られませんでした。

Incompatible argument passed to the input 'v' of component 'b': Argument type 'STRING' is incompatible with the input type 'NUMBER_INTEGER'

公式ドキュメントによると、型チェックはCompiler.compileのtype_check引数で切れ、CLIにも--disable-type-checkがあります。ただしkfp 2.18.0では、このフラグを付けても上の例は同じエラーで終了コード1のままでした。型エラーは「フラグで黙らせる」より「出力側か入力側の型を直す」前提でClaude Codeに指示するほうが確実です。

段2: ローカル実行でhermetic違反を再現する

kfp.localを使うと、コンポーネントやパイプラインを普通のPython関数のように呼び出して実行できます。ドキュメントのとおり、local.init()でランナーを選びます。Dockerが使えるならDockerRunnerが推奨で、Dockerを入れられない環境のためのSubprocessRunnerはLightweight Python Componentしか実行できません。今回のパイプラインはどちらもLightweightなのでSubprocessRunnerで足ります。

# local_run.py
from kfp import local
from pipeline import demo
 
local.init(runner=local.SubprocessRunner())
t = demo(n=10)
print("output:", t.output)

実行すると、prepareは成功し、filter-rowsで失敗しました。ログの要点は次のとおりです。

INFO - Executing task 'prepare'
INFO - Task 'prepare' finished with status SUCCESS
INFO - Executing task 'filter-rows'
ERROR - Task 'filter-rows' finished with status FAILURE
    NameError: name 'THRESHOLD' is not defined
RuntimeError: Pipeline 'demo-pipeline' finished with status FAILURE. Inner task failed: 'filter-rows'.

どのタスクが落ちたかが最終行に、原因がNameErrorの行に出ます。compileでは見えなかった失敗が、クラスタに触れずに再現できました。SubprocessRunnerは既定で仮想環境に依存を入れるため、このパイプラインでは1タスクあたり約6秒かかっています。ループを回すときの待ち時間の目安になります。

直し方は2通りです。THRESHOLDを関数の中に移すか、filter_rowsの引数に昇格させてthreshold: floatとして受け取ります。後者はパイプラインのパラメータとして実行時に差し替えられるので、こちらが扱いやすい構成です。

ドキュメントが挙げているローカル実行の制約も押さえておく必要があります。キャッシュやリトライの設定は効かず、メモリやアクセラレータの指定も無視されます。認証が要る処理は試せません。dsl.Condition、dsl.ParallelFor、dsl.ExitHandlerもまだサポート外とされています。分岐や並列を含むパイプラインは、段2では部分的にしか検証できません。

base_imageと依存パッケージで落ちる型

hermetic違反のほかに、Lightweight Python Componentが実行時に落ちる典型を表にします。

症状原因の型見る場所
NameError原因の型関数外の定数やimportを参照している見る場所importと定数を関数本体へ移す
ModuleNotFoundError原因の型packages_to_installが未指定見る場所@dsl.component(packages_to_install=[...])
型は合うのにPython構文エラー原因の型base_imageのPythonが古い見る場所base_imageの指定
依存のインストール失敗原因の型非公開インデックスのURL指定漏れ見る場所pip_index_urls

pip_index_urlsには1つ注意があります。ドキュメントによると、これを指定しても公開のPyPIは自動では加わりません。非公開リポジトリとPyPIの両方から入れたいときは、両方のURLを並べる必要があります。

base_imageについても、ドキュメントと実際の出力に食い違いがありました。Lightweight Python Componentsのページには既定のイメージがpython:3.7と書かれていますが、kfp 2.18.0でcompileしたYAMLのイメージはpython:3.11でした。compile時の警告では、既定値が2027年10月1日にpython:3.12へ切り替わると予告されています。バージョンに依存する書き方を使うコンポーネントは、base_imageを明示しておくのが安全です。Claude Codeに「期待するPythonのバージョンをbase_imageに明示する」ことをルールとして渡せば、既定値の変更に巻き込まれません。

Container Componentで見る入出力の落とし穴

@dsl.container_componentは、イメージ・コマンド・引数を直接指定する書き方です。出力を返すときは、コンテナ内でファイルに書き出す必要があります。ドキュメントではdsl.OutputPath(str)で注釈した引数に、システムが生成したパスが渡されます。そのパスへJSONとして値を書くのがコンポーネント側の責務です。

@dsl.container_component
def say_hello(name: str, greeting: dsl.OutputPath(str)):
    return dsl.ContainerSpec(
        image='alpine',
        command=['sh', '-c', 'RESPONSE="Hello, $0!" && echo $RESPONSE '
                 '&& mkdir -p $(dirname $1) && echo $RESPONSE > $1'],
        args=[name, greeting])

公式の例にはmkdir -p $(dirname $1)が入っています。出力先のディレクトリが無い状態でファイルを書くとコンポーネントが失敗するためで、コンテナ系の失敗を調べるときの最初の確認点になります。パイプライン側では、出力パラメータを自分で渡さないことも覚えておく必要があります。名前付きの出力はhello_task.outputs['greeting']のように参照し、値はバックエンドが実行時に用意します。

Container ComponentはDockerRunnerであればローカルでも実行できます。SubprocessRunnerでは動かないので、Dockerが使えない環境の検証は段3に回ります。

段3: クラスタで失敗したrunの情報を集める

段2までで再現しなかった失敗は、クラスタ固有の要因が疑われます。認証、リソース、イメージの取得、権限などです。runの状態はCLIのkfp run getで取れます。

kfp --endpoint https://kfp.example.com --output json \
  run get --detail RUN_ID

--endpointは接続先、--outputはtableかjson、run getの-d(--detail)は詳細表示、-w(--watch)は完了までの監視です。いずれもCLIリファレンスに載っているオプションです。JSONで出力しておくと、Claude Codeに読ませるときも、jqで失敗したタスクだけに絞るときも扱いやすくなります。

CLIリファレンスのkfp run配下は、archive・create・delete・get・list・unarchiveの6つです。失敗したステップのログ本文を取るサブコマンドは含まれません。ダッシュボードのrun画面などから取ったテキストを貼り付けて渡すのが現実的です。エラー文のコピーでも、Claude Codeはスタックトレースを読んで原因の候補を挙げられます。dbtの場合に失敗の一次情報を成果ファイルから抜く手順はClaude Codeでdbt runが失敗した原因を特定するにあります。KFPでも「ログ全体ではなく失敗ノードの部分だけを渡す」という考え方は同じです。

Claude Codeに任せる範囲を設定で決める

段1と段2は副作用がないので、確認なしで走らせてかまいません。一方、kfp run createやkfp pipeline deleteはクラスタの状態を変えます。.claude/settings.jsonで許可と拒否を分けておく構成にします。

{
  "permissions": {
    "allow": [
      "Bash(kfp dsl compile *)",
      "Bash(python local_run.py)",
      "Bash(kfp run get *)",
      "Bash(kfp * run get *)"
    ],
    "deny": [
      "Bash(kfp run create *)",
      "Bash(kfp * run create *)",
      "Bash(kfp pipeline delete *)",
      "Bash(kfp * pipeline delete *)"
    ]
  }
}

段3のコマンドは--endpointや--outputをサブコマンドの前に置くため、kfp run get *だけでは一致しません。kfpとrunの間にグローバルオプションが入る形を、kfp * run get *のように別のルールとして並べています。denyも同様に両方の形を書かないと、kfp --endpoint X run create ...が拒否をすり抜けます。

Bashのルールは*がワイルドカードとして働き、Bash(npm run *)はnpm run buildに一致してnpm installには一致しません。権限ルールの管理は/permissionsコマンドの解説も参考になります。

パイプラインの.pyを編集するたびにcompileを走らせたいなら、PostToolUseのhookが使えます。公式のガイドは、Edit|Writeにマッチさせ、jqでtool_input.file_pathを取り出して整形ツールに渡す例を示しています。同じ形でcompileに差し替えると次のようになります。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/kfp-compile.sh" }
        ]
      }
    ]
  }
}
#!/bin/sh
# .claude/hooks/kfp-compile.sh
f=$(jq -r '.tool_input.file_path')
case "$f" in
  */pipelines/*.py)
    out=$(kfp dsl compile --py "$f" --output /tmp/kfp-check.yaml 2>&1) \
      || { echo "$out" | tail -5 >&2; exit 2; }
    ;;
esac

PostToolUseのhookは、終了コード2でstderrをClaudeに見せられます。上のスクリプトはcompileが失敗したときだけ末尾5行を返す想定です。複数のパイプラインを1ファイルに置くと--functionの指定が要るので、パイプラインは1ファイル1つにそろえると単純です。hookの使い分けはhooksでテストを自動実行する記事で詳しく扱っています。

最後に、調査の順序をCLAUDE.mdに書いておきます。公式の説明では、CLAUDE.mdは短いほど守られやすく、200行以下が目安とされています。

## KFPパイプラインの失敗調査
1. `kfp dsl compile` が失敗したら型の食い違い。出力と入力の型を直す
2. compile が通るなら `local_run.py` を実行し、落ちたタスク名と例外を読む
3. `@dsl.component` の関数が外の変数・importを参照していないか確認する
4. 再現しない失敗だけクラスタの `kfp --endpoint <URL> --output json run get --detail <RUN_ID>` に進む
5. `base_image` は必ず明示する。`kfp run create` と `kfp pipeline delete` は、`--endpoint` を挟んだ形も含めて実行しない

よくあるつまずき

  • ローカルで通ったのにクラスタで落ちる: kfp.localはリソース指定・キャッシュ・認証を再現しません。メモリ不足や権限エラーは段3の領分です。
  • SubprocessRunnerでContainer Componentが動かない: 仕様です。DockerRunnerに切り替えるか、段3で確認します。
  • --disable-type-checkで型エラーを消そうとする: kfp 2.18.0ではCLIのフラグ経由で消えませんでした。型を直します。
  • 警告が大量に出て本当のエラーが埋もれる: base_imageの既定値変更を知らせるFutureWarningがデコレータごとに出ます。base_imageを明示すると警告も止まります。

まとめ

Kubeflow Pipelinesの失敗は、compile・ローカル実行・クラスタの3段で原因の種類が違います。compileは型、ローカル実行は関数本体、クラスタは環境を見ます。Claude Codeに渡す最初の指示は「原因を調べて」ではなく、「段1から順に通し、再現しなかった失敗だけ上に持ち上げて」にすると、調査の範囲が小さく保てます。

権限ルールでクラスタを触る操作を止め、hookで編集のたびにcompileを回し、CLAUDE.mdに順序を書く。この3点を用意すれば、パイプラインの修正を何度往復させても、確認の手間はほぼ手前の2段で収まります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. kubeflow.org/docs/components/pipelines/user-guides/core-functions/compi…
  2. kubeflow.org/docs/components/pipelines/user-guides/components/lightweig…
  3. kubeflow.org/docs/components/pipelines/user-guides/core-functions/execu…
  4. kubeflow-pipelines.readthedocs.io/en/stable/source/cli.html
  5. code.claude.com/docs/en/permissions
  6. code.claude.com/docs/en/hooks-guide
この記事を共有:XはてブLinkedIn