← 목록으로 돌아가기

HAProxy stick-table로 Redis 없이 Rate Limiting 구현하기: 세션 고정부터 IP 기반 DDoS 차단까지

DevOps

HAProxy stick-table rate limiting and session affinity without Redis

Redis는 너무 무거웠다 — stick-table을 다시 본 계기

2024년 말, 우리 팀은 B2B SaaS 플랫폼의 API 레이어 앞단을 대대적으로 정비했습니다. 당시 Rate Limiting 구현체는 Redis를 중앙 카운터 저장소로 쓰는 전형적인 구조였습니다. 애플리케이션이 요청을 받을 때마다 Redis에 INCR을 치고, TTL을 확인하고, 한도 초과 여부를 판단했습니다. 이론적으로는 깔끔해 보였지만, 실제 운영에서는 두 가지 문제가 누적됐습니다.

첫째, Redis 클러스터 레이턴시가 카운터 로직에 직접 노출됐습니다. Redis가 재시작되거나 네트워크 지터가 생기면 애플리케이션 응답 시간 p99가 튀었습니다. 둘째, 조직 내 규정상 Redis 클러스터는 별도 팀이 관리하는 공용 인프라였고, 스키마를 바꾸거나 키 전략을 수정할 때마다 승인 프로세스가 끼어들었습니다.

그때 누군가 HAProxy의 stick-table을 꺼냈습니다. 사실 우리는 이미 HAProxy를 엣지 로드밸런서로 쓰고 있었고, stick-table은 "세션 고정 때나 쓰는 것" 정도로만 알고 있었습니다. 막상 HAProxy 3.x 공식 설정 매뉴얼을 제대로 읽어보니 그게 아니었습니다. stick-table은 인메모리 KV 스토어로서 커넥션 수, 요청 레이트, 에러 레이트, 바이트 전송량을 시계열로 추적할 수 있었고, HAProxy 프로세스 내부에서 동작하기 때문에 외부 네트워크 왕복이 전혀 없었습니다. 이 글은 그 이후 우리 팀이 stick-table 하나로 Rate Limiting, 세션 어피니티, DDoS 차단 자동화를 직접 처리하기까지 겪은 것들을 정리한 기록입니다.

HAProxy의 기본 아키텍처와 Nginx와의 비교가 궁금하다면 리버스 프록시 원리 가이드를 먼저 읽어두면 이 글의 설정들이 훨씬 빠르게 맥락을 잡습니다.


1. stick-table이란 무엇인가: HAProxy 인메모리 KV 스토어의 구조와 생명주기

stick-table은 HAProxy 프로세스 내부에 존재하는 인메모리 해시 테이블입니다. 내부 구현은 이진 탐색 트리(elastic binary tree) 기반으로 키 삽입·삭제·조회 모두 O(log n) 복잡도로 동작합니다. 외부 프로세스가 필요 없고, HAProxy 프로세스가 살아 있는 동안 데이터가 유지됩니다.

테이블을 정의할 때 결정해야 할 핵심 파라미터는 세 가지입니다.

type — 테이블의 키 타입입니다. 선택지는 ip, ipv6, integer, string, binary 다섯 가지이며, 테이블 하나에 단 하나의 타입만 지정할 수 있습니다. IP 기반 추적이라면 ip를, API 키나 사용자 ID처럼 문자열 키로 추적한다면 string 타입을 씁니다. string 타입은 len 파라미터로 최대 길이를 지정해야 하며 기본값은 32바이트입니다.

size — 테이블이 보유할 수 있는 최대 엔트리 수입니다. size 1m이면 1,048,576개 엔트리를 담을 수 있습니다. 이 한도에 도달하면 HAProxy는 가장 오래 사용되지 않은 엔트리(LRU)를 자동으로 만료시켜 공간을 확보합니다. 엔트리 수 × 타입별 바이트를 계산해 메모리 예산 안에 맞춰야 합니다(7절에서 계산법을 다룹니다).

expire — 엔트리 TTL입니다. 마지막 업데이트로부터 이 시간이 지나면 자동으로 삭제됩니다. Rate Limiting 테이블이라면 슬라이딩 윈도우 크기보다 최소 2배 이상 길게 잡아야 카운터가 조기에 사라지지 않습니다.

# stick-table 기본 선언 예시
# frontend 또는 backend 섹션 내부에 배치
backend rate_limit_backend
    # IP 타입, 최대 100만 엔트리, 30분 TTL
    # store 필드: 현재 커넥션 수, 10s 커넥션 레이트,
    #             10s HTTP 요청 레이트, 10s HTTP 에러 레이트
    stick-table type ip size 1m expire 30m \
        store conn_cur,conn_rate(10s),http_req_rate(10s),http_err_rate(10s),gpc0,gpc0_rate(60s)

store 필드 용도별 정리

필드형태측정 단위주요 용도
conn_cur절댓값현재 동시 커넥션 수커넥션 폭발 감지, DDoS 1차 필터
conn_rate(p)슬라이딩 윈도우 레이트초당 커넥션 수(평균)연결 빈도 기반 차단
conn_cnt누적 카운터총 커넥션 수장기 스캐너 탐지
http_req_rate(p)슬라이딩 윈도우 레이트초당 HTTP 요청 수API Rate Limiting 핵심
http_req_cnt누적 카운터총 HTTP 요청 수일별 할당량 추적
http_err_rate(p)슬라이딩 윈도우 레이트초당 HTTP 4xx/5xx 수스캐너·무차별 대입 탐지
bytes_in_rate(p)슬라이딩 윈도우 레이트초당 수신 바이트대용량 업로드 남용 탐지
gpc0 / gpc1정수 카운터관리자 정의커스텀 ban 카운터, 경보 임계값
gpc0_rate(p)슬라이딩 윈도우 레이트초당 gpc0 증가 횟수ban 이벤트 발생 빈도 모니터링

http_req_rateconn_rate가 슬라이딩 윈도우를 쓴다는 점이 중요합니다. 고정 윈도우(fixed window)와 달리 윈도우 경계에서 카운터가 리셋되는 현상이 없으므로, 10s 윈도우를 쓰면 항상 "최근 10초 동안의 평균 레이트"로 평가합니다. 정확히는 지수 가중 이동 평균(EWMA) 방식의 근사치입니다. Redis EXPIREAT 기반 고정 윈도우보다 구현이 단순하면서도 경계 돌파 공격에 강합니다.


2. 기본 세션 어피니티: balance source vs cookie insert vs stick-match 세 가지 비교

세션 어피니티가 필요한 상황은 애플리케이션이 세션 상태를 백엔드 프로세스 메모리에 직접 보유하는 경우입니다. 요청이 매번 다른 백엔드로 가면 로그인 상태가 사라지거나 장바구니가 초기화됩니다. 2026년 시점에서 Stateless 설계와 외부 세션 저장소가 모범 답안이지만, 레거시 코드베이스를 당장 바꾸기 어려운 환경은 여전히 많습니다.

balance source — IP 해시 방식의 한계

가장 단순한 어피니티입니다. 클라이언트 IP를 해시해 항상 같은 백엔드로 보냅니다. 설정은 한 줄이지만 CGNAT(Carrier-Grade NAT) 환경에서 치명적으로 무너집니다. 이동통신사 망에서는 수천 명의 사용자가 동일한 공인 IP를 공유합니다. 해시 결과가 같으니 수천 명이 하나의 백엔드로 몰리고, 나머지 백엔드는 놀게 됩니다. 기업 내부망도 단일 NAT 게이트웨이 IP 하나로 나가는 경우가 흔하기 때문에 같은 문제가 생깁니다.

backend legacy_app
    balance source
    server app1 10.0.1.1:8080 check
    server app2 10.0.1.2:8080 check
    server app3 10.0.1.3:8080 check

cookie insert — Set-Cookie 주입 방식

HAProxy가 직접 Set-Cookie 헤더를 응답에 삽입해 클라이언트가 어느 백엔드에 붙었는지를 쿠키에 기록합니다. 이후 요청에서 그 쿠키를 보고 같은 백엔드로 라우팅합니다.

backend sticky_cookie
    balance roundrobin
    cookie SERVERID insert indirect nocache httponly secure samesite=strict
    server app1 10.0.1.1:8080 check cookie app1
    server app2 10.0.1.2:8080 check cookie app2
    server app3 10.0.1.3:8080 check cookie app3

insert indirect는 이미 쿠키가 있으면 새로 삽입하지 않고, 없을 때만 Set-Cookie를 내립니다. nocache는 이 응답이 CDN에 캐시되지 않도록 합니다. httponly secure samesite=strict는 XSS 탈취와 CSRF 악용을 막는 필수 보안 옵션입니다. 쿠키 값이 서버 식별자를 외부에 노출한다는 것이 단점입니다. 공격자가 쿠키 값을 조작해 특정 서버를 직접 지목하는 "핀닝(pinning)" 공격을 시도할 수 있으므로, 쿠키 값 자체를 불투명한 해시로 만들거나 애플리케이션에서 추가 검증을 두는 것이 좋습니다.

stick-match + stick-store-request — stick-table 기반 쿠키 어피니티

위 두 방식의 약점을 모두 보완합니다. HAProxy가 세션 ID 역할의 쿠키를 직접 파싱해 stick-table의 키로 사용합니다. 쿠키 값에 백엔드 식별자를 노출하지 않고, 내부 테이블에서 쿠키 → 백엔드 매핑만 관리합니다.

backend app_sticky
    balance leastconn

    # 세션 추적용 테이블: 문자열 키, 최대 10만 엔트리, 4시간 TTL
    stick-table type string len 64 size 100k expire 4h

    # 요청의 JSESSIONID 쿠키 값을 키로 테이블을 조회
    stick on req.cook(JSESSIONID)

    # 백엔드 응답의 Set-Cookie에서 JSESSIONID를 테이블에 저장
    stick store-response res.cook(JSESSIONID)

    server app1 10.0.1.1:8080 check
    server app2 10.0.1.2:8080 check
    server app3 10.0.1.3:8080 check

stick on은 요청 시 테이블에서 매핑을 조회하고, stick store-response는 백엔드가 새 쿠키를 내릴 때 그 쿠키 값을 키로 서버 매핑을 저장합니다. 백엔드가 풀에서 빠지면 해당 키의 매핑이 무효화되고 다음 요청은 새 서버로 배정됩니다.

방식장점단점CGNAT 환경
balance source설정 단순CGNAT에서 부하 집중취약
cookie insert쿠키 없어도 자동 생성서버 식별자 노출무관
stick-match (stick-table)쿠키 값이 불투명, 유연한 키 선택설정이 상대적으로 복잡무관

3. 스티키 카운터 sc0·sc1·sc2 활용법

stick-table 엔트리를 실제로 읽고 쓰려면 "스티키 카운터(sticky counter)" 슬롯을 먼저 잡아야 합니다. HAProxy는 sc0, sc1, sc2 세 개의 슬롯을 제공합니다(HAProxy 2.4 이후 sc-set-gpt 등을 통해 확장 가능하지만, 대부분의 실전 구성에서 세 개면 충분합니다).

슬롯을 잡는 방법은 tcp-request connection track-sc0 src, http-request track-sc0 src 같은 track-sc* 디렉티브입니다. 이 시점에 지정한 키(여기서는 src, 즉 클라이언트 IP)로 테이블을 조회하거나 엔트리를 생성하고, 해당 슬롯에 테이블 참조를 연결합니다. 이후 sc_conn_rate(0), sc_http_req_rate(0), sc_get_gpc0(0) 같은 함수가 그 슬롯을 통해 값을 읽습니다.

frontend http_in
    bind *:80
    bind *:443 ssl crt /etc/haproxy/certs/

    # TCP 연결 시점에 sc0 슬롯에 클라이언트 IP를 추적
    # rate_limit_backend에 정의된 stick-table을 사용
    tcp-request connection track-sc0 src table rate_limit_backend

    # HTTP 요청 시점에 sc1 슬롯에 API 키(헤더값)를 추적
    http-request track-sc1 req.hdr(X-API-Key) table api_key_backend \
        if { req.hdr(X-API-Key) -m found }

    # sc0의 gpc0 카운터가 1 이상이면 이미 차단된 IP — 즉시 거부
    http-request deny deny_status 429 \
        if { sc_get_gpc0(0) ge 1 }

    # sc0의 10초 요청 레이트가 100 초과면 429
    http-request deny deny_status 429 \
        if { sc_http_req_rate(0) gt 100 }

    default_backend app_backend

sc-inc-gpc0 / sc_get_gpc0 — 글로벌 카운터로 ban 상태 관리

gpc0(General Purpose Counter 0)는 HAProxy가 자동으로 관리하지 않는 수동 카운터입니다. 원하는 시점에 sc-inc-gpc0(0) 액션으로 증가시키고, sc_get_gpc0(0) 페치 함수로 현재 값을 읽습니다. 이 패턴이 ban-list 구현의 핵심입니다.

frontend http_in
    bind *:443 ssl crt /etc/haproxy/certs/

    tcp-request connection track-sc0 src table rate_limit_backend

    # 현재 커넥션 수가 50을 넘으면 gpc0을 올리고 연결 차단
    tcp-request connection sc-inc-gpc0(0) \
        if { sc_conn_cur(0) gt 50 }
    tcp-request connection reject \
        if { sc_get_gpc0(0) ge 1 }

    default_backend app_backend

frontend에서 track-sc0을 잡고 차단 판단을 내리는 이유는 명확합니다. TCP 커넥션 레벨에서 차단하면 HTTP 요청 파싱 비용이 전혀 발생하지 않습니다. 공격 트래픽의 경우 HTTP 헤더 파싱 없이 SYN → RST로 끊어내면 HAProxy의 CPU를 거의 소비하지 않습니다.


4. Rate Limiting 구현: Redis 없이 초당 요청 제한을 stick-table 하나로

Rate Limiting의 핵심 패턴은 단순합니다. http_req_rate로 최근 N초 동안의 요청 수를 측정하고, 임계값을 초과하면 429를 반환합니다. 이 모든 처리가 HAProxy 프로세스 내에서 이루어지기 때문에 Redis 왕복 레이턴시(우리 환경에서는 1~3ms)가 완전히 사라집니다.

#-----------------------------------------------------------------------
# Rate Limiting: IP 기반 일반 클라이언트
#-----------------------------------------------------------------------
backend ip_rate_table
    # IP 타입, 500만 엔트리, 60초 TTL
    # 10초 슬라이딩 윈도우 요청 레이트 + 에러 레이트 추적
    stick-table type ip size 5m expire 60s \
        store http_req_rate(10s),http_err_rate(10s),conn_cur,gpc0

backend api_key_rate_table
    # API 키(문자열) 타입, 10만 엔트리, 5분 TTL
    # 60초 슬라이딩 윈도우 — API 키 보유 클라이언트는 더 긴 윈도우
    stick-table type string len 128 size 100k expire 5m \
        store http_req_rate(60s),http_req_cnt,gpc0

#-----------------------------------------------------------------------
# frontend: 두 테이블을 분리 추적
#-----------------------------------------------------------------------
frontend api_gateway
    bind *:443 ssl crt /etc/haproxy/certs/

    # 모든 요청: IP 기준 sc0 추적
    http-request track-sc0 src table ip_rate_table

    # API 키 헤더가 있으면 sc1에 API 키 기준으로 추가 추적
    http-request track-sc1 req.hdr(X-API-Key) table api_key_rate_table \
        if { req.hdr(X-API-Key) -m found }

    # --- IP 기반 Rate Limit ---
    # 10초 요청 레이트 100 초과: 일반 클라이언트 제한
    acl ip_rate_exceeded sc_http_req_rate(0) gt 100

    # --- API 키 기반 Rate Limit (키 보유자는 더 관대한 한도) ---
    # 60초 요청 레이트 3000 초과: API 키 클라이언트 제한
    acl api_key_rate_exceeded sc_http_req_rate(1) gt 3000
    acl has_api_key req.hdr(X-API-Key) -m found

    # API 키 있는 클라이언트는 API 키 기준 한도 적용
    http-request deny deny_status 429 \
        hdr Retry-After 10 \
        if has_api_key api_key_rate_exceeded

    # API 키 없는 클라이언트는 IP 기준 한도 적용
    http-request deny deny_status 429 \
        hdr Retry-After 10 \
        if !has_api_key ip_rate_exceeded

    default_backend app_servers

슬라이딩 윈도우 근사 방식 이해

HAProxy의 http_req_rate(10s) 카운터는 정확한 슬라이딩 윈도우가 아닌 지수 가중 이동 평균(Exponential Weighted Moving Average) 기반의 근사치입니다. 직전 기간의 카운터와 현재 기간의 카운터를 시간 비율로 보간하는 방식입니다. 이 방식의 장점은 카운터 갱신 비용이 O(1)로 고정된다는 점입니다. 단점은 윈도우 경계 직전에 요청이 집중되는 "버스트 어택"을 정확하게 측정하지 못할 수 있다는 점입니다. 우리 환경에서는 오차가 ±5% 이내로 실용적 문제가 없었습니다.

정밀한 고정 윈도우 카운팅이 필요하다면 http_req_cnt(누적 카운터)와 별도 타임스탬프 계산을 조합해야 하는데, 그 복잡도가 Redis 카운터와 비슷해집니다. 대부분의 Rate Limiting 요건에는 EWMA 슬라이딩 윈도우로 충분합니다.


5. DDoS 차단: conn_cur·conn_rate로 비정상 IP를 자동 블랙리스트에 올리기

Rate Limiting이 HTTP 레이어에서의 요청 빈도를 제한한다면, DDoS 차단은 TCP 연결 레이어에서 더 빨리 움직여야 합니다. HTTP 파싱 전에 악성 IP를 끊어야 하기 때문입니다.

우리 팀의 공격 탐지 흐름은 다음과 같습니다.

[클라이언트 TCP SYN]
      |
      v
[HAProxy frontend - tcp-request connection 단계]
      |
      +-- track-sc0 src (IP → stick-table 조회/생성)
      |
      +-- 화이트리스트 ACL 통과? ──YES──> 정상 처리
      |         |
      |         NO
      |         v
      +-- conn_cur(sc0) > 임계값?
      |    또는 conn_rate(sc0) > 임계값?
      |         |
      |        YES
      |         v
      +-- gpc0 inc → gpc0 >= 1?
      |         |
      |        YES
      |         v
      +-- tcp-request connection reject (RST)
      |
      v
[HTTP 파싱 단계로 진입: sc1, http_req_rate 등 추가 검사]
#-----------------------------------------------------------------------
# DDoS 자동 블랙리스트: conn_cur + conn_rate + gpc0 ban-list
#-----------------------------------------------------------------------
backend ddos_track_table
    stick-table type ip size 2m expire 10m \
        store conn_cur,conn_rate(3s),http_req_rate(10s),gpc0,gpc0_rate(60s)

frontend edge_in
    bind *:80
    bind *:443 ssl crt /etc/haproxy/certs/

    # TCP 연결 수립 시점에 sc0 슬롯에 IP 추적
    tcp-request connection track-sc0 src table ddos_track_table

    # --- 화이트리스트: 내부망·모니터링 서버는 차단 제외 ---
    acl whitelist src 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
    acl whitelist src 203.0.113.50  # 모니터링 서버

    # --- 비정상 연결 패턴 감지 ACL ---
    # 3초 안에 100회 이상 연결 시도
    acl conn_rate_abuse sc_conn_rate(0) gt 100
    # 동시 커넥션 100개 초과
    acl conn_cur_abuse  sc_conn_cur(0) gt 100
    # gpc0이 이미 1 이상 → 이전에 ban된 IP
    acl is_banned       sc_get_gpc0(0) ge 1

    # 화이트리스트 아닌 IP가 임계값 초과 시 gpc0 증가 (ban 표시)
    tcp-request connection sc-inc-gpc0(0) \
        if !whitelist conn_rate_abuse
    tcp-request connection sc-inc-gpc0(0) \
        if !whitelist conn_cur_abuse

    # ban 표시된 IP는 TCP 연결 즉시 거부
    tcp-request connection reject \
        if !whitelist is_banned

    default_backend app_servers

ban-list의 자동 해제 메커니즘

gpc0 카운터 자체는 expire 파라미터에 따라 TTL이 다 되면 엔트리 전체가 사라집니다. 10분 TTL을 설정했다면 마지막 연결 시도로부터 10분 뒤 ban이 자동으로 해제됩니다. 봇이 일시적으로 멈췄다가 다시 시작하면 재차 ban될 수 있습니다.

영구 차단이 필요한 IP는 socat으로 gpc0을 수동으로 높게 설정하거나(7절 참조), HAProxy ACL의 src -f /etc/haproxy/blacklist.txt로 파일 기반 블랙리스트를 별도로 관리하는 편이 더 명확합니다.

오탐 방지 설계 원칙

임계값을 지나치게 낮게 잡으면 정상 사용자가 차단됩니다. 우리 환경에서는 처음에 conn_rate(3s) gt 30으로 잡았다가, 모바일 앱이 시작 시 동시에 여러 커넥션을 여는 패턴 때문에 오탐이 발생했습니다. 결국 gt 100으로 올렸고, 그 사이 구간은 http_req_rate로 2차 필터링을 적용했습니다. 화이트리스트 ACL을 모든 sc-inc-gpc0 디렉티브 앞에 배치하는 것은 선택이 아니라 필수입니다.


6. peers 섹션으로 HAProxy 클러스터 간 stick-table 동기화

단일 HAProxy 노드는 단일 장애점(SPOF)입니다. 일반적으로 Active-Active 또는 Active-Standby로 2대 이상을 구성하는데, 이때 각 노드의 stick-table은 기본적으로 독립적으로 존재합니다. 노드 A로 들어온 요청의 레이트 카운터가 노드 B에는 없다면, 클라이언트가 다른 노드로 들어올 때 Rate Limiting이 무효화됩니다.

peers 섹션은 이 문제를 해결합니다. HAProxy 노드들이 피어 프로토콜로 서로 연결해 stick-table 변경 사항을 실시간으로 동기화합니다. HAProxy 2.0 이후부터는 peers 섹션 내부에 table 디렉티브로 공유 테이블을 선언하는 방식이 권장됩니다.

#-----------------------------------------------------------------------
# peers 섹션: stick-table 클러스터 동기화 (HAProxy 2.0+ 권장 방식)
# peer 이름은 각 서버의 $HOSTNAME과 정확히 일치해야 합니다
#-----------------------------------------------------------------------
peers haproxy_cluster
    peer haproxy-01 10.10.0.1:1024
    peer haproxy-02 10.10.0.2:1024
    # 3노드 이상 구성도 동일 방식으로 추가
    # peer haproxy-03 10.10.0.3:1024

    # peers 섹션 내부에 공유 테이블 선언
    table ip_rate_shared type ip size 5m expire 60s \
        store http_req_rate(10s),conn_cur,gpc0

frontend api_gateway
    bind *:443 ssl crt /etc/haproxy/certs/

    # peers 섹션에 정의된 공유 테이블을 참조
    http-request track-sc0 src table haproxy_cluster/ip_rate_shared

    acl ip_rate_exceeded sc_http_req_rate(0) gt 100
    http-request deny deny_status 429 if ip_rate_exceeded

    default_backend app_servers

동기화 지연 특성과 세션 손실 범위

peers 프로토콜은 TCP 기반으로 동작하며 변경 사항을 즉시(push) 전파합니다. 정상 네트워크 환경에서 동기화 지연은 수 밀리초 이내입니다. 다만, Eventually Consistent 방식이기 때문에 두 노드가 거의 동시에 같은 IP의 카운터를 갱신하면 마지막 쓰기가 이깁니다(Last Write Wins). 우리 환경에서는 이 경쟁 조건이 Rate Limiting 정확도에 의미 있는 영향을 주지 않았습니다.

노드 한 대가 장애로 내려가면 그 노드가 담당하던 stick-table 엔트리는 다른 노드에 복제된 상태로 유지됩니다. 재연결 시 살아남은 노드가 자신이 보유한 현재 상태를 새 노드에 동기화합니다. 프로세스가 완전히 새로 시작되는 경우(급작스러운 재부팅), 피어 재연결이 완료되기 전까지는 해당 노드의 테이블이 비어 있습니다. 이 짧은 공백(수 초) 동안 해당 노드로 들어온 트래픽은 Rate Limiting 카운터가 낮게 시작됩니다. 허용 가능한 수준이지만, 엄격한 DDoS 차단이 필요한 환경이라면 재연결 완료 전까지 해당 노드를 서비스에서 제외하는 헬스체크 스크립트를 추가하는 것이 안전합니다.


7. 운영 점검: socat으로 실시간 테이블 조회/조작

stick-table은 HAProxy 통계 소켓을 통해 런타임에 조회하고 수동으로 조작할 수 있습니다. socat이 설치되어 있으면 됩니다.

먼저 haproxy.cfg에 통계 소켓을 활성화합니다.

global
    stats socket /var/run/haproxy/admin.sock mode 660 level admin expose-fd listeners
    stats timeout 30s

실시간 테이블 조회

# 테이블 목록과 현재 엔트리 수 확인
echo "show table" | socat stdio /var/run/haproxy/admin.sock

# 특정 테이블의 전체 엔트리 덤프
echo "show table ip_rate_table" | socat stdio /var/run/haproxy/admin.sock

# 특정 조건으로 필터링 (HAProxy 2.2 이후 지원)
# show table 출력 형식 예시:
# # table: ip_rate_table, type: ip, size:5242880, used:42
# 0x7f1a2b3c4d50: key=203.0.113.5 use=0 exp=29991 conn_cur=3 http_req_rate(10000)=87 gpc0=0
echo "show table ip_rate_table data.conn_cur gt 10" | socat stdio /var/run/haproxy/admin.sock

# gpc0이 1 이상인 IP 전체 조회 (ban된 IP 목록)
echo "show table ddos_track_table data.gpc0 ge 1" | socat stdio /var/run/haproxy/admin.sock

필터 조건(data.conn_cur gt 10)에는 data. 접두어를 씁니다. 그러나 show table의 실제 출력 라인에서 필드는 key=…, use=…, exp=…, conn_cur=…, gpc0=… 형태로 표시되고, http_req_rate처럼 슬라이딩 윈도우 필드는 http_req_rate(10000)=87 형태(괄호 안은 밀리초 단위 윈도우)로 나옵니다.

수동 ban 설정 및 해제

# 특정 IP의 gpc0을 1로 설정 → 즉시 ban 처리
# set table 명령에서 필드 지정에는 data. 접두어를 붙입니다
echo "set table ddos_track_table key 203.0.113.100 data.gpc0 1" \
    | socat stdio /var/run/haproxy/admin.sock

# 특정 IP의 gpc0을 0으로 초기화 → ban 해제
echo "set table ddos_track_table key 203.0.113.100 data.gpc0 0" \
    | socat stdio /var/run/haproxy/admin.sock

# 테이블 전체 초기화 (주의: 모든 엔트리 삭제)
echo "clear table ddos_track_table" | socat stdio /var/run/haproxy/admin.sock

ban된 IP를 주기적으로 정리하는 운영 스크립트

show table 명령의 출력 라인은 다음과 같은 형태입니다.

# table: ddos_track_table, type: ip, size:2097152, used:3
0x7f1a2b3c4d50: key=203.0.113.5 use=0 exp=598771 conn_cur=0 conn_rate(3000)=0 http_req_rate(10000)=0 gpc0=2 gpc0_rate(60000)=1

#으로 시작하는 줄은 헤더이고, 나머지 줄이 엔트리입니다. 슬라이딩 윈도우 필드는 필드명(밀리초)=값 형태로 나오며 data. 접두어가 없습니다. 아래 Python 스크립트는 이 형식에 맞게 파싱합니다.

#!/usr/bin/env python3
"""
haproxy_ban_reporter.py
: HAProxy stick-table에서 ban된 IP 목록을 수집해 슬랙으로 보고하는 운영 스크립트.
  30분마다 cron으로 실행.

사용법:
  python3 haproxy_ban_reporter.py --socket /var/run/haproxy/admin.sock \
      --table ddos_track_table --webhook https://hooks.slack.com/...
"""

import argparse
import re
import socket
import sys
import json
import urllib.request
from datetime import datetime, timezone

def query_haproxy(sock_path: str, command: str) -> str:
    """HAProxy 유닉스 소켓으로 커맨드를 전송하고 응답을 반환한다."""
    with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
        s.connect(sock_path)
        s.sendall((command + "\n").encode())
        chunks = []
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            chunks.append(chunk)
    return b"".join(chunks).decode(errors="replace")

def parse_field(line: str, field: str) -> str:
    """
    show table 출력 라인에서 특정 필드 값을 추출한다.
    슬라이딩 윈도우 필드는 'http_req_rate(10000)=87' 형태이므로
    정규식으로 field 이름(괄호 포함 가능)을 매칭한다.
    """
    pattern = rf'(?:^|\s){re.escape(field)}(?:\(\d+\))?=(\S+)'
    m = re.search(pattern, line)
    return m.group(1) if m else "N/A"

def parse_banned_ips(raw: str) -> list[dict]:
    """
    show table 출력에서 gpc0 >= 1인 엔트리를 파싱한다.

    출력 형식 예시:
      # table: ddos_track_table, type: ip, size:2097152, used:3
      0x7f...: key=203.0.113.5 use=0 exp=598771 conn_cur=0 ... gpc0=2 ...
    """
    banned = []
    for line in raw.splitlines():
        # 헤더 줄('#'로 시작) 및 빈 줄 제외
        if line.startswith("#") or not line.strip():
            continue
        # 엔트리 줄: 'key=' 포함 여부로 판별
        if "key=" not in line:
            continue
        # key= 값 추출
        key_match = re.search(r'key=(\S+)', line)
        if not key_match:
            continue
        ip = key_match.group(1)

        gpc0_str = parse_field(line, "gpc0")
        try:
            gpc0 = int(gpc0_str)
        except ValueError:
            gpc0 = 0

        if gpc0 >= 1:
            banned.append({
                "ip": ip,
                "gpc0": gpc0,
                "conn_cur": parse_field(line, "conn_cur"),
                "http_req_rate": parse_field(line, "http_req_rate"),
            })
    return banned

def send_slack(webhook_url: str, banned: list[dict], table: str) -> None:
    now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    if not banned:
        text = f"[{now}] *{table}*: ban된 IP 없음"
    else:
        lines = [f"[{now}] *{table}* — ban된 IP {len(banned)}개:"]
        for entry in banned[:20]:  # 슬랙 메시지 길이 제한 대응
            lines.append(
                f"  • `{entry['ip']}` gpc0={entry['gpc0']} "
                f"conn_cur={entry['conn_cur']} req_rate={entry['http_req_rate']}"
            )
        if len(banned) > 20:
            lines.append(f"  ... 외 {len(banned) - 20}개")
        text = "\n".join(lines)

    payload = json.dumps({"text": text}).encode()
    req = urllib.request.Request(
        webhook_url, data=payload,
        headers={"Content-Type": "application/json"}
    )
    urllib.request.urlopen(req, timeout=10)

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--socket", default="/var/run/haproxy/admin.sock")
    parser.add_argument("--table", default="ddos_track_table")
    parser.add_argument("--webhook", required=True)
    args = parser.parse_args()

    raw = query_haproxy(
        args.socket,
        f"show table {args.table} data.gpc0 ge 1"
    )
    banned = parse_banned_ips(raw)
    send_slack(args.webhook, banned, args.table)
    print(f"보고 완료: ban된 IP {len(banned)}개", file=sys.stderr)

if __name__ == "__main__":
    main()

stick-table 메모리 사용량 추정

각 엔트리가 소비하는 메모리는 키 타입과 store 필드 조합에 따라 달라집니다. 대략적인 기준은 다음과 같습니다.

구성 요소바이트
엔트리 기본 오버헤드(포인터, 만료 타임스탬프 등)~48
키 타입 ip (IPv4)4
키 타입 string len 6464
conn_cur4
conn_rate(Xs)12
http_req_rate(Xs)12
gpc04
gpc0_rate(Xs)12

type ip size 1m store conn_cur,conn_rate(10s),http_req_rate(10s),gpc0 조합이라면 엔트리 하나에 약 96바이트, 100만 엔트리 기준 약 96MB입니다. size를 과도하게 크게 잡아도 선언 시점에 전체 메모리를 예약하지 않고 실제 엔트리가 생성될 때만 할당하기 때문에, 보수적으로 크게 잡는 것이 오히려 안전합니다.


8. 한계와 안티패턴: stick-table이 맞지 않는 상황

stick-table이 강력하지만 모든 상황에 맞는 도구는 아닙니다. 아래 상황에서는 Redis나 다른 솔루션을 검토해야 합니다.

글로벌 멀티리전 배포: peers 프로토콜은 같은 데이터센터 내부 클러스터 동기화에 적합합니다. 대륙 간 레이턴시(100~200ms)가 있는 환경에서 peers를 쓰면 동기화 지연으로 인한 불일치가 커집니다. 멀티리전에서 정확한 Rate Limiting이 필요하다면 Redis Cluster 또는 Redis Enterprise의 Active-Active 복제가 현실적인 선택입니다.

애플리케이션 레이어 Rate Limiting: stick-table은 IP 또는 단순 문자열 키 기준으로만 추적합니다. 사용자 ID + 엔드포인트 조합, 또는 JWT 클레임 기반의 세분화된 할당량 관리는 애플리케이션 레이어에서 처리해야 합니다. HAProxy는 JWT를 디코딩하거나 복잡한 비즈니스 규칙을 평가하는 도구가 아닙니다.

long-lived WebSocket 연결: conn_cur가 WebSocket 연결을 장기적으로 카운팅하면 임계값 초과로 오탐이 발생할 수 있습니다. WebSocket 트래픽은 별도 frontend를 두고 stick-table 조건 적용을 분리하는 것이 안전합니다.

대규모 엔트리 + 높은 expire 조합: size 10m expire 24h처럼 설정하면 LRU 만료가 잘 동작하지 않아 테이블이 항상 꽉 찬 상태로 유지됩니다. 이 상태에서는 새 엔트리 삽입 시 LRU 계산 비용이 증가합니다. 테이블 크기와 TTL은 실제 고유 클라이언트 수 × 활성 세션 예상 시간을 기준으로 합리적으로 설정해야 합니다.


9. 결론: stick-table을 도입하면서 우리가 다시 배운 것

Redis Rate Limiting에서 HAProxy stick-table로 전환한 뒤, 우리 팀은 몇 가지를 새롭게 배웠습니다. 기술적 선택은 단순히 "성능이 좋으냐"가 아니라 "어디에서 무엇을 책임지느냐"의 문제라는 것입니다. stick-table은 HAProxy가 이미 트래픽을 보고 있는 위치에 카운터를 두는 방식입니다. 그 자연스러운 위치 선정 하나가 외부 의존성 제거, 레이턴시 감소, 운영 단순화를 동시에 가져왔습니다.

HAProxy의 설정 철학 전반에 대한 배경이 필요하다면 Nginx·HAProxy로 구축하는 리버스 프록시 실전 설정 가이드를 참고하면 frontend·backend 섹션 설계 원칙부터 확인할 수 있습니다.

실무 체크리스트 — stick-table 도입 전 확인할 5가지

  • 테이블 타입과 size 예산 계산: 고유 클라이언트 예상 수 × 엔트리당 바이트를 계산해 서버 메모리 예산 내에 있는지 확인한다. size는 여유 있게 잡되, 엔트리 TTL을 현실적으로 설정해 LRU가 제때 동작하도록 한다.
  • 화이트리스트 ACL 우선 배치: DDoS 차단 규칙의 모든 sc-inc-gpc0 디렉티브 앞에 내부망·모니터링 서버 화이트리스트 ACL을 반드시 넣는다. 배포 자동화 서버가 ban 목록에 올라가는 사고는 생각보다 자주 발생한다.
  • peers 섹션과 hostname 일치 확인: peer haproxy-01 ...의 peer 이름이 각 서버의 $HOSTNAME 환경변수와 정확히 일치해야 HAProxy가 자신을 로컬 피어로 인식한다. 불일치 시 테이블이 동기화되지 않고 각 노드가 독립적으로 동작한다.
  • socat 모니터링 스크립트 배포: show table ... data.gpc0 ge 1을 주기적으로 실행해 ban 목록을 외부로 보고하는 스크립트를 운영 환경에 배포한다. 오탐 감지와 임계값 튜닝의 시작점이 된다.
  • 슬라이딩 윈도우 임계값은 트래픽 데이터 기반으로: 초기값을 낮게 잡고 시작하지 말 것. 실제 트래픽 로그에서 정상 클라이언트의 p99 요청 레이트를 먼저 측정한 뒤, 그보다 5~10배 높은 값을 초기 임계값으로 설정한다. 이후 ban 목록 보고서를 보며 점진적으로 조정한다.

Sources: