AWS ALB 마이그레이션 - Haproxy Active-Passive 구성(feat. Keepalived)

ALB와 Public Ipv4 비용을 절감하기 위해 Haproxy + Keepalived Active Passive 구성으로 마이그레이션하기로 결정했다.

1. 사전 작업

# 타임존 설정
sudo timedatectl set-timezone Asia/Seoul

# haproxy + keepalived
sudo apt-get update
sudo apt-get install haproxy
sudo apt-get install keepalived

# 인증서 및 설정 동기화 자동화
sudo apt install inotify-tools

# certbot
sudo snap install core
sudo snap refresh core
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

# aws cli
sudo apt-get install unzip
curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

2. Haproxy 설정

  • 설정 파일 : /etc/haproxy/haproxy.cfg
  • 로그 파일 : /var/log/haproxy.log
# Haproxy Dashboard - 관리자 IP 대역만 접근할 수 있도록 SG 설정 필요
frontend stats
        bind *:8404
        stats enable
        stats uri /
        stats refresh 10s

# HTTP
frontend http-api.maeil-mail.kr
        bind *:80
        http-request set-header X-Forwarded-Proto http
        default_backend apps

# HTTPS
frontend https-api.maeil-mail.kr
        bind *:443 ssl crt /etc/haproxy/certs/site.pem
        http-request set-header X-Forwarded-Proto https
        http-request set-header X-SSL %[ssl_fc]
        acl letsencrypt-acl path_beg /.well-known/acme-challenge/
        use_backend letsencrypt-backend if letsencrypt-acl
        default_backend apps

# APPS
backend apps
        redirect scheme https code 301 if !{ ssl_fc }
        balance roundrobin
        server mm-app-1 172.31.82.62:8080 check
        server mm-app-2 172.31.98.184:8080 check

# 인증서 발급 및 갱신 - HTTPS의 ACL letsencrypt-acl 설정 확인
# Letsencrypt
backend letsencrypt-backend
        server letsencrypt 127.0.0.1:9090

3. Haproxy와 커널 튜닝

로드 밸런서는 일반적인 애플리케이션 서버보다 훨씬 많은 연결을 직접 받아야 한다. 따라서 Haproxy 설정뿐만 아니라 리눅스 커널의 네트워크 관련 설정도 함께 조정해야 한다.

Haproxy 튜닝

Haproxy에서는 워커 프로세스 또는 스레드 수를 조정해서 여러 CPU 코어를 활용할 수 있다. 또한, 동시에 처리할 수 있는 연결 수를 제한하는 maxconn 값을 상황에 맞게 설정해야 한다.

global
        nbthread 2
        maxconn 100000
        tune.ssl.cachesize 100000
        tune.ssl.lifetime 600

nbthread는 Haproxy가 사용할 스레드 수를 설정한다. 예전에는 프로세스 수를 늘리는 방식도 사용했지만, 현재는 스레드 기반 설정을 우선 고려하는 편이 운영하기 쉽다. maxconn은 Haproxy가 동시에 처리할 수 있는 최대 연결 수를 의미한다.

SSL 캐시도 같이 설정할 수 있다. 같은 클라이언트가 여러 번 TLS 연결을 맺는 경우 세션 정보를 재사용할 수 있으므로, TLS 핸드셰이크 비용을 줄이는 데 도움이 된다.

커널 튜닝

리눅스의 기본 네트워크 설정은 높은 연결 속도를 처리할 필요가 없는 일반적인 시스템에 맞춰져 있다. 로드 밸런서처럼 짧은 시간에 많은 연결이 몰리는 서버에서는 TCP 백로그, 수신 큐, 파일 디스크립터 제한을 조정해야 한다.

net.ipv4.tcp_max_syn_backlog = 100000
net.core.somaxconn = 100000
net.core.netdev_max_backlog = 100000
fs.file-max = 262144

net.ipv4.tcp_max_syn_backlog는 시스템이 견딜 수 있는 반개방 TCP 연결 수와 관련된다. 클라이언트의 SYN을 받고 서버가 SYN+ACK를 보냈지만, 아직 클라이언트의 최종 ACK가 도착하지 않은 상태를 반개방 연결이라고 한다. 부하가 급증하는 동안 로드 밸런서에 반개방 연결이 쌓이는 것은 자연스러운 일이므로, 이 값을 충분히 높이는 것이 좋다. 백로그 크기가 작으면 고부하 상황에서 신규 요청을 받아들이기 어려워진다.

net.core.somaxconn은 listen backlog의 상한과 관련된다. 애플리케이션이나 Haproxy에서 큰 backlog를 설정해도 커널 상한이 낮으면 실제로는 그만큼 사용할 수 없으므로 함께 높여야 한다.

net.core.netdev_max_backlog는 네트워크 인터페이스로 들어온 패킷이 커널에서 처리되기 전까지 대기할 수 있는 수신 큐 크기를 의미한다. 순간적으로 패킷이 몰리는 환경에서는 이 값을 키워 패킷 드롭 가능성을 줄일 수 있다.

파일 디스크립터 제한도 중요하다. Haproxy는 클라이언트 연결과 백엔드 연결을 모두 다루기 때문에 일반적인 작업 부하에서도 많은 파일 디스크립터를 사용한다. fs.file-max는 리눅스 커널이 할당할 수 있는 최대 파일 핸들 수를 설정한다. 일반적으로 4MB RAM당 256개의 파일 핸들을 기준으로 잡을 수 있으며, 4GB RAM VM이라면 4096 / 4 * 256 = 262144 정도가 된다.

fs.nr_open은 프로세스가 열 수 있는 파일 디스크립터 수의 상한과 관련된다. 다만 이 값의 최대치는 커널 내부의 sysctl_nr_open_max에 의해 제한되며, x86_64 기준으로는 2147483584까지 설정할 수 있다.

4. Keepalived 구성

  • 설정 파일 : /etc/keepalived/keepalived.conf
  • 로그 파일 : /var/log/syslog

active 설정

global_defs {
    router_id maeilmail-rt-active
    script_user root
}

vrrp_script check_haproxy {
    script "systemctl is-active --quiet haproxy"
    interval 2
    fall 2
    rise 2
}

vrrp_instance VI_1 {
    debug 2
    interface ens5
    state MASTER
    virtual_router_id 50
    advert_int 3
    priority 110
    unicast_src_ip 172.31.27.180

    authentication {
        auth_type PASS
	    auth_pass testpassword
    }

    unicast_peer {
        172.31.34.184
    }

    track_script {
        check_haproxy
    }

    notify_master /etc/keepalived/failover.sh
}

passive 설정

global_defs {
    router_id maeilmail-rt-passive
    script_user root
}

vrrp_script check_haproxy {
    script "systemctl is-active --quiet haproxy"
    interval 2
    fall 2
    rise 2
}

vrrp_instance VI_1 {
    debug 2
    interface ens5
    state BACKUP
    virtual_router_id 50
    advert_int 3
    priority 100
    unicast_src_ip 172.31.34.184

    authentication {
        auth_type PASS
        auth_pass testpassword
    }

    unicast_peer {
        172.31.27.180
    }

    track_script {
        check_haproxy
    }

    notify_master /etc/keepalived/failover.sh
}

failover 스크립트(failover.sh)

#!/bin/bash

EIP= # EIP
INSTANCE_ID= # 인스턴스 ID

/usr/local/bin/aws ec2 disassociate-address --public-ip $EIP --endpoint-url https://ec2.ap-northeast-2.api.aws --profile lb-user
/usr/local/bin/aws ec2 associate-address --public-ip $EIP --instance-id $INSTANCE_ID --endpoint-url https://ec2.ap-northeast-2.api.aws --profile lb-user
  • backup 노드에서 해당 스크립트를 실행하려면 인터넷 접근이 필요하다. 하지만, 우리는 EIP가 active 노드에만 존재한다.
  • 이 문제를 해결하기 위해서 ipv6를 설정해서 인터넷에 접근했다. (아래에 설정 방법 정리)
  • 다만 CLI의 기본 경로는 https://ec2.ap-northeast-2.amazonaws.com/ 로 ipv4만 지원하기 때문에 connection timeout이 발생한다.
  • 따라서, 이를 듀얼 스택 경로로 요청하도록 변경해야하는데, 서울 리전을 기준으로 ec2.ap-northeast-2.api.aws에 요청해야한다.

5. SSL 설정

인증서 발급, fullchain.pem이랑 privkey.pem을 합쳐서 site.pem으로 만들어야한다.

sudo certbot certonly \
    --standalone \ 
    --agree-tos 
    -m team.maeilmail@gmail.com \
    -w /var/www/letsencrypt \
    -d test.maeil-mail.kr \ 
    --http-01-port=9090

DOMAIN='test.maeil-mail.kr' sudo -E bash -c 'cat /etc/letsencrypt/live/$DOMAIN/fullchain.pem /etc/letsencrypt/live/$DOMAIN/privkey.pem > /etc/haproxy/certs/site.pem'

아래는 certbot renew 타이머 정보를 조회하는 방법이다.

# certbot renew 타이머 정보 조회
systemctl cat snap.certbot.renew.timer

# 다음 renew 실행 시간 조회
systemctl list-timers | grep certbot

renew 할때마다 fullchain.pem이랑 privkey.pem 합쳐야해서 deploy hook 사용해야한다.

/etc/letsencrypt/renewal-hooks/deploy/haproxy-pem-hook.sh

#!/bin/bash

DOMAIN="test.maeil-mail.kr"
TARGET_PEM="/etc/haproxy/certs/site.pem"
CERT="/etc/letsencrypt/live/$DOMAIN"

cat $CERT/fullchain.pem $CERT/privkey.pem > $TARGET_PEM

echo "$TARGET_PEM generated."

스크립트 실행되는지 실행하려면 강제 리뉴얼로 테스트할 수 있다.

sudo certbot renew --force-renewal

6. 설정 동기화

/home/ubuntu/sync/watch_sync.sh

#!/bin/bash
WATCH_DIRS="/etc/haproxy /etc/haproxy/certs"
PIPE="/tmp/haproxy-pipe"
LOG_FILE="/home/ununtu/sync/log/haproxy-sync.log"
REMOTE_USER="ubuntu"
REMOTE_HOST="172.31.34.184"
INOTIFY_PID=0

exec >> $LOG_FILE 2>&1

cleanup() {
  echo "$(date) - cleanup() called, killing $INOTIFY_PID"
  [[ $INOTIFY_PID -gt 0 ]] && kill $INOTIFY_PID
  rm -f "$PIPE"
  exit 0
}
trap cleanup SIGINT SIGTERM EXIT

[[ -p "$PIPE" ]] && rm -f "$PIPE"
mkfifo "$PIPE"

echo "$(date) - File Sync Started, main PID = $$"

inotifywait -mrq -e close_write $WATCH_DIRS > "$PIPE" &
INOTIFY_PID=$!

while read path action file;  do
  echo "$(date) - Detected $action on $path$file"

  if [ "$file" = "haproxy.cfg" ]; then
    scp -p /etc/haproxy/haproxy.cfg $REMOTE_USER@$REMOTE_HOST:/home/ubuntu/tmp/
    ssh $REMOTE_USER@$REMOTE_HOST "sudo mv /home/ubuntu/tmp/haproxy.cfg /etc/haproxy/ && sudo systemctl reload haproxy"
    sudo systemctl reload haproxy
  fi

  if [ "$file" = "site.pem" ]; then
    scp -p /etc/haproxy/certs/site.pem $REMOTE_USER@$REMOTE_HOST:/home/ubuntu/tmp/
    ssh $REMOTE_USER@$REMOTE_HOST "sudo mv /home/ubuntu/tmp/site.pem /etc/haproxy/certs/ && sudo systemctl reload haproxy"
    sudo systemctl reload haproxy
  fi
done < "$PIPE"
  • active-passive 노드 간 site.pem이랑 haproxy 설정 동기화를 위해서 inotifywait으로 감지한다.
  • close_wait만 감지하도록 설정하고, if로 haproxy.cfg랑 site.pem이 변경되면 백업 노드로 전송한다.
    • 이거 하려면, passive 노드에 해당 경로에 대한 디렉터리를 미리 만들어야 하고..
    • active 노드의 public key를 passive 노드 ~/.ssh/authorized_keys에 추가해야한다.
  • 스크립트 프로세스에 SIGINT SIGTERM 신호 받으면 트랩으로 inotify 프로세스랑 네임드 파이프를 정리한다. (inotifywait 종료안돼서.. 진짜 골치아팠다..)
  • 위에 renewal-hook이랑 마찬가지로 항상 설정을 바꾸면 reload 시켜야한다.
  • 입력 리디렉션으로 while에 inotifywait 반환값을 줘야한다.
  • 로그는 /home/ubuntu/sync/log/haproxy-sync.log에 출력했다.

이제 해당 스크립트를 자동으로 실행되게 만들려고 한다. systemd에 service로 등록해줄것이다.

/usr/lib/systemd/system/watch-sync.service

[Unit]
Description=HAProxy Config Sync Service
After=network-online.target

[Service]
Type=simple
ExecStart=/home/ubuntu/sync/watch_sync.sh
Restart=on-failure
User=ubuntu

[Install]
WantedBy=multi-user.target

아래와 같이 입력하고 인스턴스를 재부팅했다.

sudo systemctl daemon-reload
sudo systemctl start watch-sync
sudo systemctl enable watch-sync
sudo systemctl status watch-sync
shell
서비스랑 프로세스 모두 잘 띄워져있다..!

마지막으로 동기화 테스트를 해보자.

shell

7. Failover

앞서 언급했던 것과 같이 AWS CLI를 사용해서 EIP를 할당해야하기 때문에 인터넷 접근이 필요하다. EIP가 없는 백업 노드는 인터넷 접근이 불가능하다. public ipv4를 사용해서 발생하는 비용을 줄이기 위해 ipv6를 할당해서 페일오버에 필요한 인터넷 접근을 했다.

ipv6 할당

shell
public ipv4 없이도 인터넷이 된다..!
  • VPC ipv6 CIDR을 설정한다.
  • public subnet에 ipv6 CIDR을 설정한다.
  • EC2에 ipv6 주소를 할당한다.
  • 퍼블릭 서브넷 라우팅 테이블 ::/0 경로에 인터넷 게이트웨이를 추가해줘야한다.
  • DNS checker에서 IPv6 Address Lookup 수행
  • 잘안되는 경우, 라우팅 테이블 및 SG에 ipv6 관련 규칙을 살펴보자.
  • 클라이언트가 ipv6 주소가 있다면 http://[ipv6 address]로 접속할 수도 있긴한데, 국내 ipv6 보급률은 높지 않기 때문에 해당 경로에 대한 haproxy bind와 SG 규칙은 따로 추가하지 않는다.

AWS Configuration

  • EIP를 할당하기 위한 IAM 사용자를 만든다.
  • 고객관리형 정책 하나 만들고 -> “ec2:DisassociateAddress”, “ec2:AssociateAddress”, “sns:Publish” 쓰기 권한을 추가한다.
  • 사용자에게 정책 할당 및 access key 발급
  • aws confiure --profile lb-user 실행하여 키를 비롯한 설정 정보 입력한다. (주의 : failover 스크립트를 실행할 리눅스 유저로 접속하여 configure 해야한다.)

Failover Test

sudo systemctl start haproxy
sudo systemctl start keepalived
sudo systemctl enable haproxy
sudo systemctl enable keepalived

현재 API 주소에 요청을 보내면 active 노드에서만 요청을 처리한다. keepalived에서 active 노드는 MASTER STAGE가 되고, passive 노드는 BACKUP STAGE로 대기한다. 테스트는 haproxy 다운, keepalived 다운 순서로 진행했다.

1번 케이스. active 노드 haproxy 종료되는 경우

shell
haproxy 다운 시 failover 수행
shell
haproxy 재시작 시 기존 active 노드 MASTER 승격

2번 케이스. active 노드 keepalived 종료되는 경우

shell
keepalived 다운 시 failover 수행
shell
keepalived 재시작 시 기존 active 노드 MASTER 승격

알림 추가하기

관리자도 모르게 failover 되면 기존 active 노드를 조치하지 못해 passive 마저 다운될 가능성이 있다. 따라서, 알림이 필요하다. 기존 active는 굳이 알림이 필요없다고 판단해서 passive 노드의 failover.sh 스크립트만 아래처럼 수정했다. 사전에 SNS 표준형 토픽을 하나 만들고, 이메일로 구독했다.

EIP= # EIP
INSTANCE_ID= # 인스턴스 ID
TOPIC_ARN= # 토픽 ARN
FAILOVER_MESSAGE= "$(date) - active down, haproxy failover start. - 172.31.34.184 will be active"

/usr/local/bin/aws ec2 disassociate-address --public-ip $EIP --endpoint-url https://ec2.ap-northeast-2.api.aws --profile lb-user
/usr/local/bin/aws ec2 associate-address --public-ip $EIP --instance-id $INSTANCE_ID --endpoint-url https://ec2.ap-northeast-2.api.aws --profile lb-user
/usr/local/bin/aws sns publish --topic-arn $TOPIC_ARN --message "$FAILOVER_MESSAGE" --endpoint-url https://sns.ap-northeast-2.api.aws --profile lb-user

이제 active 노드 인스턴스를 종료 시켜보려고 한다.

shell
active 노드 종료 시 failver 수행
shell
알림도 잘온다!

함께 봤던 자료