namalzi

← 글 목록

nginx 소스 파헤치기 12: 부하 분산, upstream keepalive, stream 모듈 — 그리고 연재를 마치며

nginx · 게시

upstream {} 에 서버를 여러 대 적으면 nginx는 요청마다 어느 서버로 보낼지를 어떻게 고를까요. peer.init·get·free 세 함수 포인터로 짜인 부하 분산의 틀, 가중치 5:1:1을 a a b a c a a로 고르게 펴는 smooth weighted round robin, max_fails와 fail_timeout, 그 get/free를 감싸 연결을 캐시에 넣었다 꺼내는 keepalive 모듈(이 판에서는 적지 않아도 켜집니다), 그리고 같은 바닥을 TCP에 쓴 stream 모듈을 소스와 도커 예제로 확인하고 연재를 마무리합니다.

이 글에서 다루는 것

"nginx 소스 파헤치기" 연재의 열두 번째이자 마지막 글입니다. 11편에서는 proxy_pass 한 줄이 콘텐츠 핸들러를 걸고, ngx_http_upstream_t 가 백엔드에 연결해 요청을 보내고 응답을 받아 클라이언트에게 흘려보내는 길을 따라갔습니다. 그 글에서 일부러 넘긴 질문이 하나 있습니다 — 연결할 서버는 누가 고르나. 이번 글이 그 답입니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 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 을 확대합니다.

2편의 연재 지도. A 준비(기동·cycle, 모듈·디렉티브, 메모리 풀·자료구조), B 프로세스와 이벤트(마스터·워커, 이벤트 루프), C HTTP 요청 하나(요청 읽기, phase·location, 필터·정적 파일, upstream·proxy, 부하 분산·keepalive·stream)가 있고, 이 글은 upstream·proxy 다음에 오는 부하 분산·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 이 비어 있으면, 곧 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)을 알린다

부하 분산에 쓰는 구조체들. 설정 때 upstream 블록마다 ngx_http_upstream_srv_conf_t가 하나 있고, 그 peer.data가 ngx_http_upstream_rr_peers_t를 가리킨다. rr_peers_t는 number, total_weight, single, weighted를 들고 peer 필드로 첫 rr_peer를, next 필드로 backup용 rr_peers_t를 가리킨다. rr_peer는 weight, effective_weight, current_weight, fails를 들고 next로 다음 peer를 가리킨다. 요청마다 요청 풀에서 ngx_peer_connection_t(u->peer)의 data가 rr_peer_data_t를 가리키고, 그것이 peers와 이번에 고른 current와 tried 비트맵을 든다. 노란 것이 메모리에 놓인 상태, 파란 것이 함수 포인터를 든 손잡이입니다. 아래쪽은 설정 때 한 번, 위쪽은 요청마다 만들어집니다.

라운드 로빈의 요청별 초기화 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행 (줄인 곳 표시)

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절에서 다시 나옵니다.

ngx_http_upstream_get_peer의 반복. peer 목록을 처음부터 돌며 이번 요청에서 이미 시도했나(tried 비트), down 표시인가, fails가 max_fails 이상이고 fail_timeout 안인가, conns가 max_conns 이상인가를 차례로 보고 하나라도 해당하면 건너뛴다. 넷 다 아니면 current_weight에 effective_weight를 더하고 total에도 더하고 effective_weight를 1 회복한 뒤, current_weight가 가장 큰 것을 best로 기억한다. 다 돌고 나면 best의 current_weight에서 total을 빼고 tried 비트를 켠다. 걸러 내는 조건 넷(빨간 갈래)과 무게 계산 셋. 고른 서버에서 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행

여기서 일어나는 일은 두 가지입니다.

  1. 실패 횟수(fails)가 max_fails 에 닿으면, 2절의 반복이 now - peer->checked <= peer->fail_timeout 인 동안 그 peer를 건너뜁니다. 기본값이면 한 번 실패로 10초 동안 빠집니다.
  2. 무게(effective_weight)를 weight / max_fails 만큼 깎습니다. 다시 살아난 뒤에도 바로 원래 몫을 받지 않고, 고를 때마다 1씩(2절의 effective_weight++) 회복합니다.

fails 가 0으로 돌아가는 조건이 눈여겨볼 곳입니다. 성공했다고 바로 지우지 않고 accessed < checked 일 때만 지웁니다. 실패하면 accessed 와 checked 가 같은 시각이 되고, checked 는 2절 끝에서 fail_timeout 이 지난 뒤 그 peer가 골라질 때만 새 시각으로 바뀝니다. 그래서 "fail_timeout 안에서의 성공"은 실패 횟수를 지우지 못하고, fail_timeout 이 지난 뒤 다시 골라져 성공해야 정상으로 돌아옵니다.

peer의 상태 그림. 정상(fails 0)에서 실패하면 fails가 늘고 checked가 지금이 되어 실패 누적 상태가 된다. 실패 누적 상태에서 fail_timeout 안의 성공은 fails를 그대로 두고, fail_timeout 뒤에 골라져 성공하면 fails가 0이 되어 정상으로 돌아간다. 실패가 쌓여 fails가 max_fails에 닿으면 제외 상태가 된다. max_fails가 1이면 정상에서 바로 제외로 간다. 제외 상태에서 now - checked가 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

읽는 법입니다.

헷갈리기 쉬운 곳: 서버가 한 대면 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) 처럼 꺼내 넘깁니다. 이렇게 하면 라운드 로빈 쪽은 자기가 감싸였다는 것을 모릅니다.

감싸기 순서. 설정 때 init_upstream이 peer.init을 round robin으로, keepalive가 그것을 자기 것으로 바꿔 끼운다. 요청 때 ngx_http_upstream.c가 uscf->peer.init을 부르면 keepalive 모듈이 original_init_peer로 round robin을 부르고, round robin은 pc.get과 pc.free를 자기 것으로 채운다. keepalive는 그것을 저장하고 자기 것으로 바꾼다. ngx_http_upstream_connect가 ngx_event_connect_peer를 부르면 pc->get, 곧 keepalive의 get이 불리고, 그것이 original_get_peer로 round robin에게 서버를 고르게 한다. round robin이 pc->sockaddr를 채우면 keepalive는 캐시에서 같은 주소의 연결을 찾아 있으면 NGX_DONE을 돌려주고, ngx_event_connect_peer는 socket과 connect를 건너뛴다. 요청이 끝나면 keepalive의 free가 다시 쓸 만한 연결을 캐시에 넣고 original_free_peer로 round robin의 fails와 conns를 정리한다. "서버를 고르는 일"은 언제나 라운드 로빈이 하고, 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 큐

keepalive 연결 캐시. ngx_http_upstream_keepalive_srv_conf_t는 upstream 블록 하나에 하나, 워커마다 한 벌 있고, max_cached개(기본 32)의 칸을 미리 만든다. 칸은 쉬고 있는 연결이 든 cache 큐와 빈 칸이 든 free 큐 가운데 하나에 들어 있다. cache의 item은 connection, sockaddr, tag(= u->conf)를 든다. get은 주소가 같고 local이면 tag도 같은 item에서 연결을 꺼내 쓰고 그 칸을 free로 옮긴다. free는 빈 칸을 꺼내 cache 맨 앞에 넣고, 빈 칸이 없으면 cache 맨 뒤의 연결을 닫고 그 칸을 쓴다. 칸 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

포트 번호는 실행마다 다릅니다. 보이는 것을 소스와 짝지으면 이렇습니다.

  1. 기본값 — 세 요청이 같은 포트 35724. keepalive 를 적지 않은 upstream {} 에서 연결이 재사용됐습니다.
  2. keepalive 0 — 포트가 매번 다릅니다. max_cached == 0 이라 continue 로 감싸지 않았습니다.
  3. 암묵적 upstream — 같은 백엔드인데도 매번 새 연결. "skip implicit upstreams" 입니다.
  4. HTTP/1.0 백엔드 — 기본으로 켜진 upstream인데도 매번 새 연결. 바로 위 "헷갈리기 쉬운 곳"입니다.
  5. local — /default2/ 는 같은 upstream을 쓰는데도 새 포트(35766)를 받았고, 다시 /default/ 로 가자 1번에서 쓰던 35724가 돌아왔습니다. 캐시의 tag 가 location마다 다르기 때문입니다.
  6. 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처럼 헤더를 해석하거나 고쳐 쓰지 않습니다.

http와 stream의 비교. http 쪽은 ngx_http_init_connection이 데이터가 와야 요청을 만들고, ngx_http_core_run_phases가 11 phase와 location을 돌고, ngx_http_proxy_handler가 HTTP를 읽고 고쳐 쓰며 필터 체인을 지난다. stream 쪽은 ngx_stream_init_connection이 accept 하자마자 세션을 만들고, ngx_stream_core_run_phases가 location 없는 7 phase를 돌고, ngx_stream_proxy_handler는 바이트를 양쪽으로 옮길 뿐이다. 두 proxy 핸들러는 같이 쓰는 바닥인 ngx_event_connect_peer(pc->get, pc->free)를 부르고, 그것이 upstream 블록과 라운드 로빈(같은 알고리즘, 파일은 따로)에 닿는다. 이벤트 루프와 연결(7편)이 두 쪽 모두의 시작점이다. 위쪽 두 줄은 따로, 아래 바닥은 같이 씁니다. 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번 디버그 로그를 순서대로 읽으면 이렇습니다.

헷갈리기 쉬운 곳: 클라이언트가 쓰기만 닫아도 세션이 끝납니다

예제를 처음 돌렸을 때 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으로 프록시할 때 걸리기 쉬운 곳입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
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편)를 읽었습니다. 세 서버는 같은 문제를 다르게 풀었습니다.

다음에 읽을 것

이 블로그의 오픈소스 읽기는 Spring, Apache httpd, Tomcat, nginx에 이어 MariaDB 로 갑니다. 웹 서버의 바깥에서 요청을 받는 쪽을 넷이나 읽었으니, 이제 그 요청이 마지막에 닿는 데이터베이스 서버의 안을 봅니다.

다른 글