homelab89 Docs Logs Legacy Files ☰ TOC 🌓
notenetworking 2026-07-06nginxdnsupstreamresolvergslbno-live-upstreams

nginx "no live upstreams" 502 — 진짜 원인은 DNS 교체가 아니었다

한 줄 결론

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 목록(연결 가능한 서버 주소들)으로 관리하고, 요청마다 하나를 골라 연결합니다.

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 같은 설정으로 정합니다.

flowchart LR
    C["클라이언트"] --> N["nginx"]
    N -->|"proxy_pass"| U["upstream 그룹<br/>peer 목록"]
    U --> B1["백엔드 DC1"]
    U --> B2["백엔드 DC2"]
    D[("DNS / GSLB")] -.->|"valid 주기마다 재조회<br/>응답대로 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가 됩니다.

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개가 됩니다.

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이 조용히 작동을 시작합니다. 이제 사고 시나리오는 이렇습니다:

flowchart TD
    A["resolve server 라인 2개<br/>(같은 hostname이어도)"] --> B["peer 2개 → 단일 peer<br/>보호장치 해제 (single=0)"]
    B --> C["기본값 max_fails=1 무장"]
    C --> D["백엔드 순단: 요청 1건이 connect 실패<br/>(자동 재시도가 peer 2개를 모두 시도 → 둘 다 실패 기록)"]
    D --> E["peer 전원 '실패' 마킹<br/>fail_timeout(기본 10초) 동안 전부 제외"]
    E --> F["그동안 모든 요청 502<br/>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의 요청 재시도로 처리하는 구조입니다.

flowchart TB
    subgraph AS_IS["현재 구조 — DNS가 실패 지점"]
        N1["nginx"] -.->|"재조회 (6~31초 주기)"| G[("GSLB<br/>app.example.com")]
        G -.->|"그때그때 IP 1개<br/>(NXDOMAIN 리스크)"| N1
        N1 --> X1["그 순간의 DC 하나"]
    end
    subgraph TO_BE["권고 구조 — 요청 단위 failover"]
        N2["nginx"] -->|"평소 분산"| DC1["dc1.example.com (고정)"]
        N2 -->|"실패 시 즉시 재시도"| DC2["dc2.example.com (고정)"]
    end
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 수가 페이즈마다 정확히 일치함을 교차 확인했습니다.

기억할 것 3가지

  1. NXDOMAIN은 “실패"가 아니라 “빈 목록이 정답"이라는 응답 — nginx는 그 말을 믿고 서버 목록을 비운다. SERVFAIL/타임아웃과는 취급이 정반대다
  2. max_fails는 peer가 2개 이상일 때만 작동한다 — 같은 hostname의 resolve 라인 중복만으로 이 조건이 조용히 성립하고, 순단 1건이 fail_timeout만큼의 전면 정전으로 증폭된다
  3. NLU는 지속 상태다 — 재시도로는 못 고치고, 상태가 만들어지지 않게 하는 것 (per-DC hostname, max_fails=0)이 해법이다

Files