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

Xcode のビルドログの読込

$
0
0

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

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

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

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

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

ビルドログの在り処

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

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

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

Cache.db

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

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

時間

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

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

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

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

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

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

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

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

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

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

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

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

他の項目

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

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

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

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

xcactivitylog

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

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

トークン読込

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ログバージョン

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

elseraise"Unknown class name #{class_name}"end

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

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

命名

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

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

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

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

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

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

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

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

集計

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

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

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

まとめ

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

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

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


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

$
0
0

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

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

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

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

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

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

「全面リニューアル」

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

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

スコープが大きい

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

つくりたい状況は

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

というものです。

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

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

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

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

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

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

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

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

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

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

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

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

Rails のテンプレート探索

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

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

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

Resolver

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

ApplicationController.new.view_paths

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

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

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

Path

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

Pattern

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

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

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

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

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

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

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

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

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

シンプルなケース

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

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

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

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

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

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

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

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

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

Detail

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

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

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

Pattern

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

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

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

# config/initializers/monkey_patches/jpmobile.rbmoduleJpmobileclassResolver# Original: ':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freezeDEFAULT_PATTERN = '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freeze
  endend

あまり褒められた方法ではありませんが、今回行ないたいのはデザインの全面リニューアルであり、そのための一時的な仕組みに対してあまり時間をかけたくなかったため割り切った判断をしました。

実際に切り替える

ここまで出来たらあとは実際に切り替えるだけです。

切り替え自体は controller で lookup_context.directories=を呼ぶだけなので非常に簡単です。

# app/controllers/application_controller.rb
before_action :set_view_template_directoriesprivatedefset_view_template_directoriescase preferred_template # returns 'new' or 'old'when'new'self.lookup_context.directories = ['', 'old/']
  when'old'self.lookup_context.directories = ['old/', '']
  endend

新旧どちらのテンプレートを優先するかを指定するメソッドを用意し、その返り値によってテンプレート探索の優先順位を切り替えています。

プロジェクト初期には新しいテンプレートは存在しません。しかし、その都度例外を吐かれると開発しにくいので、新テンプレートが見つからない場合には旧テンプレートにフォールバックするようにしています。

実際の手順

これまでの説明ではわかりやすさのため順番を前後させてきましたが、実際の作業手順としては以下のような順番でした。

  1. テンプレート切り替えの仕組みを実装する
    • preferred_template = 'old'
    • この時点では旧テンプレートが app/viewsにある
    • old -> new のフォールバックによってアプリケーションは正常に動き続ける
  2. 旧テンプレートや関連リソースを移動する
    • 旧テンプレートが app/views/oldに移動
  3. 新テンプレートを実装していく
    • preferred_template = 'new'すると新テンプレートが優先的に render される
    • 新テンプレートが未実装のページは旧テンプレートにフォールバックする

いろいろな切り替え方

RAILS_ENV

一番わかりやすい切り替え方だと思います。

RAILS_ENV=developmentでは新しいテンプレートを優先し、RAILS_ENV=productionでは古いテンプレートを優先するなどができます。

Query String

URL に ?template=newなどを付加することによって、RAILS_ENVによるテンプレート指定を手軽に上書きする手段を提供します。production での確認などに利用していました。

Session

query string によるテンプレート指定は手軽で便利でしたが、ページ遷移を伴う場合に不便でした。そこで、プロジェクト後半にはページを遷移してもテンプレート指定が保たれるように session を用いたテンプレート指定も使用していました。 *3

外部データストアから設定を読み込み

全面リニューアルを実際にリリースする直前になると、リリース時に万一事故が起こったときの切り戻しを考えるようになりました。

しかし、我々のアプリケーションではデプロイやロールバックのために数分程度かかってしまいます。 つまり、リリース後にページが見れなくなるなどの問題が起こってしまった場合には数分間にわたってユーザーに迷惑がかかってしまいます。

そこで、デプロイなしでリリースするために外部のデータストアから指定するテンプレートを読み込めるようにしました。 Redis や memchached などの書き換えが容易なデータストアにテンプレート指定を保存し、リクエストごとに読み込むようにすることによって切り戻しにかかる時間を数秒程度まで短くすることができます。

パフォーマンスなど注意すべき点はありますが、比較的小規模なアプリケーションであることやリリース前後のみ使用するということを考慮して採用しました。

組み合わせると

それぞれのテンプレート指定方法を組み合わせて以下のような形で運用していました。

defpreferred_template# preferred_template_by_* は 'new', 'old', nil のいずれかを返す# 優先順位を query, session, data store, env の順に設定
  preferred_template_by_query || preferred_template_by_session || preferred_template_by_configuration || preferred_template_by_env || 'old'end

応用:段階的リリース

リクエストごとにテンプレート指定を柔軟に変更できるようになると「スタッフのアカウントに対して一足先に新デザインをリリースする」「ユーザー ID の末尾2桁が10以下のユーザーに対してのみ新デザインをリリースする」といったようにリリース対象を少しずつ広げるというようなことも可能になります。

おわりに

規模が大きくなることを避けられないデザインの全面リニューアルをスムーズに行なうために使用したテンプレートの柔軟な切り替え方法を紹介しました。

この方法を用いた結果、開発者以外のメンバーも含めて早い段階から production で新しいデザインを確認することができ、バグの早期発見に繋げることができました。 また、リリースまでに新しいデザインを触る時間を十分取れたため、リリース規模のわりには安心してリリースすることができましたし、事実としてもリリース後に大きな不具合は起こりませんでした。

ここまで触れませんでしたが、画面に関連しているものとしてテストがあります。しかし、古いテストを隔離してテストの中で指定するテンプレートを切り替えるという方針は同じです。

リリース後しばらくして問題なく動いていることを確認できたら、テンプレートを切り替えの仕組みを削除して新しいデザインだけを使用するようにした上で、はじめにつくった /oldディレクトリを rm -rfしてリニューアル完了です。

影響範囲が大きいリニューアルをすることはあまり多くはないと思いますが、もし同じような状況に置かれている方の参考になれば幸いです。

*1:我々のアプリケーションでは kaminari, letter_opener_webなどが含まれていました。

*2:デフォルトパターンは https://github.com/rails/rails/blob/6a902d43c76a8b5bc2ddd00b7c8af38f9fb82bdb/actionview/lib/action_view/template/resolver.rb#L209で定義されています。

*3:切り替え方法とは別に session の書き換え方法を別途用意する必要があります

Ruby の NODE を GC から卒業させた

$
0
0

こんにちは、技術部のフルタイム Ruby コミッタの遠藤(@mametter)です。メリークリスマス。

本日 Ruby 2.5.0 がリリース予定です。いろいろな改善が含まれています。クックパッドからの主な貢献としては、「trace 命令の削除による高速化」「分岐・メソッドカバレッジの測定のサポート」などがあります。

ユーザから見える改善はいろいろと記事が出てくると思うので、この記事では、「抽象構文木のメモリ管理のリファクタリング」というあまりユーザから見えない改善を紹介してみます。

概要

Ruby のパーサは、NODE という内部的なオブジェクトで構成された抽象構文木を生成します。2.4 までの NODE は GC に管理される普通のオブジェクトでしたが、2.5 からは GC の外で管理するようになりました。これにより、3 つ嬉しいことがあります。

  • 大きなコードのパースが速くなりました。
  • NODE に詳細なコード位置情報(カラム情報)を載せることができました。
  • 将来的に NODE まわりのコードを整理できる準備ができました。

背景

NODE は、Ruby の抽象構文木のノードを表現する要素です。抽象構文木はソースコード文字列をプログラムで扱いやすい木構造として表現したものです。知らない人は拙著『Ruby でつくる Ruby』などを読んでください :-)

2.4 の NODE は GC に管理されるオブジェクトとして実装されていました。Ruby のオブジェクトは GC の制約で 5 ワード長と決まっています。そのうち 2 ワードは NODE の種別や行番号を表現するために予約されていて、自由に使えるのは 3 ワードのみでした。イメージ的には、長さが 3 で固定されている配列みたいなものです。

この方法には 3 つの問題がありました。

  • パース中に無駄な GC が起きる
  • 抽象構文木に詳細なコード位置情報を載せる余地がない
  • 抽象構文木の表現にムリ・ムダがある

パース中に無駄な GC が起きる

大きなコードをパースする際に NODE オブジェクトが大量に生成され、GC が走ってしまいます。しかも生成中の NODE は回収できないので、この GC はほぼ完全に無駄です。この現象は、a = 1が大量に並ぶコードを実行すると観測できます。

図:eval("a = 1\n" * N)の実行時間(x 軸は行数 N、y 軸は時間)

a = 1が N 行並ぶコードの実行時間を、N を変えながら計測したものです。実行時間が非線形に増えていっているのがわかります。これはパース中に起きる GC のせいです。1 回の GC にかかる時間は O(N) 、GC が起きる回数は O(log N) なので、全体で O(N log N) の時間がかかります。

抽象構文木に詳細なコード位置情報を載せる余地がない

NODE には、その NODE に対応するコードの行番号だけが記録されていました。バックトレースの表示や行カバレッジの測定などでは、この情報だけで十分でした。

しかし、2.5 では分岐カバレッジをサポートすることになりました。分岐は同一の行の中で複数個現れることが普通にあるため、分岐カバレッジのレポートで「どの分岐の実行回数であるか」を示すために、カラム番号(行内で左から何番目か)が必要になりました。また、メソッドカバレッジのレポートでも、開始位置(def の位置)だけではなく終了位置(end の位置)もある方が便利でしょう。

しかし、大きさが限られている NODE には、これらの情報を載せるための場所がありませんでした。

抽象構文木の表現にムリ・ムダがある

3 ワード制限のために、Ruby の抽象構文木にはムリ・ムダが生じています。たとえば trueを表すノード NODE_TRUEは、子ノードを持たないので、3 ワードがまるまるムダになってます。逆に、obj.attr += valを表すノード NODE_OP_ASGN2は情報を 4 つ持つ *1ので、2 つの NODE をカスケードさせて無理やり表現しています。

おまけ:昔は GC 管理する意味があったが、今はもう意味がない

Ruby 1.8 のころは、抽象構文木をトラバースする方式でインタプリタが実装されていました。このような実装では、抽象構文木が不要になるタイミングが自明ではないので、GC 管理に任せたい気持ちも理解できます。

しかし Ruby 1.9 では YARV が導入され、YARV コンパイラが抽象構文木をバイトコードに変換したあとは、抽象構文木はもう使われません。つまり、パースから実行開始までのわずかな期間だけのために、少なくない NODE オブジェクトを作って捨てることになります。世代別 GC が導入されたから実害はあまりないですが、ムダはムダです。

やったこと

NODE を GC 管理されるオブジェクトとしてではなく、ただの malloc されたバッファの中に確保するようにしました。大量に NODE を作っても、malloc バッファが増えるだけで GC のオブジェクトバッファは圧迫されないので、無意味な GC 起動は基本的に起きません。

主に大変だったのは次の 3 つです。

NODE が NODE 以外のオブジェクトを指すケースの検出と対応

NODE の子どもは基本的に NODE ですが、一部の NODE は NODE 以外のオブジェクトを指すことがあります。たとえばリテラルを表す NODE_LITは、そのリテラルオブジェクト(文字列や配列など)を参照します。 NODE は GC 管理オブジェクトではないので、これらのオブジェクトはマークされません。そのままでは回収されてしまいます。そこで、NODE のバッファの他に、マークが必要なオブジェクトを管理する配列(mark_ary)を用意し、NODE が NODE 以外のオブジェクトを指すタイミングで mark_aryに追加するようにしました。*2

NODE を目的外使用しているコードの削除

NODE は抽象構文木のためのものなのに、「自動的に free される便利なデータ構造」として転用されてしまっていました。NODE_ALLOCA(自動的に free される一時的バッファ)と、NODE_HEREDOC(ヒアドキュメント関係のパーサの状態を管理するための一時的データ構造)で、いずれも抽象構文木の一部にはなりません。これらは imemoと言われる別種の内部オブジェクトに置き換えて対応しました。

Ripper 対応

Ripper は NODE が GC 管理オブジェクトであることを仮定して書かれているので、切り離しが大変でした。実は完全には切り離せておらず、NODE の先頭ワードはオブジェクトと同じ構造でないといけません *3。これはいずれなんとかしたいと思っています。

結果

この改善により、背景に上げた 3 つの問題が解決しました(または解決のめどが立ちました)。

パース中の無駄な GC がなくなった

大きなコードの eval が線形になりました。

図:eval("a = 1\n" * N)の実行時間(x 軸は行数 N、y 軸は時間)

グラフ的には圧倒的ですが、正直この改善が現実世界で生きてくることはあんまり期待できないと思っています。クックパッドの全ソースコードのパースで評価すると、2.67 秒が 2.60 秒になった程度でした。まあ、10,000,000 行のコードとか書きませんよね。コードを自動生成しているプロジェクトでは、ひょっとしたら役立つかもしれません。

笹田コメント:「おまけ:昔は GC 管理する意味があったが、今はもう意味がない」にあるように、Ruby 1.9 から NODE を GC 対象にする必要はないことはわかっており、ずっとやりたいと思ってペンディングしていたところ、遠藤さんが入社して一瞬で作ってくれました。ただ、当初は GC 回数が減るので、もっと性能インパクトがあるかと思ったんですが、現実的なコードでは影響がほとんどなく、意外でした。世代別 GC の性能が十分高い、ということだと思います。

NODE に範囲情報をもたせた

NODE が GC 管理から外れて自由に拡張できるようになりました。今までは各 NODE は開始行番号しか持っていませんでしたが、今は次の 4 つの情報を持っています。

  • 開始行番号
  • 開始カラム番号
  • 終端行番号
  • 終端カラム番号

分岐カバレッジ・メソッドカバレッジはこの情報にもとづいて測定結果を出力します。 また、カラム情報は他にも利用価値がありそうです。たとえば、NoMethodErrorが起きた箇所を行番号だけでなくカラム番号も出すことで、より詳細に位置を特定できるようにできるかも。

抽象構文木の表現のムリ・ムダを省いていける準備ができた

NODE が自由に使える領域は 3 ワードに限らなくなったので、今後はより柔軟に拡張できます。Ruby のソースコードのうち、評価器部分は YARV への置き換えで大きく整理されましたが、パーサ部分は未整理のまま拡張され続けてきていて、現在は Ruby の中でも最もわかりにくいソースコードの 1 つになっています。メンテナンスの観点でも、将来的に型システムを検討する土台としても、わかりやすくてメモリ効率的によいものになるように整理を進めたいと考えています。

まとめ

Ruby 2.5 NODE を GC 管理から外すことで、(1) パース時の無駄な GC を抑えた、(2) NODE の位置情報を詳細化した、(3) 抽象構文木の整理を進める土台を確立した、という改善を行いました。

謝辞:改良の方針や実装について弊社笹田とたくさん相談しました。また、bison を使って NODE の位置情報を実際に実装していくのは @yui-knkさんがやってくれました。ありがとうございます。

*1:レシーバ obj 、読み書きする attr 、演算子 + 、値 val 。

*2:mark_ary は YARV のコンパイラでも使われているテクニックです。

*3:RB_TYPE_P(obj, T_NODE) によって、NODE かそれ以外かを区別できないといけない。

Ruby 2.5 の改善を自慢したい

$
0
0

技術部でフルタイム Ruby コミッタをしている笹田です。最近ひさびさに Ruby のライブラリに pull request をしました(show valid break point lines #393)。

12/25 のクリスマスに、Ruby 2.5 が無事にリリースされました(Ruby 2.5.0 リリース)。関係各位の努力に感謝します。いろいろなバグ修正、いろいろな新機能、いろいろな性能改善があります(詳細は、上記リリースノート、もしくは Ruby のソースコードにある NEWS ファイルをご参照ください)ので、試して頂けると良いと思います。そういえば、私がクックパッドに入社して初めての Ruby リリースでした。

前回の techlife ブログ( Ruby の NODE を GC から卒業させた )で遠藤さんが

クックパッドからの主な貢献としては、「trace 命令の削除による高速化」「分岐・メソッドカバレッジの測定のサポート」などがあります。

と書いていました。

リリースノートより「trace 命令の削除による高速化」について引用します。

命令列中のすべての trace命令を削除することで、5~10% の高速化を実現しました。trace命令は TracePoint をサポートするために挿入されていましたが、ほとんどの場合、TracePoint は有効にされず、これらの命令は無用なオーバヘッドとなっていました。Ruby 2.5 では、trace 命令を用いる代わりに、動的書き換えを利用します。詳細は [Feature #14104]をご覧ください。

それから、同じくリリースノートに、ブロックパラメータに関する性能改善について書いてあります。

ブロックパラメータによるブロック渡し(例:def foo(&b); bar(&b); end)が、”Lazy Proc allocation” というテクニックを用いることで、Ruby 2.4 と比べて約3倍高速化しました。渡されたブロックを、さらに他のメソッドに渡したい場合、ブロックパラメータを利用する必要があります。しかし、ブロックパラメータは Proc オブジェクトの生成が必要であり、ブロック渡しのためにはこれが大きなオーバヘッドとなっていました。”Lazy Proc allocation” はこの問題を解決します。詳細は [Feature #14045]をご覧ください。

これらは私の仕事だったので、紹介文を書かせて頂きました。他と比べて長すぎますね。まぁ、迫力があっていいんじゃないでしょうか。具体的な数字があると、うれしいですしね。

本稿では、これらの機能をもう少し深掘りして、リリースノートやチケットでの議論では出てこない、普段、私がどんなことを考えながら開発しているのかをご紹介できればと思っています。また、これらの目立つ改善以外の、Ruby 2.5 のために私が行ってきた活動についてもご紹介します。

trace命令の除去と命令の動的書き換え

まずは、リリースノートに書いてある「trace命令の除去」についての話です。

何を実現したいのか

この新機能については、福岡Ruby会議02でのキーノート「Rubyにおけるトレース機構の刷新」でお話ししました。

www.slideshare.net

というか、このキーノートに間に合わせるために開発予定を調整しました(EDD: Event Driven Development)。

Ruby には TracePointという機能があります(リファレンス。古くは set_trace_func)。何かあると、例えば行を越えると何かフックを実行する、ということに使います。例えばこんな感じ。

trace = TracePoint.new(:line) do |tp|
  p tp
end

trace.enable do
  x = 1
  y = 2
  z = x + y
end

は、TracePoint#enableのブロック内で TracePoint:lineイベントごとに TracePoint#newに渡したブロックを実行します。そのため、出力は次のようなものになります。

#<TracePoint:line@t.rb:6>
#<TracePoint:line@t.rb:7>
#<TracePoint:line@t.rb:8>

この機能を実現するために、VM が実行する命令列(バイトコード)中に、trace命令というものを、フックを実行する可能性があるところに沢山挿入しています。一番多いのは :lineイベント用に、行が変わる度にこの trace命令が挿入されています。つまり、5 行のメソッドには 5 つの trace命令が入っています。

TracePointって知ってますか?」と聞くと、多くの Rubyist は「知らない」と答えると思います。つまり、あんまり使われない機能なのですが、使われないと、trace命令は単なるオーバヘッドにしかなりません。つまり、多くの場合、この命令は無駄なわけです。この無駄を排除するためのコンパイルオプション(Ruby のコンパイラはいくつかコンパイルオプションを受け取ります)を指定すれば、TracePointは動かなくなるけどちょっと速くなる、ということができたのですが、そもそもコンパイルオプションが指定できることを知っている人はごく少数ですよね。

なお、Ruby 2.4 以前を利用しなければならず、Ruby プログラムを 1% でも高速化したい、という方は、プログラムの最初に RubyVM::InstructionSequence.compile_option = {trace_instruction: false}と書いておけば、trace命令を利用しなくなります(が、TracePointが利用できなくなるため、例えば byebug といったデバッガが利用できなくなります)。

どうやって高速化するのか:trace命令を排除

そこで、TracePointのために trace命令を利用する、ということをやめました。代わりにどうするか。命令列の命令を書き換える、ということにしました。

実際の例を用いて説明します。

x=1
y=2
p x+y+3

このプログラムは、Ruby 2.4 では次のようにコンパイルされていました。

# Ruby 2.4
0000 trace            1     (   2)
0002 putobject        1
0004 setlocal         x, 0
0007 trace            1     (   3)
0009 putobject        2
0011 setlocal         y, 0
0014 trace            1     (   4)
0016 putself          
0017 getlocal         x, 0
0020 getlocal         y, 0
0023 send             :+
0027 putobject        3
0029 send             :+
0033 send             :p
0037 leave 

いくつか trace命令が入っていることがわかります。これを、Ruby 2.5 では、

# Ruby 2.5
0000 putobject      1       (   2)[Li]
0002 setlocal       x, 0
0005 putobject      2       (   3)[Li]
0007 setlocal       y, 0
0010 putself                (   4)[Li]
0011 getlocal       x, 0
0014 getlocal       y, 0
0017 send           :+
0021 putobject      3
0023 send           :+
0027 send           :p
0031 leave 

このように trace命令を排除した状態でコンパイルしておきます。trace命令がないので、なんとなく速そう、という気分が伝わるんじゃないかと思います。伝わるといいな。

さて、TracePointを利用した時です。有効にした瞬間、Ruby プロセス中に存在するすべての命令列を探し出して、必要な形に変換します。今回の場合、次のように変換されます。

# Ruby 2.5 / Trace on!
0000 trace_putobject 1    (   2)[Li]
0002 setlocal       x, 0
0005 trace_putobject 2    (   3)[Li]
0007 setlocal       y, 0
0010 trace_putself        (   4)[Li]
0011 getlocal       x, 0
0014 getlocal       y, 0
0017 send           :+
0021 putobject      3
0023 send           :+
0027 send           :p
0031 leave 

最初の putobjecttrace_putobjectに変わったのが見て取れると思います。普通の putobjectTracePointについて何もしませんが、trace_putobjectは、まず TracePointについての処理を行ってから、従来の putobjectの処理を行います。

この手法は、「TracePointをオンにするタイミングで命令書き換えが起こるので、それが大きなオーバヘッドになる」という問題がありますが、そもそも TracePointは使われないので、問題ないと判断しました。

検討した点、苦労したところ

この辺から、リリースノートに書いていない話になります。

なぜ今まで trace命令を使っていたのか?

見ての通り、難しい話はないので、もっと前からやっておけよ、と言われるかもしれませんが、YARV 開発から10年以上たって、やっと入りました。

以前は、命令の書き換えをしないほうが、別言語への変換(例えば、C 言語への変換)がやりやすいかな、と思っていたからなのですが、最近、「結局そういうことやってないしなぁ」と思ったり(すみません)、現在 JIT コンパイラの導入の話が進んでいますが、「書き換えるなら一度 JIT コンパイル済みコードをキャンセルすればいい」という踏ん切りがついたためです。なら、どうせなら派手に書き換えるようにしてしまえ、と思い、このようにしてみました。

書き換えるに当たり、trace_ prefix 命令ではなく、trace命令を動的に挿入する、という選択肢もありました(これが、最も互換性に優れた方法です)。ただ、命令を増やすと命令アドレスを変更しなければならず、若干面倒です。そのため、各命令の位置は変更しない、という選択をしました(そのため、プロトタイプは一晩で実現できました)。今後、もっとアグレッシブに命令書き換えを行うためには、命令位置変更にも対応しないといけないと思っています。

trace命令を沢山入れると、TracePointを有効にしない場合の速度劣化を気にしなければなりませんでしたが、これからはこのオーバヘッドが気にならなくなります。そのため、TracePoint向けのイベントを追加できると思っています。例えば、定数やインスタンス変数をトレースしたり、メソッドを呼び出す caller 側をフックしたりすることができると思っています。

trace_ prefix 命令をいつ戻すのか

TracePointを有効にしている間は trace_ prefix 命令が必要です。ですが、無効にしたタイミングで、TracePoint向けの処理が不要になります。そのため、初期実装では、TracePointがすべて不要になったタイミングで、同じようにすべての命令列を探し出して元に戻す処理を入れていました。これは、TracePointをパタパタと on/off 繰り返すようなプログラムはないだろうな、という予測に基づく設計でした。上記福岡Ruby会議02で紹介した時には、このような設計をしていました。off にするときのオーバヘッドを軽減するための工夫も盛り込んでいます。ただ、ある程度のオーバヘッドは、やはり必要になります(具体的には、ヒープ上からすべての命令列を探し出す部分)。

しかし、一部のライブラリ(具体的に問題としてあがってきたのは power-assert)では、TracePointの on/off が多く起こるため、問題になることがわかりました。そこで、結局一度 trace_ prefix 命令に変更すれば、その後 TracePointを無効にしても、そのままにしておくようにしました。TracePoint向けのチェックがついてしまい、trace命令があったときと同じように、若干遅くなるのですが、TracePointをちょっとだけ on にしてその後は利用しない、というシチュエーションは、あまりなさそうかな、と思い、最終的に「戻さない」とすることにしました。

非互換の対応

この変更にともない、バックトレースや TracePointなどで得られる行番号がずれる、という問題がありました。多少、変わっても、人間が見る分には問題ないだろう、と思っていたのですが、人間以外が見る、具体的にはデバッガ等で特定の行番号(例えば、endの位置の行番号)に依存した処理があったため、byebug という有名な Ruby 用デバッガで問題が起こる、ということがありました。

問題は修正できたのですが、この問題が発覚した(教えてもらった)のが 12/23 で、リリースの直前でした。久々にリリース直前にたくさんコーディングをして(例年は、リリース直前には怖くてコードをいじれません)、なんとか問題ないところまでもっていくことができました。本件でお世話になった関係各位に感謝いたします。

我々も、もっとちゃんと著名ライブラリはチェックしておかないとな、という反省をするとともに、RC1 リリースなどでちょっと試してもらえないかと読者の皆様にお願いするところです。

Lazy Proc allocation によるブロックパラメータを用いたブロック渡しの高速化

Lazy Proc allocation というテクニックを考えて、ブロックパラメータを用いたブロック渡しを約3倍高速化することができました。

何を実現したいのか

あるメソッドに渡されたブロックを、ほかのメソッドに渡したい、というシチュエーションが時々あると思います。

defblock_yieldyieldenddefblock_pass&b
  # do something
  block_yield(&b)
end

block_passのようなメソッドを書くと思いますが、このときブロックローカル変数 bでブロックを受け取り、その受け取ったブロックを block_yield(&b)のように渡すことで、このような受け渡しを実現することができます(なお、ブロックを渡す他の(素直な)方法はありません)。

とりあえず、これで一件落着なのですが、ブロックローカル変数を使うと、yieldするだけに比べて遅くなってしまう、という問題が生じます。というのも、ブロックローカル変数は Procオブジェクトを受け取るのですが、この Procオブジェクトの生成が重いためです。なぜ遅いかを大雑把に言うと、関連するローカル変数領域などをメソッドフレームをたどって芋ずる的にヒープに確保しなければならないためです(この理由をより深く理解するには、Rubyのしくみ Ruby Under a Microscopeなどをご参照ください)。

渡されたブロックという情報を他のメソッドに渡すために、ブロックパラメータを経由してしまうため、Procという、より冗長なデータを受け取ってしまい、遅い、という問題です。これを解決したい。

ポイントは、ブロックの情報だけだったら軽い、というところです。

どうやって高速化をするか:Lazy Proc creation

block_yield(&b)のようにブロックの情報を渡すだけなら、Procは必要ありません。なので、ブロックローカル変数が block_yield(&b)のように、他のメソッドにブロックを渡すだけであれば、Procを作らなくてもよいようにすれば速くなりそうです。本当に Procが必要になるまで Procオブジェクトの生成を遅延する、だから Lazy Proc creation と名付けています。まぁ、ある意味自明な機能なのですが、それでも名前を付けると、なんかカッコいいですよね。なお、並列分散処理の分野で "Lazy task creation"という技法があります。あまり関係ないですけど、カッコいい手法なので興味があれば調べてみてください。

さて、ここで問題になるのは、「Procが必要ないのか?」ということを知る必要があることです。

defsample1&b
  block_yield(&b)
end

このプログラムは、bProcにする必要はありません。ブロックの情報のまま、他のメソッドに渡してやればいいからです。

defsample2&b
  b
end

このプログラムは、bProcにする必要があります。呼び出し側が返値として Procオブジェクトを期待する(かもしれない)からです。

defsample3&b
  foo(b)
end

このプログラムも、bProcにする必要があります。fooを呼んだ先で Procオブジェクトを期待する(かもしれない)からです。

こう見ると、block_yield(&b)のようにしか使っていなければ、bはブロック情報のままで良さそうです。では、次の例はどうでしょうか。

defsample4&b
  get_b(binding)
end

一見すると、bは触っていないので、ブロック情報のままで良いような気がします。が、bindingオブジェクトを用いると、そのバインディングを生成した箇所のローカル変数にアクセスすることができるので、get_bの定義を、

defget_b bind
  bind.local_variable_get(:b)
end

のようにすると、bの中身をアクセスすることができます。この場合、bsample4の返値になるため、やはり Procオブジェクトにしなければなりません。bindingが出たら諦める、という方法もあるのですが、bindingはメソッドなので、任意の名前にエイリアスをつけることができます。つまり、どんなメソッド呼び出しも、bindingになる可能性があるのです。まぁ、ほぼそんなことは無いと思いますが。

どうやら、プログラムの字面を見て、「bProcオブジェクトにする必要は無い」と言い切るのは難しそうです(このようなことを調べることを、コンパイラの用語ではエスケープ解析ということがあります)。

そこで、実行時に bblock_yield(&b)のようなブロック渡し以外のアクセスがあったとき、初めて Procオブジェクトを生成するようにしました。

この高速化手法自体は長い間検討していたのですが、もう少し一般的なエスケープ解析が必要じゃないかと思って、それは Ruby では難しそうだな、どうしようかな、と考えていて実現できていませんでした。ただ、改めて考えてみると、ブロックパラメータへのアクセスを実行時に監視すればできることに、ふと自転車を乗っているときに気づいたので、実装することができました。

defiter_yieldyieldenddefiter_pass&b
  iter_yield(&b)
enddefiter_yield_bp&b
  yieldenddefiter_call&b
  b.call
endN = 10_000_000# 10Mrequire'benchmark'Benchmark.bmbm(10){|x|
  x.report("yield"){
    N.times{
      iter_yield{}
    }
  }
  x.report("yield_bp"){
    N.times{
      iter_yield_bp{}
    }
  }
  x.report("yield_pass"){
    N.times{
      iter_pass{}
    }
  }
  x.report("send_pass"){
    N.times{
      send(:iter_pass){}
    }
  }
  x.report("call"){
    N.times{
      iter_call{}
    }
  }
}

__END__ruby 2.5.0dev (2017-10-24 trunk 60392) [x86_64-linux]                 user     system      total        realyield        0.634891   0.000000   0.634891 (  0.634518)yield_bp     2.770929   0.000008   2.770937 (  2.769743)yield_pass   3.047114   0.000000   3.047114 (  3.046895)send_pass    3.322597   0.000002   3.322599 (  3.323657)call         3.144668   0.000000   3.144668 (  3.143812)modified                 user     system      total        realyield        0.582620   0.000000   0.582620 (  0.582526)yield_bp     0.731068   0.000000   0.731068 (  0.730315)yield_pass   0.926866   0.000000   0.926866 (  0.926902)send_pass    1.110110   0.000000   1.110110 (  1.109579)call         2.891364   0.000000   2.891364 (  2.890716)

ベンチマーク結果を見ると、ブロック渡しをしているケースでは、修正前と後で3倍程度性能向上していることがわかります。

なぜ block.callは速くならないのか?

defblock_call&b
  b.call
  # b.call と同じことをやるように見える yield なら速い。end

このようなプログラムがあったとき、bがブロック情報のままでも yield相当の処理に変換してしまえば、Procオブジェクトを生成せずに済みそうな気がします。が、Proc#callyieldには違いがあり、単純に yieldに変換することはできません。

さて、何が違うかというと、$SAFEの設定、待避を行う、という機能です。yieldでは $SAFEについて特に何もしませんが、Proc#callでは、$SAFEProcオブジェクト生成時のものに設定し、呼び出しから戻すときに、呼び出し時の $SAFEに戻します。つまり、Proc呼び出しの中で $SAFEを変更しても、呼び出しが終われば元通り、ということです。この違いがなければ、単純な yieldに変換することは容易なのですが...。

ところで、$SAFEってそもそもご存じですかね? 知らない方もいらっしゃるかと思いますが、これからも知らないでもあまり困らないのではないでしょうか。外部からの入力を用いて systemメソッドなどで外部コマンドを呼ぶ、といった危険な機能を検知するかどうかを決めるための機能ですが、現在ではあまり利用するということは聞きません(危険なことができないようにするには、もっと OS レベルのセキュリティ機能を使うことを検討してください)。

そういうわけで、「あまり使って無さそうだから、$SAFEなくしませんか? 性能向上も阻害するし」、といったことを Ruby 開発者会議という毎月行っている Ruby コミッタの集まりで聞いてみたところ、まつもとゆきひろさんから、「$SAFEをなくすのはどうかと思うが、Procオブジェクトで $SAFEの復帰・待避はしなくていいよ」という言質を取ったので([Feature #14250])、Ruby 2.6 では b.callのようにブロックパラメータを呼び出す速度が向上するのではないかと思います。だいたい、上記マイクロベンチマークの処理では、callyieldと同じくらいの速度になるんじゃないかと思います。実際、Ruby コミッタ(パッチモンスター)の中田さん実験ではそれくらいの速度が出ているようです。

その他の貢献の話

さて、実はここからが本番なのです。が、もう長々と書いてしまったので、短くまとめます。

上記二つの性能向上は、良い数字が出ているので目立つのですが、実はあんまり苦労していません。だいたい数日で実現できてしまっています(その後、安定させるために、もう少し時間がかかっているんですが)。では、それ以外は何をしていたのでしょうか。

クックパッドに入って、Ruby のテスト環境を新たに整備しました([ruby-core:81043] rapid CI service for MRI trunk)。いわゆる普通のテストを定期的に行う CI は rubyciというものがあるのですが、結果が出るまで時間がかかる、通知がないなど不満がありました。そこで、最短で2分で結果が出る環境を整備することにしました。計算機はクラウド環境では無く、実機を利用しています。私が主催した東京Ruby会議11の予備費を用いて購入したマシンと、ある企業様から Ruby Association へ寄贈頂いたマシン、それからクックパッドで確保できたマシンを利用しています。マシン調達・運用にお世話になった/なっている皆様に深く感謝いたします。

テストは、コミットフックを用いるのではなく、とにかく何度も何度もテストを繰り返す、という方針をとっており、時々しか出ないタイミング問題などをあぶり出すことも挑戦することができました(普通は、同じテストを2度以上実行しても、結果は変わらないと思いますが、Ruby のテストですと、そうでもないことがあります)。実際、いくつかの問題を発見しています(多くはテストの不備でした)。また、結果を Slack に流して(普通ですね)、問題のあるコミットがあれば、すぐに気づくことができるようにしました。複数環境で実行しているため、たとえばビルドエラーが起こると Slack に数十の通知が一斉に飛んでくるので、とても焦るので直さないと、という気分になります。

それから、Ruby でコルーチンを実現するための Fiber周りの整理・改善を行いました(リファレンスマニュアル)。結果は Fiberの切り替えが数% 速くなる、というなんとも地味な結果になりました。詳細は Ruby会議2017 での私の発表をご参照ください。

www.slideshare.net

実は、今年の多くの開発時間が、この改善につぎ込まれています。というのも、Fiber という機能を私が作ったのが約10年前なのですが、その頃テキトーにしていたところを、全部見直して書き直す、という作業になったからです。「実行コンテキストの管理」という、バグも出やすいムズカシイ部分ですし、そもそも覚えていない。そういう点でも気合いと開発時間が必要でした。うまくいかなくて、何度も最初からやり直しました。

この修正は、一義的には Fiber のための改善なのですが、実は狙いは将来導入を検討している新しい並行・並列のための機能を入れるための基盤作りでした。将来のデザインのために、今のうちに改善を行ったということになります。今年この点を頑張ったおかげで、来年は挑戦しやすくなったなという感触を持っています。

また、今年最大の Ruby への貢献といえば、遠藤さんにクックパッドにジョインして頂き、フルタイム Ruby コミッタとして活躍して頂いたことじゃないかと思います。遠藤さんによる目覚ましい成果は、だいたい私の成果と言っても過言ではないかと思います(過言です)。

おわりに

本稿では、Ruby 2.5.0 に導入した性能向上の仕組みについて、詳しくご紹介しました。その際、どのようなことを考え、気をつけながら開発をしているかも書いてみました。ちょっとだけのつもりが、長くなってしまいました。Ruby 2.5.0 と Ruby 開発の魅力を少しでもお伝えできていれば幸いです。来年も Ruby 2.6 そして Ruby 3 の実現のためにがんばります。

もし、Ruby インタプリタの開発が面白そうだな、と思ったら、できる限りサポートしますのでお声かけください。今年8月に好評だったRuby Hack Challenge( Cookpad Ruby Hack Challenge 開催報告 )の2回目である Ruby Hack Challenge #2は1月末に英語メインで開催予定ですが(申し込み締め切りは過ぎてしまいました)、2月にも日本語メインで開催しようと思いますので、よろしければ参加をご検討ください。また、RHC もくもく会を、だいたい毎月どこかで開催していますので、そちらもチェック頂ければ幸いです。来月は 1/29 (月) に RHCもくもく会#4を開催予定です。

寒いのでお体に気をつけて、良いお年をお迎えください。

サーバーレスなバックアップシステムを AWS SAM を用いてシュッと構築する

$
0
0

こんにちは。昨晩のお夕飯は鮭のカレー風味ムニエル定食だったインフラ部 SRE グループの @mozamimyです。

今回は、SRE グループでの取り組みのひとつであるマルチクラウドバックアップを題材にして AWS SAM、CodePipeline (CodeBuild および CodeDeploy を含む) を用いたサーバーレスアプリケーションの構築、ビルドおよびデプロイについて書いていきたいと思います。また、1月に Lambda で Golang が利用可能になったこともあり、CodePipeline の進捗を Slack に投稿する Lambda function を Golang で作ってみたので、そちらもあわせて解説したいと思います。

今回取り扱うトピック

  • マルチクラウドバックアップの重要性
  • サーバーレスアプリケーションについて
  • AWS SAM (Serverless Application Model)
  • s3-multicloud-backup: Lambda を中心に実装するイベント駆動の S3 -> GCS へのバックアップシステム
  • codepipeline-notify: Golang で実装された CodePipeline の進捗を Slack に投稿するアプリケーション

マルチクラウドバックアップ #とは

クックパッドでは、オンプレミスから AWS に移行してから長きにわたって AWS を中心に利用しています。その一部として、レシピ画像やつくれぽ画像の配信のための tofu というシステムが稼働しており1、tofu のバックエンドストレージとして Amazon S3 (以降 S3 と略記) を利用しています。S3 バケットに格納された画像データは、それぞれが大切なユーザーの皆様の画像であり、データの可用性が非常に重視されます。心のこもったひとつひとつの画像を大切に守って配信すべきときに漏れなく配信できるようにする、それは我々 SRE の使命です。

クックパッドにとって、ユーザーの皆様からお預かりしたデータはサービスの要です。S3 は非常に高い可用性を持っているため、たとえば S3 の障害などからデータを守るためには十分と言えるかもしれません。しかし、サービスの要であるからには、たとえば AWS アカウントへのアクセスができなくなるような自体にも備える必要があるとチームでは考えています。そこで我々は Google Cloud Storage (以下 GCS と略記) をバックアップ先として採用し、GCS にバックアップデータを預けることにしました。

今回は、AWS の S3 から Google の GCS のように、クラウドサービスをまたいだバックアップのことをマルチクラウドバックアップと呼ぶことにします。そして、それを実現するためのイベント駆動 Lambda function を中心としたサーバーレスアプリケーションを、AWS SAM のもとで構築するためのノウハウを解説していきます。

サーバーレスアプリケーション

サーバーレスアプリケーションとは、サーバのプロビジョニングなしに2利用できるマネージドサービスを駆使して、何らかのサービスを提供するアプリケーションのことを指します。たとえば AWS では以下のようなコンポーネントを利用することができます。

  • Lambda: コンピューティング
  • API Gateway: リバースプロキシ
  • SNS (Simple Notification Service): pub/sub によるメッセージング
  • SQS (Simple Queue Service): キューによるメッセージング
  • S3: オブジェクトストレージ
  • DynamoDB: NoSQL データベース
  • CloudWatch: ロギングおよびモニタリング

これらのコンポーネントは AWS によって維持管理されており、高い可用性とスケーラビリティを持つとされています。たとえば、コンピューティングという意味では Lambda は EC2 に代わるものであり、Lambda を利用することで EC2 のように OS そのものの面倒を見るといった運用作業から解放され、実現したい機能に集中することができます。また、Lambda function が実行されている時間だけ課金するというモデルなので、EC2 のようにインスタンスが起動している限り課金され続けるコンポーネントよりも経済的に運用しやすいというメリットもあります3

ただし、これらの個別のコンポーネントを組み合わせて動作させるという性質上、アプリケーション全体として俯瞰したときにピタゴラ装置になることは避けられない上、デプロイ作業が非常に面倒なものになります。そのため、これらのコンポーネントを定義し、スマートにデプロイできる「何か」が必要になります。そして、その「何か」の一つとして AWS SAM (Serverless Application Model) が公式に提唱されており、実際に利用できる状態になっています。

AWS SAM (Serverless Application Model)

AWS SAM (以降 SAM と略記) とは、ひとことで言うならば「CloudFormation をベースとしたサーバーレスアプリケーションのリソースを作成、管理できる仕組み (モデル)」といったところです。詳しくは以下の公式リポジトリに置かれているドキュメントに目を通してみてください。

awslabs/serverless-application-model: AWS Serverless Application Model (AWS SAM) prescribes rules for expressing Serverless applications on AWS.

SAM テンプレート自体は CloudFormation テンプレートの特殊化に過ぎず、最終的にベーシックな CloudFormation テンプレートに展開されます。つまり、SAM を使ってサーバーレスアプリケーションをデプロイする場合、最終的に CloudFormation stack が生えて、Lambda function を中心とした各リソースが作られることになります。

ちなみに Lambda のデプロイツールの有名所として Apexといったツールも検討しましたが、公式のエコシステムに賭けるほうが将来性があるということや、Apex は Lambda function のデプロイのみに特化しているといった点が気になり、SAM を中心にしていこうという流れになっています。

aws-sam-local でいい感じにサーバーレスアプリケーションを開発する

SAM を利用することのもう一つのメリットとして、ローカルで開発するときにとても便利な aws-sam-local というツールを利用することができる、ということがあげられます。まだベータ版の位置付けとなっていますが、十分使えるという印象です。API Gateway をシミュレーションすることもでき、SAM に乗っかるならば必携のツールといえます。以降の解説でも aws-sam-local を利用するので、チュートリアルとして試してみたい場合は README を読んでインストールしておいてください。

awslabs/aws-sam-local: AWS SAM Local 🐿 is a CLI tool for local development and testing of Serverless applications

2018-02-07 現在、npm を用いたインストールが Recommended となっていますが、個人的には https://github.com/awslabs/aws-sam-local#build-from-sourceにあるように、go get でインストールすることをおすすめします。まだベータ版ということもあってか、エラーメッセージがやや不親切であったり、不具合と思われる挙動に出くわしたときにちょっと直して動かしてみることが簡単だからです。今回説明するマルチクラウドバックアップシステムを構築する際にも、SAM テンプレートの CodeUriに相対パスが含まれていると期待通りに動作しないという問題を発見し、それを修正するための PR を出しました。

Fix a glitch that a tamplate CodeUri has like ../ relative path by mozamimy · Pull Request #279 · awslabs/aws-sam-local

余談ですが、SAM のリスのキャラクターは SRE グループの中ではとっとこ SAM 太郎と呼ばれてたいへん愛されて(?)おります。New – AWS SAM Local (Beta) – Build and Test Serverless Applications Locally | AWS News Blogのブログ記事でも、

At it's core, SAM is a powerful open source specification built on AWS CloudFormation that makes it easy to keep your serverless infrastructure as code –and they have the cutest mascot.

とあり、公式にも (?) SAM 太郎推しであることがうかがえます。

f:id:mozamimy:20180202132607p:plain

s3-multicloud-backup: Lambda を中心に実装するイベント駆動の S3 -> GCS へのバックアップシステム

アーキテクチャ

以下に、今回構成する s3-multicloud-backup のアーキテクチャを図示します。

ここでは、ソースとなる S3 バケットの名前を source-s3-bucket とし、バックアップ先の GCS バケットの名前を dest-bucket とします。source-s3-bucket にオブジェクトがアップロードされると、以下の順に処理が進み、最終的に dest-bucket にオブジェクトがバックアップされます。

  1. オブジェクトがアップロードされたという通知が source-s3-bucket-notification SNS topic に飛ぶ。
  2. source-s3-bucket-notification SNS topic を subscribe する s3-multicloud-backup function が起動する。
  3. s3-multicloud-backup function は、SNS 経由で届いた通知を開封し、対象のオブジェクトを source-s3-bucket から取り出して dest-bucket にアップロードする。

ただし、s3-multicloud-backup function によるオブジェクトのダウンロードとアップロードは、何らかの理由で失敗することがあり、堅牢なバックアップシステムを構築するためには失敗した事実を適切にハンドリングする必要があります。ここで起こりうる失敗の原因としては、以下の 3 点が上げられます。

  • S3 もしくは GCS に障害が起きている。
  • function にバグにより例外が起こって処理が正常に完了しなかった。
  • Lambda の実行環境でメモリ不足やタイムアウトが起きた。

このうち、上から 2 つについては障害が回復してから手動でリトライしたりバグを修正するなどの対応が必要となりますが、これらは避けることができません。とはいえ S3 や GCS の可用性を考えると滅多に起こることはないでしょう。

ここで特に考慮すべきは 3 つめのパターンで、オブジェクトのファイルサイズと Lambda の実行環境として設定するメモリ量によっては、それなりに頻繁に起こりえます。だからといって大きめのメモリサイズを確保するとコストに直接響くため、なるべく確保するメモリ量は小さくしたいところです。そこで、ここでは DLQ を用いた多段呼び出しというパターンを用いることにします。

Lambda では非同期呼び出しの場合、適当な時間を置いて 2 回まで自動的に再試行され、それでもダメなら DLQ として指定した SNS topic もしくは SQS queue にメッセージが送られます4。この機能を利用し、1 段目の s3-multicloud-backup function の実行が失敗した場合、s3-multicloud-backup-retry SNS topic に通知を送り、2 段目に控える s3-multicloud-backup-retry function を起動するようにします。もちろん、2 段目の function には、1 段目よりも大きめのメモリサイズを確保するように設定します。

このような構成にすることで、設計としてはやや複雑になりますが、より低コストでバックアップシステムを運用することができます。

最終的に s3-multicloud-backup-retry による実行も失敗した場合、最終的に DLQ として指定されている s3-multicloud-backup-dlq SQS queue に情報が格納されます。CloudWatch Alarm を用いてこの SQS queue の NumberOfMessageSentを監視しておき、DLQ にメッセージが届いたら PagerDuty を経由して SRE が状況判断をして対応する、という流れになっています。

ソースコードと SAM テンプレート

アーキテクチャを理解したところで、ここから function のソースコードと SAM テンプレートについて説明します。ソースコードは https://github.com/mozamimy/s3-multicloud-backupに push してあるので、必要に応じてご活用ください。

ディレクトリツリーは以下のようになっています。

.
├── README.md
├── deploy
│   ├── buildspec
│   │   ├── production.yml
│   │   └── staging.yml
│   └── template
│       ├── production.yml
│       └── staging.yml
├── sample-event-dlq.json
├── sample-event-s3-via-sns.json
└── src
    ├── index.js
    └── package.json

ここでは、ビルドやデプロイに関連するファイルを deploy ディレクトリ以下にまとめ、処理本体が記述された index.js およびパッケージ情報である package.json を src ディレクトリ以下にまとめています。また、aws-sam-local を用いて手元で function を動かすために、サンプルのイベントを含んだ 2 つの JSON ファイルが置かれています。

index.js

'use strict';

const fs = require('fs');
const uuid = require('uuid');
const aws = require('aws-sdk');
const kms = new aws.KMS();
const s3 = new aws.S3();

const GCS_BUCKET = process.env.GCS_BUCKET;
const PROJECT_ID = process.env.PROJECT_ID;
const ENCRYPTED_GC_CREDENTIAL = process.env.GC_CREDENTIAL;
const SKIP_KEY_REGEX = process.env.SKIP_KEY_REGEX;
const RETRY_SNS_TOPIC_ARN = process.env.RETRY_SNS_TOPIC_ARN;
const GC_CREDENTIAL_FILE_PATH = '/tmp/gcp_cred.json';
const TMP_FILE_PATH = `/tmp/${uuid.v4()}`;

function replicateObject(s3ObjectKey, s3Bucket) {const kmsParamas = {
    CiphertextBlob: new Buffer(ENCRYPTED_GC_CREDENTIAL, 'base64'),
  };

  const s3Params = {
    Bucket: s3Bucket,
    Key: s3ObjectKey,
  };

  return Promise.all([
    kms.decrypt(kmsParamas).promise().then((data) => {const decrypted = data.Plaintext.toString('ascii');
      fs.writeFileSync(GC_CREDENTIAL_FILE_PATH, decrypted);
    }),
    s3.getObject(s3Params).promise(),
  ]).then((results) => {const s3Object = results[1];
    fs.writeFileSync(TMP_FILE_PATH, s3Object.Body);

    const storage = require('@google-cloud/storage')({
      projectId: PROJECT_ID,
      keyFilename: GC_CREDENTIAL_FILE_PATH,
    });

    const gcsBucket = storage.bucket(GCS_BUCKET);
    const gcsParams = {
      destination: s3ObjectKey,
      validation: 'crc32c',
      resumable: false,
    };

    return gcsBucket.upload(TMP_FILE_PATH, gcsParams);
  });
}function deleteTempFile() {if (fs.existsSync(TMP_FILE_PATH)) {
    fs.unlinkSync(TMP_FILE_PATH);
  }}

exports.handler = (event, context) => {const message = (event.Records[0].Sns.TopicArn == RETRY_SNS_TOPIC_ARN)
    ? JSON.parse(JSON.parse(event.Records[0].Sns.Message).Records[0].Sns.Message)
    : JSON.parse(event.Records[0].Sns.Message);

  const s3Bucket = message.Records[0].s3.bucket.name;
  const s3ObjectKey = message.Records[0].s3.object.key;

  if (!(SKIP_KEY_REGEX == null) && s3ObjectKey.match(SKIP_KEY_REGEX)) {const logMessage = `skipped: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
    console.log(logMessage);
    context.succeed(logMessage);
  }else{
    replicateObject(s3ObjectKey, s3Bucket).then((success) => {
      deleteTempFile();
      const logMessage = `replicated: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
      console.log(logMessage);
      context.succeed(logMessage);
    }).catch((err) => {
      deleteTempFile();
      const logMessage = `ERROR!: Bucket: ${s3Bucket}, Key: ${s3ObjectKey}`;
      console.log(logMessage);
      console.log(err);
      context.fail(logMessage);
    });
  }};

Lambda が呼び出されたとき、exports.handlerに格納された関数が実行されます。この関数では、SNS topic から受け取ったイベントを開封し、バックアップ元のバケットとオブジェクトのキーを取り出し、GCS バケットにアップロードします。SKIP_KEY_REGEX環境変数に設定された正規表現にマッチするキーに対してはバックアップを行いません。また、184 行目で 1 段目の実行なのか 2 段目の実行なのかを判定して、適切にイベントの開封処理を変えています。

replicateObject()がバックアップ処理の本体で、GCS にアクセスするための暗号化されたクレデンシャル (JSON) を環境変数 ENCRYPTED_GC_CREDENTIALから読み出し、KMS API を呼ぶことで復号します。そして、S3 バケットから対象のオブジェクトをダウンロードし、復号したクレデンシャルを用いて GCS バケットにアップロードします。クレデンシャルの暗号化や、この function につけるべき IAM role については後述します。

package.json

package.json に関しては特筆すべきところはないでしょう。

{"name": "s3-multicloud-backup",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {"test": "echo \"Error: no test specified\"&& exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {"@google-cloud/storage": ">=0.52.0",
    "aws-sdk": ">=2.44.0",
    "uuid": ">=3.0.1",
    "fast-crc32c": ">=1.0.4"
  }}

ちなみに、fast-crc32c を依存に含めることにより、@google-cloud/storage による CRC チェックが高速になるという利点があります。また、後述しますが @google-cloud/storage の依存にネイティブライブラリが含まれるため、Lambda 環境で実行するためには Linux 上で npm installを実行する必要があります。

buildspec

package.json の項でも説明しましたが、依存ライブラリにネイティブバイナリのビルドが必要になるため、ローカル環境で npm installしたときに作成される node_modules をそのまま Lambda 環境に置いてもきちんと動作する保証はありません。ここではその問題を解決するために CodeBuild を利用し生成された生成物を CodeDeploy でデプロイする、という構成にし、一連の処理を CodePipeline にまとめることにします。

以下はそのための buildspec の例です。

version:0.2phases:install:commands:- 'cd src && npm install'build:commands:- 'aws cloudformation package --template-file ../deploy/template/production.yml --s3-bucket sam-artifact.ap-northeast-1 --output-template-file template.package.yml'artifacts:files:- 'src/template.package.yml'

AWS CLI の cloudformation packageサブコマンドを使うことで、--template-fileに指定したテンプレートを読み込んで src ディレクトリ以下を zip ファイルにまとめ、--s3-bucketに指定した S3 バケットに生成物を自動的にアップロードしてくれます。また、テンプレートに含まれる CodeUriを生成物用の S3 バケットに書き換えたテンプレートを --output-template-fileに指定したファイルに出力します。--s3-bucketに指定するバケットは、各自の環境の合わせて変更してください。

SAM テンプレート

以下の YAML ファイルがデプロイの要となる SAM テンプレート です。上述したアーキテクチャ図と見比べて、どこがどの項目にあたるかをチェックするとよいでしょう。SAM の詳細な記法は以下の公式ドキュメントを参照してください。

serverless-application-model/2016-10-31.md at master · awslabs/serverless-application-model

また、SAM のベースは CloudFormation なので、CloudFormation に慣れておくとより理解が深まるでしょう。CloudFormation については以下のドキュメントが参考になります。

What is AWS CloudFormation? - AWS CloudFormation

AWSTemplateFormatVersion:'2010-09-09'Transform:'AWS::Serverless-2016-10-31'Description:'A serverless application that replicate all objects in S3 bucket to Google Cloud Storage.'Resources: # リトライ通知用 topicRetrySNSTopic:Type:'AWS::SNS::Topic'Properties:DisplayName:'s3mc-stg'TopicName:'s3-multicloud-backup-retry' # 2 段目でも処理が失敗したとき用の queueDLQ:Type:'AWS::SQS::Queue'Properties:QueueName:'s3-multicloud-backup-dlq'MessageRetentionPeriod:1209600 # 14 days # 1 段目の functionS3MulticloudBackupFunction:Type:'AWS::Serverless::Function'Properties:Handler:'index.handler'Runtime:'nodejs6.10'CodeUri:'../../src'FunctionName:'s3-multicloud-backup' # 事前に作成しておいた IAM role をここに指定するRole:'arn:aws:iam::(Account ID):role/LambdaS3MulticloudBackup'MemorySize:256Timeout:30Events:ObjectUploaded:Type:'SNS'Properties: # S3 バケットからきた通知を受け渡すための # source-s3-bucket-notification topic の ARN を指定するTopic:'arn:aws:sns:ap-northeast-1:(Account ID):source-s3-bucket-notification'DeadLetterQueue:Type:'SNS' # `!Ref` を利用して論理名 `RetrySNSTopic` から ARN を取り出して設定するTargetArn:!Ref RetrySNSTopic
      # Lambda を起動した環境における環境変数を設定できるEnvironment:Variables: # マッチするキーのオブジェクトはバックアップしないSKIP_KEY_REGEX:'^(_sandbox|test)' # バックアップ先の GCS バケット名を指定 # 環境に合わせて書き換えるGCS_BUCKET:'s3-multicloud-backup' # バックアップ先のプロジェクトを指定 # 環境に合わせて書き換えるPROJECT_ID:'foo-project' # リトライ用の SNS topic の ARN を指定 # `!Ref` は環境変数を設定するときにも使えるRETRY_SNS_TOPIC_ARN:!Ref RetrySNSTopic
          GC_CREDENTIAL:'暗号化したクレデンシャルをここに(後述)' # 2 段目の functionS3MulticloudBackupRetryFunction:Type:'AWS::Serverless::Function'Properties:Handler:'index.handler'Runtime:'nodejs6.10'CodeUri:'../../src'FunctionName:'s3-multicloud-backup-retry' # 事前に作成しておいた IAM role をここに指定するRole:'arn:aws:iam::(Account ID):role/LambdaS3MulticloudBackup' # MemorySize および Timeout は 1 段目より大きめにするMemorySize:2048Timeout:90Events:BackupFailed:Type:'SNS'Properties: # 1 段目の DLQ に指定したリトライ用 SNS トピックの ARNTopic:!Ref RetrySNSTopic
      DeadLetterQueue:Type:'SQS' # 2 段目でも失敗した場合は SQS queue に格納するので、`!GetAtt` を用いて論理名から ARN を取り出して設定 # SNS の場合は `!Ref` で ARN が返るが、SQS の場合は `!GetAtt` であることに注意TargetArn:!GetAtt DLQ.Arn
      Environment:Variables:SKIP_KEY_REGEX:'^(_sandbox|test)'GCS_BUCKET:'s3-multicloud-backup'PROJECT_ID:'foo-project'RETRY_SNS_TOPIC_ARN:!Ref RetrySNSTopic
          GC_CREDENTIAL:'暗号化したクレデンシャルをここに(後述)'

SAM では、基本的に Resourcesに各種リソースを記述していくことになります。Resourcesのキーは各リソースの論理名で、別の場所で !Ref!GetAttでリソースの ARN や名前を取得することが可能です。

ここでは、リトライを通知する s3-multicloud-backup-retry SNS topic および DLQ である s3-multicloud-backup-dlq SQS queue のみを SAM テンプレートに定義します。Lambda function にアタッチする IAM role や、S3 バケットからの通知を受ける source-s3-bucket-notification SNS topic は SAM テンプレートの中に定義しません。

これは、source-s3-bucket-notification は s3-multicloud-backup 以外の用途でも利用されるかもしれないということと、クックパッドでは IAM を codenize ツールである codenize-tools/miam: Miam is a tool to manage IAM. It defines the state of IAM using DSL, and updates IAM according to DSL.を用いて GitHub flow に則ってレビューできるように中央集権的に管理しているからです。読者の皆さんの環境次第では、これらの設定も SAM テンプレートに含めてしまってもよいでしょう。

RetrySNSTopicおよび DLQは、基本的にデフォルトのパラメータを使うようにすれば十分でしょう。ただし、DLQでは MessageRetentionPeriod1209600に明示的に指定することにより、メッセージが最長の 14 日間キューに保持されるようにします。

S3MulticloudBackupFunctionおよび S3MulticloudBackupRetryFunctionは Lambda function で、それぞれ 1 段目、2 段目の Lambda function になります。MemorySizeTimeoutの値が違うことに注目してください。設定する値の詳細については、YAML 中のコメントを確認してください。

クレデンシャルを暗号化する

GCS バケットへのアクセスに利用するクレデンシャルをハードコードするわけにはいかないため、何らかの手段を用いて暗号化して環境変数にセットする必要があります。ここでは、AWS のキーマネジメントサービスである KMS (Key Management Service) を利用してクレデンシャルを暗号化および復号することにします。KMS の詳細については以下のドキュメントを参照してください。

AWS Key Management Service Documentation

まず、s3-multicloud-backup 専用の鍵を作成して、エイリアスおよび ARN をメモしておきます。そして、以下のような Ruby スクリプトを使ってクレデンシャル JSON ファイルの内容を暗号化します。暗号化の際には、作成した鍵を使える適当なアクセスキーを AWS_ACCESS_KEY_IDおよび AWS_SECRET_ACCESS_KEY環境変数にセットしてください。

https://github.com/mozamimy/toolbox/tree/master/ruby/easykms

#!/usr/bin/env rubyrequire'aws-sdk-kms'

key_id = ARGV[0]
kms_resp = Aws::KMS::Client.new.encrypt({
  key_id: key_id,
  plaintext: STDIN.read,
})

print Base64.strict_encode64(kms_resp.ciphertext_blob)
$ cat credential.json | ruby kms_encoder.rb [鍵の ARN]

以上の作業で暗号化され Base64 エンコードされた出力をテンプレート中の GC_CREDENTIALに書き込んでください。

Lambda function に付与する role

以下のような権限を持つ role を事前に作り、作った role の ARN を SAM テンプレートの Roleに指定するとよいでしょう。

{"Version": "2012-10-17",
  "Statement": [{"Effect": "Allow",
      "Action": ["kms:Decrypt"
      ],
      "Resource": ["暗号化用の KMS 鍵の ARN をここに"
      ]},
    {"Effect": "Allow",
      "Action": ["logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {"Effect": "Allow",
      "Action": ["sns:Publish"
      ],
      "Resource": "s3-multicloud-backup-retry SNS topic の ARN をここに"
    },
    {"Effect": "Allow",
      "Action": ["sqs:SendMessage"
      ],
      "Resource": "s3-multicloud-backup-dlq SQS queue の ARN をここに"
    },
    {"Effect": "Allow",
      "Action": ["s3:Get*"
      ],
      "Resource": "バックアップ元の S3 バケットの ARN をここに/*"
    }]}

Trust relationship には以下のように指定して Lambda function に付与する role であることを示します。

{"Version": "2012-10-17",
  "Statement": [{"Effect": "Allow",
      "Principal": {"Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }]}

aws-sam-local を使って開発する

SAM テンプレートを書いておけば、aws-sam-local を使ってローカルで Lambda function を実行することができます。その際、以下の点に注意してください。

  • 事前に Lambda 環境に近い状態で依存モジュールをビルドする
  • aws-sam-local コマンドを実行する際に、対象の S3 バケットを読み書きするためのクレデンシャルを AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYで与える

依存モジュールをビルドするには、aws-sam-local も内部で利用している lambci/lambda Docker imageを利用すると便利です。以下のようなコマンドで npm installしましょう。

$ docker run --rm -v "$PWD/src/":/var/task lambci/lambda:build-nodejs6.10 npm install

すると、Lambda 環境に近い状態でビルドされた node_modules が src/ ディレクトリに作られ、以下のようなコマンドで function を動かすことができます。

$ aws-sam-local local invoke S3MulticloudBackupFunction -e sample-event-s3-via-sns.json --template=deploy/template/staging.yml

このように、手元から動かしてみることも考慮して、テンプレートは staging (もしくは development) 用と production に分けておくとよいでしょう。

buildspec も同様で、開発用の AWS アカウントと本番用の AWS アカウントが分かれている場合など、CodeBuild によるビルドによる生成物をアップロードするための S3 バケットが異なる場合があるので、staging 用と production 用に設定が分かれているほうが何かと便利だと思います。

aws-sam-local の詳しい使い方を知りたい場合は GitHub ページの README を参照するとよいでしょう。

CodePipeline を使ってビルドとデプロイを行う

ここからは、CodePipeline を使って CodeCommit、CodeBuild および CodeDeploy を組み合わせて、SAM テンプレートで定義したアプリケーションのビルドおよびデプロイについて説明します。

それぞれのサービスの詳細については、以下のドキュメントを参照してください。

デプロイできるようになるまでの大まかな流れとしては、以下のようになります。

  • 各種サービスロールを用意する
    • 各サービスで利用する S3 バケットを 3 つ用意する
      • CodePipeline 用
      • CodeBuild 用
      • CloudFormation 用
  • CodeCommit にリポジトリを用意する
  • CodeBuild にプロジェクトを追加する
  • CodePipeline にパイプラインを作成する
    • ここで CodeCommit、CodeBuild プロジェクト、CodeDeploy を組み合わせる

各種サービスロールを用意する

まだ Code シリーズや CloudFormation を利用していない場合サービスロールがない状態なので、事前に作成する必要があります。CodePipeline などを利用する IAM ユーザが持ってない権限であっても、サービスロールに強い権限があると間接的に行使できることになるため、以下の例を参考になるべく権限を絞るほうがよいでしょう。

CodePipeline 用のサービスロール

以下に CodePipeline が利用するためのサービスロール CodePipelineServiceRoleの一例を示します。

{"Version": "2012-10-17",
  "Statement": [{"Effect": "Allow",
      "Action": ["cloudformation:CreateChangeSet",
        "cloudformation:CreateStack",
        "cloudformation:DeleteChangeSet",
        "cloudformation:DeleteStack",
        "cloudformation:DescribeChangeSet",
        "cloudformation:DescribeStacks",
        "cloudformation:ExecuteChangeSet",
        "cloudformation:SetStackPolicy",
        "cloudformation:UpdateStack",
        "cloudformation:ValidateTemplate"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["codebuild:BatchGetBuilds",
        "codebuild:StartBuild"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["codecommit:CancelUploadArchive",
        "codecommit:GetBranch",
        "codecommit:GetCommit",
        "codecommit:GetUploadArchiveStatus",
        "codecommit:UploadArchive"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["codedeploy:CreateDeployment",
        "codedeploy:GetApplicationRevision",
        "codedeploy:GetDeployment",
        "codedeploy:GetDeploymentConfig",
        "codedeploy:RegisterApplicationRevision"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["iam:PassRole"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["lambda:InvokeFunction",
        "lambda:ListFunctions"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["opsworks:CreateDeployment",
        "opsworks:DescribeApps",
        "opsworks:DescribeCommands",
        "opsworks:DescribeDeployments",
        "opsworks:DescribeInstances",
        "opsworks:DescribeStacks",
        "opsworks:UpdateApp",
        "opsworks:UpdateStack"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["s3:GetBucketVersioning",
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": ["[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]",
        "[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]/*",      ]
    }
  ]}
CodeBuild 用のサービスロール

以下に CodeBuild が利用するためのサービスロール CodeBuildServiceRoleの一例を示します。

{"Version": "2012-10-17",
  "Statement": [{"Effect": "Allow",
      "Action": ["codecommit:GitPull"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["ecr:BatchCheckLayerAvailability",
        "ecr:BatchGetImage",
        "ecr:GetAuthorizationToken",
        "ecr:GetDownloadUrlForLayer"
      ],
      "Resource": "*"
    },
    {"Effect": "Allow",
      "Action": ["logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:ap-northeast-1:(Account ID):log-group:/aws/codebuild/*"
    },
    {"Effect": "Allow",
      "Action": ["s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": ["[ここに CodePipeline が利用するための S3 バケットの ARN を入れる]/*",
        "[ここに CodeBuild が利用するための S3 バケットの ARN を入れる]/*",
        "[ここに CodeBuild によってビルドされた生成物を置くための S3 バケット (CloudFormation 用) の ARN を入れる]/*"
      ]}]}
CloudFormation 用のサービスロール

以下に CloudFormation が利用するためのサービスロール CloudFormationServiceRoleの一例を示します。

{"Version": "2012-10-17",
  "Statement": [{"Effect": "Allow",
      "Action": ["apigateway:*"
      ],
      "Resource": ["arn:aws:apigateway:ap-northeast-1::*"
      ]},
    {"Effect": "Allow",
      "Action": ["cloudformation:CreateChangeSet"
      ],
      "Resource": ["arn:aws:cloudformation:ap-northeast-1:aws:transform/Serverless-2016-10-31"
      ]},
    {"Effect": "Allow",
      "Action": ["events:*"
      ],
      "Resource": ["arn:aws:events:ap-northeast-1:(Account ID):rule/*"
      ]},
    {"Effect": "Allow",
      "Action": ["iam:PassRole"
      ],
      "Resource": ["*"
      ]},
    {"Effect": "Allow",
      "Action": ["lambda:*"
      ],
      "Resource": ["arn:aws:lambda:ap-northeast-1:(Account ID):function*"
      ]},
    {"Effect": "Allow",
      "Action": ["s3:GetBucketVersioning",
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:PutObject"
      ],
      "Resource": ["[ここに CodeBuild によってビルドされた生成物を置くための S3 バケット (CloudFormation 用) の ARN を入れる]/*"
      ]},
    {"Effect": "Allow",
      "Action": ["sns:ListSubscriptionsByTopic",
        "sns:Subscribe",
        "sns:Unsubscribe"
      ],
      "Resource": ["arn:aws:sns:ap-northeast-1:(Account ID):*"
      ]}]}

CodeCommit にリポジトリを用意する

CodeBuild では、コードのソースとして S3 バケットや CodeCommit、GitHub などをサポートしており、最近以下のブログポストにあるように、GitHub Enterprise 連携も登場しました。

Announcing AWS CodeBuild Support for GitHub Enterprise as a Source Type and Shallow Cloning | AWS DevOps Blog

ここでは、もっともお手軽な CodeCommit を利用することにし、CodeCommit に s3-multicloud-backup という名前でリポジトリを作成し、master ブランチをビルド・デプロイすることとします。

CodePipeline を構築する

SAM テンプレートで定義されたアプリケーションをデプロイするための最小のパイプラインは、以下のようなものになります。1 段目で CodeCommit からソースを取得し、2 段目で CodeBuild を用いて依存ライブラリのビルドおよび aws cloudformation packageを行い、3 段目で stack の change set を作成し、4 段目で stack の change set を適用します。

f:id:mozamimy:20180202132555p:plain

各ステップは以下のように設定します。

1 段目: CodeCommit

f:id:mozamimy:20180202132537p:plain

Source provider に AWS CodeCommit を指定し、Repository name と Branch name をいい感じに埋めてください。

2 段目: CodeBuild

まず、以下のような設定で CodeBuild のプロジェクト s3-multicloud-backupを作成しましょう。Node.js 用の image を利用し、Buildspec name に buildspec ファイルの位置を指定し、Service role に事前に用意しておいた CodeBuild 用のサービスロールを指定すれば OK です。

f:id:mozamimy:20180202132523p:plain

パイプラインには以下のように設定します。

f:id:mozamimy:20180202132532p:plain

3 段目: CodeDeploy と CloudFormation による stack の change set の作成

以下のスクリーンショットのように、Deployment provider に AWS CloudFormation を指定し、Action mode を Create or replace a change set を指定し、Role name には事前に用意しておいた CloudFormation 用のサービスロールを指定します。

f:id:mozamimy:20180202132548p:plain

4 段目: CodeDeploy と CloudFormation による stack の change set の適用

4 段目では、3 段目で作成した実行計画ともいえる change set を実行できるように、以下のスクリーンショットのように設定します。Action mode に Execute a change set を指定するのがミソです。

f:id:mozamimy:20180202132543p:plain

以上で CodePipeline の設定は完了です。Release change ボタンを押せばパイプラインの各ステップが実行され、最終的に SAM テンプレートに定義された各リソースが作成され、アプリケーションが利用可能な状態になるでしょう。

アラートの発行とモニタリング

クックパッドではアラート用の SNS topic が存在し、その topic にアラートの内容を送るとアラート用のメールアドレスにメールが飛び、最終的に PagerDuty で incident が発行されるという仕組みになっています。以下の例では DLQ 用の SQS の NumberOfMessageSentを CloudWatch Alarm で監視し、メッセージが発行されたことを検知するとアラート用の SNS topic に通知が飛ぶようになっています。アラートに関しては読者の皆さんの環境によって違うと思うので、それに合わせて設定してください。

f:id:mozamimy:20180207085432p:plain

CloudWatch では任意のメトリクスを組み合わせてダッシュボードを作ることができるので、以下のようなダッシュボードを作っておくと便利でしょう。

f:id:mozamimy:20180207085424p:plain

たまに数回エラーが発生していますが、ほとんどは Lambda の非同期呼び出しの自動リトライで成功していることがわかります。また、点になっていてやや見づらいですが、02/02 に一度だけ s3-multicloud-backup-retry function が実行されていることがわかります。

codepipeline-notify: Golang で実装された CodePipeline の進捗を Slack に投稿するアプリケーション

ここまで、SAM テンプレートで定義された s3-multicloud-backup というサーバーレスアプリケーションを CodePipeline を用いてデプロイする方法について述べてきました。

ここからは、1月に Lambda で Golang が利用可能になったということもあり、Golang で実装された Lambda function の一例として、CodePipeline の進捗を Slack に投稿するアプリケーションを SAM テンプレートで定義してデプロイする方法について書いていきます。従来 Lambda で公式にサポートされていた静的型付け言語は Java および C# のみであったことを考えると、手軽に書ける静的型付け言語ということで Golang は選択肢の一つとして有力なものとなるでしょう。

ソースコードと SAM テンプレート

ここからは Golang で実装された function のソースコードと SAM テンプレートについて説明します。ソースコードは https://github.com/mozamimy/codepipeline-notifyに push してあるので、必要に応じてご活用ください。

ディレクトリツリーは以下のようになります。

.
├── Makefile
├── README.md
├── deploy
│   ├── buildspec
│   │   ├── production.yml
│   │   └── staging.yml
│   └── template
│       ├── production.yml
│       └── staging.yml
├── handler
│   └── handler.go
├── main.go
└── sample-event.json

main.go

package main

import (
    "github.com/mozamimy/codepipeline-notify/handler""github.com/aws/aws-lambda-go/lambda"
)

func main() {
    lambda.Start(handler.HandleRequest)
}

ハンドラは handler.go に記述することにし、main では lambda.Start()にハンドラを渡して実行を待ち受ける形になります。

handler.go

package handler

import (
    "encoding/json""fmt""io/ioutil""net/http""net/url""os"
)

type CodePipelineEventDetail struct {
    Pipeline    string`json:"pipeline"`
    State       string`json:"state"`
    ExecutionID string`json:"execution-id"`
    Version     json.Number `json:"version"`
}

type CodePipelineEvent struct {
    Detail CodePipelineEventDetail `json:"detail"`
}

type SlackPayload struct {
    Text        string`json:"text"`
    Username    string`json:"username"`
    Icon_emoji  string`json:"icon_emoji"`
    Icon_url    string`json:"icon_url"`
    Channel     string`json:"channel"`
    Attachments []SlackAttachment `json:"attachments"`
}

type SlackAttachment struct {
    Color  string`json:"color"`
    Fields []SlackField `json:"fields"`
}

type SlackField struct {
    Title string`json:"title"`
    Value string`json:"value"`
    Short bool`json:"short"`
}

func HandleRequest(codePipelineEvent CodePipelineEvent) {
    field := SlackField{
        Value: fmt.Sprintf("The state of pipeline `%s` is changed to `%s` (execution_id: %s)", codePipelineEvent.Detail.Pipeline, codePipelineEvent.Detail.State, codePipelineEvent.Detail.ExecutionID),
        Short: false,
    }
    colorMap := map[string]string{
        "CANCELED":   "warning",
        "FAILED":     "danger",
        "RESUMED":    "warning",
        "STARTED":    "good",
        "SUCCEEDED":  "good",
        "SUPERSEDED": "warning",
    }
    attachment := SlackAttachment{
        Color:  colorMap[codePipelineEvent.Detail.State],
        Fields: []SlackField{field},
    }
    params, _ := json.Marshal(SlackPayload{
        Username:    "CodePipeline",
        Icon_emoji:  os.Getenv("SLACK_EMOJI_ICON"),
        Channel:     os.Getenv("SLACK_CHANNEL"),
        Attachments: []SlackAttachment{attachment},
    })

    resp, _ := http.PostForm(
        os.Getenv("SLACK_WEBHOOK_URL"),
        url.Values{
            "payload": {string(params)},
        },
    )

    body, _ := ioutil.ReadAll(resp.Body)
    defer resp.Body.Close()

    fmt.Printf(string(body))
}

CodePipelineEvent構造体がミソで、ハンドラとなる関数 HandleRequest()の引数に構造体を与えることで、自動的に JSON をパースして仮引数に渡してくれます。CloudWatch Event から受け取れるサンプルイベント (sample-event.json) を以下に示します。

{"version": "0",
  "id": "CWE-event-id",
  "detail-type": "CodePipeline Pipeline Execution State Change",
  "source": "aws.codepipeline",
  "account": "123456789012",
  "time": "2017-04-22T03:31:47Z",
  "region": "us-east-1",
  "resources": ["arn:aws:codepipeline:us-east-1:123456789012:pipeline:myPipeline"
  ],
  "detail": {"pipeline": "myPipeline",
    "version": 1,
    "state": "CANCELED",
    "execution-id": "01234567-0123-0123-0123-012345678901"
  }}

この仕組みは便利な半面、JSON と静的型付け言語の相性の悪さが浮き彫りになるところでもあり、デバッグがやや面倒になりがちな部分なので注意してください。ハンドラの仕様については以下のドキュメントや実装そのものが参考になります。ここでは、引数を取る場合でもっともシンプルなパターン func (TIn)で実装しています。

buildspec

buildspec は以下のように書くとよいでしょう。install フェーズでソースコードを GOPATH 以下に配置し、pre_build フェーズで go getし、build フェーズで go buildして得たバイナリを zip にまとめ、AWS CLI の cloudformation packageサブコマンドを利用してパッケージングします。このステップにより、--template-fileに指定したテンプレートを読み込んで src ディレクトリ以下を zip ファイルにまとめ、--s3-bucketに指定した S3 バケットに生成物を自動的にアップロードしてくれます。s3-multicloud-backup と同様、--s3-bucketに指定するバケットは、各自の環境の合わせて変更してください。

version:0.2env:variables:PACKAGE:'github.com/mozamimy/codepipeline-notify'phases:install:commands:- 'mkdir -p "/go/src/$(dirname ${PACKAGE})"'- 'ln -s "${CODEBUILD_SRC_DIR}" "/go/src/${PACKAGE}"'- 'env'pre_build:commands:- 'cd "/go/src/${PACKAGE}"'- 'go get ./...'build:commands:- 'go build -o main'- 'zip main.zip main'- 'aws cloudformation package --template-file deploy/template/production.yml --s3-bucket sam-artifact.ap-northeast-1 --output-template-file template.package.yml'artifacts:files:- 'template.package.yml'

SAM テンプレート

以下に SAM テンプレートの例を示します。今回はシンプルに Lambda function が 1 コしかないため、テンプレートの内容もシンプルです。SLACK_から始まる各環境変数は、お手持ちの環境に合わせて適宜書き換えてください。

AWSTemplateFormatVersion:'2010-09-09'Transform:'AWS::Serverless-2016-10-31'Description:'A serverless application to notify whether it succeeded or not.'Resources:CodePipelineNotify:Type:'AWS::Serverless::Function'Properties:CodeUri:'../../main.zip'Handler:'main'Runtime:'go1.x'FunctionName:'codepipeline-notify'MemorySize:128Timeout:8Events:CodeCommitStateChanged:Type:'CloudWatchEvent'Properties:Pattern:source:- 'aws.codepipeline'detail-type:- 'CodePipeline Pipeline Execution State Change'Environment:Variables:SLACK_WEBHOOK_URL:'Slack の incoming webhook URL をここに入れる'SLACK_CHANNEL:'#serverless'SLACK_EMOJI_ICON:':samtaro1:'

aws-sam-local を用いてローカルで function を動かす

今回は以下のような簡単な Makefile を用意したので、make コマンドを使ってビルドし aws-sam-local コマンドを使ってローカルで function を動かすことができます。s3-multicloud-backup の場合とは違い、Golang はクロスコンパイルが容易なため、手持ちの環境が Linux でない場合も Linux コンテナを使わずに GOOS=linuxとしてビルドすれば十分でしょう。

main: main.go handler/handler.go
    go build -o main
main.zip: main
    zip main.zip main
clean:
    rm -f main main.zip
$ GOOS=linux make main.zip
$ aws-sam-locallocal invoke CodePipelineNotify -e sample-event.json --template=deploy/template/production.yml
$ make clean

また、s3-multicloud-backup の場合と同様、CodePipeline を使ってビルドおよびデプロイのための仕組みを構築することができます。

まとめ

今回は、マルチクラウドバックアップを題材にして AWS SAM、CodePipeline を用いたサーバーレスアプリケーションの構築、ビルドおよびデプロイについて、チュートリアル的に解説しました。また、Golang で実装した CodePipeline の進捗を Slack に投稿するためのアプリケーションについても解説しました。

SAM を用いてサーバーレスアプリケーションの構成をテキストファイルに記述することで、いわゆる Infrastructure as Code の恩恵を受けることができ、加えて CodePipeline をはじめとする AWS の開発者ツールを利用することにより、ビルド及びデプロイの自動化が簡単になります。

今回は扱いませんでしたが、SAM と aws-sam-local の組み合わせによって API Gateway を用いた Web アプリケーションの開発やデプロイが容易になるといったメリットもあります。また、今回はテストコードを含めていませんが、buildspec にテストを起動するためのコマンドを含めれば、ビルドのパイプラインにテストを組み込むことも可能です。

他にも、codepipeline-notify の改良として、DynamoDB にパイプライン名と Slack チャンネルの対応を持たせておき、Slack channel の出し分けといったようなこともできるでしょう。また、AWS SDK や AWS CLI を用いて Slack などのチャットボットが CodePipeline を起動するようにすれば、サーバーレスアプリケーションに Chatops を持ち込むこともできます。

本記事がこれから Lambda を本格利用していこうとしている方や、すでに Lambda を利用しているが SAM などの管理手法を導入していない方の助けになれば幸いです。


  1. 料理を楽しくする画像配信システム

  2. 実際には DynamoDB のようにオートスケールを設定できるものの、プロビジョニングの概念が存在するコンポーネントはありますが…

  3. これはいかなる場合も当てはまるというわけではありません。たとえば、普段はほとんど利用されないが、定期的に実行がバーストするようなワークロードだと Lambda 型のほうが有利になります。

  4. Understanding Retry Behavior - AWS Lambda

Cookpad の新規事業と Firebase

$
0
0

国内事業開発部 iOS エンジニアの三浦です。私は17年新卒で入社したのですが、それ以来複数の新規事業の開発に携わってきました。 現在開発中のアプリでは、バックエンドに Firebase を用いた開発を進めています。 この記事ではなぜ Firebase を使っているのかと、そこで得られた知見についてまとめようと思います。

なぜ Firebase

みなさんご存知かと思いますが、Cookpad のレシピサービスでは主にバックエンドに AWS と Ruby on Rails が使われています。 なぜ新規事業ではその構成ではなく Firebase を使うのかということですが、以下のような理由があります。

基盤サービスが豊富

Firebase には RealtimeDatabase、FireStore といった Database を始めとして、CloudMessaging(Push通知基盤)、Authentication(認証基盤)といった開発のためのツールがあります。 これらの機能はサービス開発において大抵必要不可欠なものですが、サービスリリースまでの間ではメインの機能に時間を取られ、あまり時間を割くことができない部分になります。

これらの基盤部分が開発開始時から品質が担保された上で提供されていることで、本来のサービス開発に時間をかけることができ、開発スピード、アプリケーションの品質を高くすることができます。 また作っては壊しといったことを繰り返すリリース前の段階での開発においては、修正の範囲がクライアントのみで済むため非常にコストが低くアプリケーションの改修を行うことができます。

実際にどれくらい楽に実装できるか、簡単な iOS でのサンプルコードを載せます。

認証

ユーザーモデルを定義して Firebase に Facebook ログインするサンプルは以下のように書くことができます。 FireStore のモデルフレームワークとして Pring を利用し、モデルでは facebook のユーザーIDと名前をプロパティに持つとします。

// User modelimport Pring

classFirebase { }
extensionFirebase {
    classUser:Object {
        @objc dynamic varname:String?
        @objc dynamic varfacebookUserID:String?
    }
}

コントローラーから sign in するときにはこのような処理で実現できます。 実際は ViewModel や Helper を利用して処理を分割するのですが、サンプルなので1つのメソッドで処理を完結させています。

import UIKit
import Firebase
import FacebookLogin
import FacebookCore

classSignInViewController:UIViewController {
    privatefuncsignInWithFacebook() {
        letloginManager= LoginManager()
        // Facebook へログイン
        loginManager.logIn(readPermissions:[.email, .publicProfile, .userFriends], viewController:self) { result inswitch result {
            case .success(_, _, lettoken):// Facebook からユーザー情報を取得
                GraphRequest(graphPath:"me").start { (response, result) inswitch result {
                    case .success(letresponse):letuserID:String= response.dictionaryValue!["id"] as! String
                        letusername:String= response.dictionaryValue!["name"] as! String
                        letcredential= FacebookAuthProvider
                            .credential(withAccessToken:token.authenticationToken)
                        // Firebase への認証
                        Auth.auth().signIn(with:credential) { (user, error) inifleterror= error {
                                // error handlingreturn
                            }
                            letuser= Firebase.User()
                            user.name = username
                            user.facebookUserID = userID
                            // User モデルを FireStore に save
                            user.save { (_, error) inifleterror= error {
                                    // error handlingreturn
                                }
                                // 認証成功
                            }
                        }
                    case .failed:// error handlingbreak
                    }
                }
            case .cancelled:// Facebook へのログインがキャンセルされたbreakcase .failed:// Facebook へのログインが失敗したbreak
            }
        }
    }
}

電話番号や、Twitter、メールアドレスとパスワードによる認証に関しても同じような処理で実装をすることができます。

通知

次は Firebase Cloud Messaging を利用して Push 通知を受け取れるようにします。

User の Model に Token を管理できるようにようにプロパティを増やし、現在のログインユーザーを取得するメソッドを追加します。

// User modelimport Pring

classFirebase { }
extensionFirebase {
    classUser:Object {
        typealiasDeviceID= String
        typealiasToken= String

        @objc dynamic varname:String?
        @objc dynamic varfacebookUserID:String?
        // DeviceID をキー、FCMToken をバリューに持つ Dictionary@objc dynamic varfcmTokens:[DeviceID: Token]= [:]

        staticfunccurrent(_ completion:@escaping (Firebase.User?) ->Void) {
            guardletauthUser= Auth.auth().currentUser else {
                return completion(nil)
            }
            self.get(authUser.uid) { user, _ inguardletuser= user else {
                    completion(nil)
                    return
                }
                completion(user)
            }
        }
    }
}

あとは通知を登録したいところで Tsuchi(https://github.com/miup/Tsuchi) いう私の開発したライブラリを利用して Token を取得することができるので、 その Token をユーザーに DeviceID と共に保存します。

import Tsuchi

funcsaveFCMToken(_ toekn:String, completion: (()->Void)?) {
    Firebase.User.current { user inguardletuser= user else { return }
        user.fcmTokens[UIDevice.current.identifierForVendor!.uuidString] = token
        user.update { _ in completion?() }
    }
}

Tsuchi.shared.didRefreshRegistrationTokenActionBlock = { token in
    saveFCMToken(token)
}
Tsuchi.shared.register { granted inif!granted {
        // ユーザーが登録を拒否return
    }
}

通知を受け取ったときの処理に関しても Tsuchi を利用して以下のように書くことができます。

import Tsuchi

// payload の型
struct FCMNotificationPayload: PushNotificationPayload {
    var aps: APS?
    // custom payload data
}

// payload の型と通知受取時の処理を渡して push 通知を subscribe する
Tsuchi.shared.subscribe(FCMNotificationPayload.self) { result in
    switch result {
    case .success(let notification):
        let (payload, _) = notification
        print(payload)
    case .failure(let error):
        // error handling
        break
    }
}

// ログアウトなどで通知の受取を終了するとき
Tsuchi.unregister {
    Firebase.User.current { user in
        guard let user = user else { return }
        _ = user.fcmTokens.removeValueForKey(UIDevice.current.identifierForVendor!.uuidString)
        user.update()
    }
}

Growth もしっかりしている

リリース後に関しても Analytics や Crash Report が用意されていること、 ユーザーの行動を元に機械学習でユーザーのセグメント分けをしてくれる Prediction 機能、さらにそれを利用してABテストを行う事もできるなど、サービスのグロースに関する部分でもかなり強力なツールが揃っているため、将来的にも有用だろうということで技術選定をしました。

外部サービスとの連携

ここまで Firebase の利点についてお話してきましたが、当然 Firebase では用意されていないサービスも多くあります。

例えば現在開発しているサービスで必要なものだと、全文検索や決済機能などがあります。 それらの機能はすでに外部 SaaS が用意されているため、私たちは図のように Firebase の FaaS (Function as a Service) である CloudFunctions を利用してそれらのサービスとの連携を行っています。

f:id:MiuP:20180209102151p:plain

CloudFunctions では DB への変更をトリガーにして関数を発火することができるため、変更のあったオブジェクトから外部サービスへ渡すデータを構成し渡すだけの必要最低限の実装で外部サービスとの連携が行なえます。 実際にどのように連携を実装しているかという部分に関しては自分が Firebase.Yebisu #1での登壇でまとめてありますので、こちらの記事も一緒に読んでいただければ詳しい部分についても理解できると思います。

Firebase コミュニティへのコミット

Firebase をフルでバックエンドに置くサービスは世界的にもあまり多くはありません。そのため開発において、壁に当たることも多くあります。私達のチームではそれらの問題と解決法に関して外部にアウトプットしていくことを積極的に行っており、Firebase.Yebisuといったイベントや、サンプルコードでもいくつか登場しましたが、ライブラリを OSS として Github 上で公開するなど、コミュニティへの貢献も進めています。 実際に私達のチームのメンバーが開発した Firebase 関連のライブラリは以下です。

  • Salada (Realtime Database model framework)
  • Pring (Cloud Firestore model framework)
  • Tsuchi (Firebase Cloud Messaging helper)
  • Lobster (RemoteConfig helper)

もし Firebase を利用した開発をする場合は、よろしければ一度使用してみてください。

まとめ

AWS + Ruby on Rails の会社だと思われがちな Cookpad ですが、社内外向けを問わず新規アプリケーションではサービス毎に特徴を考慮し様々なフレームワーク、言語を用いた開発が行われています。 先程も述べましたが Firebase をフルで利用しているサービスは業界でもそれほど多くはないと思いますので、今後もいろいろな形で経過を報告していけたらと思います。

"体系的"に開発サイクルを回して "効果的"に学びを得るには

$
0
0

会員事業部エンジニアの新井( @SpicyCoffee66 )です。 Splatoon2 で各ルール S+1 以上になるため日々奮闘中のところに MHW が発売されました。 加えて最近ぷよぷよを始めたので、どう考えてもいろいろ計算が合わなくなってきました。

本日おこなわれた Cookpad TechConf 2018 では「クックパッドの "体系的"サービス開発」と題し、社内でどのような点に気をつけて開発サイクルが回されているかをお話しさせていただきました。 動画・発表資料は後日アップロードされる予定ですので、よろしければ合わせてご覧ください。 今回は、TechConf 2018 での発表内容から、BML ループの運用について、多少の補足や要約を交えながら書きたいと思います。

サービス開発は難しい

まず前提として、サービス開発は難しいです。 その難しさの大部分は、以下の2つの要因からきています。

  • 到達するべきサービスのゴールが明確でない
  • サービスの今いる地点が明確でない

到達するべきサービスのゴールが明確でない

サービス開発においては、ユーザーさんの持つ欲求や、抱えている課題を解決することがゴールとなります。 しかし、この欲求や課題は、ユーザーさん本人を含めて、誰にもわからないことがほとんどです。 また、仮に一度この欲求を捉えたとしても、その後時間とともに変化してしまうことが一般的です。

私たちが何もしなくても、ユーザーさんの使うデバイスはガラケーからスマートフォン、タブレットへと移り変わっていくでしょうし、 個々人のライフステージに関しても、独身だったユーザーさんが結婚し、子どもを持つようになるなど、多くの変化が起こり得ます。 このような状況のもとでは、ユーザーさんが抱えている欲求も、常に変化し続けると考えるほうが理にかなっています。

サービスの今いる地点が明確でない

得てしてサービス開発者は、自分のサービスを正しく理解できていないことがほとんどです。 サービスの価値はこうだ!コアとなる機能はこれだ!と信じていても、 実際にユーザーさんに使ってもらっているところを見てみると、想像と全く違う使われ方をしていた、なんてことは日常茶飯事でしょう。

学びのサイクル

前述したような状況の中でサービス開発に取り組むためには、自分たちの仮説をユーザーさんにぶつけ、その結果からフィードバックを得ることで新たな仮説を立てるという作業を繰り返す必要があります。 そうるすことで、目指すべきゴールや、サービスの今いる地点を確認しながら前に進んでいくわけです。 この「自分たちの仮説をユーザーさんにぶつけ、その結果からフィードバックを得ることで新たな仮説を立てる」という行為を、開発サイクルや学びのサイクルと呼んでいます。

BML ループ

学びのサイクルを実現するフレームワークに、BML ループと呼ばれるものがあります。 これは、リーン・スタートアップの中で提唱されているフレームワークで、以下の 3 フェーズから成ります。

  1. 仮説からプロダクトを作成する Build
  2. プロダクトをリリースしユーザーの利用状況を計測する Measure
  3. 得られたデータから知見を抽出し、新たな仮説を構築する Learn

f:id:spicycoffee:20180210144116p:plain

それぞれのフェーズの頭文字を取って BML ループと呼ばれているわけです。 このループを数多く回しながら、その都度学びを得ていくのがサービス開発では重要になってきます。

よくある失敗とその対策

しかしながら、サービス開発ではサイクルを回しながら学びを得るのが重要であるということがわかったところで、実際に BML ループを回そうとすると大抵の場合どこかのフェーズで失敗します。 具体的には、各フェーズで

  • Build
    • プロダクトが不必要に大きくなって実装に時間がかかる
    • 検証したい仮説と完成したプロダクトの機能が噛み合っていない
    • そもそも仮説に考慮漏れがある
  • Measure
    • いざ計測しようとするとログが埋まっていない
    • 複数の A/B テストが衝突して計測結果に影響が出る
    • 集計 SQL に間違いがあり、最悪の場合それに気がつかない
  • Learn
    • 出てきた数字をどう解釈すればいいかイマイチわからない
    • 数字は動いたがその原因がわからない、再現性が取れない
    • 得られた知見が属人的になる、あるいは闇に消える

といったような失敗がよく起こります。

f:id:spicycoffee:20180210144443p:plainf:id:spicycoffee:20180210144447p:plainf:id:spicycoffee:20180210144451p:plain

※ 巷にあふれる失敗例

このような失敗をなるべく減らすために、社内では 「最初に BML ループ全体を設計する」ということが意識されています。 そうすることで「手戻りの防止」や「効率的な学び」を実現することが可能になります。

最初にサイクル全体を設計する

手戻りの防止

BML ループを、Build が終わってから Measure、Measure が終わってから Learn といったように、逐次的に実行した場合、大きな手戻りに繋がる可能性があります。

たとえば、Build が終わって Measure のフェーズに入ったタイミングで、ログが取れてないなかったことが発覚した場合、もう一度 Build のフェーズに立ち返ることになります。 Learn のフェーズに入って知見を抽出しようとしたタイミングで、数字の解釈がよくわからないといった状況に陥ってしまうと、もはや手戻りをすることすら難しく、何の学びも得られないままサイクルを回し終えてしまうこともありえます。

しかし、よく考えると、前のフェーズが終わらなくても、次のフェーズで何をやるかについては考えることが可能です。 むしろ、BML ループ自体を一つのプロジェクトと考えると、各フェーズを前から順番に実行していくようなやり方よりも、 最初に全体を設計することが自然に思えてきます。 BML ループにおいて、最初に全体を設計するというのは、仮説が立った段階で、各フェーズで必要になりそうなことを明確にしておくことになります。 実際にサイクルを回しだす前に、各フェーズでの要件を明確にした上で、それに沿って Build・Measure・Learn と施策を進めていくことで、手戻りの原因となりうる事故を事前に察知し、それを防ぐことが可能になるのです。

効率的な学び

最初にサイクル全体を設計することは、効率的に学びを得るためにも必要なことです。 "学び"は定義しにくい概念ではありますが、その一つの重要な要素として、 サービスに対する理解と現実とのギャップがあげられます。

これをもう少し具体化すると、サービスに対する理解は、今のサービスに対して施策を打ったときの、施策結果に対する予想と考えることができます。 それに対応する現実は、実際に施策を打った結果です。 この2つを比較することで、思ったより結果が良かった/悪かったといった事実が出てきます。 その事実について「それはなぜか?」という点を考えることで、自分たちがサービスに対して抱いている理解のズレ・勘違いが明確になり、それが大きな学びになるのです。

f:id:spicycoffee:20180210144500p:plain

したがって、サービスに対する理解を事前に固めておくことは非常に重要なことになります。 今のサービスに施策を打ったとき、ユーザーさんはどのような体験をして、その結果どういう指標がどのくらい動きそうかということを事前に予想しておくことが大事なのです。 これはすなわち、事前に Measure や Learn のフェーズで出てくる結果について、考えを巡らせておくことになります。 こういった観点からも、実際にサイクルを回しだす前に、先のフェーズの設計をおこなっておくことが重要になってきます。

各フェーズの設計

この節では、BML ループの各フェーズについて、何を設計すればよいのか、つまりは、具体的にどのようなことを事前に決定しておけばよいのかについて書いていきます。

Build の設計

Build の設計では、以下のようなことを決定・確認しておきます。 とはいっても、このフェーズは仮説立案から距離が近いため、特に意識せずとも施策立案の段階で合わせてやっていることも多いでしょう。

  • 絶対に検証したい仮説の明確化
  • 検証背景の整理
  • 検証内容・注意点などの整理

Measure の設計

Measure の設計では以下の様なことを決定・確認しておきます。

  • 計測手法
    • A/B テストでいいのか?
    • A/B テストでいいとして、全ユーザーを対象にしてもいいのか?
  • KPI
    • 施策をどういう数字で評価するのか
    • 他に影響を与える指標はないか?
  • ログの確認、SQL の実行
    • 取りたいデータに必要なログは、現在集計されているか?
    • SQL を叩いた結果が概ね正しそうか?

特に KPI 周りの設計については注意が必要です。 指標というのはそれ単体で存在することは珍しく、大抵は相反する指標、影響を与え合う指標が同時に存在します。 これらの指標を見落としてしまうと、たとえば

TOP ページにプロトタイプとして会員登録の導線を置いたら、ある程度の会員登録が認められた
→ この施策を採用してプロダクトにリリースした
→ しばらく経ってみると、別のページ(検索結果ページ等)からの会員登録数が減っていた

といったような事故が発生します。 このような事例を防ぐために、予め関連する指標としてどのようなものがありそうかリストアップしておくことが必要です。

Learn の設計

Learn の設計では以下の様なことを決定・確認しておきます。

  • 指標の解釈
    • この数値が高くてこの数値が低いときは、ユーザーさんはどのような体験をしているのだろうか?
  • 結果の想定
    • 測定指標がどのくらいの数字になったら施策を採用するか
    • そこまではいかなくても、どのくらいの数字になったら再度議論するか

この2つの項目について事前に考えておくことを、社内では「成功のイメージを共有する」といったような言葉で表現することが多いです。 施策が成功したときに、ユーザーさんがどのような体験をして、その結果どういった指標がどの程度まで上がっているだろうか といったことを事前に想定しておきます。 そうすることで、結果から意味のある知見を抽出しやすくなりますし、効果の薄い施策を採用してしまう可能性も減らすことができます。

まとめ

上述したように開発サイクルを回していくやり方は、既に当たり前の方法となっています。 しかし、当たり前の方法だからこそ、自分たちの中で注意するポイントをしっかりと定めて運用することで、より大きな効果を期待することができます。 組織の特性や置かれている状況によって、注意するポイントは変わってくると思いますが、一つのベースとして、この記事がみなさんの参考になれば幸いです。

Alignment and Autonomyな組織づくり

$
0
0

はじめに

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

背景

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

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

課題感

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

チーム内での課題

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

チーム外での課題

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

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

個人の成長の課題

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

Spotifyモデルの適用

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

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

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

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

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

なぜSpotifyか?

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

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

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

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

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

Spotifyモデルの導入

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

ミッションの棚卸し

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

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

を再整理しました。

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

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

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

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

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

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

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

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

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

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

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

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

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

導入の結果

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

意思決定の速度向上

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

チーム間の協調

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

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

新たな課題感

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

数字レベルでの成果

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

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

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

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

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

まとめ

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

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

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


施策の質と職務能力を高めたい!ディレクター会の取り組み

$
0
0

こんにちは。サービス開発部 ディレクターの五味です。 Android版クックパッドアプリのリリースマネージャーと、アプリ利用者に関わるいくつかのプロジェクトを担当しています。今回は私たちの部で実施している、ディレクターの定例会について紹介します。

f:id:natsuki53:20170829223631p:plain

サービス開発部

クックパッドの開発体制は、2年前に私が ディレクター知見共有会についてのエントリー*1を書いた頃から少し変遷を経て、2017年からはサービス開発部が、レシピ検索・投稿などの基幹機能と、サービス全体のユーザー体験を一手に管轄するようになっています。

部のメンバーは現在40人ほどおり、部の注力指標からブレイクダウンしたKPIをベースに9つのプロジェクトチームに分かれています。チームの編成や人数は様々で、状況に合わせて入れ替わりもOK、KPI達成に向かっていれば、各チーム主体的に動くことが推奨される柔軟な組織を試みています。

プロジェクトチームで働く中で

このような体制の利点は、自分のチームのミッションに対して裁量を持って施策を考え取り組めることです。やりがいがある反面、以下のような悩みを感じるようになりました。

  • 部の目標に対するチーム横断での進捗度や、自分のチームの遅れが見えづらい
    • チームで決めた施策を進めるだけで、施策数や速度は本当に十分なの?
  • チームが自律的に動く反面、チーム間の情報連携や相互補完が難しい
    • 他のチームは目標をどう考えてどんな施策をしているのか、知りたいけど聞きづらい…
  • ディレクターとしての自分の成長がわからない
    • この職種に必要なスキルは何なのか、自分のパフォーマンスは足りているんだろうか?

ディレクター会の発足

これらの悩みを持ち掛けた方々から助言を得て、部のディレクターがチームを越えて集うディレクター会を始めることにしました。部内のディレクター職の他、ディレクター不在のチームからは同等の役割を担っている他職種の方にも声をかけます。

初回の開催で、会の目的とアジェンダを以下のように決めました。

  • 会の目的
    • サービス開発部でディレクターの役割を持つ人の情報・知見をチーム横断で共有する
  • 成功のイメージ(会の参加者に対して)
    • 担当施策について目標に対する成果を把握し、責任を持って報告できるようになる
    • 部内の施策の内容・効果を横断的に把握し、自分の提案に活かせる
    • 定期的に悩み相談や意見交換をする機会を得て、施策の精度が部署全体で上がる
    • ディレクターとしてのスキルアップに積極的に取り組めるようになる
  • アジェンダ(60分)
    • ① 実施した施策の共有 30分
    • ② 施策やチーム運営の相談 20分
    • ③ その他アナウンス、連絡事項 10分

意識したことは「先週これをやりました、今週これをやります」という業務進捗報告に時間を割かないことです。他のチームの施策の進捗を聞いても必要な情報や問題を見出すのは難しいことと、ディレクターなのでチームの進捗管理は各自できている前提にしたかったためです。

会議の時間は1時間、開催頻度は週1回と仮決めしてスタートしましたが、これは毎週ちょっとだけ時間が足りないくらいアジェンダがある状態が続けられているので、そのまま継続しています。

「実施施策の共有」について

ディレクター会のメインコンテンツにしている施策の共有について少し紹介します。

この会では、部で実施する施策をできるだけすべて議題にあげたいので、施策共有用に手間のかかる資料は作らないことにし、GitHub Issue に報告事項の箇条書きだけ準備する方式にしました。

ただし、箇条書きの項目はテンプレートで決まっており、報告には、仮説・試算・実数・考察・次のアクションの5項目が必要です。PDCAを回せるような設計がきちんとできていない施策はこの5つに埋められない項目が出てくるため、施策を考える人の自浄装置のような働きをしています。

例えばこのディレクター会をテンプレートに沿って報告しようとすると、下記のようになります。

# 施策名:サービス開発部のディレクター週例
- 仮説
  - ディレクターが定期的に施策情報を共有し意見交換できる場ができると、部全体の施策の精度とスピードが上がる
- 試算
  - 部の施策数が週5本(各チーム2週に1本)になる
  - 部の目標達成の進捗度が10%上がる
- 実数
  - 施策報告数:2〜3本/週
  - 部の目標達成進捗度:変化なし
- 考察
  - 定性意見より、会があることで施策/プロジェクトの成功への責任者意識は強まった
  - 他チームの成功・失敗事例やお互いの助言を担当案件に活かせる機会はできた
  - ただ、実際の施策のスピードやKPIの進捗に変化が起こるほどの成果には至っていない
- 次のアクション
  - アジェンダの見直し:参加メンバーに課題提起し、次の会で改善策を話す時間を取る

また直接この会に起因することではありませんが、最近サービス開発部では、施策結果のレポートをPull Requestで作ってチームでレビューする手法が採られ始めています。何かをリリースして完了ではなく、検証内容を振り返り次にどう進めるのかの判断にチームで取り組めることと、メンバーがレビューに入ることで、施策に対するチームの理解が揃う利点があります。

ディレクター会ではこれらの箇条書きやPull Requestを見ながら、施策共有に使える30分を週ごとの施策数で割って時間配分を決め、どんどん報告していってもらいます。報告を聞いている側の人は、気になる点や使える知見があれば自由に発言してもらい、特筆すべき意見は後で議事録に残して使ってもらいます。

ディレクター会の効果と課題

現在、この会を始めて2ヶ月ほどが経ったところです。前段の報告テンプレートの事例で少し前述していますが、現時点で良かったと感じている点は以下です。

  • 他チームの成功・失敗事例や、他のメンバーの助言など、自分の施策に活かせる第三者からの情報を得やすくなった
  • 週ごとに報告できる施策の数から、各チームの進捗スピードが推し測れるようになった
  • ディレクター:プロジェクトを成功に進める責任者という意識を合わせ、施策に取り組めるようになった

反面、まだ成果は定性的なものに止まっており、施策のスピードや部の目標達成の進捗に効果が表れるには至っていません。またディレクターのスキルアップのような長期の取り組みには手を出せていない状況です。

ちょうど先週これらを課題として改善策を相談し、次から以下の2つを変更してみる予定です。

  • 施策報告を、終了した施策だけでなく、これから実施する施策も対象にする
    • 結果だけだとチームが何を考えてその施策をしたのかわからない、終了施策にツッコミをもらっても「次頑張ります」としか言えないという意見から。施策の改善の余地に事前に気づいて検証の精度を上げられるように。
  • 進行中施策に直接紐づかない大きめのトピックも持ち込むようにする
    • 仮説定義や分析手法のノウハウなど、具体的な解がすぐ出せないから話題にしづらいが、各自悩みの深い相談を持ち掛けられるように。

このような取り組みを継続させるコツ

前回ディレクター知見共有会のエントリーを読んだ方から「うちはこういう会を始めても3回で自然消滅します…」という感想をいただいたので、大変僭越ですが、複数のメンバーを巻き込んで定常的な取り組みを行う際に意識していることを紹介させていただきます。

1. 参加者のコストを必要最小限にする

時間と手間を取りすぎないことを念頭に置いています。 今回であれば、会議が1時間を過ぎないよう時間配分することと、準備はGitHubのIssueにテンプレートに沿った箇条書きで済むようにしています。

2. 参加者がすぐ活かせる粒度の情報を入れる

「ディレクターに必要なスキルとは?」といった少し高い次元の議論だけでなく、明日から自分の業務に使える実用的な情報を得られる議題を含めることで、参加の利点を感じやすくします。 そのためディレクター会では実施施策の話題に時間を厚めに充てています。

3. “他人事” になっているメンバーを放置しない

取り組みが軌道に乗ってから1番気を配る点です。会議中ぼんやり聞いているだけの人が出てくるようになったら要注意です。敢えてその人に指名で意見を求めてみたりして反応を見ながら、会議の内容自体に原因がないか見直しを考えます。

最後に

ディレクターはエンジニアやデザイナーに比べて職務定義が難しいということをよく聞きます。また1つのプロジェクトに複数名でアサインされることは少なく、1人で複数のプロジェクトを掛け持ちすることは多いため、各自が抱える情報や知見を共有するには意識的な働きかけが必要だと感じます。

ただ、どんなプロジェクトでどのような働きをしているにせよ、ゴールに向かってチームを進めていく大事な役割を担っていることは確実だと考えます。

開発者がすごい!と言われるクックパッドですが、「ディレクターもすごいんです!」と言えるよう、今後も頑張っていきたいと思います。

そして、そんなチームに一緒に加わって頑張ってくださるメンバーを募集しておりますので、よろしくお願いいたします! https://info.cookpad.com/careers

*1:注: 「ディレクター知見共有会」はそのあと対象を広げ、今は参加者の職種は問わず様々な部署の体制や取り組みについて聞ける場として継続されています。

クックパッド サマーインターンシップ2017 「17day 技術インターンシップ」を開催しました

$
0
0

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

クックパッドでは毎年恒例となりつつある、クックパッドのサマーインターンシップのうち「17day 技術インターンシップ」を開催しました。インターンに来てくれた学生のみなさんは本当に優秀で、毎日真剣に取り組んでくれました。本当に感謝しています!

インターンは、前半の「サービス開発講義・課題」パートと後半の「サービス開発実践」から構成されています。前半パートでの講義について資料を公開いたしますので、みなさまぜひご覧ください。

f:id:takai_naoto:20170831080358j:plain


【1日目】サービス開発

初日は、クックパッドで実践されているサービス開発の手法について学ぶワークショップです。グループでのユーザーインタビューを通じてサービスの設計をしました。

【2日目】Rails・TDD・Git

昨年に引き続き、講義初日はGit、TDD、Railsを1日で一巡りするという、忙しい構成でした。

【3日目】モバイルアプリケーション

3日目は、 iOS と Android のふたつに分かれて、 Google 社の Firebase をつかった Cookpatodon というマイクロブログ風のアプリケーションを題材に学習をしました。アプリケーションの基本部分を実装したあとは各自で自由に機能を実装してもらい、最後に成果発表会という形で発表してもらいました。皆ユニークな機能を実装して大変盛り上がりました。

【4日目】インフラストラクチャー

Web アプリケーションのインフラについてAWSをつかいながら、Railsアプリケーション動作させるところから、パフォーマンスチューニング、スケールアウト、キャッシュなどのトピックについて触れています。

(資料は公開準備中です)

【5日目】SQL

Redshiftで構築されたデータウェアハウスをつかって、分析用のSQLを書いていました。クックパッドの実際のデータをつかったので、参加者たちは億単位のレコードがあるテーブルと格闘していました。

(内部データを利用した講習のため資料の公開はありません。どのようなものだったかを知りたい方はこちらまで。こちらの書籍でも概要を知ることができます)

【6日目】機械学習

機械学習は講義と実習のセットになっており、講義では「機械学習とは何か」という概観とディープラーニング(特にCNN)を学んだうえで、実習ではクックパッドのデータを使ったレシピ分類に取り組みました。最後は各々が興味をトピックを取り組んでもらってその成果を提出しました。

【7日目】Ruby

最終日のRubyの講義では、RubyでRubyのコンパイラを実装したり、その最適化を行ないました。


番外編

前半の講義が終わった懇親会では、先輩社員による就職活動の体験談LTなどが行なわれ、参加者のみなさんが楽しんでいました。

f:id:takai_naoto:20170831080459j:plain

その後の二次会も盛り上ったようです。

クックパッドと分散トレーシング

$
0
0

こんにちは、技術部の Taiki (@taiki45) です。

近年の Web サービスの開発ではマイクロサービスに代表されるように分散アーキテクチャが採用されるようになってきました。大規模でも素早いプロダクト開発をするために、クックパッドでもマイクロサービスを採用し分散アーキテクチャへの移行を進めています*1。今回は、そのような分散アーキテクチャを利用したシステム構築において必須のコンポーネントになりつつある分散トレーシングについて、クックパッドでの事例を紹介したいと思います。

分散トレーシングとは

マイクロサービスのような分散アーキテクチャでは、個々のサービス同士の通信が複雑になるため、モノリシックアーキテクチャと比較して、システム全体としての振る舞いを把握することが難しくなります。これはプロダクト開発においては、障害発生時の原因究明が難しくなったり、あるいはシステム全体でのパフォーマンスの分析が難しくなるといった問題として顕在化します。 分散トレーシングはこのような問題に対処するためのツールです。開発者が、特定のクライアントリクエストを処理するのに関わったサービスを探したり、レイテンシに関するパフォーマンスをデバッグする時に利用されます。

分散トレーシングの実現のアプローチには大きくわけて2種類あり、一つは Black-box schemes *2、もう一つが Annotation-based schemes と呼ばれています。 前者の Black-box schemes はシステム内の各サービスに手を入れる必要がないことが利点ですが、それと引き換えに特定のリクエストに対する分析はできません。後者の Annotation-based schemes は各サービスに分散トレーシング用のメタデータを下流サービス*3へと伝播させる実装を加えることが必要になるという欠点がありますが、特定のリクエストを分析することができます。Annotation-based schemes は Google の Dapper*4や Twitter の Zipkin*5等に採用されており、Web サービス業界では主流なようです。

Annotation-based schemes に基づく実装

Annotation-based schemes に基づいた分散トレーシングシステムの実装の仕組みを大まかに説明すると、ユーザーからリクエストをうける最初のポイントで “トレースID” という分散トレーシングシステム内で一意となる文字列を発行し、トレースIDや “アノテーション” と呼ばれる処理結果等の追加情報を含んだログをストレージに保存し、さらに下流に存在するサービスへリクエストを発行する際にトレースIDとアノテーションを伝播していきます。このような「トレースIDに紐付く一連のログのまとまり」を “トレース” と呼びます。このトレースをトレースIDをキーにしてストレージから検索することにより、特定のリクエストに関わったサービスを特定したり、また複数のトレース情報を集計することで分散システム内のコミュニケーションパターンを分析することができます。また、各サービスがリクエストの処理を開始/終了した時刻もトレースログに一緒に保存すれば、レイテンシの算出もできます。Google の Dapper や Twitter の Zipkin といった実装では、トレースログのタイムスタンプから各ログの親子関係を算出するのではなく、 各トレース内で一意となる文字列である “スパンID” をログの識別子として利用し、ログの親子関係をスパンIDで表現するようになっています。

ほとんどの分散トレーシングシステムは次のようなコンポーネントに分解できます:

f:id:aladhi:20170905163926p:plain

  • Instrumented library: 各サービスのアプリケーションに組み込み、トレースIDの採番や伝播やトレースログの送信を担うライブラリ
  • Log Collector: 各サービスインスタンスから送信されるトレースログの集約を担う
  • Storage and Query:トレースデータの保存と検索を担う
  • UI: 人間がトレースデータを検索・分析する際に利用する

Instrumented library は各言語向けに整備する必要があるので、各分散トレーシングシステム普及のボトルネックになっています。この問題を緩和すべく OpenTracing*6のように Instrumented library API の標準化を進めているプロジェクトもあります。

クックパッドでの導入

分散トレーシングシステムの選定

クックパッドで分散トレーシングを導入するに当たり、いくつかの点を考慮して AWS が提供するマネージドサービスである AWS X-Ray*7を採用しました。クックパッド内では一般的なユースケースを想定しているので、既存の分散トレーシング実装を利用することを決めました。分散トレーシングシステム実装として採用例も多く開発も活発な Zipkin に焦点を当てましたが、大規模な環境で Zipkin を利用するには Cassandra/HBase/Manhattan いずれかの運用が必要であり、データストアを自分たちで運用するよりは、解決したい問題にフォーカスできるマネージドサービスの利用に比較優位がありました。クックパッドでは AWS を積極的に活用するインフラストラクチャを構築していることもあり AWS X-Ray の検証を始めました。

検証開始時点では AWS X-Ray が提供する Instrumented library は Java/Node.js/Python のみのサポート*8で、クックパッドではほとんどのサービスは Ruby を用いて実装されているため、そのままでは AWS X-Ray は利用できませんでした。サードパーティ製のものも特に存在しなかったのですが、Instrumented library の実装方法については目処が立っていたこと、及び Instumentation library を自分たちで管理できることで他の分散トレーシングシステムへ低いコストで移行できる余地を残せる利点があったので、自作することにしました。自作した Instrumented library である aws-xray gem は OSS として公開しています*9

現状の構成

AWS X-Ray を利用したクックパッドでの分散トレーシングは以下のような構成で実現されています:

f:id:aladhi:20170905163959p:plain

  • Instrumented library: aws-xray gem を利用
  • Log Collector: AWS X-Ray の提供する X-Ray daemon というソフトウェア*10を利用
    • ECS を利用しているアプリケーションではいわゆる Sidecar 構成を取っています
    • EC2 インスタンス上で動作しているアプリケーションについては EC2 インスタンスの上に X-Ray daemon プロセスを動作させています
    • Instrumented library から UDP で X-Ray daemon にトレースログを送信し、X-Ray daemon がバッファリングと AWS の管理する API へのトレースログの送信を担います
  • Storage and Query: Storage はこちら側からは見えません。Query として AWS X-Ray の提供する API*11を利用します
  • UI: AWS コンソールに組み込まれています*12

aws-xray gem の実装において、トレードオフを考慮しつつモンキーパッチを活用することにより、ほとんどの Rails アプリケーションでは gem の導入と X-Ray daemon への接続情報を設定するのみで、トレースログの収集を開始できるようになっています。

今後の展望

現状はデータを AWS X-Ray に集めるところまでで、まだ本格的なトレースデータの活用には至っていません。データの収集については、社内の主要なサービスをカバーしており、サービスマップのノード数は現在約70ほどです。

f:id:aladhi:20170905164033p:plain

エラートラッカーなどに記録されているトレースIDから該当リクエストに関する分析ができるようになっています:

f:id:aladhi:20170905164117p:plain

サンプリング方式については Head-based coherent sampling*13を採用しており、ユーザーからリクエストを受ける最初のサービスで sampled/not sampled を決めて下流サービスに伝播させています。サンプリングレートについては、特に rps の高いサービスのみ1%設定、他のサービスについては100%設定で運用しています。サンプリングについては課題があり、ミッションクリティカルなサービス*14の処理を含むトレースはトラブルシューティング用途に全件保存しておきたいですが、流量の高いサービスが上流にいるケースではサンプルされるトレースの割合が少なく、トラブルシューティングを行うユースケースで支障があります。その対策として、パス毎によるサンプリング設定等を実装・導入する予定です*15

クックパッドでは Barbeque*16という非同期ジョブシステムを利用して非同期ジョブを実行しています。多くのジョブは Web アプリケーションのリクエストによりトリガーされているので、リクエストとジョブ実行との紐付けを記録できるようにする予定です。

また、システム全体のレイテンシ変化を検知できるように、AWS X-Ray の API を利用して監視システムを構築する予定です。監視システムについては自前で実装する以外にも AWS X-Ray の機能追加にも期待しています。

おわりに

AWS X-Ray を利用した分散トレーシングの実現について、クックパッドでの事例を紹介しました。クックパッドでは比較的大規模な Web サービス開発が行われており、分散アーキテクチャ周辺に存在する興味深い問題が多々あります。このような課題解決を一緒に取り組む仲間を積極的に募集しています

*1:http://techlife.cookpad.com/entry/2016/03/16/100043

*2:M. K. Aguilera, J. C. Mogul, J. L. Wiener, P. Reynolds, and A. Muthitacharoen. Performance Debugging for Dis- tributed Systems of Black Boxes. In Proceedings of the 19th ACM Symposium on Operating Systems Principles, December 2003.

*3:ここではユーザーからリクエストを受けるフロント側を “上流"、その反対側を "下流” と呼びます

*4:https://research.google.com/pubs/pub36356.html

*5:http://zipkin.io/

*6:http://opentracing.io/

*7:https://aws.amazon.com/xray/

*8:今では Go 言語向けのライブラリもサポートされました https://aws.amazon.com/jp/about-aws/whats-new/2017/08/aws-x-ray-sdk-for-go-beta/

*9:https://github.com/taiki45/aws-xray

*10:http://docs.aws.amazon.com/xray/latest/devguide/xray-daemon.html

*11:http://docs.aws.amazon.com/xray/latest/api/Welcome.html

*12:http://docs.aws.amazon.com/xray/latest/devguide/xray-console.html

*13:http://www.pdl.cmu.edu/PDL-FTP/SelfStar/CMU-PDL-14-102_abs.shtml

*14:例えば課金系サービス

*15:http://docs.aws.amazon.com/xray/latest/devguide/xray-sdk-java-configuration.html#xray-sdk-java-configuration-sampling

*16:https://github.com/cookpad/barbeque

データ分析からUI改善

$
0
0

こんにちは。サービス開発部デザイナーの平塚です。

クックパッドでは一部のデザイナーは日々の業務でSQLを書いて数値を見たり、リリースした施策の分析を行っています。 このエントリーでは機能をリリースしてデータ分析し、そこからUI改善を行った事例について紹介したいと思います。

なぜデザイナーがデータ分析?

サービスやプロダクトを改善するには現状について定性的・定量的の両方を理解しておく必要があります。
そのため、自分が進める施策やデザインするものを数値で把握しておくことで、より納得感を持って施策を進められます。
データ分析というと数学や分析の深い知識が必要そう…と構えてしまう印象ですが、日頃から自分の担当分野の基本的な数値を見ておくだけでもデザインで悩んだときの判断材料として使えるなど、デザイナーが数値をみる利点は多々あります。

分析の流れ

私は最近からデータ分析に取り組み始めたのですが、今はこのような流れで分析しています。

1.分析に必要な数値・グラフを決める

GitHubで分析用issueを立てて何を知りたいのか、そのためにはどんな数値をどんな形で見れると良いかを決めてから数値出しに進みます。

2.数値を出す

数値を出したらSQLが正しいかエンジニアにレビューしてもらいます。 レビューが通ったら数値から考えられることをチームで話しながら分析します。

3.分析レポートにまとめてGitHubのPull Request(以下PR)をだす

f:id:tsukasio:20170911145527p:plain

分析レポートを「仮説・試算・実数・考察・次のアクション」で整理してPRを出します。
レビューを受けることでさらに理解を深めるきっかけになったり、客観的な意見でアイディアをもらえたりします。
また、サービス開発部ではディレクター定例で施策の共有を行っていて、施策の分析結果はこのPRを共有しています。
ディレクター定例についてはこちらで詳しく説明されています。

つくれぽを簡単に送れる機能の分析

一部のユーザ向けに、よく見たレシピを利用したつくれぽを簡単に送れる機能を公開し、仮設通りの効果は得られたのか?どのように使われているのか?などを分析しました。

f:id:tsukasio:20170911145848p:plain

分析を進めていくうちに、アプリを起動してからつくれぽを送るまでの推移をファンネルグラフでみたところ、離脱ポイントが2つあることがわかりました。

  • 「投稿する」の分岐で離脱
  • レシピをフリックしてから離脱

f:id:tsukasio:20170911145750p:plain

まず「投稿する」の分岐での離脱はどんなユーザーが離脱しているのか調べました。結果はつくれぽを送ったことがないユーザーがほとんどで、興味本位で「投稿する」をタップしていた可能性がありそうです。
次に、つくれぽしようと思ってこの画面に来たのにフリックした後離脱してしまうのはなぜかを考えました。

  • つくれぽを送れるレシピがなかった
    • 実際には作っていなかった?
  • 料理画像がなかった
    • レシピをみて料理を作ったが料理画像を撮り忘れた?
  • つくれぽしようと思うレシピがわからなかった
    • 似たようなレシピが複数並んでいた?
    • レシピ名をきちんと覚えていなかった?

いくつか仮説を立てた中で、3つ目はUIで解決できそうだということになり改善を進めました。

UI改善

この機能のデザインをした時に考えたことは、新しい機能とはいえ、つくれぽを送るというアクションなのでユーザーが戸惑わないように既存のつくれぽ画面を参考にデザインしました。また、さくさく送れる感じを出したかったのでレシピをフリックして見れるようにしました。
ただ、既存のレシピ詳細画面からのつくれぽはすでにレシピを決めているので、つくれぽ画面ではレシピ名だけでもスムーズにつくれぽできていたという違いに気づきました。
この機能はつくれぽ送信画面に来てからつくれぽするレシピを探すので、どのレシピかがきちんと分かるUIが良いのではと思い、レシピ画像とレシピ作者名を入れたUIに変更しました。

beforeafter
f:id:tsukasio:20170911150004p:plainf:id:tsukasio:20170911150637p:plain

まとめ

現在は施策を企画する段階とリリース後の2つのタイミングで定量データを見ることを心がけています。
漠然とした「使いづらい」「分かりづらい」から改善を進めるのではなく、定量的なデータからその機能がどう使われているかを把握した上で仮説を立てると、より良い改善に繋がります。
ただ数値がすべてというわけではなく、定量データから見えない課題はユーザーテストで掘り下げるなど定量分析・定性調査をバランスよく見ていくと良いと思います。

料理きろくにおける料理/非料理判別モデルの詳細

$
0
0

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

クックパッドのアプリには「料理きろく」という機能があります。 携帯端末から料理画像のみを抽出して表示することで自分が食べたものを振り返れるようになっており、ここからレシピ投稿やつくれぽを送ることもできるようになっています。

20170914153231

料理きろくはユーザ数が約12万8千人、累積の写真判別枚数が約7900万枚、そのうち料理と判別された画像が約960万枚(数字は20170912時点)と多くの方々に使っていただいている機能です。 本記事では、その料理きろくのコアの技術部分である、機械学習による料理/非料理判別の詳細に関してお伝えします。 料理きろく全体のアーキテクチャに関してはここでは述べませんが、ご興味のある方は AWS Summit Tokyo での発表資料をご覧ください。

料理きろくの背景と機械学習の役割

我々は、新しい技術を駆使してユーザ体験をより良いものに改善することで、クックパッドの使用頻度を増やしてもらったり、ユーザからもっとレシピやつくれぽを投稿してもらう、などの目標を持っています。 料理きろくはその目標に資する一つの施策であり、これは携帯端末に大量の食事情報が記録されていることから、それを活用してクックパッドのサービスに連携することを狙いとしています。 過去の自分の食事を振り返ることで食への楽しみや関心を高めてもらい、そこからレシピやつくれぽ投稿につなげたいというのが最初のターゲットとなっています。

ちなみに料理きろくではユーザのプライベートな画像を判別することになるため、全てサーバ上で処理がなされ、我々はユーザの画像を一切閲覧できないようになっています。

料理きろくにおける機械学習の役割は、料理/非料理を判別する高性能のモデルを提供することです。 機械学習によって正確に画像の料理/非料理が判別できるかどうかがサービス全体の質に直結するため、ここに Convolutional Neural Network (CNN) を用いたモデルを適用しました。 一口に CNN と言っても実に多様なので、データも拡充しながら、様々なモデルを試行錯誤をして改善を図りました。

以降では本番環境にデプロイしたモデルを中心に、どのような試行錯誤で改善をしていったのかをご紹介します。

最初にデプロイしたモデル

  • モデル:CaffeNet
  • データ:{料理,非料理}の二値ラベルの画像データ
  • フレームワーク:Chainer

最初のリリースでは CaffeNetというモデルを使っています。 これは ILSVRC2012で優勝した AlexNetを少し変更したもので、pooling が Local Response Normalization の前に来ています。

最初の段階では、素早くリリースまで持っていきたい、社内に画像分析の知見がまだ蓄積していなかった*1、などの理由で、事例も多いこのモデルを採用しました。 データはシンプルに料理画像と非料理画像をとりあえず手当たり次第に集めたものを使用しました。 テストデータも集めた画像の一部をテスト用に切り出して、料理判別の精度と再現率をチェックするというごくごく基本的な手法でした。

本番運用に際しては、精度が低いと料理ではないものが表出されてユーザ体験が悪くなるという考えのもと、再現率をある程度犠牲にしても精度を高めるという方向で閾値を調整しました。 単純な二値判別では softmax の出力が0.5を超えれば料理と判別されることになりますが、このモデルでは閾値を0.9で設定しました。 これは閾値を変えながらテストデータでの結果を目視でチェックしながら定めたものです。

最初のモデルは手探りの部分も多かったですが、素早くリリースまで到達できたことは非常に良かった点で、継続的改善を遂行していける土台が整えることができたので後の改善につながりました。

一回目のモデルアップデート

  • モデル:Inception-v3
  • データ:{料理,非料理,複数の間違えやすい非料理カテゴリ}の多値ラベルの画像データ
  • フレームワーク:TensorFlow

最初にデプロイしたモデルでも結構精度が高かったのですが、自分たちでも使っていくうちに間違いやすい画像があることが分かってきました。 具体的には植物や赤ちゃんの画像などが間違いやすい傾向があることが判明したため、これらの画像にもロバストなモデルを作りたいという要望が出てきました。 また、CNN のモデルも様々な発展があるため、それらを検証してより基本的な性能が高いモデルを採用したいという考えもありました。

そこで、まずは様々な CNN のモデルを比較検証をして良いモデルを探すという実験をしました。 この頃には画像分析ができる人員も増えていたため、手分けをして実験をして GHE の wiki に情報を集約し、最終的に我々のデータセットに対して最も良い結果を出した Inception-v3を採用することにしました。 モデルとして試したのは、{Inception-v3, GoogLeNet, ResNet, VGG, GAN を使った classification, NIN(軽量なモデルにも興味がある), …}、です。

フレームワークに関しても、TensorFlow を使う人が多くなったため、Chainer から切り替えました。 モデルの学習には Keras with TensorFlow backend を使っていますが、本番にデプロイする時は TensorFlow のみで動かすようにしています。 料理きろくのアーキテクチャはモデル部分を疎結合にしてあるので、この変更はそれほど大きなコストもなく実現できました。

次に単純な二値判別では問題として単純化しすぎているのではという考えのもと、多値判別に切り替えるという実験をしてみました。 思考回路としては、仮に世の中の料理画像の全集合が手に入ればその補集合が非料理画像だがそれは不可能→モデルは我々が集めた(非)料理画像の集合から(非)料理らしさを学習→これらは多様なので一つのカテゴリに集約し切るのは無理がありそう→特に間違いやすいものに関しては陽にカテゴリを作ってそちらに誘導したほうが我々が望むモデルができそう、という感じです。 料理/非料理ともに画像(二値ではなく多値のラベルを付与したもの)を追加収集して、それぞれが単一カテゴリ(この場合は多値の情報を潰して二値として扱う)の場合と複数カテゴリの場合とで性能を比較しました。 モデルの出力は一般に多値になりますが、判別結果としては多値の情報を潰して料理/非料理の二値判別として扱うようにしています。 全体的な性能を上げつつ特に精度を高めるものとして、料理は単一カテゴリとして非料理は多値カテゴリ(具体的には植物や人物を含む5カテゴリ)として扱うことに決定しました。

これらの改善によって、手元のデータで試験したところ、精度も再現率も向上し、特に間違えやすかった植物の画像に対しては間違いが約 1/3 に、赤ちゃんの画像に対しては間違いが約 1/20 ほどになりました。

これの取り組みは以前クックパッドで開催された Cookpad Tech Kitchenでも発表しています(発表資料)。

また、2017年度 人工知能学会全国大会やIJCAIのワークショップとして開催された 9th Workshop on Multimediafor Cooking and Eating Activitiesなどの学術的な場でも発表をしています。

二回目のモデルアップデート

  • モデル:Inception-v3 + patched classification
  • データ:{料理,非料理}の二値ラベルの画像データ、それらを14×14のパッチにしたもの
  • フレームワーク:TensorFlow

一回目のアップデートで大きく改善はしましたが、画像中に人と料理が同時に写っている場合はモデルが判断に迷う(人と判断すべきか、料理と判断すべきか)という問題が残っていました。 我々は「画像中の一部を切り取ってクックパッドのレシピとして掲載できそうなものは料理と判断する」という基準で料理/非料理を判断しているので、十分に料理が写っているのにモデルが非料理と判断されているものは改善の余地がありました。

この問題に取り組むために、まずはマルチラベルの判別をすることを考え、そのために Keras の ImageDataGenerator 辺りを改修したりもしましたが、データ準備のコストが高いため一旦保留としました。 次に、問題の根本は複数のものが写っているのにそれらをまとめて判別してしまっていることだと考え、画像をパッチに分割してパッチ毎に料理/非料理を判別するというモデルを構築しました。 具体的には、通常の Inception-v3 の出力付近で使う GlobalAveragePooling と Dense を、Conv2D や Dropout などを組み合わせて出力が 14×14×1 にするように置き換えて、sigmoid の出力で binary cross entropy を計算するようにしています。 パッチに分けることで料理と非料理を区別しやすくなることが分かったため、再び二値分類のモデル(ただしパッチ毎)になっています。 パッチサイズの 14×14 に関してはいくつかのパターンを実験した結果最も良い結果を返すものを選択しました。

このモデルの学習にはパッチ毎にラベルが付与されたデータが必要ですが、これは単純に元データをパッチに分割して、全パッチに元データと同じラベルを付与するという作り方でデータ準備を簡略化しました。 ただしこの作り方だと特に画像の端の部分が悪さをする可能性があるので、適切なラベルが得られるように一部の画像を crop したりもしています。

また、本番にデプロイした場合の性能を見積もるために、本番でのデータ分布に近くなるように社員から許諾を得て携帯端末のデータを提供してもらいました。 プライベートな画像のため、閲覧権限を絞って、特定の人が正解ラベルを付与してそれを使ってモデルの詳細な性能検証を実施しました。

このような改善を経て本番環境にデプロイされたモデルの結果の一例が以下の図となります。 色がついている領域が料理らしさが高い領域で、閾値以上のパッチを取り出してその領域が一定以上であれば料理と判別するというモデルになっています。

20170914153255

この改善によって、テストデータに対して精度を落とさずに再現率を5%以上改善することができました。 料理きろくは1000万というオーダーで画像を処理しているので、改善のインパクトは大きなものとなります。

モデル構築に使用したデータ

クックパッドには大量の料理画像があるので正例のデータには事欠きませんが、モデルの学習には負例のデータも重要になるため Creative Commons のデータや社員からデータを集めたりして様々な画像を収集しました。 プロジェクトの進行と共に画像を追加して試行錯誤してという作業を繰り返し、結果としてトータルで数十万枚程度の画像を扱いました。

どのような画像が重要になったかは上述のモデルアップデートでもご紹介しましたが、改めてモデルの性能向上に重要であった特徴的なデータをいくつか挙げたいと思います。

  • 料理 
    当然ながら正例としての料理画像は最重要のデータとなります。 クックパッドの豊富な料理画像データを用いて充実したデータセットを構築することができました。
  • 人(特に赤ちゃん) 
    人物画像は判別を間違えた場合のリスクも高い(ユーザ体験の観点から)ため、負例の中でも特に気をつけて画像を収集して学習に用いました。
  • 植物 
    想像に難くないですが、色合いや形状から誤判別される場合が多かったのが植物の画像でした。
  • 植木鉢 
    植物と似ていますが、植木鉢の場合は器もあるためにより一層誤判別されるものが多かったです。 そのため意識して負例としてデータを収集しました。
  • 料理が乗っていない空の皿
    料理には皿がつきものなので、皿があれば正例と勘違いしかねないため何も乗っていない皿も負例としてデータに追加しています。
  • 本番運用時にモデルが扱うデータ分布に近いテストデータ
    本番ではユーザの携帯端末中の画像が対象ですが、学習は収集したラベル付きの限定的なデータを用いているので、モデルの正しい性能が測りづらいという問題があります。 ユーザの画像は我々も閲覧できないため、この問題はオフライン/オンラインに限らず原理的な問題です。 そこで、許諾を得た社員のスマホのデータを使い、人力でラベルを付け、これを使ってモデルの評価を実施しました。 プライベートな画像であるため、閲覧権限を必要最低限の人間に絞って詳細な性能評価をして、他の人には統計情報のみを共有するという手法を採りました。

やはり学習データは最重要ですね。 料理きろくにおいては、多くの人の協力によって様々なデータを収集することができたので、非常に心強かったです。

今後の展望

料理きろくのプロジェクトを通じて、やはり継続的な試行錯誤に基づいた適切な改善策を講じることが重要であることが再認識できました。 それゆえに、これで完成ではなく、日進月歩の Deep Learning 技術を取り込んだり、データを拡充したりして、よりユーザにとって有用なモデルを構築し続けるのが大事だと考えています。

例えば最新の SENetや料理ドメインに特化した 横方向に広くスライスした畳み込みを試してみたり、自分たちで使いながら判別を間違った画像を収集してその傾向を探る、より高速に動作するモデルを構築するなど、改善の方向性は様々です。

「自分ならもっと良いモデルが作れる!」という人がいましたら是非一緒にやりましょう。

おまけ

以前 Twitter で話題になっていた画像として、犬と食べ物の区別が難しい画像というものがありました。 みなさんは下の画像のどれが犬でどれが食べ物かが判別できるでしょうか? 遠目で見ると判別はかなり難しい感じがします。

20170914153246

画像は以下から引用させていただきました。
左の画像:https://twitter.com/teenybiscuit/status/707727863571582978
右の画像:https://twitter.com/ohmycorgi/status/867745923719364609

これらの画像に我々の料理/非料理判別モデルを適用して、料理画像だけを抽出してもらうとしましょう。

20170914153237

見事に料理画像だけを選び出すことができました! Deep Learningのモデルが適切に料理とそれ以外を区別できていることを伺い知ることができます。

これ以外にも、近い内容の話として 弁護士の柿沼太一先生との対談などもあります。 ご興味があれば是非ご覧ください。

まとめ

クックパッドアプリの料理きろくという機能で用いている料理画像判別技術に関してお伝えしました。 CNN のモデル自体はもの凄く特殊なものを構築しているわけではありませんが、試行錯誤を経たモデルの変遷やその過程で遭遇したタスク特有の問題点などに興味を持っていただけたなら幸いです。 本記事でお伝えしたのは一例であり、クックパッドでは様々なサービスにおいて、発展著しい機械学習の技術をユーザに有益なものへと昇華させる取り組みに日々励んでいます。

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

*1:このプロジェクトが始まった段階では私はまだ入社していませんでした

Cookpad Tech Kitchen #10を開催しました。

$
0
0

f:id:tsukasio:20170915160252p:plain

こんにちは、サービス開発部デザイナーの平塚です。

2017年9月13日(水)に、クックパッドオフィスにてデザイナー向けイベント「Cookpad Tech Kitchen #10」を行いました。

f:id:tsukasio:20170915160042j:plain

今回は「自社サービスで取り組むデザイン」をお題に、開発現場での実践事例を交えながら各社の文化やデザイナーの働き方についてご紹介しました。

お集まりいただいた皆さまの熱量も高く、イベントスタート時からQAセッション、懇親会に至るまで、とても充実した時間を過ごさせていただきました。 ご来場いただいた皆さま、本当にありがとうございました。

また今回は多数応募をいただいたため残念ながら抽選に漏れてしまった皆さま、たいへん申し訳ありません。 こういったイベントは今後も開催していきたいと思いますので、またのご参加を心よりお待ちしております!

一部ですが、イベントで行った各プレゼンテーションの概要をご紹介します。

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

  • 若月 啓聡(Cookpad/デザイナー)

新機能タイムラインのデザインや、チームのユーザーとの関わり方についてお話を聞くことができました。

f:id:tsukasio:20170915160058j:plain

「はてなブログの世界観になじむ機能デザイン」

  • 松井 沙織(はてな/デザイナー)

はてなブログの新機能やUI変更の中で、書くことを邪魔しないデザインについてお話を聞くことができました。

f:id:tsukasio:20170915160114j:plain

「温度のあるサービスづくり」

  • 木坂 名央(GMOペパボ/シニアデザイナー)

minneの、作家に向き合った温かみのあるデザインについてお話を聞くことができました。

f:id:tsukasio:20170915160133j:plain

「自然さを追求した音楽体験のためのUX」

  • 冨樫 晃己(CyberAgent/Product Manager)

AWAの、現実世界にありえる自然な動きについてお話を聞くことができました。

f:id:tsukasio:20170915160153j:plain

QAセッション

パネルディスカッションでは、

「UIデザイナーとUXデザイナーは兼任していますか? 実際の所UIとUXの兼任は難しいと思うのですがどのように分業や兼業していますか?」

「ドッグフーディングをチーム内で浸透させるのにはどうしていますか?」

といった参加者からの質問について、各社の取り組みについてより深く触れていただきました。

f:id:tsukasio:20170915164720j:plain

これからも楽しいイベントを企画していきます。 今後ともよろしくお願いします!


【クックパッドではデザイナー/エンジニアを積極採用中です】
ユーザー体験に向き合ってサービス開発をしたいデザイナーやエンジニアの方は、下記をご覧ください。

クックパッド採用情報 | UX/UIデザイナー
https://info.cookpad.com/careers/jobs/careers/ux-ui-designer
クックパッド採用情報 | エンジニア
https://info.cookpad.com/careers/jobs/careers/type/engineer

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

$
0
0

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

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

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

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

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

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

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

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

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

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

f:id:takanabe_w:20170920163049p:plain

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

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

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

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

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

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

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

f:id:takanabe_w:20170920163203p:plain

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

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

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

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

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

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

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

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

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

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

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

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

レイテンシの計測方法

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

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

  • Synthetic Monitoring
  • Real User Monitoring(RUM)

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

Synthetic Monitoring “Catchpoint”

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

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

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

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

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

f:id:takanabe_w:20170920163403p:plain

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

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

f:id:takanabe_w:20170920163426p:plain

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

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

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

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

f:id:takanabe_w:20170920163504p:plain

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

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

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

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

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

f:id:takanabe_w:20170920163546p:plain

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

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

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

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

f:id:takanabe_w:20170920163619p:plain

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

f:id:takanabe_w:20170920163639p:plain

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

おわりに

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

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

参考文献

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

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

$
0
0

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

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

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

コードレビューの目的

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

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

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

品質向上

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

スキルアップ

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

何に注意するべきか

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

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

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

アーキテクチャ・設計

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

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

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

挙動

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

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

改善

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

手順

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

  1. 内容を把握する

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

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

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

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

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

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

おわりに

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

Cookpad Ruby Hack Challenge 開催報告

$
0
0

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

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

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

短いまとめ:

RHC の概要

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

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

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

イベントの流れ

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

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

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

8/30 (水) 一日目

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

8/31 (木) 二日目

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

一日目 基礎編

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

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

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

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

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

二日目 応用編

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

いくつかご紹介します。

Hash#find_as_hash の実装

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

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

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

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

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

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

Procに関数合成を実装

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

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

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

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

サブイベント

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

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

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

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

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

Ruby開発者との Q&A

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

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

パーティー

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

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

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

まとめ

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

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

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

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

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

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

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

$
0
0

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

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

f:id:puzzeljp:20170929172254p:plain

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

タイムラインとは

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

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

f:id:puzzeljp:20170929172351p:plain

新しい見え方

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

f:id:puzzeljp:20170929172408p:plain

登壇資料

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

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

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

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

工夫した点

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

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

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

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

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

f:id:puzzeljp:20170929172428p:plain

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

f:id:puzzeljp:20170929172450p:plain

苦労した点

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

誰が作ったがわかる機能

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

工夫した点

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

f:id:puzzeljp:20170929172510p:plain

苦労した点

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

まとめ

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

Ruby の脆弱性を見つけた話

$
0
0

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

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

HackerOne について

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

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

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

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

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

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

ターゲットの選定

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

探す脆弱性の選定

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

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

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

脆弱性の発見

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

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

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

脆弱性の確認

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

require "webrick"
require "webrick/httpauth"

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

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

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

require "open-uri"

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

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

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

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

脆弱性の報告

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

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

セキュリティリリース

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

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

報奨金の獲得

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

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

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

RubyGems の脆弱性

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

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

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

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

まとめと所感

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

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

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

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


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

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

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

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

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

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

$
0
0

こんにちは! @yoshioriです。

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

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

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

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

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

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

発表内容

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

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

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

資料

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

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

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

資料

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

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

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

資料

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

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

f:id:cookpadtech:20171003220752j:plain

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

f:id:cookpadtech:20171003221913j:plain

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

f:id:cookpadtech:20171003221721j:plain

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

まとめ

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

cookpad.connpass.com

新しい仲間を募集中

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

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

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

Viewing all 801 articles
Browse latest View live