--- title: nginx "no live upstreams" 502 — 진짜 원인은 DNS 교체가 아니었다 date: 2026-07-06 type: note domain: networking tags: [nginx, dns, upstream, resolver, gslb, no-live-upstreams] --- ## 한 줄 결론 > nginx가 간헐적으로 뱉는 `no live upstreams` 502는 "DNS가 IP를 바꾸는 순간 서버 목록이 > 비어서"가 아니라, **(1) DNS가 NXDOMAIN("그런 이름 없음")을 한 번이라도 답한 순간**이거나 > **(2) 설정 때문에 조용히 켜진 max_fails가 순간 장애 1건을 10초 정전으로 증폭**한 결과다. > 두 경로 모두 실험으로 재현·실증했고, 해법도 실측으로 검증했다. 처음 보는 사람 기준으로, 배경지식 → 원인 → 해법 순서로 설명합니다. 모든 수치는 nginx 1.28.0 + 부하 500req/s 실측값입니다 (검증 방법은 문서 끝). --- ## 3분 배경지식 — 이 문제를 이해하는 데 필요한 것 ### nginx는 "서버 목록(peer)"으로 트래픽을 나눈다 nginx가 리버스 프록시로 동작할 때, 요청을 넘길 백엔드들을 `upstream` 블록에 정의합니다. nginx는 이 블록을 **peer 목록**(연결 가능한 서버 주소들)으로 관리하고, 요청마다 하나를 골라 연결합니다. ```nginx upstream be { server app.example.com:8080 resolve; # <- 이 문서의 주인공 } server { location / { proxy_pass http://be; } } ``` **`no live upstreams`란**: 요청이 왔는데 "골라줄 peer가 하나도 없는" 상태입니다. 목록이 비었거나, 전원이 '실패' 딱지가 붙어 제외된 경우 — nginx는 연결 시도조차 없이 즉시 502를 반환하고 error.log에 이 문구를 남깁니다. ### `resolve` 옵션: 서버 목록을 DNS로 계속 갱신한다 원래 nginx는 시작할 때 딱 한 번 hostname을 IP로 풀어 고정합니다. `resolve` 옵션을 붙이면 **주기적으로 DNS에 다시 물어보고**(재조회), 응답이 바뀌면 peer 목록을 갈아끼웁니다. 재조회 주기는 `resolver ... valid=5s` 같은 설정으로 정합니다. ```mermaid flowchart LR C["클라이언트"] --> N["nginx"] N -->|"proxy_pass"| U["upstream 그룹
peer 목록"] U --> B1["백엔드 DC1"] U --> B2["백엔드 DC2"] D[("DNS / GSLB")] -.->|"valid 주기마다 재조회
응답대로 peer 목록 갱신"| U ``` > 소소하지만 중요한 실측: 재조회 주기는 valid 값 그대로가 아니라 **valid+1초**입니다 > (타이머 계산식이 `valid - now + 1`). valid=5s면 실제로는 6초마다 물어봅니다. > 장애 타임라인을 역산할 때 1초씩 어긋나는 이유입니다. ### GSLB: DNS 응답으로 데이터센터를 골라주는 장치 GSLB(Global Server Load Balancing)는 DNS 서버의 일종으로, 같은 이름(`app.example.com`)에 대해 **상황에 따라 다른 IP를 답**합니다. DC1이 건강하면 DC1의 IP를, 장애면 DC2의 IP를 답하는 식입니다. 그래서 `resolve`와 조합하면 재조회 때마다 nginx의 peer 목록이 통째로 바뀔 수 있습니다. 여기까지가 배경입니다. 이제 "왜 502가 나는가"로 갑니다. --- ## 처음의 의심: "IP 교체 순간에 목록이 잠깐 비는 것 아닌가?" — 아니다 가장 그럴듯한 가설은 이것이었습니다: 재조회로 peer를 "삭제 → 추가"하는 사이에 요청이 끼어들면 목록이 0개인 순간을 보는 것 아닌가? **결론: 불가능합니다.** 소스를 보면 삭제와 추가가 **하나의 잠금(rwlock) 구간 안에서** 끝나고, 요청 쪽의 peer 선택도 같은 잠금을 쓰기 때문에 "비어 있는 중간 상태"는 요청에게 보이지 않습니다. 실측으로도 확인했습니다. GSLB의 동작 그대로 — **매 질의에 A 레코드를 딱 1개만, 질의마다 두 IP를 엄격히 교번해서** 반환하는 DNS를 만들어 붙였습니다 (모든 질의·응답을 로그로 남겨 교번을 직접 검증: 10분간 A 질의 99회, 연속 응답이 바뀐 횟수 98회, 두 IP가 50:49). 즉 재조회 때마다 "기존 IP 삭제 + 새 IP 추가"라는 최악의 전체 교체가 98번 일어났지만, 같은 시간 요청 30만 건 중 오류는 **0건**이었습니다. 그럼 범인은 누구인가. 실증된 경로는 두 개입니다. --- ## 원인 1 — NXDOMAIN: "그런 이름 없음"이라는 응답을 nginx는 믿는다 DNS 응답에는 종류가 있습니다. 이 구분이 이 문제의 핵심입니다. | DNS 응답 | 의미 | nginx `resolve`의 반응 | |---|---|---| | 정상 (A 레코드) | "이 이름의 IP는 이것" | peer 목록 갱신 | | SERVFAIL / 무응답 | "지금 답을 못 주겠다" (서버 사정) | **기존 peer 유지** (안전) | | **NXDOMAIN** | **"그런 이름은 존재하지 않는다"** | **peer 전부 삭제** | SERVFAIL이나 타임아웃은 "실패"라서 nginx가 기존 목록을 지킵니다 (실측: DNS를 10초간 침묵시켜도 오류 0건). 그런데 NXDOMAIN은 실패가 아니라 **"빈 목록이 정답"이라는 권위 있는 응답**입니다. nginx는 이를 믿고 peer를 전부 지웁니다. 그리고 다음 성공 재조회까지 — 즉 **한 재조회 주기 동안 — 100% 502**가 됩니다. ```mermaid sequenceDiagram participant D as DNS(GSLB) participant N as nginx participant C as 클라이언트 N->>D: app.example.com의 IP는? D-->>N: 10.0.0.20 Note over N: peer = [10.0.0.20] — 정상 서빙 N->>D: (6초 뒤 재조회) D-->>N: NXDOMAIN — 이 순간이 사고 Note over N: peer 전부 삭제 → 목록 0개 C->>N: 요청 N-->>C: 502 (no live upstreams, 연결 시도조차 없음) N->>D: (또 6초 뒤 재조회) D-->>N: 10.0.0.20 (DNS 복구됨) Note over N: peer 복원 — 즉시 정상 ``` 실측 (NXDOMAIN을 6초간 주입): | 설정 | 결과 | |---|---| | valid=5s | 정전 **정확히 6.0초** (= 재조회 1주기), 502 3,005건 | | valid=30s | 재조회가 뜸해서 6초짜리 사고를 아예 못 볼 확률 81% — 그러나 **걸리면 31초 정전 (5.2배)** | 여기서 흔한 임시조치의 함정이 보입니다. "valid를 늘리면 DNS 순단에 둔감해지지 않을까?" → 확률은 줄지만 **걸렸을 때 피해가 주기에 비례해 커지고**, 뒤에서 볼 failover 속도까지 같이 느려집니다. 방향이 틀린 조치입니다. **운영 판별법**: 이 경로였다면 error.log에 반드시 이 순서가 남습니다. ``` ... app.example.com could not be resolved (3: Host not found) <- NXDOMAIN 수신 ... no live upstreams while connecting to upstream <- 그 직후부터 연쇄 ``` --- ## 원인 2 — max_fails 무장: 설정 한 줄이 2초 순단을 10초 정전으로 키운다 이쪽이 더 교묘하고, 실제 상황에서는 더 흔할 가능성이 높습니다. ### 배경: max_fails와 단일 peer 보호장치 nginx에는 passive health check가 내장되어 있습니다: peer로의 연결이 `max_fails`번(기본 1) 실패하면 그 peer를 `fail_timeout`(기본 10초) 동안 목록에서 제외합니다. 순간적으로 이상한 서버를 잠시 쉬게 하는 좋은 기능인데, 전제가 있습니다 — **다른 peer가 남아 있어야** 합니다. 그래서 nginx는 **peer가 1개뿐이면 이 검사를 통째로 끕니다**(내부 플래그 `single=1`). 1개를 제외해 버리면 갈 곳이 없으니까요. 합리적인 보호장치입니다. ### 함정: resolve 라인이 2개면 보호장치가 풀린다 문제는 이 보호장치의 판단 기준이 "**peer 개수**"라는 점입니다. 같은 hostname이라도 `resolve`가 붙은 server 라인이 2개면 — 예를 들어 설정 리팩토링 중 중복이 생겼거나, 템플릿이 두 번 렌더링됐거나 — DNS가 같은 IP 하나를 답해도 **peer는 2개**가 됩니다. ```nginx upstream be { zone be 64k; server app.example.com:8080 resolve; # peer 1 server app.example.com:8080 resolve; # peer 2 (같은 IP!) -> single=0 } ``` 이 순간 보호장치가 풀리고(`single=0`), 기본값 `max_fails=1`이 조용히 작동을 시작합니다. 이제 사고 시나리오는 이렇습니다: ```mermaid flowchart TD A["resolve server 라인 2개
(같은 hostname이어도)"] --> B["peer 2개 → 단일 peer
보호장치 해제 (single=0)"] B --> C["기본값 max_fails=1 무장"] C --> D["백엔드 순단: 요청 1건이 connect 실패
(자동 재시도가 peer 2개를 모두 시도 → 둘 다 실패 기록)"] D --> E["peer 전원 '실패' 마킹
fail_timeout(기본 10초) 동안 전부 제외"] E --> F["그동안 모든 요청 502
no live upstreams — 연결 시도 자체가 없음"] F -->|"10초 경과"| G["자동 복구 (재시도 허용)"] ``` 핵심은 **백엔드가 살아나도 nginx 혼자 계속 502를 낸다**는 점입니다. peer 제외는 시간 기반(fail_timeout)이라, 장애가 2초 만에 끝나도 10초를 채울 때까지 풀리지 않습니다. 실측 (백엔드에 2초간 연결 거부(RST) 주입): ``` 시각 0.0s 요청 1건 connect 실패 (이 한 건이 peer 2개를 모두 실패 처리) 시각 0.0s~ 이후 모든 요청 502 no live upstreams <- 초당 500건씩 실패 시각 2.1s 백엔드 정상 복구 — 그러나 502 계속 시각 10.4s fail_timeout 만료 → 회복. 총 피해 5,210건 ``` 같은 2초 장애를 두 대조군에 주입하면: | 설정 | 결과 | |---|---| | resolve 2줄 (기본값) | **10.4초 정전, 502 5,210건** — 장애의 5배 증폭 | | resolve 2줄 + `max_fails=0` | 장애 구간 2초의 실패 1,047건만, **증폭 없음** | | resolve 1줄 (보호장치 작동) | 위와 동일 — **증폭 없음** | **운영 판별법**: DNS 관련 에러 없이, `connect() failed ...` 몇 건 뒤에 `no live upstreams`가 **약 10초(=fail_timeout) 지속되다 저절로 회복**하는 패턴이면 이 경로입니다. upstream 블록에 resolve 컨텍스트가 2개 이상인지 확인하세요. --- ## 그래서 어떻게 고치나 ### 구조적 해법 (권고): GSLB에게 맡기지 말고 nginx가 두 DC를 직접 든다 GSLB용 hostname 1개 대신, **DC별 고정 hostname 2개**를 nginx에 등록합니다. DC 선택 (failover)을 DNS 재조회가 아니라 **nginx의 요청 재시도**로 처리하는 구조입니다. ```mermaid flowchart TB subgraph AS_IS["현재 구조 — DNS가 실패 지점"] N1["nginx"] -.->|"재조회 (6~31초 주기)"| G[("GSLB
app.example.com")] G -.->|"그때그때 IP 1개
(NXDOMAIN 리스크)"| N1 N1 --> X1["그 순간의 DC 하나"] end subgraph TO_BE["권고 구조 — 요청 단위 failover"] N2["nginx"] -->|"평소 분산"| DC1["dc1.example.com (고정)"] N2 -->|"실패 시 즉시 재시도"| DC2["dc2.example.com (고정)"] end ``` ```nginx resolver <내부DNS> valid=5s ipv6=off; upstream be { zone be 128k; 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; # dc1 실패 -> 같은 요청을 dc2로 재시도 proxy_connect_timeout 1s; ``` 왜 좋은가: - GSLB 응답이 요동치든 NXDOMAIN을 주든 **dc1/dc2 이름은 그대로**라 peer 목록이 흔들리지 않음 - DC 장애 시 복구 속도가 "다음 재조회까지"(평균 3~15초)가 아니라 **요청 1건의 재시도 지연(밀리초)** 임 - 이번엔 peer가 2개라도 **서로 다른 실제 서버**라 max_fails가 제 역할(한쪽만 제외)을 함 실측: 이 구성에 5초짜리 백엔드 장애를 주입해도 클라이언트 오류 **0건** (재시도로 넘어간 요청 2건이 흔적의 전부. 30,000건 전량 성공). #### 왜 여기서는 max_fails=0이 아니라 2인가 "원인 2에서는 max_fails가 범인이었는데 왜 다시 켜나?" 하는 의문이 들 수 있습니다. **max_fails의 제외 동작이 위험한 것은 '제외하면 갈 곳이 없을 때'뿐**입니다. - 단일 hostname 중복 라인 구조: peer 2개가 사실상 **같은 서버 1대** → 전원 제외 = 전면 정전 → 그래서 그 구조에서는 `max_fails=0`으로 꺼야 함 - per-DC 구조: peer 2개가 **서로 다른 실제 DC** → 한쪽을 제외해도 다른 쪽이 받음 → 제외가 오히려 이득. 죽은 DC로의 헛시도(연결 실패 대기 — SYN이 버려지는 유형의 장애면 요청마다 `proxy_connect_timeout`만큼 지연)를 fail_timeout 동안 건너뛰게 해줌 - **왜 1(기본값)이 아니라 2**: 스치는 실패 1건(일시적 RST 등)으로 멀쩡한 DC를 빼는 오탐을 줄이기 위한 여유 - **왜 fail_timeout을 10s→5s로**: 복구된 DC를 빨리 복귀시키고, 만에 하나 양쪽이 동시에 제외되는 최악 케이스의 정전 상한을 5초로 묶기 위해 (참고로 per-DC + `max_fails=0` 조합도 정합성은 성립합니다 — 실패 요청은 재시도로 살아납니다. 다만 장애 DC로의 헛시도가 장애 내내 계속되는 지연·부하 비용 때문에 2를 권고합니다. 이 조합 자체는 별도 실측하지 않았습니다.) ### 당장 구조를 못 바꿀 때의 단기 완화 `resolve` 라인마다 `max_fails=0`을 붙여 원인 2를 차단합니다. 죽은 peer로의 시도는 `proxy_next_upstream`이 흡수합니다. (원인 1은 남으므로 근본 해법은 아님) ### 하지 말 것 (실측으로 기각) - **valid 상향(5s→30s)**: NXDOMAIN 정전은 확률만 줄고 피해 5.2배, DC 전환 속도(RTO)는 평균 3.8초→12.2초로 3.2배 악화. 양방향 손해 - **502 재시도 shim**(`error_page 502 = @retry`): NLU는 "빈 목록"이라는 **지속 상태**라서 재시도가 같은 빈 목록에 다시 떨어짐. 실측 개선 효과 정확히 0% --- ## 운영 치트시트 — error.log만 보고 원인 찍기 | error.log 패턴 | 진단 | |---|---| | `could not be resolved (3: Host not found)` 직후 NLU 연쇄, 재조회 1주기 뒤 회복 | **원인 1 (NXDOMAIN)** — DNS/GSLB 쪽 조사 | | `connect() failed` 소수 → NLU ~10초 지속 후 자연 회복, DNS 에러 없음 | **원인 2 (max_fails)** — resolve 라인 개수 확인 | | `cannot add new server to upstream ..., memory exhausted` | zone 메모리 고갈 — `zone` 증설 (정상 운영에선 재현 안 됨) | | `could not be resolved (110: Operation timed out)` 만 있음 | **NLU 원인 아님** — peer는 유지됨 (실측 무해) | --- ## 이 결론들은 어떻게 검증했나 Docker로 nginx 1.28.0 + 모드 전환이 가능한 가짜 DNS + 백엔드 2대를 구성하고, vegeta로 500req/s를 걸면서 장애(NXDOMAIN, DNS 무응답, 연결 거부)를 재조회 타이밍에 맞춰 주입 — nginx access log·DNS 질의 로그·조작 이벤트 로그를 시각 동기화해 502를 전수 분류했습니다. error.log의 `no live upstreams` 라인 수와 클라이언트 502 수가 페이즈마다 정확히 일치함을 교차 확인했습니다. - 전체 실측 데이터·페이즈별 판정: [files/REPORT.md](files/REPORT.md) - nginx 1.28.0 소스 대조 (잠금 구간·NXDOMAIN·memory exhausted 경로): [files/source-check.md](files/source-check.md) - 실험 재현 가이드 (compose 구성·스크립트): [files/README.md](files/README.md) ## 기억할 것 3가지 1. **NXDOMAIN은 "실패"가 아니라 "빈 목록이 정답"이라는 응답** — nginx는 그 말을 믿고 서버 목록을 비운다. SERVFAIL/타임아웃과는 취급이 정반대다 2. **max_fails는 peer가 2개 이상일 때만 작동한다** — 같은 hostname의 resolve 라인 중복만으로 이 조건이 조용히 성립하고, 순단 1건이 fail_timeout만큼의 전면 정전으로 증폭된다 3. **NLU는 지속 상태다** — 재시도로는 못 고치고, 상태가 만들어지지 않게 하는 것 (per-DC hostname, max_fails=0)이 해법이다