HAProxyを使用した汎用的ABテスト基盤への挑戦

この記事は[Speee Advent Calendar 2016](http://qiita.com/advent-calendar/2016/speee) の12日目の記事です。

前日の記事は
@suemocの[re:dashの権限管理とその活用事例 - Qiita](http://qiita.com/suemoc/items/34090c5609909cfcc8e7)
です。

新卒エンジニアの@_miyachikです。今回は(フロントの変更なら)エンジニアの工数を必要とせず、**アプリケーションの開発言語を問わない**汎用的ABテスト基盤の話をします。

## 前提
今回のABテスト基盤では

- ブランチを分けた状態で個別にABテストができる(masterにマージする必要なし)
- フロントの軽微な修正はエンジニアにはエンジニアの工数は割かない(ディレクターとデザイナーのみで行えるようにする)
- API側などbackendのロジックに対するABテストも行えるようにする
- **開発言語にとらわれずABテスト基盤導入をすることが可能**

を実現することが出来ます。まずは最上段の振り分けについて話したいと思います。
*　ただ立ち上がるインスタンスのIPがわからないオートスケールにはまだ対応しきれていません

## やったこと
- HAProxyによるABテスト振り分けと再抽選の防止
- HAProxyの設定ファイルをRailsアプリケーションから動的に吐き出し、アプリケーションをデプロイとHAproxyの設定を再読込するタスクを作成
    - ABテストサーバをbackendから切り離し
    - 対象のABテストブランチをABテストサーバにデプロイ
    - 生成したHAProxyの設定を再読込
    - フロントのダウンタイムはゼロになるように

## HAProxyについて
[HAProxy](http://www.haproxy.org/) とはプロキシサーバであり、ソフトウェアロードバランサーです。
HAProxyが何であって、何でないかは[公式のドキュメント](http://cbonte.github.io/haproxy-dconv/intro-1.6.html#3.1)に書いてあります。
ドキュメントにもある通りHAProxyにはロードバランサーとしての機能があり、HAProxy自身がCookieを発行することができます。そしてCookieがセットされているかを判断することもできます。
また、HAProxy単体でABテスト振り分けに関する機能は実現可能なため、Front/backendのアプリケーションの種類は問われません。

## インフラ構成図
ざっくりとした構成は以下です。各sideごとのサーバ台数はHAProxyのbackend定義により行えます。
<img width="503" alt="スクリーンショット 2016-12-12 16.12.04.png" src="https://qiita-image-store.s3.amazonaws.com/0/71904/385fb3a1-34a4-52f2-2c09-725ebd778a1d.png">

ABテストパターンが反映されたブランチはab_sideにのみにデプロイされ、結果が芳しくない場合にはmergeせずに次のABテストに進むことが出来ます。

## HAProxyによるABテスト振り分け
振り分けについてはHAProxyのACLとbackendの複数定義により行っています。

### ACL(Access Control List)
[公式ドキュメント](http://cbonte.github.io/haproxy-dconv/configuration-1.5.html#4.2-acl)
HAProxyのACLは非常に柔軟なアクセスを振り分けることができます。
下記のように書くことでCookieを判断して振り分けるbackendを変更することが可能です。 

``` text:haproxy.cfg
    acl normal hdr_sub(cookie) ab-test-pattern=0
    acl ab-test-pattern hdr_sub(cookie) ab-test-pattern=1
    use_backend normal_side if normal
    use_backend ab_side if ab-test-pattern
    default_backend first_side
```
**ab-test-pattern**というCookieが存在していない場合(初回アクセス時)には **first_side** に振り分けら、Cookieが存在しており && 値が**1**なら **ab_side**へ、値が0なら **normal_side**へ振り分けられます。

### backendの定義
backendの定義は下記の様に行い、初回アクセス時にはCookieを挿入し、重み付け付きの振り分けに関してはweightで行います。この様にすればweightによってABテストサーバーに振り分けられたユーザはその証拠としてCookieの値として **1**が書き込まれます。

下記の例では**normal:AB = 75:25**になっています。normalの確立 = ホスト名末尾が **0**に行く確立の総和です。なので、設定を吐き出すときにはnormal_sideのweightは設定されている`(normal_sideのホスト数)/(設定したいnormal側のweight)`である必要があります。
デプロイされているアプリケーションのブランチが違うので、アプリケーション側で　ab_side側かnormal_side側のアクセスなのかは判断する必要はありませんが、事故を防ぐ場合Cookieの値を参照することによりユーザがどちら側に抽選されたのかを判断することができます。
初回アクセス時にはCookieはセットされておらず、**X-HOST-NAME** を使用しHeaderからホスト名を参照しab_sideかnormal_sideかを判断することが出来ます。

```  text:haproxy.cfg
backend first_side
    mode http
    cookie ab-test-pattern insert nocache
    balance roundrobin
    http-send-name-header X-HOST-NAME

    server web1-0 127.0.0.1:80 weight 25% check cookie 0 inter 1000 fall 2
    server web2-0 127.0.0.2:80 weight 25% check cookie 0 inter 1000 fall 2
    server web3-0 127.0.0.3:80 weight 25% check cookie 0 inter 1000 fall 2
    server web4-1 127.0.0.4:80 weight 25% check cookie 1 inter 1000 fall 2
```
あとは**normal_side**にはnormal側のホストのみを宣言すればnormalにしか行きません。
ただし、**ab_side**にはnormal側のホストを含めて宣言しないと、万が一ABテストサーバ(今回の場合web4-1)が落ちていた場合に該当するホストが見つからずアクセスできなくなってしまうので注意が必要です。(check cookieをしている場合でもホストが落ちていると振り分けは行われず別のサーバへ振り分けられる)

以下が実際に吐き出されるファイルです。負荷などを気にする場合は適切にmaxconn等の設定を追記してください。ブランチ名は**ab-test-pattern**の想定です。
**normal_side**のIPなどはRailsのconfigファイル内に配列で書いています。

``` text:haproxy.cfg
global
    log         127.0.0.1 local2 info
    chroot      /var/lib/haproxy
    pidfile     /var/run/haproxy.pid
    maxconn     10000
    user        haproxy
    group       haproxy
    daemon
defaults
    log     global
    timeout connect 60s
    timeout server 1m
    timeout client 1m
    timeout check  5s

# web
listen frontside
    maxconn 4000
    fullconn 4000
    bind 0.0.0.0:80
    mode http
    acl normal hdr_sub(cookie) ab-test-pattern=0
    acl ab-test-pattern hdr_sub(cookie) ab-test-pattern=1
    use_backend normal_side if normal
    use_backend ab_side if ab-test-pattern
    default_backend first_side

backend first_side
    mode http
    cookie ab-test-pattern insert nocache
    balance roundrobin
    http-send-name-header X-HOST-NAME

    server web1-0 127.0.0.1:80 weight 25% check cookie 0 inter 1000 fall 2
    server web2-0 127.0.0.2:80 weight  25% check cookie 0 inter 1000 fall 2
    server web3-0 127.0.0.3:80 weight  25% check cookie 0 inter 1000 fall 2
    server web4-1 127.0.0.4:80 weight  25% check cookie 1 inter 1000 fall 2

backend normal_side
    mode http
    balance roundrobin

    server web1-0 127.0.0.1:80 check inter 1000 fall 2
    server web2-0 127.0.0.2:80 check inter 1000 fall 2
    server web3-0 127.0.0.3:80 check inter 1000 fall 2

backend ab_side
    mode http
    cookie ab-test-pattern insert nocache
    balance roundrobin

    server web1-0 127.0.0.1:80 check cookie 0 inter 1000 fall 2
    server web2-0 127.0.0.2:80 check cookie 0 inter 1000 fall 2
    server web3-0 127.0.0.3:80 check cookie 0 inter 1000 fall 2
    server web4-1 127.0.0.4:80 check cookie 1 inter 1000 fall 2
```

HAProxyの設定変更とアプリケーションのデプロイを行うタスクは、今回はJenkinsのJobを連鎖させることで実現しました。
用意したJobは

- HAProxyからab_sideを切り離したconfigをreload
- 指定したブランチをデプロイ
- 生成したHAProxyのconfigファイルを再読込

です。HAProxyはgraceful restartが可能なので、デプロイによる表側への影響はありません。

作成したHAProxyのconfigのみを吐き出すアプリケーションは現在公開準備中になりますので、今しばらくお待ち下さい :bow: