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

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

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

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

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

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

f:id:winebarrel:20180227123255p:plain

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

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

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

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

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

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

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

f:id:winebarrel:20180227123314p:plain

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

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

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

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

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

 

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

コンテナへのItamaeの適用

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

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

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

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

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

module RecipeHelper
  def include_role(name)
    include_role_or_cookbook(name, "role")
  end

  def include_cookbook(name)
    include_role_or_cookbook(name, "cookbook")
  end

  def include_role_or_cookbook(name, type)
    Pathname.new(File.dirname(@recipe.path)).ascend do |dir|
      names = name.split("::")
      names << "default" if names.length == 1

      if type == "cookbook"
        recipe_file = dir.join("cookbooks", *names)
      else
        recipe_file = dir.join(*names)
      end
      if recipe_file.exist?
        include_recipe(recipe_file.to_s)
        return
      end
    end

    raise "#{name} is not found."
  end
end

Itamae::Recipe::EvalContext.send(:include, RecipeHelper)

Itamaeのレシピ内で

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

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

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

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

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

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

module FakeNode
  module Value
    def [](key)
      key.to_s
    end
    alias :fetch :[]
  end

  def [](key)
    value = super

    if value.nil? && !self.mash.has_key?(key)
      case key
      when :http_proxy
        nil
      when :rspec
        true
      else
        key.to_s.tap do |v|
          v.extend(Value)
        end
      end
    else
      value
    end
  end
end

Itamae::Node.prepend FakeNode

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

module RecipeHelper
  SKIP_RECIPES = ENV.fetch('SKIP_RECIPES', '').split(',')

  def include_role_or_cookbook(name, type)
    return if SKIP_RECIPES.include?(name)

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

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

ENV SKIP_RECIPES haproxy,td-agent,zabbix-agent

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

テストの改善

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

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

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

f:id:winebarrel:20180227123328p:plain

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

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

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

elbコンテナ(ssl_termination_proxy/

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

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

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

  underscores_in_headers on;

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

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

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

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

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

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

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

rproxyコンテナ(rproxy/

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

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

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

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

internal-serviceコンテナ(internal_service_proxy

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

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

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

  location / {
    set $custom_x_forwarded_for $proxy_add_x_forwarded_for;

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

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

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

backendコンテナ(backend/

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

#!/usr/bin/env ruby
require 'webrick'
require 'json'
require 'mime/types'

URI_ATTRS = %w(
  scheme
  userinfo
  host
  port
  registry
  path
  opaque
  query
  fragment
)

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

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

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

  res.keep_alive = false

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

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

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

server.start

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

tasterコンテナ(taster/

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

RSpec

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

# spec_helper.rb
require 'infrataster/rspec'

%w(
  cookpad.com
  xxx.cookpad.com
).each do |server|
  Infrataster::Server.define(server, server)

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

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

 

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

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

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

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

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

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

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

まとめ

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

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

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

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

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

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

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

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

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

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

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

概要

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

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

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

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

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

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

Puree-Swiftの特徴

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

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

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

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

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

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

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

依存関係の廃止

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

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

実装例

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

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

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

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

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

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

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

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

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

import Foundation
import Puree

struct PVLogFilter: Filter {
    let tagPattern: TagPattern

    init(tagPattern: TagPattern, options: FilterOptions?) {
        self.tagPattern = tagPattern
    }

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

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

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

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

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

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

class ConsoleOutput: Output {
    let tagPattern: String

    init(logStore: LogStore, tagPattern: String, options: OutputOptions?) {
        self.tagPattern = tagPattern
    }

    func emit(log: Log) {
        if let userData = log.userData {
            let jsonObject = try! JSONSerialization.jsonObject(with: log.userData)
            print(jsonObject)
        }
    }
}

BufferedOutput

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

class LogServerOutput: BufferedOutput {
    override func write(_ chunk: BufferedOutput.Chunk, completion: @escaping (Bool) -> Void) {
        let payload = chunk.logs.flatMap { log in
            if let userData = log.userData {
                return try? JSONSerialization.jsonObject(with: userData, options: [])
            }
            return nil
        }
        if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) {
            let task = URLSession.shared.uploadTask(with: request, from: data)
            task.resume()
        }
    }
}

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

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

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

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

import Puree

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

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

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

まとめ

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

どうぞご利用ください。

try!Swift

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

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

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

TOKYO - try! Swift Conference

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

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

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

仮説検証

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

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

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

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

前提条件を確認する

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

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

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

検証の設計をする

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

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

パターンA パターンB
f:id:ragi256:20180221172307p:plain f:id:ragi256:20180221172324p:plain

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

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

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

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

両パターンを実装する

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

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

module TimeSlotPsKondate
  include Chanko::Unit
  include EasyAb

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

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

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

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

-# 対象となるviewの書かれているファイル

= invoke(:time_slot_ps_kondate, card) do
  -# 差し替え部分 
end

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

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

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

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

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

Ruby

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

JavaScript

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

HTML(hamlで書いた場合)

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

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

デプロイ後の監視

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

f:id:ragi256:20180221172442p:plain

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

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

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

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

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

まとめ

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

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

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

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