*본 포스팅은 쿠버네티스 네트워크 스터디 3기 에 참여하여 산출물로 작성한 글입니다.
컨테이너를 설명할 때 항상 가상머신과 비교를 하거나, 가상화 개념을 설명하면서 컨테이너를 설명하게 되는데 간단하게 컨테이너와 가상머신을 비교하면 아래와 같다.
복잡하게 전가상화 반가상화 컨테이너 가상화 같은 개념을 설명하기보다 둘의 어플리케이션 실행 차이만 보고자 하는데, 먼저 컨테이너로 어플리케이션을 실행하는 경우 Host라고 불리는 서버 OS(리눅스)에서 컨테이너 런타임을 설치 후 이를 통해 격리된 컨테이너 프로세스로 원하는 어플리케이션을 실행하는 구조이다. 반면 가상머신으로 어플리케이션을 실행할 때에는 Host OS에 다른 커널을 얹을 수 있는 하이퍼바이저를 설치한 뒤 이 하이퍼바이저로 가상의 머신(서버)을 만들고 이 가상머신에 OS를 다시 설치하고, 어플리케이션을 설치한 뒤 실행하는 방식이다. 가상머신을 만드는 과정은 이전글 에서 잘 나타나있다. 글로 적을 때에도 후자의 방식이 조금 길고 복잡해 보이는데, 실제로도 복잡하다. 때문에 최근 서버 운영 추세가 가상머신에서 컨테이너로 변화하고 있지만, 레거시한 곳도 많으니 대충 둘의 차이는 알아두면 좋다.
컨테이너를 알아보기 앞서 프로세스라는 개념을 알고 진행하면 쉽게 이해되므로, 먼저 프로세스에 대해 살펴 보면
- 프로세스는 실행 중인 프로그램의 인스턴스. OS에서 프로세스를 관리하며, 각 프로세스는 고유한 ID(PID)를 가짐.
- 프로세스는 CPU와 메모리를 사용하는 기본 단위로, OS 커널에서 각 프로세스의 자원을 관리
로 정리할 수 있는데 대충 OS에서 프로세스를 살펴보면 아래 그림처럼 살펴볼 수 있다.
보편적인 컴퓨터 공학에서 나오는 설명이지만, 우리는 격리된 프로세스인 컨테이너 프로세스에 대해 공부해야 하므로 폭을 좁혀서 리눅스 프로세스에 대해 깊게 알아보자.
1. 리눅스 프로세스[2]
리눅스에서는 한 번에 여러 개의 프로그램이 저장되고 실행할 수 있다. 보통 프로그램을 설치하면 하드디스크와 같은 디스크에 저장되는데, 프로그램을 실행하면 디스크의 데이터를 RAM으로 옮겨오면서 CPU의 자원을 할당 받아 실행 중인 프로그램을 "프로세스"라고 한다. 프로세스에는 ID가 할당되어 관리되는데, 이를 PID라고 한다. 커널은 이러한 프로세스들을 구조화시킨 형태로 유지하기 위해서 PID 정보를 가지고 있다.
또한 프로그램은 프로그램을 실행시킬 수 있는데, 이를 부모와 자식 프로세스라고 표현한다. 예를 들어 OS가 구동 될 때, 커널은 /etc 에 위치한 init 이라는 스크립트를 실행함으로써 시스템 서비스들을 차례대로 시작시킨다. 이 서비스들은 데몬 프로그램(백그라운드)으로 구현되어 있기 때문에 로그인하지 않은 상태에서도 필요 작업들을 수행한다. 예를들어, 위에서 말한 init은 항상 1번 PID 를 할당 받는다.
리눅스 프로세스에 대한 정보를 실시간으로 확인하려면 /proc 경로에서 프로세스 아이디 폴더에 들어가면 확인할 수 있는데, 폴더별 적재되는 실시간 데이터는 아래와 같다.
- /proc/[PID]/cmdline: 해당 프로세스를 실행할 때 사용된 명령어와 인자를 조회
- /proc/[PID]/cwd: 프로세스의 현재 작업 디렉터리에 대한 심볼릭 링크
- /proc/[PID]/environ: 프로세스의 환경 변수. 각 변수는 NULL 문자로 구분
- /proc/[PID]/exe: 프로세스가 실행 중인 실행 파일에 대한 심볼릭 링크
- /proc/[PID]/fd: 프로세스가 열어놓은 모든 파일 디스크립터에 대한 심볼릭 링크를 포함하는 디렉터리. 이 파일들은 해당 파일 디스크립터가 가리키는 실제 파일이나 소켓 등을 참조.
- /proc/[PID]/maps: 프로세스의 메모리 맵. 메모리 영역의 시작과 끝 주소, 접근 권한, 매핑된 파일 등을 확인
- /proc/[PID]/stat: 프로세스의 상태 정보를 포함한 파일. 프로세스의 상태, CPU 사용량, 메모리 사용량, 부모 프로세스 ID, 우선순위 등의 정보가 존재.
- /proc/[PID]/status: 프로세스의 상태 정보를 사람이 읽기 쉽게 정리한 파일. PID, PPID(부모 PID), 메모리 사용량, CPU 사용률, 스레드 수 등을 확인할 수 있음.
왜 이 프로세스 구조를 살펴보는가? 라는 의문을 갖을수 있으니 이쯤해서 컨테이너를 살펴보자.
2. 컨테이너
- 정확히는 컨테이너화 된 프로세스를 말함
- 리눅스 프로세스를 격리시킨 형태이기 때문에 컨테이너 이미지는 리눅스 OS 기반으로 구성
앞에서도 이야기 했지만 컨테이너는 격리된 프로세스이다. 또한 이 컨테이너는 리눅스 커널에서만 사용이 가능한데, Windows OS의 경우 Windows Subsystem for Linux version 2(WSL2)라는 HyperV 하이퍼바이저(VM을 만드는 녀석)로 리눅스OS VM을 생성하여 컨테이너를 사용할 수 있게 된다. 아래 그림을 살펴보자.
이 그림에서 Linux의 프로세스 격리 기능이라는 용어가 나온다. 참고로 윈도우에는 이 기능이 없어, WSL2로 VM을 만들고 여기에서 컨테이너를 이용하게 되는 것이다. 또한 이 격리기능을 사용하기 위해 컨테이너 런타임이 필요한데, 위 그림에서는 Daemon이 이 역할을 하게 된다.
컨테이너 런타임에는 저수준/고수준 런타임으로 나뉘며 아래와 같이 구분한다.
- 저수준 컨테이너 런타임의 대표적인 예시는 runc이며, 컨테이너만을 실행
- 고레벨 컨테이너 런타임은 cri-o와 containerd이고, 컨테이너만 실행 기능에 추가로 컨테이너의 이미지 전송과 관리, 이미지 압축 풀기 등이 가능
이 컨테이너 런타임에서 Linux 프로세스 격리를 수행하며 이에 대한 세부 학습은 다음 포스팅에서 다루게 될 것이다. 이번 포스팅에서는 이 격리 기술의 근간이 되는 루트 경로를 바꾸는 기술에 대해 알아보려 한다.
루트 경로를 바꾸는 기술은 Change Root(chroot)와 Pivot Root가 있는데, Chroot는 변경한 루트 경로를 벗어날 수 있기 때문에 컨테이너에서는 Pivot Root를 사용한다. Pivot Root를 이해하려면 Chroot의 이해가 선행되어야 하므로, Chroot를 먼저 살펴보자.
3. Chroot(Change Root)
- 프로세스가 실행되는 루트를 변경하는 기술
영문 해석 그대로 루트를 변경하는 기술이다. 여기서 루트는 최상위 폴더(/)를 의미한다. 그렇다면 왜 루트를 바꾸는 것일까? 아래 그림을 살펴보자
위 그림은 리눅스에서 bash라는 명령어의 실행 경로를 찾아가는 그림이다. bash 파일을 최상위 root(/)에서부터 한계층씩 내려오면서 찾아 내려온다. 그러나 특정 폴더가 root 역할을 하도록 chroot 명령어를 사용하면 커널은 pwd 명령어를 기존 최상위 root(/)가 아닌 변경된 root 디렉토리부터 찾아 내려오게 된다. 가령, /chroot 라는 폴더를 만들고 이 폴더를 root 폴더로 재정의했다고 해보자.
이 경우, bash 파일을 변경된 최상위 경로인 chroot부터 한계층씩 내려오면서 찾아 내려온다. 정리하자면 chroot는 사용자나 프로세스가 특정 파일시스템 구역에서 벗어나지 못하도록 울타리를 치는 역할을 한다고 보면 된다.
이론은 대강 파악이 되었으니, 실습을 통해 정말로 root가 바뀌는지, 앞서 이야기한 변경한 루트 경로를 벗어날 수 있는지를 살펴보자.
* 실습 환경은 이전글 에서 만든 VM 에서 진행
실습1) chroot를 이용한 격리 환경을 만들고, 지정된 루트 경로를 탈출 해보기
#편의를 위해 root로 진행
ubuntu@docker-test:~$ sudo -i
#암호 입력
[sudo] password for ubuntu:
root@docker-test:~# cd /tmp
root@docker-test:~# mkdir myroot
root@docker-test:/tmp# ls | grep myroot
myroot
# chroot 사용법 : [옵션] NEWROOT [커맨드]
chroot myroot /bin/sh
이 때, 아래 스샷과 같은 상황이 발생할 것이다.
이유는 /tmp/myroot가 /로 바뀌었으므로 경로 안에 sh실행을 위한 경로 및 파일들이 없기 때문인데 아래 명령어로 bash와 의존성 패키지들을 위치시켜준다.
root@docker-test:/tmp# which sh
/usr/bin/sh
root@docker-test:/tmp# ldd /bin/sh
linux-vdso.so.1 (0x00007ffc4d95d000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3d7dde7000)
/lib64/ld-linux-x86-64.so.2 (0x00007f3d7e03a000)
# 바이너리 파일과 라이브러리 파일 복사
root@docker-test:/tmp# mkdir -p myroot/bin
root@docker-test:/tmp# cp /usr/bin/sh myroot/bin/
root@docker-test:/tmp# mkdir -p myroot/{lib64,lib/x86_64-linux-gnu}
root@docker-test:/tmp# tree myroot
myroot
├── bin
│ └── sh
├── lib
│ └── x86_64-linux-gnu
└── lib64
4 directories, 1 file
cp /lib/x86_64-linux-gnu/libc.so.6 myroot/lib/x86_64-linux-gnu/
cp /lib64/ld-linux-x86-64.so.2 myroot/lib64
#최종 sh 바이너리 파일 구조 확인
root@docker-test:/tmp# tree myroot/
myroot/
├── bin
│ └── sh
├── lib
│ └── x86_64-linux-gnu
│ └── libc.so.6
└── lib64
└── ld-linux-x86-64.so.2
4 directories, 3 files
이제 sh를 실행할 수 있으나 which로 경로 비교를 못하고, 탈출 확인을 위한 ls를 사용하지 못하니 sh와 같이 라이브러리와 실행파일을 세팅해보자
root@docker-test:/tmp# which ls
/usr/bin/ls
root@docker-test:/tmp# ldd /usr/bin/ls
linux-vdso.so.1 (0x00007ffec69f3000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f7d9624d000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f7d96024000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f7d95f8d000)
/lib64/ld-linux-x86-64.so.2 (0x00007f7d962a5000)
root@docker-test:/tmp# which which
/usr/bin/which
root@docker-test:/tmp# ldd /usr/bin/which
not a dynamic executable
#
root@docker-test:/tmp# cp /usr/bin/ls myroot/bin/
root@docker-test:/tmp# cp /usr/bin/which myroot/bin/
root@docker-test:/tmp# cp /lib/x86_64-linux-gnu/{libselinux.so.1,libc.so.6,libpcre2-8.so.0} myroot/lib/x86_64-linux-gnu/
root@docker-test:/tmp# cp /lib64/ld-linux-x86-64.so.2 myroot/lib64
root@docker-test:/tmp# tree myroot
myroot
├── bin
│ ├── ls
│ ├── sh
│ └── which
├── lib
│ └── x86_64-linux-gnu
│ ├── libc.so.6
│ ├── libpcre2-8.so.0
│ └── libselinux.so.1
└── lib64
└── ld-linux-x86-64.so.2
이제 준비가 끝났으니 chroot로 /를 변경하여 기존 환경과 실행파일 경로를 비교해보고, 탈출을 시도해보자
#chroot전 sh 경로
root@docker-test:/tmp# which sh
/usr/bin/sh
#chroot 이후 sh경로
$ which sh
/bin/sh
#탈출 시도
$ ls /
bin lib lib64
$ cd ../../../
$ ls /
#탈출 시도 전과 똑같은 구조
bin lib lib64
#탈출이 가능했다면 보여져야하는 구조
bin boot cdrom dev etc home lib lib32 lib64 libx32 lost+found media mnt opt proc root run sbin snap srv sys tmp usr var
탈출이 가능하다고 했지만, 현재 상태에서는 탈출이 불가능한 것을 볼 수 있다.
때문에 탈출 시킬수 있는 프로그램이 필요하므로 다음 C로 짜여진 코드를 컴파일 하여 새로운 root에 넣어보자
root@docker-test:/tmp# cd /tmp/myroot
root@docker-test:/tmp/myroot# cat <<EOF > escape_chroot.c
#include <sys/stat.h>
#include <unistd.h>
int main(void)
{
mkdir(".out", 0755);
chroot(".out");
chdir("../../../../../");
chroot(".");
return execl("/bin/sh", "-i", NULL);
}
EOF
root@docker-test:/tmp/myroot# gcc -o escape_chroot.c escape_chroot.c
이제 준비가 다 됐으니 탈출을 해보자
root@docker-test:/tmp# cd /tmp
root@docker-test:/tmp# tree -L 1 myroot
myroot
├── bin
├── escape_chroot
├── escape_chroot.c
├── lib
└── lib64
root@docker-test:/tmp# file myroot/escape_chroot
myroot/escape_chroot: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=db08d2c6b8345c74424c2e0b0cc711501ac4e440, for GNU/Linux 3.2.0, not stripped
#탈출하러 가자!
root@docker-test:/tmp# chroot myroot /bin/sh
$ ./escape_chroot
$ ls /
이렇게 가둬놓은 루트를 탈출할 수 있다보니, 보안상 좋지 않아 컨테이너에서는 Pivot Root(+Mount Namespace) 기술을 쓴다.
4. Pivot Root(+Mount Namespace)
Pivot Root의 경우, 새로운 /경로(new-root)를 기존 / 경로(old-root)와 바꾸면서 파일시스템이 마운트된 파일시스템으로 변경된다. 이것만으로는 완전히 분리되지 않으므로, 이후 Mount Namespace란 기술로 old -root 마운트 포인트를 격리시켜버린다.
어려운 개념이니 한번 실습 해보자
* 실습 환경은 이전글 에서 만든 VM 에서 진행
- 명령어
- pivot_root
pivot_root [new-root] [old-root]
(new-root와 old-root 경로를 지정) - mount
mount -t [filesystem type] [device_name] [directory - mount point]
(root filesystem tree에 다른 파일시스템을 붙이는 명령)- -t : filesystem type
ex) -t tmpfs (temporary filesystem : 임시로 메모리에 생성) - -o : 옵션 ex) -o size=1m (용량 지정 등 …)
참고) * /proc/filesystems 에서 지원하는 filesystem type 조회 가능
- -t : filesystem type
- unshare
unshare [options] [program] [arguments]
(새로운 네임스페이스를 만들고 나서 프로그램을 실행)
- pivot_root
실습1) 네임스페이스
# [터미널1]
root@docker-test:/tmp# unshare --mount /bin/sh
-----------------------
# 아래 터미널2 호스트 df -h 비교 : mount unshare 시 부모 프로세스의 마운트 정보를 복사해서 자식 네임스페이스를 생성하여 처음은 동일
$ df -h
-----------------------
# [터미널2]
root@docker-test:/tmp# df -h
#터미널 1
$ mkdir new_root
$ mount -t tmpfs none new_root
$ ls -l
$ tree new_root
# [터미널1,2] 마운트 정보 비교
df -h
mount | grep new_root
findmnt -A
# [터미널1]
## 파일 복사 후 터미널2 호스트와 비교
cp -r myroot/* new_root/
tree new_root/
-----------------------
# [터미널2]
tree new_root
네임스페이스로 unshare한 상태이기 때문에 두 프로세스 간에 폴더 내용이 독립됨을 확인
실습2) Pivot Root
# 터미널1
-----------------------
$ mkdir new_root/put_old
## pivot_root 실행
$ cd new_root # pivot_root 는 실행 시, 변경될 root 파일시스템 경로로 진입
$ pivot_root . put_old # [신규 루트] [기존 루트]
##
$ cd /
$ ls / # 터미널2와 비교
$ ls put_old
-----------------------
# 터미널2
root@docker-test:/tmp# ls /
#Pivot Root 적용된 프로세스에서 탈출 시도 프로그램 실행 후 탈출 시도 확인
$ ./escape_chroot
$ cd ../../../
$ ls /
$ exit
추가1. Chroot 에서 ps 실행해보기
ps의 경우 단순 바이너리 및 라이브러리 파일 복사만으로는 수행되지 않는다. 이 부분을 확인해보자.
# copy ps
root@docker-test:/tmp# ldd /usr/bin/ps;
root@docker-test:/tmp# cp /usr/bin/ps /tmp/myroot/bin/;
root@docker-test:/tmp# cp /lib/x86_64-linux-gnu/{libprocps.so.8,libc.so.6,libsystemd.so.0,liblzma.so.5,libgcrypt.so.20,libgpg-error.so.0,libzstd.so.1,libcap.so.2} /tmp/myroot/lib/x86_64-linux-gnu/;
root@docker-test:/tmp# mkdir -p /tmp/myroot/usr/lib/x86_64-linux-gnu;
root@docker-test:/tmp# cp /usr/lib/x86_64-linux-gnu/liblz4.so.1 /tmp/myroot/usr/lib/x86_64-linux-gnu/;
root@docker-test:/tmp# cp /lib64/ld-linux-x86-64.so.2 /tmp/myroot/lib64/;
# copy mount
root@docker-test:/tmp# ldd /usr/bin/mount;
root@docker-test:/tmp# cp /usr/bin/mount /tmp/myroot/bin/;
root@docker-test:/tmp# cp /lib/x86_64-linux-gnu/{libmount.so.1,libc.so.6,libblkid.so.1,libselinux.so.1,libpcre2-8.so.0} /tmp/myroot/lib/x86_64-linux-gnu/;
root@docker-test:/tmp# root@docker-test:/tmp# cp /lib64/ld-linux-x86-64.so.2 /tmp/myroot/lib64/;
# copy mkdir
root@docker-test:/tmp# ldd /usr/bin/mkdir;
root@docker-test:/tmp# cp /usr/bin/mkdir /tmp/myroot/bin/;
root@docker-test:/tmp# cp /lib/x86_64-linux-gnu/{libselinux.so.1,libc.so.6,libpcre2-8.so.0} /tmp/myroot/lib/x86_64-linux-gnu/;
root@docker-test:/tmp# cp /lib64/ld-linux-x86-64.so.2 /tmp/myroot/lib64/;
root@docker-test:/tmp# tree myroot
구성이 완료 되었으니 ps 시도
root@docker-test:/tmp# chroot myroot /bin/sh
$ ps
ps는 /proc의 실시간 정보를 받아오기 때문에 /proc폴더를 생성 후 proc type으로 mount하여 프로세스 정보를 /proc에 파일로 생성될 수 있게 설정해야한다.
$ mkdir /proc
$ mount -t proc proc /proc
$ mount -t proc
이제 ps를 시도해보자
잘 된다.
proc 마운트 해제
root@docker-test:/tmp# sudo umount /tmp/myroot/proc
root@docker-test:/tmp# mount -t proc
추가2. Chroot와 sFTP 설정
sFTP에서는 유저별로 접근 경로를 바꿔야하는 경우가 발생하므로 chroot로 이를 변경할 수 있다. 대신, 설정한 루트 경로를 벗어나지 못하게 쉘 권한을 제한하는 형식으로 사용한다. 하지만 이 설정을 잘못 사용하면 ssh를 사용하지 못할 수 있으니 유의하자. 최근 참여한 프로젝트에서 해외 사이트의 오퍼레이터 실수로 해당 사고가 일어나서 진땀 뺐던 경험이 있다.
잘못 설정된 예시) /etc/ssh/sshd_config 파일에서 115번째줄
참사 현장
올바른 사용법)
위와 같이 설정하면 sftpuser에게만 chroot가 반영되어 다른 유저의 홈 디렉토리는 영향 받지 않는다.
출처
- 컨테이너(도커)와 가상머신의 비교[1]
https://pkh11.medium.com/docker-%EB%8F%84%EC%BB%A4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80-8b93d1a46aa8 - 리눅스 프로세스[2]
https://wiseworld.tistory.com/53
https://inpa.tistory.com/entry/LINUX-%F0%9F%93%9A-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EA%B4%80%EB%A6%AC-%EB%AA%85%EB%A0%B9%EC%96%B4-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC-Foreground-Background - 컨테이너[3]
https://pmh.codes/docker-internals/ - bash탐색, chroot bash탐색[4]
https://whitewing4139.tistory.com/200 - Pivot Root, 마운트 네임스페이스, Pivot Root + 마운트 네임스페이스 [5]
https://speakerdeck.com/kakao/ige-dwaeyo-dokeo-eobsi-keonteineo-mandeulgi?slide=80
https://speakerdeck.com/kakao/ige-dwaeyo-dokeo-eobsi-keonteineo-mandeulgi?slide=86
https://speakerdeck.com/kakao/ige-dwaeyo-dokeo-eobsi-keonteineo-mandeulgi?slide=87
https://speakerdeck.com/kakao/ige-dwaeyo-dokeo-eobsi-keonteineo-mandeulgi?slide=88
도움이 될 만한 동영상
1. 가상화 및 컨테이너
2. 프로세스 이해
3. 프로세스 종류
'엔지니어링 > Docker' 카테고리의 다른 글
[KANS 3기 - 1주차(0)] 컨테이너 격리 & 네트워크 및 보안 실습 환경 구성 (0) | 2024.08.26 |
---|---|
Rocky Linux(Centos 8) 8 Docker install (0) | 2022.08.14 |
Docker exit 코드 (0) | 2022.07.31 |
Ubuntu Docker 설치 (0) | 2022.04.12 |
쿠버네티스용으로 자주 세팅하는 도커 daemon.json (0) | 2022.04.04 |