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

GraphQL Asia 2019 で登壇しました

$
0
0

こんにちは。メディアプロダクト開発部の我妻謙樹(@itiskj)です。 ソフトウェアエンジニアとして、広告配信システムの開発・運用を担当しています。好きな言語は Go と TypeScript です。

先日、GraphQL に関してアジア圏初の大型カンファレンス、GraphQL Asiaがバンガロールで開催されました。元 Facebook で GraphQL の策定者の一人である Lee Byron を始めとし、Twitter, PayPal, Airbnb, Atlassian などのエンジニアが登壇し、GraphQL の導入事例やベストプラクティスの紹介が行なわれました。私も CFP が通過し発表してきたので、他の登壇者の内容と合わせて紹介いたします。

GraphQL Asia 2019 での発表

全発表資料は後ほど https://www.graphql-asia.org/にて公開されるとのことですが、先にいくつかピックアップしてご紹介します。

BrikL - A GraphQL native

まず、GraphQL Asia 2019 の主催者の一社である BrikLから、GraphQL を用いた自社製品の開発の変遷について紹介がありました。

個人的には、S3/DynamoDB/Elasticsearch などの外部サービスを Directivesを用いて宣言している点がユニークで学びでした。

f:id:itiskj:20190422134337p:plain
graphql asia brikl slide capture

APIS.GURU - GraphQL Tools are easy or how to write one in less than 100 lines

https://github.com/graphql/graphql-jsのコミッタや https://github.com/APIs-guru/graphql-voyagerのメンテナをしている OSS 開発者からの発表でした。

GraphQL tool を書くことは思ったより簡単であることを、実際に demo で GraphQL coverage tool を書きながら伝える内容でした。

GraphQL 界隈は、基本的なものに関しては出揃ってきた感がありますが、エコシステム全体的にはツールも production ready でないものが多かったり、作りかけのものが多かったりと、成熟期には達していません。GraphQL community に対して、ツールを書くことに敷居を下げ、ヒントや手法を与えた、という意味でこの発表はとても有意義なものでした。

Airbnb - GraphQL @ Airbnb

Airbnb における GraphQL 活用事例の紹介でした。

Airbnb では、monolithic な Rails アプリ、いわゆる "Monorail"が 10 年以上稼働していました。2 年前から Airbnb SOA というアーキテクチャを導入し、徐々に service oriented な構成へと移行しているとのこと。その中でも、presentation layer に GraphQL Gateway を導入しているとのことでした。

この発表の最大の価値は、RPC フレームワークであるFacebook's branch of Apache Thriftと GraphQL を併用した場合のアンチパターン及びエッジケースについて紹介されていることでした。おそらくこの組み合わせを大規模なサービスで用いているのは Airbnb が初めてとのことで、彼らならではのチャレンジから生まれる知見が世に出たことは、今後の GraphQL community の資産となることでしょう。

発表資料

メディアプロダクト開発部のうち、動画領域を担当するプロダクト開発グループでは AWS AppSync を利用しており、GraphQL および AppSync の活用事例として、以下のような資料が公開されています。

一方、私が所属する広告領域を担当するマーケティングサービス開発グループでも、社内の広告管理システムにおいて GraphQL を導入しています。今回は、こちらのシステムに GraphQL を入れるにあたって行った技術選定の過程や、実際に GraphQL を利用して得た知見を共有する、という内容で発表を行いました。

GraphQL の導入事例は他にも Tokopedia, Intuit や Phillips, Adobe からもいくつかありましたが、どの会社も違った課題を抱えており、それぞれのユースケースの講演も参考になりました。

感想

国際カンファレンスに参加したのは初めてだったのですが、Speaker として参加したからこそ得られた知見や経験が非常に貴重でした。というのも、Speaker 同士の交流会や市内観光などもカンファレンススタッフによって予定されていたのですが、それらに参加する中で、リアリティに富んだ新鮮度の高い情報(GraphQL の欧米圏における浸透具合や、今必要とされているツールや技術)のみならず、決して SNS では得られることのできないような情報についても聞くことができたのは、非常に有意義な点でした。また、彼らと接点を持てたことも大きいです。

発表資料は後からオンラインに公開されるので世界中どこに住んでいても同じ情報にアクセスできますが、やはり直接会うからこその経験を得ることができるのも、カンファレンス(国内・国外問わず)に参加することの意義だということを再認識しました。

また、国外ではカンファレンス渡航費を会社が出してくれないという Speaker もそれなりにいました。今回、渡航費をサポートしてくれたり、出張中に業務をカバーしてくれたりした同僚や上司の方々には感謝しかありません。ありがとうございました。

まとめ

今までは、"Header Bidding 導入によるネットワーク広告改善の開発事情""cookpad storeTV の広告配信を支えるリアルタイムログ集計基盤"など、まさに広告領域を代表するような技術およびシステムについて紹介してきました。

しかし、広告領域ではネットワーク広告や配信サーバーのみならず、社内入稿システムも開発しています。そして、その入稿システムを利用する業務推進チームや制作チームなどが、会社の広告事業の売上を支えてくださっています。そのメンバーの業務効率を上げるために、社内入稿システムの開発及び改善・保守にも力を注いでいます。今回の記事をきっかけに、また違った観点からの技術的チャレンジもお伝えできれば嬉しいです。

広告領域は、技術的にチャレンジングな課題も多く、かつ事業の売上貢献に直結することが多いだけでなく、入稿から配信まで様々なシステムが複雑に絡み合う、非常にエキサイティングな領域です。ぜひ、興味を持っていただけたら、Twitterからご連絡ください。

また、メディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、以下をご覧ください。


RubyKaigi 2019 Cookpad Daily Ruby Puzzles の正解と解説

$
0
0

Ruby 開発チームの遠藤です。RubyKaigi 2019 が無事に終わりました。すばらしい会議に関わったすべてのみなさんに感謝します。

開催前に記事を書いたとおり、クックパッドからはのべ 7 件くらいの発表を行い、一部メンバは会議運営にもオーガナイザとして貢献しました。クックパッドブースでは、様々な展示に加え、エンジニアリングマネージャとトークをする権利の配布やクックパッドからの発表者と質疑をする "Ask the speaker"など、いろいろな企画をやりました。

クックパッドブースの企画の 1 つとして、今年は、"Cookpad Daily Ruby Puzzles"というのをやってみました。Ruby で書かれた不完全な Hello world プログラムを 1 日 3 つ(合計 9 問)配布するので、なるべく少ない文字を追加して完成させてください、というものでした。作問担当はクックパッドのフルタイム Ruby コミッタである ko1 と mame です。

RubyKaigi の休憩時間を利用して正解発表してました↓

問題と解答を公開します。今からでも自力で挑戦したい人のために、まず問題だけ掲載します。(会議中に gist で公開したものと同じです)

問題

Problem 1-1

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" .. "Hello"} world"

Problem 1-2

puts&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

Problem 1-3

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2 * PI)
Out.puts("Hello world" *
         Count.abs.round)

Problem 2-1

defsay
  -> {
    "Hello world"
  }
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

Problem 2-2

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.yield"Hello world"end

puts e.next

Problem 2-3

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say i
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

Problem 3-1

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say :p

Problem 3-2

defsay s, t="Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

Problem 3-3

defsay"Hello world"iffalse&& false# Hint: No hint!end

puts say

以下、ネタバレになるので空白です

自力で解いてみたい人は挑戦してみてください。











解答

では、解答です。重要なネタバレですが、すべての問題は 1 文字追加するだけで解けるようになってます。

Answer 1-1

作問担当は ko1 でした。問題再掲↓

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" .. "Hello"} world"

解答↓

# Hint: Use Ruby 2.6.
puts "#{"Goodbye" ..; "Hello"} world"

"Goodbye" ..の後に ;を入れています。これにより、Ruby 2.6 で導入された終端なし Range (Feature #12912) になります。この Range は使われずに捨てられ、"Hello"が返り値になって文字列に式展開されるので、Hello worldが出力されるようになります。

この問題の勝者は tompngさんでした。なお、tompng さんは 1-2 と 1-3 も最初に 1 文字解答を発見しましたが、勝者になれるのは 1 人 1 問だけ、としました。

Answer 1-2

作問担当は ko1 でした。問題再掲↓

puts&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

解答↓

puts$&.then {
  # Hint: &. is a safe# navigation operator."Hello world"
}

&.の前に $を入れて $&.にしています。$&は正規表現にマッチした部分文字列を表す特殊変数です。ここでは正規表現マッチは使われていないのでこの変数は nilになりますが、重要なのはこの書換によって putsメソッドに $&.then { "Hello world" }を引数として渡す、というようにパースされるようになることです。thenメソッドはブロックの返り値を返すので、この引数は文字列 "Hello world"になり、めでたく Hello world プログラムになります。

この問題の勝者は Seiei Miyagiさんでした。

Answer 1-3

作問担当は mame でした。問題再掲↓

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2 * PI)
Out.puts("Hello world" *
         Count.abs.round)

解答↓

includeMath# Hint: the most beautiful equationOut, *,
     Count = $>,
             $<, E ** (2i * PI)
Out.puts("Hello world" *
         Count.abs.round)

E ** (2i * PI)というように iを入れました。

これはちょっと知識問題で、e^{i\pi} = -1という公式を使います。この公式は「オイラーの公式」と呼ばれ、ヒントにあるように「最も美しい等式」などと言われることもあります。Ruby で書くと Math::E ** (1i * Math::PI) #=> -1です。E ** (2i * PI)はそれの二乗になので、浮動小数点数演算の誤差もあるのでおよそ 1 になります。Count,abs,roundによって正確に 1 になって、Hello world プログラムとなります。

この問題には別の意図もありました。これらの問題は 1 文字で解けると知っていたら、ブルートフォース(いろんな箇所にいろんな文字を挿入して実行してみるのを網羅的に試す)によって頭を使わずに解けてしまうのですが、この問題はそれをじゃまするために用意しました。というのは、$<の前に *を挿入して *$<とすると、標準入力を配列化する演算となり、標準入力を待ち受けて動かなくなるようになります。よって、下手にブルートフォースをするとここで実行が止まります。ただ、このトラップにひっかかった人はいたかどうかはわかりません。

この問題の勝者は pockeさんでした。

Answer 2-1

作問担当は ko1 でした。問題再掲↓

defsay
  -> {
    "Hello world"
  }
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

解答↓

defsay
  -> {
    "Hello world"
  }.
  # Hint: You should call the Proc.yieldend

puts say { "Goodbye world" }

}..を追加してあります。これにより、yieldはブロック呼び出しではなく、上の Proc 式に対して yieldメソッドを呼び出すようになります。Proc#yieldProc#callの別名なので、このラムダ式が実行され、"Hello world"を返すようになります。

この問題の勝者は Shyouheiさんでした。

Answer 2-2

作問担当は mame でした。問題再掲↓

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.yield"Hello world"end

puts e.next

普通に考えたら、次の 2 文字の解答になります。

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.
  g.yield "Hello world"end

puts e.next

Enumerator の最初の要素として "Hello world"yieldメソッドで渡し、Enumerator#nextによってそれを取り出し、それを表示します。Enumerator についてはドキュメントの class Enumeratorを参照ください。

ヒントに従って考えると、次の 6 文字の解答にたどり着きます。

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentially Fiber.Fiber.yield "Hello world"end

puts e.next

Enumerator は Fiber のラッパのようなものなので、実はブロックの中で Fiber.yieldを呼ぶことでも要素を渡すことができ、上のプログラムと同じように動きます。

ただしこれは 6 文字も追加しているのでまったく最短ではありません。どうすればよいかというと、次が 1 文字解答です。

解答↓

e = Enumerator.new do |g|
  # Hint: Enumerator is# essentiallyFiber.
  yield "Hello world"end

puts e.next

コメントの中の essentiallyFiber.の間に改行文字を追加しました。コメントの中にある Fiber.という文字列を利用するのがミソでした。すべての問題に適当なヒントコメントが書いてあるのは、この問題にだけヒントコメントをもたせることで不自然になってしまわないようにするためでした。

余談ですが、より面白い想定回答は↓でした。

e = Enumerator.new do |g|
  # Hint: Enumerator is#
 essentially Fiber.
  yield "Hello world"end

puts e.next

essentiallyの前に改行を入れています。essentiallyは関数呼び出しとみなされますが、引数が Fiber.yield "Hello world"なのでこちらが先に評価され、essentiallyが実際に呼び出されることはなく、正しく動きます。この解答にたどり着いた人はいなかったようです。

この問題の勝者は youchanさんでした。

Answer 2-3

作問担当は mame でした。問題再掲↓

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say i
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

2 文字解答はたくさんあります。3535-8に変えたり、say jsay j*2に変えたり、or putsor 0;putsと変えたり、いろいろなやり方が発見されていました。

1 文字解答は、意外と理詰めでたどり着けるようになっています。sayメソッドは「$sを右に 2 ビットシフトし、引数 nを足す演算」です。ヒントにあるとおり 35 の二進数表現を考えると 10 00 11になります。それぞれ二進数で 2, 0, 3 なので、say(2); say(0); say(3)という順序で sayを呼び出せばいいことがわかります。say i; say j; say ksay(1); say(2); say(3)なので、say kはいじらなくて良さそうです。また、sayの引数を省略したら 0になるので、say i; say jをうまくいじって say j; sayという意味にする方法はないか、と考えます。ということで答えです。

解答↓

$s = 0defsay(n = 0)
  $s = $s * 4 + n
end

i, j, k = 1, 2, 3

say if
say j
say k

# Hint: Binary representation.$s != 35or puts("Hello world")

say iのあとに fを足して、後置 if 文にします。条件式は次行の say jです。これにより、先に say jが評価されて、say jは真の値を返すので、ifの中の sayが無引数で呼び出されます。それから say kが呼ばれることで、所望の挙動になります。

この問題の勝者は k. hanazukiさんでした。

Answer 3-1

作問担当は ko1 でした。問題再掲↓

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say :p

解答↓

defsay s="Hello", t:'world'"#{ s }#{ t } world"end# Hint: Arguments in Ruby are# difficult.

puts say t:p

say :psay t:pに書き換えています。これにより、シンボルの :pを渡していたところから、キーワード tのキーワード引数として pを渡すように変わります。pKernel#pの呼び出しで、無引数の場合は単に nilを返します。よって、s = "Hello"かつ t = nilになり、"#{ s }#{ t } world""Hello world"になります。

この問題の勝者は Akinori Mushaさんでした。

Answer 3-2

作問担当は mame でした。問題再掲↓

defsay s, t="Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

解答↓

defsay s, t=#"Goodbye "# Hint: You can ignore a warning.
  s = "#{ s }#{ t }"
  t + "world"end

puts say :Hello

t=#"Goodbye "というように、オプショナル引数のデフォルト式をコメントアウトしています。これにより、次の行にある式がデフォルト式になります。この場合、s = "#{ s } #{ t }"がデフォルト式です。sはすでに受け取った引数で :Helloが入っています。引数 tは未初期化の状態で参照され、これは nilになります(コメントにあるとおり、それは問題ないです)。よってこのデフォルト式は "Hello "という文字列になります。あとはそのまま。

この問題の勝者は DEGICAさんでした。

Answer 3-3

作問担当は mame でした。問題再掲↓

defsay"Hello world"iffalse&& false# Hint: No hint!end

puts say

解答↓

defsay"Hello world"if%
    false&& false# Hint: No hint!end

puts say

ifの後に %を書き足します。答えを見ても意味がわからない人のほうが多いのではないでしょうか。

Ruby には %記法というリテラルがあります。%!foo!と書くと、文字列リテラル "foo"と同じです。デリミタ(先の例では !)には、数字とアルファベット以外の任意の文字を使うことができます。上の例は、このデリミタとして改行文字を使っています。わかりやすく、デリミタを改行文字から !に書き換えると、こうなります。

defsay"Hello world"if%!    false && false!# Hint: No hint!end

puts say

後置 if の条件式に文字列リテラル(常に真)を書いたことになるので、このメソッド sayは常に "Hello world"を返します。

なお、%記法のデリミタに改行文字や空白文字を使える仕様は、matz が「やめたい」と言っていたので、将来廃止されるのかもしれません。

この問題の勝者は cuzicさんでした。

まとめ

Cookpad Daily Ruby Puzzles の問題と解答と解説でした。今回はわりと手加減せずに Ruby の仕様の重箱の隅をつつくような問題ばかりでしたが、「クックパッドのパズルがおもしろかった」という声も結構いただきました。まだやっていないかたは、今からでも(上の解説を見ずに)楽しんでいただければ幸いです。

こういうパズルが入社試験として出るわけではありませんが、このパズルをきっかけにクックパッドに興味を持ってくれた人は、↓からぜひ応募してください。

cookpad.jobs

Special thanks

  • hogelog:クックパッドのブースに「超絶技巧パズル(ってなに?)置いておこう」と発案した人
  • sorah:シュッとチラシをデザインした人
  • ブースにいた全員:パズルの配布や運営をした人たち
  • 参加してくれた全員:解けた人も解けなかった人も

おまけ

もっと遊びたい人のためにエクストラステージを用意しておきました。答えはないので考えてみてください。

Extra 1

作問担当:mame

Hello = "Hello"# Hint: Stop the recursion.defHello
  Hello() +
    " world"end

puts Hello()

Extra 2

作問担当:mame

s = ""# Hint: https://techlife.cookpad.com/entry/2018/12/25/110240
s == s.upcase or
  s == s.downcase or puts "Hello world"

Extra 3

作問担当:ko1

(1 文字解答が 2 つあります)

defsay
  s = 'Small'
  t = 'world'
  puts "#{s}#{t}"endTracePoint.new(:line){|tp|
  tp.binding.local_variable_set(:s, 'Hello')
  tp.binding.local_variable_set(:t, 'Ruby')
  tp.disable
}.enable(target: method(:say))

say

XcodeGenによる新時代のiOSプロジェクト管理

$
0
0

こんにちは。モバイル基盤部の@giginetです。平成最後のエントリを担当させていただきます。

iOSアプリの開発では、Xcodeが生成するプロジェクトファイルである、*.xcodeprojをリポジトリで共有するのが一般的です。

しかし、この運用は大規模なプロジェクトになるほど、数多くの課題が発生します。

クックパッドiOSアプリは巨大なプロジェクトであり、通常の*.xcodeprojによる管理には限界が生じていました。

そこで、昨年秋にXcodeGenというユーティリティを導入し、プロジェクト管理を改善したので、その知見をお伝えします。

f:id:gigi-net:20190425150234g:plain

従来のプロジェクト管理の問題点

ファイル追加の度にコンフリクトが発生する

*.xcodeprojファイルはプロジェクトに含まれるソースファイルの管理を行っています。

開発者がプロジェクトにファイルを追加すると、このプロジェクトファイルが更新されることになります。

そのため、同時に数十人が開発するクックパッドiOSアプリの開発環境では、プロジェクトファイルのコンフリクトが日常茶飯事で、解消に多くの工数が発生していました。

レビューがしづらい

*.xcodeprojは特殊なテキストデータで表現されますが、とても人間が読める物ではなく、差分をレビューするのは困難です。

簡単なファイルの移動でも複数の差分が発生します。

大きな変更に向かない

クックパッドiOSアプリでは、巨大なビルドターゲットを分割し、ビルド時間を改善する『霞が関*1と呼ばれる取り組みを行っています。

マルチモジュール化を行っていくに当たって、ドラスティックな*.xcodeprojの変更に耐える必要がありました。

単なるファイル追加であれば、コンフリクトの解消や、レビューの難しさという問題はまだ解決可能でしたが、ターゲットやBuild Configurationの追加、大量のファイルの移動といったプロジェクトの変更をもはや人類が適切に扱うことは困難でした。

XcodeGenとは

そこで導入したのがXcodeGenです。

XcodeGenは、XcodeのプロジェクトデータをYAMLで記述し、定義から冪等に*.xcodeprojを生成できるユーティリティです。

このようなYAMLを定義し

targets:Cookpad:type: application
    platform: iOS
    sources:- path: Cookpad

XcodeGenを実行すると、*.xcodeprojを自動生成することができます。

$ xcodegen
Loaded project:
  Name: Cookpad
  Targets:
    Cookpad: iOS application
  Schemes:
    Cookpad
⚙️  Generating project...
⚙️  Writing project...
Created project at Cookpad.xcodeproj

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

このツールの導入により、数々の問題が解消できました。

導入して良かったこと

ファイルツリー構成が強制される

まず、*.xcodeprojの問題点として、ファイルシステム上のツリーと、プロジェクトの保持するツリーが一致しないという問題がありました。

追加されるファイルは、ファイルシステム上の階層と必ずしも一致しませんし、思い思いに追加されるため、プロジェクトが煩雑になります。

XcodeGenによる生成では、ファイルツリーからプロジェクト構造を生成するため、この不一致が解消されます。

targets:Cookpad:sources:- path: Cookpad

例えば、この指定では、Cookpad以下のファイル全てがCookpadターゲットに所属するため、ファイルシステム上の位置を強制することができます。

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

コンフリクト解消が不要に

上記の仕様による一番わかりやすい恩恵は、プロジェクト差分のコンフリクトからの解放です。

従来の*.xcodeprojでは、開発者がソースファイルを追加する度に更新が入り、差分が発生していました。

しかし、XcodeGenの仕様においては、ソースファイルの追加時にリポジトリへのファイル追加以外の操作が不要になり、一切のプロジェクトのコンフリクトがない世界が到来しました。

ターゲットの追加が容易に

上記の特性はビルドターゲットの追加にも役立ちます。XcodeGenでは、わずか数行のYAMLの定義のみでビルドターゲット追加を行うことができます。

targets:CookpadCore:type: framework
    platform: iOS
    sources:- CookpadCore

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

また、ターゲット間のソースファイルの移動も簡単です。 従来は、1ファイルごとにどのビルドターゲットでビルドされるか、という情報が保持されていたため、ファイルを移動する度にプロジェクトに大きな差分が発生していました。

しかし、XcodeGenでプロジェクトを生成することにより、特定のディレクトリ下のソースコードは、必ず特定のビルドターゲットに含まれることを保証することができるようになりました。

これにより、ビルドターゲット間の移動は単にgit mvするだけで済むようになりました。

この特性は、プロジェクトのマルチモジュール化に大きく役立ちました。

XcodeGenの導入

XcodeGenを導入したい場合、残念ながら既存の*.xcodeprojから簡単にプロジェクト定義ファイルを生成する方法はありません。

基本的には、ドキュメントを追いながら、生成結果を目で見て確認していきます。

GUIでの設定値をプロジェクト定義に忠実に移植する為には、既存の*.xcodeproj/project.pbxprojをテキストエディタで開き、設定値を探していくという地道な作業も発生しました。綺麗なプロジェクト定義を記述するには、Xcodeプロジェクトの構造をよく理解している必要があるでしょう。

最終的にクックパッドiOSアプリは、400行程度のYAMLファイルでほぼ元の挙動を再現することができました。

そこで、複雑なプロジェクトをXcodeGenの定義ファイルで記述するためのテクニックをいくつかご紹介します。

SettingGroup

まずはSettingGroupの機能です。 複数のターゲットで共通して利用したいビルドフラッグなどの設定をSettingGroupとして定義しておき、利用したいターゲットで読み込んで使用することができます。

settingGroups:SharedSettings:configs:OTHER_SWIFT_FLAGS: -DDEBUG
targets:Cookpad:type: application
    settings:groups:[Shared]OtherFramework:type: framework
    settings:groups:[Shared]

パッケージ管理

プロジェクトにCarthage*2でインストールしたライブラリを統合したい場合も簡単に記述できます。

targets:Cookpad:type: application
    platform: iOS
    dependencies:- carthage: RxSwift

このcarthage指定を用いるだけで、Embed Frameworkの設定や、Frameworkのコピーなど、Carthageの利用に必要な設定を自動で行ってくれます。

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

一方で、CocoaPodsを併用する場合、事態は複雑です。

CocoaPodsは、プロジェクトファイルにあとからビルド設定の注入を行う必要があるからです。現在のXcodeGenでは、プロジェクト定義だけでそれを管理することはできません。

例えば一連の処理をMakefileに記述するというアプローチが考えられるでしょう。

xcodegen
bundle exec pod install

ソースコード生成

Sourceryなどのコードジェネレーションと、XcodeGenを併用する場合には少し工夫が必要です。

XcodeGenによるプロジェクトツリーは、通常、存在しているソースファイルのファイルシステム上の構成により構築されます。

一方で、ソースジェネレーションを行うためには、他の定義からソースコードを生成するため、プロジェクトツリーが必要になります。 このように、鶏と卵問題が発生してしまうのです。

そこで、optionalオプションで、生成前のソースファイルの参照だけ持ち、先にプロジェクトを構築し、あとからソースジェネレーションを行うことでこの問題を解決しています。

targets:CookpadTests:type: unit-test
    platform: iOS
    sources:- path:"CookpadTests/AutoGenerated/AutoGenerated.swift"optional:truetype: file

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

今後の課題

XcodeGenを大規模に運用している中で以下のような問題が発生しました。

主にプロジェクトの生成時間に関する課題で、現在解決している最中です。

CocoaPodsを利用するときの生成速度

CocoaPodsの利用時に、プロジェクト生成後、毎回pod installが必要なことは、先ほど触れました。

XcodeGenは冪等に実行されますが、それ故に、プロジェクト生成ごとにCocoaPodsによるビルド設定の注入を毎回行う必要が出てくるのです。

この仕組みでは、XcodeGenの生成ごとに毎回パッケージインストールが走り、数十秒の待ち時間が発生しています。

この問題を解決するアイディアはいくつかあります。

まずは、CocoaPodsによるプロジェクトの設定変更を無効化し、自分で依存関係を記述する方式です。*3

もう一つの方法は、CocoaPods 1.7で利用可能になったincremental_installを有効にすることです。 このオプションを有効にすることで、差分がある依存関係のみが生成されるため、プロジェクト生成速度が改善すると踏んでいます。

いずれの方式も構想段階でまだ実用できていません。

プロジェクトキャッシュの問題

毎回プロジェクトファイルを上書きしていると、希にXcodeのビルドキャッシュが無効になり、フルビルドが発生してしまう問題にも遭遇しています。

この問題は、生成されたプロジェクトの差分が発生しないようにしても再現しており、解決していく必要があります。

まとめ

ご覧いただいたように、XcodeGenを使ったプロジェクト運用は、クックパッドiOSアプリほどの規模であっても十分に実用できていると言えます。

*.xcodeprojで苦しむのは平成までです。皆さんもプロジェクトを破壊して新しい時代を迎えませんか。

クックパッドではXcodeプロジェクトのコンフリクト解消で消耗したくないエンジニアを募集しています。

*1:詳しくは2月に行われたCookpad Tech Confの資料をご覧ください https://techconf.cookpad.com/2019/kohki_miki.html

*2:ちなみに筆者は最近Carthageのコミッターになりました 💪

*3:この手法は integrate_targetsというオプションを有効にすることで実現できますが、難しいのでここでは解説しません

Google I/O 2019 に参加しました

$
0
0

こんにちは、技術部品質向上グループの加藤です。 普段は主にモバイルアプリのテスト周りに関わっています。 今回は先日開催された Google I/O 2019 に参加したので、現場の環境や気になったセッションを初参加の目線で書いていきます。

Google I/O 2019

毎年5月ごろに Google が開催するカンファレンスです。 Google が展開するプロダクトやサービスに関する情報が多く発表され、カンファレンス冒頭にある Keynote は毎年非常に注目を集めています。 カンファレンス中は広い会場で多くの発表が行われていますが、発表のセッション以外にも多くの企画があります。

Office Hour & Sandbox

カンファレンス中にはセッションが行われる施設とは別にいくつもの施設が併設されています。 そのなかでも今回は Office Hour と Sandbox について触れたいと思います。

Office Hour では時間帯ごとにテーマが定められ、枠を予約することでテーマに沿った内容についてそれに関わる Google 社員と直接会話することができます。 テーマは非常に多岐に渡り、Kotlin for Android のような一般的なものから、R8 / shrinking app code のような少しニッチな部分まで数多くのテーマが存在しています。 もちろんコミュニケーションには英語が必要とされますが、社員の方も熱心に耳を傾けてくれるため英語に自信がない自分でも内容を十分に伝え合う程度には会話をすることが可能でした。

Sandbox ではテーマごとにテントが設置されており、その中でカジュアルに Google 社員と会話をすることが可能です。 Office Hour と違い予約制ではないため、常に人に溢れているような環境ですが想像以上にしっかりと会話をすることができ、Office Hour と合わせて直接コミュニケーションを取ることができる場となっています。 ちなみに人が溢れているおり必然的に場がかなり騒がしくなっているので、強い気持ちでコミュニケーションをとる必要がありました。

I/O 参加前に社内で Android や Firebase 関連の質問と要望を取り纏めていましたが、Office Hour 及び Sandbox で全ての内容を Google 社員と直接議論することができました。 特に要望に関しては実際のユースケースを合わせて会話をすることで、一方的に要望を伝えるだけでなく、現状取り組めるアプローチ等の提案もあり非常に価値がありました。

セッション紹介

ここからは I/O のセッションで興味が惹かれたものを少し紹介します。

※筆者は Android アプリの開発に関わっているので、内容は Android のものばかりなってしまっています。

New Tools to Optimize Your App's Size and Boost Installs on Google Play

https://www.youtube.com/watch?v=rEuwVWpYBOY

タイトルの通り、アプリサイズを最適化(サイズダウン)することでアプリインストールを促進させるという内容です。 Play Store 上でのいくつかの変更と合わせてアプリインストールへ如何につなげるか、具体的にどの程度の効果が見込めるのかという話でした。 いくつか Play Store の変更点がありましたが、私が注目した点としては以下の2点です。

  • Play Store 上で表示されるアプリ評価で評価者のアプリ利用年数に応じて重み付けが始まる
  • Play Console 上にアプリサイズの項目が追加

1点目については昔から長くリリースを続けているアプリであればあるほど、開発側にとってメリットとなるように受け取ることができます。 利用年数に対しての重み付けの具体的なロジックは好評されていませんが、弊社のモバイルアプリに関しては評価を上げることとなりました。 新しいロジックによる評価への切り替えは 2019年8月 から始まるようです。

2点については情報が多いのですが、まとめると Play Console 上でアプリサイズについての情報をいくつか確認できるようになるようです。 設定した類似アプリのアプリサイズの中央値との比較や、端末の空き容量が 1GB 未満の端末の利用者やその環境でアンインストールを行った利用者を確認できるようになるとのことです。 App Bundle も合わせて、Google のアプリサイズ減少への強い意向が感じられる機能です。

他にもアプリサイズが大きくなってしまうゲームアプリ向けに対しての施策や、デバイス間でのファイル共有の話題がでました。

Customizable Delivery with the App Bundle and Easy Sharing of Test Builds

https://www.youtube.com/watch?v=flhib2krW7U

昨年の Google I/O で発表された App Bundle ですが、次のレベルとしてコンテンツ配信についての新たな仕組みが紹介されました。 In-app updates はそのうちの1つですが、緊急の強制アップデートとユーザが選択可能なアップデートの2種類の仕組みが提供されるとのことでした。

また新たな仕組みに合わせてそれらをテストするツールについてもアップデートがありました。 従来 App Bundle や Dynamic Feature を検証する際には、Play Console 上にアプリをアップロードする必要があり、なおかつアップロードされたテスト版のアプリの利用側にも登録が必要であるなど制約が存在していました。 これらの問題に対して Internal App Sharing の発表がありました。

Internal App Sharing では、version code の制限と配信対象者の登録が必要なくなり、インストールリンクを踏むだけでテスト番のアプリを利用することが可能となりました。 各インストールリンクごとに利用者の制限はあるようですが、これにより App Bundle を利用したアプリの検証の難易度が下がることとなりそうです。 App Bundle に関わらず幅広い用途が見込めるため、社内の多くのチームで検証が効率化されることを期待しています。

Build Testable Apps for Android

https://youtu.be/VJi2vmaQe6w

Testable な実装を目指して、テストピラミッドから実際の実装例までを包括的に説明する発表でした。 昨年の Google I/O で発表された Android Test を利用したモダンな実装例が紹介され、同日にこのセッションに合わせて Android Testing の Codelabが更新されたので、ご興味ある方はぜひお試しください。 また昨年テスト実行環境として、Nitrogen という ART や JVM、 Firebase Test Lab 等実行環境を意識することなくテストの実行を可能とする概念が発表されました(Jetpack の燃料ということで Nitrogen という命名)。 これまで完全に謎なものとなっていましたが、今回 Early Access Program が発表されました。 まだ全貌は明らかになっていませんが、いち早く応募してみなさんの目で確かめてください。

参加してみての感想

今回の Keynote 中で発表されたように すべての人に向けて 技術を提供するという目的に向けた内容が多かった印象でした。 紹介しなかったセッションでもアプリサイズの最小化や機械学習技術のアクセシビリティへの応用など発表や展示が非常に多く見受けられました。

また昨今のカンファレンスでは、セッションの発表内容がインターネット上で公開されることが多いなかで、 発表された多くの内容について直接開発者とコミュニケーションをとることで、実際の開発にどう活かせるのかという点を確認することができ、現地に赴く重要性が強く感じられました。

f:id:ksfee:20190604210755p:plain

クックパッドでは Google I/O で発表されるような最新の技術をガンガン取り込んでいく Android エンジニアを募集しています。

Working with AWS AppSync on iOS

$
0
0

Hi, this is Chris from Cookpad's Media Product Globalization department.

I'm going to discuss some pitfalls we've run into while working with AWS AppSync for iOS. This post is not a primer on AppSync, nor is it a general review of whether you should or should not use AppSync for your project. My goal is to point out some various lessons we've learned so far that weren't obvious at first. My second disclaimer is that AppSync itself is under active development, so you can probably expect that some of the points I cover in this post will be altered in the future.

Background

My team has been working on a standalone iOS app for shooting, editing, and sharing 1-minute, top-down recipe videos called Cookpad Studio (here's a completed example video). At the time of this posting, our app is still in closed beta.

The shooting and editing parts are local to an iOS device.

f:id:christopher-trott:20190614105506p:plain
Video editor screens

But the sharing to the community part relies on a server backend to share data between users.

f:id:christopher-trott:20190614105609p:plain
Community screens using AWS AppSync

For the community part of the app, we decided to use AWS AppSync and various AWS backend technologies as an alternative to more established frameworks like Ruby on Rails.

Our AppSync setup is a bit different than the standard use case. AppSync is designed to be configured by app developers through the Amplify CLI. Since our team has dedicated backend engineers, we've opted to do most configuration and server development through the AWS component services directly (e.g. AWS Lambda, DynamoDB, etc.).

SDKs

AppSync on the iOS side is an amalgamation of a few different AWS SDKs. Luckily, all of them are open source and you can dive into their code when necessary. The three SDKs we're using so far are:

  • Authentication - The SDK that facilitates user authentication via Cognito.
  • Storage - The SDK that facilitates file uploads/downloads to/from S3.
  • API - The GraphQL client that facilitates fetching and mutating records in DynamoDB.

The first thing to understand about these SDKs is that they're all very different. They were written at different times by different teams with different technologies and have evolved with different goals in mind.

To give you an idea of what I mean by different, here's some various specs about each SDK:

  • Authentication
    • Objective-C & some Swift wrappers
    • Uses AWSTask, a fork of Facebook's Bolts Framework, for async communication, alongside Cocoa conventions (e.g. delegates, closures, GCD).
  • Storage
    • Objective-C
    • Uses AWSTask alongside Cocoa conventions.
  • API
    • Swift
    • Uses a custom Promise implementation for async communication, alongside Cocoa conventions.
    • Uses .graphqlconfig.yml for additional GraphQL configuration.

Authentication SDK

Singletons

I generally prefer to use initializer-based dependency injection over singletons. This is often unavoidable, even when only using Apple's first-party SDKs.

I was pleased to find that code completion gave me a couple different initialization options for AWSMobileClient, the primary class for interfacing with the Cognito authentication APIs. The most complete of the initializers being:

- (instancetype)initWithRegionType:(AWSRegionType)regionType
                    identityPoolId:(NSString *)identityPoolId
                     unauthRoleArn:(nullableNSString *)unauthRoleArn
                       authRoleArn:(nullableNSString *)authRoleArn
           identityProviderManager:(nullableid<AWSIdentityProviderManager>)identityProviderManager;

I went down this path, discovering later that using this initializer leaves the AWSMobileClient instance in a very broken state.

AWSMobileClient is actually a Swift wrapper and subclass of the Objective-C _AWSMobileClient class. Inside you'll find some code that certainly stretches my understanding of subclassing rules across Swift and Objective-C:

publicclassAWSMobileClient:_AWSMobileClient {
    staticvar_sharedInstance:AWSMobileClient= AWSMobileClient(setDelegate:true)
    
    @objcoverridepublicclassfunc sharedInstance() ->AWSMobileClient {
        return _sharedInstance
    }
        
    @objcpublicvarisSignedIn:Bool {
        get {
            if (operateInLegacyMode) {
                return _AWSMobileClient.sharedInstance().isLoggedIn
            } else {
                returnself.cachedLoginsMap.count >0
            }
        }
    }
    
    // ...
}

Additionally, the initialize method that must be called by the client references itself and several other singletons:

  • _AWSMobileClient.sharedInstance()
  • DeviceOperations.sharedInstance
  • AWSInfo.default() - reads from awsconfiguration.json in the bundle.
  • AWSCognitoAuth.registerCognitoAuth(...)

Takeaway: For this SDK and the other AWS SDKs, you have to use the singletons.

Keychain credentials

The Authentication SDK uses the keychain APIs to store user credentials securely.

We changed server environments a few times during development. First, we had a prototype environment, then changed to a more long-term development environment, and finally to a production development in parallel with the development environment. By environment, I mean the keys used to locate our apps resources (e.g. PoolId, Arn, ApiUrl, ApiKey, etc.).

A few of our team members had installed and ran a release build of the app in the prototype environment at some point, thereby storing some Cognito tokens in their keychain. When we switched to the development environment, we started seeing deadlocks during our authentication bootstrapping process. The bootstrapping process happens on a cold launch and runs the required asynchronous AWSMobileClient initialization methods.

The debugging steps of deleting the app and reinstalling did not work because the keychain contents are retained by iOS across app installs for the same bundle ID.

Once we had determined that AWSMobileClient could not handle loading "bad" environment user credentials – user credentials created with a different AWS configuration parameters – I had to create special builds for these devices that called AWSMobileClient.sharedInstance().signOut() immediately on launch.

We actually saw a similar deadlock in AWSMobileClient when running the app on the iOS simulator during development, which threw me off the trail a bit during debugging.

Takeaway: Be careful when changing environment configuration parameters.

Drop in Authentication UI

The Authentication SDK includes a drop-in UI. Because we wanted to ship our app to beta users as quickly as possible to start gathering feedback, I was particularly pleased that I wouldn't need to write a custom UI for authentication.

Unfortunately, we found a few dealbreakers that prevented us from using the drop-in UI.

First, the drop-in UI has no support for localization. Since our first market is Japan, we definitely needed the UI to support Japanese. The localization issue has appeared in other contexts as well, especially errors returned by the SDK. I would keep this point in mind if the product you're working on requires any other language besides English.

Second, I was planning on presenting the authentication view controller from our root view controller, an instance of UIViewController. I found that the entry point to the drop-in UI requires a UINavigationController:

+ (void)presentViewControllerWithNavigationController:(UINavigationController *)navigationController
                                        configuration:(nullable AWSAuthUIConfiguration *)configuration
                                    completionHandler:(AWSAuthUICompletionHandler)completionHandler;

This seemed like an odd requirement since the drop-in UI view controller seemed to be presented modally. Digging into the code, I came to the same conclusion as this GitHub Issue: the only API used is the UIViewController presentation API.

There's also this long-running GitHub Issue with feature requests for the drop-in UI.

Takeaway: Using the drop-in UI may not be feasible for your use case.

Is initialize an asynchronous task?

The signature of AWSMobileClient's required initialization method is:

public func initialize(_ completionHandler: @escaping (UserState?, Error?) -> Void)

From this signature, I would assume this function is asynchronous, and therefore anything that depends on the result of this call needs to wait until the completionBlock is called.

However, if we look at the implementation:

internalletinitializationQueue= DispatchQueue(label:"awsmobileclient.credentials.fetch")

publicfuncinitialize(_ completionHandler:@escaping (UserState?, Error?) ->Void) {
    // Read awsconfiguration.json and set the credentials provider here
    initializationQueue.sync {
        // ... full implementation
    }
}

I wasn't sure what to expect when stepping through this code, but it looks like if initialize is called on the main thread, the implementation within the sync closure continues to be executed on the main thread. After the completion handler is called within initialize and that code runs, control flow returns to the end of initialize.

f:id:christopher-trott:20190614105742p:plain
Callstack during `AWSMobileClient.initialize`

Takeaway: You can probably assume that AWSMobileClient.sharedInstance().initialize(...) is synchronous. However, if you're paranoid about the implementation changing at some point, treat it in your calling code as asynchronous.

Storage SDK

Initialization

Similar to our takeaway from the Authentication's section above about singletons, I recommend being extra cautious about the set up of your AWSS3TransferUtility instance.

Internally, AWSS3TransferUtility the class maintains a static dictionary of instances and a default instance.

// AWSS3TransferUtility.mstatic AWSSynchronizedMutableDictionary *_serviceClients = nil;
static AWSS3TransferUtility *_defaultS3TransferUtility = nil;

There are some directions in the API docs about how to register an instance with custom configuration options.

However, if you decide to use the default instance like I did, you need to set the service configuration in a different singleton before calling AWSS3TransferUtility.default() for the first time. (I only learned this by eventually finding my way to the implementation of AWSS3TransferUtility.default() after struggling for hours with various unauthorized errors at runtime when trying to perform uploads).

AWSServiceManager.default()!.defaultServiceConfiguration = AWSServiceConfiguration(region: .APNortheast1, credentialsProvider:AWSMobileClient.sharedInstance())
lettransferUtility= AWSS3TransferUtility.default()

Takeaway: Register your own AWSS3TransferUtility. Or if you want to use the default, set an AWSServiceConfiguration in the AWSServiceManager singleton before calling AWSS3TransferUtility.default() for the first time.

AWSTask for upload & download operations

The Storage SDK uses AWSTask throughout. AWSTask is a fork of Facebook's Bolts Framework.

Tasks... make organization of complex asynchronous code more manageable.

The usage of the primary Storage SDK's APIs for uploading and downloading are shown in the API docs, but since I wanted to ensure all codepaths for errors were handled properly, I had to dig a little deeper to understand how these tasks work under the hood. I'll use multi-part uploading as an example, but this applies to all three scenarios (uploading, multi-part uploading, and downloading).

I've annotated the types so that you can see the identity of what's actually flowing around all these closures.

letexpression= AWSS3TransferUtilityMultiPartUploadExpression()
expression.progressBlock = { (task:AWSS3TransferUtilityMultiPartUploadTask, progress:Progress) in
    DispatchQueue.main.async(execute: {
        // ...
    })
}

letcompletionHandler:AWSS3TransferUtilityMultiPartUploadCompletionHandlerBlock= { (task:AWSS3TransferUtilityMultiPartUploadTask, error:Error?) ->Voidin
    DispatchQueue.main.async {
        // ...
    }
}

lettaskQueuedHandler: (AWSTask<AWSS3TransferUtilityMultiPartUploadTask>) ->Any? = { (task:AWSTask<AWSS3TransferUtilityMultiPartUploadTask>) ->Any? in
    DispatchQueue.main.async {
        ifletresult= task.result {
            // An `AWSS3TransferUtilityMultiPartUploadTask` was queued successfully.
        } elseifleterror= task.error {
            // The `AWSS3TransferUtilityMultiPartUploadTask` was never created.       
        } else {
            // Not sure if this code path is even possible.        
        }
    }
    returnnil
}

lettask:AWSTask<AWSS3TransferUtilityMultiPartUploadTask>= transferUtility.uploadUsingMultiPart(fileURL:fileURL, bucket:bucketName, key:objectKey, contentType:contentType, expression:expression, completionHandler:completionHandler)
task.continueWith(block:taskQueuedHandler)

The overloaded use of the identifier Task in the types caused me some confusion at first. AWSS3TransferUtilityMultiPartUploadTask is not a subclass or in any way related to AWSTask as a concept.

Let's start at the bottom. The transferUtility.uploadUsingMultiPart(...) method takes some parameters, two closures, and returns an AWSTask<AWSS3TransferUtilityMultiPartUploadTask>: an AWSTask that will asynchronously return an AWSS3TransferUtilityMultiPartUploadTask? or an Error? to the block provided to continueWith.

The moment of understanding I had was realizing that just creating an AWSS3TransferUtilityMultiPartUploadTask is an asynchronous, fallible operation, with an error case that must be handled. That is why we've defined taskQueuedHandler above.

Keep in mind that taskQueuedHandler may be called on a background queue.

completionHandler will always get called if the if let result = task.result code path in taskQueuedHandler executes. completionHandler still has to handle both success and failure cases.

If, for example, you start a UIActivityIndicatorView as loading before calling uploadUsingMultiPart, but you don't handle the task.continueWith error, it's possible that the UIActivityIndicatorView will spin forever.

Takeaway: If you're expecting the result of an upload or download at some point in the future, you need to handle the error case in task.continueWith.

AWSTask for get{*}Tasks

Since AWSS3TransferUtility maintains its own database of tasks, even across app cold launches, you may need to retrieve these tasks. This use case is shown in the API docs.

letdownloadTasks= transferUtility.getDownloadTasks().result
letuploadTasks= transferUtility.getUploadTasks().result
letmultiPartUploadTasks= transferUtility.getMultiPartUploadTasks().result

Note that even though these getter functions return an AWSTask, they're not asynchronous and the result is available immediately. There's also no way for the returned AWSTask to contain an error.

Takeaway: Sometimes the AWS SDKs return AWSTasks for synchronous operations. Sometimes they return AWSTasks for operations that are not fallible. However, be careful relying on this behavior because the underlying implementation could always be changed in a future version without your knowledge.

API SDK

Because AWSAppSyncClient in built on top of ApolloClient, some of the below points are applicable to Apollo GraphQL as well.

Offline Mutations

One of the marketing points of AppSync is that mutations (i.e. POST, PUT, or DELETE in the REST world) can be triggered by a user while they're offline, and the mutations will be queued in local storage and relayed to the server when the user's device has connectivity again.

This is a feature set available in certain types of apps, including many of Apple's own stock apps like Reminders or Contacts.

However, this behavior does not always make sense for all types of mutations. Even when it does make sense, it often comes with an additional heavy burden of proper UX design. Handling errors. Handling conflicts. These are problems that even the most mature apps still struggle with.

In our app, we have a pretty straightforward createUser mutation (i.e. sign up). createUser is a particularly poor candidate for offline mutation support:

  • It has several server-side validation rules for form elements (e.g. unique username).
  • The app is logically partitioned to only allow registered users to access certain parts of the app.

Before learning that offline mutations were the default in AppSync and could not be turned off, I was struggling to understand why when simulating network errors, the completion block to my mutation was never getting called, even beyond the timeout duration.

When I realized this behavior was intentional, it took more time to figure out a workaround that didn't require the huge maintenance burden of subclassing or implementing manual timeout code throughout the app.

It turns out the workaround is as simple as using the underlying appSyncClient.apolloClient instance.

// Before
appSyncClient.perform(mutation:mutation, queue: .main, optimisticUpdate:nil, conflictResolutionBlock:nil) { (result, error) in// ...
}

// After
appSyncClient.apolloClient?.perform(mutation:mutation, queue: .main) { (result, error) in// ...
}

From my reading of the AWSAppSyncClient source, it's safe to force unwrap apolloClient at the moment. But certainly use caution in your particular use case.

With the above code, mutations attempted while offline will fail with an error after the default timeout (60 seconds) and call the completion block.

Takeaway: Use appSyncClient's underlying apolloClient directly to perform mutations that shouldn't be queued offline.

Errors

Overall, GraphQL is a welcome addition of structure compared to REST. However, I've found the error story to be a little disappointing.

When writing my first AppSync API request handler, I soon found the control flow for errors to be a little overwhelming. All layers of the stack have their own set of errors, and Swift's untyped errors don't help the situation.

Let's look at an example fetch request. I've set up and documented the completion handler.

appSyncClient.fetch(query:query) { (result:GraphQLResult<Query.Data>?, error:Error?) in// 1ifletnetworkError= error as? AWSAppSyncClientError {
        // The first layer of error handling is a network stack error.// 2
    } elseifletunknownError= error {
        // This case probably shouldn't happen, but I don't know the network stack// well enough to guarantee that.// 3
    } elseifletdata= result?.data? {
        // This is sort of the happy path. We got the data we requested.// However, `result?.errors?` may still contain errors!// It depends on your use case whether you want to ignore them if// `data` is non-null.// 4
    } elseifletgraphQLErrors= result?.errors?, !graphQLErrors.isEmpty {
        // According to the GraphQL spec, graphQLErrors will be a non-empty list.// These errors are also more or less untyped.// 5
    } else {
        // Although logically we should have covered all the cases,// the compiler can't statically guarantee we have so we should throw// an `unknown` error from here.
    }
}
  1. The network stack is provided by AWSAppSyncHTTPNetworkTransport and throws AWSAppSyncClientError. In the .requestFailed case, the Cocoa error can be extracted and the localizedDescription shown to the user. The other cases probably aren't that useful. Note that although AWSAppSyncClientError conforms to LocalizedError, the error messages are English only and usually add various codes that would probably be unideal to show users.
  2. I haven't dug through the network stack enough to know whether there are other error types that can be thrown, but the presence of an error at this level of the stack probably means that result will be nil.
  3. The GraphQL spec says that result can contain both data and errors. It's up to you to determine whether you need to handle this case, and if so, how to handle it. For many use cases though, getting data means success.
  4. The GraphQL spec defines an error as a map with a message that's intended for developers, and optionally locations and path fields. As of the June 2018 spec, user fields should be contained within the extensions field. However, the AppSync spec was based on the October 2016 GraphQL spec, and therefore defines an errorType field in the root of the error map. errorType is a String type which makes it more readable to developers, but also more error prone.
  5. All those nullable fields have left us with an else case.

I really wish errors were typed in GraphQL (and Swift too!).

Takeaway: Handling the results of a fetch or perform requires some knowledge about the various layers of the network stack. Make sure you've considered the possible errors at each layer and how can you help your user recover from them.

Equatable structs

The codegen utility included in AWS Amplify and part of Apollo's tooling does not support generating structs that conform to Equatable. Generated enums do conform to Equatable.

The way structs are laid out, all the struct's data is stored in a dictionary [String: Any?] (typealiased as Snapshot). Its typed properties are decoded from or encoded into that dictionary on the fly in a property's getter and setter, respectively.

Equatable could probably be generated the old fashioned way by comparing all properties. I'm unsure of whether this could introduce performance problems for deeply nested structs due to the lazy (and non-cached) decoding.

This was discussed in a (now closed) GitHub issue.

Takeaway: Code generated enums conform to Equatable. Code generated structs do not conform to Equatable. If you need Equatable structs, you'll have to write the == function yourself manually, generate it with a tool like Sourcery, or create wrapper structs.

Query watching

AWSAppSyncClient has a useful watch feature that allows you to receive updates to any resources fetched by the query you're watching throughout the lifetime of the watch. Experimenting with this feature, I've found a few conceptual points to keep in mind.

watch works by first adding a subscription to any changes to the store. Next, it makes a normal fetch with the same configurable cache policy options available to fetch. The results of this initial fetch are used to create a list of dependentKeys. When the cache notifies the GraphQLQueryWatcher that its contents have changed, the GraphQLQueryWatcher checks if any of the changed keys are contained in its dependentKeys, and if so, it fetches the query again (with cache policy .returnCacheDataElseFetch) then calls the closure registered in watch with the result.

Set up the cache key identifier on your store

As stated in the docs, you have to tell apolloClient how you uniquely identify your resources:

// Use something other than "id" if your GraphQL type is different
appSyncClient?.apolloClient?.cacheKeyForObject = { $0["id"] }

In their example, it says that a Post with id = 1 would be cached as Post:1. However, in my testing, only the id itself is used (i.e. 1). Currently, we have ids that are unique across our resources, but if you don't, you may need to investigate this more to ensure you don't have key collisions in the cache.

A fetch must succeed before watching will work

Since dependentKeys are derived from the results of the first fetch (and is regenerated on subsequent fetches), this fetch has to be successful in order for the watch to respond to changes produced by other queries.

If you use watch, you have to allow your user to retry in case the initial fetch fails. Call GraphQLQueryWatcher.refetch(). Even if the same query is fetched from a different part of your app, this query must succeed at least once in order to receive changes.

Use a pessimistic cache policy

You essentially cannot (safely) use the .returnCacheDataDontFetch cache policy with watch.

Granted, it's rare case to want to do so. But if you thought that the partial results from a different query in your app could be picked up by a watch query, this won't work. It has to be the exact same query and it has to have been fetched before with the exact same parameters from the server.

If you used .returnCacheDataDontFetch as the cache policy and the fetch resulted in a cache miss, you would have to call refetch() anyway to make a fetch to the server.

It's not straightforward to use watch with paging queries

It's common in GraphQL to use a Connection type to implement indefinite paging.

Let's look at the following GraphQL schema:

type MovieConnection {
  movies: [Movie!]! # contains a maximum of 10 items
  nextToken: String
}

type Query {
  getLatestMovies(nextToken: String): MovieConnection!
  getMovie(id: Int!): Movie!
}

For example, if you set up a watch for the first call to getLatestMovies(nextToken: nil), this watch will only respond to changes to the 10 Movie resources returned by the query. If you make a normal fetch request for the next page using nextToken, the watch you have set up will not observe changes in the Movie resources returned in the second request.

If you wanted to respond to changes to any Movie returned in any pages, you'd have to do a watch for each page and add the GraphQLQueryWatcher to a collection. The logic in your result handlers would depend heavily on how you structured your data source since the result could be an add or an update.

It's not possible to watch resources outside a query

It's probably obvious from the interface to watch since the first parameter is of type GraphQLQuery, but you cannot watch for changes to an arbitrary key in the cache. For example, if there was a resource in your database keyed by id 12345, you can't simply register a watcher with a dependent key for this id.

Any connection between resources and queries must be resolved by the server

If you have two different queries that you know reference the same object, that relationship must be codified by the server.

Continuing with the getLatestMovies example in the previous section, imagine we received a list of 10 Movies and wanted to watch for granular changes in the Movie with id = 12345.

To accomplish this you might think you could simply call:

let watcher = appSyncClient.watch(query: GetMovieQuery(id: 12345), cachePolicy: .returnCacheDataDontFetch, queue: .main, resultHandler: { (result, error) in ... }

But this would not work! It would result in a cache miss and the watch would be inert until refetch() was called.

Although the Movie returned by GetMovieQuery(id: 12345) is already in the cache, the association between the query itself and the Movie resource can't be resolved by AppSync/Apollo until the server returns the result for the query and this result is cached too.

Conclusion

In this post, I outlined some development points to watch out for in the Authentication, Storage, and API SDKs of AWS AppSync. I hope the takeaways from this post are valuable for current and future users of AWS AppSync.

レシピ検索を支えるレガシーでクリティカルな大規模バッチを刷新した話

$
0
0

こんにちは、会員事業部の新井です。余暇を全て Auto Chess に喰われています。

過去このブログにはサービス開発に関する記事*1を投稿させていただいているのですが、今回はシステム改修についての記事になります。 クックパッドには検索バッチと呼ばれる大規模なバッチが存在するのですが、今回それを刷新することに成功しました。 そこでこの記事では旧システムに存在していた問題点、新システムの特徴や実際の開発について述べたいと思います。

背景

クックパッドのレシピ検索では Apache Solr を検索サーバーとした全文検索を利用しています。古くは Tritonn を利用して MySQL に作られた専用 table を対象に全文検索を実行していたようですが、その頃から「検索バッチ」と呼ばれるバッチが存在していました。 このバッチは、簡単に言うと「検索インデックス」と呼ばれる検索用メタデータを生成するものです。関連各所からデータを収集し、分かち書きやスコアの計算といった処理を実行して検索インデックスを生成し、現在はそれを Solr にアップロードするところまでを実行するバッチ群となっています。

この検索バッチは 10 年以上利用されており、年々検索のメタデータとして使用したいデータ(field)が増加してきたこともあって、種々の問題を抱えたレガシーシステムとなっていました。サービスにとって非常に重要なシステムであるがゆえに思い切った改修に踏み切れなかったのですが、今回のプロジェクトはその一新を目的としたものでした*2

旧検索バッチの問題点

複数の DB やサービスに依存している

検索バッチはレシピ情報にとどまらず、レシピに紐づく様々なメタデータや、別バッチによって集約された情報などを収集する必要があるため、依存先のサービスや DB が多岐にわたっていました。 DB でいえばレシピサービスが利用している main, 集約されたデータが格納されている cookpad_summary, 検索や研究開発関連のデータが格納されている search_summaryなどなど……。サービスへの依存についても、料理動画サービスの API を叩いてそのレシピに料理動画が紐付けられているかを取得してくるなどの処理がおこなわれており、新規事業が次々に増えている現在の状況を考えると、この手の依存はこれからも増大することが予想されていました。

cookpad_all に存在している

旧検索バッチは cookpad_all と呼ばれる、レシピ Web サービスとその管理画面や関連するバッチ群、mobile アプリ用 API などがすべて盛り込まれたレポジトリ上に存在しており、各サービス間でモデルのロジック等が共有されていました。このこと自体はそれほど大きくない規模のサービスであれば問題になることはありません。しかし、クックパッドについて言えば、ロジック共有を通したサービス間の依存を把握するのが困難な規模になっており、「レシピサービスに変更を加えたらバッチの挙動が意図せず変わった」というようなことが起こる可能性がありました。このような状況であったため、特に新しいメンバーがコードに変更を加える際に考えるべき要素が多すぎて生産性が著しく低下し、バグを埋め込んでしまう可能性も高くなってしまっていました。

不必要に Rails である

cookpad_all に存在するバッチ群は kuroko と呼ばれていますが、それらが Rails で実装されていたことから、旧検索バッチも Rails で実装されていました。 しかし、このバッチの実態は「大量のデータを収集して処理」することであり「user facing な Web アプリケーションをすばやく開発することができる」という Rails の強みが活かされるようなものではありませんでした。 実際の実装としても、その大部分が「データを取得するためだけに ActiveRecord のインスタンスを大量に生成する」といったロジックで構成されており、オーバーヘッドの大きさが目立つものになっていました。

責務が大きすぎる

旧検索バッチでは、検索インデックスにおける全ての field が一つのメソッド内で生成されていました。そのため、新たな field の追加や既存の field の編集において必ずそのメソッドに手を入れる必要があり、メンテナンス性に問題を抱えていました。

たとえば、新たな field を追加する際に該当メソッド内に存在する既存のロジックを踏襲したとします。しかし、クックパッドには「レシピ」を表現するモデルが複数存在するため、既存ロジックで利用されていた「レシピ」を表現するモデルと、新たな field のロジックが参照するべきだった「レシピ」のモデルが食い違っており、意図した挙動になっていなかったといったような問題が起こることがありました。

また、研究開発部の施策で検索インデックスに field を追加したいケースなど、レシピサービスにおける検索機能の開発以外を主業務としているメンバーも検索バッチに手を入れることがありました。このように、ステークホルダーの観点から見ても複数の理由からこのバッチが編集されており、「単一責任の原則」が満たされていないシステムになってしまっていました。

実行時間が長すぎる

旧検索バッチではすべての field を生成する処理が直列に実行されているため、Rails での実装であるということも相まって実行時間が非常に長くなってしまっていました。 この時間はバッチの構成がそのままであるうちは今後も field の増加に伴って増大していくことが予想されていましたし、実行時間短縮のために自前で並列実行の実装をおこなっていたのも可読性やメンテナンス性に影響を与えていました。

f:id:spicycoffee:20190617111728p:plain
旧検索バッチの構成

新検索バッチ概要

上に挙げた旧検索バッチの問題点を解消するため、新検索バッチ(以下 fushigibana*3)は以下の要素を実現するように実装されました。

データフローの一本化

先ほど「検索バッチはその性質上多くの箇所からデータを収集し加工する必要がある」と述べましたが、現在クックパッドには組織内のあらゆるデータが集約されている DWHが存在します。各種データソースから DWH へのインポートという作業が存在するためデータの更新頻度に関する問題はありますが、旧検索バッチの時点で検索結果の更新は日次処理であったことも鑑み、fushigibana が利用するデータソースは DWH に限定しました。こうすることで各種 DB やサービスへの依存が解消され、データフローを一本化することが可能となりました。

Rails ならびに cookpad_all からの脱却

fushigibana は redshift-connectorを用いて DWH から取得したデータに、Ruby で分かち書きなどの処理を施して検索インデックスを生成し、それを S3 にアップロードするというつくりになっており、plain Ruby で実装されています(Ruby を選択したのは社内に存在する分かち書き用の gem などを利用するため)。その過程で cookpad_all からもコードベースを分離し、完全に独立したバッチ群として存在することになりました。

クラスの分割と並列実行

fushigibana では検索インデックスの生成処理をサービスにおける意味やアクセスする table などの観点から分割し、「単一責任の原則」を満たすよう実装しています。分割されたクラスはそれぞれいくつかの field を持つ検索インデックスを生成します。最後にそれらのインデックスを join することですべての field を持った検索インデックスを生成しています。こうすることで、それぞれのクラスを並列実行することが可能になり、バッチの実行時間が短縮されました。

また、検索インデックスに新しく field を追加する際にも、既存のロジックに手を加えることなく新しいクラスを実装することで対応が可能となり、システム全体で見ても「オープン・クローズドの原則」を満たしたバッチとなりました。

f:id:spicycoffee:20190617111724p:plain
fushigibana の構成

fushigibana の開発と移行作業

ここからは、実際にどのようにして fushigibana を開発し、それをどのようにして本番環境に適用したかについて述べていきます。

開発の流れ

fushigibana の開発は、大まかに次のような流れでおこなわれました。

  1. 現状の調査
  2. ロジックの SQL 化
  3. 新ロジックの検証
  4. staging 環境における検索レスポンスの検証
  5. kuroko 上での本番運用
  6. コードベースの分離

現状の調査

旧検索バッチの改修に入る前にまずは現在利用されていない field を洗い出し、少しでも移行時の負担を軽減することを目指しました。 コードを grep して一見使われていなさそうな field について識者に聞いて回ります。 この辺りは完全に考古学の域に入っており、作業の中で過去のサービスについていろいろなエピソードを知ることができておもしろかったです。 この作業の後、最終的には 111 の field についてロジックの移行をおこなうことになりました。

ロジックの SQL 化

既存の Rails ロジックを凝視しながらひたすら SQL に書き換えていきます。中には既存のロジックの挙動が明確でないもの、単純にバグっているものなども存在しており、適宜直しながらひたすら SQL に書き換えます。最終的には 32 のクラスで 111 の field を生成することになりました。

新ロジックの検証

新旧ロジックで生成した検索インデックス同士を比較することで、新ロジックの妥当性を検証します。 「データソースが変わるためそもそもデータの更新タイミングが違う」「ロジック改修の際にバグ改修もおこなった」などの理由から厳密な比較は不可能でしたが、できる範囲で新ロジックの不具合を潰していきました。

開発環境における検索レスポンスの検証

それぞれの検索インデックスをアップロードした Solr に対して検索リクエストを投げ、そのレスポンスを比較します。 実際に開発環境のレシピサービスを利用して手動で挙動を確認することはもちろん、検索回数上位 1000 キーワードほどについてスクリプトを回し、「人気順」「新着順」「調理時間絞り込み」など、利用ユーザー数や重要度の観点から選択した、いくつかの機能で発行される検索クエリのレスポンス件数や順序を比較しました。 ここでも厳密な比較はできないものの、ユーザー視点で重要な体験に絞った上で「ある程度の誤差を許容する」「誤差の原因を特定することを目的とする」ことで費用対効果を意識して検証作業を進めました。

旧実行環境上での本番運用

本番運用に入るにあたっていきなりコードベースを分離するのではなく、まずは既存のバッチが動いているシステムの上で新ロジックを走らせる方針を取りました。これは、なにか問題があった際に、それが「コードベースの分離ではなく新しいロジックそのものに問題がある」ことを保証するためのステップです。

コードベースの分離

上記のステップで新ロジックに問題がないことを確認した上でコードベースを分離していきます。実際には cookpad_all 内に存在したロジックをいくつか社内 gem に移行するなどの作業が発生したため、新ロジックの妥当性が完全に保証された状態でコードが分離できたわけではありませんでしたが、それでも一度既存のシステム上で問題なく実行できていたため比較的不安なく分離を進めることができました。

移行作業における安全性の保証

検索バッチが影響を与えるレシピサービスは非常に多くのユーザーが利用しているサービスであり、移行作業に際して不具合が発生する可能性は可能な限り抑える必要がありました。 今回の開発ではシステムの安全性を以下の 4 地点で検証してから反映しています。

  1. 新ロジックの生成する検索インデックスと、旧ロジックの生成する検索インデックスを比較
  2. 新検索インデックスをアップロードした Solr が返すレスポンスと、現在の Solr が返すレスポンスを比較
  3. 新検索インデックスを production の Solr にアップロードした後、現在の検索結果と前日の検索結果を比較
  4. ユーザーからの問い合わせを監視

このうち、1 と 2 については上述した「開発の流れ」における「新ロジックの検証」と「開発環境におけるレスポンスの検証」そのものであるため、ここでは 3 と 4 について述べます。

f:id:spicycoffee:20190617111720p:plain
移行時の検証作業

前日との検索結果の比較

クックパッドの Solr は master-slave 構成で運用されており、検索インデックスが master Solr にアップロードされた後、ユーザーからのリクエストを受ける slave Solr がそれをレプリケーションしてくる形になっています(厳密にはこれに加えてキャッシュ機構があったりします)。逆にいうと検索インデックスをアップロードしても、slave のレプリケーション処理をおこなわなければユーザーへの影響は出ないということになります。

この仕組みを利用して、検索インデックスをアップロードした後検索回数上位の各キーワードについて前日の検索結果と新しい検索結果を件数ベースで比較し、大きな差があった場合レプリケーションを実行しないというテスト機構が存在していました。 この機構は検索インデックスの生成ロジックを変更しても問題なく利用できるものであったため、そのまま活用することになりました。

ユーザーからの問い合わせ監視

いくら開発段階での検証を繰り返しても、実際に不具合の出る可能性を 0 にすることはできません。当然のことではありますが、本番適用日はインフラやサポートチームに共有し、万が一のときにすばやくロールバックできるよう検索インデックスをユーザーからアクセスのこない slave Solr にバックアップした上で反映作業を実施しました。 その後もユーザーから届くお問い合わせには定期的に目を通し、fushigibana 導入による不具合らしきものが報告されていないかどうかを確認していました。

プロジェクトの振り返り

成果

以上に述べたように検索バッチの改修をおこなった結果、どのような成果を得ることができたのかをまとめます。 冒頭に上げた「旧検索バッチの問題点」についてはそれぞれ

  • 複数の DB やサービスに依存している
    • DWH をデータソースとすることで解消した
  • cookpad_all に存在している
    • 別レポジトリに切り出して実装することで解消した
    • 結果 cookpad_all から 1,357 行のコードを削除することに成功した
  • 不必要に Rails である
    • plain Ruby として実装することで解消した
  • 責務が大きすぎる
    • index-generator を複数のクラスに分割して実装することで解消した
    • 「小さな処理を並列で実行する」形に改修したことでリトライ処理も入れやすくなり、バッチ全体の安定性も向上した
    • 同様の理由でバッチ実行基盤の spot instance 化も達成され、将来的にはコスト削減にも繋がりそう
  • 実行時間が長すぎる
    • 分割実装した index-generator を並列実行することで解消した
    • 具体的には全体で 7.5h かかっていたものが 4.5h となり、約 3 時間の短縮化に成功した

という形で解決することができました。 丁寧に検証フェーズを重ねたこともあり、今のところ不具合やユーザーからのお問い合わせもなく安定して稼働しています。 また、上記に加えて「コードの見通しが改善したことによる開発の容易化」や「ドキュメンテーションによるシステム全体像の共有」といった成果もあり、検索バッチ周りの状況は今回のプロジェクトによって大きく改善されました。

反省

今回の一番大きな反省点はプロジェクトの期間が間延びしてしまったことです。 着手してみた結果見積もりが変わった・プロジェクトのスコープが広くなっていったという事実もあるため仕方のないところもありましたが、特に検証フェーズにおいてはより費用対効果の高い方法を模索することができたのではないかと思います。

たとえば検索インデックスの比較と Solr レスポンスの比較はかなり近いレイヤーに属するものであり、どちらか一方を省略しても検証の精度に大きな差は存在しなかった可能性があります。 結果として「不具合が出ていない」という事実は喜ばしいことですが、組織にとってはエンジニアリソースも重要な資源ですし、今後は「かかるコスト」についてもしっかりと意識をしてプロセスやアーキテクチャの選定をしていきたいと思います。

今後の課題

今回のバッチ改修はあくまで「レシピ検索」についてのものであり「つくれぽ検索」「補完キーワード検索」などについては(また別の)古いシステムで動いています。 今後はそれらの検索インデックスを生成するシステムについても改修をおこなう必要があると思いますし、その際に fushigibana に乗せるのか、あるいはどう関係させるのかというのはしっかりと考慮する必要があると思います。

fushigibana そのものについての課題としては、現在 AWS Glue へのスキーマ登録を AWS console から手動でおこなう必要があることがあげられます。 ドキュメントは残しているものの、この作業だけ fushigibana のリポジトリ上で完結しないのは開発者に優しくないと感じていますし「スキーマ定義ファイルの内容に従って AWS Glue の API を叩くスクリプトを実装する」といったような解決策を取るべきであると思っています。

まとめ

今回の記事ではクックパッドにおける検索バッチシステムの改修について解説しました。 「現状のシステムを調査することで洗い出した問題点を解決する構成を考え、技術を用いて可能な限りシンプルに実現する」という当然かつ難しいことを、規模の大きなシステムに対して実践するのは非常にやりがいがあり、エンジニア冥利に尽きる仕事でした。 システムの構成も現時点における「普通」にかなり近いものになっており、今後の開発にもいい影響があると期待されます。

クックパッドには検索バッチ規模のシステムが多数存在し、その多くはよりよい実装に改修されることが期待されているものです。もちろんそのためには多くのリソースが必要であり、弊社は年がら年中エンジニアを募集しています。 大規模なシステムの開発に挑戦したいエンジニア、多くのユーザーを支えるサービスに関わりたいエンジニア、技術の力でサービスをよくしたいエンジニアなど、少しでも興味を持たれた方は是非ともご応募ください。

*1:https://techlife.cookpad.com/entry/2018/02/10/150709https://techlife.cookpad.com/entry/2018/12/07/121515

*2:クックパッドでは 2017 年よりレシピサービスのアーキテクチャ改善を目的とするお台場プロジェクトが進んでおり、それに貢献する意味もありました

*3:Solr にデータを撃ち込む → ソーラービーム → フシギバナ

モダンBFFを活用した既存APIサーバーの再構築

$
0
0

技術部の青木峰郎です。 去年までは主にデータ分析システムの構築を担当していましたが、 最近はなぜかレシピサービスのサービス開発をやっています。 今日は、そのサービス開発をする過程で導入したBFF(Backends for Frontends)であるOrchaについて、 導入の動機と実装の詳細をお話しします。

Orcha導入にいたる経緯

まずはOrcha導入までの経緯、動機からお話ししましょう。

最初のきっかけは、わたしが去年から参加しているブックマークのようなサービスの開発プロジェクトでした。 このプロジェクトの実装のために新しいmicroserviceを追加することになったのですが、 そのときにいくつかの要望(制約)がありました。

1つめは、撤退するとなったときに、すぐに、きれいに撤退できること。

2つめが、スマホアプリからのAPI呼び出し回数はできるだけ増やしたくない、という要望です。

図1を見てください。 既存APIサーバーとは別に新しいmicroservice(API)を追加してスマホアプリから呼べば、 今回追加する部分はきれいに分かれていて実装も簡単です。 しかし、それではスマホアプリからのAPI呼び出し回数が増えてしまいます。

f:id:mineroaoki:20190621215345j:plain
図1: 単純にサービスを増やすとAPI呼び出し回数が増えてしまう

例えばクックパッドアプリのトップページは現在でもすでに10以上のAPIを呼んでいるので、 もうできるかぎりAPI呼び出し回数を増やしたくありません。

かと言って、既存APIサーバー(Pantry)の改修もしたくありません。 図2のように、Pantryから新サービスを叩くように変更すればAPI呼び出しを1つにまとめることはできます。 しかしこのPantryというサーバーは以前の記事で説明した「世界最大のモノリシックなRailsアプリケーション」であり、 理由はよくわからないがとにかくこれをさわるだけで開発期間が3倍になる優れモノです。 できることならいっさいPantryにさわることなく開発を終えたいわけです。

f:id:mineroaoki:20190621215427j:plain
図2: Pantryをいじれば目的は達成できるが対価が必要

つまり、API呼び出し回数は増やしたくないのでできれば既存のAPIに値を追加する形で実装したい。 しかしそのためにPantryはいじりたくない。

API呼び出し回数を増やしたくない……既存のAPIに手を加えたい……でもPantryはいじりたくない……。

この3つの思いが謎の悪魔合体を遂げて生まれたのがOrcha(オルカ)なのです。

Orcha 〜クックパッドのためのBFF〜

Orchaを導入した後のアーキテクチャを図3に示しました。 見てのようにOrchaはリバースプロキシと既存のAPIサーバーであるPantryの間にはさまって、 スマホアプリに特化したAPIを提供します。

f:id:mineroaoki:20190621215234j:plain
図3: Orchaのアーキテクチャ

今回は既存APIに新規サービスの情報を追加したいというのがそもそもの目的だったので、 まずOrchaがPantryのAPIを呼んで、レスポンスで得たJSONに新規サービスからの情報を差し込むことで目的を達成しています。 この場合のOrchaは「高機能なJSON用sed」のような働きをします。

OrchaはクックパッドのiOS/Androidアプリに特化したAPIを提供することを主眼としたシステムなので、 いわゆるBFF(Backends for Frontends)だとも言えます。 BFFとは、スマホアプリやウェブフロントエンドのような特定のクライアントに特化したAPIサーバーのことです。 汎用のAPIではなく、あるクライアントに密着した固有のAPIを提供することを目的にしています。 BFFについての詳細はこのあたりの記事をお読みください。

ちなみに、当初はBFFというよりオーケストレーション層を作るぞ! という気持ちのほうが強かったので、 "Orchestration Layer"の先頭を適当に切ってOrchaと命名しました。

すべてのAPIはカバーしない

Orchaはこのような経緯で導入したため、現在のところ、 スマホアプリが必要とするすべてのAPIを提供しているわけではありません。 スマホアプリのトップページのすべてのデータを返すトップページAPIなどの、スマホアプリに特化したAPIの一部のみを提供しています。 残りのAPIについては、現在もリバースプロキシからPantryへ直接リクエストを投げています。

そのような中途半端な入れかたをした1つめの理由は、 もし今回のプロジェクトがうまくいかなかったときは新規サービスをOrchaごと捨てて撤退する予定だったからです。 まるごと捨てるなら、必要最小限のAPIだけをOrchaにサーブさせておいたほうが、当然捨てるのも簡単です。

2つめは消極的な理由で、Orcha経由にするメリットが特にないからです。 既存のAPIをOrcha経由で呼ぶようにしたところで、単にレイテンシが数ミリ秒増えるだけで、たいしていいことがありません。 強いて言うとこの記事を書くときに「一部しか経由しませんよ」という説明をしなくて済むくらいでしょう。 それにもし将来メリットが発生してOrchaを経由するように変えようと思ったら、その時に変えればいいだけです。 したがって、当初は実装量がより少ないほうを選ぶことにしました。

Orchaの実装設計

Orchaの実装言語はしばし悩んだのちJavaに決めました。 Spring WebFluxとSpring Reactorを使って、非同期のリクエスト処理を実装しています。

JavaとSpringを選択した第一の理由はパフォーマンスです。 Pantryはやたらとリソース食いなので、 1 ECS task(サーバー台数とおおむね同じ意味)が3 CPUコア、メモリ4GBで、 毎日のピークタイムには150以上のECS taskが必要になっています。 これと同じ調子でリソースをバカ食いするサーバーをもう1つ立てるのはさすがに避けたいところです。

またレイテンシについても気を遣う必要があります。OrchaをPantryの前に立てるということは、 Orchaでかかったレイテンシーがそのまま既存のレイテンシーに追加されるということです。 Orchaのレイテンシーはできるだけ小さくしておかなければ、 スマホアプリの使い勝手を大きく悪化させてしまうことになるでしょう。 それを避けるには例えば、複数システムへのAPI呼び出しを並列化するなどの工夫をすべきです。

さらに、どのようなAPIを呼ぶことになるかは予測できないので、非常に遅いAPIもあるかもしれません。 そのような場合にもワーカーを使い果たして停止するようなことのないアーキテクチャを選択する必要があります。 ここまで来ると選択肢は非同期I/Oしかないでしょう。

非同期リクエストのフレームワークがあり、実行効率が高いとなると、定番はJVM系かGoです。 そこで結局、何度もJavaを利用した実績があったこと、 Java 8とJava 9での改善およびLombokの登場により言語仕様に目立った不満がなくなったこと、 さらに品質の高いAWS SDKやDBドライバがあること、の3点からJavaとSpringに落ち着きました。

なお正確に言うと、限定公開を始めた当初はリクエスト数も非常に少なかったため、 Spring WebMVCを使って同期リクエスト処理を実装しました。その後、全体公開することが決まった時点で、 API単位でSpring WebFluxに切り替えて非同期化していきました。 ここは同期・非同期のフレームワークが両方あり、しかも同居が可能なSpring Frameworkの利点が最大限に活きたところです。

認証処理の共通化

パフォーマンス向上という点ではOrcha導入にあたってもう1つ配慮したポイントがあります。 それは認証処理の共通化です。

図4はOrcha導入前のクックパッドアプリの認証経路です。 一言で言うとAuthCenterというシステムがすべての認証を請け負っており、 マイクロサービス各位はそれぞれ独立に認証を行うという仕組みです。

f:id:mineroaoki:20190621215506j:plain
図4: これまでの認証処理

これまではそれでも大きな問題はありませんでした。 なぜなら、基本的に1 APIは1システムによってハンドルされていたため、 APIリクエスト数と認証回数が等しかったからです。

しかしOrcha導入後は、1 APIリクエストにつき2回以上の認証処理が発生します。 つまり、最悪の場合はAuthCenterへのリクエスト数が突如として2倍以上になる可能性があるわけです(図5)。

f:id:mineroaoki:20190621215527j:plain
図5: 何も考えずにOrchaを導入したときの認証処理

AuthCenterは現時点でもすでに社内随一のリクエスト数を誇る人気サービスで、 ぶっちゃけた話DBがけっこうパツパツだったりするので、 いきなりリクエスト数が倍になれば陥落する可能性もあります。 それはいくらなんでもまずかろうということで、 Orchaの全体公開に合わせてID tokenを使った認証処理の共通化を実装しました(図6)。

f:id:mineroaoki:20190621215614j:plain
図6: ID tokenを使った認証の共通化

仕組みはこうです。 まず最初にリクエストを受けたOrchaはAuthCenterにアクセストークンを渡して検証してもらい、 認可などのためのメタデータを含むID tokenを受け取ります。 Orchaはアクセストークンの代わりに、AuthCenterから受け取ったID tokenを各サーバーに付与してリクエストします。

ID tokenは、JWTという形式のJSONを秘密鍵で署名したものです。 秘密鍵に対応する公開鍵は社内の全サービスに共有されているため、 そのトークンは間違いなくAuthCenterが発行したものであることが検証できます。 つまり各サービス内でその検証だけ行えば、 いちいちAuthCenterに問い合わせなくとも認証を完了することができるのです。

このへんはめんどくさかったのでわたしにはあまり知見がなかったので、 弊社の無敵万能エンジニア id:koba789に仕様決めから全部ぶんなげて実装してもらいました。 そのうち id:koba789が詳しいことを書いてくれると思います。

サービスメッシュを使った他システムとの連携

Orchaと他の上流システムとの通信は、 すべてクックパッドの標準的なサービスメッシュシステムを介して行いました。 サービスメッシュは特にBFFだから使うというものではありませんが、 個人的に今回いくつか利点を実感できたので述べたいと思います。

まずサービスメッシュでの自動リトライ機能について。 エンドポイントごとにタイムアウトを設定でき、タイムアウトした場合は自動的にリトライする、 それでもだめならしばらく通信を止める(サーキットブレーカー)という機能があり、これが非常に便利です。 最初はリトライなんていつ起きるんだよと疑っていたのですが、実際に試してみたら毎分起きていて認識を改めました。 また障害などで大量にエラーが発生したときにはサーキットブレーカーが働いて輻輳を防止してくれるので、 高いレベルで可用性を高めてくれます。

第二にクライアントサイドロードバランシングが容易に実装できる点。 Orchaには上流システムにgRPCのシステムがいくつかあるのですが、 普通のHTTP通信でも、クライアントサイドロードバランシングのgRPCでも、 こちら側の設定はほぼ同じ設定で通信できるようになるのでとても楽でした。

最後に、他システムとの通信のメトリクスが自動的に取得されて視覚化される点です(図7)。 これは正確に言えばサービスメッシュ自体の機能ではなく「サービスメッシュがあると容易に実装できる機能の1つ」です。 自システムで発生したエラーの数はもちろん、どの上流システムとの通信で500がいくつ出ているのか、 どのシステムとの通信が遅くなっているかも一目でわかるため、性能調査や障害調査に役立ちまくりでした。 他社のエンジニアにこの画面を見せると異常にうらやましがられる画面です。 この画面のためだけにでもサービスメッシュを実装する価値があると思います。

f:id:mineroaoki:20190621215640p:plain
図7: 他システムとの通信のモニター画面

Orcha導入後の評価

以上が、Orchaを入れた経緯とその設計などの詳細です。 これを踏まえて、現時点までの結果と評価を述べます。

まず、当初の目的であった 「API呼び出し回数を増やさずに、撤退しやすい仕組みで、新規サービスを高速に追加すること」は問題なく達成できたと思います。 Orchaと新規サービスを合わせて、インフラ構築からとりあえず動き出すまでをわたし1人だけで、1週間で完了できました。 これはPantryで開発をしていてはとても達成できない目標でした。 また、現在は新卒で入ったばかりのエンジニアにOrchaの開発をしてもらっているのですが、こちらもスムーズに開発できています。 これもPantryではありえないことです。

第二に、かなり真剣に考えたパフォーマンスについても、全体公開後の数値を見るかぎり問題なさそうです。 現在、ピークタイムでも全プロセスの合計リソースがCPU 1コア、メモリ8GBで余裕をもって全リクエストをさばけています。 もちろんECS(Docker)で動いていますし、オートスケールを設定してあるので、必要なときは勝手にECS task数が増減されます。 非同期処理に特有のつらい点として、「ものすごい勢いでメモリリークする」などの問題が全体公開直後に発生したりしましたが、 これも早期に解決できました(タイムアウト設定の問題でした)。

第三にJavaとSpringの選択についても満足しています。 Springについてはいろいろいい点はありましたが、 まずデフォルトでアプリケーション設定がファイル(application.yml)と環境変数で透過的に設定できる点が便利です。 開発環境ではいろいろと便利なデフォルトや設定例を提供したいのでapplication.ymlをレポジトリにコミットしておき、 本番環境ではDocker前提なのですべての設定を環境変数で設定する、ということが簡単にできるので大変便利でした。 また当然ながら設定項目はアノテーション一発でオブジェクトに自動マッピングしたうえDIで注入できます。

ちょっとした追加の機能実装をしたいときにほぼ間違いなくライブラリがある点も有利です。 例えば開発環境でだけ動く単純なリバースプロキシ機能を追加したくなったのですが、 Spring Cloud Gatewayを導入し、application.ymlを少し書くだけで簡単に実装できました。 このへんのライブラリの充実っぷりはさすがです。

総じて、アーキテクチャ・実装設計ともに現時点では満足しています。 次のチェックポイントは、スマホアプリのエンジニアがさわるようになったときでしょう。

これからのOrcha開発ロードマップ

最後に、今後のOrchaの開発ロードマップについて今考えていることを述べます。

直近の目標は、Orchaをより完全な集約層にすることです。 具体的には、既存のAPIサーバー(Pantry)に存在する、 実質的に集約層として機能しているAPIのコードをすべて剥がしてOrchaに移動することです。

集約層的なAPIは全体からすれば数は少ないですが、実装が複雑なので分量はけっこうあります。 このコード移動を完遂して、スマホアプリの開発者がOrchaをいじれるようになることが当面のゴールです。

また、集約層的なAPIの移動が完了すれば、 残るAPIはすべてリソースを処理するAPIになるはずなので、 そちらは小さいシステムに分割してgRPCにしてしまいたいところですが…… これが終わるにはあと何年かかるやら、という感じです。終わりが見えない。

まとめ

本稿では、クックパッドのレシピサービスに新たに追加したBFF "Orcha"に関して、 その動機と実装、評価をお話ししました。 今回、個人的に一番うまくやれたと思う点は、既存システムの改善と新機能の追加を両立できたことです。 通常、この2つは利益相反の関係にあることが多く、どちらを取るかジレンマに悩まされがちです。 しかしOrchaについては珍しいことに両者を同時に満たす一石二鳥の手を打てたので大変満足しています。

では最後にいつものやつです。

弊社は世界最大のモノリスを共に崩していく仲間を募集中です。 三度のメシよりRailsが大好きなかたも、 RailsアプリをJavaに書き換えてこの世から消滅させたいかたも、 あとついでに今回の話とは関係ないですがデータエンジニアも S3とSQSとLambdaでAWSピタゴラスイッチしたい人も、 ともに大募集しております。 興味を持たれたかたはぜひ以下のサイトよりご応募ください。

SwiftUIで使用されているSwift5.1の新機能

$
0
0

こんにちは。会員事業部の岡村 (@iceman5499) です。 普段はクックパッドアプリ(iOS)を開発しています。 先日San Joseで開催されたWorldwide Developers Conference 2019 (WWDC19)に参加し、そこでSwiftUIの発表をうけていくつか調べたことがあるので簡単にまとめておきたいと思います

SwiftUIの登場

今年のKeynoteの最後に、SwiftUIという新たなUIフレームワークが発表されました。 SwiftUIはReactやFlutterのような形式でViewを宣言して画面を構築できる、これまで使用されてきたUIKitとは全く異なる形式のフレームワークです (AppleのSwiftUI紹介ページ )

この発表をうけてKeynoteはとても盛り上がっていました。期間中もSwiftUIの話題でもちきりで、セッションも多く開かれていました

SwiftUIでできるようになること

  • DSLでViewを宣言的に適宜できるようになりUIの構成要素を簡単に表現できるようになった
  • コード編集中にリアルタイムにUIプレビューを利用できる *1
  • 余白調整やアクセシビリティ・ダークモード対応などがある程度自動で行われ、Human Interface Guidelinesに則った画面を作成しやすい
  • スムーズなアニメーションが簡単に設定できるようになった

UIKitではよくあるリスト形式の画面を作るだけでも TableViewDelegateTableViewDataSourceのメソッドを多数実装したり、ラベルを上下に並べるのに

label.constraint(equalTo:otherLabel.topAnchor, constant:16).isActive =true

などといった長いコードを書いていく必要がありましたが、SwiftUIではそれがすっきりして

 List(contents) { content in
    VStack {
      Text(content.title)
      Text(content.subtitle)
    }
 }

のような形でシュッと書けるようになりました 🎉

f:id:iceman5499:20190624145219j:plain
Introducing SwiftUI: Building Your First App

(Introducing SwiftUI: Building Your First Appより)

実際にさわってみた感想

2019年6月現在。macOS Catalina 10.15 Beta 2 と Xcode 11 Beta2 を使用しています

プレビューめちゃくちゃ使いやすい!?

  • 起動して目的の画面にたどり着くための操作をしなくてもいい
  • その場でタップフィードバックなども試せる
  • モックデータを簡単に挿せる

あたりの機能は非常に便利で、今後のプロトタイピング開発やエラー表示のテスト、デザインドキュメントとしての利用など様々な場面での活用が予想されます

新規プロジェクトならいい感じに動いたのですが、一方で既存プロジェクトで動かそうとした場合にいくつかの問題点に遭遇しました

  • ビルドターゲットがiOS13未満に設定されているとプレビュービルドができない *2

Swiftでは @availableを用いることで指定コードが有効化されるiOSバージョンを制御することができます。これを用いてビルドターゲットがiOS13未満のプロジェクトにおいては

@available(iOS 13.0, *)
structContentView:View {
    varbody:some View {
        Text("Hello World")
    }
}

のように記述をすることでビルド及び実行することができるようになります(iOS13未満ではSwiftUIを使用できないため代わりの実装を用意する必要があります)

こちらの書き方を使用して既存プロジェクトからSwiftUIを利用する場合、Xcode11Beta2時点ではプレビュービルドはエラーとなり利用することができませんでした

  • Objective-C製ライブラリ(Firebaseなど)を使ってるとたびたびそれらのビルドが走る

これはSwiftUIの問題ではなくビルドシステムの都合だと思うのですが、プレビューを使用する際は変更部分のみがリビルドされるはずがObjective-Cを利用している場合にそれらのビルドが走ることがあり、現実的な待ち時間でプレビューを使用することが難しいことがありました

  • 新規プロジェクトでも急に止まったり調子悪くなったりしがち

こちらはシンプルにプレビューの描画が止まったり明らかにおかしくなったり、ビルドが長くなったりなどです

と、このような障害もあり、まだBetaであるため安定してないのはしょうがないですが、安定しないままリリースされる可能性も十分にあり得るため今のところプレビューはあくまで補助的なものと捉えています。

個人的にあるだろうと思った機能がないこともある

触っていくとだんだんと気づくのですが、Betaということもあって個人的に必要だと思った機能が実は存在していないといったケースがあります

  • HStackなどを用いて複数のViewを等幅で配置できない *3
  • ボタンハイライト時の挙動を設定できない *4
  • 画面を閉じる or 戻るボタンを配置できない

コンポーネントはどんどん拡充されていくはずですので、リリース時点やその後のコンポーネントの拡充に期待です

SwiftUIで使用されているSwift5.1の新機能

SwiftUIにはSwift5.1で新規追加される機能がふんだんに使用されていました

  • @propertyDelegate
  • @_functionBuilder
  • Opaque Result Type
  • @_dynamicReplacement(for: )
  • KeyPathに対する @dynamicMemberLookup

順にみていきます

@propertyDelegate

Proposal: SE-0258

(※ Proposalでは Property Wrappersという命名になっていますが、Xcode11Beta2上ではまだ @propertyDelegateが使用されているため本記事ではこちらで表記します)

この修飾子をつけるとプロパティに対して新しいattributeを宣言できるようになります

例えば次のような Lazyを宣言してみます

@propertyDelegateenumLazy<Value> {
  case uninitialized(() ->Value)
  case initialized(Value)

  init(initialValue:@autoclosure@escaping () ->Value) {
    self= .uninitialized(initialValue)
  }

  varvalue:Value {
    mutatingget {
      switchself {
      case .uninitialized(letinitializer):letvalue= initializer()
        self= .initialized(value)
        return value
      case .initialized(letvalue):return value
      }
    }
    set {
      self= .initialized(newValue)
    }
  }
}

このような @propertyDelegateが宣言されているとき、次のようにその宣言された型の名前でattributeを宣言できるようになります

@Lazyvarfoo=1738

これは実際のコンパイル時に以下のように展開されます(イメージのための疑似コードです)

var_foo:Lazy<Int>= Lazy<Int>(initialValue:1738)
varfoo:Int {
  get { return _foo.value }
  set { _foo.value = newValue }
}

fooへのアクセスが _fooに移譲される形となり暗黙に Lazy<Int>の機能を利用できるようになります。Lazyでは遅延初期化の実装がされているため、lazy varと同じような機能が @Lazyをつけることによって利用できるようになりました

Property Delegateを使用したSwiftUIの型は多数存在しています。 例えば @StateではViewに使用される値の更新検知をしており、View.bodyの中で @Stateつきの変数にアクセスするとその変数の監視が始まり、その値が変化したときに自動的にViewが更新されるといった挙動をみせています

また $をつけることによってラップしている本来の型のオブジェクトにアクセスすることができます

$foo// → Lazy<Int>

このとき、さらにラップしている型に var delegateValue: T { get }が定義されていれば delegateValueを取り出すことになります

例えば @Stateでは var delegateValue: Binding<Value> { get }が定義されている*5ため、

@StatevarinputText:String...varbody:some View {
  // ↓ TextField.init(_ text: Binding<String>) に対して//   $inputText.delegateValue: Binding<String> を $inputText という記法で取り出して渡している
  TextField($inputText)
}

次のようなコードがある場合に $inputTextBinding<String>を返します。 TextFieldは自身への入力を Binding<String>を経由して別のところへ渡すというインターフェースをしています

ややこしいですが、これによってSwiftUIはViewへのデータバインディングのためのプロパティの更新検知を実現しています

@_functionBuilder

Forum、 Proposal: SE-XXXX

VStack {
  Text("Hoge")
  Text("Fuga")
}

このコードを見たときSwiftのエンジニアは当然 🤔となると思います。クロージャが返り値を持っておらず、途中で評価しただけの Textが何らかの形でクロージャの外に現れています

VStackのイニシャライザ(一部省略)はこうなっていて

init(@ViewBuilder content: () ->Content)

なるほど怪しい @ViewBuilderが生えてることがわかります

これは新たに追加された @_functionBuilderによる機能で、どこかに @_functionBuilder struct ViewBuilder {}が宣言されているときクロージャ引数に @ViewBuilderを付与できるようになり、そのクロージャの中で評価された式は ViewBuilderが持つ各種build関数の中を通って出力されます

例えば中で2つのViewが評価されていたときはViewBuilderの public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View関数の中を通ります(1つ目に評価されたViewがc0、2つ目がc1として引数が与えられます)。結果、クロージャは TupleViewという型のインスタンスを返します

Swiftにこのような言語機能が搭載されたことによって、Swiftの型検査を有効にしたままDSLが記述できるようになっています

Opaque Result Type

Proposal: SE-0244

これまで関数の返り値にprotocolを指定した場合はexistential type *6にラップされて返され、ラップやアンラップの処理にオーバヘッドが発生していました。 またprotocolがassociated typeを持っていた場合はprotocolはGenericsの型パラメータを持てないので AnyHashableなど型消去のテクニックを用いて返却する必要がありました。これは元の型の情報を失っているために本来比較可能でないもの同士を比較できてしまうなどのコードを記述できてしまいました

Swift5.1からその問題を解決するために、 "protocol P を満たすある一つの型"を返すという意味で some Pという表現ができるようになりました。これによってPを満たす任意の型を実際の型を知らずとも扱えるようになりました

これがどのように作用しているかというと、 多くのSwiftUIの構造体は

structButton<Label>where Label :View {

のようにGenericsでそのViewの内部にあるViewの型を指定して受け取ります。existentialはそれ自身のprotocolに適合しないのでexistential経由でGenericsへ型パラメータをわたすことはできず、この型パラメータのためにはconcreteな型を知る必要があります

ただしSwiftUIの型は非常に複雑で、例えば上の

VStack {
  Text("Hoge")
  Text("Fuga")
}

VStack<TupleView<(Text, Text)>>型です。 これはまだマシですが、

List {
    Section {
        ForEach(names.identified(by: \.self)) { name in
            Text(name)
        }
    }
}

だと List<Never, Section<EmptyView, ForEach<IdentifierValuePairs<Array<String>, String>, Text>, EmptyView>>型になります。 こんな複雑な型をいちいち返り値に記述することは人間には難しいですし、変更に弱すぎます

そこで、それらをまるごとひっくるめて some Viewとして表現できるようになっています。 このような表現をSwift proposalでは、Opaque Result Typeと説明しています

varbody:some View {
    List {
        Section {
            ForEach(names.identified(by: \.self)) { name in
                Text(name)
            }
        }
    }
}

実際のコードはこうなので、上のような複雑な型を書く必要がなくなっています。 この機能によって実装中に複雑な型の存在を意識せずともViewを取り扱えるようになっています

@_dynamicReplacement(for: )

Forum

これはXcodeでのプレビュー用に使われている属性で、dynamic修飾子がついた関数などにこの属性がついたモジュールをロードしてあげるとその関数の実装を入れ替えることができるようになります。 SwiftUIのPreviewではこれを用いて実行中のシミュレータが持つバイナリの実装を動的に差し替えてリアルタイムなプレビューを実現しています

ちなみにこの挙動の存在は、Preview機能がクラッシュしたときのエラーログからXcodeがプレビュー対象のコードに @_dynamicReplacement(for: )をつけて回っていてあとから差し替えてる様子が確認できたことから確認しました

さながらObjective-C時代のMethod Swizzlingですね

KeyPathに対する @dynamicMemberLookup

Proposal: SE-0252

@dynamicMemberLookupは以前からSwiftに実装されている機能ですが、今回新たにKeyPathに対してsubscriptできるようになりました。 具体的な定義はこんな感じです

// BindingConvertibleの例subscript<Subject>(dynamicMember keyPath:WritableKeyPath<Self.Value, Subject>) ->Binding<Subject> { get }

任意のKeyPathをdynamicMemberLookupできるようになったため、プロパティアクセスのふりをしつつ型安全にsubscriptでアクセスできるようになりました。 これが具体的にどういうことか、以下のコードをみてみましょう

@dynamicMemberLookupstructBox<T> {
    varvalue:Tsubscript<U>(dynamicMember keyPath:WritableKeyPath<T, U>) ->U {
        return value[keyPath:keyPath]
    }
}

structUser {
    varname:String="taro"varage:Int=42
}

letboxedUser= Box(value:User())
print(boxedUser.age) // → 42

boxedUser.ageはいかにも Boxに生えているように見えますが、実際にアクセスする先は Userの持つ ageとなっています。 このようにして、 @dynamicMemberLookupを使用することで subscriptで指定されてる型に適合するkeyPathを \.ageなどの記法を使わずに取り出してあたかもプロパティ呼び出しであるかのようにsubscriptに流し込んで呼び出せるようになっています

これはSwiftUIではprotocolの BindableObjectで活用されており、

structViewModel:BindableObject {
  varname:String...
}

@ObjectBindingvarviewModel:ViewModel

として宣言されている viewModelに対して、

TextField($viewModel.name)

のように $viewModel.nameBinding<String>として取り出す操作を可能にしています。 (ObjectBindingのdelegateValueは ObjectBinding<BindableObjectType>.Wrapper型であり、それはKeyPathのdynamicMemberLookupで Binding<T>を返す) 一見viewModelに生えてるStringのプロパティを取り出しているように見せかけてBindingを返せているのでpropertyDelegateの恩恵をぶら下がってるプロパティにも適用できるようになっています

まとめ

SwiftUIで使用されているSwift5.1で追加された新機能について調べてみました。 マイナーアップデートでありながら大胆な機能が多数追加されてコードの様子が一気に様変わりしましたね。見た目は大きく変わりつつも中身は型の効いてるSwiftらしさがあり挙動や実装を調査していくのはとても楽しいですね

クックパッドアプリ(iOS)は1年前のiOSバージョンまでサポートする運用をしており、なんとあと1年とちょっと待てばSwiftUIが実用段階になる予定です。 また新規アプリを作成する際は最初からSwiftUIでやっていけるかもしれません。 クックパッドではSwiftUIを使ってすばやくサービス開発していくエンジニアや、SwiftやXcodeに詳しく開発環境を改善していけるエンジニアを募集しています

https://info.cookpad.com/careers/

*1:正確にはXcode11&macOS Catalinaの機能

*2:Beta2時点

*3:内部を HStack { Spacer(); content(); Spacer() } で囲む、GeometryReader でframe直打ちなどのやり方はありますがすっきりするものではありません

*4:longPressAction や DragGesture を使うという裏技もありますがすっきりするものではありません

*5:https://developer.apple.com/documentation/swiftui/state/3287851-delegatevalue

*6:https://blog.waft.me/2017/10/27/swift-type-system-08/などで詳しく解説されています


Markdown と GitHub で社内規程を便利に管理

$
0
0

VP of Technology の星 (@kani_b) です。技術基盤や研究開発領域などを担当しつつ、社内の色々なことを技術の力でいい感じにする仕事をしています。セキュリティや AWS の話が好きです。

さて、みなさんは、ご自身が勤務する会社の就業規則を読んだことはあるでしょうか。 エンジニアに限らず、会社の全スタッフが仕事をする上で関わってくるのが、就業規則や情報セキュリティドキュメントなど、会社のルールや規程を記す文書です。 特にセキュリティやインフラに携わるエンジニアは、その改訂も含め携わったことがある方もいるのではと思います。

よくある文書管理

こうした文書は、以下のように管理されていることが多いようです。

  • ベースドキュメントは Word
    • 保存時は PDF で保存
  • 版管理は Word の編集履歴 + PDF に保存する際のファイル名
  • 編集は担当部門, 担当者のみが行う

かつてのクックパッドでも、上記のように作成された PDF ファイルを Google Drive に保存して従業員向けに公開していました。 この記事を書くにあたり他のいくつかの企業の状況を伺ったところ、細かな差異はあれど同じような運用をされている例がほとんどでした。

つらい点

上記のような管理において、自分がつらいと感じる点がいくつかありました。以下に挙げていきます。

レイアウト難しい問題

複数人で編集することを前提とした文書の体裁を Word や他のワープロソフトで保ち続けるのはなかなか難しいものです。 全員が習熟していれば良いのですが、習熟度に差があると同じレイアウトでさえ記述方法が違っていたりします。 「番号付きリストかと思ったら番号は手動入力されていた」「中央寄せかと思ったら全角スペースの数でレイアウト調整されていた」「改行の数が違うとレイアウトが崩れる」なんてことはよくある話ではないでしょうか。

そもそも、そこまで頑張って整えている体裁は本当に必要…?

版管理難しい問題

ワープロソフト側に版管理の機能が備わっていることも多いのですが、複数人での編集を前提とするとき、全員が意識して同様の管理を行う必要があります。また、担当者の引き継ぎによって文化が失われてしまうような悲しい事態も起こります。 それ以外にも、規程閲覧側に公開されるのは最終成果物である PDF ファイルのみであることが多く、差分を確認するためにはそのバイナリに対応したソフトを利用する必要があります。閲覧側にとっても便利とはいえない状況です。

これら2点が感じていた大きな問題ですが、他にも

  • 文書を横断した検索性が悪い
  • 複数人でのレビューが難しい

といった問題を感じていました。

社内文書管理に求めていたこと

ここまでに書いた問題を感じつつ、つらい〜と鳴きながら文書編集をしていたのですが、ある日雑談の中でそうした文書管理を担当していた、いわゆるバックオフィスの同僚も同じようなつらさを感じていたことを知りました。 そこで、規程や社内マニュアルなどの文書に求められることを簡単にまとめてみたところ、概ね以下のような条件を満たしていれば良いのでは、という結論に至りました。

  • 然るべき責任者の承認のもと編集されていること
  • きちんと版管理が行われていること
  • 編集すべきときにすばやく編集できること
  • 見たいとき、見るべきときにすばやく参照できること
    • 閲覧しやすいフォーマットであることが望ましい

あれ、これって…?

あえてプレーンテキストを使う

というわけで、Word と社内ファイルサーバおよび Google Drive で管理されていた社内文書を、Markdown で書かれたファイルを Git (GitHub) で管理する形に移行しました。

Markdown は、 GitHub 上でリッチな表示を使ったレビューが可能です。また Groupad と呼ばれる社内 Wiki では長年 Markdown 記法が使われていました。このため、Markdown という名前を知らなくても 「Groupad と同じ記法」と説明すれば通じる状況にあり、利用をはじめるにあたりあまり障壁はありませんでした。

f:id:kani_b:20190626175720p:plain
Markdown で書かれた就業規則

また、GitHub についても、数年前から全社員に GitHub Enterprise のアカウントが発行されており、人事部門や法務部門も Issue ベースでのやり取りに慣れているといったところから、ある程度スムーズに利用を開始することができました。

GitHub からのファイル編集

Git 移行にあたって最も障壁となりやすいのは、編集作業をどのような環境で行うか、という点だと思います。 素の Git コマンドをターミナルから使ってくれ、ではハードルはどこまでも高くなるだけですので、Git を使ったことがない同僚にはまず GitHub の編集機能を使ってもらうことにしました。 あまり利用されていないようにも思えますが、GitHub そのものにも編集機能が用意されており、ファイル編集や変更の Commit, Branch や Pull Request の作成なども可能になっています。Git の概念をすべて理解してもらうのでなく、使う機能を限定することで、極力移行をスムーズにしました。

改訂フロー

現在の文書改訂フローは以下のようなものです。

  • 担当者が改訂案を Markdown で起案し Pull Request を作成
    • 必要に応じて他の担当や上長からのレビューを受ける
  • 責任者は確認し、内容に問題がなければ承認とマージを行う*1
    • GitHub の Branch Protection を使い、責任者の Approve がなければマージできないようになっている
    • 会議体の承認が必要な文書は Pull Request のスクリーンショット (記録のため) ごと会議体にまわり承認されたのち、責任者によって Approve される
  • マージ後は自動的に公開される

GitHub を使った開発でも行われるようなフローで文書管理を行えるようになっています。

文書の公開

GitHub における Markdown ビューを使っても、文書として読むのに問題ないビューを得ることができます。 ですが、文書の移行をすすめるうち、複数ある社内文書のインデックス作成やカテゴリ分類、少し複雑な計算式表現などが必要になりました。*2

そこで、Markdown が利用可能な静的サイトジェネレータである Jekyll を使い、Markdown で作成した文書から静的ページを生成して社内に公開しています。ドキュメント用の Jekyll テーマである tomjoht/documentation-theme-jekyllを採用しました。 Jekyll は GitHub Pages によって GitHub 側でビルドを行えるので、作成した文書をそのまま提供することもでき便利です。

当初 GitHub Pages による提供を考えていたのですが、例えば入社が内定された (入社前の) 方に公開するなど、アクセス制御の要件が増えてきたため、現在では Jenkins 上で自動ビルドを行って Amazon S3 上でホストしています。

f:id:kani_b:20190626175943p:plain
Jekyll でビルドされ公開されている就業規則

クックパッドでの利用状況

現在クックパッドでは、就業規則や賃金規程などほぼすべての社内規程が上記のような形で管理されており、法務部門や人事部門も GitHub を使った文書管理を行っています。 従業員はビルドされた HTML ドキュメントを参照することも可能ですし、元の Markdown を確認することや、Git のコミットログを確認することも可能です。

情報セキュリティのガイドブックといった、改訂しやすい文書は担当者外からの Pull Request も受け付けています。レイアウトの修正のほかに、ルールそのものに対する提案も Pull Request として来るものが生まれており、議論しやすくなったと感じています。

また、文書全体のレイアウトを整えやすくなり、より構造を意識して書くようになったり、従業員からの検索性が上がったり*3といった効果もありました。

あくまで個人的な感想ですが、編集しやすくなったことで、改訂に対する (気持ち的な) 腰の重さも軽くなったように思います。規則やそれを記述する文書は従業員の業務を助けるものであり、会社や世間の状況が変わった際すばやく改訂できる状態を保つことはとても重要と考えています。

まとめ

クックパッドにおける社内規程の文書管理を Markdown および GitHub を使った管理に移行した事例についてご紹介しました。

担当者全員が Git を覚えなければならない世界にするのでなく、GitHub といったある程度親しみやすいインタフェースを間に利用するといったように、担当者や閲覧する従業員にとって良い形を追い求めていくことは非常に重要です。こうした改善は「やりたい人の独り善がりにならないようにすること」がとても大事な、かつ楽しい部分だと感じています。

また、クックパッドでは、今回ご紹介したように技術を活用しながら全社の業務をより良くしたいコーポレートエンジニアや、自らの領域を一緒に改善していける財務・人事といったコーポレート部門スタッフを大募集しております。興味をお持ちいただけましたら、キャリア採用情報 (https://info.cookpad.com/careers/jobs/) から詳細を是非ご確認ください。

*1:軽微な修正のため、担当者の判断でマージできる文書もあります

*2:たとえば、賃金規程において、賃金の計算式を示すのに MathJaxを使っています

*3:現在は GitHub における検索にまかせています

おすすめの食べ方を見ながら食材を買える体験を作った話

$
0
0

はじめに

こんにちは、買物事業部のデザイナー兼エンジニアの長野です。

生鮮食品ECサービス「クックパッドマート」の開発チームで、注文ユーザー向けのサービス開発全般を担当しています。

今日は、先日クックパッドマートのiOSアプリでリリースした新機能とその開発プロセスについて、お話ししたいと思います。

クックパッドマートの詳細については、以前にも サービス立ち上げ期の話や、エンジニアメンバーの連載記事が投稿されているので、そちらもぜひご参照ください。

食べ方を想像しながら食材を選ぶ

今回リリースした新機能は、クックパッドマートで扱う様々な商品(食材)に対して、その食材を使ったおすすめの「食べ方」を提案する、というものです。

これまでのバージョンのアプリでも、商品におすすめのレシピを紐付けて見せるということは行なっていたのですが、今回のアップデートではより幅広い種類の「食べ方」とそのレシピを見ることができるようになりました。

f:id:yoshiko-nagano:20190627094151p:plain
「食べ方」が表示されるホーム画面と商品詳細画面

例えるなら、お店の人と対面で買い物をするときに「この魚は煮付けでもいいし、塩焼きでも美味しいよ」などと会話して、自分の気分で食卓をイメージして食材を購入する体験のようなものです。

このような機能のかたちに至るまでのプロセスを順を追ってご紹介していきます。

数ある課題の何から手をつけていくか?

カスタマージャーニーマップで現状を把握する

今回プロジェクトの開始時点では、アプリのどの部分に手を入れるか、どのような課題にアプローチするか、何も決まっていませんでした。「色々課題はありそうだけど、何から手をつけよう?」という状態です。そこで、まずは現状を正しく把握するためのカスタマージャーニーマップを作成するところからプロジェクトを開始しました。

f:id:yoshiko-nagano:20190627094202p:plain
作成したカスタマージャーニーマップ

社内でサービスを日常的に使っている数名にインタビューをし、サービスの利用フローとその時の思考の流れをマップに書き起こしました。

本来はよりリアリティのある社外ユーザーの情報を集めたいところですが、まだ開始間もないサービスでユーザー数が少ないこと、社内でも普段使いしている人が複数いたことから、スピード優先で社内リサーチを選択しました。

数名のインタビュー結果から、ユーザー属性によって傾向が見られたので、最終的に3人のペルソナとしてまとめることができました。

非同期型ワークショップで課題を洗い出す

カスタマージャーニーマップからは様々な課題が見えてきます。それらを洗い出すために、チームメンバー全員参加の非同期型ワークショップを行いました。

クックパッドマートのチームは、アプリ開発をするメンバーもいれば、日々の配送を回していく流通系のメンバーなど、担当領域が多岐に渡ります。それぞれの視点から見た課題意識をきちんと洗い出すために、今回は「全員参加」の形式にこだわりました。

とはいえ、総勢25名近いメンバー全員の時間を確保するのは非常にコストが高いです。そこで「非同期」で参加できるワークショップという形をとりました。

ワークショップのやり方は以下です。

  • 開催期間は3日間
  • 3人のペルソナのマップを、席近くの壁3箇所に貼り出す
  • メンバーは好きな時間に席近くのマップを眺めて、気づいた課題と解決策のアイデアをポストイットに書いて貼る
  • 毎日マップの掲載場所を入れ替え、全てのマップに目を通してもらう

f:id:yoshiko-nagano:20190121112748j:plain
非同期ワークショップ会場の様子

結果、3日後には25人の視点から洗い出された現状の課題とアイデアを集めることができました。

課題をマッピングし、優先順位をつける

大量に洗い出された課題は似た観点の課題をグルーピングし、以下の3つに仕分けをしました。

  • 今集中して掘り下げるべきもの
  • 今意識しなくても必然的に取り組むことになるもの
  • 今のフェーズではやらなくてよいもの

f:id:yoshiko-nagano:20190627094514p:plain
グループごとに3色の付箋を貼って仕分けた様子

ここまでくると、現状のサービスの課題とそれらの優先度が自ずと見えてきて、次のトライを集中して考えられる状態になりました。

どうやって課題を解決する?

デザインスプリントで解決策を探る

上記の課題の整理から、直近フォーカスして掘り下げる課題が以下に定まりました。

  • どうすれば、日常の買い物の選択肢になれるか?
  • どうすれば、食材の購入だけに止まらない買い物体験を作れるか?
  • どうすれば、使い続けても飽きないサービスになれるか?

そこで、これらに対する解決策を考えていくためのデザインスプリントを実施しました。

デザインスプリントでは、課題に対して一つのソリューションをプロトタイピングし、ユーザーインタビューを行なって検証するという一連のプロセスを、短期間で集中して行います。(こちらの書籍に詳しく書かれているGoogleの手法を踏襲して実践しています)

f:id:yoshiko-nagano:20190627094601p:plain
スプリント中の様子

f:id:yoshiko-nagano:20190627094627p:plain
家にある食材をベースに食べ方を提案するプロトタイプを作成

ここで作成したプロトタイプがそのまま使えるものになったわけではありませんでしたが、スプリントを通した検証結果から下記のような学びを得ることができました。

  • 食材から単一のレシピへ誘導すると、ユーザーの気分や制約条件(家族の状況、調理時間、使用する調味料や調理器具など)とのミスマッチが起こりやすい (現行アプリはこの状態だった
  • レシピより一段階抽象的な「食べ方(唐揚げ、和え物、煮付けなど)」を複数提示されると、ユーザーは気分に合う料理をイメージしやすい
  • 一つの「食べ方」に対してレシピを複数提示できると、ユーザーは自分の制約条件をクリアしたレシピに出会いやすい

したがって、これらをサービスに落とし込めれば、 いつサービスを訪れても自分にあった料理と必要な食材を見つけることができ、クックパッドマートが日々の買い物の選択肢になれるのではないかという仮説を立てることができました。

本当に実現可能なの?

コアになる技術要素を検証する

スプリントを終えて、試す価値のある仮説が得られたものの『本当にユーザーにとってグッとくる「食べ方」を複数提案できるのか?』というサービスのコアとなる部分の実現可能性には疑問が残っていました。実現できなければプロダクトが成り立たなくなってしまうので、早々に検証を進めました。

幸い、クックパッドには毎日料理をしているユーザーさんが提供してくれるたくさんのデータがあります。これまでのレシピサービスの開発の過程で、レシピデータや検索ログを元にして、食材に対する「食べ方」のデータを返す機能が複数開発されていました。そのような既存機能を活用して、クックパッドマートに適した「食べ方」を提案する方法がないかを検証していきました。

検証の対象としたのは、レシピサービスの検索部分でもすでに利用されている「食べ方検索」機能のロジックや、検索キーワードと関連性の強い食材を抽出するロジック、食材に対して多くのユーザーがよく作っている定番のメニューを抽出するロジックなどです。

f:id:yoshiko-nagano:20190627094722p:plain
クックパッドアプリで提供している食べ方検索機能

検証の方法はシンプルで、クックパッドマートで扱う主な食材キーワードを各機能に投げ、返ってきた結果をスプレッドシートにまとめました。それを、日常的に料理をしている人にユーザー目線で見てもらい、一番グッとくる結果を返せた機能はどれかを精査しました。

結果として、一番良さそうという感触が得られたのは「定番メニュー抽出ロジック」でした。定番メニューと言えども、10件以上提案されると自分の頭だけでは浮かんでこなかった食べ方に出会うことができる実感が得られました。また「定番」なので、突飛過ぎずにイメージしやすいといういい塩梅の提案が出せることがわかりました。

仮説をサービスに落とし込む

技術的な実現目処もたち、いよいよサービスの中でかたちにしていきます。

Figmaで画にしてレビューを繰り返す

UIデザインは基本的にFigmaを使って共有しながら作っていますが、初期はパターンをとにかくたくさん出し、良さそうな案を探っていきます。

f:id:yoshiko-nagano:20190627094756p:plain
まずは手書きのスケッチから

f:id:yoshiko-nagano:20190627094822p:plain
Figmaでたくさんのパターンをつくる

クックパッドマートチームは、日常的に料理をしているメンバーが多く、部内で簡易なユーザーテストをしてみるだけでも有益なフィードバックが多く得られます。Figmaのプロトタイピング機能を使って実機でデザインを見せるということを繰り返し、デザインの方向性を固めていきました。

また、画にする <->フィードバック のサイクルを出来るだけ短くする方法として、最近はペアデザインも試しています。UIデザイナーとPMで数時間社内の空きスペースにこもり、画にすることと議論することを同時進行で進めることで、デザインの精度とアウトプットのスピードが高められることが実感できました。

f:id:yoshiko-nagano:20190627094857p:plain
オフィスの片隅でペアデザインをする様子

実データを見られるようにする

ダミーデータでデザインをしていると、どうしてもこちらの都合の良い見え方だけでデザインが進んでしまいがちです。なので、早い段階で実データを見られるようにすることも意識しました。

レシピサービス側に必要なAPIのエンドポイントを作成し、クックパッドマートの管理画面からアクセスしてデータを取得できるようにする機能を早めの段階で実装しました。実際にどんなデータが何件返ってくるのかを確認できることで、デザインと現実のギャップを埋めることができました。

段階的に実装・リリースする

今回は、変更全体を一度にリリースするのではなく、商品詳細に「食べ方」を表示するフェーズと、アプリのホームに「食べ方」を表示するフェーズの二段階に分けて、リリースを進めました。

リリースを分けた理由はいくつかありますが、実装範囲が絞られることで既存機能への予想外の影響を少なくできることと、QAの対象範囲を狭めてQA期間を最小限にできることが大きいと思っています。

出来るだけリリーススピードを落とさず、チームが常にサービスを改善し続けている実感を持てる状態が、健全なサービス開発を進めていく上でとても大切だと考えています。

まとめ

「何から手をつけようか…?」と完全に手探りなところから、様々な手法を使って仮説を定め、一つの新機能としてサービスに落とし込むまでのプロセスをご紹介しました。

クックパッドマートは、買い物を便利にするだけのサービスにとどまらず、食材を買うことの先にある「料理をして食べる」という体験全体をデザインしていくことが重要だと考えています。おいしい食材でおいしい料理を作って大切なひとと食べる時間を世の中にもっともっと増やしていくために、今後もサービスを進化させていきたいと思います。

この記事を読んでクックパッドマートの開発にご興味を持っていただけた方がいれば、ぜひ一緒にサービスを作りましょう!ご応募おまちしております。

www.wantedly.comwww.wantedly.com

Firebaseで運用するKomercoの管理用アプリケーションの開発

$
0
0

こんにちは。Komerco事業部エンジニアの高橋(id:yosuke403)です。「料理が楽しくなるマルシェアプリ」であるKomercoの開発を行っています。

Webサービス開発と聞くとユーザが利用するWebアプリやモバイルアプリの開発を思い浮かべますが、運営スタッフがサービスのデータを閲覧・更新するための管理用アプリケーションの開発も必要になることがほとんどです。

KomercoはバックエンドにFirebaseを活用しているのを一つの特徴としているサービスです。 今回はKomercoの開発事例を通して、Firebaseを用いた管理アプリケーション開発の知見をご紹介したいと思います。

Komercoの管理用アプリケーションについて

KomercoではFirebaseのHostingを利用し、Webで管理用アプリケーション(以下、管理アプリ)を提供しています。

f:id:yosuke403:20190705083445p:plain

Komercoの管理アプリでできることとして、

  • 登録商品の監視
  • 販売許可証の審査
  • コメルコバナシ(アプリ内の記事コンテンツ)や特集(テーマに沿った商品のピックアップ)の更新
  • Push通知

などがあります。

管理アプリの利用者はKomercoのスタッフですが、エンジニア以外のメンバーが触ることが多いです。また、社外の業務委託先にも管理アプリを利用してもらい、一部の業務を依頼しています。

アクセスログを残す

管理アプリからは一般ユーザのプライベートな情報も閲覧できますし、またデータの更新が一般ユーザに影響を与えることもあるので、トラブルに備えて誰が、いつ、どんな操作をしたかを記録する必要があります。

KomercoではデータベースにFirebaseのFirestoreを利用していますが、Firestoreには誰がどんな操作をしたのかをログ出力する機能がありません。 そのため面倒ではありますが、Webフロントエンドから直接Firestoreへはアクセスさせず、Cloud Functionsを経由して読み込み、書き込みを行うようにしています。

Cloud Functionsはイベント駆動でアプリケーションを実行できるFirebase(GCP)の機能です。イベントトリガーにはいろいろな種類がありますが、 functions.https.onCallをトリガーとしてセットした場合、渡されてくるCallableContextのデータから簡単にユーザIDが取得できます。このユーザIDはFirebase Authenticationに登録されているIDで、これを確認することで誰がこのFunctionを呼んだかが分かります。

またCloud FunctionsからFirestoreへのアクセスは通常Firebase Admin SDKを使用しますが、 Firestoreへのアクセスを記録するためにの薄いラッパーを作っていて、それを経由してSDKを呼ぶようにしています。 これによりFirestoreへのデータ読み込み・書き込みが発生すると、誰がFirestoreへどんなアクセスしたか、ログに吐き出されるようになります。

f:id:yosuke403:20190705085904p:plain

こちらは実際に出力されているログの例です。uidに対応するスタッフがproductとadminのドキュメントをgetしたことが分かります。

f:id:yosuke403:20190705083450p:plain

Webフロントエンドから直接Firestoreへアクセスできないのが面倒ではありますが、Firestoreのセキュリティルールが複雑化しなくて済むというメリットもあります。 もし直接Firestoreにアクセスできるようにするなら、一般ユーザ向けアプリ用のルールと管理アプリ用のルールが混在するようになってしまいます。

管理アプリユーザごとの権限を設定する

管理アプリユーザが誤って意図しない操作を行ってしまったり、閲覧してはいけないデータを参照したりしないよう、管理アプリユーザごとにロールを設定しています。 Functionが実行されたときは、まず最初にアクセスユーザのロールをチェックし、リクエストのあった機能の利用権限があるかを判定しています。

現状は簡易な設計をしていて、Firestoreに管理者を登録するコレクションを作成し、各ドキュメントにはユーザIDをドキュメントIDとし、ロールに対応する文字列の配列をフィールドとして持たせています。 Functionが実行されたときにこのドキュメントを参照して権限チェックを行います。

f:id:yosuke403:20190705083510p:plain

パフォーマンス問題

Cloud Functions経由でデータ取得・更新するようになって問題になったのが、管理アプリのデータ表示にかかる時間が長くなったことです。

管理アプリは基本的に限られたスタッフしか利用しないので、Functionが呼ばれる頻度が低く、その結果多くの場合においてFunctionのコールドスタート(実行環境がゼロから初期化されてスタートする状態)が発生しやすい状況になっていました。 コールドスタート時に遅くなる原因はいくつかありますが、大きな原因の一つがFunctionを実行するインスタンスとFirestore間で新規にコネクションを張る処理時間でした。 これはFirestoreへの最初のリクエストの時点で発生します。

Functionが変わればそれを実行するインスタンスも変わるので、コネクションが新規に必要になります。 管理アプリでは画面ごとに異なるFunctionが呼ばれることがほとんどで、新しい画面を開く度に時間がかかり、非常にストレスでした。

f:id:yosuke403:20190705083504p:plain

メモリ割り当てを増やす

Firestoreとのコネクションの確立を速くするにはメモリ割り当てを増やすのが効果的です。

次のグラフはメモリ割り当てを変更してコールドスタートの実行時間を計測したものです。 Firestoreから指定IDのドキュメントを一つだけ取得する処理を行っています。

exportconst testFunction = functions.https.onRequest(async(req, res)=>{const result =await admin.firestore().doc('/aaa/bbb').get()
    console.log(result)return res.send('succeeded')})

f:id:yosuke403:20190705083443p:plain

実行時間が半分ぐらいになっているのが分かります。 GCPのコンソールを見れば分かるのですがメモリ使用量としては256Mでも十分で、実はメモリ割り当てに紐付いて変更されるCPU割り当てが効いているものと思われます。 もちろん料金はその分上がるのですが、今のところ管理アプリ用のFunctionは実行回数がそこまで多くないので、高めに設定しています。

Functionを統合して呼び出しごとのコールドスタートを減らす

もう一つの対策として、管理アプリから利用するのFunctionを1つに統一し、クエリパラメータに応じてロジックを分岐するようにしました。 これにより一度確立したFirestoreのコネクションを、どのロジックを実行する場合でも流用できるため、画面遷移ごとにコールドスタートすることは少なくなりました。

f:id:yosuke403:20190705083506p:plain

Function統合のメリット・デメリット

Functionの統合はコネクションの流用以外にもメリットがあります。Functionが分かれないので、先の項目で説明したロールのチェックをはじめとする全Function共通の処理を一箇所に置くことができます。また、Functionの数が減るのでデプロイの負荷も抑えることができます。

Function統合のデメリットとしては、本来Functionごとに分かれるログがひとつに混ざってしまうことです。 しかし、現状限られたスタッフのみが管理アプリを利用している状況であるため、今のところログの量も比較的少なく、Webコンソールの検索機能から十分追えています。

パフォーマンスの改善だけであればメモリ割り当ての変更のみでもよいのですが、Komercoでは以上を踏まえ両方の対応を行いました。

データの検索

管理アプリとしてよく要求される機能がユーザや商品の検索機能なのですが、Firestoreで文字列検索を行うのは難しく、必要ならAlgolia等の外部サービスと連携するする必要があります。とはいえ、わざわざ外部サービスと連携するのは面倒です。

完全な解決作ではありませんが、前方一致だけなら対応できます。 例えばproductコレクションのnameフィールドに対して前方一致検索をかけたい場合、

firebase.firestore()
  .collection('product')
  .orderBy('name')
  .where('name','>=', input)
  .where('name','<=', input + '\uf8ff')

と書くことができます。

複雑な検索が不要な場合はこれで対応しています。

Cloud FunctionsのエラーをSlackに通知

Cloud Functionsで発生したエラーはSlackに流してすぐに気づけるようにしています。 Cloud Functionsには直接Slackに通知する機能はありませんが、ログはGCPのStackdriverに記録されているため、StackdriverのError Reporting機能を使うことができます。これによりメール通知としてエラー報告を受け取れます。Gmailでこのメールを受信し、フィルタと転送、及びSlackのEmail Integration機能を使うことでSlackに通知が流れるようになります。

f:id:yosuke403:20190705083412p:plain

プレビュー機能で結果を分かりやすく

管理アプリのいくつかの機能では、データ入力後にユーザにどう見えるかをプレビューする機能をつけています。 管理アプリはエンジニア以外のメンバーが使うことが多いので、自分が入力した情報がユーザにどのように表示されるのかを伝える必要があります。 プレビュー機能があると、管理アプリのユーザは安心して情報入力できるようになります。

例えばこちらはコメルコバナシの編集画面です。右半分がアプリに表示される様子をプレビューしています。

f:id:yosuke403:20190705083440p:plain

こちらはユーザへのPush通知画面画面です。画面下にiOSで通知される様子が表示されています。 FirebaseコンソールにもPush通知を送る画面はありますが、こちらの方が結果がより分かりやすいかと思います。

f:id:yosuke403:20190705083455g:plain

スタッフから喜ばれる機能なので、エンジニアも自主的に開発に取り組んだりしています。

最後に

Komercoの管理アプリを通して、Firebaseの特徴を踏まえた管理アプリの開発の知見をご紹介しました。

管理アプリはユーザの目には直接触れませんが、スタッフの効率に直接影響するため、なるべくよいものを提供したいと思っています。 Firebaseは運用にかかるコストが低く、エンジニアがサービス開発に専念できるのがとてもよいところです。 管理アプリも積極的に開発して全体の効率を上げていきたいです。

Komercoでは、モノで料理を楽しくしたいエンジニアを募集しています。 Firebaseを使ったサービス開発に興味のある方いましたらぜひご連絡ください! ご応募お待ちしております!

www.wantedly.com

www.wantedly.com

Simpacker: Rails と webpack をもっとシンプルにインテグレーションしたいのです

$
0
0

技術部の外村(@hokaccha)です。Rails で webpack を使うためのシンプルな gem を作ったのでそれについて紹介します。

Webpacker

Rails で webpack を利用した Web フロントエンドの環境を作る場合、最近では Webpacker が選択されることが多いでしょう。Rails 6 からは Webpacker が標準になることもあり、この流れはますます加速すると思われます。

私自身もこれまでいくつかのプロジェクトで Webpacker を利用してきました。Webpacker は webpack を Rails から簡単に利用でき非常に便利なのですが、使っているうちにいくつか不満な点がでてきました。

一番大きい問題として Webpacker が @rails/webpacker という npm パッケージに webpack の設定を隠蔽し、Webpacker 独自の API や webpacker.yml という設定ファイルを通して webpack の設定をする必要があるというところです。

Webpacker のドキュメントに書いてある範囲内であったり、Webpacker にデフォルトで組み込まれている設定であれば webpack のことを知らなくてもなんとなく webpack が使えるという点は便利なのですが、webpack のことをすでに知っていたり、Webpacker のドキュメントにない設定をしようとした場合、webpack の設定を Webpacker に反映する方法を調べなければいけません。なので使っているうちに、「頼むから webpack の設定を直接書かせてくれ!」となります(少なくとも私はなりました)。

また、webpack のバージョンの Webpacker に引きづられてしまうというのも問題の一つです。webpack 4 がリリースされてから、Webpacker の webpack 4 対応バージョンが正式リリースされるまで1年以上の間がありました。

Simpacker

Webpacker は webpack を知らない人でも簡単に使えるというところがいいところだと思うのですが、もう少しシンプルに webpack とのインテグレーションだけをしてくれるツールがほしいと思い作ったのが Simpacker という gem です。クックパッドでもいくつかのプロジェクトで導入し、うまく動いています。

Simpacker は基本的なところは Webpacker と同じです。manifest.json という、ハッシュ値が付与されたファイル名ともとのファイル名のマッピング情報を持つファイルをもとに、JS や CSS を読み込むタグを生成するヘルパーを提供します。Webpacker と違うのは、Simpacker は webpack 側の設定を一切管理せず、出力する manifest.json のパスしか知らないというところです。開発者は好きな webpack のバージョンを使い、直接 webpack.config.js を書いて、Simpacker で設定されたパスに manifest.json を出力すればいいだけです。

そのため、原理的には webpack でなくても Parcel や Rollup などのモジュールバンドラーでも利用できます。実際に Parcel で動くことは確認してはいています1

例えば webpack では webpack-manifest-pluginなどのプラグインで manifest.json を出力できます。これによって以下のような JSON が出力されます。

{"application.css": "/packs/application-6ebc34fd09dc6d4a87e9.css",
  "application.js": "/packs/application-6ebc34fd09dc6d4a87e9.js",}

Rails 側では Webpacker と同じように、javascript_pack_tagstylesheet_pack_tagなどが使えます。

<%= stylesheet_pack_tag 'application' %>
<%= javascript_pack_tag 'application' %>

これは manifest.json の値を読んで以下のようなタグが出力れます。

<linkrel="stylesheet"href="/packs/application-6ebc34fd09dc6d4a87e9.css" /><scriptsrc="/packs/application-6ebc34fd09dc6d4a87e9.js"></script>

Simpacker の導入

simpackerをGemfileに追加してインストールしたら以下のコマンドで初期設定のファイルがインストールできます。

$ rails simpacker:install 

これで必要最低限の設定とJavaScriptのコードがインストールされるのでnpx webpackでビルドしてjavascript_pack_tag 'application'のヘルパを書けば動きます。

ただし、これでインストールされる webpack.config.js は本当に最低限の設定と manifest.json を出力する設定がしてあるくらいです。Webpacker ではデフォルトで組み込まれる、Babel や PostCSS、file-loader、webpack-dev-server などは、どれも入りません。これは、利用しないものがデフォルトで色々入るのは個人的にあまり好きではないという理由が大きいところです。

また、Webpacker であれば webpacker:install:reactなどのコマンドで React をインストールしたりできますが、このような機能も提供していません。Babel や TypeScript などのトランスパイラ、React や Vue などのフレームワーク、CSS も 素の CSS から Sass、PostCSS など様々な組み合わせがあり、それらの組み合わせをインストーラーでまかなうのは複雑すぎると思ったからです。

なので開発者自身が自分が使いたいものを選んで webpack.config.js を設定していく必要があります。webpack や各種言語やフレームワークのドキュメントを見て webpack の設定をがんばってください、でもいいのですが、何も知らない状態から webpack を設定するのはまあまあ難しいことが知られているので、各種設定例を用意することにしました。現時点では以下のような設定例を用意しています。

お察しの通り、半分以上 Simpacker は関係なくて単に webpack の設定です。初期の最低限の設定から、必要なものだけをこれらから選んで設定することで webpack の設定についても知ることができるので、webpack の入門にもいいのではないかと思います。単なるドキュメントでなく実際に動く例なので手元に持ってきて色々試すのにも便利です。

Simpacker にない機能

Webpacker が提供している機能で、Simpacker が提供していない機能もいくつかあります。

デプロイ

例えば、デプロイ関連の機能です。Webpacker では webpacker:compileという rake タスクが提供されていて、このタスクは assets:precompileの後に自動で実行されます。したがって、多くの場合はデプロイの設定を変えずにこれまでと同じようにデプロイできるはずです。

Simpacker はどんなコマンドで webpack のコマンドが実行されるか知りませんし、package manager に npm と yarn のどちらを使うのかも知りません。当然そのような設定項目を足せば実現できますが、シンプルさを重視してこの機能は提供しないことにしています。

とはいえ難しいことはなくて、以下のようなコマンドをデプロイフローに足せば済むはずです。

$ npm install
$ NODE_ENV=production ./node_modules/.bin/webpack

デプロイに関しては上記の設定例にもいくつか例を書いています。

リクエスト時のコンパイル

また、Webpacker には webpack --watchwebpack-dev-serverなどの監視プロセスを立てなくても、リクエストの処理中に Rails 側で webpack のコンパイルを実行するという機能があります。当然リクエストのたびにコンパイルが走ると遅すぎて話にならないので、対象ファイルの変更があったときだけコンパイルが走りますが、フロントエンドを開発しているときは当然毎回ファイルが更新されてコンパイルが走ることになるので、監視プロセスを別途立てるのが一般的です。この機能は普段フロントエンドを開発しない開発者が別途監視プロセスを立てなくても開発できる、というためのものと理解しています。

この機能は便利なケースもあると思うですが、これを実現するには Simpacker が知らないといけない webpack 側の情報がかなり多くなり、設定が大幅に複雑化してしまうので、一旦対応を見送っています。webpack --watchwebpack-dev-serverなどのコマンドを foreman などで実行すればよいだけなので、そこまで困ることはないと思っています。

まとめ

Rails 自身がそうなのですが、Webpacker も同じように、いかに簡単に使えるか(easyさ)を重視した仕組みと言えます。それに対して Simpacker はひとつのことだけうまくやる、シンプルさを大事にした設計にしました。そのような設計思想の違いがあるので、easy さを好む人は Webpacker を使うほうがいいと思います。シンプルさを求めて webpack の設定を直接書きたいという場合はぜひ Simpacker も検討してみてください。

クックパッドではWebフロントエンドの開発基盤をつくったりすることが好きなエンジニアも募集しています!!!


  1. manifest.json を作るプラグインが .ts.vueなどの拡張子でうまく動かなかったり、ファイル名にハッシュ値をつけるためにエントリポイントとして html が必要だったりするので、現時点では現実的な利用は難しいかもしれません。

EuRuKo 2019 で発表してきました

$
0
0

技術部でフルタイム Ruby コミッタをしている遠藤(@mametter)です。フルタイムで Ruby を開発しています。

先日、オランダのロッテルダムで開催された EuRuKo 2019 で発表してきたので、簡単にレポートします。

EuRuKo とは

EuRuKo は、毎年ヨーロッパのどこかで開催されている Ruby のカンファレンスです。

f:id:ku-ma-me:20190709131404j:plain
EuRuKo 2019 会場

シングルセッション

世界の Ruby カンファレンスといえば、アメリカの RubyConf 、ヨーロッパの EuRuKo 、日本の RubyKaigi だと勝手に思っていますが、この中で EuRuKo の特徴というと、シングルセッションなことです*1。つまり、発表を聞く会議室は 1 つだけです。どれを聞くか迷わなくていいですね。

必然的に、発表の数は少ないです。YouTube の動画の数を見てみると、RubyConf 2018 は 70 件、RubyKaigi 2019 は 65 件ですが、今回の EuRuKo 2019 の発表は 15 件、LT を含めても 20 件でした。発表したい人からすると、採択されるのが最難です(たぶん採択率 1 桁)。

city pitch

また、次回の開催地が会期中に投票で決められるのも特徴です。立候補した街の人が city pitch(街の宣伝)を行い、会場の拍手喝采の大きさで決められていました。

f:id:ku-ma-me:20190709131545j:plain
EuRuKo 2020 開催に立候補した都市

オーストラリアのメルボルンが立候補してるのが話題でしたが、来年はヘルシンキになりました。

EuRuKo 2019 の様子

オランダのロッテルダムにある、ss Rotterdam っていう退役したクルーズ船が会場でした。現在は岸に固定されてホテルになってます。

f:id:ku-ma-me:20190709131400j:plain
EuRuKo 2019 の会場(ss Rotterdam)

EuRuKo 発表の傾向としては、Ruby コア開発の話よりは Ruby を使う話が中心です。そういう意味で、RubyKaigi よりは RubyConf に似ています。

その中でも印象に残った話を 3 つほど簡単に紹介します。

Functional (Future) Ruby - Yukihiro Matsumoto

matz のキーノート。動画がすでに上がっています。

ざっと内容を紹介します。まずは Ruby 3 の 3 つのゴールである Static analysis 、Performance 、Concurrency の進捗が説明されました。詳細はいろいろなところで見つかるので、主にキーワードだけ挙げておきます。

Static analysis は、公式型シグネチャフォーマットとなる予定の ruby-signature、型シグネチャのない Ruby プログラムをゆるく型検証し型シグネチャを推定する type profiler、型シグネチャを前提として、型シグネチャとコードの対応を検証する SorbetSteepなどが登場してきたことがダイジェストで紹介されました。 Performance は、オブジェクトを並べ替えてメモリのフラグメンテーションを軽減する Object compactionによってメモリボトルネックなアプリの効率を上げ、また JIT コンパイラである MJIT や MIR によって CPU ボトルネックなアプリの実行効率を上げることが述べられました。 Concurrency については、並列実行向けには Guilds(仮称)が計画されており、大規模並行処理向けには AutoFiber(仮称)が検討されていることが触れられました。

それから、関数型プログラミングっぽい他言語からインスパイアされた検討中の機能が紹介されました。

  • ブロックの無名引数

@1で引数を暗黙的に参照できます。

3.times { p @1 }
# 3.times {|i,| p i } と同じ

@2だと 2 番目の引数、@3だと 3 番目の引数を参照できます。しかし現実的には複数の引数を参照仕分けるユースケースは稀なので、引数 1 つに特化し、別の記号(@、Kotlin や Grooby 風の it、Scheme の <>)などにするか検討中とのこと。他人事のように言ってますが、itを提案しているのは遠藤です(Feature #15897)。

  • パターンマッチング。

データ構造が想定パターンになっているか調べて、その要素を変数に束縛することができます。

case JSON.parse(json, symbolize_names: true)
in {name: "Alice", children: [name: "Bob", age: age]}
  p age
in _
  p "no Alice"
end

典型的には、JSON の処理に便利そうです。

  • パイプライン演算子

パイプライン演算子が試験的に Ruby に取り込まれました。

1..100
  |> map {|x| rand(x)}
  |> sort
  |> reverse
  |> take 5
  |> display

「パイプライン演算子はプライマリの引数を呼び出しにわたすもの」「Ruby ではプライマリの引数はレシーバ」ということで、実質的にメソッド呼び出し演算子(.)と同じものになっています(このへんの詳細は遠藤のブログを参照ください)。しかし非常に controversial であり、名称・記号を変更するか、キャンセルするか(まだリリース前なので互換性の問題なく取り消せる)、現在も悩んでいるとのことです。

というように、様々な意見はありつつも Ruby はまだまだ新しい機能を取り入れて進化していってます。

What causes Ruby memory bloat? - Hongli Lai

Ruby プロセスのメモリ肥大化の原因を調査した報告です。発表者の Hongli Lai は Passenger や Ruby Enterprise Edition の作者です。この発表はだいたい次のブログで発表済みだった内容ですが、今回の EuRuKo で一番テクニカルで、個人的には一番おもしろい発表でした。

www.joyfulbikeshedding.com

Ruby のメモリ肥大化と言えば、「Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳)」という記事が有名です。端的に言えば「jemalloc を使え、もしくは環境変数 MALLOC_ARENA_MAX=2 を設定しろ」というやつで、見たことがある人も多いのではないでしょうか。しかし Hongli Lai はこの記事を疑い、再調査したところ、メモリ肥大化の本当の原因は別にあったということです。

かいつまんで説明します。Ruby がメモリを使うのは 2 種類あります。

  • (1) Ruby オブジェクトのヒープ
  • (2) Ruby オブジェクト以外のヒープ(主に文字列や配列など)

(1) については、あるアプリケーションでオブジェクトのヒープサイズを測定したところ、全使用メモリ 230 MB に対して 7 MB だったので、肥大化の原因としては無視できます。

(2) についてはちょっとややこしいのですが、malloc したメモリが一部だけ生き残って断片化が起きるという前提で、glibc の malloc はスレッドごとにメモリアリーナを確保するので問題がひどくなるというのが元記事の主張で、そのために MALLOC_ARENA_MAX=2を設定すればアリーナの数が高々 2 に抑えられるので問題が軽減するとされています。

しかし Hongli Lai は「malloc 領域の中で断片化は本当に起きているのか?」と疑問を持ち、可視化機能付き malloc ラッパを書いて確かめたそうです(すごい)。その結果がこれ。

f:id:ku-ma-me:20190709131409j:plain
"What causes Ruby memory bloat?" Ruby プロセスのメモリ使用状況の可視化

赤い領域は使用中、灰色は free 済み(すでに使用されていない)だが OS に返却されていない領域、白は返却済みの領域です。赤い領域が飛び飛びにあるので断片化は多少起きているものの、完全に灰色のところも多いことがわかります。つまり、断片化はそれほどひどくなく、むしろ OS に領域を返却できていないことが問題です。 使用済みだが OS に返却されていないのは、こまめに返却すると実行効率が下がることがあるためだと思われます。malloc_trimという関数を使えば使用済み領域を明示的に OS に返却できる *2ということで、Ruby にパッチをあてて再実験した結果がこちら。

f:id:ku-ma-me:20190709131803j:plain
"What causes Ruby memory bloat?" malloc_trim パッチ後のメモリ使用の様子

めでたく、白い部分が増えました。これにより、使用メモリ 230 MB だったところが 60 MB 減ったということです。MALLOC_ARENA_MAX=2を指定したら 53 MB なので、効果としてはほぼ同じとのこと。

また、Fullstaq Rubyという Ruby のディストリビューションを始めたということが発表されていました。今回の調査結果を含むということです。

The Past, Present, and Future of Rails at GitHub - Eileen M. Uchitelle

GitHub で働く Rails コミッターである Eileen の発表。Rails わかんないのでさらっと紹介です。

なんと、GitHub では、Rails をフォークして使っていたそうです。

f:id:ku-ma-me:20190709131553j:plain
"The Past, Present, and Future of Rails at GitHub" GitHub は Rails をフォークしていた

しかし、先端の Rails にどんどん置いてかれて開発もメンテナンスも辛くなり、採用やセキュリティ対応も厳しくなってきたので、がんばってフォークを卒業した、ということでした。

f:id:ku-ma-me:20190709131558j:plain
"The Past, Present, and Future of Rails at GitHub" GitHub は Rails のフォークをやめた

ということで、「フォークをしても良いことないよ」「ちゃんと Rails の最新版を使っていこうね」ということが何度も語られていました。

がんばってフォークを卒業するまでに相当がんばったのではないかと思われるのですが、あんまりその辺の苦労は語られていなかったのが若干心残りです。しかし、Eileen は Rails 6 に GitHub で必要な機能(Multi-DB とか)を入れまくった人なので、その背景が想像される感じで面白かったです。独自フォークを無くすためには、フォークを公式にすればいいわけです。*3

A Plan towards Ruby 3 Types - Yusuke Endoh

ついでに自分の発表です。発表資料はこちら。

発表内容は大筋で Ruby 3 の静的解析の計画と、その中で自分が作っている型プロファイラの進捗報告でした。おおよそは RubyKaigi と同じなので、クックパッド開発者ブログで書いた遠藤の過去記事をご覧ください。

進捗としては、解析速度に難があった(型プロファイラ自身のプロファイルに 10 分、optcarrotに 3 分)だったので、解析アプローチを大幅に見直して、精度を少し犠牲にしつつ高速化をしたところ、それぞれ 2.5 秒・6 秒になった、というところです。とはいえまだまだ未実装な機能だらけなので、引き続きがんばってます。

f:id:ku-ma-me:20190709131550j:plain
発表のときに壇上から撮った会場の様子

おまけ:発表者の特典

前述のとおり EuRuKo は採択率が非常に低いのですが、そのためか発表者のおもてなしがなんかすごかったです。

  • 旅費・宿泊費支給(いろいろあって遠藤は旅費をクックパッドに、宿泊費を EuRuKo に出してもらいました)
  • 空港からの送迎
  • 水上タクシーでの周辺観光
  • 現地の Rails Girls イベントへの招待
  • スピーカディナーへの招待(発表者と運営だけが参加できるディナー)
  • スピーカラウンジ(発表準備したり、議論したり、ゲームで遊んだりできるスペース)
  • 記念品(会場だった ss Rotterdam の模型)

f:id:ku-ma-me:20190709133117j:plain
EuRuKo 2019 発表者記念品と名札

まとめ

EuRuKo 2019 のダイジェスト紹介でした。

ちなみに今回の日本人参加者は matz と自分の 2 名だけでした(たぶん)。海外の Ruby カンファレンスと言うとアメリカの RubyConf が注目されがちですが、EuRuKo も行ってみるといいのではないでしょうか。来年はフィンランドのヘルシンキです。

f:id:ku-ma-me:20190709133255j:plain
EuRuKo 2019 エンディングの様子

*1:ちなみに歴史は RubyConf(2001 年)、EuRuKo(2003 年)、RubyKaigi(2006 年)の順で、最近の規模は RubyKaigi(1000 人程度)、 RubyConf(800 人程度)、EuRuKo(600 人程度)です。

*2:ドキュメントによると「ヒープの一番上の未使用メモリーの解放を試みる」となっていますが、Hongli Lai がソースを読んだ限りだと、一番上に限らず未使用領域を返却していくらしいです。

*3:手前味噌ですが oneshot coverageもクックパッドの Ruby フォークを本家に整理して再実装した感じです。

サービス開発でぶつかってきた壁と、そのとき助けてくれた本

$
0
0

こんにちは、開発ディレクターの五味です。クックパッドにレシピを投稿してくれるユーザーのための機能やサービスを開発する「投稿開発部」に在籍しております。

投稿開発部は、2018年1月に前身となる部からメンバーを一新して発足した部署です。自分たちで1から戦略を作るため、強い実感を持ってユーザーを理解することを信条に、資料を読んだり前任者に聞いたりするだけではなく、実際にユーザーとたくさん話し、たくさんレシピを投稿し、ユーザーのことをたくさん考えてきました。

この記事では、その中でぶつかった課題を解決するために取り入れた書籍や、それをうまく業務に取り入れるために行っている工夫を紹介します。

サービス開発にはさまざまな壁が現れる

ユーザーと事業目標に真摯に向き合うほど、サービス開発にはたくさんの壁が現れます。私たちも例外ではなく、部の発足以降、以下のような壁に激突してきました。

  1. 「ユーザー課題の見極め難しい!」の壁
  2. 「戦略づくり難しい!」の壁
  3. 「良いソリューションアイデアが出ない!」の壁

f:id:natsuki53:20190711170248j:plain
開発の歩みとぶつかった壁の所在

壁にぶつかったら学習チャンス

投稿開発部には、日常的に書籍を読んで、仕事に取り入れようとする文化があります。特に壁にぶつかった時は、ブレイクスルーを図るため、チームで意識的に本を読んだりします。

この1年半で、激突した壁ごとにお世話になった本をご紹介します。

1. 「ユーザー課題の見極め難しい!」の壁

部の発足後、早々に苦悩したのが「レシピ投稿ユーザーの本質的な課題は何か」という問いでした。

クックパッドには、レシピを検索するユーザーと投稿するユーザーがいますが、それぞれ数や志向が大きく異なります。投稿ユーザーのための開発をするなら、彼らのことを誰よりもわかっているべきです。「投稿ユーザーはなぜレシピを投稿してくれるのか?」という命題に、自分たちが心底信じられる答えを得たい、でもどうすれば良いのだろう...というのが最初の壁でした。

そしてその時は、以下の本からとっかかりを得ました。

「ジョブ理論」

https://www.amazon.co.jp/dp/B0746JCN8B/

人が何かプロダクトを使う時、必ずその人は何らかの解決したい「ジョブ」を持っており、その解決のためにプロダクトを「雇用」している、という見地に立って、顧客の「ジョブの解決」に寄り添うプロダクト開発を論じた本。「ジョブ」はいわゆる「課題」や「インサイト」と似た意味だが、顧客の置かれている「状況」により注目し、特定状況下で発生する実用的な欲求に目を向けている。

どう活用したか?

  • ユーザーにレシピ投稿を雇用させている「ジョブ」は何なのか、読み解くことにしました
  • 具体的には、さまざまなレシピ投稿者を呼んで根掘り葉掘り話を聞き出し、あとから彼らの「ジョブ」を推察するインタビューを実施しました

やってみてどうだったか?

ユーザーの抱える「ジョブ」の解決をサービス発想の起点にすることは本質的だと感じます。これだ!という「ジョブ」を発見できると強いコンセプトが作れる、という実感が、取り組むうちに芽生えてきました。

ただし「ジョブ」は、ユーザーの発言内容だけでなく、発話時の表情やその人の価値観、普段の生活の様子など、さまざまな情報を複合的に組み合わせて考えないと推測できないので、慣れるまでは考えるのが難しいです。私たちは、インタビュー後に観察した情報を参加者全員でぶちまけ、それを見ながら「このユーザーのジョブは何だったか」を議論して定義する手法で乗り切りました。インタビューの内容は、本に出てくる、ユーザーの発話からキーワードを捉えて深堀りしていく様子を参考にし構成しています。

また、「ジョブ」の定義を何度か重ねていくと、「ジョブ」の粒度をどのくらいに設定するかが難しいことに気づくと思います。その際は本で紹介される事例を引き合いに出しながら、”定義した「ジョブ」を解決している競合サービスを思いつくか”という基準で調整するのがおすすめです。

なお今では施策設計の際、誰のどんな「ジョブ」をターゲットにするのか定義することが必須になるほど、「ジョブ理論」は深く活用されています。最近はインタビューの結果を人物ごとに「ジョブ」起点で記事のようにまとめた「ユーザー白書」を作成・蓄積するなど、実践手法も進化させながら続けています。

f:id:natsuki53:20190711174549p:plain
インタビュー後、「ジョブ」を見つけようとしている議論の様子

2. 「戦略づくり難しい!」の壁

向き合うユーザーのことがわかってきたところで、次は、事業目標をどう達成するか、戦略立案の壁にぶつかりました。投稿開発部の事業戦略は、部長が部の発足前に作った草案を、適時メンバーを巻き込みながら見直し、アップデートしています。目標がかなり動かし難い数字であることに加え、自分たちの学びも日々進化していく状況であるためです。

ただ当時は、事業戦略を考えたことのないメンバーが大半だったので、議論に入っても、意見すらうまく出ない状態でした。そこで、部長主導で以下の書籍を取り入れました。

「ストーリーとしての競争戦略」

https://www.amazon.co.jp/dp/4492532706/

競争戦略(事業戦略)は、静止画でなく動画、ストーリーであり、良い戦略は人に思わず話したくなる面白いストーリーになるはずだ、という見地に立って、他社が追従しようと思えないほど優れた戦略を作るための考え方を説いた本。講義を受けているような文調で、文量も多いが、事業戦略立案の本質を捉えて論じている良著。実在企業の事例も多く紹介されて参考しやすく、チームでの議論の際の引き合いに出しやすい(ただしITの事例は少ない)。

どう活用したか?

  • レシピ投稿サービスのコンセプト=「本当のところ我々は誰に何を売っているのか?」を定義し直すことから始め、自分たちの戦略ストーリー図を作りました
  • その上で、本で紹介されている他社の戦略図と見比べながら、自分たちの戦略図を磨いていきました

やってみてどうだったか?

自分たちのサービスが「本当のところ誰に何を売っているのか?」を定義するのは、想像以上に難しいです!これまでのインタビューで得たユーザーのエピソードをかき集めたり、競合サービスのコンセプトを推測して自社と比較したり、腹落ちする定義に至るまでに少し時間がかかりました。

しかし、そこで定義した提供価値を軸に、ゴール達成を引き起こすまでの中長期のストーリーを描いて作った戦略図は、シンプルで筋が通っていて、戦術や計画を考えるのに使いやすいです。現場でも、単発のアイデアをやみくもに実行することがなくなり、複数の要素を因果関係を持たせながら実現していく計画を考えられるようになりました。

ただし、実際に戦略図を書き起こすのは至難です。筋の良い戦略ストーリー図は、そらで描けるほどシンプルで、且つ、目標達成のために必要な変化を含む...とのことですが、ここは本に紹介されている実在企業の戦略図を横目に見ながら頭を捻りまくるしかありません。戦略図をチームで議論して描くのは難しすぎるので、今はコンセプトと解決策をチームで議論し、ある程度骨子が見えたところで部長が戦略図の草案を書くことが多いです。チームでは、その草案を元に、ストーリーを確実に実現する方法や、より強い「非合理」を入れてストーリーを面白くする方法を考えることにしています。

f:id:natsuki53:20190711171051p:plain
何度もお手本にしている、スターバックスの戦略ストーリー図

3. 「良いソリューションアイデアが出ない!」の壁

事業戦略ができ、具体的な施策づくりに入って行けるようになると、今度は自分たちの出すソリューションアイデアが今ひとつに思えて悩むようになってきました。戦略と仮説には自信があるのに、思いつく解決策に新しさや捻りがない、そもそも出てくるアイデアの数が少ない...。この壁は二重構造になっていて、以下2つの問題で成り立っていました。

  • ①チームでのアイデア出しの進め方がわからない
  • ②出すアイデアの量・質に自信が持てない

それぞれを助けてくれた本は以下です。

① 「SPRINT 最速仕事術―あらゆる仕事がうまくいく最も合理的な方法」

https://www.amazon.co.jp/dp/B06Y5NW5PQ/

問題①に効いた本。Googleで開発されたという、5日間で新しいアイデアを形にして検証・評価まで完了させる「スプリント」というフレームワークを紹介し、その実践手順を詳しく解説している。

どう活用したか?

  • 施策検証の段階にある施策で何度かそのまま取り入れて、スプリントを実施しました
  • その後、一部の手法を部分的に切り出して、普段の会議に取り入れるようにしました

やってみてどうだった?

スプリント自体は一長一短あると感じます。良いところは、テーマに対して参加者全員で大量のインプットを得て、めちゃくちゃ集中して考えることです。メンバーの脳内同期やステークホルダーの巻き込みにも効いたりします。また、とにかく時間が制限されるので、煮え切らないアイデアを捨てる判断ができる点も良いです。デメリットは、参加者全員の5日間の時間拘束が辛いことと、得られる成果の質が参加者の能力に左右されること。また、「レシピ投稿」はもともと施策の効きに時間がかかる傾向があるのですが、それを1日のインタビューで評価して良いのか?という疑念が拭いきれないことも、私たちにとっては大きな気がかりです。

ただ、「有能な人の仕事の流れをフレームワーク化した」と言うだけあって、スプリントのフレームワークには、目的に対する情報インプット、アイデア出し、検証、評価を効率的に行う工夫が詰まっていると感じます。それらを切り出して普段の業務に取り入れても、アイデアを考えたり意思決定するのがラクになり、チームのアイデア出しのパフォーマンスが高まります。専門家インタビュー、光速デモ、クレイジー8、ストーリーボード、ヒートマップ、サイレント投票などの手法は、普段の会議でも単発で取り入れやすいのでおすすめです。

f:id:natsuki53:20190711171137p:plain
スプリントの手法を活用したアイデア出しの様子

② 「直感と論理をつなぐ思考法 VISION DRIVEN」

https://www.amazon.co.jp/dp/B07NMN1B5Z/

問題②に効いた本。世の中を動かしてきたのは、ロジカルに組み上げられたアイデアではなく、「自分駆動の妄想」を起点にしたビジョンだ、という前提の元、まず根拠のない妄想(ビジョン)があって、それを実現する筋道を作るために論理を組み立てる「ビジョン思考」を提唱している。「ビジョン思考」の強化方法や、それに則ったアイデア作りの方法も詳しく解説されている。

どう活用したか?

  • チームで、既存路線上にない新しいアイデアを出さなければいけない時に、本で紹介されている「組替」の手法を取り入れました
    • 変化を起こしたい事象の「当たり前」を洗い出し、違和感のある「当たり前」をひっくり返してから、それを元に新しいアイデアを生み出していく手法です
  • 併せて、チームメンバーと「プロトタイピング志向」を徹底する同盟を組んでいます
    • 生煮えの考えもissue化したり、粗いプロトタイプを作って早期に人に見せることでアイデアに客観的な視点を加え、そこから練り上げていくところに時間をかけます

やってみてどうだったか?

「組替」の手法は取り入れ始めて日が浅いですが、以前より新しさを含んだアイデアがたくさん出るようになったと感じます。フレームワークに則って頭と手を動かせば、「ひらめき」=”既存要素の組み替え”を意図的に起こせるよう考えられており、チームで一緒に実践しやすいのも良い点です。

「プロトタイピング志向」は、普段のディレクター業務で実践するのにはまだ慣れないですが、うまくできた時は、1人で考え込むことに時間を使ってしまった時よりも成果物の質は高まると感じます。ちなみにこのブログ記事も、構想段階から色んな人に相談し、レビューしてもらって仕上げました!

また個人的にはこの本によって、「仕事のアイデアは論理で組み上げなければならない」という思い込みを打破できたことは大きかったです。まず直感に目を向け、それを人に説明できるよう後から論理づけして磨いていくという思考を意識すると、出せるアイデアの質が変わってくるのを感じます。

f:id:natsuki53:20190711171221p:plain
「組替」の手法を使ったアイデア出しの様子

本の知識をチームに取り入れるために

せっかく読んだ本から知識を”モノにする”には、読了後すみやかに得た知識を業務で実践することが1番重要だと感じます。また、チームで仕事を進める中では、”共通言語を作る”という意味でも、書籍で得た知識の共有は有効です。特にチームが悩んでいる時や、抽象度の高い議論を進めなければならない時、うまい共通言語を得ると、停滞していた話が進み始めることが多々あります。

新しい知見を業務で実践するところまで漕ぎつけたり、自分の読んでいる本の知識をうまくチームに共有できるかは、読んだ人の裁量によりがちです。そのため、本の知識をチームや業務に取り入れやすくするために、以下のような工夫をしています。

1.読んでいる本を共有しやすくする工夫

読書感想文共有スレ

GHEの部署のリポジトリ配下に「サービス開発系の読んだ本の感想を書くスレ」というissueを常設。簡素な1行コメントから超長文レポートまで、メンバーが読書感想を自由にpostしています。熱量の高い感想文には自然と注目が集まるし、共有したい知識はそこにまとめておけば参照してもらえるので、その後の議論でも話題に出しやすくなるのが利点です。最近このissueはコンテンツ力が増してきて、他部署からもファンや投稿者を創出する人気スレ(?)になりつつあります。

定例や1on1で読んでいる本の話をする

定例ミーティングや部長との1on1で、いま読んでいる本をよく共有しあいます。同じ本でも人によって読み取り方が異なるので、話すことで新しい観点が得られたり、チームの誰かと「それいいね!」「あの施策で試せるじゃん!」という会話をしておけると、そのあとの情報発信や業務での実践提案がしやすくなるので、意図的に活用しています。

その他、読んで良かった本は、物理本を買ってデスクに置いておくのも一手です。興味を持ってくれた人にサッと貸し出して、味方を増やします。

f:id:natsuki53:20190711171325j:plain

2.本の知識を業務で実践しやすくする工夫

わかりやすい事例や概念を切り出しておく

目的の書籍を読んでいない人を巻き込みたい時に使います。何も知らない相手に、自分が本から得た知識を口頭で説明してわかってもらう(その上で同じレベルで議論に参加してもらう)ことは至難の技ですが、本の中からわかりやすい事例や、概念を端的に表した図解などを切り出しておいて「うちもこんな風にやってみませんか」と提案すると、やりたいことをわかってもらいやすくなります。

読書後、自分の業務で実践するtodoを出す

「本を読んでも自分の仕事にどう活かせるか、パッとわからない...」という人(=私)におすすめの手法です。本を1冊読んだら、そこから自分の仕事で試してみたいtodoを1〜3個だけ考え出します。それだけで実践に運べる確率が上がります。「前のtodoが終わるまで次の本を読めない」というルールをつけると、より強制力が働くのでおすすめです。あとは実際にやってみて、継続するか・やめるかを振り返る機会を作れば完璧です。

f:id:natsuki53:20190711171436p:plain

おわりに

今回は特に大きかった壁と、ヘビロテしている選りすぐりの本を紹介しましたが、チームでお世話になってる本は他にもまだまだたくさんあります。仕事に行き詰まった時は、視野を広げて新しい知識を取り入れるチャンスと捉え、これからも積極的に本を読んでいきたいところです。

なお現在は、よりインプットの幅を広げたく、書籍に加えて、似た課題に直面している方々との情報交換も積極的に行っていきたい所存です!もしご興味を持ってくださる方がいらっしゃいましたら、お気軽にご連絡ください。
fb: https://www.facebook.com/natsuki.gomi.7

そしてそして、こんな私たちと一緒に壁に激突して、一緒に成長してくれる仲間も募集中です!募集中の職種は採用サイトからご確認ください!
https://info.cookpad.com/careers/

冪等なデータ処理ジョブを書く

$
0
0

こんにちは、マーケティングポート事業部データインテリジェンスグループの井上寛之(@inohiro)です。普段はマーケティングに使われるプライベートDMP(データマネジメントプラットフォーム)の開発を行っています。本稿では、その過程で得られた冪等なデータ処理ジョブの書き方に関する工夫を紹介したいと思います。今回は、RDBMS上で SQL によるデータ処理を前提に紹介しますが、この考え方は他の言語や環境におけるデータ処理についても応用できるはずです。

まずクックパッドのDMPと、冪等なジョブについて簡単に説明し、ジョブを冪等にするポイントを挙げます。また、SQL バッチジョブフレームワークである bricolage を使った、冪等なジョブの実装例を示します。

クックパッドのDMPと冪等なジョブ

クックパッドのプライベートDMPは、データウェアハウス(社内の巨大な分析用データベースで、クックパッドでは Amazon Redshift を使っている。以下 DWH) 上で構築されており、主に cookpad.com 上のターゲット広告や、社内のデータ分析に活用されています。材料となるデータは、広告のインプレッションログや、クックパッド上での検索・レシピ閲覧ログです。また他社から得たデータを DWH に取り込んで、活用したりしています。

これらのデータを活用したバッチジョブ群は、社内でも比較的大きめのサイズになっており、途中でジョブが止まってしまうことも考慮して、基本的にそれぞれのジョブが冪等な結果を生成するように開発されています。

冪等についての詳しい説明は省略しますが、簡単に言うと「あるジョブを何度実行しても、同じ結果が得られる」ということです。特にデータ処理の文脈においては、「途中で集計ジョブが失敗してしまったがために、ある日のデータが重複・欠損して生成されていた」ということはあってはなりません。ジョブが冪等になるように開発されていれば、失敗した場合のリトライも比較的簡単になります。また、ジョブが失敗しなかったとしても、(オペミス等で)たまたま複数回実行されるかもしれませんし、毎回同じ結果が生成されるべきです。

さらに、ジョブを冪等になるように開発すると、開発時に手元で試しに実行してみるときも検証が簡単なため、おすすめです。

冪等なジョブにするポイント

プライベート DMP を開発して得られた、ジョブを冪等にするためのポイントはズバリ「トランザクションを使え」です。

トランザクションを使ってロールバック

大量のデータを、長時間(N時間)かけて書き込むようなバッチジョブを考えるとき、途中で止まってしまったり、そこから復旧(リトライ)するという状況は予め考慮されているべきです。このとき、書き込む先がトランザクションをサポートするようなデータベース(一般的なRDBMSなど)ならば、トランザクションを利用しましょう。一つのトランザクションとしてまとめた一連の処理は、「すべて成功した状態」か、「すべて失敗した状態(ロールバック)」のどちらかになることが保証され、中途半端な状態にはなりません。途中で失敗しても、最初からぜんぶ書き直すことになりますが、冪等性は保たれています。

クックパッドの DMP は並列分散 RDB である Amazon Redshift 上に構築されているので、トランザクションをフルに活用しています。

自前でロールバック

一度実行された集計ジョブを再度実行した場面を考えてみます。再度実行される理由はいろいろ考えられますが、「意図せず間違って実行されてしまった」というのも同じような状況と考えられます。前回実行したときと同じ結果が得られれば問題ありませんが、集計した結果が重複してしまうと、後続のジョブが失敗するか、最悪の場合正しくない分析結果を用いて、何らかの意思決定が行われてしまうかもしれません。

つまり、現在実行中のジョブが書き込むテーブルに、今から書き込もうとしている条件で、既にデータが書き込まれているかもしれないのです。そこで、新たな結果をを書き込む前に、既存の行を削除(自前でロールバック)することで重複の発生を避けます。さらに、「削除」と「新しい結果の書き込み」を一つのトランザクションにまとめることで、このジョブは冪等になります。

冪等なデータ構造を利用する

一方で、トランザクションをサポートしないような NoSQL データベースを使っているとき、ジョブを冪等にするのは比較的簡単ではありません。このような状況で考えられる一つの解決策として、何度書き込まれても結果が変わらないデータ構造の利用が挙げられます。集合(Set)やハッシュテーブルです。これらのデータ構造は、データの順序は保証されないものの、既に存在する値(もしくはキー)を書き込んでも、要素が重複しません。

クックパッドの DMP で作成したターゲット広告用のデータは、最終的に Amazon DynamoDB *1に書き込まれ、広告配信サーバーがそのデータを使っています。ターゲット広告用のデータは、一度に数千万要素をバッチジョブが並列で書き込みますが、このジョブが稀に失敗することがあったり、過去に書き込まれている要素が時を経て再度書き込まれることがあるため、SS(文字列のセット)型を使っています。過去には Redis のセット型を使っていることもありました。

bricolage による冪等なジョブの実装例

クックパッドの DMP だけでなく、社内で SQL バッチジョブを書くときのデファクトスタンダードになっている bricolageには、頻出パターンのジョブを書く際に便利な「ジョブ・クラス」がいくつかあり、これを使うことで冪等なジョブを簡単に実装することができます。この節では bricolage を使った「トランザクションでロールバック」パターンと、「自前でロールバック」パターンの実装例を示します。

bricolage については、ここでは詳しく説明しませんが、詳細については過去の記事「巨大なバッチを分割して構成する 〜SQLバッチフレームワークBricolage〜」や、RubyKaigi 2019 でのLT「Write ETL or ELT data processing jobs with bricolage.」をご参照ください。また inohiro/rubykaigi2019_bricolage_demoにデモプロジェクトを置いてあります。

「トランザクションでロールバック」パターン

rebuild-dropもしくは rebuild-renameジョブ・クラスを使うと、「現行のテーブルを削除し、新規のテーブルに集計結果を書き込む」または「新規にテーブルを作り、集計結果を書き込み、現行のテーブルとすり替える」という操作を、一つのトランザクションで行うジョブを簡単に実装することができます。rebuild-dropは対象のテーブルを作り直す前に drop tableし、rebuild-renameはすり替えられた古いテーブルを、別名で残しておきます。

以下は、毎日作り変えられるようなサマリーテーブルを rebuild-dropジョブ・クラスで実装した例です。

/*class: rebuild-drop -- ジョブ・クラスの指定dest-table: $public_schema.articles_summarytable-def: articles_summary.ctsrc-tables:  pv_log: $public_schema.pv_loganalyze: false*/insertinto $dest_table
select
    date_trunc('day', logtime)::dateas day
    , id_param::integeras article_id
    , count(*) as pv
from
    $pv_log
where
    controller = 'articles'and action = 'show'and logtime < '$today'::dategroupby1, 2
;

このジョブは、以下の SQL に変換されて実行されます。

\timing onbegin transaction; -- トランザクション開始droptableifexistspublic.articles_summary cascade; -- 既存テーブルの削除/* /Users/hiroyuki-inoue/devel/github/rubykaigi2019_bricolage_demo/demo/articles_summary.ct */createtablepublic.articles_summary
( day date
, article_id integer
, pv bigint
)
;

/* demo/articles_summary-rebuild.sql.job */insertintopublic.articles_summary
select
    date_trunc('day', logtime)::dateas day
    , id_param::integeras article_id
    , count(*) as pv
frompublic.pv_log
where
    controller = 'articles'and action = 'show'and logtime < '2019-07-13'::dategroupby1, 2
;

commit; -- トランザクション終了

ジョブ全体が begin transaction;commit;で囲われているので、仮に集計クエリに問題があり失敗した場合は、元のテーブルは削除されずに残ります。

「自前でロールバック」パターン

insert-deltaジョブ・クラスは既存のテーブルに差分を書き込むために利用され、差分を書き込む直前に指定した条件でdeleteを実行します。また、一連の SQL は一つのトランザクションの中で行われるので、delete直後の差分を集計するクエリが失敗しても安心です。

以下は、日毎に広告インプレッションを蓄積しているテーブルimpressions_summaryに、前日($data_date*2の集計結果を書き込むジョブの例です。delete-cond:に削除条件を指定します。今回の例では、集約条件の一つである日付を指定しています。

/*class: insert-delta -- ジョブ・クラスの指定dest-table: $public_schema.impressions_summarytable-def: impressions_summary.ctsrc-tables:    impressions: $ad_schema.impressionsdelete-cond: "data_date = '$data_date'::date" -- 削除条件の指定analyze: false*/insertinto $dest_table
select'$data_date'::dateas data_date
    , platform_id
    , device_type
    , count(*) as impressions
from
    $impressions
groupby1, 2, 3
;

このジョブは以下のような SQL に変換され、実行されます。

\timing onbegin transaction; -- トランザクション開始deletefrom impressions_summary where data_date = '2019-07-12'::date; -- 既存行を指定した条件で削除/* demo/impressions_summary-add.sql.job */insertinto impressions_summary
select'2019-07-12'::dateas data_date
    , platform_id
    , device_type
    , count(*) as impressions
from
    ad.impressions
groupby1, 2, 3
;

commit; -- トランザクション終了

テーブルに書き込む前に指定した条件(delete-cond: "data_date = '$data_date'::date")で deleteクエリが実行され、"掃除"してから書き込むクエリが実行されるのが確認できると思います。対象の行がなければ何も削除されませんし、対象の行が存在すれば、新たな結果を書き込む前に削除されます。

まとめ

本稿では、クックパッドの DMP 開発において「冪等なデータ処理ジョブ」を書くために行われているいくつかの工夫について紹介しました。また、bricolage を使ってこれらのジョブを実装する例を示しました。

このように、トランザクションのあるデータベースを利用する場合は、なるべくその恩恵に乗っかるのがお手軽です。また、一つのジョブに色々なことを詰め込まず、ジョブを小さく保つことで、ロールバックの対象も小さくなり、失敗した場合のリトライなどもシンプルに行えると思います。bricolage のジョブ・クラスを上手に使うことで、トランザクションを利用した冪等なデータ処理ジョブを簡単に実装することができます。ぜひお試しください。

*1:この記事を書いていて思い出しましたが、Amazon DynamoDB はトランザクションをサポートしたのでした https://aws.amazon.com/jp/blogs/news/new-amazon-dynamodb-transactions/

*2:変数には前日の日付が入るように仮定しているが、ジョブのオプションで上書きが可能


ISMM 2019 で発表してきました

$
0
0

技術部の笹田です。遠藤さんと同じく Ruby のフルタイムコミッタとして、Ruby インタプリタの開発だけをしています。

先日、アメリカのフェニックスで開催された ISMM 2019 という会議で発表してきたのと、同時開催の PLDI 2019 という会議についでに参加してきたので、簡単にご報告します。

f:id:koichi-sasada:20190717032426j:plain
カンファレンス会場

ISMM 2019

ISMM は、International Symposium on Memory Management の略で、メモリ管理を専門にした、世界最高の学術会議です。というと凄いカッコイイんですが、メモリ管理専門って凄くニッチすぎて、他にないってだけですね。多分。ACM(アメリカのコンピュータ関係の学会。すごい大きい)SIGPLAN(プログラミングに関する分科会。Special Interest Group)のシンポジウムになります。

発表するためには、他の多くの学術会議と同じく、論文投稿をして、査読をうけ、発表に値すると判断される必要があります。基本的に、ガーベージコレクション(GC)のテクニックの提案や、新しい malloc ライブラリの提案とか、NVMどう使うかとか、そういう話を共有する場です。

ISMM 2019 は、6/23 (日) にアメリカのアリゾナ州フェニックスで1日で開催されました。外はムッチャ暑い(40度近い)ですが、室内は空調でムッチャ寒い、というつらい環境でした。外は暑すぎて歩けなかった。

会議は、キーノート2件に通常発表が11件でした。投稿数が24件だったそうで、採択率は50%弱だったようです。日本国内の会議より難しい(私の知っている範囲では、50%はあまり切らない)けど、トップカンファレンスに比べると通りやすい、というレベルだと思います。

今回、ISMM 2019 に投稿した論文が採択されたので、はじめて行ってきました。GC に関する仕事をしているので、ISMM は一度行ってみたい会議だったので、今回参加できてとても嬉しい。Ruby の GC に関する論文の発表だったので、出張としていってきました。感謝。おかげで、最新研究の雰囲気を感じることができました。

正直、内容と英語が難しくて、あんまり聞き取れなかったんですが、分かる範囲でいくつか発表などをご紹介します。

基調講演

2件の発表がありました。

Relaxed memory ordering needs a better specification

1件目はGoogleのHans-J. Boehmさんよる「Relaxed memory ordering needs a better specification」という発表でした。Boehmさんといえば、私にとってはBoehm GCというよく知られた実装の開発者の方ということで、お会いできて大変光栄でした。最近はC++言語仕様の策定などでお名前をよく聞きますが、今回はその話でした。なお、ここ最近は GC の実装にはほとんど関わってないと伺いました。

f:id:koichi-sasada:20190717032523j:plain
Boehmさんのキーノート

マルチスレッドプログラミングにおいて、メモリを読み書きする順序(メモリオーダリングといいます)が問題になることがあります。書いたと思った変数の値を読み込んでみたら、書き込む前の値だった、ってことがあったら驚きますよね。実は、マルチスレッドだとそういうことが起こってしまうんです。性能を良くするために、いくつかのCPUでは、共有メモリに対する他のスレッドからの書き込みが、逐次実行で見える書き込みの順序と違う可能性があるのです。

何を言っているかよくわからないと思うんですが、正直私もよくわかりません。例えば、0初期化された共有メモリ上にある変数 a, bがあったとき、a = 1; b = 2;というプログラムがあったら、(a, b) の組は (0, 0)、(1, 0)、(1, 2) の3通りしかないように思うんですが(逐次(シングルスレッド)プログラムだと、実際そうです)、他のスレッドから観測すると、(0, 2) という組が見えたりします(他の最適化が絡むと、もっと変なことが起る可能性があるらしいです)。わけわからないですよね? わからないんですよ。人間にはこんなの管理するのは無理だと思う。共有メモリなんて使うもんじゃない(個人の感想です)。

さて、どんなふうにメモリーオーダリングをするか、という指定をするための言語機能が C++ などにあります(std::memory_order - cppreference.com)。例えば memory_order_seq_cstというのが一番厳しい指定で、他のスレッドからも同じように見える(つまり、上記例だと (0, 2) という組は見えない)ようになり、プログラミングするにはこれが多分一番便利です。ただ、性能のために都合の良いように CPU が順序を変えている(可能性がある)のに、その順序を厳しく制御する、ということになるので、オーバヘッドがかかります。で、どの程度厳しくするか、みたいなので、いくつか種類があるわけです。CPU によって、どの程度デフォルトが厳しいか決まるんですが、幸い、x86(x86_64)は比較的強いメモリオーダリングを行うので、あんまり難しくない、らしいのです。ARM とかだと弱いらしいとか、さっきググったらありました。やばいっすね。

今回の基調講演では memory_order_relaxedという、多分一番ゆるい(何が起こるかわからない)メモリオーダリング指定を、どうやって仕様化すればいいか難しい、という話を、実際にすごく不思議な挙動があるんだよねぇ、という豊富な実例をあげて紹介されていました。従来の仕様では、例ベースでしか仕様に書けなかったんだけど、なんとか書きたいなぁ、でも難しいなあ、というお話でした。結論がよくわかってなかったんだけど、結局うまいこと書けたんだろうか。

なんでメモリ管理の会議 ISMM でメモリオーダリングの話が問題になるかというと、並行GCっていう研究分野があって、GC するスレッドとプログラムを実行するスレッドを並行・並列に実行していくってのがあるんですね。で、それを実現するためにはメモリオーダリングをすごく気にしないといけないわけです。これもきっと人間には無理だと思うんですが、実際にいくつかの処理系でやってるのが凄いですよねえ。いやぁ凄い。

Why do big data and cloud systems stop (slow down)?

2件目のキーノートは、シカゴ大学のShan Lu氏による「Why do big data and cloud systems stop (slow down)?」という発表でした。

実際のウェブアプリケーションや分散処理基盤(Azure。共同研究されてるんでしょうなあ)でどんな問題があるか、主に性能の観点から分析してみたよ、という話でした。ウェブサイト(Shan Lu, CS@U-Chicago)を拝見すると、輝かんばかりの業績ですね(研究者は良い学会に論文を通すことが良い業績と言われています。で、見てみると本当に凄い学会に沢山論文が採択されていて凄い)。

面白かったのが、ウェブアプリケーションの性能分析で Rails が題材になっていたことです。「あ、見たことあるコードだ」みたいな。

ウェブアプリケーションに関する分析の話は、View-Centric Performance Optimization for Database-Backed Web Applications (ICSE'19) のものだったように思います。主に ORM でのアンチパターンをいろいろ分析して(講演では、そのパターンを色々紹介されていました)、それを静的解析してアプリからそのアンチパターンを見つけて良い方法を提案するツールを作ったよ、と。Panoramaというツールを作っていて公開されています。なんと IDE (Rubymine)との統合までやっているようです。凄い。論文中に、いくつかリファクタリング例があるので、気になる方は参考にしてみてください。しかし、Rails アプリの静的解析って、えらく難しそうだけど、どれくらい決め打ちでやってるんですかねぇ。

Azure のほうは、設定間違いがほらこんなに、とか、そんなご紹介をされてたような気がします。具体的には What bugs cause production cloud incidents? (HotOS'19) の話かなぁ。論文中 Table 1 がわかりやすいので引用します。

    What are the causes of incidents?
↓ Few hardware problems
↓ Few memory bugs
↓ Few generic semantic bugs
↑ Many fault-detection/handling bugs
↑ Many data-format bugs
↑ More persistent-data races

    How are incidents resolved?
↑ More than half through mitigation w/o patches

Table 1: How are cloud incidents different from failures in single-machine systems?
(↑ and ↓ indicate cloud incidents follow certain pattern more or less than single-machine systems.)

いやぁ、こういう網羅的な調査を行うって凄いですよね。

一般発表

一般発表は、次の4つのセッションに分かれていました(Program - ISMM 2019)。

  • Scaling Up
  • Exotica
  • Mechanics
  • Mechanics / Message Passing

かなり大ざっぱな区切りですよね。Exotica とか凄い名前。

そういえば、"Scaling Up"セッションは、東工大とIBM東京基礎研の方々による3件の発表となっており「東京セッション」と座長に紹介されてました。また、私が発表しているので、東京の組織の発表が11件中4件あったことになるんですね。日本人はメモリ管理好きなんでしょうか。まぁ、私は好きですけど。

いくつか紹介します。

malloc の改良

  • Timescale functions for parallel memory allocation by Pengcheng Li (Google) et.al.
  • A Lock-Free Coalescing-Capable Mechanism for Memory Management by Ricardo Leite (University of Porto) et.al.
  • snmalloc: A Message Passing Allocator by Paul Lietar (Drexel University) et.al.

これら3件の発表は、malloc の実装を改良、もしくは新規に作りました、という話でした。なんというか、malloc()は、まだまだ進化するんだなぁ、やることあるんだなぁ、という感想。どれも、並列計算機(マルチスレッド環境)での弱点をどう克服するか、という研究でした。

とくに最後の snmalloc は面白くて、確保 malloc()、解放 free()のペアって、たいていは同じスレッドで行われると仮定してライブラリを作るので、別スレッドで free()しちゃうと余計なオーバヘッドがかかっちゃう、ことが多いようです(実際、私も作るならそう作りそう)。ただ、いくつかの種類のプログラム、例えば複数スレッドで仕事をパイプライン的に流していくとき、確保と解放は必然的に別スレッドになって、そこがボトルネックになるので、メッセージパッシング機構をうまいこと作ることで、free()の時にしか同期が不用で速いアロケータを作ったよ、というものでした。

Google の中川さんが論文の説記事を書いていたので、ご参照ください(論文「snmalloc: A Message Passing Allocator」(ISMM 2019))。

GC の改良

  • Scaling Up Parallel GC Work-Stealing in Many-Core Environments by Michihiro Horie (IBM Research, Japan) et.al.
  • Learning When to Garbage Collect with Random Forests by Nicholas Jacek (UMass Amherst) et.al.
  • Concurrent Marking of Shape-Changing Objects by Ulan Degenbaev (Google) et.al.
  • Design and Analysis of Field-Logging Write Barriers by Steve Blackburn (Australian National University)

GCの改善の話も結構ありました。

最初の話は、IBM東京基礎研の堀江さんらによる、並列GCの work-stealing を効率化した、という話でした。GCスレッドを複数立てて、GC処理を速く終わらせるには、仕事を分散させるためのテクニックである work-stealing が必要になります。それに関するテクニックの話でした。対象が POWER なのが IBM っぽくて凄いですね。

二つ目は、GCのいろいろなチューニングをランダムフォレストでやってみよう、という話でした。GC の制御も AI 導入、みたいな文脈なんでしょうか?

三つ目は、Google V8 での並行マーキングにおいて、メモリの形(というのは、メモリレイアウトとかサイズとか)を変更しちゃう最適が、並行GCと食い合わせが悪いので、それをうまいこと性能ペナルティなくやるって話で、実際に Chrome に成果が入っているそうです。みんなが使うソフトウェアに、こういうアグレッシブな最適化を入れるの、ほんと凄いですね。話は正直よくわからんかった。

最後は、Field-Logging Write Barriersというのは、フィールド単位(Ruby でいうとインスタンス変数)ごとにライトバリアを効率良く入れる提案でした。Ruby 2.6(MRI)だと、オブジェクト単位でライトバリアを作っているんですが、さらに細かく、バリア、というか、バリアによって覚えておくものを効率良く記録する方法、みたいな話をされていました。むっちゃ既存研究ある中(発表中でも、既存研究こんなにあるよ、と紹介していた)で、さらに提案するのは凄い。

Gradual Write-Barrier Insertion into a Ruby Interpreter

私(笹田)の発表は、Ruby にライトバリア入れて世代別GCとか作ったよ、という Ruby 2.1 から開発を続けている話を紹介しました(Gradual write-barrier insertion into a Ruby interpreterスライド資料)。2013年に思いついたアイディアなので、こういう学会で発表するのはどうかと思ったんですが、ちゃんとこういう場で発表しておいたほうが、他の人が同じような悩みをしなくても済むかも、と思って発表しました。RubyKaigi などでしゃべっていた内容をまとめたものですね。

簡単にご紹介すると、Ruby 2.1 には世代別GC(マーキング)、2.2 にはインクリメンタルGC(マーキング)が導入されました。これを実現するために、"Write-barrier unprotectred object"という概念を導入して、ライトバリアが不完全でもちゃんと動く仕組みを作った、という話です(次回の Web+DB の連載「Ruby のウラガワ」でも解説しますよ。宣伝でした)。GC は遅い、という Ruby の欠点は、この工夫でかなり払拭できたんじゃないかと思います。まだ GC が遅い、というアプリケーションをお持ちの方は、ぜひベンチマークを添えて笹田までご連絡ください。

「Gradual WB insertion」というタイトルは、ライトバリアをちょっとずつ入れて良い、って話で、実際 Ruby 2.1 から Ruby 2.6 までに、徐々にライトバリアを入れていったという記録を添えて、ちゃんと「Gradual に開発できたよ」ということを実証しました、という話になります。

結構面白い話だと思うんだけど、アイディア自体が簡単だったからか、質問とかほとんどなくて残念でした。まぁ、あまり研究の本流ではないので、しょうがないのかなぁ(本流は、ライトバリアなど当然のようにある環境でのGCを考えます)。

PLDI 2019

PLDI は、Programming Language Design and Implementation の略で、プログラミング言語の設計と実装について議論する、世界で最高の学術会議の一つです。以前は、実装の話が多かったんですが、PLDI 2019から引用しますが、

PLDI is the premier forum in the field of programming languages and programming systems research, covering the areas of design, implementation, theory, applications, and performance.

とあって、設計と実装だけじゃなく、理論やアプリケーション、性能の分析など、プログラミング言語に関する多岐にわたる話題について議論する場です。言語処理系に関する仕事をしているので、一度は行ってみたかった会議です。ISMM出張のついでに出席させて貰いました。参加費だけでも6万円くらいするんですよね。

PLDI 2019 は、6/24-26 の3日間で行われました。ISMM 2019 は、この PLDI 2019 に併設されています。PLDI は言語処理系によるメモリ管理もスコープに入っているので、実は ISMM で発表するよりも PLDI で発表するほうが、他の人から「凄い」と言われます。どの程度凄いことかというと、283論文が投稿され、その中で76本が採択されたそうです(27%の採択率)。これでも、例年より高かったそうです。死ぬまでに一度は通してみたい気もしますね。まぁ、難しいかなぁ(例えば、日本人で PLDI に論文を通した人は、あんまり居ません)。

発表

三日間で最大3セッションパラレルに発表がされるため、あまりちゃんと追えていないのですが、印象に残った発表についてちょっとご紹介します。

ちなみに、以前は結構、がっつり実装の話が多かったんですが、今回の発表は、

  • 理論的な分析の話
  • 特定分野(例えば機械学習)の DSL の話

が多いなぁという印象であり、あんまり(私が)楽しい実装の話は少なかったように思います。

セッションは次の通り(これだけ見てもムッチャ多い)

  • Concurrency 1, 2
  • Language Design 1, 2
  • Probabilistic Programming
  • Synthesis
  • Memory Management
  • Parsing
  • Bug Finding & Testing 1, 2
  • Parallelism and Super Computing 1, 2
  • Type Systems 1, 2, 3
  • Learning Specifications
  • Reasoning and Optimizing ML Models
  • Static Analysis
  • Dynamics: Analysis and Compilation
  • Performance
  • Systems 1, 2
  • Verification 1, 2

いくつかご紹介します。

Renaissance: Benchmarking Suite for Parallel Applications on the JVM

発表は聞いてないんですが、JVM の並列実行ベンチマークについての発表だったそうです。よく DaCapo とかが使われていましたが、また新しく加わるのかな。

DSL

繰り返しになりますが、ある分野に対する DSL の話が沢山ありました。ちょっと例を挙げてみます。

  • LoCal: A Language for Programs Operating on Serialized Data は、シリアライズされた状態のままデータを操作する DSL
  • Compiling KB-Sized Machine Learning Models to Tiny IoT Devices は、IoT 環境みたいなリソースセンシティブな閑居杖、良い感じに整数で浮動小数点っぽい計算をする DSL
  • CHET: An Optimizing Compiler for Fully-Homomorphic Neural-Network Inferencing は、暗号化したまま計算する仕組みのための DSL/Compiler(多分。自信ない)。
  • FaCT: A DSL for Timing-Sensitive Computation は、タイミングアタック(計算時間によって秘密情報を取ろうというサイドチャンネルアタック)を防ぐために、計算時間を結果にかかわらず一定にするコードを生成するための DSL(多分)。

なんかがありました。もっとあると思います。適用領域が変われば言語も変わる。正しいプログラミング言語の用い方だと思いました。

メモリ管理

メモリ管理はわかりやすい話が多くて楽しかったです。

  • AutoPersist: An Easy-To-Use Java NVM Framework Based on Reachability は、Java (JVM) に、良い感じに NVM (Non-volatile-memory) を導入する仕組みを提案。
  • Mesh: Compacting Memory Management for C/C++ Applications C/C++ で無理矢理コンパクションを実現しちゃう共学のメモリアロケータの実装。
  • Panthera: Holistic Memory Management for Big Data Processing over Hybrid Memories は、NVM をでかいメモリが必要な計算でうまいこと使うためのシステムの紹介。

Mesh については、これまた Google 中川さんの論文紹介が参考になります(論文「MESH: Compacting Memory Management for C/C++ Applications」(PLDI 2019))。むっちゃ面白い。Stripe にも務めている(多分、論文自体は大学の研究)ためか、評価プログラムに Ruby があって面白かった。ちょっと聞いたら(発表後の質疑応答行列に30分待ちました。凄い人気だった)、Ruby のこの辺がうまくマッチしなくて云々、みたいな話をされてました。

Reusable Inline Caching for JavaScript Performance

V8のインラインキャッシュを、再利用可能にして、次のブート時間を短縮しよう、という研究でした。私でも概要がわかる内容で良かった。インラインキャッシュの情報って、基本的には毎回変わっちゃうんで、難しいのではないかと思って聞いてたんですが、巧妙に変わらない内容と変わる内容をわけて、変わらないものだけうまいことキャッシュして、うまくボトルネック(ハッシュ表の検索など)を避ける、という話でした。V8って膨大なソースコードがありそうなので、Google の人に聞いたのですか、と聞いてみたら、全部独学だそうで、すごい苦労して読んだと言ってました。凄い。

Type-Level Computations for Ruby Libraries

RDL なんかを作っている Foster 先生のグループの発表で、Ruby では動的な定義によって、実行時に型が作られるので、じゃあ実行時に型を作ってしまおうという提案です。Ruby でも PLDI に通るんだなあ、と心強く感じます。Ruby 3 の型はどうなるんでしょうね。

A Complete Formal Semantics of x86-64 User-Level Instruction Set Architecture

x86-64 の全命令(3000命令弱といってた)に形式定義を K というツールのフォーマットで記述した、という発表で、ただただ物量が凄い。おかげで、マニュアルなどにバグを見つけたとのことです。成果は Github で公開されてます(kframework/X86-64-semantics: Semantics of x86-64 in K)。

おわりに

ISMMはPLDIに併設されたシンポジウムですが、PLDIもFCRC という、学会が集まった大きな会議の一部として開催されました。懇親会はボーリング場などが併設された会場で行われ、いろいろ規模が凄かったです。

f:id:koichi-sasada:20190717032623j:plain
懇親会の様子

こういう学会に出席すると、最新の研究成果に触れることができます。正直、しっかりと理解できないものが多いのですが、雰囲気というか、今、どういうことが問題とされ、どういうところまで解けているんだ、ということがわかります(まだ、malloc ライブラリの研究ってこんなにやることあるんだ...とか)。このあたりの知見は、回り回って Ruby の開発にも役に立つと信じています。立つと良いなぁ。

今回の論文執筆と参加をサポートしてくれたクックパッドに感謝します。

Grafana の scripted dashboards を利用してダッシュボードを自動生成する

$
0
0

技術部 SRE グループの鈴木 (id:eagletmt) です。

去年クックパッド開発者ブログでも紹介した hako-consoleの延長として、メトリクス表示に Grafana の scripted dashboards を利用するようにしているのでその紹介をしようと思います。

アプリケーション毎のダッシュボード

クックパッドではダッシュボードの作成に Grafana を利用しており、主に Amazon CloudWatch と Prometheus に保存されているメトリクスを Grafana で可視化しています。それ以外にも一部開発用のメトリクスは InfluxDB に保存しており、その可視化にも Grafana が利用されています。

Grafana の variables 機能を利用すればリソースの種類毎にダッシュボードを作成することは簡単です。 ELB のロードバランサー名、RDS のクラスタ名、ECS のサービス名を variable として受け取るようにして CloudWatch の dimensions や Prometheus の PromQL にその variable を入れるようにすれば、各リソースの状況を閲覧することができるようになります。

ではアプリケーション毎のダッシュボードはどうでしょうか。典型的な Web アプリケーションの状態やパフォーマンスを知りたいときには

  • ALB のリクエスト数やレスポンスタイムの95パーセンタイルはどうなっているか
  • cAdvisorから得られるコンテナの CPU 使用率やメモリ使用率はどうなっているか
  • RDS の CPU 使用率やクエリのレイテンシはどうなっているか
  • Memcached の CPU 使用率や Eviction はどうなっているか

等の情報を一覧したいでしょう。

hako-console がその一覧するための役割を担っていたのですが、私が実装した hako-console のメトリクス表示画面よりも Grafana でのダッシュボードのほうが圧倒的に見やすく使いやすいため、アプリケーション毎に Grafana にダッシュボードを自動生成する方法を考えました。

Scripted Dashboards

そこで Grafana の scripted dashboards 機能を利用することにしました。 これは Grafana サーバに public/dashboards/nanika.js という JS ファイルを設置して Grafana 上で /dashboard/script/nanika.js にアクセスすると設置した JS ファイルを評価し、その結果をダッシュボードの JSON 表現として解釈しダッシュボードを表示するという機能です。この JS ファイルでは任意の JS コードを実行できるため、以下のように別のサーバが返した JSON をそのまま返すような JS ファイルを設置することで、サーバから Grafana のダッシュボードを制御することが可能になります。

'use strict';

var ARGS;

return async function(callback) {const fallback = {
    schemaVersion: 18,
    title: 'Failed to load dashboard from hako-console',
    panels: [{
        id: 1,
        type: 'text',
        gridPos: {
          w: 24,
          h: 3,
          x: 0,
          y: 0,
        },
      },
    ],
  };
  try{const response = await fetch(`https://hako-console.example.com/grafana_dashboards/${ARGS.app_id}`, { credentials: 'include' });if (response.status === 200) {const dashboard = await response.json();
      callback(dashboard);
    }else{
      fallback.panels[0].content = `hako-console returned ${response.status} error.`;
      callback(fallback);
    }}catch (e) {
    fallback.panels[0].content = `Failed to fetch API response from hako-console: ${e}`;
    callback(fallback);
  }};

この例ではクエリストリングで app_id というアプリケーションの識別子を受け取り、それを hako-console に問い合わせています。hako-console はこの問い合わせに対してこのアプリケーションに関連する ALB、RDS、Memcached 等のリソースを見つけ、それぞれに対応するメトリクスを表示するようなダッシュボードの JSON 表現を返すようになっています。ダッシュボードの JSON 表現についてはドキュメントがあるのですが、すべてを網羅できているわけではないので、Grafana 上で実際にダッシュボードを作ってその JSON Model を見てそれに合わせる、と進めたほうが私は分かりやすかったです。 https://grafana.com/docs/reference/dashboard/

たとえばとある Web アプリケーションの自動生成されたダッシュボードは以下のようなものです。このダッシュボードを表示する JSON 表現として hako-console は https://gist.github.com/eagletmt/45f8c8bffcbe34f48e937a756aac2a34のようなレスポンスを返しています (※一部の値はマスキングしてます)。 f:id:eagletmt:20190724111907p:plainf:id:eagletmt:20190724112029p:plain Grafana はダッシュボードの JSON 表現を介して import することもできます。したがって自動生成されたダッシュボードでは物足りず、たとえばアプリケーションが独自に Prometheus に保存しているメトリクスも表示したい場合にも、簡単に拡張することもできます。hako-console 上で固定のメトリクスを表示していたときと比べて、この点も Grafana を利用するメリットだと思っています。

ダッシュボードの工夫

アプリケーション毎のダッシュボードを自動作成するにあたって、見やすさと実用性を重視するために各リソースについて頻繁に参照するメトリクスのみを表示するようにしました。たとえば ElastiCache Memcached の場合、使用可能な空きメモリの量 (FreeableMemory) やキャッシュされた容量 (BytesUsedForCacheItems) 等が役に立つこともありますが、多くのケースで役立つメトリクスは CPU 使用率 (CPUUtilization) や eviction の発生回数 (Evictions)、キャッシュのヒット率等でしょう。公式のドキュメントも参考になります。 https://docs.aws.amazon.com/ja_jp/AmazonElastiCache/latest/mem-ug/CacheMetrics.WhichShouldIMonitor.html

ちなみにキャッシュのヒット率は CloudWatch の基本的なメトリクスには含まれていませんが、GetHits と GetMisses から算出することができます。Grafana は CloudWatch の Metric Math 機能をサポートしているため、これを利用して GetHits / (GetHits + GetMisses) の値を Grafana 上に表示できます。

見やすさのために一部の主要なメトリクスに絞って表示することにしたとはいえ、主要でないメトリクスが手掛かりになることがあるのはたしかです。そこで Grafana パネルのリンク機能を使い、別途用意された詳細なメトリクスが表示されたダッシュボードへ移動できるようにしています。 この機能を利用すると現在のダッシュボードで選択されている time range をリンク先のダッシュボードに引き継ぐことができます。とくに1日以上前の障害を調査したり振り返ったりするときには time range の引き継ぎは便利でしょう。

また、このアプリケーション毎のダッシュボードにはデプロイのタイミングを annotationとして表示するようにしています。 f:id:eagletmt:20190724112414p:plainメトリクスの傾向が変化する原因の多くはデプロイです。プロモーション等によるユーザ数の急激な変化や下流のマイクロサービスのデプロイによるアクセス傾向の変化といった他の要因でメトリクスの傾向が変化することもありますが、それらよりもそのアプリケーション自身の変更が原因であることが多いでしょう。ダッシュボード上でデプロイのタイミングを分かりやすくすることで、意図しないパフォーマンス劣化が発生していないか、パフォーマンス改善を狙った変更がうまくいったかどうか、といったことが分かりやすくなることを狙っています。

なお、このデプロイのタイミングはどうやって取得しているかというと、クックパッドではほとんどのアプリケーションが ECS で動いているため、ECS の UpdateService API が実行されたタイミングをデプロイのタイミングとすることができます。そこで、S3 バケットに配信された CloudTrail のログファイルを加工して Prismに渡すことで Redshift Spectrum で読める状態にし、Redshift にクエリすることで UpdateService API が実行されたタイミングを取得して InfluxDB に保存し、Grafana からそれをデータソースとして annotation の query に設定しています。CloudTrail のログは他にも用途があるため、このように一度 Redshift に入れてからそれぞれが使うようになっています。 f:id:eagletmt:20190724112506p:plain

まとめ

Grafana の scripted dashboards という機能と、それを利用してどのようなダッシュボードを自動生成しているかについて紹介しました。Grafana は手軽に見やすいダッシュボードを作成できて便利な反面、variables 機能ではカバーできないような個別のダッシュボードを1つ1つ作るのが面倒に感じている方は、自由度の高い scripted dashboards 機能を利用してみてはどうでしょうか。

AWS re:Inforce 2019に参加してきました

$
0
0

技術部セキュリティグループの三戸 (@mittyorz) です。こんにちは。 去る6/25,26日に開催されたAWS re:Inforce 2019に参加しましたので、簡単ではありますが紹介させていただきたいと思います。 今回が初開催なため規模感や雰囲気などは未知数の中、クックパッドからはセキュリティグループの三戸・水谷 (@m_mizutani)とVP of Technologyの星 (@kani_b) のあわせて3名で参加いたしました。

AWS re:Inforce とは

AWS re:Inforceは、数あるAWSのカンファレンスの中でもセキュリティに特化したカンファレンスです。 セキュリティに関するイベントはre:Inventなどでもこれまであったわけですが、re:Inventが非常に巨大なイベントとなってしまったこと、クラウドベンダーとしてセキュリティにおいてどのような姿勢で望むのかを積極的にアピールする場としてre:Inforceが新たに用意されたのではないかなと思っています。 AWS以外にも様々なセキュリティベンダーがパートナーとして参加しており、ブースの様子などについては後ほど触れたいと思います。

Builderにとってのセキュリティ

初日、AWSのVPかつCISOであるSteve SchmidtによりKeynoteが行われました。この中で、セキュリティはセキュリティエンジニアだけが考えるものではなくサービスに関係する全てのメンバーが意識し関わるべきものであること、 サービスを作り上げるうえで必要なものを自分たちで選択し作り上げていくように、セキュリティにおいてもベンダーの製品をそのまま使うのではなく、要素を組み合わせ自分たちにとって本当に必要なものを作っていく必要があることが強く主張されていました。 その際に、AWSのクラウドサービスをどのように上手に選択するか、ということが大事になってくるわけです。

セキュリティベンダーの事例紹介でも同様で、単に顧客の環境に自社の製品を導入したという話ではなく、AWSのサービスと組み合わせることでどういうことが新たにできるようになったかということが説明されていました。 そのためには自分たちでセキュリティを作り上げていく必要があり、度々キーワードとして出てきた「Builder」にそのことが表れていると思います。

会場の様子

AWS re:Inforceは現地ボストンにおいて6/25,26の二日間に渡って開催されました。 AWSのカンファレンスといえばまずre:Inventを思い浮かべる方が多いかと思いますが、規模としてはそこまで巨大というわけではなくおおよそ一つの建物にまとまっていました*1

f:id:mittyorz:20190723152400j:plain
会場となったBoston Convention and Exhibition Centerの、おそらく正面入口

f:id:mittyorz:20190723152532j:plain
ベンダーによるブースなどが並ぶ、受付からすぐのスペース。会場は4階まであったので写っているのはごく一部と言えます

日本からは24日18時に成田から離陸して現地に24日18時に到着*2、翌日から二日間会場入りして、三日目木曜日には帰国に向け出発し金曜日夕方に日本に到着するというちょっと強行軍かなとも言えるスケジュールでした。 幸いにも恐れていた時差ボケにはならず、はじめての海外出張かつ大規模なカンファレンス初参加の割には落ち着いて見て回れたのではないかと自分では思っています。

セッションについて

日程は二日間でしたが、聴講タイプのいわゆるSessionの他、手を動かすWorkshopやSecurity Jam他、非常に沢山の ブースではベンダーの説明を聞いたり質問を行うことも出来ますし、待ち合わせ時間などに手元のPCから参加できるCTFも用意されていました。

1時間単位のセッションにいくつか参加したのと、4時間かけて点数を競い合うSecurity Jamに3名でチームを組んで参加しました。

以下、参加したセッションから特に印象に残った点などを簡単に紹介します。

Encrypting Everything with AWS (SEP402)

AWSの各サービスでどの部分でどのような暗号化をしているか、物理層の安全性をどう担保しようとしているか、について広く説明するセッションです。 ただ、時間のかなりの部分が「暗号論入門」という感じで、暗号アルゴリズム自体の説明になっていてちょっと期待とは違ったかなという感想でした。 セッションには難易度が設定されていて、これは「Expert」向けとされていたのですが、暗号アルゴリズムの数学的背景を予め知っているのであればだいぶわかりやすかったのではないかなと思います。 後半はAWSの各サービスでどのように暗号化が行われているかそのアルゴリズムも含め解説され、AWS SDKからの利用といったユーザが直接触れる部分から、インスタンス間やVPC間の通信をどのように行っているのか、更にはリージョン間の物理ネットワークの暗号化などレイヤーごとに説明されていました。 後述するNitro Controllerを用いて暗号化する際には、複数のキーストアから取得した鍵の「一部分」をNitron Controller内であわせることで実際に用いる鍵を生成し用いているという話が特に興味深かったです。

Firecracker: Secure and fast microVMs for serverless computing (SEP316)

LambdaおよびFargateの実行環境を効率的に隔離するために実装されたLinux KVMベースのHypervisior、Firecrackerについてのセッションです。 アカウントごとに実行環境をきちんと分離しつつ、オーバーヘッドの低減を両立させるのは難しいと説明しつつ、一方でFirecrackerを用いることでVMの起動をだいたい120ms以下で行い、一つのホストで1秒間に150VM起動できるようになると話していたのが興味深かったです。 Lambdaを実行している環境は、そのLambdaをデプロイしたアカウントごとに別々のゲストOSにHypervisorによって分離されているわけですが、このHypervisiorにFirecrackerを用いることで一つのホストによりたくさんの環境を詰め込める、ということが詳しく説明されていました*3。 Fargateについては、Firecrackerを用いることでEC2のインスタンスサイズの荒い粒度ではなくより細かくリソースが割り当てられるようになったこと、起動が非常に速いので「EC2 Warm Pool」を用意する必要がなくなり価格が大きく削減できたこと*4が説明されていました。 今後のロードマップでは、現在はIntel CPUのみがサポートされていますが、年内にはAMD CPUのサポートも計画されているとアナウンスされました。

Security benefits of the Nitro architecture (SEP401)

このセッションでは、Nitro Contollerと呼ばれる物理的なチップとそれを用いた全体のアーキテクチャについて説明がありました。 「Nitro」という言葉自体はAWSの最新世代のEC2インスタンスなどで用いられている仮想化技術というコンテキストでよく出てきますが、このシステムを構成するために通常のCPUや周辺機器とは独立して組み込まれているのがNitro Contollerです。 仮想化に伴うオーバーヘッドを低減するために、出来る限りNitro Controllerを用いて処理をオフロードする仕組みになっているのですが、インスタンスストレージへのアクセスを含めDisk I/O、Network I/OなどすべてのI/OはNitro Controllerを用いて透過的に暗号化される仕組みになっています。 また、暗号化に必要な情報はNitro Contoroller同士で自動的にやり取りされるとのこと。 Nitro Controller自体も独自に内部的なストレージを持っていて、ファームウェアのアップデートなどはシステムを再起動すること無く行えるようになっているそうです。 Nitro Controllerが改ざんされないようCPUからは書き込みができないようになっていたり、システムの起動時にはNitro Contorollerによってマザーボードのファームウェアがチェックされるようになっているなど、システムの健全性を担保するのにNitro Controllerが要になっていることがよく分かるセッションでした。

Security Jam

複数人でチームを組み*5、競技時間の4時間の間に出題された問題を出来るだけ問いて順位を競い合います。 競技時間終了と同時に出題ページがクローズされてしまったので記憶を元にちょっと紹介すると、

  • 複数のVPCにまたがって構成されたウェブサービスを再構成する問題
  • EKSのCI/CDと、EKS自体のセキュリティの設定を修正する問題

といったように、AWSの各サービスをきちんと理解していないと問題が解けないようにうまく設計されていて、普段の業務で触ってない部分の知識も容赦なく必要とされなかなか苦労しました。

f:id:mittyorz:20190723152620j:plain
全体で60席くらい用意されていましたが、オンラインで参加できるのでこのホール外から参加していたチームもあったようです

f:id:mittyorz:20190723152643j:plain
5-hour ENERGY という、ちょっとヤバそうなエナジードリンク

最終的に45チーム中11位という結果になりました。

セキュリティの競技だといわゆる「CTF」をまず思い浮かべる方が多いかと思いますが、普通のCTFと比較してみてAWSの環境自体を直す要素が強かったかなと思います。 また、AWSのアカウント・リソースが問題ごとに別々に用意されていて、出題ページからAWSのコンソールにログインすると独立した環境が自由に使えるようになったのは流石だなという感じでした。

ブース

企業ブースでは日本でもよく見る有名・老舗セキュリティベンダーがやはり多かったですが、一方で日本ではあまり見ないベンダのブースも多くありました。こういったブースだと直接担当の人と話して質問ができるのでなかなかカタログスペックなどから分からないことを聞けて面白かったです。全体の傾向としては、以下のような印象でした。 - コンテナ環境を対象としたサービスがメインストリームになりつつあり、逆に言うとEC2インスタンスなどにフォーカスするようなサービスの宣伝はあまり見なかった - クラウド環境全体の監査・コンプライアンスチェックを自動化するというサービスがかなり目立っていた

程度の差はありましたが、ブースエリアはそれほど混んでおらず気になった製品のブースで気軽に質問できるようになっていました。

おわりに

AWS re:Inforce 2019を簡単にですが紹介させていただきました。 AWS自身が提供するセキュリティ機能だけではなく、各社がクラウド向けに出してきているセキュリティ製品をどううまく活用していくのか、いうなれば「顧客が本当に必要だったもの」をきちんと構築できるようになりましょう、というメッセージを強く感じました。 新しい技術・製品がどんどん出てくる中で、適切な技術選択を行うのはとても難しいことですが、難しいからこそ面白い分野でもあると再確認できてとても良かったと思います。

クックパッドでは技術を用いてセキュリティを高め最高のサービスを提供することに興味のある仲間を募集しています。

セキュリティエンジニア

*1:もっとも、建物自体が巨大で、規模感としては東京国際展示場の各展示棟を一つにまとめたくらいをイメージすると近いのではないかなと思います

*2:搭乗時間と時差がちょうど一致

*3:動画で18:30頃から出てくる図を見るとわかりやすいかと思います

*4:https://aws.amazon.com/jp/blogs/compute/aws-fargate-price-reduction-up-to-50/

*5:一人でも参加可能なようでした

インフラのコスト最適化の重要性と RI (リザーブドインスタンス) の維持管理におけるクックパッドでの取り組み

$
0
0

技術部 SRE グループの mozamimyです。

クックパッドでは、 SRE が中心となって、サービスを動かす基盤の大部分である AWS のコスト最適化を組織的に取り組んでいるため、今回はそれについてご紹介します。

前半では、そもそもの話として「なぜコスト最適化が重要なのか」「何が難しいのか」「何をすべきなのか」といったことを述べます。これは、当たり前すぎて逆に陽に語られることが少ない (とわたしは感じています) トピックで、一度しっかり言語化しておいてもいいかなと考えたからです。内容のほとんどはわたしの脳内ダンプで、クックパッドという会社のコンテキストや組織としてのステージが前提になっているため、大多数の組織について当てはまる内容とは限りません。

後半では、コスト最適化の一例として、リザーブドインスタンス (以下 RI と略記) を維持管理するためのフローと、それを支えるモニタリングシステムについて述べます。こちらは AWS を利用するいかなる組織においても、今日から使えるテクニックとなるでしょう。もしかすると、似たような仕組みを整えている組織もあるかもしれません。

パブリッククラウドのコスト最適化の重要性

オンプレミスであれクラウドであれ、インフラにかかるコストを最適化し、できる限り無駄のない状態を保つことが重要なのは言うまでもないでしょう。とりわけクラウドでは、必要なときに必要な分だけお金を対価にリソースを得られる反面、簡単な操作でリソースを増やすことができるため、気づいたときには大きな無駄が発生していたという自体も起こりえます。

組織において、お金という限られたリソースは大変重要かつ貴重なものです。たとえば、インフラにかかるコストを年間 $10,000 節約できたとすれば、その $10,000 は投資やその他の重要な部分に回すことができます。インフラコストは投資と違って単に失われるだけなので、エンジニアリングリソースを割いて最適化することは、十分に理にかなっているでしょう。クラウドであれば、API を通してリソースを操作できるため、ソフトウェアエンジニアリングで解決できる部分が大きいです。

コスト最適化に見て見ぬふりをしていると、じわじわと組織の首を絞めていきますし、節約できていれば本来有意義に使えたお金が失われることになります。キャッシュが枯渇してから慌てていては、もう手遅れです。

コスト最適化を考えるにあたって、以下の 2 つの軸が存在するとわたしは考えています。

  • リソースプールのコスト最適化: RI を適切に保つ、スポットインスタンスを積極的に利用する、など。
  • リソースプールの利用に対するコスト最適化: 各サービスで利用しているインフラのキャパシティ (ECS サービスのタスク数、RDS インスタンス、ElastiCache Redis/Memcached クラスタなど) が、適切にプロビジョン・スケーリングしていて無駄遣いしていない状態に保つこと。

コスト最適化の基本的なポイント

たとえば AWSだと、以下のような取り組みがあげられます。これらは AWS の公式ページにも書かれています。

  1. スポットインスタンスを積極的に利用する。
  2. RI の購入によってある程度のキャパシティを予約・前払いし、オンデマンドインスタンスやマネージドサービスを割安で利用する。
  3. Cost Explorer や Trusted Advisor といったサービスを利用してオーバープロビジョニングなリソースを発見し、インスタンスサイズやその数を適切に保つようオペレーションする。

これらのうち、クックパッドでは 1 番と 2 番、すなわちリソースプールそのもののコスト最適化については非常に熟れており、理想に近い状態を維持することができています。3 番のリソースプールの利用については、これからも継続的に取り組んでいくべき課題となっています。

1. スポットインスタンスの積極的な利用

コンテナオーケストレーション基盤としてクックパッドでは早くから ECS を採用しており、SRE の鈴木 (id:eagletmt) による Cookpad Tech Kitchen #20での「Amazon ECSを安定運用するためにやっていること」という発表にあるように、スポットインスタンスを利用した ECS クラスタを安定して運用できています。Rails を始めとする HTTP をしゃべるアプリケーションサーバなど、状態を持たないサービスのほとんどは、スポットインスタンスで構成された ECS クラスタで動いています。これは、コストの最適化に大きく寄与しています。

2. RI (リザーブドインスタンス) の運用

このトピックは、記事の後半で述べるためここではいったん隅に置いておきます。

3. キャパシティを過不足ない適切な状態に保つ

文字で書いただけでは「いいじゃんやろうよ」という感じなのですが (ですよね?)、実際にはその道のりは非常に険しいです。たとえば...

  1. インスタンスサイズの変更のためにどうしても停止メンテナンスが必要になったらどうするか? 開発チームとの《調整》が発生。
  2. アプリケーションの改善でコストを最適化できる場合 (たとえばスロークエリを改善するなど) だと、SRE よりもそのアプリケーションを普段から触っていてドメイン知識のある開発チームが対応したほうが早い。
  3. SRE の限られた人的リソースですべてのインフラのリソースの状況をウォッチして対応し続けるのは組織の拡大に対してスケールしない。

といった問題があります。

これは、SRE という概念が解決しようとしてる問題に密接に関連しています。Google による SRE 本にも、コスト最適化のためのオペレーションはトイルとして扱われています。

エラーバジェットや信頼性といった真髄からはやや外れるため、コスト最適化に SRE という概念を絡めることには議論があるかもしれません。ただ、わたしは場当たり的なコスト最適化は技芸だと思っていて、それをエンジニアリングで解決することは立派な SRE way だし、SLO を守れる範囲でコストを切り詰めていくことは信頼性に深く関わることだと信じています。トイルをなくそうという SRE の方針とも合致しますね。

この問題はクックパッドの SRE で取り組んでいる、開発チームへの権限と責任の移譲が進み、適切にコストをモニタリングできる仕組みが整えば解決できる見込みがあります。少し前の記事ですが、権限と責任の移譲については、クックパッドの開発基盤、インフラ環境での開発で心がけているラストワンマイルでも触れられています。

各開発チームが、自分たちのサービスにかかっているインフラコストを把握できるようなり、自律的にリソースプールの利用を最適化できると、組織の拡大に対してスケールするようになるでしょう。開発チーム自身の手で実装起因の無駄を改善することができ、スケールダウンのための停止メンテナンスが必要になった場合でも、チーム内で調整を完結して行うことができます。

SRE はリソースプールそのものの最適化と、リソースプールの利用をモニタリングできる仕組みを提供することに専念し、必要に応じて開発チームを手伝うことはありますが、基本的には開発チームにコスト最適化の責任と権限を持ってもらうのです。

また、プロジェクトや部署ごとに、どの程度サービス運用のためのインフラコストがかかっているかを把握できるようなダッシュボードを作ることができると、財務管理上でもメリットになるでしょう。まるっと「インフラ代金」として予算管理しているものが、開発チームや部署、プロジェクトごとに予算を細かく設定し、追跡することができるのです。財務などのバックオフィスもインフラコストの状況を追跡できるようにしておく重要性はWhitepaper: The guide to financial governance in the cloudでも触れられています。

リソースプールの最適化に限界が見え始めた今、やるべきこと

SRE が責任として持っている、スポットインスタンスの利用推進や RI の適切な運用によって、開発チームが利用するリソースプール自体のコスト最適化は限界に近づきつつあります。次にやるべきことは、リソースプールの利用を最適化していくことで、これは組織全体として取り組めるように SRE がエンジニアリングで解決していく必要があります。

リソースプールの利用に対するコスト最適化はこれからの課題として、後半では、リソースプール自体のコスト最適の取り組みの一つとして、RI の維持管理のフローと、それを支えるモニタリングシステムについて説明します。

クックパッドにおける RI (リザーブドインスタンス) の維持管理と対応フロー

RI の費用削減効果を最大限に発揮させるためには、その状況の変化を察知して、以下のようなオペレーションによって理想の状態に戻るようにメンテナンスし続けるのが一般的です。

  • RI の追加購入
  • 動いているインスタンスタイプの変更
  • RI の exchange (2019-08-14 現在、convertible な EC2 の RI のみ可能)

これは CPU 利用率やキューの待ち行列の長さといったメトリクスに基づくキャパシティのスケールアウト・スケールインと似ています。RI の状況がしきい値を割ったときに、上述のリストにあげたようなオペレーションによって、理想的な状態に戻るようにメンテナンスし続けるのです。

クックパッドでは、後述の ri-utilization-plotter や github-issue-opener といった Lambda function を利用して、以下のような GitHub issue を使った対応フローをとれるようにしています。

  • RI の状況を監視し、変化があったことを検知してアラートをあげる。
  • アラートがあがると、自動的に GitHub のリポジトリに issue が作成される。
  • RI の追加購入などのオペレーションをする。
  • RI の状況が元に戻ったことを確認して issue を閉じる。

RI の状況ベースでの監視は、実際に状況が変わってからしか検知できないため、対応が後手に回ってしまいがちという弱点があります。RI の追加購入や exchange の際には、いくつ買っていくつ exchange するのかというプランを練ってチーム内でレビューする必要があるため、その作業が終わるまでは RI による費用削減効果が減ってしまうことになります。

少なくとも RI の失効は事前に知ることができるため、上述の RI の状況をベースとした監視に加え、以下のような対応フローも整えています。

  1. RI の失効が発生する 7 日前に PagerDuty の incident を発行してアラートをあげる。
  2. RI の追加購入を検討・実施し、incident を resolve 状態にする。

これらの仕組みにより、RI を理想に近い状態で運用することができています。

実際の対応フロー

百聞は一見にしかずということで、この仕組みを使った Grafana によるダッシュボードや、実際の対応フローを見てみましょう。

この仕組みでは、RI の状況は CloudWatch のカスタムメトリクスに蓄積されるため、以下のように Grafana から一覧することができます。

f:id:mozamimy:20190813143925p:plain
Grafana から確認できる CloudWatch カスタムメトリクスに集積された RI の状況

ちなみに、Cost Explorer API から取得できる値は、月に一回程度、変な値を示してしまう場合があるのですが、頻度も少ないですしそれは許容しています。このスクリーンショットでは、左上のグラフの谷になっている箇所ですね。

実際に RI の状況が変化してメトリクスがしきい値を割ると、GitHub に issue が自動的に立って場が作られ、そこで対応することになります。

f:id:mozamimy:20190813144027p:plain
RI の監視システムによってあがったアラートに GitHub 上で対応している様子

このスクリーンショットのシナリオでは、Amazon ES の RI カバレッジがしきい値を割ったことが検知されており、わたしがその原因を調べてコメントし、オペレーションによる一時的なものであることを確認して CloudWatch Alarm のしきい値を調整し、メトリクスがもとに戻ったことを確認して issue をクローズとしました。別のケースでは、RI を追加で購入したり exchange したりといったオペレーションをしたこともありました。

さて、ここからは実装の話に移ります。以下のトピックについて説明していきましょう。

  • RI の状況を知る
  • RI の状況が変わったときにアラートをあげる
  • RI の失効が近づいたときにアラートをあげる

RI (リザーブドインスタンス) の状況を知る

RI がどのような状況にあるかは、RI 利用率RI カバレッジによって知ることができます。

RI 利用率は、購入した RI がどの程度有効になっているか、すなわちどの程度余らせることなく有効に使えているかを示す割合です。RI カバレッジは、オンデマンドで動いているインスタンスの総量に対して、RI によってどの程度カバーされているかを示す割合です。

RI 利用率は 100% を維持できている状態が望ましいです。RI カバレッジも同様に、できる限り 100% に近い状態を保っているのが理想です。しかしながら、ある時刻において RI カバレッジが 100% になるように買ってしまうと、将来的な利用の変化に対応することが難しくなります。アプリケーションの改善により必要なキャパシティが減ったり、EC2 であれば、スポットインスタンス化やマネージドサービスに寄せることで RI が余るといったことも起こるでしょう。あまり考えたくはないですが、サービスのクローズによって不要になるリソースもあるかもしれませんね。そこで、ターゲットとなるカバレッジを、たとえば 70% などと決めておき、その値を下回らないように RI をメンテナンスするのがよいでしょう。

RI 利用率や RI カバレッジは Cost Explorer のコンソールや API から確認でき、フィルタを駆使することでリージョンやサービスごとに値を絞り込めます。

f:id:mozamimy:20190813145616p:plain
Cost Explorer から確認する RI 利用率の様子

この画面からは利用率だけでなく、RI によってどの程度コストを節約できたのかといった情報も表示できます。また、左側のメニューにある Reservation summary には、RI の情報をサマライズしたビューが用意されています。

f:id:mozamimy:20190813145723p:plain
Cost Explorer で RI のサマリを確認する

この画面では、すべてのサービスを横断した RI のサマリを確認でき、30 日以内に失効する RI も確認することができて便利です。

Cost Explorer の基となっているデータ (RI の情報だけでなく、請求に関するすべての情報を含む) は AWS Cost and Usage Report (以下 CUR と略記) によって S3 バケットに出力することができ、Athena などでクエリすることで Cost Explorer では実現できないような集計結果を SQL によって自在に引き出すことができます。

クックパッドでは、CUR を Redshift Spectrum からクエリできるような仕組みがデータ基盤グループによって整備されており、Tableau を用いてダッシュボードを作ることも可能になっています。

RI (リザーブドインスタンス) の状況の変化に対してアラートを仕掛ける

ここまでの説明で、Cost Explorer やその API、CUR などを駆使することで、RI の状況を確認できることがわかりました。次はそれらのメトリクスに対してアラートを仕掛けることを考えていきましょう。

なるべく AWS 標準の機能で済ませたい場合は、AWS Budgetsが利用できます。reservation budget をしきい値とともに作成し、アラートの設定に SNS トピックやメールアドレスを設定することで通知を HTTP エンドポイントや Lambda function 、特定のメールアドレスに送ることができます。試していませんが、少し前のアップデートによって、AWS Chatbot を利用して Slack や Chime に簡単に通知できるようになったようです。

Launch: AWS Budgets Integration with AWS Chatbot | AWS Cost Management

これはこれで便利ですが、以下の理由で今回の要件を満たせないと考え、自前で仕組みを用意しようと判断しました。

  • reservation budget を daily で判定するように設定すると、しきい値を割っている間、毎日通知が届いてしまう。
  • 通知の細かい制御ができない。たとえば「3 日以上しきい値を割り続けていたら」のようなアラートを設定できない。
  • メールや Slack の通知だけでは見落としが発生する可能性が高く、後半の冒頭で述べたような対応フローを実現できない。

今回は、ri-utilization-plotter という Lambda function を中心とした、以下のような仕組みを構成しました。仕組みとしては非常に素朴ですが、よく動きます。

  1. 12 時間ごとに起動する Lambda function から、Cost Explorer API をたたき、取得した RI 利用率と RI カバレッジを CloudWatch のカスタムメトリクスにプロットする。
  2. そのカスタムメトリクスに CloudWatch Alarm を仕掛け、しきい値を割ったときに SNS topic 経由で Lambda function を起動し、GitHub の SRE のタスクをまとめるためのリポジトリに issue を立てる。

ちなみに、CloudWatch にメトリクスを寄せることで、Grafana を使って Prometheus といった他の様々なメトリクスと組み合わせたダッシュボードを作ることもできて便利というメリットもあります。

実際には、たとえば Redshift の RI はデータ基盤グループが管理しているため、SRE 用とは別のリポジトリに issue を立てるようにするなど、もう少し複雑な構成になっています。そのため、上記の構成図はエッセンスを抜き出したものとなっています。

ri-utilization-plotter は、AWS サーバーレスアプリケーションモデル (以下 SAM と略記) に則り、CloudFormation stackを aws-sam-cliを利用してデプロイしています。

メトリクスをプロットする ri-utilization-plotter と github-issue-opener の stack が分かれているのは、github-issue-opener は「SNS トピックに届いた通知をもとに GitHub に issue を作成する便利 Lambda function」として、ri-utilization-plotter からだけでなく、汎用的に利用できるように実装しているからです。このような構成をとることにより、他の Lambda を中心としたアプリケーションで GitHub Appを用いたコードを自前で実装しなくても、SNS トピックに通知するというシンプルな操作だけで issue を立てることができるようになっています。

それでは、ri-utilization-plotter と github-issue-opener の中身を見ていきましょう。

ri-utilization-plotter

ソースコードはこちらにあります。https://github.com/mozamimy/ri-utilization-plotter

template.example.yml に記述されている RIUtilizationPlotter という論理名を持つ AWS::Serverless::Functionリソースが本体で、make コマンドを経由して SAM CLI を実行して手元で実行できるようになっています。

この function は、event.example.json にあるように、

{"region": "us-east-1",
  "service": "Amazon ElastiCache",
  "linked_account": "123456789012",
  "granularity": "DAILY",
  "namespace": "RIUtilizationTest",
  "metric_name": "UtilizationPercentage",
  "ce_metric_type": "utilization"
}

のようなイベントを、CloudWatch Events から受け取ります。namespace に CloudWatch カスタムメトリクスのネームスペースを指定し、metric_name にメトリクスの名前を指定します。この例では RI 使用率をプロットする設定になっていますが、metric_name を CoveragePercentage のようにし、ce_metric_type を coverage にすると、RI カバレッジもプロットすることができます。ce_metric_type は Cost Explorer API のオプションとして渡す文字列で、utilization (RI 使用率) と coverage (RI カバレッジ) に対応しています。

上述のリポジトリには開発時にテスト用途として SAM CLI を利用することが前提の CloudFormation テンプレートしか含まれていません。これは意図したもので、Lambda function のソースコードと AWS の実環境へのデプロイの設定を分離するためにこうなっています。したがって、AWS の実環境にデプロイする場合には、そのための CloudFormation テンプレートを別途用意する必要があり、これは社内固有の設定も含むため社内のリポジトリで管理されています。

社内で管理されている実環境用の CloudFormation テンプレートは、Jsonnetを利用して JSON に変換することで生成しています。

たとえば、この場合だとリージョンとメトリクスのタイプ (utilization/coverage) の組み合わせごとに CloudWatch Event ルールが必要となるため、以下のように event.ruleという関数を定義することで DRY になるようにしています。./lib/event.libsonnet というファイルに event.rule の定義を出力する rule という関数を作り、CloudFormation テンプレートの Resourcesの中でそれを呼び出すイメージです。

また、実行ファイルを含む zip ファイルは GitHub の release にあるため、デプロイ時に https://github.com/mozamimy/ri-utilization-plotter/releases/download/v1.0.0/ri-utilization-plotter.zipから curl でダウンロードし、jsonnet コマンドを実行するときに jsonnet template.jsonnet --ext-str codeUri=${PWD}/tmp/ri-utilization-plotter-v1.0.0.zipのようにオプションを与え、std.extVar('codeUri')のようにして実行可能ファイルを含む zip ファイルのパスを差し込むようにしています。

{
  AWSTemplateFormatVersion: '2010-09-09',
  Transform: 'AWS::Serverless-2016-10-31',
  Resources: {
    MainFunction: {
        // 省略
        Properties: {
            CodeUri: std.extVar('codeUri'),
            // 省略
        },
    },
    DLQ: {
        // 省略
    },
    EventRuleUtilizationApne1: event.rule(alias, 'ap-northeast-1', 'utilization'),
    EventRuleUtilizationUse1: event.rule(alias, 'us-east-1', 'utilization'),
    EventRuleCoverageApne1: event.rule(alias, 'ap-northeast-1', 'coverage'),
    EventRuleCoverageUse1: event.rule(alias, 'us-east-1', 'coverage'),
  },
}

Jsonnet はパワフルなテンプレート言語で、クックパッドでは ECS を使ったアプリケーションのデプロイを簡単に行うためのツールである Hakoでも採用されています。

パワフルがゆえに何でもできてしまうという側面もあり、用法用量を間違えると複雑怪奇な設定ファイルができてしまうというダークサイドもありますが、個人的にはとても気に入っています。実は JSON は YAML として評価しても合法なため、設定ファイルに YAML を用いるソフトウェアでも Jsonnet を使うことができます。

github-issue-opener

ソースコードはこちらにあります。https://github.com/mozamimy/github-issue-opener

これは SNS topic で受けた通知をもとに GitHub に issue を立てる Lambda function です。

ISSUE_BODY_TEMPLATEISSUE_SUBJECT_TEMPLATEといった環境変数に issue のタイトルや本文の内容を Go のテンプレートして記述することができます。たとえば、{{.Message}}のように設定した場合、SNS メッセージのメッセージがそのままレンダリングされます。 https://github.com/mozamimy/github-issue-opener/blob/master/template.example.jsonを見れば、雰囲気が掴めるでしょう。

通知を送る側から issue のタイトルや本文を指定したい場合は、message attribute が使えます。https://github.com/mozamimy/github-issue-opener/blob/5699d16606c4da13edc40c2c674c7110aaec43e7/event.example.json#L18-L42を見れば雰囲気が掴めるかと思います。

SNS メッセージの本文に JSON 文字列が入っていて、それをパースして issue テンプレートで利用したい場合は、PARSE_JSON_MODE環境変数を 1 に設定することで実現できます。その場合、

{"Foo": "bar"
}

のような JSON 文字列がメッセージ本文に含まれていた場合、テンプレートからは {{.ParsedMessage.Foo}}のようにして値を取り出してレンダリングすることができます。

こちらも ri-utilization-plotter と同様に、AWS 実環境へのデプロイ用の CloudFormation テンプレートは社内のリポジトリで Jsonnet として管理されています。

issue テンプレートは

# RI Utilization Alert

{{.ParsedMessage.NewStateReason}}

- **Alarm Name**: {{.ParsedMessage.AlarmName}}
- **Namespace**: {{.ParsedMessage.Trigger.Namespace}}
- **Metric Name**: {{.ParsedMessage.Trigger.MetricName}}
{{- range .ParsedMessage.Trigger.Dimensions }}
  - **{{.name}}**: {{.value}}
{{- end }}

Please check current utilization and fix it. 

のような形で ./issue_template/body_ri_notify.md.tmpl に保存されており (一部実際のものから変更を加えています)、CloudFormation テンプレートからは

{
  AWSTemplateFormatVersion: '2010-09-09',
  Transform: 'AWS::Serverless-2016-10-31',
  Resources: {
    MainFunction: {
      // 省略
      Environment: {
        Variables: {
          // 省略
          ISSUE_BODY_TEMPLATE: importstr './issue_template/body_ri_notify.md.tmpl',
        },
      },
    },
  },
}

のような形で、Jsonnet の importstr でファイルを読んだそのままを ISSUE_BODY_TEMPLATE環境変数に設定しています。

RI (リザーブドインスタンス) の失効が近づいたときにアラートをあげる

ri-utilization-plotter のように、RI の失効が近づいたときにアラートをあげる仕組みを内製しようとしていましたが、AWS Cost Explorer で予約の有効期限切れアラートを提供開始にあるように、Cost Explorer 自体に 60 日前、30 日前、7 日前、当日にメールを送信する機能が実装されたため、それを利用することにしました。

ただし、単にメールを送るだけだと、後半の冒頭で述べたような対応フローをとることができません。見逃してしまうこともあるでしょう。

そこで、今回は PagerDuty の email integration のメールアドレスをこの通知に設定することで、low urgency でアラートを飛ばし SRE の誰かにアサインされるようにしました。週次のミーティングでも low urgency に設定されたアラートが残っていないか振り返りを行っているため、作業が漏れることもありません。万が一漏れた場合でも、ri-utilization-plotter を中心とした仕組みにより、後手になりつつも異常に気づくことができます。

RI (リザーブドインスタンス) の購入・exchange プランを練るときの細かい話

RI を追加購入したり exchange したりする場合は、現在どの程度オンデマンドインスタンスの利用があるのかを把握して、プランを立てる必要があります。

その際には、Cost Explorer の画面で、過去何日間かを指定してインスタンスタイプごとの RI 利用率や RI カバレッジを確認したり、今この瞬間に動いているインスタンスの一覧を得るために https://github.com/mozamimy/toolbox/blob/master/ruby/list_on_demand_ec2_instances/list.rbのようなスクリプトを利用したりしています。このスクリプトは、bundle exec ruby list.rb t3のように実行すると、t3 ファミリの EC2 インスタンス一覧を出した上で、

Total instance count: 49
Normalized total count: 110.0
    as t3.small 110.0
    as t3.large 27.5

のように、トータルで何台オンデマンドインスタンスが動いているのかと、.small および .large に正規化した台数を算出します。

現状はこれらの値をもとに SRE が職人の技で丹精を込めてプランを立てていますが、将来的には自動化したいと考えています。素朴にやろうとするとすべての購入プランを網羅して最適な解を選ぶことになりますが、それはおそらく現実的ではありません。問題を適切にモデル化して既存のアルゴリズムを利用するなど、賢くやらねばなりません。ある意味で競技プログラミングのようですね。

まとめ

前半ではパブリッククラウドのコスト最適化についての考えを述べ、後半では、その取り組みの一部である RI の維持管理のための仕組みについて説明しました。

RI は、スポットインスタンスの積極的な利用に次ぐコスト最適化の強力な武器ですが、日々の手入れを怠るとその力を十分に発揮することができません。この記事では、クックパッドではその武器をどのように手入れしているかということについて説明しました。RI の管理にお悩みの方は、参考にしてみてはいかがでしょうか。

Google Play Billing Client 2.0における消費型商品の決済の承認(acknowledgement)について

$
0
0

ユーザ・決済基盤部の宇津(@uzzu)です。

クックパッドでは複数のAndroidアプリでGoogle Play決済(定期購読、消費型商品)を利用しており、 ユーザ・決済基盤部ではそれらのアプリの決済情報を取り扱う共通決済基盤サービスクライアントライブラリを日々開発しています。 直近ではGoogle I/O 2019にて発表されたGoogle Play Billing Client 2.0にも対応し、Cookpad.apk #3のLT枠にてどのように対応していったか発表させて頂きました。

speakerdeck.com

本記事では同発表にて時間が足りず深堀りできなかった、消費型商品における決済の承認(acknowledgement)対応について解説します。 スライドと合わせて読んで頂ければ幸いです。

消費型商品における2.0とそれ以前との違い

2.0以前の消費型商品の購入フローは概ね以下の図のようになっていたかと思います。

f:id:himeatball:20190815060223p:plain
2.0以前の購入フロー

2.0からはこれに加えて、決済の承認が必要になります。 Google Play決済自体は決済処理時に走る(Pending Purchaseを除く)のですが、3日以内に開発者が決済の承認を行わない場合返金されます。 通信断や障害等で購入フローが正常に完了せず商品が付与されなかったユーザが自動的に救済されるようになるのは、サポートコスト削減の面でも非常に良いですね。

f:id:himeatball:20190815060447p:plain
2.0での購入フロー

一見、購入フローに処理が1ステップ追加されただけのように見えます。加えてリリースノートにも

For consumable products, use consumeAsync(), found in the client API.

とあるように、アプリ上でconsumeAsyncを呼び出す事で消費(consume)しつつ決済の承認も行われるので、図に追加した⑤については特にやる事はないのでは?と思われた方もいるかと思います。 しかしながら、商品付与が行われるタイミングにおける決済の承認状態は2.0では未承認、それ以前では承認済という違いがあり、 この違いによってアプリ改ざんに対するリスクを考慮する必要性がでてきます。

consumeAsyncを呼び出さないようにアプリを改ざんされる事を想定した場合、購入処理を実施すると以下のように処理されます。

  1. 消費型商品の購入ボタンを押す
  2. 購入フローに則り商品が付与される
  3. consumeAsyncを呼び出さない為消費が行われないが、商品は既に付与されている(決済が未承認の間、商品は消費されない為、再購入はできない)
  4. 3日後、決済が未承認の為返金される
  5. 返金されると消費型商品が再度購入可能になる
  6. 1に戻る

つまり、2.0以前の購入フローの実装のまま愚直に2.0対応してしまうと、アプリ改ざんによって3日毎に消費型商品を無料で取得する事ができてしまいます。

対策A: サーバサイドで決済を承認する

決済の承認はレシート検証同様にサーバサイドで行いたいという需要に応えるように、Purchases.products: acknowledge
が用意されています。 クックパッドの共通決済基盤サービスではこれを利用して決済を承認しユーザと決済情報の紐付けが正常に行われた上で、各サービスで商品の付与ができる状態とするようにしています。 商品付与後、アプリ上でconsumeAsyncします。

この対策方法はアプリ改ざんに対するリスク、及び決済の承認に関連するアプリ上での購入フローの実装が2.0とそれ以前とで変わらないのが利点です。 ただし、クックパッドのような決済サービスと商品を販売しているサービスが分離されている環境下においては、 決済状態と商品付与状態の整合性の担保ができている前提での対策方法になると考えています。 クックパッドの共通決済基盤サービスにおける整合性についての取り組みは以下の記事を参考にしてください。

https://techlife.cookpad.com/entry/2016/06/01/070000

加えて決済を承認するタイミングについて、商品を付与した上で決済を承認するか、あるいはレシート検証を終えた段階で一旦決済を承認した後に商品を付与するかを検討するかと思います。

クックパッドの共通決済基盤サービスではどうしているかというと、消費型商品においては購入フローの完了処理である所の消費処理がアプリ上でしか実施できない為、購入処理の冪等性を担保できるよう後者を選択しています。 定期購読においてはGoogle Play決済を終えた以降の購入フローをサーバサイドで完結できる為、前者で且つレスポンスタイムを上げる為にJob Queueで非同期に決済の承認を実施しています。

対策B: アプリ上で決済の承認を実施してからサーバにレシートを送信し、サーバ間通信で決済の承認状態を検証する

Billing Client Libraryに決済の承認をするためのメソッド(BillingClient#acknowledgePurchase)が用意されています。 Google Play決済を実施後にこれを呼び出してまず承認してしまい、その上でサーバにレシートを送信し、サーバサイドでPurchases.products: getを呼び出してacknowledgementStateを確認し、 承認済か否かを検証した上で商品を付与した後、アプリ上でconsumeAsyncするような購入フローにします。

この対策方法ではアプリ上に実装されている購入フローはもちろん、通信断等で滞留した決済の再開処理にも手を入れる必要がある為、対策Aよりは手がかかるものの、 サーバ間通信で決済の承認状態を検証する為、対策A同様に介入される余地はないと考えています。

その他の対策方法

例えばdeveloper notificationを頼りに商品を付与する方法があるかと思いますが、developer notificationは現在定期購読のみサポートしているのと、 仮にサポートされるようになったとしても、消費型商品において大半のユーザは購入完了したら遅延なくすぐに商品を使用したい為、 その仕組みを整えるのはそれなりに開発コストがかかりそうです。

決済処理フローはそのままにアプリ改ざん対策に本腰を入れていくとしても、アプリ改ざん対策はいたちごっこになってしまう為、運用コストの増大が予想されます。 素直に前述の対策Aないし対策Bを適用するのが良さそうです。

まとめ

本記事ではGoogle Play Billing Client 2.0における消費型商品の決済の承認(acknowledgement)について解説しました。 弊社において利用していない機能もあり(定期購読のupgrade/downgrade等)、決済の承認に関する網羅的な解説とまではなっていないですが、 Google Play Billing Client 2.0導入の手助けとなれば幸いです。

クックパッドではアプリ内課金をやっていくエンジニアを募集しています

Viewing all 734 articles
Browse latest View live