RubyのRactor::Portを用いてCICDパイプラインランナーをスクラッチで実装する

Posted on

先日、HackerNewsで面白い記事を見つけた。
Building a CI/CD Pipeline Runner from Scratch in Python | Muhammad
ちょうど、依存関係にある処理のオーケストレーションに興味があったので1、Rubyで同様のCICDパイプラインランナーをスクラッチで実装してみることにした。

パイプラインランナー

前述の記事に記載があるが、今回構築するパイプラインランナーは以下の機能を持つ。

  • ymlをパースする
  • ジョブの依存関係を解決する
  • ジョブを実行する(依存に問題がなければ並列実行も可能)
  • ログをストリームする
  • ジョブ間でアーティファクトを共有する
  • 結果を報告する

今回は並列実行にRubyのRactorを使用している。サンプルコードはGitHubに乗せている。
ruby ci_cd.rb pipeline.ymlでどのバージョンも実行できる。サンプルコードはRuby 4.0-devで登場した機能を利用しており、それ以前のバージョンだと動作しないので注意。

Version 1: 単一のジョブを実行する

まずは、単一のジョブを実行するだけのシンプルなバージョンを作成した。 ymlを読んで、ジョブを実行し、ログを表示するだけのものだ。

version 1 Single Job Executor

RubyのOpen3.popen2eは標準出力と標準エラー出力を同時に扱えて便利。僕が知らないだけでPythonにも同様の機能があるのかもしれないが。

Version 2: 複数のステージを逐次実行する

次に、複数のステージをそれぞれ逐次実行するバージョンを作成した。

Version 2: Multi-Stage Pipeline with Sequential Execution

Version 3: ジョブを並列実行する

ここが今回のハイライト。参考ブログではPythonのmultiprocessingを使用していた。この記事ではRubyのRactorを使用する。
Version 3: Parallel Job Execution

このVersionでは、並列に実行されるジョブの結果を集約し、失敗か成功か確認する処理がある。
今回はファイル一つだけの簡単なスクリプトなので、メインのRactorに結果を全部送信してしまってもいいが、他のライブラリでメインのRactorに何を送信するかもわからないので、専用の通信路を用意したほうがいいだろう。
これまでだと、こういった用途を限定した通信路を作成するためにはchannelを用意してRactor間で共有する必要があった。

result_channel = Ractor.new do
                   while true
                     Ractor.yield Ractor.receive
                   end
                 end


stage_jobs.map do |job|
  Ractor.new(job, workspace, result_channel) do |j, ws, result_channel|
    result_channel << JobExecutor.new(ws).run(j)
  end
end

results = stage_jobs.size.times.map do
  result_channel.take
end

儀礼的にchannel書くの面倒くさいな〜と思っていた時にRactor::Port2
Ractor::PortはRubyでこれから導入される予定3の機能で、軽量かつ生成したRactorでしか受け取れない受信箱をつくる機能である。
今回は各ジョブの結果を受け取る箱として使用した。スッキリして( ・∀・)イイ!!

result_port = Ractor::Port.new

stage_jobs.map do |job|
  Ractor.new(job, workspace, result_port) do |j, ws, result_port|
    result_port << JobExecutor.new(ws).run(j)
  end
end

results = stage_jobs.size.times.map do
  result_port.receive
end

Version 4: 依存関係とアーティファクト

依存関係の導入と、アーティファクト生成、他のジョブの残したアーティファクトを利用できる機能を導入した。

Version 4: Dependencies and Artifacts

ちょっと困ったのが、アーティファクトの概念を導入する際、以下の用にPathname#relative_path_fromをnon main ractor内で使用するとRactor::IsolationErrorが出てしまうということ。

relative_path = artifact_path.relative_path_from(job_artifact_dir)
# => 'Pathname#relative_path_from': can not access non-shareable objects in constant Pathname::SAME_PATHS by non-main ractor. (Ractor::IsolationError)

Pathname::SAME_PATHSの実装をみると共有可能なProcになっていなかったのでしょうがなさそう。
今回は「ベースのパスを消せば実質相対パス!」という荒業で回避した。

relative_path = artifact.delete_prefix("#{job_artifact_dir}/")

依存関係の解決の為にトポロジカルソートも実装したが、これは元のPythonのコードとそれほど変わらない。依存が無くなったものから並列処理できるよう配列にまとめておくのがポイント。

  def topological_sort(jobs)
    execution_order = []
    job_map = jobs.map { |job| [job.name, job] }.to_h
    in_degree = jobs.map { |job| [job.name, 0] }.to_h
    adjacency = Hash.new { |h, k| h[k] = [] }

    jobs.each do |job|
      job.needs.each do |dep|
        adjacency[dep] << job.name if job_map.key?(dep)
        in_degree[job.name] += 1 if job_map.key?(dep)
      end
    end

    queue = in_degree.select { |_, degree| degree.zero? }.keys

    while queue.any?
      current = queue
      execution_order << current.map { |job_name| job_map[job_name] }
      queue = []

      current.each do |job_name|
        adjacency[job_name].each do |neighbor|
          in_degree[neighbor] -= 1
          queue << neighbor if in_degree[neighbor].zero?
        end
      end
    end

    raise 'Cyclic dependency detected in job definitions.' if execution_order.flatten.size != jobs.size

    execution_order
  end

実際に書いてみて、依存がない順にジョブを足していく感覚を得れたので良かった。

Version 5: 変数の導入、ブランチの指定

変数を用いた書き換えと、ブランチの指定を実装した。
Version 5: Production-Ready Features

これはどちらかというとおまけかな。設定ファイルに記述された変数を置換しているだけ。
ただ、実際にPythonアプリのCICDが走って感動した。

まとめ

スクリプト書くのたのし〜。
Ractor::Port、使いやすくていいですね。リリースが楽しみです。


  1. overmindというtmuxのセッション起動いい感じにしてくれる君のプロセスの依存に関するissueを解決したかった。(https://github.com/DarthSim/overmind/issues/70↩︎

  2. Ractor::Port ― Ractor の API を一新した話 - STORES Product Blog ↩︎

  3. Ruby 3.5.0-preview1ではRactor::Portは使えなかった。筆者は3.4.0-devを利用した。 ↩︎