ロードバランサ（haproxy + serf + keepalived）を構築してみた

ハードウェアのロードバランサは予算上厳しくなりそうだったので、仮想サーバで動作するOSSのロードバランサを構築することにした。今回検証を行ったので、その共有です。

# 構成
![config.png](https://qiita-image-store.s3.amazonaws.com/0/28210/540474e1-3b09-58e9-eaa4-444f98049555.png)

### ロードバランサ
- Ubuntu Server 14.04 LTS
- HAProxy v1.5.2
- keepalived v1.2.7
- serf v0.6.3

### 負荷分散するアプリサーバ
- Windows Server 2012 R2 or Windows Server 2008 R2 (どちらも standard edition）
- IIS
- web service A（独自）※ここでは名前をWSA、使用するポートを1111とします
- web service B（独自）※ここでは名前をWSB、使用するポートを2222とします

# インストール
すでに多くの方が書かれていますが、参考までに。

## ロードバランサ
#### haproxy
```
sudo apt-get install -y build-essential
sudo apt-get install -y libpcre3-dev
sudo apt-get install -y libghc-zlib-dev
wget http://www.haproxy.org/download/1.5/src/haproxy-1.5.1.tar.gz
tar zxf haproxy-1.5.1.tar.gz
cd haproxy-1.5.1
make TARGET=linux2628 CPU=native USE_PCRE=1 USE_ZLIB=1  # openssl support is disabled
sudo make install

# 最新版が不要な方は下記でOKです
sudo apt-get install -y haproxy
```


#### keepalived
```
sudo apt-get install -y keepalived
```

#### serf
```
wget https://dl.bintray.com/mitchellh/serf/0.6.3_linux_amd64.zip
sudo apt-get install -y unzip
sudo apt-get install -y ruby2.0 # イベントをトリガとしてRubyスクリプトを実行します
sudo gem install serf_handler # serf イベントを処理するライブラリです
unzip 0.6.3_linux_amd64.zip
sudo cp serf /usr/local/sbin/
sudo mkdir /etc/serf/
sudo mkdir /var/log/serf/
```

## アプリサーバ
#### serf
- https://dl.bintray.com/mitchellh/serf/0.6.3_windows_amd64.zip をダウンロード
- 適当なフォルダ（C:¥APP¥serf）を作成し、上記を展開して serf.exe を配置


# 設定
こちらも参考までに。

## ロードバランサ
#### haproxy
IISはあるURLを2秒間隔でチェックし、応答がなかった場合にそのサーバを負荷分散対象から外します。負荷分散対象が0になると、指定したSorryサーバへリダイレクトします。Web Service AとWeb Service Bは負荷分散していますが、すべてのサーバがNGになってもリダイレクトを行うことはしません（クライアント側で処理できるよう独自実装をしているため）。
なお、IISとWeb Service Bのヘルスチェックはステータスコードで判定し、Web Service AはResponseのBodyに含まれるキーワード（rstringを使っているので正規表現で指定）で死活判定を行っています。
さらに、監視のみ行うBackendの設定も行っています。

```haproxy.cfg
global
   log      127.0.0.1   local0 info
   user     root
   group    root
   daemon

defaults
   log      global
   mode     http
   option   httplog
   option   dontlognull
   option   redispatch
   balance  leastconn
   retries  3
   maxconn  4092
   timeout  connect  5000
   timeout  client   50000
   timeout  server   50000
   stats    enable

frontend frontend_iis
   bind     :80
   acl      is_iis_available nbsrv(lb-iis) eq 0
   redirect location http://SORRY_SERVER_URL if is_iis_available
   use_backend lb-iis

backend  lb-iis
   option   httpchk GET /IIS_URL
   option   httpclose
   rspidel  ^Set-cookie:\ IP=
# additional-iis-server

listen  lb-wsa :1111
   option   httpchk
   option   httpchk GET /WEB_SERVICE_A_URL
   http-check expect rstring RETURN_STRING_OF_WEB_SERVICE_A_RESPONSE
   option   httpclose
   rspidel  ^Set-cookie:\ IP=
# additional-lb-web_service_a-server

listen  lb-wsb :2222
   option   httpchk GET /WEB_SERVICE_B_URL
   option   httpclose
   rspidel  ^Set-cookie:\ IP=
# additional-lb-web_service_b-server

backend  lb-wsa :1111
   option   httpchk
   option   httpchk GET /WEB_SERVICE_A_URL
   http-check expect rstring RETURN_STRING_OF_WEB_SERVICE_A_RESPONSE
   option   httpclose
   rspidel  ^Set-cookie:\ IP=
# additional-monitor-web_service_a-server

backend  lb-wsb :2222
   option   httpchk GET /WEB_SERVICE_B_URL
   option   httpclose
   rspidel  ^Set-cookie:\ IP=
# additional-monitor-web_service_b-server
```
haproxyはSyslogにログ出力するようになっているので、Syslogの設定をします。

```/etc/rsyslog.d/49-haproxy.conf
$ModLoad imudp
$UDPServerAddress 127.0.0.1
$UDPServerRun 514
local0.* -/var/log/haproxy.log
```
さらにログをローテートする設定もします。

```/etc/logrotate.d/haproxy
/var/log/haproxy.log {
    daily
    missingok
    rotate 30
    compress
    ifempty
    sharedscripts
    postrotate
        /etc/init.d/haproxy reload
    endscript
}
```
#### keepalived
/etc/keepalivedの配下に下記コンフィグを配置します。

```keepalived.conf(master)
vrrp_script chk_haproxy {
  script "killall -0 haproxy"
  interval 2
  weight 2
}

vrrp_instance VI_1 {
  interface eth0
  state MASTER
  virtual_router_id 51
  priority 101
  virtual_ipaddress {
    VIRTUAL_SERVER_IP
  }
  track_script {
    chk_haproxy
  }
}
```
```keepalived.conf(slave)
vrrp_script chk_haproxy {
  script "killall -0 haproxy"
  interval 2
  weight 2
}

vrrp_instance VI_1 {
  interface eth0
  state SLAVE
  virtual_router_id 51
  priority 100
  virtual_ipaddress {
    VIRTUAL_SERVER_IP
  }
  track_script {
    chk_haproxy
  }
}
```

#### serf
サーバ起動時にserfもあわせて起動するよう、Upstartで設定します。

```/etc/init/serf.conf
description "serf, the cluster orchestration tool"
author "xxxx"
start on stopped rc
stop on runlevel [!2345]
script
  /usr/local/bin/serf agent -config-file /etc/serf/proxy.json > /var/log/serf/serf.out &
end script
pre-stop script
  /usr/local/bin/serf leave > /var/log/serf/serf.out &
end script
```

Upstartを有効にします。

```
sudo initctl reload-configuration
sudo initctl list | grep serf
sudo initctl start serf
```

serfを起動するときの設定。LB1_IPはロードバランサ（Master）に割り当てられているIPです。
なお、masterとslaveで若干設定内容が異なるので注意です。

```/etc/serf/proxy.json(master)
{
    "tags": {
            "role": "lb"
    },
    "bind": "LB1_IP", 
    "event_handlers": [
            "ruby /etc/serf/handler.rb"
    ]
}
```
```/etc/serf/proxy.json(slave)
{
    "tags": {
            "role": "lb"
    },
    "bind": "LB2_IP",
    "start_join": [
            "LB1_IP"
    ], 
    "event_handlers": [
            "ruby /etc/serf/handler.rb"
    ]
}
```
アプリサーバ起動時にhaproxyのコンフィグを変更するスクリプトです。
[こちらのサイト](http://deeeet.com/writing/2014/04/01/serf-haproxy/)を大いに参考にさせていただきました。

```/etc/serf/handler.rb
# handler.rb
# serf agent -config-file=proxy.json
# serf agent -config-file=web.json
# 
# for maintenance
#  leave cluster temporarily
#   serf event pause "SERVER_HOSTNAME IP ROLE"
#  rejoin cluster
#   serf event resume "SERVER_HOSTNAME IP ROLE"
require 'yaml'
require 'fileutils'
require 'serf_handler'

# load balancer handler
class LBHandler < SerfHandler
  # location of haproxy configration file
  CONFIGFILE = '/etc/haproxy/haproxy.cfg'
  TMP_CONFIGFILE = '/tmp/haproxy.cfg'
  LOGFILE = '/data/log/serf/handler.log'
  ROLEFILE = '/etc/serf/role.yml'
  RETRY_COUNT = 3

  # specifying the marker to append server
  MARKER = Regexp.compile('^# additional-(.+)-(.+)-server$')

  METHOD_NAME = %w( member_join member_leave pause resume )
  ACTION = { 'member_join' => :join, 'member_leave' => :leave,
             'pause' => :leave, 'resume' => :join }

  def initialize
    super(LOGFILE)
    @roles = YAML.load_file(ROLEFILE)
    @wflg = true
  end

  # **** generate serf standard event processing methods ****
  METHOD_NAME.each do |name|
    define_method(name) do
      exit 0 unless member_info
      info "node: #{@node}, role: #{@role}, event #{@event}"
      rewrite_config ACTION["#{name}"]
    end
  end

  # get information of the server which joined the cluster
  def member_info
    STDIN.each_line do |line|
      @node, @ip, @role, _ = line.split(' ')
    end
    @role == ENV['SERF_SELF_ROLE'] ? false : true
  end

  def rm(file)
    FileUtils.rm(file) if File.exist?(file)
  end

  def generate_target
    v = @roles[@role]['interval']
    info "#{@node} #{@ip} #{v}"
    { iis:   "   server  #{@node} #{@ip}:80 check inter #{v} rise 2 fall 2",
      web_service_a:  "   server  #{@node} #{@ip}:1111 check inter #{v} rise 2 fall 2",
      web_service_b:   "   server  #{@node} #{@ip}:2222 check inter #{v} rise 2 fall 2",
      mongo: "   server  #{@node} #{@ip}:27017 check inter #{v} rise 2 fall 2" }
  end

  def generate_line(line, target)
    @wflg = false if target.value?(line.chomp)
    if (m = MARKER.match(line.chomp))
      services = @roles[@role][m[1]]
      line = target[m[2].to_sym] + "\n" + line if services.include?(m[2]) && @wflg
      @wflg = true
    end
    line
  end

  def generate_tmpcfg(action, target)
    File.open(TMP_CONFIGFILE, 'w') do |f|
      File.open(CONFIGFILE, 'r').each do |line|
        case action
        when :join then line = generate_line line, target
        when :leave then next if target.value?(line.chomp)
        end
        f.write line
      end
    end
  end

  def rewrite_config(action)
    exit 0 unless @roles.key?(@role)
    rm TMP_CONFIGFILE

    generate_tmpcfg action, generate_target
    FileUtils.mv TMP_CONFIGFILE, CONFIGFILE

    rm TMP_CONFIGFILE
    reload_proxy
  end

  def reload_proxy
    info 'rewrite config: done.'
    `/etc/init.d/haproxy reload`
    info "execute #{command} => result: #{$CHILD_STATUS}"
  end
end

if __FILE__ == $PROGRAM_NAME
  handler = SerfHandlerProxy.new
  handler.register 'lb', LBHandler.new
  handler.run
end
```

```role.yml
# service: iis, mongo, none(nothing to healthcheck)
web:
    lb:
        - iis
    monitor:
        - none
    interval: 60s
A:
    lb:
        - web_service_a
    monitor:
        - mongo
    interval: 60s
B:
    lb:
        - web_service_b
    monitor:
        - mongo
    interval: 60s
test:
    lb:
        - none
    monitor:
        - iis
        - mongo
    interval: 300s
```

## アプリサーバ
#### serf

まずは起動・停止スクリプト。 ```gpedit.msc``` でローカルグループポリシーエディターを起動し、コンピューターの構成＞Windowsの設定＞スクリプト（スタートアップ/シャットダウン）で、スタートアップに  join-cluster.bat を、シャットダウンに leave-cluster.bat をそれぞれ登録します。これでWindows起動時・シャットダウン時 serfクラスタへ参加・離脱ができます。なお、MY_IPには自サーバのIPアドレスを設定します。

``` APP¥serf¥web.json
{
    "tags": {
            "role": "web"
    },
    "bind": "MY_IP",
    "start_join": [
            "LB1_IP"
    ]
}
```

``` APP¥serf¥A.json
{
    "tags": {
            "role": "A"
    },
    "bind": "MY_IP",
    "start_join": [
            "LB1_IP"
    ]
}
```

``` APP¥serf¥join-cluster.bat
set SERF_HOME=C:\APP\serf
%SERF_HOME%\serf.exe agent -config-file=%SERF_HOME%\web.json
```
``` APP¥serf¥leave-cluster.bat
set SERF_HOME=C:\APP\serf
%SERF_HOME%\serf.exe leave
```

また、今回はメンテナンス時は負荷分散先から外すようにしたいため、serfのユーザイベントを使い、かつイベント発生時に呼び出される前述した handler.rb で対応するにようにしています。メンテナンス開始時に pause.bat 、終了時に resume.bat を実行することで可能にします。

``` APP¥serf¥pause.bat
set SERF_HOME=C:\APP\serf
for /f "usebackq delims=: tokens=2*" %%i in (`ipconfig.exe ^| findstr.exe /r /c:"IPv4 .*"`) do (set IP=%%i)
%SERF_HOME%\serf.exe event pause "%COMPUTERNAME% %IP% web"
```
``` APP¥serf¥resume.bat
set SERF_HOME=C:\APP\serf
for /f "usebackq delims=: tokens=2*" %%i in (`ipconfig.exe ^| findstr.exe /r /c:"IPv4 .*"`) do (set IP=%%i)
%SERF_HOME%\serf.exe event resume "%COMPUTERNAME% %IP% web"
```

# 使ってみて
haproxyは以前LVSやUltramonkeyと比較したことがあり、設定の柔軟さ・扱いやすさを高く評価していました。ただ、当時はまだヘルスチェックのResponseの戻り値判定がステータスコードのみだったということもあり、強くおすすめしたい！というところまでもう一歩という製品でした。今回検証してみて、そういったところもずいぶん改善されており、よくできているなと改めて思いました。

# 注意
動作は確認していますが、保証するものではありません。実際お試しになる場合は正しく動作しない可能性もありますので、ご留意ください。
内容で間違っている部分等ありましたらご指摘いただけるとうれしいです。

# 参考にさせていただいたサイト

- [SerfでHAProxyの更新 on Vagrant](http://deeeet.com/writing/2014/04/01/serf-haproxy/)
- [Serf+HAProxyで作るAutomatic Load Balancer](http://blog.glidenote.com/blog/2013/10/30/serf-haproxy/)
- その他各ソフトウェアの公式サイト