※当サイトの記事には、広告・プロモーションが含まれます。

Oxidizedでmulti-threadに関連するissueを覗いたら闇が深すぎた話

gigazine.net

中国のAI企業・DeepSeekが2025年1月20日にリリースした推論モデルの「DeepSeek-R1-Zero」と「DeepSeek-R1」は、OpenAIの推論モデル・o1相当の性能でありながらo1と比べてトレーニングコストが非常に低いことや、MITライセンスの下でオープンソース化されていることが特徴です。

DeepSeek-R1がオープンソースでなければならなかった理由、そしてなぜOpenAIを倒せないのか - GIGAZINE

AIテクノロジー企業のLagoが、「なぜDeepSeekは自社のAIモデルをオープンソース化したのか」について解説しています。

DeepSeek-R1がオープンソースでなければならなかった理由、そしてなぜOpenAIを倒せないのか - GIGAZINE

Lagoによると、DeepSeekがAIモデルをオープンソース化した理由は、DeepSeekが中国企業であるためとのこと。実際にアメリカでは中国を含む敵対国によってAI関連アプリケーションが軍事転用されるという懸念から、中国へのAIチップの輸出規制を行っています。

DeepSeek-R1がオープンソースでなければならなかった理由、そしてなぜOpenAIを倒せないのか - GIGAZINE

こうした要因から、DeepSeek-R1も欧米市場では懐疑的な目を向けられたり、利用に不安視されたりすることは避けられません。そこでDeepSeekはAIモデルをオープンソース化することで、ユーザーならびに開発者からの信用を得ようとしているというわけです。

DeepSeek-R1がオープンソースでなければならなかった理由、そしてなぜOpenAIを倒せないのか - GIGAZINE

Lagoはこうしたモデルのオープンソース化について「オープンソース化は単なる技術的な行動ではなく、市場での足場を固めるための文化的な行動でもある」と述べています。

DeepSeek-R1がオープンソースでなければならなかった理由、そしてなぜOpenAIを倒せないのか - GIGAZINE

⇧ 開発者にとっては、

  1. 提供されているドキュメントのファインダビリティ(Findability)が良いか
  2. 提供されている機能が容易に実装できるか

など、

  • 目的とする情報に苦労なく辿り着くことが可能か

が解決できるかどうかが重要なんですけどね...

Oxidizedでmulti-threadに関連するissueを覗いたら闇が深すぎた話

「Ruby」製の「Oxidized」というライブラリの利用に際して、スレッド数をデフォルトの30に設定していたのだが、ふと、本当に複数スレッドで処理されているのだろうかと気になっていたのだが、

github.com

⇧ 設定で気になる説明を見つけてしまったのですが、

interval: 3600 #interval in seconds
log: ~/.config/oxidized/log
debug: false
threads: 30 # maximum number of threads
# use_max_threads:
# false - the number of threads is selected automatically based on the interval option, but not more than the maximum
# true - always use the maximum number of threads
use_max_threads: false

⇧ とあり、「use_max_threads」という設定項目について、

  • false
    • the number of threads is selected automatically based on the interval option, but not more than the maximum
  • true
    • always use the maximum number of threads

という説明になっており、「use_max_threads: false」の設定の場合に、自動的にスレッド数を決めてくれてしまっているらしいのですが、どのような条件で決定されているのかについては全く説明が無く皆目見当が付かない...

で、「Oxidized」のスレッドが生成される仕組みについてのissueが上がっているのですが、

github.com

⇧ 具体的な算出方法については解説されていないので、ソースコードを見た感じでは、

■https://github.com/ytti/oxidized/blob/master/lib/oxidized/jobs.rb

module Oxidized
  class Jobs < Array
    AVERAGE_DURATION  = 5   # initially presume nodes take 5s to complete
    MAX_INTER_JOB_GAP = 300 # add job if more than X from last job started
    attr_accessor :interval, :max, :want

    def initialize(max, use_max_threads, interval, nodes)
      @max = max
      @use_max_threads = use_max_threads
      # Set interval to 1 if interval is 0 (=disabled) so we don't break
      # the 'ceil' function
      @interval = interval.zero? ? 1 : interval
      @nodes = nodes
      @last = Time.now.utc
      @durations = Array.new @nodes.size, AVERAGE_DURATION
      duration AVERAGE_DURATION
      super()
    end

    def push(arg)
      @last = Time.now.utc
      super
    end

    def duration(last)
      if @durations.size > @nodes.size
        @durations.slice! @nodes.size...@durations.size
      elsif @durations.size < @nodes.size
        @durations.fill AVERAGE_DURATION, @durations.size...@nodes.size
      end
      @durations.push(last).shift
      @duration = @durations.inject(:+).to_f / @nodes.size # rolling average
      new_count
    end

    def new_count
      @want = if @use_max_threads
                @max
              else
                ((@nodes.size * @duration) / @interval).ceil
              end
      @want = 1 if @want < 1
      @want = @nodes.size if @want > @nodes.size
      @want = @max if @want > @max
    end

    def increment
      # Increments the job count if safe to do so, which means:
      # a) less threads running than the total amount of nodes
      # b) we want less than the max specified number of threads

      @want = [(@want + 1), @nodes.size, @max].min
    end

    def work
      # if   a) we want less or same amount of threads as we now running
      # and  b) we want less threads running than the total amount of nodes
      # and  c) there is more than MAX_INTER_JOB_GAP since last one was started
      # then we want one more thread (rationale is to fix hanging thread causing HOLB)
      return unless @want <= size && @want < @nodes.size

      return unless @want <= size

      increment if (Time.now.utc - @last) > MAX_INTER_JOB_GAP
    end
  end
end

⇧ とあって、相変わらず、「Ruby」のインスタンス変数がグローバル変数のような感じで追跡し辛過ぎるんだが、

■抜粋:https://github.com/ytti/oxidized/blob/master/lib/oxidized/jobs.rb

((@nodes.size * @duration) / @interval).ceil

⇧ の部分が、「use_max_threads: false」の設定の場合のスレッド数になるらしいのだが、このロジックだと、「対象機器」の数が少ないと、常に1スレッドでしか動作しないことになりそうなんよね...

そもそも、

■抜粋:https://github.com/ytti/oxidized/blob/master/lib/oxidized/jobs.rb

@durations = Array.new @nodes.size, AVERAGE_DURATION

⇧「@nodes.size」の値をどこで設定しているのかが分からないので「@duration」の数も定まらず何とも言えないのだが、「@interval」は設定ファイルにあった「interval: 3600 #interval in seconds」の値のことだとは思うので、仮に、この値が「1日」間隔である「interval: 86400」というような設定になっている場合、「対象機器」の数によっては、シングルスレッドでの直列処理になってしまうので、期待した時間内に処理しきれない問題が出てくる気がするんだが...

ちなみに、

■https://github.com/ytti/oxidized/blob/master/lib/oxidized/worker.rb

module Oxidized
  require 'oxidized/job'
  require 'oxidized/jobs'
  class Worker
    def initialize(nodes)
      @jobs_done  = 0
      @nodes      = nodes
      @jobs       = Jobs.new(Oxidized.config.threads, Oxidized.config.use_max_threads, Oxidized.config.interval, @nodes)
      @nodes.jobs = @jobs
      Thread.abort_on_exception = true
    end

    def work
      ended = []
      @jobs.delete_if { |job| ended << job unless job.alive? }
      ended.each      { |job| process job }
      @jobs.work

      while @jobs.size < @jobs.want
        Oxidized.logger.debug "lib/oxidized/worker.rb: Jobs running: #{@jobs.size} of #{@jobs.want} - ended: #{@jobs_done} of #{@nodes.size}"
        # ask for next node in queue non destructive way
        nextnode = @nodes.first
        unless nextnode.last.nil?
          # Set unobtainable value for 'last' if interval checking is disabled
          last = Oxidized.config.interval.zero? ? Time.now.utc + 10 : nextnode.last.end
          break if last + Oxidized.config.interval > Time.now.utc
        end
        # shift nodes and get the next node
        node = @nodes.get
        node.running? ? next : node.running = true

        @jobs.push Job.new node
        Oxidized.logger.debug "lib/oxidized/worker.rb: Added #{node.group}/#{node.name} to the job queue"
      end

      if cycle_finished?
        run_done_hook
        exit 0 if Oxidized.config.run_once
      end
      Oxidized.logger.debug("lib/oxidized/worker.rb: #{@jobs.size} jobs running in parallel") unless @jobs.empty?
    end

    def process(job)
      node = job.node
      node.last = job
      node.stats.add job
      @jobs.duration job.time
      node.running = false
      if job.status == :success
        process_success node, job
      else
        process_failure node, job
      end
    rescue NodeNotFound
      Oxidized.logger.warn "#{node.group}/#{node.name} not found, removed while collecting?"
    end

    def reload
      @nodes.load
    end

    private

    def process_success(node, job)
      @jobs_done += 1 # needed for :nodes_done hook
      Oxidized.hooks.handle :node_success, node: node,
                                           job:  job
      msg = "update #{node.group}/#{node.name}"
      msg += " from #{node.from}" if node.from
      msg += " with message '#{node.msg}'" if node.msg
      output = node.output.new
      if output.store node.name, job.config,
                      msg: msg, email: node.email, user: node.user, group: node.group
        node.modified
        Oxidized.logger.info "Configuration updated for #{node.group}/#{node.name}"
        Oxidized.hooks.handle :post_store, node:      node,
                                           job:       job,
                                           commitref: output.commitref
      end
      node.reset
    end

    def process_failure(node, job)
      msg = "#{node.group}/#{node.name} status #{job.status}"
      if node.retry < Oxidized.config.retries
        node.retry += 1
        msg += ", retry attempt #{node.retry}"
        @nodes.next node.name
      else
        # Only increment the @jobs_done when we give up retries for a node (or success).
        # As it would otherwise cause @jobs_done to be incremented with generic retries.
        # This would cause :nodes_done hook to desync from running at the end of the nodelist and
        # be fired when the @jobs_done > @nodes.count (could be mid-cycle on the next cycle).
        @jobs_done += 1
        msg += ", retries exhausted, giving up"
        node.retry = 0
        Oxidized.hooks.handle :node_fail, node: node,
                                          job:  job
      end
      Oxidized.logger.warn msg
    end

    def cycle_finished?
      if @jobs_done > @nodes.count
        true
      else
        @jobs_done.positive? && (@jobs_done % @nodes.count).zero?
      end
    end

    def run_done_hook
      Oxidized.logger.debug "lib/oxidized/worker.rb: Running :nodes_done hook"
      Oxidized.hooks.handle :nodes_done
    rescue StandardError => e
      # swallow the hook erros and continue as normal
      Oxidized.logger.error "lib/oxidized/worker.rb: #{e.message}"
    ensure
      @jobs_done = 0
    end
  end
end    

⇧ ログの出力を信じるならば、「Worker」クラスがnewされる時に呼び出されるらしい「initialize」メソッドの引数に「nodes」が渡されているので、「Worker」クラスがnewしている箇所を追跡してみると、

■https://github.com/ytti/oxidized/blob/master/lib/oxidized/core.rb

module Oxidized
  class << self
    def new(*args)
      Core.new args
    end
  end

  class Core
    class NoNodesFound < OxidizedError; end

    def initialize(_args)
      Oxidized.mgr = Manager.new
      Oxidized.hooks = HookManager.from_config(Oxidized.config)
      nodes = Nodes.new
      raise NoNodesFound, 'source returns no usable nodes' if nodes.empty?

      @worker = Worker.new nodes
      @need_reload = false

      # If we receive a SIGHUP, queue a reload of the state
      reload_proc = proc do
        @need_reload = true
      end
      Signals.register_signal('HUP', reload_proc)

      # Initialize REST API and webUI if requested
      if Oxidized.config.rest?
        begin
          require 'oxidized/web'
        rescue LoadError
          raise OxidizedError, 'oxidized-web not found: sudo gem install oxidized-web - \
          or disable web support by setting "rest: false" in your configuration'
        end
        @rest = API::Web.new nodes, Oxidized.config.rest
        @rest.run
      end
      run
    end

    private

    def reload
      Oxidized.logger.info("Reloading node list and log files")
      @worker.reload
      Oxidized.logger.reopen
      @need_reload = false
    end

    def run
      Oxidized.logger.debug "lib/oxidized/core.rb: Starting the worker..."
      loop do
        reload if @need_reload
        @worker.work
        sleep Config::SLEEP
      end
    end
  end
end

⇧「Core」クラスで「nodes = Nodes.new」した値が大元らしいので、「Nodes」クラスを確認しに行ってみると、

■https://github.com/ytti/oxidized/blob/master/lib/oxidized/nodes.rb

module Oxidized
  require 'ipaddr'
  require 'oxidized/node'
  class NotSupported < OxidizedError; end
  class NodeNotFound < OxidizedError; end

  class Nodes < Array
    attr_accessor :source, :jobs
    alias put unshift
    def load(node_want = nil)
      with_lock do
        new = []
        @source = Oxidized.config.source.default
        Oxidized.mgr.add_source(@source) || raise(MethodNotFound, "cannot load node source '#{@source}', not found")
        Oxidized.logger.info "lib/oxidized/nodes.rb: Loading nodes"
        nodes = Oxidized.mgr.source[@source].new.load node_want
        nodes.each do |node|
          # we want to load specific node(s), not all of them
          next unless node_want? node_want, node

          begin
            node_obj = Node.new node
            new.push node_obj
          rescue ModelNotFound => e
            Oxidized.logger.error "node %s raised %s with message '%s'" % [node, e.class, e.message]
          rescue Resolv::ResolvError => e
            Oxidized.logger.error "node %s is not resolvable, raised %s with message '%s'" % [node, e.class, e.message]
          end
        end
        size.zero? ? replace(new) : update_nodes(new)
        Oxidized.logger.info "lib/oxidized/nodes.rb: Loaded #{size} nodes"
      end
    end

    def node_want?(node_want, node)
      return true unless node_want

      node_want_ip = (IPAddr.new(node_want) rescue false)
      name_is_ip   = (IPAddr.new(node[:name]) rescue false)
      # rubocop:todo Lint/DuplicateBranch
      if name_is_ip && (node_want_ip == node[:name])
        true
      elsif node[:ip] && (node_want_ip == node[:ip])
        true
      elsif node_want.match node[:name]
        true unless name_is_ip
      end
      # rubocop:enable Lint/DuplicateBranch
    end

    def list
      with_lock do
        map { |e| e.serialize }
      end
    end

    def show(node)
      with_lock do
        i = find_node_index node
        self[i].serialize
      end
    end

    # Returns the configuration of group/node_name
    #
    # #fetch is called by oxidzed-web
    def fetch(node_name, group)
      yield_node_output(node_name) do |node, output|
        output.fetch node, group
      end
    end

    # @param node [String] name of the node moved into the head of array
    def next(node, opt = {})
      return if running.find_index(node)

      with_lock do
        n = del node
        n.user = opt['user']
        n.email = opt['email']
        n.msg  = opt['msg']
        n.from = opt['from']
        # set last job to nil so that the node is picked for immediate update
        n.last = nil
        put n
        jobs.increment if Oxidized.config.next_adds_job?
      end
    end
    alias top next

    # @return [String] node from the head of the array
    def get
      with_lock do
        (self << shift).last
      end
    end

    # @param node node whose index number in Nodes to find
    # @return [Fixnum] index number of node in Nodes
    def find_node_index(node)
      find_index(node) || raise(NodeNotFound, "unable to find '#{node}'")
    end

    # Returns all stored versions of group/node_name
    #
    # Called by oxidized-web
    def version(node_name, group)
      yield_node_output(node_name) do |node, output|
        output.version node, group
      end
    end

    def get_version(node_name, group, oid)
      yield_node_output(node_name) do |node, output|
        output.get_version node, group, oid
      end
    end

    def get_diff(node_name, group, oid1, oid2)
      yield_node_output(node_name) do |node, output|
        output.get_diff node, group, oid1, oid2
      end
    end

    def find_index(node)
      index { |e| [e.name, e.ip].include? node }
    end

    private

    def initialize(opts = {})
      super()
      node = opts.delete :node
      @mutex = Mutex.new # we compete for the nodes with webapi thread
      if (nodes = opts.delete(:nodes))
        replace nodes
      else
        load node
      end
    end

    def with_lock(...)
      @mutex.synchronize(...)
    end

    # @param node node which is removed from nodes list
    # @return [Node] deleted node
    def del(node)
      delete_at find_node_index(node)
    end

    # @return [Nodes] list of nodes running now
    def running
      Nodes.new nodes: select { |node| node.running? }
    end

    # @return [Nodes] list of nodes waiting (not running)
    def waiting
      Nodes.new nodes: select { |node| not node.running? }
    end

    # walks list of new nodes, if old node contains same name, adds last and
    # stats information from old to new.
    #
    # @todo can we trust name to be unique identifier, what about when groups are used?
    # @param [Array] nodes Array of nodes used to replace+update old
    def update_nodes(nodes)
      old = dup
      # load the Array "nodes" in self (the class Nodes inherits Array)
      replace(nodes)
      each do |node|
        if (i = old.find_node_index(node.name))
          node.stats = old[i].stats
          node.last  = old[i].last
        end
      rescue NodeNotFound
        # Do nothing:
        # when a node is not found, we have nothing to do:
        # it has already been loaded by replace(nodes) and there are no
        # stats to copy
      end
      sort_by! { |x| x.last.nil? ? Time.new(0) : x.last.end }
    end

    def yield_node_output(node_name)
      with_lock do
        node = find { |n| n.name == node_name }
        raise(NodeNotFound, "unable to find '#{node_name}'") if node.nil?

        output = node.output.new
        raise NotSupported unless output.respond_to? :fetch

        yield node, output
      end
    end
  end
end

⇧「Nodes」クラスの「initialize」メソッドが引数無しで呼びされていることから、「Nodes」クラスの「load」メソッドに処理が遷移し、「@source = Oxidized.config.source.default」については、「Config」クラスを参照して欲しいのだが、「asetus.default.source.default = 'csv' # csv, sql」という記述が見られるので、「nodes = Oxidized.mgr.source[@source].new.load node_want」を実行していることから、「Csv」クラスの「load」メソッドに遷移するのだろうと仮定すると、

■https://github.com/ytti/oxidized/blob/master/lib/oxidized/source/csv.rb

module Oxidized
  module Source
    class CSV < Source
      def initialize
        @cfg = Oxidized.config.source.csv
        super
      end

      def setup
        if @cfg.empty?
          Oxidized.asetus.user.source.csv.file      = File.join(Config::ROOT, 'router.db')
          Oxidized.asetus.user.source.csv.delimiter = /:/
          Oxidized.asetus.user.source.csv.map.name  = 0
          Oxidized.asetus.user.source.csv.map.model = 1
          Oxidized.asetus.user.source.csv.gpg       = false
          Oxidized.asetus.save :user
          raise NoConfig, "no source csv config, edit #{Oxidized::Config.configfile}"
        end
        require 'gpgme' if @cfg.gpg?

        # map.name is mandatory
        return if @cfg.map.has_key?('name')

        raise InvalidConfig, "map/name is a mandatory source attribute, edit #{Oxidized::Config.configfile}"
      end

      def load(_node_want = nil)
        nodes = []
        open_file.each_line do |line|
          next if line =~ /^\s*#/

          data = line.chomp.split(@cfg.delimiter, -1)
          next if data.empty?

          # map node parameters
          keys = {}
          @cfg.map.each do |key, position|
            keys[key.to_sym] = node_var_interpolate data[position]
          end
          keys[:model] = map_model keys[:model] if keys.has_key? :model
          keys[:group] = map_group keys[:group] if keys.has_key? :group

          # map node specific vars
          vars = {}
          @cfg.vars_map.each do |key, position|
            vars[key.to_sym] = node_var_interpolate data[position]
          end
          keys[:vars] = vars unless vars.empty?

          nodes << keys
        end
        nodes
      end
    end
  end
end    

⇧ で計算して最終的に「nodes」を戻り値としているっぽい。

「keys[key.to_sym] = node_var_interpolate data[position]」の処理は、「Source」クラスの「node_var_interpolate」メソッドで処理されているらしい。

「継承」を利用しているらしく「Source」クラスが「親」クラスということらしいですな、他にも「open_file」というメソッドが「Source」クラスで定義されている模様、分り辛い...

で、おそらく、「open_file」というメソッドがConfigファイルを処理しているのだろうと仮定して、

■https://github.com/ytti/oxidized/blob/master/docs/Configuration.md

---
username: oxidized
password: S3cr3tx
model: junos
interval: 3600 #interval in seconds
log: ~/.config/oxidized/log
debug: false
threads: 30 # maximum number of threads
# use_max_threads:
# false - the number of threads is selected automatically based on the interval option, but not more than the maximum
# true - always use the maximum number of threads
use_max_threads: false
timeout: 20
retries: 3
prompt: !ruby/regexp /^([\w.@-]+[#>]\s?)$/
crash:
  directory: ~/.config/oxidized/crashes
  hostnames: false
vars:
  enable: S3cr3tx
groups: {}
rest: 127.0.0.1:8888
pid: ~/.config/oxidized/oxidized.pid
input:
  default: ssh, telnet
  debug: false
  ssh:
    secure: false
output:
  default: git
  git:
      user: Oxidized
      email: oxidized@example.com
      repo: "~/.config/oxidized/oxidized.git"
source:
  default: csv
  csv:
    file: ~/.config/oxidized/router.db
    delimiter: !ruby/regexp /:/
    map:
      name: 0
      model: 1
      username: 2
      password: 3
    vars_map:
      enable: 4
model_map:
  cisco: ios
  juniper: junos
  !ruby/regexp /procurve/: procurve    

⇧ が読み込まれるファイルの内容と仮定して、結局のところ、「Csv」クラスの「load」メソッドで算出される「nodes」に最終的に格納されているっぽい「keys」の要素の数はいくつになるのかChatGPTに質問したところ、

■『「keys」の要素の数はいくつになるのか』に対してのChatGPTの回答

keys = {
  :name => "router1",     # ノード名
  :model => "junos",      # モデル名(juniper)
  :username => "oxidized", # ユーザー名
  :password => "S3cr3tx",  # パスワード
  :vars => {}             # 変数(今回は空)
}

⇧ という回答が返ってきた。

で、改めて、

■抜粋:https://github.com/ytti/oxidized/blob/master/lib/oxidized/jobs.rb

((@nodes.size * @duration) / @interval).ceil

⇧ の数値がいくつぐらいになるのか見積もりしてみると、

■抜粋:https://github.com/ytti/oxidized/blob/master/lib/oxidized/jobs.rb

    AVERAGE_DURATION  = 5   # initially presume nodes take 5s to complete
    MAX_INTER_JOB_GAP = 300 # add job if more than X from last job started

      @durations = Array.new @nodes.size, AVERAGE_DURATION
      duration AVERAGE_DURATION

    def duration(last)
      if @durations.size > @nodes.size
        @durations.slice! @nodes.size...@durations.size
      elsif @durations.size < @nodes.size
        @durations.fill AVERAGE_DURATION, @durations.size...@nodes.size
      end
      @durations.push(last).shift
      @duration = @durations.inject(:+).to_f / @nodes.size # rolling average
      new_count
    end

⇧ 更に「duration」メソッドで計算処理を実施していることから、スレッド数を算出するロジックが一か所に集約されていないのが分り非常に辛いのだが、仮に、

  1. @nodes.size = 5(ノードの数)
  2. @durations = [5, 5, 5, 5, 5]
    • Array.new @nodes.size, AVERAGE_DURATION ※1
  3. @interval = 86400(間隔、1日の秒数です)

※1 以下、公式のマニュアルによるRubyの標準で用意されているArrayクラスの特異メソッドによる初期化を利用しているっぽい。

docs.ruby-lang.org

の場合にどうなるか、ChatGPTに質問したところ、

■「duration」メソッドの処理で算出される「@duration」の値が、

@duration = 5.0

⇧ となり、

■「((@nodes.size * @duration) / @interval).ceil」の計算

((5 × 5.0) / 86400 ≈ 0.00028935185).ceil
0.00028935185.ceil = 1

⇧ となり、スレッド数は、「1」になるらしい...

つまり、「threads: 30」の設定をしていようが「use_max_threads: false」の設定になっていると、「multi-thread」での処理にならないケースが出てくるという罠...

「Oxidized」の非機能要件がハッキリしないのだが、「対象機器」の数が膨大になる場合は、「use_max_threads: true」とかにして利用するスレッド数を固定するしかない感じなんかね?

それでも、どうしようもない場合は、「Oxidized」のプロセスを複数にする感じなんかね?

「Docker Compose」とかであれば、「Oxidized」が稼働している「Docker」のコンテナ数を増やす感じになるんかな...

コンフリクトしたりしそうなのと、運用が複雑になりそう気がするので、「Oxidized」のプロセスを複数にするのは避けたいところですが...

如何せん、「Oxidized」の開発者の方が想定している利用方法が分からないので、何とも言えませんが...

とりあえず、公式のドキュメントの説明をもう少し充実させて欲しい気はしますな...

設定項目の関係性とか暗黙知な部分があるとどうにもならんので...

毎度モヤモヤ感が半端ない…

今回はこのへんで。