이 글에서 다루는 것
"Apache httpd 소스 파헤치기" 연재의 열한 번째 글입니다. 10편에서는 요청 하나가 URL 에서 파일과 핸들러에 이르는 단계를 따라갔습니다. 핸들러가 불리면 응답을 만들기 시작합니다. 이번에는 그 응답이 어떤 모양으로, 누구를 거쳐 소켓까지 가는지를 봅니다.
답하려는 질문은 네 가지입니다.
- 핸들러가
ap_rputs()로 쓴 글자, 정적 파일의 내용은 httpd 안에서 무엇에 담겨 움직이는가 (버킷과 브리게이드) - 왜 그냥
char *버퍼가 아니라 이런 구조를 쓰는가 — 파일 버킷과sendfile, 읽을 때 모양이 바뀌는 버킷 SetOutputFilter DEFLATE같은 설정 한 줄로 끼우는 필터는 어떤 순서로 줄을 서고, 어떻게 다음 필터로 넘기는가- 맨 아래의 코어 출력 필터는 소켓에 어떻게 쓰고, 소켓이 가득 차면 무엇을 하는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache httpd |
| 버전 | 2.5.1-dev (trunk) |
| 커밋 | e4af30fe6d (2026-10-24) |
| APR-util | 1.6.3 (태그 1.6.3, 커밋 28327eeca2) — 버킷과 브리게이드 |
| APR | 1.7.2 (태그 1.7.2, 커밋 e905526629) — 링 매크로 |
| 주로 읽는 파일 | APR-util include/apr_buckets.h, buckets/apr_buckets_*.c / httpd include/util_filter.h, server/util_filter.c, server/core_filters.c, modules/http/http_filters.c, modules/http/chunk_filter.c |
줄 번호는 모두 이 커밋 기준입니다. 같은 소스를 받는 방법과 예제를 돌리는 도커 환경은 1편에 있습니다. 필터와 버킷은 코드가 넓게 퍼져 있어서, 이 글은 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름만 적어 두었습니다.
먼저 알아 둘 말
버킷(bucket)은 응답(또는 요청)의 데이터 한 조각을 가리키는 작은 구조체입니다. "메모리의 이 15바이트", "디스크의 이 파일 0번째부터 1000바이트", "이 파이프에서 앞으로 나올 것"처럼 데이터가 어디에 있는지를 적어 둡니다. 데이터를 꼭 들고 있지는 않다는 것이 핵심입니다.
브리게이드(bucket brigade)는 버킷을 줄줄이 이은 목록입니다. 원래 "양동이 릴레이"라는 뜻으로, 불을 끌 때 사람들이 줄을 서서 양동이를 손에서 손으로 넘기는 모습에서 왔습니다. httpd 의 필터들이 손에서 손으로 넘기는 것이 이 브리게이드입니다.
메타데이터 버킷은 데이터가 없는 표시용 버킷입니다. "응답이 끝났다"(EOS), "지금까지 것을 내보내라"(FLUSH) 같은 신호를 데이터와 같은 줄에 세워 보냅니다.
필터(filter)는 브리게이드를 받아 무언가를 하고 다음 필터에 넘기는 함수입니다. 압축(mod_deflate), SSI(mod_include), chunked 인코딩, TLS 암호화(mod_ssl)가 모두 필터입니다. 응답이 나가는 출력 필터와 요청이 들어오는 입력 필터가 따로 줄을 섭니다.
함수 포인터 표는 2편에서 본, 함수의 주소를 담은 구조체입니다. Java 의 인터페이스처럼 "같은 이름의 동작을 종류마다 다르게" 하는 C 의 방법이고, 버킷 종류가 정확히 이것으로 만들어져 있습니다. 풀(apr_pool_t)은 4편, 훅은 6편에서 봤습니다.
1. 버킷 — "데이터가 어디 있는지"를 적은 쪽지
버킷과 브리게이드는 httpd 가 아니라 APR-util 에 있습니다. httpd 는 그것을 가져다 씁니다. 버킷 구조체부터 봅니다. (/* ... */ 는 이 글에서 줄인 곳이고 나머지는 소스 그대로입니다.)
struct apr_bucket {
/** Links to the rest of the brigade */
APR_RING_ENTRY(apr_bucket) link;
/** The type of bucket. */
const apr_bucket_type_t *type;
/* ... */
apr_size_t length;
/* ... */
apr_off_t start;
/** type-dependent data hangs off this pointer */
void *data;
/* ... */
void (*free)(void *e);
/** The freelist from which this bucket was allocated */
apr_bucket_alloc_t *list;
};
APR-util include/apr_buckets.h 224–255행
link— 앞뒤 버킷을 가리키는 포인터 두 개입니다. 2절에서 봅니다.type— 이 버킷이 어떤 종류인지를 가리킵니다. 아래의 함수 포인터 표입니다.length— 데이터 길이. 아직 모르면(apr_size_t)(-1)입니다(232행 주석). 파이프처럼 끝까지 읽어 봐야 아는 경우입니다.start,data— 종류마다 다른 내용물과, 그 안에서 어디부터인지.
종류를 나타내는 apr_bucket_type_t 는 이름 하나, 데이터인지 메타데이터인지, 그리고 함수 포인터 다섯 개입니다.
struct apr_bucket_type_t {
/* ... */
const char *name;
/* ... */
int num_func;
/* ... */
enum {
/** This bucket type represents actual data to send to the client. */
APR_BUCKET_DATA = 0,
/** This bucket type represents metadata. */
APR_BUCKET_METADATA = 1
} is_metadata;
/* ... */
void (*destroy)(void *data);
/* ... */
apr_status_t (*read)(apr_bucket *b, const char **str, apr_size_t *len,
apr_read_type_e block);
/* ... */
apr_status_t (*setaside)(apr_bucket *e, apr_pool_t *pool);
/* ... */
apr_status_t (*split)(apr_bucket *e, apr_size_t point);
/* ... */
apr_status_t (*copy)(apr_bucket *e, apr_bucket **c);
};
APR-util include/apr_buckets.h 131–213행
| 함수 | 하는 일 |
|---|---|
read |
데이터를 읽어 포인터와 길이를 돌려준다. 필요하면 버킷 자신을 다른 종류로 바꾼다(3절) |
setaside |
지금 함수가 끝나도 데이터가 살아 있도록 더 오래 사는 풀로 옮긴다(5절) |
split |
한 버킷을 두 개로 자른다(데이터는 복사하지 않고 구조체만) |
copy |
같은 데이터를 가리키는 버킷을 하나 더 만든다 |
destroy |
버킷이 지워질 때 내용물을 정리한다 |
이 표는 종류마다 하나씩, 전역 상수로 있습니다. 예를 들어 EOS 버킷의 표는 이렇습니다.
APU_DECLARE_DATA const apr_bucket_type_t apr_bucket_type_eos = {
"EOS", 5, APR_BUCKET_METADATA,
apr_bucket_destroy_noop,
eos_bucket_read,
apr_bucket_setaside_noop,
apr_bucket_split_notimpl,
apr_bucket_simple_copy
};
APR-util buckets/apr_buckets_eos.c 47–54행
그리고 "버킷을 읽어라"는 매크로 하나입니다. #define apr_bucket_read(e,str,len,block) (e)->type->read(e, str, len, block)(1089행). 부르는 쪽은 버킷이 무슨 종류인지 몰라도 되고, 종류마다 자기 방식으로 읽습니다. 버킷이 어떤 종류인지 묻는 매크로도 이 표의 주소를 비교할 뿐입니다 — APR_BUCKET_IS_EOS(e) 는 ((e)->type == &apr_bucket_type_eos)(461행).
노란색은 메모리에 있는 것, 파란색은 함수 포인터 표입니다. 2편에서 본 "구조체 + 함수 포인터"가 그대로 쓰였습니다.
APR-util 이 준비해 둔 종류는 열 가지입니다(1188–1235행).
| 종류 | 데이터 / 메타 | 무엇을 가리키나 |
|---|---|---|
| HEAP | 데이터 | malloc 으로 잡은 메모리. 버킷이 지워질 때 함께 풀린다 |
| POOL | 데이터 | 풀에서 잡은 메모리. 풀이 먼저 지워지면 힙으로 옮겨진다 |
| TRANSIENT | 데이터 | 잠깐만 유효한 메모리(예: 함수의 지역 배열). 보관하려면 setaside 로 복사해야 한다 |
| IMMORTAL | 데이터 | 프로그램 내내 사는 메모리(문자열 상수 등). 아무것도 하지 않는다 |
| FILE | 데이터 | 열린 파일의 한 구간 |
| MMAP | 데이터 | 메모리에 매핑(mmap)한 파일 구간 |
| PIPE | 데이터 | 파이프. 길이를 모른다 |
| SOCKET | 데이터 | 소켓. 길이를 모른다 |
| EOS | 메타 | 응답(스트림)의 끝 |
| FLUSH | 메타 | 지금까지 것을 다음으로 내보내라 |
httpd 는 여기에 자기 종류를 더합니다. 표를 하나 더 만들면 되기 때문입니다. server/ 아래에 eor_bucket.c(EOR — 요청 하나의 끝), eoc_bucket.c(EOC — 연결의 끝), error_bucket.c(ERROR — HTTP 오류 상태), headers_bucket.c(REQUEST·RESPONSE·HEADERS)가 있고 모두 메타데이터 버킷입니다. 예를 들어 EOR 버킷은 지워질 때 요청 풀을 지웁니다.
static void eor_bucket_destroy(void *data)
{
ap_bucket_eor *h = data;
if (apr_bucket_shared_destroy(h)) {
request_rec *r = h->r;
if (r) {
/* eor_bucket_cleanup will be called when the pool gets destroyed */
apr_pool_destroy(r->pool);
}
apr_bucket_free(h);
}
}
그 풀 정리 함수 eor_bucket_cleanup() 이 접근 로그를 남기는 ap_run_log_transaction(r) 을 부릅니다(27–46행). 핸들러가 끝나면 ap_process_request_after_handler() 가 이 EOR 버킷을 만들어 연결 쪽 필터에 곧장 보냅니다(modules/http/http_request.c 336–363행). 그래서 요청의 메모리는 "핸들러가 끝났을 때"가 아니라 "응답의 마지막 바이트까지 소켓으로 보낸 뒤" 풀립니다. 응답이 아직 버퍼에 남아 있는데 요청 풀이 사라지면 안 되기 때문입니다. 접근 로그는 16편에서 다시 봅니다.
헷갈리기 쉬운 곳: headers_bucket.c 는 trunk 에만 있습니다
REQUEST·RESPONSE·HEADERS 버킷은 2.4.x 브랜치에는 없는 파일입니다. trunk 에서는 응답 헤더도 RESPONSE 라는 메타데이터 버킷으로 필터를 타고 내려가다가, HTTP/1.x 를 쓰는 필터에서 비로소 글자가 됩니다(6절). 2.4.x 에서는 헤더 필터가 곧바로 헤더 글자를 씁니다.
2. 브리게이드 — 끝이 처음으로 이어진 링
브리게이드 구조체는 짧습니다.
struct apr_bucket_brigade {
/* ... */
apr_pool_t *p;
/* ... */
APR_RING_HEAD(apr_bucket_list, apr_bucket) list;
/** The freelist from which this bucket was allocated */
apr_bucket_alloc_t *bucket_alloc;
};
APR-util include/apr_buckets.h 258–276행
APR_RING_HEAD 와 버킷 쪽의 APR_RING_ENTRY 는 APR 의 링(ring) 매크로입니다. 링은 "머리가 어디인지 몰라도 다룰 수 있는 이중 연결 리스트"입니다(APR include/apr_ring.h 43–44행). 매크로를 펼치면 그냥 포인터 두 개짜리 구조체입니다.
#define APR_RING_ENTRY(elem) \
struct { \
struct elem * volatile next; \
struct elem * volatile prev; \
}
#define 은 컴파일 전에 글자를 바꿔 치는 매크로이고, 줄 끝의 \ 는 "다음 줄도 이 매크로에 이어진다"는 뜻입니다(2편). 링의 특이한 점은 머리(브리게이드)도 링의 한 칸처럼 다룬다는 것입니다. 마지막 버킷의 next 는 NULL 이 아니라 머리를 가리키고, 그 머리를 센티널(sentinel, 보초)이라고 부릅니다. 그래서 브리게이드를 도는 코드는 언제나 이 모양입니다.
for (e = APR_BRIGADE_FIRST(bb);
e != APR_BRIGADE_SENTINEL(bb);
e = APR_BUCKET_NEXT(e)) {
/* ... */
}
위는 이 글의 예제 모듈(describe())에서 본문을 줄여 옮긴 것이고, httpd 의 거의 모든 필터에 같은 모양의 반복문이 있습니다.
정적 파일 하나를 보낼 때 소켓 바로 위에서 본 실제 브리게이드입니다(예제 1의 출력). 회색은 데이터가 없는 머리입니다.
링이 좋은 이유는 잇고 자르는 일이 포인터 몇 개만 바꾸면 끝난다는 것입니다. 브리게이드 둘을 합치는 APR_BRIGADE_CONCAT(a, b)(380–383행), 버킷을 끼워 넣는 APR_BUCKET_INSERT_BEFORE, 어느 버킷에서 둘로 가르는 apr_brigade_split_ex() 모두 데이터를 한 바이트도 옮기지 않습니다. 필터는 받은 버킷 사이에 새 버킷을 끼우고(chunked 의 길이 줄), 버킷을 빼고(압축 전 원본), 다른 버킷으로 갈아 끼우는(압축 결과) 일을 자주 하는데, 그때마다 복사가 없습니다.
3. 왜 이렇게 만들었나 — 복사하지 않기, 늦게 읽기
버킷 대신 char * 버퍼 하나를 필터에서 필터로 넘겼다면 어땠을지 생각해 보면 이 설계의 이유가 보입니다.
정적 파일을 보낼 때 파일을 읽지 않아도 됩니다. default_handler()(정적 파일을 보내는 핸들러, 12편)는 파일을 열고 내용을 읽는 대신 FILE 버킷 하나와 EOS 하나를 넣어 보냅니다.
e = apr_brigade_insert_file(bb, fd, 0, r->finfo.size, r->pool);
이 버킷이 아무도 손대지 않은 채 맨 아래 코어 출력 필터까지 가면, 코어 필터는 리눅스의 sendfile() 시스템 콜로 파일에서 소켓으로 커널 안에서 바로 보낼 수 있습니다(5절). 데이터가 사용자 메모리로 올라왔다 내려가는 복사가 없어서 이것을 흔히 "제로 카피"(zero-copy)라고 부릅니다.
읽어 봐야 아는 데이터는 읽을 때 모양을 바꿉니다. PIPE 버킷의 read 를 보면, 파이프에서 한 번 읽은 만큼을 HEAP 버킷으로 자기 자신을 바꾸고, 남은 파이프를 가리키는 새 PIPE 버킷을 바로 뒤에 끼웁니다.
if (*len > 0) {
apr_bucket_heap *h;
/* Change the current bucket to refer to what we read */
a = apr_bucket_heap_make(a, buf, *len, apr_bucket_free);
h = a->data;
h->alloc_len = APR_BUCKET_BUFF_SIZE; /* note the real buffer size */
*str = buf;
APR_BUCKET_INSERT_AFTER(a, apr_bucket_pipe_create(p, a->list));
}
else {
apr_bucket_free(buf);
a = apr_bucket_immortal_make(a, "", 0);
*str = a->data;
if (rv == APR_EOF) {
apr_file_close(p);
}
}
APR-util buckets/apr_buckets_pipe.c 58–74행
다 읽어서 더 나올 것이 없으면 길이 0 인 IMMORTAL 버킷이 되고 파이프를 닫습니다. SOCKET 버킷도 같은 방식입니다. FILE 버킷도 읽으면 바뀝니다 — mmap 을 쓸 수 있으면 MMAP 버킷으로, 아니면 읽은 만큼 HEAP 버킷과 나머지 FILE 버킷으로 나뉩니다(buckets/apr_buckets_file.c 75–152행). 이렇게 읽을 때 종류가 바뀌는 버킷을 소스 주석은 "morphing bucket"이라고 부릅니다(server/core_filters.c 552–553행).
예제 4(6절)에서 실제로 일어난 변화입니다. CGI 의 출력도 이렇게 파이프 버킷으로 흘러갑니다(mod_cgi.c 754행).
덕분에 CGI 프로그램의 출력처럼 몇 바이트가 될지 모르는 응답도, 누군가 실제로 필요로 할 때까지 읽지 않고 미룰 수 있습니다. 메모리에 한꺼번에 모을 필요가 없습니다.
예제 1 — 필터가 받는 버킷을 찍어 보기
말로만 하면 감이 오지 않으니 직접 확인해 봅니다. 출력 필터 두 개를 등록하는 작은 모듈을 썼습니다. 하나는 콘텐츠 쪽 맨 위(AP_FTYPE_RESOURCE)에, 하나는 소켓 바로 위(AP_FTYPE_CONNECTION)에 꽂고, 둘 다 받은 브리게이드의 버킷 종류와 길이를 한 줄로 찍은 뒤 그대로 다음 필터에 넘깁니다. 필터 종류는 4절에서 설명합니다.
/* 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다) */
/* ... */
/* 같은 함수를 두 이름으로 등록한다. 몇 번째 호출인지는 f->ctx 에 센다 */
static apr_status_t log_filter(ap_filter_t *f, apr_bucket_brigade *bb)
{
int *n = f->ctx;
if (n == NULL) {
n = f->ctx = apr_pcalloc(f->c->pool, sizeof(*n));
}
++*n;
if (*n == 1 && f->r) { /* 요청 필터의 첫 호출: 아래로 이어진 사슬을 찍는다 */
const char *chain = f->frec->name;
ap_filter_t *g;
for (g = f->next; g; g = g->next) {
chain = apr_pstrcat(f->c->pool, chain, " -> ", g->frec->name, NULL);
}
ap_log_cerror(APLOG_MARK, APLOG_NOTICE, 0, f->c, "chain: %s", chain);
}
ap_log_cerror(APLOG_MARK, APLOG_NOTICE, 0, f->c, "%-13s #%d:%s",
f->frec->name, *n, describe(bb, f->c->pool));
return ap_pass_brigade(f->next, bb); /* 손대지 않고 다음 필터로 */
}
/* ... */
/* 요청마다: 콘텐츠 쪽 맨 위에 BUCKETLOG_TOP 을 꽂는다 */
static void insert_filters(request_rec *r)
{
ap_add_output_filter("BUCKETLOG_TOP", NULL, r, r->connection);
}
/* 연결마다: 소켓 바로 위(CORE 앞)에 BUCKETLOG_NET 을 꽂는다 */
static int pre_conn(conn_rec *c, void *csd)
{
ap_add_output_filter("BUCKETLOG_NET", NULL, NULL, c);
return OK;
}
static void register_hooks(apr_pool_t *p)
{
ap_register_output_filter("BUCKETLOG_TOP", log_filter, NULL,
AP_FTYPE_RESOURCE);
ap_register_output_filter("BUCKETLOG_NET", log_filter, NULL,
AP_FTYPE_CONNECTION);
/* ... */
ap_hook_insert_filter(insert_filters, NULL, NULL, APR_HOOK_FIRST);
ap_hook_pre_connection(pre_conn, NULL, NULL, APR_HOOK_MIDDLE);
/* ... */
}
describe() 는 2절의 반복문으로 e->type->name 과 e->length 를 이어 붙이는 함수입니다(길이를 모르면 ?). 이 예제는 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev 로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. ErrorLogFormat "%M" 으로 오류 로그에 메시지만 남겼고, curl 로 정적 파일 하나(26바이트)를 받았습니다.
$ curl http://127.0.0.1:8080/index.html
Hello from a static file.
--- error_log
chain: bucketlog_top -> byterange -> content_length -> http_header -> http_outerror -> http1_response_out -> bucketlog_net -> core
bucketlog_top #1: FILE(26) EOS(0)
bucketlog_net #1: HEAP(228) FILE(26) EOS(0)
bucketlog_net #2: EOR(0)
bucketlog_net #3: FLUSH(0) EOC(0)
읽을 거리가 많습니다.
- 위에서 받은 것은 FILE 과 EOS 두 개뿐입니다. 파일 내용은 아직 아무도 읽지 않았습니다.
- 소켓 바로 위에서는 앞에 HEAP(228) 이 붙었습니다. 상태 줄과 헤더 228바이트입니다. 헤더 필터들이 FILE 앞에 버킷을 하나 끼웠을 뿐 FILE 은 그대로 내려왔습니다.
- 그다음 브리게이드는 EOR 하나입니다. 1절에서 본 "요청 끝" 표시로, 이것이 지워질 때 요청 풀이 정리됩니다. 이 버킷은 연결 필터부터 시작하므로
bucketlog_top은 보지 못합니다. - 마지막은 FLUSH 와 EOC 입니다. curl 이 연결을 닫자 httpd 가 연결을 정리하면서 보낸 것입니다(
ap_shutdown_conn(), server/connection.c 104–125행). - 필터 이름이 소문자로 찍혔습니다. 4절에서 이유를 봅니다.
헷갈리기 쉬운 곳: 필터는 한 응답에 여러 번 불립니다
bucketlog_net 은 요청 하나에 세 번 불렸습니다. 필터 함수는 "응답 전체를 한 번에 받는" 함수가 아니라 브리게이드가 올 때마다 불리는 함수입니다. 그래서 필터가 여러 호출에 걸쳐 기억해야 하는 것은 f->ctx 에 둡니다(예제에서 호출 횟수를 센 자리). util_filter.h 의 설명 주석도 같은 말을 합니다 — 필터는 filter->ctx 에서 자기 상태를 찾는다(include/util_filter.h 103–105행).
4. 필터 — 여섯 단계로 줄을 섭니다
필터 하나는 두 구조체로 나뉩니다. 등록 정보(ap_filter_rec_t — 이름, 함수, 종류. 서버 전체에 하나)와 사슬에 꽂힌 한 칸(ap_filter_t — 요청이나 연결마다 새로 생김)입니다.
struct ap_filter_t {
/* ... */
ap_filter_rec_t *frec;
/** A place to store any data associated with the current filter */
void *ctx;
/** The next filter in the chain */
ap_filter_t *next;
/* ... */
request_rec *r;
/* ... */
conn_rec *c;
/** Filter private/opaque data */
struct ap_filter_private *priv;
};
include/util_filter.h 278–303행
next 하나로 이어진 단방향 목록입니다. 출력 필터 함수의 모양은 apr_status_t (*ap_out_filter_func)(ap_filter_t *f, apr_bucket_brigade *b) 입니다(134–135행). 마지막 필드 priv 는 trunk 에서 생긴 것으로, 2.4.x 의 ap_filter_t 는 c 에서 끝납니다. 5절의 "보내지 못한 것 맡아 두기"가 여기를 씁니다.
필터의 종류(ftype)는 숫자이고, 이 숫자가 줄 서는 순서를 정합니다.
typedef enum {
/** These filters are used to alter the content that is passed through
* them. Examples are SSI or PHP. */
AP_FTYPE_RESOURCE = 10,
/* ... */
AP_FTYPE_CONTENT_SET = 20,
/** These filters are used to handle the protocol between server and
* client. Examples are HTTP and POP. */
AP_FTYPE_PROTOCOL = 30,
/** These filters implement transport encodings (e.g., chunking). */
AP_FTYPE_TRANSCODE = 40,
/* ... */
AP_FTYPE_CONNECTION = 50,
/** These filters don't alter the content. They are responsible for
* sending/receiving data to/from the client. */
AP_FTYPE_NETWORK = 60
} ap_filter_type;
include/util_filter.h 158–184행
| 종류 | 무엇을 하나 | 이 커밋의 예 |
|---|---|---|
| RESOURCE (10) | 내용 자체를 바꾼다 | INCLUDES(SSI) |
| CONTENT_SET (20) | 내용을 통째로 바꾸되 Content-Type 은 그대로 | DEFLATE |
| PROTOCOL (30) | HTTP 같은 프로토콜 처리 | HTTP_HEADER, CONTENT_LENGTH, BYTERANGE |
| TRANSCODE (40) | 전송 인코딩 | CHUNK, HTTP1_RESPONSE_OUT |
| CONNECTION (50) | 연결에 묶인 처리 | SSL/TLS Filter(mod_ssl 의 TLS 필터, AP_FTYPE_CONNECTION + 5 = 55, 15편) |
| NETWORK (60) | 실제로 보내고 받는다 | CORE, CORE_IN |
DEFLATE·INCLUDES 의 종류는 각 모듈의 등록 줄에서 확인했습니다(mod_deflate.c 1927–1928행, mod_include.c 4247–4248행). 코어와 HTTP 모듈의 필터 등록은 server/core.c 6199–6213행과 modules/http/http_core.c 369–392행에 모여 있습니다. 표의 여섯 값 사이의 숫자도 쓸 수 있어서, mod_ssl 은 AP_FTYPE_CONNECTION + 5(55)로 등록하고(modules/ssl/ssl_engine_io.c 2307–2309행) 코어의 OLD_WRITE 는 AP_FTYPE_RESOURCE - 10, 곧 0 으로 등록합니다(6절).
등록 — 이름을 붙여 둔다
모듈은 훅을 등록하는 함수(register_hooks, 6편)에서 ap_register_output_filter(이름, 함수, 초기화 함수, 종류) 를 부릅니다. 예제의 register_hooks 가 그 모양입니다. 등록 함수는 이름을 소문자로 바꿔 문자 트리(trie)에 넣습니다.
normalized_name = apr_pstrdup(FILTER_POOL, name);
ap_str_tolower(normalized_name);
예제 로그의 이름이 bucketlog_top 처럼 소문자였던 이유입니다. 찾을 때 대소문자를 가리지 않도록 알파벳마다 대문자 쪽 가지도 같이 잇습니다(256–258행). 그래서 설정 파일에 SetOutputFilter deflate 라고 써도, DEFLATE 라고 써도 같은 필터를 찾습니다.
꽂기 — 세 가지 길
등록만 해서는 아무 일도 일어나지 않습니다. 요청(또는 연결)의 사슬에 꽂아야 불립니다. 꽂는 함수는 ap_add_output_filter(이름, ctx, r, c) 하나이고, 그것을 부르는 자리가 여럿입니다.
- 설정 파일 —
SetOutputFilter UPPER는 코어의 디렉터리 설정에 문자열로 저장되고, 코어의insert_filter훅이 그 이름들을;로 잘라 하나씩 꽂습니다(server/core.c 5417–5435행).AddOutputFilterByType은mod_filter의 디렉티브입니다(modules/filters/mod_filter.c 750행). insert_filter훅 — 핸들러를 부르기 직전에ap_invoke_handler()가 이 훅을 돌립니다(server/config.c 405행). 예제의BUCKETLOG_TOP이 이 길입니다. 요청 처리 단계의 순서는 10편에 있습니다.- 코드에서 직접 — HTTP 모듈은 요청이 만들어질 때
BYTERANGE,CONTENT_LENGTH,HTTP_HEADER,HTTP_OUTERROR를 꽂고(modules/http/http_core.c 257–271행), 코어는 연결이 생길 때CORE_IN과CORE를 꽂습니다(server/core.c 5690–5691행). 예제의BUCKETLOG_NET은pre_connection훅에서 꽂았습니다.
어느 길로 꽂든 자리는 종류 숫자가 정합니다. 꽂는 함수 안의 비교 매크로가 이것입니다.
#define INSERT_BEFORE(f, before_this) ((before_this) == NULL \
| (before_this)->frec->ftype > (f)->frec->ftype \
| (before_this)->r != (f)->r)
새 필터는 자기보다 숫자가 큰 필터 앞에 들어갑니다. 숫자가 같으면 먼저 꽂힌 것 뒤로 갑니다. util_filter.h 의 주석도 "같은 무리 안에서는 ap_add_*_filter() 를 부른 순서"라고 적고 있습니다(150–157행).
그리고 사슬의 시작점이 셋입니다. add_any_filter_handle() 은 종류에 따라 어느 목록에 넣을지 고릅니다(server/util_filter.c 431–452행). PROTOCOL 미만은 r->output_filters, CONNECTION 미만은 r->proto_output_filters, 나머지는 c->output_filters 입니다. 셋은 따로 떨어진 목록이 아니라 한 사슬의 서로 다른 지점을 가리킵니다. 요청 필터의 끝은 연결 필터의 처음으로 이어져 있습니다. 그래서 요청 필터는 요청과 함께 사라지고, 연결 필터는 keep-alive 로 연결이 이어지는 동안 다음 요청에도 그대로 남습니다.
파란 상자 안의 이름은 예제에서 실제로 찍힌 필터와 이 커밋의 대표적인 필터입니다. 노란 상자는 사슬의 어디를 가리키는 포인터인지를 보여 줍니다.
예제 1의 chain: 줄이 바로 이 순서입니다 — bucketlog_top(10) → byterange·content_length·http_header·http_outerror(30) → http1_response_out(40) → bucketlog_net(50) → core(60).
넘기기 — ap_pass_brigade()
필터가 다음 필터에 넘기는 함수는 놀랄 만큼 짧습니다.
AP_DECLARE(apr_status_t) ap_pass_brigade(ap_filter_t *next,
apr_bucket_brigade *bb)
{
if (next) {
apr_bucket *e = APR_BRIGADE_LAST(bb);
if (e != APR_BRIGADE_SENTINEL(bb) && APR_BUCKET_IS_EOS(e) && next->r) {
/* ... */
next->r->eos_sent = 1;
/* ... */
}
return next->frec->filter_func.out_func(next, bb);
}
return AP_NOBODY_WROTE;
}
마지막 버킷이 EOS 면 "이 요청은 끝을 보냈다"고 표시하고, 다음 필터의 함수 포인터를 부릅니다. 그게 전부입니다. 필터 A 가 ap_pass_brigade(f->next, bb) 를 부르면 그 안에서 필터 B 가 불리고, B 가 다시 C 를 부르는 식으로 호출이 겹겹이 쌓이며 내려갑니다. 맨 아래 core 가 소켓에 쓰고 돌아와야 위의 필터들도 차례로 돌아옵니다. 데이터를 "밀어 내리는"(push) 구조입니다.
예제 2 — 내용을 바꾸는 필터, 그리고 모양을 바꾸는 버킷
같은 모듈에 데이터 버킷을 읽어 대문자로 바꾼 HEAP 버킷으로 갈아 끼우는 UPPER 필터를 넣고, 설정에서 SetOutputFilter UPPER 로 꽂았습니다.
before = e->type->name;
rv = apr_bucket_read(e, &data, &len, APR_BLOCK_READ);
if (rv != APR_SUCCESS) {
return rv;
}
/* 읽고 나면 같은 버킷의 종류가 바뀌어 있을 수 있다 */
ap_log_cerror(APLOG_MARK, APLOG_NOTICE, 0, f->c,
"upper: read %s -> now %s(%" APR_SIZE_T_FMT ")",
before, e->type->name, e->length);
buf = apr_bucket_alloc(len, f->c->bucket_alloc);
for (i = 0; i < len; i++) {
buf[i] = apr_toupper(data[i]);
}
upper = apr_bucket_heap_create(buf, len, apr_bucket_free,
f->c->bucket_alloc);
APR_BUCKET_INSERT_BEFORE(e, upper);
apr_bucket_delete(e); /* 원래 버킷은 지운다 */
e = upper;
$ curl http://127.0.0.1:8080/upper/index.html
HELLO FROM A STATIC FILE.
--- error_log
chain: bucketlog_top -> upper -> byterange -> content_length -> http_header -> http_outerror -> http1_response_out -> bucketlog_net -> core
bucketlog_top #1: FILE(26) EOS(0)
upper: read FILE -> now MMAP(26)
bucketlog_net #1: HEAP(228) HEAP(26) EOS(0)
bucketlog_net #2: EOR(0)
bucketlog_net #3: FLUSH(0) EOC(0)
upper는bucketlog_top바로 뒤에 섰습니다. 둘 다 RESOURCE 인데, 예제의insert_filter훅을APR_HOOK_FIRST로 걸어 코어의SetOutputFilter처리보다 먼저 꽂았기 때문입니다.apr_bucket_read()한 번에 FILE 버킷이 MMAP 버킷이 되었습니다. 3절의 "읽을 때 모양이 바뀌는 버킷"입니다. mmap 할 최소 크기APR_MMAP_THRESHOLD는 빌드할 때 따로 정하지 않으면 SunOS 4 가 아닌 한 1바이트여서(APRinclude/apr_mmap.h95–103행), 26바이트 파일도 mmap 되었습니다.- 소켓 위에서는 더 이상 FILE 이 아니라 HEAP(26) 입니다. 내용을 바꾸는 필터가 하나라도 끼면 파일 버킷은 읽혀서 메모리로 올라오고,
sendfile은 쓸 수 없게 됩니다. 압축(DEFLATE)을 켜면 정적 파일도 메모리를 거치는 이유가 이것입니다.
헷갈리기 쉬운 곳: 받은 버킷을 다음 호출까지 들고 있으면 안 됩니다
UPPER 처럼 그 자리에서 다 처리하면 문제가 없지만, "조금 모았다가 한꺼번에 처리"하고 싶은 필터는 받은 버킷을 f->ctx 에 넣어 두고 싶어집니다. 그런데 TRANSIENT 버킷은 넘겨준 쪽 함수가 돌아가면 사라질 수 있는 메모리를 가리킵니다. 그래서 util_filter.h 는 다음 호출까지 버킷을 들고 있으려면 반드시 apr_bucket_setaside() 로 "맡겨 두기"를 하라고 적고 있고, 보통 ap_save_brigade() 를 쓴다고 합니다(include/util_filter.h 117–122행). TRANSIENT 의 setaside 는 데이터를 힙으로 복사합니다.
5. 맨 아래 — 코어 출력 필터가 소켓에 쓰기
사슬의 끝 CORE 필터는 ap_core_output_filter() 입니다(server/core_filters.c 359–443행). 큰 줄기는 이렇습니다.
/* Prepend buckets set aside, if any. */
ap_filter_reinstate_brigade(f, bb, NULL);
if (APR_BRIGADE_EMPTY(bb)) {
return APR_SUCCESS;
}
/* Non-blocking writes on the socket in any case. */
apr_socket_timeout_get(sock, &sock_timeout);
apr_socket_timeout_set(sock, 0);
do {
rv = send_brigade_nonblocking(sock, bb, ctx, c);
/* ... */
} while (rv == APR_SUCCESS && !APR_BRIGADE_EMPTY(bb));
/* ... */
return ap_filter_setaside_brigade(f, bb);
}
server/core_filters.c 389–443행
- 지난번에 못 보낸 것부터. 앞선 호출에서 맡겨 둔 버킷이 있으면 이번 브리게이드 앞에 붙입니다.
- 소켓을 논블로킹으로. 타임아웃을 0으로 두어, 소켓이 받을 수 없으면 기다리지 않고
EAGAIN("지금은 안 됨")을 돌려받게 합니다. - 보낼 수 있는 만큼 보냅니다 —
send_brigade_nonblocking(). - 남은 것은 맡겨 두고 돌아갑니다 —
ap_filter_setaside_brigade().
send_brigade_nonblocking()(514–647행)은 버킷을 앞에서부터 보며 두 갈래로 나눕니다.
#if APR_HAS_SENDFILE
if (can_sendfile_bucket(bucket)) {
if (nvec > 0) {
sock_nopush(s, 1);
rv = writev_nonblocking(s, bb, ctx, nbytes, nvec, c);
if (rv != APR_SUCCESS) {
goto cleanup;
}
nbytes = 0;
nvec = 0;
}
rv = sendfile_nonblocking(s, bucket, ctx, c);
if (rv != APR_SUCCESS) {
goto cleanup;
}
continue;
}
#endif /* APR_HAS_SENDFILE */
if (bucket->length) {
/* Non-blocking read first, in case this is a morphing
* bucket type. */
rv = apr_bucket_read(bucket, &data, &length, APR_NONBLOCK_READ);
server/core_filters.c 532–554행
sendfile을 쓸 수 있는 FILE 버킷이면, 그때까지 모아 둔 메모리 조각을 먼저writev()로 보내고 파일은sendfile()로 보냅니다.#if APR_HAS_SENDFILE은 컴파일할 때 그 운영체제에 sendfile 이 있을 때만 이 코드를 넣으라는 뜻입니다.- 나머지는
apr_bucket_read()로 읽어(모양이 바뀌는 버킷이면 여기서 바뀝니다) 메모리 조각 목록(iovec)에 모았다가 한 번의writev()로 보냅니다. 메타데이터 버킷은 지웁니다 — EOR 이 여기서 지워지며 요청 풀이 정리됩니다.
can_sendfile_bucket() 의 조건은 셋입니다. FILE 버킷일 것, 길이가 AP_MIN_SENDFILE_BYTES(256) 이상일 것, 파일을 EnableSendfile On 으로 열었을 것(467–480행, 57행). EnableSendfile 은 기본이 꺼짐입니다. 디렉티브를 적지 않으면 AP_SENDFILE_ENABLED() 매크로가 0 을 내놓습니다(include/http_core.h 689–691행).
예제 3 — sendfile 이 실제로 불리는지
1000바이트 파일을 EnableSendfile 을 켜지 않은 경로(/big.txt)와 켠 경로(/sf/big.txt)로 받으면서, strace 로 httpd 가 부른 시스템 콜 중 writev 와 sendfile 만 골라 봤습니다. httpd 는 -X(한 프로세스로 실행)로 띄웠습니다. 줄 앞의 숫자는 스레드 번호이고, 주소 값과 함께 실행마다 다릅니다.
--- http://127.0.0.1:8080/big.txt
274 writev(8, 0x7fac5c0b6a30, 2) = 1232
--- http://127.0.0.1:8080/sf/big.txt
276 writev(8, 0x7fac5c0b6a30, 1) = 232
276 sendfile(8, 9, 0x7fac57ffe458, 1000) = 1000
두 경우 모두 필터 로그는 HEAP(232) FILE(1000) EOS(0) 로 똑같았습니다. 다른 것은 맨 아래에서 일어난 일뿐입니다. 꺼진 쪽은 헤더 232바이트와 파일 1000바이트를 조각 두 개짜리 writev 한 번(1232바이트)으로 보냈고, 켠 쪽은 헤더만 writev 로 보낸 뒤 파일은 sendfile 로 보냈습니다. 버킷이 "데이터 그 자체"가 아니라 "데이터가 있는 곳"을 들고 다녔기 때문에 맨 아래에서 이런 선택이 가능합니다.
소켓이 가득 차면 — 맡겨 두기와 흐름 제어
클라이언트가 느리면 소켓의 보내기 버퍼가 차서 writev 가 EAGAIN 을 돌려줍니다. 이때 코어 필터는 바로 기다리지 않고, ap_filter_reinstate_brigade() 에 "남은 것 가운데 꼭 지금 보내야 하는 데가 어디까지인가"를 묻습니다. 그 함수의 주석이 규칙 세 가지를 적고 있습니다(server/util_filter.c 1111–1135행).
- FLUSH 버킷이 있으면 그 FLUSH 까지는 막혀서라도(블로킹으로) 보낸다.
- 파일이 아닌 메모리 버킷의 바이트가
FlushMaxThreshold를 넘으면 그만큼은 보낸다. 핸들러가 클라이언트보다 훨씬 빨리 데이터를 만들어 메모리가 끝없이 불어나는 것을 막는 흐름 제어입니다. - 파이프라이닝으로 쌓인 EOR(끝난 요청)이
FlushMaxPipelined를 넘으면 보낸다. 끝난 요청들이 파일 핸들을 너무 많이 쥐고 있지 않게 하려는 것입니다.
기본값은 65535바이트와 4개입니다(server/core.c 91–92행). 파일 버킷과 길이를 모르는 버킷은 "읽기 전에는 메모리를 쓰지 않는다"는 이유로 2번 계산에서 빠집니다(같은 주석). 셋 다 아니면 남은 버킷을 ap_filter_setaside_brigade() 로 연결 쪽 풀(deferred_pool)에 맡겨 두고 돌아갑니다(946–1047행).
노란 상자가 "맡겨 두기"와 "되찾기"입니다. 회색은 8편의 event MPM 이 맡는 부분입니다.
맡겨 둔 것이 있으면 ap_filter_should_yield() 가 참이 됩니다(1218–1268행). event MPM 은 요청을 다 처리한 뒤 이것을 보고, 남은 출력이 있으면 그 연결을 쓰기 대기열에 넣고 워커 스레드를 놓아 줍니다(server/mpm/event/event.c 1260–1261행). 느린 클라이언트 하나 때문에 스레드 하나가 묶이지 않게 하는 장치입니다.
헷갈리기 쉬운 곳: 이 "맡겨 두기" API 는 trunk 의 것입니다
ap_filter_setaside_brigade(), ap_filter_reinstate_brigade(), ap_filter_should_yield() 는 2.4.x 의 server/util_filter.c 에는 없습니다. 2.4.x 에서는 같은 일을 core_filters.c 안의 static 함수(setaside_remaining_output(), should_send_brigade())가 코어 필터 혼자 합니다. trunk 는 그것을 일반 API 로 끌어올려 mod_ssl 같은 다른 연결 필터도 같은 방식으로 맡겨 두고 양보할 수 있게 했습니다(ssl_engine_io.c 가 이 함수들을 부릅니다). FlushMaxThreshold·FlushMaxPipelined 디렉티브는 2.4.x 에도 있고, 양보할 필터의 범위를 정하는 AsyncFilter 디렉티브는 trunk 에만 있습니다.
6. HTTP 필터 — 헤더와 chunked 인코딩
예제 1에서 FILE 앞에 붙은 HEAP(228) 은 누가 만들었을까요. trunk 에서는 두 필터가 나눠 맡습니다.
HTTP_HEADER(PROTOCOL) — 첫 데이터 버킷이나 EOS 를 보면 그 앞에 RESPONSE 메타데이터 버킷을 끼웁니다. 상태 코드와 헤더 표를 담은 버킷입니다(modules/http/http_filters.c 1284–1285행).HTTP1_RESPONSE_OUT(TRANSCODE) — RESPONSE 버킷을 만나면HTTP/1.1 200 OK와 헤더 줄을 글자로 써서 그 자리에 넣고 RESPONSE 버킷을 지웁니다(2000–2003행). 이 필터는 HTTP/1.x 연결일 때만 꽂힙니다(modules/http/http_core.c 274–285행).
헤더를 바로 글자로 쓰지 않고 버킷으로 내려보내는 까닭은, 같은 응답을 HTTP/2 처럼 다른 방식으로 쓰는 쪽도 RESPONSE 버킷 하나만 읽으면 되게 하려는 것으로 보입니다. 다만 이것은 구조에서 읽은 추측이고, 소스 주석으로 확인한 이유는 아닙니다.
chunked 인코딩은 응답 길이를 헤더에 미리 적을 수 없을 때 쓰는 HTTP/1.1 의 방식입니다. 본문을 "길이(16진수) 줄 + 데이터 + 줄바꿈" 덩어리(chunk)로 나눠 보내고, 길이 0 인 덩어리로 끝을 알립니다. 언제 길이를 모르게 될까요. CONTENT_LENGTH 필터는 첫 호출에 EOS 까지 다 받으면 길이를 세어 Content-Length 를 붙이지만, EOS 전에 무언가를 먼저 아래로 넘겨야 하면(FLUSH 를 받았거나, 길이를 모르는 버킷을 읽다가) 길이를 붙일 수 없습니다(server/protocol.c 1784–1937행). 그러면 연결을 keep-alive 로 이어 갈 수 있는 HTTP/1.1 요청일 때 r->chunked 가 켜지고(modules/http/http_protocol.c 269–270행), HTTP1_RESPONSE_OUT 은 헤더를 먼저 내려보낸 뒤에 CHUNK 필터를 꽂습니다.
if (ctx->final_response_sent && r->chunked) {
/* We can't add this filter until we have already sent the headers.
* If we add it before this point, then the headers will be chunked
* as well, and that is just wrong.
*/
rv = ap_pass_brigade(f->next, b);
apr_brigade_cleanup(b);
/* ... */
ap_add_output_filter("CHUNK", NULL, r, r->connection);
}
modules/http/http_filters.c 2005–2020행
필터를 응답 도중에 사슬에 꽂는 것도 가능하다는 것을 보여 주는 자리입니다. CHUNK 필터는 브리게이드의 데이터 길이를 세어 맨 앞에 길이 줄을, 끝에 줄바꿈을 끼웁니다.
hdr_len = apr_snprintf(chunk_hdr, sizeof(chunk_hdr),
"%" APR_UINT64_T_HEX_FMT CRLF, (apr_uint64_t)bytes);
ap_xlate_proto_to_ascii(chunk_hdr, hdr_len);
e = apr_bucket_transient_create(chunk_hdr, hdr_len,
c->bucket_alloc);
APR_BRIGADE_INSERT_HEAD(b, e);
/*
* Insert the end-of-chunk CRLF before an EOS or
* FLUSH bucket, or appended to the brigade
*/
e = apr_bucket_immortal_create(CRLF_ASCII, 2, c->bucket_alloc);
modules/http/chunk_filter.c 176–187행
길이 줄은 함수의 지역 배열 chunk_hdr 에 있으므로 TRANSIENT, 줄바꿈은 문자열 상수이므로 IMMORTAL 입니다. EOS 를 만나면 끝 표시 0\r\n\r\n(5바이트)을 IMMORTAL 버킷으로 EOS 앞에 넣습니다(modules/http/http_protocol.c 1627–1631행). 1절의 표에서 본 버킷 종류가 쓰임새대로 골라 쓰였습니다.
예제 4 — 핸들러가 ap_rputs() 로 쓸 때, 그리고 chunked
예제 모듈의 핸들러는 ap_rputs("hello, ", r); ap_rputs("buckets\n", r); 를 부르고, 주소에 ?flush 가 붙으면 중간에 ap_rflush(r) 를 부른 뒤 한 줄을 더 씁니다.
ap_set_content_type(r, "text/plain");
ap_rputs("hello, ", r);
ap_rputs("buckets\n", r);
if (r->args && !strcmp(r->args, "flush")) {
ap_rflush(r);
ap_rputs("after flush\n", r);
}
먼저 ?flush 없이 받은 결과입니다.
$ curl http://127.0.0.1:8080/demo
hello, buckets
--- error_log
chain: bucketlog_top -> byterange -> content_length -> http_header -> http_outerror -> http1_response_out -> bucketlog_net -> core
bucketlog_top #1: HEAP(15) EOS(0)
bucketlog_net #1: HEAP(135) HEAP(15) EOS(0)
bucketlog_net #2: EOR(0)
bucketlog_net #3: FLUSH(0) EOC(0)
ap_rputs() 를 두 번 불렀는데 버킷은 HEAP(15) 하나이고, 핸들러가 돌아간 뒤 EOS 와 함께 한 번에 내려왔습니다. ap_rputs() 는 바로 필터를 부르지 않고, 사슬 맨 앞에 OLD_WRITE 라는 필터를 꽂아 그 안의 브리게이드에 글자를 모읍니다(server/protocol.c 2036–2067행). 모으는 함수 apr_brigade_write() 는 마지막 버킷이 아직 여유 있는 HEAP 이면 거기에 이어 씁니다(APR-util buckets/apr_brigade.c 429–486행). 처음 만드는 HEAP 의 크기는 APR_BUCKET_BUFF_SIZE(8000바이트, 54행)입니다. 핸들러가 돌아가면 ap_finalize_request_protocol() 이 EOS 를 보내고(server/protocol.c 1645–1653행), OLD_WRITE 가 모아 둔 것을 그 앞에 붙여 내려보냅니다. EOS 까지 한 번에 왔으므로 CONTENT_LENGTH 가 길이를 셀 수 있었고, 응답 헤더에는 Content-Length: 15 가 붙었습니다(curl -i 로 확인).
이번에는 ?flush 를 붙이고 curl --raw 로 chunked 인코딩을 풀지 않은 채 받았습니다.
$ curl --raw http://127.0.0.1:8080/demo?flush
f
hello, buckets
c
after flush
0
--- error_log
chain: bucketlog_top -> byterange -> content_length -> http_header -> http_outerror -> http1_response_out -> bucketlog_net -> core
bucketlog_top #1: HEAP(15) FLUSH(0)
bucketlog_net #1: HEAP(143)
bucketlog_net #2: TRANSIENT(3) HEAP(15) IMMORTAL(2) FLUSH(0)
bucketlog_net #3:
bucketlog_top #2: HEAP(12) EOS(0)
bucketlog_net #4: TRANSIENT(3) HEAP(12) IMMORTAL(2) IMMORTAL(5) EOS(0)
bucketlog_net #5: EOR(0)
bucketlog_net #6: FLUSH(0) EOC(0)
ap_rflush()가 FLUSH 버킷을 보내자 모여 있던 HEAP(15) 가 FLUSH 와 함께 내려왔습니다(top #1). 아직 EOS 가 없으므로 길이를 모릅니다.net #1— 헤더 143바이트만 먼저 내려왔습니다. 위 코드의 "헤더를 먼저 보내고 CHUNK 를 꽂는" 자리입니다. 응답 헤더는Content-Length대신Transfer-Encoding: chunked였습니다.net #2— TRANSIENT(3) 은f\r\n(15의 16진수), HEAP(15) 는 본문, IMMORTAL(2) 는\r\n입니다. curl 의 출력 첫 덩어리와 정확히 맞습니다.net #3— 빈 브리게이드가 한 번 지나갔습니다.CONTENT_LENGTH필터는 FLUSH 까지를 먼저 넘긴 뒤, 함수 끝에서 남은 브리게이드(이제 비었습니다)를 한 번 더 넘깁니다(server/protocol.c 1936–1937행).net #4— 두 번째 덩어리c\r\n+ 12바이트 +\r\n, 그리고 IMMORTAL(5) 가 끝 표시0\r\n\r\n입니다.
위 error_log 의 net #1·#2·#4 줄을 버킷마다 칸으로 옮기고, 각 칸에 실제로 든 바이트를 적었습니다(빈 브리게이드인 net #3 은 뺐습니다). 파란 칸이 CHUNK 필터가 끼운 것, 노란 칸이 핸들러가 쓴 본문입니다.
?big 을 붙이면 핸들러가 마지막에 ap_rwrite() 로 10000바이트를 한 번에 씁니다. 이미 있는 HEAP 의 남은 자리(8000 − 15)보다 크므로 apr_brigade_write() 는 그 데이터를 복사하지 않고 TRANSIENT 버킷으로 감싸 곧바로 다음 필터로 넘깁니다(455–459행).
$ curl -o /dev/null http://127.0.0.1:8080/demo?big
--- error_log (두 줄만 옮겼습니다)
bucketlog_top #1: HEAP(15) TRANSIENT(10000)
bucketlog_net #2: TRANSIENT(6) HEAP(15) TRANSIENT(10000) IMMORTAL(2)
TRANSIENT(6) 은 271f\r\n(10015 의 16진수)입니다. 마지막으로, 파이프를 PIPE 버킷으로 넘기는 핸들러(? 는 길이를 모른다는 뜻)입니다. 3절 그림의 변화가 그대로 찍혔습니다.
$ curl http://127.0.0.1:8080/pipe
data from a pipe
--- error_log
chain: bucketlog_top -> byterange -> content_length -> http_header -> http_outerror -> http1_response_out -> bucketlog_net -> core
bucketlog_top #1: PIPE(?) EOS(0)
bucketlog_net #1: HEAP(143)
bucketlog_net #2: TRANSIENT(4) HEAP(17) IMMORTAL(2)
bucketlog_net #3: IMMORTAL(0) IMMORTAL(5) EOS(0)
bucketlog_net #4: EOR(0)
bucketlog_net #5: FLUSH(0) EOC(0)
CONTENT_LENGTH 필터가 PIPE 를 읽자 HEAP(17) 과 새 PIPE 로 나뉘었고, 그 HEAP 을 먼저 넘기느라 길이를 정하지 못해 chunked 가 되었습니다. 다음 읽기에서 파이프가 끝나 PIPE 는 IMMORTAL(0) 이 되었습니다.
헷갈리기 쉬운 곳: FLUSH 한 번이 응답 형식을 바꿉니다
?flush 하나로 Content-Length 응답이 chunked 응답으로 바뀌었습니다. 핸들러 입장에서는 "빨리 보내 달라"고 했을 뿐이지만, 그 시점에는 전체 길이를 모르므로 HTTP/1.1 에서는 chunked 말고 방법이 없습니다. 필터를 쓸 때도 마찬가지여서, 내용을 바꾸는 필터가 EOS 전에 조각을 내려보내면 뒤의 CONTENT_LENGTH 는 길이를 적지 못합니다.
7. 입력 쪽 — 끌어오는 사슬
요청 본문이 들어오는 입력 필터도 같은 버킷과 같은 여섯 종류를 쓰지만 방향이 반대입니다. 출력은 위에서 아래로 밀어 내리고(ap_pass_brigade), 입력은 위에서 아래에 달라고 합니다(ap_get_brigade).
AP_DECLARE(apr_status_t) ap_get_brigade(ap_filter_t *next,
apr_bucket_brigade *bb,
ap_input_mode_t mode,
apr_read_type_e block,
apr_off_t readbytes)
{
if (next) {
return next->frec->filter_func.in_func(next, bb, mode, block,
readbytes);
}
return AP_NOBODY_READ;
}
인자가 셋 늘었습니다. block 은 데이터가 없을 때 기다릴지(APR_BLOCK_READ) 말지(APR_NONBLOCK_READ), readbytes 는 최대 몇 바이트, mode 는 어떻게 읽을지입니다(include/util_filter.h 41–63행).
| 모드 | 뜻 | 쓰는 곳 |
|---|---|---|
AP_MODE_GETLINE |
한 줄(CRLF 까지) | 요청 줄과 헤더 읽기(server/protocol.c 248행) |
AP_MODE_READBYTES |
최대 readbytes 바이트 |
본문 읽기(ap_get_client_block(), modules/http/http_filters.c 1650–1651행) |
AP_MODE_SPECULATIVE |
읽되 소비하지 않고 남겨 둔다(엿보기) | 다음 요청이 와 있는지 보기(server/protocol.c 418행) |
AP_MODE_EATCRLF |
앞쪽의 빈 줄을 버린다 | 요청 뒤에 남은 빈 줄(코어의 주석). 이 커밋에서 이 모드로 부르는 곳은 찾지 못했습니다 |
AP_MODE_EXHAUSTIVE |
끝까지 전부 | 소스 주석이 "매우 조심해서"라고 경고 |
AP_MODE_INIT |
연결 초기화만 | TLS 핸드셰이크(mod_ssl.c 731행) |
맨 아래 CORE_IN 필터(ap_core_input_filter())는 처음 불릴 때 자기 브리게이드에 SOCKET 버킷 하나를 넣어 둡니다(server/core_filters.c 118–124행, 넣는 쪽은 server/core.c 5898–5905행). 그다음은 3절의 PIPE 와 같습니다. 소켓 버킷을 읽으면 읽은 만큼 HEAP 이 되고 뒤에 새 SOCKET 버킷이 붙습니다. GETLINE 이면 그 브리게이드에서 한 줄을 잘라 돌려주고(apr_brigade_split_line()), READBYTES 면 원하는 길이에서 잘라 돌려줍니다. 남은 것은 다음 호출을 위해 들고 있습니다. 요청 줄과 헤더를 이 사슬로 읽는 과정은 9편에서 봤고, mod_ssl 은 이 사슬 중간에 끼어 암호문을 평문으로 바꿉니다(15편).
SOCKET 버킷이 읽히며 HEAP 과 새 SOCKET 으로 나뉘는 것은 3절 그림의 PIPE 와 같은 모양입니다.
이 글에서 뺀 것
큰 줄기를 위해 이름만 적고 넘어간 것들입니다. 버킷 메모리를 재사용하는 할당자 apr_bucket_alloc_t(APR-util buckets/apr_buckets_alloc.c). 여러 버킷이 같은 데이터를 나눠 가질 때의 참조 횟수(apr_bucket_refcount, buckets/apr_buckets_refcount.c). 필터를 조건에 따라 고르는 mod_filter 의 FilterDeclare/FilterProvider(ap_filter_provider_t). 필터 초기화 함수(filter_init_func, server/config.c 375–387행). 필터를 빼는 ap_remove_output_filter() 와 쓰고 남은 필터 구조체를 연결 안에서 다시 쓰는 get_spare()/put_spare()(server/util_filter.c 333–368행). 서브요청의 출력을 받는 SUBREQ_CORE 필터. 오류 상태를 실어 나르는 ERROR 버킷과 HTTP_OUTERROR 필터. Range 요청을 자르는 BYTERANGE 필터는 12편에서 봅니다. 그리고 HTTP/1.x 요청을 버킷으로 읽어 들이는 trunk 의 새 입력 필터 HTTP1_REQUEST_IN·HTTP1_BODY_IN(modules/http/http_core.c 372–377행)이 있습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 버킷 | 데이터가 "어디 있는지"를 적은 구조체. 종류는 함수 포인터 표 apr_bucket_type_t |
| 데이터 / 메타데이터 버킷 | HEAP·FILE·PIPE 처럼 데이터를 가리키거나, EOS·FLUSH·EOR·EOC 처럼 신호만 나른다 |
| 브리게이드 | 버킷의 링. 머리(센티널)로 돌아오면 끝. 잇고 자르는 데 복사가 없다 |
| 모양이 바뀌는 버킷 | PIPE·SOCKET·FILE 은 읽을 때 HEAP·MMAP 등으로 바뀐다. 필요할 때까지 읽지 않는다 |
| 필터 | 브리게이드를 받아 일하고 ap_pass_brigade() 로 넘기는 함수. 응답 하나에 여러 번 불린다 |
| 필터 종류 | RESOURCE 10 · CONTENT_SET 20 · PROTOCOL 30 · TRANSCODE 40 · CONNECTION 50 · NETWORK 60. 숫자 순으로 줄 선다 |
| 꽂기 | SetOutputFilter, insert_filter 훅, 코드에서 ap_add_output_filter(). 요청 필터와 연결 필터는 수명이 다르다 |
| 코어 출력 필터 | 논블로킹으로 writev/sendfile. 못 보낸 것은 맡겨 두고, 규칙 셋에 걸리면 기다려서 보낸다 |
| chunked | 길이를 모르고 헤더를 먼저 보내야 할 때. 헤더를 보낸 뒤 CHUNK 필터가 꽂힌다 |
| 입력 필터 | 아래에 ap_get_brigade() 로 달라고 하는 사슬. 모드로 한 줄·몇 바이트·엿보기를 고른다 |
다음에 볼 것
응답이 버킷에 담겨 필터 사슬을 따라 소켓까지 가는 길을 봤습니다. 예제에서 정적 파일이 FILE 버킷 하나로 출발하는 것도 확인했습니다.
그 FILE 버킷을 만든 쪽, 곧 핸들러를 다음 글에서 봅니다. 파일 하나를 응답으로 만드는 default_handler(), 확장자로 Content-Type 을 정하는 mod_mime, DirectoryIndex 와 끝 슬래시 리다이렉트, 304 Not Modified 와 Range 요청이 어디서 처리되는지를 12편: 핸들러와 정적 파일 — 파일 하나는 어떻게 응답이 되나에서 이어 갑니다.
Spring 은 같은 문제를 이렇게 풀었습니다. 서블릿 필터는 요청 하나에 doFilter() 가 한 번 불리고, 그 안에서 chain.doFilter() 를 불러 뒤를 맡긴 뒤 돌아오면 응답이 끝나 있는 "감싸는" 구조입니다(Spring 20편). httpd 의 필터는 본문 조각(브리게이드)이 올 때마다 여러 번 불리고 출력과 입력이 따로 줄을 서는 "흐르는" 구조여서, 압축이나 chunked 처럼 스트림을 다루는 일이 필터 하나로 자연스럽게 됩니다. 본문을 바이트 덩어리의 흐름으로 다루는 쪽은 오히려 WebFlux 쪽과 닮았습니다(Spring 24편).
nginx 는 요청마다 필터를 꽂는 대신, configure 때 정한 사슬 하나를 모든 요청이 지나가게 했습니다(nginx 소스 파헤치기 10: 필터와 정적 파일).