[DevOpsプラットフォームの取り組み #6] CI/CDにおけるパラメータの課題とQmonus Value Streamの取り組み

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

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

連載第6回では、パラメータを効率的に管理するためのQmonus Value Streamの取り組みについて紹介します。

第3回 で解説したとおり、Qmonus Value StreamではInfrastructure as Code(以後IaC)およびCI/CDパイプラインを記述するためにCUE言語を用いています。 CUE言語は洗練されたデータ記述言語であり、CUE言語が有する型システムやプログラマビリティにより、宣言的マニフェストを高品質かつ効率的に記述することが可能になります。 しかしながら、CUE言語を用いても改善できない領域が存在します。 それは、パラメータです。

本記事では、IaCおよびCI/CDパイプラインの構築におけるパラメータの課題について掘り下げたのち、この解決のためにQmonus Value Streamが導入している3つのプラクティスを紹介します。

IaCとCI/CDにおけるパラメータの肥大化

IaCやCI/CDの技術領域では、非常に多くのパラメータを扱うことが要求されます。 クラウドインフラストラクチャの構成をコードで扱うということは、即ち管理対象の全てのクラウドリソースが要求(もしくは許可)している全てのパラメータを決定し、設定することと同義です。 試験環境や本番環境など複数の環境をIaCで扱う場合、ドメイン名やマシンリソースの割当など多くのパラメータで環境ごとに異なる値が必要になります。

環境ごとにマニフェスト全体を複製すると保守性が下がるため、ソフトウェアエンジニアリングのプラクティスに従いテンプレート化やモジュール化による再利用を行うと、それらのテンプレート・モジュールを利用するためにパラメータが必要になります。 CI/CDパイプラインにおいても同様で、ビルド・スキャン・テスト・デプロイなどの各タスクを実行するには、それぞれのタスクを実行するためのパラメータが必要です。

IaC・CI/CDにおけるモジュール化では、一般的なソフトウェア開発に比べてパラメータが増えやすい傾向にあります。 一般的なソフトウェア開発では、ドメインモデリングでよりよい抽象を見つけてDeep Module (インターフェースが狭くて実装が深いモジュール)を目指すことで、パラメータの数を減らすことができます。 一方でIaC・CI/CDは、扱う対象リソースがすでにDeep Moduleとなっており、モジュール化してもテンプレートマッピング程度のShallow Module(インターフェースが広くて実装が浅いモジュール)にしかならず、パラメータの数を大きく減らすことができません。 そのため、扱うリソースの数が増えて複雑度が増すほど、比例してパラメータの数が増えていきます。

加えて、近代的なソフトウェアエンジニアリングのプラクティスがパラメータの増大を助長します。 マイクロサービスアーキテクチャの普及により、サービスはコンテキストの境界で分割され、多数の小さなサービスを扱うことが増えています。 また、クラウドコンピューティングの普及で環境構築のコストが下がったこと、アジリティの高い開発のためテストに重点を置くようになり試験環境が複数必要になったことから、CI/CDパイプラインの中で複数の環境にデプロイすることが一般的になっています。 下図に示すように、開発初期はシンプルなマニフェストと少数のパラメータで事足りたのに、プロダクトの成長に伴いアプリケーションと環境の数が増加し、IaCマニフェストとパラメータが肥大化して管理が大変になったという方も多いのではないでしょうか。

IaCやCI/CDを宣言的マニフェストで管理したときに構成ファイルが増えすぎる問題は a Wall of YAML(YAMLの壁)と揶揄され、SREやリリースエンジニアリングに携わるものの大きな悩みとなっています。 YAMLの壁問題は、データ表現形式(およびデータ記述言語)の表現力・保守性と、パラメータ管理の2つに大きく分解できると考えます。 前者は、CUE言語の登場により飛躍的な向上を遂げました。 一方で後者については、未だ有効な解が見つかっていないのが実情です。

Tektonにおけるパラメータ管理の複雑さ

Qmonus Value StreamではCI/CD実行基盤としてTektonを採用していますが、Tektonもパラメータ問題をまだ解けておらず、最新のリリースパラメータ流通の効率化の機能 が開発されるなど、試行錯誤を繰り返しています。 Tektonにおけるパラメータの問題とはどのようなものか、見てみましょう。

Tektonを用いて、Gitの変更をトリガとしてCIを実行するには、以下に示す5つのコンポーネントが必要となります(PipelineとTaskについては、連載第5回 で詳細に解説しています)。

  • Events:GitプロバイダーなどによるWebhook。 Interceptorを記述することでデータを加工・拡張することが可能。
  • TriggerBinding:Eventsのペイロードのうち、TriggerTemplateに渡すパラメータを定義する。
  • TriggerTemplate:TriggerBindingから渡されるパラメータと、その他パラメータをハードコードして、Pipelineを実行するためのパラメータ一式を定義する。
  • Pipeline:実行可能なパイプライン定義。 Taskの有向非巡回グラフを持ち、順番にTaskを実行する。 実行時にパラメータを受け取り、Taskに引き渡す責務を持つ。
  • Task:Pipelineから実行パラメータを受け取り、Kubernetes Podを生成してタスクとして定義された処理を実行する。

Tektonは、上記のとおり責務ごとに異なるコンポーネントに分割されており、疎結合に作られていて入れ替えが容易なことから、高い柔軟性を具備しています。 Tektonコミュニティから提供される汎用的なTaskを組み入れつつ、オープンソースとして幅広いユースケースに対応するため、パイプラインの柔軟な構成が可能なこの設計は理に適っています。

一方で、柔軟性の高さ故に複雑であることがTektonの課題です。 上記の例を見ても分かる通り、Gitの変更をトリガとして単純なCIタスクを実行したいというだけでも、多くのコンポーネントが必要になります。

パラメータに注目してみると、その複雑さは一目瞭然です。 以下の図では、前述した5つのコンポーネントのうちパラメータマッピングの責務を持っているコンポーネントと、値の注入が可能なコンポーネントを示しました。 TriggerBinding、TriggerTemplate、Pipelineは、目的は異なれど全てパラメータマッピングの責務を持っています。 また、最終的に実行する対象となるTaskに対してパラメータの値を注入できる層は、Defaulting(値未指定時のデフォルト値の挿入)が可能なTaskも含めて4つもあります。

パラメータマッピング・注入層が複数あることにはメリットもあります。 下位の層でパラメータを凝集したりハードコードすることが可能となり、上位で必要なパラメータの数を減らせます。 シンプルなユースケースであれば、このアプローチは有効に機能するでしょう。

その一方で、層が多くて構成が複雑なため、継続的かつインクリメンタルな改善を繰り返すことで以下の問題が顕在化します。

  • パラメータがTaskに到達するまでの途中経路で、誤った値を挿入するリスクが高まります。 行き過ぎた汎化をして依存が多すぎる場合と、汎化せず複製された類似コンポーネントが多数ある場合のどちらでも発生します。
  • 最終的にTaskで実行される際に使われる値を追いかけるのが困難になります。 想定と異なる設定値がTask実行時に使われていた場合に、パラメータマッピング・注入層が沢山あるため、上位のマニフェストを順に辿っていかないと、問題箇所に行き着くことができません。
  • Taskのパラメータが増えた場合に、外部から値を注入するには、Pipeline、TriggerTemplate…と値を注入する階層に至るまで再帰的に新規パラメータを追加する必要が生じます。 これは、PipelineやTriggerTemplateのレベルで汎化され凝集されている場合は問題にはなりませんが、複製のストラテジーを取っている場合は、大量のマニフェストを漏れなく修正する必要が生じます。

このようにIaC・CI/CDでは、パラメータマッピング・注入層を増やすことで得られるメリットよりも、複雑さの増加というデメリットの方が大きくなる傾向にあります。

リリースエンジニアやSREの努力と、コードにガバナンスを効かせるソフトウェアエンジニアリングのプラクティスによりこの問題に立ち向かうことはできますが、限界があります。 私達もDevOps基盤チームとして、増え続ける一方のCI/CD YAMLと戦い続けていましたが、そのあまりの多さと複雑さと管理コストの増大によって徐々に立ち行かなくなっていました。

IaC・CI/CDのパラメータの問題は、一般的なソフトウェアエンジニアリングとは背景・要件が異なるため、異なる解き方が求められていると考えています。

Qmonus Value Streamのアプローチ

Qmonus Value Streamは、前述の問題に対して独自のアプローチで問題解決に取り組んでいます。 パラメータの増大と複雑さに立ち向かい、構成が複雑化しても保守性を損なわないために、我々が導入した3つのプラクティスについて紹介します。

1. パラメータマッピング・注入層を1つに限定する

Qmonus Value Streamでは、パラメータマッピング・注入の層を1層に限定することで、透明性を高め、Taskに渡されるパラメータを追いかけることを容易にしています。 Qmonus Value Streamは以下の図に示すとおり、PipelineおよびTaskに加えて、Pipelineを実行するAssemblyLine、AssemblyLineに対してパラメータの値を注入するConfiguration RegistryやGit Eventsによって構成されます。 AssemblyLineとConfiguration Registryは、Qmonus Value Streamで独自に開発したコンポーネントです。 このように、前述したTektonのコンポーネントの例と同様に5つで構成されており、コンポーネント間の依存関係に差異はあるものの、有する責務も大きくは変わりません。

ここで、パラメータマッピング・注入の責務をもつコンポーネントに対して、Tektonの例と同様にマークを記述します。 以下の図を見ると、パラメータマッピング・注入の責務を持つコンポーネントがAssemblyLineに限定されていることがわかります。 Qmonus Value Streamでは、Cloud Native AdapterによりPipeline/Taskが自動生成され、この際にPipelineからTaskにどのようにパラメータをマッピングするかが一意に決定されるため、ユーザは関与できません。 また、Configuration RegistryやGit Eventsはパラメータを提供することだけを責務としており、Taskで実行されるパラメータの決定に関与する機能を持ちません。 唯一、Defaultingにより値を注入できる余地がPipelineに残されていますが、この値はAssemblyLineでのパラメータ未指定時の初期値として扱われます。

これにより、利用者は「AssemblyLineが有するパラメータマッピング・注入の機能を用いて、Pipelineが提供するパラメータのインターフェースをどのように満たすか」という問題にのみ集中すれば良くなります。 パラメータマッピングを行う箇所が1つに凝集されるため、1つの変更に伴うコード修正箇所を1箇所に限定できます。 AssemblyLineの層を見ればTaskで使われる値が一意に特定できるため、パラメータの調査も容易です。 3つ以上のパラメータマッピング・注入層が存在したTektonの例と比べると、大幅に複雑さが低減されています。

本設計の実現には、Cloud Native AdapterからPipeline/Taskマニフェストを自動生成する機構が重要な役割を果たしています。 こちらの詳細については、別の記事で紹介予定です。

2. スコープを限定し、関心を分離する

パラメータマッピング・注入層をAssemblyLineの1層に限定したことで、パラメータの透明性を高めマッピングの複雑さを低減できましたが、そのトレードオフとして、1層で扱うパラメータが増大するという問題が生じます。 複数のパラメータマッピング・注入層がある場合は、下位の層でパラメータが決定できる場合はそこで注入してしまうことで、上位の層で注入するパラメータを減らすという選択を取ることができました (ただし、前述の通りこの選択は別の問題を引き起こすため、一概に良いものとは言えません)。 一方、パラメータマッピング・注入層を1層に限定する場合は、パラメータを挿入する余地がなくなるため、すべてのパラメータを1箇所で指定しなければなりません。

Qmonus Value Streamでは、Pipelineがタスクを実行するスコープ(以下、実行スコープ)に制限を導入し、同時に扱うコンテキストを減らすことで、この問題に対応します。 連載第2回 で言及したとおり、AssemblyLineは複数のStageによって構成され、1つのStageで1つのPipelineを実行します。 各Stageでは、1つのアプリケーションと1つの環境のペアをタスク実行対象として指定します。 このため、Qmonus Value Streamでは、1つのアプリケーションと1つの環境に対してタスク実行する粒度でPipelineを構成することが強制されます。 AssemblyLine Stageで実行対象とパイプラインを指定する例を以下に示します。 図中に記されている「AppA x Staging」や「AppA x Prod」が、Pipelineの実行スコープとなります。 Pipelineの実行スコープを1つのアプリケーションと1つの環境に限定することで、スコープを限定して関心を分離し、Pipelineごとに取り扱うパラメータの数を絞ります。 その結果、1層で扱うパラメータを小さく分割して管理できます。

Tekton自体にはこのような制約はなく、複数のアプリケーションや複数の環境を対象とした任意の大きさのPipelineを実行できます。 Tektonだけを用いてCI/CDパイプラインを構成する場合、Pipelineが複数のTaskをまとめて実行できる唯一の単位となりますので、このような構成を取ることが有効な場合もあるでしょう。 一方で、複数のアプリケーション・複数の環境を同時に1つのPipelineで扱うということは、多数のコンテキストを同時に扱うことになり、パラメータの増大に繋がります。

Qmonus Value StreamではPipelineの実行スコープが限定されますが、1つのワークフローとして束ねられるスコープには制限がなく、任意のスコープのワークフローを構成できます。 1つのアプリケーションと1つの環境を選択したPipelineをビルディングブロックとしてAssemblyLineを定義することで、複数のアプリケーションと複数の環境を対象としたワークフローを構成できるためです。 この仕組みにより、1つのワークフローで実行したい処理のスコープが広がった場合でも、Pipelineの実行スコープとパラメータを小さく維持できます。

3. スコープごとのパラメータセットとパラメータの自動注入

Pipelineの実行スコープを限定することで、パラメータマッピング・注入層を1つに限定したことによるパラメータの増加を限定的にできました。 この機構を活用して、更にパラメータ設定の負担を下げることを検討します。

Pipelineの実行スコープが1つのアプリケーションと1つの環境に限定されたので、パラメータを提供するConfiguration Registry側もこれにあわせて、アプリケーションと環境のペアの単位でパラメータセットを配備します。 これにより、Pipelineへのパラメータマッピング・注入がやりやすくなります。 Configuration Registryの機構により、GUIからアプリケーションや環境の設定をするだけで、設定した内容が自動で取り込まれ、アプリケーションと環境のペアの単位でのパラメータセットが構成されます。

下図に示すとおり、AssemblyLine StageでPipelineと実行対象を指定することは、満たすべきパラメータインターフェースと、その注入に利用できるパラメータセットを指定することに相当します。

加えて、AssemblyLine Stageの定義では、前述の通りパラメータのマッピングを柔軟に記述できます。 パラメータのマッピングは以下の形式で記述します。

params:
  - name: <Pipelineのインターフェースのキー>
    value: <`$(params.<パラメータセットのキー>)`で表される置換対象、を含む任意の文字列>

パラメータマッピングが記述されているキーについては、記述の内容に従って置換評価された結果が格納されます。 パラメータマッピングが記述されていないキーについては、インターフェースで指定されているキーと同じキーを用いてパラメータセットを参照します。

以下の例では、Pipelineは appNamebaseUrl の2つのパラメータを要求しています。 それに対して、AssemblyLine Stageでは baseUrl のみパラメータマッピングが記述されており、 $(params.domainName) が値に指定されています。 この場合、 baseUrl にはパラメータセットの domainName の値が注入され、 appName にはパラメータセットにおける同名の appName の値が注入されます。

キーが一致する場合の暗黙のパラメータ自動注入(以下、Implicit Binding)があることで、パラメータマッピングの記述量を減らすことができます。 TektonのPipeline/Taskのように、キーが一致している場合であってもすべて明示的な記述が必要だと、パラメータ数が多い場合には面倒です。 記述を忘れると、実行時にパラメータ不足でエラーとなり、余計な工数増に繋がります。

Implicit Bindingは、Pipelineの実行スコープを限定していることでより効果を発揮します。 Configuration Registryのパラメータセットは、決まった仕様・命名規則でパラメータを出力するため、Pipeline側をその命名規則に合わせて構成することで、明示的なマッピングが必要なパラメータの数を減らせます。 Configuration Registryに任意のKey-Valueを追加し、Pipelineのインターフェースにあわせてパラメータセットを拡張することで、同様にマッピングを減らせます。

また、パラメータマッピング・注入層を1層に集めていることで、パラメータの値の透明性を損なうことなくImplicit Bindingを活用できます。 パラメータマッピング・注入層が多数あるなかでパラメータ記述を省略すると、タスクで実際に利用される値を辿るのが更に難しくなります。 前述した通り、TektonでもPropagated Parameters というPipelineからTaskへのパラメータ流通の効率化の機能を提供していますが、透明性を損なわず利用できる一部記法に限られており、効果が限定的です。

Qmonus Value Streamでは、パラメータマッピング・注入層を1層に限定しPipelineの実行スコープを限定しているからこそ、任意のケースでImplicit Bindingを活用しつつ、値の透明性と保守性を損なわず両立することが可能となっています。

おわりに

DevOpsプラットフォームの取り組み連載の6回目の記事として、IaC・CI/CDの複雑化に伴うパラメータの増大という問題について紹介し、その解決のためにQmonus Value Streamが導入しているプラクティスを3つ紹介しました。 パラメータの問題は業界としてまだ発展途上であり、私達もさらなる改善に向けて試行錯誤を繰り返しています。 長い記事となってしまいましたが、私達がパラメータの問題をどのように捉え、その解決に向けてどのような取り組みを実践しているか、少しでも伝われば嬉しいです。

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

次はQmonus Value Streamの重要な構成要素であるAssemblyLineについて踏み込んでご紹介します。 お楽しみに!

© NTT Communications Corporation All Rights Reserved.