Linux 커널의 pidfd가 무엇인지, 어떻게 얻는지, 그리고 어떤 작업에 사용할 수 있는지를 정리한 개요.
corsix.org에 2024년 5월 16일 게시됨
최근 버전의 Linux 커널에서 pidfd는 프로세스를 참조하는 특별한 종류의 파일이다. 특히 pidfd를 사용하면 특정 프로세스 관련 작업을 경쟁 조건 없이 수행할 수 있고, poll / select / epoll을 사용해 프로세스 종료를 감지할 수 있다.
너무 기대하기 전에:
wait / waitpid / waitid를 통해 프로세스의 종료 코드 / 상태를 가져오는 동작의 최대 한 번 의미론을 우회할 수 없다.pidfd를 얻는 방법은 여러 가지가 있다:
| 커널 버전 | glibc 버전 | 함수 |
|---|---|---|
| 5.2 | 2.2.5 / 2.31 | clone with CLONE_PIDFD flag |
| 5.3 | N/A | clone3 with CLONE_PIDFD flag |
| 5.3 / 5.10 | 2.36 | pidfd_open |
| 5.4 | 2.39 | pidfd_spawn / pidfd_spawnp |
| 6.5 | 2.2.5 / N/A | getsockopt with SO_PEERPIDFD optname |
| 6.5 | 2.2.5 / 2.39 | recvmsg with SCM_PIDFD cmsg_type |
pidfd를 손에 넣고 나면, 그것으로 할 수 있는 일도 꽤 많다:
| 커널 버전 | glibc 버전 | 함수 |
|---|---|---|
| 5.1 | 2.36 | pidfd_send_signal |
| 5.2 / 5.5 | 2.39 | pidfd_getpid |
| 5.3 | 2.2.5 / 2.3.2 | poll / select / epoll |
| 5.4 | 2.2.5 / 2.36 | waitid with P_PIDFD mode |
| 5.6 | 2.36 | pidfd_getfd |
| 5.8 | 2.14 | setns |
| 5.10 / 5.12 | 2.36 | process_madvise |
| 5.15 | 2.36 | process_mrelease |
| 6.9 | 2.2.5 / 2.28 | fstat / statx for meaningful stx_ino |
이어지는 일부 설명에서는 프로세스가 alive, zombie, dead 상태라고 말한다. 이 용어들은 유닉스 프로세스의 일반적인 생명주기에서 온 것이다. 프로세스는 처음에 alive 상태이고, 종료되면 zombie 상태로 전이되며, 누군가가 이를 기다린 뒤에는 dead 상태로 전이된다. 상태를 빠르게 요약하면 다음과 같다:
| Alive | Zombie | Dead | |
|---|---|---|---|
| 코드를 실행하고 시그널을 받을 수 있음 | ✅ | ❌ | ❌ |
| pid 번호를 가짐 | ✅ | ✅ | ❌ |
| 종료 코드 / 상태를 가져올 수 있음 | ❌ | ✅ | ❌ |
| pidfd가 읽기 가능으로 poll됨 | ❌ | ✅ | ✅ |
| 커널에 의해 정리됨 | ❌ | ❌ | ✅ |
사용 가능 시점: 커널 5.2, glibc 2.31 (CLONE_PIDFD를 직접 정의하면 glibc 2.2.5에서도 가능하며, 값은 0x1000이다).
CLONE_PIDFD 플래그를 지정하면 clone은 자식을 가리키는 새로 할당된 pidfd를 반환한다(자식의 pid 번호를 반환하는 것에 추가로). 반환되는 pidfd에는 O_CLOEXEC 플래그가 자동으로 설정된다. CLONE_PIDFD가 지정된 경우 CLONE_THREAD는 지정할 수 없고, CLONE_DETACHED도 지정할 수 없다. 또한 CLONE_PIDFD가 지정된 경우 CLONE_PARENT_SETTID도 지정할 수 없다(clone3를 사용하는 경우는 제외).
clone의 인자 중 하나는 자식이 종료될 때 부모에게 보낼 시그널 번호이다. 이를 SIGCHLD가 아닌 다른 값으로 설정하면 여러 결과가 따른다:
wait 호출은 그 자식을 인식하지 못한다.waitpid / waitid 호출은 __WALL 또는 __WCLONE 옵션이 전달된 경우에만 그 자식을 인식한다(P_PIDFD 호출에서도 마찬가지다).SIGCHLD 핸들러가 SIG_IGN이거나 SA_NOCLDWAIT를 가지고 있더라도, 누군가가 기다릴 때까지 그 상태에 머문다.SIGCHLD가 아닌 다른 시그널이 보내진다(종료 시그널이 0으로 설정되어 있으면 아무 시그널도 보내지지 않는다).자식이 execve(또는 비슷한 exec 함수)를 호출하면 종료 시그널 번호는 SIGCHLD로 재설정되고, 위의 사항들은 더 이상 적용되지 않는다는 점에 유의하자.
사용 가능 시점: 커널 5.3, glibc 래퍼 없음.
이 함수는 단지 clone의 더 확장 가능한 버전일 뿐이며, 위에서 clone에 대해 설명한 모든 내용이 clone3에도 똑같이 적용된다.
사용 가능 시점: 커널 5.3, glibc 2.36.
이 함수는 pid 번호 하나를 받으며(호출자의 pid 네임스페이스 기준), 해당 프로세스를 가리키는 새로 할당된 pidfd를 반환한다(해당 프로세스가 존재하지 않으면 오류 반환). 넘기는 pid 번호가 getpid의 결과가 아닌 한, 이 함수는 본질적으로 경쟁 조건에 취약하다(즉 자기 자신의 프로세스를 가리키는 pidfd를 만드는 경우는 예외).
커널 5.10부터는 PIDFD_NONBLOCK 플래그를 pidfd_open에 전달할 수 있으며, 이는 이후의 waitid 호출에 영향을 준다. 다른 플래그는 유효하지 않다. 반환되는 pidfd에는 O_CLOEXEC 플래그가 자동으로 설정된다.
사용 가능 시점: 커널 5.4, glibc 2.39.
이 함수들은 posix_spawn / posix_spawnp와 비슷하지만, pid 번호를 위한 pid_t* 출력 매개변수 대신 새로 할당된 pidfd를 위한 int* 출력 매개변수를 가진다. 반환되는 pidfd에는 O_CLOEXEC 플래그가 자동으로 설정된다.
glibc 2.39에서는 버그 BZ#31695 때문에 일부 오류 시나리오에서 파일 디스크립터가 누수된다. 이 문제는 아마 2.40에서 수정될 것이다.
getsockopt with SO_PEERPIDFD optname
사용 가능 시점: 커널 6.5, getsockopt에 대해서 glibc 2.2.5. SO_PEERPIDFD의 정의는 특정 glibc 버전에 묶여 있지 않으며, 직접 정의해야 한다면 값은 77이다.
SO_PEERPIDFD는 SO_PEERCRED의 pidfd 버전이다. socketpair로 생성된 유닉스 소켓의 경우 SO_PEERPIDFD는 socketpair를 호출한 프로세스를 가리키는 pidfd를 돌려준다. 한편 연결된 유닉스 스트림 소켓에서는 SO_PEERPIDFD가 connect를 호출한 프로세스를 가리키는 pidfd를 돌려준다(소켓의 서버 쪽에서 호출한 경우). 또는 클라이언트 쪽에서 호출하면 listen을 호출한 프로세스를 가리키는 pidfd를 돌려준다. 반환되는 pidfd에는 O_CLOEXEC 플래그가 자동으로 설정된다.
recvmsg with SCM_PIDFD cmsg_type
사용 가능 시점: 커널 6.5, glibc 2.39 (SCM_PIDFD를 직접 정의하면 glibc 2.2.5에서도 가능하며, 값은 0x04이다).
SCM_PIDFD는 SCM_CREDENTIALS의 (pid 부분에 대한) pidfd 버전이다. 수신자가 유닉스 소켓에 SO_PASSPIDFD를 설정하면(SO_PASSCRED를 설정하는 것과 비슷함), 메시지를 수신할 때 SCM_PIDFD cmsg도 함께 받게 된다. 여기서 연관된 cmsg 데이터는 메시지 발신자 프로세스를 가리키는 새로 할당된 pidfd이다(단, 발신자가 CAP_SYS_ADMIN을 가지고 있고 자신의 pid가 아닌 다른 pid 번호를 SCM_CREDENTIALS의 일부로 지정한 경우에는 다른 프로세스를 가리킬 수 있다). pidfd에는 O_CLOEXEC 플래그가 자동으로 설정된다.
사용 가능 시점: 커널 5.1, glibc 2.36.
이 함수는 kill / rt_sigqueueinfo와 비슷하게 프로세스에 시그널을 보낸다. 차이점은 대상이 pid 번호가 아니라 pidfd로 주어진다는 점이다.
이 함수는 open("/proc/$pid")의 결과 fd도 받을 수 있지만, 그렇게 할 수 있는 함수는 이것뿐이다. open("/proc/$pid")는 pidfd를 주는 것이 아니며, 다른 어떤 함수도 pidfd 대신 open("/proc/$pid")의 결과를 받지 않는다.
사용 가능 시점: 커널 5.2, glibc 2.39.
이 함수는 pidfd_open의 역연산이다. pidfd가 주어지면 기반 프로세스와 연관된 pid 번호를 반환한다. 이 함수는 /proc가 마운트되어 있어야 하며, 마운트된 /proc에 연관된 pid 네임스페이스 기준의 pid 번호를 반환한다. 기반 프로세스가 dead 상태가 되면 그 pid 번호는 다른 프로세스에 재사용될 수 있다는 점에 유의하자.
커널 5.5에서 변경: pidfd가 참조하는 프로세스가 dead 상태라면 이 함수는 -1을 반환한다(5.5 이전에는 프로세스가 죽기 전에 갖고 있던 pid 번호를 그대로 반환했다).
이것은 직접적인 시스템 호출이 아니라는 점도 유의하자. 대신 /proc/self/fdinfo/$pidfd를 열고 그 안의 Pid: 줄을 파싱한다.
사용 가능 시점: 커널 5.3, glibc 2.2.5(poll / select) 또는 glibc 2.3.2(epoll).
이 함수들은 pidfd를 비동기적으로 감시하는 데 사용할 수 있다. 기반 프로세스가 zombie 상태이거나 dead 상태인 경우에만 pidfd가 읽기 가능한 것으로 보고된다. 다만 pidfd에 대해 read를 호출하는 것은 항상 실패한다. 프로세스의 종료 코드 / 상태를 얻으려면 waitid를 사용하라(WNOHANG를 함께 쓰는 것도 가능하다).
사용 가능 시점: 커널 5.4, glibc 2.36 (P_PIDFD를 직접 정의하면 glibc 2.2.5에서도 가능하며, 값은 3이다).
waitid(P_PIDFD, fd, infop, options)는 waitid(P_PID, pidfd_getpid(fd), infop, options)와 동일하지만, 다음 차이점이 있다:
pidfd_getpid 호출이 waitid의 일부로 원자적으로 수행되므로 경쟁 조건이 없다.pidfd_getpid 호출은 /proc가 마운트되어 있을 필요가 없다.PIDFD_NONBLOCK 플래그와 함께 열렸고, options에 WNOHANG가 포함되지 않았으며, pidfd가 참조하는 프로세스가 alive 상태라면, waitid는 블로킹하는 대신 EAGAIN으로 실패한다. options에 WNOHANG가 포함된 경우에는 PIDFD_NONBLOCK가 아무 효과가 없다는 점에 유의하자. pidfd가 참조하는 프로세스가 alive 상태이면, waitid는 블로킹하는 대신 결과 0으로 성공한다.특히 다음 점에 유의하자:
si_code / si_status에 들어간다), 프로세스는 zombie에서 dead로 전이된다. si_signo, si_errno, si_pid, si_uid 필드도 설정된다.ECHILD로 실패한다.위 사항은 P_PIDFD 호출을 포함한 모든 waitid 호출에 대해 참이다. zombie에 대해 처음으로 기다리기가 수행되면(wait / waitpid / waitid 중 어떤 종류의 호출이든 상관없음), 종료 코드 / 상태가 회수되고, 이후 다시 기다리려고 하는 시도는(마찬가지로 어떤 종류의 wait / waitpid / waitid 호출이든) 실패한다.
프로세스가 alive에서 zombie로 전이될 때, 그 프로세스의 부모의 SIGCHLD 핸들러가 SIG_IGN이거나 SA_NOCLDWAIT를 가지고 있으면, 커널은 부모를 대신해 자동으로 wait 호출을 수행하고 결과를 버린다. 그 결과 자식은 zombie에서 dead로 바로 전이된다. 이렇게 되면 자식에 대해 기다리려는 모든 시도(P_PIDFD를 통한 경우 포함)는 실패한다. 이에 대한 유일한 예외는 자식이 clone 또는 clone3로 생성되었고, 종료 시그널이 SIGCHLD가 아닌 다른 값으로 지정되었으며, 자식이 아직 execve 또는 유사한 호출을 하지 않은 경우이다. 이 조건들이 모두 만족되면 자동 wait 호출은 그 자식을 인식하지 못한다.
사용 가능 시점: 커널 5.6, glibc 2.36.
이 함수는 pidfd와, 그 pidfd가 참조하는 프로세스의 파일 테이블 안에 있는 fd 번호를 받아, 호출 프로세스의 파일 테이블 안에 그 파일 디스크립터의 복제본을 만들고, 새 fd 번호를 반환한다. 효과는 참조된 프로세스가 SCM_RIGHTS 메시지를 사용해 호출 프로세스로 파일 디스크립터를 보냈을 때와 비슷하다. 새 fd에는 O_CLOEXEC 플래그가 자동으로 설정된다.
이 함수를 호출하면 PTRACE_MODE_ATTACH_REALCREDS 보안 검사가 수행된다.
사용 가능 시점: 커널 5.8, glibc 2.14.
이 함수에 pidfd를 전달하면 호출자는 그 pidfd가 참조하는 프로세스가 속한 하나 이상의 네임스페이스로 이동한다. 이 함수에는 open("/proc/$pid/ns/$name")의 결과 fd를 전달할 수도 있다는 점에 유의하자.
사용 가능 시점: 커널 5.10, glibc 2.36.
이 함수는 madvise와 비슷하지만, 호출 프로세스가 아니라 pidfd를 통해 지정한 임의의 프로세스에 대해 동작한다.
5.12부터 이 함수를 호출하면 PTRACE_MODE_READ_FSCREDS 및 CAP_SYS_NICE 보안 검사가 수행된다. 5.10과 5.11에서는 PTRACE_MODE_ATTACH_FSCREDS 보안 검사가 수행되었다.
사용 가능 시점: 커널 5.15, glibc 2.36.
이것은 비교적 틈새 용도의 함수라서, 사용자 공간 OOM 킬러를 작성하는 경우가 아니라면 아마 필요할 일이 없을 것이다. 더 이상 alive 상태는 아니지만, 커널이 아직 가상 메모리를 해제하지 않은 프로세스에 대해 호출할 수 있으며, 이를 통해 커널이 해당 가상 메모리를 더 빨리 해제하게 만들 수 있다.
fstat / statx for meaningful stx_ino
사용 가능 시점: 커널 6.9, glibc 2.2.5(fstat) 또는 glibc 2.28(statx).
pidfd에 대해 fstat 또는 statx를 호출하는 것은 항상 가능하긴 했지만, 커널 6.9 이전에는 그렇게 해도 유용하지 않았다. 6.9부터는 pidfd에 대해 statx를 호출하면 의미 있는 stx_ino를 얻을 수 있다. pidfd의 64비트 inode 번호는 프로세스를 유일하게 식별하므로, 같은 프로세스를 참조하는 두 pidfd는 동일한 stx_ino 값을 가지며, 서로 다른 프로세스를 참조하는 두 pidfd는 서로 다른 stx_ino 값을 가진다. st_ino가 64비트 폭인 경우에는 fstat도 마찬가지다. 다시 말해 6.9부터는 pidfd를 통해 관측한 프로세스의 inode 번호가 그 프로세스를 위한 유일한 64비트 식별자가 되며, 이 값은 시스템이 재시작되기 전까지 재사용되지 않고, 서로 다른 pid 네임스페이스 사이에서도 고유하다.
앞으로의 커널 버전에서는 pidfd로 할 수 있는 일(또는 pidfd에 대해 할 수 있는 일)이 더 추가될 가능성이 크다. 기존 기능과 관련해서는, 커널 버전이 아니라 glibc 버전 때문에 제약을 받는 상황이라면, 한 가지 선택지는 아주 최신 glibc를 대상으로 컴파일한 뒤 polyfill-glibc를 사용해 더 오래된 glibc 버전과의 런타임 호환성을 복원하는 것이다.
미래 방향이라는 관점에서, 내가 보고 싶은 것들 중 일부는 다음과 같다:
GetExitCodeProcess 참고).SA_NOCLDWAIT와 비슷하지만, 부모의 속성이 아니라 자식의 속성이라는 차이가 있다. 앞선 항목과 결합하면 종료 코드와 상태는 여전히 관련 pidfd를 가진 누구에게서나 가져올 수 있을 것이다.process_vm_readv와 process_vm_writev의 pidfd 버전.