Quantcast
Channel: クックパッド開発者ブログ
Viewing all 726 articles
Browse latest View live

Synthetic Monitoring を活用したグローバルサービスのネットワークレイテンシの測定と改善

$
0
0

インフラ部 SRE グループの渡辺(@takanabe)です。普段はクックパッドのグローバルサービス (https://cookpad.com/us) のインフラの開発や運用をしています。

クックパッドは、21 言語・67 カ国以上を対象にサービスを展開しています ( 2017 年 6 月末時点)。今後もその数を増やしていく予定です。 世界中で使われるサービスのインフラを開発していく上で、乗り越える必要がある課題は沢山ありますが、その中でも、ユーザが利用するクライアントとクックパッドのインフラをむすぶネットワークのレイテンシは特に大きい課題です。 本稿ではなぜグローバルに利用されるサービスにおいて、ネットワークレイテンシが問題になるのか、また、クックパッドではネットワークレイテンシをどう計測し改善しようとしているかについて解説します。

ネットワークレイテンシとは

ユーザがサービスにリクエストを送ってからレスポンスを受け取るまでにかかる時間 (レスポンスタイム) は、主にネットワークレイテンシとアプリケーションの処理時間の合計です。 アプリケーションの処理時間短縮もサービスのレスポンスタイム改善には有用ですが、グローバルに展開されたサービスにおいてはネットワークレイテンシも大きなオーバーヘッドになりえます。この両方を改善していくことがユーザ体験向上のために重要です。本稿ではネットワークレイテンシについてご紹介します。

ネットワークレイテンシは大きく分けると以下の 4 つから構成されています。

  • 伝播遅延: クライアントがパケットを送出してから私達の管理するサーバに到達するまでの時間(あるいはその逆方向の通信にかかる時間)
  • 伝送遅延: パケットがリンクに載るまでの時間
  • 処理遅延: ルータがパケットのヘッダをチェックして宛先を決定するまでの時間
  • キューイング遅延: ルータのパケット処理待ち状態の際にバッファキューで待機する時間

一方で私たちが普段ネットワークレイテンシという言葉を使う場合は、往復の伝搬遅延、つまり Round-trip-time を意味することが多いです。本稿でもネットワークレイテンシ(以下レイテンシ)を Round-trip-time (以下 RTT ) の意味で使います。

グローバルサービスとレイテンシ

クックパッドのグローバルサービスのサーバは現在 AWS の米国東部リージョンに集約されているため、サービスの通信は基本的にはユーザの居住地と米国との間を往復することになります。

サーバが米国東部リージョンに集約されていることで、米国や米国近辺に住んでいるユーザはレイテンシが小さくなります。一方で、アジアや中東など地理的に遠い国に住むユーザにとってはレイテンシを悪化させる要因の一つとなっています。例えばリクエストがネットワークを伝わる速度を光速( 300,000 km / sec)とし、日本から米国東部までの距離を 11,000 km としたとき、レイテンシは約 73.3 ms になります。現実には、サーバまでのネットワークの経路は一直線ではありません。加えて、伝送において 300,000 km / sec もの速度が出ることはないためレイテンシはさらに大きくなります。*1

f:id:takanabe_w:20170920163049p:plain

例として、東京の私の家から Amazon S3 の東京リージョンのエンドポイントと米国東部リージョン( us-east-1 )のエンドポイントに ping を打つと以下のように平均レイテンシは前者は約 22.2 ms、後者は約 186.2 ms でした。

# AWS の東京リージョンのエンドポイントに ping を打った場合
> ping -t 5 s3-ap-northeast-1.amazonaws.com
PING s3-ap-northeast-1.amazonaws.com (52.219.68.108): 56 data bytes
64 bytes from 52.219.68.108: icmp_seq=0 ttl=50 time=21.811 ms
64 bytes from 52.219.68.108: icmp_seq=1 ttl=50 time=20.666 ms
64 bytes from 52.219.68.108: icmp_seq=2 ttl=50 time=24.138 ms
64 bytes from 52.219.68.108: icmp_seq=3 ttl=50 time=22.797 ms
64 bytes from 52.219.68.108: icmp_seq=4 ttl=50 time=21.750 ms

--- s3-ap-northeast-1.amazonaws.com ping statistics ---
5 packets transmitted, 5 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 20.666/22.232/24.138/1.167 ms

# AWS の米国東部リージョンのエンドポイントに ping を打った場合
> ping -t 5 s3.us-east-1.amazonaws.com
PING s3.us-east-1.amazonaws.com (54.231.120.114): 56 data bytes
64 bytes from 54.231.120.114: icmp_seq=0 ttl=43 time=179.987 ms
64 bytes from 54.231.120.114: icmp_seq=1 ttl=43 time=208.230 ms
64 bytes from 54.231.120.114: icmp_seq=2 ttl=43 time=176.016 ms
64 bytes from 54.231.120.114: icmp_seq=3 ttl=43 time=182.457 ms
64 bytes from 54.231.120.114: icmp_seq=4 ttl=43 time=184.399 ms

--- s3.us-east-1.amazonaws.com ping statistics ---
5 packets transmitted, 5 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 176.016/186.218/208.230/11.357 ms

164 ms のレイテンシの差がユーザに与える影響

HTTP がクライアントとサーバ間の通信の往復から成っていることを考えると、この 164 ms のレイテンシの差がユーザが快適にサービスを使えるかに大きな影響を与えます。例えば、クックパッドは HTTPS を利用して暗号化された安全な通信をユーザに提供しています。 HTTPS に利用されている TLS 接続を確立するには下図のように TCP ハンドシェイクに 1.5 往復、 TLS ハンドシェイクに 2 往復の通信が必要です。

f:id:takanabe_w:20170920163203p:plain

(https://hpbn.co/transport-layer-security-tls/#tls-handshake Figure 4-2. TLS handshake protocol より引用)

TLS ハンドシェイクの Client Hello メッセージは TCP ハンドシェイクの ACK と同じタイミングで送出されることから、 TLS 接続には正味 3 往復必要になります。 つまり RTT の 3 倍の時間がかかります。

この TLS 接続が成立するまでの時間を先程例に上げた東京と東京リージョンの往復で換算すると、22.2 x 3 = 66.6 msの時間がかかることになります。一方で、東京と米国東部リージョンの往復で換算すると、186.2 x 3 = 558.6 msの時間がかかることになります。

その差は 492 ms です。 この数値は一見問題にならないようにも感じられますが、Jakob Nielsen の著書 Usability Engineering ではレスポンスタイムには3つの境界値が存在すると言われています。

0.1 second is about the limit for having the user feel that the system is reacting instantaneously, meaning that no special feedback is necessary except to display the result.

1.0 second is about the limit for the user’s flow of thought to stay uninterrupted, even though the user will notice the delay. Normally, no special feedback is necessary during delays of more than 0.1 but less than 1.0 second, but the user does lose the feeling of operating directly on the data.

10 seconds is about the limit for keeping the user’s attention focused on the dialogue. For longer delays, users will want to perform other tasks while waiting for the computer to finish, so they should be given feedback indicating when the computer expects to be done. Feedback during the delay is especially important if the response time is likely to be highly variable, since users will then not know what to expect.

(Jakob Nielsen, “Usability Engineering”, 1993, pp 135)

これによると人間はリクエストを送出して 100 ms 〜 1 sec 以内のレスポンスでも遅延を感じるとされています。つまり、 164 ms のレイテンシの違いが生む 492 ms の差はユーザの体験を悪化させる要因に成り得るのです。

今までのクックパッドのレイテンシ対策

クックパッドのグローバルサービスは、日本のサービスとは使われているリージョンやコードベースが異なりますが、cookpad.com ドメインを共有しています。 同じドメインにおいて異なるアプリケーションにリクエストを振り分けるため、ロードバランサ (ELB) 下のリバースプロキシでリクエストがグローバルサービスのもの (/uk, /id など) か日本のサービスのものかを判定してルーティングをしています。 ただ、アプリケーションがリージョンを跨いでいるのにリバースプロキシを 1 リージョンに置くだけではリバースプロキシが無いリージョンへのリクエストが遅くなってしまうため、 同様の設定がされたリバースプロキシを東京 (ap-northeast-1) と米国東部 (us-east-1) リージョンに配置し、Amazon Route 53 のレイテンシベースルーティング*2を利用して DNS ベースでユーザーからレイテンシの低いリージョンへ最初にリクエストを送信させるようにしています。 これにより、ユーザーから近いリージョンのロードバランサにアクセスできるというメリットがあります。

以上のようにレイテンシを増加させる要因はサーバと利用するユーザの所在や使っている技術により異なります。クックパッドの場合サービス利用者が多いインドネシアなどではこのレイテンシの問題は顕著に現れてきています。

レイテンシの計測方法

レイテンシを改善するにはレイテンシがどのような要素から成っており、何をした時にどのくらい改善されたのか、あるいは悪化したのかを定量的に評価しなければなりません。これを実現するにはレイテンシやレスポンスタイムなどのメトリクスを計測し続ける必要があります。

サービスのレイテンシを計測する方法を大別すると、以下の二つが挙げられます

  • Synthetic Monitoring
  • Real User Monitoring(RUM)

Synthetic Monitoring は専用の監視サーバからリクエストを送出して計測します。一方で RUM はユーザのクライアント上で実際にかかった時間そのものを収集します。この2つの方法はどちらが良いと言うわけではなく、集計の粒度も計測する対象も異なるため組み合わせて使うものです。今回は平均的な統計情報をまず取得し、その上でレイテンシを悪化させている要因を分析するという目的があり、Synthetic Monitoring を導入することにしました。

Synthetic Monitoring “Catchpoint”

クックパッドは Catchpoint Systems の Synthetic Monitoring サービス(以下 Catchpoint )を利用しています。 Catchpoint を選択した理由は他の Synthetic Monitoring ツールと同様の日別パフォーマンスの比較ができる点、 Waterfall Chart などの基本的な機能を有している点、 UI がシンプルである点で条件を満たしており、加えて監視サーバのノード数とロケーションの数が他社のものより多いためです*3。サービスの世界展開を目指しているクックパッドにとってこれは重要な機能のひとつです。

Catchpointによるパフォーマンスの分析

パフォーマンス解析機能による全体像の把握

Catchpoint では計測対象のエンドポイント、監視サーバ群、監視の頻度など監視に関する条件を定義するテストを作る必要があります。この記事では https://cookpad.com/usに対して複数の国の監視サーバからアクセスする “Global top page” というテストを用意しました。

パフォーマンス解析の機能を使うことでこの Global top page テストで定義した各国の監視ノードから https://cookpad.com/usにアクセスした際のレスポンスタイム(ms)を確認できます。例えば、2017年2月25 - 27日の3日間を対象に横軸を日時、縦軸をレスポンスタイムにして描画するとこのようなグラフが得られます。

f:id:takanabe_w:20170920163403p:plain

ご覧の通りテストで選択した監視サーバの国別のレスポンスタイムを俯瞰することができています。これをみるとインドネシア、アルゼンチン、エジプトなどの国のレスポンスタイムが相対的に悪いですね。グラフの上にポインタを乗せると他のメトリクスを確認できたり、この時間帯の Waterfall chart に飛ぶこともできます。 Waterfall chart については後述します。

また、 Geo chart という機能を使うと監視サーバそれぞれでパフォーマンスメトリクスの一つを地図上で可視化できます。以下ではサーバにリクエストを行って最初の1バイトが到着するまでの時間を示す Time To First Byte(TTFB)を表示しています。インドネシアは TTFB が他国と比較して長いことがわかります。物理的な距離が影響しているかもしれません。

f:id:takanabe_w:20170920163426p:plain

このようにおおまかなパフォーマンスメトリクスをパフォーマンス解析機能で確認し、当たりをつけ、その後より細かい分析を行うのが自然な流れかと思います。次は上記で確認したインドネシアのパフォーマンスを Waterfall chart で分析していきたいと思います。

Waterfall chartを使ったパフォーマンスボトルネックの分析

Waterfall chart の画面では特定のエンドポイントに特定の監視サーバからアクセスしたときのパフォーマンスメトリクスの詳細な分析ができます。例えば、2017年2月26日12時にインドネシアのジャカルタの監視サーバから https://cookpad.com/us ( Global top page テストを利用)にアクセスした時の Waterfall chart の画面はこのような感じです。ご覧の通り、名前解決や TLS 接続などを含む TTFB 、レンダリング開始、対象のページのレンダリングが完了したことを表す Document Complete などの時間が確認できます。 f:id:takanabe_w:20170920163450p:plain

また Waterfall chart を見るとどのリクエストがパフォーマンスに悪影響を与えているかが簡単にわかります。 Global top page テストの場合 cookpad.com/us にアクセスした際の最初のリクエストの TTFB で全体の約1/3の時間を要しています。さらに、TTFB の内訳を確認すると名前解決や TLS 接続に多くの時間を割いている事がわかります。本稿の最初に TLS ハンドシェイクについて言及しましたが、ここで計測された TLS 接続に必要な時間を短くする事は、すなわちレイテンシの改善につながります。

f:id:takanabe_w:20170920163504p:plain

またいわゆるクリティカルレンダリングパスが赤く塗りつぶされているのでどのリクエストをパフォーマンス改善のターゲットにすれば良いのかが分かりやすくなっています。以上が Cacthpoint の基本的な機能と使い方の紹介になります。他にもダッシュボードを作って public url で共有できたり、トランザクション処理のあるリクエストのボトルネックを解析したりと様々なことができます。

クックパッドでの Catchpoint の使用例

ここまでわかればこれらをどのように改善すべきかという手段の話ができるようになりますね。当初の目的の通りレイテンシを改善するのであれば相関関係が強い名前解決、TLS 接続、TTFB などを改善する方法を少し検討してみます。

サーバのマルチリージョン展開によるレイテンシ改善効果の調査

米国東部に集約しているサーバをインドネシア付近のデータセンタにも展開した場合 RTT はどのように変わるでしょうか。簡単な効果測定は Catchpoint の Instant Test 機能(定期的な計測ではなく任意の監視サーバから任意のエンドポイントに単発のリクエストを実行する機能)を使うことでできます。インドネシアの監視サーバから AWS の米国東部( us-east-1 )、東京( ap-northeast-1 )、シンガポール( ap-southeast-1 )の各リージョンに対して ping を打った結果を比較すると、インドネシアは米国東部や東京よりシンガポールの方が RTT は小さくなることがわかります。

f:id:takanabe_w:20170920163546p:plain

AWS のシンガポールリージョンを使うことでインドネシアのレイテンシは改善されそうですね。わざわざ現地に行かずとも世界中の監視サーバからのリクエストのパフォーマンスが計測できる Instant Test はとても便利です。

CDNの導入によるレスポンスタイム改善効果の調査

CDN の導入もレイテンシの改善に効果があります。CDN は主にキャッシュ用途で使われることが多いですが、今回は TCP と TLS の 終端のためだけに使っています。ユーザーとの接続を近いサーバで終端することで、レイテンシに大きく寄与する TCP および TLS ハンドシェイクの時間を短縮します。CDN のエッジサーバからオリジンとなるアプリケーションサーバへの TLS 接続は HTTP Keep-Alive により再利用することで、さらにレイテンシを短縮することができます。

クックパッドのグローバルサービスでも全てのリクエストを CDN を経由させる施策を進めています。しかし、全てのユーザに大きな影響がある上、それなりにコストをかける必要があるため、 CDN 導入後に改善効果がありペイするのか、 どの CDN を導入すれば良いかなどの検証を Catchpoint で行いました。 以下は CDN 利用前と Fastly と X 社の CDN を利用した場合のある API のレスポンスタイムの比較です。

f:id:takanabe_w:20170920163619p:plain

これを見るとインドネシアのユーザに対しては Fastly と X 社 共にレスポンスタイムの改善効果があるとわかります。現在はグローバルサービスには部分的に Fastly を導入しています。TLS 接続や全体のレスポンスタイムは以下のようになりました。期待していた通り、 TLS ハンドシェイクの短縮やレスポンスタイムの改善がされています。

f:id:takanabe_w:20170920163639p:plain

余談ですが、2017年7月中頃までは Fastly を導入するとアルゼンチンのユーザのレスポンスタイムは悪化するという計測結果が出ていました。その時点で Fastly もブラジルにデータセンタ、 いわゆる Point of Presence(POP) を持っていたのですが期待した結果が得られなかったため原因を調べました。 すると、当時アルゼンチンからのトラフィックではブラジルの POP が使えない状態であることがわかりました。その代わりに米国西部の POP が使われていたのです。計測せずに導入していた場合、大きなコストをかけてユーザ体験を悪化させていた可能性がありました。現在は Fastly でもブラジルの POP のキャパシティが拡張*4されて POP 数も増えました*5。南米のユーザのレスポンスタイムを改善する際の一つの手段となり得ると思います。

おわりに

この記事ではクックパッドのグローバルサービスにおいてなぜレイテンシが問題になっているのか、それを Catchpoint でどのように計測改善しようとしているのかについて書きました。

Catchpoint で日々レイテンシの計測をしているため問題の解決に集中できる環境が整いましたが、グローバルサービスのパフォーマンス改善はまだ始まったばかりです。今後は RUM の導入やパフォーマンス計測によって得た結果をレイテンシやレスポンスタイムの改善に活かし、世界中のユーザがサービスをより快適に使えるようにしていきたいと思っています。

参考文献

  • Jakob Nielsen, “Usability Engineering”, 1993
  • A・S・タネンバウム, “コンピュータネットワーク第4版”, 2003
  • 竹下隆史, 村山公保, 荒井透, 苅田幸雄, “マスタリングTCP/IP 入門編 第5版”, 2012
  • Ilya Grigorik, “High Performance Browser Networking”, https://hpbn.co/

たのしくなるコードレビュー

$
0
0

こんにちは!サービス開発部でAndroidアプリの開発をしているこまたつ(@k0matatsu)です。

みなさんコードレビューしていますか?
最近ではとりいれているチームも多いと思いますが、良い効果をもたらしてくれる一方で、負荷の高い作業でもあります。
また、コードレビュー自体に馴染みの薄かった人はなにをどうしたらいいのか難しいですよね。
同僚から得たアドバイスと自分なりのノウハウをあわせて、コードレビューの指針を考えていたので公開してみようと思います。

前提として、クックパッドではGitHub Enterpriseとプルリクエストを使った開発プロセスを採用しています。
また、コードレビューの前には自動テストと静的解析ツールによる単純なフォーマット、コードスタイル、頻出バグのチェックは行われているものとします。
静的解析による機械的なチェックはコードレビューよりも低コストで有効な方法ですので是非取り入れてみてください。

コードレビューの目的

なにをやるかの前に、なぜやるかをハッキリさせておくことはとても大事です。
目的を明確にしておくことで、判断が必要になった際に指標になります。

コードレビューの目的は会社やチーム、レビュアーとレビュイーの関係性などによって様々ですが、私は次の二軸に比重を置いています。

  • 品質向上
  • スキルアップ

品質向上

プログラムを書いているのは人間なので、ミスが発生します。
コンパイラや静的解析をすり抜けて来るものもありますし、全体の設計に沿っているかなど、人間でなくては確認が難しい要素もあります。
コードレビューを行うことで、複数人の違う視点が入るため、ミスを検出し「読みにくい」などの感覚的な部分のフィードバックも得ることができます。
ここで言う品質とは、バグの量ではなく、可読性やメンテナンスのしやすさも含めたソースコード全体の品質をさします。

スキルアップ

コードレビューの中で疑問を解決したりアドバイスを得ることで自分自身が知り得なかった情報を得ることができます。
自分のスキルに不安があっても、疑問を感じた部分を積極的に質問していくことで多くの学びが得られます。
一方的な査読ではなく、双方向コミュニケーションの場と捉えることでレビュアーとレビュイー双方のスキルアップが期待できます。

何に注意するべきか

チェックすべき項目は多岐に渡りますが、次のような部分を重点的に確認します。
ここであげる項目の他に、ドメイン知識など他の開発者よりも詳しい分野があれば、その知見を使ってフィードバックを行います。

  • アーキテクチャ・設計
  • 挙動
  • 改善

それぞれどのようなチェックを行うか、掘りさげて見ていきましょう。

アーキテクチャ・設計

目的に沿った設計がされているか、全体のアーキテクチャに沿った設計になっているかを確認します。
具体的には次のようなところを重点的に確認しています。

  • 単一責任原則: ひとつのメソッドに違う目的の処理を入れない
  • 命名: 一連の処理の中で統一されているか
  • 粒度: プルリクエストを分割した方がいい部分はあるか

また仕様に疑問を感じた場合も、コードレビューと一緒にその仕様で問題ないか確認してもらいます。

挙動

主に準正常系を見ていきます。
Androidの場合はとくに次の場合に予期せぬ挙動をすることが多いので注意しています。

  • バックキーが押されたとき
  • バックグラウンドから戻ったとき
  • 画面回転をしたとき

改善

より良い書き方があれば指摘します。
AndroidではSDKにTextUtilsやDateUtils、Uri.Builderなどの便利クラスが存在します。
この手のクラスは存在自体が知られてない場合もあるため、積極的にオススメしていきましょう。
標準ライブラリ以外にも、チーム内で運用している便利クラスやメソッドは新しいメンバーは知らないことが多いです。

手順

ここまで、コードレビューの内容部分をみてきました。
次はどのような手順でレビューを行っているかを記します。
人によってやりやすい方法は様々ですが、参考になれば幸いです。

  1. 内容を把握する

    • どのような目的で書かれたコードなのか、主な変更点などをdescriptionを読んで確認します
    • 必要な情報が足りなければ追加をお願いすることもあります
  2. 軽いチェックを行う

    • typoや粒度、明らかな間違いなど、ブラウザから差分を見ただけですぐに判断できる問題が無いか確認します
    • この時点でたくさん問題が見つかった場合は一旦修正を待ってから次のステップへ進みます
  3. 挙動を確認する

    • 実際の挙動を確認します
    • 前に上げたとおり、準正常系をメインに正常系と、切断などの簡単な異常系も確認します
    • 挙動が確認できないものや、確認する必要がないもの、微細な修正の場合はスキップする場合もあります
  4. 細かいチェックを行う

    • 挙動が問題なければソースを読んで細かいチェックを行います
    • 必要があればコードを手元に持ってきて、呼び出し箇所を調べることもあります
  5. 修正後の確認

    • 修正差分の確認を行います
    • 修正量によりますが、コミット内容だけをチェックする場合と、ステップ2からチェックしなおす場合があります

このように、コードレビューを軽いチェックと細かいチェックの2層に分けています。
作業の合間などに軽いチェックを行い、まとまった時間が取れるときに細かいチェックをすることで、レビュイーに素早くフィードバックが返せるように心がけています。
他にも、なるべくポジティブなコメントもつけるようにすることで心理的負担を減らす工夫をしています。
冒頭で軽く触れた、静的解析による機械的なチェックもレビュアーの負担を軽減するための取り組みのひとつです。

おわりに

コードレビューは業務の中でも集中力を要する大変な作業のひとつではないでしょうか?
技術によって人間が負担する部分を減らしていくのが理想ではありますが、コードレビューは多くの学びを得られるチャンスでもあります。
自分なりの手順を決めてこなせるようになってくると、今まで気が重かったタスクも楽しく進められると思います。
やっていきましょう。

Cookpad Ruby Hack Challenge 開催報告

$
0
0

f:id:koichi-sasada:20170929111101j:plain

技術部の笹田です。Ruby インタプリタの開発をしています。先日、RubyKaigi 2017 のために、広島に行ってきました(その話はまた別途)。

本記事では、2017/08/30, 31 に弊社で開催した Cookpad Ruby Hack Challenge (RHC) の模様についてご紹介します。

短いまとめ:

RHC の概要

Cookpad Ruby Hack Challenge は、Ruby インタプリタ(MRI: Matz Ruby Interpreter)に対して機能を追加したり、性能向上させたりする方法、つまり Ruby インタプリタを Hack する方法を、二日間かけてお伝えするイベントでした。細かい中身の話はせずに、最低限必要となる手順を一通り体験してもらう一日目と、自由にハックを行う二日目にわけて行いました。

イベント申し込みページにて6月末から7月末まで募集をしたところ、10人の定員に100名近くのご応募を頂きました。急遽、定員を5名追加し、15名定員としました。加えて、記事を執筆いただくために池澤あやかさんにご参加いただき、また弊社から4名の希望者が(サポート要員をかねて)参加しました。当日欠席は1名のみで、19名+私、という体制で行いました。

参加者とのコミュニケーションは Gitter を用いました。https://gitter.im/rubyhackchallenge/Lobbyという場所で連絡を行ったり、質問を受け付けたりしました。また、Ruby コミッタの集まる場所を https://gitter.im/ruby/rubyにも作り、Ruby の質問ができる状態にしましたが、遠慮したためか、参加者からの質問は、あまりありませんでした。

イベントの流れ

初日は基礎編、二日目は応用編という流れでした。

一日目は基本的に座学で、資料に沿って進めて頂きました。 解散が 17:00 と早いのは、私が保育園へお迎えに行かなければならないからでした。

二日目に、好きなテーマに挑戦してもらいました。また、その間にサブイベントとして、「まつもとゆきひろ氏 特別講演」「Ruby開発者との Q&A」を行いました。これらを開催するために、毎月行っているRuby開発者会議を、裏番組として同時開催してもらいました。

8/30 (水) 一日目

  • 10:00 オープニング
  • 10:30 ハックに必要となる事前知識の講義
  • 12:00 ランチ
  • 13:00 共通課題
  • 16:00 発展課題の紹介と割り振り
  • 17:00 解散

8/31 (木) 二日目

  • 10:00 発展課題の開始
  • 11:30 まつもとゆきひろ氏 特別講演
  • 12:00 Ruby開発者を交えてのランチ
  • 13:00 Ruby開発者との Q&A セッション
  • 14:00 発展課題の再開
  • 18:30 打ち上げパーティー

一日目 基礎編

f:id:koichi-sasada:20170929111031j:plain

https://github.com/ko1/rubyhackchallengeにある資料をもとに、説明を聞いてもらい、演習を行ってもらう、というように進めました。

資料をざっとご紹介します。

  • (1) MRI 開発文化の紹介
    • MRI の開発は、誰がどのように行っているのか、大雑把に説明しています。
    • バグ報告の仕方など、一般的な知識も含んでいます。
  • (2). MRI ソースコードの構造
    • MRI のソースコードの構造を紹介し、演習として、MRI をビルドしてもらいました。
    • 演習といっても、実際に行う手順は書いてあるため、その通りに手を動かしてもらう、というものになっています。ここで扱った演習一覧を抜き出します。
      • 演習: MRI のソースコードを clone
      • 演習: MRI のビルド、およびインストール
      • 演習:ビルドした Ruby でプログラムを実行してみよう
      • 演習:バージョン表記の修正(改造)
  • (3) 演習:メソッドの追加
    • 実際に、MRI に機能を追加していきます。
    • 次のようなメソッドを、演習として追加してもらいました(手順はすべて記述してあります)。
      • Array#second
      • String#palindrome?
      • Integer#add(n)
      • Time#day_before(n=1)
    • また、拡張ライブラリの作り方や、デバッグに関する Tips を補足しています。
  • (4) バグの修正
    • バグの修正方法と、バグの報告の方法について紹介しています。
    • 次の二つのケースについて、具体的な話を紹介しています。バグ発見の技術的な方法に加え、心構えみたいなことも書いているので、そこそこ実践的な内容だと思いますが、どうでしょうか。
      • 他の人のバグ報告を見る場合(Kernel#hello(name)という架空のメソッドを例に)
      • バグを自分で発見してしまった場合(Integer#add(n)という架空のメソッドを例に)
  • (5) 性能向上
    • 性能向上についての諸々の話を書いています。
    • このあたりは、最後の方に書いたので、だいぶ雑になっています。演習もありません。

読むだけで進められるように書いたつもりなので、興味がある方は、読んで実際に手を動かしてみてください(読んでもわからない、という場合は、どの辺がわかりづらいか、こっそり教えてください)。

二日目 応用編

発展課題として、いくつか課題の例をあげておきましたが、これに限らず好きなことに取り組んで頂きました。ただ、こちらにリストした内容を選んだ人が多かった印象です。取り組んで頂いたテーマは、GitHub の issue でまとめてもらいました https://github.com/ko1/rubyhackchallenge/issues

いくつかご紹介します。

Hash#find_as_hash の実装

Hash#findの返値が配列なので、Hash を返す版が欲しい、という新規メソッド開発の挑戦です。が、1要素の Hashを返しても使いづらい、ということに気づいたそうで、nice try! ということで、終わりました。

フレームスタックの可視化

VM の状態を可視化するために、各フレームの状態を JSON で出力する仕組みを作り、そしてそれを表示するビューア https://github.com/kenju/vm_stackexplorerを作ってくださいました。懇親会でデモまで行ってくださいました。一日でここまでできるとは。

help に --dump オプションを追加

ruby -hで出てくるメッセージに不足があったので、足しましょう、という提案です。この挑戦を行ったのは Ruby コミッタの sonots さんで、さすが手堅い、実際に困ったんだろうな、という提案です。

なお、参加者に Ruby コミッタの sonots さんも混ざっているのは、サポート役としてお願いしたためです。後で伺ったら、曖昧にしていたことが多く、得るものは多かったということです。

Procに関数合成を実装

Proc#composeという、二つの Procを合成する、いわゆる関数合成を行うためのメソッドを提案されました。一度試したことがあったそうで、C で書き直し、似たような提案のチケットにコメントとして追記してくださいました。コミッタを交えたパーティーでは、この仕様についていろいろと議論が盛り上がりました。

ビルドしたRubyでのGemのテスト

開発中の Ruby で、任意の gem のもつテストを行うことができる、という仕組みの提案です。私がほしーなー、と言っていたら、作ってくださいました。

最近の Ruby には bundled gem という仕組みで、いくつかの Gem をインストール時に同時にインストールする仕組みがあるのですが、それらの Gem のテストを簡単に行う方法がありませんでした。また、人気の Gem(例えば、Active Support とか)も、同様に試すには、一度インストールして、bundleして、... といくつかの手順を必要としていました。この提案では、これらのテストを、Ruby をインストールせずに makeコマンド一発でできるようになります。MRI 開発者が(人気の)Gem を動かせなくなるような変更を入れる前に気づくことができるように(多分)なります。

サブイベント

二日目の途中に、Ruby 開発者会議で来ている Ruby コミッタに頼んで、下記のイベントを行いました。

まつもとさんゆきひろさんによる特別講演

f:id:koichi-sasada:20170929111037j:plain

大雑把に「30分でいい話をしてください」と依頼したら、いい話をしてくださいました。話の詳細は、池澤さんのレポート記事( Rubyのなかを覗いてみよう!「Cookpad Ruby Hack Challenge」に参加してみた )をご参照ください。

昼時だったので、発表を行ってもらった場所に隣接するキッチンで、社員の昼食を作っていました。料理しているところで発表するのは、多数の発表経験のあるまつもとさんでもさすがに初めての経験だったとのこと。

Ruby開発者との Q&A

f:id:koichi-sasada:20170929111044j:plain

Ruby 開発者を並べて、参加者および弊社社員を含めた質疑応答大会を行いました。RubyKaigi での企画 Ruby Committers vs the World の前哨戦でした。

パーティー

f:id:koichi-sasada:20170929111051j:plain

最後に、開発を終えた参加者の皆さんと、開発者会議を終えた Ruby コミッタが合流し、パーティーを行いました。

パーティーでは、二日目に行った挑戦を発表してもらいました。その場で、まつもとさんをはじめ Ruby 開発者と本気のディスカッションが発生していました。

まとめ

本記事では、2017/08/30, 31 に弊社で開催した Cookpad Ruby Hack Challenge (RHC) の模様についてご紹介しました。

参加者の皆様へのアンケートからは、良かったという感想を多く頂きました。 ただし、いくつか反省する点があり、次回以降で改善していきたいと思っています。

今回は(多分)成功したので、今後も続けて行ければと思っています。 初回だったので、まつもとさんに特別講演をお願いするなど、だいぶ力を入れてしまいました。 次回以降は、もうちょっと力を抜いていこうと思います。

参加したかったけど、定員オーバーで参加出来なかった方、そして、今回知って、興味を持たれた方、次回以降にぜひご期待ください。 調整次第ですが、出張して行うことも可能かと思います。 また、Ruby 以外にも広げられるといいですね。夢(だけ)は広がります。

本イベント開催にあたり、Ruby コミッタや、多くの弊社社員に助けて頂きました。 この場を借りて、御礼申し上げます。

最後にご案内です。フォローアップイベントとして、RHC もくもく会を弊社にて開催します(2017/10/11 (水) 18:30-、申し込みは Ruby hack Challenge もくもく会)。Ruby コミッタとして遠藤と笹田がサポートします。 RHC 参加者に限らず、Ruby のハックに興味のある方がいらっしゃいましたら、ぜひご参加ください。

料理の追体験を実現する「タイムライン」のデザイン

$
0
0

こんにちは、サービス開発部のデザイナー若月(id:puzzeljp)です。

すでにご利用していただいている方もいらっしゃると思いますが、iOS / Android アプリにタイムラインという機能が登場しました。

f:id:puzzeljp:20170929172254p:plain

先日そのタイムラインのデザインについての登壇しました。 (イベントレポートはこちら)
今回はその時話しきれなかったこと、タイムラインの開発時のデザインの工夫や苦労についてご紹介します。

タイムラインとは

フォローしているユーザーさんやすべてのユーザーさんの新しいレシピ投稿やつくれぽが見られるようになりました。 レシピ検索では出会えなかった料理に出会うことができ、実際にレシピが見られるので料理をすることができます。

どんな使い方があるかと言うと例えば「Aさんがパエリアを作っている!私作ったことないけど、Aさんが作っているなら私でも作れそう。作ってみよ!」のような料理の追体験ができるようになります。

f:id:puzzeljp:20170929172351p:plain

新しい見え方

タイムラインを開くと、「レシピのカード」と「つくれぽのカード」があります。検索結果と比べて料理の写真を大きく見やすく表示しています。ユーザーさんのアイコンや名前がカードに表示されているので、誰がどんな料理をしているかわかるようになりました。

f:id:puzzeljp:20170929172408p:plain

登壇資料

当日の登壇資料については、以下で見ることができます。

デザインアプローチ〜工夫と苦労〜

つくれぽカードのデザイン

タイムラインでは、「つくれぽのカード」で料理の追体験ができるような様々な工夫をしています。

工夫した点

つくれぽとレシピ投稿を比較すると、つくれぽは気軽に投稿できます。そのため、タイムラインを見ると「つくれぽのカード」の方が多く存在します。もう1つに、つくれぽは作った直後に送るため、今日何の料理を作ったこともわかるようになります。 そのため、レシピ投稿と違い「誰のレシピを作ったのか」「何のレシピを作ったのか」が必要となります。

「誰のレシピを作ったのか」「何のレシピを作ったのか」がタイムライン上でより伝わる物は何かをWebプロトタイピングで検証をしました。Webプロトタイピングのメリットして、以下があげられます。

  1. 実データを利用できること
  2. アプリよりもより高速に検証できること
  3. デザインプロトタイピングよりも正確に検証できること

実際に検討したレイアウトについてご説明します。

A案
作者名やレシピ名を同じ文章として扱いました。文字の大きさは同じですが、色はそれぞれ分けています。1文として見えるので文章としては見やすくしました。
B案
A案と似ていますが、レシピ名を目立たせるために、作者名を小さくすることでバランスを取りました。1文というのは同じですが、文章内で優先度がつきました。

f:id:puzzeljp:20170929172428p:plain

他にもレイアウトを考えましたが、最終的には以下のレイアウトになりました。
理由としては、タイムラインでカードが並んでいる場所に、文章があっても読まないのでは?という仮説がありました。それを解決するために、写真の上に「誰のレシピなのか」、写真の中に「何のレシピなのか」というレイアウトにしました。このレイアウトにしたことにより、適度な文章量と写真も目立つように、カードの高さも少なくなりました。

f:id:puzzeljp:20170929172450p:plain

苦労した点

今回のWebプロトタイピングは、メリットを活かした検証ができました。 問題点として、細かいデザインはアプリと異なるため、実際のアプリに実装してみたらイメージと異なったことやWebプロトタイピングも実装の時間がかかり、アプリの実装にも時間がかかってしまうことでしたが、デザインのプロトタイピングよりは検証は正確にできる点はやはりメリットだと感じました。

誰が作ったがわかる機能

フォローユーザーさんが既につくれぽを送ったレシピに対して、自分自身がつくれぽを送ると、送ったつくれぽが、フォローユーザーさんの「つくれぽのカード」に表示されるようになります。他のフォローユーザーさん同士でも同じレシピにつくれぽを送っていても「つくれぽのカード」に表示されます。

工夫した点

表示される内容は、「誰が作ったかわかる見出し」、それぞれのアイコン・名前とつくれぽの写真です。 例えば見出しには、「○○さんと△△さん他n人が作りました」や「○○さんがn回リピートしています」等と表示され、「誰が作っているか」「誰が何回作っているのか」「自分自身が何回作ってるか」がわかるような文言を20パターンほど用意しています。パターンが多いことで、より正確に「誰が作っているか」「誰が何回作っているのか」「自分自身が何回作ってるか」がわかるようになりました。

f:id:puzzeljp:20170929172510p:plain

苦労した点

見出しの部分が一番苦労しました。まず起こりうる組み合わせを考え、その後実際に表示される文言を考えましたが、「カード投稿者が他人の場合は他人をテキスト内に含めない」や「テキストがカード投稿者が自分の場合と異なる」などのカードの見た目は共通しているものの表示される文言が違うと複雑になってしまいました。 複雑になってしまったことで、テストケースを回した時に、その起こりうる組み合わせを出すことが難しく、専用のアカウントを作成し、ログインし確認を行う作業を20回以上しました。
もし次回こういった文言を考える時には、ユーザーさんに最低限伝わる文章を考えようと思いました。ユーザーさんにわかりやすいものをと思いましたが、開発が遅くなってしまいユーザーさんに届くのが遅くなってしまうよりも早くリリースを行い、検証をしたほうが良いためです。

まとめ

タイムラインは、時間をかけて開発を行ってきました。新しい機能のため、実装に時間がかかるのはもちろんですが、デザイン的にも様々な工夫を行ったためです。 そんなにデザインを工夫する必要があるの?…といった部分があるかもしれませんが、「タイムラインを見て料理がしたくなる」「タイムラインを見ていて、料理をしたくなったらレシピを見たら料理ができる。」そんな体験が自然とできるようにと開発を行いました。
タイムラインがリリースされたことによって、みんながどんな料理をしていることがわかるようになりました。 気になった作者さんをフォローをすると、よりタイムラインが楽しく、料理がしたくなるようになると思います。ぜひタイムラインを使ってみてください!

Ruby の脆弱性を見つけた話

$
0
0

こんにちは、技術部の遠藤(@mametter)です。フルタイム Ruby コミッタとして、クックパッドにあたらしく入社しました。よろしくお願いします。

最近、Ruby や RubyGems の脆弱性を発見して、その結果セキュリティリリースにつながるということを経験しました。どういう動機でどのように脆弱性を発見したか、どのように通報したか、などについてまとめてみます。Ruby の脆弱性を見つけたけどどうしよう、という人の参考になれば幸いです。

HackerOne について

HackerOneという脆弱性情報の通報と公開のためのプラットフォームをご存知でしょうか。

OSS にとって脆弱性情報の管理は面倒なものです。脆弱性の通報を秘密裏に受け付け、関係者だけで議論しなければなりません。そのため、通常のバグトラッカとは別のコミュニケーションチャンネルを用意する必要があります。

そこで HackerOne が使えます。HackerOne は簡単に言えば、脆弱性情報の管理に特化した非公開のバグトラッカサービスです。登録されたOSSプロジェクトに対して誰でも脆弱性情報を通報できます。また、プロジェクトメンバ間や報告者の間で非公開の議論もできます。問題が解決された際には議論の内容が公開されます。

さらに、Internet Bug Bounty (IBB) programがインターネットを維持するために特に重要なソフトウェアと指定している一部のプロジェクトについては、通報されたバグが開発者によって脆弱性と認定された場合、IBB から報告者に報奨金が支払われます。

ただ、報奨金が出るのは良し悪しです。良い通報をしてくれた人が報われるのは当然良いことなのですが、報奨金目当ての雑な指摘がたくさん来るという副作用があります。完全に見当違いな例を上げると、「SVN が公開状態だぞ!」とか、「バグトラッカの issue 一覧が丸見えだぞ!」とか 1。もちろん有益な通報も来るのですが、通報を受ける側としては、もうちょっとノイズが減るといいなあ、と思っています。

そこで、 Ruby ユーザの方々に HackerOne を紹介したいと思い、そのために一回、私自身が通報者としてのプロセスを経験してみました。

ターゲットの選定

自分が一番慣れている OSS プロジェクトは Ruby なので、Ruby のソースコードから脆弱性を探すことにしました 2。Ruby に標準添付されたライブラリの中で、「脆弱性といえば WEBrick」。という直観にもとづき、そのへんをターゲットにしました。

探す脆弱性の選定

「Ruby の脆弱性」に明確な定義はありません。ある Rails アプリに任意コード実行(外部から攻撃コードを送り込んで実行させられる)があれば、どこかに脆弱性があることは確かですが、Ruby の脆弱性かもしれないし、Rails(またはサードパーティ)の脆弱性かもしれないし、はたまたユーザの書いたプログラムの脆弱性かもしれません。極端な例では、system("ls " + user_input)みたいなプログラムがあると OS コマンドインジェクションができますが、これを Ruby の systemのせいだと言われても困ります。Ruby 本体かユーザプログラムかの切り分けは、わりと揉めやすいところです。

今回はここで揉めないよう、言い逃れしにくい脆弱性を探すことにしました。それは、そのプロジェクト自身が過去に脆弱性と認めたバグに近いバグを見つけることです。

WEBrick の過去の脆弱性を探したら、『WEBrick にエスケープシーケンス挿入の脆弱性』が見つかりました。要するに、ログにエスケープシーケンスを紛れ込ませることができたら脆弱性のようです。個人的にはこのくらいで脆弱性なんだ、という驚きがありますが、一部のターミナルエミュレータはエスケープシーケンスで危うい挙動を起こせることがあるそうです。詳しくはリンク先を読んで下さい。

脆弱性の発見

実際に脆弱性を探します。過去の脆弱性の修正コミットを手がかりに WEBrick のログ出力まわりを読解すると、WEBrick::AccessLog.escapeというメソッドでエスケープシーケンスを除去(サニタイズ)し、WEBrick::BasicLog#error#warnなどのメソッドで実際にログを書き出すらしいことがわかります。ここで、AccessLog.escapeWEBrick::HTTPStatus::Status#initializeの中でしか呼ばれていないことに気づきました。つまり、この例外経由でしかサニタイズがされないらしいということです。

そこで、#error#warnを直接呼び出すところを探したところ、WEBrick::HTTPAuth::BasicAuth#initializeに見つかりました。不正なユーザ ID で BASIC 認証すると、そのユーザ ID がサニタイズなしでログに流れ出るようです。

(あっさり見つけたように書いてますが、実際にはいろいろ探したり試行錯誤したりしながらだったので 2 晩くらいはかかったと思います)

脆弱性の確認

この脆弱性が実際に exploit 可能であることを確かめます。WEBrick の BASIC 認証のコードを Web 検索しながら書きます。

require "webrick"
require "webrick/httpauth"

srv = WEBrick::HTTPServer.new({ Port: 34567 })
db = WEBrick::HTTPAuth::Htpasswd.new("dot.htpasswd")
authenticator = WEBrick::HTTPAuth::BasicAuth.new(UserDB: db, Realm: "realm")
srv.mount_proc("/") do |req, res|
  authenticator.authenticate(req, res)
    res.body = "foobar"
  end
srv.start

↓サーバを起動した様子 f:id:ku-ma-me:20171004180721p:plain

このサーバに対して、エスケープシーケンスを混入した不正なユーザ ID でログインを試みます。ここでは、"\e]2;BOOM!\a"というエスケープシーケンスで実験しました。これは、端末のタイトルを BOOM!という文字列に変える命令です。

require "open-uri"

open("http://localhost:34567/login",
  http_basic_authentication: [
  "ESCAPE SEQUENCE HERE->\e]2;BOOM!\a<-SEE WINDOW TITLE",
  "passwd"
]).read

↓クライアントを起動する様子 f:id:ku-ma-me:20171004180728p:plain

この結果、WEBrick サーバを動かしている端末のタイトルが、BOOM!に変わることが確認できました。

↓攻撃成功した様子(タイトルバーが "BOOM!"になっているところがポイント) f:id:ku-ma-me:20171004180738p:plain

脆弱性の報告

めでたく(?)脆弱性を確認できたので、HackerOne に投稿します。Weakness や Severity は該当すると思うものを選ぶだけですが、よくわからなかったら空欄でもよさそうです。重要なのは Proof of Concept です。といっても、普通のバグ報告と同じです。どういう問題であるかと、再現手順をきっちり書けば十分でしょう。問題の重大さを書くとさらに親切です。今回の脆弱性は過去の脆弱性の修正漏れなので重大さに議論の余地はないと考え、ほとんど再現手順だけを簡単に書きました

あとは普通のバグ報告と同じ対応です。よほど致命的な問題でない限り(あるいは致命的な問題であっても)、開発者はなかなか返事をしてくれないものです。パッチを書いて送ったり、ときどき催促したりしながら、気長に待ちます。今回は、4 月に報告して、セキュリティリリースは 9 月でした。

セキュリティリリース

普通の報告者ならここで終わりですが、今回は私が Ruby コミッタでもあるということで、セキュリティリリースに少しだけ参加しました。といっても私がやったのは、ブランチマネージャや公式サイト管理人たち(@unakさん、@nagachikaさん、@hsbtさん)の指示の下、私が書いたパッチをコミットしただけです。あとは彼らが一生懸命 tar ball を作ってリリースするのを応援していました。

コミットしてからリリースアナウンスを出すまでの時間を最小化するため、リアルタイムのコミュニケーションを取りながら進める必要があります。Ruby のブランチマネージャたちは、セキュリティリリースのたびに命を燃やして頑張っています。敬礼。

報奨金の獲得

無事セキュリティリリースがなされたということで、IBB から報奨金として $500 をいただきました。このプロセスも簡単に説明しておきます。

まず、初めて報奨金をもらう場合、税務上の書類 W-8BENを作成して提出します 3。すべてオンラインの手続きなので難しいことはありませんでした。

それから支払いの受取方法を登録します。PayPal 、Bitcoin via Coinbase 、銀行間振替がサポートされていました。私は銀行間振替を選んだので、口座情報を入力するだけでした。4

RubyGems の脆弱性

同じようなプロセスで、RubyGems にも通報をしました。

詳細は割愛しますが、CVE-2015-3900という過去の脆弱性が適切に修正されていないというものでした。ただ、こちらはすでに他の人が通報済みだったので、Duplicate でクローズされました。

ただ、コードを読んでいるうちに次の 3 つの問題を新規発見しました。こちらの方の通報は認められたようです。

これらの通報に対する修正は、RubyGems 2.6.13としてリリースされています。特に 3 つめの問題は、WEBrick の問題よりもう少し重大だと思うので、バージョンアップすることをおすすめします。なお、Ruby 2.4.2 は RubyGems 2.6.13 の修正を含んでいるので、Ruby 2.4.2 にするのでも大丈夫です。

まとめと所感

Ruby の脆弱性を探して HackerOne に通報した事例を紹介しました。

セキュリティ報告をすると、多くの場合、公式サイトでクレジットに載せてもらえるので、承認欲求が満たされますし、HackerOne ならちょっとした報奨金までもらえます 5

最初に触れたとおり、今のところ HackerOne 経由で Ruby にくる通報は、雑な通報が多くて Ruby 開発者的にはノイズが多いと感じられています。この記事を見た人が、(Ruby に限らず)有意義な通報を増やしてくれるといいなと思います。

最後になりましたが、クックパッドでは脆弱性のない Rails アプリを作れる Web アプリケーションエンジニアを募集しています。詳しくは募集要項ページをご覧ください。


  1. Ruby はオープンソースプロジェクトなので、もちろん意図的に公開しています。

  2. IBB の FAQによると、プロジェクトの開発者自身でも、(1) そのプロジェクトで収入を得ていないこと、(2) 問題のコミットに関わった人間でないこと、の条件を満たせば報奨金がもらえます。私はフルタイムコミッタになったので、もう無資格のようですが、今回の通報は入社前にやりました。

  3. 米国非居住者が米国の人から支払いを受け取るときに、源泉徴収の金額を低減してもらうための書類。

  4. 正確には、海外からの送金を受け取るために、銀行にマイナンバーの登録をする手続きもありました。

  5. エスケープシーケンスインジェクションでは大した金額にはなりませんでしたが、もっと重大な脆弱性ならそれなりに高額になるはずです。たとえば有名な Shellshock だと $20,000も支払われたそうです。

Cookpad Tech Kitchen #9 〜1行のログの向こう側〜 を開催しました!

$
0
0

こんにちは! @yoshioriです。

2017/07/26、技術系イベント「Cookpad Tech Kitchen #9 〜1行のログの向こう側〜Cookpad Tech Kitchen #9」を開催しました。 (はい。僕が記事公開するの忘れててだいぶ遅くなっちゃいました>< ごめんなさい)

クックパッドの技術的な知見を定期的にアウトプットすることを目的とする本イベント。第9回目となる今回は「ログの活用方法」をテーマに開催しました。(月に1回程度開催しています)

月間6000万ユーザが使っているクックパッドには大量のログが集まってきます。そのログを効果的に活用してサービスやユーザに還元するための取り組みについて、インフラ、広告事業、サービス開発それぞれの視点で知見の発表を行いました。

発表資料を交えてイベントのレポートをしたいと思います。

f:id:cookpadtech:20171003220443p:plainイベントページ:

Cookpad Tech Kitchen #9 〜1行のログの向こう側〜 - connpass

発表内容

「クックパッドのログをいい感じにしているアーキテクチャ」

一人目の発表者であるインフラストラクチャー部部長の星(@kani_b)は、SRE としてAWSやセキュリティ関連でのサービスインフラ改善に携わっています。

今回は AWS Summit Tokyo 2016 Developer Conference で発表した内容の続きとして発表を行いました。 具体的な数字として Fluentd に流れているログベースで言うとデータ総量は 400 〜 600GB / 日、レコード数は 8 億レコード以上 / 日(秒間 8,000 〜 25,000 レコードくらい)という規模のデータを扱っています。これをどのように集めて処理しているのかを紹介しました。

資料

「広告ログのリアルタイム集計とその活用」

二人目の発表者であるマーケティングプロダクト開発部の渡辺(@wata_dev)は、主に配信基盤の改善やマーケティングプロダクト開発部で開発しているサービスの基盤周りのサポートを行っています。

クックパッドでは広告の配信も自社で行っており、そのためにどのようにログを活用しているのかを、過去どういった問題点があってそれをどのように解決していったか、異常検知だけではなく配信制御や在庫予測などなど広告配信というドメインで実際に必要になるケースを出しながら紹介しました。

資料

「ログを活用したサービス開発」

3人目の発表者であるサービス開発部の外村(@hokaccha)は、バックエンドからWebフロントエンド、モバイルアプリの開発など幅広い分野でCookpadのサービス開発に携わっています。

発表ではモバイルアプリのロギングのやりかたから始まり、実際のログの活用方法としてサービス開発側でログをどのように扱っているか、どう活かしているかを紹介しました。 行動分析としてログの設計、ログの分析やその可視化によってサービス改善の意思決定に使われていること、ログを利用した機能開発として調理予測という実際にログがサービスとして使われている事例を紹介しました。

資料

「ログ」をテーマにしたご飯も登場!

クックパッドのイベントではご来場の感謝を込めて、会場で手作りしたご飯でおもてなしをします(食べながら飲みながら発表を聞いていただくスタイル)。今回はテーマである「ログ」にちなんだメニューを用意してもらいました。

f:id:cookpadtech:20171003220752j:plain

こちらはメッセージ入りのライスケーキ。クックパッドのインフラエンジニアが大切にしている言葉に「1行のログの向こうには1人のユーザがいる」というものがあります。画面で見るとたった1行のログだけど、その向こうには大切にすべき1人のユーザがいる、ということを思い出させてくれる合言葉です!

f:id:cookpadtech:20171003221913j:plain

川嶋シェフの粋な心意気で、素敵なメッセージもテーブルに並びました。

f:id:cookpadtech:20171003221721j:plain

こちらはログ=丸太をイメージした湯葉のデザートです。中に入っているのは甘すぎないところがおいしいあんこです。

まとめ

いかがでしたか。クックパッドでは毎月テーマを変えて技術イベントを開催しています。ご興味のある方は是非ご応募ください。

cookpad.connpass.com

新しい仲間を募集中

■ 日本最大の食のビッグデータを扱う「データ基盤」の開発に興味がある方 https://info.cookpad.com/careers/jobs/careers/data-infra-engineer

■ 広告事業の「Webアプリケーション開発」に興味がある方 https://info.cookpad.com/careers/jobs/careers/marketing-product-engineer

■ クックパッドアプリの「Webアプリケーション開発」に興味がある方 https://info.cookpad.com/careers/jobs/careers/software-engineer

クックパッドのデータ活用基盤

$
0
0

インフラ部 & 技術部の青木峰郎です。 クックパッドでは全社的にAmazon Redshiftを中心としたデータ活用基盤を構築しています。 今日はその全体像についてお話ししたいと思います。

データ活用基盤の全体像

まず、以下にクックパッドのデータ活用基盤の全体像を示します。

f:id:mineroaoki:20171005230822p:plain

大きく分けると入力が2系統、内部処理が1系統、出力が3系統あります。 入力はMySQLからのインポートとログのロードがあり、どちらも独自に構築したシステムで行われています。 DB内部のデータ処理はSQLバッチのみです。 そして出力は管理画面やBIツールからのアクセスとバッチ処理によるエクスポートに大別できます。

以下1つずつ説明していきましょう。

入力その1: MySQLインポートシステム

MySQLからRedshiftへのマスターテーブル取り込みにも独自のインポートシステムを使っています。 このインポート処理には、つい最近まではごく普通のバッチジョブを使っていたのですが、 現在は独自開発の専用システム(pipelined-migrator)に乗り換えつつあります。

専用システムを作った理由は、インポートするテーブルの追加を誰でも簡単にできるようにするためです。 pipelined-migratorにはウェブベースの管理画面が付いており、 この画面からボタン1つでインポートするテーブルの追加や削除が行えます。 またインポート状況などを確認することもできます。

バッチとpipelined-migratorのいずれにしても、 MySQLからテーブルを取り込む方法としてはごく単純な全行ダンプ・全行ロードのみを実装しています。 分析システムの構築当初はbinlogを使った差分更新も検討したのですが、運用が面倒すぎることと、 「全行ロードでも間に合うから」という消極的な理由によってこの実装になりました。 将来的にパフォーマンスが間に合わないなどの理由があれば差分更新にするかもしれません。

入力その2: ログをロードするStreaming Loadシステム

ログのロードには自前で開発した bricolage-streaming-loaderbricolage-streaming-preprocessorを使っています。 loaderはRuby製でpreprocessorはJava製です。

このシステムは、一言で言うと、fluentdからS3に書き込んだJSONファイルを前処理しながらロードするシステムです。 またRedshiftはコミットの遅延が比較的大きいため、そこを軽減するためにバッファリングも行っています。 このシステムの設計方針については本ブログの過去の記事 「Amazon Redshiftへ継続的にデータをロードする際に気をつけること」で詳しく説明しているので、そちらをごらんください。

このStreaming Loadシステムには専用の管理画面が用意されており、 ログが処理されていく様子を1オブジェクトずつ丁寧に見守ることができます。

入力その3: Redshift Spectrum向けロードシステム(未リリース)

さきほどの図には存在しませんでしたが、 RedshiftからS3のデータをアクセスできる「Redshift Spectrum」への対応も計画しています。 Spectrumはまだ東京リージョンに来ていないので計画に留めていますが、来た瞬間に稼働させるつもりです。

Spectrumを使う目的は、第1にログのロードのレイテンシを短縮すること、第2にディスク節約です。 特に、巨大なわりにあまりアクセスのない過去ログなどは、Spectrum(S3)に逃してやると、 Redshiftの高速性とS3の安価なストレージをいいとこ取りできるので大変いい選択肢だと思います。

データの加工: SQLバッチ

いったんRedshiftにデータを取り込んだら、あとは原則としてすべての処理をSQLで記述します。 このSQLによるバッチ処理はBricolageというSQLバッチフレームワークと、 ジョブ管理システムKuroko2の組み合わせで構築しました。

この2つについては過去にだいぶいろいろ書きましたので、 Bricolageについては 「巨大なバッチを分割して構成する 〜SQLバッチフレームワークBricolage〜」を、 Kuroko2については 「クックパッドのジョブ管理システム kuroko2 の紹介」を、それぞれごらんください。

Redshift内のデータアーキテクチャ

SQLバッチは全体で一番地味ですが、最も重要な部分でもあります。 データ分析基盤と言うとデータを取り込むところばかりが注目されがちですが、 データ取り込みは本番前の準備にすぎません。 その後ろに連なるデータの統合と分析こそがデータ「活用」の本丸です。

クックパッドではRedshift内を論理的に3層に区切り、 1つめを入力層、2つめを論理DWH層、3つめを論理データマート層としています。

入力層は名前の通り、他のデータソースからデータを入力する層です。 基本的に送られてきたデータが元のままの形でそのまま入っています。

論理DWH層は、いわゆるデータウェアハウス(Data WareHouse)です。 入力層のデータをクレンジングし、複数システムのデータを統合し、場合によっては集計もして、 全社の分析基盤となるデータを作ります。

……と書くとかっこいいですが、まだこの層はあまり成長させられていません。 まだここまで手が回っていないというのが正直なところで、今年末から来年にかけての最大の課題だと考えています。

最後の論理データマート層は特定のデータ分野ごとの特化領域です。 例えばクックパッドの場合だと、レシピ検索、広告、有料会員、レシピサービス、などの分野が存在します。

またこの層は対応する部がはっきり決まるので、その部に全権を委任しています。 逆に言うと、入力層とDWH層はインフラ部が管理しており、 他の部のメンバーが何か変更したい場合はpull requestが必須ということです。

これらの主要領域以外に、それぞれのメンバーは自分専用のスキーマを持つことができ、 その中では自分の自由にデータを加工・保存することができます。いわゆるサンドボックスです。 エンジニアはもちろん、ディレクターやプランナーも場合によっては自分のスキーマを持ち、 自分でSQLを書いて分析することがあります。 最近の社内におけるSQL熱の高まりを受けて、先日はインフラ部メンバーによる社内向けSQL講座も行われました。

出力その1, 2: BIツールと管理アプリからの参照

データベースへの入力と加工について話したので、残るは出力系です。 まずBIツールと管理アプリ(社内用ウェブアプリ)について話しましょう。

BIツールと管理アプリのアクセスは傾向が似ており、 ごく少量のメタデータ読み書きと、大量の統計データ読み込みが発生します。 前者はO/Rマッパーによる構造化されたアクセス、 後者は直SQLとカーソルを使ったアクセスが主になるでしょう。

Redshiftにおけるカーソルの特徴と使いかたについては過去の記事 「ActiveRecordを使ってRedshiftから大量のデータを効率的に読み出す」を参照してください。

なお、BIツールとしては現在のところ、社内で動かしているRedashをメインに使っています。 しかし正直なところRedashはキューまわりのできが悪すぎて、 アドホックな社内ローカルパッチを大量に当ててなんとか回しているような状況です。 いま真剣に移行を検討しています。

移行先の第一候補はなんだかんだでTableauでしょうか。 Tableauは以前から細々と使ってはいたのですが、 ついにTableau ServerのLinux版が出そうなのでいよいよ本格導入の時かもしれません。

ところで、RedashやTableauは共有ダッシュボードを作るために使われることが多いですが、 それ以外に個人単位のアドホックな分析も多く行われます。 そのような目的には、Posticoのような普通のPostgreSQLクライアントや Jupyter、 それに弊社社員の外村が開発したbdashなどを使っています。 bdashは手軽にグラフが書けることと、過去のクエリーを記録しておける点が非常に便利で、 個人的にも気に入っています。

出力その3: バッチからの参照

3つめの出力系統は、主に他システムへのエクスポートを目的とした、バッチからの参照です。 以前はこの処理のためには単純にRedshiftへ接続してselectさせていたのですが、 最近はQueueryというHTTP APIシステムを挟むようにしています。

Queueryは、APIでselect文を受け付けて結果をS3にUNLOADし、そのURLを返すだけの単純なシステムです。 このシステムを作った一番の理由は、バッチからの読み込み方法をRedshiftのUNLOADだけに限定したかったという点です。

Redshiftのカーソルはleader nodeにデータをマテリアライズするうえに、カーソルがクローズされるまでコネクションを占有しつづけます。 いずれの特徴もleader nodeにかなり負荷をかけることになります。 そこを考えると、長時間に渡って大量のデータを転送する可能性のあるバッチアクセスは、ぜひともUNLOADにしておきたいのです。 UNLOADはcompute nodeからS3へ直接に、並列でデータを転送するので安心です。

また特に、Redshiftで作ったサマリーをMySQLへ単純転送する用途のためには、 redshift-connectorというRubyのライブラリ(gem)を用意して対応しました。 むろん、このredshift-connectorも抜かりなくQueueryに対応しています。

データベースのドキュメント: dmemo

さて、ここまでで、データを入れて、きれいにして、サマリーも作り、 他のシステムから参照・利用できるようになりました。ではそれで終わりでしょうか?

当然、違います。データを作ったら、それを使えるように説明する必要があります。 ようするにドキュメントがいるのです。

データのドキュメントのためには、これまた弊社社員の小室が開発したdmemoを使っています。 これにも過去記事があるので、詳しくは下記の記事をごらんください。

まとめ

今回はクックパッドのデータ活用基盤について、その全体像をお話ししました。 これまでそれぞれの部分について書いたり話したことはたくさんあったのですが、 よくよく考えてみると全体を説明したことがなかったので、この機会にまとめてご紹介しました。 過去に書きためてきたブログ資産も生かすことができて一石二鳥ですね! ネタが思い付かないときはまたこの手で行こうと思います。

[宣伝] 『ふつうのLinuxプログラミング』の第2版が出ました

www.amazon.co.jp

わたしが12年前(!)に書いた書籍『ふつうのLinuxプログラミング』の第2版が出版されました。 第2版には、各方面からリクエストされまくっていたKindle版もついに出ています。 よっしゃ、いっちょ久しぶりにLinuxでCでも書いたるか! などという(わりと珍しい)機会がありましたらぜひご活用ください。

しかし、なぜわたしがブログ当番のときに限ってこう毎年毎年新しい本が出るのか…… まったく狙っていないのに、本当に不思議です。

Alignment and Autonomyな組織づくり

$
0
0

はじめに

サービス開発部部長の勝間(@ryo_katsuma)です。 普段は、エンジニア、デザイナ、ディレクターを含む様々な職種のメンバーのマネジメントを行っています。 今日は、私の部署における組織づくりの取り組みについてお話いたします。

背景

現在、私が所属しているサービス開発部は、年初の組織改編時に発足しました。レシピをさがす、のせるなどを含むレシピサービス、いわゆる「クックパッド」において、広告事業、会員事業など事業にまつわる開発以外のユーザーに触れる部分の開発を行っています。 クックパッドはPCウェブ、モバイルウェブ、モバイルアプリといくつかのプラットフォームをサポートしていますが、ここ最近の部署での開発はモバイルアプリを中心に行っています。

メンバーの数も他の部署と比較しても多く、学生アルバイトも含めて約45人が所属し、役割ごとに分割されたグループにも10人前後のメンバーが配置される、全社で見ても大規模な部署となっています。

課題感

モバイルアプリ「クックパッド」は2009年に最初のリリースが行われました。iOS, Android共に何度かリニューアルを経て今に至り、コードベースも関わる人の規模も大きなアプリです。部署としてはモバイルアプリの開発にフォーカスを行う中、このような環境下で価値を早く多く生み出していきたいという思いはあるものの、部署が立ち上がって2, 3ヶ月ほど経過した時点で次のような課題感を抱きました。

チーム内での課題

前述の通り、チーム規模も大きいものになっていることからか、なかなか開発のスピードが上がらないという現象が生まれていました。純粋に開発そのものの速さについての課感もありましたが、マネジメントの観点での課題が目立ちました。例えば、方向性の確認、デザインのすり合わせ待ちなど、チーム内でコミュニケーションの渋滞が起きるケースも少なくなく、「もっと意思決定を早く行い進めることができるはず」という考えを持っていました。

チーム外での課題

クックパッドでは、部署内の役割や施策の目的に応じて「グループ」という単位で組織を分割を行うケースが多くあります。たとえば私の部署では、レシピ検索の体験向上を目的にした「さがすグループ」調理後の料理の記録体験を目的にした「きろくグループ」など、期初の段階で幾つかのグループを設置していました。

一方、グループで分割されたメンバーは、良くも悪くもグループでの施策に閉じてしまう傾向がありました。たとえば「レシピをさがす観点で記録系プロダクトはどうあるべきか」「レシピの調理という観点で、記録のあるべき姿を考える」など、グループをまたいでサービスを捉え、どのような体験をユーザーに提供すべきか?という議論は十分に実施できていませんでした。

個人の成長の課題

前述のような観点での課題に対する取り組みを進め、部署の目標達成を目指す中、個人の成長を促す機会もしっかりつなげたい思いがありました。つまり、ただ業務効率の最適化を目指すだけではなく、業務を通じて個人の役割と責任範囲を広げ、結果として各メンバーが成長している状態を目指したいと考えました。

Spotifyモデルの適用

このような課題を解決する上で、自分たちだけでいろいろ試行錯誤を行うことも可能でしたが、まずは他社の「うまく回っている」開発スタイルについて事例調査をすすめることにしました。そんな中、Spotifyの考え方、カルチャーにたどり着きました。

この中で、特に注目したものは「Alignment and Autonomy」という考え方です。

f:id:ryokatsuma:20171009162924p:plain
図1: Alignment and Autonomy / Spotify engineering culture (part1) より引用

ここでの「Alignment」とは、いろいろなものや人を1つの考えのものに一致させていくこと、「Autonomy」とは、自律的に動いていくことです。これらの概念は、相反するものといっても過言ではないですが、「リーダーがどの課題をなぜ解くのかということにフォーカスし、どのように解くか?はチームに任せる」というアプローチによって、両立を目指そうとする考え方です。

会社の方向を向きながら、チームメンバーは自律的に垣根を超えて協力体制を作り進んでいく。これは、前述の課題に対して最適なアプローチで、理想の姿の1つとして目指していく価値はあるのでは?と考えました。

なぜSpotifyか?

Spotifyは以下のような特徴を持っています。

  • 組織の人数規模が数百人
  • グローバルな開発チームを構成
  • 音楽プレイヤーという1つのアプリケーションを多くのプラットフォームで展開

これらは、エンジニアが国内だけでも約100人、レシピサービス「Cookpad」をグローバルで展開するクックパッドの今の状況と非常に似ています。

また、Spotifyの取り組みについて研究、および言及されている文献も非常に数多くあります。たとえばHarvard Business Schoolでもその開発スタイルについて言及されており、アジャイル開発における1つのスタイルとして、デファクトスタンダードと言っても過言ではありません。

国内外含めていくつかの企業の事例を調査していましたが、これらの背景からSpotifyのモデルの導入検討は十分に価値があるものとして考えました。

Spotifyモデルの導入

以下の3つのステップでSpotifyモデルの導入を試みました。

ミッションの棚卸し

期初でも会社の方向性とそれに基いた部署で実施していくことのすり合わせは実施していましたが、Alignmentを改めて強化する観点で

  • 会社としてやるべきこと
  • 部やメンバー自身がやりたいと考えていること

を再整理しました。

前者は、「Spotify Rhythm」と呼ばれる全社的な戦略立案のフレームワークをそのまま採用しました。Data→Insight→Belief→Betという観点で現状を整理し、サービス開発の観点で「Company Bets (会社として賭けるもの)」について、上長(本部長)との議論を通じてお互いの認識を揃えました。また、後者は、私自身の考えや部署のグループリーダーたちの考えをもとに再整理しました。

結果として、部署で進めるテーマは期初に計画していたものと大きく変わることはありませんでしたが、Spotify Rhythmによって関係者の認識を改めて整理し、揃えることに大きく貢献をしました。

グループを撤廃した少人数のチーム編成

前述の通り、部署において「グループ」という構造を置くことで、メンバーは「これはxxグループの担当だから」と、グループ間の線引きを無意識に行ってしまうことがありました。 マネージメントの観点では、グループを作ることが重要ではなく、KPI達成に向けて目標管理を含めメンバーの日々のマネージメントを行うことが重要です。 そこで、思い切って組織構造上のグループを全て撤廃することにしました。メンバーは全て部付けにして、「みんな同じ立場である」ということを組織構造上で再認識してもらいました。

とはいえ、45人前後の部付けメンバーを全て直接私がマネジメントすることは不可能なので、さすがにチームの概念は導入します。チームはAutonomyを向上させる観点で以下の戦略で構成しました。

  • Spotify Rhythmで再定義した部署で開発をすすめるいくつかのテーマを「ミッション」と定義
    • たとえば「日本中の料理を記録する」「日本中の人がクックパッドで毎日の料理を見つけている状態にする」などのミッション
  • 各ミッションには、リーダー役として「ミッションオーナー」を設置する
  • ミッションに応じて1~2のチームに分割
  • 1チームは最大で5人前後
  • ロールは下記の人たちで構成し、チームによってはエンジニアはiOSエンジニアのみ、ディレクターはなしのようなケースも
    • チームリーダー(1チームの場合はミッションオーナーと同義)
    • エンジニア (バックエンド / iOS / Android)
    • デザイナ
    • ディレクター

f:id:ryokatsuma:20171010225406p:plain
図2: 組織構造の変更

人数を5人前後に絞っているのは、既存の10人前後のグループでの構成において、コミュニケーションに渋滞を引き起こし意思決定のスピードを下げていると判断したことが背景です。人数を絞った上で、ミッションに紐づくチームの役割を明確化することで、チーム内での意思決定を推し進めてAutonomyの強化を図りました。

余談ですが、ここでの「チームリーダー」のロールはエンジニアやデザイナ含めて若いメンバーにもどんどん挑戦してもらっています。チーム規模を小さくすることで、チームをまとめることが最悪うまくいかなかったとしても影響範囲を小さく留めることで、「将来的にミッションオーナーを任せたい人」「ミッションオーナーに興味がある人」「個人の役割を広げてもらいたい人」などに、積極的にトライをしてもらっています。

チームをまたいだ横断会の実施

小さいチームに分割しただけでは、結局既存のグループを分割しただけでチーム間の連携を取る、チームを俯瞰してユーザー体験を考えることは大きく変わらないと考えました。

そこで、ディレクターのチーム横断会、デザイナーのチーム横断会、Androidエンジニアのチーム横断会など、職種別の横断会を設けて、参加者のコンテキストを揃えた上で、お互いの持つ課題を解決できる環境を作ることにしました。各横断会では責任者を立て、それぞれの会ごとに最適な運営を実施しています。たとえば、ディレクター会ではチームを横断した知見共有を目的において、互いの施策に活かしたりスキルの向上に役立て、 iOSエンジニア共有会では、普段抱えている開発上での課題感や設計方針について議論をするなど、技術力の向上に繋がる取り組みを行っています。

このように、横断会を通じて、俯瞰して物事を捉え各メンバーのスキル向上の実現を試みています。

導入の結果

Spotifyモデルを導入し、組織構造に手を入れてから3ヶ月ほど経過しました。全て数字で測れるものではありませんが、次のような変化が見えてきました。

意思決定の速度向上

少人数のスモールチームを推し進めたことで、関係者とのコミュニケーションパスの数が減り、チームの中での意思決定を早く進められるようになりました。あわせて、少人数化を進めることによって施策における1人当たりの責任感が増すことになることで、施策における課題の定義の議論も徐々に活発になってきたように感じられます。

チーム間の協調

そもそもSpotifyモデル導入にあたって「もっとチーム間の協調を推し進めよう」という話をしていたという前提もありますが、「これはXXチームとやろうとしていることが似てるから、声をかけて一緒に進めよう」「XXチームが次の方向性を掘り下げようとしているみたいだから、話を聞きに行こう」のような会話が増えてきました。施策を進めるに上で、相乗効果を生み出すために、お互いに気を配る姿が見受けられます。

これらの効果の1つ1つはまだ小さいものではありますが、当初から期待している効果でした。継続的に取り組みを磨き込むことで、より高いレベルの「Alignment and Autonomyな組織」を目指したいと考えています。

新たな課題感

一方で、今回の取り組みを実施しても、また新たな課題感も感じています。

数字レベルでの成果

施策を遂行するスピードは上がってきていますが、まだまだ十分に数字レベルで目に見える効果が出てきているものは多くありません。より数字や効果の規模を意識した施策の進め方を考える必要がある、言い換えれば、Autonomyは高い状態になっているものの、施策の方向性についてのAlignmentはまだ高いレベルを目指す必要があると言えるでしょう。

もちろん、チームリーダーを含めて若いメンバーも多く、経験が十分に無いということもあると思いますが、同時にミッションに対して目指す方向性と高さについて、自分を含めてマネジメント側がメンバーと認識をもっと合わせていく必要があるでしょう。

開発そのもののスピード向上

作るものを決める、リリース計画を立てるなど、開発以外のマネジメントの観点において今回の取り組みによって開発のスピードの改善は進みました。 一方で、マネジメント以外での開発のスピード向上、つまり開発そのもののスピード向上については、今回の取り組みのスコープ外ではありますが、まだまだ工夫の余地があると考えています。

アプリ開発とWeb開発は当然、単純に比較することはできませんが、アイディアを形にしてユーザーに届けるまでに現状は大きな差があることは事実です。この点については、技術部など他部署を巻き込み、コードレビューや採用などいろんな観点で課題の整理と解決方法の模索をしているところです。スピードを上げることは同時に品質の問題も発生することになるので、なかなか難しいテーマですが、継続的に改善を目指していきたいです。

まとめ

規模が大きな組織において、意思決定のスピード向上や個人の成長を目指すために、Spotifyの開発モデルを自分たちなりに導入し、「Alignment and Autonomyな組織」を目指した経緯を述べました。背景として抱えていた課題感は少しづつ解決されてきたものもありますが、理想として目指したい状態とはまだまだギャップがあるのも事実です。

組織は生きもの」と(2年前から同じことを)言いますが、組織のサイズもメンバーも常に変化し続ける中、同じ取り組みをし続けても効果はありません。課題に対して、常に最適なアプローチを考え、時には外部から適切な手段を導入するなど、トライし続ける必要があります。読者の皆さんの周りでも規模はともかく、組織的な課題感があると思いますが、本エントリがそのトライの手助けになると幸いです。

また、「もっと良い組織を目指せるんじゃないの?」と思ってもらえた方は、ぜひ一緒により良い組織作りを目指しましょう!)


高速な研究開発を支えるGPU計算機環境

$
0
0

研究開発部の染谷 ( @ayemos_y ) です。好きな ImageNetのラベル(Synset)は "Eccentric, eccentric person, flake, oddball, geek"です。

クックパッドの研究開発部は2016年7月に発足し、現在はアルバイトを含め13名の体制となっています。その中で、こちらの記事でも紹介されている料理/非料理判別モデルを開発するなど、機械学習/ディープラーニング分野に特に力を入れて取り組んでいます。

最近は、モデルを開発し改善する手法などについて多くの情報にアクセス出来るようになり、このような技術をプロダクトに応用していきたいという気持ちが高まっている現場が多いかと思います。しかし一方で、分析の為のデータへのアクセスや画像認識モデルの実装や実験を行うためのGPU環境の整備など、現場の都合に対応するコストによって開発の勢いが鈍化してしまうような状況もめずらしくありません。

特にそのようなコストがチームのサイズに従って増えていくような場合は(つまり多くの場合においては)、組織のスケーラビリティを損なう重大な問題です。

クックパッドの研究開発部では、このような研究開発、特に機械学習における開発基盤にまつわる課題の解決にも積極的に取り組んでいます。今回はそのような取り組みの一部を紹介したいと思います。

研究開発専用AWSアカウントの利用

クックパッドには技術部開発基盤グループ/モバイル基盤グループなどの組織があり、開発基盤の整備や改善に取り組んでいますが、最近ではこのような組織が部署横断的かつ中央集権的に請け負っていた仕事を、部署ごとに分散出来るような体制に移行しつつあります。(この辺りの詳しい話はAWS Summit Tokyo 2017でも発表しました。 資料動画 )

その取り組みの一環として、研究開発目的で利用するAWSアカウントを本番アカウントと別に用意し、研究開発部のメンバーの一部(現在は私だけですが)にAdminに近い権限を付与した上で、部署の責任範囲において開発基盤を積極的に改善するという体制が敷かれています。

オンデマンドGPUインスタンス

アカウント分離以前にも研究開発目的でGPUを利用したいという案件がありました。その際にはインフラストラクチャー部への依頼に始まり、必要なコンポーネントなどの要件のすり合わせと実際の作業が行われ、その間コミュニケーションが数往復しました。

結果として、本番アカウントに g2.2xlarge インスタンスが立ち上がり、必要コンポーネントがインストールされて利用可能になるまで4週間程かかりました。このような作業を部内で消化出来るようにするというのがアカウント分離の目的の中心でしたので、アカウントを分離後早速GPU環境の整備に取り組みました。

GPU環境とはつまり、GPUの搭載されたEC2インスタンスで、GPUを利用した開発が出来るものですが、OS環境が(不可逆に)壊れてしまった場合や、2台以上のGPUが必要になった時などを想定すれば、このような環境を低コストで繰り返し用意出来るような状況が望ましいです。手動でセットアップする従来の手法ではこれが実現出来ていないという課題がありました。ドライバーやCUDA Toolkitなど比較的低レイヤなセットアップ作業が多く、手間と時間もかかります。

そこで、最低限のコンポーネントを揃えた状態からAmazon Machine Image(AMI)を作成し、同じ状態のインスタンスを繰り返し立ち上げられるようにしました。

Packer を利用したAMIの構成管理

またさらに、各コンポーネントのバージョンアップや、異なるバージョンでセットアップされた複数のAMIを安定して管理するために、Packerを利用し、機械学習で利用するAMIを作成/構成管理しています。

PackerのChef Solo Provisioner)を利用し、Chefのrecipeと呼ばれるセットアップ手順を定義することで、これを自動で実行し、実行後の状態をAMIとして保存することが出来ます。これにより、手順をコードとして管理しておけるだけでなく、recipeを適切にパラメータ化することで、CUDA8/9をインストールしたAMIをそれぞれ別の名前で作成する等の作業を簡単に行うことが出来ます。(下図)

Slack Chat Botを利用したインスタンスの操作

AMIを用意した事で、必要なコンポーネントがインストールされた状態のGPUインスタンスを繰り返し作成することが出来るようになりました。しかしながらEC2インスタンスをオンデマンドで立ち上げる際には、VPCと特定のサブネット(のうち1つ)、キーペアやセキュリティグループを適切に選んでやる必要があります。またクックパッドではEC2インスタンスへの接続を適切に経由するためにインスタンスへのタグ情報を利用しており、これもまた設定してやる必要があります。

このような一連の操作を繰り返さない為に、Slack上で動作するChat Botから、より抽象度の高い操作によりインスタンスの作成や停止/再開を行えるようにしました。Chat Botの開発にはLitaを利用しました。

蛇足ですが、このような問題を解決するためにコマンドラインで動作するツールを配る事もできますが、Slackをインターフェースにする事で実行環境が統一されるので、開発者ごとの環境の差異を気にしなくて良くなり、また、利用されている様子から使い方を自然に見て覚えられたり、改善のヒントが得られるなどの利点があります。

インスタンスの自動停止

AMIとChat Botを整備したことにより、必要なGPU環境が数分で手に入るようになりました。一方副作用として、立ち上げたインスタンスの落とし忘れにより、アイドルなインスタンスから発生するコストが増えてきました。例えば金曜日の退勤前に実行した実験が深夜に終了し、その後週末の間なにもせずに立ち上がったままである、といったことが頻繁に起こるようになりました。

これ自体はコスト面で致命的な問題になるほどではないのですが、EC2インスタンスの課金も秒単位になったことですし、使用していないインスタンスはなるべく早く停止したいところです。

そんな時は、Amazon CloudWatch EventsとAWS Lambdaを組み合わせることで、「一定時間以上アイドルなインスタンスを停止させる」Pythonスクリプトを「5分毎に実行する」といった構成を非常に簡単に実現することが出来ます。

ちょっとした考察

計算機コストの最適化を考えた時に、例えばコンテナによって仮想化され、場所透明性を持ったワークロードをインスタンスのグループ(あるいはプール)において実行する事で、よりインスタンスのアップタイムを効率的に利用出来るといった手法も考えることが出来ます。

しかしながら複数のワークロードでGPUを共有する場合には、コンテナ化するだけではなく、ワークロードのスケジューリングなどの複雑性を導入する必要が出てきます。またコンテナ仮想化それ自身も、実験環境の可搬性を高めるという点で便利な一方で、ネットワーキング、ロギング、データ永続化など、いくつかの複雑さが導入されるという意味で、実験環境において必要でなければ使わないという姿勢が適切なようにみえます。

そのような理由で現状は、個人に対して個人が専有出来るインスタンスを用意して必要に応じて動かすという構成を取っています。しかしながら今後も使用感やコスト面の課題を探しながら継続的に改善を進めて行くことになります。

まとめ

というわけで、今回はクックパッドの機械学習基盤についてお話しました。 Packerを利用して必要な環境をAMIとして作成/管理し、Chat Botを利用して作成/停止/再開する事で、チームメンバのそれぞれが、長くとも数分で必要なGPU環境を利用出来るという環境を整えました。

研究開発部というと、どうも実際のプロダクトと距離があるという印象ですが、チーム単体で見た時にもそれはやはりユーザーに価値を届け続ける組織である必要があると考えています。言い換えれば、ユーザーとの接点であるプロダクトに繋がるアウトプットをし続ける事が必要であり、その為にすばやい実験や実装が出来る環境があることも重要です。

そのような考えから、機械学習/ディープラーニングを中心とした最新技術へのキャッチアップとその応用はもちろん、チームの成果を最大化するための運用の改善にも積極的に取り組んでいます。

クックパッドで機械学習を利用して新たなサービスを創り出していける方に加え、研究開発部の開発環境を改善していく方も募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。

クックパッド株式会社 研究開発部 採用情報

追記(お知らせ)

11月27日-12月1日にラスベガスで開催される AWS re:Invent 2017にインフラ部のと一緒に登壇します。 "Containerized Machine Learning on AWS"というテーマで料理きろくで利用されている画像判定システムののアーキテクチャを中心に話します。乞うご期待!

Encoder-Decoder でレシピの材料名を正規化する

$
0
0

研究開発部の原島です。部のマネージメントのかたわら、自然言語処理関連の開発に従事しています。本エントリでは、最近社内で開発した自然言語処理システムを紹介します。

■ 「しょうゆ」のバリエーションは 100 種類以上

クックパッドで以前から解決したかった課題の一つに材料の名前(以下、材料名)の正規化があります。

f:id:jharashima:20171030074621j:plain

クックパッドのレシピは複数の材料から構成され、各材料は名前と分量から構成されています。例えば、上のレシピの一つ目の材料は「豚薄切り肉」が名前で、「200g」が分量です。

さて、この材料名はこのレシピでは「豚薄切り肉」という表現でした。しかし、他のレシピでは「豚うす切り肉」という表現かもしれません。「豚うすぎり肉」や「ぶた薄切り肉」、「豚薄切り肉」等の表現もありえますね。

これは異表記同義(いわゆる表記揺れ)の問題ですが、同様の問題は他にも沢山あります。例えば、以下のようなものです。

  • 同義表現系の問題
    • 異表記同義(上述)
    • 異形同義(e.g.「じゃがいも」と「馬鈴薯」)
    • 略記(e.g.「ホットケーキミックス」と「HM」)
    • 綴り間違い(e.g.「アボカド」と「アボガド」)
  • 付属表現系の問題
    • 記号(e.g.「★味噌」)
    • 括弧表現(e.g.「油(炒め用)」)
    • 接頭辞(e.g.「お砂糖」)
    • その他(e.g.「お好みでローズマリー」)

面倒なことに、これらの問題は複合的に発生することも珍しくありません。その結果、例えば、クックパッドにおける「しょうゆ」には、分かっているだけで、100 種類以上のバリエーションが存在しています。

時に、これがサービス開発において問題となります。

例えば、あるレシピのカロリーを計算するとしましょう。そのためには、そのレシピ中の全ての材料名のカロリーが必要です。つまり、材料名ごとにカロリーを登録したデータベースが必要です。

しかし、クックパッドには数百万種類の材料名が存在します。これらの全てに人手でカロリーを登録するのは途方もない作業です。「しょうゆ」は大さじ 1 杯で約 13 kcal ですが、あらゆる「しょうゆ」のバリエーションにこの情報を登録するのは大変です。

こういった時に、「材料名を正規化したい」という考えに至ります。あらゆる「しょうゆ」のバリエーションを全て「しょうゆ」に正規化できれば、「しょうゆ」にだけカロリーを登録すれば良いわけです。大分コストを削減できそうですね。

このように、材料名の正規化は、以前から解決したいと思っていた課題の一つでした。

■ 正規化しよう!とは言え...

どうすれば材料名を正規化できるのでしょうか。

最初に検討すべきは正規表現です。例えば、材料名の冒頭の記号と末尾の括弧表現を除去するというのはどうでしょう。しかし、この方法だと、同義表現系の問題は全く解決できません。

クラウドソーシングはどうでしょう。人手で正規化すれば、全ての問題を解決できそうです。しかし、1 個の材料名を 10 円で正規化できたとしても、数千万円が必要です。仕様変更等でやり直しが発生すれば、さらに必要ですね。

機械学習の出番かもしれません。例えば、何らかの語彙を定義できれば、SVM や Random Forest 等で各材料名を語彙のいずれかに分類できそうです。しかし、そもそも語彙を定義するのが困難です。未知語とかどうしましょう。

材料名の各文字について CRF で「必要」もしくは「不要」というラベルを付与して、必要な文字だけを抽出するというのはどうでしょう。しかし、この方法でも、同義表現系の問題は解決できません。

いずれの手法にも何かしらの問題がありそうです。

■ Encoder-Decoder で正規化しよう!

そこで、クックパッドでは Encoder-Decoder を利用することにしました。

Encoder-Decoder は、おおまかには、「入力の情報を何らかのベクトルに集約し、そのベクトルから出力を生成するモデル」です。前半を処理する部分が Encoder で、後半を処理する部分が Decoder です。

このモデルの利用例としては機械翻訳が代表的です。日英翻訳であれば、日本語文が入力で、その英訳が出力です。入力と出力は一般的には単語列です。正解データ(この場合は日英対訳)さえあれば、翻訳モデルが構築できます。

材料名を正規化する場合、材料名が入力で、その正規化後の表現が出力です。イメージは下図の通りです。上述の通り、Encoder-Decoder の一般的な設定では入力も出力も単語列ですが、我々の設定では文字列になります。図の EOW は End-of-Word の略です。

f:id:jharashima:20171030074722p:plain

Encoder-Decoder は入力から出力を抽出するわけではなく、生成します。そのため、付属表現系の問題だけではなく、同義表現系の問題も解決できます。また、正規化のパターンが学習できれば、未知語にもある程度は対応できます。

なお、このように Encoder-Decoder を正規化に利用するのは我々が最初ではありません(業務に導入したのは我々が最初かもしれません)。例えば、以下の 2 本の論文で同様のアイデアが提案されています。

  • Japanese Text Normalization with Encoder-Decoder Model(Ikeda et al. 2016)
  • 疑似データの事前学習に基づくEncoder-decoder型日本語崩れ表記正規化(斉藤ら 2017)

■ 全体像はどうなってる?

正規化システムの全体像は大きく三つに分割できます。正解データ収集とモデル開発、バッチ実行です。以下でそれぞれについて概説します。

f:id:jharashima:20171030074818p:plain

正解データ収集

Encoder-Decoder を学習(と検証、テスト)するには正解データが必要です。そこで、スタッフが毎日利用する社内ツール(クックパッドと同様、Rails 製)に、正解データを収集するための仕組みを追加しました。普段の業務をこなすと、正解データが自然と MySQL に蓄積されるようにしました。

本エントリの執筆時点で、約 12,000 個の正解データが収集されていました。

モデル開発

開発環境には p2.8xlarge を、フレームワークには Chainer を利用しました。やや詳細な話になりますが、Encoder と Decoder には Stacked Uni-directional LSTM(3 層)を使用しました。なお、我々のタスクでは Bi-directional LSTM や Attention はあまり効果がありませんでした。

PR がマージされると Jenkins が起動して、プログラムの実行環境をまとめた Docker イメージが社内の Docker レポジトリに登録されます。このイメージは後述するバッチで利用されます。

バッチ実行

クックパッドは主に Rails で構築されているため、そのデータの多くは MySQL に蓄積されています。さらに、これらはログデータ等とともに Redshift に集約されています。正規化バッチはこれらのデータをもとに動作しています。

まず、正規化の対象とする材料名を Redshift から取得して、S3 に保存します。次に、Docker イメージをもとに Encoder-Decoder の実行環境をインスタンスに準備した後、材料名を正規化します。最後に、S3 を経由して、結果を MySQL に保存します。

■ 正答率はどれくらい?

最後に、簡単な実験結果を紹介します。実験では正解データの 10% をテストデータに、別の 10% を検証データに、残りの 80% を学習データに使用しました。

手法正答率
Baseline A(変更なし)20.8%
Baseline B(正規表現)28.6%
Encoder-Decoder71.2%

まず、ベースラインの結果を確認しましょう。Baseline A は、入力をそのまま出力した場合の結果です。正答率(正解との完全一致率)は 20.8% でした。これは、テストデータの約 80% に何らかの正規化が必要ということを意味しています。

Baseline B は正規表現です。冒頭の記号と末尾の括弧表現を入力から除去した場合の結果です。正答率は 28.6% でした。「検討すべき手法」の節でも議論したように、正規表現だけで正規化するのはやはり困難でした。

最後に、Encoder-Decoder の正答率は 71.2% でした。同義表現系の問題(e.g. 桜エビ → サクラエビ)や付属表現系の問題(e.g. ※牛乳 → 牛乳)、それらの複合的な問題(e.g. ☆ローリエ → 月桂樹)に幅広く対応できたので、ベースラインより圧倒的に高い正答率となりました。

補足として、この数字はあくまでもテストデータでの値です。我々のテストデータ(と言うより、正解データ)は、テストのため、低頻度の材料名を少なからず含んでいます。現実のデータにおける数字はもう少し高いです。

また、実際に正規化結果を利用する際はスタッフがざっと確認して、明らかな間違いは修正しています。なお、修正されたデータは新たな正解データとして利用できます。

■ Appendix

以下は少し発展的な話題です。Encoder-Decoder でも失敗するのはどういうケースでしょうか。そして、それらのケースはどうすれば改善できるのでしょうか。

探索範囲が狭い

Decoder が N 文字目を出力する際は、その時点での生成確率が一番高い文字を選択します。しかし、確率が二番目に高い文字は無視して良いのでしょうか。N+1 文字目以降を出力する際に後悔しないでしょうか。

そこで、ビームサーチです。N 文字目を出力する際、確率が高い文字を M 個キープしておきます。そして、最終的に生成確率が一番高い文字列を出力としましょう。追加実験では、M = 10 の時、正答率が 72.3% になりました(統計的に優位でした。以降の数字も同様)。

存在しえない文字列を出力

例えば、「こめ油」を「米油」と正規化すべきところ、「コー油」と正規化してしまうケースです。「コー油」という文字列は少なくともクックパッドには存在しません。機械翻訳でも、Encoder-Decoder がおかしな文(単語列)を出力してしまうのはよくある話です。

そこで、このような文字列を出力しないように、Decoder を制御してみました。具体的には、ビームサーチの途中でクックパッドに存在しない文字列が生成された場合、その文字列を正規化候補から除外してみました。トライ木を利用すれば簡単に実装できます。正答率は 72.8% になりました。

入力と無関係の文字列を出力

例えば、「トーモロコシ」を「とうもろこし」と正規化すべきところ、「とろろこんぶ」と正規化してしまうケースです。「とろろこんぶ」は世の中に存在する文字列ですが、もちろんこの正規化は間違いです。

そこで、ビームサーチで取得した正規化候補を入力との類似度(と Encoder-Decoder のスコア)でリランキングしてみました。類似度の計算には word2vec を、その学習データにはクックパッドの全レシピを利用しました。正答率は 73.3% になりました。

その他

さて、まだまだ改善の余地があります。しかし、調査してみたところ、これらの多くは学習データの不足に起因するものでした。ここから先はモデルを改善するよりも、学習データを追加する方が楽かもしれませんね。

■ おわりに

本エントリでは、Encoder-Decoder でレシピの材料名を正規化するシステムについて紹介しました。

このモデルには、レシピの材料名だけでなく、EC サイトの商品名や不動産サイトの物件名等も正規化できる可能性があります。本エントリが、正規化に頭を抱える誰かのお役に立てば幸いです。

最後に、クックパッドの研究開発部では自然言語処理や画像認識の専門家を募集しています。あなたの知識・スキルで世界中の毎日の料理を楽しみにしませんか。ご興味がある方は採用ページを是非ご覧ください。ご連絡をお待ちしております。

クックパッド株式会社 研究開発部 採用情報

料理画像判定のためのバックエンドアーキテクチャ

$
0
0

サービス開発部の外村 (@hokaccha)です。

クックパッドのアプリには「料理きろく」という機能があります。 モバイル端末から料理画像のみを抽出して記録することで食べたものが自動的に記録されていくという機能です。

f:id:hokaccha:20171108125806p:plain

今回はこの料理きろくで画像判定をおこなっているバックエンドのアーキテクチャについて紹介します。なお、実際に判定をおこなう機械学習のモデルのはなしは以下の記事に書かれているのでそちらを参照してください。

料理きろくにおける料理/非料理判別モデルの詳細 - クックパッド開発者ブログ

また、以下のスライドでも料理きろくのバックエンドについて紹介されているのでこちらも参照してみてください。

処理の概要

ざっくりとした画像判定のフローとしては、次のようになります。

  1. クライアントアプリは端末内の画像を判定用に縮小してサーバーにアップロードする
  2. サーバーはアップロードされた画像を機械学習を用いて料理画像かどうかを判定する
  3. クライアントアプリは判定結果を受け取る

クライアントアプリが結果を受け取った後にオリジナルの画像をアップロードしたり、RDBMSにデータを保存するといった処理もありますが、今回は判定処理をクライアントアプリが受け取るまでの流れに絞って解説します。

初期のアーキテクチャ

プロトタイプの段階ではこの処理を単純に料理画像判定用のHTTPのAPIエンドポイントを用意し、そこに画像をアップロードして判定処理を行い、結果をレスポンスで返すという構成で実現していました。

f:id:hokaccha:20171108125340p:plain

Web APIはRailsとUnicornで動いているサーバーで、その後ろに実際に判定をおこなうPythonのサーバーがたっていてRailsのアプリケーションはそこと通信しています。

この処理は画像アップロードに加えて、料理画像判定の処理にディープラーニングを用いているのでそれなりに重たい処理になります。APIサーバーで利用しているUnicornは仕組み上プロセスがリクエストを処理している間他のリクエストをブロックするため、大量のアクセスがきた場合にI/O待ちによりworkerを使い切ってしまいます*1

機能の性質上、端末にある画像をすべてアップロードする*2ので利用者が増えると判定の処理が大量にくる可能性は高かったため、アーキテクチャを変更する必要がありました。

現状のアーキテクチャ

そこでシステムの安定性を高めるため、以下のような方法を取ることにしました。

  • 画像はAPIサーバーにアップロードせず、S3に直接アップロードする
  • サーバー側は料理画像の判定処理をバックグラウンドワーカーで非同期に行う

APIサーバーで重い処理を受けないようにすることでアクセスが集中してもAPIサーバーが詰まらないようにし、バックグラウンドワーカーをスケールアウトさせればサービスがスケール可能になるアーキテクチャを目指しました。

他にもI/O多重化ができるサーバーを導入するであったり、コストを覚悟でUnicornのサーバーをスケールアウトするという方法も考えられますが、今回は社内のノウハウなども加味して一番安定して稼働できそうという判断のもと、このアーキテクチャを採用しました。

最終的な構成としては次のようになります。

f:id:hokaccha:20171108125343p:plain

順に説明していきます。

S3に直接画像をアップロードする

まず、画像アップロードの負荷を軽減するため、S3に直接アップロードすることにしました。そのためにS3のPre-Signed URLを利用しています。

Uploading Objects Using Pre-Signed URLs - Amazon Simple Storage Service

クライアントがアップロード処理を開始するときにAPIサーバーにリクエストし(1)、アップロード可能なS3のURLを取得します。このときAPIサーバーはRDBMSにレコードを作成し、アップロードの状態管理を開始します。クライアントアプリは返ってきたURLに対して画像をアップロードすることでS3に画像がアップロードされます(2)

S3のイベント通知でSQSにエンキューする

S3にはEvent Notificationsという機能があります。

Configuring Amazon S3 Event Notifications - Amazon Simple Storage Service

この機能を使うと、S3にファイルアップロードされたり、変更されるといったイベントが発生されときに、特定のサービスに通知を送ることができます。これを利用し、S3にファイルがアップロードされたときにSQSにエンキューが行われるようにします(3)

バックグラウンドワーカーの処理

バックグラウンドワーカーはSQSのキューをポーリングしていて、エンキューされたメッセージはバックグラウンドワーカーがデキューすることで処理を行います(4)

メッセージのペイロードにはS3にアップロードされたオブジェクトのキーなどの情報が入っているので、その情報を元にバックグラウンドワーカーはS3からアップロードされた画像を取得し(5)、機械学習による判定を行い(6)、結果をAPIサーバーにHTTPで送信し(7)、メッセージの処理を完了させます。

このワーカーはPythonで書かれていて、hakoを利用しECS上で動いており、オートスケールの設定もhakoでおこなっています。

結果の取得

最後にクライアントアプリは結果をAPIサーバーから取得(8)してフローは終了です。

ただ、処理を非同期にした場合に難しいのがこの結果の取得です。同期的に判定ができるのであれば、クライアントアプリは判定結果が返ってくるのを待ってから次の処理に進めばいいのですが、非同期の場合はいつ処理が終わるかクライアントアプリにはわかりません。

今回はクライアントアプリが定期的に結果取得のAPIエンドポイントにリクエストすることで結果を取得するようにしています。料理きろくという機能はユーザーが写真を撮って、後で見た時に自動で写真が記録されている、という機能なのでそのケースではリアルタイム性が求められないので数十分に一回程度のリクエストで後から結果が取得できれば問題ありません*3

ただ、クライアントアプリでそういった処理を定期実行するのは簡単ではなく、様々な苦労がありました。詳しくは料理きろくのAndroidの実装を行った吉田の以下の資料にまとまっています。

まとめ

料理きろくの料理画像判定部分のアーキテクチャについて解説しました。現時点で一日あたり約50万枚以上の画像を判定していますが、大きな問題はなく安定して稼働しています。基本的にはよくあるジョブキューを利用した非同期処理のアーキテクチャですが、S3やSQSはこういったシステムを組むのにとても便利です。

また、最近ではモバイル端末でも機械学習のモデルが動くようになってきています。まだ実用段階まではいっていませんが、それが実現すればこのようなシステムは一切いらなくなり、クライアントの実装も非常にシンプルになるので、できるだけ早く実現してこのシステムがいらなくなる日がくればよいと思っています。

*1:Railsを噛ませずにPythonのサーバーで直接受けてもHTTPサーバーの仕組みがUnicornと同じであれば大きな違いはありません

*2:料理画像以外も判定のために一旦サーバーに保存しているため、ユーザーのプライバシーについては十分に配慮し、判定後には画像は完全に削除し、開発者であってもどのような画像が判定に使われたかを見ることがはできないようになっています

*3:ユーザーが最初に料理きろくを利用する場合に数十枚の画像の判定を進捗を見せながら表示するという要件もあり、この場合は間隔の短いポーリングを行っています

【出張開催レポ】Cookpad tech kitchen #11, #12 in 京都・福岡

$
0
0

こんにちは!人事部の冨永です。

2017/09/28~29の二日間に渡って、技術系イベント「Cookpad Tech Kitchen」を開催しました。クックパッドの技術的な知見を定期的にアウトプットすることを目的とする本イベント。#11, 12の今回は株式会社はてなさん(京都)とGMOペパボ株式会社さん(福岡)のお力をお借りして、京都・福岡での出張開催を実現しました!

テーマは一夜限りのPremium Talkと題して「各社開発の裏側」を発表。本イベントで初めて公開する情報や、ここだけでしか聞けない裏話など、興味深い内容が盛り沢山なイベントとなりました。

発表資料を交えてイベントのレポートをお届けします。

f:id:mamiracle:20171115180848j:plain

9月28日【京都開催 feat.はてな】Cookpad Tech Kitchen #11

f:id:mamiracle:20171115174411p:plain(Premium Talk in Kyoto)

京都のはてなオフィスにお邪魔しての開催。実は登壇者同士が学生時代のインターンシップ同期で良き仲間でありライバル(!)だったこともあり、和気あいあいとしながらも白熱した登壇となりました。

f:id:mamiracle:20171115174335j:plain(大盛り上がりだったQAディスカッションの様子)

それでは登壇内容をご紹介します。

「ScalaとPerlでMicroservices in production」中澤 亮太/株式会社はてな

1人目の登壇者である中澤 亮太さん(@aereal)は、アプリケーションエンジニアとしてご活躍されています。静的解析や静的型付けがお好きで、はてなブログチームのテックリードをお務めになられています。この日は、ScalaとPerlを使ったマイクロサービス化について登壇をしてくださいました。

speakerdeck.com

「イカリング2におけるシングルページアプリケーション」加藤 尋樹/株式会社はてな

2人目の登壇者である加藤 尋樹さん(@cockscomb)は、アプリケーションエンジニアとしてご活躍されています。モバイルアプリからWebサービスの開発まで積極的に取り組まれており、あの「イカリング2」の開発も担当されています。この日は、「イカリング2」の開発の裏側を教えて下さいました。

speakerdeck.com

f:id:mamiracle:20171115174355j:plain(美味しくておしゃれなケータリングに大喜び!)

はてなさんがいつもお世話になっているという京都のケータリングご飯を用意していただきました!発表を聞きながらでも楽しめるピンチョススタイルです。 イベントの後は、仕事終わりのはてなメンバーの方々と、行きつけだという居酒屋で打ち上げもしましたよ!はてなさん、ありがとうございました。

9月29日【福岡開催 feat.ペパボ】Cookpad Tech Kitchen #12

f:id:mamiracle:20171115174432p:plain(Premium Talk in Fukuoka)

翌日は、福岡のGMOペパボオフィスにお邪魔して開催!弊社社員は福岡初上陸のメンバーが多く、みんなのテンションが高かったことをよく覚えています。笑

f:id:mamiracle:20171115174808j:plain(こちらでもQAセッションが大盛り上がりでした)

それでは登壇内容をご紹介します。

「コンテナたちを計測すること - マネージドクラウドの今まさに開発中の裏側」近藤 うちお/GMOペパボ株式会社

3人目の登壇者である近藤 うちおさん(@udzura)は技術基盤チームに所属されています。mruby製のLinuxコンテナエンジン「Haconiwa」をリリースされたり、『パーフェクトRuby』『パーフェクトRuby on Rails』などを共著されたり広くご活躍されています。この日は、コンテナ計測について登壇してくださいました。

speakerdeck.com

「ムームードメイン ショッピングカート機能を支える技術」中村 光佑/GMOペパボ株式会社

4人目の登壇者である中村 光佑さん(@litencatt)ホスティング事業部ムームードメイングループに配属。Webサービス開発においてPHPやRuby、サービス知識など福岡支社の凄腕エンジニアたちに囲まれて日々多くのことを学ばれながら、毎日を全力で楽しんでいるそうです。この日は、ムームードメインに新しく追加されたショッピングカート機能の裏側についてお話してくださいました。

speakerdeck.com

f:id:mamiracle:20171115174515j:plain(ペパボさんのケータリングご飯もボリュームたっぷりで大満足!)

この日のご飯には、「minne」に出品されていたクラフトチーズも登場。お洒落で美味しくて感動の一言でした…。ペパボさん、ありがとうございました!

また今回、クックパッドからは2名が登壇しました。

「機械学習でサービスの常識を破壊する」杉本 風斗/クックパッド株式会社

サービス開発部エンジニアの杉本 風斗(@uiureo)。機械学習を使ったプロダクト開発に携わっています。入社当初はAndroid開発を担当するはずが、気づいたらPythonやRedshiftを使ったバックエンド開発が中心になっていたそうです。この日は機械学習を活用したクックパッドの新機能「料理きろく」について登壇しました。

speakerdeck.com

「巨大アプリにおける新規開発とチームビルディング」勝間 亮/クックパッド株式会社

現在サービス開発部長を務める勝間 亮(@ryo_katsuma)は、これまで新規事業、検索、投稿、会員事業などの部門での開発およびエンジニアリーダーを担当してきました。著書に「Webサービス開発徹底攻略」「すべての人に知っておいてほしいJavaScriptの基本原則」などがあり、この日は大規模なチーム開発におけるチームビルディングの方法について登壇をしました。

speakerdeck.com

f:id:mamiracle:20171115174454j:plain(楽しかった2日間の様子です♪)

まとめ

いかがでしたか?とても実りが大きい出張イベントだったため、また開催したいと思っています。次はどこの会社にお邪魔させていただこうかと考え中です…!

クックパッドでは毎月テーマを変えて技術イベントを開催しておりますので、ご興味のある方はイベント Connpassページをご覧くださいね。

▼イベントページ

cookpad.connpass.com

新しい仲間を募集中

クックパッドではレシピサービスの更なるパワーアップと、新規事業の開発に注力をしています!少しでも興味のある方は、まずお気軽にご連絡をお待ちしております!

■ Android アプリエンジニアの募集はこちらhttps://cookpad.wd3.myworkdayjobs.com/ja-JP/jobs/job/-/Android---_R-000145

■ iOS アプリエンジニアの募集はこちらhttps://cookpad.wd3.myworkdayjobs.com/ja-JP/jobs/job/-/iOS---_R-000162

■ Web アプリエンジニアの募集はこちらhttps://cookpad.wd3.myworkdayjobs.com/ja-JP/jobs/job/-/Web--_R-000095

クックパッドのAmazon Echo向けサービスをリリースしました 〜開発で得られた音声操作の知見〜

$
0
0

こんにちは。研究開発部エンジニアの山田(@y_am_a_da)です。ついに日本でも Amazon Echo の発売が始まりましたね。

今回は Amazon Echo 向けにリリースをしたクックパッドのスキル( Amazon Echoではアプリと呼ばずにスキルと呼びます)についての紹介と、開発をしてみてわかった音声操作の強みと弱みについて紹介をしていきたいと思います。

Amazon Echoとは

Amazon Echo は、Amazon 社の販売するスマートスピーカーです。声で命令をすることで、端末に内蔵されている Alexa と呼ばれる音声アシスタントが処理を行ってくれます。 特徴として、基本的に操作は全て音声で行い、レスポンスも音声で返ってくる点が挙げられます(海外では液晶が搭載されており、そこへレスポンスを返すモデルも存在します)。

すなわち、スキルの開発者は、 PC やスマートフォン上と違い、基本的には全てを音声のみで完結させる必要があるという前提で開発を進めていく必要があります。 この制約のもと開発を進めて得られた音声でのユーザーインターフェース、いわゆる Voice User Interface についての強みと弱みについて紹介をします。

Voice User Interface の強み

  • 情報の入出力が早い

端末を手に届く範囲に持ってくる必要がないため、使いたいと思った瞬間に命令を行い、その結果を受け取ることが出来ます。

  • 操作が直感的

音声アシスタントのインタラクションは基本的に人間を模倣しているため、人間とのコミュニケーションを取るように操作が出来ます。

  • ハンズフリー

入出力は音声なので、キッチンや車内、フィットネス中には嬉しい利点です。

Voice User Interface の弱み

  • 一覧性に乏しい

視覚での情報と比較して処理できる情報量は少ないため、一度に多くの情報を返す用途には向いていません。何かの検索結果を30件読み上げられる状況を想像してみるとその難しさがわかると思います。

  • 情報のフィルタリングが難しい

視覚的な情報であれば、慣れてくると流し読みのように必要な情報だけを受け取ることができるのですが、音声では読み上げ側に工夫をしないとこれが出来ません。

  • 全体のインタラクションはそこまで早くない

入出力こそ早いものの、出力された情報をフィルタリングすることが難しいことから、工夫をしないと全体のインタラクションは早くなりません。

また、強みか弱みかはケースバイケースなのですが

  • インタラクションは基本的に全てオープンである

という点も大きな違いです。

クックパッド 〜使いたい材料だけで、すぐに作れる人気の料理レシピ提案〜

上記の点を踏まえた上で、さらに

  • Amazon Echoは基本的に屋内、特に個人での購入であれば家に置かれることが多いだろう
  • 聞かれるタイミングは料理をする直前、もしくは最中である

という仮説を立てて、わざわざ買い物に行かなくてもすぐに料理を始められるよう、冷蔵庫にある食材1つだけで美味しい料理のインスピレーションを与えるスキルを開発しました。

スキルの具体例は、明日弊社デザイナーの倉光が投稿する記事にございますのでこちらでは省略致します。

現在プレミアムサービスユーザーでない方は、スキルの起動時にプレミアムサービスが最大2ヶ月無料になるクーポンをプレゼントしておりますのでこの機会にぜひお試しください。

工夫をした点

スキルを開発する上で意識をしたことを紹介します。

モバイルアプリの代替は目指さない

Voice User Interface の特性上、一貫した情報が取得しにくく、フィルタリングも難しいため一度に提供できる情報はかなり少ないです。また、スマートスピーカーはスマートフォンとハードウェアの特性が異なり、おそらく購入の用途も異なることが多いと思います。

そのため、スマートフォン用に提供されているモバイルアプリをそのまま移植しようとせず、音声操作の利便性をできる限り活かせるよう意識して開発を進めました。

インタラクションをできる限り減らす

せっかく情報の入出力が早いという利点を持つ Voice User Interface を使っても、インタラクションを増やしてしまうと全体としてはスマートフォンを使ったほうが早いし便利ということになりかねません。

そうならないように、クックパッドのスキルでは無駄なインタラクションを省きシンプルにすることを目指しました。これは、例えば読み上げる文章を短くするというだけのことではなく、そもそもスマートスピーカーには向いていないような機能は実装せず、できることを思い切って減らすということもしています。

例えば、何でも検索できるフリー検索機能でユーザーの望む検索結果を提供するためには、スキルは提案の量を増やすか聞かれた内容の意図を絞り込むためにユーザーに質問をする必要があります。これではユーザーに長い文章を我慢して聞いてもらうか、多くの質問に答えてもらうことが必要となってしまい離脱の原因となるおそれがあります。

聞かれた内容によって提案のフローを変える

無駄なインタラクションを減らしつつもできる限り多くのニーズに応えられるよう、聞かれた内容によってレシピを提案するロジック、インタラクションを変更しています。大まかには

  • 食材1つの場合 もともとのコンセプトである「使いたい材料だけで、すぐに作れる人気の料理レシピ提案」に準じて聞かれた材料だけで簡単に作ることのできるレシピを提案します。
  • 食材2 or 3つの場合 インスピレーションを得ることを目的としていると想定し、食材が1つの場合よりも幅広くレシピを提案します。
  • 料理名を聞かれた場合 その料理の作り方を思い出したい(もしくは知りたい)と想定し、その料理で一番人気のレシピを提案します。

これら3パターンにおいて、それぞれユーザーが聞く状況の仮説を立て、適切な提案ができるようロジックを組み立てています。 あくまでも仮説をベースとしているので、利用のされ方を見ながら検証と改善を進めていく必要があると思います。

このような工夫をすることで、モバイルアプリのような万能さはなくても、特定のシーンではより役に立てるよう意識しています。 PCとスマートフォンの関係のように、シーンごとに使い分けられる存在になることを目指しています。

日常的に使えるものを目指す

Amazon Echo には液晶が存在しないため、ユーザーはスキルのインストール時以外にそのアイコンを目にする機会がありません。

すなわち、一度ユーザーにスキルの存在を忘れられてしまうと再び見つけてもらうことが困難であるため、リテンション率が低くなってしまいます。 そのためにも、日常的に使ってもらうことでその存在を覚えていてもらえることを目指しました。

まとめ

いかがでしたでしょうか。スマートスピーカー向けに提供するサービスは、その制約の厳しさにより、ユーザーやその周辺情報へのより深い理解が必要となります。

今回は仮説と検証にもとづき開発を進めていきましたが、機械学習やIoTを活用することでよりユーザーフレンドリーなサービスを開発できる可能性があると考えています。弊社ではこのような課題を解決できる機械学習、IoTの知識を持つエンジニアを募集しています。 クックパッド株式会社 研究開発部 採用情報

明日は弊社デザイナーの倉光よりスキルの開発にあたって実際に行ったプロトタイピングなどについて紹介致します。

対話のデザインプロセス〜Amazon Echoのスキル開発〜

$
0
0

f:id:transit_kix:20171121225937p:plain

デザイナー倉光です。先日ついに日本語に対応した Amazon Echo!エンジニア山田の記事に引き続き、今日は音声操作のデザインプロセスについて紹介します。

💬 Amazon Echoとは

f:id:transit_kix:20171121225949p:plain

Amazon Echoは、音声だけでリモート操作できるスマートスピーカーです。「アレクサ」と話しかけることで、様々なスキルを実行することができます。音声操作の特徴は「命令はことばで実行し、結果は音声で受け取る」こと。そのため、視覚や触覚の情報なしでもコンピューターと対話可能なデザインが求められます。

🍳 クックパッドのスキルについて

「使いたい材料だけで、すぐに作れる人気の料理レシピ提案」

このスキルでは、Amazon Echoを使って使いたい材料だけですぐに作れる人気の料理レシピを教えてくれます。

f:id:transit_kix:20171121225953p:plain

「晩ごはん用意したけどなんかちょっと物足りない...」 「冷蔵庫になすが余っているけど、スマホでレシピさがすのは面倒…」

そんな時は使いたい材料をクックパッドに呼びかけてください。クックパッドが材料にあった食べ方やレシピを一緒に考えて、今日作る料理を考えているあなたに提案します。レシピが決まったらiOS/Androidアプリへ自動でそのレシピを送信します。*1

✍️ 初期コンセプト

日々の料理に役立つスキルとしては、レシピ読み上げ/調理補助/ニュースなどいくつかの構想がありましたが、

  • 日常で繰り返し使える
  • インスピレーションを与えることで、「今日は料理するぞ!」というモチベーションを与える
  • クックパッド上にある278万品のレシピを活用

といった点を念頭に置き、検証活動を通し現在のスキルができあがりました。

[図]初期に構想していたユーザーストーリー図

f:id:transit_kix:20171121230007p:plain

🏃 Let's プロトタイピング

音声操作の可能性を探るために、さまざまな検証活動を行いましたが、その中で特徴的だったものを紹介します。

Case1. Alexaになりきる

登場人物は、作り手役とAlexa役の2名。作り手役は、Alexa役に話しかけてレシピを読み上げてもらいながら料理してみるといったもの。人間が擬似的にAlexa役をする*2ので、一行もコードを書かずとも検証可能です。(音声アシスタントの検証方法としては、相性が良いですね)

結果は…あまり芳しくはありませんでした。音声入力は便利でも、音声出力はやはり認知の面でハードルがあります。とはいえ実際に体験してみると、「食材を切って…と言われてもどう切るの?写真がないとわからない」「想像していた見た目と全然違った」など、何がどう不便なのかが可視化された点では収穫がありました。

Case2.音声対応レシピを自分で書いてみる

つぎに「音声で聴いた時に理解しやすい料理の表現とは何か?」を知るために、自分で音声対応レシピを書いてみました。以下は「ツナと枝豆のマヨネーズ和え」というレシピの手順です。音声で聞き取りやすいようリライトしてみると、いくつかの表現のコツが見えてきます。

f:id:transit_kix:20171121230000p:plain

クックパッドに存在するレシピは基本的に目で読み取りやすいフォーマットで書かれたレシピが大半です。先ほどのCase1の検証結果も合わせて、既存レシピをそのまま音声で読み上げるだけでは実際に料理をする行動まで導くことは現段階では難しいことがわかりました。

そのため、今回のスキルではAmazon Alexaは料理するきっかけを生み出す存在として捉え、全ての行動を音声で完結させるのではなく、スマートフォンのクックパッドアプリと連携することでユーザーの目的を達成する設計に変更しました。

Case3.いざ食材を目の前にすると、人々はどう行動するのか?

スマートフォンのクックパッドアプリと連携をするという方針も決まり、理想的な発話フローは設計できました。つぎに確かめたのは「スキルを使ってみようかなと思う場面に遭遇した時、人は何を話しかけるのか?」についてです。

開発中のスキルのユーザーテストを実施するにあたって、Amazon Echo/スキルページ…そしてちょっとした小道具を用意しました。

f:id:transit_kix:20171121230013p:plain

段ボールを横置きして食材を入れただけの簡易冷蔵庫です(紙で書いた冷蔵庫をつかうことをもありました)。こんな粗末な小道具でも、テストユーザーは真剣に食材と向き合ってくれて自由にAlexaに話しかけ始めます。

  • 「(スキル紹介ページを見ながら恐る恐る)アレクサ、クックパッドでアボカドでできる料理教えて…?」
  • 「アレクサ、高野豆腐で作れる料理を教えて…あ!クックパッドって言ってない!」
  • 「アレクサ、たまご わかめ スープ 作りたい」

数人のユーザーテストの結果、1つの問題が発覚しました。全員が複数食材からレシピを探す発話を試みたのです。当初このスキルはレシピ提案の精度の問題から食材1つだけからレシピを探す仕様だったのですが、複数食材対応の作業優先をあげて集中的に改良を加えることにしました。

なおリリース後の現在、ユーザーからの全発話のうち44%が複数食材からレシピを探すリクエストだったため、これは事前に発見できてよかった気づきでした。

💡 音声デザインのポイント

音声に関するデザインプロセスについては、amazonが提供している音声デザインガイドを一読していただくのが良いと思いますが、今回の開発を通して特にポイントと感じたのは以下です。

音声認識結果はユーザーにフィードバックしよう

音声操作UIはまだまだ発展途上であり、ありとあらゆる問いかけに万能に対応できるわけではありません。また、環境音やユーザーのイントネーションなど様々な要因で誤認識が発生してしまうこともあります。

ユーザーテストの中でも、ユーザーとアレクサが違う食材を想起したまま気づかず会話が進んでしまい、最終的にレシピを見たときに「間違ってた!」と気づく例もありました。

音声の認識結果は、「…ナスですね。それでは次の中から気になる食べ方を教えてください。」など、つぎの応答で伝えるなどの工夫をしましょう。

シングルタスク、シンプルセンテンス

音声操作UIは、人とコンピューターの対になる言葉のキャッチボールで成立します。そのためスキルの全体像を俯瞰して見るのは難しく、ユーザーは今この瞬間の応答に神経を集中しています。モバイルスクリーン上で動作するUIと違い、前の画面に戻る/中断/スキップといった行為が容易にはできません。3つ以上の長い文章が連なると、何をしていたのかわからなくなります。

極力シンプルなフィードバックを心がけましょう。前述のガイドラインでは「普通に会話する速度で一息にそのセリフを読み上げることができたら、適切なセリフの長さと考えてよいでしょう。」とされています。

人間味のあるセリフはほどほどに

実は開発当初は「人っぽい対話感」を重視するあまり、返答のたびに「それではこちらはいかがでしょうか?」といったコンシェルジュのような丁寧な問いかけを使っていました。

しかしスキルの利用回数を重ねると「もうわかってて、早く次の命令を出したいのに、話しかけるのを待たなければならない…」とWebページのローディング待ちのような苛立ちを感じるようになったため、あまり意味を持たないフィードバックテキストは極力カットしました。

…以上、是非Amazon Echoをお持ちの方はクックパッドを使ってみて、何か気になる点ございましたら是非私たちにフィードバックをいただければ幸いです。 それでは、皆さん楽しいスマートスピーカーライフを。

様々な領域でデザインしたい仲間を募集中です

クックパッドでは、新しい領域におけるインターフェイスデザインも手掛けたいデザイナーを絶賛募集中です。 こちらから是非コンタクトお待ちしています。

*1:こちらのスキルはクックパッドプレミアムサービスご利用の方に提供されております。プレミアムサービス会員ではない方も、クックパッドのスキルを使うとプレミアムサービスがご利用いただけるクーポンが発行されますので、ぜひこの機会にお試しください。

*2:サービスデザインの分野では、オズの魔法使いやアクティングアウトと呼ばれることもあります

2017 Lifestyle Product Award by Cookpad のお知らせ

$
0
0

いつもお世話になっております。エンジニア統括マネージャーの高井です。

クックパッドは 2017 Lifestyle Product Award by Cookpadを開催いたします。このアワードは「生活をより良くする」をテーマに、個人と社会と地球がかかえるさまざまな課題を見つけ、考え、解決するインターネットサービス、もしくはモバイル・アプリケーションを応援し、表彰するアワードです。

世の中には多くのサービスやアプリケーションがあります。そのようなサービスやアプリケーションの中でも、生活に根差したアプリケーションというのは、必ずしも多いというわけではありません。クックパッドは、そういったサービスやアプリケーションが少しでも増えればという気持ちから、このアワードを開催することを決定いたしました。

アワードの対象は、2017年に初めてリリースされたインターネットサービス、モバイルアプリケーションです。応募いただいた作品の中から優秀賞を3作品選出いたします。さらにその中から最優秀作品には Life Style Product Award として、賞金100万円を贈呈いたします。また、応募は個人、もしくは3名程度までのグループに限らせていただいています。

ぜひ、みなさまからのご応募をお待ちしております。アワードの詳細は下記のサイトをご覧ください。


【開催レポ】Cookpad Tech Kitchen #13 〜クックパッドにおける研究開発のサービス活用事例〜

$
0
0

こんにちは!人事部の冨永です。

2017年11月15日に「Cookpad Tech Kitchen #13 〜クックパッドにおける研究開発のサービス活用事例〜」を開催しました。クックパッドではこのイベントを通して、技術的やサービス開発に関する知見を定期的に発信しています。

第13回のテーマはずばり「研究開発」です。クックパッド 研究開発部では、既存技術ではなかなか難しかった事を新しい研究などをもとに解決して、ユーザにより高水準な価値を届けることを目標としています。研究成果としても、既にいくつかの技術がプロダクトに組み込まれているものがあります。そこで今回は発足から1年*1が経った研究開発部の、研究成果のサービス活用事例に焦点をあてました。

それでは各登壇についてご紹介します。

f:id:cookpadtech:20171126172143p:plain

Encoder-Decoder にもとづく材料名の正規化(原島)

最初は研究開発部長 原島(jun-harashima.net)がクックパッドにおける自然言語処理について発表しました。

クックパッドのレシピはユーザの自由記述によって作成されています。そのため、材料名に種々のバリエーションがあります。例えば、「しょうゆ」には 100 種類以上のバリエーションがあります。

これらのバリエーションを吸収するため、クックパッドでは Encoder-Decoder を利用しています。レシピ中の材料名(e.g. ★醤油)を入力に、その正規化された表現(e.g. しょうゆ)を出力にして、Encoder-Decoder を学習しています。学習されたモデルを使用すれば、様々な材料名を正規化できます。

この発表については以下のエントリでも解説しているので、よろしければご覧ください。

画像分析によるレシピのカテゴリ分類(菊田)

次は画像分析の発表で、研究開発部 菊田(@yohei_kikuta)が画像分析を用いたレシピのカテゴリ分類に関して話しました。 ここで言うレシピのカテゴリとは {カレー, から揚げ, ...} などの料理の種類に対応しています。

クックパッドのアプリでは「料理きろく」というユーザの携帯端末から料理の画像のみを抽出してカレンダー形式で表示するサービスを提供しており、2017年9月28日時点で料理と判定された画像が1000万枚 *2に達しています。 これらの画像を活用してユーザにとってより良いサービスを創っていきたいわけですが、そのためには色々な情報(レシピのカテゴリ、朝昼晩、カロリー、...)を付与することが助けとなります。

ユーザの画像はプライバシーの観点から我々が目でチェックすることはできないため、機械学習を用いてレシピのカテゴリ分類をしよう、そしてそのためにはどんな工夫をしているか、ということが発表の主旨となります。

資料内では実サービスにおける画像分析の難しさや、サービスとして成立する precision をどう実現するか、などを紹介しています。
難しい問題でまだまだ改善すべき点も多いですが、面白い内容ですので、どこかのタイミングでまた詳しく解説をしたいと考えています。

また今回は、料理を提供してくれたシェフにお願いをして、料理の中にレシピのカテゴリ分類の対象となるから揚げを入れていただきました。 菊田の携帯で写真を撮ったところ、適切にから揚げと判別され結果が表示されました。

f:id:cookpadtech:20171126141646j:plainつくっていただいたから揚げの写真がこちらです。

f:id:cookpadtech:20171126154923p:plainこの時の写真がから揚げと認識されて、サムネイル画像として選出されたときのスクリーンショットがこちらです。

また、レシピのカテゴリ分類の前段となる「料理きろく」に関して興味のある方は、以下の過去エントリをご覧下さい。

Alexa Skill 開発のあれこれ(山田)

最後は最近日本に上陸したばかりの Amazon Echo 向けにリリースしたクックパッドのスキル( Amazon Echo ではアプリと呼ばずにスキルと呼びます)について研究開発部 山田(@y_am_a_da)が発表をしました。内容としては開発の際に意識をしたこと、またAmazon Echo の特徴である音声インターフェースの強みと弱みについて話をしました。

Amazon Echo はAmazon社から発売された音声アシスタント搭載デバイスで、基本的に入出力は音声のみです。サードパーティにより提供されているスキルを有効化することでさまざまなことができるようになりますが、スキルのアイコンを目にするタイミングが有効化の時のみなので一度存在を忘れられてしまうと再び利用してもらうことが困難です。そのため、ある統計の結果でスキルのリテンション率は3%とも言われています。

また、視覚による情報の取得と比較して、聴覚による情報の取得は一覧性の乏しさやフィルタリングの難しさなどからいわゆるモバイルアプリケーションとして提供しているサービスをそのまま移植しても上手くいきません。

そのため、プロトタイピングを重ねつつ音声インターフェースに適したサービスを提供できるよう開発を進めました。

スキルの開発について、以下に詳しくまとめられているので興味のある方はぜひご覧ください。

イベントの様子をご紹介

f:id:cookpadtech:20171126170325j:plain Cookpad Tech Kitchenでは、よりリラックスした状態で発表を聞いていただくために、イベントの開始とともに乾杯をします!この日は中華料理をメインとした絶品ご飯が登場しました♪

f:id:cookpadtech:20171126165844j:plain中でも目玉はクックパッドのロゴをあしらったライスケーキ。ご飯が2層になっていて間に具材が挟まれているんです。見た目がきれいなことはもちろん、美味しくてお腹いっぱいになるので大人気なメニューとなりました。

f:id:cookpadtech:20171126170449j:plainご飯を食べながら発表を聞いた後には、付箋方式でのQAディスカッションを行います。発表を聞いて気になったことをその場でメモをしていただくことで、より新鮮な質問を気兼ねなくできるように工夫しています。

まとめ

いかがでしたか?クックパッドではテーマを変えて定期的に技術イベントを開催しています。みなさんも是非遊びに来てくださいね。

👇イベントページはこちらクックパッド株式会社 - connpass

また今回登壇したメンバーが所属する研究開発部では新しい仲間を募集しています!ご興味がある方は是非ページをご覧になってください。👇研究開発部では仲間を募集しています🔍・リサーチエンジニア・研究員

*1:クックパッドの研究開発部は2016年7月に発足しました

*2:https://info.cookpad.com/pr/news/press_2017_0928

サービスイメージをより魅力的に見せる写真撮影

$
0
0

国内事業開発部のデザイナー、木村です。私が現在携わっている「おうちレッスン」*というサービス上で利用する写真を撮影するために、久しぶりにプロのカメラマンさんとお仕事をする機会がありました。

今回は、外部のカメラマンさんとサービスのイメージ写真を撮影する際に参考になりそうなことをブログに残そうと思います。

ヒアリング

まず写真撮影の目的を定めます。どういった目的で、ユーザーにどんな印象を与えたいか、ゴールはなにか、といった項目を洗い出し、オーナーと話し合いました。

今回は「ランディングページやサービス上で、サービスの魅力をユーザーに伝えたい」「楽しさや親しみやすさが増幅されるようなイメージ」「気取らない、日常の延長線上で」といった要望が上がりました。

イメージを固める

ヒアリングが終わったら、次はサービスのイメージを固めます。

以下は私がチームに参加した際に、競合・類似サービスなどをまとめ、ポジショニングしたデザインのマトリクス図です。

f:id:mura24:20171201152034p:plain

また、撮影する写真のイメージをキーワードとして書き出し、イメージに齟齬はないかも合わせて確認しました。

f:id:mura24:20171201152041p:plain

これらのすり合わせの作業をどこまで深掘りするかは、プロジェクトへの携わり方で調整するとよいでしょう(今回はサービス全体設計からの参加だったので結構がっちりやっています)。

トンマナのすり合わせ

並行して、写真のトンマナのサンプルをPinterestのシークレットボードを利用して収集しました。方向性をオーナーに確認してもらい、問題なさそうであれば、具体的なカットのラフ作業へ進みます。

f:id:mura24:20171201152044p:plain

絵コンテの作成

サービスのイメージを踏まえながら、ユーザーストーリーに沿って、デザイン上必要なカットを割り出し、絵コンテに起こしていきます。

f:id:mura24:20171201174301p:plain

テキストを交えながら、オーナーやカメラマンに、おうちレッスンのリラックスした和やかな雰囲気が伝わるよう気を配りました。

なお、イラストが不得手、イメージ通りのサンプル写真が用意できない場合などは、既存のストックフォトをコラージュしたり、自分やメンバーを素材としてスマホで撮影してコンテを用意するのもよいでしょう。

方法はなんでもよいので、なるべくコストをかけず、頭の中のイメージをスピーディーにメンバーに共有できる方法を選ぶことが重要です。

実際に写真をデザインに当て込んだデザインカンプなどがあれば、より完成形をイメージしやすくなるので、準備しておくとよいでしょう。

カメラマンとの打ち合わせ

写真のイメージ・必要カットが確定したらカメラマンと打ち合わせをします。

サービスの概要説明、写真のイメージ、絵コンテなどを元に、スケジュール、撮影場所や衣装、必要な機材などの相談と確認を行いました。

画角について

今回は、一部、9:16(スマートフォン縦サイズ)での利用を検討しておりましたが、コンテでその場合の指定が不十分であったことが判明しました。

複数の画角で撮影を予定している場合は、予めリストアップし、そのフレームに応じてコンテを切るようにしましょう。

撮影場所・衣装・小物について

今回は実際のユーザーさんのお宅にお邪魔して撮影することになっていたので、当日の間取りの確認、ユーザーさんの衣装(服装や髪形のかぶりがないか、サービスのイメージに沿った服装の依頼など)、当日のメニューの確認などを行いました。

撮影

撮影日当日は、プロダクトオーナー・カメラマン・デザイナーの3名でユーザーさんのお宅に伺い、撮影に協力してくださるユーザーさんに、絵コンテを見せながら撮影のイメージを伝え、撮影に望みました。

撮影立会時に、今回は私が撮影の進行管理・写真の確認などを担当しました。

進行管理では、撮影順の調整、カットの抜け漏れがないかのチェック、時間帯や天候により撮影状況が変わってしまった際の判断(カット数の増減、ほかのシチュエーションへの変更)なども行います。今回の撮影では、天候に恵まれ滞りなく進行できました。

カットごとに撮影時間を確保する

今回はユーザーさんが実際にお料理する流れに沿って、リラックスした自然な様子を撮影したいと考え、撮影に望みました。

ですが、被写体が複数人の場合、状況をコントロールしないと、誰かが目をつぶってしまっている、画面に対し立ち位置が左右一方に寄りすぎてしまうということが発生しがちです。

サービスの利用シーンを撮影する場合は、手順ごとに手を止めて、撮影時間を確保してから進行させたほうが結果的にスムーズに撮影が進みました。

写真確認

撮影終了後、追ってカメラマンから確認用のデータが送られてくるので、その中から納品用のカットを指定します。

f:id:mura24:20171201152053j:plain

私は昔から利用しているAdobe Bridgeを使用して、写真選別を行っております。Finderでも似たようなことは可能ですが、ビューワー機能のあるツールを利用し、効率よく写真を選別してゆくのがオススメです。

納品

納品してもらう写真が決まったら、納品形式(ファイル形式、カラープロファイル、画像サイズなど)を指定して、撮影データを受け取ります。

進行・撮影ともに、カメラマン、そして参加してくださったユーザーさんにかなり助けていただだいたおかげで、めちゃんこエモい写真に仕上がったので、一部公開します。

f:id:mura24:20171201152517j:plain

Photo by 福田 栄美子

おわりに

目的を定め、最小限のコストでチーム内で合意を取りながらユーザーに届ける…というプロセスは、写真撮影でもサービス開発でも、そう違いはありません。

自社サービス以外にも、採用募集やイベント用の写真などで、デザイナーに写真撮影の相談が入ることも多いと思います。みなさまの参考になれば幸いです。


*…「おうちレッスン」は現在クローズドテスト中のCtoC料理教室プラットフォームです。

Xcode のビルドログの読込

$
0
0

モバイル基盤グループのヴァンサン(@vincentisambart)です。

開発者がどれくらいアプリのビルドを待っているのか気になったことありませんか?計測してみたらおもしろいかもしれません。どうすれば Xcode でビルド時間を計測できるのでしょうか。

プロジェクトの Build Phases の一番上と一番下にスクリプトを入れたら、ある程度計測できそうですが、制限が多そうですね。失敗したビルドや途中で止められたビルドは計測できないし、ビルドのどういうところに時間が掛かったのか詳しく分かりません。

ビルド時に Xcode がログを取っているはずなので、ログの中に時間が入っていないかな…?

最初から複雑なプロジェクトで試すのは不便でしかないので、始める前に Xcode (現時点で 9.1 ) で新規のプロジェクト(例えば iOS の Single View App)を作って、いじらずに1〜2回ビルドします。以下の調査はそのビルドで生成されたファイルを見ます。

ビルドログの在り処

求めているデータが入っているのを確認するために、まずどこに保存されているのを探す必要があります。

既に知っている開発者が多いかと思いますが、 Xcode はビルド時に生成する殆どのファイルを ~/Library/Developer/Xcode/DerivedData/<アプリ名>-<ID>に入れます。そのディレクトリの中を見てみると、 Logs/Buildにビルドログが入っていそうですね。最近ビルドされたプロジェクトの場合、そこに Cache.dbというファイルと、拡張子が xcactivitylogのファイルが入っています。

因みに、ビルドログがビルド終了後に更新されるので、ビルドの途中は前のビルドのログしか見られないようです。

Cache.db

Cache.dbの中身をエディターなどで見てみると、バイナリファイルではありますが、頭に bplistがあります。バイナリ plist なのでは?ターミナルで plutil -pを使って中身を見てみましょう。

$ plutil -p Cache.db
{
  "logs" => {
    "4E46321A-9204-42C9-AC76-BF6F01B77E64" => {
      "timeStartedRecording" => 532831205.501172
      "timeStoppedRecording" => 532831210.725163
      "domainType" => "Xcode.IDEActivityLogDomainType.BuildLog"
      "title" => "Build BlogTest"
      "signature" => "Build BlogTest"
      "schemeIdentifier-schemeName" => "BlogTest"
      "schemeIdentifier-containerName" => "BlogTest project"
      "schemeIdentifier-sharedScheme" => 1
      "documentTypeString" => "<nil>"
      "highLevelStatus" => "S"
    }
    "A6D6AD38-4367-439C-8021-31156A579B81" => {
      "timeStartedRecording" => 532831597.574763
      "timeStoppedRecording" => 532831597.597417
      "domainType" => "Xcode.IDEActivityLogDomainType.BuildLog"
      "title" => "Build BlogTest"
      "signature" => "Build BlogTest"
      "schemeIdentifier-schemeName" => "BlogTest"
      "schemeIdentifier-containerName" => "BlogTest project"
      "schemeIdentifier-sharedScheme" => 1
      "documentTypeString" => "<nil>"
      "highLevelStatus" => "S"
    }
  }
  "logFormatVersion" => 8
}

時間

timeStartedRecordingtimeStoppedRecordingが興味深いですね。 timeという名前だけど、浮動小数点数のようですね。よく考えてみると、 Swift で Dateを浮動小数点数から作成する方法が幾つかあります:

  • Date(timeIntervalSinceNow: TimeInterval)
  • Date(timeIntervalSince1970: TimeInterval)
  • Date(timeIntervalSinceReferenceDate: TimeInterval)

Date(timeIntervalSinceNow:)は呼ばれるタイミングによって結果が変わるので、違うはずですね。

全般的に、タイムスタンプは 1970 年からの秒数がよく使われるので、 Playground で試してみましょう。

Date(timeIntervalSince1970: 532831205.501172)
"Nov 20, 1986 at 9:40 AM"

ビルドしたばかりなので、 1986 年のはずがない(笑)

Date(timeIntervalSinceReferenceDate:)だとどうなるんだろう。

Date(timeIntervalSinceReferenceDate: 532831205.501172)
"Nov 20, 2017 at 9:40 AM"

お、丁度いい!実際ビルドにどれくらい掛かったのかは timeStoppedRecordingtimeStartedRecordingを引けば秒数が分かるので Dateにする必要ないのですが(笑)

因みに、 timeIntervalSinceReferenceDateが Apple 独自のものだとはいえ、 Ruby でも簡単にできます。

APPLE_REFERENCE_DATE = Time.new(2001, 1, 1, 0, 0, 0, 0) # 2001/01/01 00:00:00 UTCdeftime_from_time_interval_since_reference_date(time_interval)
  APPLE_REFERENCE_DATE + time_interval
end

time_from_time_interval_since_reference_date(532831205.501172).getlocal
# => 2017-11-20 09:40:05 +0900

他の項目

Cache.dbの他の項目は分かりやすいものが多いですね。

logsに入っている GUID が同じディレクトリに入っている xcactivitylogファイルのファイル名と一致しています。

logFormatVersionは Xcode のバージョンによるもののようです。 Xcode 8.3.3 が生成した Cache.dblogFormatVersionは 7 ですが、 Xcode 9.0~9.1 が生成したやつはlogFormatVersionが 8 です。でも logFormatVersion 7 も 8 も Cache.dbの中身が同じのようです。

これでビルド時間が正確に分かります。ただし、詳細が分かりませんし、ビルドが成功したのかどうか分かりません。

xcactivitylog

もっと詳しくは xcactivitylogファイルの中身を見る必要があるかもしれません。少しネットで調べてみたら、 xcactivitylogの中身が gzip で圧縮されているらしいことが分かりました。

でも gzip -cdで展開してみると、テキストファイルに見えなくもないが、変な文字が入っているし、改行がおかしいし、時間らしいものが見当たりません…一応ファイルの最後を見ると Build stopped-Build failed-Build succeeded-でビルドの結果が分かります。ファイル名と Cache.dbに入っている GUID が一致するので、情報を合わせるとビルド時間とビルド結果が分かりますけど、詳細がまだ…

トークン読込

ネットでもう少し調べてみたら Haskell で書かれた xcactivitylog を読み込むコードがありました。結局テキストファイルじゃなかった。

Haskell はよく分からないけど、 Haskell でのコードやそのコメントを見ながら、 xcactivitylogを Ruby スクリプトで読み込もうとして試行錯誤で分かった形式は以下の通りです。

まず、ファイルが SLF0で始まって、その後はトークンのリストが並んでいるだけです。

トークンは以下の7種類のようです。

正規表現 種類 頭の数字が表しているもの
- nil
[0-9]+#数字
[0-9]+"文字列 文字列の長さ
[0-9]+\(リスト リストに入っている項目の数
[0-9]+%クラス名 クラス名の長さ
[0-9]+@オブジェクト クラス名の番号(%で定義された最初のクラス名が 1となる)
[a-f0-9]{16}\^浮動小数点数 16進法でメモリ上のリトルエンディアンの64-bitの浮動小数点数(double)

では、 Ruby で読み込むスクリプトを書きましょう。まず gzip で圧縮されたデータを展開します。

require'zlib'raise"Syntax: #{$0} file.xcactivitylog"unlessARGV.length == 1
file_path = ARGV[0]
raw_data = Zlib::GzipReader.open(file_path, encoding: Encoding::BINARY) { |gzip| gzip.read }

その後、トークンを1個ずつ読み込みます。

require'strscan'
scanner = StringScanner.new(raw_data)

# なぜか StringScanner に特定の文字数を読み込むメソッドはないので生やすdef scanner.read(length)
  string = peek(length)
  self.pos += length
  string
endraise'Invalid format'unless scanner.scan(/SLF0/)
class_names = []
tokens = []

while !scanner.eos?
  if scanner.scan(/([0-9]+)#/) # integer
    value = scanner[1].to_i # 頭の数字が値
    tokens << { type: :int, value: value }
  elsif scanner.scan(/([0-9]+)%/) # class name
    length = scanner[1].to_i # 頭の数字がクラス名の長さ
    name = scanner.read(length)
    raise"Class name #{name} should not be present multiple times"if class_names.include?(name)
    class_names << name.to_sym
  elsif scanner.scan(/([0-9]+)@/) # object
    class_index = scanner[1].to_i # 頭の数字がクラスの番号(最初に定義されたクラスが 1)raise"Unknown class reference #{class_index} - Known classes are #{class_names.join(', ')}"if class_index > class_names.length
    tokens << { type: :object, class_name: class_names[class_index-1] }
  elsif scanner.scan(/([0-9]+)"/) # string
    length = scanner[1].to_i # 頭の数字が文字列の長さ
    string = scanner.read(length)
    tokens << { type: :string, value: string }
  elsif scanner.scan(/([0-9]+)\(/) # list# 頭の数字がリストの項目数
    count = scanner[1].to_i
    tokens << { type: :list, count: count }
  elsif scanner.scan(/([a-f0-9]+)\^/) # double
    hexadecimal = scanner[1] # 16進法でメモリ上のリトルエンディアンのdouble# "cf4c80e55bc2bf41" -> ["cf", "4c", "80", "e5", "5b", "c2", "bf", "41"]
    characters_grouped_by_2 = hexadecimal.each_char.each_slice(2).map(&:join)
    # ["cf", "4c", "80", "e5", "5b", "c2", "bf", "41"] -> [207, 76, 128, 229, 91, 194, 191, 65]
    bytes = characters_grouped_by_2.map { |hex| hex.to_i(16) }
    # [207, 76, 128, 229, 91, 194, 191, 65] -> "\xCFL\x80\xE5[\xC2\xBFA" -> [532831205.501172] -> 532831205.501172
    double = bytes.pack('C*').unpack('E').first
    tokens << { type: :double, value: double }
  elsif scanner.scan(/-/) # nil
    tokens << { type: :nil }
  elseraise"unknown data #{scanner.peek(30).inspect}"endendrequire'pp'
pp tokens

シンプルなプロジェクトのビルドで生成された xcactivitylogファイルを上記のスクリプトに読み込ませると以下のような出力が出ます。

[{:type=>:int, :value=>8},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>0},
 {:type=>:string, :value=>"Xcode.IDEActivityLogDomainType.BuildLog"},
 {:type=>:string, :value=>"Build BlogTest"},
 {:type=>:string, :value=>"Build BlogTest"},
 {:type=>:double, :value=>532831205.501172},
 {:type=>:double, :value=>532831210.725163},
 {:type=>:list, :count=>1},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>1},
 {:type=>:string,
  :value=>"Xcode.IDEActivityLogDomainType.target.product-type.tool"},
 {:type=>:string, :value=>"Build target BlogTest"},
 {:type=>:string, :value=>"BlogTest-ehwnkjvfrwpvqwdylenlszdndskk"},
 {:type=>:double, :value=>532831205.611886},
 {:type=>:double, :value=>532831210.71247},
 {:type=>:list, :count=>7},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>2},
 {:type=>:string, :value=>"com.apple.dt.IDE.BuildLogSection"},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:double, :value=>532831205.611923},
 {:type=>:double, :value=>532831205.613694},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:int, :value=>0},
 {:type=>:int, :value=>1},
 {:type=>:int, :value=>0},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"E8680327-DEA4-4414-8A84-5FD0D3E2C765"},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>2},
 {:type=>:string, :value=>"com.apple.dt.IDE.BuildLogSection"},
 {:type=>:string, :value=>"Compile Swift source files"},
 {:type=>:string,
  :value=>"CompileSwiftSources normal x86_64 com.apple.xcode.tools.swift.compiler"},
 {:type=>:double, :value=>532831205.61325},
 {:type=>:double, :value=>532831209.491755},
 {:type=>:list, :count=>2},
 (略)
 {:type=>:int, :value=>0},
 {:type=>:int, :value=>0},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:string, :value=>"4E46321A-9204-42C9-AC76-BF6F01B77E64"},
 {:type=>:string, :value=>"Build succeeded"},
 {:type=>:nil}]

もっと多くの情報が取れそう。でも上記のスクリプトと出力に不自然だと思われるところがあるかもしれません。なぜリストは作らずに項目数を取っておくだけ?オブジェクトはクラス名は分かるけど中身は?

実はオブジェクトトークンは「ここからこのクラスのオブジェクトが始まる」ことを表しています。オブジェクトの属性はその直後に来るいくつかトークンです。ただし属性の種類や数は分かりません。 Xcode はもちろん各クラスの属性を分かっているでしょうけど、僕らは色々調査してみるしかありません。

リストは入っているオブジェクトの属性の数が分からないと各オブジェクトがどこまでなのか分からないのでまだ作れません。

属性の種類や数は少し時間掛かるけどそこまで難しくありません。

ログバージョン

オブジェクトに入っている属性に集中する前に、まずファイルの最初のトークンを見ましょう。8Cache.dbに入っていた logFormatVersionと同じ。偶然? Xcode 8.3 でアプリをビルドしてみて、生成されたログでは、 Cache.dblogFormatVersion同様 7になります。やっぱり、 logFormatVersionでしょう。因みに、 xcactivitylogは見てみた限りでは、 78で変わった部分が1ヶ所があります(具体的には IDEActivityLogSectionの最後に項目が1つ追加された)。

分かりやすさのため、以下は Xcode 9 のログ形式バージョン 8 だけに集中します。

オブジェクトの属性を調査

属性はどうしましょう。試行錯誤するしかないですね。トークンのリストを見て仮説をたてて、その仮説を元にスクリプトを変えて、スクリプトをいくつかの xcactivitylogファイルに処理させてみて、結果によって仮説とスクリプトを調整する、の繰り返しです。

トークンのリストを見ると、 IDEActivityLogSectionがいつも以下のような項目で始まるようですね。その仮説を検証してみましょう。

 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>2},
 {:type=>:string, :value=>"com.apple.dt.IDE.BuildLogSection"},
 {:type=>:string, :value=>"Compile Swift source files"},
 {:type=>:string,
  :value=>"CompileSwiftSources normal x86_64 com.apple.xcode.tools.swift.compiler"},
 {:type=>:double, :value=>532831205.61325},
 {:type=>:double, :value=>532831209.491755},
 {:type=>:list, :count=>2},
 {:type=>:object, :class_name=>:IDEActivityLogSection},

仮説を検証するために、期待していない値がある時点ですぐ raise (例外発生)をしましょう。以前のスクリプトが出したトークンのリストを見れば属性の型はある程度分かるけど、名前は分からないので一旦 fieldXXXにします。 IDEActivityLogSectionを幾つか見てみると7番目に入るリストは nilになることもあるようなのでそれに対応しました。最初からそれに気づかなくても問題ありません。実行したらエラーが出て、直して、また実行する、の繰り返しなので。あと開発中、コード内にデバッグ出力のため pppをよく使いますが、読む時はノイズになるので以下のコードではそれを省きました。また、このブログが長くなりすぎないように、細かい試行錯誤については省略しています。

classTokenReaderdefinitialize(tokens)
    @tokens = tokens.dup
  enddeftokens_left_count@tokens.length
  enddefread(expected_type, args = {})
    token = @tokens.shift
    returnnilif token[:type] == :nil&& args[:nullable]
    raise"Expecting token of type #{expected_type.inspect} but got #{token.inspect}"if token[:type] != expected_type

    case expected_type
    when:list
      expected_class_name = args[:class_name]
      (0...token[:count]).map { read(:object, class_name: expected_class_name) }

    when:object
      expected_class_name = args[:class_name]
      class_name = token[:class_name]
      raise"Expected an object of class #{expected_class_name} but got an instance of #{class_name}"if class_name != expected_class_name
      fields = { class_name: class_name }
      case class_name
      when'IDEActivityLogSection'
        fields[:field1] = read(:int)
        fields[:field2] = read(:string)
        fields[:field3] = read(:string)
        fields[:field4] = read(:string)
        fields[:field5] = read(:double)
        fields[:field6] = read(:double)
        fields[:field7] = read(:list, nullable: true, class_name: :IDEActivityLogSection)

      elseraise"Unknown class name #{class_name}"end
      
      fields

    else
      token[:value]
    endendend# tokens は上記のスクリプトで生成したもの
reader = TokenReader.new(tokens)
log_format_version = reader.read(:int)
raise"Unknown log format version #{log_format_version}"if log_format_version != 8
pp reader.read(:object, class_name: :IDEActivityLogSection)
p reader.tokens_left_count

実行してみたら Expecting token of type :object but got {:type=>:nil}と怒られました。スタックトレースを見ると、リストを読み込もうとしている時です。もう少し調査してみると、7つめの属性である IDEActivityLogSectionのリストは1項目が無事に読み込まれたけど2項目目を読もうとしている時にエラーが起こります。リストの全項目が同じ型を想定していましたが、 IDEActivityLogSectionの直後に nilが入っている。リストにオブジェクトに混ざって nilが入っていると考えにくいので、理由は別にありそうです。

リストの始めからエラーが起きた少しあとまでのトークンを見てみましょう。

 {:type=>:list, :count=>7},
 {:type=>:object, :class_name=>:IDEActivityLogSection},
 {:type=>:int, :value=>2},
 {:type=>:string, :value=>"com.apple.dt.IDE.BuildLogSection"},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:double, :value=>532831205.611923},
 {:type=>:double, :value=>532831205.613694},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:int, :value=>0},
 {:type=>:int, :value=>1},
 {:type=>:int, :value=>0},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"E8680327-DEA4-4414-8A84-5FD0D3E2C765"},
 {:type=>:nil},
 {:type=>:nil},
 {:type=>:object, :class_name=>:IDEActivityLogSection},

このリストには項目が7つもある。どの項目も IDEActivityLogSectionの可能性が高い。なら少し下にある IDEActivityLogSectionはリストの2項目目なのでは?別のオブジェクトの属性の可能性もありますが、まずそれで試してみましょう。

fields[:field1] = read(:int)
fields[:field2] = read(:string)
fields[:field3] = read(:string)
fields[:field4] = read(:string)
fields[:field5] = read(:double)
fields[:field6] = read(:double)
fields[:field7] = read(:list, nullable: true, class_name: :IDEActivityLogSection)
fields[:field8] = read(:nil)
fields[:field9] = read(:nil)
fields[:field10] = read(:int)
fields[:field11] = read(:int)
fields[:field12] = read(:int)
fields[:field13] = read(:nil)
fields[:field14] = read(:nil)
fields[:field15] = read(:string)
fields[:field16] = read(:string)
fields[:field17] = read(:nil)
fields[:field18] = read(:nil)

また実行してみましょう。field14を読み込もうとする時に以下のエラーが出ました。

Expecting token of type :nil but got {:type=>:object, :class_name=>:DVTDocumentLocation}

field14nilの場合もあれば、 DVTDocumentLocationのインスタンスの場合もあるようですね。

fields[:field14] = read(:object, nullable: true, class_name: DVTDocumentLocation)

DVTDocumentLocationの中身も探る必要がありますね。

 {:type=>:object, :class_name=>:DVTDocumentLocation},
 {:type=>:string,
  :value=>
   "file:///Users/vincent-isambart/Desktop/BlogTest/BlogTest/main.swift"},
 {:type=>:double, :value=>0.0},
 {:type=>:string,
  :value=>"CompileSwift normal x86_64 (略)"},
 {:type=>:string, :value=>"1D50F5EA-D2D1-4F45-9017-8D2CEFE85CBC"},
 (略)
 {:type=>:object, :class_name=>:DVTDocumentLocation},
 {:type=>:string,
  :value=>"file:///Users/vincent-isambart/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/Objects-normal/x86_64/BlogTest.swiftmodule"},
 {:type=>:double, :value=>140736883871744.0},
 {:type=>:string,
  :value=>"MergeSwiftModule normal x86_64 (略)"},
 {:type=>:string, :value=>"D19441A3-B3BE-4814-B29D-173A5F24F876"},

DVTDocumentLocationの属性はどこまででしょうか。ログファイル全体のトークンのリストを見て、ヒントになりそうなところを探しましょう。 IDEActivityLogSectionfield14nilの場合がありますね。その時、直後の field15field16が以下の通りだったところがあります。

 {:type=>:string, :value=>"Check dependencies"},
 {:type=>:string, :value=>"E8680327-DEA4-4414-8A84-5FD0D3E2C765"},

DVTDocumentLocationの属性が2つだったらうまくいきそうです。それでやってみましょう。

when :DVTDocumentLocation
  fields[:field1] = read(:string)
  fields[:field2] = read(:double)

その後出ていた nullable 関連のエラーをちょこっと直したら、テストで使っていたすごくシンプルなプロジェクトのビルドログが無事に解析できました。オブジェクトの属性の読込が以下のようになりました。

case class_name
when:IDEActivityLogSection
  fields[:field1] = read(:int)
  fields[:field2] = read(:string)
  fields[:field3] = read(:string)
  fields[:field4] = read(:string)
  fields[:field5] = read(:double)
  fields[:field6] = read(:double)
  fields[:field7] = read(:list, nullable: true, class_name: :IDEActivityLogSection)
  fields[:field8] = read(:nil)
  fields[:field9] = read(:nil)
  fields[:field10] = read(:int)
  fields[:field11] = read(:int)
  fields[:field12] = read(:int)
  fields[:field13] = read(:string, nullable: true)
  fields[:field14] = read(:object, nullable: true, class_name: :DVTDocumentLocation)
  fields[:field15] = read(:string, nullable: true)
  fields[:field16] = read(:string)
  fields[:field17] = read(:string, nullable: true)
  fields[:field18] = read(:nil)

when:DVTDocumentLocation
  fields[:field1] = read(:string)
  fields[:field2] = read(:double)

elseraise"Unknown class name #{class_name}"end

IDEActivityLogSectionを読み込んだあとに残っているトークンを見ようとしたら、トークンが残っていないので、ファイルに入っているのはログバージョンと1つの IDEActivityLogSectionだけのようですね。もちろんその IDEActivityLogSectionには色々入っています。

もう少し複雑なビルドログで同じことを繰り返したら、こんな感じになりました。

命名

オブジェクトを読み込めたのはいいのですが、オブジェクトに入っている属性に名前がまだありません。どう付ければいいのでしょうか。

まず、 IDEActivityLogSectionに入っている2つの doubleに簡単に名前を付けられます。最初に読み込もうとした xcactivitylogファイルでは最初の2つ double532831205.501172532831210.725163でした。見た覚えあるような…そう、 Cache.dbに入っていた timeStartedRecordingtimeStoppedRecordingと同じ値なので、 Cache.dbに入っていた名前を使えばいいです。

同様、 Cache.dbの中身と比べて domainTypetitlesignatureも分かります(titlesignatureは値が同じなのでどっちがどっちか逆になってしまうかもしれませんが)。

あとはクラス名や値自体を元に名前を付けてみましょう。何もないよりマシです。DVTDocumentLocationの最初の項目が file:///Users/...で始まる文字列なので名前は urlで良さそう。 DVTDocumentLocationが入る属性は locationでいいんじゃないかな。 IDEClangDiagnosticActivityLogMessageIDEActivityLogMessageのリストは messagesでいかが。

一部の項目に名前を付けたスクリプトのバージョンがこちらで見られます。因みにログバージョン 7 にも対応しています。

もっと多くの属性に名前を付けるには方法が色々ありそうです。例えば意図的にビルドログに影響ありそうなもの(ビルド結果、警告、エラー)を変えて、何が変わったのかを見て名前を付けられそうですね。僕は目的がビルド時間だけだったのでそこまでやっていませんが。

名前を付けているスクリプトをシンプルなログに実行すると以下のような出力が出ます。読みやすさのためにクラス名、 nilな値、各 Swift ファイルのビルド詳細、を省いておきました。各ステップにどれくらい時間が掛かったのかがよく分かります。

{:domain_type=>"Xcode.IDEActivityLogDomainType.BuildLog",
 :title=>"Build BlogTest",
 :signature=>"Build BlogTest",
 :time_started_recording=>532831205.501172,
 :time_stopped_recording=>532831210.725163,
 :result=>"Build succeeded",
 :subsections=>
  [{:domain_type=>"Xcode.IDEActivityLogDomainType.target.product-type.tool",
    :title=>"Build target BlogTest",
    :signature=>"BlogTest-ehwnkjvfrwpvqwdylenlszdndskk",
    :time_started_recording=>532831205.611886,
    :time_stopped_recording=>532831210.71247,
    :subsections=>
     [{:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Check dependencies",
       :signature=>"Check dependencies",
       :time_started_recording=>532831205.611923,
       :time_stopped_recording=>532831205.613694},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Compile Swift source files",
       :signature=>"CompileSwiftSources normal x86_64 com.apple.xcode.tools.swift.compiler",
       :time_started_recording=>532831205.61325,
       :time_stopped_recording=>532831209.491755,
       :subsections=>[(略)]
       :location=>{:url=>"file:///Users/user-name/Desktop/BlogTest/BlogTest/main.swift"}},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Copy /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/DerivedSources/BlogTest-Swift.h",
       :signature=>"Ditto /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/DerivedSources/BlogTest-Swift.h /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/Objects-normal/x86_64/BlogTest-Swift.h",
       :time_started_recording=>532831209.492459,
       :time_stopped_recording=>532831209.500314,
       :location=>{:url=>"file:///Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/DerivedSources/BlogTest-Swift.h"}},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Link /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest",
       :signature=>"Ld /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest normal x86_64",
       :time_started_recording=>532831209.500942,
       :time_stopped_recording=>532831210.568323},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Copy /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftdoc",
       :signature=>"Ditto /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftdoc /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/Objects-normal/x86_64/BlogTest.swiftdoc",
       :time_started_recording=>532831209.50099,
       :time_stopped_recording=>532831209.507525,
       :location=>{:url=>"file:///Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftdoc"}},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Copy /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftmodule",
       :signature=>"Ditto /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftmodule /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Intermediates.noindex/BlogTest.build/Debug/BlogTest.build/Objects-normal/x86_64/BlogTest.swiftmodule",
       :time_started_recording=>532831209.50093,
       :time_stopped_recording=>532831209.507456,
       :location=>{:url=>"file:///Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest.swiftmodule/x86_64.swiftmodule"}},
      {:domain_type=>"com.apple.dt.IDE.BuildLogSection",
       :title=>"Sign /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest",
       :signature=>"CodeSign /Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest",
       :time_started_recording=>532831210.571077,
       :time_stopped_recording=>532831210.711349,
       :location=>{:url=>"file:///Users/user-name/Library/Developer/Xcode/DerivedData/BlogTest-ehwnkjvfrwpvqwdylenlszdndskk/Build/Products/Debug/BlogTest"}}]}]}

集計

ログファイルを読み込めたのは良いけど、それでどうやって開発者のビルド時間を集計できるのでしょうか。僕はプロジェクトの Build Phases で Xcode にスクリプトを実行させています。スクリプトがまだ処理されていないログファイルから必要なデータだけを抽出してサーバーに送ります。ビルドが終わるまでログファイルが生成されないので、データは1個前のビルドになりますが、実行された日時が入っているのでデータが少し遅れて送られれても問題ありません。

この仕組は制限が色々あります。1個前のビルドログなので、処理が走る前にログが削除されたらデータがなくなります。 DerivedData内のデータを自分で消さなくても、例えば別のログバージョンを使う Xcode で同じプロジェクトを開くとビルドログが全部削除されるようです。

でもビルド時間の計測が完璧じゃなくていいのではないでしょうか。

まとめ

どうやって Xcode のビルド時間を計測できるのか考えてみたら、 Xcode のログファイルからできないのか試してみました。結果的にビルドの各ステップの時間まで取得できるようになりました。

弊社では、集計されたビルド時間をグラフ化して、開発者が毎日どれくらいビルドを待っているのか、何回ビルドを実行しているのか、ビルドに平均でどれくらい時間が掛かるのか、が見えるようにしています。

そのデータでビルド時間短縮の必要性を証明できるようになったと思います。

料理教室のデザインリニューアルを支えた技術

$
0
0

料理教室事業部の長(@s_osa_)です。最近読んで面白かった漫画は『ランウェイで笑って』です。

クックパッド料理教室では今年10月にデザインの全面リニューアルを行ないました。

Before After
f:id:s_osa:20171219172624p:plainf:id:s_osa:20171219171826p:plain

ユーザー向けページの HTML, CSS, JavaScript を約1ヶ月でまるっと書き換えるプロジェクトでした。

今回はそんなデザインリニューアルを支えた仕組みについて書きたいと思います。

全面リニューアルの大変さ

「全面リニューアル」

聞いただけで大変さがにじみ出る言葉ですが、具体的に何が大変なのか少し考えてみます。

主な大変さは2つあると考えています。

スコープが大きい

デザインの全面リニューアルという性質上、全ページが対象になります。 クックパッド料理教室のコードはそれほど大きくない Rails ですが、それでも対象の view ファイルは約200ほどあります。

もちろん、これら200個のファイルだけを変更するわけではなく関連するファイルも同時に修正する必要があるため、実際の作業量はもっと大きくなります。

リリースブランチの長期間運用とビッグバンマージ

リニューアルにともなってデザインを大きく変更するため、全ページのデザインを一度に切り替える必要があります。 また、プロジェクトを進める一方で、バグ修正をはじめとして日常的にコードに変更を入れていく必要もあります。 これら2つの目的を果たすためにプロジェクトの期間中ずっとリリースブランチをメンテナンスしていく必要が生じます。

リリースブランチを長期間にわたってメンテナンスしていくのも大変ですが、その後、master にマージするのも大変です。 差分が大きくなればなるほど、バグが入り込む可能性は大きく、バグが起こったときの原因究明も難しくなります。

大変じゃない全面リニューアルを考えてみる

大変な理由がわかったところで、その大変さを取り除くことを考えます。

スコープをできるだけ小さくする

デザインの全面リニューアルである以上、すべての HTML, CSS, JavaScript, Image を書き換えることは避けがたいです。 しかし、それ以外の箇所は触らないようにしました。

「せっかくリニューアルするなら」と新機能の追加や機能改善をしたくなりますが、そこはグッと我慢して粛々と画面だけを書き換えます。 ただでさえ大きいスコープをさらに膨らませてリリースが遅れるくらいなら、可能な限り早くリリースした後に小さく扱いやすいスコープで機能追加や改善を行なうという方向性をプロジェクト開始時にチームで合意しました。

また、デザインの刷新だけでもユーザーにとっては大きな変更であり戸惑いが生じるので、機能については据え置くことで少しでも戸惑いを減らしたいという意図もありました。

リリースブランチをつくらない

身も蓋もないことを言ってしまうと、リリースブランチをなくせばリリースブランチの長期間運用もビッグバンマージも発生しません。 そこで、リリースブランチをつくるのはやめて、書いたコードは順次 master に入れるようにします。

しかし、先述のとおり全ページのデザインを一度に切り替える必要があるので、単純に既存の view を書き換えるという手段は使えません。

そこで、「全ページのデザインを一度に切り替える」と「順次 master にマージする」を両立するための仕組みをつくりました。

柔軟なデザイン切り替えを実現するために

つくりたい状況は

  • master に新旧2つのデザインが共存している
  • 2つのデザインを柔軟に切り替えられる

というものです。

上記2点を実現するための方法についてそれぞれ考えていきます。

master に新旧2つのデザインを共存させる

まず、master に新旧両方のデザインを共存させる方法を考えます。

プロジェクト期間中は一時的に共存期間が必要ですが、プロジェクト完了後は新しいデザインのみが使用され古いデザインが必要になることはありません。

そこで、古いデザインのためのファイルをまとめたディレクトリを作ります。 具体的には app/views, app/assets/images, app/assets/javascripts, app/assets/stylesheetsの中に旧デザインのためのファイルを置くためのディレクトリを掘って、既存のファイルをそちらに移動し、プロジェクト完了後にまるっと削除できるようにします。

イメージとしては以下のようなディレクトリ構成になります。

# tree app
app
├── assets
│   ├── images
│   │   └── old
│   ├── javascripts
│   │   ├── application
│   │   │   └── foo.js
│   │   ├── application.js
│   │   ├── application_old
│   │   │   └── foo.js
│   │   └── application_old.js
│   └── stylesheets
│       ├── application
│       │   └── foo.scss
│       ├── application.scss
│       ├── application_old
│       │   └── foo.scss
│       └── application_old.scss
└── views
    ├── foos
    │   └── index.html.haml
    ├── layouts
    │   └── application.html.haml
    └── old
        ├── foos
        │   └── index.html.haml
        └── layouts
            └── application.html.haml

また、asset precompile 時に新旧両方の asset を作成するようにし、新旧それぞれの layout ファイルから読み分けるようにします。

# config/initializers/assets.rbRails.application.config.assets.precompile += %w(application application_old)
# app/views/layouts/application.html.haml
= stylesheet_link_tag 'application', media: 'all'= javascript_include_tag 'application'# app/views/layouts/application_old.html.haml
= stylesheet_link_tag 'application_old', media: 'all'= javascript_include_tag 'application_old'

2つのデザインを柔軟に切り替える

ここまでで新旧両方のファイルを master に共存させることができました。

あとは render するテンプレートをいい感じに切り替えることさえできれば当初の目的を達成することができます。

Rails のテンプレート探索

テンプレートを望む通りに切り替えるためにはテンプレートがどのように探索されているかを知る必要があります。

Rails がどうやってテンプレートを探索しているかについては以下のエントリが詳しいです。

リンク先にあるようにテンプレート探索の仕組みは結構複雑なのでここでその詳細を解説することはしませんが、今回作ろうとしている仕組みは Rails がテンプレート探索に使用している resolver の仕組みを利用します。ここでは resolver についてのみ簡単に説明します。

Resolver

Rails が render するテンプレートを探索するために使用しているオブジェクトです。 現在のアプリケーションが持っている resolver の一覧は rails console で以下のメソッドを呼ぶことで確認できます。

ApplicationController.new.view_paths

メソッドの返り値は resolver が入った配列で、デフォルトでは以下のような resolver だけが入っています。

#<ActionView::OptimizedFileSystemResolver:0x007fe41c5d88a8
  @cache=#<ActionView::Resolver::Cache:0x7fe41c5d8bf0 keys=0 queries=0>,
  @path="/Users/shunsuke-osa/projects/cooking_school/app/views",
  @pattern=":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}">

何らかの view を追加するタイプの gem を使用している場合はその gem が提供する view を探索対象に含むための resolver が追加されているはずです。 *1

Path

resolver が持っている @pathは文字通り探索対象のディレクトリを指し示す path です。デフォルトの resolver には app/viewsが指定されており、普段 Rails がこのディレクトリを対象にテンプレートの探索を行なっていることがわかります。

Pattern

resolver の @patternからなんとなく察せるとおり、普段 Rails がやっている locale (e.g. ja, en), format (e.g. html, json), handler (e.g. haml, erb) に応じたテンプレートの切り替えも resolver によって行われています。 *2

パターン定義の中に含まれる :hogeはテンプレート探索で用いられる LookupContextにおいて detail と呼ばれているもので、探索 path を動的に生成するために使用されます。現在使用されている detail の一覧は以下のメソッドで確認できます。

ActionView::LookupContext.registered_details
# => [:locale, :formats, :variants, :handlers]

また、パターン中に含まれる {}はブレース展開されます。

柔軟なテンプレート探索を実現する

Rails のテンプレート探索の仕組みを調べた結果、

  • Rails はテンプレート探索に使うための resolver を持っている
  • 適切な path や pattern を指定した resolver を追加すれば任意のテンプレートを render する仕組みをつくることができる

ということがわかりました。

方針が決まったので実装していきます。

シンプルなケース

view_pathsに独自 resolver が追加されていない Rails に対してテンプレート探索の対象ディレクトリを追加するのは簡単です。 ActionView::ViewPathsが提供している prepend_view_pathappend_view_pathを使用することで任意の @pathを持った resolver を追加することができます。

# app/controllers/application_controller.rb
before_action :fallback_to_old_templatesprivatedeffallback_to_old_templatesif prefer_new_template?
    append_view_path('app/views/old')
  else
    prepend_view_path('app/views/old')
  endend

独自 resolver が使用されているケース

我々のアプリケーションでは jpmobileを使用していました。

jpmobile は resolver の pattern に :mobileという detail を追加して PC 向けとモバイル端末向けのテンプレートを切り替えています。 つまり、jpmobile が提供する端末ごとのテンプレート切り替えに対応しつつ、今回追加する新旧デザインの切り替えにも対応する必要があるため、前述の prepend_view_path, append_view_pathに path を渡す方法では目的を果たすことができません。

そこで、jpmobile が提供する resolver を拡張した resolver を用意する必要があります。

つくりたい resolver は以下のようなものです。

  • jpmobile の提供する :mobileという detail に対応している
    • ':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'
  • 探索ディレクトリを柔軟に変更するための detail として :directoriesのようなものを持っている
    • '{:directories}:prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'

つまり、両者を同時に満たすために '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'という pattern を持つ resolver である必要があります。

Detail

detail の追加は非常に簡単で、ActionView::LookupContext.register_detailを使用します。

# config/initializers/action_view.rbActionView::LookupContext.register_detail(:directories) { [] }

こうして detail を登録することによって、controller で self.lookup_context.directories=を呼んで、resolver の pattern にある :directoriesに値を渡すことができるようになります。

Pattern

本来であれば、jpmobile を拡張して pattern が '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'となる resolver を作成して prepend_view_pathに渡すべきなのですが、

  • jpmobile が既存の resolver それぞれに対して resolver を作成しており、すべてに対応するのが面倒なわりにメリットが薄い
  • 今回はプロジェクト中のみ一時的に使用する

といった点を考慮し、jpmobile にモンキーパッチを当てることにしました。

# config/initializers/monkey_patches/jpmobile.rbmoduleJpmobileclassResolver# Original: ':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freezeDEFAULT_PATTERN = '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freeze
  endend

あまり褒められた方法ではありませんが、今回行ないたいのはデザインの全面リニューアルであり、そのための一時的な仕組みに対してあまり時間をかけたくなかったため割り切った判断をしました。

実際に切り替える

ここまで出来たらあとは実際に切り替えるだけです。

切り替え自体は controller で lookup_context.directories=を呼ぶだけなので非常に簡単です。

# app/controllers/application_controller.rb
before_action :set_view_template_directoriesprivatedefset_view_template_directoriescase preferred_template # returns 'new' or 'old'when'new'self.lookup_context.directories = ['', 'old/']
  when'old'self.lookup_context.directories = ['old/', '']
  endend

新旧どちらのテンプレートを優先するかを指定するメソッドを用意し、その返り値によってテンプレート探索の優先順位を切り替えています。

プロジェクト初期には新しいテンプレートは存在しません。しかし、その都度例外を吐かれると開発しにくいので、新テンプレートが見つからない場合には旧テンプレートにフォールバックするようにしています。

実際の手順

これまでの説明ではわかりやすさのため順番を前後させてきましたが、実際の作業手順としては以下のような順番でした。

  1. テンプレート切り替えの仕組みを実装する
    • preferred_template = 'old'
    • この時点では旧テンプレートが app/viewsにある
    • old -> new のフォールバックによってアプリケーションは正常に動き続ける
  2. 旧テンプレートや関連リソースを移動する
    • 旧テンプレートが app/views/oldに移動
  3. 新テンプレートを実装していく
    • preferred_template = 'new'すると新テンプレートが優先的に render される
    • 新テンプレートが未実装のページは旧テンプレートにフォールバックする

いろいろな切り替え方

RAILS_ENV

一番わかりやすい切り替え方だと思います。

RAILS_ENV=developmentでは新しいテンプレートを優先し、RAILS_ENV=productionでは古いテンプレートを優先するなどができます。

Query String

URL に ?template=newなどを付加することによって、RAILS_ENVによるテンプレート指定を手軽に上書きする手段を提供します。production での確認などに利用していました。

Session

query string によるテンプレート指定は手軽で便利でしたが、ページ遷移を伴う場合に不便でした。そこで、プロジェクト後半にはページを遷移してもテンプレート指定が保たれるように session を用いたテンプレート指定も使用していました。 *3

外部データストアから設定を読み込み

全面リニューアルを実際にリリースする直前になると、リリース時に万一事故が起こったときの切り戻しを考えるようになりました。

しかし、我々のアプリケーションではデプロイやロールバックのために数分程度かかってしまいます。 つまり、リリース後にページが見れなくなるなどの問題が起こってしまった場合には数分間にわたってユーザーに迷惑がかかってしまいます。

そこで、デプロイなしでリリースするために外部のデータストアから指定するテンプレートを読み込めるようにしました。 Redis や memchached などの書き換えが容易なデータストアにテンプレート指定を保存し、リクエストごとに読み込むようにすることによって切り戻しにかかる時間を数秒程度まで短くすることができます。

パフォーマンスなど注意すべき点はありますが、比較的小規模なアプリケーションであることやリリース前後のみ使用するということを考慮して採用しました。

組み合わせると

それぞれのテンプレート指定方法を組み合わせて以下のような形で運用していました。

defpreferred_template# preferred_template_by_* は 'new', 'old', nil のいずれかを返す# 優先順位を query, session, data store, env の順に設定
  preferred_template_by_query || preferred_template_by_session || preferred_template_by_configuration || preferred_template_by_env || 'old'end

応用:段階的リリース

リクエストごとにテンプレート指定を柔軟に変更できるようになると「スタッフのアカウントに対して一足先に新デザインをリリースする」「ユーザー ID の末尾2桁が10以下のユーザーに対してのみ新デザインをリリースする」といったようにリリース対象を少しずつ広げるというようなことも可能になります。

おわりに

規模が大きくなることを避けられないデザインの全面リニューアルをスムーズに行なうために使用したテンプレートの柔軟な切り替え方法を紹介しました。

この方法を用いた結果、開発者以外のメンバーも含めて早い段階から production で新しいデザインを確認することができ、バグの早期発見に繋げることができました。 また、リリースまでに新しいデザインを触る時間を十分取れたため、リリース規模のわりには安心してリリースすることができましたし、事実としてもリリース後に大きな不具合は起こりませんでした。

ここまで触れませんでしたが、画面に関連しているものとしてテストがあります。しかし、古いテストを隔離してテストの中で指定するテンプレートを切り替えるという方針は同じです。

リリース後しばらくして問題なく動いていることを確認できたら、テンプレートを切り替えの仕組みを削除して新しいデザインだけを使用するようにした上で、はじめにつくった /oldディレクトリを rm -rfしてリニューアル完了です。

影響範囲が大きいリニューアルをすることはあまり多くはないと思いますが、もし同じような状況に置かれている方の参考になれば幸いです。

*1:我々のアプリケーションでは kaminari, letter_opener_webなどが含まれていました。

*2:デフォルトパターンは https://github.com/rails/rails/blob/6a902d43c76a8b5bc2ddd00b7c8af38f9fb82bdb/actionview/lib/action_view/template/resolver.rb#L209で定義されています。

*3:切り替え方法とは別に session の書き換え方法を別途用意する必要があります

Ruby の NODE を GC から卒業させた

$
0
0

こんにちは、技術部のフルタイム Ruby コミッタの遠藤(@mametter)です。メリークリスマス。

本日 Ruby 2.5.0 がリリース予定です。いろいろな改善が含まれています。クックパッドからの主な貢献としては、「trace 命令の削除による高速化」「分岐・メソッドカバレッジの測定のサポート」などがあります。

ユーザから見える改善はいろいろと記事が出てくると思うので、この記事では、「抽象構文木のメモリ管理のリファクタリング」というあまりユーザから見えない改善を紹介してみます。

概要

Ruby のパーサは、NODE という内部的なオブジェクトで構成された抽象構文木を生成します。2.4 までの NODE は GC に管理される普通のオブジェクトでしたが、2.5 からは GC の外で管理するようになりました。これにより、3 つ嬉しいことがあります。

  • 大きなコードのパースが速くなりました。
  • NODE に詳細なコード位置情報(カラム情報)を載せることができました。
  • 将来的に NODE まわりのコードを整理できる準備ができました。

背景

NODE は、Ruby の抽象構文木のノードを表現する要素です。抽象構文木はソースコード文字列をプログラムで扱いやすい木構造として表現したものです。知らない人は拙著『Ruby でつくる Ruby』などを読んでください :-)

2.4 の NODE は GC に管理されるオブジェクトとして実装されていました。Ruby のオブジェクトは GC の制約で 5 ワード長と決まっています。そのうち 2 ワードは NODE の種別や行番号を表現するために予約されていて、自由に使えるのは 3 ワードのみでした。イメージ的には、長さが 3 で固定されている配列みたいなものです。

この方法には 3 つの問題がありました。

  • パース中に無駄な GC が起きる
  • 抽象構文木に詳細なコード位置情報を載せる余地がない
  • 抽象構文木の表現にムリ・ムダがある

パース中に無駄な GC が起きる

大きなコードをパースする際に NODE オブジェクトが大量に生成され、GC が走ってしまいます。しかも生成中の NODE は回収できないので、この GC はほぼ完全に無駄です。この現象は、a = 1が大量に並ぶコードを実行すると観測できます。

図:eval("a = 1\n" * N)の実行時間(x 軸は行数 N、y 軸は時間)

a = 1が N 行並ぶコードの実行時間を、N を変えながら計測したものです。実行時間が非線形に増えていっているのがわかります。これはパース中に起きる GC のせいです。1 回の GC にかかる時間は O(N) 、GC が起きる回数は O(log N) なので、全体で O(N log N) の時間がかかります。

抽象構文木に詳細なコード位置情報を載せる余地がない

NODE には、その NODE に対応するコードの行番号だけが記録されていました。バックトレースの表示や行カバレッジの測定などでは、この情報だけで十分でした。

しかし、2.5 では分岐カバレッジをサポートすることになりました。分岐は同一の行の中で複数個現れることが普通にあるため、分岐カバレッジのレポートで「どの分岐の実行回数であるか」を示すために、カラム番号(行内で左から何番目か)が必要になりました。また、メソッドカバレッジのレポートでも、開始位置(def の位置)だけではなく終了位置(end の位置)もある方が便利でしょう。

しかし、大きさが限られている NODE には、これらの情報を載せるための場所がありませんでした。

抽象構文木の表現にムリ・ムダがある

3 ワード制限のために、Ruby の抽象構文木にはムリ・ムダが生じています。たとえば trueを表すノード NODE_TRUEは、子ノードを持たないので、3 ワードがまるまるムダになってます。逆に、obj.attr += valを表すノード NODE_OP_ASGN2は情報を 4 つ持つ *1ので、2 つの NODE をカスケードさせて無理やり表現しています。

おまけ:昔は GC 管理する意味があったが、今はもう意味がない

Ruby 1.8 のころは、抽象構文木をトラバースする方式でインタプリタが実装されていました。このような実装では、抽象構文木が不要になるタイミングが自明ではないので、GC 管理に任せたい気持ちも理解できます。

しかし Ruby 1.9 では YARV が導入され、YARV コンパイラが抽象構文木をバイトコードに変換したあとは、抽象構文木はもう使われません。つまり、パースから実行開始までのわずかな期間だけのために、少なくない NODE オブジェクトを作って捨てることになります。世代別 GC が導入されたから実害はあまりないですが、ムダはムダです。

やったこと

NODE を GC 管理されるオブジェクトとしてではなく、ただの malloc されたバッファの中に確保するようにしました。大量に NODE を作っても、malloc バッファが増えるだけで GC のオブジェクトバッファは圧迫されないので、無意味な GC 起動は基本的に起きません。

主に大変だったのは次の 3 つです。

NODE が NODE 以外のオブジェクトを指すケースの検出と対応

NODE の子どもは基本的に NODE ですが、一部の NODE は NODE 以外のオブジェクトを指すことがあります。たとえばリテラルを表す NODE_LITは、そのリテラルオブジェクト(文字列や配列など)を参照します。 NODE は GC 管理オブジェクトではないので、これらのオブジェクトはマークされません。そのままでは回収されてしまいます。そこで、NODE のバッファの他に、マークが必要なオブジェクトを管理する配列(mark_ary)を用意し、NODE が NODE 以外のオブジェクトを指すタイミングで mark_aryに追加するようにしました。*2

NODE を目的外使用しているコードの削除

NODE は抽象構文木のためのものなのに、「自動的に free される便利なデータ構造」として転用されてしまっていました。NODE_ALLOCA(自動的に free される一時的バッファ)と、NODE_HEREDOC(ヒアドキュメント関係のパーサの状態を管理するための一時的データ構造)で、いずれも抽象構文木の一部にはなりません。これらは imemoと言われる別種の内部オブジェクトに置き換えて対応しました。

Ripper 対応

Ripper は NODE が GC 管理オブジェクトであることを仮定して書かれているので、切り離しが大変でした。実は完全には切り離せておらず、NODE の先頭ワードはオブジェクトと同じ構造でないといけません *3。これはいずれなんとかしたいと思っています。

結果

この改善により、背景に上げた 3 つの問題が解決しました(または解決のめどが立ちました)。

パース中の無駄な GC がなくなった

大きなコードの eval が線形になりました。

図:eval("a = 1\n" * N)の実行時間(x 軸は行数 N、y 軸は時間)

グラフ的には圧倒的ですが、正直この改善が現実世界で生きてくることはあんまり期待できないと思っています。クックパッドの全ソースコードのパースで評価すると、2.67 秒が 2.60 秒になった程度でした。まあ、10,000,000 行のコードとか書きませんよね。コードを自動生成しているプロジェクトでは、ひょっとしたら役立つかもしれません。

笹田コメント:「おまけ:昔は GC 管理する意味があったが、今はもう意味がない」にあるように、Ruby 1.9 から NODE を GC 対象にする必要はないことはわかっており、ずっとやりたいと思ってペンディングしていたところ、遠藤さんが入社して一瞬で作ってくれました。ただ、当初は GC 回数が減るので、もっと性能インパクトがあるかと思ったんですが、現実的なコードでは影響がほとんどなく、意外でした。世代別 GC の性能が十分高い、ということだと思います。

NODE に範囲情報をもたせた

NODE が GC 管理から外れて自由に拡張できるようになりました。今までは各 NODE は開始行番号しか持っていませんでしたが、今は次の 4 つの情報を持っています。

  • 開始行番号
  • 開始カラム番号
  • 終端行番号
  • 終端カラム番号

分岐カバレッジ・メソッドカバレッジはこの情報にもとづいて測定結果を出力します。 また、カラム情報は他にも利用価値がありそうです。たとえば、NoMethodErrorが起きた箇所を行番号だけでなくカラム番号も出すことで、より詳細に位置を特定できるようにできるかも。

抽象構文木の表現のムリ・ムダを省いていける準備ができた

NODE が自由に使える領域は 3 ワードに限らなくなったので、今後はより柔軟に拡張できます。Ruby のソースコードのうち、評価器部分は YARV への置き換えで大きく整理されましたが、パーサ部分は未整理のまま拡張され続けてきていて、現在は Ruby の中でも最もわかりにくいソースコードの 1 つになっています。メンテナンスの観点でも、将来的に型システムを検討する土台としても、わかりやすくてメモリ効率的によいものになるように整理を進めたいと考えています。

まとめ

Ruby 2.5 NODE を GC 管理から外すことで、(1) パース時の無駄な GC を抑えた、(2) NODE の位置情報を詳細化した、(3) 抽象構文木の整理を進める土台を確立した、という改善を行いました。

謝辞:改良の方針や実装について弊社笹田とたくさん相談しました。また、bison を使って NODE の位置情報を実際に実装していくのは @yui-knkさんがやってくれました。ありがとうございます。

*1:レシーバ obj 、読み書きする attr 、演算子 + 、値 val 。

*2:mark_ary は YARV のコンパイラでも使われているテクニックです。

*3:RB_TYPE_P(obj, T_NODE) によって、NODE かそれ以外かを区別できないといけない。

Viewing all 726 articles
Browse latest View live