サーバを二台置くことと障害に耐えることは違う
한국어 원문으로 표시합니다.
한 줄 요약
부하분산의 첫 목적은 요청을 반씩 나누는 것이 아니라, 한 서버의 실패가 전체 서비스의 실패로 번지지 않게 만드는 것입니다. 그래서 고를 것은 알고리즘이 아니라 실패를 언제 알아채고 언제 다시 넣을 것인가 입니다.
왜 이게 필요했나
백엔드를 두 대 띄우고 nginx upstream 에 넣었다고 고가용성이 완성되지는 않습니다. 죽은 서버를 언제 제외할지, 연결을 재사용할지, 세션을 어디에 보관할지 정하지 않으면 사용자는 요청할 때마다 성공과 실패를 번갈아 보게 됩니다.
가장 흔한 사고는 이렇게 시작합니다. 백엔드 한 대가 배포 중에 30초 동안 500 을 냅니다. nginx 는 그 사실을 모르므로 계속 절반을 그쪽으로 보냅니다. 사용자 눈에는 "새로고침하면 되기도 하고 안 되기도 하는" 상태가 됩니다. 모니터링은 평균값을 보므로 에러율 50% 가 25% 로 희석되어 경보도 늦습니다.
알고리즘은 무엇을 기준으로 고르나
| 방식 | 분배 기준 | 잘 맞는 곳 | 조심할 것 |
|---|---|---|---|
round_robin(기본) |
요청 순서 | 처리 시간이 고른 API | 느린 요청이 섞이면 편차가 커진다 |
least_conn |
처리 중인 연결 수 | 응답 시간이 들쭉날쭉한 곳 | 연결이 길게 유지되는 SSE·WebSocket 에서는 왜곡된다 |
ip_hash |
클라이언트 IP 해시 | 세션을 서버 메모리에 두는 레거시 | 서버를 넣고 빼면 배정이 통째로 바뀐다 |
hash $key consistent |
임의 키의 일관 해시 | 캐시 적중률이 중요한 곳 | 키 설계를 잘못하면 한쪽으로 쏠린다 |
weight 는 서버 성능이 다를 때만 씁니다. 같은 사양인데 가중치를 다르게 두면
용량 계산이 어긋나 장애 때 한쪽이 먼저 무너집니다.
실패를 알아채는 방식이 수동이라는 것
max_fails 와 fail_timeout 은 능동 헬스체크가 아닙니다. 실제 요청이 실패하는
것을 관찰해 서버를 잠시 제외하는 방식입니다. 여기서 세 가지가 따라옵니다.
- 트래픽이 없으면 장애도 발견하지 못합니다. 새벽에 죽은 서버는 아침 첫 요청이 희생되고 나서야 제외됩니다.
- 판정은 워커 프로세스마다 따로입니다.
worker_processes 4면 각 워커가 자기 카운터를 갖습니다.max_fails=3이면 최악의 경우 12번의 실패 요청이 나갑니다. fail_timeout이 지나면 아무 확인 없이 다시 넣습니다. 서버가 아직 살아나지 않았으면 다시 실패하고, 이 순환이 계속됩니다.
무엇을 실패로 셀지는 proxy_next_upstream 이 정합니다. 기본값은 error timeout
이라 백엔드가 낸 500 은 실패로 세지 않습니다. 애플리케이션이 죽어 500 을
내는데도 계속 그쪽으로 보내는 이유가 이것입니다. http_500 http_502 http_503 을
명시해야 셉니다. 다만 여기에 non_idempotent 를 함께 두지 않으면 POST 가 두 번
실행될 수 있으니, 재시도 대상은 반드시 멱등한 요청으로 한정합니다.
업스트림 Keep-Alive 는 세 조건이 함께 맞아야 한다
연결을 재사용하면 TCP 핸드셰이크와 TLS 협상 비용이 사라집니다. 그런데 세 가지를 모두 해야 켜집니다. 하나라도 빠지면 조용히 매번 새 연결을 맺습니다.
upstream app {
server 127.0.0.1:8080;
server 127.0.0.1:8082;
keepalive 32; # 1) 워커당 유지할 유휴 연결 수
}
location / {
proxy_pass http://app;
proxy_http_version 1.1; # 2) 기본값 1.0 은 keep-alive 를 못 쓴다
proxy_set_header Connection "";# 3) 클라이언트가 보낸 Connection 헤더를 지운다
}
keepalive 값은 "동시 처리량" 이 아니라 "유휴 상태로 남겨 둘 연결 수" 입니다.
워커 수를 곱한 값이 백엔드의 최대 동시 연결 설정을 넘지 않아야 합니다. Tomcat 의
maxThreads 가 200 인데 nginx 워커 4개가 각각 100을 유지하면 400 개의 연결이
스레드를 기다리며 쌓입니다.
세션 고정은 마지막 수단이다
ip_hash 로 고정하면 서버를 추가·제거하는 순간 해시 공간이 바뀌어 상당수
사용자가 다른 서버로 재배정되고, 세션이 그쪽 메모리에 없으므로 한꺼번에
로그아웃됩니다. 배포할 때마다 이 일이 벌어집니다. 게다가 회사·학교처럼 NAT 뒤에
있는 사용자들은 IP 가 같아 한 서버로 쏠립니다.
우선순위는 이 순서입니다.
- 세션을 서버 밖으로 뺀다 — Redis 나 DB 에 두면 어떤 서버가 받아도 됩니다.
- 상태를 토큰에 담는다 — 서명된 JWT 라면 서버는 아무것도 기억하지 않아도 됩니다.
- 그래도 안 되면 고정한다 — 이때도
ip_hash보다 쿠키 기반 sticky 가 낫습니다.
현장에서 만나는 모습
장애가 가끔만 보이면 먼저 액세스 로그에 $upstream_addr 와 $upstream_status 를
남겨 백엔드별로 결과를 나눕니다.
log_format lb '$remote_addr [$time_local] "$request" $status '
'$upstream_addr $upstream_status '
'$upstream_response_time $request_time';
읽는 법이 중요합니다. $upstream_addr 에 주소가 쉼표로 두 개 찍혀 있으면
재시도가 일어난 것입니다(127.0.0.1:8080, 127.0.0.1:8082). 특정 주소에서만 5xx 가
나면 분배 알고리즘보다 배포·설정 차이를 먼저 봐야 합니다. 반대로 모든 주소가 동시에
느리면 공통 DB 나 하류 서비스를 조사합니다.
$upstream_response_time 과 $request_time 의 차이도 진단에 씁니다. 둘이 비슷하면
백엔드가 느린 것이고, $request_time 만 크면 클라이언트 쪽 네트워크나 응답 전송이
느린 것입니다.
다음 실습에서 할 것
두 백엔드를 직접 띄워 라운드로빈, 가중치, 세션 고정, 실패 제외와 연결 재사용을
비교합니다. $upstream_addr 를 로그에 남겨 어느 쪽으로 갔는지 세어 보고, 한 대를
일부러 죽여 max_fails 가 실제로 몇 번의 실패 뒤에 동작하는지 확인합니다.
마지막에는 알고리즘마다 언제 쓰고 언제 피해야 하는지 운영 기준으로 정리합니다.