⚙️

Dynamic Configurationを使って.circleci/config.ymlを分割する

2021/11/10に公開

.circleci/config.yml が大きくなりすぎる問題

リポジトリの成長に伴ってCIのコードも大きくなるのは自然なことですが、比較的読みやすいフォーマットであるyamlであってもコード量が200, 300行を超えてくる全体を把握するのがかなり大変になってきます。会社で長期間に渡ってメンテされているプロジェクトの場合、1000行を超えてくることもあるでしょう。

Github ActionsやAzure Pipelinesはワークフロー単位で別々のyamlで管理できるのですが、CircleCIは昔から .circleci/config.yml の1ファイルにしか書けない仕様になっているためyamlの肥大化を抑えるための選択肢がほぼないという状況が続いています。

.circleci/config.yml 自体はyamlですので、別々のyamlに分割しておき自作のツールでマージするという事例も存在するようです。

https://quipper.hatenablog.com/entry/2020/12/01/080000

このような方法でたしかに分割して管理は可能なのですが、CircleCI自体が読み込んでくれるファイルは .circleci/config.yml に限定されているため結合後のyamlをコミットしてリポジトリで管理する手間がどうしても必要でした。

Dynamic Configurationとは

今回紹介する手法はCircleCIのDynamic Configurationという機能をベースにしているため、まずはこちらの紹介をします。

Dynamic Configurationは2021年の4月にリリースされた機能です。これは全く新しい機能であるからか登場時にフォーラムやブログで活用方法の紹介もされていました。

https://discuss.circleci.com/t/intro-to-dynamic-config-via-setup-workflows/39868

https://circleci.com/ja/blog/building-cicd-pipelines-using-dynamic-config/

登場してからもう半年は経つのですが、この機能を活用した事例がほとんど紹介されていないため知名度もかなり低いのではないでしょうか。

Dynamic Configurationは、その名の通りCircleCIの中で動的に生成した設定ファイルを使えるということぐらいは雰囲気を感じ取れるかと思いますが、公式ドキュメントのページを見ても応用例が書いていないためどのように使えばいいのかがよくわからないでしょう。

https://circleci.com/docs/ja/2.0/dynamic-config/

実は応用例はCookbooksのページに存在しており、まず generate-config というコマンドで動的に生成したyamlを読み込ませるという超基本の使い方が解説されています。その後にmonorepoのような構成において、差分が存在するファイルに応じて実行するワークフローを変更するという Github Actionsの paths と同等な機能を実現する例が解説されています。

https://circleci.com/docs/ja/2.0/configuration-cookbook/?section=examples-and-guides#dynamic-configuration

解説されてはいるのですが・・・正直言ってこの応用例はかなりのハイレベルです。この応用例はDynamic Configurationだけではなく、Pipelilne Parameterという別の機能も併用しておりコア部分の複雑な処理はpath-filteringというorbに隠されています。

CircleCI側の意図としてはDynamic Configuration機能によって実現できるハイレベルな事例を見せることでその柔軟性をアピールする狙いなのかもしれません。ですが、初歩レベルの次がいきなりハイレベルすぎて大多数のユーザーに対してこの機能の便利さを伝えられていないような気がしてなりません。

Dynamic Configurationの解説

結局Dynamic Configurationとは何を行っているのか?それについては自分が説明するよりもこちらの図を見てもらう方が早いです。

https://github.com/CircleCI-Public/api-preview-docs/blob/master/docs/setup-workflows.md#concepts

Dynamic Configurationが有効になっているプロジェクトでは、まず最初にSetup Workflowという特別なワークフローが実行されます。そしてSetup Workflowの最後にPipeline ContinuationというAPIを呼び出すことで今までconfig.ymlに書いていたような通常のワークフロー(Regular Workflows)に実行を引き継ぐという流れです。

Dynamic Configuration自体の機能はこれだけです。先ほどのCircleCIのサンプルは、最初のSetup Workflowの中でgit diffから更新されたファイルのパスに応じてパラメータを生成し、Pipeline Continuation APIから通常のワークフローを呼び出す際に渡されたパラメータで挙動を変えるという2段階の処理が行われています。これにより、差分のファイルパスに応じてジョブを実行するかどうかを決めるという仕組みになっているようです。

CircleCIのサンプルから話を戻しましょう。Setup Workflowの中でgit diffのようなちょっとした処理が可能であるということは、何らかの方法で事前に分割しておいたyamlを結合できればconfig.ymlの分割が実現できそうということが分かります。

分割されたconfig.ymlを結合する方法

circleci config pack

CircleCIを普段使っている方でも知ってる人少ないコマンドだと思いますが、自作のOrbsを作成する時になどに紹介されているコマンドです。

CLIのドキュメントで解説されているようにexecutor, jobs, commandなどの命名規則に従ったディレクトリへそれぞれ分割したyamlとして配置しておき circleci config pack を実行すると @orb.yml をベースに結合した1つのyamlを出力してくれる機能があります。

Orbを作成するときには一度結合して出力されたyamlをpublishして公開するだけなのですが、これを応用してDynamic ConfigurationのSetup Workflowの中で毎回yamlを結合させることでconfig.ymlを分割して管理することが可能になります。

ただ、先述のようにこのコマンドはあらかじめ決まったディレクトリ構成に従って分割されたyamlが配置されている前提となっており、その分割の単位がexecutor, jobなどと決まっていることが個人的には使いにくいと思っています。ライブラリ的な役割であるOrbを作る場合ならばこの分割単位は使いやすいのですが、通常のパイプラインが書かれたconfig.ymlの場合は肥大化したyamlをジョブごとに分割したいというニーズがほとんどではないでしょうか。

ちなみに、結合後のconfig.ymlはCircleCI的にvalidであるという保証はされていないため、Orbsのドキュメントでは結合後のyamlに対して circleci config validate を実行するようにと書かれています。validateは引数にファイルを渡す一方、circleci config pack は標準出力に出力するので2つのコマンドをくっつけるにはちょっとしたシェルの小技が必要になります。

circleci config pack | circleci config validate -

この小技は後述の別の方法でyamlを結合する場合にも便利な小技です。

yq

yqはjqのyaml版のようなコマンドですが、使い方次第ではyamlをパースして中身を取り出すだけではなく複数のyamlのマージも可能です。複数のyamlをマージできるということはSetup Workflow中にyqを使うことでもconfig.ymlの分割が実現できるということです。

実はyqでマージするテクニックは自分のアイディアではなく、@m61kさんの circle-makotom/circle-advanced-setup-workflowでこのテクニックが使われているのを発見したのが最初でした。

yqは circleci config pack とは異なり任意のyamlをマージ可能なのでディレクトリの命名規則などに縛られることはありません。従って、例えば以下のようにそれぞれが完全にconfig.ymlとして正しい2つのyamlですら結合可能です。

# 結合前1
version: 2.1

orbs:
  gradle: circleci/gradle@2.2.0

jobs:
  gradle:
    executor: gradle/default
    steps:
      - checkout
      - run: gradle --version

workflows:
  java:
    jobs:
      - gradle

----
# 結合前2
version: 2.1

orbs:
  node: circleci/node@4.7.0

jobs:
  node:
    executor: node/default
    steps:
      - checkout
      - node/install-npm
      - run: |
          node --version
          npm --version

workflows:
  node:
    jobs:
      - node

この2つのyamlを .circleci/config_yq/ の下に配置した場合、 yq ea '. as $item ireduce ({}; . * $item )' .circleci/config_yq/*.yml で結合後のyamlが表示されます。

# 結合後
version: 2.1
orbs:
  gradle: circleci/gradle@2.2.0
  node: circleci/node@4.7.0
jobs:
  gradle:
    executor: gradle/default
    steps:
      - checkout
      - run: gradle --version
  node:
    executor: node/default
    steps:
      - checkout
      - node/install-npm
      - run: |
          node --version
          npm --version
workflows:
  java:
    jobs:
      - gradle
  node:
    jobs:
      - node

どうでしょうか。分割のための特殊なルールなども存在しないため、かなり直感的にyamlを分割して管理が可能でしょう。ただし、重複するキーが存在する場合は上書きされてしまうというに注意が必要です。

たとえば、先ほどの2つのyamlのjobを両方ともbuildという名前にしてみます。

# 結合前1
version: 2.1

orbs:
  gradle: circleci/gradle@2.2.0

jobs:
  build:
    executor: gradle/default
    steps:
      - checkout
      - run: gradle --version

workflows:
  build:
    jobs:
      - build

----
# 結合前2
version: 2.1

orbs:
  node: circleci/node@4.7.0

jobs:
  build:
    executor: node/default
    steps:
      - checkout
      - node/install-npm
      - run: |
          node --version
          npm --version

workflows:
  build:
    jobs:
      - build

これをyqで結合させると以下の結果となり、キーが重複しているため上書きされてしまっていることがわかるかと思います。

# 結合後
version: 2.1
orbs:
  gradle: circleci/gradle@2.2.0
  node: circleci/node@4.7.0
jobs:
  build:
    executor: node/default
    steps:
      - checkout
      - node/install-npm
      - run: |
          node --version
          npm --version
workflows:
  build:
    jobs:
      - build

分割したyamlのそれぞれでキーが重複しているかどうかを毎回調べるのは面倒ですので、分割したときのファイル名などをprefixにつけて xxx-build, yyy-build のようにしておくなど、何らかの規則でキー名が重複しないように工夫しておくとよいでしょう。

独自で結合のツールを自作する

ここまでは手軽に使用可能な2つのコマンドによる結合を紹介しましたが、どちらの使い勝手もイマイチであるという場合は自作してしまってもよいでしょう。使い慣れた言語で好きなようにyamlを結合する処理を書くことも可能です。

自作する場合はSetup Workflowとして動かすコンテナのイメージは少々工夫が必要になると思います。CircleCIのサンプルで使われている circleci/continuation のOrbが提供するexecutorには例えばnodejsのような言語を動かすランタイムは含まれていません。このような場合は自分でランタイムのインストール処理を書くか、あるいは逆に元々nodejsのランタイムが含まれているイメージを使って circleci のコマンドをインストールする必要があるはずです。

デモ

実際にDynamic Configurationを有効にしてSetup Workflowの中で複数のyamlを結合させるconfig.ymlはこのようになります。

version: 2.1

# Dynamic Configuration
setup: true

orbs:
  cli: circleci/circleci-cli@0.1.9
  continuation: circleci/continuation@0.2.0

jobs:
  pack:
    executor: continuation/default
    steps:
      - checkout
      - cli/install
      - run: circleci config pack .circleci/config_pack > .circleci/packed.yml
      - continuation/continue:
          configuration_path: .circleci/packed.yml
  yq:
    executor: continuation/default
    steps:
      - checkout
      - run:
          name: Install yq
          command: |
            curl -Lo yq https://github.com/mikefarah/yq/releases/download/v4.12.2/yq_linux_amd64
            chmod +x yq
      - run: yq ea '. as $item ireduce ({}; . * $item )' .circleci/config_yq/config_* > .circleci/merged.yml
      - continuation/continue:
          configuration_path: .circleci/merged.yml

workflows:
  setup:
    jobs:
      # この例ではyqの方を採用。packはコメントアウトして実行しない
      # - pack
      - yq

自分が実験したときのリポジトリはこちらです。実際にCircleCIで動かしたときのログもこちらから見ることができるはずです。

https://github.com/Kesin11/circlceci-dynamic-config-sandbox/tree/v0.2.0

https://app.circleci.com/pipelines/github/Kesin11/circlceci-dynamic-config-sandbox?branch=main

CircleCIで実際に動かしているのはyqで結合するバージョンですが、 circleci config pack を使用した場合のコマンドもコメントアウトで残してあります。

Dynamic Configurationを使うときの注意点

Server版の場合はcircleci_domainのパラメータが必須

com版をお使いの方は関係ないのでスキップしてください。

自分の同僚や、Server版(いわゆるエンタープライズ版)をお使いのところで今後v3にアップデートしてDynamic Configurationを使う場合、 circleci_domain というパラメータが必須になります。

- continuation/continue:
    circleci_domain: circleci.XXXX.com
    configuration_path: .circleci/merged.yml

これはDynamic Configurationが内部的にはCircleCI API v2のContinuation APIを呼び出していることに起因するのですが、自社のCircleCI Serverのドメインを間違えずに入力してください。もしも空白だったり間違えてしまった場合、Setup Workflowがいつまでも終了せずにタイムアウトのエラーになってしまいます。

Scheduleとの併用は難しいかあるいは不可能かもしれない(ただ今後は改善されそう)

自分でまだしっかりと試したわけではないのですが、

今まではconfig.ymlで定義されているworkflowのうち、schedule の設定を追加することで特定のworkflowだけスケジュールで実行できていました。一方でDynamic Configurationの場合はSetup Workflow → 実際のWorkflowという2段階になるため、Scheduleの設定もSetup Workflow側に書くしかないはず。ですが、そうしてしまうと実際に実行されるWorkflowは結合後のyamlに書かれているすべてのWorkflowが対象となってしまう気がします。

探してみたところフォーラムにてDynamic ConfigurationとScheduleを併用したい場合のワークアラウンドが紹介されていましたが、スケジュール用のリポジトリを別に作るという個人的にはかなり無理矢理な方法に感じました。

まとめ

知る人ぞ知る機能であったDynamic Configurationの解説と、これを用いてconfig.ymlを分割する方法を紹介しました。特に大規模なリポジトリに関わっている人ほどconfig.ymlを分割して管理したいというのは悲願だったのではないでしょうか。

Dynamic Configuationの概念自体が少々複雑である感は否めませんが、config.ymlの肥大化に困っている人ほど試してみる価値はあるかと思います。

また、Server版のv3でもDynamic Configuationは動作することを少なくとも自分の会社では確認しています。Server版のv2からv3への移行はかなり大変だと思いますが、v3へ移行する1つのモチベーションなれば幸いです。(とはいえこんなニッチな機能より、v3にアップデートしたいモチベーションとしてはOrbsが使えるようになるだけで十分かと思いますが)

Discussion