namalzi

← 글 목록

nginx 소스 파헤치기 6: 마스터와 워커 — 프로세스, 시그널, 무중단 reload

nginx · 게시

nginx -s reload 한 줄이 어떻게 마스터의 sigsuspend() 루프를 깨우고, 새 설정으로 새 워커를 띄운 뒤 옛 워커를 하던 일을 마치게 하고 내보내는지 src/os/unix/ngx_process_cycle.c 를 따라 읽습니다. 시그널 핸들러가 플래그만 세우는 이유, 마스터와 워커를 잇는 socketpair 채널, ngx_processes[] 표의 respawn·just_spawn·exiting 비트, kill -9 당한 워커를 다시 띄우는 길, USR2 바이너리 교체까지 strace·디버그 로그·ps 로 확인합니다.

이 글에서 다루는 것

"nginx 소스 파헤치기" 연재의 여섯 번째 글입니다. 4편에서 main() 이 설정 파일을 읽어 ngx_init_cycle() 로 cycle(설정을 읽은 결과 전부를 담은 구조체)을 만드는 데까지, 5편에서 설정 한 줄이 모듈의 설정 구조체가 되는 과정을 봤습니다. main() 의 마지막 줄은 ngx_master_process_cycle(cycle) 입니다. 이번에는 그 함수 안으로 들어갑니다.

2편의 지도에서 이 글은 B. 프로세스와 이벤트의 마스터·워커 상자를 확대합니다.

연재 지도에서 이 글의 위치. 준비, 프로세스와 이벤트, HTTP 요청 하나의 세 묶음 가운데 프로세스와 이벤트 묶음의 마스터·워커 상자가 이 글이다. 나머지 상자는 회색이다. 2편 지도에서 초록 상자가 이 글입니다. 옆의 이벤트 루프 상자(워커 안에서 도는 것)는 7편에서 봅니다.

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

항목 내용
대상 nginx
버전 nginx 1.31.9 (master, 개발판)
커밋 9a7439fd79 (2026-10-23)
주로 읽는 파일 src/os/unix/ngx_process_cycle.c, src/os/unix/ngx_process.c, src/os/unix/ngx_channel.c, src/core/nginx.c, src/core/ngx_cycle.c

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

ngx_process_cycle.c 는 1,241줄이고 그 가운데 캐시 관리자 프로세스, 이 빌드에서 꺼져 있는 control API 같은 부분이 섞여 있습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐고, 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

마스터 프로세스와 워커 프로세스. nginx 를 띄우면 root 로 도는 프로세스 하나(마스터)와 worker_processes 개수만큼의 자식 프로세스(워커)가 생깁니다. 연결을 받고 요청을 처리하는 것은 워커뿐이고, 마스터는 설정을 읽고, 리스닝 소켓을 열고, 워커를 띄우고 지켜보는 관리자입니다. 2편에서 그림 한 장으로 본 구조를 이 글은 소스로 확인합니다.

시그널(signal)은 운영체제가 프로세스에 보내는 짧은 알림입니다. 번호 하나만 실어 나르며, 받은 프로세스는 하던 일을 잠시 멈추고 등록해 둔 시그널 핸들러 함수를 실행합니다. kill -HUP 1234 는 1234번 프로세스에 SIGHUP 을 보내라는 뜻입니다. 핸들러는 프로그램의 아무 지점에서나 끼어들 수 있으므로, 그 안에서 할 수 있는 일은 많지 않습니다(malloc() 처럼 내부 상태를 가진 함수를 부르면 위험합니다). 그래서 nginx 의 핸들러는 플래그 변수 하나를 1로 바꾸는 것이 거의 전부입니다.

sig_atomic_t 플래그. 핸들러와 본 코드가 함께 읽고 쓰는 변수는 sig_atomic_t 형으로 선언합니다. 핸들러가 끼어들어도 반쯤 쓰인 값이 보이지 않는다고 C 표준이 보장하는 정수형입니다. nginx 의 ngx_reconfigure, ngx_quit, ngx_terminate, ngx_reopen 이 이 형입니다.

sigsuspend(). "막아 둔 시그널을 잠시 풀고, 시그널이 올 때까지 잠들었다가, 깨면 다시 막는다"를 한 번에 하는 시스템 콜입니다. 마스터는 평소에 이 안에서 잠들어 있습니다. 왜 그냥 pause() 가 아닌지는 2절에서 봅니다.

채널(channel)은 마스터와 워커 사이의 nginx 전용 통신선입니다. 워커를 fork() 하기 전에 socketpair() 로 서로 이어진 유닉스 도메인 소켓 한 쌍을 만들어, 한쪽은 마스터가, 다른 쪽은 워커가 씁니다. 마스터는 여기에 "그만 받고 나가라(QUIT)" 같은 32바이트짜리 명령을 써 보냅니다.

graceful(우아한) 종료는 "새 연결은 받지 않되, 처리 중인 요청은 끝까지 마치고 나간다"는 뜻입니다. reload 가 무중단인 이유가 이것이고, nginx 에서는 SIGQUIT(또는 채널의 NGX_CMD_QUIT)이 이 일을 시킵니다. 곧바로 나가라는 것은 SIGTERM 입니다.

1. 프로세스 지도 — 마스터가 들고 있는 것, 워커가 들고 있는 것

마스터와 워커가 들고 있는 것. root 로 도는 마스터는 sigsuspend 로 시그널만 기다리며 ngx_processes 표와 리스닝 소켓을 가진다. 마스터는 fork 로 워커 0과 워커 1을 만들고, 워커는 nobody 로 epoll 이벤트 루프를 돈다. 리스닝 소켓은 fork 로 워커에 물려지고, ngx_processes 표의 칸마다 있는 socketpair 채널로 마스터가 QUIT, TERMINATE, REOPEN 같은 32바이트 명령을 보낸다. 노란 상자가 마스터가 들고 있는 자료, 초록 상자가 실제로 일하는 워커입니다.

예제 1: 띄우고 ps 로 보기

이 글의 예제는 기준 커밋(9a7439fd79)을 그대로 빌드한 nginx 1.31.9 로 도커 컨테이너(Debian bookworm, --with-debug)에서 실행했습니다. 컨테이너 안에서는 root 로 실행합니다. 첫 예제 ex/u06-reload 의 설정은 이렇습니다.

# 이 글을 위해 쓴 예제입니다 (nginx 의 소스가 아닙니다)
# reload 전후의 워커: 응답에 버전 글자(v1)와 그 요청을 처리한 워커의 pid 를 싣는다.
# run.sh 가 이 파일을 /tmp/u06/ 로 복사한 뒤 v1 을 v2 로 바꿔 reload 한다.
worker_processes 2;
error_log /tmp/u06/error.log notice;
pid /tmp/u06/nginx.pid;

events {
    worker_connections 64;
}

http {
    access_log off;
    keepalive_timeout 60s;

    server {
        listen 8080;

        location = /hello {
            return 200 "v1 pid=$pid\n";
        }

        # 3초 늦게 답하는 백엔드 — 처리 중인 요청을 만들기 위해
        location = /slow {
            proxy_pass http://127.0.0.1:9000;
        }
    }
}

$pid 는 그 요청을 처리한 워커 프로세스의 pid 를 돌려주는 변수입니다. 띄운 직후의 ps 와 첫 요청입니다(run.sh 의 첫 부분. 전체 출력은 4절에서 이어집니다).

=== 1. after start ===
    PID    PPID USER     CMD
     19       1 root     nginx: master process nginx -c /tmp/u06/nginx.conf
     20      19 nobody   nginx: worker process
     21      19 nobody   nginx: worker process
 0.55s curl /hello: v1 pid=21

pid 와 시각은 실행마다 다릅니다. 읽을 것은 세 가지입니다.

  1. 마스터는 root, 워커는 nobody 입니다. 설정에 user 를 적지 않았고 root 로 띄웠으므로 기본값 nobody 가 쓰였습니다(ngx_core_module_init_conf() 가 NGX_USER 로 채웁니다. 이 빌드의 objs/ngx_auto_config.h 에서 NGX_USER 는 "nobody"). 80번 포트처럼 root 만 열 수 있는 소켓은 마스터가 열어 두고, 인터넷에서 오는 요청을 직접 다루는 워커는 권한을 내려놓습니다. 내려놓는 곳은 6절의 ngx_worker_process_init() 입니다.
  2. 워커의 부모(PPID)는 마스터입니다. 마스터가 fork() 로 만들었기 때문입니다. 마스터의 부모는 1번(컨테이너에서 run.sh 를 돌리는 셸)입니다. daemon on(기본값)이면 셸이 실행한 nginx 프로세스를 ngx_daemon() 이 한 번 더 fork() 하고 자신은 끝나므로, 부모를 잃은 마스터는 1번 프로세스에게 맡겨집니다(src/os/unix/ngx_daemon.c 17–27행). 그래서 error.log 의 첫 몇 줄(using the "epoll" event method 등)은 pid 18 이 찍고, 그다음 start worker processes 부터는 마스터 19 가 찍습니다.
  3. 프로세스 이름이 nginx: master process ... 로 바뀌어 있습니다. ngx_setproctitle() 이 바꿉니다. 마스터는 명령줄을 붙여 master process nginx -c ..., 워커는 worker process, graceful 종료 중인 워커는 worker process is shutting down 입니다. ps 만으로 상태를 읽을 수 있게 한 것입니다.

ngx_processes[] — 마스터의 장부

마스터가 자식들을 기억하는 곳은 전역 배열 ngx_processes[] 입니다. 칸 하나가 이렇게 생겼습니다.

typedef void (*ngx_spawn_proc_pt) (ngx_cycle_t *cycle, void *data);

typedef struct {
    ngx_pid_t           pid;
    int                 status;
    ngx_socket_t        channel[2];

    ngx_spawn_proc_pt   proc;
    void               *data;
    char               *name;

    unsigned            respawn:1;
    unsigned            just_spawn:1;
    unsigned            detached:1;
    unsigned            exiting:1;
    unsigned            exited:1;
} ngx_process_t;

src/os/unix/ngx_process.h 20–36행

필드 뜻
pid 자식의 pid. 빈 칸은 -1
status waitpid() 가 돌려준 종료 상태
channel[2] 그 자식과 이어진 socketpair. [0] 은 마스터 쪽, [1] 은 자식 쪽
proc, data, name 자식이 fork() 직후 실행할 함수와 인자, 이름. 죽었을 때 똑같이 다시 띄우려고 적어 둔다
respawn 죽으면 다시 띄울 자식인가
just_spawn 방금 띄운 자식인가 — reload 때 "옛 워커에게만 QUIT" 을 보내려고 쓴다(4절)
detached 마스터가 돌보지 않는 자식(USR2 로 띄운 새 바이너리, 5절)
exiting 나가라고 이미 말했다
exited 끝난 것을 waitpid() 로 확인했다

배열 크기는 NGX_MAX_PROCESSES(1024)이고, fork() 할 때 넘기는 respawn 인자의 다섯 가지 값 — NGX_PROCESS_NORESPAWN(-1), _JUST_SPAWN(-2), _RESPAWN(-3), _JUST_RESPAWN(-4), _DETACHED(-5) — 이 위 비트 셋의 조합을 정합니다(src/os/unix/ngx_process.h 47–53행, 비트를 켜는 곳은 src/os/unix/ngx_process.c 220–251행).

음수인 것은 이유가 있습니다. ngx_spawn_process() 는 이 인자가 0 이상이면 "그 번호의 칸에 다시 띄워라" 로 읽습니다(3절). 죽은 워커를 같은 칸에 되살릴 때 칸 번호를 그대로 넘기는 것입니다.

워커를 낳는 함수: ngx_spawn_process()

마스터는 main() 에서 넘겨받은 cycle 로 먼저 워커를 띄웁니다. ngx_start_worker_processes() 는 "start worker processes" 를 찍고, n 번 되풀이하며 ngx_spawn_process(cycle, ngx_worker_process_cycle, (void *) (intptr_t) i, "worker process", type) 와 ngx_pass_open_channel(cycle) 을 부르는 것이 전부입니다(src/os/unix/ngx_process_cycle.c 357–371행).

n 이 worker_processes 값입니다(auto 면 ngx_set_worker_processes() 가 CPU 수 ngx_ncpu 를 넣습니다, src/core/nginx.c 1637–1640행). 워커마다 ngx_spawn_process() 에 실행할 함수 ngx_worker_process_cycle 과 워커 번호 i 를 넘깁니다. 함수 포인터를 넘기는 것이므로 같은 함수가 캐시 관리자 프로세스(ngx_cache_manager_process_cycle)나 새 바이너리 실행(ngx_execute_proc)에도 쓰입니다.

ngx_spawn_process() 의 핵심은 socketpair() 와 fork() 두 군데입니다.

    if (respawn != NGX_PROCESS_DETACHED) {

        /* Solaris 9 still has no AF_LOCAL */

        if (socketpair(AF_UNIX, SOCK_STREAM, 0, ngx_processes[s].channel) == -1)
        {
            /* ... "socketpair() failed" ... */
            return NGX_INVALID_PID;
        }

        /* ... 양쪽을 논블로킹으로, channel[0] 에 FIOASYNC·F_SETOWN, 양쪽에 FD_CLOEXEC ... */

        ngx_channel = ngx_processes[s].channel[1];

    } else {
        ngx_processes[s].channel[0] = -1;
        ngx_processes[s].channel[1] = -1;
    }

    ngx_process_slot = s;


    pid = fork();

    switch (pid) {

    case -1:
        /* ... "fork() failed" 로그, 채널 닫기 ... */
        return NGX_INVALID_PID;

    case 0:
        ngx_parent = ngx_pid;
        ngx_pid = ngx_getpid();
        proc(cycle, data);
        break;

    default:
        break;
    }

src/os/unix/ngx_process.c 113–204행

  1. fork() 전에 전역 변수 ngx_channel 에 자식 쪽 끝(channel[1])을, ngx_process_slot 에 칸 번호를 넣어 둡니다. fork() 는 메모리를 통째로 복사하므로, 자식은 태어나자마자 "내 채널은 이것, 내 칸은 몇 번" 을 압니다.
  2. 자식(case 0)은 proc(cycle, data) — 곧 ngx_worker_process_cycle(cycle, (void *) i) — 으로 들어가 돌아오지 않습니다. 워커의 일생이 그 함수 안의 무한 루프입니다.
  3. 부모(마스터)는 default 로 빠져나와 칸에 pid, proc, data, name 을 적고 respawn 인자대로 비트를 켭니다(206–251행).

ngx_pass_open_channel() 은 방금 만든 워커의 channel[0] 을 다른 자식들에게 보내 줍니다(NGX_CMD_OPEN_CHANNEL, 파일 디스크립터 자체를 소켓으로 넘기는 SCM_RIGHTS 를 씀). 워커끼리도 서로의 채널을 알게 하려는 장치인데, 이 빌드의 코어에서 채널에 쓰는(ngx_write_channel()) 곳은 ngx_process_cycle.c 의 마스터 쪽 세 함수뿐입니다(447·519·610행). 4절의 strace 에서 이 메시지들이 실제로 오가는 것을 봅니다.

헷갈리기 쉬운 곳: 워커를 몇 개 띄우든 리스닝 소켓은 하나입니다

listen 8080; 의 소켓은 마스터가 설정을 읽을 때(ngx_init_cycle() → ngx_open_listening_sockets(), 4편) 한 번 열고, 워커들은 fork() 로 같은 소켓을 물려받습니다. 그래서 워커 여럿이 같은 포트에서 accept() 할 수 있습니다. 여러 워커 가운데 누가 새 연결을 받는지(accept_mutex, reuseport)는 7편의 주제입니다. 마스터 자신은 이 소켓으로 accept() 하지 않습니다 — 이벤트 루프가 없기 때문입니다.

2. 시그널은 플래그가 되고, 마스터는 sigsuspend() 에서 기다린다

signals[] — 시그널 이름표

nginx 가 다루는 시그널은 한 표에 모여 있습니다.

ngx_signal_t  signals[] = {
    { ngx_signal_value(NGX_RECONFIGURE_SIGNAL),
      "SIG" ngx_value(NGX_RECONFIGURE_SIGNAL),
      "reload",
      ngx_signal_handler },

    { ngx_signal_value(NGX_REOPEN_SIGNAL),
      "SIG" ngx_value(NGX_REOPEN_SIGNAL),
      "reopen",
      ngx_signal_handler },

    /* ... NOACCEPT "", TERMINATE "stop", SHUTDOWN "quit", CHANGEBIN "" ...
       ... SIGALRM, SIGINT, SIGIO, SIGCHLD ... */

    { SIGSYS, "SIGSYS, SIG_IGN", "", NULL },

    { SIGPIPE, "SIGPIPE, SIG_IGN", "", NULL },

    { 0, NULL, "", NULL }
};

src/os/unix/ngx_process.c 39–83행

칸은 네 개입니다 — 시그널 번호, 로그에 찍을 이름, nginx -s 뒤에 쓰는 이름, 핸들러. NGX_RECONFIGURE_SIGNAL 같은 이름은 ngx_config.h 에서 HUP, USR1 같은 글자로 정해지고(src/core/ngx_config.h 60–71행 — Linux 에서 NGX_LINUXTHREADS 가 아니면 REOPEN 은 USR1, CHANGEBIN 은 USR2), ngx_signal_value(HUP) 은 ## 로 글자를 붙여 SIGHUP 이, ngx_value() 는 매크로를 문자열로 바꿔 "SIG" "HUP" → "SIGHUP" 이 됩니다.

정리하면 이렇습니다.

시그널 nginx -s 마스터가 세우는 플래그 하는 일
SIGHUP reload ngx_reconfigure 설정 다시 읽기, 새 워커로 교체 (4절)
SIGUSR1 reopen ngx_reopen 로그 파일 다시 열기 (로그 회전용)
SIGQUIT quit ngx_quit graceful 종료
SIGTERM, SIGINT stop ngx_terminate 곧바로 종료
SIGUSR2 — ngx_change_binary 새 바이너리 실행 (5절)
SIGWINCH — ngx_noaccept 워커만 graceful 종료, 마스터는 남음 (5절)
SIGCHLD — ngx_reap 자식이 끝났다 (3절)
SIGALRM — ngx_sigalrm 종료를 기다리는 타이머 (아래)
SIGPIPE, SIGSYS — (무시, SIG_IGN) SIGPIPE 를 무시해야 끊긴 소켓에 쓸 때 프로세스가 죽지 않는다

nginx -s reload 는 결국 표의 세 번째 칸에서 "reload" 를 찾아 그 시그널을 kill() 하는 것입니다(ngx_os_signal_process(), src/os/unix/ngx_process.c 631–648행). -s 뒤에 올 수 있는 값은 stop·quit·reopen·reload 넷뿐이라 USR2·WINCH 는 kill 로 직접 보내야 합니다(src/core/nginx.c 950–957행).

nginx -s reload 가 마스터의 루프에 닿는 길. 새로 띄운 nginx -s reload 프로세스가 설정 파일을 읽어 pid 파일 경로를 알아낸 뒤 kill 로 SIGHUP 을 보낸다. 커널은 마스터가 sigsuspend 에 있는 동안에만 시그널을 배달하고, 마스터 안의 ngx_signal_handler 는 ngx_reconfigure 를 1로 바꾸고 로그를 한 줄 남긴다. 핸들러가 돌아오면 sigsuspend 도 돌아오고, 마스터 루프가 플래그를 차례로 보며 reload 를 한다. 핸들러는 플래그만 세우고, 실제 일은 루프가 합니다.

핸들러는 플래그만 세운다

    switch (ngx_process) {

    case NGX_PROCESS_MASTER:
    case NGX_PROCESS_SINGLE:
        switch (signo) {

        case ngx_signal_value(NGX_SHUTDOWN_SIGNAL):
            ngx_quit = 1;
            action = ", shutting down";
            break;

        /* ... TERMINATE·SIGINT → ngx_terminate, NOACCEPT → ngx_noaccept ... */

        case ngx_signal_value(NGX_RECONFIGURE_SIGNAL):
            ngx_reconfigure = 1;
            action = ", reconfiguring";
            break;

        /* ... REOPEN, CHANGEBIN, SIGALRM, SIGIO ... */

        case SIGCHLD:
            ngx_reap = 1;
            break;
        }

        break;

src/os/unix/ngx_process.c 340–406행

ngx_signal_handler() 는 프로세스 종류(ngx_process — 마스터, 단일 프로세스, 워커, 헬퍼)에 따라 같은 시그널을 다르게 읽습니다. 워커 쪽(408–441행)에서는 SIGHUP·SIGUSR2 를 ", ignoring" 으로 무시합니다 — reload 와 바이너리 교체는 마스터의 일이기 때문입니다. 끝에서 "signal %d (%s) received from %P%s" 한 줄을 로그에 남기고(444–453행), SIGCHLD 면 ngx_process_get_status() 로 끝난 자식을 거둡니다(462–464행, 3절).

SIGWINCH 는 데몬으로 떴을 때만 받아들입니다(if (ngx_daemonized) { ngx_noaccept = 1; ... }, 357–362행). 포그라운드에서 띄운 nginx 는 터미널 창 크기가 바뀔 때마다 SIGWINCH 를 받을 수 있는데, 그때마다 워커가 모두 나가면 곤란하기 때문으로 읽힙니다(소스에 이유가 적혀 있지는 않습니다).

마스터 루프: 막아 두고, sigsuspend() 에서만 받는다

ngx_master_process_cycle() 은 시작하자마자 다룰 시그널을 전부 막습니다(block).

    sigemptyset(&set);
    sigaddset(&set, SIGCHLD);
    sigaddset(&set, SIGALRM);
    /* ... SIGIO, SIGINT, HUP, USR1, WINCH, TERM, QUIT, USR2 ... */

    if (sigprocmask(SIG_BLOCK, &set, NULL) == -1) {
        ngx_log_error(NGX_LOG_ALERT, cycle->log, ngx_errno,
                      "sigprocmask() failed");
    }

    sigemptyset(&set);

src/os/unix/ngx_process_cycle.c 87–104행

막힌 시그널은 버려지지 않고 보류(pending)됩니다. 그리고 마지막 줄에서 set 을 빈 집합으로 비워, 루프 안의 sigsuspend(&set) 이 "잠든 동안은 아무것도 막지 않는다"가 되게 합니다.

    for ( ;; ) {
        /* ... 종료 중이면 setitimer() 로 SIGALRM 예약 ... */

        ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "sigsuspend");

        sigsuspend(&set);

        ngx_time_update();

        ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,
                       "wake up, sigio %i", sigio);

        if (ngx_reap) {
            ngx_reap = 0;
            ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "reap children");

            live = ngx_reap_children(cycle);
        }

src/os/unix/ngx_process_cycle.c 139–175행

왜 이렇게 번거롭게 할까요. 시그널을 막지 않고 "플래그를 확인하고 → 아무것도 없으면 pause() 로 잠든다"고 쓰면, 확인한 직후와 잠들기 직전 사이에 시그널이 오는 경우가 생깁니다. 핸들러는 플래그를 세우고 돌아오지만, 루프는 이미 확인을 마쳤으므로 그 플래그를 보지 못한 채 잠들고, 다음 시그널이 올 때까지 reload 가 미뤄집니다. 시그널을 평소에 막아 두면 그 사이에 온 시그널은 보류되어 있다가, sigsuspend() 가 막음을 푸는 순간 배달됩니다. "풀고 잠들기"가 한 시스템 콜 안에서 일어나므로 틈이 없습니다.

결과적으로 마스터의 하루는 "자다가 → 시그널에 깨서 → 플래그를 차례로 보고 → 다시 잔다" 입니다. 플래그를 보는 순서가 곧 우선순위입니다.

마스터 루프가 플래그를 보는 순서. sigsuspend 에서 깨면 ngx_reap 이면 ngx_reap_children 으로 죽은 자식을 정리한다. 살아 있는 자식이 없고 terminate 나 quit 이면 마스터가 끝난다. ngx_terminate 면 워커에 TERM 을 보내고 간격이 1초를 넘으면 SIGKILL, ngx_quit 이면 워커에 QUIT 을 보내고 리스닝 소켓을 닫는데, 둘 다 continue 로 루프 처음으로 돌아간다. 아니면 ngx_reconfigure, ngx_restart, ngx_reopen, ngx_change_binary, ngx_noaccept 를 차례로 보고 다시 sigsuspend 로 간다. 빨간 상자는 종료 쪽입니다. 종료 중에는 reload 같은 다른 플래그를 보지 않고 루프 처음으로 돌아갑니다.

종료 쪽 코드(src/os/unix/ngx_process_cycle.c 199–231행)는 두 갈래입니다.

헷갈리기 쉬운 곳: 워커는 시그널을 막지 않습니다

마스터와 달리 워커는 ngx_worker_process_init() 에서 막힌 시그널을 모두 풉니다(sigprocmask(SIG_SETMASK, &set, NULL), 924–929행). 워커는 epoll_wait() 에서 기다리는데, 시그널이 오면 epoll_wait() 가 EINTR 로 돌아오고 루프가 플래그를 봅니다. 마스터의 sigsuspend() 와 같은 역할을 이벤트 루프가 하는 셈입니다. 이벤트 루프 한 바퀴의 자세한 순서는 7편에서 봅니다.

3. 워커가 죽으면 — SIGCHLD 와 ngx_reap_children()

끝난 자식 거두기

자식 프로세스가 끝나면 커널은 부모에게 SIGCHLD 를 보냅니다. 핸들러 안의 ngx_process_get_status() 는 waitpid(-1, &status, WNOHANG) 를 0 이 나올 때까지 되풀이하며 끝난 자식을 모두 거두고, 장부의 그 칸에 exited = 1 을 적습니다(482–531행). 그리고 종료 이유를 로그에 남깁니다.

        if (WEXITSTATUS(status) == 2 && ngx_processes[i].respawn) {
            ngx_log_error(NGX_LOG_ALERT, ngx_cycle->log, 0,
                          "%s %P exited with fatal code %d "
                          "and cannot be respawned",
                          process, pid, WEXITSTATUS(status));
            ngx_processes[i].respawn = 0;
        }

        ngx_unlock_mutexes(pid);

src/os/unix/ngx_process.c 551–559행

종료 코드 2는 "다시 띄워도 소용없다"는 약속입니다. 워커 초기화에서 setuid() 가 실패하거나 모듈의 init_process 가 실패하면 exit(2) 로 끝나는데(뒤의 ngx_worker_process_init() 에 /* fatal */ exit(2); 가 여럿 있습니다), 그런 워커를 되살리면 같은 실패를 끝없이 되풀이하므로 respawn 을 끕니다. ngx_unlock_mutexes() 는 죽은 프로세스가 쥐고 있던 공유 메모리 잠금(accept_mutex, 공유 메모리 zone 의 slab 잠금)을 강제로 풉니다. 워커가 잠금을 쥔 채 죽으면 다른 워커가 영원히 기다리기 때문입니다.

핸들러가 ngx_reap = 1 을 세우면 마스터 루프가 ngx_reap_children() 을 부릅니다. 다시 띄우는 부분입니다.

            if (ngx_processes[i].respawn
                && !ngx_processes[i].exiting
                && !ngx_terminate
                && !ngx_quit)
            {
                if (ngx_spawn_process(cycle, ngx_processes[i].proc,
                                      ngx_processes[i].data,
                                      ngx_processes[i].name, i)
                    == NGX_INVALID_PID)
                {
                    ngx_log_error(NGX_LOG_ALERT, cycle->log, 0,
                                  "could not respawn %s",
                                  ngx_processes[i].name);
                    continue;
                }


                ngx_pass_open_channel(cycle);

                live = 1;

                continue;
            }

src/os/unix/ngx_process_cycle.c 615–637행

조건 네 개가 모두 맞아야 되살립니다 — 다시 띄울 자식이고(respawn), 마스터가 나가라고 한 적이 없고(!exiting), 마스터 자신이 종료 중이 아닐 것. 장부에 적어 둔 proc·data·name 을 그대로 넘기고, 마지막 인자로 칸 번호 i 를 넘깁니다(1절에서 본 "0 이상이면 그 칸에"). 그래서 되살아난 워커는 죽은 워커와 같은 워커 번호(data 에 든 i)를 갖습니다. worker_cpu_affinity 로 워커마다 CPU 를 묶어 두었다면 되살아난 워커도 같은 CPU 로 갑니다.

이 함수는 또 죽은 자식의 채널을 닫고, 다른 자식들에게 NGX_CMD_CLOSE_CHANNEL 을 보내 그 채널을 잊게 합니다(585–613행). 그리고 살아 있는 자식이 하나라도 있으면(detached 인 자식은 빼고 셉니다) live = 1 을 돌려줍니다.

예제 2: 워커를 kill -9 하기

ex/u06-respawn 은 같은 모양의 설정(worker_processes 2)을 error_log ... debug; 로 띄우고 워커 하나를 kill -9 합니다. error.log 는 디버그 줄을 빼고 보였습니다.

=== 1. kill -9 one worker ===
    PID    PPID USER     CMD
     11       1 root     nginx: master process nginx -c /tmp/u06r/nginx.conf
     12      11 nobody   nginx: worker process
     13      11 nobody   nginx: worker process
--- kill -9 12
    PID    PPID USER     CMD
     11       1 root     nginx: master process nginx -c /tmp/u06r/nginx.conf
     13      11 nobody   nginx: worker process
     22      11 nobody   nginx: worker process
--- error.log (non-debug lines)
2026/11/07 02:51:03 [notice] 11#11: signal 17 (SIGCHLD) received from 12
2026/11/07 02:51:03 [alert] 11#11: worker process 12 exited on signal 9
2026/11/07 02:51:03 [notice] 11#11: start worker process 22
2026/11/07 02:51:03 [notice] 11#11: signal 29 (SIGIO) received

pid 와 시각은 실행마다 다릅니다. 0.5초 만에 워커 12 가 사라지고 22 가 생겼습니다. 로그의 세 줄이 세 함수와 맞습니다 — signal 17 (SIGCHLD) received from 12 는 ngx_signal_handler(), exited on signal 9 는 ngx_process_get_status() 의 WTERMSIG 분기(533–543행, 시그널로 죽었으므로 alert), start worker process 22 는 ngx_reap_children() 이 부른 ngx_spawn_process() 의 206행입니다.

예제 2를 순서대로 그린 그림. kill -9 12 로 워커 12 가 시그널 9 로 죽으면 커널이 마스터에게 SIGCHLD 를 보낸다. 마스터의 시그널 핸들러 ngx_signal_handler 가 signal 17 (SIGCHLD) received from 12 를 찍고, ngx_process_get_status() 가 waitpid(-1, WNOHANG) 를 0 이 나올 때까지 되풀이하며 장부 칸에 exited = 1 을 적고 worker process 12 exited on signal 9 를 찍는다(종료 코드가 2 였다면 respawn = 0). 핸들러가 ngx_reap = 1 을 세우면 마스터 루프 ngx_master_process_cycle 이 sigsuspend() 에서 깨어나 ngx_reap_children() 을 부르고, respawn, !exiting, !ngx_terminate, !ngx_quit 이 모두 맞으면 ngx_spawn_process(..., 칸 번호 i) 로 같은 칸, 같은 워커 번호의 새 워커 22 를 띄우며 start worker process 22 를 찍는다. 마지막으로 죽은 채널을 닫고 다른 자식에게 NGX_CMD_CLOSE_CHANNEL 을 보낸다. kill -9 12 아래의 어두운 칸 셋은 예제 2 error.log 의 실제 줄이고, 세 함수에서 하나씩 나옵니다.

마지막 SIGIO 줄은 이 글을 쓰며 설명이 필요했던 줄입니다. 마스터는 채널의 자기 쪽 끝(channel[0])에 FIOASYNC 와 F_SETOWN(소유자 = 마스터) 을 걸어 둡니다(146–158행). 워커가 끝나 반대쪽 끝이 닫히면 커널이 그 소켓에 "읽을 것이 생겼다"(EOF)고 보고 마스터에게 SIGIO 를 보낸 것으로 보입니다. 커널의 동작이라 nginx 소스로 확인한 것은 아닙니다. 이 빌드에서 마스터는 SIGIO 를 받아도 ngx_sigio = 1 만 세우고, 그 값을 쓰는 곳은 #if (NGX_CONTROL_API) 안(177–196행)뿐이라 아무 일도 하지 않습니다.

헷갈리기 쉬운 곳: SIGCHLD 한 번에 자식 둘이 거둬질 수 있습니다

같은 시그널이 보류 중일 때 또 오면 하나로 합쳐집니다. 4절의 예제(글에 실은 실행)에서는 signal 17 (SIGCHLD) received from 40 한 줄 뒤에 worker process 39 exited, worker process 40 exited 두 줄이 이어졌습니다. 그래서 ngx_process_get_status() 는 waitpid() 를 한 번만 부르지 않고 WNOHANG 으로 0 이 나올 때까지 되풀이합니다. 로그의 "from 40" 만 보고 39 는 어디 갔나 찾을 필요가 없습니다. 합쳐질지는 두 자식이 끝나는 타이밍에 달려 있어, 같은 예제를 다시 돌렸을 때는 SIGCHLD 두 줄로 따로 거둬지기도 했습니다.

4. 무중단 reload — 새 워커를 먼저 띄우고, 옛 워커는 일을 마치게 한다

마스터 쪽: ngx_reconfigure

ngx_reconfigure 가 서 있으면 마스터 루프는 이렇게 합니다.

        if (ngx_reconfigure) {
            ngx_reconfigure = 0;

            if (ngx_new_binary) {
                ngx_start_worker_processes(cycle, ccf->worker_processes,
                                           NGX_PROCESS_RESPAWN);
                ngx_start_cache_manager_processes(cycle, 0);
                ngx_noaccepting = 0;

                continue;
            }

            ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "reconfiguring");

            cycle = ngx_init_cycle(cycle);
            if (cycle == NULL) {
                cycle = (ngx_cycle_t *) ngx_cycle;
                continue;
            }

            ngx_cycle = cycle;
            ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx,
                                                   ngx_core_module);
            ngx_start_worker_processes(cycle, ccf->worker_processes,
                                       NGX_PROCESS_JUST_RESPAWN);
            ngx_start_cache_manager_processes(cycle, 1);

            /* allow new processes to start */
            ngx_msleep(100);

            live = 1;
            ngx_signal_worker_processes(cycle,
                                        ngx_signal_value(NGX_SHUTDOWN_SIGNAL));
        }

src/os/unix/ngx_process_cycle.c 233–266행

순서대로 읽으면 reload 의 전부입니다(ngx_new_binary 분기는 5절).

  1. ngx_init_cycle(cycle) — 옛 cycle 을 넘겨 새 cycle 을 만듭니다. 설정 파일을 처음부터 다시 읽는 4편의 그 함수입니다. 옛 cycle 을 넘기는 이유 중 하나가 리스닝 소켓입니다 — 새 설정의 listen 이 옛 것과 같은 주소면 새로 열지 않고 옛 fd 를 그대로 가져옵니다(nls[n].fd = ls[i].fd;, src/core/ngx_cycle.c 535–541행). 소켓을 닫았다 다시 열지 않으므로, reload 하는 동안 커널의 대기열에 들어온 연결도 잃지 않습니다.
  2. 실패하면 아무것도 바꾸지 않습니다. NULL 이 오면 전역 ngx_cycle(옛 cycle)로 되돌리고 continue. 옛 워커는 그대로 일합니다.
  3. 새 워커를 NGX_PROCESS_JUST_RESPAWN 으로 띄웁니다. respawn = 1, just_spawn = 1 입니다. 새 워커는 fork() 시점의 메모리, 곧 새 cycle 을 물려받습니다. worker_processes 를 바꿨다면 여기서 새 개수만큼 뜹니다.
  4. 100밀리초 잡니다. 주석 그대로 "새 프로세스가 시작할 시간을 준다"입니다.
  5. ngx_signal_worker_processes(SHUTDOWN) — 모든 자식에게 QUIT 을 보내되, 방금 띄운 자식은 건너뜁니다. 아래에서 봅니다.

QUIT 은 시그널이 아니라 채널로 간다

ngx_signal_worker_processes() 는 먼저 시그널 번호를 채널 명령으로 바꿉니다. SIGQUIT → NGX_CMD_QUIT, SIGTERM → NGX_CMD_TERMINATE, SIGUSR1 → NGX_CMD_REOPEN, 그 밖(SIGKILL 등)은 0 입니다(src/os/unix/ngx_process_cycle.c 468–484행). 그리고 자식마다 이렇게 합니다.

        if (ngx_processes[i].detached || ngx_processes[i].pid == -1) {
            continue;
        }

        if (ngx_processes[i].just_spawn) {
            ngx_processes[i].just_spawn = 0;
            continue;
        }

        if (ngx_processes[i].exiting
            && signo == ngx_signal_value(NGX_SHUTDOWN_SIGNAL))
        {
            continue;
        }

        if (ch.command) {
            if (ngx_write_channel(ngx_processes[i].channel[0],
                                  &ch, sizeof(ngx_channel_t), cycle->log)
                == NGX_OK)
            {
                if (signo != ngx_signal_value(NGX_REOPEN_SIGNAL)) {
                    ngx_processes[i].exiting = 1;
                }

                continue;
            }
        }

src/os/unix/ngx_process_cycle.c 503–529행

채널로 가는 32바이트는 이 구조체입니다.

typedef struct {
    ngx_uint_t  command;
    ngx_pid_t   pid;
    ngx_int_t   slot;
    ngx_fd_t    fd;
} ngx_channel_t;

src/os/unix/ngx_channel.h 17–22행

명령 번호는 NGX_CMD_OPEN_CHANNEL 1, CLOSE_CHANNEL 2, QUIT 3, TERMINATE 4, REOPEN 5 입니다(src/os/unix/ngx_process_cycle.h 16–20행). 64비트 Linux 에서 ngx_uint_t·ngx_int_t 가 8바이트, pid·fd 가 4바이트에 맞춤 여백 4바이트씩이라 모두 32바이트입니다. 워커 쪽에서는 채널 fd 를 다른 연결처럼 이벤트 루프에 등록해 두고(ngx_add_channel_event(), 972–978행), 읽을 것이 생기면 ngx_channel_handler() 가 불려 NGX_CMD_QUIT 이면 ngx_quit = 1, TERMINATE 면 ngx_terminate = 1, REOPEN 이면 ngx_reopen = 1 을 세웁니다(src/os/unix/ngx_process_cycle.c 1089–1101행).

시그널 핸들러가 세우는 것과 같은 플래그입니다. 그래서 워커 입장에서는 kill -QUIT 으로 왔든 채널로 왔든 뒤의 길이 같습니다. 채널을 먼저 쓰는 이유가 소스에 적혀 있지는 않습니다. 다만 채널 메시지는 시그널과 달리 합쳐지지 않고 순서대로 쌓이며, 이벤트 루프 안에서 다른 이벤트와 똑같이 처리된다는 차이는 코드에서 읽힙니다.

워커 쪽: gracefully shutting down

    for ( ;; ) {

        if (ngx_exiting) {
            if (ngx_event_no_timers_left() == NGX_OK) {
                ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "exiting");
                ngx_worker_process_exit(cycle);
            }
        }

        ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "worker cycle");

        ngx_process_events_and_timers(cycle);

        if (ngx_terminate) {
            ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "exiting");
            ngx_worker_process_exit(cycle);
        }

        if (ngx_quit) {
            ngx_quit = 0;
            ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0,
                          "gracefully shutting down");
            ngx_setproctitle("worker process is shutting down");

            if (!ngx_exiting) {
                ngx_exiting = 1;
                ngx_set_shutdown_timer(cycle);
                ngx_close_listening_sockets(cycle);
                ngx_close_idle_connections(cycle);
                ngx_event_process_posted(cycle, &ngx_posted_events);
            }
        }

src/os/unix/ngx_process_cycle.c 740–771행

ngx_quit 을 본 워커는 한 번만(!ngx_exiting) 다음을 합니다.

  1. ngx_exiting = 1 — 이제부터 "나가는 중"입니다. 이 전역 변수를 HTTP 모듈들도 봅니다. 응답 헤더를 만들 때 r->keepalive && (ngx_terminate || ngx_exiting) 이면 keep-alive 를 끄고(src/http/ngx_http_header_filter_module.c 204–206행), 그래서 처리 중이던 응답에는 Connection: close 가 붙습니다. 요청을 마친 뒤 keep-alive 로 넘어가는 곳도 !ngx_exiting 을 확인합니다(src/http/ngx_http_request.c 3054–3061행).
  2. ngx_close_listening_sockets() — 이 워커의 리스닝 소켓을 닫습니다. 소켓 자체는 마스터와 새 워커도 들고 있으므로 포트가 닫히지는 않고, 이 워커만 새 연결을 받지 않게 됩니다.
  3. ngx_close_idle_connections() — 요청을 기다리며 놀고 있는(c->idle) 연결을 바로 닫습니다(src/core/ngx_connection.c 1461–1478행).

닫는 방법이 nginx 답습니다. 연결 배열을 훑으며 직접 close() 하지 않고 c->close = 1 을 세운 뒤 그 연결의 읽기 핸들러를 부릅니다. HTTP keep-alive 연결이면 ngx_http_keepalive_handler() 가 불려 c->close 를 보고 스스로 정리합니다. 연결마다 정리 방법을 아는 쪽(모듈)에게 맡기는 것입니다. HTTP/1.x 연결에 idle 이 켜지는 곳은 ngx_http_set_keepalive() 입니다(src/http/ngx_http_request.c 3556–3559행). 다만 keepalive_min_timeout(기본 0)을 주었으면 그 시간이 지난 뒤 ngx_http_keepalive_handler() 에서 켜지므로(3593–3599행), 그 전의 keep-alive 연결은 여기서 닫히지 않고 남습니다.

남은 것은 처리 중인 연결입니다. 워커는 그냥 루프를 계속 돌며 그 연결들을 처리하고, 루프 머리에서 ngx_event_no_timers_left() 가 NGX_OK 를 돌려주면 나갑니다.

    /* ... 트리가 비었으면 NGX_OK ... */

    for (node = ngx_rbtree_min(root, sentinel);
         node;
         node = ngx_rbtree_next(&ngx_event_timer_rbtree, node))
    {
        ev = ngx_rbtree_data(node, ngx_event_t, timer);

        if (!ev->cancelable) {
            return NGX_AGAIN;
        }
    }

    /* only cancelable timers left */

    return NGX_OK;
}

src/event/ngx_event_timer.c 112–126행 (함수 전체는 99–126행)

"처리 중인 연결이 남았나"를 연결 수로 세지 않고 타이머로 판단합니다. nginx 의 타이머는 레드블랙 트리(넣고 빼고 가장 작은 값을 찾는 일이 모두 빠른 균형 이진 트리) 하나에 모여 있습니다(3편, 자세한 것은 7편). 처리 중인 연결은 보통 읽기·쓰기 타임아웃 타이머(proxy_read_timeout, send_timeout 같은)를 하나씩 걸고 있으므로, "취소할 수 없는 타이머가 하나도 없다"는 "기다릴 일이 하나도 없다"와 같습니다. 로그 버퍼를 비우는 타이머나 resolver 의 타이머처럼 기다릴 필요가 없는 것은 cancelable = 1 로 표시해 두어 이 판단에서 빠집니다.

워커의 상태. fork 직후 ngx_worker_process_init 에서 setgid, initgroups, setuid, cpu affinity, 모듈 init_process, 채널 등록을 하고 Running 이 된다. Running 은 ngx_process_events_and_timers 를 돈다. ngx_quit 이면 ShuttingDown 이 되어 ngx_exiting 을 1로 하고 리스닝 소켓과 노는 연결을 닫는다. 처리 중인 연결이 남아 있으면 그대로 머물고, 취소할 수 없는 타이머가 0개가 되거나 worker_shutdown_timeout 이 지나 남은 연결을 강제로 닫으면 Exit 로 간다. ngx_terminate 면 Running 에서 바로 Exit 로 가고, ngx_reopen 은 로그 파일만 다시 연다. graceful 종료는 "연결이 0개"가 아니라 "기다릴 타이머가 0개"를 기다립니다.

끝나지 않는 연결(아주 긴 다운로드, WebSocket 같은)이 있으면 옛 워커가 오래 남습니다. 그때 쓰는 것이 worker_shutdown_timeout 이고, 기본값은 0(끔)입니다(ngx_conf_init_msec_value(ccf->shutdown_timeout, 0), src/core/nginx.c 1194행). 값을 주면 ngx_set_shutdown_timer() 가 그 시간 뒤의 타이머를 걸고, 울리면 ngx_shutdown_timer_handler() 가 남은 연결마다 close·error 를 세우고 읽기 핸들러를 불러 끝냅니다(src/core/ngx_cycle.c 1436–1484행). 이 타이머 자신은 cancelable = 1 이라 워커가 나가는 것을 붙잡지 않습니다.

전체 순서를 한 장으로 모으면 이렇습니다.

reload 의 순서. 운영자가 마스터에 SIGHUP 을 보내면 마스터는 옛 cycle 을 넘겨 ngx_init_cycle 로 새 cycle 을 만들고, 같은 주소의 리스닝 소켓은 fd 를 그대로 받는다. ngx_spawn_process 로 새 워커를 just_spawn 으로 띄우고 100밀리초 잔 뒤, 채널로 NGX_CMD_QUIT 을 보내는데 새 워커는 just_spawn 이라 건너뛴다. 옛 워커는 ngx_exiting 을 1로 하고 리스닝 소켓과 노는 keep-alive 연결을 닫는다. 새 연결은 새 워커만 받고, 옛 워커는 처리 중이던 응답을 Connection close 로 마저 보낸 뒤 취소할 수 없는 타이머가 없으면 exit 한다. 마스터는 SIGCHLD 를 받아 ngx_reap_children 에서 정리하되 exiting 이라 다시 띄우지 않는다. 2편의 reload 그림을 함수 단위로 확대한 것입니다.

예제 3: 놀고 있는 연결과 처리 중인 요청을 둔 채 reload

1절의 ex/u06-reload 를 이어서 봅니다. run.sh 는 띄운 뒤 (1) keep-alive 연결 하나를 열어 요청을 한 번 보내고 놀려 두고(idle.py), (2) 3초 늦게 답하는 백엔드(slow.py)로 가는 /slow 요청을 하나 보내 놓은 채, (3) 설정의 v1 을 v2 로 바꿔 nginx -s reload 합니다. 시각은 시작부터 잰 초입니다.

출력입니다. 1절에 실은 첫 부분, error.log 의 기동 줄과 마지막 -s quit 부분은 ... 로 줄였습니다.

=== 2. an idle keep-alive connection and an in-flight request (/slow, 3s) ===
 0.73s idle conn: got 'v1 pid=20\n'
=== 3. edit v1 -> v2 and nginx -s reload ===
 1.46s reload
 1.59s idle conn: closed by server
    PID    PPID USER     CMD
     19       1 root     nginx: master process nginx -c /tmp/u06/nginx.conf
     20      19 nobody   nginx: worker process is shutting down
     39      19 nobody   nginx: worker process
     40      19 nobody   nginx: worker process
 2.06s curl /hello: v2 pid=39
 3.94s /slow finished
--- response of /slow (headers)
HTTP/1.1 200 OK
Server: nginx/1.31.9
Date: Sat, 07 Nov 2026 02:51:16 GMT
Content-Length: 14
Connection: close

slow response
=== 4. after the old worker finished ===
    PID    PPID USER     CMD
     19       1 root     nginx: master process nginx -c /tmp/u06/nginx.conf
     39      19 nobody   nginx: worker process
     40      19 nobody   nginx: worker process
=== error.log ===
...
2026/11/07 02:51:14 [notice] 19#19: signal 1 (SIGHUP) received from 38, reconfiguring
2026/11/07 02:51:14 [notice] 19#19: reconfiguring
2026/11/07 02:51:14 [notice] 19#19: using the "epoll" event method
2026/11/07 02:51:14 [notice] 19#19: start worker processes
2026/11/07 02:51:14 [notice] 19#19: start worker process 39
2026/11/07 02:51:14 [notice] 19#19: start worker process 40
2026/11/07 02:51:14 [notice] 21#21: gracefully shutting down
2026/11/07 02:51:14 [notice] 21#21: exiting
2026/11/07 02:51:14 [notice] 21#21: exit
2026/11/07 02:51:14 [notice] 20#20: gracefully shutting down
2026/11/07 02:51:14 [notice] 19#19: signal 17 (SIGCHLD) received from 21
2026/11/07 02:51:14 [notice] 19#19: worker process 21 exited with code 0
2026/11/07 02:51:14 [notice] 19#19: signal 29 (SIGIO) received
2026/11/07 02:51:16 [notice] 20#20: exiting
2026/11/07 02:51:16 [notice] 20#20: exit
2026/11/07 02:51:16 [notice] 19#19: signal 17 (SIGCHLD) received from 20
2026/11/07 02:51:16 [notice] 19#19: worker process 20 exited with code 0
2026/11/07 02:51:16 [notice] 19#19: signal 29 (SIGIO) received
2026/11/07 02:51:17 [notice] 19#19: signal 3 (SIGQUIT) received from 53, shutting down
...
2026/11/07 02:51:17 [notice] 19#19: signal 17 (SIGCHLD) received from 40
2026/11/07 02:51:17 [notice] 19#19: worker process 39 exited with code 0
2026/11/07 02:51:17 [notice] 19#19: worker process 40 exited with code 0
2026/11/07 02:51:17 [notice] 19#19: exit

pid, 시각, 어느 워커가 어느 연결을 받는지는 실행마다 다릅니다. 이 실행에서는 놀던 연결과 /slow 요청이 모두 워커 20 에 갔습니다.

  1. reload 0.13초 뒤 놀던 keep-alive 연결이 닫혔습니다(1.46s → 1.59s). keepalive_timeout 60s 를 기다리지 않았습니다. ngx_close_idle_connections() 입니다. 클라이언트 쪽에서는 "서버가 연결을 닫았다"로 보이므로, keep-alive 연결을 재사용하는 클라이언트는 다음 요청을 새 연결로 보내야 합니다(HTTP 클라이언트 라이브러리는 보통 이것을 다시 시도합니다).
  2. 워커 20 은 worker process is shutting down 으로 남았습니다. 처리 중인 /slow 요청이 있었기 때문입니다. 같은 때 할 일이 없던 워커 21 은 gracefully shutting down → exiting → exit 을 같은 초에 마쳤습니다.
  3. 새 요청은 새 설정(v2)의 새 워커 39 가 받았습니다. 옛 워커는 리스닝 소켓을 닫았으므로 새 연결이 가지 않습니다.
  4. /slow 는 끊기지 않고 끝까지 왔고, 응답에 Connection: close 가 붙었습니다. ngx_exiting 을 본 헤더 필터가 keep-alive 를 끈 것입니다. 응답을 보낸 뒤 워커 20 은 남은 타이머가 없어 exiting 을 찍고 끝났습니다(02:51:16).
  5. 워커들의 로그에는 signal 3 (SIGQUIT) received 가 없습니다. reload 때도, 마지막 -s quit 때도 마찬가지입니다. 시그널을 받은 것은 마스터뿐이고, 워커는 채널로 QUIT 을 받았기 때문입니다. 다음 예제에서 strace 로 확인합니다.
  6. 마지막 종료 때는 SIGCHLD 한 줄에 워커 둘이 거둬졌습니다(3절의 "헷갈리기 쉬운 곳"). 타이밍에 따라 달라서, 다시 돌렸을 때는 두 줄로 나뉘었습니다.

예제 4: reload 때 마스터와 옛 워커의 시스템 콜

ex/u06-respawn 의 세 번째 부분은 reload 하는 동안 마스터와 옛 워커 하나에 strace 를 붙입니다. 마스터는 rt_sigsuspend,socketpair,clone,sendmsg,kill,wait4 만, 워커는 epoll_wait,recvmsg,close,exit_group 만 보았습니다. 줄이 길어 각 줄의 뒤쪽을 … 로 잘랐습니다.

--- master 11
rt_sigsuspend([], 8)                    = ? ERESTARTNOHAND (To be restarted if no handler)
--- SIGHUP {si_signo=SIGHUP, si_code=SI_USER, si_pid=48, si_uid=0} ---
socketpair(AF_UNIX, SOCK_STREAM, 0, [4, 10]) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f0212a5da10) = 50
sendmsg(3, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\1\0\0\0\0\0\0\0002\0\0\0\0\0\0\0\2\0\0\0\0\0\0\0\4\0\0\0\0\0\0\0", …
sendmsg(7, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\1\0\0\0\0\0\0\0002\0\0\0\0\0\0\0\2\0\0\0\0\0\0\0\4\0\0\0\0\0\0\0", …
socketpair(AF_UNIX, SOCK_STREAM, 0, [11, 12]) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f0212a5da10) = 51
sendmsg(3, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\1\0\0\0\0\0\0\0003\0\0\0\0\0\0\0\3\0\0\0\0\0\0\0\v\0\0\0\0\0\0\0", …
sendmsg(7, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\1\0\0\0\0\0\0\0003\0\0\0\0\0\0\0\3\0\0\0\0\0\0\0\v\0\0\0\0\0\0\0", …
sendmsg(4, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\1\0\0\0\0\0\0\0003\0\0\0\0\0\0\0\3\0\0\0\0\0\0\0\v\0\0\0\0\0\0\0", …
sendmsg(3, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\3\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\377\377\377\377\0\0\0\0", …
sendmsg(7, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\3\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\377\377\377\377\0\0\0\0", …
rt_sigsuspend([], 8)                    = ? ERESTARTNOHAND (To be restarted if no handler)
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=22, si_uid=65534, si_status=0, si_utime=0, si_stime=0} ---
wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG, NULL) = 22
wait4(-1, 0x7ffe6ffe9dbc, WNOHANG, NULL) = 0
sendmsg(7, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\2\0\0\0\0\0\0\0\26\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\377\377\377\377\0\0\0\0", …
sendmsg(4, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\2\0\0\0\0\0\0\0\26\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\377\377\377\377\0\0\0\0", …
sendmsg(11, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\2\0\0\0\0\0\0\0\26\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\377\377\377\377\0\0\0\0", …
rt_sigsuspend([], 8)                    = ? ERESTARTNOHAND (To be restarted if no handler)
...
--- old worker 13
epoll_wait(9, [{events=EPOLLIN, data={u32=509870544, u64=94584279663056}}], 512, -1) = 1
recvmsg(8, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\1\0\0\0\0\0\0\0002\0\0\0\0\0\0\0\2\0\0\0\0\0\0\0\4\0\0\0\0\0\0\0", …
recvmsg(8, {msg_namelen=0}, 0)          = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(9, [{events=EPOLLIN, data={u32=509870544, u64=94584279663056}}], 512, -1) = 1
recvmsg(8, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\1\0\0\0\0\0\0\0003\0\0\0\0\0\0\0\3\0\0\0\0\0\0\0\v\0\0\0\0\0\0\0", …
recvmsg(8, {msg_namelen=0}, 0)          = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(9, [{events=EPOLLIN, data={u32=509870544, u64=94584279663056}}], 512, -1) = 1
recvmsg(8, {msg_name=NULL, msg_namelen=0, msg_iov=[{iov_base="\3\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\377\377\377\377\0\0\0\0", …
recvmsg(8, {msg_namelen=0}, 0)          = -1 EAGAIN (Resource temporarily unavailable)
close(5)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++

fd 번호, pid, 주소는 실행마다 다릅니다. iov_base 의 32바이트를 ngx_channel_t 로 읽으면 이렇습니다(strace 는 바이트를 8진수 이스케이프로 찍습니다. \1=1, \v=11, \26=22(8진수 26). \0002 는 \000 다음에 글자 2(0x32=50)가 온 것입니다).

줄 command pid slot fd 뜻
첫 sendmsg 묶음 1 (OPEN_CHANNEL) 50 2 4 새 워커 50 의 채널을 다른 자식들에게 알림
둘째 묶음 1 51 3 11 새 워커 51 의 채널
sendmsg(3/7, "\3…") 3 (QUIT) 0 0 -1 (\377\377\377\377) 옛 워커 둘(채널 fd 3, 7)에게만 QUIT
SIGCHLD 뒤 2 (CLOSE_CHANNEL) 22 (\26) 0 -1 끝난 워커 22 의 채널을 잊으라고 남은 자식들에게 알림
  1. 마스터의 strace 에 kill() 이 한 번도 없습니다. QUIT 은 sendmsg() 두 번(옛 워커 둘)으로 갔고, 새 워커(fd 4, 11)에게는 QUIT 이 가지 않았습니다 — just_spawn 입니다.
  2. socketpair() → clone() 이 워커마다 한 쌍입니다. glibc 의 fork() 는 Linux 에서 clone() 시스템 콜로 나타납니다. 1절의 ngx_spawn_process() 순서 그대로입니다.
  3. 옛 워커는 epoll_wait() 로 채널(fd 8)의 읽기 이벤트를 받아 recvmsg() 로 읽었습니다. 시그널 --- SIGQUIT --- 줄은 없습니다. 세 번째 메시지가 QUIT(3)이고, 곧바로 close(5) 뒤에 exit_group(0) 으로 끝났습니다. fd 5 는 리스닝 소켓입니다(같은 설정으로 다시 띄워 워커의 /proc/<pid>/fd 와 /proc/net/tcp 로 확인했습니다) — ngx_close_listening_sockets() 입니다. 처리 중인 연결이 없으니 기다릴 타이머도 없었습니다. 이 워커가 나가기 직전까지 새 워커들의 OPEN_CHANNEL 을 받아 둔 것도 보입니다.

같은 reload 를 마스터의 디버그 로그로 보면 장부의 비트가 보입니다(마스터 pid 의 줄 가운데 일부만 골랐습니다).

2026/11/07 02:51:06 [notice] 11#11: signal 1 (SIGHUP) received from 48, reconfiguring
2026/11/07 02:51:06 [debug] 11#11: wake up, sigio 0
2026/11/07 02:51:06 [notice] 11#11: reconfiguring
2026/11/07 02:51:06 [notice] 11#11: start worker processes
2026/11/07 02:51:06 [notice] 11#11: start worker process 50
2026/11/07 02:51:06 [debug] 11#11: pass channel s:2 pid:50 fd:4 to s:0 pid:22 fd:3
2026/11/07 02:51:06 [debug] 11#11: pass channel s:2 pid:50 fd:4 to s:1 pid:13 fd:7
2026/11/07 02:51:06 [notice] 11#11: start worker process 51
2026/11/07 02:51:06 [debug] 11#11: pass channel s:3 pid:51 fd:11 to s:0 pid:22 fd:3
2026/11/07 02:51:06 [debug] 11#11: pass channel s:3 pid:51 fd:11 to s:1 pid:13 fd:7
2026/11/07 02:51:06 [debug] 11#11: pass channel s:3 pid:51 fd:11 to s:2 pid:50 fd:4
2026/11/07 02:51:06 [debug] 11#11: child: 0 22 e:0 t:0 d:0 r:1 j:0
2026/11/07 02:51:06 [debug] 11#11: child: 1 13 e:0 t:0 d:0 r:1 j:0
2026/11/07 02:51:06 [debug] 11#11: child: 2 50 e:0 t:0 d:0 r:1 j:1
2026/11/07 02:51:06 [debug] 11#11: child: 3 51 e:0 t:0 d:0 r:1 j:1
2026/11/07 02:51:06 [debug] 11#11: sigsuspend
2026/11/07 02:51:07 [notice] 11#11: signal 17 (SIGCHLD) received from 22
2026/11/07 02:51:07 [notice] 11#11: worker process 22 exited with code 0
2026/11/07 02:51:07 [debug] 11#11: wake up, sigio 0
2026/11/07 02:51:07 [debug] 11#11: reap children
2026/11/07 02:51:07 [debug] 11#11: child: 0 22 e:1 t:1 d:0 r:1 j:0
2026/11/07 02:51:07 [debug] 11#11: child: 1 13 e:1 t:0 d:0 r:1 j:0
...

child: 칸 pid e:exiting t:exited d:detached r:respawn j:just_spawn 형식입니다(ngx_signal_worker_processes() 의 493–501행, ngx_reap_children() 의 571–579행이 같은 형식을 찍습니다). QUIT 을 보내기 직전 새 워커 50·51 은 j:1 이었고, 보낸 뒤 옛 워커 22·13 은 e:1 이 되었으며 새 워커는 j:0 으로 돌아왔습니다. 끝난 22 는 t:1 이지만 e:1 이라 ngx_reap_children() 이 되살리지 않았습니다. 워커 22 는 예제 2 에서 kill -9 뒤 되살아난 그 워커입니다.

헷갈리기 쉬운 곳: 틀린 설정으로 reload 하면

ex/u06-respawn 의 두 번째 부분은 설정 끝에 oops; 를 붙이고 reload 합니다.

=== 2. reload with a broken config ===
--- nginx -s reload
nginx: [emerg] unknown directive "oops" in /tmp/u06r/nginx.conf:22
(exit code 1)
--- kill -HUP 11
    PID    PPID USER     CMD
     11       1 root     nginx: master process nginx -c /tmp/u06r/nginx.conf
     13      11 nobody   nginx: worker process
     22      11 nobody   nginx: worker process
--- error.log (non-debug lines)
2026/11/07 02:51:04 [notice] 11#11: signal 1 (SIGHUP) received from 1, reconfiguring
2026/11/07 02:51:04 [notice] 11#11: reconfiguring
2026/11/07 02:51:04 [emerg] 11#11: unknown directive "oops" in /tmp/u06r/nginx.conf:22
  1. nginx -s reload 는 시그널을 보내지도 않았습니다. -s 로 띄운 프로세스도 main() 에서 먼저 ngx_init_cycle() 로 설정을 읽고(pid 파일 경로가 설정에 있기 때문입니다), 그것이 실패하면 return 1 로 끝납니다. 신호 보내기(ngx_signal_process())는 그 뒤에 있습니다(src/core/nginx.c 300–338행).
  2. 마스터에 kill -HUP 을 직접 보내도 서버는 그대로입니다. 마스터는 reconfiguring 을 찍고 ngx_init_cycle() 에서 실패한 뒤, 4절 코드의 cycle == NULL 분기로 옛 cycle 에 남습니다. 워커 13·22 의 pid 가 바뀌지 않았습니다.

httpd 5편에서 본 httpd 는 여기가 다릅니다. 부모에게 직접 kill -USR1 을 보내 graceful 을 시켰는데 새 설정에 오류가 있으면, httpd 는 main() 루프 안의 실패를 모두 destroy_and_exit_process() 로 처리하므로 돌던 부모가 끝납니다. nginx 는 새 cycle 을 옛 cycle 옆에 따로 만들어 보고, 다 만들어졌을 때만 갈아 끼우므로 실패해도 잃을 것이 없습니다.

헷갈리기 쉬운 곳: 옛 cycle 은 누가 지우나

reload 할 때마다 cycle 이 하나씩 새로 생깁니다. 마스터는 ngx_init_cycle() 끝에서 옛 cycle 의 풀을 바로 지웁니다.

    if (ngx_process == NGX_PROCESS_MASTER || ngx_is_init_cycle(old_cycle)) {

        ngx_destroy_pool(old_cycle->pool);
        cycle->old_cycle = NULL;

        return cycle;
    }

src/core/ngx_cycle.c 783–789행

옛 워커들이 아직 옛 설정으로 요청을 처리하는데 괜찮을까요. 괜찮습니다 — 옛 워커는 fork() 할 때 복사한 자기 메모리의 옛 cycle 을 씁니다. 마스터가 자기 쪽 사본을 지워도 옛 워커의 사본에는 영향이 없습니다. 마스터는 연결을 하나도 들고 있지 않으니 옛 cycle 을 남겨 둘 이유가 없습니다. 그 아래(792–828행)의 ngx_old_cycles 와 30초마다 도는 ngx_clean_old_cycles() 는 마스터 없이 프로세스 하나로 도는 단일 프로세스 모드(master_process off)에서 reload 할 때를 위한 것입니다. 그때는 옛 cycle 의 연결이 같은 프로세스 안에 남아 있으므로, 연결이 모두 닫힐 때까지 지우지 않고 기다립니다.

5. 바이너리 교체 — USR2, WINCH, QUIT

reload 는 같은 nginx 바이너리로 설정만 바꿉니다. nginx 자체를 새 판으로 올릴 때는 프로세스를 새로 실행해야 하는데, 리스닝 소켓을 닫았다 열면 그 사이의 연결을 잃습니다. nginx 는 소켓을 열어 둔 채 새 바이너리에게 물려주는 방법을 씁니다.

바이너리 교체의 순서. 운영자가 옛 마스터에 SIGUSR2 를 보내면 옛 마스터는 nginx.pid 를 nginx.pid.oldbin 으로 이름을 바꾸고, fork 후 execve 로 argv 0 을 실행하며 환경 변수 NGINX 에 리스닝 fd 목록을 담는다. 새 마스터는 ngx_add_inherited_sockets 로 그 소켓을 받고 새 nginx.pid 를 쓴 뒤 워커를 띄운다. 두 세대의 워커가 같은 소켓에서 함께 받다가, SIGWINCH 를 받은 옛 마스터가 옛 워커에 QUIT 을 보내고, SIGQUIT 을 받으면 옛 마스터가 끝나고 새 마스터의 부모가 바뀐다. 옛 마스터를 남겨 두는 것은 되돌리기 위해서입니다. 문제가 있으면 옛 마스터에 HUP 을 보내 옛 워커를 다시 띄우고 새 마스터를 내립니다(4절 코드의 ngx_new_binary 분기).

ngx_exec_new_binary()

ngx_change_binary 를 본 마스터 루프는 ngx_exec_new_binary(cycle, ngx_argv) 를 부릅니다(284–288행). 리스닝 소켓을 넘기는 부분입니다.

    p = ngx_cpymem(var, NGINX_VAR "=", sizeof(NGINX_VAR));

    ls = cycle->listening.elts;
    for (i = 0; i < cycle->listening.nelts; i++) {
        if (ls[i].ignore) {
            continue;
        }
        p = ngx_sprintf(p, "%ud;", ls[i].fd);
    }

src/core/nginx.c 750–758행

NGINX_VAR 는 "NGINX" 입니다(src/core/nginx.h 22행). 그래서 환경 변수 NGINX=5; 처럼 리스닝 소켓의 fd 번호를 ; 로 이어 붙입니다. fd 는 execve() 를 넘어 살아남으므로(리스닝 소켓에는 FD_CLOEXEC 를 걸지 않습니다), 새 프로그램은 번호만 알면 같은 소켓을 씁니다. 그다음 pid 파일을 nginx.pid.oldbin 으로 바꾸고(799행), ngx_execute() 로 fork() + execve(argv[0], ...) 합니다(811행). ngx_execute() 는 ngx_spawn_process(..., NGX_PROCESS_DETACHED) 이므로 새 바이너리는 장부에 detached 로 적히고 채널도 없습니다.

새 바이너리의 main() 은 ngx_add_inherited_sockets() 에서 getenv("NGINX") 를 읽어 그 fd 들을 cycle->listening 에 inherited = 1 로 넣고, ngx_inherited = 1 을 세웁니다(src/core/nginx.c 481–538행). ngx_inherited 면 데몬이 되는 fork() 를 건너뛰므로(356–366행), 새 마스터는 옛 마스터의 자식으로 남습니다.

예제 5: USR2 로 바이너리 바꾸기

ex/u06-binary-upgrade 는 같은 바이너리를 "새 바이너리"로 삼아 순서를 따라갑니다. 처음에는 PATH 로 찾은 nginx 로, 다음에는 전체 경로로 띄웠습니다.

=== 1. started as 'nginx' (found through PATH), then USR2 ===
    PID    PPID CMD
     10       1 nginx: master process nginx -c /work/nginx.conf
     11      10 nginx: worker process
/tmp/u06b/nginx.pid: 10
2026/11/07 02:53:38 [notice] 10#10: signal 12 (SIGUSR2) received from 1, changing binary
2026/11/07 02:53:38 [notice] 10#10: changing binary
2026/11/07 02:53:38 [notice] 10#10: start new binary process 16
2026/11/07 02:53:38 [alert] 16#16: execve() failed while executing new binary process "nginx" (2: No such file or directory)
2026/11/07 02:53:38 [notice] 10#10: new binary process 16 exited with code 1
=== 2. started as /opt/nginx/sbin/nginx, then USR2 ===
--- kill -USR2 23
    PID    PPID CMD
     23       1 nginx: master process /opt/nginx/sbin/nginx -c /work/nginx.conf
     24      23 nginx: worker process
     29      23 nginx: master process /opt/nginx/sbin/nginx -c /work/nginx.conf
     30      29 nginx: worker process
/tmp/u06b/nginx.pid: 29
/tmp/u06b/nginx.pid.oldbin: 23
curl /hello: pid=24
curl /hello: pid=24
curl /hello: pid=30
curl /hello: pid=30
--- kill -WINCH 23  (old workers stop, old master stays)
    PID    PPID CMD
     23       1 nginx: master process /opt/nginx/sbin/nginx -c /work/nginx.conf
     29      23 nginx: master process /opt/nginx/sbin/nginx -c /work/nginx.conf
     30      29 nginx: worker process
curl /hello: pid=30
--- kill -QUIT 23  (old master exits)
    PID    PPID CMD
     29       1 nginx: master process /opt/nginx/sbin/nginx -c /work/nginx.conf
     30      29 nginx: worker process
/tmp/u06b/nginx.pid: 29
...
=== the default error log (/opt/nginx/logs/error.log) ===
2026/11/07 02:53:40 [notice] 29#29: using inherited sockets from "5;"

pid 와 시각은 실행마다 다릅니다. 가운데 error.log 전문은 ... 로 줄였습니다.

  1. nginx 라는 이름으로 띄우면 USR2 가 실패합니다. execve() 는 셸과 달리 PATH 를 뒤지지 않고, ctx.path = argv[0](732행)인 "nginx" 를 현재 디렉터리 기준 상대 경로로 찾습니다. 마스터의 현재 디렉터리(/tmp)에는 nginx 가 없으므로 No such file or directory 입니다. 실패한 새 바이너리 프로세스는 exit(1) 로 끝나고, 마스터는 ngx_reap_children() 에서 그것이 ngx_new_binary 였음을 알아보고 nginx.pid.oldbin 을 nginx.pid 로 되돌립니다(639–652행). 그래서 pid 파일은 nginx.pid: 10 하나뿐입니다. 바이너리 교체를 쓸 생각이면 nginx 를 전체 경로로 띄워야 합니다.
  2. 전체 경로로 띄우면 마스터가 둘이 됩니다. 새 마스터 29 의 부모는 옛 마스터 23 이고, pid 파일은 nginx.pid(29)와 nginx.pid.oldbin(23) 둘입니다. 이때 요청은 옛 워커 24 와 새 워커 30 이 나눠 받았습니다(어느 쪽이 몇 번 받는지는 실행마다 다릅니다) — 같은 소켓을 두 세대의 워커가 함께 accept() 하고 있습니다.
  3. WINCH 를 받은 옛 마스터는 워커만 내보내고 자신은 남습니다. ngx_noaccept → 워커에 SHUTDOWN(290–295행). ngx_noaccepting = 1 을 기억해 두었다가, 새 바이너리가 실패해 끝나면 ngx_restart 로 옛 워커를 다시 띄웁니다(659–662행). 되돌릴 길을 남겨 두는 것입니다.
  4. QUIT 을 받은 옛 마스터가 끝나자 새 마스터의 PPID 가 1 이 되었습니다. 부모가 끝난 프로세스는 다른 프로세스(여기서는 컨테이너의 1번)의 자식이 됩니다.
  5. using inherited sockets from "5;" 는 설정의 error.log 가 아니라 기본 로그 /opt/nginx/logs/error.log 에 찍혔습니다. ngx_add_inherited_sockets() 는 설정을 읽기 전에 불리므로, 그때의 로그는 configure 때 정한 기본 경로입니다. 막혔던 곳입니다 — 처음에는 설정의 error.log 만 보고 이 줄이 없어 소켓을 물려받지 못한 줄 알았습니다.

헷갈리기 쉬운 곳: 새 마스터에 USR2 를 또 보내면 무시됩니다

        case ngx_signal_value(NGX_CHANGEBIN_SIGNAL):
            if (ngx_getppid() == ngx_parent || ngx_new_binary > 0) {

                /* ... 주석: 옛 바이너리가 아직 돌면 새 바이너리에서, 새 바이너리가
                   이미 돌면 옛 바이너리에서 이 시그널을 무시한다 ... */

                action = ", ignoring";
                ignore = 1;
                break;
            }

src/os/unix/ngx_process.c 374–387행

새 마스터는 main() 에서 ngx_parent = ngx_getppid()(옛 마스터)를 적어 둡니다(src/core/nginx.c 236행). 부모가 아직 그대로면 옛 마스터가 살아 있다는 뜻이므로 USR2 를 무시하고, 옛 마스터 쪽도 이미 새 바이너리를 띄웠으면(ngx_new_binary > 0) 무시합니다. 세 세대가 겹치는 일을 막는 것입니다. 데몬으로 뜬 보통 마스터는 ngx_daemon() 이 ngx_parent 를 데몬이 되기 전 자기 pid 로 바꿔 두므로(ngx_daemon.c 29행), 실제 부모와 달라 USR2 가 받아들여집니다.

6. 워커의 첫 순간 — ngx_worker_process_init()

워커가 fork() 직후 이벤트 루프에 들어가기 전에 하는 일입니다. 권한을 내려놓는 부분입니다.

    if (geteuid() == 0) {
        if (setgid(ccf->group) == -1) {
            ngx_log_error(NGX_LOG_EMERG, cycle->log, ngx_errno,
                          "setgid(%d) failed", ccf->group);
            /* fatal */
            exit(2);
        }

        if (initgroups(ccf->username, ccf->group) == -1) {

            if (ngx_errno != NGX_EPERM) {
                /* ... "initgroups(%s, %d) failed" ... */
                exit(2);
            }

            /* ... "initgroups(%s, %d) failed, ignored" ... */
        }

        /* ... transparent 프록시용 PR_SET_KEEPCAPS ... */

        if (setuid(ccf->user) == -1) {
            ngx_log_error(NGX_LOG_EMERG, cycle->log, ngx_errno,
                          "setuid(%d) failed", ccf->user);
            /* fatal */
            exit(2);
        }

src/os/unix/ngx_process_cycle.c 833–872행

setgid() → initgroups() → setuid() 순서는 바꿀 수 없습니다. setuid() 로 root 를 버린 뒤에는 그룹을 바꿀 권한도 없기 때문입니다. 실패는 모두 exit(2) — 3절에서 본 "다시 띄우지 않는" 종료 코드입니다. 이 판의 기준 커밋이 바로 initgroups() 실패를 치명적으로 바꾼 커밋("Core: made initgroups() failures fatal")입니다. 이제는 EPERM(권한 없음)일 때만 alert 등급 로그("…failed, ignored")를 남기고 넘어가고, 그 밖의 실패는 워커를 끝냅니다.

fork() 직후의 워커는 아직 root(geteuid() == 0)다. setgid(ccf->group), initgroups(ccf->username, ccf->group), setuid(ccf->user) 순서로 권한을 내려놓으면 user 디렉티브의 사용자(예제 1에서는 nobody)로 도는 워커가 되고, 다시 root 가 될 수 없다. initgroups 가 EPERM 으로 실패하면 initgroups(…) failed, ignored 를 로그에 남기고 setuid 로 넘어간다. setgid 실패, EPERM 이 아닌 initgroups 실패, setuid 실패는 모두 exit(2) 로 끝나고, 마스터의 ngx_process_get_status() 는 exited with fatal code 2 and cannot be respawned 를 찍고 respawn = 0 으로 두어 그 워커를 다시 띄우지 않는다. 권한을 내려놓는 세 단계와 실패했을 때의 길. 빨간 칸의 끝은 3절에서 본 '종료 코드 2' 처리입니다.

ngx_worker_process_init() 전체의 순서는 이렇습니다(804–978행).

순서 하는 일 설정
1 setpriority(), setrlimit(RLIMIT_NOFILE·CORE) worker_priority, worker_rlimit_nofile, worker_rlimit_core
2 setgid → initgroups → setuid (위) user
3 ngx_setaffinity() — 워커 번호로 CPU 묶기 worker_cpu_affinity
4 chdir() working_directory
5 막아 둔 시그널을 모두 풀기 —
6 모든 모듈의 init_process 콜백 (이벤트 모듈이 여기서 epoll 과 연결 배열을 만든다 — 7편)
7 다른 자식의 channel[1], 자기의 channel[0] 닫기 —
8 자기 채널을 이벤트 루프에 등록 (ngx_channel_handler) —

6번이 중요합니다. 워커의 epoll 인스턴스와 worker_connections 개의 연결 배열은 마스터가 아니라 각 워커가 init_process 에서 따로 만듭니다(ngx_event_process_init(), 7편). 워커끼리 나누는 것은 리스닝 소켓과 공유 메모리 zone 뿐이고, 연결과 이벤트는 워커마다 따로입니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
마스터 root 로 설정을 읽고 소켓을 열고 워커를 띄운다. 연결은 받지 않고 sigsuspend() 에서 시그널만 기다린다
워커 fork() 로 태어나 권한을 내려놓고 ngx_worker_process_cycle() 의 이벤트 루프를 돈다
ngx_processes[] 마스터의 자식 장부. respawn·just_spawn·exiting·exited·detached 비트로 누구를 되살리고 누구에게 QUIT 을 보낼지 정한다
시그널 핸들러 ngx_reconfigure 같은 sig_atomic_t 플래그만 세운다. 실제 일은 루프가 한다
sigsuspend() 평소에 막아 둔 시그널을 잠든 동안만 풀어, 플래그 확인과 잠들기 사이의 틈을 없앤다
채널 워커마다 socketpair() 한 쌍. 마스터는 QUIT·TERMINATE·REOPEN 을 시그널 대신 32바이트 메시지로 보낸다
SIGCHLD waitpid() 를 되풀이해 끝난 자식을 모두 거두고, respawn 이고 exiting 이 아니면 같은 칸에 다시 띄운다. 종료 코드 2는 되살리지 않는다
reload 새 cycle → 새 워커(just_spawn) → 100ms → 옛 워커에만 QUIT. 새 설정이 틀리면 옛 cycle 그대로
graceful 종료 리스닝 소켓과 노는 연결을 닫고, 취소할 수 없는 타이머가 없어지면 나간다. 처리 중 응답에는 Connection: close
worker_shutdown_timeout 기본 0(끔). 주면 그 시간 뒤 남은 연결을 강제로 닫는다
바이너리 교체 USR2 → 환경 변수 NGINX 로 소켓 fd 를 넘겨 execve(argv[0]), WINCH 로 옛 워커 정리, QUIT 으로 옛 마스터 종료

다음에 볼 것

워커가 ngx_worker_process_cycle() 에 들어서는 데까지 왔습니다. 그 루프의 한 줄, ngx_process_events_and_timers(cycle) 안에서는 epoll 로 수천 개의 연결을 지켜보고, 레드블랙 트리의 타이머를 처리하고, 새 연결을 accept() 합니다. 이 글에서 "기다릴 타이머"라고만 하고 넘어간 타이머 트리, 채널이 끼어 있던 바로 그 이벤트 루프가 주인공입니다. 7편: 이벤트 루프 — epoll, 타이머, posted 이벤트, accept에서 이어집니다.

httpd 연재의 8편: MPM은 같은 일 — 부모가 자식을 띄우고, 세고, 내보내는 일 — 을 다르게 합니다. httpd 의 부모는 1초마다 스코어보드(공유 메모리 상태판)를 보며 자식 수를 늘리고 줄이고, 자식을 내보낼 때는 POD(pipe of death)라는 파이프에 한 글자를 씁니다. nginx 의 마스터는 워커 수를 고정해 두고 시그널이 올 때만 깨며, 내보낼 때는 워커마다 따로 있는 채널에 명령을 씁니다. 파이프로 "그만두라"를 알리는 생각은 닮았고, 누가 그만둘지를 httpd 는 먼저 읽는 자식에게 맡기고 nginx 는 마스터가 하나하나 정한다는 점이 다릅니다. graceful 재시작에서 설정을 다시 읽는 httpd 쪽 이야기는 httpd 5편: 기동과 main()에 있습니다.

다른 글