Jobでできないことが、Workflowsを生んだ
一言でいうと
Argo Workflowsは、複数のコンテナを、順序と依存関係を持たせて実行するエンジンです。KubernetesのJobから始めて、なぜ足りないのかを知ってはじめて、Workflowのフィールドに納得がいきます。Eventsは、そのワークフローを何が開始させるかを担当する、別のプロジェクトです。
なぜ必要なのか
Jobは、「このコンテナを、成功するまで実行せよ」を表現します。ここまでは完璧です。問題は、2つ目の作業ができたときです。ビルドが終わってはじめてテストが実行され、テストが終わってはじめてデプロイが実行され、デプロイが失敗したら通知が出る必要があるなら、Job2、3個をどうつなげるのか。方法は3つしかありません。コンテナ1つにシェルスクリプトですべて入れるか、外部のオーケストレーター(Jenkinsなど)にJobを順番に作らせるか、あるいは、依存関係を表現できる上位の概念を導入することです。
1つ目は、失敗した地点を見失います。スクリプトの途中で落ちると、どこで落ちたのかログを探し回ることになり、リトライは常に最初からです。2つ目は、状態をクラスターの外に置くことなので、オーケストレーターが落ちると、パイプラインが迷子になります。Workflowsは、3つ目を選びました。作業の間の依存関係とデータの受け渡しを、Kubernetesオブジェクトの中に宣言として入れたのです。
どう動くのか
Workflowのspecには、templates配列とentrypointが1つあります。entrypointは、どのテンプレートから始めるかを指す名前です。テンプレートには複数の種類があり、実際にコンテナを実行するcontainerテンプレート、ほかのテンプレートを順番に呼び出すstepsテンプレート、依存関係のグラフで呼び出すdagテンプレート、人の承認を待つsuspendテンプレートが代表的です。
stepsとdagの違いは、表現力です。stepsは、リストのリストなので、同じ層にあるものは並列、次の層はその次という、単純なモデルです。dagは、各タスクがdependenciesで自分の先行条件を直接書くため、AとBが終わってはじめてCが実行され、Bだけが終わればDが実行される、といったグラフを描けます。パイプラインが5段階を超えると、stepsでは「このステップがなぜここにあるのか」が読みにくくなるため、たいていdagに行きます。
データは、2つの流れで運ばれます。parametersは文字列で、artifactsはファイルです。あるテンプレートのoutputs.artifactsにパスを書くと、そのパスをアーティファクトストアにアップロードし、次のテンプレートのinputs.artifactsがそれをダウンロードして、指定したパスに展開します。この中間ストアが必要だという点が、Jobとの決定的な違いです。Jobは、Pod同士がファイルを受け渡す方法がないため、PVCを共有するか、自分でどこかにアップロードする必要があります。
再利用は、WorkflowTemplateが担当します。よく使うテンプレートをネームスペースに1つ置いておき、各Workflowのタスクが、templateRefで名前とテンプレートを指します。組織標準のビルド手順を1か所で直せば、すべてのパイプラインに反映される構造です。CronWorkflowは、WorkflowのspecをworkflowSpecの下にそっくり抱え、scheduleとconcurrencyPolicyを載せたものです。concurrencyPolicyをForbidにすると、前の実行がまだ終わっていないときに、新しい実行をスキップします。
Eventsは、EventSource → Sensor → Triggerの3つのオブジェクトでできています。EventSourceは、Webhook・S3・Kafka・カレンダーのような外の世界を購読して、内部のイベントバスへ流し、Sensorは、そのバスを購読して、条件が合えば、Triggerが、WorkflowやほかのKubernetesオブジェクトを作ります。なぜ別のプロジェクトなのかは、このリストを見るだけで答えが出ます。これらのアダプターをデプロイのコントローラーの中に入れ始めると、Argo CDはデプロイツールではなく、統合ミドルウェアになってしまいます。
現場での姿
筆者のホームラボで、この区別が実際に問題になった事例が、KubeVirtでした。コンポーネントの状態はすべてAllComponentsReadyだったのにVMが起動せず、virt-launcherのPod仕様を調べると、initコンテナが実行するバイナリを入れたボリュームマウントが抜けていました。このクラスターだけで3回目に確認した教訓が、「状態がReadyであることと、実際に動作することは別の命題」だということです。
ワークフローでも、まったく同じ落とし穴があります。dagのタスクがSucceededで終わったというのは、コンテナが0で終了したという意味にすぎず、次のタスクが期待するアーティファクトを実際に残したという意味ではありません。outputs.artifactsに書いたパスにファイルがないと、そのタスクは成功したのに、次のタスクが入力を見つけられず、失敗します。そのため、アーティファクトを渡すタスクは、最後に「そのパスにファイルがあるか」を自分で確認する習慣が必要です。同様に、GPUワークロードをワークフローで実行するときは、nvidia.com/gpu: 1だけを要求すると、32GBのカードが必要な学習が、8GBのノートPC向けGPUに載ってしまうことがあります。Kubernetesの立場では、どちらもGPU1枚だからで、そのため、ノードに規模のラベルを付けて、nodeSelectorで選ばせる必要があります。
次のラボですること
/root/capa-wf/に、Workflow(dagの依存関係、パラメーター、アーティファクト)、CronWorkflow、WorkflowTemplateの参照を作成したあと、同じことを行うKubernetesのJobとCronJobを、実際のクラスターに載せて、両者を並べて比較します。最後に、ファイルとクラスターの両方から値を取り出して、サマリーのConfigMapを作成します。