namalzi

← 글 목록

nginx 소스 파헤치기 11: upstream과 proxy — 리버스 프록시의 한 요청

nginx · 게시

proxy_pass 한 줄이 location 에 콘텐츠 핸들러를 걸고, proxy 모듈이 ngx_http_upstream_t 의 콜백을 채우면 upstream 이 논블로킹 connect·요청 보내기·응답 헤더 읽기·본문 흘려보내기를 맡는 과정을 소스로 따라갑니다. 백엔드로 실제 나가는 요청(이 판의 기본값 HTTP/1.1, 보내지 않는 Connection 헤더), 502·504 가 정해지는 자리와 proxy_next_upstream, proxy_buffering on/off 와 임시 파일을 디버그 로그·gdb·작은 백엔드로 확인합니다.

이 글에서 다루는 것

"nginx 소스 파헤치기" 연재의 열한 번째 글입니다. 9편에서 요청이 11단계 phase 를 지나 location 을 고르고 CONTENT phase 에서 콘텐츠 핸들러를 부르는 데까지, 10편에서는 그 핸들러가 정적 파일이었을 때 응답이 필터 사슬을 지나 sendfile 로 나가는 길을 봤습니다. 이번에는 콘텐츠 핸들러가 proxy_pass 인 경우입니다. nginx 가 응답을 스스로 만들지 않고, 뒤에 있는 다른 서버(백엔드)에 요청을 다시 보내 받은 응답을 클라이언트에게 돌려주는 리버스 프록시입니다.

2편의 지도에서 이 글은 "C. HTTP 요청 하나" 줄의 upstream·proxy 상자를 확대합니다.

연재 지도에서 이 글의 위치. A 준비(기동·cycle 4편, 모듈·디렉티브 5편, 메모리 풀·자료구조 3편), B 프로세스와 이벤트(마스터·워커 6편, 이벤트 루프 7편), C HTTP 요청 하나(요청 읽기 8편, phase·location 9편, 필터·정적 파일 10편, upstream·proxy 11편, 부하 분산·keepalive·stream 12편) 가운데 11편 upstream·proxy 상자만 파란색으로 칠해져 있다. phase·location 에서 필터·정적 파일과 upstream·proxy 로 갈라지고, upstream·proxy 는 12편으로 이어진다. 파란 상자가 이 글입니다. 9편의 CONTENT phase 에서 정적 파일(10편) 대신 이쪽으로 갈라지고, "어느 서버로 연결하나"는 12편이 맡습니다.

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

항목 내용
대상 nginx
버전 nginx 1.31.9 (master, 개발판)
커밋 9a7439fd79 (2026-10-23)
주로 읽는 파일 src/http/modules/ngx_http_proxy_module.c, src/http/ngx_http_upstream.[ch], src/event/ngx_event_connect.c, src/event/ngx_event_pipe.c

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

ngx_http_upstream.c 하나가 7,405줄, ngx_http_proxy_module.c 가 5,472줄입니다. 캐시, 백엔드와의 TLS, WebSocket 업그레이드처럼 같은 파일에 사는 기능이 많습니다. 이 글은 HTTP/1.x 백엔드에 GET 요청 하나를 보내고 응답을 돌려받는 큰 줄기만 골랐고, 뺀 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

upstream 은 nginx 뒤에 있는 서버, 곧 백엔드를 nginx 가 부르는 이름입니다. 설정 파일의 upstream {} 블록이 그 묶음이고, 소스에서는 두 가지를 가리킵니다. 하나는 설정 쪽의 서버 묶음(ngx_http_upstream_srv_conf_t), 다른 하나는 요청마다 하나씩 만드는 ngx_http_upstream_t — "이 요청을 백엔드에 보내고 응답을 받아 오는 일" 전체의 상태를 담는 구조체입니다. 이 글의 주인공은 뒤의 것입니다.

콘텐츠 핸들러는 CONTENT phase 에서 응답 본문을 만드는 함수입니다. location 설정(ngx_http_core_loc_conf_t)의 handler 필드에 함수 포인터가 들어 있으면 CONTENT phase 는 다른 핸들러를 보지 않고 그것만 부릅니다(9편).

콜백(callback)은 "내가 알맞은 때에 불러 줄 테니 함수를 맡겨 두라"는 약속입니다. C 에서는 구조체의 함수 포인터 필드로 만듭니다. ngx_http_upstream_t 에는 create_request, process_header 같은 함수 포인터가 있고, proxy 모듈이 자기 함수를 거기에 넣습니다. 모듈이 함수 포인터 표로 끼어드는 방식은 5편에서 봤고, C 문법이 낯설면 httpd 2편이 도움이 됩니다.

peer 는 연결할 서버 주소 하나입니다. 요청마다 쓰는 ngx_peer_connection_t(u->peer)에는 고른 주소와 연결, 그리고 "서버를 골라 줘"(get)·"다 썼어"(free) 함수 포인터가 있습니다. 누가 어떤 규칙으로 고르는지는 12편에서 봅니다. 이 글에서는 "get 을 부르면 주소가 나온다"고만 생각하면 됩니다.

논블로킹 connect. 소켓을 논블로킹으로 두고 connect() 를 부르면, 연결이 끝날 때까지 기다리지 않고 바로 -1 과 EINPROGRESS("진행 중")를 돌려줍니다. 연결이 끝나면 그 소켓이 쓰기 가능해졌다는 이벤트가 옵니다. 워커 하나가 이벤트 루프 하나로 수천 연결을 돌리는 nginx(7편)에서는 백엔드 연결도 이렇게 해야 합니다.

event pipe 는 src/event/ngx_event_pipe.c 의 ngx_event_pipe_t 로, 백엔드 쪽 소켓에서 읽어 클라이언트 쪽 소켓으로 내보내는 "관"입니다. 그 사이에 버퍼 여러 개와, 버퍼가 모자라면 쓰는 임시 파일을 둡니다. proxy_buffering on(기본값)일 때 쓰입니다. 버퍼(ngx_buf_t)와 버퍼 사슬(ngx_chain_t)은 3편에서 봤습니다.

1. proxy_pass 한 줄이 거는 것

설정을 읽을 때: 콘텐츠 핸들러와 upstream 하나

proxy_pass 디렉티브를 만나면 설정 파서는 proxy 모듈의 ngx_http_proxy_pass() 를 부릅니다(디렉티브 이름으로 함수를 찾는 과정은 5편).

    if (plcf->upstream.upstream || plcf->proxy_lengths) {
        return "is duplicate";
    }

    clcf = ngx_http_conf_get_module_loc_conf(cf, ngx_http_core_module);

    clcf->handler = ngx_http_proxy_handler;

    if (clcf->name.len && clcf->name.data[clcf->name.len - 1] == '/') {
        clcf->auto_redirect = 1;
    }

    /* ... */
    ngx_memzero(&u, sizeof(ngx_url_t));

    u.url.len = url->len - add;
    u.url.data = url->data + add;
    u.default_port = port;
    u.uri_part = 1;
    u.no_resolve = 1;

    plcf->upstream.upstream = ngx_http_upstream_add(cf, &u, 0);
    if (plcf->upstream.upstream == NULL) {
        return NGX_CONF_ERROR;
    }

src/http/modules/ngx_http_proxy_module.c 4391–4465행

이 함수가 하는 일은 셋입니다.

  1. 이 location 의 콘텐츠 핸들러를 ngx_http_proxy_handler 로 정합니다(4397행). 핸들러를 거는 곳이 proxy 모듈 자기 설정이 아니라 core 모듈의 location 설정(clcf)이라는 점이 중요합니다. 요청이 이 location 에 오면 ngx_http_update_location_config() 가 r->content_handler = clcf->handler; 로 옮겨 두고(src/http/ngx_http_core_module.c 1431–1433행), CONTENT phase 의 checker(phase 마다 그 phase 의 핸들러를 부르는 틀 함수, 9편)는 그것을 부르고 돌려받은 값으로 ngx_http_finalize_request() 를 부릅니다(1309–1313행).
  2. location 이름이 / 로 끝나면 auto_redirect 를 켭니다(4399–4401행). location /plain/ 에 /plain 으로 오면 /plain/ 으로 301 을 돌려주는 동작이 여기서 옵니다.
  3. URL 에서 http:// 를 떼고 ngx_http_upstream_add() 로 upstream 하나를 만들거나 찾습니다(4462행). upstream {} 블록 없이 주소를 바로 적어도 nginx 안에서는 이름이 127.0.0.1 이고 서버가 하나뿐인 upstream 이 생깁니다. 같은 이름의 upstream {} 블록이 있으면 그것을 돌려줍니다(src/http/ngx_http_upstream.c 6859–6903행). 이렇게 블록 없이 생긴 것을 흔히 암묵적 upstream 이라고 부릅니다. 12편에서 보듯 이 판에서 기본으로 켜지는 upstream keepalive 는 이것에는 걸리지 않습니다.

URL 에 $ 변수가 있으면(proxy_pass http://$backend;) 4407행의 ngx_http_script_variables_count() 가 0 이 아니어서, upstream 을 미리 만들지 않고 URL 을 스크립트로 컴파일해 둔 뒤 요청마다 계산합니다(줄인 4409–4430행). 이 글은 변수가 없는 경우만 봅니다.

ngx_http_proxy_set_vars() 는 URL 에서 나중에 쓸 조각을 뽑아 둡니다. 포트가 기본값(80)이면 Host 로 보낼 값이 127.0.0.1, 아니면 127.0.0.1:9000 이고, URL 의 경로 부분(http://127.0.0.1:9000/v1/ 의 /v1/)은 vars.uri 에 들어갑니다(5446–5471행).

헷갈리기 쉬운 곳: proxy_pass 뒤에 경로를 붙이느냐 마느냐

proxy_pass http://127.0.0.1:9000; 과 proxy_pass http://127.0.0.1:9000/v1/; 은 끝에 경로가 있느냐만 다르지만, 백엔드로 가는 URI 가 다릅니다. 갈림길은 요청을 만드는 ngx_http_proxy_create_request() 의 요청 줄 부분(1393–1418행)에 있습니다.

정규식 location 이나 if 안에서는 "location 이름의 길이만큼 잘라 낸다"는 계산이 성립하지 않으므로, 그런 곳에서 경로가 붙은 proxy_pass 를 쓰면 설정을 읽을 때 "proxy_pass" cannot have URI part in location given by regular expression … 으로 거절합니다(같은 함수의 4475–4491행).

예제 1: 백엔드가 받은 요청 줄

받은 요청 줄과 헤더를 그대로 본문으로 돌려주는 python3 백엔드(포트 9000)를 띄우고, 세 location 으로 요청을 보냈습니다. 이 예제는 기준 커밋(9a7439fd79)을 그대로 빌드한 nginx 1.31.9 로 도커 컨테이너(Debian bookworm, --with-debug)에서 실행했습니다. 예제 폴더는 ex/u11-proxy-basics 입니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
# ... (앞부분 설정과 http { 는 줄이고, server {} 만 들여쓰기를 당겨 옮김)
server {
    listen 8080;

    # 1) URI 부분이 없는 proxy_pass — 클라이언트가 보낸 URI 를 그대로 넘긴다
    location /plain/ {
        proxy_pass http://127.0.0.1:9000;
    }

    # 2) URI 부분이 있는 proxy_pass — location 접두사 /api/ 를 /v1/ 로 바꿔 넘긴다
    location /api/ {
        proxy_pass http://127.0.0.1:9000/v1/;
    }

    # 3) proxy_set_header 로 헤더를 더하고 Host 를 바꾼다
    location /hdr/ {
        proxy_pass http://127.0.0.1:9000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
=== 1. proxy_pass without URI part: /plain/a?x=1 ===
request line: GET /plain/a?x=1 HTTP/1.1
  Host: 127.0.0.1:9000
  User-Agent: curl/7.88.1
  Accept: */*
=== 2. proxy_pass with URI part: /api/users?id=7 ===
request line: GET /v1/users?id=7 HTTP/1.1
  Host: 127.0.0.1:9000
  User-Agent: curl/7.88.1
  Accept: */*

/plain/a?x=1 은 그대로, /api/users?id=7 은 /v1/users?id=7 로 갔습니다. 두 경우 모두 요청 줄 끝이 HTTP/1.1 이고 Host 가 127.0.0.1:9000 입니다. 이 둘이 어디서 오는지는 4절에서 봅니다(3번 location 의 결과도 거기서).

예제 1의 두 location 에서 백엔드로 가는 URI 가 만들어지는 길. 왼쪽 location /plain/ 의 proxy_pass http://127.0.0.1:9000; 에는 경로가 없어 vars.uri 가 비었으므로, 클라이언트가 보낸 GET /plain/a?x=1 의 r->unparsed_uri 를 해석 전 모양 그대로 붙여 백엔드는 GET /plain/a?x=1 HTTP/1.1 을 받는다. 오른쪽 location /api/ 의 proxy_pass http://127.0.0.1:9000/v1/; 에는 경로가 있어, 클라이언트가 보낸 GET /api/users?id=7 에서 location 이름 /api/ 의 길이만큼 잘라 내고 그 자리에 vars.uri /v1/ 을 붙인 뒤 나머지 users 와 ?id=7 을 이어, 백엔드는 GET /v1/users?id=7 HTTP/1.1 을 받는다. proxy_pass 뒤 경로가 있느냐 없느냐의 차이. 초록 칸은 위 예제 1 출력의 요청 줄입니다(ngx_http_proxy_module.c 1393–1418행).

2. 일을 나누는 방식 — ngx_http_upstream_t 와 콜백

proxy_pass 말고도 fastcgi_pass, uwsgi_pass, scgi_pass, grpc_pass, memcached_pass 가 모두 "백엔드에 연결해 요청을 보내고 응답을 받아 온다"는 같은 일을 합니다. 다른 것은 요청을 어떤 바이트로 만드느냐와 응답의 머리를 어떻게 읽느냐뿐입니다. nginx 는 같은 부분을 ngx_http_upstream.c 에 한 번 쓰고, 다른 부분만 모듈이 함수 포인터로 꽂게 했습니다.

proxy_pass 디렉티브가 location 의 handler 를 ngx_http_proxy_handler 로 정하고, 이 핸들러가 요청마다 ngx_http_upstream_t 의 콜백을 채운다. create_request 에는 ngx_http_proxy_create_request, process_header 에는 처음에 ngx_http_proxy_process_status_line 이 들어가고 상태 줄을 읽은 뒤 ngx_http_proxy_process_header 로 바뀐다. 본문 복사 필터, finalize_request·abort_request·reinit_request 도 채운다. ngx_http_upstream.c 는 연결·보내기·읽기·재시도·끝내기를 맡아 알맞은 때에 이 콜백을 부른다. fastcgi, uwsgi, scgi, grpc, memcached, proxy_v2, tunnel 모듈도 같은 틀을 자기 함수로 채운다. 노란 상자가 요청마다 하나 생기는 구조체, 파란 상자가 proxy 모듈이 끼워 넣는 함수입니다.

ngx_http_upstream_s — 필요한 필드만

struct ngx_http_upstream_s {
    ngx_http_upstream_handler_pt     read_event_handler;
    ngx_http_upstream_handler_pt     write_event_handler;

    ngx_peer_connection_t            peer;

    ngx_event_pipe_t                *pipe;

    ngx_chain_t                     *request_bufs;

    ngx_output_chain_ctx_t           output;
    ngx_chain_writer_ctx_t           writer;

    ngx_http_upstream_conf_t        *conf;
    ngx_http_upstream_srv_conf_t    *upstream;
    /* ... */
    ngx_int_t                      (*input_filter_init)(void *data);
    ngx_int_t                      (*input_filter)(void *data, ssize_t bytes);
    void                            *input_filter_ctx;

    /* ... */
    ngx_int_t                      (*create_request)(ngx_http_request_t *r);
    ngx_int_t                      (*reinit_request)(ngx_http_request_t *r);
    ngx_int_t                      (*process_header)(ngx_http_request_t *r);
    void                           (*abort_request)(ngx_http_request_t *r);
    void                           (*finalize_request)(ngx_http_request_t *r,
                                         ngx_int_t rc);
    /* ... */
    unsigned                         buffering:1;
    unsigned                         keepalive:1;
    unsigned                         upgrade:1;
    unsigned                         error:1;

    unsigned                         request_sent:1;
    unsigned                         request_body_sent:1;
    unsigned                         request_body_blocked:1;
    unsigned                         header_sent:1;
    unsigned                         response_received:1;
};

src/http/ngx_http_upstream.h 342–426행

세 무리로 읽으면 됩니다.

proxy 모듈이 채우는 곳 — ngx_http_proxy_handler()

1절에서 건 콘텐츠 핸들러입니다. 요청마다 불립니다.

    if (ngx_http_upstream_create(r) != NGX_OK) {
        return NGX_HTTP_INTERNAL_SERVER_ERROR;
    }
    /* ... */
    u->create_request = ngx_http_proxy_create_request;
    u->reinit_request = ngx_http_proxy_reinit_request;
    u->process_header = ngx_http_proxy_process_status_line;
    u->abort_request = ngx_http_proxy_abort_request;
    u->finalize_request = ngx_http_proxy_finalize_request;
    /* ... */
    u->buffering = plcf->upstream.buffering;

    u->pipe = ngx_pcalloc(r->pool, sizeof(ngx_event_pipe_t));
    if (u->pipe == NULL) {
        return NGX_HTTP_INTERNAL_SERVER_ERROR;
    }

    u->pipe->input_filter = ngx_http_proxy_copy_filter;
    u->pipe->input_ctx = r;

    u->input_filter_init = ngx_http_proxy_input_filter_init;
    u->input_filter = ngx_http_proxy_non_buffered_copy_filter;
    u->input_filter_ctx = r;
    /* ... */
    rc = ngx_http_read_client_request_body(r, ngx_http_upstream_init);

    if (rc >= NGX_HTTP_SPECIAL_RESPONSE) {
        return rc;
    }

    return NGX_DONE;

src/http/modules/ngx_http_proxy_module.c 894–976행

  1. ngx_http_upstream_create() 가 요청 풀에서 ngx_http_upstream_t 를 하나 할당해 r->upstream 에 답니다(516–545행). 요청 풀에서 나오므로 요청이 끝나면 함께 사라집니다.
  2. 콜백 다섯 개를 채웁니다(931–935행). process_header 에는 상태 줄 파서가 먼저 들어갑니다.
  3. event pipe 를 하나 할당하고, 버퍼링할 때와 하지 않을 때 쓸 본문 필터를 따로 겁니다(948–958행).
  4. 마지막으로 ngx_http_read_client_request_body(r, ngx_http_upstream_init) 를 부릅니다. 이름 그대로 클라이언트의 요청 본문을 먼저 다 읽고, 다 읽으면 두 번째 인자인 ngx_http_upstream_init 을 불러 달라는 뜻입니다. GET 처럼 본문이 없으면 곧바로 부릅니다. 기본값 proxy_request_buffering on 이면 본문을 메모리나 임시 파일에 다 받아 둔 뒤에 백엔드 연결을 시작합니다.

핸들러는 NGX_DONE 을 돌려줍니다. "아직 끝나지 않았지만 내가 알아서 끝내겠다"는 뜻입니다. 요청이 바로 해제되지 않는 것은 ngx_http_read_client_request_body() 가 첫 줄에서 요청의 참조 수 r->main->count 를 하나 올려 두기 때문입니다(src/http/ngx_http_request_body.c 43행). 이 뒤로 요청은 백엔드 소켓의 이벤트에 끌려 움직입니다.

상태 줄을 읽으면 process_header 가 바뀐다

process_header 에 처음 들어가는 ngx_http_proxy_process_status_line() 은 HTTP/1.1 200 OK 한 줄을 읽고 나면 자기 자리를 헤더 파서로 바꿉니다.

    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;
    }

    u->process_header = ngx_http_proxy_process_header;

    return ngx_http_proxy_process_header(r);

src/http/modules/ngx_http_proxy_module.c 1874–1887행

upstream 쪽은 응답 머리를 읽을 때마다 u->process_header(r) 만 부르면 되고, 지금 상태 줄 차례인지 헤더 차례인지는 proxy 모듈이 함수 포인터를 바꿔 기억합니다. 8편에서 클라이언트 요청을 읽을 때 ngx_http_process_request_line 이 끝나면 핸들러를 ngx_http_process_request_headers 로 바꾸던 것과 같은 수법입니다. 백엔드가 HTTP/1.0 으로 답하면 여기서 connection_close 를 켜 두어, 그 연결은 다시 쓰지 않습니다.

같은 틀을 쓰는 모듈들

ngx_http_upstream_create(r) 를 부르는 파일을 찾으면 이 커밋에서 8개입니다.

모듈 디렉티브 백엔드 프로토콜
ngx_http_proxy_module.c proxy_pass HTTP/1.0, 1.1
ngx_http_proxy_v2_module.c proxy_http_version 2 일 때 HTTP/2
ngx_http_fastcgi_module.c fastcgi_pass FastCGI (PHP-FPM 등)
ngx_http_uwsgi_module.c uwsgi_pass uwsgi
ngx_http_scgi_module.c scgi_pass SCGI
ngx_http_grpc_module.c grpc_pass gRPC (HTTP/2)
ngx_http_memcached_module.c memcached_pass memcached
ngx_http_tunnel_module.c tunnel_pass (1.31.x 에서 들어옴) (이 글에서 보지 않음)

memcached 를 뺀 일곱 모듈이 ngx_http_read_client_request_body(r, ngx_http_upstream_init) 로 upstream 을 시작합니다(memcached 는 요청 본문을 ngx_http_discard_request_body() 로 버리고 ngx_http_upstream_init() 을 바로 부릅니다). ngx_http_proxy_handler() 맨 앞의 888–892행은 proxy_http_version 2 이면 아예 ngx_http_proxy_v2_handler() 로 넘깁니다(HTTP/2 백엔드는 1.30.x 에도 있습니다). ngx_http_tunnel_module.c 는 1.30.6 에는 없는 파일로, 이 글에서는 다루지 않습니다.

헷갈리기 쉬운 곳: abort_request 는 아무도 부르지 않습니다

모든 upstream 모듈이 u->abort_request 를 채우지만, 이 커밋의 src/ 전체에서 이 포인터를 부르는 곳은 없습니다(grep -rn 'abort_request' src 로 보면 구조체 선언과 대입문, 각 모듈의 함수 정의뿐입니다). proxy 의 것도 디버그 로그 한 줄을 찍고 돌아오는 빈 함수입니다(2722–2728행). 실제로 끝낼 때 불리는 것은 finalize_request 이고, proxy 에서는 이것도 로그만 찍습니다. 정리는 대부분 upstream 쪽이 합니다(6절).

3. 한 요청의 여정 — init, connect, send, process_header

요청 하나의 순서. 클라이언트가 GET /plain/one 을 보내면 워커의 content phase 가 ngx_http_proxy_handler 를 부르고, proxy 모듈은 콜백을 채운 뒤 ngx_http_upstream_init 을 부른다. upstream 은 create_request 로 요청 버퍼를 만들게 하고 ngx_event_connect_peer 로 논블로킹 connect 를 건다. NGX_AGAIN 이면 connect_timeout 타이머만 걸고 이벤트 루프로 돌아간다. 연결이 끝나 쓰기 이벤트가 오면 ngx_http_upstream_handler 를 거쳐 send_request 로 요청을 보내고 read_timeout 타이머를 건다. 응답이 와서 읽기 이벤트가 오면 ngx_http_upstream_handler 를 거쳐 ngx_http_upstream_process_header 가 proxy 의 process_header 로 상태 줄과 헤더를 파싱하고, ngx_http_upstream_send_response 가 클라이언트에 헤더와 본문을 흘려보내고, ngx_http_upstream_finalize_request 가 연결을 닫거나 캐시에 반납한다. 점선 화살표가 이벤트 루프가 깨워 주는 자리입니다. 그 사이마다 워커는 다른 연결을 돌립니다.

ngx_http_upstream_init_request() — 요청을 만들고, 서버를 고를 준비

ngx_http_upstream_init() 은 클라이언트 연결의 읽기 타이머를 지우고 ngx_http_upstream_init_request() 를 부릅니다(549–589행). 거기서 이 글에 필요한 줄은 이렇습니다.

    if (r->request_body) {
        u->request_bufs = r->request_body->bufs;
    }

    if (u->create_request(r) != NGX_OK) {
        ngx_http_finalize_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
        return;
    }
    /* ... */
    if (uscf->peer.init(r, uscf) != NGX_OK) {
        ngx_http_upstream_finalize_request(r, u,
                                           NGX_HTTP_INTERNAL_SERVER_ERROR);
        return;
    }

    u->peer.start_time = ngx_current_msec;

    if (u->conf->next_upstream_tries
        && u->peer.tries > u->conf->next_upstream_tries)
    {
        u->peer.tries = u->conf->next_upstream_tries;
    }

    ngx_http_upstream_connect(r, u);

src/http/ngx_http_upstream.c 671–868행

줄인 679–853행에는 proxy_bind, 소켓 옵션, 출력 사슬 준비, 그리고 proxy_pass http://$host 처럼 요청 때 주소를 정하는 경우의 DNS 조회(resolver)가 있습니다.

ngx_event_connect_peer() — 논블로킹 connect

src/event/ 에 있는 함수입니다. HTTP 만의 것이 아니라 stream·mail 모듈도 같은 함수로 백엔드에 연결합니다.

    rc = pc->get(pc, pc->data);
    if (rc != NGX_OK) {
        return rc;
    }
    /* ... */
    s = ngx_socket(pc->sockaddr->sa_family, type, 0);
    /* ... */
    c = ngx_get_connection(s, pc->log);
    /* ... */
    if (ngx_nonblocking(s) == -1) {
        ngx_log_error(NGX_LOG_ALERT, pc->log, ngx_socket_errno,
                      ngx_nonblocking_n " failed");

        goto failed;
    }

src/event/ngx_event_connect.c 34–105행

먼저 pc->get() 으로 서버를 고르고(34행), 소켓을 만들어 worker_connections 만큼 미리 만들어 둔 빈 연결 구조체 하나에 담고(7편), 논블로킹으로 바꿉니다. 백엔드 연결도 클라이언트 연결과 같은 연결 배열에서 꺼내므로 worker_connections 를 함께 씁니다. 프록시로 쓰는 워커는 요청 하나에 연결 두 개를 쓰는 셈입니다.

    rc = connect(s, pc->sockaddr, pc->socklen);

    if (rc == -1) {
        err = ngx_socket_errno;


        /* ... */

    if (ngx_add_conn) {
        if (rc == -1) {

            /* NGX_EINPROGRESS */

            return NGX_AGAIN;
        }

        ngx_log_debug0(NGX_LOG_DEBUG_EVENT, pc->log, 0, "connected");

        wev->ready = 1;

        return NGX_OK;
    }

src/event/ngx_event_connect.c 221–277행

ngx_add_conn 은 epoll 에서 연결의 읽기·쓰기를 한꺼번에 엣지 트리거로 등록하는 함수입니다(epoll 모듈이 이 포인터를 채웁니다. 등록 자체는 connect() 앞의 211–215행에서 이미 했습니다). 그 뒤 connect() 의 결과가 셋으로 갈립니다.

connect() 결과 돌려주는 값 upstream 이 하는 일
바로 성공 NGX_OK 곧바로 요청 보내기
-1, EINPROGRESS NGX_AGAIN (-2) connect_timeout 타이머만 걸고 돌아감
-1, ECONNREFUSED 등 NGX_DECLINED ngx_http_upstream_next() 로 실패 처리 (5절)

pc->get() 이 NGX_OK 가 아닌 값을 돌려주면 소켓을 만들기도 전에 그 값을 그대로 돌려줍니다(35–37행). 살아 있는 서버가 없으면 NGX_BUSY, 12편의 keepalive 모듈이 캐시에 있던 연결을 꺼내 주면 NGX_DONE 입니다.

헷갈리기 쉬운 곳: 같은 컴퓨터의 꺼진 포트도 "진행 중"으로 시작합니다

표의 세 번째 줄만 보면, 아무도 듣지 않는 127.0.0.1:9000 에 연결하면 connect() 가 바로 ECONNREFUSED 를 돌려줄 것 같습니다. 예제 1의 백엔드를 끈 채 요청을 보내고 디버그 로그를 보면 그렇지 않았습니다(ex/u11-proxy-basics 의 7번).

=== 7. backend stopped: where is 'Connection refused' noticed? ===
502
[debug] 157#157: *1 connect to 127.0.0.1:9000, fd:10 #2
[debug] 157#157: *1 http upstream connect: -2
[error] 157#157: *1 connect() failed (111: Connection refused) while connecting to upstream
[debug] 157#157: *1 http next upstream, 2

connect: -2, 곧 EINPROGRESS 로 시작했고, 거절은 이벤트 루프가 다시 깨워 준 뒤에 알았습니다(pid 와 연결 번호는 실행 환경에 따라 달라집니다). 그때 거절을 알아채는 것이 ngx_http_upstream_test_connect() 의 getsockopt(SO_ERROR) 입니다(3073–3083행). 요청을 보내기 전에(2185–2188행) 또는 응답을 읽기 전에(2485–2488행) 이것을 불러, 오류가 있으면 같은 문구 connect() failed 를 남기고 NGX_HTTP_UPSTREAM_FT_ERROR(값 2 — 로그의 http next upstream, 2)로 5절의 ngx_http_upstream_next() 를 부릅니다. 같은 문구가 두 곳에서 찍히므로, 어느 쪽인지는 바로 앞 줄의 http upstream connect: 값으로 가립니다.

ngx_http_upstream_connect() — 핸들러를 걸고 돌아간다

    c = u->peer.connection;

    c->requests++;

    c->data = r;

    c->write->handler = ngx_http_upstream_handler;
    c->read->handler = ngx_http_upstream_handler;

    u->write_event_handler = ngx_http_upstream_send_request_handler;
    u->read_event_handler = ngx_http_upstream_process_header;
    /* ... */
    if (rc == NGX_AGAIN) {
        ngx_add_timer(c->write, u->conf->connect_timeout);
        return;
    }
    /* ... */
    ngx_http_upstream_send_request(r, u, 1);

src/http/ngx_http_upstream.c 1652–1750행

백엔드 연결의 읽기·쓰기 이벤트 핸들러를 둘 다 ngx_http_upstream_handler 로 걸고(1658–1659행), 진짜 할 일은 u->write_event_handler·u->read_event_handler 에 따로 적어 둡니다(1661–1662행). NGX_AGAIN 이면 쓰기 이벤트에 connect_timeout 타이머를 걸고 그냥 돌아갑니다(1736–1739행). 워커는 이벤트 루프로 돌아가 다른 연결을 돌리고, 연결이 끝나면 epoll 이 쓰기 이벤트를 알려 줍니다.

ngx_http_upstream_handler() 는 이벤트의 방향을 보고 둘 중 하나를 부르는 배전반입니다.

    if (ev->write) {
        u->write_event_handler(r, u);

    } else {
        u->read_event_handler(r, u);
    }

src/http/ngx_http_upstream.c 1346–1351행

이벤트 핸들러는 한 번 걸어 두고, 단계가 바뀔 때는 u->…_event_handler 만 바꿉니다. 그 변화가 이 글 전체의 상태 기계입니다.

upstream 핸들러가 바뀌는 상태도. ngx_http_upstream_connect 에서 시작해 연결 중·보내는 중 상태(write = send_request_handler, read = process_header)가 된다. 요청을 다 보내면 응답 헤더 기다리는 중(write = dummy_handler, read = process_header)이 된다. 헤더가 끝나면 버퍼링 on 이면 read = process_upstream, r->write = process_downstream 이 되고, 버퍼링 off 면 read = process_non_buffered_upstream, r->write = process_non_buffered_downstream 이 된다. 연결 중에 거절·시간 초과, 기다리는 중에 시간 초과·잘못된 헤더가 생기면 ngx_http_upstream_next 로 가서 연결을 닫고, 시도할 서버가 남으면 다시 연결 중으로, 남지 않으면 502·504 로 ngx_http_upstream_finalize_request 로 간다. 본문이 끝나도 finalize 로 간다. 상자 안의 이름은 모두 ngx_http_upstream_ 을 뗀 함수 이름입니다. r->write 는 클라이언트 쪽 r->write_event_handler 입니다.

보내고, 읽기 타이머를 건다

쓰기 이벤트가 오면 ngx_http_upstream_send_request_handler() → ngx_http_upstream_send_request() 입니다. 처음 보낼 때는 ngx_http_upstream_test_connect() 로 connect 가 정말 성공했는지(SO_ERROR) 확인하고(2185–2188행), 요청 사슬을 씁니다. 다 썼으면 이렇게 끝납니다.

    if (!u->request_body_sent) {
        u->request_body_sent = 1;

        if (u->header_sent) {
            return;
        }

        if (u->conf->ignore_input) {
            ngx_http_upstream_process_header(r, u);
            return;
        }

        ngx_add_timer(c->read, u->conf->read_timeout);

        if (c->read->ready) {
            ngx_http_upstream_process_header(r, u);
            return;
        }
    }

src/http/ngx_http_upstream.c 2265–2283행

이제부터는 읽기 차례이므로 proxy_read_timeout 타이머를 겁니다(2277행). 다 보내지 못했으면(NGX_AGAIN) proxy_send_timeout 타이머를 걸고 다음 쓰기 이벤트를 기다립니다(2204–2235행). 세 타임아웃은 모두 ngx_http_proxy_merge_loc_conf() 에서 기본 60초입니다(3731–3738행).

응답 머리 읽기 — ngx_http_upstream_process_header()

    for ( ;; ) {

        n = c->recv(c, u->buffer.last, u->buffer.end - u->buffer.last);

        /* ... */

        if (n == 0) {
            ngx_log_error(NGX_LOG_ERR, c->log, 0,
                          "upstream prematurely closed connection");
        }

        if (n == NGX_ERROR || n == 0) {
            ngx_http_upstream_next(r, u, NGX_HTTP_UPSTREAM_FT_ERROR);
            return;
        }

        /* ... */
again:

        rc = u->process_header(r);

        if (rc == NGX_AGAIN) {

            if (u->buffer.last == u->buffer.end) {
                ngx_log_error(NGX_LOG_ERR, c->log, 0,
                              "upstream sent too big header");

                ngx_http_upstream_next(r, u,
                                       NGX_HTTP_UPSTREAM_FT_INVALID_HEADER);
                return;
            }

            continue;
        }

src/http/ngx_http_upstream.c 2537–2602행

처음 불릴 때 proxy_buffer_size(기본 한 페이지, 보통 4KB) 크기 버퍼 하나를 요청 풀에서 받고(2490–2501행), 읽을 수 있는 만큼 읽어 u->process_header(r) 에 넘깁니다. 모자라면(NGX_AGAIN) 다음 읽기 이벤트를 기다리고, 버퍼가 가득 찼는데도 헤더가 끝나지 않으면 upstream sent too big header 로 실패합니다. 응답 헤더 전체가 proxy_buffer_size 하나에 들어가야 한다는 뜻입니다.

헤더를 다 읽으면 상태 코드가 300 이상일 때 재시도(ngx_http_upstream_test_next())나 proxy_intercept_errors 를 먼저 보고(2632–2641행), ngx_http_upstream_process_headers() 가 백엔드의 헤더를 클라이언트로 보낼 r->headers_out 에 옮긴 뒤, ngx_http_upstream_send_response() 로 넘어갑니다(6절).

예제 2: 디버그 로그와 gdb 로 본 순서

같은 ex/u11-proxy-basics 에서 /plain/one 요청 하나의 디버그 로그 가운데 upstream 줄만 골랐습니다(앞의 시각과 pid 는 지웠습니다).

*11 http init upstream, client timer: 0
*11 http proxy header: "User-Agent: curl/7.88.1"
*11 http proxy header: "Accept: */*"
*11 http proxy header:
*11 connect to 127.0.0.1:9000, fd:10 #12
*11 http upstream connect: -2
*11 http upstream request: "/plain/one?"
*11 http upstream send request handler
*11 http upstream send request
*11 http upstream send request body
*11 http upstream request: "/plain/one?"
*11 http upstream process header
*11 http proxy status 200 "200 OK"
*11 http proxy header: "Server: BaseHTTP/0.6 Python/3.11.2"
*11 http proxy header: "Date: Sat, 07 Nov 2026 03:05:06 GMT"
*11 http proxy header: "Content-Type: text/plain"
*11 http proxy header: "Content-Length: 101"
*11 http proxy header: "X-Backend: echo"
*11 http proxy header done
*11 http upstream exit: 0000000000000000
*11 finalize http upstream request: 0
*11 close http upstream connection: 10

날짜는 실행마다 다르고, 연결 번호(*11, #12)와 fd 는 그 전에 받은 연결 수와 열린 파일에 따라 정해집니다(이 스크립트를 다시 돌렸을 때는 같게 나왔습니다). 위에서 본 함수들이 차례로 보입니다.

같은 요청을 master_process off; daemon off; 로 프로세스 하나에서 돌리며 gdb 로 세 함수에 멈춰 호출 스택을 찍었습니다(gdb 가 주소 무작위화(ASLR)를 끄고 띄우므로 주소는 다시 돌려도 같게 나왔지만, 빌드가 다르면 달라집니다. 인자 목록은 지웠고, #8~#11(ngx_http_handler 에서 ngx_http_process_request_line 까지)은 ... 으로 줄였습니다).

Breakpoint 2, ngx_event_connect_peer at src/event/ngx_event_connect.c:34
#0  ngx_event_connect_peer at src/event/ngx_event_connect.c:34
#1  0x0000555555618faa in ngx_http_upstream_connect at src/http/ngx_http_upstream.c:1606
#2  0x0000555555617771 in ngx_http_upstream_init_request at src/http/ngx_http_upstream.c:868
#3  0x0000555555616a9f in ngx_http_upstream_init at src/http/ngx_http_upstream.c:588
#4  0x00005555556099d6 in ngx_http_read_client_request_body at src/http/ngx_http_request_body.c:84
#5  0x000055555567500c in ngx_http_proxy_handler at src/http/modules/ngx_http_proxy_module.c:970
#6  0x00005555555ed617 in ngx_http_core_content_phase at src/http/ngx_http_core_module.c:1311
#7  0x00005555555ec448 in ngx_http_core_run_phases at src/http/ngx_http_core_module.c:906
...
#12 0x00005555555f9765 in ngx_http_wait_request_handler at src/http/ngx_http_request.c:534
#13 0x00005555555cea51 in ngx_epoll_process_events at src/event/modules/ngx_epoll_module.c:901
#14 0x00005555555bb0b9 in ngx_process_events_and_timers at src/event/ngx_event.c:248
#15 0x00005555555ca62b in ngx_single_process_cycle at src/os/unix/ngx_process_cycle.c:322
#16 0x000055555557f1b2 in main at src/core/nginx.c:400
Breakpoint 3, ngx_http_proxy_process_status_line at src/http/modules/ngx_http_proxy_module.c:1812
#0  ngx_http_proxy_process_status_line at src/http/modules/ngx_http_proxy_module.c:1812
#1  0x000055555561b4bc in ngx_http_upstream_process_header at src/http/ngx_http_upstream.c:2588
#2  0x00005555556188f7 in ngx_http_upstream_handler at src/http/ngx_http_upstream.c:1350
#3  0x00005555555cea51 in ngx_epoll_process_events at src/event/modules/ngx_epoll_module.c:901
#4  0x00005555555bb0b9 in ngx_process_events_and_timers at src/event/ngx_event.c:248
#5  0x00005555555ca62b in ngx_single_process_cycle at src/os/unix/ngx_process_cycle.c:322
#6  0x000055555557f1b2 in main at src/core/nginx.c:400

(첫 번째 중단점 ngx_http_proxy_create_request 의 스택은 #1 ngx_http_upstream_init_request … :675 아래가 위 connect 의 #2 이하와 같아서 뺐습니다.) 두 스택을 나란히 보면 이 절의 요점이 보입니다.

4. 백엔드로 나가는 요청 — ngx_http_proxy_create_request()

두 번 훑는다: 길이를 세고, 복사한다

ngx_http_proxy_create_request() 는 400줄이 넘지만 모양은 단순합니다. 같은 재료를 두 번 훑습니다. 처음에는 길이만 더하고(1251–1370행), 그 길이로 버퍼를 딱 한 번 할당한 뒤(ngx_create_temp_buf(r->pool, len), 1373행), 같은 순서로 다시 훑으며 복사합니다(1386–1543행). 버퍼를 늘려 가며 다시 할당하는 일이 없고, 할당은 요청 풀에서 하므로 해제도 따로 하지 않습니다.

백엔드로 보낼 요청 버퍼를 만드는 과정. 재료는 메서드(r->method_name), URI(proxy_pass 의 URI 부분, location 뒤 나머지, ?args), Host 값(proxy_set_header Host, 없으면 $proxy_host), proxy_set_header 값과 기본 표(761–772행, 빈 값이면 빠짐), 클라이언트 헤더(위 표에 이름이 없는 것만)다. 1차로 길이만 더하고, ngx_create_temp_buf 로 버퍼 하나를 만든 뒤, 2차로 같은 순서로 복사해 GET /plain/one HTTP/1.1, Host, User-Agent·Accept, 빈 줄을 쓴다. 요청 본문 체인은 ngx_buf_t 구조체만 복사해 cl->next 로 뒤에 잇고, 본문 데이터는 복사하지 않는다. 노란 상자가 메모리입니다. 본문 데이터는 복사하지 않고, 이미 읽어 둔 버퍼마다 ngx_buf_t 구조체를 복사한 것과 사슬 고리만 새로 만들어 잇습니다(1576–1598행).

기본 헤더 표 — 무엇을 지우고 무엇을 바꾸나

재료 가운데 "기본 표"가 이것입니다.

static char  ngx_http_proxy_version[] = " HTTP/1.0" CRLF;
static char  ngx_http_proxy_version_11[] = " HTTP/1.1" CRLF;


static ngx_keyval_t  ngx_http_proxy_headers[] = {
    { ngx_string("Host"), ngx_string("") },
    { ngx_string("Connection"), ngx_string("") },
    { ngx_string("Proxy-Connection"), ngx_string("") },
    { ngx_string("Content-Length"), ngx_string("$proxy_internal_body_length") },
    { ngx_string("Transfer-Encoding"), ngx_string("$proxy_internal_chunked") },
    { ngx_string("TE"), ngx_string("") },
    { ngx_string("Keep-Alive"), ngx_string("") },
    { ngx_string("Expect"), ngx_string("") },
    { ngx_string("Upgrade"), ngx_string("") },
    { ngx_null_string, ngx_null_string }
};

src/http/modules/ngx_http_proxy_module.c 757–772행

proxy_set_header 로 적은 값과 이 표가 합쳐집니다(같은 이름이면 설정이 이깁니다, ngx_http_proxy_init_headers() 4266–4287행). 그리고 이 표에 이름이 있는 클라이언트 헤더는 그대로 옮기지 않습니다(1361–1365행의 ngx_hash_find → continue). 값이 빈 문자열이면 그 헤더는 아예 쓰지 않습니다(1335–1337행). 그래서

요청 줄의 버전도 같은 함수에서 정해집니다.

    if (plcf->host_value == NULL
| (host.len == 0 && plcf->http_version == NGX_HTTP_VERSION_11))
    {
        host = ctx->vars.host_header;
    }
    /* ... */
    if (plcf->http_version == NGX_HTTP_VERSION_11) {
        b->last = ngx_cpymem(b->last, ngx_http_proxy_version_11,
                             sizeof(ngx_http_proxy_version_11) - 1);

    } else {
        b->last = ngx_cpymem(b->last, ngx_http_proxy_version,
                             sizeof(ngx_http_proxy_version) - 1);
    }

src/http/modules/ngx_http_proxy_module.c 1239–1429행

이 판에서 바뀐 기본값 — HTTP/1.1, 그리고 Connection 없음

proxy_http_version 의 기본값은 ngx_http_proxy_merge_loc_conf() 에서 NGX_HTTP_VERSION_11 입니다(4071–4072행). 오랫동안 nginx 는 백엔드에 HTTP/1.0 과 Connection: close 를 보냈고, 그래서 "upstream keepalive 를 쓰려면 proxy_http_version 1.1; 과 proxy_set_header Connection ""; 을 꼭 적어라"는 설명이 널리 퍼져 있습니다. 이 판에서는 둘 다 기본입니다. git log -S 로 따라가 보면 이렇게 바뀌었습니다.

커밋 날짜 (작성) 바뀐 것 처음 들어간 태그
90a4fc793 2025-07-15 "Proxy: refactored for HTTP/2 support." — 기본 표의 Connection: close 를 $proxy_internal_connection(HTTP/1.0 일 때만 close)으로 1.29.4
6bb27a631 2026-01-27 "Proxy: enabled HTTP/1.1 by default for upstream connections." — 기본 버전을 1.1 로, Connection 을 빈 값(보내지 않음)으로 1.29.7, stable 1.30.0
dea68dbf1 2026-07-21 "Upstream: special handling of the "Host" header in proxy and gRPC" — Host 를 표에서 빼 따로 처리, 빈 값이면 $proxy_host 1.31.4 (1.30.x 에는 없음)

stable 1.30.6 의 기본 표는 Host 가 $proxy_internal_host 이고 Connection 이 빈 값입니다. 곧 HTTP/1.1 과 Connection 을 보내지 않는 것은 1.30.x 에도 있고, Host 를 따로 다루는 방식만 1.31.x 의 것입니다.

헷갈리기 쉬운 곳: HTTP/1.0 으로 바꿔도 Connection: close 는 보내지 않습니다

proxy_http_version 1.0; 을 적으면 요청 줄은 1427–1428행의 " HTTP/1.0" CRLF 가 되지만, 기본 표의 Connection 은 여전히 빈 값이므로 Connection 헤더는 붙지 않습니다(6bb27a631 에서 $proxy_internal_connection 변수가 통째로 지워졌습니다). HTTP/1.0 은 헤더가 없으면 연결을 닫는 것이 기본이라 결과는 같습니다. 이 동작은 소스로만 확인했고, 예제로 돌려 보지는 않았습니다.

예제 1 이어서: 지워진 헤더, 바뀐 Host, 숨긴 Server

3번 location 에 클라이언트가 Host: shop.example, Connection: keep-alive, Keep-Alive: timeout=5 를 보냈습니다.

=== 3. proxy_set_header Host $host + X-Real-IP, client sends Connection: keep-alive ===
request line: GET /hdr/ HTTP/1.1
  Host: shop.example
  X-Real-IP: 127.0.0.1
  User-Agent: curl/7.88.1
  Accept: */*
=== 4. response headers seen by the client ===
HTTP/1.1 200 OK
Server: nginx/1.31.9
Date: Sat, 07 Nov 2026 03:05:06 GMT
Content-Type: text/plain
Content-Length: 98
Connection: keep-alive
X-Backend: echo

Date 값은 실행마다 다릅니다.

5. 실패와 재시도 — ngx_http_upstream_next()

3절의 코드 곳곳에 ngx_http_upstream_next(r, u, NGX_HTTP_UPSTREAM_FT_…) 가 있었습니다. connect 가 거절됐을 때(1645–1648행), 쓰기·읽기 타이머가 터졌을 때(2418–2421행, 2480–2483행), 응답 헤더가 잘못됐을 때(2617–2620행)입니다. FT 는 failure type 의 줄임으로, 실패의 종류를 비트 하나씩으로 나타냅니다(ngx_http_upstream.h 20–35행). proxy_next_upstream error timeout http_502; 처럼 적는 값이 바로 이 비트들의 OR 이고, 기본값은 error timeout 입니다(3849–3853행).

    switch (ft_type) {

    case NGX_HTTP_UPSTREAM_FT_TIMEOUT:
    case NGX_HTTP_UPSTREAM_FT_HTTP_504:
        status = NGX_HTTP_GATEWAY_TIME_OUT;
        break;

    /* ... */
    default:
        status = NGX_HTTP_BAD_GATEWAY;
    }

    /* ... */
    timeout = u->conf->next_upstream_timeout;

    if (u->request_sent
        && (r->method & (NGX_HTTP_POST|NGX_HTTP_LOCK|NGX_HTTP_PATCH)))
    {
        ft_type |= NGX_HTTP_UPSTREAM_FT_NON_IDEMPOTENT;
    }

    if (u->peer.tries == 0
| ((u->conf->next_upstream & ft_type) != ft_type)
| (u->request_sent && r->request_body_no_buffering)
| (timeout && ngx_current_msec - u->peer.start_time >= timeout))

src/http/ngx_http_upstream.c 4655–4710행

ngx_http_upstream_next 의 판단 순서. 실패 종류 ft_type 이 들어오면 u->peer.free 를 NGX_PEER_FAILED 로 불러 그 서버의 실패를 세고, ft_type 을 상태 코드로 바꾼다(TIMEOUT 은 504, 기본은 502). 그다음 tries 가 0 인가, proxy_next_upstream 에 그 종류가 없는가, 요청을 이미 보냈고 POST·LOCK·PATCH 인데 non_idempotent 가 없거나 본문을 버퍼링하지 않았는가, next_upstream_timeout 이 지났는가를 차례로 본다. 하나라도 예면 finalize 로 가서 클라이언트에 502·504 를 보낸다. 모두 아니오면 연결을 닫고 ngx_http_upstream_connect 를 다시 부른다. 네 질문은 4707–4710행의 if 하나를 풀어 쓴 것입니다. 세 번째 질문의 POST 부분은 4701–4705행이 ft_type 에 NON_IDEMPOTENT 비트를 더해 두 번째 질문의 비트 검사에 걸리게 하는 방식입니다.

순서가 중요합니다.

  1. 먼저 그 서버의 실패를 셉니다(4622–4643행, u->peer.free(..., NGX_PEER_FAILED)). 다시 시도하든 안 하든 셉니다. 403·404 만 실패로 세지 않습니다(NGX_PEER_NEXT). 몇 번 실패하면 그 서버를 잠시 빼는지는 라운드 로빈의 max_fails·fail_timeout 이고 12편에서 봅니다.
  2. 상태 코드를 정합니다. 시간 초과는 504, 거절·잘못된 헤더 같은 나머지는 502 입니다(http_500·http_503·http_403·http_404·http_429 로 넘어온 것은 그 코드를 그대로 씁니다 — 위 발췌에서 줄인 4662–4686행). 클라이언트가 받는 "502 Bad Gateway"와 "504 Gateway Time-out"이 여기서 나옵니다.
  3. 다시 시도할 수 있는지 봅니다. 시도할 서버가 남았고(tries), 그 실패 종류가 proxy_next_upstream 에 있고, 이미 보낸 POST 가 아니고(백엔드가 처리했을지 모르는 요청을 두 번 보내지 않도록), proxy_next_upstream_timeout 안이면 다시 시도합니다.
  4. 다시 시도하면 지금 연결을 닫고 ngx_http_upstream_connect() 를 또 부릅니다(4746–4768행). pc->get() 이 다음 서버를 고르고, 4절에서 만들어 둔 같은 요청 버퍼를 보냅니다. 요청을 이미 보냈던 연결이면 ngx_http_upstream_reinit() 이 버퍼 위치를 되돌리고 u->reinit_request 로 모듈 상태를 처음으로 돌립니다(1697–1703행).

예제 3: 502, 504, 그리고 다음 서버

ex/u11-proxy-errors 는 9000 에 살아 있는 백엔드, 9002 에 응답 전에 3초 쉬는 백엔드를 띄우고 9001 은 비워 둡니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
# ... (앞부분 설정과 http { 는 줄이고, 그 안쪽만 들여쓰기를 당겨 옮김)
log_format up '$request_uri -> $status  upstream_addr="$upstream_addr" '
              'upstream_status="$upstream_status"';
# ... (access_log 줄임)

# 9001 에는 아무도 듣고 있지 않다. 9000 은 살아 있다
upstream pair {
    server 127.0.0.1:9001;
    server 127.0.0.1:9000;
}

upstream pair_nonext {
    server 127.0.0.1:9001;
    server 127.0.0.1:9000;
}

server {
    listen 8080;

    location /down/ {
        proxy_pass http://127.0.0.1:9001;     # 꺼진 백엔드 → 502
    }

    location /slow/ {
        proxy_pass http://127.0.0.1:9002;     # 3초 쉬는 백엔드
        proxy_read_timeout 1s;                # 1초만 기다린다 → 504
    }

    location /next/ {
        proxy_pass http://pair;               # 실패하면 다음 서버로 (기본값 error timeout)
    }

    location /nonext/ {
        proxy_pass http://pair_nonext;
        proxy_next_upstream off;              # 다음 서버로 넘어가지 않는다
    }
}
=== 1. backend down ===
/down/ -> 502 in 0.001147s
=== 2. slow backend, proxy_read_timeout 1s ===
/slow/ -> 504 in 1.006190s
=== 3. upstream with a dead server, proxy_next_upstream default ===
/next/a -> 200 in 0.015762s
/next/b -> 200 in 0.040978s
/next/c -> 200 in 0.012087s
=== 4. same, proxy_next_upstream off ===
/nonext/a -> 502 in 0.000660s
/nonext/b -> 200 in 0.035330s
/nonext/c -> 200 in 0.003075s
=== access.log ===
/down/ -> 502  upstream_addr="127.0.0.1:9001" upstream_status="502"
/slow/ -> 504  upstream_addr="127.0.0.1:9002" upstream_status="504"
/next/a -> 200  upstream_addr="127.0.0.1:9001, 127.0.0.1:9000" upstream_status="502, 200"
/next/b -> 200  upstream_addr="127.0.0.1:9000" upstream_status="200"
/next/c -> 200  upstream_addr="127.0.0.1:9000" upstream_status="200"
/nonext/a -> 502  upstream_addr="127.0.0.1:9001" upstream_status="502"
/nonext/b -> 200  upstream_addr="127.0.0.1:9000" upstream_status="200"
/nonext/c -> 200  upstream_addr="127.0.0.1:9000" upstream_status="200"

걸린 시간과 error.log 의 pid(32#32)는 실행마다 다릅니다(access.log 맨 앞의 기동 확인용 / 줄은 뺐습니다). error.log 에서 해당하는 줄입니다(클라이언트·요청 정보 뒷부분은 줄였습니다).

[error] 32#32: *2 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, ... upstream: "http://127.0.0.1:9001/down/" ...
[error] 32#32: *4 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 127.0.0.1, ... upstream: "http://127.0.0.1:9002/slow/" ...
[error] 32#32: *6 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, ... upstream: "http://127.0.0.1:9001/next/a" ...
[warn] 32#32: *6 upstream server temporarily disabled while connecting to upstream, client: 127.0.0.1, ... upstream: "http://127.0.0.1:9001/next/a" ...
[error] 32#32: *11 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, ... upstream: "http://127.0.0.1:9001/nonext/a" ...
[warn] 32#32: *11 upstream server temporarily disabled while connecting to upstream, client: 127.0.0.1, ... upstream: "http://127.0.0.1:9001/nonext/a" ...

헷갈리기 쉬운 곳: 예제를 돌리다 60초를 기다린 일

이 예제의 첫 판은 백엔드를 python 의 HTTPServer(한 번에 연결 하나만 처리)로 만들었습니다. 그랬더니 4번의 /nonext/b 가 60초 뒤에 504 로 끝났습니다. 원인은 이 판의 upstream keepalive 기본값이었습니다. upstream {} 블록으로 만든 pair 는 keepalive 가 기본으로 켜져 있어서(12편) /next/c 가 끝난 뒤에도 9000 으로 가는 연결을 열어 둔 채 캐시에 넣었고, 한 번에 연결 하나만 보는 python 서버는 그 연결의 다음 요청을 기다리느라 pair_nonext 가 새로 연 연결을 받지 못했습니다. 그래서 기본 proxy_read_timeout 60초가 터졌습니다. 백엔드를 ThreadingHTTPServer 로 바꾸자 위 결과가 나왔습니다. nginx 쪽이 아니라 백엔드 쪽 한계였지만, "nginx 가 백엔드 연결을 열어 두는 것"이 백엔드에 어떤 부담이 되는지 보여 준 일이었습니다.

6. 응답 흘려보내기 — proxy_buffering on 과 off

헤더를 다 읽으면 ngx_http_upstream_send_response() 입니다.

    rc = ngx_http_send_header(r);

    if (rc == NGX_ERROR || rc > NGX_OK || r->post_action) {
        ngx_http_upstream_finalize_request(r, u, rc);
        return;
    }

    u->header_sent = 1;
    /* ... */
    if (!u->buffering) {
        /* ... */
        u->read_event_handler = ngx_http_upstream_process_non_buffered_upstream;
        r->write_event_handler =
                             ngx_http_upstream_process_non_buffered_downstream;
        /* ... */
    u->read_event_handler = ngx_http_upstream_process_upstream;
    r->write_event_handler = ngx_http_upstream_process_downstream;

    ngx_http_upstream_process_upstream(r, u);

src/http/ngx_http_upstream.c 3297–3622행

응답 헤더는 버퍼링과 상관없이 먼저 헤더 필터 사슬에 넘깁니다(3297행의 ngx_http_send_header()). 소켓으로 실제 나가는 때는 10편의 write 필터가 정합니다. 그다음 갈림길이 u->buffering, 곧 proxy_buffering(기본 on, 3707–3708행)입니다.

event pipe 의 한 바퀴

ngx_event_pipe() 는 "내보내고, 읽고"를 더 할 일이 없을 때까지 되풀이합니다(src/event/ngx_event_pipe.c 29–58행). 재미있는 곳은 읽을 버퍼를 구하는 순서입니다.

            if (p->free_raw_bufs) {
                /* ... */
            } else if (p->allocated < p->bufs.num) {
                /* ... */
                b = ngx_create_temp_buf(p->pool, p->bufs.size);
                /* ... */
            } else if (!p->cacheable
                       && !p->downstream_error
                       && p->downstream->data == p->output_ctx
                       && p->downstream->write->ready
                       && !p->downstream->write->delayed)
            {
                /* ... */
                p->upstream_blocked = 1;
                /* ... */
                break;

            } else if (p->cacheable
| p->temp_file->offset < p->max_temp_file_size)
            {
                /* ... */
                rc = ngx_event_pipe_write_chain_to_temp_file(p);
                /* ... */
            } else {
                /* ... */
                ngx_log_debug0(NGX_LOG_DEBUG_EVENT, p->log, 0,
                               "no pipe bufs to read in");

                break;
            }

src/event/ngx_event_pipe.c 224–301행

event pipe 가 읽을 버퍼를 구하는 순서. ngx_event_pipe_read_upstream 이 읽을 버퍼가 필요하면, ① free_raw_bufs 에 빈 버퍼가 있으면 그것을 쓰고, 없으면 ② 만든 수가 proxy_buffers 의 개수보다 적으면 새 버퍼를 만들고, 한도면 ③ 클라이언트가 받을 준비가 됐으면 읽기를 멈추고 먼저 내보내고, 아니면 ④ 임시 파일 크기가 max_temp_file_size 보다 작으면 버퍼를 파일에 쓰고 비우고, 한도면 ⑤ no pipe bufs to read in 으로 기다린다. ①②④ 다음에는 recv_chain 으로 읽고, 가득 찬 버퍼와 길이를 다 채운 버퍼만 input_filter 를 거쳐 p->in 에 넣는다. ③과 p->in 은 ngx_event_pipe_write_to_downstream 을 거쳐 출력 필터로 간다. 노란 상자가 메모리·파일을 쓰는 자리, 빨간 상자가 버퍼도 임시 파일 자리도 없어 읽기를 멈추고 기다리는 자리입니다(③도 읽기를 멈추지만, 먼저 내보내려고 멈추는 것입니다).

여기서 버퍼링의 뜻이 드러납니다. 클라이언트가 느려도 백엔드에서는 계속 읽습니다. 메모리 버퍼를 다 쓰면(②의 한도) 클라이언트가 받을 수 있는지 보고(③), 못 받으면 버퍼를 임시 파일로 옮겨 비운 뒤 계속 읽습니다(④). 백엔드는 응답을 빨리 넘기고 다음 일을 할 수 있습니다. 응용 서버의 작업 스레드나 프로세스는 nginx 의 연결보다 훨씬 비싸므로, 느린 클라이언트를 nginx 가 대신 상대해 주는 것입니다.

그리고 덜 찬 버퍼는 클라이언트로 내보내지 않습니다. 읽은 바이트를 버퍼에 나눠 담는 루프(336–360행)는 버퍼가 가득 찼을 때만 p->input_filter() 를 불러 내보낼 목록(p->in)에 올립니다. 덜 찬 버퍼는 free_raw_bufs 로 돌아가 다음 읽기를 기다립니다. 예외는 응답 길이를 알고 있어 그 버퍼가 남은 길이를 다 채웠을 때(435–450행)와 백엔드가 연결을 닫았을 때(457–474행)뿐입니다. 큰 응답을 덩어리째 옮기기에는 좋지만, 조금씩 흘러나오는 응답은 버퍼가 찰 때까지 멈춰 있게 됩니다.

예제 4: 한 줄씩 나오는 응답, 그리고 임시 파일

ex/u11-proxy-buffering 의 백엔드는 /stream 에 Content-Length: 12 를 먼저 보내고 one, two, end 세 줄을 1초 간격으로 씁니다. 클라이언트는 줄마다 요청을 보낸 뒤 몇 초 만에 받았는지 찍습니다. /small/ 은 proxy_buffers 2 4k 로 메모리 버퍼를 일부러 작게 했고, 그쪽에는 32MB 응답을 요청하고 2초 동안 읽지 않는 클라이언트(slowclient.py)를 붙였습니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
# ... (앞부분 설정과 http { · access_log off 줄임)
proxy_temp_path /tmp/u11b/proxy_temp;

# ... (server { listen 8080; 줄임 — location 만 들여쓰기를 당겨 옮김)
location /buf/ {
    proxy_pass http://127.0.0.1:9000;     # proxy_buffering 기본값 on
}

location /nobuf/ {
    proxy_pass http://127.0.0.1:9000;
    proxy_buffering off;                  # 받는 대로 클라이언트에 넘긴다
}

location /small/ {
    proxy_pass http://127.0.0.1:9000;
    proxy_buffer_size 4k;                 # 메모리 버퍼를 일부러 작게
    proxy_buffers 2 4k;
    proxy_busy_buffers_size 4k;
}
=== 1. proxy_buffering on (default): /buf/stream ===
  +2.0s  one
  +2.1s  two
  +2.1s  end
=== 2. proxy_buffering off: /nobuf/stream ===
  +0.0s  one
  +1.1s  two
  +2.1s  end
=== 3. 32MB through 2 x 4k buffers to a client that does not read for 2s ===
  client read 33554555 bytes (headers included) after sleeping 2s
[warn] 21#21: *6 an upstream response is buffered to a temporary file /tmp/u11b/proxy_temp/0000000001 while reading upstream, client: 127.0.0.1, server: , request: "GET /small/big HTTP/1.0", upstream: "http://127.0.0.1:9000/small/big", host: "x"
--- files left in the temp dir
0

시각은 실행마다 조금씩 다릅니다.

헷갈리기 쉬운 곳: 임시 파일은 디렉터리에서 보이지 않습니다

예제 끝에 proxy_temp 아래 파일 수를 셌더니 0 이었습니다. 요청이 끝나서 지운 것이 아니라 만들자마자 이름을 지운 파일입니다. 캐시용(persistent)이 아닌 임시 파일은 열자마자 디렉터리에서 이름을 지웁니다(unlink).

    fd = open((const char *) name, O_CREAT|O_EXCL|O_RDWR,
              access ? access : 0600);

    if (fd != -1 && !persistent) {
        (void) unlink((const char *) name);
    }

src/os/unix/ngx_files.c 283–288행

유닉스에서는 연 파일을 unlink() 해도 열린 fd 로는 계속 읽고 쓸 수 있고, 마지막 fd 를 닫을 때 디스크 공간이 돌아옵니다. 워커가 죽어도 찌꺼기 파일이 남지 않습니다. 대신 디스크가 차는 원인을 ls 로 찾을 수 없으니 lsof 같은 도구로 워커의 열린 파일을 봐야 합니다(이 부분은 예제로 확인하지 않았습니다).

끝내기 — ngx_http_upstream_finalize_request()

본문이 끝나면(p->upstream_done, 4491–4496행) upstream 을 끝냅니다.

    u->finalize_request(r, rc);

    if (u->peer.free && u->peer.sockaddr) {
        u->peer.free(&u->peer, u->peer.data, 0);
        u->peer.sockaddr = NULL;
        /* ... */
    if (u->peer.connection) {

        /* ... */
        ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
                       "close http upstream connection: %d",
                       u->peer.connection->fd);

        if (u->peer.connection->pool) {
            ngx_destroy_pool(u->peer.connection->pool);
        }

        ngx_close_connection(u->peer.connection);
    }

src/http/ngx_http_upstream.c 4821–4861행

모듈의 finalize_request 를 부르고, u->peer.free() 로 서버 고르기 쪽에 "다 썼다"고 알린 뒤, 연결이 남아 있으면 닫습니다. 그래서 암묵적 upstream 인 예제 2는 close http upstream connection 으로 끝났습니다. keepalive 가 켜진 upstream 이면 free 가 그 연결을 캐시에 넣고 u->peer.connection 을 비워 두므로, 여기서 닫지 않습니다 — 그 바꿔치기가 12편의 주제입니다. 마지막으로 ngx_http_send_special(r, NGX_HTTP_LAST) 로 응답 끝을 필터 사슬에 알리고 ngx_http_finalize_request() 로 요청을 끝냅니다(4940–4955행).

이 글에서 뺀 것

정리

개념 한 줄 요약
proxy_pass core 모듈의 location 설정에 handler = ngx_http_proxy_handler 를 걸고, ngx_http_upstream_add() 로 upstream 을 만들거나 찾는다(블록 없으면 암묵적 upstream)
proxy_pass 의 경로 없으면 r->unparsed_uri 그대로, 있으면 location 접두사를 그 경로로 바꾼다
ngx_http_upstream_t 요청마다 하나. 공통 일(연결·보내기·읽기·재시도·끝내기)은 upstream 이, 프로토콜은 모듈의 콜백이 맡는다
콜백 create_request, process_header(상태 줄 → 헤더로 스스로 바뀜), input_filter, finalize_request, reinit_request. abort_request 는 부르는 곳이 없다
시작 ngx_http_read_client_request_body(r, ngx_http_upstream_init) — 본문을 먼저 읽고 upstream 시작
연결 ngx_event_connect_peer() — pc->get() 으로 고르고 논블로킹 connect. EINPROGRESS 면 NGX_AGAIN, 타이머만 걸고 이벤트 루프로
이벤트 핸들러 백엔드 연결의 두 이벤트 모두 ngx_http_upstream_handler, 단계마다 u->read/write_event_handler 만 바꾼다
요청 만들기 길이를 세고 버퍼 하나를 할당해 복사하는 두 번 훑기. 본문은 데이터를 복사하지 않고 버퍼 구조체와 사슬 고리만 새로 만들어 잇는다
기본값 (이 판) proxy_http_version 1.1, Connection·Keep-Alive 등은 보내지 않음, Host 는 따로 처리(없으면 $proxy_host)
타임아웃 connect·send·read 모두 기본 60초. 시간 초과는 504, 거절·잘못된 헤더는 502
ngx_http_upstream_next() 실패를 세고, 상태 코드를 정하고, tries·proxy_next_upstream·POST·시간을 보고 다시 connect 하거나 끝낸다
proxy_buffering on event pipe — 버퍼 여러 개, 모자라면 이름 없는 임시 파일. 백엔드에서 계속 읽는다. 덜 찬 버퍼는 내보내지 않는다
proxy_buffering off 버퍼 하나로 읽는 대로 내보낸다. 백엔드가 클라이언트 속도에 묶인다
끝내기 finalize_request → peer.free → 남은 연결 닫기(keepalive 면 12편의 모듈이 캐시에 넣는다)

다음에 볼 것

이 글에서는 pc->get() 을 "부르면 주소가 나온다"로 넘겼고, finalize 에서 연결을 닫는 대신 캐시에 넣는 경우도 미뤘습니다. 다음 글이 그 둘입니다. upstream {} 블록의 서버들 가운데 smooth weighted round robin 이 어떻게 하나를 고르고, max_fails·fail_timeout 이 5절의 "temporarily disabled" 를 어떻게 만들고, keepalive 모듈이 get/free 를 감싸 연결을 다시 쓰는지 봅니다. 그리고 같은 upstream 생각을 TCP 에 쓴 stream {} 모듈로 연재를 마칩니다. 12편: 부하 분산, upstream keepalive, stream 모듈 — 그리고 연재를 마치며에서 이어집니다.

같은 일을 httpd 에서는 httpd 14편: mod_proxy에서 봤습니다. 공통 틀과 프로토콜별 부분을 나눈 생각은 같습니다 — httpd 는 mod_proxy 가 스킴 핸들러 훅을 열고 mod_proxy_http 가 그 훅에 줄을 섰고, nginx 는 ngx_http_upstream_t 의 콜백 필드에 proxy 모듈이 함수를 꽂습니다. 크게 다른 것은 기다리는 방식입니다. httpd 의 mod_proxy_http 는 작업 스레드 하나가 백엔드 응답을 받아 클라이언트로 흘려보내는 동안 그 연결을 맡지만, nginx 는 connect 와 읽기마다 이벤트 루프로 돌아가므로 워커 하나가 수많은 프록시 요청을 함께 돌립니다. 백엔드 연결을 다시 쓰는 연결 풀(httpd 는 워커마다 apr_reslist)은 nginx 에서는 12편의 keepalive 모듈입니다. Tomcat 에서 커넥터가 받은 요청을 컨테이너로 넘기는 Tomcat 7편의 CoyoteAdapter 는 같은 프로세스 안의 넘김이라, 네트워크 너머로 다시 보내는 upstream 과는 자리가 다릅니다.

다른 글