Skip to content

Latest commit

 

History

History
150 lines (112 loc) · 14.4 KB

File metadata and controls

150 lines (112 loc) · 14.4 KB

v7.1 — Linux Kernel pidfd 기반 프로세스 생명주기 관리: CLONE_AUTOREAP와 CLONE_PIDFD_AUTOKILL

메타데이터

항목 내용
프로젝트 Linux Kernel
관련 릴리스 7.1 (2026-06-14 tag) — 기능 자체는 7.1 머지 윈도우에서 vfs-7.1-rc1.pidfs 태그로 2026-04-13에 mainline에 들어감
관련 커밋 12ae2c81 — clone: add CLONE_AUTOREAP, 24baca56 — clone: add CLONE_NNP, c8134b5f — pidfd: add CLONE_PIDFD_AUTOKILL, 병합 커밋 a2900f5a
작성자/리뷰어 Christian Brauner(VFS/pidfd 메인테이너, 제안 및 구현), Oleg Nesterov(리뷰)
계기가 된 이슈 uapi-group/kernel-features#45 — Child PID auto-reaping (or equivalent) (2026-02-02, 이식 가능한 C++ I/O 라이브러리 개발자가 제기)
발견 출처 kernelnewbies.org Linux_7.1 "Process management" 항목, LWN.net: As ye clone(), so shall ye AUTOREAP
검증 근거 torvalds/linux 실제 커밋 diff(kernel/fork.c, kernel/signal.c, kernel/ptrace.c, fs/pidfs.c, include/uapi/linux/sched.h, include/uapi/linux/pidfd.h), 병합 커밋 메시지, uapi-group 이슈 원문

개요

유닉스 계열 프로세스 모델에서 자식 프로세스가 종료되면 부모가 wait()로 회수(reap)하기 전까지 "좀비" 상태로 남는다. 부모가 이를 잊으면 좀비가 쌓이는데, 지금까지 커널이 제공한 유일한 자동 회수 수단은 SIGCHLDSIG_IGN으로 설정하는 것뿐이었고 이는 그 부모의 모든 자식에게 일괄 적용됐다. 서브프로세스를 만드는 라이브러리 입장에서는 자신이 만든 자식만 선택적으로 자동 회수하고 싶어도 이 전역 스위치로는 불가능했다. Linux 7.1은 clone3()CLONE_AUTOREAP 플래그를 추가해 이 문제를 자식 하나 단위로 풀고, 여기에 CLONE_NNP·CLONE_PIDFD_AUTOKILL을 더해 "pidfd를 닫으면 자식이 즉시 죽는다"는 컨테이너/서비스 매니저용 생명주기 관리 메커니즘까지 함께 도입했다.

사전 지식

좀비 프로세스와 wait()/SIGCHLD 기반 회수

프로세스가 exit()하면 커널은 그 자리에서 태스크 구조체를 곧바로 없애지 않는다. 부모가 나중에 wait()/waitpid()로 종료 상태(exit code, 시그널 등)를 읽어갈 수 있어야 하기 때문에, 자식은 커널 태스크 리스트에 "좀비"(EXIT_ZOMBIE) 상태로 남아 최소한의 정보만 유지한 채 대기한다. 부모가 wait()를 호출하면 그 정보를 넘겨받고 나서야 커널이 태스크를 완전히 해제(EXIT_DEAD → 회수)한다. 부모가 wait()를 영영 호출하지 않으면 좀비는 그 부모가 죽을 때까지(또는 프로세스 테이블이 찰 때까지) 남는다.

이 부담을 피하려는 부모는 전통적으로 두 가지 중 하나를 쓴다: SIGCHLDSIG_IGN으로 설정하거나, sigaction()SA_NOCLDWAIT를 지정한다. 둘 다 커널이 자식 종료 시 좀비 상태를 건너뛰고 즉시 회수하게 만들지만, 이 설정은 그 프로세스가 앞으로 낳는 모든 자식에 적용되는 프로세스 전역 속성이다 — 특정 자식만 선택적으로 자동 회수하고 다른 자식은 wait()로 직접 관리하는 것이 불가능하다.

pidfd와 clone3(): 파일 디스크립터로 프로세스를 다루기

pidfd(PID file descriptor)는 PID 대신 프로세스를 가리키는 파일 디스크립터다. CLONE_PIDFD 플래그로 clone3()을 호출하면 커널이 새 프로세스의 pidfd를 돌려주는데, 이 fd는 poll()로 자식의 종료를 감시할 수 있고(PID 재사용 경쟁 없이 그 프로세스만 정확히 가리킴), pidfd_send_signal()로 시그널을 보낼 수 있고, PIDFD_GET_INFO ioctl로 종료 상태를 읽을 수 있다. clone3()은 구버전 clone() 시스템 콜과 달리 struct clone_args를 통해 플래그를 64비트 폭으로 확장 가능하게 설계돼 있어, 새 동작을 추가할 때 새 플래그 비트 하나만 정의하면 된다 — 이번 변경의 CLONE_AUTOREAP(1ULL << 34), CLONE_NNP(1ULL << 35), CLONE_PIDFD_AUTOKILL(1ULL << 36)도 이 방식으로 추가됐다.

변경 분석

변경 전: 자동 회수는 부모 전체에 걸리는 전역 스위치였다

[변경 전] SIGCHLD 기반 auto-reap: 프로세스 전역, 자식 단위 선택 불가

부모 프로세스
 ├─ signal->action[SIGCHLD] = SIG_IGN  (또는 SA_NOCLDWAIT)
 │        │
 │        ▼  이 설정은 "이 부모가 낳는 모든 자식"에 적용됨
 │
 ├─ 자식 A (라이브러리가 관리하고 싶은 프로세스) ──exit()──▶ 즉시 회수(회피 불가)
 ├─ 자식 B (애플리케이션이 wait()로 직접 관리하려던 프로세스) ──exit()──▶ 마찬가지로 즉시 회수됨
 │                                                              (SIGCHLD도 안 옴, wait()도 -ECHILD)
 └─ 자식 C ...

문제: 라이브러리가 "내가 만든 프로세스만" 자동 회수하려 해도
      SIG_IGN/SA_NOCLDWAIT은 그 부모의 다른 컴포넌트(다른 라이브러리, 앱 코드)가
      만든 자식에도 그대로 적용된다. 반대로 앱이 SIGCHLD 핸들러를 쓰면
      라이브러리의 자동 회수 의도가 깨진다 — 전역 스위치라 공존이 불가능하다.

uapi-group/kernel-features#45(2026-02-02)에서 이식 가능한 C++ I/O 라이브러리 개발자가 지적한 것이 정확히 이 문제였다: "라이브러리는 전역 시그널 핸들러 같은 걸 쓸 수 없다 — 그러면 자신이 만들지 않은 자식에도 영향을 준다. 그렇다고 회수 책임을 사용자에게 떠넘길 수도 없다." 이 요구를 만족하려면 지금까지는 별도 스레드를 만들어 epoll로 자식들의 pidfd를 감시하며 회수하는 우회책을 써야 했다.

변경 후: signal_struct에 자식별 autoreap 비트를 저장

Christian Brauner가 작성한 12ae2c81("clone: add CLONE_AUTOREAP")은 struct signal_struct(include/linux/sched/signal.h)에 1비트 플래그를 추가한다.

/* include/linux/sched/signal.h */
unsigned int  is_child_subreaper:1;
unsigned int  has_child_subreaper:1;
unsigned int  autoreap:1;   /* 새로 추가 */

clone3() 호출 시 CLONE_AUTOREAP이 지정되면 kernel/fork.ccopy_process()가 이 플래그를 그 자식 하나의 signal_struct에만 기록한다 — 부모의 시그널 핸들러 설정은 전혀 건드리지 않는다. 자식이 종료할 때 kernel/signal.cdo_notify_parent()가 이 플래그를 확인한다:

/* kernel/signal.c, do_notify_parent() */
if (!tsk->ptrace && tsk->signal->autoreap) {
        autoreap = true;
        sig = 0;   /* SIGCHLD를 보내지 않음 */
}

autoreap == true가 되면 exit_notify() 경로가 좀비 상태를 거치지 않고 태스크를 곧바로 EXIT_DEAD로 전이시킨다. SIGCHLD도 보내지 않으므로(exit_signal이 0이어야만 이 플래그를 쓸 수 있도록 강제됨) 부모는 wait()를 호출할 필요도, 호출할 수도 없다(-ECHILD가 반환됨) — 대신 CLONE_PIDFD를 함께 지정했다면 그 pidfd를 poll()하고 PIDFD_GET_INFO로 종료 상태를 읽으면 된다.

이 플래그는 자식의 signal_struct에 저장되므로 원래 부모가 먼저 죽어서 자식이 subreaper나 init으로 재부모화(reparent)되더라도 그대로 유지된다 — 재부모화된 곳이 다시 SIGCHLD를 받고 회수해야 하는 번거로움 없이 자식이 스스로 자동 회수된다. copy_process()CLONE_THREAD(자동회수는 프로세스 단위 속성이므로 스레드에는 의미 없음)와 CLONE_PARENT(만약 autoreap 자식이 CLONE_PARENT로 손자를 낳으면 그 손자가 exit_signal == 0은 물려받으면서 autoreap 플래그는 못 물려받아 "시그널도 안 보내고 회수도 안 되는" 깨진 좀비가 될 수 있음) 조합을 -EINVAL로 거부한다. kernel/ptrace.c__ptrace_detach()에도 동일한 autoreap 검사가 추가돼, ptrace로 추적 중인 자식에 대해서도 일관되게 동작한다.

설계 결정 및 트레이드오프

CLONE_PIDFD_AUTOKILLCLONE_PIDFDCLONE_AUTOREAP을 둘 다 요구하는가: c8134b5f("pidfd: add CLONE_PIDFD_AUTOKILL")는 clone3()이 돌려준 pidfd의 마지막 참조가 닫히는 순간(fs/pidfs.c.release 핸들러) 자식에게 SIGKILL을 보낸다. CLONE_PIDFD가 없으면 애초에 생명주기를 묶을 fd 자체가 없고, CLONE_AUTOREAP이 없으면 SIGKILL로 죽은 자식이 회수해줄 사람 없이 좀비로 남는다 — autokill의 주 사용 사례(부모 프로세스가 크래시하거나 pidfd를 방치)는 애초에 회수해줄 부모가 없는 상황이므로, autoreap이 빠지면 이 기능은 좀비를 양산하는 기능이 돼버린다.

/* fs/pidfs.c, pidfs_file_release() */
static int pidfs_file_release(struct inode *inode, struct file *file)
{
        ...
        if (!(file->f_flags & PIDFD_AUTOKILL))
                return 0;
        ...
        do_send_sig_info(SIGKILL, SEND_SIG_PRIV, task, PIDTYPE_TGID);
        return 0;
}

주목할 점은 이 동작이 clone3()이 만든 그 pidfd 파일 하나에만 묶인다는 것이다. 같은 프로세스를 가리키는 다른 pidfd(예: pidfd_open()으로 나중에 딴 것)는 이 파일과 별개의 struct file이라 PIDFD_AUTOKILL 플래그를 갖지 않으므로 그 fd를 닫아도 자식은 죽지 않는다 — dup()이나 fork()로 같은 fd를 공유한 경우에만(같은 struct file을 참조하므로) 마지막 참조가 사라질 때까지 생명이 연장된다. PIDFD_AUTOKILL은 실제로는 pidfd 파일에는 쓰이지 않는 open() 플래그인 O_TRUNC 비트를 내부용으로 재사용하며, PIDFD_STALE/PIDFD_THREAD/PIDFD_NONBLOCK과 비트가 겹치지 않는지 BUILD_BUG_ON(hweight32(...) != 4)로 컴파일 타임에 강제한다.

권한 모델을 CLONE_NNP 유무로 가른 이유: CLONE_PIDFD_AUTOKILLCLONE_NNP(child에 no_new_privs를 강제) 없이 쓰려면 호출자가 자신의 유저 네임스페이스에서 CAP_SYS_ADMIN을 가져야 한다. CLONE_NNP와 함께 쓰면 비특권 사용자도 허용된다 — no_new_privs가 설정된 자식은 setuid/setgid 실행으로도 권한을 절대 상승시킬 수 없으므로, 예기치 않게 SIGKILL당하는 상황이 권한 상승 경로로 악용될 여지가 없기 때문이다. 커밋 메시지는 이를 기존의 PR_SET_PDEATHSIG(부모 사망 시그널) 메커니즘과 명시적으로 대비한다 — pdeath_signalsecureexec/commit_creds() 중에 리셋돼버려서, 스스로 권한을 낮추는(deprivilege) 컨테이너 런타임에는 애초에 쓸모가 없었다. CLONE_PIDFD_AUTOKILL은 이 구멍을 pidfd 소유권 기반 모델로 대체한 것이다.

[변경 후] pidfd에 생명주기를 묶어 "부모가 사라지면 자식도 죽는다"

부모(컨테이너 런타임 / 서비스 매니저)
 │
 │ clone3(CLONE_PIDFD | CLONE_AUTOREAP | CLONE_PIDFD_AUTOKILL [| CLONE_NNP])
 ▼
 ┌───────────────┐        signal->autoreap = 1
 │ 자식 프로세스    │◀────── exit_signal = 0 (SIGCHLD 없음)
 │ (워크로드)      │
 └───────┬───────┘
         │ 정상 종료
         ▼
   do_notify_parent(): autoreap=1 → 좀비 상태 건너뛰고 EXIT_DEAD
   (부모는 pidfd를 poll()하고 PIDFD_GET_INFO로 종료 상태 확인)

 부모가 pidfd를 닫음(크래시/방치/의도적 종료)
         │
         ▼
   pidfs_file_release(): PIDFD_AUTOKILL 플래그 확인
         │
         ▼
   do_send_sig_info(SIGKILL) → 자식 즉시 종료
         │
         ▼
   autoreap=1이므로 회수해줄 부모 없이도 좀비로 남지 않고 바로 정리됨

초기 구현이 커버하지 않는 것: CLONE_AUTOREAP은 명시적으로 opt-in이며 기존 wait()/SIGCHLD 기반 코드와는 아무 상호작용이 없다 — 즉 기존 프로그램의 동작은 전혀 바뀌지 않는다(하위 호환에 대한 트레이드오프가 없는 순수 추가 기능). 대신 그만큼 "이미 짜여진 자식 관리 로직을 자동으로 최적화해주는" 효과도 없다 — 라이브러리/애플리케이션이 코드를 고쳐서 이 플래그를 명시적으로 요청해야만 혜택을 받는다.

더 살펴볼 점

  • glibc의 posix_spawn()이나 libuv 같은 널리 쓰이는 프로세스 스폰 라이브러리들이 CLONE_AUTOREAP을 실제로 채택하기까지 얼마나 걸릴까?
  • CLONE_PIDFD_AUTOKILL이 systemd 같은 서비스 매니저의 기존 cgroup 기반 정리 메커니즘과 함께 쓰일 때 두 메커니즘이 경쟁하거나 중복 종료를 시도하는 경우는 없을까?
  • 재부모화(reparenting)된 autoreap 자식이 subreaper 쪽에서 종료를 알아야 하는 경우(예: 상태 로깅)를 위한 prctl() 확장이, 커밋 메시지가 언급한 대로 실제로 뒤따라 나올까?

참고 자료

참고 외 별도 확인 링크

본문 참고 자료로 충분했다.