DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

Ruby の oneshot coverage で本番稼働中の Rails アプリの使用状況を収集して不要なコードを発見するための仕組みを導入した話

前置き

プロダクト開発は人の手によって行われるものですから、開発サイクルの中で不要なコードを削除し忘れる人的なミスはどうしても発生します。

後から不要なコードに気づき削除する際には、慎重にチェックして本当にコードが使用されていないことを確認する必要があります。ただし、削除に自信が持てない場合、本番稼働中の Rails アプリに障害が発生する可能性も考慮しなければならず、何も影響しない場合はそのままにしておくことも多く、削除が難しい状況も少なくないと思います。

上記状況を打破するために DIGGLE では本番稼働中の Rails アプリのコードの使用状況を収集して不要なコードを発見するための仕組みを導入したので共有したいと思います。

不要なコードをどのように発見するか

不要なコードを発見するには、コードの実行状況を把握する必要があります。そのために利用される手法の1つに「カバレッジ」があります。カバレッジは、プログラム内の各行がどれだけ実行されたかを測定する指標であり、未使用のコードや不要なコードを見つけるのに役立ちます。

Ruby で コードカバレッジを計測する

Ruby には、処理通過した行数を記録する oneshot coverage という仕組みがあります。

oneshot coverage は Ruby 2.6 で追加された機能で、これまでのコードカバレッジの測定方法とは異なり、「各ソースコード行を1回でも実行したか否か」を記録します。そのため、1度実行された行に関しては、以降の実行でオーバーヘッドが発生せずに処理される特徴があります。

このようなコードカバレッジ測定の仕組みを本番稼働中の Rails アプリに導入する際はオーバーヘッドに懸念があると思いますが oneshot coverage ではその点においても問題がなさそうです。

oneshot coverage を使ったカバレッジの測定方法についての詳細は、以下の記事が参考になりました。

Ruby 2.6 新機能:本番環境での利用を目指したコードカバレッジ計測機能 - クックパッド開発者ブログ

仕組み導入にあたって悩んだポイント

YJIT を有効化した状態で動作するのか

DIGGLE は Ruby 3.2 系 で YJIT を有効化した状態で動作しているため、YJIT を有効化した状態で oneshot coverage が動作するのか という一時的な懸念事項がありましたが、動作検証により問題がないことを確認しました。

oneshot coverage 単体での運用は難しそう

当初は、Ruby の oneshot coverage 単体での運用を想定して進めていましたが調査を進める中で、以下のポイントに悩みました。

  • 計測結果の収集タイミング や 保存先 はどうするか
  • コードに変更が入った場合はどうするか
  • 収集したコードの使用状況を確認するUIはどうするか

oneshot coverage の仕組みを導入するとしてもひと工夫が必要そうだという話になりました。

また、自分たちでゼロからこの仕組みを構築するには時間がかかってしまいますし、ライブラリが存在していれば、運用する上でのハマりどころも対策されていることがあります。

最終的には上記の悩みポイントが解消されている Coverband というライブラリを導入して実現することとなりました。

既存のライブラリを使うという選択をしたため、今回のブログでは Coverband の導入方法に関しての文章は少なめで、導入するまでに調査したことの文章は多めとなっています。

不要なコードを発見する仕組み導入の参考になると嬉しいです。

Coverband の導入検討時に気になったポイント

本番稼働中のコードカバレッジを計測するためのライブラリに Coverband があります。

GitHub - danmayer/coverband: Ruby production code coverage collection and reporting (line of code usage)

Coverband では oneshot coverage もサポートしています。

Oneshot coverage support for ruby 2.6 · Issue #154 · danmayer/coverband · GitHub

先程挙げた悩みポイントを Coverband ではどのように解消しているか気になったので調査を行いました。

計測結果収集の頻度はどうするか

Coverband では設定の background_reporting_sleep_seconds がバックグラウンドレポート(計測結果)の収集頻度を決める設定になっていました。

  • ./config/coverband_service.rbを使用する場合で、本番環境の場合は600秒、それ以外の場合は60秒。
  • Coverband::Adapters::HashRedisStoreを使用する場合は、300秒。
  • それ以外の場合は60秒。
# see: https://github.com/danmayer/coverband/blob/v5.2.5/lib/coverband/configuration.rb#L116-L129

    # The adjustments here either protect the redis or service from being overloaded
    # the tradeoff being the delay in when reporting data is available
    # if running your own redis increasing this number reduces load on the redis CPU
    def background_reporting_sleep_seconds
      @background_reporting_sleep_seconds ||= if service?
        # default to 10m for service
        Coverband.configuration.coverband_env == "production" ? 600 : 60
      elsif store.is_a?(Coverband::Adapters::HashRedisStore)
        # Default to 5 minutes if using the hash redis store
        300
      else
        60
      end
    end

計測結果収集の頻度については、Coverband の background_reporting_sleep_seconds のコメントに記載されていました。

保存時のサーバー、Redis への負荷と計測結果が反映されるまでの時間とのトレードオフとのことでした。

「蓄積した計測結果をたまに参考するという使い方」になりそうだったので本番環境で600秒ごとに計測結果をレポーティングする設定としました。

計測結果の保存先はどこにするか

本番環境で運用する際にどこに保存されるかが気になったので調査しました。

Coverband では、Redis に保存します。

https://github.com/danmayer/coverband#redis

以前は、Coverband の保存先の選択肢として S3 もあったようですが Coverband 5.0.0 にバージョンアップしたタイミング で S3 サポートは終了していました。

https://github.com/danmayer/coverband/blob/main/changes.md#coverband-500

DIGGLE の本番環境では ElastiCache for Redis を利用しています。

Redis は揮発性のため電源断の障害などが発生した場合にデータが失われる可能性がありますが、データが失われた場合もコードカバレッジを再度取り直せば大丈夫という運用を想定しているため、こちらも大きな問題とはなりません。

コードに変更が入った場合は計測結果をどうするか

oneshot coverage では、計測結果として以下のような形式のハッシュを返します。

{ "ファイル名" => { :oneshot_lines => [実行された行番号の配列] }, ... }

例えば、次のような test.rb を用意します。

1: # test.rb
2: def foo(n)
3:   if n <= 10
4:     p "n <= 10"
5:   else
6:     p "n > 10"
7:   end
8: end
9:
10: foo(1)
11: foo(2)

test.rbを実行したときの計測結果を取得します。

$ require "coverage"

# カバレッジ測定開始 (oneshot_lines モードにする)
$ Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込む
$ load "test.rb"

# 結果の取得
# clearキーワード:新たに実行された行番号の結果のみを取得する
# stopキーワード:カバレッジの測定を停止しない
$ p Coverage.result(clear: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11]}}

では、同じファイル名で中身を以下のように書き換えると計測結果はどうなるでしょうか。

1: # test.rb
2: def hoge
3:   puts "hoge"
4: end
5: 
6: hoge
# 測定対象のプログラムを読み込む
$ load "test.rb"

# 結果の取得
# clearキーワード:新たに実行された行番号の結果のみを取得する
# stopキーワード:カバレッジの測定を停止しない
$ p Coverage.result(clear: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 6, 3]}}

ファイル名は同じですが、実行された行番号の結果が変わります。

この結果を何も考えずに古い計測結果にマージしてしまうと参考にならない計測結果になってしまいます。

このようにファイル名は同じでも中身が変わっている場合には、以前の計測結果を捨てて新しい計測結果を使うといったことを考慮する必要が出てきます。

Coverband では、レポートを保存する際に、ファイルごとに以下のようなマージ処理を行っているようでした。

  • 新規と既存の計測結果が存在しており、ファイルハッシュが同一の場合は新規の計測結果と既存の計測結果をマージする
  • 上記以外で 新規の計測結果のみ存在する場合 や 新規と既存の計測結果が存在するがファイルハッシュが同一ではない場合、新規の計測結果を採用する
  • それ以外の場合は、既存の計測結果を採用する
# see: https://github.com/danmayer/coverband/blob/v5.2.5/lib/coverband/adapters/base.rb#L116

      def merge_reports(new_report, old_report, options = {})
        # transparently update from RUNTIME_TYPE = nil to RUNTIME_TYPE = :runtime
        # transparent update for format coveband_3_2
        old_report = coverage(nil, override_type: nil) if old_report.nil? && type == Coverband::RUNTIME_TYPE
        new_report = expand_report(new_report) unless options[:skip_expansion]
        keys = (new_report.keys + old_report.keys).uniq
        keys.each do |file|
          new_report[file] = if new_report[file] &&
              old_report[file] &&
              new_report[file][FILE_HASH] == old_report[file][FILE_HASH]
            merge_expanded_data(new_report[file], old_report[file])
          elsif new_report[file]
            new_report[file]
          else
            old_report[file]
          end
        end
        new_report
      end

Coverband ではファイルハッシュは Digest::Base クラスの file メソッドを使ったハッシュ値を使用していました。

# ファイルハッシュは `Digest::Base` クラスの file メソッドを使ったハッシュ値を使用
# see: https://github.com/danmayer/coverband/blob/v5.2.5/lib/coverband/utils/file_hasher.rb#L8

      def self.hash(file, path_converter: AbsoluteFileConverter.instance)
        @cache[file] ||= begin
                           file = path_converter.convert(file)
                           Digest::MD5.file(file).hexdigest if File.exist?(file)
                         end
      end

先程例にあげたtest.rbで変更前と変更後のハッシュ値の変化を見てみます。

# 変更前
$ Coverband::Utils::FileHasher.hash('test.rb')
"da3c8fd8b579b3d26d4535aa7afb703d"

# 変更後
# rails c し直す必要がある
$ Coverband::Utils::FileHasher.hash('test.rb')
"2d394cc51d14fa5eeb0f44ab2f11ca55"

このように Coverband ではファイルの中身が変更されたかどうかの判定する際に、ファイルハッシュを利用することで処理を切り分けていました。

正しい計測結果を参照するための工夫がされており、実運用面でも問題なさそうです。

計測結果をどのように表示するか

Coverband には Web UI が提供されており、全体的なカバレッジ情報の確認や個々のファイルの詳細を確認することができます。

https://github.com/danmayer/coverband/tree/v5.2.5#coverband-web-ui

Coverband より引用

個々のファイル詳細では視覚的に未使用のコード行を確認することができます。

個々のファイル詳細

ルーティングを追加する必要があるため、その場合は適切な認証を行う必要があります。

https://github.com/danmayer/coverband/tree/v5.2.5#mounting-as-a-rack-app

このように Coverband では、本番環境中の Rails アプリの使用状況を収集して有意義に活用するための UI も提供されており実運用面で困ることはなさそうです。

Coverband 導入時に苦労したポイント

Coverband の導入自体はドキュメントに沿って行うことで比較的スムーズに導入できました。

そのため、基本的な導入方法については Coverband のドキュメントを参照していただければと思います。

ここでは、苦労したポイントに絞って共有したいと思います。

環境変数が設定されるより前に Coverband が呼び出されてしまう

DIGGLE では、環境変数を管理する dotenv という gem を使用しています。

dotenv の初期化処理が呼ばれるより前に Coverband が呼び出されてしまうため、環境変数が存在せずエラーになるということがありました。

dotenv のドキュメントを参考に Coverband が呼び出される前に dotenv を手動で呼び出す処理を追加することで解決しました。

https://github.com/bkeepers/dotenv#note-on-load-order

if Rails.env.development? || Rails.env.test?
  Dotenv::Railtie.load
end

テスト実行時に Coverband が動作してしまいエラーが出る

Coverband は本番環境でコードの使用状況を収集する目的で使用するため、テスト環境では不要でしたがどうやらテスト実行時にも動作してしまうようでした。

Coverband: detected SimpleCov in test Env, allowing it to start Coverage
Coverband: to ensure no error logs or missing Coverage call `SimpleCov.start` prior to requiring Coverband
E, [2023-10-02T10:54:05.655447 #39] ERROR -- : coverage failed to store
E, [2023-10-02T10:54:05.655896 #39] ERROR -- : Coverband Error: #<NoMethodError: undefined method `each_with_object' for nil:NilClass> undefined method `each_with_object' for nil:NilClass
.
.
.
略

下記を参考に Coverband を 特定の環境 のみ読み込むようにすることで解決しました。

※本来は本番環境のみ読む込む形でよいと思いますが、開発環境やステージング環境でも動作確認がしたいため Coverband を読み込むようにしています。

Explicitely disable coverband without an ENV var? · Issue #449 · danmayer/coverband · GitHub

# Gemfile

gem 'coverband', require: false # 環境毎に有効/無効を設定するためロードしない
# config/application.rb

# 明示的にテスト環境ではCoverbandを無効にする
require 'coverband' if Rails.env.production? || Rails.env.staging? || Rails.env.development?
# config/coverband.rb

if defined? Coverband
  # ここに設定
end
# config/routes.rb

  if defined? Coverband
      # ここにルーティング設定
  end

まとめ

記事では、導入する際に悩んだポイントやそれに対する解決策として Coverband を導入した経緯を紹介しました。

既存のライブラリを有効活用することができたので自分たちでゼロから仕組みを構築する場合に比べてスムーズに導入することができました。

本番環境での活用はもう少し先となりますが上手く活用してプロダクトのコードの見通しの良さに貢献できると思うと楽しみです。

開発のお手伝いをさせていただいている株式会社 diddyworks の sano がお送りしました。