MySQL を MHA + HAProxy で冗長化してみよう

斎藤です。こんにちは。

今日は、MySQLにてレプリケーション構成において、マスタサーバのフェイルオーバーを司るmysql-master-ha(以下、MHA)を用いる際、マスタサーバ接続先の切り替えにHAProxyを使ってみようというお話です。

※MHAは0.53.0(公式パッケージ)、MySQLは5.5.25a(Oracle公式パッケージ)、HAProxyは1.4.22(CentOS6標準パッケージ)、OSはCentOS 6.3 x86_64を用いました。

※MHAによる冗長化およびHAProxyによるMySQLの負荷分散の設定を経験された事がある前提で記述します。

本記事では、次の流れで話題を展開します。

  • フェイルオーバー時の接続先切り替え方法
  • 構成(参考)
  • なぜHAProxyなのか
  • 切り替え方
  • 2台構成の問題点
  • その他 コツ
  • 設定(参考)

主にMHA+HAProxyによるフェイルオーバーの仕組みと設定について解説します。また、途中でDBが2台の場合のちょっとしたコツについても言及します。

フェイルオーバー時の接続先切り替え方法

次の4つの方法が考えられます。

  • (1) Heartbeat
  • (2) keepalived (DSR)
  • (3) HAProxy
  • (4) アプリケーション側で対処

(1)は、MHAのWikiでも言及されている方法です。特に、Masterの候補となるサーバが2台に絞れるなら設定しやすい方法ではないでしょうか。

(2) は、MHAの情報+misc_checkなどで現在稼働中のマスタサーバを決定できるようにしておけば、実装しやすい方法です。ただし、keepalivedはAWSのEC2上ではそのままでは利用できず、unicastパッチを当てる必要があります。

(3) は、AWS上など(2)の手段が取れない場合に有効です。

(4) は、マスタサーバ切り替えのためのミドルウェアが不要な構成です。ITインフラの構成はこの4つの中では最もシンプルになります。ただし、アプリケーション側で更新系のクエリを投げるマスタサーバをオンラインで随時切り替えられるようにしておく事が必要です。

今回は、(3)を適用して構成する事にしました。

構成(参考)

図1の通りになりました。説明のため、実環境から整理して記述しています。

slide1.PNG (図1:論理構成図)

ポイントは、HAProxy用のサーバを別途立てず、各Web(DBクライアント)にHAProxyを導入したことです。

なぜHAProxyなのか

先の選択肢から(3)を選択し、かつ図1の通り各クライアントにHAProxyを導入した理由として、次の事柄がありました。

  • (A) ARP Request の検証を省きたかった
  • (B) HAProxyの冗長化を省きたかった
  • (C) アクセスするDBクライアントが少なかった

(A) は、このシステムではネットワークが完全に冗長化されていました。そのため、(1)(2)を採用しようとした場合、ネットワークのフェイルオーバーも含めて検証をする必要がありました。しかし、既に稼働しているシステムの検証は難しく、かつリスクがあります。そのため、(1)と(2)は候補から外れました(※1)。

(B) は、HAProxyを冗長化すると、Heartbeat + Pacemakerを用いることになります。しかし、(A)で説明したように、この場合でもフェイルオーバー時にARPの問題が解決できるか検証する必要があります。これでは(3)を選択するメリットがなくなります。そこで、(3)を採用するにしても、ARPの問題が起きず、かつHAProxyがSPOFにならないよう、各クライアントへの導入を選択しました。

(C) は、(B)より各DBクライアントにHAProxyを導入すると、MySQLの死活監視負荷が台数分増えるという問題がありました。こちらは、検証の結果、今回導入するシステムの台数程度であればDBに対する負荷は問題ない水準である事が確認できました。なお、DBサーバの性能によりアクセス可能な台数が変わりますので、注意が必要です(※2)。

以上の3点、特にARPについての問題を最小限にするために、各WebサーバにHAProxyを導入することにしました。

切り替え方

概要を図2に示します。肝心な所のみ解説します。

slide2.PNG (図2:フェイルオーバーの流れ)

3では、MHAがフェイルオーバー処理を開始します。具体的には、Relay Logを最新の状態になるよう処理を行います。処理中、master_ip_failover_scriptで設定したスクリプトを実行しますが、これが4の処理になります。

4では、MHAから通知された新Master DBのIPアドレスとポート番号を基に、新しいHAProxyの設定を作成します。その後、各Webサーバに設定を頒布しつつ、HAProxyに設定を再読み込みさせます。このとき、参照系の通信は途切れません。

5では、全てのWebサーバに設定の頒布が完了した段階で、MHAのmaster_ip_failover_scriptに設定したスクリプトが新Master DBのread_onlyを解除します。ここで「あれ、MHAが解除しないの?」と思われた方は、後述する「2台構成の問題点」をご覧ください。尚、DBが障害前に3台以上あれば、本処理はMHAが行いますのでmaster_ip_failover_scriptのスクリプトにて実行する必要はありません。

本処理ですが、手元の検証環境では15秒で切替りました。本番では更新トランザクション数によりますが、Relay Logの処理を鑑みるとこれ以上の時間がかかります。

2台構成の問題点

MHAは、本来DBサーバを3台以上で稼働させるものです。しかし、DBがMaster-Slaveの2台のみである事も珍しくないかと思います。その際にMHAを使いますと、フェイルオーバーを終えようとするときにMHAが「スレーブサーバがいない!」とエラーを出し、停止してしまいます。

このとき、master_ip_failover_scriptは走るのですが、新Masterのread_onlyを外す処理が走らず、更新系のDB接続が更新を実行するとエラーとなってしまいます。

そのため、回避策としてmaster_ip_failover_scriptの処理の最後で、新Masterのread_onlyを外す処理を入れています。

もちろん、3台以上で運用している場合は問題ありません。

その他 コツ

MHA Managerのデーモンは、Upstartを使ってデーモン化すると扱いやすくなるかと思います(参考:「Upstart を使ってお手軽 daemon 化」)。ただ、先の2台構成時の問題がありますので、respawnのリトライ回数は減らしておくとよいでしょう。3台以上であればこの問題は起きません。

設定(参考)

MHA Manager側に次の設定ファイルを作成します。

  • /etc/init/mha.conf: MHA Managerの起動スクリプト(Upstart用)
description "MHA"
author "Your Name <username@domain>"

start on runlevel [2345]
stop on runlevel [016]

chdir /opt/masterha
respawn limit 5 60
exec  /usr/bin/masterha_manager --conf=/etc/masterha/mha.conf >> /var/log/masterha/mysql/mha.log 2>&1
  • /etc/masterha/mha.conf: MHA Managerの設定ファイル
[server default]
manager_workdir=/var/log/masterha/mysql
master_binlog_dir=/var/lib/mysql
user=mha
password=????????
remote_workdir=/var/log/masterha/mysql
master_ipfailover_script=/opt/masterha/bin/mysql_failover.sh
master_ip_online_change_script=/opt/masterha/bin/mysql_failover.sh
ssh_user=root

[server1]
hostname=server1

[server2]
hostname=server2
  • /opt/masterha/bin/mysql_failover.sh: フェイルオーバー時の処理スクリプト
#!/bin/bash

HOME_DIR="/opt/masterha"
CONF_DIR="${HOME_DIR}/conf"

OPT=$(getopt -q -o a -l command:,ssh_user:,orig_master_host:,orig_master_ip:,orig_master_port:,new_master_host:,new_master_ip:,new_master_port: -- "$@")
if [ $? -ne 0 ]
then
  exit 1
fi

eval set -- "$OPT"
MODE=none
NEW_IP=0.0.0.0
NEW_PORT=0

while true
do
  case "$1" in
  --command)
    if [ "$2" == "start" ]
    then
      MODE=$2
    elif [ "$2" == "status" ]
    then
      MODE=$2
    elif [ "$2" == "stop" ]
    then
      MODE=$2
    else
      exit 0
    fi
    shift 2
    ;;
  --new_master_ip)
    if [ $MODE == "start" ] 
    then
      NEW_IP=$2
    fi
    shift 2
    ;;
  --new_master_port)
    if [ $MODE == "start" ] 
    then
      NEW_PORT=$2
    fi
    shift 2
    ;;
  --orig_master_ip)
    if [ $MODE == "status" ]
    then
      NEW_IP=$2
    fi
    shift 2
    ;;
  --orig_master_port)
    if [ $MODE == "status" ]
    then
      NEW_PORT=$2
    fi
    shift 2
    ;;
  --)
    shift
    break
    ;;
  *)
    shift
    ;;
  esac
done

grep -q $NEW_IP ${CONF_DIR}/mysql_update.txt
if [ $? -eq 0 ]
then
  echo 'IP address is not update.'
  exit 0
fi

echo $NEW_IP
echo $NEW_IP > ${CONF_DIR}/mysql_update.txt

/opt/masterha/bin/update_all_haproxy.sh
  • /opt/masterha/bin/update_all_haproxy.sh: HAProxyの設定切り替え処理スクリプト
#!/bin/bash

## Configurations
MASTERHA_MYSQL_CONF=/etc/masterha/mysql.cnf
CONF_DIR="/opt/masterha/conf"
HAPROXY_CONF_TEMPLATE="haproxy.cfg.template"
HAPROXY_CONF="haproxy.cfg"
UPDATE_HAPROXY_SCRIPT="update_haproxy.sh"
HAPROXY_CONF_DIR="/etc/haproxy"
SSH_OPTIONS="-oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null"
TARGET_HOSTS=$(cat ${CONF_DIR}/hostlist.txt)

## Set variabls.
PATH=/usr/bin:/bin:/usr/sbin:/sbin
export PATH

IPADDR_MYSQL_UPDATE="$(<${CONF_DIR}/mysql_update.txt)"
IPADDR_MYSQL_SELECT="$(<${CONF_DIR}/mysql_select.txt)"
SSH_REMOTE_USER=$(awk -F= '/^ssh_user/ {print $2}' ${MASTERHA_MYSQL_CONF})
MYSQL_USER=$(awk -F= '/^user/ {print $2}' ${MASTERHA_MYSQL_CONF})
MYSQL_PASSWORD=$(awk -F= '/^password/ {print $2}' ${MASTERHA_MYSQL_CONF})
TMP_SUFFIX=$(date '+%s').$$

## Rewrite haproxy.cfg
cd ${CONF_DIR}
/bin/sed -e "
    s/%IPADDR_MYSQL_SELECT%/${IPADDR_MYSQL_SELECT}/
    s/%IPADDR_MYSQL_UPDATE%/${IPADDR_MYSQL_UPDATE}/
" ${HAPROXY_CONF_TEMPLATE} > ${HAPROXY_CONF}.${TMP_SUFFIX}
md5sum ${HAPROXY_CONF}.${TMP_SUFFIX} > ${HAPROXY_CONF}.${TMP_SUFFIX}.md5

## Copy haproxy.cfg, and reload haproxy.
for TARGET_HOST in ${TARGET_HOSTS}
do
    echo "Updating haproxy config: ${TARGET_HOST}"

    scp ${SSH_OPTIONS} ${HAPROXY_CONF}.${TMP_SUFFIX}{,.md5} ${UPDATE_HAPROXY_SCRIPT} \
        ${SSH_REMOTE_USER}@${TARGET_HOST}:${HAPROXY_CONF_DIR}/
    if [ $? -ne 0 ] ; then
    echo "Unable to send config file."
        rm ${HAPROXY_CONF}.${TMP_SUFFIX}{,.md5}
        exit -1
    fi

    ssh ${SSH_OPTIONS} ${SSH_REMOTE_USER}@${TARGET_HOST} \
        ${HAPROXY_CONF_DIR}/${UPDATE_HAPROXY_SCRIPT} ${HAPROXY_CONF}.${TMP_SUFFIX}
    if [ $? -ne 0 ] ; then
    echo "Unable to reload haproxy config."
        rm ${HAPROXY_CONF}.${TMP_SUFFIX}{,.md5}
        exit -1
    fi
done

mv ${HAPROXY_CONF}.${TMP_SUFFIX} ${HAPROXY_CONF}
rm ${HAPROXY_CONF}.${TMP_SUFFIX}.md5

## Set read_only=0 to master.
if /usr/bin/mysqladmin -u${MYSQL_USER} -p${MYSQL_PASSWORD} -h ${IPADDR_MYSQL_UPDATE} ping &>/dev/null; then
    /usr/bin/mysql -u${MYSQL_USER} -p${MYSQL_PASSWORD} \
        -h ${IPADDR_MYSQL_UPDATE} -e 'SET GLOBAL read_only = 0;'
fi

exit 0
  • /opt/masterha/conf/update_haproxy.sh: HAProxyの設定切り替え実行スクリプト
#!/bin/bash
HAPROXY_CONF=/etc/haproxy/haproxy.cfg
haproxy_conf_tmp=$1

PATH=/sbin:/bin:/usr/sbin:/usr/bin
export PATH

clean_conf() {
    rm -f $haproxy_conf_tmp $haproxy_conf_tmp_md5
}

if [ -z "$haproxy_conf_tmp" ]; then
    echo "Usage: $(basename $0) haproxy.cfg.XXXX"
    exit 1
fi
haproxy_conf_tmp_md5=${haproxy_conf_tmp}.md5

cd $(dirname ${HAPROXY_CONF})

if [ ! -f $haproxy_conf_tmp ]; then
    echo "${haproxy_conf_tmp} does not found."
    clean_conf
    exit 1
fi
if [ ! -f $haproxy_conf_tmp_md5 ]; then
    echo "${haproxy_conf_tmp_md5} does not found."
    clean_conf
    exit 1
fi

md5sum -c $haproxy_conf_tmp_md5
if [ $? -ne 0 ]; then
    echo "md5sum does not match."
    clean_conf
    exit 1
fi

/bin/mv $haproxy_conf_tmp $HAPROXY_CONF
rm $haproxy_conf_tmp_md5
/etc/init.d/haproxy reload

exit 0
  • /opt/masterha/conf/haproxy.conf.template: HAProxy設定のテンプレート
global
    log         127.0.0.1 local0
    maxconn     500
    user        haproxy
    group       haproxy
    daemon
defaults
    mode                    tcp
    log                     global
    retries                 3
    timeout connect         10s
    timeout client          1m
    timeout server          1m
listen mysql-select
    bind 127.0.0.1:3316
    mode tcp
    option mysql-check user haproxy
    balance roundrobin
    server slave %IPADDRESS_SELECT%:3306 check port 3306 inter 3000 fall 3
    server master %IPADDRESS_UPDATE%:3306 check port 3306 inter 3000 backup
listen mysql-update
    bind 127.0.0.1:3306
    mode tcp
    option mysql-check user haproxy
    balance roundrobin
    server master %IPADDRESS_UPDATE%:3306 check port 3306 inter 3000 fall 3

HAProxy設定対象のサーバは、次のファイルに設定を記述します。

$ echo -e "web1\nweb2\nweb3" > /opt/masterha/conf/hostlist.txt

また、MySQLに、HAProxyの死活監視用ユーザを作成します。パスワード無しである必要があるため、接続元の制限を行う事をお勧めします。

mysql> GRANT USAGE ON *.* TO haproxy@'???.???.???.???';

初期設定、及びFailback時の設定の頒布は、次のファイルにMaster, Slave各DBのIPアドレスを保存した後、update_all_proxy.shを実行します。なお、旧MasterをSlaveとして復帰させるときにupdate_all_proxy.shを実行すると、SELECT用のDBが切り替わるため瞬断が発生します。気をつけてください。

# echo "(Slave DBのIP)" > /opt/masterha/conf/mysql_select.txt
# echo "(Master DBのIP)" > /opt/masterha/conf/mysql_update.txt
# ./update_all_proxy.sh

おわりに

ここまで、MHAとHAProxyを組み合わせて、MySQLの更新系DBの冗長化を行う事例を紹介しました。HAProxyを採用する理由として、主にARP Requestの問題がある点を述べています。そして、2台構成の場合は、ちょっとした細工が必要な点もお話ししました。

様々な選択肢が存在する中で、自分が携わるITインフラにとってどの手段が適切かを調査・判断し構築・運用する事は、骨は折れますが、なかなか面白いものです。

それでは、ごきげんよう。

参考文献

※1 初期構築時に採用する場合は、検証しやすい状況でしょうから(B)を用いたかもしれません。

※2 私が検証で用いた仮想環境では、試しに300msec.インターバルで3台のHAProxyからアクセスした場合、DBサーバ(2vCPU)のCPU sysがtop上で5%程度になっていました。ここまで上がるのは、TCPのOpen/Closeのオーバーヘッドのせいかと思います。実際、DBがフェイルオーバーをする時はRelay Logの巻き上げを待ちますので、こんなに短くする必要はありません。秒単位で充分です。

更新履歴

  • 2013/06/28: MySQLにHAProxy用のユーザを作成する手順を追記