728x90

1. fstat을 두 번 불렀더니 inode가 달라졌다

DEFCON 34 iOS 데모가 현장에서 죽었고, 그걸 추적하다 보니 커널까지 내려갔다는 글이 하다에 올라왔다. 결론이 꽤 기괴하다. 같은 UNIX 도메인 소켓 fd에 fstat()을 두 번 호출했는데 st_ino 값이 서로 달랐다는 것.

이게 왜 사고로 이어지냐면, UDS를 다루는 코드는 생각보다 자주 inode를 "이 소켓의 신분증"처럼 쓴다. 연결 하나를 맵의 키로 잡을 때, 로그에 커넥션 식별자를 박을 때, 샌드박스에서 "이 fd가 아까 그 fd 맞나?" 검증할 때. 그런데 그 신분증이 호출할 때마다 바뀌면 맵 lookup이 미스나고, 검증이 실패하고, 데모가 죽는다.

인프라 하는 사람 입장에서 이 사례가 재밌는 이유는 따로 있다. 우리는 UDS를 매일 쓴다. /var/run/docker.sock, containerd의 containerd.sock, Envoy의 xDS/admin 소켓, php-fpm·gunicorn 업스트림, systemd socket activation, PostgreSQL의 /var/run/postgresql/.s.PGSQL.5432. 그런데 "UDS의 inode 번호가 뭘 의미하는지"는 대부분 한 번도 제대로 생각해본 적이 없다. 이번 건은 그 틈을 정확히 찌른다.

2. 원인: 0을 "초기화 안 됨"으로 쓰면서, 첫 값에도 0을 준 로직

요약된 원인은 단순하다. BSD 계열(XNU 포함) 소켓 코드는 UDS의 inode 번호를 미리 할당하지 않는다. 누가 stat()을 물어볼 때 그제서야 전역 카운터에서 하나 꺼내 붙이는 lazy 할당이다. 의사 코드로 옮기면 이 모양이다.

/* BSD 계열 uipc_sense() 류의 전형적인 패턴 */
static ino_t unp_ino;          /* 전역 카운터, 0에서 시작 */

if (unp->unp_ino == 0)         /* 0 == "아직 할당 안 됨" 이라는 가정 */
        unp->unp_ino = unp_ino++;   /* 후위 증가: 첫 호출은 0을 반환 */
sb->st_ino = unp->unp_ino;

버그가 보이는가. 전역 카운터가 0에서 시작하고 후위 증가를 쓰니, 맨 처음 inode를 요구한 소켓은 0을 받는다. 그런데 0은 이 코드에서 "아직 할당 안 됨"을 뜻하는 sentinel이다. 그래서 같은 소켓에 두 번째 fstat()이 들어오면 조건문이 또 참이 되고, 카운터가 그새 올라간 만큼 다른 번호가 배정된다. sentinel 값과 유효 값의 도메인이 겹친, 교과서에 실려도 될 만한 사고다. (XNU의 정확한 함수명·해당 버전 범위는 애플 오픈소스 저장소에서 직접 확인 필요.)

Linux는 어떨까. Linux의 소켓 inode는 sockfs에서 new_inode_pseudo()get_next_ino()생성 시점에 배정되고, fs/inode.cget_next_ino()에는 아예 "0을 주지 않는다"는 처리가 명시적으로 들어 있다. 카운터가 0으로 감기면 한 번 더 증가시킨다. 그래서 같은 버그는 재현되지 않는다. 직접 확인해보자.

$ python3 -c '
import socket, os
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
print(os.fstat(s.fileno()).st_ino)
print(os.fstat(s.fileno()).st_ino)'
41582
41582

두 번 다 같다. 다만 여기서 방심하면 안 되는 포인트가 하나 더 있다. get_next_ino()가 굴리는 카운터는 unsigned int(32비트) 기반이고 CPU별 배치로 나눠 쓰다가 랩어라운드한다. 즉 Linux에서도 inode 번호는 "한 시점에 살아있는 소켓들 사이에서 대체로 유일할 뿐", 시간축 전체에서 유일한 값이 절대 아니다. 소켓을 초당 수천 개씩 만들고 닫는 게이트웨이라면 재사용은 언젠가 반드시 온다.

3. 실무 관점: 어디서 만나고, 뭘로 대체하나

inode를 실제로 보는 순간들

운영에서 UDS inode를 직접 보는 건 보통 "이 소켓 누가 붙들고 있냐"를 찾을 때다.

$ ss -xlp | grep docker
u_str LISTEN 0 4096 /run/docker.sock 32145 * 0 users:(("dockerd",pid=1187,fd=9))

$ grep docker /proc/net/unix
ffff9a1c0b3c0000: 00000002 00000000 00010000 0001 01 32145 /run/docker.sock

$ ls -l /proc/1187/fd | grep 32145
lrwx------ 1 root root 64 Feb  3 11:20 9 -> socket:[32145]

여기까지는 문제없다. "지금 이 순간의 스냅샷"으로 프로세스를 역추적하는 용도니까. 문제는 이 번호를 애플리케이션 상태에 저장하는 순간부터다. inode를 커넥션 키로 쓴 세션 테이블, inode로 접속 주체를 식별하는 인가 로직, inode를 감사 로그의 상관관계 ID로 삼은 설계 — 전부 재사용과 이번 같은 커널 버그에 노출된다.

대안: 정체를 묻고 싶으면 정체를 묻는 API를 써라

"이 UDS 반대편이 누구냐"가 궁금한 거라면 Linux에는 SO_PEERCRED가 있다. 커널이 채워주는 값이라 클라이언트가 위조할 수 없다.

import socket, struct, os

srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try: os.unlink("/tmp/app.sock")
except FileNotFoundError: pass
srv.bind("/tmp/app.sock"); srv.listen(1)

conn, _ = srv.accept()
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
                      struct.calcsize("3i"))
pid, uid, gid = struct.unpack("3i", raw)
print(f"peer pid={pid} uid={uid} gid={gid}")
# 출력 예: peer pid=28841 uid=1000 gid=1000

macOS/BSD는 LOCAL_PEERCRED, LOCAL_PEERPID 계열로 이름이 다르니 이식할 땐 분기해야 한다. 그리고 pid는 받는 순간 이미 stale일 수 있다는 것도 알고 써야 한다(그 프로세스가 죽고 pid가 재사용될 수 있다). 인가 결정은 pid보다 uid/gid 기준이 훨씬 안전하다.

흔한 함정

inode 이슈보다 훨씬 자주 밟는 UDS 지뢰 세 개.

(1) 죽은 소켓 파일이 남아서 bind 실패. 프로세스가 SIGKILL로 죽으면 소켓 파일은 파일시스템에 그대로 남는다. 재시작하면 이게 뜬다.

OSError: [Errno 98] Address already in use
nginx: [emerg] bind() to unix:/run/app.sock failed (98: Address already in use)

해법은 bind 전에 unlink하거나(위 예제처럼), systemd socket activation으로 소켓 생명주기를 systemd에 위임하는 것. 참고로 Linux에는 파일시스템을 거치지 않는 abstract socket(\0으로 시작하는 주소)도 있는데, 이건 파일 권한이 없어서 네트워크 네임스페이스 안이면 아무나 붙는다. 컨테이너 환경에서는 오히려 위험할 수 있다.

(2) 파일은 있는데 아무도 안 듣고 있음.

nginx: connect() to unix:/run/php-fpm.sock failed (111: Connection refused)
dial unix /var/run/docker.sock: connect: no such file or directory

111은 "파일은 존재하나 listen 없음", ENOENT는 "경로 자체가 없음". 컨테이너 볼륨 마운트를 빠뜨렸을 때 후자가 뜬다.

(3) 권한. UDS 접근 제어는 소켓 파일의 퍼미션과 상위 디렉터리의 실행 권한을 함께 본다. 사이드카 컨테이너를 non-root로 띄우면 이게 제일 자주 터진다.

Got permission denied while trying to connect to the Docker daemon socket
at unix:///var/run/docker.sock: ... dial unix /var/run/docker.sock: connect: permission denied

emptyDir로 소켓을 공유하는 쿠버네티스 사이드카 패턴이라면 fsGroup과 소켓 생성 시점의 umask를 같이 맞춰야 한다.

언어 불문 교훈

이번 버그의 본질은 커널이 아니라 "0을 없음으로 쓰면서 0을 유효 값으로도 발급"이다. Go에서 구조체 제로값 ID를 "미할당"으로 쓰다가 실제 ID 0이 들어오는 경우, proto3에서 기본값과 명시적 0을 구분 못 하는 경우, DB에서 AUTO_INCREMENT 시작값과 sentinel이 겹치는 경우 전부 같은 계열이다. 예방책도 같다. 카운터는 1부터 시작하고, sentinel이 필요하면 Optional/포인터/별도 플래그처럼 값 도메인 밖에서 표현하자. 후위 증가를 쓸 땐 "첫 반환값이 뭐냐"를 한 번 소리 내어 확인하는 습관도 싸게 먹힌다.

4. 정리

한 줄 요약: UNIX 도메인 소켓의 inode 번호는 디버깅용 스냅샷 값이지, 영속 식별자가 아니다.

  • ss -x, /proc/net/fd 역추적처럼 지금 이 순간을 보는 용도라면 inode를 마음껏 써라. 대체재도 없다.
  • 세션 키, 인가 근거, 장기 상관관계 ID로 쓸 거라면 쓰지 마라. 재사용되고, 플랫폼에 따라 이번처럼 호출마다 바뀌기도 한다.
  • "반대편이 누구냐"는 SO_PEERCRED(Linux) / LOCAL_PEERCRED(BSD·macOS)로 묻는 게 정답이다. uid/gid 기준 인가가 pid보다 안전하다.
  • UDS 운영 체크리스트는 여전히 stale 소켓 unlink, 디렉터리 퍼미션, 마운트 누락 이 세 가지가 8할이다.

개인적으로 이 글이 좋았던 건, 데모 하나 죽은 걸 "재현 안 되네" 하고 넘기지 않고 fstat 두 번 호출까지 최소 재현을 좁혀낸 과정 자체다. 우리 쪽 장애도 결국 같은 방식으로 풀린다. 재현 케이스를 한 줄까지 줄이면 원인은 대개 스스로 드러난다.

참고 자료

728x90

+ Recent posts