unshare --pid로 셸을 띄웠는데 ps에 호스트가 다 보이는 이유 — Linux namespace 내부 동작

seonwoo_jung·3일 전

1. 도입 — "격리가 안 됐나?" 싶었던 순간

터미널에서 sudo unshare --pid bash로 새 PID namespace에 들어간 뒤 ps -e를 쳤더니, 호스트의 프로세스가 그대로 다 보였다. "PID를 격리했다면서 왜 전부 보이지?" 하는 의문이 이 글의 출발점이다.

결론부터 말하면 격리는 정상적으로 됐고, ps가 들여다보는 창(/proc)이 아직 호스트 것이었을 뿐이다. 이 한 줄을 제대로 이해하려면 컨테이너가 "가벼운 VM"이 아니라는 사실부터 짚어야 한다. 컨테이너는 커널 하나를 호스트와 공유하면서, 몇 가지 전역 커널 자원의 "보이는 범위"만 프로세스별로 갈아끼운다. 그 갈아끼우기를 담당하는 커널 기능이 namespace다.

이 글에서는 PID·mount·user 세 종류의 namespace가 각각 무엇을 어떻게 나누는지, 그리고 위의 unshare 오해가 왜 생기는지를 man-page 흐름을 따라 정리한다.

2. 핵심 개념 — namespace는 "자원의 래퍼"다

Linux man-pages의 namespaces(7)는 namespace를 이렇게 정의한다.

namespace는 하나의 전역 커널 자원을, 그 namespace에 속한 프로세스들에게는 자기만의 격리된 인스턴스인 것처럼 감싼(wrap) 것이다.

즉 프로세스 트리·마운트 목록·UID 공간 같은 전역 자원을 래퍼로 감싸서, 같은 namespace에 속한 프로세스끼리만 그 인스턴스를 공유해 보고, 다른 namespace에서는 같은 자원이 아예 다른 값으로 보이게 만든다. 종류는 mount·PID·network·IPC·UTS·user·cgroup·time이 있다.

여기서 먼저 분명히 해둘 것: namespace는 "가시성"만 나눈다. CPU·메모리 같은 자원의 을 제한하는 것은 cgroup의 몫이고, 여기에 capability drop·seccomp·루트 파일시스템 교체가 더해져야 비로소 우리가 아는 "컨테이너"가 된다. namespace ≠ 컨테이너라는 점을 먼저 못 박아 두면 나머지가 훨씬 선명해진다.

프로세스가 어떤 namespace에 속하는지는 /proc/<pid>/ns/* 심볼릭 링크로 드러난다. 두 프로세스가 같은 namespace에 있으면 이 링크가 같은 inode 번호를 가리킨다 — 커널은 namespace를 파일처럼 inode로 식별한다.

만드는 방법은 세 가지다.

  • clone(2)CLONE_NEW* 플래그를 줘서 새 프로세스를 새 namespace에서 시작한다.
  • unshare(2)호출한 프로세스 자신을 새 namespace로 옮긴다.
  • setns(2)/proc/<pid>/ns/<type> 파일 디스크립터를 열어 기존 namespace에 합류한다. docker exec가 이 경로다.

3. PID namespace — 같은 프로세스가 여러 PID를 갖는다

pid_namespaces(7)의 핵심은 PID namespace가 계층적이라는 점이다. 새 PID namespace에서 처음 만들어진 프로세스가 그 안에서 PID 1이 되고, 한 프로세스는 자신이 속한 namespace와 그 모든 조상 namespace마다 서로 다른 PID를 갖는다. getpid()는 호출자가 속한 namespace 기준의 PID를 돌려준다.

        호스트(root) PID namespace
        ┌───────────────────────────────┐
        │ PID 4021: 컨테이너 init         │
        │      └─ 자식 PID namespace ─────┼──┐
        └───────────────────────────────┘  │
                                            v
                     컨테이너 PID namespace
                     ┌──────────────────────────┐
                     │ PID 1: 앱 프로세스         │  ← 호스트에선 4021
                     │ PID 7: 워커               │  ← 호스트에선 4103
                     └──────────────────────────┘

PID 1은 평범한 프로세스가 아니다. man-page가 명시하는 세 가지가 있다.

  1. 신호 보호: namespace 내부에서 PID 1로 보낼 수 있는 신호는 PID 1이 핸들러를 등록한 것뿐이다. 특권 프로세스가 보내도 마찬가지다. 그래서 핸들러 없는 앱을 PID 1로 띄우면 내부에서 보낸 SIGTERM이 그냥 무시된다. 단, 조상 namespace가 보내는 SIGKILL/SIGSTOP은 이 규칙을 우회한다.
  2. 고아 프로세스 reaping: 부모가 먼저 죽은 프로세스는 PID 1로 reparent되고, PID 1이 wait()로 좀비를 거둬야 한다.
  3. PID 1이 종료되면 커널이 그 namespace의 모든 프로세스에 SIGKILL을 보내고 namespace를 해체한다.

이 대목이 실무와 맞닿는다. 컨테이너 이미지의 ENTRYPOINT를 셸 스크립트로 두면 셸이 PID 1이 되는데, 셸은 받은 신호를 자식 앱에 전달하지 않는 경우가 많다. 그러면 docker stop이 보낸 SIGTERM이 앱까지 닿지 않아 graceful shutdown이 깨지고, 결국 SIGKILL로 강제 종료된다. tini 같은 최소 init을 PID 1로 두는 이유가 여기 있다.

그래서 처음의 unshare 오해로 돌아가면

두 가지가 겹쳐 있었다.

첫째, CLONE_NEWPID이미 존재하는 호출 프로세스의 PID를 바꾸지 못한다(PID는 불변이다). 그래서 unshare --pid bash만 하면 그 셸 자신은 PID 1이 되지 못하고, 이후 fork()첫 자식이 PID 1이 된다. --fork 옵션이 바로 그 자식을 만들어 준다.

둘째, PID namespace만 새로 만들면 /proc는 여전히 호스트의 procfs다. ps/proc를 읽어 프로세스 목록을 만들기 때문에, 창이 호스트 것이면 호스트 프로세스가 다 보인다. mount namespace 안에서 /proc를 다시 마운트해야 procfs가 현재 PID namespace를 반영한다.

# --fork로 자식을 PID 1로 만들고, --mount-proc로 /proc를 새 namespace 기준으로 다시 마운트
$ sudo unshare --pid --mount-proc --fork bash
$ echo $$        # 1        ← 이 namespace 기준 PID
$ ps -e          # 이 namespace의 프로세스만 보인다

# 같은 namespace인지는 inode로 판정한다
$ readlink /proc/1/ns/pid    # pid:[4026531836] 형태의 inode
$ readlink /proc/$$/ns/pid   # 위와 같으면 동일 namespace

즉 "PID 격리가 안 된다"가 아니라 "보여주는 창이 아직 호스트 것"이었던 셈이다.

4. Mount namespace — 마운트 목록의 복사본과 전파 규칙

mount_namespaces(7)에 따르면 CLONE_NEWNS로 mount namespace를 만들면 그 순간의 마운트 목록을 복사한다. 이후 한쪽에서 마운트/언마운트해도 다른 namespace에는 보이지 않는다. 컨테이너가 자기만의 루트 파일시스템을 갖는 토대가 이것이고, 런타임은 여기에 pivot_root(2)로 루트를 갈아끼운다.

단순 복사로 끝이 아니라 마운트 지점마다 전파 타입(propagation type)이 붙는다.

타입마운트 이벤트 전파
shared양방향 — peer group 안에서 서로 전파
private전파 없음
slave부모 → 자식 단방향 수신만
unbindablebind mount 원천 금지

컨테이너 런타임은 보통 루트를 slaveprivate로 만든다. 그러면 호스트에서 새로 붙인 디스크는 컨테이너가 받아 볼 수 있되, 컨테이너 내부에서 만든 마운트는 호스트로 새어 나가지 않는다.

5. User namespace — 안에서는 root, 밖에서는 무권한

user_namespaces(7)가 보안 관점에서 가장 강력하다. namespace 안의 UID/GID를 바깥 UID/GID로 매핑한다. /proc/<pid>/uid_map안쪽ID 바깥쪽ID 길이 형식으로 쓰며, 한 번만 쓸 수 있다.

# 컨테이너 uid 0..65535 를 호스트 uid 100000..165535 로 매핑
0 100000 65536

그래서 컨테이너 안에서 uid 0(root)으로 동작해도 호스트에서 보면 uid 100000인 무권한 사용자다. rootless 컨테이너의 원리가 이것이다. man-page에 따르면 user namespace를 만든 프로세스는 그 namespace가 소유한 자원에 한해 전체 capability를 얻지만, 부모 namespace에서는 아무 권한도 없다. 다른 namespace 종류들의 권한 검사도 결국 "그 namespace를 소유한 user namespace" 기준으로 이뤄진다고 알려져 있다. (보안 문제 때문에 gid_map을 쓰기 전 setgroupsdeny로 막아야 하는 제약도 있다.)

6. 정리

  • namespace는 전역 커널 자원을 감싸 가시성만 프로세스별로 나눈다. 자원의 양 제한(cgroup)·권한 축소(capability/seccomp)·루트 교체(pivot_root)가 합쳐져야 컨테이너다.
  • PID namespace는 계층적이라 한 프로세스가 조상마다 다른 PID를 갖는다. PID 1은 신호 보호와 좀비 reaping이라는 특수 책임을 지므로, 셸을 PID 1로 두면 graceful shutdown이 깨진다.
  • unshare --pid가 격리를 못 한 게 아니라, --fork로 자식을 PID 1로 만들고 --mount-proc/proc를 다시 마운트해야 ps가 격리된 목록을 보여준다.
  • mount namespace는 마운트 목록을 복사하고 전파 타입으로 새어 나감을 통제하며, user namespace는 UID를 매핑해 "안에서 root, 밖에서 무권한"을 만든다.

더 파고들 만한 주제 두 가지를 남겨 둔다. 하나는 network namespace와 veth pair — 컨테이너의 eth0이 호스트 bridge에 붙는 경로. 다른 하나는 pivot_root(2)chroot(2)의 차이 — 런타임이 왜 chroot 대신 pivot_root를 쓰는가.

참고 자료

  • Linux man-pages: namespaces(7), pid_namespaces(7), mount_namespaces(7), user_namespaces(7)
  • 시스템 콜 매뉴얼: clone(2), unshare(2), setns(2)
  • util-linux: unshare(1)

0개의 댓글