이 글에서 다루는 것
"Apache httpd 소스 파헤치기" 연재의 열네 번째 글입니다. 13편에서는 Require 한 줄이 판정되는 과정을 보면서, 모듈이 이름표를 붙여 기능을 내놓고 다른 모듈이 그 이름으로 찾아 쓰는 프로바이더를 만났습니다. 이번 글은 httpd가 응답을 스스로 만들지 않고 다른 서버에게 받아 오는 경우, 곧 mod_proxy를 봅니다. httpd를 Tomcat이나 Node.js 앞에 세워 두는 설정(ProxyPass /app/ http://127.0.0.1:8080/)을 한 번쯤 써 봤다면 그 한 줄 뒤에서 일어나는 일입니다.
답하려는 질문은 네 가지입니다.
- 포워드 프록시와 리버스 프록시는 소스에서 무엇이 다른가
ProxyPass한 줄은 요청 처리 단계의 어디에 끼어들어 요청을 가로채는가- 백엔드로 가는 연결은 요청마다 새로 여는가, 다시 쓰는가 — 다시 쓴다면 어디에 보관하는가
balancer://는 여러 백엔드 가운데 하나를 어떻게 고르는가
| 항목 | 내용 |
|---|---|
| 대상 | 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)는 "대신 가 주는 서버"입니다. 클라이언트의 요청을 받아 다른 서버에 똑같이 보내고, 받은 응답을 클라이언트에게 돌려줍니다. 누구를 위해 대신 가 주느냐에 따라 둘로 나뉩니다.
- 포워드 프록시는 클라이언트 쪽 대리인입니다. 회사 안의 브라우저가 "이 프록시를 거쳐 나가라"고 설정되어 있고, 브라우저는 목적지 주소를 알고 있습니다.
- 리버스 프록시는 서버 쪽 대리인입니다. 브라우저는 httpd가 끝이라고 생각하지만, httpd가 뒤에 숨은 서버에게 대신 물어 옵니다.
백엔드(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 하나의 차이
같은 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행). 하는 일은 두 가지입니다.
- 별칭(
struct proxy_alias)을 하나 만들어 서버 설정의aliases배열에 넣습니다(2119행).fake에/one/,real에http://127.0.0.1:8081/이 들어갑니다(mod_proxy.h 134–140행). - 대상이
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 이 남긴 표지입니다.
나머지 두 훅도 이 표지를 봅니다.
map_to_storage의proxy_map_location()은r->filename이proxy:로 시작하면proxy_walk()를 돌리고OK를 돌려줍니다(1154–1170행). 파일이 아니니 디렉터리 걷기는 건너뛰고, 그 대신<Proxy "...">구역들을proxy:뒤의 URL과 비교해 설정을 합칩니다(1078–1152행).fixups의proxy_fixup()은 같은 표지를 확인하고ap_proxy_canon_url()로 URL을 정리합니다(1180–1185행). 정리 방법은 스킴마다 달라서 이것도 하위 모듈이 거는canon_handler훅으로 넘어갑니다.
헷갈리기 쉬운 곳
<Directory>는 프록시 요청에 걸리지 않습니다. 위에서 본 대로 프록시 요청에는 디렉터리 걷기가 없습니다. 프록시 대상에 설정을 걸 때<Proxy>를 쓰는 이유가 이것입니다.<Location>은 URL 기준이라 걸립니다(10편 4절).ProxyPass는 적은 순서대로 비교하고 처음 맞는 것을 씁니다. 위 반복문은 맞으면 바로return합니다.ProxyPass / ...를ProxyPass /app/ ...보다 먼저 적으면/app/은 영영 닿지 않습니다. 긴 경로를 먼저 적어야 합니다.proxy_handler()는 핸들러 이름"proxy-server"를 보지 않습니다. 다음 절의 코드처럼r->filename의proxy:와r->proxyreq로 판단합니다."proxy-server"라는 이름은mod_dir,mod_rewrite같은 다른 모듈이 "이건 프록시 요청이구나" 하고 알아보는 데 쓰입니다(grep -rn '"proxy-server"' modules로 찾아보면 mod_dir.c 214행, mod_rewrite.c 5103행 등).
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 처리와 오류 분기를 줄였습니다)
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)를 씁니다.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 입니다.
실제 순서는 각 모듈이 건 자리(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행
워커는 두 부분으로 나뉩니다.
s—proxy_worker_shared(430–503행): 이름, 스킴, 호스트, 상태 비트(status), 밸런서가 쓰는lbfactor·lbstatus, 연결 개수 상한hmax같은 값입니다. 이름에 "shared"가 붙은 것은 여러 자식 프로세스가 같이 봐야 하는 값이기 때문입니다. 밸런서 멤버의 이 부분은 mod_proxy_balancer가 공유 메모리(mod_slotmem_shm이 내주는 slotmem)로 옮깁니다(mod_proxy_balancer.c 1026행,ap_proxy_share_worker()proxy_util.c 2247–2277행). 밸런서에 속하지 않은 워커는 공유 메모리를 쓰지 않는다고 소스에 적혀 있습니다("non-balancer members don't use shm at all", mod_proxy.c 3340행).cp—proxy_conn_pool: 이 워커로 가는 연결을 모아 둔 풀입니다. 소켓은 프로세스마다 따로이므로 이쪽은 공유하지 않고 자식 프로세스마다 한 벌입니다.
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_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)과 다릅니다. "백엔드 서버 하나 = 연결 풀 하나"가 아니라 "워커 하나 = 연결 풀 하나" 입니다.
그 밖에 기억해 둘 것 두 가지입니다.
- 자식 프로세스가 여럿이면 풀도 여럿입니다. 예제에서 자식을 하나로 묶은 이유입니다.
- 어느 워커와도 이름이 겹치지 않는 포워드 요청(
ProxyRequests On)이나 리버스 요청이 쓰는 기본 워커는 연결을 다시 쓰지 않습니다.child_init()에서 만들 때is_address_reusable = 0을 넣고(mod_proxy.c 3365행, 3389행), 위connection_cleanup()의 첫 갈래가 매번 연결을 버립니다.
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행
노란 칸이 4절의 연결 풀과 닿는 곳입니다.
- 연결 빌리기 — 함수 첫머리에서
ap_proxy_acquire_connection()(1969행). - Step One —
ap_proxy_determine_connection()으로 붙을 주소와 포트를 정합니다(2069–2075행). - 헤더 만들기 —
ap_proxy_http_prefetch()가ap_proxy_create_hdrbrgd()를 불러 백엔드로 보낼 요청 줄과 헤더를 브리게이드로 만듭니다(573행). 요청 본문도 조금 미리 읽어 둡니다. - Step Two —
ap_proxy_check_connection()이 0(살아 있음)을 돌려주면&&뒤의ap_proxy_connect_backend()는 불리지 않습니다. 이 짧은 조건식이 "다시 쓰거나, 새로 연결하거나"의 갈림길입니다. - Step Three — 새 소켓이면
ap_create_connection()으로conn_rec을 만들고pre_connection훅을 돌려 필터를 붙입니다(proxy_util.c 4150행, 4204행). 9편에서 클라이언트 연결이 받던 대접을 백엔드 연결도 그대로 받습니다. - Step Four·Five — 보내고 받습니다(아래).
- 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() 은 같은 이름의 헤더가 이미 있으면 덮어쓰지 않고 , 로 이어 붙입니다. 앞의 백엔드 로그에서 이것을 다 볼 수 있습니다.
XFF=127.0.0.1— curl이 컨테이너 안에서 보냈으니 원래 클라이언트 주소가 127.0.0.1입니다.XFF=203.0.113.7, 127.0.0.1— 예제 3에서 curl이X-Forwarded-For: 203.0.113.7을 직접 붙여 보냈더니 그 뒤에 이어 붙었습니다. 프록시가 여러 단이면 이렇게 길어집니다. 맨 앞 값은 클라이언트가 마음대로 적을 수 있다는 뜻이기도 합니다.XFH=127.0.0.1:8080과Host=127.0.0.1:8081— 원래Host는X-Forwarded-Host로 옮겨지고, 백엔드로 가는Host는 백엔드 주소로 바뀌었습니다.ProxyPreserveHost가 꺼져 있을 때(기본, 1810–1811행)의 동작입니다(proxy_util.c 4716행).XFS=localhost— 앞단의ServerName localhost입니다.
포워드 프록시로 보낸 예제 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) 멤버 같은 공통 규칙은 거기서 처리하고, 알고리즘 모듈은 "더 나은가"만 정합니다.
노란 칸이 공유 메모리의 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이 번갈아 나오는 이유입니다.
헷갈리기 쉬운 곳
lbstatus는 공유 메모리에 있지만, 고르는 동안 거는 잠금은 스레드 잠금입니다.find_best_worker()는PROXY_THREAD_LOCK으로 감싸는데(mod_proxy_balancer.c 345행), 이 매크로는 밸런서의apr_thread_mutex_t, 곧 한 프로세스 안의 잠금입니다(mod_proxy.h 599행). 바로 위pre_request의 주석에도 "XXX: perhaps we need the process lock here"라고 적혀 있습니다(483행). 그러니 자식 프로세스가 여럿이면 "정확히 번갈아"는 소스가 보장하는 성질이 아닙니다. 예제의 깔끔한 교대는 자식 프로세스가 하나이고 요청을 하나씩 보냈기 때문입니다.- mod_proxy_balancer는 mod_slotmem_shm 없이는 뜨지 않습니다.
post_config에서"shm"프로바이더를 찾지 못하면 "is mod_slotmem_shm loaded??" 를 남기고 시작을 멈춥니다(872–880행). 예제 설정에slotmem_shm_module을 실은 이유입니다. 여기서도 공유 메모리 구현을 프로바이더로 찾습니다.
이 글에서 뺀 것
큰 줄기를 위해 많은 것을 이름만 남깁니다.
- 세션 고정(sticky session) — 쿠키나 URL의 route로 같은 멤버에 계속 보내기.
find_session_route()(mod_proxy_balancer.c 270행). - balancer-manager — 웹 화면에서 멤버를 켜고 끄기.
balancer_handler()(1900행). - 실패와 재시도 — 워커를
PROXY_WORKER_IN_ERROR로 표시하고retry초 뒤에 다시 써 보기.proxy_handler()의 반복 아래쪽(mod_proxy.c 1492–1528행)과ap_proxy_retry_worker()(proxy_util.c 2429행). 상태 검사는mod_proxy_hcheck.c. - WebSocket과 터널 —
Upgrade요청을 양방향 터널로 바꾸는ap_proxy_tunnel_run()(proxy_util.c 5857행). - 비동기 처리 — 응답을 기다리는 동안 스레드를 놓아 주는
SUSPENDED경로(proxy_http_async_cb(), mod_proxy_http.c 369행). 100-continue, 요청 본문 스풀링 —ap_proxy_http_prefetch()(558행)의 나머지.ProxyPassMatch,mapping=servlet,unix:소켓,ProxyRemote(프록시 뒤의 프록시) —ap_proxy_trans_match()와proxy_handler()의 나머지 갈래.mod_proxy_beacon.c— trunk에만 있는 파일입니다(2.4.x 브랜치에는 없습니다). 이 글에서는 읽지 않았습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
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에서 이 글과 나란히 놓고 봅니다.