homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guidenetworking 2026-07-07nginxdnsupstreamresolvergslbno-live-upstreamskeepalivemax-fails

nginx "no live upstreams" — GSLB IP 변경 시점의 간헐 502: 원인·재현·해결

요약 — 무슨 문제였고, 무엇으로 해결되나

환경. nginx가 리버스 프록시로 백엔드를 IP가 아닌 도메인으로 바라보고 (server api.example.com:443 resolve;), 그 도메인은 GSLB가 관리합니다. GSLB(Global Server Load Balancer)는 DNS 기반 로드밸런서입니다 — 도메인 질의가 올 때마다 데이터센터 상태·분산 정책에 따라 여러 데이터센터의 VIP(대표 IP) 중 하나를 골라 A 레코드(도메인→IPv4 매핑) 1개로 응답합니다. 그래서 nginx가 보는 백엔드 IP는 질의할 때마다 바뀔 수 있습니다.

현상. 백엔드 서버는 내내 정상인데, GSLB 응답 IP가 바뀌는 시점 직후 수백 ms 안에서만, 그 순간을 지나던 일부 요청만 502로 실패합니다. error.log에는 no live upstreams while connecting to upstream 한 줄만 남고 직전 에러 라인은 보이지 않습니다. (정확한 정의와 속성 분해는 2-1절, 재현과의 속성 대조는 4-5절)

원인(요약). 세 가지 사실의 결합입니다 — 각각은 1장에서 자세히 설명합니다.

  1. 트래픽이 있는 시스템에는 일상적인 1차 시도 실패(keepalive 커넥션 회수 경합 등)가 낮은 비율로 상시 존재하고, 평소에는 nginx의 재시도가 전부 가려줍니다
  2. nginx 1.27.3+는 요청 시작 시 peer 목록의 세대(config generation) 번호를 기억하고, 재시도로 peer를 다시 고르는 순간 세대가 달라져 있으면(그 사이 IP 교체가 있었으면) 선택을 포기하고 no live upstreams를 냅니다
  3. 따라서 “1차 실패 → 재시도” 사이의 수백 ms 창에 IP 교체가 끼어든 요청만 실패합니다 — “IP 변경 시점 직후에만, 일부 요청만"이라는 관측 그대로

재현. backend는 한 번도 죽이지 않고, DNS가 주기적으로 다른 IP(레코드는 늘 1개)를 반환하는 랩에서 확인 (4장): 교체만으로는 0건 (98회/30만 — 교체 자체는 무죄). 운영에 상시 존재하는 일상 1차 실패(keepalive 회수 경합)를 더하자 36,001 요청 중 101건(0.28%), 전부 IP 교체 후 12~486ms 안, 전부 2-hop 궤적.

조치 결과 (동일 조건 A/B 실측, 5장):

조치 결과 판정
max_fails=0 효과 없음 — 동일 조건 A/B: 69건 vs 80건 (오차 범위) 이 경로는 실패 카운터와 무관한 세대 검사라 원리적으로 무효. 1줄 구성은 애초에 max_fails가 비활성이기도 함 (3-4절)
max_fails=2로 상향 효과 없음 — 131건 그대로 같은 이유
backup 서버 추가 효과 없음 — 128건 잔존 세대 불일치 판정은 backup 폴백도 건너뜀 (소스)
DC별 고정 도메인 2개(per-DC) 0건 — 교체 자체가 사라짐 근본 해결
error_page 502 = @retry_once 재시도 패스 내부 실패 전량 구제(127→0) 구조 변경 불가 시의 완화 (비멱등 가드 필수)
클라이언트/엣지 재시도 이 경로의 실패는 재시도 1회로 성공 완화
내용
1 배경지식 — 설정 각 줄의 의미, 재시도·세대 검사 등 필요한 내부 동작 전부
2 현상과 단서
3 원인 — 타임라인으로 보는 발생 메커니즘, max_fails가 무관한 이유
4 재현 — DNS만 바꾸면 0건, 일상 실패가 겹치면 발생
5 조치와 검증 — max_fails=0 무효 실측, 유효한 대응 3종
6 운영 판별 — 세 갈래(원인·조치)와 닫는 절차, 판별 흐름도
7~8 그 외 no live upstreams 경로(전멸·DNS 계열) / 실행 로드맵

1. 배경지식 — 설정을 한 줄씩 읽기

1-1. 문제의 설정 전문과 각 지시어의 의미

upstream be {
    resolver 10.0.0.53 valid=5s;
    resolver_timeout 3s;
    zone be 128k;
    server api.example.com:443 resolve;
    keepalive 1024;
    keepalive_time 180s;
    keepalive_timeout 60s;
}
server {
    location / { proxy_pass https://be; }
}
지시어 의미
upstream be { } 프록시 대상 서버 묶음의 정의. proxy_pass https://be가 이름으로 참조
resolver 10.0.0.53 valid=5s 이 upstream이 도메인을 조회할 DNS 서버. valid=5s는 받은 응답을 5초만 신뢰하고 다시 묻겠다는 뜻 — DNS 레코드의 TTL을 무시하고 이 값이 쓰임. 실측 재질의 주기는 valid+1초(약 6초)
resolver_timeout 3s DNS 응답을 기다리는 한도
zone be 128k peer 목록·상태를 worker 프로세스들이 공유하는 메모리에 두는 선언. resolve를 쓰려면 필수 (재조회 결과를 전 worker가 함께 봐야 하므로)
server api.example.com:443 resolve 멤버 정의. resolve 플래그가 핵심 — 없으면 기동 시 한 번만 해석해 IP가 고정되고, 있으면 위 주기로 계속 재질의해 IP 변경을 따라감
keepalive 1024 백엔드로의 유휴(idle) TCP 연결을 최대 1024개 보관해 재사용하는 커넥션 풀
keepalive_time 180s 보관 연결 하나의 총 수명 한도 — 재사용 횟수와 무관하게 180초가 지나면 폐기
keepalive_timeout 60s 유휴 상태로 보관하는 시간 한도 — 60초간 쓰이지 않으면 폐기

server 한 줄은 공유 메모리에 peer라는 항목이 됩니다 — { IP:port, fails(실패 카운터), checked(마지막 확인 시각), conns(연결 수) }. 이 설정에서는 peer가 1개이고, resolve가 그 1개의 IP를 GSLB 응답에 맞춰 갈아끼웁니다.

1-2. 요청 처리 3단계 — 재시도가 사는 곳, no live upstreams가 나는 곳

요청 하나는 다음 단계를 밟습니다:

요청 도착
   |
   v
[1] 보낼 peer 고르기 (선택)
   |
   v
[2] 그 서버에 TCP/TLS 연결
   |
   v
[3] 요청 전송 -> 응답 수신

재시도(proxy_next_upstream, 기본 on)는 [2]~[3]에서 실패했을 때만 등장합니다 — “한 서버를 골라 시도했는데 실패했다 → 다시 [1]로 돌아가 다른(또는 같은) peer로”. 이때 nginx는 두 가지를 따집니다:

  • 다시 시도할 후보 peer가 있는가
  • 이 요청을 다시 보내도 안전한가 — 메서드 구분은 여기서만 등장합니다. 본문을 이미 전송한 POST는 재전송 시 이중 처리 위험이 있어 재시도를 거부하고(비멱등 보호), GET은 다시 보냅니다

반면 “no live upstreams"는 [1]단계에서 선택 가능한 peer가 0개로 판정될 때 딱 한 곳에서 발생합니다. 시도 자체가 없었으므로 “실패한 시도"도 없고, 재시도 로직이 등장할 차례가 아예 오지 않습니다 — GET이든 POST든 그 순간의 선택이 실패했다는 이유만으로 즉시 502입니다. 두 상황을 나란히 놓으면:

[3]단계 실패 (서버까지 갔다가 실패):
  GET  : 다른 peer/새 연결로 재시도 -> 대개 성공. 클라이언트는 모름 (은폐)
  POST : 재시도 거부 -> 502
         로그: "upstream prematurely closed ..." 등 원래 실패 내용

[1]단계 실패 (선택이 거부됨):
  GET  : 즉시 502   +
  POST : 즉시 502   +-- 메서드 무관, 로그는 동일하게 "no live upstreams"

진단에 유용한 부수 사실: 재시도를 거부당한 POST의 로그에는 원래 실패 이유가 찍히지, “no live upstreams"가 찍히지 않습니다. 이 문구가 남았다는 것 자체가 “[1]단계 선택이 거부된 502"라는 단서입니다.

그리고 peer가 1개뿐인 이 설정에서 [1]단계가 거부되는 방법이 곧 이 문서의 원인 목록입니다 — 본문은 그중 **세대 검사(1-6절)**이고, 나머지(전멸·DNS 계열)는 7장에 있습니다.

1-3. $upstream_addr 궤적 읽는 법 — 1-hop과 2-hop

access log의 log_format에 $upstream_addr를 넣으면 그 요청이 어떤 peer를 거쳤는지가 남습니다. 이 문서 전체에서 진단의 축이 되는 변수입니다:

$upstream_addr 값
10.1.2.3:443 첫 시도에 성공
10.1.2.3:443, 10.1.2.4:443 첫 시도 실패 → 재시도해 성공 (2-hop) — 평소엔 이렇게 은폐됨
10.1.2.3:443, be 첫 시도 실패 → 재시도의 [1]단계가 거부됨 (2-hop no live upstreams) — 이 문서의 현상
be (그룹명 단독) 첫 선택부터 거부 — 연결 시도 자체가 없었음 (1-hop no live upstreams) — 7장의 전멸 계열 또는 세대 race의 1-hop 변형

1-4. resolve의 동작 — IP 교체 자체는 무해하다

재질의 결과에 따른 peer 목록 처리 (nginx 1.28.0, 소스·실측 대조):

DNS 응답 peer 목록 처리
정상 응답 + 레코드 바뀌었으면 구 peer 삭제 + 신 peer 추가를 한 잠금 안에서 원자적으로 수행. 같으면 무동작
SERVFAIL / 무응답 기존 목록 유지 (무해 — 실측 오류 0)
NXDOMAIN / 정상 응답인데 레코드 0개 목록 전부 삭제 — 다음 재조회까지 빈 목록 (7장의 전면 정전 계열)

중요한 사실: IP 교체 그 자체는 요청을 깨뜨리지 않습니다. 교체는 원자적이라 “빈 목록인 순간"이 없고, 진행 중인 연결도 끊지 않습니다 — 실측: 교체 98회/30만 요청, 오류 0. TCP 연결은 connect 시점에 IP가 고정되므로 in-flight 요청은 구 IP로 계속 진행되어 정상 완료됩니다. 문제는 교체가 아니라, 교체가 남기는 흔적 — “세대 번호 증가”(1-6절)가 하필 재시도 중인 요청과 만날 때입니다.

1-5. 일상적인 1차 실패 — 실패율이 0이 아닌 이유 (이 사건의 재료)

keepalive 풀의 유휴 연결에는 양쪽 끝(nginx, 백엔드, 중간 L4 장비)에 각자의 유휴 타이머가 있고 서로의 타이머를 모릅니다. 백엔드가 자기 타이머로 연결을 닫는 순간과 nginx가 그 연결을 꺼내 요청을 쓰는 순간이 교차하면, 그 요청은 upstream prematurely closed connection 류로 실패합니다. 백엔드 배포, 워커 프로세스 재활용, 세션 만료도 같은 종류의 실패를 만듭니다.

트래픽이 있는 시스템에서 1차 시도 실패는 낮은 비율로 상시 존재합니다. 평소엔 재시도가 전부 가립니다 — 실측: IP 고정 조건에서 이런 실패 1,418건 발생, 클라이언트 오류 0. peer가 1개뿐이어도 재시도는 있습니다: 보관해 둔(cached) keepalive 연결에서 실패한 요청은 시도 횟수를 보상받아 같은 peer로 새 연결을 만들어 다시 시도합니다 (소스: ngx_http_upstream.c cached 보상 — 풀에서 꺼낸 연결은 원래부터 죽어 있었을 가능성이 있어, 그 실패로 요청을 끝내지 않겠다는 설계). 이 재시도가 [1]단계(peer 선택)를 다시 통과해야 한다는 점이 다음 절과 연결됩니다.

1-6. 설정 세대(config generation) — 이 사건의 주인공

nginx 1.27.3부터 upstream의 zone(공유 메모리) peer 목록에는 세대 카운터가 있습니다 (ngx_http_upstream_zone_module.c — peer 추가·제거 때마다 +1, 따라서 IP 교체 1회 = 삭제+추가 = 세대 +2). 그리고:

  • 요청이 시작될 때 그 시점의 세대 번호를 스냅샷합니다 (ngx_http_upstream_round_robin.c:443)
  • [1]단계에서 peer를 고를 때마다 스냅샷과 현재 세대를 비교해, 다르면 — 즉 이 요청이 시작된 뒤 peer 목록이 바뀌었으면 — 선택을 포기하고 no live upstreams를 반환합니다 (:621, busy 판정)
  • busy 판정은 backup 폴백 경로도 건너뜁니다 (:692) — backup을 추가해도 이 경로에는 소용이 없는 이유

왜 이런 검사가 있을까요? resolve 때문에 peer 목록은 요청이 처리되는 도중에도 바뀔 수 있습니다. 그런데 요청이 들고 다니는 재시도 상태(이미 시도한 peer를 표시하는 비트맵, 현재 peer 포인터)는 시작 시점의 목록 구조를 전제로 합니다. 목록이 바뀐 뒤에도 계속 진행하면 비트맵이 엉뚱한 peer를 가리키거나 이미 해제된 peer를 참조할 수 있으므로, “목록이 바뀌었으면 이 요청의 peer 선택은 더 진행하지 않는다"로 안전하게 포기하는 것입니다. 요청 단위의 일관성을 지키는 보수적 설계이고, 그 대가가 이 문서의 현상입니다.

1-7. max_fails와 single 가드레일 — 이 사건과 “무관"함을 확인해 둘 배경

server 라인에는 적혀 있지 않아도 max_fails=1/fail_timeout=10s 기본값이 존재합니다 — “10초 창 안에 실패 1건이면 그 peer를 10초간 선택 제외"라는 수동적 헬스체크입니다. 단 peer가 1개뿐이면(single) 이 검사는 실행되지 않고 실패 카운터도 0으로 강제 리셋됩니다 (제외하면 보낼 곳이 없으므로).

이 문서의 설정은 resolve 1줄 = 평시 peer 1개라서 max_fails는 처음부터 꺼져 있고, 본문의 현상(세대 검사)은 실패 카운터를 아예 보지 않는 별도 검사입니다. max_fails가 주인공이 되는 것은 peer가 2개 이상이 되는 순간의 전멸 경로(7장) — 설정 중복이나 “GSLB가 일시적으로 A 레코드 2개를 반환"하는 창에서만 성립하는 형제 사건입니다.


2. 현상의 정의와 단서

2-1. 이 문서가 다루는 현상 (운영 관측 그대로)

nginx가 server <GSLB도메인> resolve 1줄로 백엔드를 바라보는 환경에서 (nginx -T 전개 확인 기준), 백엔드 서버는 죽지 않았고 GSLB가 로드밸런싱으로 A 레코드(응답은 항상 1개, 매 질의 교번)를 바꿀 뿐인데, 그 IP가 변경되는 시점에만 간헐적으로 한두 건씩 502 no live upstreams가 발생한다. 트래픽은 계속 많다. error log에는 그 한 줄뿐 직전 에러 라인이 보이지 않았고(tail -f 기준), 실패 메서드는 주로 POST에 GET도 소수 섞여 있다.

속성으로 분해하면:

# 속성
A1 서버 정상 + A 레코드 항상 1개(교번) + resolve 1줄
A2 IP 변경 시점에만 발생
A3 간헐적, 일부 요청만 (전면 장애·버스트 아님)
A4 트래픽 지속 중
A5 메서드 혼합 (POST 위주 + GET)
A6 전후로 다른 에러 라인 없이 no live upstreams 단독 (운영 로그 확인)

A1~A5는 3장의 원인(세대 검사)이 그대로 만들어내는 모양입니다. A6는 같은 원인의 두 발화 지점 중 어느 쪽인지를 가리는 속성입니다 — 재시도 순간에 걸리면(갈래 1) 직전에 1차 실패 라인이 짝으로 남고, 첫 선택 순간에 걸리면(갈래 2) 원리적으로 단독 라인만 남습니다. 단독 라인 관측은 갈래 2를 가리키며, 두 갈래는 조치가 동일합니다 (6-1절).

2-2. 단서와 해석

# 관측 해석
1 IP 변경 시점 부근에만 발생 IP 교체 자체는 무해(1-4절)하므로, 교체와 “시간적으로 겹쳐야만” 성립하는 메커니즘 — 세대 검사(1-6절)가 정확히 그 모양
2 지속 트래픽 중 일부 요청만 전멸·빈 목록 계열(7장)이면 그 구간 도착 요청 전부가 실패(버스트)해야 함 — 실측으로 구분 가능 (4-6절)
3 메서드가 섞임 (POST 위주 + GET) 선택 단계 거부는 메서드 무관 (1-2절). 실패 목록의 메서드 비율은 트래픽 비율의 반영
4 tcpdump: 같은 순간 다른 포트 2개가 같은 도메인 질의 같은 도메인을 바라보는 upstream 블록이 더 있다는 뜻 (블록별로 재질의 스트림 분리) — 각 블록 peer는 1개씩이라 본문 경로와 양립

3. 원인 — 세대 검사에 막힌 재시도 (generation race)

3-1. 타임라인으로 보는 발생 순간

sequenceDiagram
    autonumber
    participant C as 클라이언트
    participant N as nginx worker
    participant D as DNS 재조회<br/>(약 6초마다)
    participant B as backend (구 IP)

    C->>N: 요청 시작 (t0)
    Note over N: 이 순간의 peer 목록<br/>세대 번호 G를 기록해 둠
    N->>B: 1차 시도 (t1)<br/>peer 선택 통과(G==G) → 연결 → 전송
    D->>N: 재조회 응답이 다른 IP (t2)<br/>peer 교체 → 세대 = G+2
    Note over N,B: backend는 계속 정상 —<br/>바뀐 건 nginx의 peer 목록뿐
    B--xN: 1차 시도 실패 (t3)<br/>keepalive 회수 경합 등 일상 실패 (1-5절)
    Note over N: 재시도: peer 다시 선택 (t4)<br/>기록해 둔 G ≠ 현재 G+2<br/>→ 선택 포기
    N--xC: 502 "no live upstreams"<br/>(궤적: "구IP, be" = 2-hop)

세 가지 조건이 한 요청에서 동시에 성립해야 합니다:

  1. 그 요청이 1차 시도에 실패해야 하고 (낮은 확률 — 배경 실패율)
  2. 실패의 원인과 무관하게, t0~t4 사이에 IP 교체(t2)가 끼어야 하며 (교체는 재조회 주기당 최대 1회)
  3. 재시도가 세대 검사를 통과하지 못해야 (교체가 있었다면 항상)

그래서 드뭅니다 — 재현 실측에서 36,001 요청 중 101건(0.28%). 그리고 t4(실패 감지 → 재시도)는 t2(교체)로부터 1차 시도의 지연시간 이내이므로, 실패가 전부 교체 직후 수백 ms 안에 몰립니다 (실측 12~486ms — 백엔드 응답 지연 분포와 일치).

3-2. 관측이 전부 설명되는 이유

관측 설명
IP 변경 시점 부근에만 조건 2 — 교체가 요청의 진행 시간과 겹쳐야만 성립. 겹치지 않는 시간대엔 같은 1차 실패가 재시도로 조용히 구제됨
일부 요청만 조건 1×2의 곱 — “1차 실패를 겪은 요청” 중 “교체와 겹친” 것만. 나머지 트래픽은 그 순간에도 정상
간헐적 교체는 주기적이지만 조건 1(배경 실패)이 확률적이라, 교체 때마다 걸리는 게 아니라 걸릴 때만 걸림
직전 실패 로그가 눈에 안 띔 1차 실패 라인은 같은 요청의 커넥션 ID로 남지만 건수가 극소(0.28%)라 로그 홍수에 묻힘. server 블록별 error_log 분리 구성이면 다른 파일에 남기도 함 (6장)

3-3. POST가 많아 보이는 이유와, POST 특칙

선택 단계 거부는 메서드를 가리지 않으므로(1-2절) 실패 목록은 트래픽 구성을 반영합니다 — POST 위주 API면 실패도 POST 위주. 단 정밀하게는: 본문을 이미 전송한 뒤 실패한 POST는 재시도 자체가 거부되어(비멱등 보호, ngx_http_upstream.c:4464) 원래 실패 라인으로 502가 나므로, POST가 이 경로 (재시도 → 세대 검사 → no live upstreams)로 오는 경우는 연결/TLS 단계에서 실패했을 때입니다 — 이 단계 실패는 본문 전송 전이라 POST도 재시도됩니다.

3-4. max_fails가 이 사건과 무관한 이유

  • 세대 검사는 실패 카운터(fails)를 보지 않습니다 — 비교 대상은 오직 세대 번호입니다. max_fails를 0으로 하든 100으로 하든 검사식이 달라지지 않습니다
  • 게다가 이 설정(resolve 1줄)은 평시 peer 1개 = single이라 max_fails 계열은 처음부터 실행되지 않습니다 (1-7절)

→ 5장의 A/B 실측이 이를 그대로 확인합니다.

3-5. 변형 — 1-hop형 (참고)

세대 검사는 재시도만이 아니라 첫 선택에서도 수행됩니다. 요청 시작(스냅샷)과 첫 [1]단계 사이는 µs 수준이지만, 거기에 교체가 끼면 직전 실패 로그조차 없는 고립 단건 1-hop이 됩니다. 발생량이 rps × 교체 빈도에 비례해 트래픽이 큰 운영에서만 관측 가능한 수준이 되며(랩 300rps에선 26만 요청 중 0건), 판별은 6장과 같습니다. 대응도 본문과 동일합니다 (max_fails 무관, per-DC가 근본).


4. 재현 — 서버를 죽이지 않고, DNS만 바꿔서

4-1. 실험의 뼈대

검증하려는 질문을 그대로 실험으로 옮겼습니다: “backend 1·2는 절대 죽이지 않는다. DNS가 일정 주기로 다른 IP를 계속 반환할 뿐이다. 이때 nginx에서 no live upstreams가 나는가?” (Docker Compose, 재현 절차 전문: files/README.md)

[vegeta 300rps] --> [nginx 1.28.0 (official image)]
                        |  upstream be { server app.test.local:8080 resolve; ... }
                        |  A? app.test.local  (valid=5s -> 약 6초마다 재질의)
                        v
                  [모드 제어형 DNS 서버]
                        |  alternate 모드: 매 질의 두 VIP 중 1개를 번갈아 응답
                        |  (GSLB와 동일 -- 레코드는 늘 1개, IP만 계속 바뀜)
            +-----------+-----------+
            v                       v
    [backend1 172.20.0.20]   [backend2 172.20.0.30]
        (두 프로세스 모두 실험 내내 정상 -- 죽이지 않음)

nginx 설정은 운영 등가(resolve 1줄), 도메인·IP만 치환:

upstream be {
    resolver <DNS> valid=5s ipv6=off;
    resolver_timeout 3s;
    zone be 64k;
    server app.test.local:8080 resolve;
    keepalive 32; keepalive_time 180s; keepalive_timeout 60s;
}

4-2. 1차 결과 — DNS 교체"만"으로는 0건

위 구성 그대로, 추가 장치 없이 교체만 반복하면 오류가 나지 않습니다 — 누적 실측 교체 98회 / 30만 요청, 클라이언트 오류 0. 이유는 1-4절 그대로: 교체는 원자적이고 진행 중 요청은 구 IP로 계속 완주합니다. 즉 “IP가 바뀐다"는 사실 하나만으로는 이 현상이 재현되지 않습니다 — 교체 자체는 무죄이고, 이것이 이 실험의 첫 번째 확정 사실입니다.

4-3. 운영과 랩의 차이 — 빠져 있는 재료 하나

그런데 운영 환경에는 방금의 깨끗한 랩에 없는 것이 하나 있습니다: 일상적인 1차 시도 실패(1-5절)입니다. 운영의 백엔드·중간장비는 keepalive 연결을 각자의 타이머로 수시로 회수하고 배포·워커 재활용도 일어나므로, 낮은 비율의 1차 실패가 주입하지 않아도 늘 존재합니다. 이 재료를 랩에 재현하기 위해 백엔드에 다음 장치를 넣었습니다:

  • 백엔드가 자기 keepalive 연결을 무작위 시점에 응답 없이 회수(종료) — 실제 백엔드의 idle 타임아웃·배포가 하는 동작과 동일. 서버 프로세스는 계속 정상입니다 (죽는 것이 아님)
  • 운영에선 낮은 비율의 사건이라, 2분짜리 실험에서 관측되도록 빈도만 증폭

이 장치 단독으로는 무해함도 먼저 확인했습니다: IP 고정 + 회수 경합만 → 1차 실패 1,418건 발생했지만 전부 재시도로 구제, 클라이언트 오류 0.

4-4. 2차 결과 — 두 조건이 “겹치면” 현상 발생

300rps × 120초 = 36,001 요청, IP 교체 19회 기준:

조합 클라이언트 오류
IP 교체만 (회수 경합 없음) 0 (누적 98회/30만)
회수 경합만 (IP 고정) 0 (1차 실패 1,418건 전부 재시도 구제)
IP 교체 + 회수 경합 (운영과 같은 조합) no live upstreams 101건 (0.28%)

101건의 모양이 결정적입니다:

  • 101건 전부가 IP 교체 시각 기준 +12ms ~ +486ms 안 — 교체와 무관한 시각의 발생 0건. 3-1절의 타임라인 그대로
  • 전부 2-hop 궤적 ("구IP:8080, be") — “1차 시도 실패 후 재시도가 거부됨”
  • 방향 대칭 (.20→.30 / .30→.20 각 49+49건) — 특정 서버 문제가 아니라 교체라는 사건 자체가 원인

이때 error log의 실제 모양 — 실패 요청 하나가 남기는 것은 한 줄이 아니라 같은 커넥션 ID의 라인 쌍입니다 (랩 로그 원문, 같은 초):

2026/07/07 11:12:15 [error] 23#23: *307 upstream prematurely closed connection
    while reading response header from upstream, ... upstream: "http://172.20.0.30:8080/"
2026/07/07 11:12:15 [error] 23#23: *307 no live upstreams
    while connecting to upstream, ... upstream: "http://be/"

첫 줄이 1차 시도 실패(실제 구 IP가 찍힘), 둘째 줄이 거부된 재시도입니다. 2-hop 궤적과 이 라인 쌍은 항상 짝입니다 — 재현형(갈래 1)에서 no live upstreams가 전후 아무것도 없이 한 줄만 남는 일은 없습니다. 정말 한 줄만 있다면 그것은 1-hop형(갈래 2, 3-5절)이고 궤적도 "be" 단독이어야 합니다. 주의: 커넥션 ID는 클라이언트 연결 기준이라 keepalive로 여러 요청이 같은 ID를 공유합니다 — 같은 ID 라인이 여럿이면 시각(같은 초)과 request로 쌍을 식별합니다.

4-5. 정의(2-1절)와의 속성 대조 — 이 재현이 “같은 현상"인지의 답

속성 랩 실측 판정
A1 서버 정상·1레코드 교번·1줄 동일 구성, 백엔드 프로세스 내내 정상 일치
A2 IP 변경 시점에만 실패 전부 교체 후 12~486ms (5-1절 A/B에서도 149/149건이 0.5초 내) 일치
A3 간헐·일부만 36,001건 중 101건(0.28%), 고립형(연속 최장 1~2건), 교체 19회 중 일부에서만 일치
A4 트래픽 지속 300rps 유지 중 발생 일치
A5 메서드 혼합 선택 단계 거부는 메서드 무관 (1-2절) — 실패 구성은 트래픽 구성 반영 일치
A6 단독 라인 (전후 무에러) 2-hop 재현형은 라인 쌍을 남김 (위 로그 원문) — 단독 라인과는 모양이 다름 갈래 2를 가리킴 — 아래 4-7절에서 별도 재현

즉 A1~A5는 이 재현으로 일치가 확인됐고, A6(단독 라인)은 같은 원인의 다른 발화 지점 — 첫 선택 순간에 걸린 1-hop형(갈래 2, 3-5절) — 을 가리킵니다. 1-hop형은 1차 실패라는 선행 사건 자체가 없으므로 원리적으로 단독 라인만 남고, 갈래 1과 조치가 동일합니다 (6-1절). 이 모양 그대로의 재현은 4-7절입니다.

4-6. 형제 현상과의 구분 (7장 전멸 계열)

같은 환경에서 peer가 2개가 되는 순간(설정 중복, 또는 GSLB가 전환 창에 A 레코드 2개를 반환)에는 전혀 다른 모양의 사건 — 그 구간 도착 요청 전량이 1-hop으로 실패하는 버스트 — 가 납니다 (실측: 사건당 82~2,900건, 상세 7장). 본문 현상(일부 요청만, 2-hop)과는 건수 구조로 구분됩니다. 운영에서 어느 쪽인지 판별하는 절차가 6장입니다.

4-7. 단독 라인(갈래 2) 모양의 재현 시도 — 확률 상한 실측

운영 로그 모양(단독 라인)을 그대로 만들기 위해, 다른 에러가 원리적으로 나올 수 없는 환경을 구성했습니다: 장애 주입 완전 제거(회수 경합 0, 지연 0) + 서버 2대 정상 + DNS만 2초마다 다른 IP 반환(valid=1s) + 3000rps × 300초 = 90만 요청, 교체 149회. 이 환경에서 나올 수 있는 502는 “첫 선택 순간에 교체가 겹친 요청” (갈래 2)뿐이고, 그 로그는 정확히 단독 라인이어야 합니다.

결과: 오류 0건. 이것이 말해주는 두 가지:

  • 3000rps에서도 IP 교체 자체는 완전히 무해 (4-2절의 재확인, 표본 30만→90만)
  • 갈래 2의 겹침 창은 수 µs 이하로 좁습니다 (0건 관측 → 창 ≤ 약 7µs 상한). 기대 발생량 = rps × 창 × 교체 횟수이므로, 랩 규모(90만 요청 × 149교체)로는 기대값이 1건 미만이라 통계적으로 잡히지 않는 것이 정상이고, 거꾸로 운영 규모(수천 rps × 재조회마다 교번 = 하루 1만+ 교체)에서는 하루 수 건 — “간헐 한두 건"이라는 관측 빈도와 정합합니다

따라서 갈래 2의 근거는 세 겹입니다: ① 같은 검사의 다른 발화 지점(재시도 순간, 갈래 1)은 4-4절에서 실측 재현됨 ② 첫 선택 순간의 동작 자체는 소스로 확정 (1-6절) ③ 랩의 확률 상한이 운영의 낮은 빈도와 정합. 단독 라인의 남은 대안 설명은 하나 — could not be resolved다른 파일(http 레벨 error log)로 분리되는 구성이면 DNS 계열(7-2절)도 그 파일을 안 본 눈에는 단독 라인처럼 보일 수 있으므로, nginx -T | grep error_log로 로그 파일 전수를 확인해 사건 시각을 grep하는 것으로 배제합니다 (6-2절).


5. 조치와 검증

5-1. max_fails=0 — 이 현상에는 효과 없음 (직접 A/B 실측)

server app.test.local:8080 resolve max_fails=0;   # <- 이 변경의 효과를 검증

4-4절과 동일 조건(교번 19회 + 회수 경합, 36,000 요청)으로 A/B:

구성 no live upstreams
resolve 1줄, max_fails 기본 69건 — 전부 2-hop, 전부 교체 후 1~500ms
resolve 1줄 + max_fails=0 80건 — 동일한 모양 (전부 2-hop, 교체 후 41~482ms)

예측대로 변화가 없습니다 — 발생 위치·궤적·교체 정렬까지 같은 모양이고, 건수 차이(69 vs 80, 그리고 4-4절 재현 런의 101)는 “1차 실패가 교체와 우연히 겹친 횟수"의 런 간 표본 변동입니다. 이유는 3-4절 — 세대 검사는 실패 카운터를 보지 않고, 1줄 구성에서는 max_fails가 애초에 실행되지도 않습니다. max_fails=2 상향(131건 그대로), backup 추가(128건 잔존 — busy 판정이 backup을 건너뜀)도 같은 이유로 무효였습니다.

단, max_fails=0해로운 것도 아닙니다 — 평시 no-op이고, 형제 사건인 전멸 계열(7장 — GSLB가 일시적으로 A 레코드 2개를 반환하는 창)에는 결정적 방어가 되므로, 보험으로 넣어두는 것은 여전히 유효합니다. “이 한 토큰으로 본문 현상까지 사라진다"고 기대하면 안 된다는 것이 이 절의 결론입니다.

5-2. 근본 해결 — DC별 고정 도메인 2개 (per-DC)

원인이 “IP가 바뀌는 순간"이므로, IP가 바뀌지 않는 구조로 만드는 것이 근본 해결입니다:

flowchart TB
    subgraph AS_IS["현재 — GSLB 도메인 1개: 몇 초마다 IP가 바뀜"]
        N1["nginx<br/>server api.example.com resolve"] -->|"A? → 매번 VIP 1개 선택"| G["GSLB"]
        G -.->|"이번엔 이쪽"| V1a["DC1 VIP"]
        G -.->|"다음엔 저쪽"| V2a["DC2 VIP"]
    end
    subgraph TO_BE["개선 — 고정 도메인 2개: IP가 안 바뀜 = 사건 소멸"]
        N2["nginx<br/>server dc1.internal resolve<br/>server dc2.internal resolve"] -->|"항상 같은 IP"| V1b["DC1 VIP"]
        N2 -->|"항상 같은 IP"| V2b["DC2 VIP"]
    end
upstream be {
    resolver <DNS> valid=5s ipv6=off;
    zone be 128k;
    server dc1.internal:443 resolve max_fails=2 fail_timeout=5s;
    server dc2.internal:443 resolve max_fails=2 fail_timeout=5s;
    keepalive 1024; keepalive_time 180s; keepalive_timeout 30s;
}

GSLB 도메인 1개 대신 DC별 고정 도메인 2개를 바라보면 DNS 응답이 불변 → IP 교체가 사라지고 → 세대가 증가하지 않으므로 이 경로는 성립 자체가 불가능해집니다. DNS/네트워크 팀에 요청할 내용도 단순합니다 — GSLB 로직 변경이 아니라 “두 도메인을 각 DC의 VIP로 고정 등록해 달라"(일반 A 레코드 2개 신설)입니다. 분산은 GSLB 대신 nginx의 round-robin이 두 줄 사이에서 수행합니다. 실측: 교번 10분 + 백엔드 강제 장애 주입에도 클라이언트 오류 0 (1차 실패 75건 전부 같은 세대 안에서 다른 DC로 재시도 성공). 분산은 nginx의 round-robin이 대신하고, DC 장애 시 제외가 이득이므로 여기서는 max_fails를 명시적으로 켭니다. 7장의 전멸·DNS 계열까지 모든 경로가 동시에 소멸하는 유일한 조치입니다.

5-3. 구조 변경이 어려울 때의 완화 2종

완화 A — 클라이언트/엣지(L7 앞단) 재시도. 이 경로의 실패는 “그 순간"에만 성립하므로 재시도 1회면 성공합니다. 호출 측에 재시도 정책이 이미 있다면 추가 작업 없이 이것으로 흡수됩니다 — 멱등성 관리가 클라이언트에 있다면 가장 단순한 완화입니다.

완화 B — nginx 내부 재시도 패스. 세대 검사에 막힌 요청을 nginx 안에서 한 번 더 태웁니다. 새 패스는 새 세대 스냅샷으로 시작하므로 검사를 통과합니다:

server {
    location / {
        proxy_pass https://be;
        error_page 502 = @retry_once;   # nginx가 만든 502만 여기로 옴
        # (proxy_intercept_errors 기본 off: 백엔드가 직접 돌려준 502는
        #  이 error_page를 타지 않고 그대로 전달됨 — 원하는 선별성)
    }

    location @retry_once {
        # 비멱등 가드: 본문 재전송 위험이 있는 메서드는 재시도하지 않음
        if ($request_method !~ ^(GET|HEAD|OPTIONS)$) { return 502; }
        proxy_pass https://be;          # 새 패스 = 새 세대 스냅샷
        # 이 블록에는 error_page가 없음 -> 최대 2회로 종료 (루프 불가)
    }
}

실측: 내부 실패 127건 전량 구제(클라이언트 오류 0), 지속 장애 상태에서도 정확히 2회에서 종료(루프 불가 실측). 비멱등 가드로 POST 이중 전송 위험을 차단해도 구제율 손실이 없음도 실측했습니다 — POST는 어차피 본문 전송 후엔 재시도가 불가라 이 패스에 오는 POST 자체가 드뭅니다 (3-3절).

5-4. 완화 보조 — 발생 확률 자체를 줄이기

발생량 ∝ (배경 1차 실패율) × (IP 교체 빈도) 이므로 양쪽을 줄일 수 있습니다. 설정으로는 세 토큰입니다:

upstream be {
    resolver <DNS> valid=30s ipv6=off;   # valid 상향(교체 빈도↓ — 트레이드오프 아래 참조)
                                         # ipv6=off (AAAA 오염 예방 — 7-2절)
    resolver_timeout 3s;
    zone be 128k;
    server api.example.com:443 resolve max_fails=0;   # 전멸 계열 보험 (7-1절, 본 경로엔 무효)
    keepalive 1024;
    keepalive_time 180s;
    keepalive_timeout 30s;               # 백엔드 유휴 한도(예 60s)의 절반 — 회수 경합↓
}
  • keepalive 위생: nginx keepalive_timeout을 백엔드·중간장비 유휴 한도의 절반으로 (예: 60s→30s). 회수 경합(1-5절)의 빈도 자체를 낮춤. 양쪽 동률이 경합 최대화로 최악
  • valid 상향 (트레이드오프 주의): 재조회가 줄면 교체 빈도가 줄어 이 경로 발생량은 비례해서 감소합니다. 단 대가가 실측으로 확인됨 — DNS 사고(NXDOMAIN 등) 시 전면 정전이 valid에 비례해 길어지고(6→31초, 5.2배), GSLB failover 반영도 느려짐(3.8→12.2초, 3.2배). 임시 완화로는 의미가 있지만 근본 조치를 대체하지 못합니다
  • ipv6=off (해당 resolver 라인에): AAAA 질의 오염발 전면 정전 예방 (7장), 부작용 0

5-5. 하지 말 것 (실측 기각)

  • backup 서버 추가 (이 경로 목적): busy 판정이 backup 폴백을 건너뛰어 무효 (128건 잔존). backup은 DNS 계열(7장) 방어용이며, 쓸 경우 backup 라인에도 max_fails=0 누락 금지 — 누락 시 일상 실패가 backup을 10초씩 제외시켜 도입 전보다 악화 실측 (904건 vs 순정 101건; 수정 후 119건)
  • proxy_next_upstream off: 평소 재시도가 가려주던 일상 실패(1-5절) 전부가 그대로 클라이언트 502가 됨

6. 운영 판별 — 세 갈래를 닫는 절차와 갈래별 조치

6-1. 세 갈래 — 원인과 조치

2-1절의 A6(직전 라인 유무)과 사건의 건수 구조에 따라 해석이 세 갈래로 닫힙니다. 가장 빠른 쐐기는 사건 502 라인의 커넥션 ID(*숫자)로 error log 전체를 grep 하는 것입니다 (server 블록별 분리 구성이면 다른 파일 포함 — nginx -T | grep error_log로 전 경로 확인).

쉬운 말로 먼저: 갈래 1과 2는 같은 병의 두 증상입니다. 원인은 하나 — “요청을 처리하는 도중에 백엔드 IP 목록이 바뀌면, nginx는 안전을 위해 그 요청의 서버 선택을 포기하고 502를 낸다”(3장). 다른 것은 어느 순간에 걸렸느냐뿐입니다:

  • 갈래 1: 요청이 한 번 시도했다가 흔한 이유(keepalive 연결 회수 등)로 실패해 다시 시도하려는 순간에 IP가 바뀌어 있던 경우 → 직전에 첫 실패 로그가 남음
  • 갈래 2: 요청이 처음 서버를 고르는 아주 짧은 순간에 IP가 바뀐 경우 → 흔적 없이 no live upstreams 한 줄만 남음
갈래 판별 신호 원인 조치
1. IP 변경이 “재시도 순간"과 겹침 (본문 3장, r5b/r12로 재현) 커넥션 ID grep에서 직전 1차 실패 라인 발견 + 2-hop 궤적("IP, 그룹명") + 고립 소수 건 1차 실패의 재시도가 목록 변경 검사에 거부됨 5-2절 고정 도메인 2개(근본, 실측 0건) / 재시도 1회 추가 — 클라이언트 또는 nginx 내부(5-3절, 전량 구제) / valid 상향은 완화(트레이드오프, 5-4절). max_fails=0 무효
2. IP 변경이 “첫 선택 순간"과 겹침 (3-5절) grep으로도 직전 라인 정말 없음 + 1-hop("그룹명" 단독) + 고립 단건 + 같은 초에 성공 섞임 첫 선택이 같은 검사에 거부됨 — 갈래 1과 같은 원인 갈래 1과 완전히 동일
3. 전멸 (7-1절) 사건 ±2초가 전-실패 버스트(건수 ≈ rps × 지속) + temporarily disabled warn + 전환 시간대 dig에서 A 레코드 2개 peer가 일시 2개 → 숨은 max_fails=1 무장 → 배경 실패 2건 겹침 → 전 peer 제외 해당 라인 max_fails=0 (실측 7,188→104) 또는 설정 중복 제거. 고정 도메인 2개로도 소멸

정리: 조치가 실제로 갈리는 것은 갈래 3 하나입니다 (거기서만 max_fails=0이 정답). 갈래 1·2는 같은 원인이라 조치가 같고, per-DC 고정 도메인 2개(5-2절)는 세 갈래 전부를 소멸시키므로 판별이 늦어져도 종착지는 같습니다 — 판별의 실익은 “근본 조치 전까지 어떤 보험·완화를 쓸지"와 원인 보고입니다.

전체 판별-조치 흐름을 한 장으로:

flowchart TD
    S["no live upstreams 발생"] --> Q1{"사건 ±2초 access log:<br/>그 구간 요청이 전부 실패?"}
    Q1 -->|"전부 실패 (버스트)"| P3["갈래 3: 전멸 (7-1절)"]
    P3 --> F3["해당 server 라인에 max_fails=0<br/>(실측 7,188 → 104)"]
    Q1 -->|"성공 사이 소수만 실패"| Q2{"502 라인의 커넥션 ID로<br/>error log 전체 grep:<br/>직전 1차 실패 라인?"}
    Q2 -->|"있음 (2-hop)"| P1["갈래 1: IP 변경이<br/>재시도 순간과 겹침"]
    Q2 -->|"없음 (1-hop)"| P2["갈래 2: IP 변경이<br/>첫 선택 순간과 겹침"]
    P1 --> FIX["조치 동일 (5장):<br/>재시도 1회 — 클라이언트 또는 nginx (5-3절)<br/>+ 완화: valid 상향, keepalive 위생 (5-4절)<br/>(max_fails=0은 무효)"]
    P2 --> FIX
    FIX --> ROOT["근본: DC별 고정 도메인 2개 (5-2절)<br/>— 세 갈래 모두 소멸, 실측 0건"]
    F3 --> ROOT

6-2. 판별 절차

0단계 — 사건의 건수 구조 (형제 경로와의 가장 강력한 구분): no live upstreams 발생 시각 ±2초의 access log에서 같은 upstream 요청 전부를 확인합니다.

그 구간의 모양 판정
성공 사이에 고립된 소수 건만 502 본문 경로 (세대 race) — max_fails 무관, 5-2/5-3이 답
도착 요청 전부 502 (버스트, 건수 ≈ rps × 지속 시간) 전멸 계열 (7장) — 그 라인 max_fails=0으로 종결

1단계 — $upstream_addr 궤적과 직전 로그:

  • 502 라인이 "IP, 그룹명" (2-hop) → 본문 경로의 재시도형. 같은 커넥션 ID(*숫자)로 error.log를 grep하면 직전에 그 요청의 1차 실패 라인 (prematurely closed/connect() failed/reset by peer/SSL 계열)이 반드시 있음 — 이것이 확정 증거
  • "그룹명" 단독 (1-hop) + 고립 단건 → 본문 경로의 1-hop 변형 (3-5절)
  • "그룹명" 단독 (1-hop) + 버스트 → 전멸 계열 (7장)
  • 직전에 could not be resolved → DNS 계열 (7장). 주의: 이 라인은 재조회당 1줄뿐이고 server 블록별 error_log 구성이면 다른 파일(http 레벨)에 남음 — “없다” 판정 전에 nginx -T | grep error_log로 전 경로 확인

2단계 — 시각 정렬: 사건 시각들을 DNS 교체 시각(재조회 주기)과 대조 — 본문 경로면 전부 교체 직후 ~0.5초 안에 정렬됩니다 (4-4절).

보조 판별:

  • 실패 건들의 메서드 구성: GET이 섞여 있으면 메서드 무관 경로(선택 단계 거부)의 증거. POST 비중이 높은 것 자체는 트래픽 구성의 반영일 뿐
  • error_log를 warn으로 상향: upstream server temporarily disabled가 사건 시점에 있으면 전멸 계열(제외 발생), 없으면 본문 경로 쪽 증거
  • 전환 시간대 dig A <도메인> +noall +answer | grep -c ' A ' 1초 루프: 2가 찍히면 전멸 계열의 전제(일시 2레코드)가 실재 — 7장 참조
  • 과거 valid를 올려 운영한 기간이 있다면 그때의 발생 빈도 비교: 교체 빈도에 비례해 줄었다면 본문 경로 정합 (5-4절)

7. 참고 — 그 외 실측된 no live upstreams 경로

같은 랩에서 실증한 형제 경로들입니다. 시그니처가 다르므로 6장으로 판별 후 적용:

7-1. max_fails 전멸 (1-hop 버스트)

peer가 2개 이상이 되는 순간 숨은 기본값 max_fails=1/fail_timeout=10s가 동작을 시작하고(1-7절의 single 가드레일 해제), 일상 실패 2건이 10초 창에 겹치면 전 peer 제외 → 그 구간 도착 요청 전량이 1-hop으로 실패하는 버스트 (사건당 건수 = rps × 죽은 구간, 실측 82~2,900건). peer가 2개가 되는 경위와 실측:

경위 재현 실측 (36,001 요청) 그 라인 max_fails=0 적용 후
설정 전개상 resolve 라인 중복 (include 이중 로드 등 — nginx -T로 확인) 12,359건 (34.3%), 1-hop 99.7% 96건 (잔여는 본문 세대 race)
GSLB가 전환 창에서 일시적으로 A 레코드 2개 반환 (1줄 설정에서도 성립) 7,188건 (20.0%), 2레코드 창 구속 100% 104건 (동일)

이 계열의 확정 시그니처: 버스트 + temporarily disabled warn 로그. 조치는 해당 라인 max_fails=0 (평시 single이라 no-op = 부작용 0 보험) 또는 중복 제거. 배경 실패율을 낮추면 사건 빈도만 줄고 사건당 크기는 유지됨(버스트 82건 최소) — “일부 요청만"과는 모양이 다름.

7-2. DNS 계열 (1-hop, “시간 창” 형태)

경로 조건 증상 실측 방어
NXDOMAIN·빈 응답 재조회가 해당 응답 수신 (1-4절 표) 다음 성공 재조회까지(최대 valid+1초) 창이 열리고 그 안의 도착분 전부 502 + could not be resolved (파일 분리 주의) 3,005건/6.0초 @500rps static backup 추가(+전 라인 max_fails=0) — 전량 구제 실측
AAAA 오염 ipv6=off 없음 + AAAA 질의만 NXDOMAIN 응답 A 레코드는 내내 정상인데 위와 동일 3,001건/6.0초 (대조군 0) ipv6=off 한 줄

주의 — 이 계열도 “단건"처럼 보일 수 있습니다: 실패가 “시간 창”(최대 ~6초) 형태라서 사건당 건수 = 그 창에 도착한 요청 수입니다. 해당 upstream이 저빈도 (초당 1건 미만)라면 창당 한두 건만 걸려 갈래 2와 겉모양이 같아집니다. 판별: ① 사건 시각 ±10초에 could not be resolved 유무(전 로그 파일) ② 창 안에 같은 upstream의 성공이 섞여 있으면 이 계열이 아님(창 안은 전부 실패여야) ③ 전환 시간대 dig A/AAAA 모니터링.

7-3. 무해 확인 (조치 불요)

SERVFAIL·DNS 타임아웃(목록 유지), IP 교체 자체(원자적) — 각 0건 실측.


8. 실행 로드맵

단계 조치 효과
0 (판별) 6장 절차 — 건수 구조(고립 vs 버스트) + 커넥션 ID grep + 교체 시각 정렬 본문 경로/전멸/DNS 계열 확정
1 (즉시) 전 resolve 라인 max_fails=0 + ipv6=off — 전멸·AAAA 계열 보험 (본문 경로에는 무효임을 인지하고) 형제 경로 차단 (실측 7,188→104 등)
2 (완화) keepalive 위생(유휴 한도 절반) + 클라이언트/엣지 재시도 (+ 필요시 가드 포함 재시도 패스) 본문 경로 발생률·노출 축소 (실측 전량 구제)
3 (근본) DC별 고정 도메인 2개 → 5-2절 구성 본문 포함 전 경로 동시 소멸 (실측 0건)

FAQ — 진단 과정에서 나온 질문들

Q1. 기존(in-flight) 요청이 이전 IP를 계속 쓰게 하는 설정은 없나? 필요 없습니다 — 이미 기본 동작입니다. TCP 연결은 connect 시점에 고정되고 nginx는 peer 교체 시 진행 중 연결을 끊지 않습니다 (실측: 교체만 주입 시 오류 0). 문제는 in-flight가 아니라 재시도의 세대 검사입니다 (3장).

Q2. 서버가 죽지 않고 GSLB가 IP만 바꿔도 문제가 되나? 됩니다 — 원인은 서버 장애가 아니라 일상 커넥션 경합(1-5절) × IP 교체의 시간적 겹침입니다. 백엔드 전원 정상 조건에서 재현했습니다 (4장).

Q3. max_fails=0을 넣으면 이 현상이 사라지나? 아닙니다 — 직접 A/B 실측에서 변화가 없었습니다 (5-1절). 세대 검사는 실패 카운터를 보지 않고, resolve 1줄 구성은 애초에 max_fails가 비활성(single 가드레일)입니다. 단 전멸 계열(7-1절) 보험으로는 유효하고 무해하므로 넣어두는 것 자체는 권장합니다.

Q4. GET에는 재시도가 있으니 이 502는 POST에만 발생하는 것 아닌가? 아닙니다 — 재시도의 메서드 구분은 “서버까지 갔다가 실패한” 요청에만 적용되고, no live upstreams는 선택 단계 거부라 GET도 걸립니다 (1-2절). 실패 목록이 POST 위주인 것은 트래픽이 POST 위주라는 뜻일 뿐입니다. 정밀하게는 POST가 이 경로에 오는 경우는 연결/TLS 단계 1차 실패일 때입니다 (3-3절).

Q5. “1차 실패가 늘 존재한다"는 게 매 요청 실패라는 뜻인가? 아닙니다 — 낮은 비율로 끊이지 않는다는 뜻입니다 (1-5절). 평시 error.log의 prematurely closed 건수가 실측값이고, 평소엔 재시도가 전부 가립니다.

Q6. 직전에 에러 라인이 없어 보이는데 어떻게 “1차 실패 후 재시도"가 원인인가? 2-hop형이라면 직전 실패 라인이 같은 커넥션 ID로 반드시 있습니다 — 건수가 극소(0.28%)라 tail -f로는 놓치기 쉽고, server 블록별 error_log 구성이면 다른 파일에 있을 수 있습니다. 커넥션 ID grep이 확정 절차입니다 (6장). grep으로도 정말 없다면 1-hop 변형(3-5절) 또는 형제 경로(7장) — 건수 구조로 구분합니다.

Q7. 응답 IP가 1개뿐이면 NXDOMAIN을 받아도 기존 IP로 계속 가나? 아닙니다 — NXDOMAIN은 peer 수와 무관하게 목록을 통째로 비웁니다 (실측 6초/3,005건, 7-2절). 유지되는 것은 SERVFAIL·무응답뿐 (1-4절).

Q8. keepalive_timeout을 줄여도 문제없나? 30초 수준은 안전합니다 — 유휴 연결 정리는 정확성에 영향이 없고, 비용은 한산 시간대 새 연결 소폭 증가뿐. 양쪽 타이머가 동률(예: 둘 다 60초)이면 회수 경합이 최대화되어 최악입니다. 수 초 이하로 낮추면 풀이 무력화되니 금물 (5-4절).

Q9. valid를 늘리면 교체가 줄어드니 완화되지 않나? 이 경로(세대 race)에는 발생량이 교체 빈도에 비례하므로 완화 효과가 실재 합니다. 대가는 DNS 사고 시 전면 정전 5.2배·failover 반영 3.2배 연장 (5-4절 실측). 임시 완화로는 유효하되 근본 조치(per-DC)를 대체하지 못합니다.

Q10. error_page 502 = @retry_once는 진짜 502면 무한 루프 아닌가? 아닙니다 — 정확히 2회에서 끝납니다(@retry_once에 error_page 없음 + recursive_error_pages 기본 off). 실측: 지속 502 상태에서 전 요청 2-pass 후 최종 502. 비멱등 가드와 함께 쓰는 조건은 5-3절.

Q11. could not be resolved가 있으면(다른 파일 포함) 그것도 “단건 일시 실패"로 보일 수 있나? 그 upstream이 저빈도일 때만 그렇습니다. 이 계열의 실패는 최대 ~6초짜리 “시간 창” 형태라 사건당 건수 = 창에 도착한 요청 수입니다 (7-2절). 초당 1건 미만이면 한두 건 = 갈래 2와 겉모양 동일. 판별은 ① 그 라인의 유무 자체(있으면 이 계열 확정) ② 창 안 성공 유무(섞여 있으면 이 계열 아님) ③ dig A/AAAA. 판명 시 조치가 추가됩니다: ipv6=off + static backup(+전 라인 max_fails=0) — 창 전량 흡수 실측.


검증 환경과 데이터

Docker로 nginx 1.28.0(공식 이미지) + 모드 전환형 커스텀 DNS + 백엔드 2대(커넥션 회수 경합 주입 가능)를 구성하고, vegeta 부하 중 DNS 모드 전환을 재조회 주기에 정렬해 주입했습니다. access log($upstream_addr 포함)·DNS 질의 로그·주입 이벤트 로그를 epoch 시각으로 동기화해 502를 전수 분류했고, 모든 조치 후보는 동일 주입 조건의 A/B로 검증했습니다. 소스 대조는 nginx 1.28.0 원본 기준이며 1.29.x까지 해당 동작의 변경이 없음을 확인했습니다.

자가 점검 3문

  1. IP 교체 자체는 무해한데(원자적, in-flight 유지) 왜 교체 “시점"에만 실패가 나는가? 세 가지 성립 조건은 무엇인가? (1-4절, 3-1절)
  2. max_fails=0이 이 현상에 무효인 이유 두 가지는? 그런데도 넣어두라고 권장하는 이유는? (3-4절, 5-1절, 7-1절)
  3. 같은 no live upstreams라도 “고립 소수 건 2-hop"과 “전-실패 버스트 1-hop"이 가리키는 원인과 유효한 조치는 각각 무엇인가? (4-6절, 6장, 7장)

Files