권한이 있는 프로세스만 열 수 있는 파일이나 소켓을, 정작 일은 권한 없는 프로세스가 처리해야 하는 구조가 종종 필요하다. 경로만 넘겨주고 상대가 다시 open()하게 하면 권한이 없어 실패하거나, 그 사이 파일이 바뀌는 TOCTOU 문제가 생긴다. 이럴 때 쓰는 것이 유닉스 도메인 소켓의 SCM_RIGHTS로, 열려 있는 파일 디스크립터 자체를 다른 프로세스에 넘긴다. Python은 3.9부터 socket.send_fds()/socket.recv_fds()로 이 기능을 표준 라이브러리만으로 쓸 수 있다. 이 글에서는 fd 전달이 실제로 무엇을 넘기는 것인지, 어떤 패턴으로 쓰는지, 어디서 걸려 넘어지는지를 실행 결과로 정리한다.
fd를 넘긴다는 것의 의미
import os, socket, sys
def child(sock):
msg, fds, flags, addr = socket.recv_fds(sock, 64, 1)
fd = fds[0]
print(f"[child] 메시지={msg.decode()!r} 받은 fd={fd}")
print(f"[child] 읽기 전 오프셋={os.lseek(fd, 0, os.SEEK_CUR)}")
print(f"[child] 읽은 내용={os.read(fd, 32)!r}")
print(f"[child] 읽은 뒤 오프셋={os.lseek(fd, 0, os.SEEK_CUR)}")
os.close(fd)
sys.stdout.flush()
parent_sock, child_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
with open("/tmp/fdpass-demo.txt", "w") as f:
f.write("hello from fd passing\n")
fp = open("/tmp/fdpass-demo.txt", "rb")
print(f"[parent] 보낼 fd={fp.fileno()}")
sys.stdout.flush() # fork 전에 비워두지 않으면 자식이 부모 버퍼를 그대로 복사해 출력한다
pid = os.fork()
if pid == 0:
parent_sock.close()
child(child_sock)
os._exit(0)
child_sock.close()
socket.send_fds(parent_sock, [b"take this"], [fp.fileno()])
os.waitpid(pid, 0)
print(f"[parent] 자식이 읽은 뒤 부모 쪽 오프셋={fp.tell()}")
fp.close()$ python3 basic.py
[parent] 보낼 fd=5
[child] 메시지='take this' 받은 fd=3
[child] 읽기 전 오프셋=0
[child] 읽은 내용=b'hello from fd passing\n'
[child] 읽은 뒤 오프셋=22
[parent] 자식이 읽은 뒤 부모 쪽 오프셋=22
fd 번호는 5에서 3으로 달라졌지만 자식이 22바이트를 읽자 부모 쪽 오프셋도 22로 움직였다. 넘어간 것은 fd 번호가 아니라 그 뒤의 open file description이고, man 7 unix의 표현대로 상대 프로세스의 fd 테이블에 dup()한 것과 같다.
서로 다른 프로세스 사이에서 전달
socketpair 대신 경로가 있는 유닉스 도메인 소켓을 쓰면 부모·자식 관계가 아닌 프로세스끼리도 주고받을 수 있다.
import os, socket, sys
SOCK_PATH = "/tmp/fdpass.sock"
if os.path.exists(SOCK_PATH):
os.unlink(SOCK_PATH)
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(SOCK_PATH)
srv.listen(1)
print(f"[server] pid={os.getpid()} 대기 중: {SOCK_PATH}")
conn, _ = srv.accept()
fp = open(sys.argv[1], "rb")
socket.send_fds(conn, [os.path.basename(sys.argv[1]).encode()], [fp.fileno()])
print(f"[server] {sys.argv[1]} (fd={fp.fileno()}) 전달 완료")
fp.close(); conn.close(); srv.close()
os.unlink(SOCK_PATH)import os, socket
cli = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
cli.connect("/tmp/fdpass.sock")
name, fds, flags, addr = socket.recv_fds(cli, 128, 1)
fd = fds[0]
print(f"[client] pid={os.getpid()} 파일명={name.decode()} 받은 fd={fd}")
print(f"[client] /proc/self/fd/{fd} -> {os.readlink(f'/proc/self/fd/{fd}')}")
print(f"[client] 첫 40바이트={os.read(fd, 40)!r}")
os.close(fd); cli.close()$ python3 server.py /etc/os-release & sleep 0.5; python3 client.py
[server] pid=20032 대기 중: /tmp/fdpass.sock
[server] /etc/os-release (fd=5) 전달 완료
[client] pid=20034 파일명=os-release 받은 fd=4
[client] /proc/self/fd/4 -> /usr/lib/os-release
[client] 첫 40바이트=b'PRETTY_NAME="Ubuntu 24.04.4 LTS"\nNAME="U'
받은 쪽에서 /proc/self/fd/<번호>를 읽으면 그 fd가 실제로 어떤 파일을 가리키는지 확인할 수 있다. 심볼릭 링크가 풀려 /usr/lib/os-release로 표시되는 것도 fd가 경로가 아니라 파일 자체를 가리키기 때문이다.
권한 분리에 쓰기
fd 전달의 실제 쓰임새는 권한 분리다. root 프로세스가 파일을 열어 fd만 넘기면, 받는 쪽은 그 파일에 대한 권한이 없어도 읽을 수 있다. root로 /root/demo-secret.txt(모드 600)를 열어 일반 사용자 프로세스에 넘겨봤다.
import os, socket
print(f"[user] uid={os.getuid()}")
try:
open("/root/demo-secret.txt", "rb")
except PermissionError as e:
print(f"[user] 직접 열기: {type(e).__name__}: {e}")
cli = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
cli.connect("/tmp/fdpass-priv.sock")
msg, fds, flags, addr = socket.recv_fds(cli, 64, 1)
fd = fds[0]
print(f"[user] 전달받은 fd={fd} -> {os.readlink(f'/proc/self/fd/{fd}')}")
print(f"[user] 내용={os.read(fd, 64)!r}")
os.close(fd); cli.close()$ sudo python3 priv_server.py & # root로 /root/demo-secret.txt를 열어 대기
[root] uid=0 대기 중
$ python3 priv_client.py # 일반 사용자로 실행
[user] uid=1000
[user] 직접 열기: PermissionError: [Errno 13] Permission denied: '/root/demo-secret.txt'
[user] 전달받은 fd=4 -> /root/demo-secret.txt
[user] 내용=b'only root can open this file\n'
[root] fd 전달 완료
같은 프로세스가 직접 열면 EACCES인데 전달받은 fd로는 그대로 읽힌다. 권한 검사는 open() 시점에 한 번만 하기 때문이고, 이 성질이 권한 분리 설계의 근거이자 동시에 위험 요소다.
여러 개 보내기와 잘림
import os, socket
a, b = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
files = [open(p, "rb") for p in ("/etc/os-release", "/etc/hosts", "/etc/services")]
socket.send_fds(a, [b"3 fds"], [f.fileno() for f in files])
# maxfds를 2로 줄여서 받으면 나머지는 잘린다
msg, fds, flags, addr = socket.recv_fds(b, 64, 2)
print(f"보낸 fd 3개 / 받은 fd {len(fds)}개: {fds}")
print(f"MSG_CTRUNC 설정됨: {bool(flags & socket.MSG_CTRUNC)}")
for fd in fds:
print(f" fd={fd} -> {os.readlink(f'/proc/self/fd/{fd}')}")
os.close(fd)$ python3 multi.py
보낸 fd 3개 / 받은 fd 2개: [8, 9]
MSG_CTRUNC 설정됨: True
fd=8 -> /usr/lib/os-release
fd=9 -> /etc/hosts
recv_fds()의 세 번째 인자가 받을 fd 개수의 상한이다. 모자라면 예외 없이 조용히 잘리고 MSG_CTRUNC만 켜지므로, 보내는 개수가 가변적이라면 이 플래그를 반드시 확인해야 한다.
데이터 없이 fd만 보내면
$ python3 empty.py # socket.send_fds(a, [b""], [f.fileno()])
send_fds 반환값(보낸 바이트 수)=0
수신 타임아웃 — SOCK_STREAM에서는 데이터 0바이트면 fd도 전달되지 않는다
fd는 보조 데이터(ancillary data)로 실려 가기 때문에 본문 데이터가 최소 1바이트는 있어야 한다. 앞의 예제들이 b"take this"처럼 의미 없는 페이로드를 함께 보낸 이유가 이것이다.
주의사항
$ python3 cloexec.py
받은 fd의 FD_CLOEXEC: False
원본 fd의 FD_CLOEXEC: True
- Python의
open()은 fd에FD_CLOEXEC를 붙이지만recv_fds()로 받은 fd에는 붙지 않는다. 받은 뒤exec를 한다면fcntl로 직접 설정해야 자식에게 새지 않는다. - 받은 fd는 수신 프로세스의 fd 테이블을 차지한다.
os.close()를 빠뜨리면 그대로 누수이고, 전송 중인(in-flight) fd가RLIMIT_NOFILE을 넘으면 보내는 쪽에서ETOOMANYREFS가 난다. - fd를 받는 쪽은 파일 권한을 다시 검사받지 않는다. 신뢰하지 않는 프로세스에 fd를 넘기면 그 순간 권한 경계가 사라지므로, 넘기는 fd는 필요한 최소 권한으로 열고(예: 읽기 전용) 받는 쪽도
os.fstat()으로 기대한 대상이 맞는지 확인하는 편이 안전하다. SCM_RIGHTS는 유닉스 도메인 소켓 전용이다. TCP 소켓으로는 넘길 수 없고, 같은 호스트 안에서만 동작한다.
마무리
fd 전달은 경로를 넘기는 방식으로는 풀리지 않는 문제 — 권한 분리, 이미 연결된 소켓의 이관, 이름 없는 객체 공유 — 를 정확히 겨냥한 수단이다. Python에서는 send_fds()/recv_fds() 두 함수면 충분하고, 실수는 대부분 데이터 0바이트, MSG_CTRUNC 미확인, fd 미반납 세 가지에서 나온다.