Linux Container 격리
해당 글에 이어서 Namespace에 대해 좀 더 상세히 알아보자.
지금까지 chroot와 pivot_root를 통해 파일시스템의 격리를 테스트했다.
하지만 파일시스템이 격리된 상태에서 프로세스를 조회해보면 여전히 프로세스들 간에 격리가 완전히 되지 않는다는 것을 알 수 있다.
컨테이너는 이런 문제를 Namespace를 통해서 해결한다.
Namespace란?
리눅스 커널 기능으로 각 프로세스가 별도의 리소스 묶음을 나눠서 사용할 수 있게 하는 기능이다.
컨테이너는 이런 Namespace를 통해서 프로세스 간 영향을 받지않도록 격리된 환경과 시스템 자원을 제공한다.
격리하는 자원에 따라 아래와 같이 분류한다.
- Mount (mount 포인트 제어)
- PID (프로세스 ID)
- Network
- IPC (프로세스 간 통신)
- UTS (UNIX Time-Sharing, Hostname)
- USER ID
- cgroup (Control Group)
- Time (다른 시스템 시간 대역을 설정)
https://man7.org/linux/man-pages/man1/unshare.1.html
unshare 명령어는 일종의 wrapping 인터페이스로 해당 명령어를 통해 리눅스 커널의 namespace 격리를 수행해 볼 수 있다.
# unshare [options] [program [arguments]]
# 예시) mount를 격리하고 bash 쉘을 실행시키는 명령어
unshare --mount /bin/bash
Mount Namespace
이전에 pivot_root에서 사용한 명령어이다.
그 때는 그냥 수행하는데로 따라했지만 df 명령어로 마운트를 확인해봤을 때 unshare를 수행한 쉘에서 마운트를 하면 기존 쉘에서는 조회가 안되는지 이제 제대로 이해할 수 있을 것이다.
동일한 mount 상태에서 pivot_root를 수행했을 때와 똑같이 한쪽 쉘에서만 마운트를 격리하고 임의의 디렉토리를 새로 마운트 해본다.
root@MyServer:~# unshare --mount /bin/bash
root@MyServer:~#
root@MyServer:~# mkdir /tmp/mount_namespace
root@MyServer:~# mount -t tmpfs tmpfs /tmp/mount_namespace
여기서 한가지 의문이 생긴다.
리눅스 커널이 제공하는 기능이라지만 어떻게 Namespace를 구분하는걸까?
Namespace를 어떻게 구분하나?
리눅스는 잘 알려져있는 것처럼, 모든 것을 파일로 관리한다. 해당 Namespace도 마찬가진데, /proc에 각 프로세스의 namespace 디렉토리가 있다.
# /proc에서 자신의 프로세스에 할당된 namespace를 확인
# $$는 자신의 프로세스 id를 의미한다
root@MyServer:~# ls -rlt /proc/$$/ns
total 0
lrwxrwxrwx 1 root root 0 Sep 1 06:18 uts -> 'uts:[4026531838]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 mnt -> 'mnt:[4026532206]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Sep 1 06:18 cgroup -> 'cgroup:[4026531835]'
이처럼 자신의 namespace가 할당이되는데 unshare를 쓰는 순간 기존의 namespace에서 설정한 옵션에 따라 다른 namespace를 새로 할당한다.
mount에 대해 unshare를 수행했으니, 양쪽 세션의 mount에 대한 namespace를 확인해보자.
unshare로 새로 실행한 프로세스는 새로운 namespace id를 할당받았고, 해당 프로세스를 exit로 종료하면 기존과 똑같은 namespace id를 할당받은 것을 알 수 있다.
그럼 unshare를 쓰지않고 프로세스를 생성해보자.
여기서 namespace에 대한 한가지 속성을 알 수 있다.
별도로 설정하지 않았을 경우 새로 생성되는 프로세스의 namespace는 부모프로세스와 같은 namespace를 할당받는 것이다.
PID Namespace
PID는 OS에서 고유한 값을 가진다. 하지만 PID도 Namespace를 격리하면 동일한 PID를 가진 여러개의 프로세스를 만들 수 있다.
# --pid : pid의 namespace를 새로 생성해 격리한다
# --fork : 새로운 프로세스를 만들어 그 안에서 명령을 실행한다
# unshare랑 쓰면 격리된 pid namespace에서 pid 1인 프로세스를 생성한다
# --mount-proc : 새로운 프로세스 namespace에 맞는 새로운 /proc 파일시스템을 마운트한다
# 새로운 /proc은 오직 새 namespace 내의 프로세스들만 보여준다
unshare --pid --fork --mount-proc /bin/bash
이처럼 별도의 PID Namespace를 생성할 경우 해당 프로세스의 PID가 1이므로 가장 최상위 부모 프로세스가 되며, 해당 공간에서는 자신에게 포함된 프로세스 목록만 조회할 수 있다.
오른쪽의 pid를 격리하지 않은 프로세스 리스트를 보면 어떻게 동작한 것인지 더 상세히 알 수 있는데, 6248 PID를 가진 /bin/bash와 왼쪽의 현재 프로세스 PID의 Namespace를 보면 같은 것을 알 수 있다.
Network Namespace
네트워크도 마찬가지로 격리가 가능하다. 네트워크에 대한 인터페이스 외에도 라우팅에 대한 것들 등 네트워크에 관련된 것들이 모두 격리된다.
격리전 네트워크 인터페이스를 보면 같은 것을 알 수 있다.
unshare --net /bin/bash
IPC Namespace
리눅스는 프로세스간 통신에 pipe, shared memory, socket 등 다양한 방법을 지원한다.
IPC 격리는 shared memory를 사용하면 좀 더 편리하게 비교할 수 있다.
# 현재 생성된 shared memory 조회 명령어
ipcs -m
# shared memory를 1111bytes 생성
ipcmk -M 1111
UTS Namespace
리눅스에서 사용하는 hostname과 도메인을 격리한다.
root@MyServer:~# hostname
MyServer
root@MyServer:~# unshare --uts /bin/bash
root@MyServer:~# hostname WantToSleep
root@MyServer:~# hostname
WantToSleep
USER Namespace
root권한은 모든 권한을 가지고 있기에 편하지만, 보안에 있어서는 항상 위험을 가지고 있다
따라서 실제 Host에서는 일반 유저지만 격리된 Namespace 환경에서는 root권한을 가진 사용자나 다른 임의의 사용자로 분리할 수 있다.
# user namespace 격리
root@MyServer:~#
root@MyServer:~# exit
logout
ubuntu@MyServer:~$
ubuntu@MyServer:~$ id
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),119(netdev),120(lxd)
ubuntu@MyServer:~$
ubuntu@MyServer:~$ unshare --user /bin/bash
nobody@MyServer:~$
nobody@MyServer:~$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
# 격리된 user로 명령어 테스트
nobody@MyServer:~$ unshare --ipc /bin/bash
unshare: unshare failed: Operation not permitted
nobody@MyServer:~$
nobody@MyServer:~$ sudo su -
sudo: /etc/sudo.conf is owned by uid 65534, should be 0
sudo: /usr/bin/sudo must be owned by uid 0 and have the setuid bit set
nobody@MyServer:~$
여기서는 단순히 namespace만 격리했기 때문에 임의의 사용자인 65534 id로 매핑되어있다.
격리한 프로세스의 PID와 User ID를 확인해보면 격리된 환경과 격리되지 않은 환경에서 각각 다르게 출력되는 것을 확인할 수 있다.
격리된 환경에서는 uid가 65534인데, 격리되지않은 환경에서는 uid가 ubuntu(1000)으로 확인된다.
그럼 격리된 User Namespace에서 root권한을 부여하려면 어떻게 해야할까?
여기서는 uid_map에 대해 알아야 한다.
# UID Map은 /proc/[pid]/uid_map 파일을 통해 설정
# <uid> <lower_uid> <count>
root@MyServer:/# cat /proc/$$/uid_map
0 0 4294967295
# 0 : 자식 namespace 내에서의 UID
# 0 : 부모 namespace 내에서의 UID
# 4294967295 : 매핑 범위
# 즉 해당 uid_map에서 자식 namespace 내에서의 0(root)인 UID는
# 부모 namespace에서의 0(root)와 동일한 권한을 가진다
# 또한 4294967295값은 매핑 범위로 32비트 시스템에서 가능한 최대 UID 값이므로
# 자식 namespace의 모든 UID가 부모 namespace의 모든 UID와 매핑된다고 볼 수 있다
그럼 우리의 목적은 격리한 자식 namespace의 root UID(0)을 부모 namespace의 ubuntu UID(1000)에 매핑하고 싶으므로 아래와 같이 수행하면 된다.
# 격리된 USER Namespace에서 해당 PID에 대한 uid_map 확인
nobody@MyServer:~$ ls -rlt /proc/$$/uid_map
-rw-r--r-- 1 nobody nogroup 0 Sep 1 08:02 /proc/6478/uid_map
# 권한이 있는 다른 터미널에서 해당 uid_map을 변경
ubuntu@MyServer:~$ cat /proc/6478/uid_map
ubuntu@MyServer:~$
ubuntu@MyServer:~$ echo '0 1000 1' > /proc/6478/uid_map
ubuntu@MyServer:~$ cat /proc/6478/uid_map
0 1000 1
# 격리된 USER Namespace에서 변경된 uid_map 확인
nobody@MyServer:~$ cat /proc/$$/uid_map
0 1000 1
# 변경된 uid 확인 및 root권한의 명령어 수행
nobody@MyServer:~$ id
uid=0(root) gid=65534(nogroup) groups=65534(nogroup)
nobody@MyServer:~$
nobody@MyServer:~$ unshare --ipc /bin/bash
root@MyServer:~#
참고자료
- Linux namespaces : https://en.wikipedia.org/wiki/Linux_namespaces
- unshare(1) — Linux manual page : https://man7.org/linux/man-pages/man1/unshare.1.html