이 글에서 다루는 것
"nginx 소스 파헤치기" 연재의 세 번째 글입니다. 2편에서 마스터와 워커, 모듈, cycle, 설정 문맥으로 nginx 전체 지도를 그렸습니다. 이 글부터는 그 지도의 상자를 하나씩 확대합니다. 첫 상자는 2편 지도의 "A. 준비" 줄에 있는 메모리 풀·자료구조입니다.
노란 상자가 이 글입니다. 나머지 상자들이 쓰는 메모리와 자료구조가 모두 여기서 나옵니다.
이 상자를 가장 먼저 여는 이유가 있습니다. 4편부터 읽을 nginx 코드는 거의 모든 줄이 ngx_palloc(r->pool, …), ngx_str_t, ngx_array_push(), ngx_chain_t 중 하나를 씁니다. 이것을 모르고 읽으면 "이 메모리는 누가 언제 돌려주지?", "왜 문자열을 printf("%s") 로 찍지 않지?" 같은 질문에 계속 걸립니다.
답하려는 질문은 네 가지입니다.
- nginx 는 왜
malloc()/free()대신 풀을 쓰고, 풀 하나는 언제 생기고 언제 사라지는가 ngx_palloc()은 안에서 무엇을 하는가 — 블록이 차면, 큰 메모리를 달라고 하면 어떻게 되는가- nginx 의 문자열
ngx_str_t는 C 문자열과 무엇이 다르고, 왜 그렇게 만들었나 - 배열·리스트·큐·해시, 그리고 응답을 담는
ngx_buf_t·ngx_chain_t는 어떻게 생겼나
| 항목 | 내용 |
|---|---|
| 대상 | nginx |
| 버전 | nginx 1.31.9 (master, 개발판) |
| 커밋 | 9a7439fd79 (2026-10-23) |
| 주로 읽는 파일 | src/core/ngx_palloc.[ch], src/os/unix/ngx_alloc.c, src/core/ngx_string.[ch], ngx_array.[ch], ngx_list.[ch], ngx_queue.h, ngx_hash.[ch], ngx_rbtree.h, ngx_buf.[ch], src/http/ngx_http_request.c |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 소스를 같은 커밋으로 받는 방법과 예제를 돌리는 도커 환경은 1편에 있습니다. 풀과 자료구조 코드(ngx_palloc·ngx_array·ngx_list·ngx_queue·ngx_hash·ngx_rbtree·ngx_buf 의 .c·.h)만 합쳐도 3,193줄이고 문자열 함수 ngx_string.[ch] 가 2,369줄 더 있습니다. 이 글은 그중에서도 4편부터 읽을 코드를 따라가는 데 필요한 큰 줄기만 골랐습니다.
먼저 알아 둘 말
풀(pool)은 메모리를 빌려 주는 "통"입니다. 풀에서 받은 메모리는 하나씩 돌려주지 않고, 풀을 없앨 때(ngx_destroy_pool()) 한꺼번에 돌아갑니다. nginx 의 자료형 이름은 ngx_pool_t 입니다. httpd 4편의 APR 풀과 같은 생각인데, 만듦새는 꽤 다릅니다(8절에서 비교합니다).
블록(block)은 풀이 malloc 계열 함수로 한 번에 받아 오는 덩어리입니다. 풀은 블록 안을 잘게 나눠 줍니다. nginx 소스에는 따로 "블록"이라는 자료형이 없고, 블록마다 맨 앞에 ngx_pool_t 구조체(의 앞부분)가 들어 있습니다. 이 글에서는 편의상 블록이라고 부릅니다.
large(큰 할당)는 블록에 넣기에는 너무 큰 요청입니다. 풀은 이것을 블록에 넣지 않고 malloc() 으로 따로 받은 뒤 목록에 달아 둡니다.
cleanup 은 풀이 없어질 때 불러 달라고 풀에 맡겨 두는 함수입니다. 열어 둔 파일을 닫는 일처럼 "메모리를 돌려주는 것 말고 해야 할 뒷정리"를 여기에 둡니다. 함수 포인터와 void * 가 낯설다면 httpd 2편에 풀어 두었습니다.
ngx_str_t 는 nginx 의 문자열입니다. 길이(len)와 시작 주소(data) 두 필드뿐이고, C 문자열처럼 끝에 '\0'(NUL)이 있다고 기대하면 안 됩니다.
버퍼와 체인 — ngx_buf_t 는 "보낼 데이터가 어디에 있나"를 적는 구조체(메모리라면 시작·끝 주소, 파일이라면 파일과 위치), ngx_chain_t 는 그 버퍼들을 잇는 한 방향 연결 리스트입니다. 응답은 이 체인에 실려 필터를 지나 나갑니다.
정렬(alignment)은 데이터를 CPU 가 읽기 좋은 주소(예: 8의 배수)에 두는 것입니다. 정렬하지 않은 주소에 포인터나 정수를 두면 어떤 CPU 에서는 느려지고 어떤 CPU 에서는 아예 오류가 납니다.
1. 왜 풀인가 — nginx 의 메모리는 세 가지 수명으로 죽는다
웹 서버가 요청 하나를 처리하는 동안 만드는 데이터는 대부분 요청이 끝나는 순간 함께 필요 없어집니다. 요청 구조체, 헤더 목록, URI 문자열, 모듈마다 요청별로 쓰는 상태, 응답 헤더를 쓸 버퍼… 이것들을 malloc()/free() 로 하나씩 관리하면 수십 개 모듈이 저마다 "누가 언제 free() 하나"를 맞춰야 하고, 한 군데만 빠뜨려도 요청마다 메모리가 샙니다.
nginx 는 질문을 바꿉니다. "이 조각을 언제 버리나" 대신 "이 조각은 무엇과 같이 죽나" 를 고르게 합니다. 고를 수 있는 수명은 크게 셋입니다.
| 풀 | 어디에 | 만드는 곳 | 크기 | 없애는 곳 |
|---|---|---|---|---|
| cycle 풀 | cycle->pool |
ngx_init_cycle() |
NGX_CYCLE_POOL_SIZE = 16KB |
워커·마스터가 끝날 때, 마스터에서는 reload 로 새 cycle 을 세운 뒤 옛 cycle 의 풀 |
| 연결 풀 | c->pool |
ngx_event_accept() |
connection_pool_size (기본 64 * sizeof(void *) = 64비트에서 512) |
ngx_http_close_connection() |
| 요청 풀 | r->pool |
ngx_http_alloc_request() |
request_pool_size (기본 4096) |
ngx_http_free_request() |
cycle 은 2편에서 본 "설정 한 벌로 돌아가는 nginx 의 상태 전체"입니다. 설정을 읽어 만든 모듈별 설정 구조체, 리스닝 소켓 목록, 열어 둔 로그 파일이 모두 cycle 풀에서 나옵니다. 그래서 nginx -s reload 로 새 cycle 이 서면 옛 cycle 의 풀을 통째로 버리는 것으로 옛 설정이 깨끗이 사라집니다. reload 의 자세한 순서는 4편과 6편에서 봅니다.
설정 파일에서 보던 connection_pool_size·request_pool_size 디렉티브가 바로 아래 두 풀의 크기입니다. 기본값은 ngx_http_core_merge_srv_conf() 에 있습니다.
ngx_conf_merge_size_value(conf->connection_pool_size,
prev->connection_pool_size, 64 * sizeof(void *));
ngx_conf_merge_size_value(conf->request_pool_size,
prev->request_pool_size, 4096);
src/http/ngx_http_core_module.c 3622–3625행
두 디렉티브에는 값을 검사하는 함수 ngx_http_core_pool_size() 가 붙어 있어서, NGX_MIN_POOL_SIZE(64비트에서 112바이트)보다 작거나 16의 배수가 아니면 설정을 거부합니다(5504–5523행).
요청 풀을 만드는 곳은 요청 구조체를 만드는 곳과 같습니다. 순서를 눈여겨볼 만합니다.
pool = ngx_create_pool(cscf->request_pool_size, c->log);
if (pool == NULL) {
return NULL;
}
r = ngx_pcalloc(pool, sizeof(ngx_http_request_t));
if (r == NULL) {
ngx_destroy_pool(pool);
return NULL;
}
r->pool = pool;
src/http/ngx_http_request.c 584–595행
풀을 먼저 만들고, 요청 구조체 r 자체를 그 풀에서 받습니다. 그래서 요청이 끝날 때 풀 하나를 없애면 r 까지 함께 사라집니다. 없애는 쪽 ngx_http_free_request() 의 마지막 부분이 그것을 주석으로 말합니다.
/*
* Setting r->pool to NULL will increase probability to catch double close
* of request since the request object is allocated from its own pool.
*/
pool = r->pool;
r->pool = NULL;
ngx_destroy_pool(pool);
src/http/ngx_http_request.c 4063–4071행
r->pool 을 먼저 NULL 로 만드는 것은 같은 요청을 두 번 닫는 버그를 잡기 위해서입니다. 함수 앞쪽에서 r->pool == NULL 이면 "http request already closed" 를 찍고 돌아갑니다(3994–3997행). 물론 r 자체가 이미 돌려준 메모리 안에 있으므로 "확률을 높일" 뿐이라고 주석이 솔직하게 적고 있습니다.
요청 풀은 연결 풀보다 짧게, 연결 풀은 cycle 풀보다 짧게 삽니다. keep-alive 연결 하나에서 요청 풀은 여러 번 생겼다 사라집니다.
헷갈리기 쉬운 곳
- nginx 의 요청 풀은 연결 풀의 "자식"이 아닙니다. APR 풀은 부모·자식 나무를 이루어 부모를 없애면 자식이 먼저 없어지지만(httpd 4편),
ngx_pool_t에는 부모를 가리키는 필드가 없습니다(2절의 구조체를 보세요). 요청 풀과 연결 풀은 서로 모르는 독립된 풀이고, HTTP 코드가 순서를 지켜 따로따로 없앱니다. connection_pool_size512바이트는 생각보다 작습니다. 2절에서 보듯 풀은 이 크기에서 자기 머리 80바이트를 빼고 남은 432바이트보다 큰 요청을 블록에 넣지 않고malloc()으로 따로 받습니다. 기본 1KB 인client_header_buffer_size버퍼가 바로 그렇습니다. 4절 예제에서 이것이 디버그 로그에 그대로 보입니다.
2. ngx_pool_t 의 생김새 — 블록 사슬, large 목록, cleanup 목록
풀 구조체입니다. ngx_pool_s 가 이름이고 ngx_pool_t 는 그 typedef 입니다(nginx 는 struct ngx_xxx_s 를 정의하고 ngx_xxx_t 로 부르는 습관이 있습니다).
typedef struct {
u_char *last;
u_char *end;
ngx_pool_t *next;
ngx_uint_t failed;
} ngx_pool_data_t;
struct ngx_pool_s {
ngx_pool_data_t d;
size_t max;
ngx_pool_t *current;
ngx_chain_t *chain;
ngx_pool_large_t *large;
ngx_pool_cleanup_t *cleanup;
ngx_log_t *log;
};
두 층으로 나눠 읽습니다.
d(ngx_pool_data_t) — 블록마다 하나씩 있는 머리.last는 아직 아무에게도 주지 않은 첫 바이트,end는 블록의 끝,next는 다음 블록,failed는 3절에서 볼 "이 블록에서 자리를 못 찾은 횟수"입니다.- 그 뒤의 필드 — 풀 전체에 하나만 의미가 있는 것.
max는 블록에서 나눠 줄 수 있는 가장 큰 크기,current는 할당을 시작할 블록,chain은 다 쓴ngx_chain_t고리를 모아 두는 목록(7절),large는 큰 할당 목록,cleanup은 뒷정리 함수 목록입니다.
첫 블록은 ngx_pool_t 전체를 머리로 갖고, 두 번째 블록부터는 앞의 d 부분만 씁니다(3절의 ngx_palloc_block()). 자료형은 둘 다 ngx_pool_t * 로 가리키지만 두 번째 블록에서는 d 바로 뒤부터 할당에 쓰이므로 max·current 자리에는 다른 데이터가 들어 있습니다. 그 필드는 언제나 첫 블록에서만 읽어야 합니다.
large 목록의 고리 ngx_pool_large_t 는 next·alloc 두 필드, cleanup 목록의 고리 ngx_pool_cleanup_t 는 함수 포인터 handler(void (*)(void *data))·data·next 세 필드짜리 작은 구조체입니다(ngx_palloc.h 30–46행).
풀을 만드는 함수는 짧습니다.
ngx_pool_t *
ngx_create_pool(size_t size, ngx_log_t *log)
{
ngx_pool_t *p;
p = ngx_memalign(NGX_POOL_ALIGNMENT, size, log);
if (p == NULL) {
return NULL;
}
p->d.last = (u_char *) p + sizeof(ngx_pool_t);
p->d.end = (u_char *) p + size;
p->d.next = NULL;
p->d.failed = 0;
size = size - sizeof(ngx_pool_t);
p->max = (size < NGX_MAX_ALLOC_FROM_POOL) ? size : NGX_MAX_ALLOC_FROM_POOL;
p->current = p;
p->chain = NULL;
p->large = NULL;
p->cleanup = NULL;
p->log = log;
return p;
}
ngx_memalign()으로size바이트를 16바이트 정렬해서 받습니다(NGX_POOL_ALIGNMENT는 16, ngx_palloc.h 24행). 리눅스에서 이 함수는posix_memalign()을 부르고 디버그 로그에posix_memalign: 주소:크기 @정렬을 남깁니다(src/os/unix/ngx_alloc.c 51–69행).- 받은 메모리의 맨 앞에 풀 구조체 자신을 놓고,
last를 그 바로 뒤로 둡니다. 풀 구조체가 따로malloc되지 않고 자기 첫 블록 안에 들어 있는 것입니다. max는 "블록에서 머리를 뺀 크기"와NGX_MAX_ALLOC_FROM_POOL(ngx_pagesize - 1, 보통 4095, ngx_palloc.h 16–20행) 중 작은 쪽입니다.
64비트 리눅스에서 sizeof(ngx_pool_t) 는 80바이트입니다(3절 gdb 예제에서 확인합니다). 그러면 기본 크기별 max 는 이렇게 됩니다.
| 풀 | 블록 크기 | max |
이보다 큰 요청은 |
|---|---|---|---|
| 연결 풀 | 512 | 432 | malloc() (large) |
| 요청 풀 | 4096 | 4016 | malloc() (large) |
| cycle 풀 | 16384 | 4095 | malloc() (large) |
노란색은 메모리, 초록색이 다음 할당이 떼어 갈 남은 곳, 파란색이 뒷정리 함수입니다.
3. ngx_palloc — 할당은 포인터를 미는 것
풀에서 메모리를 받는 함수는 넷입니다. ngx_palloc()(정렬해서), ngx_pnalloc()(정렬하지 않고), ngx_pcalloc()(0으로 채워서), ngx_pmemalign()(원하는 정렬로, 언제나 large). ngx_pnalloc() 은 아래 ngx_palloc() 과 똑같고 ngx_palloc_small() 에 넘기는 셋째 인자만 1 대신 0 입니다(135–145행).
void *
ngx_palloc(ngx_pool_t *pool, size_t size)
{
#if !(NGX_DEBUG_PALLOC)
if (size <= pool->max) {
return ngx_palloc_small(pool, size, 1);
}
#endif
return ngx_palloc_large(pool, size);
}
src/core/ngx_palloc.c 122–132행
크기가 max 이하면 블록에서(ngx_palloc_small), 넘으면 malloc 으로(ngx_palloc_large) 갑니다. 셋째 인자 1/0 이 정렬 여부입니다. 문자열처럼 한 바이트씩 읽는 데이터는 정렬이 필요 없으므로 ngx_pnalloc() 을 써서 몇 바이트를 아낍니다. 실제로 ngx_pstrdup() 은 ngx_pnalloc() 을 씁니다(src/core/ngx_string.c 74–87행).
#if !(NGX_DEBUG_PALLOC) 는 "풀 디버깅을 켜지 않았을 때만"입니다. 이 매크로를 켜면 모든 할당이 large 로 가서 하나하나 malloc() 되므로 Valgrind 같은 메모리 검사 도구가 조각마다 경계를 볼 수 있습니다. auto/ 아래 configure 스크립트에는 이 이름이 없으므로(검색해 보면 나오지 않습니다) 켜려면 --with-cc-opt=-DNGX_DEBUG_PALLOC=1 처럼 컴파일러 옵션으로 직접 줘야 합니다. 이 글의 빌드는 켜지 않았습니다.
빠른 길: ngx_palloc_small
static ngx_inline void *
ngx_palloc_small(ngx_pool_t *pool, size_t size, ngx_uint_t align)
{
u_char *m;
ngx_pool_t *p;
p = pool->current;
do {
m = p->d.last;
if (align) {
m = ngx_align_ptr(m, NGX_ALIGNMENT);
}
if ((size_t) (p->d.end - m) >= size) {
p->d.last = m + size;
return m;
}
p = p->d.next;
} while (p);
return ngx_palloc_block(pool, size);
}
src/core/ngx_palloc.c 148–174행
current블록부터 봅니다.- 정렬이 필요하면
last를NGX_ALIGNMENT(sizeof(uintptr_t), 64비트에서 8, src/core/ngx_config.h 96–102행)의 배수로 올립니다.ngx_align_ptr는 "더하고 아래 비트를 지우는" 비트 연산 매크로입니다. - 남은 곳(
end - m)이 충분하면last를size만큼 밀고 옛 자리를 돌려줍니다. 끝입니다. - 모자라면 다음 블록에서 다시 봅니다. 끝까지 없으면 새 블록을 만듭니다.
할당의 거의 전부가 3번에서 끝납니다. 비교 한 번, 덧셈 한 번입니다. malloc() 처럼 빈 곳을 찾거나, 나중의 free() 를 위해 조각 크기를 적어 두거나, 스레드끼리 잠금을 잡는 일이 없습니다. 조각을 하나씩 돌려받지 않으니 그럴 필요가 없습니다. 함수에 ngx_inline 을 붙여 함수 호출 비용까지 줄였습니다. 잠금이 없다는 것은 풀 하나를 여러 스레드가 함께 쓰면 안 된다는 뜻이기도 한데, nginx 워커는 이벤트 루프 하나로 돌므로 보통은 문제가 되지 않습니다(7편).
블록이 찼을 때: ngx_palloc_block 과 failed
static void *
ngx_palloc_block(ngx_pool_t *pool, size_t size)
{
u_char *m;
size_t psize;
ngx_pool_t *p, *new;
psize = (size_t) (pool->d.end - (u_char *) pool);
m = ngx_memalign(NGX_POOL_ALIGNMENT, psize, pool->log);
if (m == NULL) {
return NULL;
}
new = (ngx_pool_t *) m;
new->d.end = m + psize;
new->d.next = NULL;
new->d.failed = 0;
m += sizeof(ngx_pool_data_t);
m = ngx_align_ptr(m, NGX_ALIGNMENT);
new->d.last = m + size;
for (p = pool->current; p->d.next; p = p->d.next) {
if (p->d.failed++ > 4) {
pool->current = p->d.next;
}
}
p->d.next = new;
return m;
}
src/core/ngx_palloc.c 177–210행
- 첫 블록과 같은 크기(
psize)로 새 블록을 받습니다. - 새 블록의 머리는
ngx_pool_data_t(32바이트)만큼만 쓰고, 바로 뒤에서 이번 할당을 떼어 줍니다. current부터 마지막 앞 블록까지failed를 하나씩 올립니다. 올리기 전 값이 4보다 크면(이미 5번 실패했으면)current를 그다음 블록으로 옮깁니다.- 새 블록을 사슬 끝에 붙입니다.
failed 가 하는 일이 이 함수의 요점입니다. 블록에 몇십 바이트가 남아 있으면 작은 할당은 거기서 성공할 수 있으니 버리기 아깝습니다. 그렇다고 거의 찬 블록을 매번 처음부터 훑으면 블록이 늘수록 할당이 느려집니다. 그래서 nginx 는 "새 블록을 만들어야 했던 일이 여섯 번째가 되면 그 블록은 이제 찼다고 치고 건너뛴다" 는 타협을 합니다. 남은 몇십 바이트는 버리는 대신 할당이 언제나 사슬의 뒤쪽 몇 블록만 보게 됩니다.
초록색 길이 거의 언제나 지나는 길입니다. 노란 상자 둘에서만 malloc 계열 함수가 불립니다.
큰 할당: ngx_palloc_large
p = ngx_alloc(size, pool->log);
/* ... */
for (large = pool->large; large; large = large->next) {
if (large->alloc == NULL) {
large->alloc = p;
return p;
}
if (n++ > 3) {
break;
}
}
large = ngx_palloc_small(pool, sizeof(ngx_pool_large_t), 1);
/* ... */
large->alloc = p;
large->next = pool->large;
pool->large = large;
return p;
}
src/core/ngx_palloc.c 220–249행
ngx_alloc() 은 malloc() 을 부르고 디버그 로그에 malloc: 주소:크기 를 남기는 얇은 포장입니다(src/os/unix/ngx_alloc.c 17–31행). 받은 주소는 ngx_pool_large_t 고리에 적어 둡니다. 목록 앞쪽 다섯 칸까지 alloc == NULL 인 빈 칸(4절의 ngx_pfree() 가 비운 칸)이 있으면 그 칸을 다시 쓰고, 없으면 고리 하나를 풀의 작은 할당으로 새로 받아 목록 맨 앞에 붙입니다. 큰 메모리는 malloc 으로, 그것을 기억하는 16바이트짜리 고리는 풀에서 — 두 길이 섞여 있습니다.
예제 1: gdb 로 요청 풀을 들여다보기
이 예제는 기준 커밋(9a7439fd79)을 그대로 빌드한 nginx 1.31.9 로 도커 컨테이너(Debian bookworm, --with-debug, -O0 -g)에서 실행했습니다. gdb 로 다루기 쉽게 -g 'daemon off; master_process off;' 로 프로세스 하나로 띄웠고, 정적 파일 요청 하나를 보내 ngx_http_static_handler() 첫 줄에서 멈춘 뒤 아래 gdb 명령을 실행했습니다. 전체 파일은 예제 폴더 ex/u03-pool-gdb/ 의 nginx.conf·pool.gdb·run.sh 입니다.
# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다) — pool.gdb 에서 발췌
# (줄인 곳: set pagination·confirm 두 줄, 구분 줄을 찍는 echo 들, === 1. 의 print 세 줄, 끝의 kill·quit)
# 풀의 블록 사슬을 따라가며 블록마다 쓴 양·남은 양·failed 를 찍는다
define poolwalk
set $p = $arg0
while $p
printf " block %p used=%4ld free=%4ld failed=%lu\n", $p, (long)($p->d.last - (u_char *) $p), (long)($p->d.end - $p->d.last), $p->d.failed
set $p = $p->d.next
end
printf " current=%p large=%p\n", $arg0->current, $arg0->large
end
break ngx_http_static_handler
run
print sizeof(ngx_pool_t)
print sizeof(ngx_http_request_t)
print *r->pool
poolwalk r->pool
# ngx_palloc(r->pool, 3000) 을 여덟 번 직접 부른다
set $i = 0
while $i < 8
call (void) ngx_palloc(r->pool, 3000)
set $i = $i + 1
end
poolwalk r->pool
# max 보다 큰 5000 바이트
print r->pool->max
set $big = ngx_palloc(r->pool, 5000)
print $big
print *r->pool->large
=== 2. the request pool ===
$4 = 80
$5 = 1360
$6 = {d = {last = 0x55555577e43c "ubject = \"/CN=openssl-cmp-test\"\nnewkey = insta.priv.pem\nout_trusted = apps/insta.ca.crt # does not include keyUsage digitalSignature\ncertout = insta.cert.pem\n\n[pbm] # Password-based protection for Ins"..., end = 0x55555577e700 "\020\020", next = 0x5555557aa320, failed = 0}, max = 4016, current = 0x55555577d700, chain = 0x0, large = 0x0, cleanup = 0x0, log = 0x555555780c20}
block 0x55555577d700 used=3388 free= 708 failed=0
block 0x5555557aa320 used=1152 free=2944 failed=0
current=0x55555577d700 large=(nil)
=== 3. call ngx_palloc(r->pool, 3000) eight times ===
block 0x55555577d700 used=3388 free= 708 failed=6
block 0x5555557aa320 used=1152 free=2944 failed=6
block 0x5555557ab330 used=3032 free=1064 failed=6
block 0x5555557ac340 used=3032 free=1064 failed=5
block 0x5555557ad350 used=3032 free=1064 failed=4
block 0x5555557ae360 used=3032 free=1064 failed=3
block 0x5555557af370 used=3032 free=1064 failed=2
block 0x5555557b0380 used=3032 free=1064 failed=1
block 0x5555557b1390 used=3032 free=1064 failed=0
block 0x5555557b23a0 used=3032 free=1064 failed=0
current=0x5555557ac340 large=(nil)
=== 4. call ngx_palloc(r->pool, 5000) -- larger than max ===
$7 = 4016
$8 = (void *) 0x5555557b33b0
$9 = {next = 0x0, alloc = 0x5555557b33b0}
(주소는 환경에 따라 다릅니다. gdb 가 주소 무작위화(ASLR)를 끄고 띄우므로 같은 컨테이너에서 다시 돌렸을 때는 주소까지 같았습니다. === 1. 부분은 5절에서 봅니다.)
읽을 것이 많습니다.
sizeof(ngx_pool_t)= 80,max= 4016. 2절의 계산(4096 − 80)과 같습니다.- 요청 구조체 하나가 1,360바이트입니다. 정적 파일 핸들러에 닿았을 때 이미 첫 블록에서 3,388바이트를 썼고, 두 번째 블록까지 생겨 있습니다. 요청 구조체, 헤더 목록, 모듈별 ctx 배열, 변수 배열… 기본 4KB 요청 풀은 평범한 GET 요청 하나에도 한 블록으로 모자랍니다.
- 3,000바이트를 여덟 번 달라고 하자 블록이 여덟 개 늘었습니다. 새 블록의
used=3032는 머리 32바이트(ngx_pool_data_t) + 3,000바이트입니다. 첫 블록이 아니라서 머리가 80이 아니라 32인 것이 보입니다. failed와current. 블록을 하나 만들 때마다current부터 마지막 앞 블록까지failed가 하나씩 올라, 앞쪽 블록일수록 숫자가 큽니다. 여섯 번째 호출에서 첫 블록이, 일곱 번째에 둘째 블록이, 여덟 번째에 셋째 블록이 차례로current에서 빠져current가 넷째 블록(0x5555557ac340)이 되었습니다. 첫 블록에는 708바이트, 둘째 블록에는 2,944바이트가 남아 있는데도 이제 작은 할당은 거기를 보지 않습니다. 앞의 "타협"이 숫자로 보입니다.- 5,000바이트는
max를 넘어 large 로 갔습니다.large목록의 첫 고리alloc이 돌려받은 주소와 같습니다.
헷갈리기 쉬운 곳
ngx_palloc()이 준 메모리는 비어 있지 않습니다. 위 출력의d.last를 보세요. 아직 아무도 쓰지 않은 남은 곳에"ubject = \"/CN=openssl-cmp-test\"…"라는 엉뚱한 글자가 들어 있습니다.posix_memalign()이 돌려준 메모리에 그 전에 다른 코드(이 글자로 보아 OpenSSL 설정 파일을 읽던 쪽으로 보입니다)가 쓰고 버린 내용이 그대로 남은 것입니다. 0으로 채워진 메모리가 필요하면ngx_pcalloc()을 씁니다. 요청 구조체도ngx_pcalloc()으로 받습니다(1절).max근처 크기는 블록을 낭비합니다. 3,000바이트를 받으면 4KB 블록에 1,064바이트가 남는데, 이 블록도 몇 번의 실패 뒤에는 건너뛰어집니다. 4KB 에 가까운 큰 덩어리를 요청 풀에서 자주 받는다면request_pool_size를 늘리는 것이 낫다는 뜻이기도 합니다.
4. 돌려주기 — destroy, cleanup, 그리고 large 만 되는 ngx_pfree
ngx_destroy_pool
void
ngx_destroy_pool(ngx_pool_t *pool)
{
ngx_pool_t *p, *n;
ngx_pool_large_t *l;
ngx_pool_cleanup_t *c;
for (c = pool->cleanup; c; c = c->next) {
if (c->handler) {
ngx_log_debug1(NGX_LOG_DEBUG_ALLOC, pool->log, 0,
"run cleanup: %p", c);
c->handler(c->data);
}
}
#if (NGX_DEBUG)
/* ... "free: %p" 와 "free: %p, unused: %uz" 를 찍는 디버그 전용 고리 ... */
#endif
for (l = pool->large; l; l = l->next) {
if (l->alloc) {
ngx_free(l->alloc);
}
}
for (p = pool, n = pool->d.next; /* void */; p = n, n = n->d.next) {
ngx_free(p);
if (n == NULL) {
break;
}
}
}
- cleanup 을 부릅니다. 목록 앞에서부터인데, 아래에서 보듯 cleanup 은 언제나 목록 앞에 붙으므로 결국 나중에 건 것부터 불립니다.
- large 를 모두
free()합니다. - 블록을 모두
free()합니다. 첫 블록 안에 풀 구조체가 있으므로 마지막ngx_free(p)가 풀 자신까지 돌려줍니다. 그래서 고리를 돌기 전에n = p->d.next를 먼저 읽어 둡니다.
풀을 없애는 순서. 회색 칸의 로그는 아래 예제 2의 실제 출력에서 가져왔습니다.
#if (NGX_DEBUG) 블록(61–81행)은 --with-debug 로 빌드했을 때만 들어가는 부분으로, 실제로 돌려주기 전에 large 마다 free: 주소, 블록마다 free: 주소, unused: 남은 바이트 를 로그에 찍습니다. 돌려주는 중에 찍지 않는 까닭은 주석에 있습니다 — "we could allocate the pool->log from this pool", 로그 구조체 자체가 이 풀에 들어 있을 수 있기 때문입니다.
cleanup 을 거는 쪽입니다.
c = ngx_palloc(p, sizeof(ngx_pool_cleanup_t));
/* ... */
c->handler = NULL;
c->next = p->cleanup;
p->cleanup = c;
ngx_log_debug1(NGX_LOG_DEBUG_ALLOC, p->log, 0, "add cleanup: %p", c);
return c;
}
src/core/ngx_palloc.c 316–339행
고리와 함께 size 바이트의 데이터 칸까지 풀에서 받아 주고(줄인 자리, 321–329행), handler 는 NULL 로 둔 채 돌려줍니다. 함수를 채우는 것은 부른 쪽의 일이고, handler 가 NULL 인 고리는 destroy 때 건너뜁니다. 그래서 "일단 자리를 잡아 두고, 일이 성공했을 때만 handler 를 채운다"는 쓰임이 가능합니다. 정적 파일을 여는 ngx_open_cached_file() 이 딱 그렇게 씁니다.
cln = ngx_pool_cleanup_add(pool, sizeof(ngx_pool_cleanup_file_t));
if (cln == NULL) {
return NGX_ERROR;
}
rc = ngx_open_and_stat_file(name, of, pool->log);
if (rc == NGX_OK && !of->is_dir) {
cln->handler = ngx_pool_cleanup_file;
clnf = cln->data;
clnf->fd = of->fd;
clnf->name = name->data;
clnf->log = pool->log;
}
src/core/ngx_open_file_cache.c 181–195행
(open_file_cache 를 켜지 않았을 때의 길입니다.) 파일 열기에 성공했을 때만 handler 에 ngx_pool_cleanup_file 을 넣습니다. 여기서 pool 은 정적 파일 핸들러가 넘긴 r->pool 이므로, 요청 풀이 없어질 때 파일도 닫힙니다. close() 를 부르는 코드를 요청 처리 어디에도 쓸 필요가 없습니다. ngx_pool_cleanup_file() 은 file cleanup: fd:N 을 로그에 찍고 ngx_close_file() 을 부릅니다(ngx_palloc.c 363–375행).
ngx_pfree — large 만 돌려줄 수 있다
ngx_int_t
ngx_pfree(ngx_pool_t *pool, void *p)
{
ngx_pool_large_t *l;
for (l = pool->large; l; l = l->next) {
if (p == l->alloc) {
ngx_log_debug1(NGX_LOG_DEBUG_ALLOC, pool->log, 0,
"free: %p", l->alloc);
ngx_free(l->alloc);
l->alloc = NULL;
return NGX_OK;
}
}
return NGX_DECLINED;
}
src/core/ngx_palloc.c 277–294행
APR 에는 없는, 조각 하나를 돌려주는 함수입니다. 다만 large 목록에 있는 것만 됩니다. 블록에서 받은 작은 조각을 넘기면 목록에서 못 찾고 NGX_DECLINED 를 돌려줄 뿐 아무 일도 하지 않습니다. 돌려준 칸은 alloc = NULL 로 비워 두고, 다음 ngx_palloc_large() 가 다시 씁니다(3절).
이 함수가 왜 필요한지는 keep-alive 연결을 보면 압니다. 쉬고 있는 keep-alive 연결이 수천 개라면, 그 연결마다 1KB 헤더 버퍼를 쥐고 있는 것은 낭비입니다. ngx_http_set_keepalive() 는 요청을 마칠 때 이렇게 합니다.
/*
* To keep a memory footprint as small as possible for an idle keepalive
* connection we try to free c->buffer's memory if it was allocated outside
* the c->pool. The large header buffers are always allocated outside the
* c->pool and are freed too.
*/
b = c->buffer;
if (ngx_pfree(c->pool, b->start) == NGX_OK) {
src/http/ngx_http_request.c 3463–3472행
"c->pool 밖에서(large 로) 받았다면 돌려준다"는 말이 1절 끝의 "512바이트 연결 풀" 이야기와 이어집니다. 기본 설정에서 1KB 헤더 버퍼는 연결 풀의 max(432)를 넘으므로 언제나 large 이고, 그래서 언제나 돌려줄 수 있습니다. 반대로 connection_pool_size 를 크게 잡아 버퍼가 블록 안에 들어가면 이 ngx_pfree() 는 실패하고 버퍼는 연결이 닫힐 때까지 남습니다.
예제 2: 디버그 로그로 풀이 생기고 사라지는 것 보기
워커 하나, error_log … debug; 로 띄운 nginx 에 curl 로 연결 하나에 요청 두 개(a.html, b.html)를 보내고, 디버그 로그에서 할당·해제 줄만 골랐습니다. 설정과 스크립트는 예제 폴더 ex/u03-pool-debug-log/ 에 있습니다. 실행 환경은 예제 1과 같고, 이번에는 마스터·워커로 평소처럼 띄웠습니다.
curl -s http://127.0.0.1:8080/a.html http://127.0.0.1:8080/b.html
[debug] 11#11: posix_memalign: 0000559582B79BC0:512 @16
[debug] 11#11: *2 accept: 127.0.0.1:41964 fd:3
[debug] 11#11: *2 malloc: 0000559582B7C410:1024
[debug] 11#11: *2 posix_memalign: 0000559582B76700:4096 @16
[debug] 11#11: *2 http request line: "GET /a.html HTTP/1.1"
[debug] 11#11: *2 posix_memalign: 0000559582BA3320:4096 @16
[debug] 11#11: *2 add cleanup: 0000559582B77450
[debug] 11#11: *2 set http keepalive handler
[debug] 11#11: *2 http close request
[debug] 11#11: *2 run cleanup: 0000559582B77450
[debug] 11#11: *2 file cleanup: fd:10
[debug] 11#11: *2 free: 0000559582B76700, unused: 16
[debug] 11#11: *2 free: 0000559582BA3320, unused: 2608
[debug] 11#11: *2 free: 0000559582B7C410
[debug] 11#11: *2 hc free: 0000000000000000
[debug] 11#11: *2 hc busy: 0000000000000000 0
[debug] 11#11: *2 http keepalive handler
[debug] 11#11: *2 malloc: 0000559582B7C410:1024
[debug] 11#11: *2 posix_memalign: 0000559582B76700:4096 @16
[debug] 11#11: *2 http request line: "GET /b.html HTTP/1.1"
...
[debug] 11#11: *2 http keepalive handler
[debug] 11#11: *2 malloc: 0000559582B7C410:1024
[debug] 11#11: *2 close http connection: 3
[debug] 11#11: *2 free: 0000559582B7C410
[debug] 11#11: *2 free: 0000559582B79BC0, unused: 128
(줄 앞의 날짜·시각은 지웠습니다. pid 11, 연결 번호 *2, 주소, fd 는 실행마다 다릅니다. b.html 요청의 줄들은 a.html 과 주소까지 똑같아서 ... 로 줄였습니다.)
위에서부터 소스와 짝을 맞춰 봅니다.
| 로그 | 무슨 일 | 소스 |
|---|---|---|
posix_memalign: …:512 @16 |
연결 풀(512바이트). accept 직후, 연결 번호가 붙기 전 | ngx_event_accept.c 159행 |
malloc: …:1024 |
헤더 버퍼 1KB. 연결 풀 max 432를 넘어 large |
ngx_http_request.c 413행 |
posix_memalign: …:4096 @16 (첫째) |
요청 풀 | ngx_http_request.c 584행 |
posix_memalign: …:4096 @16 (둘째) |
요청 풀의 두 번째 블록 (ngx_palloc_block) |
ngx_palloc.c 186행 |
add cleanup |
정적 파일을 열며 파일 닫기 cleanup 을 건다 | ngx_palloc.c 336행 |
run cleanup → file cleanup: fd:10 |
요청 풀 destroy 의 첫 단계, 파일이 닫힌다 | ngx_palloc.c 55–57행, 368–369행 |
free: …, unused: 16 / unused: 2608 |
요청 풀의 두 블록. 첫 블록은 16바이트만 남기고 꽉 찼다 | ngx_palloc.c 72–79행 |
free: …C410 (unused 없음) |
keep-alive 로 넘어가며 헤더 버퍼를 ngx_pfree() |
ngx_palloc.c 284–285행 |
http keepalive handler → malloc: …:1024 |
다음 요청 바이트가 오자 헤더 버퍼를 다시 받는다 | ngx_http_request.c 3644행 |
close http connection: 3 → free: …C410, free: …79BC0, unused: 128 |
연결 풀 destroy. large(헤더 버퍼) 다음에 블록 | ngx_http_request.c 4124–4128행 |
세 가지를 확인할 수 있습니다.
- 요청 풀은 요청마다 새로 생기고 사라집니다. 같은 연결(
*2)에서a.html과b.html이 저마다posix_memalign …4096으로 시작해free: …, unused:로 끝납니다. 연결 풀(…79BC0)은 연결이 닫힐 때 한 번만 사라집니다. - 두 번째 요청이 첫 번째 요청과 같은 주소를 받았습니다(
…76700,…BA3320,…C410). 풀은free()한 메모리를 C 라이브러리의malloc에 돌려주고,malloc이 같은 크기 요청에 방금 돌려받은 자리를 다시 내준 것입니다. 1절의 "재사용"이 실제 주소로 보입니다. - cleanup 은 블록을 돌려주기 전에 불립니다.
run cleanup/file cleanup줄이free:줄보다 앞섭니다. cleanup 함수가 풀 안의 데이터(clnf->fd)를 읽어야 하므로 이 순서여야 합니다.
*2 close http connection 바로 앞의 malloc: …:1024 는 curl 이 연결을 닫을 때 keep-alive 핸들러가 읽을 버퍼를 다시 받았다가 읽은 바이트가 0이라 연결을 닫은 흔적입니다. 워커가 끝날 때(-s quit)는 cycle 풀이 같은 방식으로 정리되는 줄이 이어집니다. cycle 풀은 16KB 블록 다섯 개와 large 일곱 개였습니다(출력은 싣지 않습니다).
헷갈리기 쉬운 곳
ngx_reset_pool()은 cleanup 을 부르지 않습니다. 풀을 비우는 함수가 따로 있긴 합니다. large 를free()하고 블록마다last를 머리 뒤로 되돌리는데(ngx_palloc.c 99–119행), cleanup 목록은 건드리지 않습니다. 이 커밋에서 이 함수를 부르는 곳은 HTTP 와 stream 의 geo 모듈 안뿐이니, 요청 처리 흐름에서 마주칠 일은 거의 없습니다.- 두 번째 블록부터는 머리 뒤로 되돌릴 때도
sizeof(ngx_pool_t)를 씁니다.ngx_reset_pool()은 모든 블록의last를p + sizeof(ngx_pool_t)로 돌리므로,ngx_pool_data_t(32바이트) 머리만 쓰던 블록에서는 48바이트를 덜 쓰게 됩니다. 버그라기보다 단순하게 짠 것입니다. r->cleanup은 풀의 cleanup 과 다른 목록입니다.ngx_http_free_request()는 풀을 없애기 전에 요청 전용 cleanup 목록r->cleanup을 먼저 돌립니다(3999–4008행). upstream 이 백엔드 연결을 정리하는 데 이것을 씁니다(11편).
5. ngx_str_t — NUL 로 끝나지 않는 문자열
typedef struct {
size_t len;
u_char *data;
} ngx_str_t;
길이와 시작 주소, 그게 전부입니다. C 문자열은 끝에 '\0' 을 두어 길이를 나타내지만, ngx_str_t 는 길이를 따로 들고 다니므로 끝에 '\0' 이 없어도 됩니다. 이것이 왜 중요한지는 클라이언트가 보낸 요청을 생각하면 압니다. 요청 줄 GET /index.html HTTP/1.1 을 받은 nginx 는 URI 를 새 메모리에 복사하지 않고, 헤더 버퍼 안의 / 를 가리키는 data 와 길이 11 만 적습니다. 복사도, 할당도 없습니다. C 문자열이었다면 URI 끝(공백 자리)에 '\0' 을 써 넣거나 복사본을 만들어야 했을 겁니다.
예제 1의 나머지: gdb 로 본 r->uri
예제 1에서 같은 정지점에서 Host: example.test 헤더를 붙인 요청의 ngx_str_t 들도 찍었습니다.
=== 1. ngx_str_t is not NUL-terminated ===
$1 = {len = 11, data = 0x555555783414 "/index.html HTTP/1.1\r\nHost"}
$2 = 11
$3 = {len = 12, data = 0x555555783430 "example.test"}
gdb 는 u_char * 를 C 문자열로 여겨 '\0' 이 나올 때까지 찍습니다. 그래서 r->uri 는 길이가 11인데도 그 뒤의 HTTP/1.1\r\nHost 까지 보입니다. URI 가 헤더 버퍼 안을 그대로 가리키고 있다는 증거입니다. 그런데 왜 Host 에서 멈췄을까요? 원래 그 자리에는 : 가 있어야 합니다.
h = ngx_list_push(&r->headers_in.headers);
if (h == NULL) {
ngx_http_close_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
break;
}
h->hash = r->header_hash;
h->key.len = r->header_name_end - r->header_name_start;
h->key.data = r->header_name_start;
h->key.data[h->key.len] = '\0';
h->value.len = r->header_end - r->header_start;
h->value.data = r->header_start;
h->value.data[h->value.len] = '\0';
src/http/ngx_http_request.c 1512–1526행
헤더를 읽는 ngx_http_process_request_headers() 가 헤더 이름 바로 뒤(: 자리)와 값 바로 뒤(\r 자리)에 '\0' 을 써 넣습니다. 버퍼 안의 바이트를 덮어써서, 헤더 이름과 값은 C 문자열로도 쓸 수 있게 만든 것입니다. 그래서 $3(Host 값)은 example.test 에서 딱 멈췄습니다. 요청 줄의 URI 에는 이런 처리가 없습니다.
ngx_str_t 셋이 모두 버퍼 하나를 가리킵니다. 빨간 두 칸은 nginx 가 덮어쓴 바이트입니다.
문자열을 다루는 도구
| 도구 | 하는 일 | 위치 |
|---|---|---|
ngx_string("text") |
문자열 상수로 { 길이, 주소 } 초기값을 만든다. sizeof(str) - 1 이라 배열 상수에만 쓴다 |
ngx_string.h 40행 |
ngx_null_string, ngx_str_set(), ngx_str_null() |
빈 값, 대입용 매크로 | 41–44행 |
ngx_pstrdup(pool, &str) |
풀에 복사본을 만든다. 끝에 '\0' 을 붙이지 않는다 |
ngx_string.c 74–87행 |
ngx_sprintf() 의 %V |
ngx_str_t * 를 길이만큼 찍는다 |
ngx_string.c 106행(설명), 252–258행 |
nginx 의 로그 함수는 모두 ngx_sprintf 계열이라, 소스 곳곳에서 "invalid server name or wildcard \"%V\" on %V" 처럼 %V 로 ngx_str_t 를 찍는 것을 보게 됩니다. server_name 이름 목록을 만드는 ngx_http.c 1646행이 그 예입니다.
헷갈리기 쉬운 곳
ngx_str_t의data를strcmp()·printf("%s")·strlen()에 넘기지 않습니다. 위 gdb 출력처럼'\0'이 나올 때까지 버퍼 뒤쪽을 읽어 버립니다. 비교는ngx_strncmp(a.data, b.data, len)처럼 길이를 함께 주는 함수로 합니다. 헤더 이름·값은 우연히(실제로는 위 코드 덕분에)'\0'으로 끝나지만, 그것은 헤더 읽기 코드가 해 준 일이지ngx_str_t의 약속이 아닙니다.ngx_string()에 포인터 변수를 넘기면 틀립니다.sizeof로 길이를 재므로char *p를 넘기면 포인터 크기(8)에서 1을 뺀 7이 길이가 됩니다. 문자열 상수"..."에만 씁니다.- 버퍼를 가리키는
ngx_str_t는 그 버퍼보다 오래 살 수 없습니다. URI·헤더 값은 헤더 버퍼를 가리킵니다. 요청이 끝난 뒤에도 값을 남겨야 한다면 오래 사는 풀로ngx_pstrdup()해야 합니다. 이것은 4절 keep-alive 의ngx_pfree()와도 이어집니다 — 그 버퍼는 요청이 끝나면 정말로 돌려줍니다.
6. 배열·리스트·큐·해시·레드블랙 트리
nginx 는 C 표준 라이브러리에 없는 자료구조를 직접 만들어 씁니다. 모두 풀에서 메모리를 받는다는 점이 같고, 그래서 따로 "없애는" 함수가 거의 없습니다.
회색 상자는 아무도 가리키지 않지만 풀이 없어질 때까지 메모리를 차지하는 옛 덩어리입니다.
ngx_array_t — 늘어나는 배열
typedef struct {
void *elts;
ngx_uint_t nelts;
size_t size;
ngx_uint_t nalloc;
ngx_pool_t *pool;
} ngx_array_t;
elts 는 원소들이 붙어 있는 메모리, nelts 는 지금 원소 수, size 는 원소 하나의 크기, nalloc 은 자리 수입니다. 원소의 자료형을 모르므로 void * 와 size 로 다루고, 쓰는 쪽에서 elts 를 자기 자료형 포인터로 바꿔 읽습니다. 원소를 하나 더하는 함수의 "꽉 찼을 때" 부분입니다.
if (a->nelts == a->nalloc) {
/* the array is full */
size = a->size * a->nalloc;
p = a->pool;
if ((u_char *) a->elts + size == p->d.last
&& p->d.last + a->size <= p->d.end)
{
/*
* the array allocation is the last in the pool
* and there is space for new allocation
*/
p->d.last += a->size;
a->nalloc++;
} else {
/* allocate a new array */
new = ngx_palloc(p, 2 * size);
if (new == NULL) {
return NULL;
}
ngx_memcpy(new, a->elts, size);
a->elts = new;
a->nalloc *= 2;
}
}
풀의 포인터 밀기를 아는 사람만 짤 수 있는 코드입니다. 배열이 풀의 첫 블록에서 마지막으로 받은 것이고 뒤에 자리가 있으면, d.last 를 원소 하나만큼 더 밀어 제자리에서 늘립니다. 아니면 두 배 크기를 새로 받아 복사합니다. 옛 덩어리는 돌려줄 방법이 없으니 풀에 남습니다(위 그림의 회색 상자).
설정에서 보던 것과 잇자면, 2편·9편의 phase 핸들러 목록이 이 배열입니다. 정적 파일 모듈은 설정을 읽은 뒤 h = ngx_array_push(&cmcf->phases[NGX_HTTP_CONTENT_PHASE].handlers); 로 자기 핸들러를 끼웁니다(ngx_http_static_module.c 289행). server_name 의 이름들, listen 주소들도 배열입니다.
ngx_list_t — 원소가 움직이지 않는 리스트
struct ngx_list_part_s {
void *elts;
ngx_uint_t nelts;
ngx_list_part_t *next;
};
typedef struct {
ngx_list_part_t *last;
ngx_list_part_t part;
size_t size;
ngx_uint_t nalloc;
ngx_pool_t *pool;
} ngx_list_t;
배열 덩어리(part)를 사슬로 이은 것입니다. 마지막 part 가 차면 ngx_list_push() 가 같은 크기의 part 를 하나 더 받아 끝에 붙입니다(ngx_list.c 30–63행). 복사하지 않으므로 한 번 넣은 원소의 주소가 바뀌지 않습니다.
이것이 요청 헤더가 배열이 아니라 리스트인 이유입니다. 5절의 코드에서 헤더 한 줄은 ngx_list_push(&r->headers_in.headers) 로 들어갔고(headers 는 ngx_list_t, ngx_http_request.h 187행), r->headers_in.host 같은 필드는 그 리스트 안의 원소를 포인터로 가리킵니다(190행). 배열이었다면 헤더가 늘어 복사가 일어날 때 이 포인터들이 옛 덩어리를 가리키게 됩니다. 응답 헤더 headers_out.headers 도 같은 이유로 리스트이고, 1절 ngx_http_alloc_request() 에서 처음부터 20칸으로 만듭니다(609–615행).
리스트를 도는 방법은 헤더 파일에 주석으로 적혀 있습니다(part 안의 i 가 nelts 에 닿으면 다음 part 로, ngx_list.h 55–77행). 헤더를 도는 모듈 코드에서 이 모양을 그대로 보게 됩니다.
ngx_queue_t — 구조체 안에 박는 양방향 고리
struct ngx_queue_s {
ngx_queue_t *prev;
ngx_queue_t *next;
};
데이터 필드가 없습니다. 대신 고리에 넣고 싶은 구조체 안에 ngx_queue_t 필드를 하나 둡니다(이런 방식을 흔히 "침투형(intrusive) 리스트"라고 부릅니다). 예를 들어 ngx_connection_t 에는 ngx_queue_t queue; 필드가 있습니다(ngx_connection.h 168행). 고리에서 꺼낸 ngx_queue_t * 로 바깥 구조체를 되찾는 것이 이 매크로입니다.
#define ngx_queue_data(q, type, link) \
(type *) ((u_char *) q - offsetof(type, link))
offsetof(type, link) 는 구조체 맨 앞에서 그 필드까지의 바이트 수입니다. 필드의 주소에서 그만큼 빼면 구조체의 시작 주소가 됩니다. 연결이 모자랄 때 쉬고 있는 keep-alive 연결을 골라 닫는 ngx_drain_connections() 가 이렇게 씁니다.
q = ngx_queue_last(&cycle->reusable_connections_queue);
c = ngx_queue_data(q, ngx_connection_t, queue);
src/core/ngx_connection.c 1434–1435행
넣고 빼는 일(ngx_queue_insert_head, ngx_queue_remove …)은 모두 포인터 몇 개를 바꾸는 매크로라 할당이 전혀 없습니다. 12편의 upstream keepalive 캐시도 이 큐로 연결을 넣었다 꺼냅니다.
ngx_hash_t — 설정 때 한 번 짓고 읽기만 하는 해시
해시 표는 server_name 으로 가상 서버를 찾을 때, 변수 이름을 찾을 때, map 을 찾을 때 쓰입니다. nginx 의 해시는 보통의 해시 표와 성격이 다릅니다. 설정을 읽을 때 키를 전부 모아 한 번에 짓고, 그 뒤로는 넣지도 빼지도 않습니다. 찾는 함수는 이렇습니다.
void *
ngx_hash_find(ngx_hash_t *hash, ngx_uint_t key, u_char *name, size_t len)
{
ngx_uint_t i;
ngx_hash_elt_t *elt;
/* ... */
elt = hash->buckets[key % hash->size];
if (elt == NULL) {
return NULL;
}
while (elt->value) {
if (len != (size_t) elt->len) {
goto next;
}
for (i = 0; i < len; i++) {
if (name[i] != elt->name[i]) {
goto next;
}
}
return elt->value;
next:
elt = (ngx_hash_elt_t *) ngx_align_ptr(&elt->name[0] + elt->len,
sizeof(void *));
continue;
}
return NULL;
}
한 버킷(bucket, 같은 해시 값끼리 모인 칸)의 원소들은 연결 리스트가 아니라 메모리에 줄지어 있습니다. 원소 하나는 value 포인터, len, 이름 글자들이고(ngx_hash.h 16–20행), 다음 원소는 이름 끝을 8바이트로 정렬한 자리에서 시작합니다. 버킷 끝은 value == NULL 인 칸이 표시합니다. 포인터를 따라 메모리를 뛰어다니지 않으니 CPU 캐시에 잘 맞습니다.
대신 "버킷 하나의 바이트 수"에 상한이 있습니다. 이것이 nginx 를 운영하다 한 번쯤 보는 그 오류입니다. 버킷 크기는 server_names_hash_bucket_size, 기본값은 CPU 캐시 줄 크기(ngx_cacheline_size)입니다(ngx_http_core_module.c 3549–3554행). 해시를 짓는 ngx_hash_init() 은 먼저 이름 하나하나가 버킷 하나에 들어가는지 봅니다.
#define NGX_HASH_ELT_SIZE(name) \
(sizeof(void *) + ngx_align((name)->key.len + 2, sizeof(void *)))
if (hinit->bucket_size < NGX_HASH_ELT_SIZE(&names[n]) + sizeof(void *))
{
ngx_log_error(NGX_LOG_EMERG, hinit->pool->log, 0,
"could not build %s, you should "
"increase %s_bucket_size: %i",
hinit->name, hinit->name, hinit->bucket_size);
return NGX_ERROR;
}
원소 크기는 value 포인터 8바이트 + (이름 길이 + len 2바이트를 8의 배수로 올린 것)이고, 버킷 끝의 NULL 칸 8바이트가 더 있어야 합니다. 버킷이 64바이트라면 8 + align(len + 2, 8) + 8 ≤ 64, 곧 이름이 46글자까지입니다. 계산이 맞는지 돌려 봤습니다.
버킷 하나에 이름 하나가 들어갈 수 있는지를 바이트로 셈한 그림. 64 는 아래 예제 3 출력의 버킷 크기입니다.
예제 3: server_name 46글자와 47글자
server_name 길이만 바꿔 nginx -t 를 돌리는 스크립트입니다(ex/u03-hash-bucket/run.sh). 두 번째 server {} 의 이름을 a 33개 + .example.test(46글자)와 a 34개 + .example.test(47글자)로 바꿔 시험하고, 마지막에 47글자 그대로 server_names_hash_bucket_size 128; 을 더해 봤습니다.
=== server_name of 46 chars ===
nginx: the configuration file /tmp/u03h/nginx.conf syntax is ok
nginx: configuration file /tmp/u03h/nginx.conf test is successful
=== server_name of 47 chars ===
nginx: [emerg] could not build server_names_hash, you should increase server_names_hash_bucket_size: 64
nginx: configuration file /tmp/u03h/nginx.conf test failed
=== same 47-char name with server_names_hash_bucket_size 128 ===
nginx: the configuration file /tmp/u03h/nginx.conf syntax is ok
nginx: configuration file /tmp/u03h/nginx.conf test is successful
계산대로 46글자는 통과하고 47글자에서 위 소스의 오류 문장이 그대로 나옵니다. 오류 끝의 64 가 이 컨테이너의 캐시 줄 크기입니다. "server_names_hash_bucket_size 를 늘리라"는 흔한 조언이 왜 통하는지가 이 한 줄에 있습니다. 버킷 수(server_names_hash_max_size, 기본 512)를 넘게 되면 나오는 "could not build optimal …" 경고도 같은 함수에 있습니다(ngx_hash.c 337–344행).
*.example.com 같은 와일드카드 이름은 보통 해시와 별도로 wc_head·wc_tail 해시에 들어가고, 찾을 때는 ngx_hash_find_combined() 가 정확한 이름 → 앞 와일드카드 → 뒤 와일드카드 순서로 봅니다(ngx_hash.c 211–245행). 와일드카드 해시는 이름을 점(.) 단위로 끊어 층층이 짓고, 값 포인터의 아래 두 비트에 "이 값이 데이터인지, 다음 층 해시인지"를 숨겨 둡니다(90–99행의 주석). 요청의 Host 로 가상 서버를 찾는 과정은 8편에서 봅니다.
해시를 짓는 쪽에서는 풀 사용법도 하나 볼 수 있습니다. server_name 해시를 짓는 ngx_http_server_names() 는 짓는 동안만 쓸 임시 풀 ha.temp_pool 을 만들고(ngx_http.c 1611행), 완성된 해시는 cf->pool(cycle 풀)에 남긴 채 임시 풀만 없앱니다(1710행). "결과는 오래 사는 풀에, 작업 중의 찌꺼기는 짧게 사는 풀에"라는 풀 쓰기의 기본형입니다. ngx_init_cycle() 이 설정을 읽는 동안 쓰는 conf.temp_pool 도 같은 생각입니다(ngx_cycle.c 262행, 781행).
ngx_rbtree_t — 타이머가 쓰는 레드블랙 트리
레드블랙 트리는 넣고 빼고 가장 작은 값을 찾는 일이 모두 원소 수의 로그에 비례하는 시간으로 끝나는, 스스로 균형을 잡는 이진 탐색 트리입니다. nginx 의 것도 큐와 같은 침투형입니다. 노드 ngx_rbtree_node_t 는 key·left·right·parent·color·data 를 가진 작은 구조체이고(22–29행), 트리는 이렇습니다.
typedef struct ngx_rbtree_s ngx_rbtree_t;
typedef void (*ngx_rbtree_insert_pt) (ngx_rbtree_node_t *root,
ngx_rbtree_node_t *node, ngx_rbtree_node_t *sentinel);
struct ngx_rbtree_s {
ngx_rbtree_node_t *root;
ngx_rbtree_node_t *sentinel;
ngx_rbtree_insert_pt insert;
};
트리마다 넣는 자리를 고르는 함수 insert 를 함수 포인터로 받는다는 점이 특징입니다. 균형 잡기는 공통이고 "같은 키를 어떻게 다룰지" 같은 비교만 쓰는 쪽이 정합니다. 가장 많이 쓰는 곳은 이벤트 타이머입니다. ngx_event_t 안에 ngx_rbtree_node_t timer; 가 박혀 있고(ngx_event.h 97행), 만료된 타이머를 처리할 때 ev = ngx_rbtree_data(node, ngx_event_t, timer); 로 바깥 이벤트를 되찾습니다(ngx_event_timer.c 76행). ngx_queue_data 와 똑같은 offsetof 기법입니다. 타이머가 이벤트 루프에서 어떻게 쓰이는지는 7편에서 봅니다.
맛보기: 워커끼리 나누는 메모리 — slab
지금까지의 풀과 자료구조는 모두 워커 한 프로세스 안의 것입니다. limit_req_zone, proxy_cache_path … keys_zone=, upstream { zone … } 처럼 워커끼리 상태를 나눠야 할 때는 공유 메모리를 쓰고, 그 안을 나누는 것이 ngx_slab_pool_t(src/core/ngx_slab.c)입니다. 풀과 반대로 조각 하나를 돌려주는 ngx_slab_free() 가 있고, 여러 프로세스가 동시에 쓰므로 할당 때마다 잠금을 잡습니다 — ngx_slab_alloc() 은 ngx_shmtx_lock() 과 ngx_shmtx_unlock() 사이에서 ngx_slab_alloc_locked() 를 부르는 것이 전부입니다(ngx_slab.c 169–180행).
"잠금 없음, 조각별 해제 없음"인 풀과 "잠금 있음, 조각별 해제 있음"인 slab — 쓰임이 다르니 만듦새도 정반대입니다. slab 의 페이지·크기 등급 관리는 이 글에서 다루지 않습니다.
7. ngx_buf_t 와 ngx_chain_t — 응답을 담는 그릇
마지막으로, 응답 데이터가 실려 나가는 구조체입니다.
struct ngx_buf_s {
u_char *pos;
u_char *last;
off_t file_pos;
off_t file_last;
u_char *start; /* start of buffer */
u_char *end; /* end of buffer */
ngx_buf_tag_t tag;
ngx_file_t *file;
ngx_buf_t *shadow;
/* the buf's content could be changed */
unsigned temporary:1;
/*
* the buf's content is in a memory cache or in a read only memory
* and must not be changed
*/
unsigned memory:1;
/* the buf's content is mmap()ed and must not be changed */
unsigned mmap:1;
unsigned recycled:1;
unsigned in_file:1;
unsigned flush:1;
unsigned sync:1;
unsigned last_buf:1;
unsigned last_in_chain:1;
unsigned last_shadow:1;
unsigned temp_file:1;
/* STUB */ int num;
};
struct ngx_chain_s {
ngx_buf_t *buf;
ngx_chain_t *next;
};
버퍼 하나가 메모리일 수도, 파일일 수도 있다는 것이 핵심입니다.
- 메모리 버퍼 —
start·end가 메모리 조각 전체,pos·last가 그중 아직 보내지 않은(또는 아직 읽지 않은) 부분입니다. 조각을 내용을 바꿔도 되면temporary, 바꾸면 안 되는 읽기 전용(설정 문자열 등)이면memory비트를 켭니다. - 파일 버퍼 —
in_file을 켜고file(fd 와 이름)과file_pos·file_last(파일 안의 위치)를 적습니다. 데이터를 메모리로 읽지 않은 채 "이 파일의 몇 바이트부터 몇 바이트까지"만 들고 다니는 것입니다. 마지막에 이것을 받은 쪽이sendfile()로 커널에게 바로 보내게 할 수 있습니다. - 표시 비트 —
last_buf는 "이 응답의 마지막 버퍼",flush는 "모아 두지 말고 지금 보내라",last_in_chain은 "이 체인의 마지막"입니다.
: 1 은 C 의 비트 필드로, 필드 하나가 1비트만 차지합니다. 플래그 열한 개가 unsigned 하나에 들어갑니다. 비트들을 묶어 읽는 매크로도 있습니다 — 예를 들어 ngx_buf_size(b) 는 메모리 버퍼면 last - pos, 아니면 file_last - file_pos 입니다(ngx_buf.h 125–138행).
ngx_chain_t 는 버퍼 하나와 다음 고리, 두 필드뿐인 한 방향 리스트입니다. 정적 파일 핸들러가 응답 본문을 만드는 마지막 부분을 보면 둘이 어떻게 쓰이는지 한눈에 들어옵니다.
b->file_pos = 0;
b->file_last = of.size;
b->in_file = b->file_last ? 1 : 0;
b->last_buf = (r == r->main) ? 1 : 0;
b->last_in_chain = 1;
b->sync = (b->last_buf || b->in_file) ? 0 : 1;
b->file->fd = of.fd;
b->file->name = path;
b->file->log = log;
b->file->directio = of.is_directio;
out.buf = b;
out.next = NULL;
return ngx_http_output_filter(r, &out);
src/http/modules/ngx_http_static_module.c 261–277행
파일 내용은 한 바이트도 읽지 않았습니다. 버퍼는 ngx_calloc_buf(r->pool) 로 요청 풀에서 받았고(245행), 파일 전체(0 부터 of.size 까지)를 가리키는 파일 버퍼 하나를 고리 하나(out)에 실어 필터 체인으로 넘깁니다. 고리 out 은 풀도 아닌 함수의 지역 변수(스택)입니다. 필터가 이 고리를 붙잡아 둬야 하면 자기 고리로 옮겨 담습니다. 여기서 연 파일 fd 는 4절에서 본 cleanup 이 요청 풀과 함께 닫습니다.
응답 헤더(메모리)와 파일 본문(파일)이 체인 하나에 섞여 실릴 수 있습니다.
다 쓴 고리는 풀의 chain 에
2절에서 지나친 ngx_pool_t 의 chain 필드가 여기서 쓰입니다. 필터는 요청 하나를 보내는 동안 고리를 수없이 만들고 버리는데, 고리 하나(16바이트)마다 풀에서 새로 받으면 블록이 금방 찹니다. 그래서 다 쓴 고리는 풀의 chain 목록에 걸어 두고 다음에 꺼내 씁니다. 고리를 받는 ngx_alloc_chain_link() 는 pool->chain 에 고리가 있으면 맨 앞 것을 떼어 주고, 없을 때만 ngx_palloc() 합니다(src/core/ngx_buf.c 47–65행).
돌려놓는 쪽 ngx_free_chain(pool, cl) 은 고리를 pool->chain 맨 앞에 끼우는 두 줄짜리 매크로입니다(ngx_buf.h 148–150행). 4절의 keep-alive 코드에서 헤더 버퍼 고리를 정리할 때도 이것을 썼습니다(ngx_free_chain(c->pool, ln), ngx_http_request.c 3494행). 같은 생각으로 필터들은 다 쓴 버퍼도 free·busy 목록으로 돌려 씁니다(ngx_chain_update_chains()).
필터 체인이 이 체인을 어떻게 넘겨받아 헤더를 붙이고, gzip 으로 바꾸고, 마지막에 writev·sendfile 로 보내는지는 10편에서 봅니다.
헷갈리기 쉬운 곳
pos/last와start/end는 다른 것입니다.start/end는 메모리 조각의 경계로 바뀌지 않고,pos/last는 보내거나 읽으면서 움직입니다. 보낼 양은last - pos이지end - start가 아닙니다.- 메모리 비트와
in_file은 서로 배타적이지 않습니다. 매크로들은 둘이 함께 켜진 버퍼(같은 내용이 메모리와 파일 양쪽에 있는 버퍼)도 있다는 전제로 짜여 있습니다. "메모리에 있나"를 묻는ngx_buf_in_memory()와 별도로 "메모리에만 있나"를 묻는ngx_buf_in_memory_only()가!(b)->in_file을 따로 검사하는 것이 그 흔적입니다(ngx_buf.h 126행).
8. httpd 의 APR 풀과 비교하면
httpd 4편에서 본 APR 풀과 나란히 놓으면, 같은 생각을 얼마나 다르게 구현했는지가 보입니다. APR 쪽은 httpd 4편에서 소스로 확인한 내용입니다.
| APR 풀 (httpd) | ngx_pool_t (nginx) |
|
|---|---|---|
| 할당의 빠른 길 | 지금 노드에서 포인터 밀기 | 지금 블록(current)에서 포인터 밀기 — 같다 |
| 정렬 | 언제나 8바이트 | ngx_palloc 은 NGX_ALIGNMENT(8), ngx_pnalloc 은 정렬 안 함 |
| 노드/블록이 찼을 때 | allocator 에서 노드를 받는다(목록에 있으면 재활용) | ngx_memalign() 으로 같은 크기 블록을 새로 받는다. 재활용 목록이 없다 |
| 큰 할당 | 그만큼 큰 노드를 만든다 | max 를 넘으면 malloc() 해서 large 목록에 단다 |
| 조각 하나 돌려주기 | 없다 | ngx_pfree() — large 만 |
| 풀끼리의 관계 | 부모·자식 나무. 부모를 없애면 자식이 먼저 | 관계 없음. 요청 풀·연결 풀을 코드가 따로 없앤다 |
| cleanup 순서 | 나중에 건 것부터 | 나중에 건 것부터 — 같다 |
| 다 쓴 메모리의 행방 | allocator 가 쥐고 있다가 재사용 (MaxMemFree) |
free() 로 C 라이브러리에 바로 돌려준다 |
| 비우기 | apr_pool_clear() 를 자주 쓴다 |
ngx_reset_pool() 이 있으나 거의 안 쓰고 없앴다 다시 만든다 |
nginx 쪽이 훨씬 단순합니다. ngx_palloc.c 는 430줄이고, 노드 재활용·나무 구조·스레드 대비가 모두 빠져 있습니다. 워커 하나가 스레드 하나로 이벤트 루프를 도는 구조(2편)라서, httpd 처럼 여러 스레드·여러 MPM 을 감당할 필요가 없기 때문으로 보입니다(설계 의도는 소스에 적혀 있지 않으니 추측입니다). 재활용 목록이 없는 대신 블록을 C 라이브러리의 malloc 에 돌려주고, 예제 2에서 본 것처럼 malloc 이 같은 자리를 다시 내주는 것에 기댑니다.
위 표의 '풀끼리의 관계'와 '다 쓴 메모리의 행방' 두 줄을 그린 것. APR 쪽은 httpd 4편에서 확인한 내용, 주소는 예제 2의 출력입니다.
이 글에서 뺀 것
큰 줄기를 위해 다음은 이름과 위치만 적어 둡니다.
ngx_pmemalign()— 원하는 정렬로 받는 할당. 언제나 large 로 갑니다. Direct I/O 용 버퍼를 받는ngx_output_chain.c등에서 씁니다(ngx_palloc.c 252–274행).ngx_pool_run_cleanup_file()·ngx_pool_delete_file()— 풀이 없어지기 전에 특정 fd 의 cleanup 만 미리 돌리기, 파일을 지우며 닫기(ngx_palloc.c 342–401행).ngx_create_chain_of_bufs()·ngx_chain_update_chains()·ngx_chain_get_free_buf()— 버퍼 묶음 만들기와 free/busy 목록 관리(src/core/ngx_buf.c). 10·11편의 필터와 event pipe 에서 씁니다.ngx_radix_tree.c— IP 주소 범위를 찾는 기수 트리. geo 모듈이 씁니다.- 해시를 짓는
ngx_hash_add_key()·ngx_hash_wildcard_init()— 키 모으기와 와일드카드 층 짓기(ngx_hash.c 490행부터). - 레드블랙 트리의 균형 잡기 —
ngx_rbtree_insert()·ngx_rbtree_delete()의 회전(src/core/ngx_rbtree.c). - slab 의 내부 — 페이지 단위 관리와 크기 등급(
src/core/ngx_slab.c), 공유 메모리 영역을 만드는ngx_init_zone_pool()(ngx_cycle.c 966행부터). - reload 때 옛 cycle 의 풀 — 마스터가 아닌 경우 옛 cycle 을 바로 버리지 못하고
ngx_old_cycles에 모아 30초 타이머로 치우는 길(ngx_cycle.c 792–830행). 4편에서 이어집니다.
이 글의 풀·자료구조 파일(ngx_palloc.[ch], ngx_array.c, ngx_list.c, ngx_hash.c, ngx_buf.h, ngx_string.h, ngx_queue.h)은 stable 판 1.30.6 과 이 커밋 사이에 바뀐 줄이 없습니다. 1.30.x 를 쓰고 있어도 이 글의 설명은 그대로 맞습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
풀 (ngx_pool_t) |
수명이 같은 메모리를 담는 통. 블록 사슬 + large 목록 + cleanup 목록 |
| 세 가지 수명 | cycle 풀(16KB, 설정 한 벌), 연결 풀(connection_pool_size, 기본 512), 요청 풀(request_pool_size, 기본 4096) |
ngx_palloc() |
size ≤ max 면 current 블록부터 포인터 밀기, 넘으면 malloc() 해서 large 로 |
ngx_pnalloc() |
정렬하지 않는 ngx_palloc(). 문자열용 |
failed·current |
새 블록이 필요해질 때마다 지나온 블록의 failed++, 이미 5인 블록은 건너뛴다 |
ngx_destroy_pool() |
cleanup(나중에 건 것부터) → large free() → 블록 free(). 풀 구조체도 첫 블록과 함께 사라진다 |
ngx_pfree() |
large 만 돌려줄 수 있다. keep-alive 연결의 헤더 버퍼를 놓을 때 쓴다 |
ngx_str_t |
길이 + 주소. '\0' 이 없어도 되므로 버퍼 안을 복사 없이 가리킨다. %V 로 찍는다 |
ngx_array_t |
연속 메모리. 차면 두 배로 복사(옛 덩어리는 풀에 남음) |
ngx_list_t |
part 사슬. 원소가 움직이지 않아 요청·응답 헤더에 쓴다 |
ngx_queue_t·ngx_rbtree_t |
구조체 안에 박는 고리·노드. offsetof 로 바깥 구조체를 되찾는다 |
ngx_hash_t |
설정 때 한 번 짓는 읽기 전용 해시. 버킷 크기 초과가 …_bucket_size 오류 |
ngx_buf_t |
메모리(pos~last) 또는 파일(file_pos~file_last)의 한 구간 + 플래그 비트 |
ngx_chain_t |
버퍼를 잇는 한 방향 리스트. 다 쓴 고리는 pool->chain 으로 돌려 쓴다 |
다음에 볼 것
이 글에서 "cycle 풀"이라고만 하고 넘어간 것을 만드는 곳으로 갑니다. main() 이 명령줄을 읽고, ngx_init_cycle() 이 새 풀을 만들어 그 위에 설정 파일을 읽어 올리고, reload 때 같은 함수가 다시 불려 옛 cycle 옆에 새 cycle 을 세우는 과정이 4편: 기동과 설정 읽기 — main()에서 ngx_init_cycle까지에서 이어집니다. nginx.conf 를 토큰으로 자르는 ngx_conf_read_token() 도 거기서 봅니다.
- 1편: AI와 함께 소스를 읽는 방법과 분석 지도
- 2편: 전체 지도 — 마스터와 워커, 모듈, cycle, 설정 문맥
- 7편: 이벤트 루프 — epoll, 타이머, posted 이벤트, accept — 이 글의 레드블랙 트리가 타이머로 쓰이는 곳
- 10편: 필터와 정적 파일 — 응답은 어떻게 흘러 나가나 —
ngx_chain_t가 필터를 지나는 길 - httpd 연재에서 같은 문제를 다룬 글: httpd 4편: APR 메모리 풀 — free 없이 메모리를 관리하는 법, 그리고 응답을 담는 그릇의 다른 답인 httpd 11편: 필터와 버킷 브리게이드