본문 바로가기

Database/MySQL

HAProxy 를 이용한 read / write split 및 failover

개요

MHA 기반으로 MySQL 의 서버사이드의 Failover 를 구성하였을때 클라이언트 사이드 Failover 로 HAProxy 를 이용하여 테스트 진행한다.
테스트는 2개 노드로 진행하지만 실 운영에서는 최소 3개 노드가 필요하다.

HAProxy 를 이용한 failover 예상 아키텍쳐

  • MySQL
  • MHA manager
  • Proxy( HAProxy )
  • L4,L7, GSLB

HAProxy 특징

  • Event-Driven Architeture
  • Non-Blocking I/O
  • zero-copy
  • multi process or multi thread model
  • CPU Cache efficiency
    • 같은 작업에 대해 동일 CPU 를 연결 → cache miss 를 줄여 성능향상
  • TCP, HTTP close mode
    • Kernel 85%, HAProxy 15%
  • HTTP keep-alive mode
    • Kernel 70%, HAProxy 30%
  • 대부분의 사용자(99%)에겐 싱글코어로 충분하다
    • 초당 300000 proxy 기능 수행 가능
  • Context switch latency 비용이 큰 process 와 CPU 를 공유하면 안된다.
    • 계산집약적 process 등
    • NUMA 설정 ( cpu-mapoption )

HAProxy 주요기능

HAProxy 의 기능은 범용 Proxy 인 만큼 매우 다양하지만 DB end point 에 대한 load balancing 과 failover 관점에서 필요한 주요 기능들에 대해서 기술하면 다음과 같다.

Proxying

두개의 독립적인 연결을 통해 클라이언트와 서버간에 데이터를 전송하는 기능

  • 서버포트와 서비스포트의 분리
  • 양쪽에 서로 다른 프로토콜을 지원( ipv4, ipv6, unix)
  • Timeout 제공

Statistics

  • 웹 기반의 통계데이터를 제공
  • 다른 HAProxy 노드의 가용성을 보고 받을 수 있다.
  • 데이터를 csv 형태로도 제공되어 다른 그래프 도구의 데이터로 사용될 수 있다.
  • 관리자 기능 제공
  • Prometheus 로 내보내기도 가능 ( PMM )
    • 데이터베이스 와 함게 모니터링 구성하기에 용이

Monitoring

HAProxy 는 서버 및 네트워크의 상태를 모니터링 한다.

  • 주기적으로 서버의 상태를 모니터링
  • 다양한 체크 방법 제공
    • TCP connect, HTTP request, SMTP hello, SSL hello, LDAP, SQL, Redis, send/expect scripts, all with/without SSL
  • 상태 변경에 대한 실패 이유와 당시의 수신된 데이터등을 통계인터페이스로 공유 가능

High availability

  • 유효한 서버만 사용되며 이외의 것은 자동으로 사용대상에서 삭제된다.
  • 백업 서버는 active 서버들이 사용되지 않을때 자동으로 사용되며 가능하다면 session 이 손실되지 않음
  • Stateless design
    • Caching, load balancing, scale-out 용이

Load balancing

  • 10개 이상의 load balancing algorithm 을 제공
    • Round robin
    • Leastconn
  • 서버다 가중치 적용 가능
  • 동적 가중치 가능
  • Slow-start 지원
  • 서버당 다양한 설정 가능
    • 연결 수, 연결 슬롯 수

HAProxy 를 이용한 Failover

Failover 과정

  • master 노드 장애 발생
    • 장애 발생시 수초~수분까지 시간이 소요될 수 있다.
  • 새로운 master 선정
    • mha 에 의해 master 가 lag 이 가장 적은 slave 를 master 로 선정한다.
  • 새로운 master 를 기준으로 데이터 sync
  • 구 master 노드는 복구 후 slave 로 편입
    • dba 에 의해 read_only 가 선행되고 새로운 master 를 통해 데이터를 sync 한다.

HAProxy 에서 제공하는 옵션

  • mysql-check
    listen mysql-***
      ... ...
      option mysql-check user haproxy
      ... ...
  • 문제점
    • mysql initial handshake protocol 을 이용하여 접속 가능 여부만 확인한다.

Switch 로 대체할 수 없는 이유

  • 단순한 ip/port 기반의 헬스체크
  • MySQL 에 연결이 되어도 서비스가 불가한 상태일 수도 있다.
    • IO Thread
    • SQL Thread
    • read_only status

MySQL 에 특화된 health check 필요

health check 를 위해 필요한 것을 haproxy 에서 제공하지 않는다.

다만 haproxy 의 기능과 사용자 스크립트, 외부 프로그램 등을 통해 health check 를 할 수 있다.

Master 와 slave 정상노드조건

장애가 발생하고 failover 가 끝나는 과정까지 master 노드와 slave 노드들을 구분할 수 있어야 한다.

 조건
masterglobal variable read_only = off
slave
  • variable read_only = on
  • io slave thread = on
  • sql slave thread = on
  • variable Seconds_Behind_Master <= SLAVE_LAG_LIMIT
    • 사용자가 지정한 lag 한계치 보다 적어야 한다.

HAproxy + user script

read / write split 를 위해 listen read(3307 port) 와 listen write(3306 port) 를 구성한다. 서버체크는 사용자 정의로 진행되야 하기 때문에 external-check 를 사용한다.

주의 할 것은 external-check path 는 external-check command 뿐 아니라 해당 스크립트 내에서 실행되는 모든 명령어의 path 도 함께 기입해 줘야 한다.

haproxy.cnf

global
    log         127.0.0.1 local0

    chroot      /var/lib/haproxy
    pidfile     /var/run/haproxy.pid
    maxconn     4000
    user        haproxy
    group       haproxy
    daemon
    nbproc      1
    nbthread    1

    # turn on stats unix socket
    #stats socket /var/lib/haproxy/stats

#---------------------------------------------------------------------
# common defaults that all the 'listen' and 'backend' sections will
# use if not designated in their block
#---------------------------------------------------------------------
defaults
    mode                    tcp
    log                     global
    timeout queue           1m
    timeout connect         10s
    timeout client          1m
    timeout server          1m
    timeout check           10s

listen stats
    bind *:8080
    mode  http
    stats enable
    stats realm Haproxy\ Statistics
    stats uri /stats
    stats auth admin:admin

listen mysql-masters
    bind *:3306
    log 127.0.0.1:514 local0
    balance roundrobin
    option external-check
    external-check path /usr/local/bin:/bin
    external-check command /usr/local/bin/master_check.sh
    option allbackups
    server sql1 192.168.0.100:3306 check
    server sql2 192.168.0.101:3306 check

listen mysql-slaves
    bind *:3307
    log 127.0.0.1:514 local0
    balance roundrobin
    option external-check
    external-check path /usr/local/bin:/bin
    external-check command /usr/local/bin/slave_check.sh
    option allbackups
    server sql1 192.168.0.100:3306 check
    server sql2 192.168.0.101:3306 check

master_check.sh

#!/bin/bash

SLAVE_LAG_LIMIT=5
MYSQL_HOST="$3"
MYSQL_PORT="$4"
MYSQL_USERNAME='test'
MYSQL_PASSWORD='test'
MYSQL_BIN='/bin/mysql'
MYSQL_OPTS="-q -A --connect-timeout=10"
TMP_FILE="/dev/shm/mysqlchk.$$.out"
ERR_FILE="/dev/shm/mysqlchk.$$.err"
FORCE_FAIL="/dev/shm/proxyoff"


preflight_check()
{
    for I in "$TMP_FILE" "$ERR_FILE"; do
        if [ -f "$I" ]; then
            if [ ! -w $I ]; then
                echo -e "Cannot write to $I\r\n"
                exit 2
            fi
        fi
    done
}

return_ok()
{
    exit 0
}
return_fail()
{
    exit 255
}

preflight_check

if [ -f "$FORCE_FAIL" ]; then
        echo "$FORCE_FAIL found" > $ERR_FILE
        return_fail
fi

CMDLINE="$MYSQL_BIN $MYSQL_OPTS --host=$MYSQL_HOST --port=$MYSQL_PORT --user=$MYSQL_USERNAME --password=$MYSQL_PASSWORD -e"

READ_ONLY=$($CMDLINE 'SHOW GLOBAL VARIABLES LIKE "read_only"' --vertical 2>/dev/null | tail -1 | awk {'print $2'})
if [ "${READ_ONLY}" == "OFF" ];
then
    return_ok
fi

return_fail

slave_check.sh

#!/bin/bash

SLAVE_LAG_LIMIT=5
MYSQL_HOST="$3"
MYSQL_PORT="$4"
MYSQL_USERNAME='test'
MYSQL_PASSWORD='test'
MYSQL_BIN='/bin/mysql'
MYSQL_OPTS="-q -A --connect-timeout=10"
TMP_FILE="/dev/shm/mysqlchk.$$.out"
ERR_FILE="/dev/shm/mysqlchk.$$.err"
FORCE_FAIL="/dev/shm/proxyoff"

preflight_check()
{
    for I in "$TMP_FILE" "$ERR_FILE"; do
        if [ -f "$I" ]; then
            if [ ! -w $I ]; then
                echo -e "Cannot write to $I\r\n"
                exit 1
            fi
        fi
    done
}

return_ok()
{
    exit 0
}
return_fail()
{
    exit 1
}

preflight_check

if [ -f "$FORCE_FAIL" ]; then
        echo "$FORCE_FAIL found" > $ERR_FILE
        return_fail
fi

CMDLINE="$MYSQL_BIN $MYSQL_OPTS --host=$MYSQL_HOST --port=$MYSQL_PORT --user=$MYSQL_USERNAME --password=$MYSQL_PASSWORD -e"
SLAVE_IO=$(${CMDLINE} 'SHOW SLAVE STATUS' --vertical 2>/dev/null | grep Slave_IO_Running |  tail -1 | awk {'print $2'})
SLAVE_SQL=$(${CMDLINE} 'SHOW SLAVE STATUS' --vertical 2>/dev/null | grep Slave_SQL_Running | head -1 | awk {'print $2'})


# 1. read_only = on
READ_ONLY=$($CMDLINE 'SHOW GLOBAL VARIABLES LIKE "read_only"' --vertical 2>/dev/null | tail -1 | awk {'print $2'})
[[ "${READ_ONLY}" == "OFF" ]] && return_fail

# 2. Slave_IO_Running = Yes and Slave_SQL_Running = Yes
if [[ "${SLAVE_IO}" != "Yes" ]] || [[ "${SLAVE_SQL}" != "Yes" ]]; then
    return _fail
fi

# 3. lag < $SLAVE_LAG_LIMIT
SLAVE_LAG=$(${CMDLINE} 'SHOW SLAVE STATUS' --vertical 2>/dev/null | grep Seconds_Behind_Master | tail -1 | awk {'print $2'})
if [ $SLAVE_LAG -gt $SLAVE_LAG_LIMIT ] ; then
    return_fail
fi

return_ok

reference