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

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

$
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でも書いたるか! などという(わりと珍しい)機会がありましたらぜひご活用ください。

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


Cookpad TechConf 2018 開催報告

$
0
0

こんにちは、技術広報を担当している外村です。

f:id:hokaccha:20180210115823j:plain

2018年2月10日にエンジニア向けのカンファレンス、Cookpad TechConf 2018を開催しました。当日はたくさんの方に参加いただき、活気あるカンファレンスになりました。ご来場の皆様本当にありがとうざいました。

新しい試みとして、当日の司会をAmazon Pollyの音声合成で行なったのですが、こちらもみなさんにお楽しみいただけたようでした。

講演資料・動画

当日の講演資料および動画を公開いたしましたので是非ご覧になってください。

基調講演: 毎日の料理を楽しみにする挑戦をし続けた20年 by 橋本 健太

コーポレート戦略部本部長の橋本による基調講演でイベントはスタートしました。クックパッドはテックカンパニーとしてどのように成長してきたか、グローバル展開をどのように行ってきたか、現在取り組んでいる新プロジェクトについての話などがありました。

講演資料・動画

クックパッドの "体系的"サービス開発 by 新井 康平

会員事業部の新井の講演は、クックパッドではサービス開発の難しさにどのように立ち向かっているか、という内容です。こちらの講演の捕捉記事を公開していますのでこちらもご覧になってください。

"体系的"に開発サイクルを回して "効果的"に学びを得るには - クックパッド開発者ブログ

講演資料・動画

クックパッドクリエイティブワークフロー by 辻 朝也

会員事業部デザイナの辻の講演は、クックパッドにおけるサービス開発のフローについての話です。やるべき施策を決めてリリースし、分析して評価するという一連のサイクルの中で具体的にどういったことをおこなっているのか、というのがよくまとまっていました。

講演資料・動画

What/How to design test automation for mobile by 松尾 和昭

海外事業部にてサービスの品質向上やテストを担当している松尾の講演は、モバイルテストの自動化についての話です。モバイルのテストで重要なトピックをSPLIT(Scope, Phase, Level, sIze, Type)というキーワードにまとめて解説しました。

講演資料・動画

Rubyの会社でRustを書くということ by 小林 秀和

インフラストラクチャー部の小林の講演は、Rustを使ったプロダクト開発についての話です。CookpadはRubyを使ってサービス開発をすることが多いですが、そういった環境でRustを採用した経緯や、実際にRustを導入したプロダクトで得られた知見を紹介しました。

講演資料・動画

cookpad storeTV 〜クックパッド初のハードウェア開発〜 by 今井 晨介

メディアプロダクト開発部の今井の講演は、cookpad storeTVについての話です。cookpad storeTVはスーパーに設置する料理動画を配信するサイネージで、クックパッドがハードウェアの開発から手がけました。今井はその開発を担当しており、実際に発生した問題や具体的な開発フローについて紹介しました。

講演資料・動画

Challenges for Global Service from a Perspective of SRE by 渡辺 喬之

インフラストラクチャー部SRCグループの渡邉の講演は、クックパッドのグローバルサービスのSREとしてどのような取り組みをしてきたか、という内容です。グローバルサービスならではの課題というのはどういったものがあり、それをどう解決したのかという、あまり他では聞くことが少ない興味深い話でした。

講演資料・動画

動き出したクックパッドのCtoCビジネス by 村本 章憲

Komerco事業部の村本からの講演は、クックパッドの新規事業であるKomercoについての話です。Komercoとはどのようなサービスなのか、どのようなチーム・技術スタック・フローで開発しているかということについて紹介しました。

講演資料・動画

Solve "unsolved" image recognition problems in service applications by 菊田 遥平

研究開発部の菊田の講演は、機械学習による画像分析の取り組みについての話です。機械学習をサービスに活かすうえで難しい問題はどういったところにあるのか、それを実際の業務でどのように解決したか、という内容でした。

講演資料・動画

基調講演: Beyond the Boundaries by 成田 一生

取りをつとめたのはCTOの成田による基調講演でした。クックパッドの技術スタックはどのようなものか、エンジニアの行動指針である「Beyond the Boundaries」とは何なのか、エンジニアが成長できるために具体的にどういった取り組みを行っているか、といった話でイベントを締めました。

講演資料・動画

Lifestyle Product Award授賞式

講演の途中に、昨年開催した2017 Lifestyle Product Award by Cookpadの表彰式をおこないました。今回は優秀賞としてGOKURIが選出されました。

GOKURIは嚥下機能、飲み込みの能力を計測するためのデバイスです。基礎的な研究は数年前から行なわれていたものの、精度が課題となっていました。昨年、深層学習による精度向上によってプロダクトとしてリリースできる水準となり、リハビリ学会などでその成果が発表されました。

感想エントリ

当日参加いただいた方の感想エントリを以下にまとめました。素敵な記事を書いていただき、ありがとうございます。

他にもありましたら@hokacchaまでお知らせください!

まとめ

クックパッドにおけるサービス開発の手法やプロダクト開発の事例、その背景にある技術的なトピックなど、幅広い領域の講演をお届けしました。当日参加いただいた沢山の方に楽しんでいただいたようです。

クックパッドでは引き続き、このようなイベントを開催していきます。ぜひ、楽しみにしていてください!

高速に仮説を検証するために ~A/Bテスト実践~

$
0
0

会員事業部エンジニアの佐藤です。クックパッドでは日々データと向き合い、データを基にした施策作りに関わっています。

Cookpad TechConf 2018で新井が発表した「クックパッドの "体系的"サービス開発」の中で、社内で仮説検証を行う際に使われているツールについて触れている箇所がありました。 本記事ではそのツールと実際の取組み方について、実際の流れを踏まえながらもう少し詳しく説明していきます。

仮説検証

仮説検証は以下のフローで進んでいきます。

  1. 前提条件を確認する
  2. 検証の設計をする
  3. 各パターンの機能を実装する
  4. 各パターンにログを仕込む
  5. デプロイ後の監視
  6. 検証結果の振り返りとネクストアクション

小さく・手戻りなく・高速な検証を行うためには手を動かす前の段階、上記フローにおける1・2のステップが重要となります。

具体例として「朝と夜はプレミアム献立の需要が高まる」という仮説の検証フローを見ていきます。 これは献立プレミアム献立のアクセス分布をみると朝と夜にもアクセスが増加していたことから得た仮説です。

前提条件を確認する

下記の2つの点について合意が取れている必要があります。

  • 確かめたい価値(仮説)が明確化されている
  • その検証にA/Bテストを用いる

今回は話をわかりやすくするため「朝と夜はプレミアム献立の需要が高まる」という仮説が既にたっており、それをA/Bテストで確かめるという流れになります。ですが実際にはそもそも仮説が検証可能な状態にまで明確化されていないといった状況が考えられます。 手段を具体化する前にチームで方針決定・合意形成がなければ検証は始まりません。 ごく当たり前のように感じますが、いつでも振り替えられるよう土台を固めておくことが大事です。

検証の設計をする

仮説を確かめるためにA/Bテストの設計を行います。 まず、仮説を確かめるために何と何を比較するか考えます。 この記事で例題として扱う仮説は「朝と夜はプレミアム献立の需要が高まる」という仮説でした。 前提知識として人気順検索とプレミアム献立では人気順検索の方が需要があり、単純に人気順検索の枠をプレミアム献立に差し替えて比較すると前者が有効であることがわかっているとします。 よって、今回は「普段は人気順検索での訴求に使っていた枠を朝と夜の時間にだけプレミアム献立に切り替える」施策に取り組みます。

  • パターンA: 人気順検索(通常)
  • パターンB: 朝と夜だけプレミアム献立、それ以外の時間帯は人気順検索
パターンAパターンB
f:id:ragi256:20180221172307p:plainf:id:ragi256:20180221172324p:plain

この時、対象も出来る限り明確にしておきます。 今回はサイト内の該当部分を訪れたプレミアム会員以外の全てのユーザーを対象とすることにします。 また、検証の結果がどうなったかによって次にとるアクションまで決めます。

次にA/Bテストで監視・比較をするKPIも設定します。今回はプレミアムサービス会員の転換率(CVR)をKPIとします。 KPIが決定したことで同時に具体的なログの測定箇所と測定内容も決定します。 今回はそれぞれのパターンにおける訪問ユーザー数とプレミアムサービスへの転換数が必要となります。

ここで検証期間の見積もりを行うため、必要となるサンプルサイズを算出しておきます。 サンプルサイズの算出には「A/B両パターンの平均値」と「求める確度」を事前に決めておく必要があります。 言い方を変えると「どれだけの改善を確認したいのか」と「どれだけ偶然を排除したいか」という点です。 統計学では前者を効果量、後者を有意水準と検出力と呼びます。 詳しくは「仮説検証とサンプルサイズの基礎」を御覧ください。 これらを基にしてサンプルサイズを計算し、サンプルサイズと現状のUUから今回の仮説検証に必要とする日数を求めます。

そして検証設計の最後に、検証期間が経過した時点でどういう結果だったらどうするということを決めておきます。 実際に手を動かす前に、最終的な結果を大雑把に場合分けして次の行動を決めておくことが手戻りの防止につながります。

両パターンを実装する

パターンAには従来通りの挙動を、パターンBには時間帯によって枠内表示が変わるように実装をします。 この際、プロトタイプ開発用プラグインである「Chanko」とChankoのA/Bテスト用拡張である「EasyAb」を使うことで下記のように書くことができます。

パターンの制御を行うChanko内部のコントローラー

moduleTimeSlotPsKondateincludeChanko::UnitincludeEasyAb

  split_test.add('default', partial: 'default_view')
  split_test.add('time_slot_ps_kondate', partial: 'time_slot_ps_kondate_view`')

  split_test.log_template('show', 'ab_test.time_slot_ps_kondate.[name].show')
  split_test.log_template('click', 'ab_test.time_slot_ps_kondate.[name].click')

  split_test.define(:card) donext run_default if premium_service_user? # プレミアムサービスユーザーは対象としない
      ab_process.log('show') # 訪問ユーザー数カウント用ログ
      render ab_process.fetch(:partial), time_slot: target_time?
  endend

パターンの差し替えを行うChanko外部のviewファイル(haml)

-# 対象となるviewの書かれているファイル= invoke(:time_slot_ps_kondate, card) do  -# 差し替え部分 
end

これらに加え、パーシャルとして必要となる default_card.hamltime_slot_ps_kondate_card.hamlとCSSを追加すれば実装は完了です。 既存コードとの接点はinvokeメソッドの部分のみであるため、A/Bテストのon/offはごくわずかな変更で制御することができます。 Chankoは既存のコードと切り離された場所に置かれるため、検証の後始末もスムーズに行えます。

このように、ChankoとEasyAbを使うことで必要最低限のコードのみで検証を行えます。

両パターンにログを仕込む

今回はCVRをKPIとして追いかけていく必要があります。 不要なログを大量にとっても仕方がないのでログは必要最低限に留めるべきですが、後になって「あのログをとっておけばよかった」と後悔しても遅いため必要なログに抜け漏れがないよう列挙しておきます。 今回は該当部分のページに訪れた人(show)とプレミアムサービス枠をクリックした人(click)のログを取ります。 実際にCVRを取るには前者だけで十分なのですが、後者のログもとっておくことでCTRを算出できるようになり、クリエイティブに問題があったかどうか振り返るのに役立ちます。

A/Bテストに限らず一時的なログをササッと仕込みたい場合、社内ではKPI管理ツール「Hakari2」を使っています。 Ruby・JavaScript・HTMLそれぞれで利用することができます。

Ruby

Hakari2Logger.post("ab_test.hakari_log.ruby.A", user: user, request: request)

JavaScript

hakari2.post(['ab_test.hakari_log.javascript.A'])

HTML(hamlで書いた場合)

= link_to xx_path, class: 'track_hakari2', data: { hakari2_keywords: 'ab_test.hakari_log.html.A' }

このようにしてクライアント側でセットされたログは共通ログ基盤Figlogを経由し、最終的にDWHチームの管理するRedshift内へ格納されます。 A/Bテストを開始した後、ログの監視や分析を行う時にはこのログを他のデータと組み合わせて利用します。 今回の検証で必要となるCVRはshowのログとプレミアム会員登録のログを結合することで求まります。

デプロイ後の監視

A/Bテスト用の実装をデプロイし、公開した後に実装やログ取得にミスがないか確認をする必要があります。 日次の集計結果などはcookpadの管理用アプリケーション「papa」上のダッシュボードで確認できます。 検証期間後の最終的な検証結果もこちらで確認します。

f:id:ragi256:20180221172442p:plain

検証結果の振り返りとネクストアクション

「仮説検証の設計をする」の段階であらかじめ決めておいた目標サンプルサイズに到達したところで検証を終えます。 その時点で再度ダッシュボードを確認し、今回の施策の結果がどうであったかを結論づけます。

ダッシュボードでは集計値だけでなく、数値をもとにして描かれた確率分布や計測期間中の推移を見ることができます。 これらをみることで有意差がありそうかどうか、特定日時のイベントによる影響がないかどうかを確認します。

確率分布時系列変化
f:id:ragi256:20180221172500p:plainf:id:ragi256:20180221172509p:plain

このステップでは知見を得るための考察や議論を行いますが、よほど想定外の結果にならない限り「検証の設計」で決めた方針に従い次の行動を決定します。 この施策に関しては当初想定していた量の改善が得られなかったため、仮説の正しさを証明する結果が得られませんでした。 この仮説は献立とプレミアム献立のアクセス分布から得た仮説でしたが、その仮説を得る過程をアプローチ方法から見直す必要があります。

まとめ

クックパッドで高速に仮説を検証するために普段行っている作業についてお話しました。 6ステップに分けて説明をしてきましたが、「前提条件の確認」と「検証の設計」までがキチンとこなせていればその後は特に考えることなく実行することができます。 このサイクルを回す作業に慣れていくことで、実際に手を動かす作業よりもサービス改善のためにどうするべきか頭を使う作業へ労力を割くことができるようになります。

また、今回はwebでのA/Bテストの説明をしましたが、iOS/Androidでも同様にA/Bテスト用のツールを利用することで手軽に仮説検証を行うことができます。

クックパッドでは日々このように各種ツールを利用してサービス改善のサイクルを高速にまわしています。

良い感じにログを収集するライブラリ、Puree-Swiftをリリースしました

$
0
0

こんにちは。技術部モバイル基盤グループの三木(@)です。

クックパッドでは、Pureeと呼ばれるiOS/Android/ReactNative向けのログ収集ライブラリを公開しています。

モバイルアプリのログ収集ライブラリ「Puree」をリリースしました - クックパッド開発者ブログ

ログ収集ライブラリ Puree の iOS 版をリリースしました - クックパッド開発者ブログ

最近、以前開発されていたPureeをpure Swiftで書き換え、OSSとして公開しました。

この記事では、新しくなったPureeをご紹介します。

概要

クックパッドでは全社的にAmazon Redshiftを中心としたデータ活用基盤を構築しています。

クックパッドのデータ活用基盤 - クックパッド開発者ブログ

この仕組みを使い、公開している多くのモバイルアプリからも、1つのログ基盤にさまざまなログを集積させています。

しかし、モバイルアプリからのログ送信には、さまざまな状態を考慮する必要があります。 ログを送りたいタイミングに安定した通信が確保されているとは限らないですし、闇雲に送りすぎてしまうと、ユーザーさんのギガを圧迫してしまうかもしれません。

これらを解決するライブラリがPureeです。 ログをバッファリングし、まとめて送信したり、送信に失敗したログをキャッシュし、復元時にリトライする機能などを有しています。

f:id:gigi-net:20180227165235p:plain

Puree-Swiftの特徴

Puree-Swiftは、以前公開していたObjective-C版と異なり、以下のような特徴があります。

Objective-C版の設計思想を踏襲

Puree-SwiftはObjective-C版のPureeの置き換えを目指しています。そのため、タグシステムやプラグインの設計など、基本的な仕組みを踏襲しています。 詳しく知りたい方は以下の記事をご覧ください。

ログ収集ライブラリ Puree の iOS 版をリリースしました - クックパッド開発者ブログ

よりSwiftらしいインターフェイス

Objective-Cで書かれていた物をSwiftに刷新したため、よりSwiftから利用しやすいインターフェイスとなりました。

大きく変わったのはFilterOutputの実装方法で、以前は抽象クラスとして実装していたのですが、protocolを利用することができるようになり、よりSwiftらしいプロトコル指向な設計に生まれ変わりました。

依存関係の廃止

Objective-C版のPureeでは、未送信のログの永続化のため、YapDatabaseというSQLiteにアクセスするライブラリを利用していました。 しかしこのライブラリは最近メンテナンスが止まっていたり、Swiftで書かれていなかったりと、Pureeのメンテナンスを難しくする原因となっていました。 そのため、Puree-Swiftでは一切の依存関係を廃止して、iOS標準のファイルストレージを使うようにしています。

通常はこの利用方法で問題ありませんが、巨大なデータを扱いたい需要が出たときのために、LogStoreを自分でプラグインとして拡張できる設計になりました。 必要に応じてRealmやCoreDataなど、使いたいバックエンドを採用することができます。

実装例

それではさっそくPureeの実装例を見てみましょう。最終的には、以下のようなインターフェイスで任意の場所にログを送れるようになります。

ここでは、以下のようにPVログを送るまでの実装を考えてみます。

logger.postLog(["recipe_id":42, "user_id":100], tag:"pv.recipe.detail")

Pureeを扱うには以下の3ステップが必要です。

  1. ログを加工するFilterを実装する
  2. 収集されたログを外部に出力するOutputを実装する
  3. タグにより、どのFilterやOutputを利用するかルーティングする

より詳しい使い方はREADMEをご覧ください。

1. ログを加工するFilterを実装する

まず、Filterプロトコルを用いて、Filterを実装します。これは渡ってきた任意のデータをLogEntryに加工する役目を持っています。

ここでは単純に渡ってきたペイロードをJSONとしてエンコードして、LogEntryに格納しています。

import Foundation
import Puree

structPVLogFilter:Filter {
    lettagPattern:TagPatterninit(tagPattern:TagPattern, options:FilterOptions?) {
        self.tagPattern = tagPattern
    }

    funcconvertToLogs(_ payload:[String: Any]?, tag:String, captured:String?, logger:Logger) ->Set<LogEntry> {
        letcurrentDate= logger.currentDate

        letuserData:Data?
        ifletpayload= payload {
            userData = try! JSONSerialization.data(withJSONObject:payload)
        } else {
            userData =nil
        }
        letlog= LogEntry(tag:tag,
                           date:currentDate,
                           userData:userData)
        return [log]
    }
}

このFilter上で、全てのログに共通して付加したいペイロードを載せることもできます。 例えば、ユーザー情報などが考えられます。

2. 収集されたログを外部に出力するOutputを実装する

次に、収集されたログを外部に出力するためにOutputを実装します。

以下は渡ってきたLogEntryのペイロードを標準出力に出力するだけのOutputです。

classConsoleOutput:Output {
    lettagPattern:Stringinit(logStore:LogStore, tagPattern:String, options:OutputOptions?) {
        self.tagPattern = tagPattern
    }

    funcemit(log:Log) {
        ifletuserData= log.userData {
            letjsonObject= try! JSONSerialization.jsonObject(with:log.userData)
            print(jsonObject)
        }
    }
}

BufferedOutput

Outputを用いると、ログが送信され、即座に出力されますが、代わりにBufferedOutputを用いると、一定数のログが溜まるまでバッファリングし、定期的にログを送ることができます。 以下のようにAPIリクエストを伴うようなログ送信に適しています。

classLogServerOutput:BufferedOutput {
    overridefuncwrite(_ chunk:BufferedOutput.Chunk, completion:@escaping (Bool) ->Void) {
        letpayload= chunk.logs.flatMap { log inifletuserData= log.userData {
                return try? JSONSerialization.jsonObject(with:userData, options:[])
            }
            returnnil
        }
        ifletdata= try? JSONSerialization.data(withJSONObject:payload, options:[]) {
            lettask= URLSession.shared.uploadTask(with:request, from:data)
            task.resume()
        }
    }
}

クックパッドでは、最初に紹介したログ基盤を利用するための、APIを提供しており、社内ライブラリとして、そのAPIに送信を行うOutputを提供しています。

このように、自前で用意したあらゆるログ基盤に出力することができますし、Firebase AnalyticsなどのmBaaSに対応することもできるでしょう。

3. タグにより、どのFilterやOutputを利用するかルーティングする

最後に、実装したFilterやOutputをどのログに対して適応するかのルーティングを定義しましょう。 Pureeは、ログに付加されたタグを元に、どのような処理を行うかを決定します。

import Puree

letconfiguration= Logger.Configuration(filterSettings:[                                             FilterSetting(PVLogFilter.self,                                                           tagPattern: TagPattern(string: "pv.**")!),                                         ],
                                         outputSettings:[                                             OutputSetting(ConsoleOutput.self,                                                           tagPattern: TagPattern(string: "activity.**")!),                                             OutputSetting(ConsoleOutput.self,                                                           tagPattern: TagPattern(string: "pv.**")!),                                             OutputSetting(LogServerOutput.self,                                                           tagPattern: TagPattern(string: "pv.**")!),                                         ])
letlogger= try! Logger(configuration:configuration)
logger.postLog(["page_name":"top", "user_id":100], tag:"pv.top")

例えば、上記のような定義ですと、それぞれのタグについて、以下のように処理が行われます。 これにより、ログの種類によって加工方法や出力先を変えることもできます。

tag name -> [ Filter Plugin ] -> [ Output Plugin ]
pv.recipe.list -> [ PVLogFilter ] -> [ ConsoleOutput ], [ LogServerOutput ]
pv.recipe.detail -> [ PVLogFilter ] -> [ ConsoleOutput ], [ LogServerOutput ]
activity.recipe.tap -> ( no filter ) -> [ ConsoleOutput ]
event.special -> ( no filter ) -> ( no output )

まとめ

  • iOSのログ収集ライブラリ、Puree-Swiftをリリースしました
  • すでにクックパッドアプリでは使われており、開発中の他のアプリでも利用される予定です
  • Outputを追加すれば、さまざまなログバックエンドに対応することができます

どうぞご利用ください。

try!Swift

f:id:gigi-net:20180227165258j:plain

ところで、明日3/1から開催のtry! Swift Tokyo 2018にクックパッドもブースを出展いたします。 オリジナルグッズの配布も行いますので、クックパッドでのiOS開発に興味のある方は是非遊びに来てください。

私もスピーカーとして登壇します。当日お会いしましたらよろしくおねがいします。

TOKYO - try! Swift Conference

Nginxへの変更に伴うリバースプロキシのテストの改善

$
0
0

Nginxへの変更に伴うリバースプロキシのテストの改善

SREグループの菅原です。 クックパッドではブラウザ用Webサイトのリバースプロキシ用のWebサーバとして長らくApacheを使っていたのですが、最近、Nginxへと変更しました。

Nginxへの変更に当たって、構成管理の変更やテストの改善を行ったので、それらについて書きたいと思います。

リバースプロキシのリニューアルについて

まず、ブラウザ用Webサイトの基本的なサーバ構成は以下のようになります。

f:id:winebarrel:20180227123255p:plain

リバースプロキシはELB経由でリクエストを受けて、静的ファイルの配信やキャッシュサーバ・Appサーバへの振り分けを行います。

リバースプロキシとして利用されているApacheは、長年の改修により設定が煩雑なものとなっており、設定の追加や変更にコストがかかる状態になっていました。

また、Apacheの設定ファイルはItamaeでは管理されておらず、ItamaeのレシピがあるGitリポジトリとは別に、Apacheの設定ファイルだけを格納したGitリポジトリで管理され、Capistranoで設定を配布する方式になっていました。これは当初、サーバ全体の構成管理(当時はPuppet)の適用タイミングと、プロキシサーバの設定の変更タイミングが異なると考えてのことだったのですが、現状では単に管理が複雑になるだけでメリットがない状態になっていました。

この状況を踏まえ、リバースプロキシのOSなどのリニューアルをするタイミングで、より平易に設定を書くことができるNginxへ変更し、また、Nginxの設定ファイルについてはItamaeの管理下に置くことにしました。

既存のリバースプロキシのテストについて

以前の記事でも取り上げられたのですが、リバースプロキシの設定はInfratasterでテストが行われています。

InfratasterのテストにはDocker Composeを使用しており、以下のようなコンテナの構成になっていました。

f:id:winebarrel:20180227123314p:plain

前述の通り、Apacheの設定はItamaeとは別のGitリポジトリで管理されており、cookpad.comを含む主要なサービスの設定が同じリポジトリに含まれています。

[リバースプロキシリポジトリ]
├── cookpad/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
├── other_service_a/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
├── other_service_b/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
└── spec/
    ├── cookpad_spec.rb
    ├── other_service_a_spec.rb
    ├── other_service_b_spec.rb
    ├── docker/
    │   ├── apache/
    │   │   └── Dockerfile
    │   ├── backend
    │   │   └── Dockerfile
    │   └── taster/
    │       └── Dockerfile
    └── docker-compose.yml

テスト用のdocker-compose.ymlでは、cookpad.comを含むサービス毎のコンテナを、設定ファイルのディレクトリをマウントする形で起動し、InfratasterからRSpecを実行するようになっています。

リバースプロキシからダミーバックエンドにアクセスする場合は、Docker Composeのlink_local_ips設定を使ってダミーバックエンドのコンテナにIPアドレスを割り当て、リバースプロキシの/etc/hostsを書き換えることで、リバースプロキシからバックエンドへの問い合わせを、ダミーバックエンドに差し替えるようにしていました。*1

# docker-compose.ymlbackend:networks:bridge:link_local_ips:- 169.254.100.100
        - 169.254.100.101
        - 169.254.100.102
        ...

 

# /etc/hosts
169.254.100.100 app-server-001
169.254.100.101 cache-server-001
169.254.100.102 ad-server-001
...

コンテナへのItamaeの適用

Itamae管理下に置かれたNginxの設定ファイルをテストするには、既存の方式のようにディレクトリをマウントするのではなく、コンテナに対してItamaeを適用する必要があります。

ItamaeレシピのGitリポジトリは、だいたい以下のようなファイル構成になっています。

[Itamaeリポジトリ]
├── ...
└── itamae/
    ├── function.rb
    ├── cookbooks/
    │   └── nginx/
    │       ├── default.rb
    │       ├── files/
    │       └── templates/
    └── roles/
        └── apne1_vpc-xxx/
            └── rproxy/
                ├── default.rb
                ├── td-agent.rb
                ├── files/
                └── templates/

これをCapistranoを使って、適用対象のサーバ上でitamae local function.rb roles/apne1_vpc-xxx/default.rbというようなコマンドを実行して、レシピを適用します。

function.rbはItamaeのヘルパーが定義されており

moduleRecipeHelperdefinclude_role(name)
    include_role_or_cookbook(name, "role")
  enddefinclude_cookbook(name)
    include_role_or_cookbook(name, "cookbook")
  enddefinclude_role_or_cookbook(name, type)
    Pathname.new(File.dirname(@recipe.path)).ascend do |dir|
      names = name.split("::")
      names << "default"if names.length == 1if type == "cookbook"
        recipe_file = dir.join("cookbooks", *names)
      else
        recipe_file = dir.join(*names)
      endif recipe_file.exist?
        include_recipe(recipe_file.to_s)
        returnendendraise"#{name} is not found."endendItamae::Recipe::EvalContext.send(:include, RecipeHelper)

Itamaeのレシピ内で

include_cookbook 'nginx'
include_recipe 'rproxy::td-agent'

と書くことにより、roles/cookbooks/配下のレシピを読み込めるようにしていました。

これらのItamaeのレシピをコンテナに適用する場合、以下の問題点があります。

  1. Nodeオブジェクトに含まれるサーバのメタ情報(例: node[:ec2])が、コンテナ適用時には含まれない
  2. Nginx以外の不要なレシピ(例: zabbix-agentなど)が適用されてしまう

1の問題については、Nodeクラスを書き換えることによって回避しました。

以下のコードをItamae適用時に読み込ませることで、レシピからEC2のメタ情報などを参照する場合に、未定義のときはnilを返すのではなく、ダミー値(key)が返るようにして、レシピの適用が失敗しないようにしました。

moduleFakeNodemoduleValuedef[](key)
      key.to_s
    endalias:fetch:[]enddef[](key)
    value = superif value.nil? && !self.mash.has_key?(key)
      case key
      when:http_proxynilwhen:rspectrueelse
        key.to_s.tap do |v|
          v.extend(Value)
        endendelse
      value
    endendendItamae::Node.prepend FakeNode

また、2の問題については、SKIP_RECIPESという環境変数を定義して、そこに含まれるレシピはItamaeでは適用しないようにヘルパーを修正しました。

moduleRecipeHelperSKIP_RECIPES = ENV.fetch('SKIP_RECIPES', '').split(',')

  definclude_role_or_cookbook(name, type)
    returnifSKIP_RECIPES.include?(name)

上記の修正などにより、既存のItamaeレシピに大きな修正をすることなく、サーバ同様にコンテナにもItamaeを適用できるようになりました。

DockerfileでのItamaeを適用する箇所は以下のようなコードになります。

ENV SKIP_RECIPES haproxy,td-agent,zabbix-agent

RUN cd /infra2 && \
    itamae local \
      spec/itamae/fake_node.rb \
      itamae/functions.rb \
      itamae/roles/apne_vpc-xxx/rproxy/default.rb

テストの改善

既存のリバースプロキシのテストをItamaeリポジトリに移行するに当たって、以下の点を改善するようにしました。

  1. 複数のサービスを同じdocker-compose.ymlで定義するのをやめて、環境構築の時間を短縮し、テストの相互依存をなくす
  2. link_local_ips/etc/hostsを使った経路の差し替えをやめ、エンドポイントのホスト名そのままでダミーサーバにアクセスできるようにする
  3. 実際のサーバ構成を再現するようにコンテナを構成して、SSL TerminationやIPの偽装などの処理をリバースプロキシのコンテナに持ち込まない

最終的にテストまわりのコンテナ構成・ファイル構成は以下のようになりました。

f:id:winebarrel:20180227123328p:plain

[itamae/spec]
├── backend/
│   ├── Dockerfile│   └── files/
│       ├── backend.rb
│       └── init.sh
├── internal_service_proxy/
│   ├── Dockerfile│   └── files/etc/nginx/conf.d/default.conf.tmpl
├── itamae/
│   └── fake_node.rb
├── rproxy/
│   ├── Dockerfile│   ├── docker-compose.yml
│   ├── files/etc/haproxy/haproxy.cfg
│   │   └── init.sh
│   └── spec/
│       ├── cookpad_spec.rb
│       └── spec_helper.rb
├── ssl/
│   ├── cookpad.com.crt
│   ├── cookpad.com.key
│   └── root-ca.crt
└── taster/
    └── Dockerfile

またdocker-compose.ymlは以下のようになりました。

version:'3'services:backend:build:context: ../backend
    networks:- spec-network
  internal-service:environment:SERVERS: cache-server-001:backend
    build:context: ../internal_service_proxy
    depends_on:- backend
    networks:spec-network:aliases:- cache-server-001
  rproxy:build:context: ../..
      dockerfile: spec/rproxy/Dockerfile
    depends_on:- backend
      - internal-service
    networks:- spec-network
  elb:environment:SERVERS: cookpad.com:rproxy
    build:context: ../ssl_termination_proxy
    volumes:- ../ssl:/ssl
    depends_on:- rproxy
    networks:spec-network:aliases:- cookpad.com
  taster:build:context: ../..
      dockerfile: spec/taster/Dockerfile
    volumes:- ./spec:/spec
    working_dir: /spec
    depends_on:- elb
    networks:- spec-network
networks:spec-network:

elbコンテナ(ssl_termination_proxy/

elbコンテナは、SSL Terminationを行うコンテナで、ELBの役割を担うコンテナです。 Docker Composeのaliasesを利用して、実際と同様のホスト名でコンテナにアクセスできるようにしています。

コンテナで動くNginxの設定には、以下のようにテンプレートを用意して*2

{{ range $server_name, $backend := var "SERVERS" | split "," | splitkv ":" }}
server {
  listen 80;
  listen 443 ssl;
  server_name {{ $server_name }};
  ssl_certificate /ssl/{{ $server_name }}.crt;
  ssl_certificate_key /ssl/{{ $server_name }}.key;

  underscores_in_headers on;

  location / {
    # set external network ip address
    set $custom_x_forwarded_for "93.184.216.34";

    if ($http_x_test_client_ip != "") {
      set $custom_x_forwarded_for $http_x_test_client_ip;
    }

    proxy_set_header X-Forwarded-Host $host:$server_port;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $custom_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_pass http://{{ $backend }};
  }
}
{{ end }}

環境変数SERVERSの値(ホスト名:バックエンド,...)で各サービスのserverproxy_passを定義し、また、ホスト名毎に、あらかじめ用意しておいたSSL証明書を読み込むようにして、httpsでもアクセスできるようにしています。 (ルート証明書はtasterコンテナに含めています)

また、クライアントからのリクエストにX-Test-Client-IPというヘッダをつけることで、任意のIPからリバースプロキシにリクエストが投げられたように見せかけるようにもしています。

この機能のため、ngx_http_realip_moduleを読み込む箇所のItamaeレシピでは、テスト用コンテナへの適用時にset_real_ip_fromのレンジを広げるようにしています。

real_ip_header X-Forwarded-For;
set_real_ip_from xx.xx.xx.xx/xx;
<%- if node[:rspec] -%>
set_real_ip_from 0.0.0.0/0;
<%- end -%>
real_ip_recursive on;

rproxyコンテナ(rproxy/

rproxyコンテナは、テスト対象となるリバースプロキシのコンテナです。前述のItamaeレシピの適用を行ったNginxサーバが動作します。 cookpad.com用のdocker-compose.ymlはこのディレクトリに置いて、cookpad.com関連以外のコンテナの定義が含まれないようにしました。

rproxyコンテナのNginxの設定はItamaeレシピで適用されるため、実際のサーバと同じですが、HAProxyの設定だけはItamaeレシピのものを使わずに、テスト専用に用意しています。

listen www
  bind :8080
  mode http
  balance roundrobin
  http-request set-header X-Via-Haproxy 'localhost:www:8080'
  server app-server-001 backend:80 check inter 5s fall 3

この設定により、localhost:8080へのアクセスはbackendコンテナに投げられるようになります。

internal-serviceコンテナ(internal_service_proxy

internal-serviceコンテナは、HAProxyを経由しないキャッシュサーバへのリクエストを受け付けるコンテナです。基本的にはelbコンテナと同じような構成で、SSL Terminationの機能が除かれています。

Nginxの設定は以下のようなテンプレートで、elbコンテナと同様に環境変数 SERVERSserverproxy_passを定義しています。

{{ range $server_name, $backend := var "SERVERS" | split "," | splitkv ":" }}
server {
  listen 80;
  server_name {{ $server_name }};

  location / {
    set $custom_x_forwarded_for $proxy_add_x_forwarded_for;

    if ($http_x_test_client_ip) {
      set $custom_x_forwarded_for $http_x_test_client_ip;
    }

    proxy_set_header X-Dest {{ $server_name }};
    proxy_pass http://{{ $backend }};
  }
}
{{ end }}

また、aliases設定を使って、プロダクションサーバのホスト名そのままで、internal-serviceコンテナにアクセスできるようにしています

backendコンテナ(backend/

backendコンテナは、各種サーバを偽装するダミーサーバで、Webrickで書いています。

#!/usr/bin/env rubyrequire'webrick'require'json'require'mime/types'URI_ATTRS = %w(  scheme  userinfo  host  port  registry  path  opaque  query  fragment)

server = WEBrick::HTTPServer.new(Port: 80)
trap(:INT){ server.shutdown }

server.mount_proc('/') do |req, res|
  uri = req.request_uri

  res.body = JSON.pretty_generate({
    request_method: req.request_method,
    uri: URI_ATTRS.map {|k| [k, uri.send(k)] }.to_h,
    header: req.header,
    body: req.body,
  })

  res.keep_alive = false

  mime_type = MIME::Types.type_for(uri.path).first
  res.content_type = mime_type.to_s if mime_type

  x_test_set_cookie = req['x-test-set-cookie']
  res['set-cookie'] = x_test_set_cookie if x_test_set_cookie

  x_test_status = req['x-test-status']
  res.status = x_test_status.to_i if x_test_status
end

server.start

基本的にリクエストの情報をJSONで返すだけのサーバですが、X-Test-...というリクエストヘッダが来た場合に、任意のCookieやステータスコードを返せるようにして、異常系などのテストパターンに対応しています。

tasterコンテナ(taster/

tasterコンテナは、Infratasterを実行するコンテナです。 Infratasterと、rproxy/配下のspecを含むようにしています。

RSpec

リバースプロキシをテストするためのspecファイルは以下のようになります。

# spec_helper.rbrequire'infrataster/rspec'%w(  cookpad.com  xxx.cookpad.com).each do |server|
  Infrataster::Server.define(server, server)

  RSpec.shared_examples "#{server} normal response"do
    it 'returns 200'do
      expect(response.status).to eq(200)
    end

    it "accesses #{server}"do
      expect(request_uri.fetch('host')).to eq(server)
    endendend

 

# cookpad_spec.rb
describe server('cookpad.com') do
  let(:body_as_json) { JSON.parse(response.body) }
  let(:request_uri) { body_as_json.fetch('uri') }

  describe 'normal'do
    describe http('https://cookpad.com') do
      it_behaves_like 'cookpad.com normal response'
      it_behaves_like 'https'

      it "doesn't cache"do
        expect(response.headers).not_to have_key('cache-control')
      endendend

  describe 'error pages'do
      describe http('https://cookpad.com/error', headers: {'X-Test-Status' => 500}) do
        it "return front 500 page"do
          expect(response.status).to eq(status)
          expect(response.body.strip).to eq("fw_errors/500.html")
        endendendend
end

aliases設定を使ったことで、コンテナのIPなどを意識することなくテストを記述することができます。

RSpecはdocker-composeをつかって以下のように実行します。

docker-compose run taster rspec -I. -r spec_helper .

まとめ

今回の作業により、煩雑だった設定ファイルの見通しがよくなり、設定の追加などが大分楽になりました。 また、テストまわりの改善したことで、テスト環境の構築に時間がかかったり、原因不明でテストがコケるようなことがなくなり、テストに付随するyak shavingも減らせました。 以前の「設定を変更する→テストがめんどくさい→テストをサボる/設定の追加を諦める」というような負のスパイラルをうまく断ち切れた気がします。

ちょっと実行したテストの結果がFFFFFFFFFFFFFFF...になっていると、精神に大変なダメージを受けるので、未来の自分のメンタルヘルスを保つために、今後も改善を続けていきたいところです。

*1:link_local_ipsはCompose file version 3でサポートされなくなりました

*2:テンプレートを使うためにEntrykitを利用しています

Cookpad Tech Kitchen #14 〜海外で働く〜 開催報告

$
0
0

Cookpad UKに出向中の西山(@yuseinishiyama)です。

去る2月16日、弊社で定期開催しているCookpad Tech Kitchenの一環として、海外事業をテーマとしたイベント『海外で働く』を開催しました。私も一時帰国して登壇しましたので、その内容をここで紹介させてください。

f:id:yuseinishiyama:20180301010500j:plain

f:id:yuseinishiyama:20180301010811j:plain

Introduction

まず最初に、Engineering ManagerのLeonard Chin(@l15n)から海外事業全体の概要説明がありました。

  • そもそもなぜ海外でやるのか
  • なにを目標としているのか
  • 日本のCookpadとはどういう関係性なのか
  • どういう組織体制なのか

などをカバーする内容で、詳細を以降の登壇者が埋めていきます。

Working at Cookpad UK

次に、私のほうからCookpad UKで働くことをテーマとした発表をしました。

海外オフィスがどこにあるか、そこでどんなメンバーが働いているのか、どれくらいの英語力が求められるのか、という点について触れています。

Workflow and development in globally distributed mobile teams

Data AnalysisチームのPaweł Rusin(@RusinPaw)からは海外事業部のワークフローについての説明がありました。

クックパッドの海外事業には様々なメンバーが複数のタイムゾーンからコミットしています。チームの多様性は、サービスの国際化という観点では非常に大きなメリットがありますが、一方で多くのコミュニケーションの問題を引き起こします。この発表では、我々がどのようなマインドセット、ルール、ツールを用いてこうした問題に対処しているかが言及されています。

20言語以上に対応している検索システムが楽しくない訳がない

次に、同じく一時帰国した滝口(@rejasupotaro)から、検索システムの話がありました。

様々な具体例を用いて、複数の言語をサポートし、かつ、個々の地域に合わせた細かなチューニングを行うことの難しさ(と楽しさ)が説明されています。

Architecting for Experiments at Cookpad Global

次に、iOSエンジニアのChristopher Trott(@twocentstudio)からプロトタイピングについての話がありました。

クックパッドの海外事業はプロダクトとしてはまだまだ初期のフェーズですが、一方で既に多くのユーザーが世界中にいて、日本で安定した収益源があるという点では、単なるスタートアップとは異なります。こうしたユニークな状況下で、既存のユーザーに悪影響を与えずに、新しい機能を試すためには多くの課題があります。

おわりに

クックパッドが海外事業をやっていることをなんとなく耳に挟んだことがあっても、その詳細についてご存知の方はほとんどいらっしゃらないのではないでしょうか。このイベントを通じて、皆さんにクックパッドの海外事業に興味を持っていただき、そこでどんなことが行われているのかについて、より具体的なイメージを持っていただけたなら幸いです。

イベントに参加していただいて、もしくは、この記事をご覧になってUKオフィスで働くことに興味を持ってくださった方は、ぜひ以下のリンクからご応募ください。

info.cookpad.com

クックパッドでは今後も様々なテーマで継続的にイベントを開催していく予定です。開催予定のイベントの詳細は以下のリンクからチェックできます。ご興味をお持ちいただけましたら、お気軽にお越しください。

クックパッド株式会社 - connpass

最後に最近ドローンで撮影したUKオフィスでの昼食時の写真を掲載して、この記事を締めたいと思います。

f:id:yuseinishiyama:20180305182218p:plain

Swift.Decodable + Int64 / iOS 10 = 要注意

$
0
0

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

Swift 4 で JSON を読み込むための仕組みとして Swift.Decodableが追加されました。

iOS クックパッドアプリでは、 Swift での JSON の読込は以前 Himotokiが使われていましたが、新規コードでは Swift.Decodableが使われています。依存関係を減らすために、 Himotoki を使っているコードが少しずつ Swift.Decodableに移行されています。

ただし、この間、ユーザーの報告で分かったのですが、最近 Himotoki から Swift.Decodableに移行したコード辺りに一部のユーザーにエラーが出ています。 iOS 10 に限りますが。

調査

調べてみた結果、以下のコードでエラーを再現できました。

structMyDecodable:Decodable {
    varid:Int64
}

letstr="{\"id\":1000000000000000070}"letdata= str.data(using: .utf8)!do {
    letdecodable= try JSONDecoder().decode(MyDecodable.self, from:data)
    print("id: \(decodable.id)")
} catch {
    print("error: \(error)")
}

iOS 10 で実行してみると Parsed JSON number <1000000000000000070> does not fit in Int64.というエラーが出ます。 1000000000000000080でも起きますが、 1000000000000000071では起きません。

このエラーって何だろう… Swift がオープンソースなので、コードに grep してみましょう。これっぽい。エラーが発生する条件をもう少し見てみましょう。

guardletnumber= value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else {
    throw DecodingError._typeMismatch(at:self.codingPath, expectation:type, reality:value)
}

letint64= number.int64Value
guard NSNumber(value:int64) == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath:self.codingPath, debugDescription:"Parsed JSON number <\(number)> does not fit in \(type)."))
}

numberNSNumberなのに NSNumber(value: number.int64Value) == numberを満たさない!?

JSON の解読は実は JSONDecoderが Foundation の JSONSerializationを使っているので、 JSONSerializationを直接使ってみましょう。

letstr="{\"id\":1000000000000000070}"letdata= str.data(using: .utf8)!letjsonObject= try! JSONSerialization.jsonObject(with:data) as! [NSString:Any]
letnumber= jsonObject["id"] as! NSNumber
print("number: \(number)")
print("type: \(type(of: number))")
print("comparison: \(NSNumber(value: number.int64Value) == number)")

iOS 10 で実行してみた結果は以下の通りです。

number: 1000000000000000070
type: NSDecimalNumber
comparison: false

iOS 11 では以下のように表示されます。

number: 1000000000000000070
type: __NSCFNumber
comparison: true

結果がかなり違いますね。 iOS 10 でもっと小さい数字を使ってみると、 iOS 11 と同じ結果になります。

number: 10000070
type: __NSCFNumber
comparison: true

__NSCFNumberというクラス名は不思議に見えるかもしれませんが、一番見かける NSNumberのサブクラスです。 type(of: NSNumber(value: 1))__NSCFNumberです。

iOS 11 で JSONSerializationが数字に使っているクラスの条件が変わったようですね。実際 iOS 11 でも、 64-bit に入りきらない大きい数字だと NSDecimalNumberになります。

解決方法

では、原因があの NSDecimalNumberにあるのは分かりましたが、問題はどう解決すればいいのでしょうか。

iOS 10 の JSONSerializationは流石に直せません。

NSDecimalNumberと遊んでみると挙動が分かりにくいところがありますが、上記の大きい数字でも NSDecimalNumber(value: int64) == numberが満たされるので、 Swift 本体は条件を以下のにすれば直りそうです。

letint64= number.int64Value
letrecreatedNumber= number is NSDecimalNumber ? NSDecimalNumber(value:int64) :NSNumber(value:int64)
guard recreatedNumber == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath:self.codingPath, debugDescription:"Parsed JSON number <\(number)> does not fit in \(type)."))
}

iOS クックパッドアプリはどうしたかと言いますと、Swift.Decodableを使っていて、サーバーからとても大きい ID が来そうな箇所だけを Himotoki に戻すことにしました。 iOS 10 対応をやめたら、再度 Swift.Decodableに戻す予定です。

まとめ

iOS 10 にまだ対応しているアプリは Swift.Decodableを準拠している classstruct内に Int64を使っている場合、要注意です。一部のとても大きい数字では読込中にエラーが起きる可能性があります。その場合、すぐできる対応は対象の classstructSwift.Decodableを使うのをやめる必要あるかもしれません。

バグを報告したので、修正が行われたら追記します。

firebase.yebisu #2 の開催報告

$
0
0

こんにちは。事業開発部で新規事業に取り組んでいる高田です。

Cookpad の新規事業と Firebaseでもご紹介したとおりクックパッドでは Firebase を活用しはじめています。そのような流れもあり2018年2月20日に Firebase.yebisu #2を開催しましたのでご報告いたします。

クックパッドからは3名が発表し、LT枠として3名社外のかたに発表していただきました。

発表

Firebase Cloud Messaging 入門編 by 三浦

Komerco 事業部の三浦から Firebase Cloud Messaging (以下 FCM) についての発表です。

通知対象を柔軟に指定できる Topic 機能などについてデモを通しての説明がありました。また FCM を使用する際に毎回実装する処理をまとめたライブラリ Tsuchiの紹介がありました。

speakerdeck.com

料理ショートライブアプリ Cookin'の開発 by 森川

投稿開発部の森川からは新規事業で Firebase を検討し採用するまでの経緯や、どのように Firebase を利用してサービス開発をしたかの話などがありました。

新しく Firebase を検討している人には参考になる話だったのではないかと思います。

speakerdeck.com

実践 Cloud Functions for Firebase by 星川

Komerco 事業部の星川からは在庫管理や決済処理で Cloud Functions を利用して得た知見を元に実践的な話がありました。トリガーイベントの多重起動対策やデプロイの話など参考になる話があったのではないかと思います。

speakerdeck.com

発表のなかで紹介のあったライブラリは次の通りです。

Firestore rules tips by 岸本

Komerco 事業部の岸本は当日インフルエンザにより発表できなかったため、発表予定内容を以下で共有させていただきました。 qiita.com

LT枠

LT枠では3人のかたに発表していただきました。

Firebase関連をCIでデプロイするときのTips by yamacraft さん

speakerdeck.com

スマートなcronを考案した by Yatima さん

speakerdeck.com

Firebase Auth with GAE & Cloud Endpoints by take_e10 さん

www.slideshare.net

まとめ

いずれの発表も Firebase 利用者の現場の知見があり参考になったという声を多くお聞きしました。

クックパッドでは引き続きエンジニアを募集していますので、Firebase を利用した開発や新規事業開発に興味あるかたは採用ページを是非ご覧ください。ご連絡をお待ちしております。


Google Play アプリ内定期購入を実装する

$
0
0

技術部モバイル基盤グループの宇津(@)です。

今年3月、クックパッドのAndroidアプリはこれまでサポートしていたクレジットカード、キャリア決済に加えて、Google Playアプリ内定期購入によるプレミアムサービス登録機能を追加しました。

f:id:himeatball:20180313181401p:plain

Google Playのアプリ内定期購入機能は個人的に気に入っているので早速乗り換えました。

良い機会なので、この記事ではIn-app Billing version 3(以下IABv3)以降のアプリ内課金の実装を振り返りつつ、Google Play Billing Libraryの紹介も交えながら開発TIPSを紹介します。

TIPS1. Google Play Billing Libraryの採用

IABv3以降のアプリ内課金を実施するためには(ざっくりですが)以下のフローを実装する必要がありました。

  1. ServiceConnectionを生成し、 IInAppBillingServiceにバインドする
  2. 購入可能な商品情報を IInAppBillingServiceから取得する
  3. 購入する商品のパラメータを含む Bundle (以下 BuyIntentBundle )を IInAppBillingService経由で生成する
  4. BuyIntentBundleに含まれる PendingIntentを取得する
  5. PendingIntentを用いて Activity#startIntentSenderForResultを呼び出し、Google Play決済画面を立ち上げる
  6. Google Play決済画面上での精算結果は Activity#onActivityResultにて返却される
  7. 返却された精算結果を元に(レシート検証等も実施しつつ)よしなに処理する

より詳細なフローは公式ドキュメントを参照ください。これに加えて、IabHelper等のサンプル実装クラスが提供されておりこれらを元に各位アプリ内課金を実装していたと思いますが、その内容もそこそこにAndroid開発経験がないと難しい内容となっており、ただでさえ難易度の高い決済周りの実装にさらに多くのドメイン知識を求められるものとなっていました。

さらに、素直にこれを実装した場合、処理フロー5,6 がある以上、既存の画面表示処理にアプリ内課金処理実装が混ざってしまう事になってしまいます。 決済が絡んでくる箇所においてはメンテナンス容易性の側面から避けたい所です。 その対策として、購入用のActivityを1つ用意し、そちらに処理フロー5, 6を移譲する、といった工夫がされてきたと思います。

2017年9月、GoogleからGoogle Play Billing Libraryがリリースされました。

このライブラリは前述の購入処理の分離がなされており、比較的アプリ内課金処理の分離がしやすい状況になりました。 私自身、 IInAppBillingServiceを直接取り扱う事から卒業したい思いがあり、こちらのライブラリを利用する事にしました。

ライブラリ自体にこれといった問題もなく使えているのですが、いくつか懸念点がありました。そちらについてはTIPS2, 3にて紹介します。

TIPS2. Developer Payload非推奨に対する対処

Developer PayloadとはIABv3時代からある、「購入レシートの中に含める事のできる文字列フィールド」を指します。

多くの開発者はこのフィールドに、サーバ上で発行した購入トランザクションに対して一意な識別子を入れる事で、Google Playアプリ内課金に対する問い合わせ対応等に役立てていたかと思います。

しかし、このフィールドは現在非推奨となっており、Google Play Billing LibraryにおいてもDeveloper Payloadフィールドを指定する事が出来ません。

クックパッドのAndroidアプリでは、購入/復元(ユーザ様のアプリ内定期購入の購入情報を元にプレミアムサービス登録を再開する)時に都度識別子をサーバ上で発行し、レシート検証が必要なタイミングで復元するレシートとセットで送信するようにして代替しています。

こうする事で、Developer Payloadが存在していた時代では購入時に一度だけサーバ上で発行すれば良かった識別子が、無駄に発行されてしまう事が懸念されますが、これは許容する方向に倒しています。

TIPS3. ラッパーライブラリによるドメイン知識のさらなる吸収

アプリ内定期購入に限らずですが、決済処理というのは想定以上に実装が複雑になりがちです。 クックパッドのようにプレミアムサービスという商品を1つだけ取り扱うにしても、自サービス内のアカウント種別に加え、決済手段も複数あり、それらの整合性を取りながら決済処理を実装しなければならないとなると サーバサイドはもちろんの事、アプリ単体でも中々に複雑な実装になる事が予想できます。

その為、Google Play Billing Libraryでも吸収できていない、アプリ内課金処理固有のドメイン知識をもっと吸収して、利用者側のソースコードのメンテナンス容易性を上げたい気持ちがありました。 そこで tsuruhashiという名のラッパーライブラリを(社内向けに)開発し、クックパッドのAndroidアプリではこれを使用しています。

tsuruhashiでは以下の3点をうまく吸収し、関心事を分離しています。

TIPS3-1. BillingClientをいい感じにpoolingして ServiceConnection を意識させない

BillingClientはGoogle Play Billing Libraryに含まれる、 IInAppBillingServiceとのやり取りや決済Activityの起動を行うクラスです。

BillingClientはいつ ServiceConnectionが切断されるか分からないので、利用者側でそのライフサイクルをしっかり管理する前提の実装にするのが無難です。 でも、そもそもこういった事はそもそも意識したくないですね。

tsuruhashiでは BillingClientインスタンスの生成を常に1つに抑えるようpoolingを行い、 ServiceConnectionが切断されたら自動的にpoolから破棄するような機構を設けています。

ついでではありますが、 BillingClientは 内部的に Handlerインスタンスを生成する為にメインスレッドでの生成が必須ですが、これも意識したくないのでライブラリ側で吸収しています。

TIPS3-2. BillingClient の全 interface を Rx friendly にして記述しやすく

クックパッドのAndroidアプリではRxJavaが導入されているので、アプリ内定期購入においてもRxなinterfaceで実装したい需要がありました。 BillingClientそのままの状態でもRx化は可能ですが、1つのメソッドだけ実現方法に悩むかもしれません。

それは BillingClient#launchBillingFlowです。

このメソッドは返却値としては決済Activityの起動等に成功したか否かが返却され、実際の購入結果は BillingClientの生成時に引数として渡した PurchasesUpdatedListenerに渡されます。 なので、メソッドの呼び出し元と購入結果の受け取り口が離れてしまいます。

tsuruhashiでは以下のように対応しました。まず、 PurchasesUpdatedListenerを実装します。

class CompositePurchasesUpdatedListener : PurchasesUpdatedListener {
    privateval listeners: MutableList<PurchasesUpdatedListener> = mutableListOf()
    privateval lock:ReentrantReadWriteLock = ReentrantReadWriteLock()

    fun add(listener: PurchasesUpdatedListener): () ->Unit {
        lock.write {
            if (!listeners.contains(listener)) {
                listeners.add(listener)
            } }
        return { lock.write { listeners.remove(listener) } }
    }

    fun remove(listener: PurchasesUpdatedListener): Boolean = lock.write { listeners.remove(listener) }

    fun clear() = lock.write { listeners.clear() }

    overridefun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) {
        val list = lock.read { listeners.toList() }
        list.forEach { it.onPurchasesUpdated(responseCode, purchases) }
    }
}

これを BillingClient生成時に listener として登録します。

val listener = CompositePurchasesUpdatedListener()
val client = BillingClient
        .newBuilder(context)
        .setListener(listener)
        .build()
val wrapper = BillingClientWrapperImpl(client, listener)

wrapper内での BillingClient#launchBillingflowの呼び出しは以下のように行います。(説明の為、一部省略しています)

var removeListenerFunc:(() ->Unit)? = null
removeListenerFunc = compositeListener.add(PurchasesUpdatedListener { responseCode, purchases ->if (/* 長いので省略 `PurchaseUpdatedListener#onPurchasesUpdated` がこのブロック内で捌ける時にlistenerを破棄 */) {
        removeListenerFunc?.invoke()
    }

    when (responseCode) {
        BillingResponse.OK -> {
            if (purchases.any { it.sku == params.sku }) {
                /* 省略 */
            }
        }
        /* 省略 */
    }
})

val launchResponseCode = client.launchBillingFlow(activity, params)
if (launchResponseCode != BillingResponse.OK) {

    // BillingClient#launchBillingFlowに失敗したらその時点でlistenerを破棄
    removeListenerFunc?.invoke()
    when (launchResponseCode) {
       /* 省略 */
    }
}

Rxな表現でいえばSubject(hot-observable)を内包する形ですね。 このような呼び出し方にする事で、非Rxの世界ではcallback interfaceで、Rxの世界においてもRx friendlyなinterfaceで購入結果を取り扱えるようになっています。

fun launchBillingFlow(activity: Activity, params: BillingFlowParams, listener:LaunchBillingFlowListener) // 非Rxfun launchBillingFlow(activity: Activity, params: BillingFlowParams): Single<BillingResult>              // Rx

TIPS3-3. 汎用的に必要となるinterfaceを追加して利用者側のコードの理解可読性の向上を図る

アプリ内定期購入の実装においては多くの場合、以下の2つのFeatureTypeに対応しているかを確認します。

  • FeatureType.SUBSCRIPTIONS
  • FeatureType.SUBSCRIPTIONS_UPDATE

そうなった際に FeatureTypeをまとめて確認できないと流石に利用者側のコードが冗長になってしまうので、複数の FeatureTypeをまとめて確認できるようなinterfaceを追加しています。

fun verifyFeaturesSupported(features: List<String>, listener: VerifyFeaturesSupportedResponseListener) // 非Rx fun verifyFeaturesSupported(features: List<String>): Completable                                       // Rx

上記のような、汎用的に必要となる機能についてはライブラリ機能として提供し、利用者側のソースコードの冗長化を抑止しています。

TIPS4. レシート情報は多少過剰にでもサーバサイドで保存する

決済系の格言として「まずは保存」というのがあります。

サーバサイド寄りの話になりますが、アプリからレシートを送信するようなエンドポイントを用意する場合、まず第一にアプリから送信されたレシートをDBに保存し、その後レシート検証や定期購入の開始処理といったエンドポイントの本来求められている処理を実施するようにしています。

これは、レシート検証や定期購入の開始処理に万が一不具合がありレシートがDB上に保存されず、その状態でユーザ様から問い合わせがあった場合、DB上にはレシートが存在しないため、どうしても煩わしいやり取りが発生してしまい、ユーザ様に悪い印象を与えてしまう恐れがある為です。

レシートの保存さえ出来てしまえば、Google Play Developer APIを利用する事で、ユーザ様の問い合わせに対して購入のキャンセルや払い戻し対応も可能になるので、この格言に習う事に越した事はありません。

TIPS5. 購入トランザクション上のログを細かく取る

決済系に限らず「まずはログ」という所で、TIPS4に関連して、ユーザ様が商品の購入に失敗した際、どの処理中に購入に失敗してしまったのかを追跡可能な状態にする必要があります。

クックパッドのAndroidアプリでは、よりユーザ様の購入状況が追跡できるよう、購入/復元時に実施する処理のログをサーバログとは別にアプリから送信・集計し、 問い合わせがあったユーザ様に対し適切にサポート対応が実施できるよう環境を整えています。

まとめ

クックパッドのAndroidアプリでは、Google Playアプリ内課金実装に対するドメイン知識をラッパーライブラリ上でいい感じに吸収した甲斐もあって、アプリケーション上の実装はクックパッドのプレミアムサービスのドメイン知識のみで満たされた実装に仕上がっています。[TIPS1, 3]

Developer Payloadは非推奨になってしまいましたが、それでもなんとかうまくやっています。[TIPS2]

ユーザ様に対し適切にサポート対応が実施できるよう、レシートの取扱い方とログ集計を中心に環境を整備しています。[TIPS4, 5]

今後の展望としては、まずはTIPS3で紹介した開発した社内ライブラリをオープンソース化したいという所と、まだまだ運用面を見据えて改善したい箇所が沢山あります。例えば、

  • テスト購入の為のAndroid端末上の設定の自動化
  • 購入/復元処理のE2Eな自動テスト
  • 決済処理実装に対する敷居を下げる為のあれこれ

といった事があげられます。 今後も大きな進歩があればtechlifeで報告していきます。

最後に、クックパッドではより良いサービスを提供し続ける為にエンジニアを募集しています。 もしこの記事を読んで興味を持たれたAndroidエンジニアの方、あるいは決済処理に熱い思いのあるエンジニアの方いらっしゃいましたら、是非遊びに来てください。 ご連絡をお待ちしています!

クックパッド株式会社 採用情報

巨大なWEBアプリケーションに巨大な変更を取り入れるためにやったこと

$
0
0

会員事業部ユーザー基盤チームエンジニアの井口(@iguchi1124)です。

ユーザー基盤チームでは、クックパッドのサービス開発者のあらゆる要望に答え続けられるような『柔軟でいい感じのユーザー基盤』を目指し、サービス開発者およびユーザーさんの課題と向き合いながら日々開発を進めています。

第一弾として、普段の開発の様子や一部のユーザーさんに向けてユーザー登録機能をリリースするまでの話も公開されていますので是非そちらもご覧いただければと思います。

今回は、上述の記事にも触れられているようにクックパッドでユーザーさんのアカウント登録や認証情報として電話番号を利用できるようになりましたので、そのためにやってきたことの一部をご紹介したいと思います。

一口に電話番号を利用出来るようになったと言うと簡単そうに聞こえますが実際にはそうでもありません。

クックパッドではこれまで連絡先情報あるいはアカウントの認証に必要な情報としてメールアドレスを使うという前提で長い期間に渡る開発が積み重なってきました。

その状況から、電話番号をメールアドレスと同等に連絡先情報やアカウントの認証に必要な情報として利用するには多くの技術的負債の返却や機能追加が必要になります。

また、ユーザーさんに与える影響を考えると、ユーザーさんに迷惑をかけないようにリリースする順序を考慮したり、関連するサービスでデプロイするタイミングを合わせることも必要になったりします。

このような巨大な変更を取り入れようとしている最中も、並行してクックパッドのサービス開発は継続的に行われています。様々な施策を止めるわけにはいきません。

サービスを「ユーザーさんが一通り触れる」単位で分割する

ユーザー基盤チームでは、ユーザー登録およびログインに関わるサービス内の一連の動きを垂直に分割した、社内ではShishamo(ししゃも)と呼ばれるマイクロサービスに分離することで開発を加速させています。

サービスを分割することで実際に得られた利点は次のようなものです。

  • チームの外の開発者達にコードの変更による影響を与えたり、受けたりしない
  • 自動テストを高速に実行できる
  • 新しいアーキテクチャを素早く取り入れることができる
    • Dockerを利用したナウいデプロイフローを取り入れる
    • Webpackerを利用してナウいjavascript開発環境を取り入れる
      • React.js を導入して再利用性の高いプレゼンテーショナルコンポーネントを設計してみる
    • 電話番号パーサーを導入してみる

また、サービスを水平に分割し地層を積み上げるのではなく垂直に分割することで、新しい技術要素を取り入れる場面では早い段階で技術スタックを試し、技術的に実現可能であることの裏取りができます。

巨大な開発ブランチを作らない

提供したい機能の内容によっては、それぞれの機能の依存関係から変更を同時にリリースしなければならないことがあります。

そういった場合、開発ブランチを作り水面下で作業をすすめ、変更内容が揃った段階でマージし、リリースするということになるかと思います。

しかし、それではリリースするためには負担が開発者だけに留まらず広がってしまうことが想像できます。

  • 開発者の負担
    • 他の開発者との変更の衝突
    • 他の開発者や自分の変更の影響による予想外のバグの発生
  • コードレビューにかかる負担
    • 「よさそうだけど自信がない」、「自信がないけどLGTM」の発生
  • リリース前の動作確認、リリース後の監視にかかる負担
    • テストする必要があるパターン数の肥大化
    • 動作確認の結果おかしそうな動きを直したら別の挙動がおかしくなったので再修正、再確認、再修正、再確認

ユーザー基盤チームでは、Cookieベースのfeature flagを導入することで、この問題の解消に取り組みました。

これによって、ユーザーさんには機能を提供しないサイレントリリースとスタッフによる動作確認を可能にし、最小単位での変更のマージ、デプロイとテストを繰り返すことができました。

実際に、最後のユーザーさんに届けるステップに入る頃には十分にテストされたサービスのうちの、if分岐を取り除く程度のものにできます。

非常に簡単な仕組みではありますがOSSとして公開しています。

https://github.com/iguchi1124/cookie_flag

以下に電話番号でユーザー登録する機能をリリースするために実際に運用した例を紹介します。

ログイン機能の実装の中で、電話番号によるユーザー登録機能がリリースされているときの動きを実装する場合

classSessionsController< ApplicationControllerdefcreateif feature_available?(:phone_number_registration)
      # 電話番号またはメールアドレスとパスワードを利用したユーザー認証処理else# メールアドレスとパスワードを利用したユーザー認証処理endendend

電話番号によるユーザー登録機能がリリースされると表示されるリンク

<% if feature_available?(:phone_number_registration) %>
  <%= link_to "電話番号でユーザー登録する", new_phone_number_registrations_path %>
<% end %>

「電話番号によるユーザー登録をしようとしたこと」リソースにfeature flagを適用したい場合

classPhoneNumberRegistrationsController< ApplicationController
  feature :phone_number_registrationdefnewenddefcreateendend

動作確認をするときは feature_available?(:phone_number_registration)が真になるように手動でブラウザのクッキーを設定することで一般のユーザーさんが利用する前に社内のスタッフが機能を試せるようになります。

まとめ

この記事では継続的にサービス開発が行われているクックパッドで巨大な変更を入れるためにやったことのうち、以下の2つのことを紹介しました。

  • 垂直分割によるサービス開発の効率化
  • フィーチャーフラグ導入によるリリースにかかる負担の改善

ユーザー基盤チームでは大きなサービスの基盤を再構築するにあたり、イテレティブかつインクリメンタルに価値を届けることを心がけながら周囲の開発者と協力してサービスの改善に取り組んでいます。

まだまだ失敗も多い道半ばですが、今後もユーザーさんや、となりで働く開発者、そして一緒にサービスを運営している全員にとってよいものである基盤づくりをしていけたらと思います。

TLS証明書の発行・デプロイについて

$
0
0

こんにちは、インフラストラクチャー部セキュリティグループの三戸 (@mittyorz) です。 クックパッドでは全てのサービスをHTTPSにて提供しています。 今回はHTTPSの使用にあたって必要となるTLS証明書について、申請や発行、管理やサーバへのデプロイなどの運用について書きたいと思います。

TLS終端

クックパッドでは、サービスとユーザーとの通信経路は全てTLSにより暗号化されていますが、通信内容を暗号化するためのTLS終端処理はELBあるいはCloudFrontで行っています。 ELB、CloudFrontともにAWS Certificate Manager(ACM)を用いて証明書を管理*1することが出来ますが、社内向けで外部に公開していないサービスやステージング環境についてはELB背後のリバースプロキシで終端処理をしているものも多く、これらについては証明書ファイルを直接EC2インスタンスへ配置する必要があります。

なお、クックパッドのHTTPS化については Web サービスの完全 HTTPS 化を御覧ください。

証明書の種類

TLS証明書には、ドメインの所有者について認証局が実在照会を厳格に行ったのちに発行される、Extended Validation(EV)証明書があります。 EV証明書を用いることで、ブラウザのアドレスバーにはそのドメインの所有者の情報が表示され、ユーザーにとって意図したサイトに接続しているかどうかがわかりやすくなります。 クックパッドでは、PCあるいはスマートフォン向けブラウザからユーザーが直接アクセスするページについては、原則EV証明書を設定するようにしています。

なお、EV証明書ではない証明書には、ドメインの所有者であることを確認して発行されるDomain Validation(DV)証明書と、所有者の実在照会まで行うOrganization Validation(OV)証明書が存在します。 OV証明書とEV証明書はいずれも実在照会が行われますが、CA/Browser Forumによって定められたガイドライン*2に従って発行されたものだけがEV証明書となります。

証明書の発行

新規サービスの立ち上げなどで新しいドメインを使用する場合、まずはACMを用いてDV証明書を発行し、APIエンドポイント用のドメインなどを除いて順次EV証明書を配置しています。 以前はドメインごとにEV証明書を一つ一つ購入していたためコストも無視できなかったのですが、後述するマルチドメイン証明書を用いることで年100ドルほどで追加購入できるようになりました。 また、常にEV証明書を設定するというわけでもなく、URLの変更などで使用しなくなりリダイレクトのみ行うドメインについてはEV証明書をやめてACMの証明書に切り替える、ということも行っています。

EV証明書の発行はACMでは行えないため、ACMで用いる場合別途認証局から購入しインポートする必要があります。 またACMから秘密鍵を取り出すことも出来ないため、EC2インスタンスで直接TLS終端している場合も同様に購入しています。

認証局の選定

クックパッドでは、現在はDigiCertから証明書を購入しています。 使いやすいWebコンソールが存在していることや、WebコンソールへのログインがSAMLによるシングルサインオンに対応していることが選定理由ですが、 後述するSANに対応したEV証明書の発行に対応していることやAPIが用意されていることもポイントとして挙げられるかと思います。 また、脆弱性の発生時など特に迅速な対応が必要な場合でも、認証局から直接のサポートが受けられるというのもあります。

証明書のデプロイ

Classic Load Balancer(CLB)の設定にはkelbimを、ECSと組み合わせて用いるApplication Load Balancer(ALB)の設定にはHakoを用いており、 それぞれACMに用意した証明書をARNを使って指定することが出来るようになっています。 CLBは主に社内向けのステージング環境や、Hako化がまだなされていないサービスにおいて使用されています。 最近リリースされたサービスは基本的にHakoを用いてデプロイ出来るようになっているので、以下のようなフローで証明書の設定を行っています。

  1. 証明書の発行の依頼がサービス開発チームからSREチームに来る
  2. EV証明書が必要と判断された場合は認証局へ発行を申請する
  3. ACMへ証明書をインポート、もしくはACMで証明書を発行する
  4. 証明書のARNをサービス開発チームに通知し、Hakoの定義ファイルに記載する
  5. Hakoを用いてデプロイ。ALBに証明書が設定される

Hako自体の説明はここではいたしませんが、定義ファイルでの証明書の指定の仕方はサンプルなどが参考になるでしょう*3

他、設定ファイルや証明書をサーバに直接配置する必要がある場合は、証明書や中間証明書はGitリポジトリに含めておき、itamaeを用いてデプロイしています。秘密鍵はそのままリポジトリに入れるのではなく、変数を用いてデプロイ時に展開されるようになっています。

証明書の有効期限の監視

TLS証明書には有効期限が存在します。 有効期限が切れる前に新しい証明書に更新する必要がありますが、有効期限は1年以上となっていることが多く「忘れた頃に有効期限が来る」ということが起きます。 認証局によっては、例えば30日前などにメールで通知してくれるところがありますし、ACMの場合は2017年の11月からDNSレコードによりドメインの所有者検証を行い自動更新することが出来ます*4。 EC2インスタンスで直接TLS終端している場合、どのインスタンスで証明書が使用されているのか把握しておく必要がありますが、クックパッドではZabbixを用いて監視しています。 また、一部のドメインについてはStatusCakeも併用しています。

社外のインターネット回線からアクセスした場合とオフィスからアクセスした場合とでエンドポイントが違っていて*5、設定されている証明書が異なるため監視漏れで危うく有効期限切れするところだったということもありました。 また、見落としがちなのがオフィスからのみアクセスできるサーバやアプライアンス製品で、特にワイルドカード証明書は思わぬサブドメインで使われていることもあるので、 Route 53からレコードを取得し、登録されているサブドメインも含め全てのドメインに対してチェックするということも行っています。

EV証明書発行のための実在証明

実在証明と書くとなんだか凄そうですが、手順としてはそれほど複雑ではなく、ざっくりと以下のようなことを行いました。

  1. 組織名(Organization)として商号を登録する
    • この部分がサイトにアクセスした際にアドレスバーに表示されます。
    • あわせて、本社所在地などの情報も登録します。
  2. 組織名と所在地が掲載された公的文書を提出する
  3. 担当者の在籍状況について、電話などで確認が行われる

2 について認証局が日本法人であれば登記簿謄本を提出することで証明出来たのですが、DigiCertはアメリカ合衆国の法人なため、アメリカ合衆国において発行されたものが必要となります*6。 今回はアメリカ証券取引委員会に登録された文章を見つけることが出来たため、比較的すんなりと会社の実在証明を行うことが出来ました。

一方 3 については、公的文書には代表電話番号のみ記載されていたためその番号での対応が必要となり、インフラストラクチャー部の直通番号へ入電を期待していたため何度か掛け直してもらうなど混乱もありました。 詳しい手順は前述のガイドラインにも掲載されていますが、受容可能な手順として法的に有効な文書に記載されている住所、電話番号、メールアドレスなどを用いて担当者の確認を行うこととされているため、 担当者直通など任意の電話番号に掛けてもらうにはその番号が記載された公的文書が必要となり注意が必要です。

フィーチャーフォン対応

国内のフィーチャーフォンがターゲットとなっているモバれぴ*7については特段の配慮が必要になりました。

証明書の認証パスにおいて、ルート証明書は本来その名の通り根本に存在し他のどの証明書にも依存せずに信頼される必要があるため、 OSに付属して提供されたり、ブラウザとあわせてインストールされるなど予め信頼されるようになっています。 しかし、フィーチャーフォンでは出荷後のアップデートなどで新しく証明書を追加することが出来ないことが多く、 プリインストールされているルート証明書自体も種類が少ないということがよくあります。 したがって、古いルート証明書しかサポートしていないフィーチャーフォンにおいては、証明書を切り替えてしまうと認証されずエラーとなる可能性があります。

この問題は、サポートされていないルート証明書を別のサポートされているルート証明書で署名する、クロスルート証明書という仕組みで回避することが出来ます。

DigiCertが発行しているルート証明書は多くの環境でサポートされていますが、フィーチャーフォン向けのBaltimoreのルート証明書によって更に署名されており、 この場合具体的には次のような認証パスになります。

  1. CN=Baltimore CyberTrust Root
  2. DigiCert High Assurance EV Root
  3. CN=DigiCert SHA2 Extended Validation Server CA
  4. CN=m.cookpad.com

フィーチャーフォン以外の殆どの環境では2がルート証明書、 3が中間証明書、4がサーバ証明書になりますが、このケースだと2、3が中間証明書であると言えます。 したがって、ACMに証明書を登録する場合は、以下のように登録することになります。

  • Certificate body に、4の証明書
  • Certificate private key に、4の秘密鍵
  • Certificate chainに に、3の証明書へ2の証明書を結合したもの

実際に opensslコマンドを用いて認証パスを表示すると以下のようになります。

$ openssl s_client -connect m.cookpad.com:443 -quiet
depth=3 C = IE, O = Baltimore, OU = CyberTrust, CN = Baltimore CyberTrust Root
verify return:1
depth=2 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert High Assurance EV Root CA
verify return:1
depth=1 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert SHA2 Extended Validation Server CA
verify return:1
depth=0 businessCategory = Private Organization, jurisdictionC = JP, serialNumber = 0104-01-071872, C = JP, ST = Tokyo, L = Shibuya-ku, O = COOKPAD Inc., OU = Infrastructure Division, CN = m.cookpad.com
verify return:1

マルチドメイン証明書

TLS証明書にはSubject Alternative Names(SAN)という属性をもたせることができ、Common Nameとは別の任意のドメインを追加することが出来ます。 この機能により、一つの証明書で例えば example.comexample.jpのように複数の独立したドメインに対応することが可能になります。 ただし、どんなときでもまとめてしまえば良いという訳でもなく、HTTP/2でサービスを提供している場合はコネクションの再利用に注意する必要があります。 例えば、SANに *.example.comが設定された証明書を用いて配信しているサーバがあったとして、このサーバAは a.example.comのコンテンツは配信しているものの b.example.comのコンテンツは配信しておらず、別のサーバBで配信しているとします。 クライアントが a.example.comのコンテンツを取得するためにサーバAとのコネクションを確立したあと、サーバBに存在する b.example.comの取得についてもサーバAとのコネクションを再利用してしまい、 サーバAからエラーが返されてから*8サーバBに改めてコネクションを確立するため、かえってレイテンシが増えてしまいます。 これはHTTP/1.1でよく見られた、画像やCSS、javascriptファイルを別のドメインから提供することでページ全体のレイテンシを低減している場合*9に起こりやすいと言えるでしょう。

この問題については HTTP/2 のコネクション再利用について確認してみる - ブログのしゅーくりーむに詳しく解説されています。

証明書の発行のためCSRファイルを作成する際、opensslコマンドを用いることが多いと思いますが、マルチドメイン証明書のCSRファイルについてはSANの指定が引数で指定することが出来ません。 設定ファイルのopenssl.cnfに直接記入する必要がありますが、いちいち書き換えるのも面倒なので以下のようなスクリプトで作成しています。

#!/bin/bash

subject="/C=JP/ST=Tokyo/L=Shibuya-ku/O=Cookpad Inc./OU=Infrastructure Division/"


# CN and SAN list
common_name=$1
if [ -z $common_name ]; then
      read -p 'Common name? : ' common_name
fi

sans_file=${common_name}.txt
if [ ! -f "$sans_file" ]; then
    echo $0: "$sans_file" does not exist
    exit 1
fi

# find out where the openssl.cnf is
conf=`openssl version -a | grep OPENSSLDIR | cut -d '"' -f2`
conf=$conf/openssl.cnf

# compose SAN section
sansection=$(cat <(
    echo -n "subjectAltName='DNS:"
    cat $sans_file | perl -pe "chomp if eof" | perl -pe "s/\r?\n$/,DNS:/g"
    echo "'"
))

# display CN and SANs
echo CN: $common_name
echo $sansection

# make csr/key
openssl req -new\
            -newkey rsa:2048\
            -nodes -out ${common_name}-san.csr\
            -keyout ${common_name}-san.key\
            -sha256\
            -subj "${subject}CN=$common_name" \
            -reqexts SAN \
            -config <(cat $conf \
                <(printf "\n[SAN]\n$sansection"))

作成したCSRファイルは例えば以下のようになります。

$ openssl req -text -noout -in cookpad.com-san.csr
Certificate Request:
    Data:
        Version: 0 (0x0)
        Subject: C=JP, ST=Tokyo, L=Shibuya-ku, O=Cookpad Inc., OU=Infrastructure Division, CN=cookpad.com

(中略)

        Requested Extensions:
            X509v3 Subject Alternative Name:
                DNS:info.cookpad.com, DNS:payment.cookpad.com

終わりに

クックパッドでのTLS証明書の運用について紹介しました。 HTTPS化されるインターネットサービスはどんどん増えており、証明書の発行も昔と比べてずっと容易になってきています。 一方で実際に作業してみると、コード管理されていないサーバが見つかって手作業で証明書ファイルを配置したり、認証局と電話でやり取りしたりといったこともありました。 監視対象への追加や、証明書の自動更新などまだ出来ていない部分も多く、これからも改善した点について紹介させていただきたいと思います。

*1:ACMが2016年1月にリリースされるまではIAMを使用していました。

*2:https://cabforum.org/extended-validation/

*3:サンプルでは証明書をIAMで指定していますが、ACMでも同様に指定できます

*4:https://aws.amazon.com/certificate-manager/faqs/#dns_validation

*5:特にステージング環境でよくあるケース

*6:DigiCertの場合 https://www.digicert.com/ssl-certificate-purchase-validation.htmに受容可能な文章について記載されています。

*7:https://m.cookpad.com/

*8:421 Misdirected Request

*9:いわゆるドメインシャーディング

ハッシュ値の使い方について

$
0
0

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

先日以下のツイートを拝見しました。

この変更はSwift 4.1にはまだ入りませんが、4.2か5.0に入るはずです。コードレビューでこの変更が問題を起こそうなコードを指摘したことあるので、ハッシュ値のおさらいをする良いタイミングではないでしょうか。

Swiftのことを考えて書いていますが、多くのプログラミング言語にも当てはまります。ハッシュ値はSwiftではhashValueというプロパティが返しますが、多くの言語では単にhashというメソッド・関数が返します。

ハッシュマップ

ハッシュ値はハッシュマップ(別名ハッシュテーブル)に一番使われるのではないでしょうか。SwiftではDictionary、RubyではHash、C++ではunordered_map、RustではHashMapと呼ばれるものです。

ハッシュマップはマップの一種であって、マップというのはキーに値を結びつけるためのものです。1つのキーに1つの値しか結びつけない場合が多いです(値は配列を使えますが)。例えば漫画の連載開始の年のマップを作ると以下のようになります。

キー
ONE PIECE 1997
DRAGON BALL 1984
青の祓魔師 2009
Levius 2012
宇宙兄弟 2007

ハッシュマップは基本的にキーに順がない場合が多いです。キーが挿入された順で列挙されると保証する実装(例えばRubyのHash)もありますが。

ハッシュマップのキーに一番使われるのは文字列ですが、以下の2つの条件を満たせば何でも使えます。

  • 2つのキーが等しいかどうか比較できる(SwiftではEquatableというプロトコルに準拠すること)
  • キーからハッシュ値を計算できる(SwiftではHashableというプロトコルに準拠すること。比較できないとハッシュ値が使い物にならないのでHashableEquatableに準拠している)

ハッシュマップは別のマップの種類に比べてどういうメリットあるかと言いますと、ハッシュ関数(ハッシュ値を計算する関数)が良ければ、キーが多くても値を早く取得できるところです。

ハッシュ値とそれを生成するハッシュ関数

ハッシュマップに使われるハッシュ値は基本的に32-bitか64-bitの整数です。ハッシュ値を元にキーと値がメモリ上どこに置かれるのか決まります。

ハッシュマップで使うには、ハッシュ値が以下の条件を満たす必要があります。

  • プログラムが終了するまで、ハッシュ関数(ハッシュ値を計算する関数)に同じキーを渡すと必ず同じハッシュ値が返るべき
  • ハッシュ値が違っていれば、ハッシュ関数に渡されたキーが異なるべき
  • 違う2つのキーが同じハッシュ値を持っても良い。可変長の文字列から計算されるハッシュ値が固定長数バイトだけに収まるので、すべてのキーが違うハッシュ値を持つはずがありません。

上記の条件を満たす一番シンプルなハッシュ関数が固定値を返すだけです。それだとハッシュマップは一応動きますが、性能がすごく落ちて、ハッシュマップを使うメリットがなくなります。

ハッシュ値を計算するハッシュ関数なんですが、良いハッシュ関数はハッシュ値の計算が速くて、色んなキーを渡すとできるだけ違うハッシュ値を返してくれた方がハッシュマップの性能が出ます。良いハッシュ関数を作るのはすごく大変なので、既存の研究されたものが使われる場合が多いです。

気を付けるべきところ

ハッシュ値が満たすべき条件に「プログラムが終了するまで、ハッシュ関数に同じキーを渡すと必ず同じ値が返る」と書きましたが、「プログラムが終了するまで」が重要です。プログラムをまた実行すると変わる可能性があります。Rubyで試してみると分かりやすいと思います。

$ ruby -e 'p "abcd".hash'
-2478909447338366169
$ ruby -e 'p "abcd".hash'
3988221876519392566
$ ruby -e 'p "abcd".hash'
-771890369285024305

今までSwiftではプログラムを何回実行しても標準のhashValueが毎回同じハッシュ値を返していましたが、この記事の頭にリンクされていた変更でプログラムが実行される度にハッシュ値が変わるようになります。

どうして変わるようになったのかと言いますと、DoS攻撃のリスクを下がるためです。DoS攻撃というのは簡単にいうとマシンがやるべき処理に追いつけなくなることです。

ハッシュマップに同じハッシュ値を持つキーをたくさん入れると、性能がどんどんと落ちていきます。ハッシュ値を事前に予測できると同じハッシュ値を持つキーを大量用意できます。サーバーがハッシュマップのキーにしそうなもの(例えばリクエストの引数名)に用意された大量のキーを使わせてサーバーがやるべき処理に追いつかなくなります。

ハッシュ値がプログラムの実行ごとに変わると、ハッシュ値の予測がかなり困難になるのでリスクを減らせます。

ハッシュマップ

でもどうして同じハッシュ値が多いとハッシュマップの性能が落ちていくのでしょうか。理解するにはハッシュマップの仕組みをもっと細かく見る必要があります。

ハッシュマップのコアな部分が単なる配列です。配列の項目がバケット(bucket)と呼ばれています。

配列のサイズ(バケット数)に満たすべき条件が特にありませんが、基本的に項目が増えるともっと大きい配列が用意されて、以前の項目を新しい配列に移し替えます(新しい配列でバケットが変わる可能性あるので要注意)。

挿入されるキーと値がどこに入るのかはハッシュ値で決まります。バケット数がハッシュ値の数ほど多いわけではないので、モジュロ(剰余演算)を使ってバケット数以下にします。

lethashValue= key.hashValue
// 負のインデックスだと困るので絶対値を取るletbucketIndex= abs(hashValue) % buckets.count
// ハッシュ値は後でまた計算できるけど、再計算を減らすために入れておく
buckets[bucketIndex] = Bucket(hashValue:hashValue, key:key, value:value)
0 1 2 3 4 5 6 7

ハッシュ値が-3272626601332557488のキー"ONE PIECE"を挿入すると、abs(hashValue) % 80なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997

ハッシュ値が4799462990991072854のキー"青の祓魔師"を挿入すると、abs(hashValue) % 86なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997
"青の祓魔師"

2009

ただし、それだと同じバケットに別のキーが入っていたら代入すると前のキーがなくなります。同じバケットに複数のキーが入るケースを衝突(collision)といいます。

衝突の扱いは様々あります。一番シンプルなのは連結リストや動的配列ですが、例えばキーを次に空いているバケットに入れることもあります。

ハッシュ値が4799462990991072854のキー"宇宙兄弟"を挿入すると、abs(hashValue) % 86なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997
"青の祓魔師"

2009
"宇宙兄弟"

2007

シンプルなハッシュマップを実装してみると以下のようになります。

import Foundation

structSimpleDictionary<Key: Hashable, Value> {
    typealiasHashValue= Int
    structBucketElement {
        varhashValue:HashValue// キーから計算できるけど時間掛かるので残しておくvarkey:Keyvarvalue:Value
    }

    // 連結リストがよく使われるが、実装をもっと分かりやすくするため可変長の配列を使うtypealiasBucket= [BucketElement]
    // 実質配列の配列varbuckets:[Bucket]init() {
        // 分かりやすさのためバケット数を固定にしているが、普段キーが増えるとデータをもっと大きい配列に移し替える// 移し替える時、バケット数が変わってキーのバケットが変わる可能性があるのでハッシュ値を元に新しいバケットを再度計算する必要があるletbucketCount=8
        buckets = Array<Bucket>(repeating:[], count:bucketCount)
    }

    subscript(key:Key) ->Value? {
        get {
            // ハッシュ値でバケットが決まるlethashValue= key.hashValue
            letbucketIndex= abs(hashValue) % buckets.count
            for element in buckets[bucketIndex] {
                // ハッシュ値の比較が早いのでまずハッシュ値を比較しておくif element.hashValue == hashValue && element.key == key {
                    return element.value
                }
            }
            returnnil
        }
        set(newValue) {
            lethashValue= key.hashValue
            letbucketIndex= abs(hashValue) % buckets.count

            // キーが既に使わている場合、バケット内どのインデックスに入っているのかletindexInsideBucket= buckets[bucketIndex].index { element in
                element.hashValue == hashValue && element.key == key
            }

            ifletnonNilNewValue= newValue {
                letnewElement= BucketElement(
                    hashValue:hashValue,
                    key:key,
                    value:nonNilNewValue
                )
                ifletnonNilIndexInsideBucket= indexInsideBucket {
                    // キーが既に入っているので置き換え
                    buckets[bucketIndex][nonNilIndexInsideBucket] = newElement
                } else {
                    // キーがまだ入っていなかったので、挿入
                    buckets[bucketIndex].append(newElement)
                }
            } else {
                // newValueがnilなので、削除ifletnonNilIndexInsideBucket= indexInsideBucket {
                    buckets[bucketIndex].remove(at:nonNilIndexInsideBucket)
                }
            }
        }
    }
}

肝心なところは衝突の扱いです。同じバケットにキーが増えると、バケットに入っている項目のリストが少しずつ伸びます。項目が増えると読み込みも書き込みも比較が増えて処理が重くなります。バケットに項目が1つしかなかった場合、アクセスする時は行われるのはハッシュ値の計算1回と、ハッシュ値の比較1回と、キー自体の比較1回です。同じハッシュ値のキーが100個入っていると、全部同じバケットになるので、アクセスすると行われるのはハッシュ値の計算1回と、ハッシュ値の比較100回と、キー自体の比較100回です。100個目なので、前の99回分の挿入ももちろんあります。

逆にすべてのキーが別のバケットに入ると挿入する度に比較は各1回だけです。ですのでできるだけ多くのバケットが使われる方が性能が出ます。

コードレビューで気づいた間違い

この記事の冒頭でハッシュ値に関する間違いを指摘したと言いましたが、具体的にいうと大きい間違いが以下の2つでした。

  • ==の実装が hashValueを比較していただけ
structFoo:Hashable {
    staticfunc== (lhs:Foo, rhs:Foo) ->Bool {
        return lhs.hashValue == rhs.hashValue
    }
}

lhsrhsが違っても、ハッシュ値が同じの可能性があります。ハッシュ値が違っていたらlhsrhsが必ず違いますけど。

  • hashValueUserDefaultsに保存されていた
UserDefaults.standard.integer(forKey:fooHashValueKey)
(...)
UserDefaults.standard.set(foo.hashValue, forKey:fooHashValueKey)

プログラムの次の実行でハッシュ値が変わる可能性があります。元からHashableの公式ドキュメントには明確に書いてありました。

Hash values are not guaranteed to be equal across different executions of your program. Do not save hash values to use in a future execution.

実際冒頭でリンクした変更がSwiftに入ったら、プログラムの次の実行でハッシュ値が変わっていない可能性が極めて低いです。

まとめ

ハッシュ値を扱っている場合、以下の項目を覚えておきましょう。

  • ハッシュ値はプログラムの実行ごとに変わる可能性あるためディスクに保存してはいけない(別の実行でも同じ結果を返すハッシュ関数を意図的に使わない限り)
  • 2つのキーのハッシュ値が違ったら、キーが必ず違う
  • 2つのキーのハッシュ値はが同じだとしても、キーが同じだと限らない

人工知能学会のトップカンファレンス派遣レポータとして NIPS2017 に参加しました

$
0
0

研究開発部の菊田(@yohei_kikuta)です。機械学習を活用した新規サービスの研究開発(主として画像分析系)に取り組んでいます。 最近読んだ論文で面白かったものを3つ挙げろと言われたら以下を挙げます。

人工知能学会の トップカンファレンス派遣レポータという制度で NIPS2017 に参加しました。 学会への参加に加えて、その後の論文読み会や報告会での発表など様々な活動をしましたので、一連の活動を紹介したいと思います。

NIPS2017 の特徴的な写真として invited talk の一コマを貼っておきます。驚くほど人が多い...

20180327114113

また、参加して自分が面白いと思った内容(deep learning のいくつかのトピック)をまとめた資料も最初に紹介しておきます。

経緯

昨年 トップカンファレンス派遣レポータという制度がアナウンスされ、新しい取り組みで面白そうな企画でもあるので応募しました。 学会に参加するだけなら応募せずに会社で申請して参加すればいいのですが、NIPS の内容に興味を持つ人が集まりそうな場での発表の機会が得られるということが主たるモチベーションでした。

レポータとして選ばれるのは3名で応募者の統計情報などは明らかにされていません。 3名の内訳は、大学の先生・大学院生・私、という感じでバランスも考慮されている印象を受けました。

応募自体はA4の資料を一枚程度作成すればよいもので(郵送ですが)、それで必要経費を全て出してくれるというなかなか太っ腹な制度だと思います。 今年も同様の内容で募集する可能性が高そうなので、興味がある方は申し込んでみるのもよいと思います。 昨年は5月上旬に人工知能学会のメーリングリストから応募者を募るメールを受け取りました。

NIPS2017

Neural Information Processing Systems (NIPS) は機械学習の主要な国際会議の一つで、私は2015年にも参加していて二回目の参加となりました。

昨今の機械学習ブームを牽引する学会でもあり、2017年は registration が8000人でそれもかなり早い段階で打ち切られたという状態でした。 投稿論文数も3240件(採択率21%)で2016年から30%程度増加しており、年々その熱量が増しています。 企業のスポンサーは84社にも上り、diamond sponsor に関しては展示会さながらの大々的な展示が繰り広げられていました。

論文採択に関しては面白いデータが紹介されていて、事前に arXivにも submit されていた論文は43%に上り、レビュワーがそれを見た場合の採択率が35%という高い数字であったというものです。 レビュワーが見てない場合も25%と高い水準のため、そもそも質が高めの論文が arXiv に submit されるという傾向はあるかもしれませんが(例えば地力のある研究室がそういう戦略を取っているなど)、arXiv が機械学習分野にも高い影響力を発揮していることが伺えます。 学会では査読されてから publish されるまで時間も掛かるので、論文は arXiv などですぐに共有されて open review などで評価する open science 化が進んでいくかもしれません。

内容に関しては、NIPS において長らく主題の一つである algorithm が最多でありながら、deep learning や meta learning などの勢いが著しく、それ以外にも fairness や interpretability のような分野の台頭が目立つという、様々な側面で盛んに研究が進められているという印象でした。 個々の詳細な内容の説明はここでは省きますが、deep learning 関連のまとめに関しては冒頭の紹介資料にも記載しています。

NIPS2017 では新たな取組として competition track や DeepArt contest が開催されていました。 前者は kaggle のようなコンペを事前に開催して当日に上位入賞者に解説をしてもらうというような形式で、後者は style transfer を使って画像を artistic に変換して投票で入賞者を決めるという形式でした。 学会にこれらの要素が必要なのかということは議論の余地があるかもしれませんが、学会も世の中の動向に合わせて変化を続けていることが伺えるものでした。

その他にはネットでも話題になった苛烈な人材獲得競争のような話題がありますが、参加者としてはそこまで騒ぎ立てるほどではないと感じました。 一部でバブルを感じさせるイベントがあったりしたことは事実ですが、学会の性質を歪めるほどのものではないように思います。 学生としても自分が興味のある企業に直接アプローチする機会が増えて良いのではないでしょうか。

NIPS2017 論文読み会

せっかく参加したので、興味を持った論文をもう少し深く読んで発表しようと思い クックパッドで論文読み会を主催しました。 読み会の様子です。

20180327114104

私は GAN の学習の収束性に関するいくつかの論文を読んで発表をしました。

本来はどこかで開催される読み会に参加して発表だけしようと思っていたのですが、観測範囲内で望ましいイベントが開催されなかったので主催するに至りました(その後いくつか同様のイベントが開催されました)。

イベントの主催は大変なところもあるのですが、機械学習に興味のある方々に参加していただき盛況でした。 こういったイベントを通してクックパッドに興味を持って頂ける場合も少なくないので、主催してみて良かったなと思います。

今後もこのようなイベントを開催していくことになると思いますので、興味のある方は是非ご参加下さい。

NIPS2017 報告会

派遣レポータの仕事として事前に開催が決まっていた 報告会でも発表しました。 20180221に大阪大学中之島センターで、20180228に早稲田大学西早稲田キャンパスで報告会が開催されました。

有料イベントにも関わらず満員御礼状態で、特に企業の方々の参加者が多かったと伺っています。 久しぶりの大学での発表だし、NIPS の報告でもあるので、内容は思いっきり deep learning の理論的な話をしました。 参加者の目的と合致していたかは一抹の不安が残りますが、自分が聴衆として聞くなら悪くない内容だったと思っています(自分が話してるので当然ですね)。

その他

人工知能学会紙に参加報告を載せる予定です。 また、それ以外にも付随して何かやるかもしれません。

まとめ

人工知能学会のトップカンファレンス派遣レポータとして NIPS2017 に参加した話と、それに関連するイベントで何をやったのかという紹介をしました。 NIPS は理論的な色合いが濃い学会ではありますが、次々と新しいものが出てくる機械学習界隈ではこういった内容をキャッチアップしていくのは事業会社の研究開発でも重要だと考えていて、そして何より自分が好きなので、参加して得られた知見を今後の業務に活かしていきたいと思います。

いかがでしたでしょうか。 クックパッドでは、機械学習を用いて新たなサービスを創り出していける方を募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。 クックパッド株式会社 研究開発部 採用情報

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

$
0
0

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

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

概要

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

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

背景

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

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

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

Unity での UI セッティング

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

f:id:kei_bility:20180328184731p:plain

カメラ画像の取得

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

void OnPhotoCaptureCreated(PhotoCapture captureObject)
{
    photoCaptureObject = captureObject;

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

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

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

APIリクエスト

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

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

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

f:id:kei_bility:20180328001905j:plain

まとめ

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

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

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

$
0
0

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

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

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

hako-console とは

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

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

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

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

設計方針

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

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

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

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

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

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

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

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

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

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

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

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

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

ログの閲覧、検索

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

hako-console log list pagehako-console log page

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

まとめ

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

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


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

$
0
0

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

背景

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

どうするか

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

InstructionSequence(iseq) とは

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

classCatdefsleependend

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

InstructionSequence lazy loading

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

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

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

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

// https://github.com/ruby/ruby/blob/v2_4_3/vm_core.h#L415-L424staticinlineconst rb_iseq_t *
rb_iseq_check(const rb_iseq_t *iseq)
{
#if USE_LAZY_LOADif (iseq->body == NULL) {
    rb_iseq_complete((rb_iseq_t *)iseq);
    }
#endifreturn iseq;
}

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

パッチ

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

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

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

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

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

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

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

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

# プロジェクト rootrequire"bundler"

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

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

成果

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

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

まとめ

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

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

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

ディープラーニングによるホットドッグ検出器のレシピ

$
0
0

研究開発部の画像解析担当のレシェックです。techlife を書くのは初めてです。よろしくお願いいたします。

最先端の機械学習を使うためには、常に自分のスキルアップが必要です。そのために、毎日論文を読んだり、新しいオープンソースのコードを試してみたり、クックパッドのデータで実験しています。これはちょっと料理の練習と似ています。新しいモデルを学習させるのは料理をオーブンに入れるのと同じ気持ちです。オーブンの温度は学習率と同じで、低すぎだとよく焼けず、高すぎだと焦げてしまいます。しかし、ちゃんと他のリサーチャーの論文やブログの中のレシピを見ながら自分のデータでモデルを学習させると、失敗せずに済むかもしれません。

このエントリでは、そういった機械学習のレシピの一例を紹介します。

f:id:lunardog:20180405185342j:plain

このブログで使っているテスト画像はPixabayから取得した、Creative Commonsのライセンスの写真です。

概要

クックパッドは料理/非料理のモデルを開発しています。ここでは、このモデルのミニチュア版のレシピを紹介します。カテゴリは「料理」と「非料理」の代わりに、「ホットドッグ」と「非ホットドッグ」にします。そして、パッチ化した画像に対する認識モデルを使って、画像の中でホットドッグがどこにあるかを検出します。

調理器具

  • python
  • Keras
  • numpy
  • pillow (PIL)
  • jupyter notebook(お好みでお使い下さい。)

KerasはTensorflow、CNTKやTheano上で動く高水準のライブラリーです。Keras は特に画像データに対して、単なる学習以外にも前処理などでも様々な機能があります。

材料

KaggleからHot Dog - Not Hot Dogのデーターセットをダウンロードしてください。なお、ダウンロードするには Kaggle の登録が必要です。

ダウンロードした後、seefood.zipunzipしてください。

アーカイブの中に、2つのディレクトリtraintestがあります。

seefood/train/not_hot_dog
seefood/train/hot_dog
seefood/test/not_hot_dog
seefood/test/hot_dog

hot_dogディレクトリの中にホットドッグの画像が入っており、not_hot_dogの中にそれ以外の画像が入っています。新しい機械学習のレシピを開発する時はテストデータを分けるべきです。しかし、今回は画像が少ないので、テストデータも学習に使いましょう。

mkdir seefood/all
cp -r seefood/test/* seefood/train/* seefood/all

以降では、seefood/allのディレクトリを使います。

データ拡張

Keras のモバイルネットは(224px・224px)のフィックスサイズの画像しか認識できないので、これから学習や認識用にサイズを変換します。

IMG_SIZE=[224, 224]

テストデータを学習に使っても、このデータセットはまだ小さいので、データ拡張を使いましょう。

KerasのImageDataGeneratorは学習時に画像を一つずつ変換します。

import keras.preprocessing.image

image_generator = keras.preprocessing.image.ImageDataGenerator(
        rescale=1./255,
        shear_range=0.0,
        width_shift_range=0.1,
        height_shift_range=0.1,
        rotation_range=10,
        fill_mode="wrap",
        vertical_flip=True,
        horizontal_flip=True
)

上のimage_generator"seefood/all"のディレクトリで動かします。

train_generator = image_generator.flow_from_directory(
    "seefood/all",
    target_size=IMG_SIZE,
    batch_size=32,
    class_mode="categorical",
    classes=["not_hot_dog", "hot_dog"]
)

モデルの作り方

以下のレシピでは、3 個のモデルを 3 層のスポンジケーキのように積み重ねています。

  1. base_modelMobileNetです。転移学習のために使います。
  2. その上のpatch_modelは画像のパッチごとに分類できます。
  3. さらにその上のclassifierは「ホットドッグ」と「非ホットドッグ」の二値分類器です。

まずkerasimportします:

import keras

ベースとして、Googleで開発されたMobileNetというモデルを使います。

weights="imagenet"は、ILSVRCのコンペティションのデータセットで学習されたパラメタを使って、転移学習することを意味しています。

base_model = keras.applications.mobilenet.MobileNet(
    input_shape=IMG_SIZE + [3], 
    weights="imagenet",
    include_top=False
)

ベースモデルの一番上のフィーチャサイズは1024です。パッチレイヤが学習できるようにちょっと下げましょう。

drop1 = keras.layers.SpatialDropout2D(0.3)(base_model.output)
conv_filter = keras.layers.convolutional.Conv2D(
    4, (1,1),
    activation="relu",
    use_bias=True,
    kernel_regularizer=keras.regularizers.l2(0.001)
)(drop1)

パッチレイヤもConv2Dのタイプのレイヤです。この場合、softmaxを使えば、パッチごとに分類できるようになります。

drop2 = keras.layers.SpatialDropout2D(0.3)(conv_filter)
patch = keras.layers.convolutional.Conv2D(
    2, (3, 3),
    name="patch",
    activation="softmax",
    use_bias=True,
    padding="same",
    kernel_regularizer=keras.regularizers.l2(0.001)
)(drop2)

これでパッチモデルができました。

patch_model = keras.models.Model(
    inputs=base_model.input, 
    outputs=patch
)

パッチモデルをベースにして、最後の出力レイヤを追加して分類モデルを作ります。

pool = keras.layers.GlobalAveragePooling2D()(patch)
logits = keras.layers.Activation("softmax")(pool)


classifier = keras.models.Model(
    inputs=base_model.input, 
    outputs=logits
)

学習

ベースモデルは学習させません。

for layer in base_model.layers:
    layer.trainable = False

そして全体のモデルをcompileします。

classifier.compile(optimizer="rmsprop", loss="categorical_crossentropy", metrics=["accuracy"])

では、学習を始めましょう!

いくつか実験をした結果、以下のようにnot_hot_dogのクラスのclass_weightを高くするほうが良いことが分かりました。

%%time
classifier.fit_generator(
    train_generator, 
    class_weight={0: .75, 1: .25}, 
    epochs=10
)
Epoch 1/10
32/32 [==============================] - 148s 5s/step - loss: 0.3157 - acc: 0.5051
Epoch 2/10
32/32 [==============================] - 121s 4s/step - loss: 0.3017 - acc: 0.5051
Epoch 3/10
32/32 [==============================] - 122s 4s/step - loss: 0.2961 - acc: 0.5010
Epoch 4/10
32/32 [==============================] - 121s 4s/step - loss: 0.2791 - acc: 0.5862
Epoch 5/10
32/32 [==============================] - 122s 4s/step - loss: 0.2681 - acc: 0.6380
Epoch 6/10
32/32 [==============================] - 123s 4s/step - loss: 0.2615 - acc: 0.6876
Epoch 7/10
32/32 [==============================] - 121s 4s/step - loss: 0.2547 - acc: 0.6790
Epoch 8/10
32/32 [==============================] - 122s 4s/step - loss: 0.2522 - acc: 0.7052
Epoch 9/10
32/32 [==============================] - 123s 4s/step - loss: 0.2522 - acc: 0.7045
Epoch 10/10
32/32 [==============================] - 145s 5s/step - loss: 0.2486 - acc: 0.7164
CPU times: user 1h 4min 20s, sys: 2min 35s, total: 1h 6min 56s
Wall time: 21min 8s

このデータセットの場合、10エポックぐらいが良さそうです。パッチベースを使っているので、精度は100%にならないほうがいいです。70%ぐらいがちょうどいいです。

私の MacBook Pro では10エポックで20分ぐらいかかりました。

確認作業

画像とデータの変換のために、PILnumpyを使います。

import numpy as np
from PIL import Image

画像をインファレンスする前に、numpyのデータに変換します。

defpatch_infer(img):
    data = np.array(img.resize(IMG_SIZE))/255.0
    patches = patch_model.predict(data[np.newaxis])
    return patches

そして、元の画像とインファレンス結果をビジュアライズします。

defoverlay(img, patches, threshold=0.99):
    # transposeはパッチをクラスごとに分けます。
    patches = patches[0].transpose(2, 0, 1)
    # hot_dogパッチ - not_hot_dogパッチ
    patches = patches[1] - patches[0]
    # 微妙なパッチをなくして
    patches = np.clip(patches, threshold, 1.0)
    patches = 255.0 * (patches - threshold) / (1.0 - threshold)
    # 数字を画像にして
    patches = Image.fromarray(patches.astype(np.uint8)).resize(img.size, Image.BICUBIC)
    # もとの画像を白黒に
    grayscale = img.convert("L").convert("RGB").point(lambda p: p * 0.5)
    # パッチをマスクに使って、元の画像と白黒の画像をあわせて
    composite = Image.composite(img, grayscale, patches)
    return composite

まとめて、インファレンスとビジュアライズを一つのファンクションにすると、

defprocess_image(path, border=8):
    img = Image.open(path)
    patches = patch_infer(img)
    result = overlay(img, patches)
    # 元の画像と変換された画像をカンバスに並べます
    canvas = Image.new(
        mode="RGB", 
        size=(img.width * 2 + border, img.height), 
        color="white")
    canvas.paste(img, (0,0))
    canvas.paste(result, (img.width + border, 0))
    return canvas

では、結果を見てみましょう!

f:id:lunardog:20180405185418j:plainきれいですね!

f:id:lunardog:20180405185437j:plainホットドッグの色はちょっと隣のコーヒーに移りましたが、ほとんど大丈夫です。

f:id:lunardog:20180405185457j:plainフォーカスが足りないところは認識にならなかったみたいです。なぜでしょう?学習データにフォーカスが当たらないホットドッグがなかったからです。

f:id:lunardog:20180405185342j:plainこちらも、左側のホットドッグはフォーカスが当たっておらず、モデルはホットドッグを認識できませんでした。

ホットドッグではない画像は? f:id:lunardog:20180405185526j:plain

f:id:lunardog:20180405185541j:plain

f:id:lunardog:20180405185558j:plain

f:id:lunardog:20180405185609j:plain

ホットドッグではない画像には、パッチはゼロやゼロに近い値になります。

まとめ

転移学習を使えば、データが少なくても、それなりの識別器が作れますね!

パッチごとの分類を使えば、画像の中の認識したいフィーチャーを可視化できます。

モバイルネット(MobileNet)のおかげで、CPU でもモデルを学習できます。

いかがでしたでしょうか。 クックパッドでは、機械学習を用いて新たなサービスを創り出していける方を募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。

クッキングLIVEアプリcookpadTVのコメント配信技術

$
0
0

こんにちは。メディアプロダクト開発部の長田です。

この記事では、クッキングLIVEアプリcookpadTVのLIVE中のコメント配信について工夫したことを紹介したいと思います。

2018/3/28 (水)に開催されたCookpad Tech Kitchen #15資料も合わせてご覧いただけると、分かりやすい部分もあるかと思います。

cookpadTV

f:id:osadake212:20180412145905p:plain:w150

cookpadTVでは、料理家や料理上手な有名人による料理のLIVE配信を視聴することができます。iOS/Androidのアプリがリリースされおり、LIVE配信を通して、分かりづらい工程や代替の材料の質問などをコメント機能を使って質問することができます。また、他のLIVE配信アプリのようにハートを送ることでLIVEを盛り上げることができます。

以下では、LIVE中のコメント配信を実装するにあたって私が課題だと感じたものと、それらをどう解決したのかを紹介します。

コメント配信の課題

コメント配信には次のような課題があると感じています。

1つ目は、パフォーマンスの問題です。LIVEの日時に合わせてユーザーが同時に集まるので、人気な配信ほど多くのユーザーがサーバーにリクエストしてきます。また、コメントだけではなくハートを送信する機能を設けており、これは気軽に連打できるようにしてあるので、リクエスト数も多くなることが予想されました。

2つ目は、双方向通信です。cookpadTVでは「料理家や有名人にその場で質問できる」のを価値にしていて、ユーザーのコメントは演者が読み上げて回答してくれたりします。 演者とユーザーのコミュニケーションと、それを見ている他のユーザーの体験を損なわないようにするために、サーバーとアプリの情報をある程度同期させておく必要がありました。

パフォーマンスを出すために

コメントを受けるAPIは別アプリケーションとして構築しました。コメントを受けるAPIはその他のAPIとは特性が違うので、コメントを受けるAPIだけをチューニングしやすくなるからです。 以下では、このコメントを受けるAPIサーバーのことを メッセージサーバーと呼び、その他のAPIサーバーを 通常のAPIサーバーと呼ぶことにします。*1

f:id:osadake212:20180412145148p:plain

まず、実装言語はgolangを採用しました。採用理由は以下が挙げられます。

  • 並列処理が得意な言語なので、同時接続を受け付けやすい
  • 後述のFirebaseを使うためのAdmin SDKが提供されていた
  • golang書きたかった

クックパッドはRubyの会社というイメージがあると思いますが、特性に応じてRuby以外の言語を選択できるよう、hakoを使ったDockerコンテナのデプロイ環境が全社的に整備されており、他のサービスでもRuby以外の言語で実装されているものがあります。*2*3

また、hakoによってDockerコンテナがECSにデプロイされるようになっており、必要に応じてECSのAuto Scalingの設定ができるので、このメッセージサーバーも設定しています。これにより、アクセスが増えてきてサーバーリソースが消費され始めたらスケールアウトして、アクセスが減ってサーバーリソースに余裕がでてきたらスケールインするようになります。 また、Auto Scalingが間に合わないことが予想される場合は、予めコンテナ数を増やしておくようにしています。*4

さらに、WebアプリケーションはDBアクセスがボトルネックになりがちだと思うのですが、メッセージサーバーではDBにアクセスをしない、という選択をしました。一方で、DBにアクセスしないので認証と永続化について工夫する必要がありました。

認証については、メッセージサーバー用の寿命の短い認証情報(トークン)を通常のAPIサーバーで発行しておき、それをキャッシュに乗せておきます。各アプリはそのトークンを乗せてリクエストするので、メッセージサーバーはキャッシュを見に行くことで認証を実現しています。

また、永続化については非同期で行うようにしました。 コメント/ハートは後述のFirebase Realtime Databaseを使って各アプリに配信されており、LIVE配信中に永続化できなくてもよかったので非同期で行う選択をしました。

永続化の流れは、fluentdを使ってコメント/ハートのデータをS3に送ったあと、弊社のデータ基盤を使うことで、Redshiftに継続的に取り込まれるようになっています。*5さらに、Redshiftに入ったデータは、Kuroko2を使ったバッチ処理によりMySQLに取り込む流れになります。*6

f:id:osadake212:20180412145141p:plain

これらの工夫をして、直近の配信ではピーク時 5,100rpm のメッセージを無事捌くことができました。

双方向通信

コメントやハートのやり取りで使用する、iOS/Androidアプリとの双方向通信を行うためにいくつかの手段を検討しました。

などを検討した結果、最終的にFirebase Realtime Databaseを使うことにしました。選択した理由としては、

  • iOS/AndroidのSDKが提供されており、アプリの実装工数が減らせる
  • 社内の他プロジェクトで導入されており、知見があった

というのが挙げられます。

また、Firebase Realtime Databaseに直接アプリが書き込むのではなく、以下の図のように、一度メッセージサーバーがリクエストを受け付けて、その内容をFirebase Realtime Databaseに書き込むようにしました。こうすることで、認証と永続化を実現しています。 つまり、Firebase Realtime Databaseをストレージとしてではなく、イベント通知をするために利用しています。これに関しては、この後のデータ構造の工夫と合わせて詳しく説明します。

f:id:osadake212:20180412145041p:plain

Firebase Realtime Databaseを使うことにしたので、データ構造を工夫する必要がありました。

cookpadTVでは、データ転送量を抑えるために最新のコメントだけを保存するようにしました。 具体的には以下のようなJSON構造にしています。(これはイメージなので実際のものとは異なります。)

{"latest_comment": {"user_id" : 1,
    "text": "こんにちはー"
  }}

このような構造にしておいて、 latest_commentを上書き更新することで、各アプリに配布するデータは最新のコメント分だけになるので、転送量を抑えることができます。過去のコメントはアプリ側で保持しておいて、LIVE中に受け取ったデータは遡れるようになっています。

ただしこのデータ構造には、途中からLIVE配信を見始めたユーザーは過去のコメントを見ることが出来ないという課題が残っています。 この課題に関しては、直近のコメントはいくつか保持しておく、というものと、非同期での永続化のラグを短くした上でAPIでコメントを返せるようにする、という2つのアプローチのあわせ技で解決したいと思っています。

まとめ

この記事では、cookpadTVのLIVE中のコメント配信について工夫したことを紹介しました。 最後になりましたが、この記事がLIVE配信サービスの開発について、少しでもお役に立てれば幸いです。

*1:コメントだけではなく、ハート等、他のメッセージも受けるのでメッセージサーバーと呼んでいます。

*2:hakoの近況は本ブログでも紹介されています。http://techlife.cookpad.com/entry/2018/04/02/140846

*3:2018/02/10に開催されたCookpad TechConf 2018では、「Rubyの会社でRustを書くということ」というタイトルで弊社のkobaによる発表が行われました。 https://techconf.cookpad.com/2018/hidekazu_kobayashi.html

*4:LIVEコンテンツの集客予想に応じて、自動でコンテナ数を増やす仕組みを実装しています。

*5:本ブログの過去のエントリで、クックパッドのデータ基盤について紹介しているものがあるので、詳細はこちらを御覧ください。 http://techlife.cookpad.com/entry/2017/10/06/135527

*6:弊社のオープンソースで、WebUIが用意されているジョブスケジューラーです。

React Nativeで作った新アプリについて(5日間連載)

$
0
0

こんにちは投稿開発部の丸山@h13i32maruです。

今日から5日間、本ブログに投稿開発部メンバーで連載記事を書かせていただきます!

いきなり「投稿開発部で連載記事」と言われても何のことかわからないと思うので、まず投稿開発部について簡単に紹介させてもらいます。

投稿開発部は「クックパッドに投稿されるコンテンツ全般」について責任をもっている部署なのですが、中でもレシピ事業の根幹であるレシピ投稿者向けのサービス改善に力を入れています。レシピ投稿者向けのサービス改善は「どうすれば継続的に投稿したくなるのか?」「どうすれば投稿をはじめてみたくなるのか?」の2点に答えを出すことを目標に日々サービス開発に励んでいます。

そこで、本連載では投稿開発部が今年メインで取り組んでいる「クックパッド MYキッチン」という新しいアプリについて5人のメンバーで紹介させていただきます。

1日目(vol1)では「クックパッド MYキッチン」ができるまでの話をちょっとしたストーリー仕立ての文章で紹介させていただきます。普段の記事と比べると技術的なトピックは少なめなので、肩の力を抜いて気軽にお読みください。

そして、2日目以降は以下のような内容を予定しております。

  • vol2: ReactNativeプロジェクトのAndroid環境を整備する(仮) by @101kaz
  • vol3: React Native アプリの開発基盤構築(仮) by @morishin127
  • vol4: 「クックパッド MYキッチン」のアプリアイコンができるまで(仮) by @sn_taiga
  • vol5: 料理する人の課題を起点に施策を作る試み(仮) by 五味 夏季

「クックパッド MYキッチン」とは

今年、投稿開発部では「クックパッド MYキッチン」というアプリ(以下MYキッチンアプリ)の開発に注力しています。このアプリはレシピ投稿者が使いたいと思えるアプリを目指して、これまでのクックパッドアプリ上での体験をリデザインして作られているものです。

f:id:h13i32maru:20180413135704p:plain:w100f:id:h13i32maru:20180413135755p:plainApp Store / Google Play

ではなぜ既存のアプリ上でレシピ投稿者向けの体験をリデザインしなかったかというと、「開発・検証のスピードを上げるため」というのが大きな理由です。そのために「関わる人を少なくして、意思決定を速く」と「機能の制約を受け入れて、実装を速く」ということを行っています。

特に後者の「機能の制約を受け入れて、実装を速く」について、MYキッチンアプリではReact Nativeを採用してフルスクラッチで作られています。また、CodePushについても試し始めています。

ではここから、いかにしてMYキッチンアプリが出来上がっていったのかを紹介していきます。

Prototype Labs(2017年春)

時は遡り2017年春、当時同じチームだったiOSエンジニアがReact Nativeをアプリのプロトタイピングに使えないか調査していました。当時、彼が書いた社内ブログにはこのように書いてありました。

年末年始でReactNativeの調査をしていました。

目的はReactとcssの知見でネイティブのアプリが作れれば、アプリのプロトタイプできる人口を増やせるのでは?というところ。

xxx(とあるプロジェクト)の初期でいくつかの機能を試していたときに、「アプリに組み込んで手触りを試したい」という欲求があったものの ネイティブがかける人は少ないし、ネイティブでレイアウトを変えるトライアンドエラーはどうしてもコストが高いので 何か別の手段で試せた方が良いのでは・・?と考えたのがきっかけです。

この彼の取り組みを横目でみながら、「React Nativeというものがあって」「アプリのプロトタイピングに使えるかもしれない」という情報を得た僕は、自分でもちょっと試しに触ってみることにしました。

当時、どういうふうに試し始めたのかははっきりとは覚えていないのですが、「アイテムのリスト画面」と「アイテムの詳細画面」という基本の画面を作ったと思います。そして色々触ってみた結果「開発スピードをあげるために、完成度・機能・UI・パフォーマンスなどの制約を受け入れることができる」というものに向いていることがわかりました。そう、まさにプロトタイピングに向いていると思ったのです。*1

さらに、React Nativeを使ったプロトタイピングなら、これまで静的なプロトタイピング(ペーパーモック、InVisionなど)では諦めるしかなかった点もカバーできると思いました。

  • 日常生活で使うことができるプロトタイプ
  • 実際のデータを使ったプロトタイプ
  • データを書き込むことができるプロトタイプ

というわけで、僕の中の「React Nativeでプロトタイピング環境を作りたい」という欲求がむくむくと湧き上がっていきました。なので鉄は熱いうちに打ての精神で、React Nativeを使った社内用のプロトタイピング環境「Prototype Labs」を作りました*2

Prototype Labsの中身はというと、React Nativeを社内のプロトタイピングに特化させるために、薄いラッパーと幾つかの便利機能を追加したものです。具体的には、ファイルの配置ルール、デプロイの仕組み、ドキュメントの構築、認証周りのデフォルト実装、サーバサイドのAPIを簡単に呼び出せる仕組み、よく使うカラー・レイアウトの提供、etcという感じです。

Prototype Labsとは APIリファレンス
f:id:h13i32maru:20180413135849p:plainf:id:h13i32maru:20180413135902p:plain

ドキュメントはESDocというJavaScript向けのドキュメンテーションツールで作りました

特にドキュメント周りは力を入れて整備しました。というのも、社内のデザイナー(HTML, CSS, JSに多少触れたことがある人)にも使ってもらえるようにというのを目標の1つにしていたからです。実際、デザイナーとペアプロ的にPrototype Labsを触ってもらい、簡単な画面を作ってもらったりもしました。

そして、Prototype Labsを使って「料理まとめ(自分が投稿したレシピを自由にまとめられるもの)」という機能のプロトタイピングをデザイナーと一緒に行いました。結果、実際に日常使いをしながら議論をすることができ、主要な要件を決めるのに非常に役立ちました。料理まとめはその後、iOS版のクックパッドアプリに実装され、現在、本番環境で元気に動いています。この時一緒にプロトタイピングをしたデザイナーが当時の様子を「React Nativeで作る 「触れるプロトタイプ」の活用」というタイトルで発表しているので興味がある方は見てみてください。

裏クックパッドアプリ(2017冬)

Prototype Labsを作った後、しばらく業務ではReact Nativeを触ることはありませんでした。

一方で、プライベートでは自分の料理レシピをクックパッドに投稿しはじめました。これまでもレシピはGoogle Docsやブログなどに書き散らかしていたのですが、それらをせっかくなのでクックパッドに集約しようと思い、どんどんレシピを投稿していきました。そうするとレシピ投稿者の視点でクックパッドアプリを見るようになり、「今までレシピ投稿者向けの開発はしたことがなかったけど、来年(2018年)はレシピ投稿者向けの開発をしたいな」と思うようになりました。

で、それなら「レシピ投稿者(自分)が使いたくなるクックパッドアプリ」を作ってしまえば良いんだと思い立ちました。またしても鉄は熱いうちに打ての精神で、React Nativeを使ってオリジナルのクックパッドアプリをまるっと作り変えてしまおうと開発にとりかかりました。これが後にMYキッチンアプリの土台となるもので、社内の一部からは「裏クックパッドアプリ」「RNクックパッドアプリ」などと呼ばれることになります。

上述したとおり、裏クックパッドアプリはレシピ投稿者が使いたくなるというのを目指していたので、コンセプトや体験はオリジナルのクックパッドアプリとは大きく異なります。具体的には・・・という話をしたいのですが、ここに書くのは長くなりそうなのと企業秘密というわけで詳細は伏せておきます。この話を聞いてみたい!という方がいらっしゃれば、TwitterのDMなどから是非とも僕までコンタクトしていただければと思います。

モード切替 キッチンモード さがすモード
f:id:h13i32maru:20180413140026j:plainf:id:h13i32maru:20180413140034j:plainf:id:h13i32maru:20180413140040j:plain

特徴は左下のクックパッドアイコン/ユーザアイコンからモードを切り替えるという点です

その他に気をつけたこととしては、オリジナルのクックパッドアプリにある機能はほぼ全て使えるようにするという点です。何故かと言うと、僕は普段使いのアプリをオリジナルから裏クックパッドアプリに完全に移行したいという考えがあったからです。そうしないと、結局オリジナルのアプリを使ってしまい、裏クックパッドアプリが中途半端なものになりうまく改善できなくなってしまうと危惧したからです。

そんなこんなで、コンセプトや体験の見直しをして、それを実現させる機能を実装し、さらに既存の主要な機能の実装を完了させ、僕は裏クックパッドアプリに完全に移行することができました。開発に取り組み始めてから2ヶ月ほどかかりましたが、実際に使った時間は10日間ほどでした。しかもこの期間でAndroidとiOSの両方を作ることができたのもReact Nativeの強みだと思います。裏クックパッドアプリを社内にリリースした時のブログに同僚が以下のようにコメントしてくれました。

10日間でここまで作り上げられるのはプロトタイピングにすごいインパクトがあることだと思いました。

この速さなら自分が鍵だと思ってるコンセプトを形にして提案することで、細かい調整(人的リソース、仕様共に)に時間を取られずに本質的な議論を始めやすくなるように思います。

プロダクト開発って結構一部を変えようと思っても全体を整えていかないといけない(けど時間がないからスコープを絞って細部の変更に留まってしまう)ということがありがちだと思うのでアプリ全体を素早く作り変えて試せるのは大きな価値だと思います。

クックパッド MYキッチン(2018年春)

そして、自分で毎日裏クックパッドアプリを使ってみて、この新しいアプリに未来を感じました。なので、2018年は裏クックパッドアプリを使ってレシピ投稿者向けの改善に取り組んでいくことを決めました。

そこから裏クックパッドを「クックパッド MYキッチン」に改名し、デプロイの仕組みやコードの整理、足りていない機能の追加やデザインの修正、アイコンの作成などを経て、2018年3月にAndroid/iOSともにプロダクションにリリースすることができました。

というわけで、続く3日間ではプロダクションリリースするために取り組んだ技術的な話、アプリアイコンの話、そして最終日はユーザの課題と解決策をどのように探っているかの話を各メンバーが紹介してくれます。お楽しみに!(ちなみに明日からの記事は今回のようなストーリー仕立てではなく、いつもの雰囲気に戻ると思うのでご安心ください)

自己紹介

最後になりましたが、簡単に自己紹介をさせていただきます。

僕は2014年にクックパッドに入社しWebやAndroid周りの機能実装を担当していました。その後に幾つかの機能のPMを担当して、今年から投稿開発部のマネージャー(部長)という役割を担っています。

プライベートではESDoc(JavaScriptのドキュメンテーションジェネレーター)やJasper(GitHub向けのIssueリーダー)というソフトウェアを開発しています。あと、CodeLunch.fmというポッドキャストをやっていたりもします。

僕個人に関してもっと詳しい話はForkwell Pressのインタビュー記事でお話させてもらっているので、興味のある方はご覧ください。


この連載を通して「仮説を素早くプロトタイプにしていく開発」や「React Nativeを使った開発」などに興味を持たれた方がいらっしゃれば、丸山(TwitterのDM)までお気軽にご連絡ください!もちろん、採用ページから応募していただくのも大歓迎です😊

最後に、この記事を読んだ印象を簡単なアンケートでご回答いただけるとうれしいです!

アンケートリンク

*1:ちなみに、プロダクションに使えるかどうかはどちらかわからないというのが当時に意見でした

*2:社内のエンジニア数名にも手伝ってもらいながら

React Native アプリの開発基盤構築

$
0
0

こんにちは、投稿開発部の @morishin127です。React Native 新アプリシリーズ連載2日目ということで、この記事では React Native アプリの開発基盤の構築について書こうと思います。「クックパッド MYキッチン」というアプリは React Native 製で、iOS/Android 両プラットフォームでリリースされています。元々は一人の手で JavaScript (ES2017+) によって書かれていたアプリケーションでしたが、リリースまでの間に開発メンバーも増え、TypeScript の導入や CI の整備、また高速な検証のためにログ収集の仕組み作りや CodePush の導入などを行いました。それぞれ具体的にどのようなことをしたかを説明します。

セットアップスクリプト

npm-scripts を用いて npm run ios:setup / npm run android:setupでそれぞれのプラットフォームでアプリケーションをビルドするための依存関係をインストールできるようにしています。npm-scripts は package.json に定義していて、それぞれの定義は次のようになっています。

{"scripts": {"ios:setup": "cd ios && bundle install && bundle exec fastlane setup",
        "android:setup": "cd android && bundle install && bundle exec fastlane setup",
        ⋮
    },
    ⋮
}

ネイティブアプリのセットアップやバイナリ生成の処理には Fastlaneを用いており、ここで行うセットアップの処理も ios/fastlane/Fastfile / android/fastlane/Fastfileに次のように定義しています。Fastlane は Ruby 製のタスクランナーで、ビルドやストアへのサブミット、スクリーンショットの撮影など様々なタスクを定義して自動化するためのツールです。

ios/fastlane/Fastfile

react_native_root = File.absolute_path('../../')

desc "Install dependencies"
lane :setupdoDir.chdir react_native_root do
    sh "yarn install && yarn run build"end
  cocoapods(use_bundle_exec: true, try_repo_update_on_error: true)
end

android/fastlane/Fastfile

react_native_root = File.absolute_path('../../')

desc "Install dependencies"
lane :setupdoDir.chdir react_native_root do
    sh "yarn install && yarn run build"endend

また次のような npm-scripts でシミュレータでの実行スクリプトを定義しておくと、開発者はリポジトリをクローンしてから yarn run ios:setupyarn run startyarn run ios:run:debugを実行するだけで iOS アプリをシミュレータ上で実行することができて便利です。(Android も同様)

{"scripts": {"ios:run:debug": "node node_modules/react-native/local-cli/cli.js run-ios --simulator 'iPhone SE'",
        "android:run:debug": "cd android && ./gradlew installStagingDebug",
        ⋮
    },
    ⋮
}

TypeScript 導入

開発当初は JavaScript のみでしたが途中で TypeScriptを導入しました。元々の JavaScript コードと混在することになるため tsconfig.jsonでは "allowJs": trueを指定しています。React Native 周りのライブラリは型定義が充実していたため、TypeScript の恩恵を受けながら開発することができました。既存の React Native プロジェクトに TypeScript を導入した手順は morishin/ReactNativePracticeの README.md にまとめたのでそちらをご参照ください。基本的には公式の Microsoft/TypeScript-React-Native-Starterに倣った手順を踏んでいます。

github.com

参考までに、「クックパッド MYキッチン」アプリの tsconfig.jsonは執筆時点ではこのようになっています。ソースコードは srcディレクトリに、tscによりトランスパイルされたコードが libディレクトリに配置され、アプリは lib以下のソースを読み込みます。

{"compilerOptions": {"target": "es2017",
    "module": "es2015",
    "allowJs": true,
    "jsx": "react-native",
    "sourceMap": true,
    "outDir": "./lib",
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "node"
  },
  "include": ["./src/"]}

tscによるトランスパイルを自動化するために npm-scripts に build:watchを定義し、開発中はこれを実行した状態でソースコードを編集しています。

{"scripts": {"build": "tsc",
        "build:watch": "tsc --watch",
        ⋮
    },
    ⋮
}

フォーマッタ導入

複数人で開発する際にはフォーマッタがあると便利なので、Prettierを利用しています。フォーマットルールはほぼデフォルトのままです。エディタのプラグイン等で保存時に自動フォーマットがかかるようにしておくと便利かもしれません。

CodePush 導入

「クックパッド MYキッチン」ではサービスの高速な検証のために、バンドルの配信に Microsoft 製の CodePushという仕組みを利用しています。CodePush を利用すると React Native アプリの JS バンドルのみをユーザーの端末に配信することができ、App Store / Google Play Store でアップデートを配信することなくアプリを更新することができます。この特徴はとにかく高速に仮説を検証したいサービス開発者にとって魅力的で、React Native を採用する大きな理由のひとつになると思っています。

導入手順

CodePush の導入手順については公式の通りなので割愛します。

運用

CodePush は一つのプロジェクトに対して複数のデプロイ環境を作ることができ、「クックパッド MYキッチン」では Production, Production-test, Stagingの3つの環境を用意しています。Productionは App Store / Google Play Store で配信されている本番のアプリが JS バンドルを取得する環境、Production-testは社内のみで配信しているアプリが参照している環境、Stagingはチーム内でデザインや動作を確認をするためのアプリが参照している環境です。Production-testアプリと Stagingアプリの違いとして、前者は API サーバーや DB も本番環境を参照しているに対し、後者はバックエンドが本番環境とは切り離されているため、コンテンツの投稿・公開といった動作テストを行うことができるアプリになっています。それぞれの社内配信の方法とデプロイのタイミングについては CI の項で説明します。

画像の Beta 帯アイコンのものが Production-test環境のアプリ、Staging 帯アイコンのものが Staging環境のアプリ、無印が Production環境のストア版アプリです。

f:id:morishin127:20180416150414p:plain

ちなみにこのアイコンの帯は fastlane-plugin-badgeを用いて社内配信の CI ジョブ内で付加しています。

github.com

iOS/Android アプリから CodePush のデプロイ環境を読み分ける

Production, Production-test, Stagingアプリで参照する CodePush の環境を分けていると述べましたが、iOS アプリでは Build Configuration 毎に CodePush の deployment key を切り替えることで、またAndroid アプリでは Build Type / Product Flavor 毎に deployment key を切り替えることでデプロイ環境を読み分けています。React Native アプリの Xcode プロジェクトに Build Configuration を追加するとビルドが通らなくなって苦戦したのでその解決の記録を貼っておきます。同じ問題に遭われた方の参考になれば幸いです。

qiita.com

CI 環境構築

CI マシンではプルリクエストを出したときに実行されるジョブと、プルリクエストを master にマージしたときに実行するジョブがあります。前者のジョブはアプリケーションのテストコードを実行しています。後者の master にマージしたときに実行されるジョブでは iOS/Android アプリのバイナリ生成と社内配信、CodePush の Production-test環境への JS バンドルのデプロイを行っています。CodePush の Staging環境へのデプロイは開発者が手元でビルドしたバンドルを手動でデプロイします。そうすることで master にマージする前のプルリクエスト段階のコードもチームメンバーの Stagingアプリに配信することができ、デザイナとのコミュニケーションに有用です。実際にユーザーさんが触れる CodePush の Production環境へのデプロイは Production-test環境にデプロイされたバンドルを Production環境へコピーする(promote)という形で行われます。promoteの実行はチャットボットを介して Rundeck上のデプロイジョブを実行することで行っています。アプリバイナリのデプロイと CodePush のデプロイの構造をそれぞれ図1, 図2にしました。

▼図1: アプリバイナリのデプロイ

f:id:morishin127:20180416154243p:plain

▼図2: CodePush のデプロイ

f:id:morishin127:20180416154227p:plain

ログ収集

クックパッドには複数のモバイルアプリで共通して利用しているログ収集基盤があり、「クックパッド MYキッチン」のユーザーのイベントログ等の情報もそこへ送っています。ログ収集基盤というのはクックパッドのデータ活用基盤 - クックパッド開発者ブログで触れられている基盤のことで、モバイルアプリは Logend と呼ばれる社内のログ送信用エンドポイントへログを送り、Logend は fluentd を介して Amazon S3 にログを蓄積しています。S3 に蓄積されたログは社内のデータウェアハウスにロードされ、開発者はそこで分析を行います。データ活用の基盤に関して詳しくは上述の記事をご覧ください。

アプリからログを送信するに当たってバッファリングやリトライの機構が必要になりますが、これまでのクックパッドのモバイルアプリでは Puree というライブラリがその機構を担っていました。Puree には iOS/Android 両方のライブラリがありましたが、React Native から利用できる JavaScript 版は存在しなかったため、「クックパッド MYキッチン」の開発に際して作られました。

github.com

Puree に関して詳しくは過去の記事をご覧ください。

おわりに

いかがでしたでしょうか。これから React Native でやっていこうとしているやっていき手の皆さんの参考になれば幸いです。明日は@101kazさんから React Native プロジェクトの Android 環境整備のお話です、お楽しみに!

クックパッドでは毎日の料理を楽しみにするために、より良い技術を選択し、より速くユーザーさんに価値を届けられるサービス開発エンジニアを募集しています。興味を持っていただけましたら是非気軽にご連絡ください。話をしてみたいけど応募はちょっとという方は@morishin127にDMしていただいても大丈夫です🙆

👋

Viewing all 800 articles
Browse latest View live