[도커 만들기] 2탄: 탈옥을 막아라

이지호·2025년 7월 25일
3

도커 만들기

목록 보기
2/3
post-thumbnail

0. 지난 이야기

'도커 만들기' 시리즈 2탄으로 돌아왔다. 도커가 리눅스 커널에 이미 존재하던 chroot, namespace, cgroup 같은 기능들을 조합해서 만들어진 것이라 하니, 이 정도는 직접 구현해볼 수 있지 않을까 싶어서 진짜로 만들어보는 중이다.

지난 포스팅([도커 만들기] 1탄: 프로세스 가두기)에서는 파일 시스템 격리를 위해 chroot를 사용해봤다. 프로세스가 chroot로 지정한 루트 바깥의 디렉토리에 접근할 수 없도록 말이다. 하지만 chroot는 완벽하지 않았다. 특정 C 코드를 실행하니 프로세스가 chroot 바깥으로 빠져나오는, 이른바 '탈옥' 문제가 발생했다.

이번 2탄에서는 지난번 chroot 실습에서 마주했던 이 '탈옥' 문제를 어떻게 해결할지 이야기해보고자 한다.

1. 배경지식: 파일 시스템에 관하여

지금까지 우리는 chroot로 프로세스를 격리했다. 이는 프로세스가 접근할 수 있는 디렉토리의 범위를 제한하기 위한 목적이었다.

하지만 1탄에서 보았듯이, chroot만으로는 완벽한 격리가 불가능했다.

이런 chroot의 보안 취약점을 해결하기에 앞서 먼저 파일 시스템이 무엇인지 알아야 한다.

OS의 파일 시스템 개념부터 차근차근 짚고 넘어가보자.

파일 시스템이란?

파일 시스템은 파일과 디렉토리를 보조 기억 장치(하드 디스크 등)에 일목요연하게 저장하고 접근할 수 있게 해주는 운영체제 내부의 프로그램이다.

좀 더 쉽게 말하면, 하드디스크에는 0과 1로 이루어진 데이터만 저장되어 있는데, 파일 시스템이 이 데이터들을 우리에게 친숙한 파일과 디렉토리 구조로 보여주는 역할을 한다.

파일 시스템에는 다양한 종류가 있다.

  • ext4, NTFS, FAT32: 실제 하드디스크에 데이터를 영구 저장
  • tmpfs: RAM에 임시로 저장 (재부팅하면 사라짐)

이처럼 파일 시스템에는 다양한 종류가 있고, 하나의 컴퓨터에서 여러 파일 시스템이 동시에 사용될 수 있다. 왜 동시에 사용될 필요가 있는걸까? 그 이유는 아래 파티셔닝과 포매팅 부분에서 바로 나온다.

파티셔닝과 포매팅

갓 공장에서 나온 새 하드디스크가 있다고 해보자. 하지만 우리는 바로 여기에 데이터를 저장할 수 없다. '파티셔닝'과 '포매팅' 작업이 필요하다.

파티셔닝저장 장치를 논리적인 구역으로 나누는 작업이다. 마치 큰 서랍을 칸막이로 나누어 정리하는 것과 비슷하다. 이처럼 하나의 하드디스크를 여러 개의 독립적인 영역으로 분리할 수 있다.

포매팅은 파티션에 어떤 파일 시스템을 사용할지 결정하고 초기화하는 작업이다. 이 단계에서 ext4, NTFS, FAT32 등 파일 시스템 종류가 결정된다. 이때 각 파티션마다 어떤 종류의 파일 시스템을 사용할지 결정되며, 파티션마다 서로 다른 파일 시스템을 설정할 수 있다.

이렇게 파티셔닝과 포매팅을 통해 한 컴퓨터에서 여러 개의 파일 시스템이 사용되는 것이다.

그렇다면 이처럼 하나의 컴퓨터에서 여러 파일 시스템을 동시에 사용해야하는 이유는 무엇인가? 바로 파티션 별 용도에 따른 최적화를 위함이다. 예를 들자면 아래와 같은 용례가 있다.

  • 시스템 파티션: 부팅에 필요한 핵심 파일들은 안정성이 뛰어난 ext4 사용
  • 임시 파일 파티션: 빠른 접근이 필요한 임시 파일들은 메모리 기반 tmpfs 사용
  • 데이터 파티션: 사용자의 중요한 문서들은 저널링을 지원하는 ext4 사용
  • 공유 파티션: 다른 OS와 파일을 주고받을 때는 호환성이 좋은 FAT32 사용

이처럼 각 영역의 특성에 맞는 파일 시스템을 선택함으로써 성능과 안정성을 모두 확보할 수 있다.

이제 이 개념을 바탕으로 마운트를 이해해보자.

마운트

'저장 장치를 마운트한다'는 표현을 들어본 적 있는가?

마운트(Mount)란, 서로 다른 파일 시스템을 하나의 디렉토리 트리로 통합하여 접근할 수 있게 하는 작업이다. 말이 좀 어려운데, 예시로 이해해보자.

컴퓨터에 USB를 꽂는 상황으로 예를 들어보겠다. 사용자의 입장에서는 나의 컴퓨터 디렉토리 하에서 USB 디렉토리에 자연스레 접근할 수 있다.

아래와 같이 컴퓨터와 USB의 디렉토리 구조가 구성되어 있다.
컴퓨터와 USB 파일 구조

USB의 파일 시스템을 컴퓨터의 /mnt 경로에 마운트하면, /mnt 경로에 USB 파일 시스템이 다음과 같이 연결된다. 이제부턴 /mnt/homework/os.c 같은 경로로 USB 안의 파일에 접근할 수 있게 되는 것이다.

이렇듯 파일 시스템을 디렉토리 트리의 특정 위치에 '연결'하는 게 마운트이다.

이때 파일 시스템이 연결되는 '부착 지점'(/mnt)을 마운트 포인트라고 한다.

지금까진 사용자 입장에서 디렉토리가 어떻게 보이는지를 살펴봤다. 이제는 보다 '파일 시스템' 관점에서 살펴보자.

컴퓨터의 루트 파일 시스템은 ext4다. 여기에 USB의 FAT32 파일 시스템을 /mnt에 마운트했으므로, /mnt 디렉토리 하위에서는 FAT32 파일 시스템을 사용하게 된다.

정리하자면 이렇다.

  • /~/mnt 직전까지: ext4 파일 시스템 사용
  • /mnt 이하: FAT32 파일 시스템 사용
  • 사용자는 이 차이를 의식하지 않고 하나의 통합된 디렉토리 트리로 인식
  • 마운트 포인트란 파일 시스템이 연결되는 '부착 지점' 디렉토리

마운트 테이블이란?

이처럼 리눅스는 파티셔닝과 포매팅을 통해 여러 종류의 파일 시스템을 가질 수 있고, 마운트라는 작업을 통해 이들을 하나의 디렉토리 트리처럼 보이게 합친다.

그렇다면 운영체제는 어떤 파일 시스템이 어디에 연결되어 있는지 어떻게 기억하고 관리할까? 바로 마운트 테이블을 통해서이다.

마운트 테이블은 시스템에 연결된 모든 파일 시스템의 목록과 그 연결 정보를 담은 '이정표'와 같다. 사용자가 /mnt/homework/os.c 경로를 요청하면, OS는 마운트 테이블을 보고 /mnt 디렉토리부터는 USB의 FAT32 파일 시스템을 참조해야한다는 사실을 파악한다. 덕분에 우리는 파일 시스템의 종류를 신경 쓰지 않고도 파일을 편리하게 사용할 수 있는 것이다.

실제로 마운트 테이블을 살펴보자. mount 명령어를 통해 살펴볼 수 있다.

아래 밑줄 그어놓은 부분을 보면 익숙한 ext4도 확인할 수 있다.

mount

2. 탈옥을 막아보자: pivot_root

이제 파일 시스템의 기본 개념을 알았으니, 다시 원래 문제로 돌아가보자. 왜 chroot는 탈옥을 감행할 수 있었던걸까?

핵심은 바로 마운트 정보가 격리되지 않았기 때문이다. chroot로 프로세스의 루트 디렉토리를 바꾸었지만, 그 프로세스는 여전히 시스템의 전체 마운트 테이블 정보를 볼 수 있다. 프로세스 입장에선 자신이 갇힌 감옥의 전체 설계도와 모든 출입문 정보가 담긴 지도를 손에 쥔 것과 마찬가지인 셈이다.

이 문제를 해결하고 완벽한 파일 시스템 격리를 구현하기 위해서는 전체 마운트 테이블 정보 접근을 불가능하게 막아야 한다.

pivot_root란?

그렇다면 어떻게 마운트 정보 접근을 막을 수 있을까?

프로세스가 바라보는 루트 디렉토리의 '인식'만 바꾸는 게 아니라, 커널이 관리하는 마운트 테이블 자체를 변경하여 루트 파일 시스템을 통째로 교체하는 방식을 떠올릴 수 있다!

이런 아이디어를 실현하는 도구가 바로 pivot_root다. pivot_root는 현재 프로세스의 루트 파일 시스템을 다른 마운트 지점으로 완전히 교체(swap)하는 시스템 콜이다.

pivot_root new_root put_old_root
  • new_root: 새로운 루트 파일 시스템으로 삼을 마운트 포인트
  • put_old_root: 기존 루트 파일 시스템을 옮겨놓을 위치

pivot_root를 실행하면 마운트 테이블이 다음과 같이 재구성된다.

실행전

/dev/sda1 on / type ext4        # 호스트 루트 파일시스템
/dev/sda2 on /home type ext4
tmpfs on /tmp type tmpfs

pivot_root new_root put_old_root 실행 후

/dev/sdb1 on / type ext4              # 새로운 루트 파일시스템
/dev/sda1 on /old_host type ext4      # 기존 루트는 /old_host로 이동
/dev/sda2 on /old_host/home type ext4 # 기존 마운트들도 함께 이동
tmpfs on /old_host/tmp type tmpfs

이렇게 하면 프로세스가 보는 루트(/)가 완전히 새로운 파일 시스템이 되고, 기존 호스트 파일 시스템은 /old_host 디렉토리 아래로 숨겨진다.

pivot_rootchroot의 차이점을 정리하면 아래와 같다.

구분chrootpivot_root
변경 대상프로세스의 루트 디렉토리 인식커널의 마운트 테이블
영향 범위해당 프로세스와 자식들만마운트 네임스페이스 전체
기존 루트여전히 접근 가능 (숨겨짐)완전히 분리 가능
보안탈옥 가능탈옥 불가능
사용 목적임시 격리, 테스트컨테이너, 시스템 루트 교체

하지만 위험한 pivot_root

그런데 잠깐, 여기서 중요한 문제가 있다. pivot_root를 호스트 시스템에서 직접 실행하면 어떻게 될까?

# 호스트에서 직접 실행하면...
pivot_root /container_root /old_host

시스템 전체가 망가진다! 모든 프로세스의 루트 파일 시스템이 바뀌어버리기 때문이다. 시스템 서비스들이 필요한 파일을 새로운 컨테이너 /container_root에서 찾아보지만 /에 있던 것과 달라 필요한 파일을 찾지 못해 오작동하며, 최악의 경우 부팅조차 불가능해질 수 있다.

그렇다면 이런 pivot_root를 사용할 때 호스트에는 영향을 주지 않으면서, 특정 프로세스만 안전하게 루트 파일 시스템을 바꾸는 방법은 없을까?

네임스페이스 (namespace)

이 고민의 해답이 바로 리눅스 커널의 네임스페이스(namespace) 기능이다.

네임스페이스란 프로세스를 실행할 때 PID, 네트워크 등 시스템 리소스를 격리된 환경에서 실행할 수 있도록 도와주는 기술이다. 각 프로세스가 자신만의 독립된 세상을 보도록 만드는 것이다.

우리가 사용할 것은 여러 네임스페이스 중에서도 마운트 네임스페이스다. 이름 그대로 마운트 포인트를 격리하는 기술로, 프로세스마다 독립적인 마운트 테이블을 가질 수 있게 해준다.

마치 A 프로세스에게는 지도 1번을, B 프로세스에게는 지도 2번을 나눠주는 것과 같다. A가 자기 지도에 아무리 낙서를 해도 B의 지도에는 아무런 영향이 없는, 완벽한 격리가 가능해진다.

실습: 마운트 네임스페이스와 pivot_root의 조합으로 완벽한 파일 시스템 격리

이제 두 개념을 합쳐보자. 이 둘의 조합이야말로 컨테이너 파일 시스템 격리의 핵심이다.

1단계: 마운트 네임스페이스 생성

먼저 unshare 명령어로 프로세스를 위한 독립적인 마운트 공간을 만든다.

# 새로운 마운트 네임스페이스를 생성하고, 독립적인 sh 셸을 실행한다.
$ unshare --mount /bin/sh

이 공간은 처음에는 부모의 마운트 테이블을 그대로 복사해온다.

그럼 이제 마운트된 파일시스템을 확인해보자. mount 명령어를 사용해 마운트 테이블 내용을 비교해봤더니 아래와 같이 동일한 걸 확인할 수 있었다.

mount 결과 비교

하지만 mount 출력은 시스템 내부 마운트까지 모두 포함해서 너무 복잡하다. 실제 저장공간이 있는 주요 파일시스템만 깔끔하게 보고 싶다면 df -h 명령어를 사용하자.

df -h 결과

훨씬 간결하고 이해하기 쉽다!

핵심은 새로운 마운트 네임스페이스를 생성했을 때는 기존 마운트 테이블을 복사해온다는 점이다. 마치 자식 프로세스를 생성할 때 fork() → exec() 순서에서, fork()로 부모를 그대로 복사해오는 것과 같다.

물론 이제부터 이 셸에서 일어나는 마운트 관련 작업은 호스트나 다른 프로세스에 영향을 주지 않는다.

2단계: 새로운 루트 파일 시스템 준비

컨테이너의 새로운 루트가 될 디렉토리를 만들고, 독립적인 파일 시스템(tmpfs)를 마운트해보자.

# 새로운 루트로 사용할 디렉토리 생성
$ mkdir new_root

# new_root 디렉토리에 tmpfs 타입의 파일 시스템을 마운트
# 이제 new_root는 호스트와 분리된 독립적인 메모리 기반 파일 시스템이 됨
$ mount -t tmpfs none new_root

mount -t tmpfs none new_rootnew_root 디렉토리를 마운트 포인트로 삼아, tmpfs라는 임시 파일 시스템을 연결하라는 의미이다.

그럼 과연 독립된 네임스페이스에서만 마운트가 적용되었을까? df -h로 확인해보자.

마운트 결과 비교

완벽하다! 네임스페이스 내부에서는 /tmp/new_root에 tmpfs가 마운트된 것이 보이지만, 호스트에서는 전혀 보이지 않는다. 마운트 네임스페이스가 제대로 격리되어 작동하고 있음을 확인할 수 있다.

이제 /tmp/new_root 디렉토리 하위는 호스트의 ext4 파일 시스템이 아닌, 독립적인 메모리 기반 파일 시스템(tmpfs)을 사용하게 된다. 마치 USB를 /media/usb에 마운트하면 그 경로부터는 USB의 파일 시스템을 사용하는 것과 같은 원리다.

3단계: new_root에 내용 채우고 put_old 디렉토리 만들기

이번 단계에서는 새로운 루트로 사용할 파일들을 복사하고, 기존 루트를 옮겨놓을 디렉토리를 준비한다.

# 컨테이너에 필요한 최소한의 파일들을 new_root로 복사
# 이전 실습에서 잘 만들어둔 wlghroot 내부의 내용을 new_root로 복사
$ cp -r wlghroot/* new_root

# 기존 루트 파일 시스템을 잠시 옮겨둘 디렉토리 생성
$ mkdir new_root/put_old

여기서 put_old 디렉토리가 왜 필요할까? pivot_root는 기존 루트를 그냥 버리는 것이 아니라, put_old로 지정된 디렉토리로 '이동'시킨다. 이는 커널이 마운트 정보를 안전하게 교체하기 위한 필수 과정이다. 교체 후에는 put_old에 마운트된 기존 루트를 umount하여 완전히 분리할 수 있다. (이 put_old 디렉토리는 바로 아래 4단계에서 사용된다.)

4단계: 드디어 pivot_root

드디어 pivot_root를 사용해볼 시간이다. pivot_root를 실행하여 현재 네임스페이스의 루트를 new_root로 교체해보자.

# new_root로 이동
$ cd new_root

# 현재 디렉토리(.)를 새로운 루트로, put_old를 기존 루트가 옮겨갈 위치로 지정
$ pivot_root . put_old

pivot_root 실행 전후의 마운트 테이블 변화를 살펴보면 다음과 같다.

실행전:

/dev/root    mounted_on  /
tmpfs        mounted_on  /tmp/new_root

실행후:

tmpfs        mounted_on  /           ← 새로운 루트!
/dev/root    mounted_on  /put_old    ← 기존 루트가 이동됨

이제 우리의 프로세스가 보는 루트(/)가 완전히 tmpfs 기반의 새로운 파일 시스템으로 바뀌었다!

전반적인 디렉토리 모습은 아래와 같게 변한다.

pivot_root 전후

5단계: 해치웠는지 확인

정말로 루트가 new_root로 바뀌었는지, 그리고 탈옥이 불가능한지 확인해보자.

먼저 루트 디렉토리 변화를 확인해보자.

# 네임스페이스 내부
$ ls /
bin  escape_chroot  lib  lib64	put_old  usr
# 호스트
$ cd wlghroot 	#/tmp
$ ls 			#/tmp/wlghroot
bin  escape_chroot  lib  lib64  usr

놀랍게도 네임스페이스 내부에는 put_old 디렉토리가 있는 반면 호스트에는 존재하지 않는다! 이 디렉토리는 프로세스 내부에서 만들었었는데, 딱 원하던대로 네임스페이스 내부에서만 보이고 호스트엔 나오지 않는다.

디렉토리 탐색으로 탈출시도해도 안 된다.

# 네임스페이스 내부
$ ls /
bin  escape_chroot  lib  lib64	put_old  usr
$ cd ../
$ ls
bin  escape_chroot  lib  lib64	put_old  usr
$ cd ../../../../../../../../..
$ ls
bin  escape_chroot  lib  lib64	put_old  usr

아무리 상위 디렉토리로 이동해도 동일한 내용만 보인다. 진짜 루트가 바뀌었기 때문에 더 이상 상위로 올라갈 수 없다!

탈옥 코드로 최종 확인을 해보자.

# 네임스페이스 내부
$ ./escape_chroot
$ ls
bin  escape_chroot  lib  lib64	put_old  usr

완벽하다! 이전 chroot 실습에서는 성공했던 탈옥 코드가 이번에는 전혀 작동하지 않는다.

3. 정리

개념 총정리

  • chroot 탈옥의 원인: chroot는 프로세스의 루트 디렉토리만 바꿀 뿐, 커널이 관리하는 마운트 테이블 정보는 격리하지 못한다. 이 때문에 격리된 프로세스도 시스템 전체의 파일 시스템 구조를 파악하여 탈출을 시도할 수 있다.
  • pivot_root의 등장: 이 문제를 해결하기 위해 루트 파일 시스템을 통째로 교체하는 pivot_root 시스템 콜을 사용한다. 이는 프로세스의 인식만 바꾸는 chroot와 달리, 커널의 마운트 테이블 자체를 변경한다.
  • pivot_root와 네임스페이스의 조합: 하지만 pivot_root를 호스트에서 직접 사용하면 시스템 전체가 망가질 수 있다. 따라서 마운트 네임스페이스를 생성하여 격리된 마운트 테이블을 만든 뒤, 그 안에서 pivot_root를 실행함으로써 호스트에 영향을 주지 않고 안전하게 파일 시스템을 격리할 수 있다.

3탄 커밍쑨

이번 포스팅에서는 pivot_root와 마운트 네임스페이스를 조합하여 드디어 완벽한 파일 시스템 격리를 구현했다. 이제 우리의 컨테이너는 그 어떤 방법으로도 탈출할 수 없는 견고한 감옥이 되었다.

하지만 지금껏 흐린눈 해온 한 가지가 있다. 컨테이너를 실행할 때마다 필요한 명령어와 관련된 파일들을 일일이 복사하는 현재 방식은 매우 비효율적이다. 컨테이너가 늘어날수록 똑같은 파일들이 중복해서 쌓이게 될 것이기 때문이다.

다음 3탄에서는 오버레이 파일시스템을 사용하여 이러한 중복 문제를 해결하는 과정을 다뤄보도록 하겠다.

글을 마치며

2탄을 작성하며 여러 번의 고비를 맞았다. 컨테이너는 단순히 몇 가지 명령어의 조합으로 뚝딱 만들어질 것이라 얕보고 시작했는데, 생각보다 깊은 운영체제 지식과 명령어에 대한 이해가 필요했다. 단순히 핸즈온을 따라 하는 것만으로는 '뭔가 명령어를 치고 있는데, 이게 뭐지?' 싶은 순간들이 생겼다. 이해되는 포스트를 쓰기 위해서 개념 하나하나를 꼼꼼히 파헤쳐보고자 노력했는데, 그 과정이 결코 쉽지 않았다.

컨테이너를 만들기 위해 필요한 개념으로 알게 된 것만 해도 2탄에서만 chroot, pivot_root, mount table, namespace, file system... 정말이지 도커는 빙산의 일각이었다.

빙산의 일각

특히 파일 시스템이라 하면 항상 운영체제 맨 마지막 단원이다 보니, 초심 다 잃고 대강 공부했던 기억이 나는데(ㅎㅎ) 이번 파일 시스템 격리를 학습하면서 그 기반지식이 많이 부족했음을 뼈저리게 느꼈다.

고난과 역경의 2탄이었지만, 마음을 차분히 먹고 3탄, 4탄.. 이어서 진행해보려고 한다. 블로그 글쓰기 주도 학습(BDS-내가만든말.)을 통해 핸즈온 이해를 완전히 마무리짓고, 도커처럼 명령어를 통해 이미지와 컨테이너를 만드는 것까지를 최종 목표로 하고 있다. (장편 시리즈가 될 것 같다는 예감이...)

P.S. 학습하면서 동시에 정리하는 글이다 보니 부족한 부분이 있을 수 있습니다. 잘못된 설명이나 개선할 부분에 대한 피드백을 적극 환영합니다!

레퍼런스

2개의 댓글

comment-user-thumbnail
2025년 7월 26일

글 잘 읽었습니다. 새로운 시리즈가 나올수록 더 재밌어지네요 :)
직접 만드신 미니 도커 기대하겠습니다. 감사합니다.

답글 달기
comment-user-thumbnail
2025년 7월 26일

2탄도 잘 읽었어요!! ㅎㅎ 그림이 적절히 들어가있어서 이해하기 쉬웠어요! 감사합니다!! BDS 좋네요 😁 3탄도 기대할게요~~!!!

답글 달기