DIGGLE開発者ブログ

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

一人PdMでもできる!「事業開発」はじめの一歩

BtoB事業開発アドカレの記事です

このnoteは「BtoB事業開発アドカレ 」の8日目の投稿です。面白かったらハッシュタグ「 #BtoB事業開発アドカレ 」を付けてシェアいただけますと幸いです。 前回は、問いを磨くこと #BtoB事業開発アドカレ|mai@株式会社iCAREでした。

adventar.org

想定読者

  • SaaS立ち上げフェーズでPdMとして活躍されており、全てを馬力で回しているそこのあなたに向けた記事です。
    • 事業開発したい。でも、開発伴走するのに追われて中々手が出せないなー、、と思っている方には特におすすめ。
  • すでに事業開発が仕組み化、チーム化されている皆様には初歩的すぎて味気ないかもしれません、ごめんなさい。

サマリ

  • PdMは顧客と遠くなりがち、一番顧客に近いCS,Salesの声をいかに拾えるか仕組みづくりが大事
  • ビザスクは軽い気持ちでいろんな人の話が聞けていいぞ!

ちょろっとだけでも是非読んでみてください↓

はじめに

こんにちは、DIGGLE株式会社でPdMをやっている本田です。 現状PdMというロールは私のみで担っている形になっています。ありがたいことに直近ご利用いただいている会社数も右肩上がりに伸びており、忙しい日々を過ごさせてもらってます。

そんな「一人PdMあるある」として、足元の開発デリバリーをデザイナー、開発者と伴走することに追われてしまい中々先を見据えた仕込みができなくなってしまう、、、 みたいなことがあると思っています。 私自身が半年くらい前に事業開発に積極的に関わっていきたい!けど時間がない、、というジレンマに陥っていました。

ただ、やはり事業フェーズ的にも事業開発めちゃ大事!!ということを感じたため、半年前くらいから勇気と工数を振り絞ってチャレンジしてみると、様々な取り組みから成果が生まれ始めました!

今回は「事業開発はじめの一歩」というところで、自分が最近トライをしてみた「コスパの良い」、「スモールスタートな」事業開発について少しご紹介させていただければと思います。

ホリゾンタルSaaSにおける「事業開発」とは

この後の話の前提をすり合わせるために、私が今回話す上での「事業開発」について説明します。

我々はホリゾンタルSaaSというところで、あらゆる業種/業態、規模の「予実管理」におけるペインを解決していくSaaSになっています。

予実管理は非常にカスタマイズ性の高い領域になっており、業種/業態、規模によってペインや勘所も微妙に異なってきます。 したがって、「プロダクトの刺さり具合(価値を感じていただけるか)」の度合いは各セグメントごとによって変化してきます。

その中では、「現状の獲得できているセグメント」から「新たにどのセグメントに注力していくか」が大事になっていきます。 今回の「事業開発」は「新たにどのセグメントに注力していくか」をどの方向性にするか決める、実行するということについて話しているということを前提におきたいと思います。

事業開発何から始めたか

①CSチームで行っていたサクセス/チャーン顧客分析MTGに参加

まずは基本の「き」となる部分かもしれませんが、現状顧客をしっかりと知るところからスタートしました。

具体的には、サクセス移行、チャーン移行したお客様の経緯などを分析する座組みをCSチーム内でされていたのですが、そこにお邪魔させていただき担当CSとのディスカッションを行いました。

ここでのポイントとしては、お客様の時間をいただいて直接インタビューするのではなく、担当CSを介した情報を利用するという点です。 一次情報ではないため、多少のバイアスがかかった情報であることを前提に置く必要はあります。しかし、大まかな仮説を考えていく上では短時間でまとまった情報が仕入れられるというメリットがあります。

それぞれの会でヒアリングする方向性は若干異なります。サクセス分析では「勝ち筋」、チャーン分析では「負け筋」の探索をするように意識しています。

N=1の事象なのでつい具体的な良かった、ダメだった施策に目が行きがちですが全体感を捉えることを重要視しています。 具体的には、下記のようなポイントを聞く際には意識しています。

②お客様からのポジティブなフィードバックを収集するチャンネル作成、運用

2つ目の取り組みとしては、ポジティブなフィードバックのデータを収集し始めました。 「フィードバック」というと要望(例.OO機能が欲しいなど)などの比較的ネガティブ寄りなものが一般的に収集されやすい結構にあります。

しかし、先述したように「勝ち筋」を探索する上ではお客様に刺さっている場所を特定することも大事だと考えています。

①のサクセス移行分析だけでは、限定的なお客様が対象となってしまいます。より広範囲でライトにフィードバックをいただくために 「#20-product-voice_of_customer」というチャンネルをslack上に作成しました。

こちらも実際の一次情報ではないため、フィードバックを受けた背景情報など、バイアスがなるべく無いよう見極める必要もあります。しかし、「こんな刺さり方もあるのか!」など意外な気づきもあり、新たな仮説を生み出すことにつながっています!

↓実際のSlackチャンネル(お見せできない情報ばかりで恐縮ですが、、)

③ビザスクを利用して、スポットコンサルを依頼する

最後にスポットコンサルを依頼できる「ビザスク」を依頼し、「ある業界に詳しい方」にインタビューをするということを行いました。 ①、②で得た粗い仮説をもとにより深掘りたい話や、仮説をもとに壁打ち的に検証したいことなどをスピーディに検証したいときにに利用させていただいています。(回し者ではございませんw)

特に我々のようなBtoBサービスにおいては、お客様以外のターゲット層へのインタビューの難易度はBtoCサービスよりも高いということもあり、募集-->日程調整のフローで行えるこの方法を重宝しています。

まとめ

このような3つの具体を実践してみることで、PdMの日常業務を行いながら中長期に向けた仕込みを少しずつ進めることができています。 「事業開発」というと名前からして、とっつきにくい、大変そうといったイメージがある方もいると思います。

しかし実際には、、

  • そもそも社内には事業開発の種になる情報がたくさん落ちているけど意外と拾いきれていない

  • また社外のリソースをうまく活用することで、コストを低く、社内にない情報を部分的に拾い切れる

ということからやり方次第でスモールスタートができるというのが今回の自分の学びでした。

一方で、今回の方法は手早く始められるのですが、深さは足りていないなと感じているところもあります。今後より精度を上げた事業開発を行うにあたっては時間も取組も増やしていかなければいけないと思っています。

また、今回の自分の取り組み内容として上げたものはCSやSalesのメンバーがお客様と密に接しているという前提があったり、それを積極的にフィードバックしてくれる文化があってこそだと社内メンバーへの感謝を改めて感じました。

最後の最後に

私が入社した2022年8月は30人弱だった組織は1年強で50人を超えそうな勢いになっています。 ありがたい悲鳴ですが、それでもまだまだ全然人が足りていません!プロダクトマネージャー領域は戦略から実行まで超少人数制でやっており、最近手が回ってきていない箇所が顕在化するようになってきてしまいました。

ということで全職種全方位採用強化中です!! さらなる成長を止めないために、皆さんのお力を貸してください!よろしくお願いいたします!!

herp.careers

herp.careers

herp.careers

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 がお送りしました。

運用改善につなげるための Terraform ディレクトリ構成 〜 Modules を用いてリソース定義を DRY にした話 〜

DIGGLE ではインフラのコード管理(IaC: Infrastructure as Code)のツールとして Terraform を利用しています。

www.terraform.io

私たちの開発組織では「自動化・効率化できるものはなるべくして、人的ミスや工数の無駄を無くすこと」を大切にしており、そのための手段として Terraform が有力であると考えているためです。

Terraform のコードを書く上で悩ましいポイントの一つにディレクトリ構成があると思います。 DIGGLE ではつい最近、メンテナンス性の向上を目的にディレクトリ構成の見直しを行いました。 本記事では、その見直しの内容について紹介します。

前提

はじめに前提情報として、DIGGLE のインフラ環境を共有します。

  • 運用してるプロダクトは一つ
  • AWS を用いてインフラ環境を構築
    • AWS以外のサービスはTerraformで管理していません
  • 本番環境の他に検証環境(ステージング環境)が複数存在
    • 全ての環境が Terraform による管理の対象

上記となっています。

従来のディレクトリ構成

見直し前のディレクトリ構成は以下のようになっていました。

aws
 ├ production    # 本番環境用のリソース定義
 │  ├ main.tf
 │  ├ variables.tf
 │  ├ <resource>.tf
 │  └ ...
 ├ staging-1    # 検証環境用のリソース定義
 │  └ ...
 └ ...

<resource>.tf は AWS リソースの種類ごとにリソース定義を分割して格納しているファイルです。例えば ECS に関連する ECR や ECS サービスのリソース定義は ecs.tf というファイルにまとめています。)

構成としては、環境ごとにディレクトリを分けてその中にリソース定義を各々書くというシンプルな形でした。

事業の立ち上げ期で本番環境しかないような状態でコードが書かれて、後から検証環境が必要になり本番環境用のコードを複製して... のようなあるあるパターンで生まれがちな構成だと思います。

環境が少ないうちは大して問題はありませんでしたが、組織が大きくなり検証環境の数も増えていく中で以下のような問題が出てきました。

  • ファイルやリソース定義の数が多いため、環境間の差分が分かりにくく比較が難しい
    • 各リソースのパラメータ
    • 特定の環境にしか存在しないリソース
  • リソース追加などの構成変更作業の手間が大きい
    • 同じリソース定義を環境の数分書く必要がある

これらの問題を放置しておくと環境が増えるごとにインフラの構成変更の作業コストが増えてしまうことが目に見えていたため、ディレクトリ構成の見直しを行うことにしました。

見直しの方針

今回の構成見直しにおける大方針は「環境間(本番環境、検証環境)でのリソース定義の重複をなくす(= DRY にする)」です。

各環境のディレクトリにある大半のコードが重複しているということが問題の根本原因のため、重複部分を共通化していく仕組みを取り入れました。

Modules vs Workspace

Terraform でのリソース定義の共通化の手段としては Modules と Workspace が候補にあがります。

Modules はリソース定義の一群を再利用可能なテンプレートとしてパッケージ化する仕組みです。

developer.hashicorp.com

Workspace では workspace という環境の分離単位を作成し、その workspace を切り替えることで同じリソース定義から複数の環境のリソースを作成することが可能です。

developer.hashicorp.com

Workspace は便利な一方で以下のような懸念点がありました。

  • 特定の環境にしか存在しないリソースがある場合に工夫が必要
    • 具体的には count を利用する方法 があるが、環境とリソースの対応を網羅的に把握することが難しい(各リソース定義のcountの有無を確認していく必要があるため)
  • workspace の切り替え忘れによる誤操作
    • Terraform Cloud を使う分には無視できるかもしれない
  • 環境の存在自体をコード化できない
    • コードからはどのような環境が存在しているか分からない

そのため、DIGGLE では Modules を採用して構成を行っています。

見直し後のディレクトリ構成

見直し後の Terraform のディレクトリ構成は以下の通りです。

aws
 ├ envs    # 環境ごとのリソース定義
 │  ├ production
 │  │  ├ main.tf
 │  │  ├ variables.tf
 │  │  ├ local.tf
 │  │  └ ...
 │  ├ staging-1
 │  └ ...
 └ modules    # 環境間で共通するリソース定義をmoduleとしてまとめる
    ├ base    # サービス間で共通利用されるリソース群
    │  ├ outputs.tf
    │  ├ variables.tf
    │  ├ <resource>.tf
    │  └ ...
    └ services  # サービス単位のリソース定義
      ├ service-A
      │  ├ outputs.tf
      │  ├ variables.tf
      │  ├ <resource>.tf
      │  └ ...
      ├ service-B
      └ ...

解説

従来からの差分として、ディレクトリを大きく envsmodules の二つに分割しています。

envs は環境単位でリソースをまとめる場所であり、環境差異となる部分は全て envs 下にまとまります。 そして、State ファイルも envs 下のディレクトリ単位で別れることになります。 すなわち Terraform の plan や apply を実行する単位での分割ともいえます。

modules ディレクトリには Terraform modules を利用して分割・再利用されるリソース定義を配置しています。

services 配下にはプロダクトを構成するサービスの単位でリソース定義を配置します。
この場合、サービスごとに同じような AWS リソースの定義が生まれ得ますが、この重複は許容することにしています。AWS リソースのまとまりごとにさらに module を作成して module から module を使うネスト構造にすることで回避することもできますが、必要以上に構成が複雑化し module 間の依存関係も考慮しなければならないため採用していません。

base 配下にはサービス間で共通して利用されるリソース群の定義を配置しています。
分かりやすいものだと VPC 等のネットワーク系リソースや Route53 のリソースが該当します。

modules 内のリソース定義は env 内の main.tf から参照する形で利用されます。

# main.tf

module "base" {
  source = "../../modules/base"

  vpc_cidr = "10.0.0.0"
  ...
}
# -> VPC を作成

module "service_a" {
  source = "../../modules/services/service-A"

  vpc_id = module.base.vpc_id
  ...
}
# -> base module で作成された VPC の ID を受け取り、サービス固有のリソースを作成

環境間での差異は main.tf で各 module に与えているパラメータに表れます。 そのため、各環境ディレクトリ配下にある main.tf を確認することで環境ごとの設定を容易に比較することができます。

また、AWS リソースの構成変更を行う場合は modules 内の変更だけで、variables の変更がない限りは envs 内の各環境側の変更は不要なため、従来構成よりも作業コストは削減できます。

補足: 構成変更適用時の運用フローについて

実際に構成変更を行う際には、まず検証環境に変更を適用し動作確認を行い、その後に本番環境へ変更を適用するという形をとると思います。

その場合の構成変更適用には下記の運用フローを想定しています。

  1. staging ブランチにおいて module 下のリソース定義を変更
  2. 検証環境(staging 環境)に terraform apply を実行して変更を適用
  3. staging 環境での動作確認が問題なければ、staging ブランチを main ブランチへマージ
  4. main ブランチで本番環境に terraform apply を実行して変更を適用

本番環境の構成は常に main ブランチの内容と一致することとした上で、検証環境の構成は柔軟に変更ができるという方針による運用となっています。

おわりに

今回はメンテナンス性向上のための Terraform ディレクトリ構成の改善について紹介しました。

「terraform ディレクトリ構成」でネット検索すると多くのプラクティスがヒットしますが、あらゆる組織にフィットするような決定版といえるものはないと考えています。 本記事で紹介したものも2023年時点の DIGGLE に最適だと考える構成であり絶対的な正解というわけではないため、数ある事例の一つとして参考にしていただけると幸いです。

We're hiring!

DIGGLE では共にプロダクトを開発してくれるエンジニアを大募集中です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

React の Global State 管理で Jotai を採用した話

こんにちは。 DIGGLE エンジニアの ito です。 九月末になり、厳しい残暑に少しずつ終わりが見え始めた中いかがお過ごしでしょうか?

ここ数年は、残暑という名だけで本格的な暑さが続いているように感じています。 私はDIGGLEエンジニアという身分の他に自前の田んぼでお米を育てている兼業農家という身分もあるため、毎年稲刈りの時期までこの厳しい残暑が続いてしまうと困るなと思っています。 ですが、今年も無事残暑が落ち着いてから稲刈りを実施することができて安心しました。

毎年稲を刈る度に新しいライブラリが現れるフロントエンド界隈ですが、最近の DIGGLE のフロントエンドでは従来 ContextAPI で行なっていた React の Global State 管理を見直し、移行先として有力な Recoil と Jotai を比較した上で Jotai を導入することに決定しました。 導入決定の経緯と導入する際の工夫について今回はお話しさせていただきます。

React の Global State 管理に関して

React ではアプリケーションの規模が大きくなるとしばしば Global State を導入することになると思います。 導入理由は、コンポーネントを細分化していくにあたってstate/props のバケツリレーの階層が深くなり可読性が落ちていくためなど様々だと思われますが、 DIGGLE でも例に漏れず可読性向上を目的として Global State を導入して管理を行ってきました。

DIGGLE では従来 Global State として ContextAPI を利用しており、

react.dev

利用目的としては主に可読性向上だったため、Page コンポーネントなどの大元の親コンポーネントで Context を用意して子コンポーネントに伝播させていました。 (私の入社以前(2021年4月以前)には Redux を利用している時期もあったそうなのですが、ボイラープレートの管理が辛くメリットよりもデメリットが上回ったため ContextAPI に切り替えたとのことです)

上記のような使い方で可読性の向上をすることができたのですが、アプリケーションの成熟に伴って下記の問題が発生するようになりました。

  • 無駄な再レンダリングの発生
  • Context の肥大化
  • Provider が乱立

特にDIGGLEでは大きな表を描画する必要があるなど、パフォーマンス向上が必須となる機能特性上、「無駄な再レンダリングの発生」を抑止するために、今回手を入れることになりました。

問題解決に向けた Recoil の検討

「無駄な再レンダリングの発生」は ContextAPI の使い方の問題ではあるものの元々の目的である可読性を落とさずに解決する方法は難しく、setState + ContextAPI の構成の移行先としてよく挙げられる Meta から公開されている Recoil を検討することにしました。

recoiljs.org

Recoil は 2020年5月に Meta によってリリースされた React 向けの状態管理ライブラリで、atom という単位で Global State 管理を行います。

atom はデータを入れるための箱のようなもので atom を定義すると箱が用意され、各種 getter や setter によってデータを出し入れできるようになります。

recoiljs.org

また、レンダリングするか否かはコンポーネントごとに使われている atom の値に変更があるかで実施されます。

ContextAPI Recoil
再レンダリングの判定 Contextの値が変更されたら atomの値が変更されたら
再レンダリングの範囲 Context.Providerで囲われた範囲 atomの値が使われているコンポーネント

Recoilを利用した例

実際にRecoilを利用した場合で、どのように再描画が走るのかを確認したものが下記になります。

書いたコードは下記となっており、カウントをインクリメントするボタンを押してもuseRecoilValue を使っている GrandChildコンポーネントのみ再描画が走っていることがわかります。 また、ContextAPIと変わらず可読性高く記述ができることがわかります。

"use client"
import React from 'react'
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'
import {hobbyAtomState} from "./atoms/HobbyAtom"

const GrandChild = () => {
  const count = useRecoilValue(hobbyAtomState)
  return (
    <div style={{backgroundColor: "blue", padding: "5rem"}}>
      <span>
        {count}
      </span>
    </div>
  )
}

const Child = () => {
  return (
    <div style={{backgroundColor: "yellow", padding: "5rem"}}>
      <GrandChild />
    </div>
  )
}

const Parent = () => {

  const setter = useSetRecoilState(hobbyAtomState)
  return (
    <>
      <div style={{backgroundColor: "green", padding: "5rem"}}>
        <Child />
        <button style={{marginTop: "1rem"}} onClick={()=>setter((count)=>count+1)}>
          count up
        </button>
      </div>
    </>
  )
}

export default function RenderTest(){
  return (
    <RecoilRoot>
      <div>テスト</div>
      <Parent />
    </RecoilRoot>
  );
};

一方で setState + ContextAPI を利用した場合では下記の図のようになり、カウントをインクリメントするボタンを押すとコンポーネント全体が再描画されていることがわかります。

上記検討で可読性を担保したまま再描画を抑えられることがわかったため Recoil での Global State 管理をおこなっていく方針で定めようと考えていました。 ですが、Recoil に懸念があることをある時メンバーから共有してもらったことで再度方針を考え直すことになります。

Recoil の懸念と Jotai の検討

Recoil で検討を進めていたところ DEV チームのメンバーから Recoil の開発継続性に対する不安があることを共有してもらいました。

実際のSlack上でのやり取り

github.com

上記の issue を確認するとRecoil の主要メンテナがレイオフされたり、Jotai に切り替える方がいることがわかります。 Recoil が Meta社によって開発されいてるため優先して検討をしていたのですが、上記状況を踏まえると Meta社による開発という優位性が怪しいものとなったため、Jotai と比較検討することにしました。

結論からお伝えすると Jotai を採用したのですが、採用理由は主に下記になります。

  • TypeScriptで開発されている
  • 開発が活発
  • callback 内などで atom を get する際に通常の atom の get と仕様が変わらない

TypeScriptで開発されている

Jotai は TypeSciprt で開発されているため、TypeScript で開発している DIGGLE のフロントエンドとも相性が良かったです。 型が細かく設定されており、型安全な状態で開発を進めることができました。

開発が活発

Recoil では先述の主要メンテナのレイオフであったり、major ver.の公開がされていないといった問題がありました。 Jotai は既に ver.2 が公開されており、月一以上でコンスタントに更新がされています。

callback 内などで atom を get する際に通常の atom の get と仕様が変わらない

Recoil にも Jotai にも useCallbackに似た hook が用意されています。

Recoil では useRecoilCallback であり、callback 内で atom の値を取得する際に snapshot と呼ばれるものを介して取得することになります。 最初に触った際にはここでも atom の時のように get で値を取得できれば嬉しいなと思っていました。

const logCartItems = useRecoilCallback(({snapshot}) => async () => {
    const numItemsInCart = await snapshot.getPromise(itemsInCart);
    console.log('Items in cart: ', numItemsInCart);
}, []);

recoiljs.org

一方 Jotai では useAtomCallback であり、atom の値を取得する際には atom の getter 同様に get で取得できます。

  const readCount = useAtomCallback(
    useCallback((get) => {
      const currCount = get(countAtom)
      setCount(currCount)
      return currCount
    }, [])
  )

jotai.org

似たような処理で同じような方法をとれることは可読性を上げ、実装する際には戸惑う可能性を減らすように感じました。 好みの問題ではあるのものの、上記の印象から私は Jotai の方が良さそうだと感じています。

Jotai への置き換えに関して

検討を通して Jotai を採用することに決めました。 すでに一部 Recoil で実装をしていた部分があったため、Recoil から Jotai への置き換えが発生しました。 ですが、 Recoil と Jotai には一部を除き仕様に大きな違いがなかったことや本格導入する前だったことから、置き換えの工数はそれほどかかりませんでした。

違いのあった点としては、リスト表示する複数の要素や大きなオブジェクト要素を atom で管理する方法です。

Recoil では atomFamily や selectorFamily といった collection の形でデータを保持するための Utils を用意しており、Recoil を導入した際には両方を使って処理を行なっていました。

import { atomFamily } from 'recoil';
import { Fact } from '@/models';

export const factsAtomState = atomFamily<Fact, number>({
  key: 'atoms.factsAtom',
  default: new Fact(),
});

recoiljs.org

一方 Jotai では atom の中に atom を入れるなど柔軟な表現が可能なため、 配列やオブジェクトを扱う際には、 atoms in atom パターンの利用が提案されていました。

import { atom, PrimitiveAtom } from 'jotai';
import { Fact } from '@/models';

type Facts = { [id: number]: PrimitiveAtom<Fact> };
export const factsAtom = atom<Facts>({});

jotai.org

そのため、置き換えの際に atomFamily や selectorFamily を Jotai 流の書き方に置き換えました。

また、Jotai では大きなオブジェクトを扱う際には focusAtom や splitAtom で分割するなどできます。 Recoil は atom の中に atom が入らないため、オブジェクトを細かく分割した atom を用意していたのですが、Jotai ではそれらをまとめて必要な単位で分割して切り出す形に変更しました。

import { atom } from 'recoil';

export const dateAtomState = atom<string>({
  key: 'components.features.Fact.dateAtom',
  default: '',
});

export const valueAtomState = atom<number>({
  key: 'components.features.Fact.valueAtom',
  default: 0,
});

Recoilの場合

import { atom } from 'jotai';
import { focusAtom } from 'jotai-optics';

type Fact = { date: string; value: number };
export const factAtom = atom<Fact>({ date: '', value: -1 });
export const dateAtom = focusAtom(factAtom, (optic) => optic.prop('date'));
export const numberAtom = focusAtom(factAtom, (optic) => optic.prop('value'));

Jotaiの場合

jotai.org

Jotai では手が届かない部分について

Jotai で気になった点として、 変数のスコープが複雑になりやすいという点があります。

Jotai では Atom を利用するコンポーネントを Provider で囲う必要があります。 ProviderはネストすることができるのですがそれぞれのProviderで独立してatomの値を管理することになり、useAtom などで何も指定せずに取得をすると取得コンポーネントがネストされている Provider の中で一番深いものから値をとってきます。つまり、Provider のネストによって atom のスコープが切られます。

Jotai の Provider による Atom のスコープについて

const RootComponent = () => (
  <Provider> // Provider A
    <Provider> // Provider B
      <ComponentA />
      <Provider> // Provider C
        <ComponentB />
      </Provider>
    </Provider>
  </Provider>
)

DIGGLEでは一覧ページから詳細ページへ遷移した場合や数値表現のプロパティを複数ページで使いまわしたいなど、ページを跨いだ変数のスコープが欲しくなる一方でパスパラメータやクエリパラメータの伝播などページごとに変数のスコープを切りたいものがありました。そのような場合でも一応狙ったProvider の値を取得する方法が Jotai には用意されており、 https://jotai.org/docs/guides/migrating-to-v2-api の Provider's scope prop の項目に方法が記述されています。

const MyContext = createContext()
const store = createStore()

  // Parent component
  <MyContext.Provider value={store}> // Provider A
    <Provider> // Provider B
      <ComponentA />
      <Provider> // Provider C
        <ComponentB />
      </Provider>
    </Provider>
  </MyContext.Provider>

  // ComponentB Component
  const store = useContext(MyContext)
  useAtom(..., { store })

この方法では ContextAPI を使う必要があり、useAtom 時に使用する store を指定することから多用はしづらいように感じました。

現状変数のスコープを切って扱いたいものはパスパラメータやクエリパラメータに限定されていることもあり、どうせ ContextAPI を使う必要があるならと state を使わないことを前提に一部は従来のまま ContextAPI を利用することにしました。

そのため DIGGLE としては今後 Jotai / ContextAPI の組み合わせで使っていく方針としました。

まとめ

DIGGLEでは Jotai / ContextAPI の組み合わせを利用することに決定しました。 Jotai はシンプルでわかりやすく可読性とパフォーマンスを両立しながら今後の開発を行なっていけそうです。 基本的には Jotai を用いて Global State 管理を行い、state を使わずに変数のスコープを切って再利用したいものに関しては ContextAPI を利用していこうと思います。

今回は現時点での DIGGLE の環境において最善と思われる Global State 管理を検討したものになっており、結論は時期/環境によって大きく左右されると思います。 そのため Jotai は素晴らしいライブラリとは思うものの今回の決定に囚われることなく、その時々の状況に応じて適宜方針を検討し直していきたいと考えています。

今回は DIGGLE において Global State 管理を Jotai に決めた経緯を紹介させていただきました。 本記事が皆様の検討の際の一助になれば幸いです。

We're hiring!

予実管理の明日を切り開く為に日々精進している私達と一緒に開発してくれるメンバーを大募集です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Python、Rust上でのデータフレームライブラリで速さ対決してみた!

※ Apache Arrow, Apache Arrow DataFusionは、米国およびその他の国におけるApache Software Foundationの登録商標または商標です。これらのマークの使用は、Apache Software Foundationによる保証を意味するものではありません。

こんにちは。5月入社のソフトウェアエンジニアのhirataと言います。日本全国地獄のように暑い日々ですが、元気で過ごしていますでしょうか?

私は入ったばかりで覚えることが多く毎日アップアップしていますが、それでも計算式を実行するエンジンを作ったり、予実の数字を分析する機能を作ったりして、楽しく過ごしております。

我々のサービス「DIGGLE」で取り扱っている「お客様の経営に関わるデータ」はそこそこ規模の大きいデータになります。このデータを高速に集計・分析できることはお客様への価値向上に直結します。今回はそれに関わる技術調査の一環で、各データフレームライブラリを比較する機会があったので記事にしてみました。

データフレームライブラリとは

データ分析のためのライブラリでPythonのpandasが有名です。2次元のdataframe、1次元のseriesというデータを格納するためのオブジェクトを持ち、データの操作を行う際にはこれらのオブジェクトにデータを格納し、オブジェクトに対して計算内容を指示していきます。

dataframe, seriesに対して一括して計算処理を行うライブラリであり、実際の計算処理はC++言語等で作成されたライブラリ内で行われるため、Python等のスクリプト言語においても大量のデータを高速に処理することができ、コンパイル言語並の高速性とスクリプト言語であることによる柔軟性とを両立できます。

このように書くと、コンパイル言語でデータフレームライブラリを使う恩恵が無いように感じられるかもしれませんが、そんなことはありません。スクリプト言語程の差では無いですが、データのメモリ配置等により大量データの計算に最適化されているため普通にプログラムして計算するよりも高速に処理できます。データ処理以外も必要な場合はコンパイル言語を使用することも選択肢となるでしょう。

ということで、今回はコンパイル言語に匹敵する計算速度が本当に出るのかの検証を、PythonとRustの二つで検証しました。

前提条件

今回の検証では以下の条件で処理時間を検証していきます。

  • ファイルフォーマットはparquet形式。サンプルデータ数は100万行。
  • 処理内容は三要素でgroup byしてsumする。

今回エントリーするライブラリ

今回の検証に使用するライブラリと処理系です。社内の事情で今回はPython3ではなく、Python2で計測を行います。

言語 ライブラリ
Python(2.7.17) pandas(1.1.5) with pyarrow(6.0.1)
Python(2.7.17) polars(0.29.0) lazy
Rust(1.69.0) datafusion(25.0.0)
Rust(1.69.0) polars(0.29.0) lazy

それぞれについての簡単な解説と計算を行うためのソースを以下に載せていきます。

Python pandas について

https://github.com/pandas-dev/pandas

言うまでも無いとは思いますが、データ処理ではデファクトスタンダードとなっているライブラリです。 今回はapache arrowというインメモリのフォーマットを使うためのライブラリであるpyarrowも同時に使っています。 ソースは以下のような感じです。今回は数値計算ライブラリ(blas等)等の高速化のためのライブラリ導入は行っていません。試しに入れて見たところ今回の処理内容では実行速度が変わらなかったため、素のpandasライブラリを使っています。

import time
from pyarrow import fs
import pyarrow.dataset as ds

def main():
    start_time = time.perf_counter()
    
    dataset = ds.dataset("test.parquet", format="parquet")
    dataframe = dataset.to_table(columns=['date', "account_id", "unit_id", "value"]).to_pandas()
    series = dataframe.groupby(["date", "account_id", "unit_id"])["value"].sum()
    
    print('time:', time.perf_counter() - start_time)

if __name__ == '__main__':
    main()
Python polars について

https://github.com/pola-rs/polars

こちらもapache arrowを用いたrust製のデータフレームライブラリのpythonバインディングで、かなり高速との噂です。lazyな実行とeagerな実行の両方ができます。

lazyの方が速いので今回はlazyで評価しています。今回評価を行うためのソースは以下。

import time
import polars as pl

def main():
    start_time = time.perf_counter()
    df = pl.scan_parquet("test.parquet")
    series = df.groupby(["date", "account_id", "unit_id"]).agg(pl.col("value").sum()).collect()
    
    print('time:', time.perf_counter() - start_time, series)
    print(series)

if __name__ == '__main__':
    main()
Rust dafafusion について

https://github.com/apache/arrow-datafusion

apache arrowを用いたrustのデータフレームライブラリです。こちらもApache製の様なのでapache arrowのrust向け公式ライブラリっぽいです。

今回評価を行うためのソースは以下。SQLで書いてますが、SQLじゃなくても速度は変わらずでした。

use std::time;
use std::sync::Arc;
use datafusion::prelude::*;
use datafusion::arrow::record_batch::RecordBatch;

#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
    let now = time::Instant::now();

    let ctx = SessionContext::new();

    let path = "test.parquet";
    
    // 集計用クエリの定義: SQL形式で記述可能
    let opts = ParquetReadOptions::default();
    ctx.register_parquet("facts", &path, opts).await?;
    let df = ctx.sql("SELECT date, account_id, unit_id, sum(value) FROM facts GROUP BY date, account_id, unit_id").await?;
    
    // collect() の実行で実際にクエリがexecuteされる
    let results: Vec<RecordBatch> = df.clone().collect().await?;
    
    let time = now.elapsed();
    println!("time: {:?}, result size: {}", time, df.count().await?);
    Ok(())
}
Rust polars について

https://github.com/pola-rs/polars

上記Python polarsで使用したpolarsのrustバインディングです。高速、省メモリに書けるのがrustのメリットということで、今回の最有力候補です。ソースは以下のとおり。

use polars::prelude::*;
use std::time;

#[tokio::main]
async fn main() {
    let now = time::Instant::now();
    let df = LazyFrame::scan_parquet("test.parquet", Default::default()).unwrap();
    let df = df.groupby(["date", "account_id", "unit_id"])
        .agg([col("value").sum()])
        .collect()
        .unwrap();
    let time = now.elapsed();
    println!("time: {:?}, result size: {}", time, df);
}

結果

以下計測結果です。

⁠実装 ⁠実行時間
Python pandas 0.54s
Python polars 0.76s
Rust datafusion 1.53s
Rust polars 0.25s

やはり期待通りRustでのpolarsが一番速いですが、Python版でもそれほど遅くなっていませんでした。またpandasの速さが予想以上でRust polarsとの差が2倍ほどに収まっています。この測定では処理が途中でライブラリ外に出る事が無いために、pythonオブジェクトに変換するためのオーバーヘッドがほとんど無いせいでしょう。Pythonで書く容易性を考慮すると、この程度の差であれば大抵の場合はpandasで十分ということになりそうです。

またRust datafusionが期待に反して遅いですが、ここはユーザ数が膨大で歴史もあるpandasの最適化具合には敵わなかったといったところでしょうか。「今回のユースケースでは」といったところもあるかと思いますので、皆さんが実際に使用する際には事前に想定されるユースケースでの計測を行う事をお勧めします。

まとめ

pandas素晴らしいですね。気軽に書けてこれだけ速度が出れば、デファクトスタンダードになる訳だなと思いました。

また、これは反省なのですが、実はこのブログを書くにあたりメモリの使用量を取ってなかった事に気がつきました。途中で処理速度にフォーカスした為、取らなくても良いかとその時は思っていました。が、改めて見直してみると、それがどの程度だったのかがとても気になります。メモリが気になりがちな大量データの処理なのでそこを曖昧にしておかずに、きちんとメモリ使用量も計測・記録しておいた方が今後の為に良いデータとなったでしょう。次回に向けての反省点です。

We're hiring!

予実管理の明日を切り開く為に日々精進している我々と一緒に開発してくれるメンバーを大募集です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers

Ruby のバージョンを 3.1 系から 3.2 系にアップデートしたら Ruby on Rails アプリの動きが変わったのを解決した話

私たちは Ruby on Rails の主要なマルチテナントライブラリ apartment を使ってサービスを提供しています。 Ruby のバージョンを 3.1 系から 3.2 系に上げたときに CSV ファイルを処理する部分でこのテナントの切り替えが意図通りに動作しませんでした。 この事象が興味深かったので共有します。

現在はこの事象に対応済で、私たちの環境は Ruby3.2 系で動作しています。

apartment ではマルチテナント対応部分をほとんど吸収してくれるので、アプリケーションのコードのほうにはあまりマルチテナント特有の処理が出てこず、個別処理のコードに集中できるメリットがあります。 事象が発生したコードは以下のような形式でした。

CSV.parse(filename, headers: true, header_converters: ->(header) {
  current_tenant = Apartment::Tenant.current
  # current_tenant を利用したテナント別の処理が入る
})

この事象は、以下のトピックの複合的な組み合わせによって影響が顕在化しました。

  • CSV ライブラリに渡したオプションブロックを実行する土台の Fiber が 3.2.6 から切り替わっている
  • Ruby の 3.1 から 3.2 に上げると CSV ライブラリのバージョンも変わり、動作が切り替わる
  • Thread[]Thread[]= は、Thread ローカルではなくて Fiber ローカルな変数を扱っている
  • Rails がデフォルトで利用しているサーバー Puma ではThreadでリクエストを処理する
  • apartment はリクエストローカルな値を設定するつもりで Thread[]= を使ってしまっている
  • apartment は最近アップデートがなく、最新版を使っていてもこの問題にぶつかる

CSV ライブラリに渡したオプションブロックを実行する土台の Fiber が 3.2.6 から切り替わっている

私たちが作った Ruby プログラムを windows や linux で実行するとき、そのプログラムは Process の上にある Thread の上にある Fiber の上で実行されていると認識してよいでしょう。(異論があれば教えてください m(__)m) その実行の土台となる Fiber が 3.2.5 までと、3.2.6 からは切り替わっています。

CSV ライブラリには header_converters オプションでブロックを渡せます。 https://docs.ruby-lang.org/ja/3.2/method/CSV/s/new.html

Ruby 3.1 系では、実行開始時の Fiber と header_converter の Fiber は同じでした。 Ruby 3.2 系では、実行開始時の Fiber と header_converter の Fiber は異なっていました。

$ ruby -v
ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [arm64-darwin22]
$ ruby -r csv -e 'main_fiber = Fiber.current; CSV.new("id,name,age\n1,2,3\n", headers: true, header_converters: ->(h) { p Fiber.current == main_fiber }).to_a;'
true
true
true


$ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin22]
$ ruby -r csv -e 'main_fiber = Fiber.current; CSV.new("id,name,age\n1,2,3\n", headers: true, header_converters: ->(h) { p Fiber.current == main_fiber }).to_a;'
false
false
false

私はこの現象は特にバグというわけではないと思っています。 ただ、ユーザーは特に言及されていない限り同じ Fiber で実行されるという暗黙の期待を持ってしまいがちかなとも感じているので、ここのミスマッチはトラブルを引き起すことがありそうです。 気をつけたいですね。 ちなみに Fiber は Enumerator で利用されているので、直接は利用していなくても each を利用しているあたりでその影響を受けたりします。 今回の影響も each から next を使うよう変えたときに切り替わったようでした。

Ruby の 3.1 から 3.2 に上げると CSV ライブラリのバージョンも変わり、動作が切り替わる

当初、上記の事象は Ruby 3.1 系から Ruby 3.2 系にアップデートしたときに起きたため、Ruby の言語系で何か変更があったのかなと考えましたが、そうではありませんでした。 Ruby 標準添付ライブラリ csv のバージョンが 3.2.5 から 3.2.6 に上がるときに変わった動作でした。

ただし、この標準添付ライブラリ csv は 3.1 系が 3.2.5、3.2 系が 3.2.6 と切り替わっていました。 言い替えると、Ruby 3.1 系でも、利用する csv のバージョン 3.2.6 へと上げると同じ影響が出ます。

$ ruby -v
ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [arm64-darwin22]
$ ruby -r csv -e 'p CSV::VERSION'
"3.2.5"

$ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin22]
$ ruby -r csv -e 'p CSV::VERSION'
"3.2.6"
Ruby バージョン 標準添付の CSV ライブラリバージョン
3.1.4 3.2.5
3.2.6 3.2.6

Thread[]Thread[]= は、Thread ローカルではなくて Fiber ローカルな変数を扱っている

Ruby では、1 つの Thread 上では複数の Fiber が動きます。Ruby の Thread クラスは、OS が生成するスレッド(ネイティブスレッド)に対応していて、Fiber クラスは Ruby プログラムが生成するスレッド(ユーザレベルスレッド)に対応しています。

Thread[]= は、Thread オブジェクトへと設定するため Thread ごとに設定できる値(Thread ローカル)になると思ってしまいますが、そうではありません。 Fiber ごとに設定できる値(Fiber ローカル)です。 https://docs.ruby-lang.org/ja/3.2/method/Thread/i/=5b=5d=3d.html

以下のように、新しい Fiber を Fiber.new で作った中では Thread[]= で作った値が共有できていません。

irb
irb(main):001:0> Thread.current[:a] = "hello"
=> "hello"
irb(main):002:0> p Thread.current[:a]
"hello"
=> "hello"
irb(main):003:0> Fiber.new { p Thread.current[:a] }.resume
nil
=> nil

るりまの Thread[]= の説明にもある通り、Thread#thread_variable_setThread#thread_variable_get を使うことで Thread ローカルな変数を扱えます https://docs.ruby-lang.org/ja/3.2/method/Thread/i/thread_variable_set.html

irb
irb(main):001:0> Thread.current.thread_variable_set(:a, "hello")
=> "hello"
irb(main):002:0> p Thread.current.thread_variable_get(:a)
"hello"
=> "hello"
irb(main):003:0> Fiber.new { p Thread.current.thread_variable_get(:a) }.resume
"hello"
=> "hello"

Rails がデフォルトで利用しているサーバー Puma ではThreadでリクエストを処理する

https://github.com/puma/puma/blob/v6.3.0/docs/architecture.md#how-requests-work のアーキテクチャのとおり、Puma では 1 リクエストを処理するのに 1 スレッドが対応しています。そこでリクエストの処理を開始するときに、Thread ローカルな変数に値を格納しておけば、そのリクエストの処理が終わるまで同じ値を取得できて都合がよいです。言い替えるとリクエストローカルな変数を扱うのに、Thread ローカルがばっちり対応しているということです。

apartment ではリクエストの内容からどのテナントかを判別します。判別した結果をレスポンスを作る処理の中で共有できると都合がよいです。そこでリクエストローカルな変数にテナントの情報を格納します。

余談になりますが、リクエストを実行する単位が Fiber なアプリケーションサーバーもあるので、リクエストローカルな変数を Proces、Thread、Fiber、Ractor などのどれに設定するか切り替え可能にしておかないとうまく動かないという点は将来直面する課題かもしれません。Puma を使っているうちは問題ありませんので今回はよしとします。

apartment はリクエストローカルな値を設定するつもりで Thread[]= を使ってしまっている

apartment は本家と、本家での動きがみられなくなったあとに fork した rails-on-services 版があります。どちらもリクエストローカルな値にテナントの情報を格納するつもりで Thread[]= を使ってしまっています。このため、リクエストを処理している中で Fiber が切り替わるとテナントの情報が取得できなくなります。

https://github.com/influitive/apartment/blob/f266f73e58835f94e4ec7c16f28443fe5eada1ac/lib/apartment/tenant.rb#L26 https://github.com/rails-on-services/apartment/blob/7d626d1fd53259da7c193a1710495b384cad6481/lib/apartment/tenant.rb#L22

apartment は最近アップデートがなく、最新版を使っていてもこの問題にぶつかる

問題があれば、PR を送るなりして修正すればよいですね。現在 https://github.com/rails-on-services/apartment/pull/182/files でも PR が作られています。 私たちの問題もこの PR のコードを参考にしたモンキーパッチで解決していますので、コード自体には問題ないように見えます。

この PR は 2021 年の 12 月に作られたもので、もしこの PR が既に取り込まれていれば私たちが今回直面した問題も予防できていたでしょうし、他のユーザーもこの問題にあたらなくなり良い影響となりそうですが、今のところ取り込まれていません。

また、リポジトリのコード自体 2022 年から動きがない状況で、現状メンテナンスがうまくいかないのかなという印象です。

まとめ

マルチテナントライブラリ apartment と標準添付の csv を使い、csv で convert_header にブロックを取ってその中でテナントに応じた処理を切り替えている場合、Ruby3.1 から Ruby3.2 に上げると、動きが変わってしまうという複合的な問題でした。どれか一つでも条件を満たしていなければ顕在化しなかったもので、興味深いですね。

apartment を使っていなくても、Ruby on Rails のリクエスト毎に current_user などをリクエストローカルな変数に格納しておきたい場合は多いと思われます。 その部分に Thread[]= ではなく Thread#thread_variable_set を使えているかは再度点検しておくとトラブルを未然に防げそうです。 みなさんのお手元のコードが問題なく動いていても、それはたまたま利用しているライブラリ群が新しい Fiber を作っていないだけかもしれません。

niku がお送りしました。

RubyKaigi 2023 にエンジニア4人でブース出展しました #rubykaigi

こんにちは、DIGGLE エンジニアの miyakawa と ito です。

前回のブログ記事でお伝えした通り、DIGGLE は RubyKaigi 2023 に Platinum スポンサーとして参加/ブース出展/幕間CM提供を行いました。

diggle.engineer

今回の記事では RubyKaigi のブース出展側の視点で、出展準備から当日の運営までを振り返っていきたいと思います。

初めてのブース出展にあたって何をする必要があるのか暗中模索しながら準備を進める中で、いろいろな方の過去の RubyKaigi レポートを参考にすることで意思決定をすることができました。
本記事が、今回の弊社と同じ立場(RubyKaigi に初出展される企業)の方がさまざまな意思決定をしていく際の判断の一助になることを願っています。

出展の計画/準備

RubyKaigi へのブース出展を決めたのが1月で、準備に動き出したのが2月上旬でした。
(スポンサーブースは抽選だったので、実際に出展できることが決定したのは2月中旬頃でした)

RubyKaigi へのスポンサーとしての参加は皆初めてで、RubyKaigi に一般参加したことがあるメンバーを頼りに準備を進めていきました。

準備期間の大まかなタイムラインは以下の通りです。

いつ 何をした
1月上旬 スポンサー申込
2月中旬 スポンサーブース当選
2月下旬 ノベルティ準備開始
ホテル確保
3月上旬 CM制作(外注)プロジェクト始動
3月下旬 ノベルティ・備品等の発注完了
4月上旬 ブース出し物(シューティングゲーム)制作
4月中旬 CM完成
5月10日~ RubyKaigi 本番

取り組んだこと

週1の定例ミーティング

週に1回ミーティングを行い、準備することを整理しながら各人にタスクを振り分け進めていきました。
ミーティングでは例えば、どの様なノベルティを用意するのか?当日必要な備品は何か?どこに宿泊するのか?会場で流れるCMはどう言ったものを用意するか?ブースの展示内容はどうするか?と言った内容をざっくばらんに話し合っていました。

ミーティングにはバックオフィスのメンバーも参加してもらい、必要になった備品は都度手配を依頼していく形で進めていました。
RubyKaigi ではない他のイベントの出展で使用した備品はバックオフィスが全て把握しているため、コミュニケーションコストを減らす意味でもバックオフィスと連携を密に準備を進めていけたことはとてもよかったです。

また、ノベルティやCMの制作にはマーケティングチームにも協力をしてもらい非常に助かりました。

情報収集

兎にも角にもブース出展に関する情報が足りなかったためメンバーで手分けして情報収集にあたりました。情報収集先としては過去の RubyKaigi 参加レポートのブログ、Twitter、RubyKaigi に参加経験のあるメンバーの頭の中などです。

困ったこと

ノベルティ等の配布物

ブースで頒布するノベルティや会社説明のチラシなどブース出展する立場になった際に考えなければいけないことの情報収集が必要でした。 他社事例や参加レポートを参考にしながら、どこまで用意するのかを話し合い、最終的に下記をノベルティとして用意することに決定しました。

  • ステッカー
  • クリアファイル
  • 企業説明のチラシ

ブースでの出し物

少しでも来場者の目を引いてブースに来てもらいたい!という思いで、どのような出し物を用意しようか悩みました。悩んだ末に ruby.wasm でシューティングゲームを作ることにしたのですが、その詳細については下記のブログ記事で書いているのでぜひご覧ください。

diggle.engineer

前日の設営

いざ松本へ

今回 RubyKaigi に参加したメンバーは住んでいる所が離れている(それぞれ北海道、埼玉、東京、愛知)ことから、前日に現地集合することにしました。

ito は愛知から特急しなので松本へ

miyakawa は新宿から特急あずさに乗って

設営実施

前日に会場のブースの設営を行うことができたため、当初は二人を先発組にして設営を行う計画を立てていました。 設営当日、一人のメンバーが手伝いに来てくれたため三人で設営したのですが、結果的には三人での設営がちょうどよかったです。

困ったこと

設営の際にノベルティのクリアファイルに会社ロゴが入っていないことが発覚しました。 準備段階でのコミュニケーションミスが原因でしたが、元々クリアファイルの中に会社説明のチラシを入れて頒布する想定だったため大きな問題にはなりませんでした。 準備期間が短かく細かい認識のずれを確認する場を持つことが難しかったために発生したと感じており、通常業務をこなしながらイベント参加の準備を進める難しさを痛感しました。

逆にそれ以外の問題は特になかったため、それまでの定期的なミーティングが機能したことと、備品手配を抜かりなくやってくれたバックオフィスメンバーの優秀さを改めて実感する出来事でした。

当日のブース運営

当日のブース運営は4人を2人ずつのグループに分けて行うことを計画していました。 2人がブース運営を行なっている間、他の2人はセッションを聞きにいくといった形です。

実際に作成したタイムシフト

1日目の運営では、セッション開催時にブースに来る方は少なく、lunch break や afternoon break でブースにくる方が多い傾向が見られました。 セッション開催時には1人でブースを回せるほどで、逆に break 時には4人で協力して回す必要があるほどでした。

特に、1日目のLTの間はクックパッドさんが配っていたクラフトビールを片手に散策している方も多く、ブースに来られる方はほぼいませんでした。

2日目では1日目の傾向を踏まえて、セッション開催時にブース運営を任せる人数を1人に変更したのですが、それは失敗でした。
2日目からはスタンプラリーが開催されており多くの方がブースに訪れていただいたためです。

朝に1人でブースを回していたメンバーはパンクしてしまいました。
(後から考えると2日目のスタンプラリーを踏まえて1日目はブース訪問を見送ってセッション参加をされた方も多かったのだと思います)
2人での運営も難しく、最終的には絶対に行きたいセッション以外は参加を見送って基本的に全員でブース運営を行う形にシフトしました。

上記の対応をしたにもかかわらず、ブースに訪れてくれた方を最大限おもてなしすることは難しかったです。 先述の通りブースではシューティングゲームを展示していたのですが、ブースで実際に遊んでいただくことが難しくご自身のPCにて遊んでいただく様に案内するしかなかった場面もありました。

3日目は2日目ほど忙しくはなかったものの、それまでのハードなブース運営によってメンバー全員が疲労困憊になっていました。After Party を楽しみにしながら気力で乗り切りました。

困ったこと

ノベルティの在庫切れ

会社説明のチラシを250部ほど用意していたものの、在庫が2日目の半ばで切れてしまいました。
来場者数の予測をもとに部数を見積もって用意していたのですが、300 ~ 400部ほどでも捌けそうなほどでした。

ちなみにチラシが切れた後は、弊社EMのzakkyさんのTwitterのリンクをQRコードで掲示し、下記のtweetを固定表示してそちらからアクセスできるように案内を変更して対応しました。

海外の方向けの対応

海外の方がブースに訪れる場面も多く日本語のチラシしかなかった弊社では、四苦八苦しながら会社の説明や展示物の説明を行っていました。
海外の方に向けた説明のために何を用意するのか?は事前に話し合った方が良さそうです。

ブース運営を振り返って

ブース出展を行ったことで、さまざまな方と交流を持てました。
ブースに立ち寄ってくれた方々に大変感謝しています。

展示内容のシューティングゲームも多くの方にポジティブな反応をいただけ、いろいろな方に DIGGLE という名前を認知していただけたのではと感じています。
(弊社の事業である予実管理 SaaS よりもシューティングゲームの印象の方が強く残っていそうなことは否めませんが...)

ただ、ブースに立ち寄ってくれた方との交流ができた反面、RubyKaigi のセッションを生で聴講する機会を失ってしまったことが残念でした。
4 人全員がブース運営に注力することが必要になってしまったため、全体としての余裕が全くない状態になってしまったためです。
RubyKaigi の真髄を味わいつつ十分なブース運営を回すには、少なくとも倍の人数(8人)程度はいた方が良いと感じました。
(懇親会等で他企業の皆さんとお話しした限り、弊社のブース運営人数が一番少ないようでした。「エンジニア4人でブース回してます」と言うと驚かれました😅)

おわりに

以上、RubyKaigi 2023 にブース出展した際の弊社での流れをお届けしました。

今後ブース出展される方々のお役に立てば幸いです。

We're hiring!

私たちと一緒に開発してくれるメンバーを募集しています。少しでも興味があれば、ぜひ下記採用サイトからエントリーください。

herp.careers