개발일기/Docker

5. pivot_root로 루트 파일 시스템 바꾸기

ignuy 2025. 6. 16.

Go로 "컨테이너스러운" 프로세스를 만들어보자.

 

지난 포스팅에서 chroot()를 사용해 Mount 네임스페이스를 격리하였다. 하지만, 이 상황에는 몇 가지 문제가 있다.

chroot()는 단순히 루트 디렉토리의 보이는 경로를 바꾸고 기존 루트가 그대로 남아있다. 따라서,  ..  등으로 탈출 가능성이 있어 보안상 불완전하고 프로세스가 여전히 이전 루트를 참조한다.

따라서 오늘은 pivot_root를 사용해 정상적인 루트 격리 환경을 만들 것이다. 이때, 우분투 베이스 파일시스템을 사용해 실사용 컨테이너 환경에 가까운 구조를 만들자.

🔍 왜 pivot_root?

pivot_root는 chroot와 다르게 루트 디렉토리 자체를 완전히 교체한다. 기존 루트가 새 위치로 이동되기 때문에 이전 루트 자체가 사라진다. 이는 실제 컨테이너에서 사용하는 방식이다.

항목 chroot() pivot_root()
보안 격리 ❌ (우회 가능) ✅ (완전 격리)
루트 변경 방식 루트 "보이기"만 바꿈 실제 루트를 새로 설정
루트 탈출 방지 어렵다 쉬움
도커 사용 여부
pivot_root()는 존 루트를 새 루트 하위 디렉터리로 옮기고, 새로운 루트를  로 바꾸기 때문에 호스트 파일시스템과 완전히 끊긴 환경을 만들 수 있다.

🏗️ 우분투/데비안 기반 루트 파일시스템 만들기

지난 포스팅에서는 최소 단위 구성을 위해 busybox를 사용했지만 이번에는 Ubuntu 기반의 루트 파일시스템을 구성하기 위해 다른 패키지를 사용한다. Ubuntu에서 정식으로 제공하는 debootstrap을 사용하면 루트 파일시스템을 쉽게 만들 수 있다. 본인이 사용하는 패키지 매니저를 활용하여 debootstrap을 설치하고 아래 명령어를 입력하자.

sudo mkdir -p /tmp/ubuntufs
sudo debootstrap jammy /tmp/ubuntufs http://archive.ubuntu.com/ubuntu

위 명령은 Ubuntu 22.04 (jammy)의 루트 파일시스템을 /tmp/ubuntufs에 설치해준다(환경에 따라 소요시간이 길어질 수 있다).

 

🔧 코드 수정

우선 run() 함수에서 CLONE_NEWNS를 포함하고 있는지 다시 확인한다.

// isolation namespace
cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWPID |
        syscall.CLONE_NEWUTS |
        syscall.CLONE_NEWNS,
}

🛠️ child() 함수 전체 수정

작업은 크게 아래와 같은 흐름으로 진행할 것이다.

1. 루트 디렉터리 바꾸기 : pivot_root
2. /proc, /sys, /dev 마운트하기
3. 기존 루트 언마운트 후 제거하기

전체 코드를 먼저 보여주고 부분 코드를 통해 흐름을 쫓아가보자.

📂 전체 코드

// Exec in child process
func child() {
	fmt.Printf("Running child process PID %d\n", syscall.Getpid())

	must(syscall.Sethostname([]byte("containerM")), "set hostname")

	// Configure new root
	newRoot := "/tmp/ubuntufs"
	putOld := newRoot + "/.pivot_root"

	// Mount point isolation
	must(syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""), "make mount private")

	// bind mount new root to itself (required by pivot_root)
	must(syscall.Mount(newRoot, newRoot, "", syscall.MS_BIND|syscall.MS_REC, ""), "bind mount newRoot")

	// make .pivot_root directory
	must(os.MkdirAll(putOld, 0700), "create putOld")

	// pivot_root(new_root, put_old)
	must(syscall.PivotRoot(newRoot, putOld), "pivot_root")

	// change working directory to new root
	must(os.Chdir("/"), "chdir to /")

	// unmount old root
	must(syscall.Unmount("/.pivot_root", syscall.MNT_DETACH), "unmount old root")
	must(os.RemoveAll("/.pivot_root"), "remove old root")

	// mount /proc, /sys, /dev
	must(syscall.Mount("proc", "/proc", "proc", 0, ""), "mount /proc")
	must(syscall.Mount("sysfs", "/sys", "sysfs", 0, ""), "mount /sys")
	must(syscall.Mount("tmpfs", "/dev", "tmpfs", 0, ""), "mount /dev")

	cmd := exec.Command(os.Args[2], os.Args[3:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	must(cmd.Run())
}

1. 경로 설정

// Configure new root
newRoot := "/tmp/ubuntufs"
putOld := newRoot + "/.pivot_root"

newRoot는 새로운 루트 파일 시스템의 경로이다. 이 경로가 컨테이너의 루트가 될 디렉토리이고 putOld는 기존루트( / )가 임시로 이동될 디렉토리이다. 후에 pivot_root()에서 사용할 것이다.

 

2. 마운트 격리

run() 함수에서 마운트 네임스페이스를 cmd.SysProcAttr를 이용해서 만들었다. 이제 컨테이너 안에서 하는 마운트 작업은 밖에서는 보이지 않는다.

must(syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""), "make mount private")

마운트 네임스페이스를 만들면 처음에는 부모의 마운트 상태를 그대로 복사하기 때문에 mount 해도 여전히 부모(호스트)와 공유될 수 있다.

따라서, 위 코드를 통해 mount 작업을 나만 보게 설정하고(MS_PRIVATE) 이를 재귀적으로 모든 마운트 포인트에 적용 (MS_REC)하도록 설정한다.

3. privot_root 마운트

pivot_root(newRoot, putOld)는 리눅스 시스템에서 "지금 사용 중인 루트 디렉터리를 새로운 디렉터리로 바꾸는 기능"이다. 즉, 원래 루트 디렉터리( / )를 putOld위치로 옮기고 새로운 디렉토리 newRoot를 루트( / )로 만든다. 

이때, pivot_root 시스템 콜은 newRoot와 putOld 둘 다 마운트된 파일 시스템이어야 하는 조건을 가진다.

// bind mount newRoot to itself (required by pivot_root)
syscall.Mount(newRoot, newRoot, "", syscall.MS_BIND|syscall.MS_REC, "")

따라서 위 라인을 명시하여 newRoot( /tmp/ubuntufs)라는 디렉토리를 그 자체에 bind-mount해서, 이 디렉터리 위에 진짜 마운트된 파일시스템처럼 보이게 만들어야 한다.

마운트되지 않은 디렉터리는 아래처럼 pivot_root가 거부한다.

위 코드 라인 주석 처리 후 실행 결과

4. 루트 디렉토리 변경

이제 기존 루트를 임시로 옮겨두고 루트를 newRoot로 변경한 후, 디렉토리 위치를 새 루트의  / 로 이동시킨다.

// make .pivot_root directory
must(os.MkdirAll(putOld, 0700), "create putOld")

// pivot_root(new_root, put_old)
must(syscall.PivotRoot(newRoot, putOld), "pivot_root")

// change working directory to new root
must(os.Chdir("/"), "chdir to /")

5. 리소스 정리

이제 기존 루트는 필요가 없어졌으니 제거하자.

// unmount old root
must(syscall.Unmount("/.pivot_root", syscall.MNT_DETACH), "unmount old root")
must(os.RemoveAll("/.pivot_root"), "remove old root")

6. 가상 파일 시스템 새로 마운트

컨테이너 내부에서 시스템 명령어들이 동작하도록 필요한 가상 파일 시스템들을 새로 마운트한다.

// mount /proc, /sys, /dev
must(syscall.Mount("proc", "/proc", "proc", 0, ""), "mount /proc")
must(syscall.Mount("sysfs", "/sys", "sysfs", 0, ""), "mount /sys")
must(syscall.Mount("tmpfs", "/dev", "tmpfs", 0, ""), "mount /dev")

🔍 동작 요약

단계 설명
pivot_root 기존 루트를 /tmp/ubuntufs/.pivot_root로 옮기고 /tmp/ubuntufs를 /로 설정
unmount /.pivot_root 호스트 루트로의 연결 끊기
mount /proc /sys /dev 컨테이너 안에서 필요한 시스템 파일 제공
exec.Command(...) bash나 sh 실행

 

🧪 실험

go build -o containerM .
./containerM run /bin/bash

위 명령어로 containerM에서 bash를 실행해보자.

 

# hostname
containerM

# mount | grep -E 'proc|sys|dev'
proc on /proc type proc (...)
sysfs on /sys type sysfs (...)
tmpfs on /dev type tmpfs (...)

# ls /
bin boot dev etc home lib ...

⚠️ 주의 사항

앞서 설명했듯, pivot_root()는 newRoot와 putOld가 동일한 mount point 아래 있어야 한다. bind mount 없이 pivot_root()를 호출하면 invalid argument 오류가 발생할 수 있다.

또한, 현재 컨테이너는 최소한의 파일 시스템 격리를 구현했다. /dev는 tmpfs로 마운트했으므로, 필요한 경우 mknod로 null, zero 같은 디바이스도 만들어야 한다.

📌 정리

기능 적용 방법
진짜 루트 교체 pivot_root(newRoot, putOld)
마운트 격리 CLONE_NEWNS, MS_PRIVATE
필수 시스템 마운트 /proc, /sys, /dev
컨테이너와 호스트 완전 분리

🔜 다음 이야기

이제 컨테이너는 완전히 독립적인 파일 시스템을 갖게 되었다. 다음 파트에서는 User 네임스페이스로 권한을 격리해보자.

 

댓글