namalzi

← 글 목록

Apache httpd 소스 파헤치기 11: 필터와 버킷 브리게이드 — 응답은 어떻게 흘러 나가나

Apache httpd · 게시

핸들러가 만든 응답이 소켓에 닿기까지 httpd 안에서 어떤 모양으로 흐르는지, 소스를 처음 여는 사람 기준으로 따라갑니다. APR-util 의 버킷과 브리게이드(링), 읽을 때 모양이 바뀌는 PIPE·FILE 버킷, 여섯 단계로 줄 서는 필터와 ap_pass_brigade, chunked 인코딩과 sendfile, 그리고 소켓이 가득 찼을 때 코어 출력 필터가 하는 일을 직접 쓴 필터 모듈의 로그로 확인합니다.

이 글에서 다루는 것

"Apache httpd 소스 파헤치기" 연재의 열한 번째 글입니다. 10편에서는 요청 하나가 URL 에서 파일과 핸들러에 이르는 단계를 따라갔습니다. 핸들러가 불리면 응답을 만들기 시작합니다. 이번에는 그 응답이 어떤 모양으로, 누구를 거쳐 소켓까지 가는지를 봅니다.

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

항목 내용
대상 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행

종류를 나타내는 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행).

apr_bucket 구조체의 type 필드는 종류마다 하나뿐인 apr_bucket_type_t 표를 가리키고, 그 표에는 destroy, read, setaside, split, copy 다섯 함수 포인터가 있다. data 필드는 종류별 내용물(FILE 이면 파일 핸들, HEAP 이면 메모리)을 가리킨다. apr_bucket_read 는 e->type->read 호출로 바뀐다. 노란색은 메모리에 있는 것, 파란색은 함수 포인터 표입니다. 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);
    }
}

server/eor_bucket.c 100–112행

그 풀 정리 함수 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;					\
    }

APR include/apr_ring.h 70–74행

#define 은 컴파일 전에 글자를 바꿔 치는 매크로이고, 줄 끝의 \ 는 "다음 줄도 이 매크로에 이어진다"는 뜻입니다(2편). 링의 특이한 점은 머리(브리게이드)도 링의 한 칸처럼 다룬다는 것입니다. 마지막 버킷의 next 는 NULL 이 아니라 머리를 가리키고, 그 머리를 센티널(sentinel, 보초)이라고 부릅니다. 그래서 브리게이드를 도는 코드는 언제나 이 모양입니다.

for (e = APR_BRIGADE_FIRST(bb);
     e != APR_BRIGADE_SENTINEL(bb);
     e = APR_BUCKET_NEXT(e)) {
    /* ... */
}

위는 이 글의 예제 모듈(describe())에서 본문을 줄여 옮긴 것이고, httpd 의 거의 모든 필터에 같은 모양의 반복문이 있습니다.

브리게이드 머리(센티널)에서 next 로 HEAP(228) 응답 헤더, FILE(26) index.html, EOS(0) 끝 표시로 이어지고, EOS 의 next 가 다시 머리로 돌아오는 링 정적 파일 하나를 보낼 때 소켓 바로 위에서 본 실제 브리게이드입니다(예제 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);

server/core.c 5264행

이 버킷이 아무도 손대지 않은 채 맨 아래 코어 출력 필터까지 가면, 코어 필터는 리눅스의 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행).

PIPE(?) EOS 브리게이드를 누군가 읽으면 HEAP(17) PIPE(?) EOS 가 되고, 다시 읽어 파이프가 끝나면 HEAP(17) IMMORTAL(0) EOS 가 된다 예제 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)

읽을 거리가 많습니다.

  1. 위에서 받은 것은 FILE 과 EOS 두 개뿐입니다. 파일 내용은 아직 아무도 읽지 않았습니다.
  2. 소켓 바로 위에서는 앞에 HEAP(228) 이 붙었습니다. 상태 줄과 헤더 228바이트입니다. 헤더 필터들이 FILE 앞에 버킷을 하나 끼웠을 뿐 FILE 은 그대로 내려왔습니다.
  3. 그다음 브리게이드는 EOR 하나입니다. 1절에서 본 "요청 끝" 표시로, 이것이 지워질 때 요청 풀이 정리됩니다. 이 버킷은 연결 필터부터 시작하므로 bucketlog_top 은 보지 못합니다.
  4. 마지막은 FLUSH 와 EOC 입니다. curl 이 연결을 닫자 httpd 가 연결을 정리하면서 보낸 것입니다(ap_shutdown_conn(), server/connection.c 104–125행).
  5. 필터 이름이 소문자로 찍혔습니다. 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);

server/util_filter.c 250–251행

예제 로그의 이름이 bucketlog_top 처럼 소문자였던 이유입니다. 찾을 때 대소문자를 가리지 않도록 알파벳마다 대문자 쪽 가지도 같이 잇습니다(256–258행). 그래서 설정 파일에 SetOutputFilter deflate 라고 써도, DEFLATE 라고 써도 같은 필터를 찾습니다.

꽂기 — 세 가지 길

등록만 해서는 아무 일도 일어나지 않습니다. 요청(또는 연결)의 사슬에 꽂아야 불립니다. 꽂는 함수는 ap_add_output_filter(이름, ctx, r, c) 하나이고, 그것을 부르는 자리가 여럿입니다.

  1. 설정 파일 — SetOutputFilter UPPER 는 코어의 디렉터리 설정에 문자열로 저장되고, 코어의 insert_filter 훅이 그 이름들을 ; 로 잘라 하나씩 꽂습니다(server/core.c 5417–5435행). AddOutputFilterByType 은 mod_filter 의 디렉티브입니다(modules/filters/mod_filter.c 750행).
  2. insert_filter 훅 — 핸들러를 부르기 직전에 ap_invoke_handler() 가 이 훅을 돌립니다(server/config.c 405행). 예제의 BUCKETLOG_TOP 이 이 길입니다. 요청 처리 단계의 순서는 10편에 있습니다.
  3. 코드에서 직접 — 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)

server/util_filter.c 42–44행

새 필터는 자기보다 숫자가 큰 필터 앞에 들어갑니다. 숫자가 같으면 먼저 꽂힌 것 뒤로 갑니다. 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 로 연결이 이어지는 동안 다음 요청에도 그대로 남습니다.

핸들러에서 RESOURCE(10), CONTENT_SET(20), PROTOCOL(30), TRANSCODE(40), CONNECTION(50), NETWORK(60) 순으로 내려가 클라이언트에 닿는다. RESOURCE 지점은 r->output_filters, PROTOCOL 지점은 r->proto_output_filters, CONNECTION 지점은 c->output_filters 가 가리킨다. 파란 상자 안의 이름은 예제에서 실제로 찍힌 필터와 이 커밋의 대표적인 필터입니다. 노란 상자는 사슬의 어디를 가리키는 포인터인지를 보여 줍니다.

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

server/util_filter.c 756–786행

마지막 버킷이 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 처럼 그 자리에서 다 처리하면 문제가 없지만, "조금 모았다가 한꺼번에 처리"하고 싶은 필터는 받은 버킷을 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행

  1. 지난번에 못 보낸 것부터. 앞선 호출에서 맡겨 둔 버킷이 있으면 이번 브리게이드 앞에 붙입니다.
  2. 소켓을 논블로킹으로. 타임아웃을 0으로 두어, 소켓이 받을 수 없으면 기다리지 않고 EAGAIN("지금은 안 됨")을 돌려받게 합니다.
  3. 보낼 수 있는 만큼 보냅니다 — send_brigade_nonblocking().
  4. 남은 것은 맡겨 두고 돌아갑니다 — 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행

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행).

  1. FLUSH 버킷이 있으면 그 FLUSH 까지는 막혀서라도(블로킹으로) 보낸다.
  2. 파일이 아닌 메모리 버킷의 바이트가 FlushMaxThreshold 를 넘으면 그만큼은 보낸다. 핸들러가 클라이언트보다 훨씬 빨리 데이터를 만들어 메모리가 끝없이 불어나는 것을 막는 흐름 제어입니다.
  3. 파이프라이닝으로 쌓인 EOR(끝난 요청)이 FlushMaxPipelined 를 넘으면 보낸다. 끝난 요청들이 파일 핸들을 너무 많이 쥐고 있지 않게 하려는 것입니다.

기본값은 65535바이트와 4개입니다(server/core.c 91–92행). 파일 버킷과 길이를 모르는 버킷은 "읽기 전에는 메모리를 쓰지 않는다"는 이유로 2번 계산에서 빠집니다(같은 주석). 셋 다 아니면 남은 버킷을 ap_filter_setaside_brigade() 로 연결 쪽 풀(deferred_pool)에 맡겨 두고 돌아갑니다(946–1047행).

코어 출력 필터의 흐름. 맡겨 둔 버킷을 앞에 붙이고 논블로킹으로 보낸다. 다 보내면 성공, EAGAIN 이면 꼭 지금 보내야 하는지(FLUSH, FlushMaxThreshold, FlushMaxPipelined) 보고, 그렇다면 apr_poll 로 기다렸다가 다시 보내고, 아니면 남은 것을 맡겨 두고 돌아가 event MPM 이 쓸 수 있을 때 이어서 보낸다. 노란 상자가 "맡겨 두기"와 "되찾기"입니다. 회색은 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 에서는 두 필터가 나눠 맡습니다.

  1. HTTP_HEADER(PROTOCOL) — 첫 데이터 버킷이나 EOS 를 보면 그 앞에 RESPONSE 메타데이터 버킷을 끼웁니다. 상태 코드와 헤더 표를 담은 버킷입니다(modules/http/http_filters.c 1284–1285행).
  2. 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)
  1. ap_rflush() 가 FLUSH 버킷을 보내자 모여 있던 HEAP(15) 가 FLUSH 와 함께 내려왔습니다(top #1). 아직 EOS 가 없으므로 길이를 모릅니다.
  2. net #1 — 헤더 143바이트만 먼저 내려왔습니다. 위 코드의 "헤더를 먼저 보내고 CHUNK 를 꽂는" 자리입니다. 응답 헤더는 Content-Length 대신 Transfer-Encoding: chunked 였습니다.
  3. net #2 — TRANSIENT(3) 은 f\r\n(15의 16진수), HEAP(15) 는 본문, IMMORTAL(2) 는 \r\n 입니다. curl 의 출력 첫 덩어리와 정확히 맞습니다.
  4. net #3 — 빈 브리게이드가 한 번 지나갔습니다. CONTENT_LENGTH 필터는 FLUSH 까지를 먼저 넘긴 뒤, 함수 끝에서 남은 브리게이드(이제 비었습니다)를 한 번 더 넘깁니다(server/protocol.c 1936–1937행).
  5. net #4 — 두 번째 덩어리 c\r\n + 12바이트 + \r\n, 그리고 IMMORTAL(5) 가 끝 표시 0\r\n\r\n 입니다.

?flush 요청에서 맨 아래 bucketlog_net 이 본 브리게이드를 버킷 단위로 늘어놓은 그림. net #1 은 응답 헤더 HEAP(143) 하나로, Transfer-Encoding: chunked 가 들어 있고 CHUNK 필터는 이 뒤에 꽂힌다. net #2 는 첫 덩어리로 TRANSIENT(3) 길이 줄 f CR LF(15 의 16진수), HEAP(15) 본문 hello, buckets LF, IMMORTAL(2) 줄바꿈 CR LF, 그리고 ap_rflush 가 보낸 FLUSH(0) 이다. net #4 는 둘째 덩어리로 TRANSIENT(3) c CR LF(12 의 16진수), HEAP(12) after flush LF, IMMORTAL(2) CR LF, 끝 표시 IMMORTAL(5) 0 CR LF CR LF, 그리고 EOS(0) 이다 위 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;
}

server/util_filter.c 738–749행

인자가 셋 늘었습니다. 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편).

입력 필터 사슬. 출력은 ap_pass_brigade(f->next, bb) 로 위에서 아래로 밀어 내리고, 입력은 위가 아래에 ap_get_brigade(next, bb, mode, block, readbytes) 로 달라고 한다. 요청 줄과 헤더는 AP_MODE_GETLINE, 본문은 AP_MODE_READBYTES, 다음 요청 엿보기는 AP_MODE_SPECULATIVE 로 부른다. 요청이 중간 입력 필터들(예: 암호문을 평문으로 바꾸는 mod_ssl)을 거쳐 맨 아래 CORE_IN(ap_core_input_filter())에 닿는다. CORE_IN 의 브리게이드에는 처음에 SOCKET 버킷 하나가 있고, 읽으면 읽은 만큼의 HEAP 과 새 SOCKET 이 된다. GETLINE 이면 한 줄을, READBYTES 면 원하는 길이를 잘라 돌려주고, 남은 것은 다음 호출을 위해 들고 있다 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: 필터와 정적 파일).

다른 글