QUESTION
현대 Linux 서비스도 반드시 double-fork해야 하는가?
double-fork는 controlling terminal과 session에서 분리되고 orphan을 init에 맡기던 실행 환경의 관례다. service manager가 PID, cgroup, stdout/stderr, restart를 관리하는 환경에서는 self-daemonize가 오히려 main PID 추적과 readiness 판단을 어렵게 한다.
안정적인 service는 foreground에서 실행하며 시작 완료, 종료 요청, child 회수, log fd 정책을 supervisor와 명시적으로 맞춘다. daemon이라는 형태보다 누가 process lifetime을 소유하는지가 중요하다.
STRUCTURE
구조 그림
Main process
- foreground PID
- signalfd
- shutdown owner
Inherited resources
- listener fd
- stdout/stderr
- configuration fd
Workers
- child pidfd
- active request count
- waitid 회수
Stop contract
- SIGTERM
- drain deadline
- exit status
foreground service는 supervisor와 관계를 끊지 않는다. main PID, cgroup, listener, log, readiness가 한 관리 단위에 남는다.
CALL PATH
호출 흐름
시작과 종료를 함수 내부 flag가 아니라 supervisor와 교환하는 관찰 가능한 사건으로 만든다. ready 전에 요청을 받지 않고, TERM 뒤 새 작업을 수락하지 않는다.
함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.
SOURCE COORDINATES
Linux 6.18.37 LTS 소스 위치
glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.
| 파일 | 함수·구조체 | 여기서 볼 것 |
|---|---|---|
| kernel/signal.c | do_send_sig_info(), get_signal() | SIGTERM 같은 종료 요청 전달 |
| kernel/exit.c | do_exit(), do_wait() | service와 worker 종료 및 회수 |
| kernel/cgroup/cgroup.c | cgroup_attach_task_all() | supervisor가 process tree를 cgroup 단위로 추적 |
COMPLETE PROGRAM
실행 예제 원본
아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.
cc -std=c17 -Wall -Wextra -O2 service_loop.c -o service_loop01#define _GNU_SOURCE
02#include <signal.h>
03#include <stdio.h>
04#include <sys/signalfd.h>
05#include <unistd.h>
06
07int main(void)
08{
09 sigset_t mask;
10 sigemptyset(&mask);
11 sigaddset(&mask, SIGTERM);
12 sigaddset(&mask, SIGINT);
13 if (sigprocmask(SIG_BLOCK, &mask, NULL) < 0)
14 return 1;
15
16 int sfd = signalfd(-1, &mask, SFD_CLOEXEC);
17 if (sfd < 0)
18 return 1;
19 puts("READY");
20 fflush(stdout);
21
22 struct signalfd_siginfo info;
23 if (read(sfd, &info, sizeof(info)) != sizeof(info))
24 return 1;
25 printf("stopping on signal %u\n", info.ssi_signo);
26 close(sfd);
27 return 0;
28}
CODE NOTES
코드 조각별 설명
sigaddset(&mask, SIGTERM)service manager의 정상 종료 요청을 async handler가 아니라 event fd로 받기 위해 먼저 대상 signal set을 만든다.
sigprocmask(SIG_BLOCKsignalfd를 만들기 전에 signal을 block해 생성 사이에 default action으로 종료되는 창을 없앤다. multithread라면 pthread_sigmask를 초기 thread에서 적용한다.
SFD_CLOEXECworker exec 시 supervisor용 signal fd가 우발적으로 상속되지 않게 한다.
puts("READY")예제에서는 stdout line이 readiness protocol이다. 실제 환경에서는 sd_notify, pipe close, socket activation 등 supervisor가 이해하는 방법을 쓴다.
read(sfd, &infosignal을 고정 크기 record로 소비한다. 여러 signal이 pending이면 반복해서 read해야 한다.
DETAILS
세부 동작
readiness와 process 존재는 다르다
fork/exec 성공은 config parse, socket bind, database recovery가 끝났다는 뜻이 아니다. supervisor가 단순히 PID 존재만 보고 traffic을 보내면 startup race가 생긴다.
ready notification은 필요한 resource를 확보하고 request 처리 loop가 실제로 동작할 수 있는 지점에서 한 번 보낸다.
shutdown에는 순서가 있다
SIGTERM을 받으면 listener에서 새 요청을 막고, 진행 중 작업에 deadline을 주고, child를 회수하고, durable data를 flush한 뒤 종료한다. signal handler에서 이 모든 작업을 직접 수행하지 않는다.
강제 종료 SIGKILL은 cleanup 코드를 실행하지 않으므로 supervisor timeout은 데이터 손실 허용 범위와 맞춘다.
fd ownership을 문서화한다
socket activation이나 supervisor pipe를 쓰면 fd는 exec 전부터 열려 있다. 번호를 가정하기보다 LISTEN_FDS 같은 protocol과 CLOEXEC 상태를 확인한다.
log rotation은 pathname을 바꾸는 것만으로 이미 열린 file description을 바꾸지 않는다. SIGHUP 처리나 journald/stdout 소유 모델을 정한다.
OBJECTS
객체와 수명
| 대상 | 언제 생기고 없어지는가 | 확인할 값 |
|---|---|---|
main process | supervisor가 exec하고 exit status까지 추적한다 | PID/cgroup, readiness, watchdog |
listener fd | service가 bind하거나 supervisor에서 상속받는다 | CLOEXEC, accept ownership |
shutdown deadline | TERM 수신 때 시작하고 cleanup 완료 또는 강제 종료까지 유지한다 | monotonic expiry, active work count |
FAILURE PATH
실패 조건과 오해하기 쉬운 부분
| 겉으로 보이는 현상 | 실제 원인 후보 | 확인 방법 |
|---|---|---|
| 서비스는 active인데 요청 실패 | readiness 전에 traffic 전달 | ready 시점과 listener 상태 |
| stop이 오래 걸림 | 새 작업 계속 수락 또는 worker 미회수 | accept gate, waitid loop, deadline |
| 재시작 후 port in use | 이전 child/cgroup이 listener 보유 | ss -lptn, /proc/PID/fd |
LAB
직접 확인
- 프로그램을 실행하고 다른 terminal에서 SIGTERM을 보내 signalfd read와 정상 exit status를 확인한다.
- READY 전 3초 sleep을 넣고 supervisor가 process start와 readiness를 구분하도록 작은 parent를 작성한다.
- listener fd를 parent가 만들고 exec한 child에 넘겨 socket activation의 fd lifetime을 확인한다.
./service_loopstrace -e trace=rt_sigprocmask,signalfd4,poll,read,exit_group ./service_loopPRIMARY REFERENCES