왜 설정 리로드가 문제인가
HAProxy는 설정 변경을 반영할 때 프로세스를 재시작하거나 리로드한다. 문제는 전통적인 리로드 방식에서 이미 열려 있는 리스닝 소켓을 잠깐 닫았다가 새 프로세스가 다시 여는 순간이 존재한다는 점이다. 이 짧은 틈(수 ms~수십 ms) 동안 도착한 SYN 패킷은 Connection refused 또는 재전송으로 처리되고, 초당 수천 커넥션을 받는 프론트엔드라면 이 구간이 곧 5xx 스파이크로 나타난다.
과거에는 iptables로 SYN 패킷을 잠시 DROP했다가 새 프로세스가 뜨면 푸는 편법을 썼다. TCP 재전송에 기대는 방식이라 지연이 커지고 관리가 복잡했다. HAProxy 1.8부터는 SO_REUSEPORT와 마스터-워커 모델을 이용한 seamless reload가 표준이 되었다.
seamless reload의 동작 원리
핵심은 두 가지다. 첫째, 새로 뜨는 프로세스가 기존 리스닝 소켓 파일 디스크립터를 물려받아 소켓을 절대 닫지 않는다. 둘째, 기존 워커는 즉시 죽지 않고 처리 중이던 커넥션을 끝까지 소화한 뒤(graceful) 종료한다. 이를 위해 마스터 프로세스가 유닉스 소켓을 통해 FD를 전달한다.
결과적으로 리스닝 소켓은 한 번도 닫히지 않으므로 신규 커넥션 유실이 사라지고, 기존 커넥션도 강제 종료되지 않는다.
기본 설정
마스터-워커 모드로 실행하고, FD 전달용 유닉스 소켓을 global에 지정한다.
global
# 마스터-워커 모드 (systemd 없이도 -Ws)
master-worker
# FD를 전달받을 stats/admin 소켓
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
log /dev/log local0
defaults
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
frontend web
bind *:80
default_backend app
backend app
server s1 10.0.0.11:8080 check
server s2 10.0.0.12:8080 check
expose-fd listeners가 핵심이다. 이 옵션이 있어야 리로드 시 새 프로세스가 소켓을 열지 않고 기존 FD를 소켓 경유로 넘겨받는다.
리로드 실행 방법
systemd 환경이라면 유닛 파일에서 재사용 소켓을 넘겨받도록 구성하고 reload를 호출한다.
# /etc/systemd/system/haproxy.service (핵심 부분)
[Service]
ExecStartPre=/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg -c -q
ExecStart=/usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -p /run/haproxy.pid
ExecReload=/usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -c -q
ExecReload=/bin/kill -USR2 $MAINPID
# 배포 스크립트
set -euo pipefail
# 1. 문법 검증 먼저 (실패 시 리로드 안 함)
haproxy -c -f /etc/haproxy/haproxy.cfg
# 2. seamless reload 트리거
systemctl reload haproxy
# 3. 새 워커가 리스닝 중인지 확인
echo "show info" | socat - /run/haproxy/admin.sock | grep Process_num
-USR2 시그널을 마스터가 받으면 설정을 다시 읽고 새 워커를 띄운 뒤 FD를 넘긴다. 반드시 리로드 전에 -c로 문법을 검증해야 한다. 검증을 생략하면 잘못된 설정으로 워커가 뜨지 못하고 서비스가 무너질 수 있다.
reload와 restart 비교
| 구분 | reload (USR2) | restart |
|---|---|---|
| 리스닝 소켓 | 유지(FD 상속) | 닫혔다 다시 열림 |
| 신규 커넥션 | 무손실 | 순간 유실 가능 |
| 기존 커넥션 | graceful 종료 | 강제 종료 |
| 용도 | 설정 변경 | 바이너리 교체·긴급 |
주의점
워커 누적: 오래 유지되는 커넥션(웹소켓, 롱폴링)이 많으면 old 워커가 종료되지 못하고 계속 쌓인다. hard-stop-after로 상한을 걸어 일정 시간 후 강제 종료한다.
global
hard-stop-after 5m
메모리: 리로드가 잦고 old 워커가 오래 남으면 프로세스가 여러 개 공존해 메모리가 증가한다. 리로드 빈도를 배치로 묶고 ps로 워커 수를 모니터링하자.
상태 정보: stick table, 카운터 같은 런타임 상태는 리로드 시 새 워커에서 초기화된다. 세션 지속성이 중요하면 peers로 상태를 동기화하거나 외부 스토리지를 사용해야 한다. seamless reload는 커넥션 무손실을 보장할 뿐, 인메모리 상태의 연속성까지 보장하지는 않는다는 점을 반드시 구분해야 한다.