mkdir blog && cd $_

ITエンジニアの雑感

効率的なRakeタスクのロギング実装手法

RailsのRakeタスクでログ出力を共通化する方法をメモしておく。 Rakeタスクの賢いロギングを参考に改良してみる。

qiita.com

要件

  • Rakeタスクの開始と終了がわかるようにログを出力する
  • Rakeタスクが途中でエラーとなったら、終了ログは出力せずに、FATALログを出力する
  • ログ出力をするRakeタスクが簡単に実装できるようにする
  • Rails.loggerがない場合には標準出力をする

実装

Rakeタスクのログ出力Module

module TaskLogging
  def task(*args, &block)
    Rake::Task.define_task(*args) do |task, options|
      next unless block

      logger = ActiveSupport::TaggedLogging.new(Rails.logger || Logger.new(STDOUT))
      logger.tagged(task.to_s) do
        logger.info "[#{Time.current}] Start"
        yield task, options
        logger.info "[#{Time.current}] End"
      rescue StandardError => e
        logger.fatal "[#{Time.current}] Failed: #{e.message}\n#{e.backtrace.join("\n")}"
        raise
      end
    end
  end
end

extend TaskLogging

テストコード

require 'rails_helper'

RSpec.describe TaskLogging do
  include ActiveSupport::Testing::TimeHelpers

  describe '.task' do
    let(:logger_double) { instance_double('Logger', info: true, fatal: true, formatter: ActiveSupport::Logger::SimpleFormatter.new) }
    let(:task_logging_instance) { Class.new { include TaskLogging }.new }

    before do
      allow(Rails).to receive(:logger).and_return(logger_double)
      allow(logger_double).to receive(:formatter=)
      travel_to(Time.zone.parse('2025-12-05 10:00:00'))
    end

    context '正常系: 開始と終了ログが出力される' do
      it 'Start/End を順序通りに info する' do
        task_logging_instance.task(:test_logging_task) do |_task, _opts|
          # no-op
        end

        rake_task = Rake::Task['test_logging_task']

        expect(logger_double).to receive(:info).with(a_string_including('[2025-12-05 10:00:00 +0900] Start')).ordered
        expect(logger_double).to receive(:info).with(a_string_including('[2025-12-05 10:00:00 +0900] End')).ordered

        rake_task.execute
      end
    end

    context '異常系: 例外時に FATAL ログを出して再送出する' do
      it 'fatal を呼んで例外を再送出する' do
        task_logging_instance.task(:failing_task) do |_task, _opts|
          raise StandardError, 'boom'
        end

        rake_task = Rake::Task['failing_task']

        expect(logger_double).to receive(:info).with(a_string_including('[2025-12-05 10:00:00 +0900] Start')).ordered
        expect(logger_double).to receive(:fatal).with(a_string_including('[2025-12-05 10:00:00 +0900] Failed: boom')).ordered

        expect { rake_task.execute }.to raise_error(StandardError, 'boom')
      end
    end

    context 'ロガーが存在しない場合' do
      before do
        allow(Rails).to receive(:logger).and_return(nil)
        # NOTE: 標準出力への出力を抑制
        allow($stdout).to receive(:write)
      end

      it '正常系はログが出ずに通る' do
        task_logging_instance.task(:no_logger_task) { |_task, _opts| }
        rake_task = Rake::Task['no_logger_task']
        # 何も出力されない
        rake_task.execute
      end

      it '異常系はログなしで例外再送出される' do
        task_logging_instance.task(:no_logger_fail_task) { |_task, _opts| raise StandardError, 'err' }
        rake_task = Rake::Task['no_logger_fail_task']
        expect { rake_task.execute }.to raise_error(StandardError, 'err')
      end
    end

    context 'ブロック未指定の場合' do
      it 'タスク実行してもログ出力されない' do
        task_logging_instance.task(:no_block_task)
        rake_task = Rake::Task['no_block_task']

        expect(logger_double).not_to receive(:info)
        expect(logger_double).not_to receive(:fatal)

        rake_task.execute
      end
    end

    context '引数付きタスクでもログに引数は含まれない' do
      it 'Start/End ログに引数が表示されない' do
        task_logging_instance.task(:arg_task, [:x, :y]) do |_task, _opts|
          # no-op
        end
        rake_task = Rake::Task['arg_task']

        expect(logger_double).to receive(:info).with(a_string_including('[2025-12-05 10:00:00 +0900] Start')).ordered
        expect(logger_double).to receive(:info).with(a_string_including('[2025-12-05 10:00:00 +0900] End')).ordered

        rake_task.invoke('foo', 'bar')
      end
    end
  end
end

ログ出力を利用するRakeタスクは、require 'task_logging' をするだけ。

require 'task_logging'

namespace :foo do
  task :bar => :environment do
    ...
  end
end

以下のようなログが出力される

I, [2025-12-08T23:00:20.741769 #8385]  INFO -- : [foo:bar] [2025-12-08 14:00:20 UTC] Start
I, [2025-12-08T23:00:25.200269 #8385]  INFO -- : [foo:ba] [2025-12-08 14:00:25 UTC] End

Rubyでイミュータブルなオブジェクトの作り方

イミュータブルなオブジェクトをつくる方法のひとつとして、Dataを利用する方法をメモしておく。

class UserEntity < Data.define(:name, :is_deleted)
  alias deleted? is_deleted
end

という感じで、Dataを継承する。 利用する方法は、以下のイメージ。

entity = UserEntity.new(
  name: "氏名",
  is_deleted: false
)

entity.name # => "氏名"
entity.deleted? # => false

Sentryのdelayed_job.report_after_job_retriesが想定どおりに動作しなかった

目的

DelayedJobのリトライ試行中はSentryからの通知を抑制し、最後までリトライをしてエラーになれば通知するようにしたかった。

方法

Sentryには delayed_job.report_after_job_retries という設定があり、これを true にすることでリトライ中にエラー通知はしないようにできる。

docs.sentry.io

問題

以下のように設定をした。

しかし、DelayedJobの最後のリトライでエラーが発生しても通知が来なくなってしまった。

それどころか、これまで3回リトライしてエラーがあれば3回通知が来ていたのが、エラーが発生しても通知が全く来なくなってしまった。

Sentry.init do |config|
  config.dsn = ENV['SENTRY_DSN']
...
  config.delayed_job.report_after_job_retries = true
end

原因

sentry-rubyのgemのバージョンに問題があった。

利用していたバージョンは 5.10.0 だった。その実装を確認するとDelayed::Worker.max_attemptsのみを比較していた。

https://github.com/getsentry/sentry-ruby/blob/eb826b6951a30ac91891b37da881378f9905af88/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb#L85

      def self.report?(job)
        return true unless Sentry.configuration.delayed_job.report_after_job_retries

        # We use the predecessor because the job's attempts haven't been increased to the new
        # count at this point.
        job.attempts >= Delayed::Worker.max_attempts.pred
      end

自分はDelayedのmax_attempts25 に設定していた。

Delayed::Worker.max_attempts = 25

そして実行していたJobのmax_attempts3 に設定していた。

class SampleJob < ApplicationJob
  def perform
    ...
  end
    ...
  def max_attempts
    3
  end
end

つまり期待としては3回目のリトライでエラーになったら通知をしてほしかったのだが、実態は25回目でエラーになったら通知するようになってしまっていた。

解決方法

CHANGELOG をみると、バージョン5.14 に以下のような記載があった。

Respect custom Delayed::Job.max_attempts if it's defined

実装をみるとjobのmax_attemptを優先するようになっていた。

https://github.com/getsentry/sentry-ruby/blob/5.14.0/sentry-delayed_job/lib/sentry/delayed_job/plugin.rb

      def self.report?(job)
        return true unless Sentry.configuration.delayed_job.report_after_job_retries

        # We use the predecessor because the job's attempts haven't been increased to the new
        # count at this point.
        max_attempts = job&.max_attempts&.pred || Delayed::Worker.max_attempts.pred
        job.attempts >= max_attempts
      end

ちなみに現時点の最新である 5.27.0 も同様の実装となっていたので、最新バージョンにあげた。

結果

期待するような動作になった。

ライブラリが想定どおりの動作をしない場合いは一次情報(ソースコード)を見るのが一番。 かつ利用しているバージョンのソースをちゃんと確認することが必要だと改めて感じた。

carrierwave で webmock を利用する場合

carrierwave が利用されている箇所で、webmockを利用してstub化すると、指定していないIPアドレスへのリクエストをしてしまう。

     Failure/Error:
...     
     WebMock::NetConnectNotAllowedError:
       Real HTTP connections are disabled. Unregistered request: GET http://23.215.0.138/test.png with headers {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Host'=>'example.com', 'User-Agent'=>'CarrierWave/3.0.5'}
     
       You can stub this request with the following snippet:
     
       stub_request(:get, "http://23.215.0.138/test.png").
         with(
           headers: {
          'Accept'=>'*/*',
          'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
          'Host'=>'example.com',
          'User-Agent'=>'CarrierWave/3.0.5'
           }).
         to_return(status: 200, body: "", headers: {})
     
       ============================================================

これは、carrierwave がResolv.getaddresses を利用して、指定された URL のドメイン名を IP アドレスとして解決するため。その IP アドレスを使ってリソースを取得しようとしている。

cf.)

stackoverflow.com

なので、Resolv.getaddresses をmockして固定のIPを返す必要がある。

before

    before do
      WebMock.enable!

      WebMock.stub_request(:get, "http://example.com/test.png")
        .to_return(
          body: File.new(Rails.root.join("spec/fixtures/test.png")),
          status: 200,
          headers: {
            "Content-Type" => "image/png"
          }
        )
    end

    after do
      WebMock.disable!
    end

after

    before do
      WebMock.enable!

      allow(Resolv).to receive(:getaddresses).with("example.com").and_return(["5.5.5.5"])

      WebMock.stub_request(:get, "http://5.5.5.5/test.png")
        .to_return(
          body: File.new(Rails.root.join("spec/fixtures/test.png")),
          status: 200,
          headers: {
            "Content-Type" => "image/png"
          }
        )
    end

    after do
      WebMock.disable!
    end

特定のGemのPatchバージョンのみを最新にする方法

Gemのバージョンアップをする場合に、ある特定のGemのPatchのみを最新にしたい場合がある。 よくその方法を忘れるのでメモしておく。

$ bundle update {gem名} --conservative --patch

オプションについては以下を参照する。

cf.)

bundler.io

解決策:Ubuntuパッケージのインストール障害への対応

CircleCIで、

...
commands:
  setup:
    steps:
      - run:
          name: "apt-get install -y"
          command: |
            sudo apt update -y
            sudo apt-get install -y --fix-missing pkg-config imagemagick libmagick++-dev p7zip-full
...

を最初に実行してパッケージのインストールをしていた。

先日の archive.ubuntu.com の障害以降、archive.ubuntu.com への接続が不安定になった。

https://status.canonical.com/ をみると正常に稼働している。 世間的にも大きな問題にはなっていないが、なぜかCIで並列実行しているといくつかのJobで、以下のようなエラーとなり正常に終わらずに困った。

sudo apt update -y
sudo apt-get install -y --fix-missing pkg-config imagemagick libmagick++-dev p7zip-full


Get:1 https://download.docker.com/linux/ubuntu jammy InRelease [48.8 kB]
Get:2 http://archive.ubuntu.com/ubuntu jammy InRelease [270 kB]                
Get:3 http://archive.ubuntu.com/ubuntu jammy-updates InRelease [128 kB]        
(中略)
E: Failed to fetch http://archive.ubuntu.com/ubuntu/pool/main/libw/libwmf/libwmf-0.2-7_0.2.12-5ubuntu1_amd64.deb  403  Forbidden [IP: 185.125.190.83 80]
E: Failed to fetch http://archive.ubuntu.com/ubuntu/pool/main/libw/libwmf/libwmf-dev_0.2.12-5ubuntu1_amd64.deb  403  Forbidden [IP: 185.125.190.83 80]
E: Sub-process /usr/bin/dpkg returned an error code (1)

Exited with code exit status 100

色々と試したが、結局↓のようにして解消をした。

sudo sed -i.org -e 's|archive.ubuntu.com|ubuntutym.u-toyama.ac.jp|g' /etc/apt/sources.list
sudo apt update -y
sudo apt-get install -y --fix-missing pkg-config imagemagick libmagick++-dev p7zip-full

ただ根本的な原因等は依然として不明のままなので、気になっている。

Railsのcomposed_ofの使い方

Railscomposed_of の使い方をメモしておく。

巷にはcomposed_ofの説明がたくさんある。 しかし自分が調べてもサクッと分からず、自前で実装して確認したことを残しておく。

railsdoc.com

前提

  • composed_of にする対象のカラムは json

実装例

  • class_name のValueObject(モデル)にインナークラスを利用する場合には、class_name: "ExternalSetting::ServiceConfig" のように指定することで利用できる。
  • mapping にはマッピングをしたい[%w(当該モデルのメソッド名 class_nameで指定したValueObjectのメソッド名), ... で指定する
  • ValueObjectの中で構造体を利用してJsonのデータを設定できるようにする
# == Schema Information
#
# Table name: external_settings
#
#  id                    :bigint           not null, primary key
#  external_service      :integer          default("default_setting"), not null
#  service_config        :json
#  created_at            :datetime         not null
#  updated_at            :datetime         not null
#
class ExternalSetting < ApplicationRecord
  composed_of(
    :service_config,
    class_name: "ExternalSetting::ServiceConfig",
    mapping: [%w(external_service service), %w(service_config config)],
    allow_nil: true
  )

  enum external_service: { default_setting: 0 }

  class ServiceConfig
    attr_reader :service, :config

    DefaultConfig = Struct.new(:usase) do
      def foo?
        usase == :foo
      end

      ....
    end

    def initialize(external_service, service_config)
      @service = external_service.to_i
      @config = service_config

      if service == ExternalSetting.external_services[:default_setting]
        @default_setting_config = DefaultConfig.new(get_value(:usase))
      end
    end

    private

    def get_value(key)
      config.present? && config[key]
    end
  end
end

利用方法

config = { usase: :foo }
external_setting = ExternalSetting.first
service_config = ExternalSetting::ServiceConfig.new(external_setting.external_service, config)
external_setting.service_config = service_config
external_setting.save
external_setting.service_config.default_config.foo?