namalzi

← 글 목록

Apache httpd 소스 파헤치기 14: mod_proxy — 리버스 프록시와 로드 밸런서

Apache httpd · 게시

ProxyPass 한 줄이 요청을 다른 서버로 넘기기까지를 소스로 따라갑니다. URL이 r->filename의 "proxy:..."로 바뀌는 자리, proxy_handler()가 스킴 핸들러 훅에 일을 넘기는 방식, 백엔드마다 하나씩 생기는 워커와 연결 풀, mod_proxy_http가 브리게이드로 응답을 흘려보내는 길, 그리고 byrequests 밸런서가 두 백엔드를 번갈아 고르는 계산을 컨테이너 안의 백엔드 두 대로 직접 확인합니다.

이 글에서 다루는 것

"Apache httpd 소스 파헤치기" 연재의 열네 번째 글입니다. 13편에서는 Require 한 줄이 판정되는 과정을 보면서, 모듈이 이름표를 붙여 기능을 내놓고 다른 모듈이 그 이름으로 찾아 쓰는 프로바이더를 만났습니다. 이번 글은 httpd가 응답을 스스로 만들지 않고 다른 서버에게 받아 오는 경우, 곧 mod_proxy를 봅니다. httpd를 Tomcat이나 Node.js 앞에 세워 두는 설정(ProxyPass /app/ http://127.0.0.1:8080/)을 한 번쯤 써 봤다면 그 한 줄 뒤에서 일어나는 일입니다.

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

항목 내용
대상 Apache httpd
버전 2.5.1-dev (trunk)
커밋 e4af30fe6d (2026-10-24)
주로 읽는 파일 modules/proxy/mod_proxy.c, mod_proxy.h, proxy_util.c, mod_proxy_http.c, mod_proxy_balancer.c, balancers/mod_lbmethod_byrequests.c
함께 읽는 APR APR-util 1.6.3 (태그 1.6.3, 커밋 28327eeca2) — misc/apr_reslist.c

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 우리가 흔히 설치하는 2.4.x 브랜치와도 이 글의 큰 줄기는 같습니다 — 2.4.x의 mod_proxy.c 도 같은 훅에 같은 함수를 걸고, mod_lbmethod_byrequests.c 는 두 브랜치의 파일이 완전히 같으며(git 객체 해시가 같습니다), mod_proxy_http.c 도 ws 스킴을 맡습니다(줄 번호는 다릅니다).

modules/proxy/ 의 여섯 파일만 합쳐도 15,000줄이 넘습니다(proxy_util.c 하나가 6,053줄). 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

프록시(proxy)는 "대신 가 주는 서버"입니다. 클라이언트의 요청을 받아 다른 서버에 똑같이 보내고, 받은 응답을 클라이언트에게 돌려줍니다. 누구를 위해 대신 가 주느냐에 따라 둘로 나뉩니다.

백엔드(backend)는 리버스 프록시 뒤에서 실제로 응답을 만드는 서버입니다. 소스 주석에서는 오리진 서버(origin server)라고도 부릅니다.

워커(worker)는 mod_proxy가 "백엔드 하나"를 나타내는 구조체 proxy_worker 입니다. 주소, 시간 제한 같은 설정과 그 백엔드로 가는 연결 풀(미리 열어 둔 연결을 모아 두고 빌려주는 곳)을 들고 있습니다. MPM의 "워커 스레드"와는 다른 말입니다(8편).

밸런서(balancer)는 워커 여러 개를 묶은 proxy_balancer 입니다. 요청이 올 때마다 묶음 가운데 하나를 고릅니다. 고르는 규칙은 lbmethod(load balancing method)라는 이름으로 따로 떼어 프로바이더로 붙입니다.

훅과 RUN_FIRST는 6편과 10편에서 봤습니다. 짧게 되짚으면, 훅은 "이 시점에 등록된 함수들을 차례로 불러 주겠다"는 자리이고, RUN_FIRST 방식은 차례로 부르다가 DECLINED(내 일이 아니다)가 아닌 답이 처음 나오면 멈춥니다. mod_proxy는 코어의 훅에 끼어들 뿐 아니라 자기만의 훅도 열어 두고 하위 모듈(mod_proxy_http 등)을 거기에 받습니다. 이 글의 중심이 그 구조입니다.

브리게이드(bucket brigade)는 응답 데이터를 담은 버킷의 줄이고, 필터를 따라 ap_pass_brigade() 로 흘러 나갑니다(11편). 프록시는 백엔드에서 읽은 데이터를 이 줄에 담아 클라이언트 쪽 필터로 그대로 흘려보냅니다.

1. 포워드와 리버스 — r->proxyreq 하나의 차이

포워드 프록시와 리버스 프록시의 비교. 포워드 프록시에서는 목적지를 아는 브라우저가 전체 URL을 보내고 httpd는 r->proxyreq를 PROXYREQ_PROXY로 표시해 인터넷의 아무 서버로 보낸다. 리버스 프록시에서는 브라우저가 경로만 보내고 httpd는 PROXYREQ_REVERSE로 표시해 미리 정해 둔 백엔드로 보낸다. 같은 mod_proxy가 두 일을 다 합니다. 소스에서 둘을 가르는 것은 request_rec 의 필드 하나입니다.

request_rec 에는 proxyreq 라는 필드가 있고, 값은 넷 가운데 하나입니다(include/httpd.h 1181–1184행). 보통 요청은 PROXYREQ_NONE(0), 포워드 프록시 요청은 PROXYREQ_PROXY, 리버스 프록시 요청은 PROXYREQ_REVERSE, 그리고 프록시가 백엔드의 응답을 읽으려고 만드는 가짜 요청에는 PROXYREQ_RESPONSE 가 붙습니다(5절).

포워드 프록시는 요청 줄을 읽자마자 알아챕니다. 포워드 프록시를 쓰는 브라우저는 요청 줄에 경로만이 아니라 GET http://다른서버/a HTTP/1.1 처럼 전체 URL을 적어 보내기 때문입니다. mod_proxy는 post_read_request 훅(9편에서 본, 헤더까지 다 읽은 직후의 자리)에 proxy_detect() 를 걸어 이것을 봅니다.

    if (conf->req && r->parsed_uri.scheme) {
        /* but it might be something vhosted */
        if (!r->parsed_uri.hostname
| ap_cstr_casecmp(r->parsed_uri.scheme, ap_http_scheme(r)) != 0
| !ap_matches_request_vhost(r, r->parsed_uri.hostname,
                                         (apr_port_t)(r->parsed_uri.port_str
                                                      ? r->parsed_uri.port
                                                      : ap_default_port(r)))) {
            r->proxyreq = PROXYREQ_PROXY;
            r->uri = r->unparsed_uri;
            r->filename = apr_pstrcat(r->pool, "proxy:", r->uri, NULL);
            r->handler = "proxy-server";
        }
    }

modules/proxy/mod_proxy.c 818–831행

conf->req 가 설정 파일의 ProxyRequests On 입니다. 그것이 켜져 있고 요청 줄에 스킴(http:)이 있으며, 그 주소가 이 서버 자신의 가상 호스트가 아니면 포워드 프록시 요청으로 표시합니다. 마지막 조건이 있는 까닭은 위의 주석("vhosted")대로, 전체 URL로 보냈어도 목적지가 바로 이 서버일 수 있기 때문입니다.

리버스 프록시는 요청 줄만 봐서는 알 수 없습니다. 브라우저는 /app/a 처럼 경로만 보내니까요. 그래서 설정 파일의 ProxyPass 목록과 경로를 비교하는 다음 절의 일이 필요합니다.

헷갈리기 쉬운 곳: X-Forwarded-For는 리버스 프록시에만 붙습니다

리버스 프록시는 백엔드에게 "원래 클라이언트는 누구였다"를 알려 주려고 X-Forwarded-For 같은 헤더를 붙입니다(5절에 코드가 있습니다). 그런데 이 코드는 PROXYREQ_REVERSE == r->proxyreq 일 때만 돕니다. 포워드 프록시에서 이것을 붙이면 회사 안 사용자의 주소를 바깥 아무 서버에나 알려 주는 셈이라, 소스 주석도 "potential privacy violation"이라고 적어 두었습니다(proxy_util.c 4801–4820행). 아래 예제 6에서 실제로 포워드 요청에는 이 헤더가 없는 것을 봅니다.

2. ProxyPass 한 줄은 어떻게 r->filename이 되나

설정을 읽을 때: 별칭 하나와 워커 하나

ProxyPass /one/ http://127.0.0.1:8081/ 을 읽으면 add_pass() 가 불립니다(mod_proxy.c 2006–2206행). 하는 일은 두 가지입니다.

  1. 별칭(struct proxy_alias)을 하나 만들어 서버 설정의 aliases 배열에 넣습니다(2119행). fake 에 /one/, real 에 http://127.0.0.1:8081/ 이 들어갑니다(mod_proxy.h 134–140행).
  2. 대상이 balancer:// 로 시작하면 밸런서를, 아니면 워커를 정의합니다(2140행, 2173–2183행). 이 URL을 이미 맡을 수 있는 워커(이름이 URL의 앞부분과 맞는 워커 — 3절에서 볼 ap_proxy_get_worker_ex() 로 찾습니다)가 있으면 새로 만들지 않고 나눠 씁니다(로그 AH01145 "Sharing worker").

설정이 C 구조체가 되는 일반적인 과정은 7편에 있습니다.

요청이 올 때: translate_name 훅에서 가로채기

mod_proxy가 코어의 훅에 거는 함수는 이렇습니다.

    /* handler */
    ap_hook_handler(proxy_handler, NULL, NULL, APR_HOOK_FIRST);
    /* filename-to-URI translation */
    ap_hook_pre_translate_name(proxy_pre_translate_name, NULL, NULL,
                               APR_HOOK_MIDDLE);
    /* mod_rewrite has a say on the uri before proxy translation */
    ap_hook_translate_name(proxy_translate_name, aszModRewrite, NULL,
                           APR_HOOK_FIRST);
    /* walk <Proxy > entries and suppress default TRACE behavior */
    ap_hook_map_to_storage(proxy_map_location, NULL,NULL, APR_HOOK_FIRST);
    /* fixup after mod_rewrite so that a [P] URL from there gets fixed up */
    ap_hook_fixups(proxy_fixup, aszModRewrite, NULL, APR_HOOK_FIRST);
    /* post read_request handling */
    ap_hook_post_read_request(proxy_detect, NULL, NULL, APR_HOOK_FIRST);

modules/proxy/mod_proxy.c 3428–3441행

10편에서 본 요청 처리 단계에 그대로 겹쳐 보면 됩니다. 보통 요청은 translate_name 단계에서 코어가 DocumentRoot 에 URL을 붙여 디스크 경로를 만듭니다. mod_proxy는 그보다 앞서(APR_HOOK_FIRST, 단 mod_rewrite.c 뒤에) proxy_translate_name() 을 걸어 두고, ProxyPass 목록과 맞으면 디스크 경로 대신 가짜 이름을 r->filename 에 넣어 버립니다. translate_name 은 RUN_FIRST 훅이라 여기서 OK 를 돌려주면 코어의 번역은 불리지 않습니다.

비교는 서버 설정의 별칭 배열을 앞에서부터 도는 단순한 반복입니다(그 바로 앞에서 <Location> 안에 적은 ProxyPass 하나를 먼저 봅니다 — 1042–1051행).

    /* long way - walk the list of aliases, find a match */
    for (i = 0; i < conf->aliases->nelts; i++) {
        ent = &((struct proxy_alias *)conf->aliases->elts)[i];
        enc = (ent->flags & PROXYPASS_MAP_ENCODED) != 0;
        if (!(pre_trans ^ enc)) {
            int rv = ap_proxy_trans_match(r, ent, dconf);
            if (rv != HTTP_CONTINUE) {
                return rv;
            }
        }
    }

modules/proxy/mod_proxy.c 1053–1063행

ap_proxy_trans_match() 는 앞부분이 맞으면 "proxy:" + real + 나머지 경로 를 만들고(937행), 요청에 세 가지 표시를 합니다.

        r->filename = found;
        r->handler = "proxy-server";
        r->proxyreq = PROXYREQ_REVERSE;

modules/proxy/mod_proxy.c 960–962행

/one/hello.txt 라면 r->filename 은 proxy:http://127.0.0.1:8081/hello.txt 가 됩니다. "proxy:" 로 시작하는 r->filename 이 이후 모든 단계에서 "이건 프록시 요청"이라는 표지 노릇을 합니다.

요청 하나가 mod_proxy를 지나는 길. translate_name 훅에서 ProxyPass 목록과 비교해 r->filename, r->handler, r->proxyreq를 정하고, map_to_storage 훅에서 파일 대신 Proxy 구역을 맞추고, fixups 훅에서 URL을 정리한 뒤, handler 훅의 proxy_handler가 pre_request 훅(밸런서가 워커를 고름), scheme_handler 훅(mod_proxy_http가 백엔드와 주고받음), post_request 훅을 차례로 부른다. 위 네 칸은 코어의 훅, 아래 세 칸은 mod_proxy가 스스로 연 훅입니다. 노란 칸이 translate_name 이 남긴 표지입니다.

나머지 두 훅도 이 표지를 봅니다.

헷갈리기 쉬운 곳

3. proxy_handler()와 스킴 핸들러 훅

표지를 확인하고 스킴을 떼어 낸다

핸들러 단계에서 proxy_handler() 가 불립니다. APR_HOOK_FIRST 로 걸려 있어 다른 핸들러보다 먼저 물어봅니다.

    /* is this for us? */
    if (!r->filename) {
        return DECLINED;
    }

    /* We may have forced the proxy handler via config or .htaccess */
    if (!r->proxyreq && r->handler && strncmp(r->handler, "proxy:", 6) == 0) {
        /* ... SetHandler "proxy:..." 로 지정한 경우 ... */
    }
    else if (r->proxyreq && strncmp(r->filename, "proxy:", 6) == 0) {
        apr_table_unset(r->notes, "proxy-sethandler");
        rc = OK;
    }
    if (rc != OK) {
        return rc;
    }

    uri = r->filename + 6;
    p = strchr(uri, ':');
    /* ... */
    scheme = apr_pstrmemdup(r->pool, uri, p - uri);

modules/proxy/mod_proxy.c 1251–1286행

r->filename + 6 은 C에서 흔한 기법입니다. 문자열은 첫 글자의 주소이므로 6을 더하면 앞의 "proxy:" 여섯 글자를 건너뛴 주소가 됩니다(복사하지 않습니다). 거기서 다음 : 까지가 스킴, 곧 http 나 balancer 입니다.

워커를 고르고, 스킴 핸들러를 부른다

본론은 do { ... } while 반복 하나입니다. 실패하면 다른 워커로 다시 해 보기 위한 반복이라 길지만, 한 바퀴의 뼈대는 두 줄입니다.

    do {
        char *url = uri;
        /* Try to obtain the most suitable worker */
        access_status = ap_proxy_pre_request(&worker, &balancer, r, conf, &url);
        /* ... */
        AP_PROXY_RUN(r, worker, conf, url, attempts);
        access_status = proxy_run_scheme_handler(r, worker, conf,
                                                 url, NULL, 0);
        /* ... */
    } while (!PROXY_WORKER_IS_USABLE(worker) &&
             max_attempts > attempts++);

modules/proxy/mod_proxy.c 1386–1528행 (가운데의 ProxyRemote 처리와 오류 분기를 줄였습니다)

  1. ap_proxy_pre_request() — 이 요청을 맡을 워커를 정합니다(proxy_util.c 2647–2703행). 먼저 mod_proxy의 pre_request 훅을 돌리는데, balancer:// URL이면 여기서 mod_proxy_balancer가 손을 듭니다(6절). 아무도 맡지 않으면 URL과 이름이 가장 길게 겹치는 워커를 찾습니다(ap_proxy_get_worker_ex() 1871–1954행, 주석 "longest match" 1929행). 그것도 없으면 포워드·리버스용 기본 워커(conf->forward, conf->reverse)를 씁니다.
  2. proxy_run_scheme_handler() — 실제로 백엔드와 말을 주고받을 모듈을 부릅니다.

스킴 핸들러 훅 — 하위 모듈이 줄을 서는 자리

proxy_run_scheme_handler 는 mod_proxy가 스스로 만든 훅입니다. 코어의 훅을 만드는 것과 같은 매크로로 만듭니다.

APR_IMPLEMENT_EXTERNAL_HOOK_RUN_FIRST(proxy, PROXY, int, scheme_handler,
                                     (request_rec *r, proxy_worker *worker,
                                      proxy_server_conf *conf,
                                      char *url, const char *proxyhost,
                                      apr_port_t proxyport),(r,worker,conf,
                                      url,proxyhost,proxyport),DECLINED)

modules/proxy/mod_proxy.c 3474–3479행

RUN_FIRST 이므로 등록된 함수를 차례로 부르다 DECLINED 가 아닌 답이 처음 나오면 멈춥니다. 이 훅에 함수를 거는 것이 mod_proxy_http, mod_proxy_ajp, mod_proxy_fcgi 같은 하위 모듈입니다. 각자 URL의 스킴을 보고 자기 것이 아니면 DECLINED 를 돌려줍니다. mod_proxy_http 쪽을 보면 이렇습니다.

static void ap_proxy_http_register_hook(apr_pool_t *p)
{
    ap_hook_post_config(proxy_http_post_config, NULL, NULL, APR_HOOK_MIDDLE);
    proxy_hook_scheme_handler(proxy_http_handler, NULL, NULL, APR_HOOK_FIRST);
    proxy_hook_canon_handler(proxy_http_canon, NULL, NULL, APR_HOOK_FIRST);
    /* ... */
}

modules/proxy/mod_proxy_http.c 2212–2218행

    scheme = get_url_scheme(&u, &is_ssl);
    /* ... */
    if (!scheme || u[0] != '/' || u[1] != '/' || u[2] == '\0') {
        ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, APLOGNO(01113)
                      "HTTP: declining URL %s", url);
        return DECLINED; /* only interested in HTTP, WS or FTP via proxy */
    }

modules/proxy/mod_proxy_http.c 1950–1960행

get_url_scheme() 은 http, https, ws, wss 네 가지만 알아봅니다(31–71행). 나머지는 모두 DECLINED 입니다.

스킴 핸들러 훅의 동작. proxy_handler가 proxy_run_scheme_handler를 부르면 등록된 함수가 차례로 불린다. mod_proxy_http는 http, https, ws, wss이면 처리하고 거기서 끝나며, 아니면 DECLINED를 돌려 다음 모듈(예: mod_proxy_ajp, mod_proxy_fcgi)로 넘어간다. 모두 DECLINED이면 AH01144와 함께 500이 된다. 실제 순서는 각 모듈이 건 자리(APR_HOOK_FIRST 등)와 싣는 순서로 정해집니다. 그림의 순서는 한 예입니다.

이렇게 나눠 둔 덕분에 mod_proxy 본체는 HTTP를 전혀 모릅니다. 워커를 고르고, 재시도하고, 상태를 기록하는 공통 일만 하고, 말을 주고받는 일은 스킴마다 다른 모듈에 맡깁니다. 새 프로토콜(예: uwsgi)을 붙이려면 이 훅에 함수 하나를 거는 모듈을 쓰면 됩니다(mod_proxy_uwsgi.c 615행).

헷갈리기 쉬운 곳: 스킴 핸들러는 프로바이더가 아니라 훅입니다

13편의 프로바이더와 닮아 보이지만 방식이 다릅니다. 프로바이더는 이름으로 하나를 찾아 씁니다(ap_lookup_provider("proxylbmethod", "byrequests", ...)). 스킴 핸들러는 이름표가 없고, 모두에게 차례로 물어 처음 손 든 쪽이 맡습니다. mod_proxy 안에서도 두 방식을 같이 씁니다 — 스킴은 훅, 밸런서 알고리즘(lbmethod)은 프로바이더(6절).

예제: mod_proxy_http를 빼면

이 글의 예제는 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 예제 폴더는 ex/u14-mod-proxy/ 이고, run.sh 하나가 컨테이너 안에서 백엔드 httpd 두 개(8081, 8082)와 앞단 프록시(8080)를 띄운 뒤 요청을 보냅니다. 앞단 설정의 핵심은 이것입니다.

# (1) 백엔드 하나로 그대로 넘기기
ProxyPass        /one/ http://127.0.0.1:8081/
ProxyPassReverse /one/ http://127.0.0.1:8081/
# (2) ProxyPassReverse 없이 넘기기 (Location 헤더를 비교하려고)
ProxyPass        /raw/ http://127.0.0.1:8082/
# (3) 두 백엔드에 번갈아 보내기
<Proxy "balancer://pair">
    BalancerMember http://127.0.0.1:8081
    BalancerMember http://127.0.0.1:8082
</Proxy>
ProxyPass /lb/ balancer://pair/

다른 예제는 다음 절들에서 보고, 여기서는 마지막 단계(5)만 봅니다. 같은 설정에서 LoadModule proxy_http_module ... 줄(과 밸런서 관련 줄)을 지우고 다시 띄운 뒤 /one/hello.txt 를 요청했습니다.

== 5. without mod_proxy_http
500
[warn] [proxy] AH01144: No protocol handler was valid for the URL /one/hello.txt (scheme 'http'). If you are using a DSO version of mod_proxy, make sure the proxy submodules are included in the configuration using LoadModule.

ProxyPass 는 그대로라 2절의 번역은 일어났지만, 스킴 핸들러 훅에 http 를 맡겠다는 함수가 하나도 없어 훅이 DECLINED 를 돌려주었고, proxy_handler() 가 그것을 500으로 바꿨습니다(1530–1538행). 오류 문장이 "하위 모듈을 LoadModule 했는지 보라"고 안내하는 것도 이 구조 때문입니다.

4. 워커와 연결 풀 — 백엔드 연결은 다시 씁니다

구조체 세 개

struct proxy_worker {
    proxy_hashes    hash;       /* hash of worker name */
    unsigned int local_status;  /* status of per-process worker */
    proxy_conn_pool     *cp;    /* Connection pool to use */
    proxy_worker_shared   *s;   /* Shared data */
    proxy_balancer  *balancer;  /* which balancer am I in? */
    /* ... */
};

modules/proxy/mod_proxy.h 508–521행

워커는 두 부분으로 나뉩니다.

struct proxy_conn_pool {
    apr_pool_t     *pool;     /* The pool used in constructor and destructor calls */
    apr_sockaddr_t *addr;     /* Preparsed remote address info */
    apr_reslist_t  *res;      /* Connection resource list */
    proxy_conn_rec *conn;     /* Single connection for prefork mpm */
    apr_pool_t     *dns_pool; /* The pool used for worker scoped DNS resolutions */
};

modules/proxy/mod_proxy.h 310–316행

res 는 APR-util의 자원 목록(apr_reslist_t)입니다. "만들기 비싼 것을 최대 몇 개까지 만들어 두고 빌려주고 돌려받는" 범용 도구이고, 여기서 빌려주는 것이 연결 하나를 나타내는 proxy_conn_rec 입니다. 스레드가 하나뿐인 prefork MPM에서는 목록 대신 연결 하나(conn)만 둡니다.

typedef struct {
    conn_rec     *connection;
    request_rec  *r;           /* Request record of the backend request
                                * that is used over the backend connection. */
    proxy_worker *worker;      /* Connection pool this connection belongs to */
    /* ... */
    apr_socket_t *sock;        /* Connection socket */
    /* ... */
    unsigned int close:1;      /* Close 'this' connection */
    /* ... */
} proxy_conn_rec;

modules/proxy/mod_proxy.h 274–302행

눈여겨볼 것은 connection 이 3편에서 본 바로 그 conn_rec 이라는 점입니다. 클라이언트와의 연결을 나타내는 구조체를 백엔드와의 연결에도 그대로 씁니다. 그래서 백엔드로 보내는 데이터도 그 conn_rec 의 필터를 지나 소켓으로 나갑니다(5절).

워커와 연결 풀의 관계. proxy_worker는 백엔드 URL마다 하나이고 자식 프로세스마다 한 벌 있다. s는 이름, 상태, lbfactor, lbstatus, hmax 등을 담은 proxy_worker_shared를 가리키며 밸런서 멤버의 것은 공유 메모리에 있다. cp는 proxy_conn_pool을 가리키고, 그 안의 res가 APR-util의 apr_reslist로 최대 hmax개의 proxy_conn_rec을 보관한다. proxy_conn_rec은 소켓과 conn_rec을 들고 있다. 노란 칸은 보관소입니다. 맨 아래 proxy_conn_rec 들이 요청 사이를 건너 살아남는 연결입니다.

빌리고 돌려주기

연결 목록의 크기는 자식 프로세스가 시작할 때 child_init() 이 워커마다 ap_proxy_initialize_worker() 를 부르면서 정해집니다(mod_proxy.c 3344–3346행). 따로 정하지 않으면 자식 프로세스의 스레드 수가 상한(hmax)이 됩니다(mod_http2 가 실려 있으면 그 작업 스레드 수만큼 늘려 잡습니다, proxy_util.c 2318–2331행). 스레드마다 연결 하나씩이면 기다릴 일이 없다는 계산입니다.

        if (max_threads > 1) {
            /* Default hmax is max_threads to scale with the load and never
             * wait for an idle connection to proceed.
             */
            if (worker->s->hmax == 0) {
                worker->s->hmax = max_threads;
            }

modules/proxy/proxy_util.c 2332–2338행

요청을 처리할 때는 ap_proxy_acquire_connection() 으로 빌립니다.

    if (worker->s->hmax && worker->cp->res) {
        rv = apr_reslist_acquire(worker->cp->res, (void **)conn);
    }
    else {
        /* create the new connection if the previous was destroyed */
        if (!worker->cp->conn) {
            rv = connection_constructor((void **)conn, worker, worker->cp->pool);
        }
        else {
            *conn = worker->cp->conn;
            worker->cp->conn = NULL;
            rv = APR_SUCCESS;
        }
    }

modules/proxy/proxy_util.c 2831–2844행

apr_reslist_acquire() 는 쉬는 자원이 있으면 그것을 꺼내 주고, 없고 상한 아래면 새로 만들고, 상한이면 누가 돌려줄 때까지 기다립니다(APR-util misc/apr_reslist.c 330–412행). 다 쓰면 ap_proxy_release_connection() 이 connection_cleanup() 을 불러 돌려놓습니다.

    /* determine if the connection should be cleared, closed or reused */
    if (!worker->s->is_address_reusable) {
        apr_pool_t *p = conn->pool;
        apr_pool_clear(p);
        conn = connection_make(p, worker);
    }
    else if (!conn->sock
| (conn->connection
                 && conn->connection->keepalive == AP_CONN_CLOSE)
| !ap_proxy_connection_reusable(conn)) {
        socket_cleanup(conn);
    }
    /* ... */
    if (worker->s->hmax && worker->cp->res) {
        conn->inreslist = 1;
        apr_reslist_release(worker->cp->res, (void *)conn);
    }
    else {
        worker->cp->conn = conn;
    }

modules/proxy/proxy_util.c 1670–1697행

proxy_conn_rec 자체는 언제나 목록으로 돌아갑니다. 다만 백엔드가 연결을 닫겠다고 했거나(AP_CONN_CLOSE), 이번 요청에서 문제가 있어 close 가 켜졌으면 안의 소켓만 정리(socket_cleanup())한 빈 껍데기로 돌아갑니다. 다음에 그 껍데기를 빌린 요청은 소켓이 없으니 새로 연결합니다. 소켓이 살아 있는 채로 돌아왔다면 다음 요청이 그 연결을 다시 씁니다. 이것이 백엔드와의 keep-alive입니다.

정말 살아 있는지는 빌린 쪽이 쓰기 직전에 확인합니다. ap_proxy_check_connection() 이 그 일을 하고, 살아 있으면 trace2 수준으로 "reusing backend connection" 로그를 남깁니다(proxy_util.c 3726–3818행, 로그는 3785행).

예제: 연결이 다시 쓰이는 것 보기

연결 풀은 자식 프로세스마다 따로라서, 예제의 앞단은 StartServers 1, ServerLimit 1 로 자식 프로세스를 하나로 묶었습니다(ThreadsPerChild 25). 백엔드는 접근 로그에 자기에게 붙은 쪽(=프록시)의 주소와 포트를 남깁니다.

LogFormat "b$n peer=%h:%{remote}p Host=%{Host}i XFF=%{X-Forwarded-For}i XFH=%{X-Forwarded-Host}i XFS=%{X-Forwarded-Server}i \"%r\"" fwd

(b$n 은 run.sh 가 백엔드 번호로 바꿔 넣습니다.) 프록시를 지나 /one/ 으로 3번(예제 1, 3, 4의 첫 줄), /lb/ 로 6번(예제 2), /raw/ 로 1번(예제 4의 둘째 줄) 요청한 뒤의 백엔드 로그입니다. 포트 번호는 실행마다 다릅니다.

== backend 1 access_log
b1 peer=127.0.0.1:41900 Host=127.0.0.1:8081 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /hello.txt HTTP/1.1"
b1 peer=127.0.0.1:41902 Host=127.0.0.1:8081 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /hello.txt HTTP/1.1"
b1 peer=127.0.0.1:41902 Host=127.0.0.1:8081 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /hello.txt HTTP/1.1"
b1 peer=127.0.0.1:41902 Host=127.0.0.1:8081 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /hello.txt HTTP/1.1"
b1 peer=127.0.0.1:41900 Host=127.0.0.1:8081 XFF=203.0.113.7, 127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /hello.txt HTTP/1.1"
b1 peer=127.0.0.1:41900 Host=127.0.0.1:8081 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /sub HTTP/1.1"
== backend 2 access_log
b2 peer=127.0.0.1:49342 Host=127.0.0.1:8082 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /hello.txt HTTP/1.1"
b2 peer=127.0.0.1:49342 Host=127.0.0.1:8082 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /hello.txt HTTP/1.1"
b2 peer=127.0.0.1:49342 Host=127.0.0.1:8082 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /hello.txt HTTP/1.1"
b2 peer=127.0.0.1:49358 Host=127.0.0.1:8082 XFF=127.0.0.1 XFH=127.0.0.1:8080 XFS=localhost "GET /sub HTTP/1.1"

백엔드 1이 받은 요청 6개가 연결 두 개(포트 41900과 41902)로만 왔습니다. 앞단의 오류 로그에도 같은 연결을 다시 썼다는 줄이 6개 있습니다.

== front error_log: backend connection reuse
[trace2] [proxy] http: reusing backend connection 127.0.0.1:41902<>127.0.0.1:8081
[trace2] [proxy] http: reusing backend connection 127.0.0.1:49342<>127.0.0.1:8082
[trace2] [proxy] http: reusing backend connection 127.0.0.1:41902<>127.0.0.1:8081
[trace2] [proxy] http: reusing backend connection 127.0.0.1:49342<>127.0.0.1:8082
[trace2] [proxy] http: reusing backend connection 127.0.0.1:41900<>127.0.0.1:8081
[trace2] [proxy] http: reusing backend connection 127.0.0.1:41900<>127.0.0.1:8081

헷갈리기 쉬운 곳: 같은 백엔드인데 연결이 왜 둘이지?

백엔드 1(8081)은 ProxyPass /one/ http://127.0.0.1:8081/ 의 워커와 BalancerMember http://127.0.0.1:8081 의 워커, 워커 두 개의 대상입니다. 앞의 것은 서버 설정의 workers 배열에, 뒤의 것은 밸런서의 workers 배열에 따로 들어갑니다(ap_proxy_define_worker_ex() proxy_util.c 2139–2160행). 연결 풀은 워커마다 있으니 연결도 따로입니다. 로그에서 /one/ 요청(1·5·6번째 줄)은 41900, 밸런서를 지난 요청(2–4번째 줄)은 41902로 갔습니다. 백엔드 2의 마지막 줄(/raw/, 49358)도 같은 이유로 밸런서 쪽 연결(49342)과 다릅니다. "백엔드 서버 하나 = 연결 풀 하나"가 아니라 "워커 하나 = 연결 풀 하나" 입니다.

그 밖에 기억해 둘 것 두 가지입니다.

5. mod_proxy_http — 요청을 보내고 응답을 흘려보내기

여섯 단계

proxy_http_handler() 는 소스 주석에 Step One부터 Step Six까지 번호를 매겨 두었습니다. 가운데 부분입니다.

        /* Step Two: Make the Connection */
        if (ap_proxy_check_connection(scheme, backend, r->server, 1,
                                      PROXY_CHECK_CONN_EMPTY)
                && ap_proxy_connect_backend(scheme, backend, worker,
                                            r->server)) {
            /* ... */
            status = HTTP_SERVICE_UNAVAILABLE;
            break;
        }

        /* Step Three: Create conn_rec */
        if ((status = ap_proxy_connection_create_ex(scheme, backend, r)) != OK)
            break;
        req->origin = backend->connection;
        /* ... */
        /* Step Four: Send the Request
         * On the off-chance that we forced a 100-Continue as a
         * kinda HTTP ping test, allow for retries
         */
        status = ap_proxy_http_request(req);
        /* ... */
        /* Step Five: Receive the Response... Fall thru to cleanup */
        status = ap_proxy_http_process_response(req);

modules/proxy/mod_proxy_http.c 2127–2169행

mod_proxy_http가 요청 하나를 처리하는 순서. 풀에서 proxy_conn_rec을 꺼내고, Step One에서 주소와 포트를 정하고, prefetch에서 요청 줄과 헤더를 브리게이드로 만든다(X-Forwarded-*가 여기서 붙는다). Step Two에서 꺼낸 연결이 살아 있으면 재사용하고 아니면 새 소켓을 연결한다. Step Three에서 conn_rec을 준비하고, Step Four에서 백엔드 연결의 출력 필터로 보내고, Step Five에서 백엔드에서 읽은 브리게이드를 클라이언트로 넘긴 뒤, 다시 쓸 수 있으면 연결을 풀에 돌려놓는다. 노란 칸이 4절의 연결 풀과 닿는 곳입니다.

  1. 연결 빌리기 — 함수 첫머리에서 ap_proxy_acquire_connection()(1969행).
  2. Step One — ap_proxy_determine_connection() 으로 붙을 주소와 포트를 정합니다(2069–2075행).
  3. 헤더 만들기 — ap_proxy_http_prefetch() 가 ap_proxy_create_hdrbrgd() 를 불러 백엔드로 보낼 요청 줄과 헤더를 브리게이드로 만듭니다(573행). 요청 본문도 조금 미리 읽어 둡니다.
  4. Step Two — ap_proxy_check_connection() 이 0(살아 있음)을 돌려주면 && 뒤의 ap_proxy_connect_backend() 는 불리지 않습니다. 이 짧은 조건식이 "다시 쓰거나, 새로 연결하거나"의 갈림길입니다.
  5. Step Three — 새 소켓이면 ap_create_connection() 으로 conn_rec 을 만들고 pre_connection 훅을 돌려 필터를 붙입니다(proxy_util.c 4150행, 4204행). 9편에서 클라이언트 연결이 받던 대접을 백엔드 연결도 그대로 받습니다.
  6. Step Four·Five — 보내고 받습니다(아래).
  7. Step Six — ap_proxy_release_connection() 으로 돌려놓습니다(2181–2186행).

보내기: 백엔드 연결의 출력 필터로

보내는 일은 결국 ap_proxy_pass_brigade() 의 한 줄로 모입니다.

    status = ap_pass_brigade(origin->output_filters, bb);

modules/proxy/proxy_util.c 5157행

origin 은 Step Three의 백엔드 conn_rec 입니다. 응답을 클라이언트에게 보낼 때 쓰던 ap_pass_brigade() 를 방향만 바꿔 백엔드 쪽 필터 사슬에 넘깁니다.

받기: 읽은 브리게이드를 그대로 넘긴다

응답을 받을 때 mod_proxy_http는 백엔드 연결 위에 가짜 request_rec 을 하나 만듭니다(make_fake_req(), 780–817행). 1절의 PROXYREQ_RESPONSE 가 이 가짜 요청의 표시입니다(1133–1137행). 응답 헤더를 읽은 뒤 본문은 반복문 하나로 옮깁니다.

            do {
                apr_off_t readbytes;
                apr_status_t rv;

                rv = ap_get_brigade(backend->r->input_filters, bb,
                                    AP_MODE_READBYTES, mode,
                                    req->sconf->io_buffer_size);
                /* ... */
                /* try send what we read */
                if (ap_pass_brigade(r->output_filters, pass_bb) != APR_SUCCESS
| c->aborted) {
                    /* ... */
                }
                /* ... */
            } while (!finish);

modules/proxy/mod_proxy_http.c 1711–1869행

백엔드 연결의 입력 필터에서 한 덩이(io_buffer_size 만큼) 읽어 클라이언트 요청의 출력 필터로 넘기고, 이것을 EOS 버킷(끝 표시)이 나올 때까지 되풀이합니다. 응답 전체를 메모리에 모았다가 보내지 않으므로 큰 파일을 프록시해도 메모리가 크게 늘지 않습니다. 그 사이에 11편의 출력 필터(압축 같은 것)가 평소처럼 끼어들 수 있는 것도 이 구조 덕분입니다.

헷갈리기 쉬운 곳: 마지막 덩이를 보내기 전에 연결부터 돌려줍니다

반복문 안에서 EOS를 만나면 ap_pass_brigade() 로 클라이언트에게 보내기 전에 ap_proxy_release_connection() 으로 백엔드 연결을 먼저 돌려놓습니다(1814–1846행). 주석의 이유는 "백엔드가 느린 클라이언트를 기다리느라 묶여 있지 않게"입니다. 그래서 그 전에 버킷들을 apr_bucket_setaside() 로 요청 풀에 옮겨 둡니다 — 연결의 메모리는 연결이 돌아가면 다음 사람이 쓰기 때문입니다(4편의 "수명은 풀이 정한다"가 여기서도 그대로입니다).

백엔드가 받는 헤더: X-Forwarded-*

백엔드는 클라이언트가 아니라 프록시와 연결되어 있으므로, 원래 클라이언트가 누구였는지 알 길이 없습니다. 그래서 ap_proxy_create_hdrbrgd() 가 헤더를 셋 붙입니다.

    if (dconf->add_forwarded_headers) {
        if (PROXYREQ_REVERSE == r->proxyreq) {
            /* Add X-Forwarded-For: so that the upstream has a chance to
             * determine, where the original request came from.
             */
            apr_table_mergen(r->headers_in, "X-Forwarded-For",
                             r->useragent_ip);

            /* Add X-Forwarded-Host: so that upstream knows what the
             * original request hostname was.
             */
            if (saved_host) {
                apr_table_mergen(r->headers_in, "X-Forwarded-Host",
                                 saved_host);
            }

            /* Add X-Forwarded-Server: so that upstream knows what the
             * name of this proxy server is (if there are more than one)
             * XXX: This duplicates Via: - do we strictly need it?
             */
            apr_table_mergen(r->headers_in, "X-Forwarded-Server",
                             r->server->server_hostname);
        }
    }

modules/proxy/proxy_util.c 4821–4844행

add_forwarded_headers 는 ProxyAddHeaders 디렉티브이고 기본값이 켜짐(1)입니다(mod_proxy.c 1815행). apr_table_mergen() 은 같은 이름의 헤더가 이미 있으면 덮어쓰지 않고 , 로 이어 붙입니다. 앞의 백엔드 로그에서 이것을 다 볼 수 있습니다.

포워드 프록시로 보낸 예제 6에서는 셋 다 없습니다(- 는 LogFormat이 "헤더 없음"을 적는 방식). 예제 6은 ProxyPass 없이 ProxyRequests On 만 둔 별도의 앞단(8090)으로 보냈습니다 — /raw/ 의 워커(http://127.0.0.1:8082/)가 있는 앞단이었다면 이름이 맞는 그 워커가 쓰였을 것입니다. 포트 번호는 실행마다 다릅니다.

== 6. forward proxy (curl -x)
hello from backend 2 (port 8082)
b2 peer=127.0.0.1:48462 Host=127.0.0.1:8082 XFF=- XFH=- XFS=- "GET /hello.txt HTTP/1.1"
[trace2] [proxy] *: found forward proxy worker for http://127.0.0.1:8082/hello.txt

마지막 줄은 3절에서 본 ap_proxy_pre_request() 가 이름이 맞는 워커를 찾지 못해 포워드용 기본 워커를 쓴 기록입니다(2666–2669행).

응답 헤더 고치기: ProxyPassReverse

반대 방향의 문제도 있습니다. 백엔드가 Location: http://127.0.0.1:8082/sub/ 처럼 자기 주소로 리다이렉트를 보내면, 브라우저는 숨겨 둔 백엔드로 직접 가려다 실패합니다. 응답 헤더를 하나씩 옮겨 적는 process_proxy_header() 가 이름이 정해진 헤더 몇 개만 고쳐 적습니다.

    static const struct {
        const char *name;
        ap_proxy_header_reverse_map_fn func;
    } transform_hdrs[] = {
        { "Location", ap_proxy_location_reverse_map },
        { "Content-Location", ap_proxy_location_reverse_map },
        { "URI", ap_proxy_location_reverse_map },
        { "Destination", ap_proxy_location_reverse_map },
        { "Set-Cookie", ap_proxy_cookie_reverse_map },
        { NULL, NULL }
    };

modules/proxy/mod_proxy_http.c 825–835행

헤더 이름과 함수 포인터를 짝지은 표입니다(2편에서 본 C다운 설계). ap_proxy_location_reverse_map() 은 ProxyPassReverse 로 등록한 목록을 돌며, 값이 real(백엔드 주소)로 시작하면 그 부분을 fake(프록시 쪽 경로)로 바꿉니다(proxy_util.c 886–995행). 예제 4에서 디렉터리 sub 를 끝 슬래시 없이 요청해 백엔드의 mod_dir이 리다이렉트를 보내게 했습니다(12편).

== 4. Location header: with / without ProxyPassReverse
Location: http://127.0.0.1:8080/one/sub/
Location: http://127.0.0.1:8082/sub/

ProxyPassReverse 가 있는 /one/ 은 프록시 주소로 고쳐졌고, 없는 /raw/ 는 백엔드 주소가 그대로 새어 나왔습니다.

6. 밸런서 — byrequests는 어떻게 번갈아 고르나

pre_request 훅에서 워커를 바꿔 끼운다

balancer://pair 는 진짜 주소가 아닙니다. 3절의 ap_proxy_pre_request() 가 돌리는 pre_request 훅에 mod_proxy_balancer가 proxy_balancer_pre_request() 를 걸어 두었습니다(mod_proxy_balancer.c 2069행). URL이 balancer:// 로 시작하지 않으면 DECLINED, 맞으면 밸런서를 찾아(472–480행) 멤버 가운데 하나를 고르고, URL을 그 멤버의 진짜 주소로 고쳐 씁니다. 예제 로그의 AH01172가 그 순간입니다.

== front error_log: balancer:// rewritten
[debug] [proxy_balancer] AH01172: balancer://pair: worker (http://127.0.0.1:8081) rewritten to http://127.0.0.1:8081/hello.txt
[debug] [proxy_balancer] AH01172: balancer://pair: worker (http://127.0.0.1:8082) rewritten to http://127.0.0.1:8082/hello.txt

그 뒤의 스킴 핸들러는 밸런서가 있었다는 것을 모른 채 평범한 http:// URL과 워커를 받습니다. 밸런서를 붙이는 데 mod_proxy_http는 한 줄도 바뀌지 않는 구조입니다.

고르는 규칙은 프로바이더로

멤버를 고르는 함수는 밸런서가 들고 있는 lbmethod 의 함수 포인터 finder 입니다.

    candidate = (*balancer->lbmethod->finder)(balancer, r);

    if (candidate)
        candidate->s->elected++;

modules/proxy/mod_proxy_balancer.c 353–356행

lbmethod 는 proxy_balancer_method 구조체이고(mod_proxy.h 588–596행), 각 알고리즘 모듈이 이것을 13편의 프로바이더로 등록합니다.

static void register_hook(apr_pool_t *p)
{
    ap_register_provider(p, PROXY_LBMETHOD, "byrequests", "0", &byrequests);
    ap_hook_post_config(lbmethod_byrequests_post_config, NULL, NULL, APR_HOOK_MIDDLE);
}

modules/proxy/balancers/mod_lbmethod_byrequests.c 151–155행

밸런서를 정의할 때 기본값으로 "byrequests" 를 이름으로 찾아 넣고(proxy_util.c 1237행), ProxySet lbmethod=bytraffic 처럼 바꾸면 그 이름으로 다시 찾습니다(mod_proxy.c 457–473행). 같은 폴더에 bybusyness, bytraffic, heartbeat 가 같은 방식으로 있습니다.

byrequests의 계산

알고리즘 전체가 함수 둘, 열 줄 남짓입니다.

static int is_best_byrequests(proxy_worker *current, proxy_worker *prev_best, void *baton)
{
    int *total_factor = (int *)baton;

    current->s->lbstatus += current->s->lbfactor;
    *total_factor += current->s->lbfactor;

    return (!prev_best || (current->s->lbstatus > prev_best->s->lbstatus));
}

modules/proxy/balancers/mod_lbmethod_byrequests.c 28–36행

static proxy_worker *find_best_byrequests(proxy_balancer *balancer,
                                request_rec *r)
{
    int total_factor = 0;
    proxy_worker *worker = ap_proxy_balancer_get_best_worker_fn(balancer, r, is_best_byrequests, &total_factor);

    if (worker) {
        worker->s->lbstatus -= total_factor;
    }

    return worker;
}

modules/proxy/balancers/mod_lbmethod_byrequests.c 83–94행

ap_proxy_balancer_get_best_worker_fn 은 mod_proxy가 내놓은 공통 함수로, 쓸 수 있는 멤버마다 is_best 콜백을 불러 "지금까지 최고보다 낫나?"를 묻습니다(proxy_util.c 1391–1505행, 콜백 호출은 1466행). 고장 난 멤버 건너뛰기, 예비(spare)·대기(standby) 멤버 같은 공통 규칙은 거기서 처리하고, 알고리즘 모듈은 "더 나은가"만 정합니다.

byrequests의 한 번의 선택. 요청이 오면 쓸 수 있는 워커마다 lbstatus에 lbfactor를 더하고 total에도 더한다. lbstatus가 가장 큰 워커를 고르되 같으면 먼저 본 워커를 고른다. 고른 워커만 lbstatus에서 total을 뺀다. 그 워커로 보내고 elected를 하나 늘린 뒤 다음 요청에서 되풀이한다. 노란 칸이 공유 메모리의 lbstatus 를 고치는 곳입니다.

말로 하면 이렇습니다. lbfactor 는 "이 멤버가 맡을 몫", lbstatus 는 "몫에 비해 얼마나 밀려 있나"입니다. 매번 모두에게 몫만큼 더해 주고, 가장 밀린 멤버를 골라 일을 시킨 뒤, 그 멤버에게서 이번에 나눠 준 몫의 합을 뺍니다. 그래서 lbstatus 의 합은 늘 그대로이고(소스 주석의 "the sum of all lbstatus does not change", 50–51행), 몫이 큰 멤버일수록 자주 뽑힙니다.

lbfactor 의 기본값은 100이고(proxy_util.c 2171행), loadfactor=2 처럼 적으면 100을 곱한 200이 들어갑니다(mod_proxy.c 107–116행). 예제처럼 두 멤버가 모두 100이면 계산은 이렇게 흘러갑니다.

요청 더한 뒤 (8081, 8082) 고른 멤버 뺀 뒤 (8081, 8082)
1 100, 100 8081 (같으면 먼저 본 쪽) -100, 100
2 0, 200 8082 0, 0
3 100, 100 8081 -100, 100
4 0, 200 8082 0, 0

예제: 번갈아 가는 것 보기

/lb/hello.txt 를 6번 요청했습니다.

== 2. balancer://pair, 6 requests
hello from backend 1 (port 8081)
hello from backend 2 (port 8082)
hello from backend 1 (port 8081)
hello from backend 2 (port 8082)
hello from backend 1 (port 8081)
hello from backend 2 (port 8082)

앞단의 오류 로그(LogLevel proxy_balancer:debug 와 proxy:trace2 를 켰습니다)에 고른 결과와 그때의 lbstatus 가 찍힙니다.

== front error_log: byrequests choices
[debug] [proxy] AH10123: proxy: byrequests selected worker "http://127.0.0.1:8081" : busy 0 : lbstatus 100
[debug] [proxy] AH10123: proxy: byrequests selected worker "http://127.0.0.1:8082" : busy 0 : lbstatus 200
[debug] [proxy] AH10123: proxy: byrequests selected worker "http://127.0.0.1:8081" : busy 0 : lbstatus 100
[debug] [proxy] AH10123: proxy: byrequests selected worker "http://127.0.0.1:8082" : busy 0 : lbstatus 200
[debug] [proxy] AH10123: proxy: byrequests selected worker "http://127.0.0.1:8081" : busy 0 : lbstatus 100
[debug] [proxy] AH10123: proxy: byrequests selected worker "http://127.0.0.1:8082" : busy 0 : lbstatus 200

위 표의 "더한 뒤" 칸과 같습니다. 이 로그 줄은 공통 함수가 고른 직후, find_best_byrequests() 가 total 을 빼기 전에 찍힙니다(proxy_util.c 1498–1502행). 100과 200이 번갈아 나오는 이유입니다.

헷갈리기 쉬운 곳

이 글에서 뺀 것

큰 줄기를 위해 많은 것을 이름만 남깁니다.

정리

개념 한 줄 요약
r->proxyreq 포워드(PROXYREQ_PROXY)·리버스(PROXYREQ_REVERSE)·백엔드 응답(PROXYREQ_RESPONSE)을 가르는 필드
proxy_detect() post_read_request 에서 전체 URL 요청을 포워드 프록시 요청으로 표시
ProxyPass 설정 때 별칭과 워커를 만들고, 요청 때 translate_name 에서 r->filename = "proxy:…" 로 가로챈다. 적은 순서대로 처음 맞는 것
"proxy:" 표지 이후 map_to_storage(<Proxy> 걷기)·fixups·handler 가 모두 이것을 본다
proxy_handler() 워커를 고르고(pre_request) 스킴 핸들러 훅을 부르는 공통 틀
스킴 핸들러 훅 mod_proxy가 연 RUN_FIRST 훅. mod_proxy_http는 http·https·ws·wss 만 맡고 나머지는 DECLINED
proxy_worker 백엔드 URL 하나. 공유할 값(s)과 프로세스별 연결 풀(cp)로 나뉜다
연결 풀 APR-util apr_reslist. 기본 상한은 자식의 스레드 수. 소켓이 살아 있으면 다음 요청이 다시 쓴다
mod_proxy_http 헤더를 브리게이드로 만들어 백엔드 conn_rec 의 필터로 보내고, 응답은 ap_get_brigade → ap_pass_brigade 로 흘려보낸다
X-Forwarded-* 리버스 프록시일 때만, 이미 있으면 이어 붙인다
ProxyPassReverse 응답의 Location 등에서 백엔드 주소를 프록시 경로로 바꾼다
밸런서 pre_request 훅에서 멤버를 골라 URL을 바꿔 끼운다. 고르는 규칙(lbmethod)은 프로바이더
byrequests 모두에게 몫(lbfactor)을 더하고, 가장 밀린 멤버를 골라 몫의 합을 뺀다

다음에 볼 것

이 글에서 https:// 백엔드는 mod_proxy_http가 backend->is_ssl 을 켜면 Step Three의 연결 준비 코드(proxy_util.c)가 ap_proxy_ssl_engine() 으로 연결에 TLS를 켜 달라고 부탁하는 데까지만 봤습니다. 그 부탁을 받는 쪽, 곧 TLS가 클라이언트 연결과 백엔드 연결에 필터로 끼어드는 방법이 다음 글의 주제입니다. 15편: mod_ssl — TLS는 어디에 끼어드나에서 이어집니다.

Spring 연재와 비교하면 두 군데가 닮았습니다. 연결을 빌리고 돌려주는 짝(ap_proxy_acquire_connection() / ap_proxy_release_connection())은 Spring 18편의 DataSourceUtils.getConnection() / releaseConnection() 과 같은 자리에 있습니다. 다만 Spring은 풀 자체를 DataSource 구현에 맡기고, httpd는 워커마다 apr_reslist 를 직접 들고 있습니다. 그리고 mod_proxy_http는 서버이면서 동시에 HTTP 클라이언트인데, Spring에서 보내는 쪽을 맡는 것은 Spring 24편 5절의 WebClient 입니다.

같은 리버스 프록시를 nginx 가 작업 스레드 없이 이벤트 루프로 어떻게 돌리는지는 nginx 소스 파헤치기 11: upstream과 proxy에서 이 글과 나란히 놓고 봅니다.

다른 글