Ruby の lazy loading の仕組みを利用して未使用の gem を探す

技術部開発基盤グループのシム(@shia)です。 最近は cookpad のメインレポジトリを開発しやすい環境に改善するために様々な試みをしています。 この記事ではその試みの一つとして不要な gem を検出し、削除した方法を紹介したいと思います。

背景

cookpad は10年以上にわたって運用されている巨大なウェブアプリケーションです。 巨大かつ古いアプリケーションには昔は使っていたが、現在は使われてない依存性などが技術負債として溜まっています。 事業的観点から技術的負債を完全返却するのはコストとのバランスが悪いことも多いです。 これは20万行を超えるプロジェクトを幾つも抱えている cookpad のメインレポジトリも例外ではなく、その規模から使ってないと思われる依存性を探しだすのも大変な状況でした。

どうするか

人が頑張るより機械に頑張らせたほうが楽ができるし、何より確実です。 ですので今回は未使用の gem を探すために Ruby の遅延ロード仕組みに乗りました。 遅延ロードのために用意された仕組みにパッチを当て、使用されている gem のリストを出します。 これを利用して依存してる gem のリストから未使用である gem のリストを逆引きします。

InstructionSequence(iseq) とは

InstructionSequence(iseq) とは Ruby のソースコードをコンパイルして得られる命令の集合を指します。 この命令は Ruby VM が理解できるもので、各 iseq はツリー構造で成り立ちます。例えば

class Cat
  def sleep
  end
end

このコードからはCat クラスを表現する iseq が一つ、 sleep メソッドを表現する iseq が一つ作られます*1が、 構造的には Catの iseq に sleep の iseq が含まれている状態です。 これより詳しい説明を見たい方は RubyVM::InstructionSequence の説明や「Rubyのしくみ」という本がおすすめです。 もしくは弊社で Ruby の内部が分かる Ruby Hack Challenge というイベントが不定期に開催されるので参加してみるのも良いも思います。 参考記事

InstructionSequence lazy loading

Ruby 2.3 では iseq を lazy loading するという仕組みが試験的に導入されました。 この機能は iseq を初めて実行する時まで中身の読み込みを遅延させることで、

  • アプリケーションのローディングが早くなる
  • メモリーの使用量を減らす

ということを狙っています。ですが、今回は「初めて実行する時まで中身の読み込みを遅延させる」ために用意された仕組みに興味があります。 iseq の定義パスや first line number は iseq から簡単に取り出せるので、これらを利用すれば実際に使用された gem のリストを作れます。

どういうパッチを当てるのかを見る前に少しだけ Ruby のコードを見てみましょう。

// https://github.com/ruby/ruby/blob/v2_4_3/vm_core.h#L415-L424
static inline const rb_iseq_t *
rb_iseq_check(const rb_iseq_t *iseq)
{
#if USE_LAZY_LOAD
    if (iseq->body == NULL) {
    rb_iseq_complete((rb_iseq_t *)iseq);
    }
#endif
    return iseq;
}

rb_iseq_check は iseq が実行される前に呼ばれる関数です。 ここで iseq の中身が空なら(まだ実行されたことがない)、中身をロードしてるのがわかります。 先程話したようにこれは実験的な機能であるため USE_LAZY_LOAD がマクロで宣言されてないと使われません。 ですので普段はなにもせず引数として渡された iseq を返すだけの関数です。 ここで iseq の初回実行のみ特定の関数を呼び、そこで必要なロギング作業すれば良さそうです。

パッチ

上記のコードからどういう感じのパッチを書けばよいのか理解できると思うので実際のパッチを見てみましょう。 以下のパッチは 2.4.3 をターゲットとして書かれてるので注意してください。

---
 iseq.c    | 16 ++++++++++++++++
 vm_core.h | 15 +++++++++++++++
 2 files changed, 31 insertions(+)

diff --git a/iseq.c b/iseq.c
index 07d8828e9b..322dfb07dd 100644
--- a/iseq.c
+++ b/iseq.c
@@ -2482,3 +2482,19 @@ Init_ISeq(void)
     rb_undef_method(CLASS_OF(rb_cISeq), "translate");
     rb_undef_method(CLASS_OF(rb_cISeq), "load_iseq");
 }
+
+#if USE_EXECUTED_CHECK
+void
+rb_iseq_executed_check_dump(rb_iseq_t *iseq)
+{
+    iseq->flags |= ISEQ_FL_EXECUTED;
+    char *output_path = getenv("IE_OUTPUT_PATH");
+    if (output_path == NULL) { return; }
+
+    char *iseq_path = RSTRING_PTR(rb_iseq_path(iseq));
+    FILE *fp = fopen(output_path, "a");
+    fprintf(fp, "%s:%d\n", iseq_path, FIX2INT(rb_iseq_first_lineno(iseq)));
+    fclose(fp);
+}
+#endif
diff --git a/vm_core.h b/vm_core.h
index 8e2b93d8e9..96f14445f9 100644
--- a/vm_core.h
+++ b/vm_core.h
@@ -412,6 +412,16 @@ struct rb_iseq_struct {
 const rb_iseq_t *rb_iseq_complete(const rb_iseq_t *iseq);
 #endif

+#ifndef USE_EXECUTED_CHECK
+#define USE_EXECUTED_CHECK 1
+#endif
+
+#define ISEQ_FL_EXECUTED IMEMO_FL_USER0
+
+#if USE_EXECUTED_CHECK
+void rb_iseq_executed_check_dump(rb_iseq_t *iseq);
+#endif
+
 static inline const rb_iseq_t *
 rb_iseq_check(const rb_iseq_t *iseq)
 {
@@ -419,6 +429,11 @@ rb_iseq_check(const rb_iseq_t *iseq)
     if (iseq->body == NULL) {
        rb_iseq_complete((rb_iseq_t *)iseq);
     }
+#endif
+#if USE_EXECUTED_CHECK
+    if ((iseq->flags & ISEQ_FL_EXECUTED) == 0) {
+       rb_iseq_executed_check_dump((rb_iseq_t *)iseq);
+    }
 #endif
     return iseq;
 }
--
  • iseq が持っている未使用のフラグ一つを iseq が実行されたことがあるかを判断するためのフラグ(ISEQ_FL_EXECUTED)として使えるようにする
  • ISEQ_FL_EXECUTED フラグが立ってない場合 rb_iseq_checkrb_iseq_executed_check_dump という関数を呼ふ
  • rb_iseq_executed_check_dump ではその iseq の path, first_lineno を(環境変数 IE_OUTPUT_PATH で指定した)ファイルに書き込む

このように rb_iseq_check にフックポイントを作ることで TracePoint とは比べるまでもないほどの低コストで実行された iseq を探せます。 もちろんロギングのコストは発生するので注意する必要はありますが、仕組み自体のコストは実質ゼロに近いことがわかっています。

このパッチを当てた Ruby を利用することで実行された iseq のリストを得ることができます。 今回は手作業で確認したい対象を減らすためのものなので、パッチを当てた ruby でテストを完走させ、そのログを利用することにしました。以下のような大量のログが吐かれるのでこれらを処理して実際使われてる gem のリストを作成できます。

.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:46
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:58
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:71
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:63
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:67
.../2.4.3/lib/ruby/gems/2.4.0/gems/capybara-2.13.0/lib/capybara/node/matchers.rb:245
.../2.4.3/lib/ruby/gems/2.4.0/gems/capybara-2.13.0/lib/capybara/node/matchers.rb:3

依存している gem のリストは Bundler::LockfileParser を利用すると簡単に得られます。

# プロジェクト root
require "bundler"

lockfile_parser = Bundler::LockfileParser.new(File.read("Gemfile.lock"))
lockfile_parser.specs.map(&:name)

この使用された gem のリストと依存している gem のリストから、後者から前者を引き算することで、 依存しているが使用されてない gem のリストを作れます。

成果

現在、cookpad のメインレポジトリには1つの mountable engine を共有する 5つのプロジェクトがあります。 この5つのプロジェクトを対象に上記のパッチを利用して作り出した未使用 gem のリストを作成し、必要のないものをなくす作業を進めました。

結果としてすべてのプロジェクトから未使用の gem が 41個見つかりました。 これらを削除することで、依存している gem の数を大幅に減らすことができました。 さらに require するファイルの数が大量に減ったため、アプリケーションの読み込み時間が最大1秒程度速くなりました。

まとめ

Ruby の lazy loading という仕組みを利用して未使用の gem を探す方法を紹介しました。 この方法は使用されてないコードを探すのに以下のような利点を持っています。

  • プロジェクト別にコードを書く必要がないのでどのプロジェクトからも簡単に利用することができる
  • 動的に生成されるメソッドもある程度追跡ができる
  • 低コストにコードの使用状況が分かる

特に三番目が重要だと思っていて、本番のサービスから使われてない依存 gem やプロジェクトコードを簡単に追跡できるんじゃないかと期待しているので、次回にご期待ください。

*1:正確には3つが作られますが、ここでは説明のため省略しています

Web アプリケーションを把握するためのコンソール

技術部開発基盤グループの鈴木 (id:eagletmt) です。 クックパッドではほとんどの Web アプリケーションが Amazon ECS 上で動く状態となり、またマイクロサービス化や新規サービスのリリースにより Web アプリケーションの数も増えていきました。 個々のアプリケーションでは Docker イメージを Jenkins でビルドして Amazon ECR にプッシュし、Rundeck から hako を用いて ECS にデプロイし、またその Web アプリケーションからは Amazon RDS、Amazon ElastiCache 等のマネージドサービスを活用しています。

このように多くの Web アプリケーションが存在し、また各アプリが別のアプリや AWS の様々なマネージドサービスを利用している状況では、どのアプリが何を使っているのかを把握することが困難になっていきます。 具体的には新しくチームに所属したメンバーが、どのアプリがどの GitHub リポジトリに存在するのか、どの Jenkins ジョブを使っているのか、どうデプロイするのかを把握することが難しかったり、また自分のアプリの調子が悪いときにどのデータベースのメトリクスを見ればいいのか、どの ELB のメトリクスを見ればいいのかが難しかったりします。 マイクロサービス化を推し進めて各チームに権限と責任を移譲していく上で、各チームが自分たちのアプリの状態をすばやく把握できる状況は不可欠です。 そういった課題を解決するために hako-console を昨年開発していたので、その話を書こうと思います。 なお今回の話は昨年11月に行われた Rails Developers Meetup #7 での発表と一部重複します。

https://speakerdeck.com/eagletmt/web-application-development-in-cookpad-2017

hako-console とは

hako-console はアプリケーション毎にそのアプリケーションに関連したシステムやメトリクス等の情報を閲覧することができる Web アプリケーションです。 たとえば

  • そのアプリの Docker イメージがビルドされている Jenkins ジョブがわかる
  • そのアプリのエラーログが蓄積されている Sentry プロジェクトがわかる
  • そのアプリが利用している RDS インスタンスがわかり、そのメトリクスも閲覧できる
  • そのアプリが stdout/stderr に出力したログを閲覧、検索できる

といった機能を持ちます。

hako-console app page hako-console ECS metrics page hako-console ELB metrics page

設計方針

Web アプリケーションの把握を手助けするための手段としてまず最初に挙がるのがドキュメンテーションだと思います。 しかしながら、人間が入力した情報は構成変更等があったときに必ず古くなり誤った記述になります。 README に書かれていたジョブを探したけど見つからなかった、ドキュメントに書かれていない別のデータベースにも実は接続していた、みたいな経験はよくあると思います。 これを解決するには「人間が入力しない」ということが重要だと考えていて、AWS のように API で情報を取得できるインフラを使っていたり、hako の定義ファイルのように機械的に読み込める情報があるので、これらの実際に使われている実態と乖離していない一次情報を自動的に集めることを念頭に置いて設計しました。

  • hako-console 自体は極力マスターとなるデータを持たない
    • 既に別の場所にあるデータやメトリクスを表示するだけにする
    • 人間が何かを入力することも極力しない
  • かわりに他のシステムからデータを取得して、それを機械的に処理する

アプリの状態を知るためのメトリクスは Zabbix、Amazon CloudWatch、Prometheus といったものに既に存在していたので、hako-console の役割は各アプリとメトリクスを繋ぐものにしようと考えました。 当初はサーバやマネージドサービスのメトリクスのようなものだけを考えていましたが、AWS X-Ray を使うようになって からは X-Ray のデータを使って通信があるアプリ間にリンクを表示する機能を追加する等 *1、そのアプリについて知るために役立つ情報を追加していきました。

開発に必要な情報を集める

クックパッドでの Web アプリケーションの主要な開発フローは以下のようになっています。

  1. GitHub Enterprise にリポジトリを作成し、そこで開発を行う
  2. 同リポジトリに Dockerfile を追加し、Jenkins ジョブでテストを実行し、docker build && docker push を実行する
  3. hako_apps という中央リポジトリにアプリケーション定義を追加する
    • たとえば example というアプリであれば example.jsonnet を追加する
  4. hako_apps リポジトリの webhook を通じて hako-console にアプリケーションが自動的に登録される
  5. hako-console から Rundeck にデプロイ・ロールバック用のジョブを作成する
    • デプロイ用のジョブは hako deploy example.jsonnet 、ロールバック用のジョブは hako rollback example.jsonnet を実行するように作成される
  6. ruboty deploy example と Slack で発言することで chatbot の ruboty を通じて Rundeck のデプロイジョブが起動され、ECS でアプリが動く状態になる

社内では https://gist.github.com/eagletmt/f66150364d2f88daa20da7c1ab84ea13 のような hako の script を使っており、example.jsonnet の scripts に jenkins_tag を追加すると hako deploy -t jenkins example.jsonnet で最新の安定ビルドのリビジョンを Docker イメージのタグに指定できるようになっています。 そのため example.jsonnet を読めば example というアプリがどの Jenkins ジョブを使っているかが分かります。 Rundeck ジョブは ruboty から使われるため、hako-console を作成する前から Rundeck ジョブ名はアプリ名にすることが習慣となっていました。そのため、Rundeck のジョブを API で取得しアプリ名で寄せることで、どの Rundeck ジョブを使っているかが分かります。 hako-console ができてからは、この習慣に従って Rundeck ジョブが hako-console によって半自動的に作成されるようになっています。

hako-console はこのような形で情報を集めて、アプリケーション毎にリンクを表示しています。

運用に必要な情報を集める

Web アプリケーションが使っている ELB は、hako が hako-${app_id} という固定の命名規則で ELB を作成するため、それで見つけることができます。 たとえば example.jsonnet というアプリケーション定義であれば、hako-example という名前のロードバランサーやターゲットグループが対応します。

RDS インスタンスや ElastiCache インスタンスはどうでしょうか。 Docker コンテナで動かすアプリの場合、接続先の MySQL や memcached のエンドポイントといった設定値は環境変数で渡すことが多く、環境変数は hako のアプリケーション定義に書かれます。 したがって example.jsonnet の環境変数定義を調べて、その中に RDS のエンドポイントっぽい文字列 (つまり /\b(?<identifier>[^.]+)\.[^.]+\.(?<region>[^.]+)\.rds\.amazonaws\.com/ にマッチするような文字列) であったり、ElastiCache のエンドポイントっぽい文字列を探すことで、多くの場合うまくいきます。 こうすることで、各アプリケーションが接続している RDS インスタンスや ElastiCache インスタンスを見つけることができ、また逆に各 RDS インスタンスや ElastiCache インスタンスを使っているアプリケーションを知ることもできます。

hako-console RDS and ElastiCache list page hako-console RDS page

Sentry のプロジェクトについても同様で、Sentry 用の各種 SDK は SENTRY_DSN という環境変数で DSN を設定できるようになっており、API を通じてプロジェクトの一覧を取得できるので、 example.jsonnet に書かれた SENTRY_DSN と API の結果を突き合わせることで Sentry プロジェクトを見つけることができます。

ログの閲覧、検索

ECS で動かしている Docker コンテナのログは、log driver として fluentd を指定し fluent-plugin-s3 を使って Amazon S3 に送信するようにしています。 S3 に送信されたログはそのままでは閲覧しにくいので、hako-console 上でアプリ毎やタスク毎に閲覧や検索できるようにしています。

hako-console log list page hako-console log page

検索には Amazon Athena を使っており、そのためのテーブル定義は AWS Glue のデータカタログに作成しています。 fluentd が ${アプリ名}/${コンテナ名}/${日付}/ のようなプレフィックスで jsonl を gzip で圧縮したものを保存し、日次のバッチジョブがログの中身にあわせたパーティションを Glue のテーブル定義に追加していくことで、S3 にログが送信されてからすぐに検索対象になるようにしています。

まとめ

内製している hako-console について紹介しました。 Web アプリケーションを把握するためのコンソールを作ること、またそのようなコンソールを作成できるようなツールやインフラにすることは重要だと考えています。 hako-console は社内のインフラ事情と密接に関係しているため OSS ではありませんが、それぞれの環境にあわせてこのようなコンソールを作成することに意味があるんじゃないかなと思います。

*1:ちなみに、この「どのアプリとどのアプリが通信するのか」という情報はトレーシングではなくサービスメッシュによって達成しようと動いています https://speakerdeck.com/taiki45/observability-service-mesh-and-microservices

MR による料理サポートのための取り組み

研究開発部アルバイトの山谷 @kei_bility です。リモートアルバイトとして IoT やモバイル、VR, MR の領域で新規サービス開発に取り組んでいます。

その中で HoloLens を活用した MR (Mixed Reality) による料理サポートのための取り組みを少しだけ紹介したいと思います。

概要

HoloLens を装着したユーザーが空間上で AirTap と呼ばれるジェスチャーをすると、いま見ている状態をキャプチャしてその画像を API リクエストでサーバへ送信します。

そしてサーバ側で画像認識した結果を受け取りその結果を表示します。

背景

背景としては、Semantic ARのように HoloLens と画像認識モデルを組み合わせ画像認識結果を現実世界に重畳させてユーザーに提供したいというモチベーションがありました。 HoloLens で画像認識するには HoloLens 自体で画像認識モデルを動かすか、API を経由してサーバ側で画像認識した結果を可視化するかの2通りがあります。 前者の方法について TensorFlowSharp などを使ってモデルを動かせないか検討していましたが、 HoloLens の CPU/GPU では画像認識モデルの推論計算に時間を要する or モデル自体を圧縮する必要があるなど、すぐに試せなさそうなことがわかりました。 ということで、今回は HoloLens から画像を取得しその画像に対して API を通してその結果を可視化することにしました。

社内には Cookpad Computer Vision API (別名 See Food API) があります。ここにはクックパッドの画像認識モデルがAPI化されており、最新の研究成果を利用することができるようになっています。 今回はこの API と HoloLens を連携させてみました。

以下では Unity でのセッティング、HoloLens でのカメラ画像の取得、APIリクエストについて触れます。

Unity での UI セッティング

Unity で作る UI としては、キャプチャした画像を表示するパネルと、画像認識結果を表示するパネルの2つをユーザーの前に配置します。

f:id:kei_bility:20180328184731p:plain

カメラ画像の取得

HoloLens のカメラ画像を Unity で取得するため以下のように PhotoCaptureObject を使ってカメラパラメータを設定します。

void OnPhotoCaptureCreated(PhotoCapture captureObject)
{
    photoCaptureObject = captureObject;

    Resolution cameraResolution = PhotoCapture.SupportedResolutions.OrderByDescending((res) => res.width * res.height).First();

    CameraParameters c = new CameraParameters();
    c.hologramOpacity = 0.0f;
    c.cameraResolutionWidth = cameraResolution.width;
    c.cameraResolutionHeight = cameraResolution.height;
    c.pixelFormat = CapturePixelFormat.JPEG;

    captureObject.StartPhotoModeAsync(c, false, OnPhotoModeStarted);
}

APIリクエスト

ユーザーがジェスチャーをしたらイベント発火し、API へリクエストを送ります。Unity の WWWクラスを利用し、C# スクリプトとして以下のように実装します。 レスポンスの json データからインスタンスを生成するため、レスポンスデータに対応する [Serializable] なクラスを用意しておき、 Unity 5.3 から利用できる JsonUtility で json からインスタンスを生成します。

WWW www = new WWW(apiEndPoint);
yield return www;
string response = www.text;
var result_json = JsonUtility.FromJson<ImageRes>(response);

こうしてユーザーが AirTap すると目の前の画像に対して認識した結果を表示します。

f:id:kei_bility:20180328001905j:plain

まとめ

Mixed Reality を実現する HoloLens を活用した料理サポートのための取り組みを紹介しました。 今後は料理動画をキッチンの任意の場所に貼り付けて操作できるようになったり、食材を認識して下ごしらえの仕方を教えてくれるなど、 Mixed Reality によって料理の新しい体験やサポートができるようになればいいなと考えています。

いかがでしたでしょうか。 クックパッドでは、IoTやモバイル、VR, MRで新たなサービスを創り出そうとチャレンジしています。 興味のある方はぜひ話を聞きに遊びに来て下さい。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/