[DevOpsプラットフォームの取り組み #7] 独自のKubernetesカスタムオペレーターを用いたCI/CDエンジン

DevOpsプラットフォームの取り組みを紹介する7回目の記事です。

Qmonus Value Stream 開発チームの奥井( @HirokiOkui )です。

連載第7回では、Qmonus Value Streamの中核を担うコンポーネントであるAssemblyLineについて深堀りします。

第2回 および 第6回 で解説したとおり、Qmonus Value Streamでは、AssemblyLineという独自のリソースを定義してCI/CDパイプラインを構成します。 AssemblyLineは、 Tekton と同様にKubernetesのカスタムオペレーターとして実装されています。 AssemblyLineは、Tekton Pipelineを実行するワークフローエンジンとしての責務に加えて、柔軟性の高いCI/CDパイプラインを構成・実行するために必要な様々な機能を有しています。

本記事では、AssemblyLineが有する機能の概要について紹介したのち、それらの機能をどのように実現しているかを解説します。

AssemblyLineが果たす3つの役割

Qmonus Value Streamでは、アプリケーションのビルド・デプロイ・リリースを一貫して行うCI/CDパイプラインをAssemblyLine(組立ライン)と呼称しています。 CI/CDパイプラインを構成する一連のリソースのうち一番上の層に位置するのがAssemblyLineであり、AssemblyLineを実行することでPipelineが駆動され、実際のタスク処理が実行されます。

AssemblyLineの構成例を以下に示します。 AssemblyLineは複数のステージから構成され、1つのステージは1つのPipelineで構成されます。 また、ステージ間の依存関係を定義することでDAG(有向非巡回グラフ)を形成し、各ステージを順番に実行できます。 このように、AssemblyLineに対して複数のPipelineを定義することで、多くの処理をまとめて実行する高度なCI/CDパイプラインを構成できます。

AssemblyLineは、TektonのPipelineやTaskと同様に、Kubernetesのカスタムリソースとして構成されています。 このため、AssemblyLineのマニフェストはKuberenetes YAMLの形式で記述します。 具体的な記述例は、第2回 の記事を参照してください。

このように、Qmonus Value Streamでは、Pipeline/Taskとすでに2層構造(Stepも含めると3層構造)になっているTektonの構成に対して、さらにAssemblyLineという上位の層を追加しています。 複数のTaskを統合する層として既にPipelineが提供されている以上、この構成は冗長に思えることでしょう。 AssemblyLine層を追加した理由は、大きく分けて3つあります。

1. Pipeline・Taskを再利用可能な汎用モジュールにする

AssemblyLine層が必要な1つ目の理由は、Pipeline・Taskの汎用性を高めて再利用可能にし、ユーザが自身で開発するCI/CDパイプラインマニフェストをできるだけ少なくするためです。 再利用可能な汎用モジュールとして実装しCloud Native Adapterに組み込むことで、ユーザはCloud Native Adapterを選択するだけで商用にデプロイするCI/CDパイプラインを構成することを可能にしています。 (Cloud Native Adapterについては、第3回 をご覧ください)

TektonのTaskとPipelineについて、簡単におさらいします。 Taskは、KubernetesのPodを生成し、TaskにStepとして定義された複数のコンテナを順番に起動する責務を持ちます。 KubernetesにおいてPodはワークロードリソースの最小の単位であり、これを起動する責務を持つことから、TaskはCI/CDパイプライン定義における最小の単位となります。 必要なコンテナを組み合わせることで、任意の処理を行うTaskが構成できます。 一方Pipelineは、自身に定義されたDAGに従ってTaskを順番に実行する責務を持ちます。 複数のTaskを直列・並列に組み合わせることで、任意の複雑なパイプラインを構成できます。

ここで、再利用性を高めるために汎化することを考えます。 Taskは複数コンテナを組み合わせて任意の処理を組み上げられますが、あまり多くのコンテナを包含して責務を持ちすぎると、汎用モジュールとして扱うことができません。 再利用性の観点から、1つもしくは数個のコンテナを使って、例えばGitからのチェックアウト、コンテナのビルド、スキャンなど、限られた責務だけを具備させることが望ましいです。

Taskを再利用可能な汎用モジュールとして構成すると、AssemblyLine層がない場合には、Taskを組み合わせてCI/CDパイプラインを構成することをPipeline一層だけで行わなければなりません。 単一のアプリケーションのビルド・デプロイ・テストだけを行うようなシンプルなCIであれば、1つのPipelineだけで十分でしょう。 一方で、総合検証環境でテスト用のカスタムビルドを作成しつつ、複数のマイクロサービスをデプロイするケースや、検証用に複数の試験環境を構成したい場合はどうでしょうか。 1つのPipelineで組み上げると複雑になりすぎるため、複数の異なるPipelineに分割し、それぞれを実行することになるでしょう。 複数のPipelineに分割された一方で実施したい目的は1つのため、複数のPipeline実行を自動化するためにシェルスクリプトなどでラップすることになりそうです。 Pipeline間に順序依存があったり、Pipelineの実行結果を後段のPipelineで利用する必要がある場合は、シェルスクリプトでワークフローもどきを書き始めることになってしまいます。

Qmonus Value Streamでは、Pipelineを分割し統合管理する必要が生じる複雑なユースケースを想定して、AssemblyLineという複数のPipelineを統合するコンポーネントを設けました。 そして、Pipelineの責務を「1つのアプリケーションを1つの環境に対して、ビルド・デプロイ・リリースなどのまとまった処理のみ実行する」ように限定することを選択しました。 この粒度で分割されたPipelineを組み合わせてAssemblyLineが構成され、AssemblyLineを実行することでCI/CDパイプラインを駆動します。 これにより、以下の効果を狙っています。

  • Pipelineが、ビルド・デプロイ・リリースというCI/CDパイプラインにおいて異なるアプリケーションや異なる環境向けに再利用しやすい粒度で構成されるため、これをビルディングブロックとして扱うことで、複数のアプリケーションや複数の環境を扱う複雑なCI/CDパイプラインでも簡単に組み上げることができます。
  • 汎用Taskを実装する際に、単一責任の原則を守りやすくなります。 汎化の層がTaskだけの場合は、あまりに細かな粒度だとPipeline側で扱いにくくなることからTaskの粒度を小さくすることを妨げる圧力がかかります。 Pipelineも汎化の層となることで、Taskの再利用性を高めることがPipelineの構成の容易性を高めることに繋がり、Taskの汎化の価値が高まります。
  • AssemblyLineにより、複数Pipelineからなるワークフローを形成できるため、前述のように複数Pipelineの実行を自動化するためのスクリプティングを行う必要がなくなります。 Pipelineと同様の宣言的記述でAssemblyLineを表現し、宣言的なCI/CDパイプラインというエクスペリエンスを一貫します。

Cloud Native Adapterをコンパイルして生成されるPipelineは、上述のとおり「1つのアプリケーションを1つの環境に対して、ビルド・デプロイ・リリースなどのまとまった処理のみ実行する」責務のみを有しています。 下の図で示すように、生成されたPipelineをビルディングブロックとして扱い、リリースされるまでのライフサイクルを構成する一連のAssemblyLineにおいて複数回使用できます。

AssemblyLine、Pipeline、Taskのそれぞれの機能と責務をまとめると、以下のとおりです。

なお補足ですが、TektonにはPipeline-in-Pipelineという拡張機能があり、これを用いることで、Tekton公式が提供する機能だけでPipelineよりも上位の層を追加できます。 このため、本節に記載した1つ目の理由だけであれば、Pipeline-in-Pipelineを使用するという選択肢もあります。

2. パラメータマッピングと注入を集約する

AssemblyLine層が必要な2つ目の理由は、Pipelineに対してパラメータを渡すためにパラメータをマッピングおよび注入する責務をAssemblyLineが担っているためです。 Qmonus Value Streamでは、複数の層でパラメータマッピングをするのではなく、パラメータが供給される層とパラメータを注入する層をすべてAssemblyLineに限定することで、パラメータ設定の透明性と保守性を高めています ( 第6回 で詳しく説明していますので、詳細は割愛します)。

具体例を下図に示します。AssemblyLineのステージ定義において、アプリケーションと環境のペアから構成されるスコープ(例:AppA x Staging)と、ビルド用の汎用Pipelineを指定することで、「AppA x Staging」用に配置されたパラメータセットをビルド用のPipelineのパラメータインターフェースに対して注入できます。 このパラメータマッピング・注入の機構により、大量のパラメータを小さなスコープに分割して効率的に管理できます。

3. リトライ機能の提供

Tektonを汎用のワークフローエンジンとして実用していくことを考えると、致命的に足りない機能があります。

まず、Pipeline/Taskを失敗したところからリトライする機能がありません。 一時的な事由により失敗したケースを考慮して、指定回数に限り自動でリトライする機能はありますが、手動で再開させることができません。 また、Taskが失敗したときに、DAGを逆方向にたどりながら補償トランザクション(実行前の状態に切り戻す処理)を実行していくこともできません。 これらの機能がないことはすなわち、ワークフローエンジンが一般的に備えている分散トランザクション管理機能が備わっていないことに相当します。

Tektonは、主たる責務がCIの実行であるというドメイン要件から、大胆に割り切った設計となっており、上記の機能不足は問題になりません。 CIではミッションクリティカルなシステムを扱わないため、仮に失敗しても再度実行して成功すれば良いと割り切ることができ、トランザクションの考慮を放棄できます。 重要なのは「再実行できる」ことだけであり、失敗時の未開放リソースの開放や再実行を妨げる中間ファイルの破棄さえできれば十分です。 このケースに対応するために、TektonはFinally機能を具備しており、Taskが失敗した場合に「再実行できる」状態まで戻すための術を利用者に提供しています。 「失敗してもPipelineの先頭から再実行できる」ことさえ担保すれば良い、と割り切って考えると、Tektonは必要な機能を効率的に具備していると言えます。

「失敗したらPipelineの先頭から再実行」と割り切るTektonの思想は理に適っているように見えますが、実シーンを考えると困るケースが多々あります。 例えば、デプロイの際にデータベースのマイグレーションを伴うケースはどうでしょうか。 クリティカルなエラーが出た場合には当然切り戻す必要がありますが、たまたまネットワークが瞬断していたとか、軽微な構成誤りであり即座に修正してデプロイを再開できるケースにおいて、データベースを切り戻して再度先頭からやり直すことは避けたいでしょう。 また、ビルドなど時間がかかる処理を伴う場合も、全体を再度やり直すことは避けたいでしょう(ビルドについては、アーティファクトの有無で分岐したりキャッシュを活用する方法もあります)。

このように、Task単位でのリトライが選択できずPipeline単位でのリトライしかできないのは、ユーザのオペレーションの選択肢を減らし、利便性を損なうことに繋がります。 利用者目線で見ると、失敗したTaskの先頭から前回実行時の状態を引き継いで再開できる方が、Pipelineの先頭からやり直しになるよりも自然です。

また、リトライ時に前回実行時のパイプライン定義・パラメータ・内部状態を引き継くことができません。 Qmonus Value StreamのAssemblyLineはCD機能も提供しており、1つのAssemblyLineで複数リージョンに対してデプロイをするようなユースケースを想定しています。 複数リージョンに対してデプロイしている最中に、途中で失敗したケースを考えてみます。 失敗した原因を解析し解消できた場合は、リトライにより失敗した環境のデプロイから再開しますが、初回実行とリトライ実行のパイプラインの構成やパラメータが変わってしまうと、複数のリージョンに対して一貫したデプロイを行うことができません。 リトライ実行であっても、前回実行時のパイプライン定義・パラメータ・内部状態を引き継ぎ、同じ状態で処理を再開することが必要となります。

このリトライ機能とリトライ時に状態を引き継ぐ機能は、CDツールには欠かせない機能となっています。 複数環境への決定的かつ一貫したデプロイを実現するために必須の機能であり、Google Cloud Deploy などパブリッククラウドから提供されるCDツールにも類似の機能を具備されています。

Tektonでは具備されていないTask単位でのリトライと、リトライ時に状態を引き継ぐ機能を、AssemblyLineによって補います。 これが、AssemblyLineが必要となる3つ目の理由です。

なお、本節の冒頭で言及したTektonに補償トランザクションの機能がないという設計については、CI/CDパイプラインの取りうる状態と複雑性を減らす観点から、AssemblyLineでも同じ方針を採用しています。

AssemblyLineの実現方式

Kubernetes カスタムオペレーターとして実装

AssemblyLineは、Kubernetesのカスタムリソースとして定義されています。 Kubernetesは、ユーザが独自でカスタムリソースを定義することで様々な機能拡張ができます。 TektonのPipelineやTaskもKubernetesのカスタムリソースです。

Kubernetesでは、Podなどの標準のリソースもしくはカスタムリソースを宣言的に定義することで、制御対象のシステムをデプロイし、宣言された状態になるまで自動的にデプロイ処理が繰り返されます。 現在の状態(Current State)が宣言されたあるべき状態(Desired State)に至るまで繰り返されるループ処理のことをReconciliation Loopと呼び、Kubernetes上で定義されるあらゆるリソースは、Reconciliation Loopによって制御されます。

Managing Kubernetes より)

Tekton Pipelineは、このReconciliation Loopの機構を使ってDAGで定義されたTaskを先頭から順番に実行するつくりになっており、AssemblyLineでも同様の仕組みを採用しています。 Tekton PipelineがTaskを実行する処理とAssemblyLineがPipelineを実行する処理は、ロジックがかなり似ています。 AssemblyLineの設計・実装においてTekton Pipelineの設計やアプローチを参考にすることで、Reconciliation Loopを用いてワークフローエンジンを実装するTektonのノウハウを活かしています。

カスタムリソースを用いてReconciliation Loopによる制御を行うには、Kubernetesの標準リソースとは異なり、Reconciliation Loopで実行される処理を実装する必要があります。 この処理を行うプログラムはカスタムコントローラと呼ばれ、カスタムリソースの定義マニフェスト(Custom Resource Definition: CRD)とカスタムコントローラの2つをあわせてオペレーターと呼びます。

オペレーターを実装するにはいくつかの方法がありますが、現在では kubebuilder を利用するのが一般的であり、AssemblyLineオペレーターもkubebuilderを用いて実装しました。 kubebuilderではカスタムリソースを定義・拡張する専用のCLIがあり、CRDやマニフェストの自動生成機能も充実しています。 Reconciliation Loopのロジック開発に集中し、簡単にオペレーターを開発できますので、興味があれば一度オペレーターの開発にチャレンジしてみてください。

AssemblyLineを構成する3つのカスタムリソース

以下の図に、AssemblyLineオペレーターを構成する3つのカスタムリソースを示します。 比較のために、Tekton Pipelineオペレーターを構成するカスタムリソースも併記しています。

第5回 でも紹介したとおり、Tektonは、Pipelineリソースで実行対象のTaskやパラメータマッピングを宣言的に定義し、PipelineRunリソースを定義することでワークフローを走らせます。 定義と実行で異なるリソースを用いるため、処理の内容をPipelineリソースに事前に定義しておき、実行時にはパラメータだけを指定してPipelineRunを作成する、という分担が可能です。 PipelineRunに都度実行対象のワークフロー全体を記述する手間が省けます。

PipelineRunはワークフローエンジンとして振る舞うためにカスタムコントローラが必要ですが、Pipelineは実行対象のTaskと実行順序が定義されているだけであり、カスタムコントローラがありません。 Pipelineで定義されているTaskは、PipelineRunを作成することで、カスタムコントローラによって実行されます。

AssemblyLineにおいても同様に、定義と実行でリソースを分離した構成を採用しています。 AssemblyLineに実行対象のPipeline・実行順序・パラメータマッピングを定義しますが、AssemblyLineは実際の処理を駆動しないため、カスタムコントローラがありません。 AssemblyLineで定義されているPipelineは、AssemblyLineFactoryを作成することで、カスタムコントローラによって実行されます。

AssemblyLineオペレーターでは、AssemblyLineFactoryとAssemblyLineRunの2つのカスタムリソースを用いてPipelineを実行します。 Tekton PipelineオペレーターではPipelineRunだけがこの責務を担っているため、大きく構成が異なっています。 これには、記事の前半で説明したAssemblyLineが必要となる理由のうち、3つめの「リトライ機能」が関係しています。

AssemblyLineオペレーターは、AssemblyLineFactoryでAssemblyLineRunの実行状態を管理しつつ前回実行時の状態を保持することで、「リトライ機能」を実現しています。 PipelineRunの責務が「1回のPipeline実行」である一方、AssemblyLineにはリトライ機能があることから、AssemblyLineRunの責務は「1回のAssemblyLine実行における1回のトライ」になります。 ただし、前述のとおり、リトライをしても同じデプロイ結果に収束するためには、前回のトライのパイプライン定義・パラメータ・内部状態を引き継ぐ必要があり、それらを永続化する機能が必要です。 その機能を担うのが、AssemblyLineFactoryです。 AssemblyLineFactoryは、「1回のAssemblyLine実行における複数回のトライ」を責務として持ち、過去のトライの状態を永続化し、リトライ実行時にはリトライ用に加工されたAssemblyLineRunを1つ生成します。 このため、N回リトライを行った場合は、AssemblyLineFactoryは1つですが、AssemblyLineRunはN+1このリソースが生成されることになります。

AssemblyLineオペレーターとTekton Pipelineオペレーターの違いをまとめると、以下の3つの点において設計に差異があります。

  • リトライを可能にするためにAssemblyLineFactoryが追加されている
  • リトライ用のAssemblyLineRunを生成するために、初回実行時に使用したマニフェスト・パラメータ・進捗状況を管理する責務を担っている
  • AssemblyLineRunがリトライの都度別のリソースとして生成される

このようにAssemblyLineオペレーターは、Tekton Pipelineオペレーターの実装を参考にしつつも、Qmonus Value Streamにおいて要求されるAssemblyLineの3つの機能、特にリトライ機能の実現のために様々な追加拡張を導入したことで生み出されました。

KubernetesのReconciliation Loopをワークフローエンジンに応用するというTektonの発想はとても面白く、手続きアプローチで実装したワークフローエンジンに比べてエラーに対する頑強性が高いというメリットもあります。 しかしながら、いざAssemblyLineオペレーターを実装してみると、その時々のリソース状態に応じて次に実行する内容が変わるために条件分岐が増えてしまう問題、状態のパターン網羅が出来ておらず考慮外の状態に陥ってしまう問題、テストが難しい問題などにより、難易度の高い開発となりました。 これらの詳細については、また別の記事で改めて紹介できればと考えています。

おわりに

DevOpsプラットフォームの取り組み連載の7回目の記事として、Qmonus Value Stream中核を担うコンポーネントであるAssemblyLineについて深堀りしました。 AssemblyLineには大きく3つの責務があること、Pipelineを参考にKubernetesのカスタムリソースとして実装されていることを紹介しました。

Qmonus Value Streamチームは、メンバ一人一人が、利用者であるアプリケーション開発者のために信念を持って活動を続けています。 プラットフォームを内製開発するポジション、プラットフォームを使ってNTTグループのクラウド基盤やCI/CDを構築支援するポジションそれぞれで 、一緒に働く仲間を募集していますので、興味を持たれた方は応募いただけると嬉しいです。

次回は、Cloud Native AdapterによるPipeline/Task生成機能について踏み込んでご紹介します。 お楽しみに!

© NTT Communications Corporation All Rights Reserved.