이 글에서 다루는 것
"Apache httpd 소스 파헤치기"의 다섯 번째 글입니다. 4편에서는 httpd가 메모리를 free() 없이 풀(pool)로 관리하는 방법을 봤습니다. 그 글에서 pconf, ptemp, plog 같은 이름이 server/main.c에서 만들어진다고만 하고 넘어갔는데, 이번에는 그 파일을 처음부터 끝까지 읽습니다. httpd라는 프로그램의 main() 함수가 들어 있는 곳입니다.
답하려는 질문은 네 가지입니다.
httpd -k start를 치면main()은 어떤 순서로 일을 하는가- 설정 파일(httpd.conf)을 두 번 읽는다는 말은 사실인가, 사실이라면 왜 그렇게 하는가
apachectl graceful이나restart를 하면 이미 돌고 있는 서버 안에서는 무슨 일이 일어나는가-t,-S같은 옵션은 어디서 끝나고, 루트(root)로 시작한 서버는 어디서User로 내려가는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache HTTP Server (httpd) |
| 버전 | 2.5.1-dev (trunk) |
| 커밋 | e4af30fe6d (2026-10-24) |
| 함께 읽는 APR | APR 1.7.2 (커밋 e905526629) |
| 주로 읽는 파일 | server/main.c, server/mpm_unix.c, server/mpm/event/event.c, os/unix/unixd.c, modules/arch/unix/mod_unixd.c |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받는 방법과 예제를 돌리는 도커 환경은 1편에 있습니다. 우리가 흔히 설치해 쓰는 2.4.x와 다른 곳은 그때그때 밝힙니다. 미리 말해 두면 main.c의 큰 흐름은 2.4.x 브랜치와 거의 같습니다.
main.c에는 Windows·NetWare·EBCDIC 같은 특수한 환경을 위한 코드도 섞여 있지만, 이 글은 리눅스에서 event MPM으로 도는 경우의 큰 줄기만 따라갑니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름만 적어 두었습니다.
먼저 알아 둘 말
main() 은 C 프로그램이 시작되는 함수입니다. 셸에서 httpd를 실행하면 운영체제가 이 함수를 부릅니다. httpd의 main()은 server/main.c에 있고, 이 파일 하나가 914줄입니다.
프로세스와 PID: 실행 중인 프로그램 하나를 프로세스라 하고, 운영체제가 붙여 준 번호를 PID라고 합니다. 프로세스는 fork()로 자기를 복사해 자식 프로세스를 만들 수 있습니다. httpd는 루트 권한의 부모 프로세스 하나가 요청을 처리하는 자식 프로세스 여러 개를 거느리는 구조입니다. 이 구조를 정하는 부품이 MPM(Multi-Processing Module)이고 자세한 내용은 8편에서 봅니다.
데몬(daemon)은 터미널과 떨어져 뒤에서 도는 프로세스입니다. httpd -k start를 치면 프롬프트가 곧바로 돌아오는데, 그 사이에 httpd가 스스로 데몬이 되기 때문입니다. 이 글의 4절에서 그 순간을 소스로 찾습니다.
풀(pool)은 메모리를 묶어서 한꺼번에 비우는 APR의 장치입니다. 이 글에서 중요한 것은 두 가지입니다. 풀을 비우면(apr_pool_clear) 그 풀에서 할당한 것과 거기에 등록한 정리 함수(cleanup)가 한꺼번에 처리된다는 것, 그리고 설정은 pconf 라는 풀에 담긴다는 것입니다. 풀 자체는 4편에서 다뤘습니다.
훅(hook)은 "이 시점이 되면 이 함수를 불러 달라"고 모듈이 걸어 두는 자리입니다. 이 글에는 기동 단계의 훅인 pre_config, open_logs, post_config, child_init 등이 나옵니다. 이 넷은 등록된 함수를 전부 차례로 부르는 방식(RUN_ALL 또는 VOID)이고, RUN_ALL 방식은 하나라도 오류를 돌려주면 거기서 멈춥니다(server/config.c 88–104행, 160–167행). 훅을 만드는 매크로와 순서를 정하는 방법은 6편에서 봅니다.
신호(signal)는 유닉스에서 프로세스끼리 주고받는 짧은 알림입니다. kill 명령이 보내는 것이 신호이고, httpd는 SIGTERM(멈춰라), SIGHUP(재시작하라), SIGUSR1(점잖게 재시작하라)을 알아듣습니다.
1. main()이 하는 일 한눈에
main()(527행)을 위에서 아래로 읽으면 크게 다섯 덩어리입니다.
파란 상자가 설정을 읽고 훅을 부르는 단계, 초록 상자가 일찍 끝나는 출구입니다.
- 프로세스 준비 —
init_process()(342–410행)가 APR을 초기화하고, 프로세스가 끝날 때까지 사는 프로세스 풀과 그 아래의pconf를 만듭니다. 프로세스 풀은 전역 변수ap_pglobal에도 담깁니다(551행). - 기본 모듈 등록 —
ap_setup_prelinked_modules()(574–579행)가 httpd 실행 파일 안에 처음부터 들어 있는 모듈을 등록합니다. - 명령줄 읽기 —
-f,-D,-t같은 옵션을 읽습니다(586–702행). - 1차 읽기 — 설정 파일을 읽고 훅을 부릅니다(704–808행).
-t나-k stop은 이 안에서 끝납니다. - 루프 —
do { ... } while안에서 설정을 다시 읽고 MPM에게 넘깁니다(810–886행). 재시작은 이 루프를 한 바퀴 더 도는 것입니다.
처음부터 들어 있는 모듈은 셋뿐입니다
2번에서 등록하는 모듈의 목록 ap_prelinked_modules는 소스에 없습니다. 빌드할 때 configure가 만드는 modules.c라는 파일에 들어 있습니다. 이 연재의 예제 컨테이너(--enable-mods-shared=most로 빌드)에서 그 파일을 열어 보면 이렇습니다.
AP_DECLARE_DATA module *ap_prelinked_modules[] = {
&core_module,
&so_module,
&http_module,
NULL
};
빌드 때 생성되는 /usr/src/httpd/modules.c의 일부. httpd -l도 같은 세 개(core.c, mod_so.c, http_core.c)를 보여 줍니다.
코어, 그리고 .so 파일을 싣는 LoadModule 디렉티브를 가진 mod_so, HTTP 프로토콜을 맡는 http_module입니다. MPM조차 들어 있지 않습니다. 이 빌드는 MPM도 .so로 만들었기 때문에(--enable-mpms-shared=all), httpd.conf에 LoadModule mpm_event_module ...이 없으면 서버가 뜨지 못합니다. 설정을 다 읽은 뒤 MPM이 하나도 없으면 ap_read_config()가 오류를 냅니다(server/config.c 2355–2360행). 코어와 모듈이 이렇게 나뉘는 큰 그림은 3편에 있습니다.
ap_setup_prelinked_modules()(server/config.c 763–816행)는 각 모듈에 번호(module_index)를 매기고 ap_add_module()로 등록합니다. 이때 모듈의 훅 등록 함수도 불립니다. 769행에서 훅을 담는 메모리를 pconf로 정해 두는데(apr_hook_global_pool=process->pconf), 이것이 3절에서 중요해집니다.
-k 는 main()이 읽지 않습니다
명령줄을 읽는 apr_getopt() 루프에 넘기는 옵션 목록은 "C:c:D:d:E:e:f:vVlLtTSMh?X"입니다(include/http_main.h 36행). 여기에 k가 없습니다. 그런데도 httpd -k start가 동작하는 것은 그보다 앞의 ap_run_rewrite_args(process)(581행) 덕분입니다.
코어 모듈의 구조체에는 "명령줄을 읽기 전에 부를 함수" 칸이 있고, 유닉스에서는 여기에 ap_mpm_rewrite_args()가 들어갑니다(server/core.c 6218행, os/unix/os.h 45행). 이 함수가 명령줄에서 -k start|stop|restart|graceful|graceful-stop만 골라 따로 적어 두고, 나머지 인자로 새 명령줄을 만들어 process->argv를 바꿔치기합니다(server/mpm_unix.c 900–955행). 그래서 main()의 apr_getopt()는 -k를 보지 못합니다. 적어 둔 -k 값은 2절 끝에서 다시 나옵니다.
옵션마다 하는 일은 대부분 짧습니다. -t와 -S를 보겠습니다.
case 't':
if (ap_run_mode == AP_SQ_RM_UNKNOWN)
ap_run_mode = AP_SQ_RM_CONFIG_TEST;
break;
case 'T':
ap_document_root_check = 0;
break;
case 'S':
ap_run_mode = AP_SQ_RM_CONFIG_DUMP;
new = (const char **)apr_array_push(ap_server_config_defines);
*new = "DUMP_VHOSTS";
new = (const char **)apr_array_push(ap_server_config_defines);
*new = "DUMP_RUN_CFG";
break;
-t는 실행 방식(ap_run_mode)을 "설정 검사"로 바꿀 뿐이고, -S는 -D DUMP_VHOSTS -D DUMP_RUN_CFG를 붙인 것과 똑같이 처리됩니다. -M도 -D DUMP_MODULES로 바뀝니다(673–677행). 반대로 사용자가 -D DUMP_RUN_CFG를 직접 적어도 607–622행이 실행 방식을 "설정 덤프"로 바꿉니다. 이 값들이 어디서 쓰이는지는 6절에서 봅니다.
2. 1차 읽기 — 설정을 읽고 훅을 부르는 순서
명령줄을 다 읽으면 main()은 상태를 AP_SQ_MS_CREATE_PRE_CONFIG로 바꾸고 풀 두 개를 더 만듭니다.
ap_main_state = AP_SQ_MS_CREATE_PRE_CONFIG;
apr_pool_create(&plog, ap_pglobal);
apr_pool_tag(plog, "plog");
apr_pool_create(&ptemp, pconf);
apr_pool_tag(ptemp, "ptemp");
/* Note that we preflight the config file once
* before reading it _again_ in the main loop.
* This allows things, log files configuration
* for example, to settle down.
*/
plog는 로그 파일처럼 "설정을 읽을 때마다 새로 여는 것"을 담는 풀입니다. 부모가pconf가 아니라 프로세스 풀(ap_pglobal)입니다.ptemp는 설정을 읽는 동안에만 쓰는 임시 풀이고pconf의 자식입니다. 다 읽으면 808행에서 통째로 버립니다.
그리고 주석이 이 글의 질문에 대한 첫 번째 답입니다. "설정 파일을 메인 루프에서 다시 읽기 전에 한 번 미리 읽어 본다(preflight). 그러면 로그 파일 설정 같은 것들이 자리를 잡는다." 이 말이 실제로 무엇을 뜻하는지는 4절에서 소스로 확인합니다.
1차 읽기는 이 순서로 진행됩니다.
ap_read_config()(721행) — 설정 파일을 읽어 디렉티브 트리로 만듭니다. 이때LoadModule처럼 읽는 즉시 실행해야 하는 디렉티브(EXEC_ON_READ)만 실행됩니다. 그래야 그 뒤에 나오는, 방금 실은 모듈의 디렉티브를 알아볼 수 있기 때문입니다(modules/core/mod_so.c449행).pre_config훅 (742–746행) — 모듈들이 "이제 설정을 처리할 테니 기본값을 준비하라"는 신호를 받습니다. 예를 들어mod_unixd는 여기서User와Group을 기본값으로 되돌려 놓습니다(7절).ap_process_config_tree()(748–749행) — 트리를 걸어 다니며 나머지 디렉티브를 모두 실행합니다. 이 과정은 7편의 주제입니다.check_config훅 (760–764행) — 설정 값끼리 맞는지 모듈이 검사합니다.- 여기서
-t·-S면 끝나고,-k stop같은 신호 보내기도 끝납니다(아래 코드). open_logs훅 (794–800행) — 그 직전에plog를 비우고 로그 파일을 엽니다. 코어의ap_open_logs()가 여기서 표준 오류(stderr)를ErrorLog파일로 돌립니다(server/log.c405–438행).post_config훅 (802–806행) — 설정이 다 끝난 뒤 모듈이 공유 메모리를 만들거나 하는 마무리 자리입니다.
위 목록의 일곱 단계와 5번의 출구를 한 줄로 이었습니다. 빨간 칸으로 빠지면 httpd 는 거기서 끝납니다.
5번의 두 출구는 이렇게 생겼습니다.
if (ap_run_mode != AP_SQ_RM_NORMAL) {
if (showdirectives) { /* deferred in case of DSOs */
ap_show_directives();
destroy_and_exit_process(process, 0);
}
else {
ap_run_test_config(pconf, ap_server_conf);
if (ap_run_mode == AP_SQ_RM_CONFIG_TEST)
ap_log_error(APLOG_MARK, APLOG_STARTUP, 0, NULL, "Syntax OK");
}
destroy_and_exit_process(process, 0);
}
}
/* If our config failed, deal with that here. */
if (rv != OK) {
destroy_and_exit_process(process, 1);
}
signal_server = APR_RETRIEVE_OPTIONAL_FN(ap_signal_server);
if (signal_server) {
int exit_status;
if (signal_server(&exit_status, pconf) != 0) {
destroy_and_exit_process(process, exit_status);
}
}
-t나 -S처럼 실행 방식이 "보통"이 아니면 test_config 훅을 부르고(덤프는 여기서 찍힙니다) 종료합니다. 보통이면 ap_signal_server라는 함수를 찾아 부릅니다. 이 함수는 1절의 ap_mpm_rewrite_args()가 선택 함수(optional function — 있으면 쓰고 없으면 건너뛰는, 이름으로 찾는 함수)로 등록해 둔 것입니다(server/mpm_unix.c 954행). 이 함수가 0이 아닌 값을 돌려주면 httpd는 여기서 끝납니다. 5절에서 자세히 봅니다.
헷갈리기 쉬운 곳: 오타는 pre_config 뒤에 발견됩니다
"설정을 읽는다"는 말이 한 번에 일어나는 일 같지만, 위의 1번과 3번은 따로입니다. 1번 ap_read_config()는 파일을 트리로 만들 뿐이고 디렉티브 이름이 맞는지는 3번 ap_process_config_tree()가 트리를 걸으며 확인합니다. 등록된 적 없는 이름을 만나면 "Invalid command"를 돌려줍니다(server/config.c 1312–1327행, 오류를 찍는 곳은 1961–1989행). 그래서 오타가 있는 설정이라도 pre_config 훅은 한 번 불립니다. 아래 3절 예제의 마지막 단계에서 실제로 확인합니다.
3. do { ... } while — 2차 읽기, 그리고 재시작의 정체
1차 읽기가 끝나면 main()은 루프에 들어갑니다. 길어서 훅을 부르는 부분은 줄였습니다.
do {
ap_main_state = AP_SQ_MS_DESTROY_CONFIG;
reset_process_pconf(process);
ap_main_state = AP_SQ_MS_CREATE_CONFIG;
ap_config_generation++;
for (mod = ap_prelinked_modules; *mod != NULL; mod++) {
ap_register_hooks(*mod, pconf);
}
/* This is a hack until we finish the code so that it only reads
* the config file once and just operates on the tree already in
* memory. rbb
*/
ap_conftree = NULL;
apr_pool_create(&ptemp, pconf);
apr_pool_tag(ptemp, "ptemp");
ap_server_root = def_server_root;
ap_server_conf = NULL; /* set early by ap_read_config() for logging */
/* ... ap_read_config(), pre_config, ap_process_config_tree(),
* check_config, open_logs, post_config — 1차 읽기와 같은 순서 ... */
apr_pool_destroy(ptemp);
apr_pool_lock(pconf, 1);
ap_run_optional_fn_retrieve();
ap_main_state = AP_SQ_MS_RUN_MPM;
rc = ap_run_mpm(pconf, plog, ap_server_conf);
apr_pool_lock(pconf, 0);
} while (rc == OK);
if (rc == DONE) {
rc = OK;
}
else if (rc != OK) {
ap_log_error(APLOG_MARK, APLOG_EMERG, 0, NULL, APLOGNO(02818)
"MPM run failed, exiting");
}
destroy_and_exit_process(process, rc);
루프의 몸통은 1차 읽기와 거의 같습니다. 다른 점은 앞과 뒤에 있습니다.
- 앞:
reset_process_pconf()로pconf를 비우고, 설정 세대 번호(ap_config_generation)를 하나 올리고, 기본 모듈 세 개의 훅을 다시 등록합니다. - 뒤:
ap_run_mpm()으로 MPM에게 제어를 넘깁니다. MPM은 자식 프로세스를 띄우고, 신호가 올 때까지 이 함수에서 돌아오지 않습니다.
노란 상자에서 지난 세대의 설정이 통째로 사라집니다. 재시작은 화살표를 따라 이 상자로 돌아가는 것입니다.
ap_run_mpm()이 무엇을 돌려주는지가 루프의 운명을 정합니다. 훅의 선언에 붙은 설명이 짧고 분명합니다. "재시작이나 종료가 지시될 때까지 돈다. 종료면 DONE, 아니면 OK를 돌려준다."(include/ap_mpm.h 83–93행) OK(0)면 while (rc == OK)가 참이라 루프를 한 바퀴 더 돕니다. 이것이 재시작입니다. DONE(-2)이면 루프를 빠져나와 정상 종료합니다.
즉 httpd에는 "재시작" 전용 코드가 따로 없습니다. 처음 기동할 때 루프를 처음 도는 것과, 재시작으로 루프를 한 번 더 도는 것이 같은 코드입니다.
pconf 를 비우면 무엇이 사라지나
루프 맨 앞의 reset_process_pconf()입니다.
/* Deregister all hooks when clearing pconf (pre_cleanup).
* TODO: have a hook to deregister and run them from here?
* ap_clear_auth_internal() is already a candidate.
*/
static apr_status_t deregister_all_hooks(void *unused)
{
(void)unused;
ap_clear_auth_internal();
apr_hook_deregister_all();
return APR_SUCCESS;
}
static void reset_process_pconf(process_rec *process)
{
if (process->pconf) {
apr_pool_clear(process->pconf);
ap_server_conf = NULL;
}
else {
apr_pool_create(&process->pconf, process->pool);
apr_pool_tag(process->pconf, "pconf");
}
apr_pool_pre_cleanup_register(process->pconf, NULL, deregister_all_hooks);
}
pconf가 이미 있으면 비우고(apr_pool_clear), 비울 때 먼저 불릴 정리 함수(pre-cleanup)로 deregister_all_hooks를 걸어 둡니다. 그래서 pconf가 비워지면 다음이 함께 일어납니다.
pconf에서 할당한 설정 구조체 전부가 사라집니다. 지난 세대의server_rec과 모든 모듈의 설정이 그렇습니다.- 등록된 훅 전부가 지워집니다(
apr_hook_deregister_all()). 루프가 기본 모듈의 훅을 816–818행에서 다시 등록하는 이유입니다..so모듈의 훅은LoadModule이 다시 실행될 때 다시 등록됩니다. LoadModule로 실은.so가 내려갑니다.mod_so는 모듈을 실을 때 설정 풀에 정리 함수를 걸어 두고(modules/core/mod_so.c309–314행), APR은.so핸들을 연 풀에dlclose()를 부르는 정리 함수를 걸어 둡니다(APRdso/unix/dso.c58–78행, 162행).
그러니 재시작 뒤의 서버는 설정 파일을 처음부터 다시 읽은 서버입니다. httpd.conf에서 LoadModule 한 줄을 지우고 graceful을 하면 그 모듈이 빠지는 것도 이 때문입니다.
재시작을 넘어 무언가를 기억하려면 초록 상자처럼 프로세스 풀에 붙여야 합니다.
재시작을 넘어 남아야 하는 값은 어떻게 할까요. httpd는 ap_retained_data_create()와 ap_retained_data_get()을 줍니다. 열쇠 이름으로 프로세스 풀(ap_pglobal)에 메모리를 붙여 두는 함수입니다(server/config.c 2526–2541행). MPM이 "몇 번째 세대인지", "graceful이었는지"를 기억하는 것도 이 방법입니다.
예제: 기동 훅이 몇 번, 어느 프로세스에서 불리나
눈으로 확인해 보겠습니다. 기동 훅 네 개와 pconf의 정리 함수에서 한 줄씩 로그를 남기는 모듈을 만들었습니다. 이 예제는 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 아래 코드는 예제 파일 mod_startlog.c에서 같은 모양이 반복되는 훅 함수와 #include 줄을 줄인 것입니다.
/* 이 글을 위해 쓴 예제입니다 (httpd 의 소스가 아닙니다) */
/* ... 파일 설명 주석과 #include 들 ... */
static int calls_static = 0; /* .so 안의 평범한 static 변수 */
typedef struct {
int calls; /* 재시작을 넘어 살아남는 카운터 */
} startlog_retained;
/* ... state_name(): ap_state_query(AP_SQ_MAIN_STATE) 를 이름으로 ... */
static void note(const char *hook)
{
startlog_retained *r = ap_retained_data_get("startlog");
FILE *f;
if (r == NULL) {
/* 처음 한 번만 만든다 — 프로세스 풀(ap_pglobal)에 붙어 있다 */
r = ap_retained_data_create("startlog", sizeof(*r));
}
r->calls++;
calls_static++;
f = fopen("/tmp/startlog.txt", "a");
if (f) {
fprintf(f, "%-11s pid=%-5d ppid=%-5d uid=%d state=%-17s gen=%d"
" static=%d retained=%d\n",
hook, (int)getpid(), (int)getppid(), (int)getuid(),
state_name(), ap_state_query(AP_SQ_CONFIG_GEN),
calls_static, r->calls);
fclose(f);
}
}
/* pconf 가 비워질 때 불리는 cleanup */
static apr_status_t pconf_cleared(void *data)
{
note("(pconf cleanup)");
return APR_SUCCESS;
}
static int startlog_pre_config(apr_pool_t *pconf, apr_pool_t *plog,
apr_pool_t *ptemp)
{
note("pre_config");
apr_pool_cleanup_register(pconf, NULL, pconf_cleared,
apr_pool_cleanup_null);
return OK;
}
/* ... open_logs, post_config, child_init 도 note("훅 이름") 한 줄,
* register_hooks() 는 네 훅을 모두 APR_HOOK_MIDDLE 로 건다 ... */
설정은 event MPM, User daemon, 자식 프로세스 2개(StartServers 2)이고, run.sh가 httpd -k start → -k graceful → -k restart → -k stop을 차례로 실행하며 그 사이사이 ps를 찍습니다. 컨테이너 안에서 run.sh를 실행하는 셸이 PID 1이므로 ppid=1은 "셸이 부모" 또는 "부모가 이미 끝났다"는 뜻입니다. PID는 실행마다 다를 수 있고, 여러 프로세스가 동시에 쓰는 줄은 실행마다 순서가 조금씩 바뀝니다(같은 예제를 다시 돌렸을 때 child_init과 (pconf cleanup) 줄의 순서, 또는 child_init 두 줄끼리의 순서가 바뀌었습니다).
== httpd -k start (shell pid 1)
pre_config pid=153 ppid=1 uid=0 state=CREATE_PRE_CONFIG gen=0 static=1 retained=1
open_logs pid=153 ppid=1 uid=0 state=CREATE_PRE_CONFIG gen=0 static=2 retained=2
post_config pid=153 ppid=1 uid=0 state=CREATE_PRE_CONFIG gen=0 static=3 retained=3
(pconf cleanup) pid=153 ppid=1 uid=0 state=other gen=0 static=4 retained=4
pre_config pid=154 ppid=153 uid=0 state=CREATE_CONFIG gen=1 static=1 retained=5
open_logs pid=154 ppid=1 uid=0 state=CREATE_CONFIG gen=1 static=2 retained=6
post_config pid=154 ppid=1 uid=0 state=CREATE_CONFIG gen=1 static=3 retained=7
child_init pid=155 ppid=154 uid=1 state=RUN_MPM gen=1 static=4 retained=8
child_init pid=156 ppid=154 uid=1 state=RUN_MPM gen=1 static=4 retained=8
httpd -k start 한 번에 pre_config·open_logs·post_config가 두 번씩 불렸습니다. 첫 번째는 상태가 CREATE_PRE_CONFIG(1차 읽기), 두 번째는 CREATE_CONFIG(루프 안의 2차 읽기)입니다. 그 사이에 pconf가 한 번 비워졌고, 세대 번호(gen)는 0에서 1이 됐습니다. 그리고 눈여겨볼 것이 두 가지 더 있습니다.
- PID가 153에서 154로 바뀌었습니다. 1차 읽기는 셸이 띄운 153이 했는데, 2차 읽기부터는 154가 합니다. 154의 부모는 153입니다. 4절의 주제입니다.
static은 1로 돌아갔고retained는 이어서 셉니다. 아래 "헷갈리기 쉬운 곳"에서 설명합니다.
이어서 graceful과 restart, stop입니다.
== httpd -k graceful
pre_config pid=214 ppid=1 uid=0 state=CREATE_PRE_CONFIG gen=0 static=1 retained=1
(pconf cleanup) pid=154 ppid=1 uid=0 state=other gen=1 static=4 retained=8
pre_config pid=154 ppid=1 uid=0 state=CREATE_CONFIG gen=2 static=1 retained=9
open_logs pid=154 ppid=1 uid=0 state=CREATE_CONFIG gen=2 static=2 retained=10
post_config pid=154 ppid=1 uid=0 state=CREATE_CONFIG gen=2 static=3 retained=11
(pconf cleanup) pid=214 ppid=1 uid=0 state=other gen=0 static=2 retained=2
child_init pid=216 ppid=154 uid=1 state=RUN_MPM gen=2 static=4 retained=12
child_init pid=217 ppid=154 uid=1 state=RUN_MPM gen=2 static=4 retained=12
== httpd -k restart
pre_config pid=273 ppid=1 uid=0 state=CREATE_PRE_CONFIG gen=0 static=1 retained=1
(pconf cleanup) pid=154 ppid=1 uid=0 state=other gen=2 static=4 retained=12
pre_config pid=154 ppid=1 uid=0 state=CREATE_CONFIG gen=3 static=1 retained=13
open_logs pid=154 ppid=1 uid=0 state=CREATE_CONFIG gen=3 static=2 retained=14
post_config pid=154 ppid=1 uid=0 state=CREATE_CONFIG gen=3 static=3 retained=15
(pconf cleanup) pid=273 ppid=1 uid=0 state=other gen=0 static=2 retained=2
child_init pid=275 ppid=154 uid=1 state=RUN_MPM gen=3 static=4 retained=16
child_init pid=276 ppid=154 uid=1 state=RUN_MPM gen=3 static=4 retained=16
== httpd -k stop
pre_config pid=332 ppid=1 uid=0 state=CREATE_PRE_CONFIG gen=0 static=1 retained=1
(pconf cleanup) pid=332 ppid=1 uid=0 state=other gen=0 static=2 retained=2
(pconf cleanup) pid=154 ppid=1 uid=0 state=other gen=3 static=4 retained=16
로그에 두 종류의 프로세스가 섞여 있습니다.
- 새로 띄운
httpd -k graceful프로세스(214, 273, 332)는pre_config를 한 번 부르고 끝납니다.open_logs와post_config까지 가지 않습니다. 2절의 785–792행, 신호만 보내고 끝나는 출구입니다. - 원래 돌던 부모 154는
pconf를 비우고(세대가 끝남) 루프를 한 바퀴 더 돌아pre_config·open_logs·post_config를 한 번씩 부르고, 새 자식을 띄웁니다.gen이 1→2→3으로 오르고, 자식의 PID도 155·156 → 216·217 → 275·276으로 바뀝니다. 부모 154는 끝까지 그대로입니다.
훅별로 센 값과, 부모가 남긴 오류 로그의 해당 줄은 이렇습니다.
### error_log (notice lines)
[mpm_event:notice] [pid 154:tid 154] AH00489: Apache/2.5.1-dev (Unix) configured -- resuming normal operations
[mpm_event:notice] [pid 154:tid 154] AH00493: SIGUSR1 received. Doing graceful restart
[mpm_event:notice] [pid 154:tid 154] AH00489: Apache/2.5.1-dev (Unix) configured -- resuming normal operations
[mpm_event:notice] [pid 154:tid 154] AH00494: SIGHUP received. Attempting to restart
[mpm_event:notice] [pid 154:tid 154] AH00489: Apache/2.5.1-dev (Unix) configured -- resuming normal operations
[mpm_event:notice] [pid 154:tid 154] AH00491: caught SIGTERM, shutting down
### counts per hook
pre_config 7
open_logs 4
post_config 4
child_init 6
(pconf cleanup) 7
post_config 4번 = 기동 때 2번 + graceful 1번 + restart 1번입니다. pre_config가 7번인 것은 거기에 -k graceful·-k restart·-k stop 프로세스가 한 번씩 부른 3번이 더해졌기 때문입니다.
헷갈리기 쉬운 곳: static 변수는 재시작을 넘지 못합니다
로그의 static 값은 .so 안의 평범한 static int입니다. pconf가 비워질 때마다 1로 돌아갑니다. 위에서 본 대로 pconf를 비우면 .so가 dlclose()로 내려가고, 2차 읽기의 LoadModule이 다시 싣기 때문입니다. 이 예제(리눅스, Ubuntu 24.04)에서는 그때 static 변수도 처음 값으로 돌아왔습니다. 반면 ap_retained_data_create()로 만든 retained는 프로세스 풀에 붙어 있으므로 1→16까지 이어서 셉니다.
자식의 static=4는 부모가 post_config까지 센 3을 fork()로 물려받아 1을 더한 값입니다. 자식은 부모 메모리의 복사본으로 시작합니다.
재시작을 넘어 무언가를 기억해야 하는 모듈은 static 변수가 아니라 retained data를 써야 합니다. httpd의 MPM들이 그렇게 합니다(4절의 event_pre_config() 코드).
막혔던 곳: 처음에는 child_init 이 한 줄도 찍히지 않았습니다
처음 돌렸을 때는 로그에 child_init이 0번이었습니다. 원인은 7절의 주제 그대로였습니다. 로그 파일 /tmp/startlog.txt를 루트(셸)가 만들었는데, 자식 프로세스는 child_init이 불리기 전에 daemon 사용자로 내려가 있어서 그 파일에 쓸 권한이 없었습니다. fopen()이 실패해도 조용히 넘어가게 짜 둔 탓에 오류도 보이지 않았습니다. run.sh에서 파일을 만든 뒤 chmod 666을 하자 위처럼 찍혔습니다.
4. 설정을 왜 두 번 읽나
예제가 보여 준 대로 httpd는 기동할 때 설정을 정말 두 번 읽습니다. 이유를 소스에서 찾아보면, 소스가 직접 말하는 것과 소스에서 읽히는 효과로 나눌 수 있습니다.
소스가 직접 말하는 것
세 군데에 설명이 있습니다.
- 2절에서 본 710–714행: 루프에서 다시 읽기 전에 한 번 미리 읽어 본다(preflight). 그러면 로그 파일 설정 같은 것들이 자리를 잡는다.
- 상태 값의 설명:
AP_SQ_MS_CREATE_PRE_CONFIG는 "로그 설정 등을 준비하기 위한 첫 설정 실행",AP_SQ_MS_CREATE_CONFIG는 "보통의 설정 실행"입니다(include/http_core.h1083–1095행). - 루프 안 820–823행: "이건 설정 파일을 한 번만 읽고 메모리에 있는 트리만 다루도록 코드를 마무리할 때까지의 임시방편(hack) 이다." 루프에서
ap_conftree = NULL로 지난 트리를 버리고 파일을 다시 읽는 부분에 붙은 주석입니다.
즉 개발자들 스스로도 두 번 읽기를 이상적인 설계로 보지는 않았습니다. 다만 이 주석 이후로 그 "마무리"는 이뤄지지 않았고, 2.4.x 브랜치의 main.c도 같은 구조와 같은 주석을 갖고 있습니다.
소스에서 읽히는 효과 — 데몬이 되는 순간
"자리를 잡는다"가 구체적으로 무엇인지는 MPM 쪽 코드에서 보입니다. event MPM의 pre_config 훅입니다.
retained = ap_retained_data_get(userdata_key);
if (!retained) {
retained = ap_retained_data_create(userdata_key, sizeof(*retained));
retained->mpm = ap_unixd_mpm_get_retained_data();
retained->mpm->baton = retained;
if (retained->mpm->module_loads) {
test_atomics = 1;
}
}
else if (retained->mpm->baton != retained) {
/* If the MPM changes on restart, be ungraceful */
retained->mpm->baton = retained;
retained->mpm->was_graceful = 0;
}
retained->mpm->mpm_state = AP_MPMQ_STARTING;
++retained->mpm->module_loads;
/* ... */
/* sigh, want this only the second time around */
if (retained->mpm->module_loads == 2) {
rv = apr_pollset_create(&event_pollset, 1, plog,
APR_POLLSET_THREADSAFE | APR_POLLSET_NOCOPY);
if (rv != APR_SUCCESS) {
ap_log_error(APLOG_MARK, APLOG_CRIT, rv, NULL, APLOGNO(00495)
"Couldn't create a Thread Safe Pollset. "
"Is it supported on your platform?"
"Also check system or user limits!");
return HTTP_INTERNAL_SERVER_ERROR;
}
apr_pollset_destroy(event_pollset);
if (!one_process && !foreground) {
/* before we detach, setup crash handlers to log to errorlog */
ap_fatal_signal_setup(ap_server_conf, pconf);
rv = apr_proc_detach(no_detach ? APR_PROC_DETACH_FOREGROUND
: APR_PROC_DETACH_DAEMONIZE);
if (rv != APR_SUCCESS) {
ap_log_error(APLOG_MARK, APLOG_CRIT, rv, NULL, APLOGNO(00496)
"apr_proc_detach failed");
return HTTP_INTERNAL_SERVER_ERROR;
}
}
}
server/mpm/event/event.c 3993–4047행
module_loads는 이 pre_config가 불린 횟수이고, retained data에 들어 있어 재시작을 넘어 셉니다. 그리고 두 번째로 불렸을 때만(주석: "sigh, want this only the second time around") apr_proc_detach()로 데몬이 됩니다. 이 훅은 APR_HOOK_REALLY_FIRST로 등록돼 있어(4378행) 2차 읽기의 pre_config 가운데 가장 앞쪽 무리에서 불립니다. 같은 REALLY_FIRST로 걸린 훅이 몇 개 더 있어서(코어의 core_pre_config, server/core.c 6160행, mod_log_config 등) "맨 처음"이라고 할 수는 없지만, APR_HOOK_MIDDLE로 건 예제 모듈의 pre_config보다는 앞입니다. 예제 로그에서 2차 pre_config 줄이 이미 새 PID 154에서 찍힌 것이 그 증거입니다.
apr_proc_detach()가 하는 일은 APR에 있습니다.
if (daemonize) {
int x;
if ((x = fork()) > 0) {
exit(0);
}
else if (x == -1) {
perror("fork");
fprintf(stderr, "unable to fork new process\n");
exit(1); /* we can't do anything here, so just exit. */
}
/* RAISE_SIGSTOP(DETACH); */
}
APR threadproc/unix/procsup.c 28–40행
fork()로 자식을 만들고 원래 프로세스는 exit(0)으로 끝납니다. 예제에서 PID가 153에서 154로 바뀐 순간이 이것입니다. 153이 exit(0)하므로 셸에는 httpd -k start가 "성공"으로 돌아오고 프롬프트가 나타납니다.
빨간 출구는 데몬이 되기 전에만 있습니다. 데몬이 된 뒤의 실패는 셸이 알 길이 없습니다.
여기서 1차 읽기가 하는 역할이 드러납니다.
- 설정 오류를 셸에 알릴 수 있는 것은 데몬이 되기 전뿐입니다. 데몬이 되는 순간 원래 프로세스는 무조건
exit(0)으로 끝나니, 그 뒤에 무엇이 실패해도httpd -k start의 종료 코드는 0입니다. 1차 읽기는 셸이 띄운 프로세스 안에서 일어나므로, 거기서 실패하면destroy_and_exit_process(process, 1)(284–308행)로 종료 코드 1을 돌려줄 수 있습니다. -k stop·graceful은 설정을 알아야 합니다. 실행 중인 서버의 PID는PidFile에 적혀 있고, 그 경로는 설정 파일에 있습니다. 그래서 신호 보내기(785–792행)는 1차 읽기 뒤에 있습니다.- 로그 파일이 미리 열려 봅니다. 1차 읽기에서도
open_logs가 불려ErrorLog를 열어 보므로, 로그 경로가 잘못됐으면 역시 데몬이 되기 전에 실패합니다(Unable to open logs, 796–800행).
3번은 710–714행 주석의 "로그 파일 설정"과 맞아떨어지지만, 1·2번이 두 번 읽기의 원래 이유였다고 소스가 말하지는 않습니다. 소스에서 확인되는 것은 "1차 읽기는 데몬이 되기 전에, 셸이 띄운 프로세스에서 일어난다"는 사실과 그 결과까지입니다.
예제의 마지막 단계에서 1번을 확인했습니다. StartServers를 StartServer로 틀리게 적고 -k start를 했습니다.
### 6) a typo in the config: httpd -k start
AH00526: Syntax error on line 12 of /tmp/bad.conf:
Invalid command 'StartServer', perhaps misspelled or defined by a module not included in the server configuration
exit status: 1
### /tmp/startlog.txt after the failed start
pre_config pid=346 ppid=1 uid=0 state=CREATE_PRE_CONFIG gen=0 static=1 retained=1
(pconf cleanup) pid=346 ppid=1 uid=0 state=other gen=0 static=2 retained=2
PID COMMAND
(no httpd processes)
오류 메시지가 터미널에 나오고 종료 코드가 1입니다. 그리고 2절에서 말한 대로 오타가 있어도 pre_config는 한 번 불렸습니다.
헷갈리기 쉬운 곳: post_config 는 기동할 때 두 번 불립니다
모듈을 쓰는 사람에게는 이것이 가장 실용적인 결론입니다. post_config에서 공유 메모리를 만들거나 외부 프로세스를 띄우면 기동할 때 두 번 하게 됩니다. 그래서 httpd의 모듈들은 1차 읽기에서는 건너뛰는 코드를 둡니다. 예제 모듈 mod_example_ipc의 주석이 그 이유를 그대로 적어 두었습니다.
/*
* Do nothing if we are not creating the final configuration.
* The parent process gets initialized a couple of times as the
* server starts up, and we don't want to create any more mutexes
* and shared memory segments than we're actually going to use.
*/
if (ap_state_query(AP_SQ_MAIN_STATE) == AP_SQ_MS_CREATE_PRE_CONFIG)
return OK;
modules/examples/mod_example_ipc.c 127–134행
"부모 프로세스는 기동하면서 여러 번 초기화되는데, 실제로 쓸 것보다 많은 뮤텍스와 공유 메모리를 만들고 싶지 않다." event MPM의 post_config(4081–4084행), mod_systemd, mod_proxy_balancer, mod_ssl의 세션 캐시 등 기준 커밋의 modules/와 server/에서 main.c 말고도 21개 파일, 23곳이 AP_SQ_MS_CREATE_PRE_CONFIG를 확인합니다.
5. 재시작과 종료 — -k 한 줄이 루프를 돌리기까지
-k graceful을 친 프로세스와 원래 돌던 부모 사이에 무슨 일이 일어나는지 이어서 봅니다.
보내는 쪽: ap_signal_server()
1절에서 적어 둔 -k 값은 ap_signal_server()(server/mpm_unix.c 812–898행)가 씁니다. PidFile에서 PID를 읽고, 그 프로세스가 살아 있는지 kill(pid, 0)으로 확인한 뒤 값에 따라 신호를 보냅니다. graceful 부분입니다.
if (!strcmp(dash_k_arg, "graceful")) {
if (!running) {
printf("httpd not running, trying to start\n");
}
else {
*exit_status = send_signal(otherpid, AP_SIG_GRACEFUL);
return 1;
}
}
-k 값 |
보내는 신호 | 서버가 안 돌고 있으면 |
|---|---|---|
start |
없음 | 그대로 기동을 이어 감 (돌고 있으면 "already running"을 찍고 끝) |
stop |
SIGTERM |
상태만 찍고 끝 |
restart |
SIGHUP |
"httpd not running, trying to start" — 기동을 이어 감 |
graceful |
SIGUSR1 (AP_SIG_GRACEFUL) |
위와 같음 |
graceful-stop |
SIGWINCH (AP_SIG_GRACEFUL_STOP) |
상태만 찍고 끝 |
AP_SIG_GRACEFUL이 SIGUSR1인 것은 include/mpm_common.h 76행에서 확인했습니다. graceful-stop의 신호 이름은 같은 파일의 정의에서 왔고 이 글의 예제로 돌려 보지는 않았습니다. 함수가 1을 돌려주면 main()은 거기서 끝나고, 0을 돌려주면(start이거나, 서버가 없어서 대신 시작하는 경우) 기동을 이어 갑니다.
받는 쪽: 신호 → MPM의 반환값 → main()의 루프
회색이 잠깐 떴다 사라지는 -k 프로세스, 파란색이 계속 도는 부모입니다.
부모 쪽 신호 처리기는 표시만 해 둡니다. SIGHUP과 SIGUSR1은 sig_restart()가 받아 restart_pending = 1로 만들고, SIGUSR1이 아니면(즉 SIGHUP이면) is_ungraceful = 1까지 표시합니다(os/unix/unixd.c 486–504행). SIGTERM은 sig_term()이 shutdown_pending을 세웁니다(466–484행).
MPM의 부모는 server_main_loop()에서 자식을 돌보다가 둘 중 하나가 서면 빠져나옵니다(server/mpm/event/event.c 3496행). 그리고 event_run()의 끝에서 무엇을 돌려줄지 정합니다. 종료면 DONE(3806행, 3868행), 재시작이면 이 부분입니다.
/* we've been told to restart */
if (one_process) {
/* not worth thinking about */
return DONE;
}
if (!retained->mpm->is_ungraceful) {
ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00493)
"%s received. Doing graceful restart",
AP_SIG_GRACEFUL_STRING);
}
else {
ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00494)
"SIGHUP received. Attempting to restart");
}
return OK;
server/mpm/event/event.c 3871–3886행
OK가 main()으로 돌아가면 3절의 루프가 한 바퀴 더 돕니다. 예제 오류 로그의 AH00493·AH00494가 이 코드에서 찍힌 것입니다. -X(디버그, 프로세스 하나로 실행) 모드에서는 재시작 신호를 받아도 DONE을 돌려줘 그냥 끝납니다(3872–3875행).
graceful 과 restart 는 어디서 갈리나
예제에서는 graceful도 restart도 자식 PID가 바뀌어서 겉으로는 차이가 보이지 않았습니다. 차이는 새 세대의 event_run()이 시작할 때, 지난 세대의 자식을 어떻게 내보내느냐에 있습니다.
if (!retained->gen_pool) {
apr_pool_create(&retained->gen_pool, ap_pglobal);
}
else {
if (retained->mpm->was_graceful) {
/* wake up the children...time to die. But we'll have more soon */
for (i = 0; i < num_buckets; i++) {
ap_mpm_podx_killpg(retained->buckets[i].pod,
active_daemons_limit, AP_MPM_PODX_GRACEFUL);
}
}
else {
/* Kill 'em all. Since the child acts the same on the parents SIGTERM
* and a SIGHUP, we may as well use the same signal, because some user
* pthreads are stealing signals from us left and right.
*/
for (i = 0; i < num_buckets; i++) {
ap_mpm_podx_killpg(retained->buckets[i].pod,
active_daemons_limit, AP_MPM_PODX_RESTART);
}
ap_reclaim_child_processes(1, /* Start with SIGTERM */
event_note_child_stopped);
}
apr_pool_clear(retained->gen_pool);
retained->buckets = NULL;
/* advance to the next generation */
/* XXX: we really need to make sure this new generation number isn't in
* use by any of the previous children.
*/
++retained->mpm->my_generation;
}
server/mpm/event/event.c 3627–3658행
- graceful(
was_graceful이 참): 자식들에게AP_MPM_PODX_GRACEFUL을 알릴 뿐 기다리지 않습니다. 자식은 하던 요청을 마저 처리하고 스스로 나갑니다. - restart:
AP_MPM_PODX_RESTART를 알린 뒤ap_reclaim_child_processes(1, ...)로SIGTERM부터 보내며 자식을 거둬들입니다. 처리 중이던 요청은 끊깁니다(자식 쪽에서 비정상 종료 모드면 "현재 연결을 모두 강제로 끊는다"는 주석과 함께close_worker_sockets()를 부릅니다, 675–679행).
was_graceful은 pconf가 비워질 때 불리는 unset_signals()가 is_ungraceful을 보고 정합니다(os/unix/unixd.c 506–517행). 그 아래(3664–3671행)에서 graceful이 아닐 때만 스코어보드(자식들의 상태판)를 새로 만드는 것도 둘의 차이입니다. 자식을 내보내는 "파이프"(POD)와 스코어보드는 8편에서 봅니다.
헷갈리기 쉬운 곳: 새 설정에 오류가 있으면 graceful 은 서버를 내립니다
1차 읽기가 셸에 오류를 알려 주는 것은 새로 띄운 프로세스의 이야기입니다. httpd -k graceful은 1차 읽기를 하므로 그 프로세스가 오류를 찍고 종료 코드 1로 끝나면서 신호를 보내지 않습니다. 반면 부모에게 직접 kill -USR1을 보내면 부모는 1차 읽기 없이 곧바로 루프의 2차 읽기로 갑니다. 루프 안의 실패는 모두 destroy_and_exit_process(process, 1)입니다(829–831행, 838–874행). 즉 돌던 부모 프로세스가 종료 코드 1로 끝납니다. 재시작 전에 httpd -t로 설정을 먼저 검사하는 습관이 이 코드 경로 때문에 중요합니다. (kill -USR1을 직접 보내 이 경로를 타는 것은 이 글의 예제로 돌려 보지 않았고, 소스의 코드 경로로만 확인했습니다. 그때 남아 있던 자식 프로세스가 어떻게 되는지도 확인하지 않았습니다.)
6. -t, -S, -D DUMP_* 는 어디서 끝나나
1절에서 -t는 실행 방식을 AP_SQ_RM_CONFIG_TEST로, -S·-M·-D DUMP_*는 AP_SQ_RM_CONFIG_DUMP로 바꾼다고 했습니다. 이 옵션들은 2절의 766–777행에서 끝납니다. 정리하면 이렇습니다.
| 옵션 | 끝나는 곳 | 하는 일 |
|---|---|---|
-v |
642–645행 | 버전만 찍고 즉시 종료 (설정을 읽지 않음) |
-l |
647–649행 | 실행 파일에 들어 있는 모듈만 찍고 즉시 종료 |
-V |
679–688행, 734–737행 | MPM이 실행 파일에 들어 있으면 즉시, .so면 설정을 읽어 MPM을 실은 뒤 찍고 종료 |
-L |
767–770행 | 1차 읽기 뒤 디렉티브 목록을 찍고 종료 |
-t |
772–776행 | 1차 읽기 + test_config 훅 뒤 "Syntax OK" |
-S, -M, -D DUMP_* |
772–776행 | 1차 읽기 + test_config 훅(여기서 각 모듈이 덤프를 찍음) 뒤 종료 |
-V가 두 갈래인 것이 재미있습니다. 이 연재의 빌드처럼 MPM을 .so로 만들면 httpd -V도 설정 파일을 읽어야 "Server MPM: event"를 찍을 수 있습니다. 734행 주석이 "동적으로 싣는 MPM 때문에 미룬다"고 적어 두었습니다.
표의 "끝나는 곳"을 위에서 아래로 실행 순서대로 늘어놓았습니다. 빨간 칸이 각 옵션의 출구입니다.
덤프를 찍는 것은 main()이 아니라 test_config 훅에 걸린 각 모듈입니다. -D DUMP_RUN_CFG면 코어가 ServerRoot와 DocumentRoot 등을(server/core.c 5917–5935행), mod_unixd가 User와 Group을 찍습니다(modules/arch/unix/mod_unixd.c 343–361행). DUMP_VHOSTS는 server/vhost.c가, DUMP_MODULES는 mod_so가 맡습니다. 예제의 5단계 출력입니다.
### 5) httpd -t -D DUMP_RUN_CFG
ServerRoot: "/opt/httpd"
Main DocumentRoot: "/opt/httpd/htdocs"
Main ErrorLog: "/tmp/error_log"
Mutex default: dir="/opt/httpd/logs/" mechanism=default
PidFile: "/tmp/u05.pid"
Define: DUMP_RUN_CFG
User: name="daemon" id=1
Group: name="daemon" id=1
exit status: 0
### /tmp/startlog.txt after -t
pre_config pid=343 ppid=1 uid=0 state=CREATE_PRE_CONFIG gen=0 static=1 retained=1
(pconf cleanup) pid=343 ppid=1 uid=0 state=other gen=0 static=2 retained=2
-t 계열은 1차 읽기에서 pre_config·check_config·test_config까지만 부르고 open_logs와 post_config는 부르지 않습니다(예제 모듈은 이 셋 가운데 pre_config에만 걸려 있으므로 위 로그에는 한 줄만 보입니다). 설정 검사를 해도 로그 파일이 열리거나 공유 메모리가 만들어지지 않는 이유입니다. Define: DUMP_RUN_CFG 줄은 -D로 넘긴 이름이 <IfDefine>에서 쓰는 정의 목록(ap_server_config_defines)에 그대로 들어간다는 것을 보여 줍니다(607–609행).
7. 루트로 시작해 User 로 내려가기
웹 서버가 80번 포트를 열려면 보통 루트 권한이 필요합니다. 하지만 요청을 루트로 처리하면 위험합니다. 그래서 httpd는 부모는 루트로 남고 자식만 User로 내려가는 방식을 씁니다. 예제의 1단계에서 run.sh가 찍은 ps 출력이 그 모습입니다(부모 154는 root, 자식은 daemon).
### 1) httpd -k start
pidfile: 154
PID PPID USER COMMAND
154 1 root httpd -f /tmp/u05.conf -k start
155 154 daemon httpd -f /tmp/u05.conf -k start
156 154 daemon httpd -f /tmp/u05.conf -k start
로그에서도 1차·2차 읽기는 uid=0, child_init은 uid=1이었습니다.
그 일을 하는 곳은 main.c가 아니라 mod_unixd 입니다. User·Group 디렉티브를 가진 모듈이고, 세 개의 훅에 걸려 있습니다.
static void unixd_hooks(apr_pool_t *pool)
{
ap_hook_pre_config(unixd_pre_config,
NULL, NULL, APR_HOOK_FIRST);
ap_hook_test_config(unixd_dump_config,
NULL, NULL, APR_HOOK_FIRST);
ap_hook_drop_privileges(unixd_drop_privileges,
NULL, NULL, APR_HOOK_MIDDLE);
}
modules/arch/unix/mod_unixd.c 363–371행
pre_config:User·Group을 빌드 때 정한 기본값으로 되돌립니다(307–340행). 설정은 세대마다 새로 읽으므로 지난 세대의 값이 남으면 안 됩니다.test_config: 6절의User: name="daemon" id=1줄을 찍습니다.drop_privileges: 실제로 권한을 내려놓습니다.
drop_privileges 훅은 MPM이 자식 프로세스 안에서 부릅니다. event MPM의 자식 초기화 코드입니다.
/*stuff to do before we switch id's, so we have permissions. */
ap_reopen_scoreboard(pchild, NULL, 0);
/* done with init critical section */
if (ap_run_drop_privileges(pchild, ap_server_conf)) {
clean_child_exit(APEXIT_CHILDFATAL);
}
/* ... */
ap_run_child_init(pchild, ap_server_conf);
server/mpm/event/event.c 3003–3027행
순서가 중요합니다. 주석대로 "아이디를 바꾸기 전에, 권한이 있을 때 해야 할 일"(스코어보드 다시 열기)을 먼저 하고, 권한을 내려놓고, 그다음에 child_init 훅을 부릅니다. 3절의 "막혔던 곳"에서 child_init이 루트가 만든 파일에 쓰지 못한 이유가 이 순서입니다.
mod_unixd의 drop_privileges는 ap_unixd_setup_child()를 부르고, 그 핵심은 setgid() → initgroups() → (설정했다면 chroot()) → setuid() 순서입니다(90–138행, 146–216행). 사용자 아이디 부분만 보겠습니다.
PID 와 uid 는 예제 1단계의 ps 출력과 로그에서 가져왔습니다. 빨간 칸까지가 root, 초록 칸은 daemon 입니다.
/* Only try to switch if we're running as root */
if (!geteuid() && (
#ifdef _OSD_POSIX
os_init_job_environment(NULL, ap_unixd_config.user_name, ap_exists_config_define("DEBUG")) != 0 ||
#endif
setuid(ap_unixd_config.user_id) == -1)) {
rv = errno;
ap_log_error(APLOG_MARK, APLOG_ALERT, errno, NULL, APLOGNO(02162)
"setuid: unable to change to uid: %ld",
(long) ap_unixd_config.user_id);
return rv;
}
modules/arch/unix/mod_unixd.c 177–188행
geteuid()가 0, 곧 지금 루트일 때만 setuid()를 부릅니다. 일반 사용자로 httpd를 띄우면 이 부분을 건너뛰고 그 사용자로 그대로 돕니다. 6절의 덤프에서 루트가 아니면 User 줄 끝에 not_used가 붙는 것도 같은 이유입니다(351–352행). setgid()를 initgroups()보다 먼저 부르는 이유는 주석(116–119행)에 있습니다. 어떤 플랫폼에서는 setgid()가 보조 그룹 목록을 지워 버리기 때문입니다. setuid()가 맨 마지막인 것은 루트를 버리고 나면 그룹을 바꿀 권한도 없어지기 때문인데, 이것은 유닉스의 일반적인 규칙이고 이 파일의 주석에 적힌 것은 아닙니다.
헷갈리기 쉬운 곳: User root 는 설정 단계에서 거부됩니다
User root라고 적으면 어떻게 될까요. User 디렉티브의 처리 함수가 사용자 아이디가 0이면 오류 문자열을 돌려줍니다.
ap_unixd_config.user_name = arg;
ap_unixd_config.user_id = ap_uname2id(arg);
#if !defined (BIG_SECURITY_HOLE) && !defined (OS2)
if (ap_unixd_config.user_id == 0) {
return "Error:\tApache has not been designed to serve pages while\n"
"\trunning as root. There are known race conditions that\n"
"\twill allow any local user to read any file on the system.\n"
"\tIf you still desire to serve pages as root then\n"
"\tadd -DBIG_SECURITY_HOLE to the CFLAGS env variable\n"
"\tand then rebuild the server.\n"
"\tIt is strongly suggested that you instead modify the User\n"
"\tdirective in your httpd.conf file to list a non-root\n"
"\tuser.\n";
}
#endif
modules/arch/unix/mod_unixd.c 228–242행
"Apache는 루트로 페이지를 내보내도록 설계되지 않았다. 알려진 경쟁 상태(race condition) 때문에 로컬 사용자가 시스템의 아무 파일이나 읽을 수 있다." 이 검사는 BIG_SECURITY_HOLE이라는 이름의 매크로를 정의하고 다시 빌드해야만 꺼집니다. #if !defined (...)는 "이 이름이 정의돼 있지 않으면 이 코드를 넣는다"는 전처리기 문법입니다(2편 참고). 이 오류는 디렉티브를 실행하는 단계(2절의 3번)에서 나므로 1차 읽기에서 걸리고, 셸에 오류가 보입니다. (User root로 실제로 돌려 보지는 않았습니다. 코드 경로로 확인한 내용입니다.)
이 글에서 뺀 것
큰 줄기를 위해 다음은 이름만 적어 둡니다.
- 재시작을 넘어 듣기 소켓(Listen)을 유지하는 방법 —
server/listen.c,event_open_logs()의ap_setup_listeners()(server/mpm/event/event.c3947–3969행). 8편에서 다룹니다. -k graceful-stop과GracefulShutdownTimeout—event_run()의 3809–3869행.- 메모리가 모자랄 때 부르는
abort_on_oom()(310–315행)과 난수 초기화ap_init_rng()(554행). - 훅을 다시 정렬하는
apr_hook_sort_all()이 거듭 불리는 이유(ap_setup_prelinked_modules()끝의server/config.c813행, 1차 읽기의 739–740행, 753–758행) — 6편. -C·-c로 명령줄에서 디렉티브를 앞뒤에 넣는ap_server_pre_read_config·ap_server_post_read_config(593–601행).- 선택 함수를 다시 찾게 하는
ap_run_optional_fn_retrieve()(879행)와 MPM이 도는 동안pconf를 잠그는apr_pool_lock()(877행, 884행). - 루트 권한의 일부만 남기는
mod_privileges(솔라리스)와 systemd에 "준비됨"을 알리는mod_systemd—modules/arch/unix/. - Windows의 서비스 모드(
-k install등,server/mpm/winnt/). - 2.4.x와의 차이:
main.c에서 확인한 차이는 작습니다. 2.4.x에서는-D·-C·-c값을pcommands라는 별도 풀에 담는데 trunk는 프로세스 풀을 바로 씁니다(566행). 종료 코드가 0이 아니고 터미널이면 "abnormal exit"를 찍는 코드(304–306행)는 trunk에만 있습니다. 설정을 두 번 읽는 구조, 루프,eventMPM이 두 번째pre_config에서 데몬이 되는 것은 2.4.x 브랜치에서도 같습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
main()의 순서 |
프로세스 준비 → 기본 모듈 3개 등록 → 명령줄 → 1차 읽기 → do { 2차 읽기 → ap_run_mpm() } while |
-k |
main()이 아니라 ap_mpm_rewrite_args()가 미리 빼 두고, 1차 읽기 뒤 ap_signal_server()가 PidFile의 PID로 신호를 보낸다 |
| 1차 읽기 | 셸이 띄운 프로세스에서 상태 CREATE_PRE_CONFIG로. 설정 오류는 여기서 종료 코드 1이 된다 |
| 두 번 읽는 이유 | 소스 주석은 "미리 읽어 로그 설정 등이 자리 잡게"와 "임시방편". 효과는 데몬이 되기 전에 검사·신호·로그 열기를 끝내는 것 |
| 데몬이 되는 곳 | event MPM의 두 번째 pre_config에서 apr_proc_detach() — fork() 후 원래 프로세스는 exit(0) |
| 루프 맨 앞 | pconf를 비운다 — 설정, 훅, .so가 모두 사라지고 세대 번호가 오른다 |
| 재시작 | 신호 → MPM이 OK를 돌려줌 → 루프 한 바퀴 더. 종료는 DONE |
| graceful 과 restart | 지난 세대 자식을 기다려 주느냐(graceful) 거둬들이느냐(restart) |
| retained data | 프로세스 풀에 붙어 재시작을 넘어 남는 메모리. .so의 static 변수는 넘지 못한다 |
post_config 두 번 |
기동 때 두 번 불리므로 무거운 일은 AP_SQ_MS_CREATE_PRE_CONFIG면 건너뛴다 |
-t·-S·-D DUMP_* |
1차 읽기의 check_config 뒤 test_config 훅에서 덤프를 찍고 종료. open_logs·post_config는 안 부른다 |
User·Group |
mod_unixd가 자식 안에서 drop_privileges 훅으로 setgid → initgroups → setuid. 그 뒤에 child_init |
다음에 볼 것
이번 글에서 "모듈이 훅을 걸어 둔다", "훅을 다시 등록한다", "훅을 정렬한다"고만 하고 넘어간 곳이 여러 번 있었습니다. pre_config 같은 훅이 C 코드에서 어떻게 선언되고, 모듈 여러 개가 같은 훅에 걸리면 누가 먼저 불리는지, LoadModule이 .so를 어떻게 서버에 끼우는지가 6편: 모듈과 훅 — 모듈 하나가 서버에 끼어드는 방법에서 이어집니다.
Spring과 비교하면, Spring은 애플리케이션 컨텍스트의 기동을 refresh()라는 메서드 하나의 열두 단계로 정리했습니다(Spring 7편). httpd에서 그 자리에 해당하는 것이 main() 루프의 몸통이고, 설정 파일을 처음부터 다시 읽어 새 세대를 만드는 방식으로 재시작까지 같은 코드로 처리한다는 점이 다릅니다. 모듈이 post_config 같은 단계에 끼어드는 모습은 Spring이 빈 생성 흐름에 후처리기를 끼우는 방식(Spring 6편)과 닮았습니다.