# nginx `resolve` "no live upstreams" 재현·검증 실험 — 최종 보고서 - 일자: 2026-07-06 / 환경: 홈랩 워크스테이션 (Docker compose, 상세 `README.md`) - 목적: nginx 1.28.0 `server ... resolve` + GSLB 단일 A 레코드 교번 환경에서 "no live upstreams"(이하 NLU) 502의 실제 발생 경로 실증 및 설정 변경 전/후 정량 비교 - 원본 데이터: `artifacts//` (access/error/dns 로그, vegeta 결과, 페이즈별 verdict.md) --- ## 1. 환경 지문 | 항목 | 값 | |---|---| | nginx | nginx/1.28.0 — 공식 이미지 `nginx:1.28.0`, digest `sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609`, gcc 12.2.0 (Debian), `nginx-debug` 바이너리 존재 | | worker | `worker_processes 4` | | 소스 대조 | `nginx-1.28.0.tar.gz` sha256 `c6b5c6b0...ff76a` (nginx.org), `ngx_http_upstream_zone_module.c` | | 부하 | vegeta 500rps (peterevans/vegeta), 백엔드 용량 사전 검증 (500rps p99<0.5ms, 성공 100%) | | DNS | 커스텀 UDP 응답기 (TTL=1, 모드 제어), 전 질의 로깅 | | 계측 | access log `$msec $status $upstream_addr $upstream_status $request_time`, epoch 기준 3-로그 join | **P0 소스 대조 결론** (`artifacts/p0/source-check.md`, 코드 인용 포함): 1. resolve handler는 **단일 wlock 임계구역** 안에서 기존 peer 제거+신규 추가를 완료(770→996행). 요청 측 peer 선택(`round_robin.c:618`)이 같은 rwlock을 사용 → **"peer 0개 중간 상태"는 요청이 관찰 불가. "0-peer 스왑 창" 가설은 1.28.0에서 구조적으로 배제** 2. NXDOMAIN 수신 시 `naddrs=0`으로 제거 루프 진입 → **전 peer 제거** (801~813행). SERVFAIL/timeout은 peer를 건드리지 않고 조기 return (안전) 3. slab 할당 실패 시 `memory exhausted` 로그 + 추가 포기 경로 존재 (919~950행) 4. (부수 발견) 재조회 타이머는 `valid - now + 1초` (1002행) → **실측 재질의 주기 = valid+1초** (valid=5s → 6.0초, valid=30s → 31.0초로 전 페이즈에서 실측 일치) --- ## 2. 페이즈별 결과 | 페이즈 | 조작 | 기대 | 실측 | 판정 | |---|---|---|---|---| | P1 baseline | alternate(매 재질의 IP 교번), 500rps×600s | 502_nlu=0 | 300,000/300,000 성공, **스왑 98회, NLU 0** | **지지** (스왑 원자성) | | P2 가설A base | valid=5s, NXDOMAIN 6s 주입(재조회 정렬) | Host not found + NLU 연속 | **NLU 3,005건 / 정확히 6,008ms** (재질의 1주기) | **지지** | | P2 valid30 동일절차 | valid=30s, 동일 6s 주입 | (검증) | **NLU 0 — 버스트 미샘플링** (재질의 31s 주기) | 지지(양면성↓) | | P2 valid30 정렬주입 | valid=30s, 재질의가 창에 걸리게 정렬 | 정전 ≈ 30s급 | **NLU 15,521건 / 31,040ms (base의 5.2배)** | **지지** | | P3 안전경로 | DNS 무응답 10s | 502=0 | 45,000/45,000 성공, errno 110만 기록, **502=0** | **지지** | | P4 가설C v-dual | resolve 2줄(single=0), RST 2.1s | conn 후 ~10s NLU tail | 최초 실패 **1건** → **NLU 5,209건/10,415ms, RST 제거 후 +8.3s 지속** | **지지 (핵심)** | | P4 v-dual-mf0 | 위 + max_fails=0 | tail 소멸 | conn 1,047건(RST 구간만), **NLU 0, tail 없음** | **지지** | | P4 base 대조군 | resolve 1줄(single=1), 동일 주입 | tail 없음 | conn 1,047건, **NLU 0, tail 없음** | **지지** | | P5 RTO | T0: backend1 RST+DNS를 backend2로 전환 | RTO ~ U(0, valid] | valid5: **2.5/3.1/5.8s** (평균 3.8) · valid30: **8.6/10.8/17.2s** (평균 12.2) | 정량화 완료 | | P6 구조적 대응 | v-perdc, alternate 600s + RST 5s | 스왑 0, 오류 ≈0 | 600s: 300,000 전량 성공·스왑 0 / RST 5s: **30,000 전량 성공 (재시도 2건뿐)** | **지지** | | P7a slab 탐색 | multi:25↔static 50회 전환(무부하) | 탐색적 | 재질의 28회가 multi:25 샘플, **memory exhausted 0건**, 종료 후 200 정상 | **미재현 (부정 결과)** | | P7b shim 반증 | v-shim(error_page 502 재시도)로 P2 재실행 | 502 감소 없음 | **NLU 3,000건/5,998ms — p2-base(3,005건/6,008ms)와 동일.** 전 502가 `be : be`(내부 재시도도 같은 빈 집합에 도달) | **지지 (shim 무효)** | ### 증거 발췌 **P2-base — NXDOMAIN → 전 peer 제거 → NLU (error.log, 라인 수가 access.log 502_nlu와 정확 일치: 3,005)** ``` 2026/07/06 11:13:41 [error] 22#22: app.test.local could not be resolved (3: Host not found) 2026/07/06 11:13:41 [error] 22#22: *5009 no live upstreams while connecting to upstream, ... (6.0초 뒤 다음 성공 재질의로 즉시 회복 — dns.log: NXDOMAIN 응답 정확히 1회) ``` **P4-dual — 요청 1건의 connect 실패가 10.4초 전면 정전으로 증폭 (access.log)** ``` 1783337007.583 502 "172.20.0.20:8080, 172.20.0.20:8080" "502, 502" <- 동일 IP peer 2개 모두 시도/실패 1783337007.6~ 이후 전 요청 "be" (NLU) — connect 시도 자체가 없음 1783337009.707 RST 제거 (그러나 NLU 지속) 1783337017.998 fail_timeout(10s) 경과 → 회복. NLU 5,209건 = 2초 순단의 5배 피해 ``` **P6-failover — per-DC 구성의 무손실 failover (RST 5초, 30,000요청 중 전체 오류 흔적)** ``` access.log: 1783338577.395 200 "172.20.0.20:8080, 172.20.0.30:8080" "502, 200" (2건 — 재시도로 성공) error.log: connect() failed (111: Connection refused) upstream:"http://172.20.0.20:8080/" (2건) upstream server temporarily disabled (1건 — max_fails=2 도달, 이후 dc2 직행) ``` --- ## 3. 설정 전후 비교 요약 | 항목 | Before 실측 | After 실측 | 근거 페이즈 | |---|---|---|---| | valid 5s→30s | NXDOMAIN 정전 6.0s / failover RTO 평균 3.8s | 정전 **31.0s (5.2배)** / RTO **평균 12.2s (3.2배)**. 이득은 버스트 샘플링 확률 ≈1→19%뿐 | P2, P5 | | max_fails=0 | (dual) 2초 순단 → **10.4s NLU 5,209건** | 동일 주입에서 **NLU 0, tail 0** (conn 1,047건만) | P4 | | per-DC 2-hostname | 단일 hostname: NXDOMAIN·스왑·max_fails 리스크 노출 | 교번 무관(스왑 0) + RST 5s에도 **클라이언트 오류 0** | P6 | | retry shim | P2-base: 클라이언트 502 3,005건 | **3,000건 — 감소 0%** (재시도가 같은 빈 peer 집합에 떨어짐. access.log 전 건 `be : be` 확인) | P7b | --- ## 4. 결론 ### prod "no live upstreams" 원인 후보 우선순위 1. **가설 C — max_fails 무장 (최우선)**: resolve 컨텍스트 2개 정황(peer≥2 → single=0)에서 백엔드 순단·RST·타임아웃 **단 1건**이 전 peer를 fail_timeout(기본 10s) 동안 제외 → "백엔드는 살아있는데 nginx만 NLU". 흔한 트리거(순단)만으로 발생하므로 가장 개연성 높음 (P4) 2. **가설 A — NXDOMAIN**: GSLB가 일시적으로 NXDOMAIN을 반환하는 순간이 재조회에 샘플링되면 재질의 1주기(valid+1초) 동안 100% NLU. error.log에 명확한 시그니처가 남으므로 판별 쉬움 (P2) 3. **가설 B — slab(zone) 고갈**: 소스상 경로는 실재하나 정상 churn으로는 미재현 (P7a: 25-peer 추가/제거 28사이클에서 0건 — 할당·해제 균형). 대규모 peer 수/긴 서버명 조건에서만 후보 - 기각: "0-peer 스왑 창"(재조회 자체의 원자성 깨짐) — 소스+실측(스왑 98회 무오류) 이중 배제 (P0, P1) ### prod 판별용 error.log 시그니처 3종 | 시그니처 | 의미 | |---|---| | ` could not be resolved (3: Host not found)` 직후 NLU 연쇄 | **가설 A 확정.** 정전 길이 ≈ valid+1s | | `connect() failed (111/110...)` 1~수건 → NLU가 ~fail_timeout 지속 후 자연 회복, resolve 에러 없음 | **가설 C 확정.** upstream 블록의 resolve 컨텍스트 수 확인 | | `cannot add new server to upstream "...", memory exhausted` | **가설 B 확정.** zone 크기 증설 필요 | (음성 대조: `could not be resolved (110: Operation timed out)`만 있으면 NLU 원인 아님 — P3에서 무해 실증) ### 권고 config (실측 근거 인용) ```nginx resolver <내부DNS> valid=5s ipv6=off; # 30s 상향 비권장: P2(정전 5.2배)·P5(RTO 3.2배) resolver_timeout 3s; upstream be { zone be 128k; # 64k에서 churn 무고갈 확인(P7a), 2배 여유 # 구조적 해법(P6): GSLB hostname 대신 DC별 고정 hostname + passive failover server dc1.internal:8080 resolve max_fails=2 fail_timeout=5s; server dc2.internal:8080 resolve max_fails=2 fail_timeout=5s; } proxy_next_upstream error timeout; # P6에서 무손실 failover의 핵심 proxy_connect_timeout 1s; ``` - **단기 완화** (GSLB 단일 hostname 구조를 당장 못 바꿀 때): 모든 resolve 라인에 `max_fails=0` → P4 실측처럼 NLU tail 제거. 부작용: 죽은 peer로의 시도가 계속되나 `proxy_next_upstream`이 흡수 - **비권장**: `valid=30s` 임시조치 (양방향 정전 확대), retry shim (P7b: 클라이언트 502 감소 0%) ### 한계·미해결 - 단일 nginx 인스턴스·합성 부하 — prod의 GSLB 헬스체크 주기/다중 인스턴스 위상차 미반영 - 가설 B는 부정 결과(정상 churn 무고갈) — 극한 조건(수백 peer, 긴 이름)은 미탐색 - 랩 아티팩트 1건: keepalive 없는 500rps 단일 목적지에서 nginx 컨테이너 ephemeral port 고갈 (`connect() failed (99: Cannot assign requested address)`, ~56초 주기 버스트) 발견 → sysctl(`ip_local_port_range` 확장, `tcp_tw_reuse=1`)로 제거, 오염 전 데이터는 `artifacts/p2-*-v1-portexhaust/` 보존. **prod에서도 upstream keepalive 미사용 + 고rps 단일 IP 조건이면 동일 errno 99 리스크 있음** (본 실험 범위 밖의 별개 발견) - nginx 1.28.0 open source 기준. NGINX Plus·타 버전의 resolve 구현 차이 미검증