이 글에서 다루는 것
"nginx 소스 파헤치기" 연재의 열두 번째이자 마지막 글입니다. 11편에서는 proxy_pass 한 줄이 콘텐츠 핸들러를 걸고, ngx_http_upstream_t 가 백엔드에 연결해 요청을 보내고 응답을 받아 클라이언트에게 흘려보내는 길을 따라갔습니다. 그 글에서 일부러 넘긴 질문이 하나 있습니다 — 연결할 서버는 누가 고르나. 이번 글이 그 답입니다.
답하려는 질문은 네 가지입니다.
upstream {}에 서버를 여러 대 적으면 요청마다 어느 서버를 고르는가.weight=5는 정확히 무엇을 하는가- 서버 하나가 죽으면 nginx는 그것을 어떻게 알고, 언제 다시 보내 보는가
- 백엔드로 가는 연결은 요청마다 새로 여는가. 다시 쓴다면 어디에 보관하는가 — 그리고 이 판에서 바뀐 기본값
stream {}은http {}와 무엇이 같고 무엇이 다른가
| 항목 | 내용 |
|---|---|
| 대상 | nginx |
| 버전 | nginx 1.31.9 (master, 개발판) |
| 커밋 | 9a7439fd79 (2026-10-23) |
| 주로 읽는 파일 | src/http/ngx_http_upstream_round_robin.[ch], src/http/modules/ngx_http_upstream_keepalive_module.c, ngx_http_upstream_least_conn_module.c, src/event/ngx_event_connect.h, src/stream/ngx_stream_handler.c·ngx_stream_core_module.c·ngx_stream_proxy_module.c |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 부하 분산 모듈만 합쳐도(ngx_http_upstream_round_robin.c 와 modules/ngx_http_upstream_*_module.c 여덟 개) 7,000줄이 넘고 src/stream/ 은 25,000줄이 넘습니다. 이 글은 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적었습니다.
2편의 지도에서 이 글은 C. HTTP 요청 하나 구역의 맨 마지막 상자, 부하 분산·keepalive·stream 을 확대합니다.
11편의 upstream이 "어디로 연결할까"를 물을 때 답하는 쪽이 이 글의 상자입니다.
먼저 알아 둘 말
upstream 과 peer. upstream은 11편에서 본 "nginx 뒤의 서버"이고, 설정 파일의 upstream {} 블록이 그 묶음입니다. 소스는 그 안의 서버 한 대(정확히는 주소 하나)를 peer 라고 부릅니다. server backend.example:8080; 이 주소 두 개로 풀리면 peer도 두 개입니다.
부하 분산(load balancing)은 요청이 올 때마다 peer 가운데 하나를 고르는 일입니다. nginx에서는 "고르기"와 "돌려주기"를 함수 포인터 두 개(get, free)로 떼어 두고, 고르는 방식(라운드 로빈, least_conn, ip_hash …)은 그 포인터를 채우는 모듈로 갈아 끼웁니다. 함수 포인터 표로 모듈을 끼우는 방식은 5편에서 본 것과 같은 생각입니다.
smooth weighted round robin. 가중치를 준 라운드 로빈인데, 가중치 5:1:1을 a a a a a b c 처럼 몰아서 보내지 않고 a a b a c a a 처럼 고르게 섞어 보내는 계산법입니다. 2절에서 숫자로 따라갑니다.
keepalive(upstream 쪽). 클라이언트 쪽 keep-alive(8편)가 "브라우저와 nginx 사이의 연결을 다음 요청에 다시 쓰기"였다면, upstream keepalive는 nginx와 백엔드 사이의 연결을 다시 쓰는 것입니다. TCP 연결을 새로 맺는 비용(3-way handshake, TLS라면 핸드셰이크까지)을 아낍니다. 다 쓴 연결을 닫지 않고 모아 두는 곳을 연결 캐시(connection pool)라고 합니다.
stream 모듈. stream {} 블록으로 켜는 TCP/UDP 프록시입니다. HTTP를 해석하지 않고 바이트를 그대로 옮깁니다. MySQL·Redis 앞에 nginx를 세울 때 씁니다.
posted 이벤트·연결 구조체처럼 이벤트 루프의 말은 7편에서, phase 와 checker 는 9편에서 봤습니다. stream 모듈을 볼 때 다시 나옵니다.
1. 부하 분산의 틀 — init_upstream, init, get, free
설정 파일의 한 줄이 함수 포인터 넷이 되기까지
upstream app {
least_conn; # 없으면 라운드 로빈
server 127.0.0.1:9001 weight=5;
server 127.0.0.1:9002;
}
이 블록 하나는 소스에서 ngx_http_upstream_srv_conf_t 하나가 되고, 그 맨 앞에 부하 분산의 손잡이가 있습니다.
typedef struct {
ngx_http_upstream_init_pt init_upstream;
ngx_http_upstream_init_peer_pt init;
void *data;
} ngx_http_upstream_peer_t;
src/http/ngx_http_upstream.h 94–98행
두 함수 포인터는 불리는 때가 다릅니다.
init_upstream— 설정을 읽을 때 한 번. 서버 목록을 보고 peer 배열을 만듭니다.least_conn;같은 디렉티브는 이 포인터에 자기 함수를 넣는 것이 하는 일의 거의 전부입니다(ngx_http_upstream_least_conn_module.c330행).init— 요청마다 한 번. 이 요청이 쓸 작은 상태 구조체를 만들고, 다음 절의get/free를 채웁니다.
init_upstream 이 비어 있으면, 곧 least_conn·ip_hash·hash·random 같은 것을 적지 않았으면 라운드 로빈이 들어갑니다. http {} 블록을 다 읽은 뒤 upstream 모듈의 init_main_conf 가 블록마다 이렇게 부릅니다.
for (i = 0; i < umcf->upstreams.nelts; i++) {
init = uscfp[i]->peer.init_upstream ? uscfp[i]->peer.init_upstream:
ngx_http_upstream_init_round_robin;
if (init(cf, uscfp[i]) != NGX_OK) {
return NGX_CONF_ERROR;
}
}
src/http/ngx_http_upstream.c 7362–7370행
그리고 ngx_http_upstream_init_round_robin() 의 첫 줄이 요청마다 불릴 함수를 정합니다 — us->peer.init = ngx_http_upstream_init_round_robin_peer;(ngx_http_upstream_round_robin.c 51행). least_conn도 먼저 이 라운드 로빈 초기화를 그대로 부른 뒤 peer.init 만 자기 것으로 바꿉니다(65–79행). peer 배열을 만드는 일은 라운드 로빈 것을 같이 쓰고, 고르는 규칙만 다르다는 뜻입니다.
요청 때: get 으로 고르고 free 로 돌려준다
요청마다 쓰는 쪽은 ngx_peer_connection_t 입니다. 11편에서 본 u->peer 가 이 구조체이고, 이벤트 코어(src/event/)에 있습니다 — HTTP만의 것이 아니라는 표시입니다(5절에서 stream도 같은 것을 씁니다).
struct ngx_peer_connection_s {
ngx_connection_t *connection;
struct sockaddr *sockaddr;
socklen_t socklen;
ngx_str_t *name;
ngx_uint_t tries;
ngx_msec_t start_time;
ngx_event_get_peer_pt get;
ngx_event_free_peer_pt free;
ngx_event_notify_peer_pt notify;
void *data;
/* ... */
unsigned cached:1;
/* ... */
};
src/event/ngx_event_connect.h 36–79행 (줄인 곳 표시)
get 은 peer 하나를 골라 sockaddr·name 을 채우고, free 는 요청이 그 peer를 다 썼을 때 성공·실패를 알려 줍니다. data 는 고르는 방식마다 다른 상태를 담는 void * 이고, tries 는 "몇 번까지 다른 서버로 다시 시도할 수 있나"입니다. 세 곳에서 불립니다.
| 언제 | 어디서 | 무엇을 |
|---|---|---|
| upstream을 시작할 때 | ngx_http_upstream_init_request() 의 uscf->peer.init(r, uscf) (854행) |
pc->get·pc->free·pc->data 를 채운다 |
| 연결할 때 | ngx_event_connect_peer() 의 rc = pc->get(pc, pc->data); (ngx_event_connect.c 34행) |
서버를 고른다. NGX_OK 가 아니면 소켓을 만들지 않고 그대로 돌아간다 |
| 실패해서 다음 서버로 갈 때 | ngx_http_upstream_next() 의 u->peer.free(..., state) (4637행) |
NGX_PEER_FAILED 를 알린다(403·404 는 NGX_PEER_NEXT) |
| 요청이 끝날 때 | ngx_http_upstream_finalize_request() 의 u->peer.free(&u->peer, u->peer.data, 0) (4824행) |
성공(0)을 알린다 |
노란 것이 메모리에 놓인 상태, 파란 것이 함수 포인터를 든 손잡이입니다. 아래쪽은 설정 때 한 번, 위쪽은 요청마다 만들어집니다.
라운드 로빈의 요청별 초기화 ngx_http_upstream_init_round_robin_peer() 가 이 그림의 위쪽(요청마다 만드는 부분)을 만듭니다. rrp 를 요청 풀에서 잡고, tried 비트맵을 마련하고, r->upstream->peer.get·peer.free 에 라운드 로빈 함수를 넣습니다(521–565행). tried 는 "이 요청이 이미 시도한 peer" 를 한 비트씩 표시하는 비트맵입니다. 서버가 64대 이하(64비트 기계에서 8 * sizeof(uintptr_t) = 64)면 따로 메모리를 잡지 않고 구조체 안의 uintptr_t 하나를 비트맵으로 씁니다. 실패해서 다음 서버로 갈 때 같은 서버를 또 고르지 않게 하는 장치입니다.
헷갈리기 쉬운 곳: peer 상태는 워커마다 따로입니다
peer 배열은 ngx_pcalloc(cf->pool, ...) 로 설정 풀에 만들어집니다(145–154행). 설정 풀은 마스터가 만들고 워커는 fork() 로 그 사본을 받으므로(6편), current_weight·fails·conns 는 워커마다 따로 셉니다. 워커가 넷이면 "5:1:1" 순서도, max_fails 의 실패 횟수도 워커마다 따로 굴러갑니다. 아래 예제가 worker_processes 1; 로 도는 것도 그래서입니다. 워커끼리 한 상태를 보게 하려면 zone 디렉티브로 peer를 공유 메모리에 옮깁니다(ngx_http_upstream_zone_module.c 의 ngx_http_upstream_zone_copy_peers()). 그때부터 위 헤더의 ngx_http_upstream_rr_peers_wlock() 같은 매크로가 실제 잠금이 됩니다 — 공유 메모리가 없으면 peers->shpool 이 비어 있어 잠그지 않습니다(round_robin.h 141–145행).
2. smooth weighted round robin — 5:1:1 을 고르게 펴기
세 숫자: weight, effective_weight, current_weight
peer 구조체에는 무게가 셋 있습니다.
struct ngx_http_upstream_rr_peer_s {
struct sockaddr *sockaddr;
socklen_t socklen;
ngx_str_t name;
ngx_str_t server;
ngx_int_t current_weight;
ngx_int_t effective_weight;
ngx_int_t weight;
ngx_uint_t conns;
ngx_uint_t max_conns;
ngx_uint_t fails;
time_t accessed;
time_t checked;
ngx_uint_t max_fails;
time_t fail_timeout;
/* ... */
ngx_uint_t down;
/* ... */
ngx_http_upstream_rr_peer_t *next;
/* ... */
};
src/http/ngx_http_upstream_round_robin.h 47–105행 (줄인 곳 표시)
weight— 설정 파일에 적은 값(weight=5). 바뀌지 않습니다.effective_weight— 지금 믿어 주는 무게. 처음엔weight와 같고, 실패하면 깎였다가(3절) 고를 때마다 1씩 회복합니다.current_weight— 고를 때마다 오르내리는 계산용 값. 처음엔 0.
server 줄에서 아무것도 적지 않았을 때의 기본값은 weight=1, max_fails=1, fail_timeout=10(초)입니다.
weight = 1;
max_conns = 0;
max_fails = 1;
fail_timeout = 10;
src/http/ngx_http_upstream.c 6546–6549행
고르는 함수
서버가 한 대(peers->single)이면 계산할 것 없이 그 서버를 돌려주고, 여러 대면 ngx_http_upstream_get_peer() 가 고릅니다.
for (peer = rrp->peers->peer, i = 0;
peer;
peer = peer->next, i++)
{
n = i / (8 * sizeof(uintptr_t));
m = (uintptr_t) 1 << i % (8 * sizeof(uintptr_t));
if (rrp->tried[n] & m) {
continue;
}
if (peer->down) {
continue;
}
if (peer->max_fails
&& peer->fails >= peer->max_fails
&& now - peer->checked <= peer->fail_timeout)
{
continue;
}
if (peer->max_conns && peer->conns >= peer->max_conns) {
continue;
}
peer->current_weight += peer->effective_weight;
total += peer->effective_weight;
if (peer->effective_weight < peer->weight) {
peer->effective_weight++;
}
if (best == NULL || peer->current_weight > best->current_weight) {
best = peer;
p = i;
}
}
/* ... */
if (best == NULL) {
return NULL;
}
best->current_weight -= total;
src/http/ngx_http_upstream_round_robin.c 858–910행 (/* ... */ 는 sticky 용 #if (NGX_HTTP_UPSTREAM_SID) 블록 — 우리 빌드에서 켜져 있지만 sticky 를 쓰지 않으면 지나가기만 합니다)
고른 뒤에는 rrp->current 에 적고 tried 비트를 켜고, fail_timeout 이 지났으면 best->checked = now; 로 시각을 새로 적습니다(916–926행). 이 마지막 줄은 3절에서 다시 나옵니다.
걸러 내는 조건 넷(빨간 갈래)과 무게 계산 셋. 고른 서버에서 total 을 빼는 마지막 한 줄이 "고르게 펴기"의 비밀입니다.
계산은 세 걸음입니다. ① 살아 있는 모든 peer의 current_weight 에 자기 무게를 더한다 ② 가장 큰 것을 고른다 ③ 고른 것에서 무게의 합(total)을 뺀다. 한 바퀴(무게 합만큼의 요청)가 지나면 모든 current_weight 가 다시 0으로 돌아옵니다. 5:1:1(합 7)로 손으로 따라가면 이렇습니다(서버를 a, b, c라 합니다).
| 요청 | ① 더한 뒤 (a, b, c) | ② 고른 것 | ③ 뺀 뒤 (a, b, c) |
|---|---|---|---|
| 1 | 5, 1, 1 | a | −2, 1, 1 |
| 2 | 3, 2, 2 | a | −4, 2, 2 |
| 3 | 1, 3, 3 | b | 1, −4, 3 |
| 4 | 6, −3, 4 | a | −1, −3, 4 |
| 5 | 4, −2, 5 | c | 4, −2, −2 |
| 6 | 9, −1, −1 | a | 2, −1, −1 |
| 7 | 7, 0, 0 | a | 0, 0, 0 |
3번 요청에서 b와 c가 3으로 같을 때 b가 이기는 것은 비교가 >(같으면 바꾸지 않음)이기 때문입니다. 무거운 a가 다섯 번 뽑히되 연달아 다섯 번이 아니라 사이사이에 b와 c가 끼어듭니다. 백엔드 한 대에 요청이 몰려 들어가는 순간을 줄이는 계산입니다.
예제 1: 가중치 5:1:1, 그리고 죽은 서버 하나
이 예제는 기준 커밋(9a7439fd79)을 그대로 빌드한 nginx 1.31.9 로 도커 컨테이너(Debian bookworm, --with-debug)에서 실행했습니다. 예제 폴더는 ex/u12-weights 입니다. 백엔드는 자기 포트를 찍어 주는 작은 python3 서버 세 대(9001~9003)입니다.
# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
worker_processes 1;
error_log /tmp/u12w/error.log debug;
http {
# 1) 가중치 5:1:1
upstream weighted {
server 127.0.0.1:9001 weight=5;
server 127.0.0.1:9002;
server 127.0.0.1:9003;
}
# 2) 9009 에는 아무도 듣고 있지 않다 (max_fails=1, fail_timeout=10s 가 기본)
upstream withdead {
server 127.0.0.1:9001;
server 127.0.0.1:9002;
server 127.0.0.1:9009;
}
server {
listen 8080;
location /w/ { proxy_pass http://weighted/; }
location /d/ { proxy_pass http://withdead/; }
}
}
(실제 파일에는 두 번째 줄의 설명 주석과 pid·events {}·access_log 줄이 더 있고, location 블록은 여러 줄로 썼습니다.) /w/ 에 요청을 14개 보냈을 때 답한 백엔드의 포트와, 디버그 로그에서 처음 일곱 요청의 get rr peer, current: 줄입니다.
=== 1. weight=5,1,1 : 14 requests ===
9001 9001 9002 9001 9003 9001 9001 9001 9001 9002 9001 9003 9001 9001
=== 1b. current_weight of the chosen peer, first 7 (debug log) ===
33#33: *2 get rr peer, current: 000055F74EDCB0C0 -2
33#33: *4 get rr peer, current: 000055F74EDCB0C0 -4
33#33: *5 get rr peer, current: 000055F74EDCB1C8 -4
33#33: *7 get rr peer, current: 000055F74EDCB0C0 -1
33#33: *8 get rr peer, current: 000055F74EDCB2D0 -2
33#33: *10 get rr peer, current: 000055F74EDCB0C0 2
33#33: *11 get rr peer, current: 000055F74EDCB0C0 0
순서가 표와 같고(a a b a c a a, 그리고 다시 처음부터), 로그의 숫자는 표의 ③ 열에서 고른 서버의 값과 하나하나 맞습니다(−2, −4, b의 −4, −1, c의 −2, 2, 0). 이 줄은 753–755행의 ngx_log_debug2 가 best->current_weight -= total 뒤에 찍습니다. 워커 pid(33#33), 포인터 주소, 연결 번호 *N 은 실행마다 다릅니다(연결 번호가 하나씩 건너뛰는 것은 nginx가 백엔드로 연 연결도 번호를 받기 때문입니다).
같은 실행에서 /d/ 로 여섯 번 보낸 결과입니다.
=== 2. one dead server (9009): 6 requests ===
200 9001
200 9002
200 9001
200 9002
200 9001
200 9002
클라이언트는 한 번도 실패를 보지 않았습니다. 세 번째 요청이 9009를 골랐다가 거절당하고 다른 서버로 넘어갔기 때문입니다. 그 요청의 로그는 3절에서 봅니다.
3. 서버가 죽으면 — max_fails 와 fail_timeout
연결이 거절되거나 시간이 넘으면 11편에서 본 ngx_http_upstream_next() 가 free 를 NGX_PEER_FAILED 로 부릅니다. 라운드 로빈의 free 는 그때 이렇게 셉니다.
if (state & NGX_PEER_FAILED) {
now = ngx_time();
peer->fails++;
peer->accessed = now;
peer->checked = now;
if (peer->max_fails) {
peer->effective_weight -= peer->weight / peer->max_fails;
if (peer->fails >= peer->max_fails) {
ngx_log_error(NGX_LOG_WARN, pc->log, 0,
"upstream server temporarily disabled");
}
}
ngx_log_debug2(NGX_LOG_DEBUG_HTTP, pc->log, 0,
"free rr peer failed: %p %i",
peer, peer->effective_weight);
if (peer->effective_weight < 0) {
peer->effective_weight = 0;
}
} else {
/* mark peer live if check passed */
if (peer->accessed < peer->checked) {
peer->fails = 0;
}
}
src/http/ngx_http_upstream_round_robin.c 1056–1087행
여기서 일어나는 일은 두 가지입니다.
- 실패 횟수(
fails)가max_fails에 닿으면, 2절의 반복이now - peer->checked <= peer->fail_timeout인 동안 그 peer를 건너뜁니다. 기본값이면 한 번 실패로 10초 동안 빠집니다. - 무게(
effective_weight)를weight / max_fails만큼 깎습니다. 다시 살아난 뒤에도 바로 원래 몫을 받지 않고, 고를 때마다 1씩(2절의effective_weight++) 회복합니다.
fails 가 0으로 돌아가는 조건이 눈여겨볼 곳입니다. 성공했다고 바로 지우지 않고 accessed < checked 일 때만 지웁니다. 실패하면 accessed 와 checked 가 같은 시각이 되고, checked 는 2절 끝에서 fail_timeout 이 지난 뒤 그 peer가 골라질 때만 새 시각으로 바뀝니다. 그래서 "fail_timeout 안에서의 성공"은 실패 횟수를 지우지 못하고, fail_timeout 이 지난 뒤 다시 골라져 성공해야 정상으로 돌아옵니다.
이 소스에는 백엔드를 따로 찔러 보는 헬스 체크 모듈이 없습니다(요청 구조체에 health_check 비트만 있고 그것을 켜는 코드는 없습니다). 상태는 진짜 요청이 실패하고 성공한 기록으로만 바뀝니다.
예제 1의 세 번째 요청(*23)이 9009를 만난 기록입니다. error.log 의 warn·error 줄과 디버그 로그에서 그 요청 줄만 골랐습니다.
=== 2b. error.log (warn and above) ===
[error] 33#33: *23 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, server: , request: "GET /d/ HTTP/1.1", upstream: "http://127.0.0.1:9009/", host: "127.0.0.1:8080"
[warn] 33#33: *23 upstream server temporarily disabled while connecting to upstream, client: 127.0.0.1, server: , request: "GET /d/ HTTP/1.1", upstream: "http://127.0.0.1:9009/", host: "127.0.0.1:8080"
=== 2c. debug: the request that hit 9009 ===
33#33: *23 get rr peer, try: 3
33#33: *23 get rr peer, current: 000055F74EDCB6A8 0
33#33: *23 connect to 127.0.0.1:9009, fd:15 #24
33#33: *23 http next upstream, 2
33#33: *23 free rr peer 3 4
33#33: *23 free rr peer failed: 000055F74EDCB6A8 0
33#33: *23 get rr peer, try: 2
33#33: *23 get rr peer, current: 000055F74EDCB498 -1
33#33: *23 free rr peer 2 0
읽는 법입니다.
try: 3— 이 upstream의tries는 down이 아닌 서버의 수(3)입니다(156–160행의peers->tries = t).http next upstream, 2—2는NGX_HTTP_UPSTREAM_FT_ERROR입니다.free rr peer 3 4— 남은 시도 3,state4 =NGX_PEER_FAILED(ngx_event_connect.h 19행).max_fails=1이라 한 번에 warn이 찍히고,effective_weight는 1 − 1/1 = 0 이 되었습니다.- 두 번째
get은try: 2로 다시 고릅니다. 9009는tried비트도 켜져 있고 제외 상태이기도 해서 후보에 오르지 않습니다. 마지막free rr peer 2 0은 성공(0)입니다. - 그 뒤 세 요청(4~6번째)은 9001·9002만 번갈아 갔습니다 — 10초 동안 9009는 2절 반복의 세 번째 조건에 걸립니다.
헷갈리기 쉬운 곳: 서버가 한 대면 max_fails 가 듣지 않습니다
free 함수의 앞부분에는 서버가 한 대일 때의 갈래(if (rrp->peers->single))가 따로 있습니다(1038–1054행). 그 갈래는 실패든 성공이든 fails 를 0으로 만들고 pc->tries = 0; 으로 끝납니다. 고를 다른 서버가 없으니 빼 봐야 소용이 없다는 판단입니다. 서버 한 대짜리 upstream에 max_fails=3 을 적어도 "temporarily disabled" 는 찍히지 않습니다. (backup 서버가 있으면 single 이 0이 되어 이 갈래를 타지 않습니다 — 294–296행.)
다른 고르기 방식: least_conn 하나만 보기
least_conn 의 get 은 2절의 반복과 걸러 내는 조건이 같고, 고르는 기준만 "연결 수 ÷ 무게가 가장 작은 것"으로 바뀝니다. 나눗셈 대신 곱셈으로 비교합니다.
if (best == NULL
| peer->conns * best->weight < best->conns * peer->weight)
{
best = peer;
many = 0;
p = i;
} else if (peer->conns * best->weight == best->conns * peer->weight) {
many = 1;
}
src/http/modules/ngx_http_upstream_least_conn_module.c 181–190행
같은 값이 여럿(many)이면 그것들끼리 2절의 smooth weighted round robin을 한 번 돌려 고릅니다(200–248행). conns 는 라운드 로빈의 get 이 peer->conns++, free 가 peer->conns-- 하는 그 값입니다 — 워커마다 따로 세므로(1절) 워커가 여럿이면 zone 과 함께 써야 뜻이 분명해집니다. ip_hash(IPv4면 클라이언트 주소의 앞 세 바이트로 해시), hash(임의의 키, consistent 를 붙이면 링 해시), random, 1.31.x에서 들어온 least_time(1.30.x에는 없습니다)도 모두 init_upstream 에 자기 함수를 넣고 라운드 로빈의 peer 배열 위에서 get 만 다르게 씁니다.
4. upstream keepalive — get/free 를 감싸는 모듈
감싸기: 원래 포인터를 저장하고 자기 것으로 바꾼다
keepalive 모듈은 고르는 방식이 아닙니다. 이미 정해진 고르기 방식(라운드 로빈이든 least_conn이든)의 get/free 를 한 겹 감싸서, get 앞에서는 캐시에서 연결을 꺼내 주고 free 뒤에서는 연결을 캐시에 넣습니다. 10편의 필터가 ngx_http_next_body_filter 에 앞 것을 저장하고 자기를 맨 위에 올리던 것과 같은 수법입니다.
감싸기는 두 번 일어납니다. 설정을 다 읽은 뒤 한 번(peer.init 을 감쌈), 요청마다 한 번(get·free 를 감쌈).
for (i = 0; i < umcf->upstreams.nelts; i++) {
/* skip implicit upstreams */
if (uscfp[i]->srv_conf == NULL) {
continue;
}
kcf = ngx_http_conf_upstream_srv_conf(uscfp[i],
ngx_http_upstream_keepalive_module);
if (kcf->max_cached == 0) {
continue;
}
ngx_conf_init_msec_value(kcf->time, 3600000);
ngx_conf_init_msec_value(kcf->timeout, 60000);
ngx_conf_init_uint_value(kcf->requests, 1000);
if (kcf->max_cached == NGX_CONF_UNSET_UINT) {
kcf->local = 1;
kcf->max_cached = 32;
}
kcf->original_init_peer = uscfp[i]->peer.init;
uscfp[i]->peer.init = ngx_http_upstream_init_keepalive_peer;
src/http/modules/ngx_http_upstream_keepalive_module.c 534–559행
이 함수는 keepalive 모듈의 init_main_conf 입니다. ngx_http_block() 은 HTTP 모듈의 init_main_conf 를 모듈 순서대로 부르고(ngx_http.c 264–265행), 우리 빌드의 objs/ngx_modules.c 에서 ngx_http_upstream_module 은 111번째 줄, ngx_http_upstream_keepalive_module 은 146번째 줄에 있습니다. 그래서 1절의 반복이 peer.init 을 라운드 로빈(또는 least_conn …)으로 채운 뒤에 keepalive가 그것을 original_init_peer 에 저장하고 자기 함수로 바꿉니다. 모듈 순서가 감싸는 순서를 정하는 셈입니다.
요청 때의 ngx_http_upstream_init_keepalive_peer() 는 같은 일을 get/free 에 합니다.
if (kcf->original_init_peer(r, us) != NGX_OK) {
return NGX_ERROR;
}
kp->conf = kcf;
kp->upstream = r->upstream;
kp->data = r->upstream->peer.data;
kp->original_get_peer = r->upstream->peer.get;
kp->original_free_peer = r->upstream->peer.free;
r->upstream->peer.data = kp;
r->upstream->peer.get = ngx_http_upstream_get_keepalive_peer;
r->upstream->peer.free = ngx_http_upstream_free_keepalive_peer;
src/http/modules/ngx_http_upstream_keepalive_module.c 174–186행
peer.data 도 바꿔치기한다는 점을 보세요. 라운드 로빈의 상태(rrp)는 kp->data 안으로 들어가고, 원래 함수를 부를 때 kp->original_get_peer(pc, kp->data) 처럼 꺼내 넘깁니다. 이렇게 하면 라운드 로빈 쪽은 자기가 감싸였다는 것을 모릅니다.
"서버를 고르는 일"은 언제나 라운드 로빈이 하고, keepalive는 고른 서버로 가는 연결이 이미 있는지만 봅니다.
예제 2: 감싸인 것을 gdb 로 보기
정말 그렇게 불리는지 ngx_http_upstream_get_round_robin_peer 에 중단점을 걸어 봤습니다(ex/u12-gdb-wrap, gdb를 붙이기 쉽게 master_process off; daemon off; 로 프로세스 하나에서 돌렸습니다). keepalive 줄이 없는 평범한 upstream {} 입니다.
#0 ngx_http_upstream_get_round_robin_peer (pc=0x5555557afbd0, data=0x5555557b0100) at src/http/ngx_http_upstream_round_robin.c:699
#1 0x00005555556b0fac in ngx_http_upstream_get_keepalive_peer (pc=0x5555557afbd0, data=0x5555557b00c0) at src/http/modules/ngx_http_upstream_keepalive_module.c:219
#2 0x00005555555c003a in ngx_event_connect_peer (pc=0x5555557afbd0) at src/event/ngx_event_connect.c:34
#3 0x0000555555618faa in ngx_http_upstream_connect (r=0x55555577d750, u=0x5555557afbc0) at src/http/ngx_http_upstream.c:1606
#4 0x0000555555617771 in ngx_http_upstream_init_request (r=0x55555577d750) at src/http/ngx_http_upstream.c:868
#5 0x0000555555616a9f in ngx_http_upstream_init (r=0x55555577d750) at src/http/ngx_http_upstream.c:588
#6 0x00005555556099d6 in ngx_http_read_client_request_body (r=0x55555577d750, post_handler=0x555555616988 <ngx_http_upstream_init>) at src/http/ngx_http_request_body.c:84
#7 0x000055555567500c in ngx_http_proxy_handler (r=0x55555577d750) at src/http/modules/ngx_http_proxy_module.c:970
#8 0x00005555555ed617 in ngx_http_core_content_phase (r=0x55555577d750, ph=0x5555557a6a88) at src/http/ngx_http_core_module.c:1311
#9 0x00005555555ec448 in ngx_http_core_run_phases (r=0x55555577d750) at src/http/ngx_http_core_module.c:906
...
#15 0x00005555555cea51 in ngx_epoll_process_events (cycle=0x555555784120, timer=60000, flags=1) at src/event/modules/ngx_epoll_module.c:901
#16 0x00005555555bb0b9 in ngx_process_events_and_timers (cycle=0x555555784120) at src/event/ngx_event.c:248
#17 0x00005555555ca62b in ngx_single_process_cycle (cycle=0x555555784120) at src/os/unix/ngx_process_cycle.c:322
#18 0x000055555557f1b2 in main (argc=3, argv=0x7fffffffed38) at src/core/nginx.c:400
(#10~#14 는 요청 읽기 — ngx_http_handler 에서 ngx_http_wait_request_handler 까지 — 라 줄였습니다. 주소는 실행마다 다릅니다.) 프레임 #1이 핵심입니다. keepalive 를 한 줄도 적지 않았는데 ngx_http_upstream_get_keepalive_peer 가 라운드 로빈을 감싸고 있습니다. 그리고 #1과 #0의 data 가 다릅니다(...00c0 과 ...0100) — 위에서 본 kp 와 kp->data(= rrp) 입니다. #2의 34행이 1절 표의 pc->get 이고, 그 아래는 9편의 content phase와 11편의 upstream 시작 경로 그대로입니다.
이 판의 기본값: keepalive 는 적지 않아도 켜져 있습니다
위 init_main_conf 에서 max_cached == NGX_CONF_UNSET_UINT, 곧 keepalive 디렉티브를 적지 않았으면 local = 1; max_cached = 32; 로 켭니다. 이것은 2026년 3월에 들어온 변경입니다.
$ git -C /d/openSource/nginx log --format='%h %ad %s' --date=short -3 -- src/http/modules/ngx_http_upstream_keepalive_module.c
d787755d5 2026-03-24 Upstream keepalive: fixed parameter parsing.
4fbe4b627 2026-03-23 Upstream: enabled keepalive by default for explicit upstreams.
c5d36eac3 2026-03-19 Upstream keepalive: distinguish cached connections by location.
git tag --contains 4fbe4b627 로 보면 이 커밋은 mainline 1.29.7에 처음 들어갔고 stable 1.30.0부터 있습니다(1.30.6 에도 같은 코드가 있는 것을 확인했습니다). 백엔드와 HTTP/1.1로 이야기하는 기본값(proxy_http_version 1.1, 커밋 6bb27a631, 2026-01-27 — ngx_http_proxy_merge_loc_conf() 4071–4072행)도 같은 1.29.7부터입니다. 그리고 Connection 헤더는 기본으로 빈 값이라 백엔드로 보내지 않습니다(761–772행, 자세한 것은 11편 4절).
그래서 블로그나 옛 문서에서 흔히 보는 이 세 줄은 이 판에서는 적지 않아도 같은 결과입니다.
# 옛 판(1.29.6 까지)에서 upstream keepalive 를 켜던 방법 — 이 판에서는 기본값이다
upstream app { server 127.0.0.1:9001; keepalive 32; }
proxy_http_version 1.1;
proxy_set_header Connection "";
다만 완전히 같지는 않습니다. 기본값으로 켜질 때는 local 이 붙습니다. 커밋 메시지가 직접 설명합니다 — "Cached keepalive connections are no longer shared between different locations referencing the same explicit upstream unless keepalive is explicitly configured without the "local" parameter." 다음 절의 tag 가 그 구분입니다. 끄려면 keepalive 0; 입니다(max_cached == 0 이면 감싸지 않고 continue).
upstream {} 블록 없이 proxy_pass http://127.0.0.1:9001; 처럼 주소를 바로 적은 암묵적 upstream 은 이 반복의 첫 조건 srv_conf == NULL 에 걸려 건너뜁니다("skip implicit upstreams"). 암묵적 upstream은 ngx_http_upstream_add() 가 ngx_pcalloc 으로 만들고 srv_conf 를 채우지 않기 때문입니다(6905–6915행). upstream {} 블록을 읽는 ngx_http_upstream() 만 uscf->srv_conf = ctx->srv_conf; 를 합니다(6455행).
캐시 — cache 큐와 free 큐
칸 32개는 설정 때 한 번에 만들어 두고(563–575행), 요청 중에는 두 큐 사이를 오가기만 합니다. 큐는 3편의 ngx_queue_t 입니다.
get 은 먼저 원래의 get 으로 서버를 고르게 한 뒤, 그 주소로 가는 쉬는 연결을 찾습니다.
/* ask balancer */
rc = kp->original_get_peer(pc, kp->data);
if (rc != NGX_OK) {
return rc;
}
/* search cache for suitable connection */
cache = &kp->conf->cache;
for (q = ngx_queue_head(cache);
q != ngx_queue_sentinel(cache);
q = ngx_queue_next(q))
{
item = ngx_queue_data(q, ngx_http_upstream_keepalive_cache_t, queue);
c = item->connection;
if (kp->conf->local && item->tag != kp->upstream->conf) {
continue;
}
if (ngx_memn2cmp((u_char *) &item->sockaddr, (u_char *) pc->sockaddr,
item->socklen, pc->socklen)
== 0)
{
ngx_queue_remove(q);
ngx_queue_insert_head(&kp->conf->free, q);
goto found;
}
}
return NGX_OK;
src/http/modules/ngx_http_upstream_keepalive_module.c 217–251행
찾으면 found: 에서 pc->connection = c; pc->cached = 1; 을 하고 NGX_DONE 을 돌려줍니다(270–273행). ngx_event_connect_peer() 는 NGX_OK 가 아니면 그대로 돌아가므로 socket()·connect() 를 건너뛰고, ngx_http_upstream_connect() 는 "rc == NGX_OK || rc == NGX_AGAIN || rc == NGX_DONE"(1650행)을 같은 길로 받아 u->peer.connection 을 씁니다. 못 찾으면 NGX_OK — 새로 연결하라는 뜻입니다.
local 일 때 비교하는 tag 는 u->conf, 곧 그 요청을 처리한 location의 proxy 설정(ngx_http_upstream_conf_t)입니다. 같은 upstream app 을 쓰는 location이 둘이면 연결 캐시도 둘로 나뉩니다.
free 는 연결을 넣기 전에 다시 써도 되는지를 꼼꼼히 봅니다(296–325행). 실패했거나, 이 연결로 이미 keepalive_requests(기본 1000)번 보냈거나, 연결한 지 keepalive_time(기본 1시간)이 넘었거나, 워커가 끝나는 중이면 넣지 않습니다. 가장 중요한 것은 u->keepalive 입니다. 이 값은 프로토콜 모듈(proxy)이 응답을 끝까지 읽었고 백엔드가 연결을 닫겠다고 하지 않았을 때 켭니다(u->keepalive = !u->headers_in.connection_close; — 예를 들어 2115행). 넣을 때 빈 칸이 없으면 cache 큐 맨 뒤(가장 오래 쉰 연결)를 닫고 그 칸을 씁니다(334–350행). 쉬는 연결에는 keepalive_timeout(기본 60초) 타이머를 걸고 읽기 핸들러를 ngx_http_upstream_keepalive_close_handler 로 바꿔, 백엔드가 먼저 닫으면 알아채 정리합니다(358–365행). 그리고 어느 길로 가든 마지막에 kp->original_free_peer(pc, kp->data, state) 로 라운드 로빈의 free 를 부릅니다(383행) — 3절의 fails·conns 정리는 keepalive와 상관없이 일어납니다.
헷갈리기 쉬운 곳: 백엔드가 HTTP/1.0 으로 답하면 다시 쓰지 않습니다
처음 예제를 만들 때 백엔드로 python3의 http.server 를 그대로 쓰려 했습니다. 그런데 python3의 BaseHTTPRequestHandler 는 따로 정하지 않으면 HTTP/1.0 으로 답합니다. proxy 모듈은 상태 줄이 HTTP/1.1 미만이면 연결을 닫겠다는 뜻으로 받습니다.
if (ctx->status.http_version < NGX_HTTP_VERSION_11) {
if (ctx->status.code == NGX_HTTP_EARLY_HINTS) {
ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
"upstream sent HTTP/1.0 response with early hints");
return NGX_HTTP_UPSTREAM_INVALID_HEADER;
}
u->headers_in.connection_close = 1;
}
src/http/modules/ngx_http_proxy_module.c 1874–1883행
그러면 u->keepalive 가 0이 되어 위 free 의 !u->keepalive 에서 걸립니다. nginx 쪽 설정이 아무리 맞아도 백엔드가 HTTP/1.0이면 연결은 재사용되지 않습니다. 그래서 예제의 백엔드는 protocol_version 을 고를 수 있게 따로 만들었고, 아래 4번이 그 비교입니다.
예제 3: 백엔드가 본 클라이언트 포트로 재사용 확인하기
nginx가 백엔드에 연결할 때마다 운영체제가 새 출발 포트를 줍니다. 그러니 백엔드가 본 클라이언트 포트가 같으면 같은 TCP 연결을 다시 쓴 것입니다. 백엔드는 응답에 그 포트를 찍습니다(ex/u12-keepalive).
# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
worker_processes 1;
error_log /tmp/u12k/error.log debug;
http {
# keepalive 를 적지 않았다 (이 판에서는 기본으로 켜진다: max_cached 32, local)
upstream ka_default { server 127.0.0.1:9001; }
# keepalive 0 — 끈다
upstream ka_off { server 127.0.0.1:9001; keepalive 0; }
# 백엔드가 HTTP/1.0 으로 답한다
upstream ka_http10 { server 127.0.0.1:9002; }
# local 없이 적었다 — location 끼리 캐시를 나눠 쓴다
upstream ka_shared { server 127.0.0.1:9001; keepalive 8; }
server {
listen 8080;
location /default/ { proxy_pass http://ka_default/; }
location /default2/ { proxy_pass http://ka_default/; }
location /off/ { proxy_pass http://ka_off/; }
location /implicit/ { proxy_pass http://127.0.0.1:9001/; }
location /http10/ { proxy_pass http://ka_http10/; }
location /shared/ { proxy_pass http://ka_shared/; }
location /shared2/ { proxy_pass http://ka_shared/; }
}
}
(실제 파일은 upstream 블록을 여러 줄로 썼고, 두 번째 줄의 설명 주석과 pid·events {}·access_log off; 줄이 더 있습니다.) 9001은 HTTP/1.1, 9002는 HTTP/1.0으로 답하는 백엔드입니다. 결과입니다.
=== 1. explicit upstream, no keepalive line (default) ===
backend 9001 client-port 35724
backend 9001 client-port 35724
backend 9001 client-port 35724
=== 2. keepalive 0 ===
backend 9001 client-port 35738
backend 9001 client-port 35742
backend 9001 client-port 35744
=== 3. implicit upstream (proxy_pass http://127.0.0.1:9001/) ===
backend 9001 client-port 35746
backend 9001 client-port 35752
backend 9001 client-port 35756
=== 4. backend answers HTTP/1.0 ===
backend 9002 client-port 47662
backend 9002 client-port 47678
backend 9002 client-port 47686
=== 5. default (local): /default/ -> /default2/ -> /default/ ===
backend 9001 client-port 35724
backend 9001 client-port 35766
backend 9001 client-port 35724
=== 6. keepalive 8 (not local): /shared/ -> /shared2/ -> /shared/ ===
backend 9001 client-port 35774
backend 9001 client-port 35774
backend 9001 client-port 35774
=== debug log: keepalive lines ===
init keepalive peer: 12
using connection (reused): 6
saving connection (cached): 9
포트 번호는 실행마다 다릅니다. 보이는 것을 소스와 짝지으면 이렇습니다.
- 기본값 — 세 요청이 같은 포트 35724.
keepalive를 적지 않은upstream {}에서 연결이 재사용됐습니다. keepalive 0— 포트가 매번 다릅니다.max_cached == 0이라continue로 감싸지 않았습니다.- 암묵적 upstream — 같은 백엔드인데도 매번 새 연결. "skip implicit upstreams" 입니다.
- HTTP/1.0 백엔드 — 기본으로 켜진 upstream인데도 매번 새 연결. 바로 위 "헷갈리기 쉬운 곳"입니다.
local—/default2/는 같은 upstream을 쓰는데도 새 포트(35766)를 받았고, 다시/default/로 가자 1번에서 쓰던 35724가 돌아왔습니다. 캐시의tag가 location마다 다르기 때문입니다.keepalive 8;(local 없이) —/shared2/도/shared/가 쓰던 연결을 그대로 받았습니다.
디버그 로그의 숫자도 맞습니다. init keepalive peer 12번은 감싸인 upstream으로 간 요청(1·4·5·6번 묶음의 3개씩) 수이고, 2·3번 묶음에는 이 줄이 없습니다. 재사용 6번은 1번 묶음의 2·3번째, 5번 묶음의 1·3번째, 6번 묶음의 2·3번째입니다. 캐시에 넣은 9번은 1·5·6번 묶음의 응답마다 한 번씩이고, 4번 묶음(HTTP/1.0)은 한 번도 넣지 않았습니다.
5. stream 모듈 — 같은 바닥을 TCP 에 쓰기
세션은 accept 하자마자 생긴다
stream {} 은 http {} 와 나란히 서는 최상위 블록이고 소스도 src/stream/ 에 따로 있습니다. 그런데 파일 이름부터 HTTP와 짝이 맞습니다 — ngx_stream_handler.c(연결 받기), ngx_stream_core_module.c(phase), ngx_stream_proxy_module.c, ngx_stream_upstream_round_robin.c.
8편에서 HTTP는 연결을 받아도 데이터가 올 때까지 요청 구조체를 만들지 않았습니다. stream에는 "요청"이 없으므로 accept 직후 ngx_stream_init_connection() 이 연결 풀에서 세션(ngx_stream_session_t)을 바로 만들고(s = ngx_pcalloc(c->pool, sizeof(ngx_stream_session_t)); — 119행), 읽기 핸들러를 ngx_stream_session_handler 로 정한 뒤 바로 부릅니다(180–205행). 그 핸들러가 하는 일은 phase 엔진을 돌리는 것 하나입니다.
7개 phase 와 같은 모양의 엔진
typedef enum {
NGX_STREAM_POST_ACCEPT_PHASE = 0,
NGX_STREAM_PREACCESS_PHASE,
NGX_STREAM_ACCESS_PHASE,
NGX_STREAM_SSL_PHASE,
NGX_STREAM_PREREAD_PHASE,
NGX_STREAM_CONTENT_PHASE,
NGX_STREAM_LOG_PHASE
} ngx_stream_phases;
src/stream/ngx_stream.h 84–92행
HTTP의 11개(9편)보다 적습니다. URI가 없으니 rewrite도, location을 찾는 FIND_CONFIG도 없습니다. 대신 PREREAD 가 있습니다 — 첫 바이트를 들여다보고(예를 들어 ssl_preread 가 TLS ClientHello의 SNI를 읽어) 어디로 보낼지 정하는 자리입니다. 엔진은 HTTP와 똑같은 모양입니다.
void
ngx_stream_core_run_phases(ngx_stream_session_t *s)
{
ngx_int_t rc;
ngx_stream_phase_handler_t *ph;
ngx_stream_core_main_conf_t *cmcf;
cmcf = ngx_stream_get_module_main_conf(s, ngx_stream_core_module);
ph = cmcf->phase_engine.handlers;
while (ph[s->phase_handler].checker) {
rc = ph[s->phase_handler].checker(s, &ph[s->phase_handler]);
if (rc == NGX_OK) {
return;
}
}
}
src/stream/ngx_stream_core_module.c 168–187행
phase마다 등록된 핸들러를 한 줄짜리 배열로 펴고, 각 칸의 checker 가 핸들러를 불러 결과에 따라 다음 칸으로 넘기는 구조 — 9편의 ngx_http_core_run_phases() 와 줄 단위로 닮았습니다. 배열을 펴는 ngx_stream_init_phase_handlers()(ngx_stream.c 330–385행)는 CONTENT phase에 칸을 딱 하나 두고, LOG phase는 배열에 넣지 않습니다 — 로그 핸들러는 세션을 끝낼 때 ngx_stream_log_session() 이 따로 부릅니다(ngx_stream_handler.c 323–324행).
CONTENT phase의 그 한 칸은 서버 설정의 handler 를 부르고, proxy_pass 디렉티브가 그 자리를 채웁니다. HTTP에서 proxy_pass 가 location의 콘텐츠 핸들러를 걸던 것(11편)과 같은 수법입니다.
cscf = ngx_stream_conf_get_module_srv_conf(cf, ngx_stream_core_module);
cscf->handler = ngx_stream_proxy_handler;
src/stream/ngx_stream_proxy_module.c 2766–2768행
proxy 는 바이트를 옮길 뿐 — 그러나 upstream 은 같다
ngx_stream_proxy_handler() 는 upstream을 찾은 뒤 HTTP와 똑같이 uscf->peer.init(s, uscf) 를 부르고 ngx_stream_proxy_connect() 로 갑니다(633–646행). 그 안에서 부르는 것도 같은 ngx_event_connect_peer(&u->peer)(791행)입니다. 1절의 ngx_peer_connection_t 가 src/event/ 에 있던 까닭이 여기서 드러납니다. stream의 라운드 로빈(ngx_stream_upstream_round_robin.c)은 파일이 따로지만 계산은 같아서, 2절의 두 줄이 그대로 있습니다(peer->current_weight += peer->effective_weight; 756행, best->current_weight -= total; 781행). 다만 stream에는 keepalive 모듈이 없습니다 — 클라이언트 연결 하나가 백엔드 연결 하나와 짝을 이뤄 끝까지 가니, 다시 쓸 연결이 생기지 않습니다.
연결된 뒤에는 ngx_stream_proxy_process(s, from_upstream, do_write) 가 두 방향을 같은 코드로 처리합니다. from_upstream 이 1이면 출발지 src 가 백엔드 연결(pc), 도착지 dst 가 클라이언트(c)이고, 0이면 둘을 바꿔 끼울 뿐입니다(1962–1985행). 그다음은 src->recv() 로 읽어 버퍼에 담고, 3편의 ngx_chain_t 로 엮어 ngx_stream_top_filter 로 내보내는 반복입니다. HTTP처럼 헤더를 해석하거나 고쳐 쓰지 않습니다.
위쪽 두 줄은 따로, 아래 바닥은 같이 씁니다. 2편에서 "HTTP·stream·mail이 같은 코어 위에 섰다"고 한 것이 이 모양입니다.
예제 4: stream 으로 TCP 프록시
백엔드는 받은 줄을 대문자로 바꿔 자기 포트를 붙여 돌려주는 python3 TCP 서버 두 대(9101, 9102)입니다. HTTP가 아닙니다(ex/u12-stream).
# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
stream {
upstream echo {
server 127.0.0.1:9101;
server 127.0.0.1:9102;
}
# 기본값: proxy_half_close off
server {
listen 7000;
proxy_pass echo;
}
# 클라이언트가 쓰기 쪽만 닫아도 응답을 끝까지 기다린다
server {
listen 7001;
proxy_pass echo;
proxy_half_close on;
}
}
(실제 파일에는 두 번째 줄의 설명 주석과 worker_processes 1;·error_log·pid·events {} 줄이 더 있습니다.) 결과입니다. pid, 포트, 포인터 주소, 연결 번호는 실행마다 다릅니다.
=== 1. keep the client open for 1s, two sessions (port 7000) ===
9101: HELLO
9101: NGINX
9102: SECOND
=== 2. client closes its write side right after sending (port 7000, half_close off) ===
(nothing came back)
=== 3. same, but port 7001 with proxy_half_close on ===
9102: FOURTH
=== 4. debug log of the first session (selected lines) ===
[info] 23#23: *5 client 127.0.0.1:39326 connected to 0.0.0.0:7000
[debug] 23#23: *5 generic phase: 0
[debug] 23#23: *5 generic phase: 1
[debug] 23#23: *5 generic phase: 2
[debug] 23#23: *5 generic phase: 3
[debug] 23#23: *5 generic phase: 4
[debug] 23#23: *5 proxy connection handler
[debug] 23#23: *5 get rr peer, try: 2
[debug] 23#23: *5 get rr peer, current: 000056143A5BFB28 -1
[debug] 23#23: *5 connect to 127.0.0.1:9101, fd:11 #6
[debug] 23#23: *5 proxy connect: -2
[info] 23#23: *5 proxy 127.0.0.1:32856 connected to 127.0.0.1:9101
[info] 23#23: *5 client disconnected, bytes from/to client:12/24, bytes from/to upstream:24/12
[debug] 23#23: *5 finalize stream session: 200
=== 5. 'disconnected' lines of all sessions ===
[info] 23#23: *5 client disconnected, bytes from/to client:12/24, bytes from/to upstream:24/12
[info] 23#23: *7 client disconnected, bytes from/to client:7/13, bytes from/to upstream:13/7
[info] 23#23: *9 client disconnected, bytes from/to client:6/0, bytes from/to upstream:0/6
[info] 23#23: *11 upstream disconnected, bytes from/to client:7/13, bytes from/to upstream:13/7
1번은 세션 두 개가 9101, 9102로 번갈아 간 것 — 2절의 라운드 로빈(무게가 같으면 그냥 번갈아)이 stream에서도 돕니다. 4번 디버그 로그를 순서대로 읽으면 이렇습니다.
client ... connected to—ngx_stream_init_connection()의ngx_log_error(NGX_LOG_INFO, ...)(149–151행). 클라이언트가 아무것도 보내기 전입니다.generic phase: 0~4—ngx_stream_core_generic_phase()의 디버그 줄(201–202행)이고, 숫자는 phase 번호가 아니라 펴 놓은 핸들러 배열의 칸 번호(s->phase_handler)입니다. 우리 빌드에서는 POST_ACCEPT(realip), PREACCESS(limit_conn, set), ACCESS(access), SSL(ssl) 의 핸들러 다섯이 0~4번 칸에 있습니다. PREREAD에 핸들러를 거는ssl_preread는 빌드하지 않아서 칸이 없고, 5번 칸이 CONTENT입니다(ngx_stream_core_content_phase()는 디버그 줄을 찍지 않고 바로proxy connection handler로 넘어갑니다).get rr peer— HTTP와 같은 문자열입니다. stream 라운드 로빈 파일이 HTTP 것과 같은 모양으로 짜여 있다는 증거입니다.proxy connect: -2—-2는NGX_AGAIN. 논블로킹 connect가 진행 중이라는 뜻이고(11편의 upstream connect와 같습니다), 연결이 끝나면proxy ... connected to가 찍힙니다.
헷갈리기 쉬운 곳: 클라이언트가 쓰기만 닫아도 세션이 끝납니다
예제를 처음 돌렸을 때 printf 'hello\n' | nc -q1 127.0.0.1 7000 이 아무것도 돌려받지 못했습니다. 백엔드에 직접 붙으면(nc -q1 127.0.0.1 9101) 잘 돌아오는데 nginx를 거치면 빈손이었습니다. nc -q1 은 표준 입력이 끝나면 소켓의 쓰기 쪽만 닫고(half-close) 1초 동안 답을 기다립니다. nginx는 클라이언트 쪽에서 EOF를 읽은 순간 세션을 끝내 버린 것입니다.
if (pc == NULL
| (!c->read->eof && !pc->read->eof)
| (!c->read->eof && c->buffered)
| (!pc->read->eof && pc->buffered))
{
return NGX_DECLINED;
}
if (pscf->half_close) {
/* avoid closing live connections until both read ends get EOF */
if (!(c->read->eof && pc->read->eof && !c->buffered && !pc->buffered)) {
return NGX_DECLINED;
}
}
src/stream/ngx_stream_proxy_module.c 2199–2212행
ngx_stream_proxy_test_finalize() 의 TCP 갈래입니다. 첫 if 는 "어느 한쪽이라도 EOF이고 보낼 것이 남지 않았으면" 아래로 내려가 세션을 끝냅니다. 이를 막는 것은 proxy_half_close 뿐인데 기본값이 off입니다(ngx_conf_merge_value(conf->half_close, prev->half_close, 0); — 2500행). 5번 출력의 *9 가 그 세션입니다 — client disconnected, 클라이언트에게 보낸 바이트가 0입니다. 7001(proxy_half_close on)의 *11 은 백엔드가 답을 보내고 닫을 때까지 기다렸다가 upstream disconnected 로 끝났습니다. 1번에서 (printf ...; sleep 1) | nc -q0 처럼 입력을 1초 열어 둔 것도 이 때문입니다. "요청을 보내고 쓰기를 닫은 뒤 답을 기다리는" 프로토콜을 stream으로 프록시할 때 걸리기 쉬운 곳입니다.
이 글에서 뺀 것
zone과 실행 중 이름 풀기 — 워커끼리 peer를 공유 메모리로 나누고,server ... resolve로 DNS가 바뀌면 peer를 고칩니다.src/http/modules/ngx_http_upstream_zone_module.c(ngx_http_upstream_zone_copy_peers(),ngx_http_upstream_zone_resolve_handler()), round robin의NGX_HTTP_UPSTREAM_ZONE블록들.sticky— 쿠키 등으로 같은 클라이언트를 같은 서버에 붙이기.ngx_http_upstream_sticky_module.c(이번에 본/* ... */의NGX_HTTP_UPSTREAM_SID블록이 그 자리입니다).init_upstream을 감싸는 방식이라 keepalive와 닮았습니다.hash ... consistent(ketama 링)·random two·least_time—ngx_http_upstream_hash_module.c의ngx_http_upstream_init_chash()·ngx_http_upstream_get_chash_peer(),ngx_http_upstream_random_module.c,ngx_http_upstream_least_time_module.c.backup서버로 넘어가기 —ngx_http_upstream_get_round_robin_peer()의failed:갈래(772–806행).- TLS 세션 재사용 —
set_session/save_session포인터(ngx_http_upstream_set_round_robin_peer_session()). keepalive 모듈도 이것을 감쌉니다. max_conns— 2절의 네 번째 걸러 내기 조건으로만 봤습니다. peer 구조체의slow_start필드는 이 소스에서 값을 넣거나 읽는 곳이 없습니다(grep -rn slow_start src/로 선언 네 줄만 나옵니다).- stream의 UDP(
listen ... udp,proxy_responses),ssl_preread,ngx_stream_pass_module, 그리고 mail 모듈(src/mail/).
정리
| 개념 | 한 줄 요약 |
|---|---|
ngx_http_upstream_peer_t |
init_upstream(설정 때, peer 배열) + init(요청 때, get/free 채우기). 고르는 방식은 이 둘을 바꿔 끼운다 |
ngx_peer_connection_t |
src/event/ 의 손잡이. get 으로 고르고 free 로 성공·실패를 알린다. HTTP와 stream이 같이 쓴다 |
| smooth weighted round robin | 모두에 무게를 더하고, 가장 큰 것을 고르고, 고른 것에서 합을 뺀다. 5:1:1 → a a b a c a a |
max_fails·fail_timeout |
기본 1번·10초. 실패는 free(NGX_PEER_FAILED) 로만 센다. 서버가 한 대면 듣지 않는다 |
| peer 상태의 범위 | 설정 풀에 있어 워커마다 따로. 나누려면 zone |
| keepalive 모듈 | 고르기 방식의 init·get·free 를 감싸 연결 캐시(cache·free 큐)를 붙인다 |
| 이 판의 기본값 | 명시한 upstream {} 은 keepalive 없이도 켜짐(32, local), proxy_http_version 1.1. 1.29.7·1.30.0부터 |
| 재사용되지 않는 경우 | keepalive 0, 암묵적 upstream, HTTP/1.0 백엔드, Connection: close 응답, local 이면 다른 location |
| stream 모듈 | accept 직후 세션, 7 phase(location 없음), 바이트만 옮기는 proxy. upstream과 connect는 HTTP와 같은 바닥 |
proxy_half_close |
기본 off — 클라이언트가 쓰기만 닫아도 세션이 끝난다 |
연재를 마치며
이 글로 1편의 지도에 적어 둔 12편을 모두 썼습니다. 연재의 글 전체입니다.
| 편 | 글 | 한 줄 요약 |
|---|---|---|
| 1 | AI와 함께 소스를 읽는 방법과 분석 지도 | 기준 커밋을 정하고 도커로 빌드해, 폴더 지도와 요청 하나가 지나는 큰 길을 그렸다 |
| 2 | 전체 지도 — 마스터와 워커, 모듈, cycle, 설정 문맥 | 마스터 하나와 워커 여럿, 모듈 종류, ngx_cycle_t, 설정 문맥으로 연재 전체의 지도를 세웠다 |
| 3 | 메모리 풀과 기본 자료구조 | 수명이 같은 것을 한 풀에 모아 한 번에 버리고, 문자열·버퍼·체인은 포인터 두 개로 다룬다 |
| 4 | 기동과 설정 읽기 — main()에서 ngx_init_cycle까지 | 설정을 읽을 때마다 새 cycle을 만들고, 디렉티브 이름으로 ngx_command_t 를 찾는다 |
| 5 | 모듈과 디렉티브 | 설정 한 줄이 모듈의 create·merge 콜백을 거쳐 설정 구조체의 필드가 된다 |
| 6 | 마스터와 워커 — 프로세스, 시그널, 무중단 reload | 마스터는 시그널을 기다리고, reload는 새 워커를 띄운 뒤 옛 워커를 조용히 보낸다 |
| 7 | 이벤트 루프 — epoll, 타이머, posted 이벤트, accept | 워커 하나가 epoll·타이머 트리·posted 큐로 수천 연결을 한 스레드에서 돌린다 |
| 8 | HTTP 요청 읽기 | 데이터가 와야 요청을 만들고, 상태 기계로 요청 줄과 헤더를 버퍼 위에서 읽는다 |
| 9 | 11단계 phase와 location 찾기 | phase 핸들러를 한 줄 배열로 펴서 checker가 차례로 돌리고, location은 트리로 찾는다 |
| 10 | 필터와 정적 파일 | 응답은 함수 포인터 사슬의 필터를 지나 sendfile 로 나간다 |
| 11 | upstream과 proxy | proxy는 upstream의 콜백을 채우고, upstream이 연결·전송·응답 흘려보내기를 맡는다 |
| 12 | 부하 분산, upstream keepalive, stream 모듈 (이 글) | get/free 두 포인터로 서버를 고르고, 그것을 감싸 연결을 다시 쓰고, 같은 바닥을 TCP에 쓴다 |
요청 하나의 길, 다시 한 번
1편에 그린 큰 길을 이번에는 편 번호를 붙여 말로 따라가 봅니다. nginx 를 실행하면 main() 이 설정을 읽어 cycle을 만들고(4편), 그 사이 디렉티브마다 모듈의 설정 구조체가 채워지며(5편), 그 모든 메모리는 풀에서 나옵니다(3편). 마스터는 워커를 띄우고 시그널을 기다립니다(6편). 워커 안에서는 이벤트 루프가 epoll_wait 에서 깨어나 ngx_event_accept 로 연결을 받고(7편), 데이터가 오면 ngx_http_request_t 를 만들어 요청 줄과 헤더를 읽습니다(8편). ngx_http_core_run_phases 가 phase를 차례로 돌며 location을 고르고(9편), 콘텐츠 핸들러가 파일을 내보내면 필터 사슬을 지나 sendfile 로 나가고(10편), proxy_pass 였다면 upstream이 백엔드에 연결해 응답을 받아 옵니다(11편). 그 연결할 서버를 고르고, 이미 열린 연결을 다시 쓰는 것이 이 글이었습니다. 응답이 끝나면 연결은 keep-alive로 돌아가 다음 요청을 기다리거나(8편) 닫힙니다.
12편을 지나며 되풀이해 나온 것은 세 가지였습니다. 함수 포인터 표는 모듈(5편), phase의 checker(9편), 필터 사슬(10편), upstream 콜백(11편), 그리고 이 글의 get/free 까지 nginx가 "남의 코드를 끼우는" 한결같은 방법이었습니다. 풀은 무엇이 얼마나 오래 사는지를 정했습니다 — 설정 풀의 peer 배열이 워커마다 따로인 것도 그 결과였습니다. 이벤트 루프 하나는 모든 일을 "기다리지 않는" 조각으로 쪼개게 만들었습니다. 논블로킹 connect가 NGX_AGAIN 을 돌려주고 나중에 다시 불리는 것도 그 때문이었습니다. 이 셋을 알면 처음 보는 nginx 모듈의 소스도 어디부터 볼지 짐작할 수 있습니다.
httpd·Tomcat 연재와 비교하면
이 블로그는 앞서 Apache httpd 소스 파헤치기(16편)와 Tomcat 소스 파헤치기(16편)를 읽었습니다. 세 서버는 같은 문제를 다르게 풀었습니다.
- 연결을 어떻게 나눠 받나. httpd는 MPM을 갈아 끼워 프로세스·스레드 모델을 고르고(httpd 8편), Tomcat은 Acceptor·Poller·작업 스레드 풀로 나눴습니다(Tomcat 5편). nginx는 워커마다 이벤트 루프 하나로 모든 연결을 돌립니다(7편). Tomcat의 Poller가 셀렉터 하나로 많은 연결을 지켜보는 것은 nginx와 닮았지만, Tomcat은 일이 생기면 작업 스레드에 넘기고 nginx는 그 자리에서 처리합니다.
- 처리 단계. httpd의 훅(httpd 10편)은 모듈이 등록한 함수를 단계마다 차례로 부르고, nginx의 phase(9편)는 그것을 설정 때 한 줄 배열로 펴 둡니다. Tomcat은 컨테이너마다 밸브 사슬을 둡니다(Tomcat 8편).
- 응답 흐름. httpd의 버킷 브리게이드(httpd 11편)와 nginx의
ngx_chain_t필터(10편)는 "버퍼의 사슬을 필터로 흘려보낸다"는 같은 생각입니다. - 부하 분산. 이 글의 smooth weighted round robin은 httpd 14편의
byrequests와 같은 계산입니다 — httpd는lbstatus += lbfactor, 가장 큰 것을 고르고, 고른 것에서 합을 뺍니다. 다른 점은 고르는 방식을 끼우는 법입니다. httpd는lbmethod를 이름으로 찾는 프로바이더로, nginx는init_upstream함수 포인터를 디렉티브가 바꿔 넣는 방식으로 합니다. 백엔드 연결 풀도 httpd는 워커마다 APR의apr_reslist를, nginx는 이 글의 cache·free 큐를 씁니다.
다음에 읽을 것
이 블로그의 오픈소스 읽기는 Spring, Apache httpd, Tomcat, nginx에 이어 MariaDB 로 갑니다. 웹 서버의 바깥에서 요청을 받는 쪽을 넷이나 읽었으니, 이제 그 요청이 마지막에 닿는 데이터베이스 서버의 안을 봅니다.