네임스페이스 (Namespaces)

Linux 네임스페이스를 프로세스 가시성 격리의 핵심 메커니즘 관점에서 심층 분석합니다. PID/mount/network/user/IPC/UTS/cgroup/time 네임스페이스의 역할 분리, clone/unshare/setns 시스템 콜 경로, user namespace와 capability 매핑, 컨테이너 런타임의 네임스페이스 조합 전략, 호스트와의 경계 누수 위험과 방어 패턴, 디버깅 시 네임스페이스 진입 절차(nsenter/procfs), 운영 중 장애 원인 추적까지 실제 컨테이너 인프라에 필요한 핵심 내용을 다룹니다.

전제 조건: 프로세스 관리cgroups 문서를 먼저 읽으세요. 컨테이너 격리는 커널 객체 자체를 복제하는 것이 아니라 가시성 범위를 분리하는 방식이므로, 프로세스 구조와 리소스 제어를 먼저 이해해야 합니다.
일상 비유: 이 주제는 공유 건물의 층별 출입 통제와 비슷합니다. 건물은 같지만 각 층의 출입 권한과 표지판이 다르듯이, 네임스페이스는 같은 커널 위에서 관측 범위를 분리합니다.

핵심 요약

  • nsproxy — task가 참조하는 네임스페이스 집합
  • clone/unshare/setns — 생성/분리/진입 API
  • User NS — 권한 매핑과 공격면에 가장 큰 영향
  • Mount/Net/PID NS — 컨테이너 격리의 실질 핵심
  • pidfd — PID 재사용 문제를 줄이는 현대적 제어 방식

단계별 이해

  1. 개념 분해
    어떤 자원이 어떤 네임스페이스에 의해 격리되는지 표로 정리합니다.
  2. API 실습
    unshare --mount --pid --fork 같은 최소 예제로 동작을 확인합니다.
  3. 런타임 연결
    containerd/runc가 생성 단계에서 어떤 API를 호출하는지 추적합니다.
  4. 보안 점검
    User NS 허용 범위와 관련 sysctl 정책을 운영 환경에 맞춥니다.
관련 표준: OCI Runtime Specification 1.0 (네임스페이스 격리 요구사항), POSIX.1-2017 (프로세스 격리 기반) — 컨테이너 런타임의 네임스페이스 활용 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

네임스페이스 개요

네임스페이스는 커널 리소스를 격리하여 프로세스 그룹이 독립적인 시스템 뷰를 갖게 하는 메커니즘입니다. 컨테이너(Docker, LXC)의 핵심 기반 기술입니다.

네임스페이스 격리 원리

네임스페이스의 핵심 원리는 "같은 커널 위에서 서로 다른 시스템처럼 보이게 만든다"는 것입니다. 운영체제 수준의 가상화가 아니라, 커널 자원에 대한 뷰(view)를 분리하는 것입니다.

커널 구현: nsproxy 구조체

모든 프로세스(task_struct)는 nsproxy 포인터를 통해 자신이 속한 네임스페이스 집합을 참조합니다. 같은 네임스페이스를 공유하는 프로세스들은 동일한 nsproxy를 가리킵니다:

/* include/linux/nsproxy.h */
struct nsproxy {
    refcount_t count;              /* 참조 카운트 */
    struct uts_namespace  *uts_ns;   /* 호스트명 */
    struct ipc_namespace  *ipc_ns;   /* IPC 객체 */
    struct mnt_namespace  *mnt_ns;   /* 마운트 포인트 */
    struct pid_namespace  *pid_ns_for_children; /* 자식의 PID NS */
    struct net           *net_ns;   /* 네트워크 스택 */
    struct time_namespace *time_ns;  /* 시간 오프셋 */
    struct time_namespace *time_ns_for_children;
    struct cgroup_namespace *cgroup_ns; /* cgroup 뷰 */
};

/* task_struct에서 네임스페이스 접근 */
struct task_struct {
    /* ... */
    struct nsproxy *nsproxy;      /* UTS/IPC/mount/net/time/cgroup 묶음 */
    const struct cred *cred;   /* cred->user_ns 가 권한 기준 */
    struct fs_struct *fs;      /* root/pwd 는 mount NS와 함께 해석 */
    /* 현재 PID 뷰는 task_active_pid_ns(task) 경로로 별도 조회 */
};

중요한 점은 nsproxy가 "현재 task의 모든 격리 상태를 하나의 구조체에 완전히 담아 둔 것"은 아니라는 것입니다. 권한 판정은 cred->user_ns, 현재 PID 뷰는 task_active_pid_ns(task), 현재 경로 해석은 fs_struct와 mount namespace를 함께 봐야 정확하게 복원됩니다. 즉, 컨테이너 문맥을 조사할 때 task->nsproxy만 덤프해서는 충분하지 않습니다.

task_struct → nsproxy → namespaces 참조 구조 프로세스 (task_struct) PID 100 (init) nsproxy → 0x12345 cred → user_ns PID 200 (nginx) nsproxy → 0x12345 공유 (same ptr) PID 1234 (container) nsproxy → 0xABCDE 새 namespace 집합 cred → user_ns (UID 매핑) nsproxy (0x12345) Host Namespaces uts_ns → host_uts ipc_ns → host_ipc mnt_ns → host_mnt net_ns → host_net pid_ns_for_children nsproxy (0xABCDE) Container Namespaces uts_ns → container_uts mnt_ns → container_mnt net_ns → container_net ... 실제 Namespace 객체 uts_namespace hostname, domainname ipc_namespace SysV IPC, POSIX MQ mnt_namespace mount tree net namespace network stack pid_namespace PID 번역 time_namespace clock offsets cgroup_namespace cgroup view user_namespace cred에서 참조 참조 카운팅: 같은 nsproxy를 공유하는 프로세스는 동일 namespace 집합 사용 | 마지막 참조 해제 시 자동 정리

네임스페이스 격리의 동작 방식은 다음과 같습니다:

nsproxy 심화

nsproxy는 namespace object 자체가 아니라 현재 task가 어떤 namespace 집합을 보고 있는지 가리키는 묶음 포인터입니다. 실제 격리 상태는 각 concrete namespace 객체(struct net, struct ipc_namespace, struct time_namespace 등)에 들어 있고, nsproxy는 그 객체들을 한 번에 task에 묶어 둡니다. 그래서 task가 네임스페이스 하나만 바꾼다고 해도 필드 하나만 덮어쓰는 방식보다 nsproxy를 만들고 필요한 포인터만 교체한 뒤 task의 nsproxy 포인터 자체를 바꾸는 설계가 선택됩니다.

이 간접 계층은 세 가지 면에서 중요합니다. 첫째, task_struct hot path에 namespace별 포인터를 낱개로 직접 박아 넣지 않고도 현재 격리 상태를 표현할 수 있습니다. 둘째, 대부분의 fork()/clone()은 새 네임스페이스를 만들지 않으므로 get_nsproxy(old) 한 번으로 빠르게 공유할 수 있습니다. 셋째, unshare()setns()가 "현재 task가 보는 namespace 묶음 전체"를 일관되게 교체할 수 있어, mount/net/ipc/user/time 같은 서로 다른 수명 규칙을 가진 객체들을 하나의 일괄 교체 단위처럼 취급할 수 있습니다.

현재 task가 namespace를 보는 실제 경로

실무에서 가장 많이 생기는 오해는 "task->nsproxy만 보면 현재 task의 namespace 문맥을 전부 알 수 있다"는 가정입니다. 실제로는 그렇지 않습니다. 대부분의 namespace는 nsproxy에서 시작하지만, PID는 활성 뷰와 자식 뷰가 분리되고, User NS는 cred, 경로 해석은 fs_struct가 추가로 개입합니다.

task_struct가 namespace를 해석하는 실제 경로 current task_struct nsproxy 대부분의 namespace 묶음 cred UID/GID, capability, user_ns thread_pid / struct pid 현재 활성 PID 뷰의 근거 fs_struct root / pwd mount namespace 위에서 경로 해석 struct nsproxy task가 공유할 수 있는 namespace 집합 uts_ns / ipc_ns / mnt_ns / net_ns time_ns / cgroup_ns pid_ns_for_children 다음 fork/clone 자식이 들어갈 PID NS time_ns_for_children 다음 자식의 time namespace 후보 현재 가시 자원 UTS, IPC, Mount, Net, Cgroup 대부분은 task -> nsproxy에서 바로 출발 현재 Time view current->nsproxy->time_ns 의 오프셋 적용 현재 PID view task_active_pid_ns(task) struct pid 계층에서 계산 권한 view cred->user_ns capability / UID map 기준 다음 자식의 view pid_ns_for_children / time_ns_for_children unshare(CLONE_NEWPID/CLONE_NEWTIME) 후 먼저 바뀌는 영역 핵심: nsproxy만 보면 절반은 맞지만, PID/User/경로 해석은 추가 맥락이 필요
대부분의 namespace는 nsproxy가 출발점이지만, 현재 PID 뷰는 task_active_pid_ns(task), 권한은 cred->user_ns, 경로 해석은 fs_struct가 함께 관여합니다.
조회 대상실제 출발점현재 task에서 의미핵심 주의점
UTS / IPC / Mount / Net / Cgrouptask->nsproxy현재 task가 바로 보는 자원 범위대부분의 namespace는 이 경로에서 결정됩니다.
현재 Time viewtask->nsproxy->time_ns현재 task에 적용되는 monotonic / boottime 오프셋읽기 경로는 현재 task의 time_ns를 즉시 참조합니다.
현재 PID viewtask_active_pid_ns(task)현재 task가 속한 활성 PID namespacepid_ns_for_children와 동일하다고 가정하면 잘못된 진단이 나옵니다.
자식 PID viewtask->nsproxy->pid_ns_for_children다음 fork / clone 자식이 들어갈 PID namespaceunshare(CLONE_NEWPID) 직후 현재 task의 PID는 바뀌지 않습니다.
자식 Time viewtask->nsproxy->time_ns_for_children다음 자식이 상속할 time namespaceTime NS는 exec_task_namespaces()까지 엮이는 특수 경로가 있습니다.
User NS / capabilitytask->cred->user_nsUID/GID 매핑과 권한 판정 기준User NS는 nsproxy가 아니라 cred에 붙습니다.
root / pwdtask->fs + task->nsproxy->mnt_ns현재 작업 디렉터리와 루트 디렉터리mount namespace만 바꿔도 fs_struct 정합성까지 맞춰야 합니다.

또 하나 중요한 사실은 각 concrete namespace 객체가 다시 자기 소유 user_namespace를 들고 있다는 점입니다. 예를 들어 struct net, struct pid_namespace, struct ipc_namespace, struct time_namespace에는 보통 struct user_namespace *user_nsstruct ns_common ns가 들어 있습니다. 즉 nsproxy는 "어떤 namespace 객체를 볼 것인가"를 결정하고, cred->user_ns는 "그 객체를 조작할 권한이 있는가"를 판정합니다.

예외: PID와 Time은 현재 뷰와 자식 뷰가 분리된다

nsproxy를 이해할 때 가장 자주 놓치는 부분이 바로 pid_ns_for_childrentime_ns_for_children입니다. 이름 그대로 이 둘은 현재 task 자체가 즉시 쓰는 namespace가 아니라, 앞으로 만들어질 자식에게 적용할 namespace입니다. 이 설계는 "이미 존재하는 task의 PID를 중간에 갈아끼울 수 없는" 현실과, Time NS가 vvar/vDSO와 맞물려 단계적으로 전환되어야 하는 제약에서 나옵니다.

# 현재 task의 "즉시 적용된" namespace와 "다음 자식용" namespace를 같이 본다.
readlink /proc/self/ns/pid
readlink /proc/self/ns/pid_for_children
readlink /proc/self/ns/time
readlink /proc/self/ns/time_for_children

# PID namespace는 보통 자식을 만들어야 차이가 드러난다.
unshare --pid --fork --mount-proc /bin/sh

컨테이너 런타임이 초기 진입기(process bootstrap)를 별도로 두는 이유도 여기에 있습니다. PID namespace를 바꾸고 싶다면 기존 관리 프로세스 자신이 즉시 "새 PID 1"이 될 수는 없으므로, 보통 얇은 부모 프로세스가 새 namespace를 준비하고 진짜 workload 자식을 그 안에서 시작합니다.

공유, 복사, 해제 수명

nsproxy 자체의 count는 "이 nsproxy를 참조하는 task 수"를 뜻합니다. 반면 각 concrete namespace 객체의 refcount는 "이 namespace 객체를 가리키는 nsproxy 수"를 뜻합니다. 헤더 주석에서도 이 점을 분명히 말합니다. 따라서 스레드 10개가 하나의 nsproxy를 공유해도, net_nsmnt_ns의 refcount는 task 수가 아니라 그 namespace를 가리키는 nsproxy 수를 기준으로 변합니다.

/* include/linux/nsproxy.h 의 핵심 규칙 요약 */
/* 1. current만 자신의 tsk->nsproxy 포인터를 바꿀 수 있다. */
/* 2. current의 namespace를 읽을 때는 그냥 역참조하면 된다. */
/* 3. 다른 task의 namespace를 읽을 때는 task_lock(task)가 필요하다. */
/* 4. task->nsproxy == NULL 이면 거의 종료 직전인 task일 수 있다. */

static inline void put_nsproxy(struct nsproxy *ns)
{
    if (refcount_dec_and_test(&ns->count))
        deactivate_nsproxy(ns);
}

최신 커널 트리의 kernel/nsproxy.c를 보면 deactivate_nsproxy()는 결국 put_mnt_ns(), put_uts_ns(), put_ipc_ns(), put_pid_ns(), put_time_ns(), put_cgroup_ns(), put_net()를 차례로 호출해 하위 namespace 객체들의 refcount를 내립니다. 즉 nsproxy가 마지막으로 해제되는 순간이, concrete namespace 객체들의 수명 감소가 시작되는 지점입니다.

clone, unshare, setns에서 nsproxy가 바뀌는 경로

최신 커널 트리 기준으로 nsproxy는 세 가지 큰 경로에서 움직입니다. clone()/clone3()는 자식 task를 만들면서 결정하고, unshare()는 현재 task의 문맥을 갈라내며, setns()는 기존 namespace로 현재 task를 이동시킵니다. 세 경로 모두 최종적으로는 "새 namespace 묶음을 준비한 뒤 switch_task_namespaces()로 교체"하는 형태를 취합니다.

/* clone 경로: copy_process() 내부 */
copy_process(...)
    -> copy_namespaces(flags, child)
       -> /* NEW* 플래그 없음 */ get_nsproxy(old)
       -> /* NEW* 플래그 있음 */ create_new_namespaces(...)
       -> child->nsproxy = new

/* unshare 경로: 현재 task 분리 */
ksys_unshare(...)
    -> unshare_userns(...)
    -> unshare_nsproxy_namespaces(...)
    -> switch_task_namespaces(current, new_nsproxy)

/* setns 경로: fd 또는 pidfd 기반 진입 */
setns(fd, flags)
    -> prepare_nsset(...)
    -> validate_ns() / validate_nsset()
    -> commit_nsset(...)
    -> switch_task_namespaces(current, nsset.nsproxy)
nsproxy가 생성되고 교체되는 세 경로 1. clone / clone3 copy_process() CLONE_NEW* 포함? 아니오 get_nsproxy(old) child가 공유 create_new_namespaces() 새 nsproxy 부착 공유 경로는 빠르고, 생성 경로는 새 묶음을 준비 2. unshare ksys_unshare() unshare_userns(), unshare_fs() 멀티스레드 / CLONE_FS 제약 검사 unshare_nsproxy_namespaces() create_new_namespaces()로 새 묶음 준비 switch_task_namespaces(current, new) 현재 task 문맥을 교체 3. setns setns(fd 또는 pidfd, flags) prepare_nsset() validate_ns() / validate_nsset() 대상 task 스냅샷, 권한, 종류 검증 commit_nsset() cred / fs / IPC / time 정리 후 switch 특수점: CLONE_NEWPID 와 CLONE_NEWTIME 는 현재 task보다 "다음 자식"에 먼저 반영되는 경우가 있어 fork / exec 경계를 함께 봐야 합니다.
clone()은 보통 공유 경로가 빠르고, unshare()setns()는 새 nsproxy 묶음을 준비한 뒤 최종 commit 단계에서 현재 task에 전환합니다.

clone 경로: 최신 kernel/nsproxy.ccopy_namespaces()CLONE_NEWNS, CLONE_NEWUTS, CLONE_NEWIPC, CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWCGROUP, CLONE_NEWTIME가 하나도 없으면 빠르게 기존 nsproxy를 공유합니다. 새 namespace가 필요할 때만 create_new_namespaces()를 호출해 자식에게 새 묶음을 달아 줍니다.

unshare 경로: unshare()는 이미 실행 중인 current task를 바꾸므로 비활성 child에 대해 동작하는 copy_* 경로를 그대로 쓸 수 없습니다. 그래서 ksys_unshare()는 먼저 CLONE_NEWUSERCLONE_THREAD | CLONE_FS를 자동 포함시키고, CLONE_NEWNSCLONE_FS를 같이 요구하는 등 제약을 정리한 뒤, 새 cred, 새 fs_struct, 새 nsproxy를 준비해 마지막에 현재 task로 스위치합니다.

setns 경로: 최근 커널의 setns()는 오래된 "/proc/<pid>/ns/net 파일 디스크립터 하나" 방식뿐 아니라 pidfd + namespace bitmask 경로도 갖습니다. 내부적으로는 prepare_nsset()가 임시 nsset를 만들고, validate_ns() 또는 validate_nsset()가 대상 task의 nsproxycred를 스냅샷으로 잡은 뒤, 모든 검증이 끝난 후 commit_nsset()commit_creds(), timens_commit(), switch_task_namespaces()를 순서대로 수행합니다. 즉 검증 실패로 중간 상태가 남지 않도록 일괄 적용하는 구조입니다.

/* include/linux/nsproxy.h - setns / unshare 지원용 보조 컨텍스트 */
struct nsset {
    unsigned flags;
    struct nsproxy *nsproxy;
    struct fs_struct *fs;
    const struct cred *cred;
};

int copy_namespaces(u64 flags, struct task_struct *tsk);
int unshare_nsproxy_namespaces(unsigned long flags,
    struct nsproxy **new_nsp, struct cred *cred, struct fs_struct *fs);
void switch_task_namespaces(struct task_struct *tsk, struct nsproxy *new);

/proc/pid/ns, nsfs, bind mount로 namespace를 붙잡는 법

네임스페이스를 사용자 공간에서 다루게 해 주는 접점은 /proc/<pid>/ns/*입니다. 최신 fs/proc/namespaces.c를 보면 procfs는 net, uts, ipc, pid, pid_for_children, user, mnt, cgroup, time, time_for_children 같은 항목을 노출합니다. 이 파일들은 보통 symlink처럼 보이지만, 실제로는 nsfs inode와 struct ns_common를 경유해 namespace 객체를 가리킵니다.

namespace 객체가 nsfs 파일 디스크립터로 노출되는 과정 concrete namespace net / mnt / pid / time ... 실제 커널 자원과 상태 struct ns_common ops, inum, count proc_ns_operations 와 연결 readlink 시 name:[inum] 표기 /proc/1234/ns/net procfs 엔트리 open(), readlink(), stat() 가능 setns() 입력 핸들로 사용 open fd / bind mount 열린 파일 디스크립터 또는 /run/ns/demo 로 bind namespace 수명을 고정 의미 마지막 task가 종료해도 열린 fd나 bind mount가 남아 있으면 namespace는 즉시 사라지지 않습니다.
nsproxy는 task와 namespace 객체를 연결하고, nsfs는 그 namespace 객체를 파일 디스크립터로 바꿔 사용자 공간에서 보존하고 재진입할 수 있게 합니다.
# 네임스페이스 식별자 확인
readlink /proc/self/ns/net
readlink /proc/self/ns/mnt
readlink /proc/self/ns/pid_for_children

# namespace를 파일 경로로 고정하여 수명 유지
mkdir -p /run/ns
mount --bind /proc/$TARGET_PID/ns/net /run/ns/demo-net

# 이후 task가 모두 종료해도 bind mount가 남아 있으면 namespace는 유지될 수 있다.
nsenter --net=/run/ns/demo-net ip link show
umount /run/ns/demo-net

운영 환경에서는 이 메커니즘이 자주 쓰입니다. CNI 플러그인이나 디버깅 도구가 컨테이너 네트워크 namespace를 잠시 보존하려고 /proc/$pid/ns/net를 bind mount해서 /var/run/netns/... 아래에 두는 방식이 대표적입니다. 이렇게 하면 원래 task가 죽어도 namespace 핸들이 남아 있어 추후 setns()nsenter로 재진입할 수 있습니다.

실전 디버깅 체크리스트

PID 번역 원리

PID 네임스페이스는 계층적 번역(hierarchical translation) 모델을 사용합니다. 하나의 프로세스가 여러 PID 네임스페이스에서 서로 다른 PID를 가질 수 있습니다:

/* 커널 내부: 프로세스는 레벨별로 PID를 가짐 */
struct pid {
    refcount_t count;
    unsigned int level;        /* PID NS 깊이 */
    struct upid numbers[];      /* 각 레벨별 PID 번호 */
};

struct upid {
    int nr;                        /* 해당 NS에서의 PID 값 */
    struct pid_namespace *ns;      /* 소속 NS */
};

/* 예: 컨테이너 프로세스의 PID 매핑
 * Host NS (level 0): PID 1234
 * Container NS (level 1): PID 1
 * → numbers[0] = {nr=1234, ns=host_ns}
 * → numbers[1] = {nr=1, ns=container_ns}
 *
 * 자식 NS에서는 부모 NS의 PID를 볼 수 없지만,
 * 부모 NS에서는 자식의 모든 프로세스를 볼 수 있음
 */
ℹ️

User 네임스페이스의 특수성: User NS는 nsproxy가 아닌 cred(자격 증명) 구조체에서 관리됩니다. 이는 UID/GID 매핑이 프로세스의 보안 컨텍스트에 직접 영향을 미치기 때문입니다. User NS는 다른 모든 네임스페이스의 "소유자(owner)"를 결정하며, 비특권 사용자도 User NS 내에서 다른 네임스페이스를 생성할 수 있게 합니다.

네임스페이스 유형

네임스페이스격리 대상시스템 콜 플래그
PID프로세스 IDCLONE_NEWPID
Mount마운트 포인트CLONE_NEWNS
Network네트워크 스택CLONE_NEWNET
UserUID/GID 매핑CLONE_NEWUSER
UTS호스트명, 도메인명CLONE_NEWUTS
IPCSystem V IPC, POSIX MQCLONE_NEWIPC
CgroupCgroup 루트 디렉터리CLONE_NEWCGROUP
TimeCLOCK_MONOTONIC 등CLONE_NEWTIME

Time Namespace 통합 요약

Time Namespace는 다른 네임스페이스와 달리 "자원 자체"가 아니라 CLOCK_MONOTONIC/CLOCK_BOOTTIME의 읽기 오프셋을 격리합니다. 컨테이너 체크포인트/복원(CRIU) 시 시간축 일관성을 보장하는 것이 주목적입니다.

네임스페이스 API

#include <sched.h>

/* 새 네임스페이스로 프로세스 생성 */
clone(child_fn, stack, CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | SIGCHLD, arg);

/* 현재 프로세스를 새 네임스페이스로 이동 */
unshare(CLONE_NEWNS | CLONE_NEWPID);

/* 기존 네임스페이스에 진입 */
int fd = open("/proc/<pid>/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET);

PID Namespace

PID 네임스페이스는 프로세스 ID를 격리합니다. 새 PID NS 내의 첫 프로세스는 PID 1이 되며, 부모 NS에서는 다른 PID로 보입니다.

PID Namespace 계층 Host PID NS (PID 1=systemd, PID 1234=container) Container PID NS (PID 1=init, PID 2=app) 컨테이너 내부에서는 PID 1, 호스트에서는 PID 1234

Network Namespace

네트워크 네임스페이스는 네트워크 인터페이스, 라우팅 테이블, iptables 규칙, 소켓 등을 완전히 격리합니다. veth 페어로 네임스페이스 간 통신을 구성합니다.

# 네트워크 네임스페이스 생성
ip netns add myns

# veth 페어 생성 및 연결
ip link add veth0 type veth peer name veth1
ip link set veth1 netns myns

# 네임스페이스 내에서 명령 실행
ip netns exec myns ip addr add 10.0.0.2/24 dev veth1
ip netns exec myns ip link set veth1 up
veth 페어를 통한 네트워크 네임스페이스 격리 Host Network Namespace veth0 10.0.0.1/24 UP, RUNNING Host 네트워크 스택 (라우팅 테이블, iptables, 소켓 등) Container Network Namespace veth1 10.0.0.2/24 UP, RUNNING Container 네트워크 스택 (독립된 라우팅 테이블, iptables) veth 페어 (커널 내부 연결) 한쪽으로 전송한 패킷이 다른 쪽에서 수신됨 ip link add veth0 type veth peer name veth1 | ip link set veth1 netns myns | ip netns exec myns ip addr add 10.0.0.2/24 dev veth1
💡

lsns 명령으로 시스템의 모든 네임스페이스를 확인할 수 있습니다. /proc/[pid]/ns/ 디렉터리에서 프로세스가 속한 네임스페이스의 파일 디스크립터를 얻을 수 있습니다.

Mount Namespace

Mount namespace는 프로세스별로 독립적인 마운트 포인트 테이블을 제공합니다. 컨테이너가 호스트와 다른 파일시스템 뷰를 가질 수 있게 합니다. 내부적으로 각 Mount NS는 자체 struct mnt_namespace를 가지며, 이 안에 마운트 포인트의 트리 구조가 저장됩니다.

Mount Propagation 원리

Mount namespace 간의 마운트 이벤트 전파는 peer group 메커니즘으로 제어됩니다. 새 Mount NS를 생성하면 기본적으로 부모와 peer group을 형성하여 마운트 이벤트가 양방향으로 전파됩니다. propagation 유형에 따라 이 동작을 제어합니다:

/* 새 mount namespace에서 프로세스 생성 */
int flags = CLONE_NEWNS;
unshare(flags);

/* pivot_root: 컨테이너의 루트 파일시스템 변경 */
pivot_root("./newroot", "./newroot/oldroot");
umount2("/oldroot", MNT_DETACH);
# mount namespace 생성 및 확인
unshare --mount --propagation private /bin/bash
mount --bind /tmp/myroot /mnt
# 호스트에서는 /mnt가 변경되지 않음

# mount propagation 유형
mount --make-shared /mnt     # 양방향 전파
mount --make-slave /mnt      # 단방향 (호스트→컨테이너)
mount --make-private /mnt    # 전파 없음
mount --make-unbindable /mnt # bind mount도 불가
Mount Propagation 유형 비교 shared Host NS Child NS 양방향 전파 peer group 공유 mount/umount 이벤트 동기화 slave Host NS Child NS 단방향 전파 Host → Child만 Child 마운트는 격리됨 private Host NS Child NS 전파 없음 완전 격리 독립적인 마운트 트리 unbindable Host NS Child NS bind mount 차단 private + 추가 제한 보안 격리에 유용 컨테이너 환경에서의 권장 사용 shared 활용 • 호스트↔컨테이너 동기화 • 동적 볼륨 마운트 • Kubernetes CSI 기본값 (unshare 시) propagation 미지정 시 slave 활용 • 호스트 마운트 수신만 • 컨테이너 격리 강화 • systemd-nspawn 보안/격리 균형 Docker 기본 설정 private 활용 • 완전 독립 환경 • 임시 컨테이너 • 테스트 격리 최대 격리 --propagation private unbindable 활용 • 보안 크리티컬 경로 • /proc, /sys 보호 • 재마운트 방지 보안 강화 샌드박스 환경

User Namespace

User namespace는 UID/GID 매핑을 격리합니다. 컨테이너 내에서 root(UID 0)로 보이지만 호스트에서는 일반 사용자입니다.

/* user namespace 생성 */
unshare(CLONE_NEWUSER);

/* UID 매핑 설정: /proc/PID/uid_map */
/* 형식: container_uid  host_uid  range */
/* 예: "0 1000 1" → 컨테이너 UID 0 = 호스트 UID 1000 */
# unprivileged 컨테이너 생성
unshare --user --map-root-user /bin/bash
id   # uid=0(root) gid=0(root)  (컨테이너 내부에서)

# UID 매핑 확인
cat /proc/self/uid_map
#          0       1000          1

Cgroup Namespace

Cgroup namespace는 프로세스의 cgroup 뷰를 격리합니다. 컨테이너 내에서 /proc/self/cgroup이 루트(/)로 보입니다.

UTS와 IPC Namespace

# UTS namespace: 호스트네임 격리
unshare --uts /bin/bash
hostname container-host
hostname  # container-host (호스트에는 영향 없음)

# IPC namespace: System V IPC, POSIX 메시지 큐 격리
unshare --ipc /bin/bash
ipcs  # 빈 IPC 테이블 (호스트의 IPC 객체 안보임)

네임스페이스 생명주기와 관리

/* 네임스페이스 생성 방법 3가지 */

/* 1. clone() 시 플래그로 생성 */
clone(child_fn, stack, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, arg);

/* 2. unshare()로 현재 프로세스의 namespace 분리 */
unshare(CLONE_NEWNS | CLONE_NEWPID);

/* 3. setns()로 기존 namespace에 진입 */
int fd = open("/proc/PID/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET);
close(fd);
# 프로세스의 네임스페이스 확인
ls -la /proc/self/ns/
# cgroup -> cgroup:[4026531835]
# ipc    -> ipc:[4026531839]
# mnt    -> mnt:[4026531841]
# net    -> net:[4026531840]
# pid    -> pid:[4026531836]
# user   -> user:[4026531837]
# uts    -> uts:[4026531838]

# 네임스페이스를 파일로 유지 (bind mount)
touch /run/netns/my_netns
mount --bind /proc/self/ns/net /run/netns/my_netns
# ip netns는 이 방식 사용

# nsenter: 기존 네임스페이스에 진입
nsenter --target $PID --net --pid --mount /bin/bash

컨테이너 런타임과의 관계

Docker, Podman 등의 컨테이너 런타임은 namespace를 조합하여 격리된 환경을 구성합니다:

Namespace격리 대상커널 버전clone 플래그
Mount파일시스템 마운트 포인트2.4.19CLONE_NEWNS
UTS호스트네임, 도메인네임2.6.19CLONE_NEWUTS
IPCSystem V IPC, POSIX MQ2.6.19CLONE_NEWIPC
PID프로세스 ID2.6.24CLONE_NEWPID
Network네트워크 스택2.6.29CLONE_NEWNET
UserUID/GID 매핑3.8CLONE_NEWUSER
Cgroupcgroup 루트 디렉토리4.6CLONE_NEWCGROUP
Time부트 시간, 모노토닉 시계5.6CLONE_NEWTIME
💡

참고 자료: LWN: Namespaces in operation 시리즈, man 7 namespaces, man 7 pid_namespaces, man 7 network_namespaces

네임스페이스 관련 주요 취약점

User namespace는 비특권 사용자에게 네임스페이스 내부의 CAP_SYS_ADMIN을 부여하므로, 커널의 다양한 공격 면적을 노출합니다. 많은 커널 취약점이 user namespace를 전제 조건으로 요구하며, 이는 namespace 자체의 보안 설계와 밀접하게 연관됩니다.

CVE-2022-0185 — fsconfig 힙 오버플로우 (네임스페이스 탈출):

fsconfig() 시스템 콜의 legacy_parse_param()에서 파라미터 길이 검증 부족으로 정수 언더플로우가 발생합니다. User namespace 내의 CAP_SYS_ADMIN만으로 트리거 가능하며, 힙 오버플로우를 통해 init namespace로 탈출할 수 있습니다. Linux 5.1~5.16에 영향을 미칩니다.

CVE-2023-2640 / CVE-2023-32629 (GameOver(lay)) — OverlayFS + User NS 권한 상승:

Ubuntu 커널에 적용된 OverlayFS 패치에서, user namespace 내에서 OverlayFS를 마운트할 때 trusted.overlayfs.metacopy xattr 설정이 권한 검사를 우회합니다. 이를 통해 비특권 사용자가 임의 파일에 setuid/setcap 속성을 설정하여 root 권한을 획득할 수 있습니다.

/* User Namespace가 공격 전제 조건인 주요 CVE 목록 */

/*
 * CVE-2022-0185: fsconfig() 힙 오버플로우
 *   → user NS의 CAP_SYS_ADMIN으로 fsconfig() 호출 가능
 *
 * CVE-2022-0492: cgroup v1 release_agent
 *   → user NS + cgroup NS로 cgroup 마운트 가능
 *
 * CVE-2022-1015: nf_tables 스택 버퍼 오버플로우
 *   → user NS의 CAP_NET_ADMIN으로 nf_tables 규칙 생성
 *
 * CVE-2023-0386: OverlayFS + FUSE setuid 우회
 *   → user NS에서 FUSE + OverlayFS 마운트 가능
 *
 * CVE-2023-32233: nf_tables 익명 set UAF
 *   → user NS의 CAP_NET_ADMIN으로 nf_tables 조작
 *
 * CVE-2024-1086: nf_tables verdict UAF
 *   → user NS의 CAP_NET_ADMIN으로 트리거
 */

/* User Namespace 공격 면적 제한 방법 */

# 방법 1: User NS 완전 비활성화 (가장 강력)
sysctl -w user.max_user_namespaces=0

# 방법 2: 비특권 User NS 비활성화 (Debian/Ubuntu)
sysctl -w kernel.unprivileged_userns_clone=0

# 방법 3: BPF와 perf 제한 (user NS 내 CAP_SYS_ADMIN 영향 축소)
sysctl -w kernel.unprivileged_bpf_disabled=2
sysctl -w kernel.perf_event_paranoid=3

# 방법 4: AppArmor로 user NS 내 특정 작업 제한 (Ubuntu 23.10+)
# /etc/apparmor.d/unprivileged_userns 프로파일 활용
PID Namespace 보안 고려사항:

PID 재사용 공격: PID namespace 내에서 프로세스가 종료되고 동일 PID가 재할당될 때, kill()이나 ptrace() 등의 시스템 콜이 의도하지 않은 프로세스에 작용할 수 있습니다. pidfd_open()(5.3+)을 사용하여 PID 대신 파일 디스크립터로 프로세스를 참조하면 이 문제를 방지할 수 있습니다.
프로세스 정보 누출: /proc 파일시스템의 hidepid=2 마운트 옵션을 사용하여 다른 사용자의 프로세스 정보를 숨길 수 있습니다.

네임스페이스와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.