들어가며
이번 포스팅은 CloudNet@ 커뮤니티에서 주최하는 KANS 스터디 1주 차 주제인 "컨테이너 격리 & 네트워크 및 보안"에 대해서 정리한 내용입니다.
(스터디 내용이 많아 "도커 컨테이너 격리"와 컨테이너 네트워크 & IPTables로 포스팅을 나누어 작성합니다.)
실습환경 구성
#가시다님의 실습환경 세팅 정보
curl -O https://s3.ap-northeast-2.amazonaws.com/cloudformation.cloudneta.net/kans/kans-1w.yaml
aws cloudformation deploy --template-file kans-1w.yaml --stack-name mylab --parameter-overrides KeyName=devlos SgIngressSshCidr=$(curl -s ipinfo.io/ip)/32 --region ap-northeast-2
aws cloudformation describe-stacks --stack-name mylab --query 'Stacks[*].Outputs[0].OutputValue' --output text --region ap-northeast-2
# 실습환경 모니터링
while true; do
date
AWS_PAGER="" aws cloudformation list-stacks \
--stack-status-filter CREATE_IN_PROGRESS CREATE_COMPLETE CREATE_FAILED DELETE_IN_PROGRESS DELETE_FAILED \
--query "StackSummaries[*].{StackName:StackName, StackStatus:StackStatus}" \
--output table
sleep 1
done
실습환경이 세팅되었습니다. Docker가 설치된 전후 비교를 위해 네트워크 관련 기본 정보들을 확인해 보았습니다.
도커 이해하기
컨테이너화 된 프로세스를 의미하고, 호스트의 커널을 공유하여 사용합니다. 컨테이너 환경은 주로 가상머신과 비교되는데, 큰 차이는 가상머신은 운영체제 위에 하드웨어를 에뮬레이션 하고 그 위에 운영체제를 올려 실행하는 반면에, 도커 컨테이너는 하드웨어 에뮬레이션 없이 리눅스 커널을 공유해서 바로 프로세스를 실행하는 것입니다.
그림 출처 : https://netpple.github.io/docs/make-container-without-docker/
컨테이너는 호스트의 커널을 공유하지만, 개별적인 user space를 가집니다. 가상화된 공간을 생성하기 위해 리눅스의 pivot-root, namespace, cgroup을 사용하여 프로세스 단위의 격리환경과 리소스를 제공합니다.
도커 아키텍처
- docker run 명령어: 클라이언트에서 실행하여 Docker 데몬에 요청을 보내고, 특정 이미지를 기반으로 컨테이너를 실행합니다.
- docker build 명령어: 클라이언트에서 실행하여 Docker 데몬에 이미지 빌드를 요청합니다. 사용자가 제공한 Dockerfile을 기반으로 새로운 이미지를 생성합니다.
- docker pull 명령어: 클라이언트에서 실행하여 Docker 데몬에 이미지를 레지스트리에서 가져오도록 요청합니다.
- 이러한 Client와 Server 간의 통신을 Unix socket을 사용하여 수행합니다.
도커 기본 사용
- Linux Process의 이해
프로세스는 실행 중인 프로그램의 인스턴스를 의미합니다. 각 프로세스는 고유한 PID를 가지고 동작하며 OS가 이를 관리합니다. 프로세스는 또한 CPU와 Memory를 사용하는 기본 단위로, OS 커널(Cgroup)에서 각 프로세스의 자원을 관리합니다.
프로세스와 관련된 명령어는 다음 표와 같습니다.
명령어 | 설명 |
ls /proc | /proc 디렉터리 이하에 프로세스와 연관된 가상 파일시스템 내용을 확인할 수 있음 |
ps | 현재 실행 중인 프로세스의 정보를 출력하는 명령어. 기본적으로 터미널에서 실행된 프로세스만 표시합니다. |
ps aux | 시스템의 모든 사용자에 대한 모든 프로세스를 자세히 출력합니다. CPU 사용량, 메모리 점유율, 실제 메모리 사용량 등을 확인할 수 있습니다. 비율로 표시되는 이유는 상대적인 사용량을 표현하기 위해서입니다. |
ps -ef | 모든 프로세스를 표 형식으로 자세히 출력합니다. UID, PID, PPID, 시작 시간 등을 포함하여 프로세스의 자세한 정보를 제공합니다. |
pstree -apnTZ | 프로세스트리를 명령어와 모든 인수(a), PID(p), 사용자 ID(n), 터미널 친화적 형식(T), 보안 컨텍스트 정보(Z)를 포함하여 출력합니다. |
pgrep sleep -u ubuntu | 사용계정이 "ubuntu"이고 이름이 "sleep"인 프로세스를 검색하여 해당 프로세스의 PID를 출력합니다. |
실행 결과는 다음과 같습니다.
/proc 디렉터리에는 현재 동작중인 프로세스들의 정보가 담겨있습니다. 해당 디렉터리의 주요 내용을 살펴보면 다음과 같습니다.
[전체 정보 확인]
명령어 | 설명 |
mount -t proc | 프로세스 파일 시스템(proc)을 마운트합니다. 보통 시스템이 부팅될 때 자동으로 마운트됩니다. |
findmnt /proc | /proc 파일 시스템의 마운트 정보를 찾고, 해당 파일 시스템의 타겟, 소스, 파일 시스템 타입, 옵션 등을 출력합니다. |
ls /proc | /proc 디렉터리의 내용을 나열합니다. 이 디렉터리에는 커널이 동적으로 생성하는 시스템 정보와 각 프로세스의 정보가 포함됩니다. |
tree /proc -L 1 | more |
|
cat /proc/cpuinfo | CPU 정보(프로세서 수, 모델, 속도 등)를 출력합니다. |
cat /proc/meminfo | 시스템 메모리 정보를 출력합니다. |
cat /proc/uptime | 시스템 부팅 후 경과 시간과 유휴 시간을 실시간으로 출력합니다. |
cat /proc/loadavg | 시스템의 평균 부하를 출력합니다. |
cat /proc/version | 커널 버전과 컴파일 정보, 운영 체제 버전을 출력합니다. |
cat /proc/filesystems | 커널에 의해 지원되는 파일 시스템의 목록을 출력합니다. |
cat /proc/partitions | 디스크 파티션 정보를 출력합니다. |
[개별 정보 확인]
명령어 | 설명 |
tree /proc/$(pgrep sleep) -L 1 | sleep 프로세스의 /proc 디렉터리를 트리 형식으로 1단계 깊이까지 출력합니다. |
cat /proc/$(pgrep sleep)/cmdline ; echo | sleep 프로세스의 명령 줄을 출력합니다. |
ls -l /proc/$(pgrep sleep)/cwd | sleep 프로세스의 현재 작업 디렉터리를 심볼릭 링크로 출력합니다. |
ls -l /proc/$(pgrep sleep)/exe | sleep 프로세스의 실행 파일의 경로를 심볼릭 링크로 출력합니다. |
cat /proc/$(pgrep sleep)/environ ; echo | sleep 프로세스의 환경 변수를 출력합니다. |
cat /proc/$(pgrep sleep)/maps | sleep 프로세스의 메모리 매핑 정보를 출력합니다. |
cat /proc/$(pgrep sleep)/stat | sleep 프로세스의 상태 정보를 출력합니다. |
cat /proc/$(pgrep sleep)/status | sleep 프로세스의 상태 정보를 텍스트 형식으로 출력합니다. |
프로세스가 생성될 때 프로세스 정보가 생기는 것을 다음을 통해 확인할 수 있습니다.
# [터미널1]
sleep 10000
# [터미널2]
## 프로세스별 정보
ls /proc > 2.txt
ls /proc
diff 1.txt 2.txt
도커 설치 및 확인
# [터미널1] 관리자 전환
sudo su -
whoami
id
# 도커 설치
curl -fsSL https://get.docker.com | sh
# 도커 정보 확인 : Client 와 Server , Storage Driver(overlay2), Cgroup Version(2), Default Runtime(runc)
docker info
docker version
# 도커 서비스 상태 확인
systemctl status docker -l --no-pager
# 모든 서비스의 상태 표시 - 링크
systemctl list-units --type=service
# 도커 루트 디렉터리 확인 : Docker Root Dir(/var/lib/docker)
tree -L 3 /var/lib/docker
non-root user로 docker를 사용 & Socket
Docker daemon은 기본적으로 동일 호스트 내에서 통신 효율성을 위해 Unix Socket을 사용합니다. Unix 소켓은 운영 체제(OS)에서 프로세스 간 통신(IPC, Inter-Process Communication)을 위해 사용되는 파일 시스템 내의 특수 파일입니다.
Unix 소켓을 사용하게 되면, loopback 형태의 데이터 통신보다 거쳐야 하는 레이어가 줄기 때문에 아래와 같이 더욱 빠른 속도로 통신할 수 있습니다.
출처: https://miintto.github.io/docs/unix-socket
Docker는 시스템의 많은 권한을 필요하기 때문에 기본적으로 Docker 데몬이 root 권한으로 실행됩니다. Docker의 Unix socket 파일은 root 사용자가 소유하고 있기 때문에, 다른 사용자가 socket을 사용하려면 "sudo" 명령어를 사용해야 합니다.
그래서, "docker" 그룹에 사용자를 추가하면, 해당 사용자는 "sudo" 명령 없이 Docker 명령어를 사용할 수 있지만, root 수준의 권한을 가지기 때문에 보안에 취약해지게 됩니다.
root 유저와 일반(ubuntu) 유저를 통해 docker 명령어를 실행해 보면 일반 유저가 unix socket 정보를 확인할 수 없기 때문에 다음과 같이 에러가 발생합니다.
아래의 실습을 통해 인스턴스에서 사용하고 있는 tcp 소켓과 unix 소켓 정보를 확인해 봅니다.
# 소켓 정보 확인 : tcp, udp, sctp, Unix Domain
ss -h | grep sockets
ss -tl # 혹은 ss --tcp --listening
ss -xl # 혹은 ss --unix --listening
ss -xl | grep -i docker
u_str LISTEN 0 4096 /run/docker.sock 69239 * 0
u_str LISTEN 0 4096 /var/run/docker/metrics.sock 69882 * 0
u_str LISTEN 0 4096 /var/run/docker/libnetwork/914c2d2f1446.sock 69422 * 0
# 특정 소켓 파일을 사용하는 프로세스 확인
lsof /run/docker.sock
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
systemd 1 root 40u unix 0xffff96bd96236a80 0t0 69239 /run/docker.sock type=STREAM
dockerd 5178 root 4u unix 0xffff96bd96236a80 0t0 69239 /run/docker.sock type=STREAM
# unix domain socket 중 docker 필터링
lsof -U | grep -i docker
다음으로 docker 그룹에 일반 사용자(ubuntu)를 추가하여 docker 명령어를 실행해 봅니다. 통신이 되지만 이는 위에서 설명한 이유로 인해 보안적으로 취약함을 가지고 있습니다.
* 번외: 컨테이너가 host docker socket file을 공유하여 도커 실행이 가능한 방법이 있습니다. 예를 들어 container 젠킨스가 docker build가 필요할 때 다음과 같이 세팅하여 사용이 가능합니다. (볼륨을 공유하는 방법입니다.)
docker run -d -p 8080:8080 -p 50000:50000 --name jenkins-server --restart=on-failure -v jenkins_home:/var/jenkins_home -v /var/run/docker.sock:/var/run/docker.sock -v /usr/bin/docker:/usr/bin/docker jenkins/jenkins
Docker 설치 후 네트워크 정보를 확인해 보면 다음과 같이 추가된 내용들을 확인할 수 있습니다.
추가된 내용을 정리하자면 다음과 같습니다.
1. 네트워크 인터페이스 관련
- 3: docker0: <BROADCAST, MULTICAST, UP> mtu 1500 qdisc noqueue state DOWN group default
- docker0 네트워크 인터페이스에 대한 설명입니다.
- docker0: Docker가 설치되면 생성되는 기본 브리지 인터페이스입니다.
- BROADCAST, MULTICAST, UP: 브로드캐스트 및 멀티캐스트 트래픽을 지원하며, 인터페이스가 "UP" 상태로 활성화되어 있습니다.
- mtu 1500: 최대 전송 단위(MTU)는 1500 바이트입니다.
- qdisc noqueue: 큐잉 원칙은 사용되지 않음(noqueue).
- state DOWN: 인터페이스의 상태가 "DOWN"으로, 현재 활성화되어 있지 않음을 의미합니다.
- link/ether 02:42:a8:0 b:7d:1b brd ff:ff:ff:ff:ff:ff
- docker0 인터페이스의 MAC 주소와 브로드캐스트 주소입니다.
- link/ether 02:42:a8:0 b:7d:1b: MAC 주소가 02:42:a8:0 b:7d:1b로 설정됨.
- brd ff:ff:ff:ff:ff:ff: 브로드캐스트 주소는 일반적으로 모든 비트가 1로 설정된 ff:ff:ff:ff:ff:ff입니다.
- inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
- docker0 인터페이스의 IPv4 주소 및 서브넷 정보입니다.
- inet 172.17.0.1/16: IP 주소가 172.17.0.1이며, 서브넷 마스크가 255.255.0.0을 의미하는 /16입니다.
- brd 172.17.255.255: 브로드캐스트 주소는 172.17.255.255입니다.
- scope global: 전역 범위로 설정되어 있으며, docker0 인터페이스에 적용됩니다.
2. 라우팅 테이블 관련
- 172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown
- Docker 네트워크의 라우팅 정보입니다.
- 172.17.0.0/16: 서브넷이 172.17.0.0/16 범위에 포함된 모든 IP 주소에 대한 라우팅 정보입니다.
- dev docker0: docker0 장치를 통해 트래픽을 전송합니다.
- proto kernel: 이 라우팅 규칙은 커널에 의해 자동으로 추가된 것입니다.
- scope link: 링크 수준(scope)이며, 직접 연결된 네트워크임을 나타냅니다.
- src 172.17.0.1: 소스 IP 주소가 172.17.0.1입니다.
- linkdown: 네트워크 링크가 다운되어 있음(linkdown 상태).
3. iptables 규칙 관련
- -A FORWARD -o docker0 -m conntrack --ctstate RELATED, ESTABLISHED -j ACCEPT
- -A FORWARD: FORWARD 체인에 규칙을 추가합니다.
- -o docker0: docker0 인터페이스로 나가는 트래픽에 대해 규칙이 적용됩니다.
- -m conntrack --ctstate RELATED, ESTABLISHED: 연결 추적 모듈을 사용하여, 이미 설정된 연결 상태(ESTABLISHED) 또는 관련된 상태(RELATED)의 트래픽을 의미합니다.
- -j ACCEPT: 위 조건을 만족하는 트래픽을 허용(ACCEPT)합니다.
- -A FORWARD -i docker0! -o docker0 -j ACCEPT
- -A FORWARD: FORWARD 체인에 규칙을 추가합니다.
- -i docker0 ! -o docker0: docker0 인터페이스에서 들어오는(incoming) 트래픽이지만 docker0 인터페이스로 나가는(outgoing) 트래픽이 아닌 경우를 의미합니다.
- -j ACCEPT: 위 조건을 만족하는 트래픽을 허용(ACCEPT)합니다.
- -A FORWARD -i docker0 -o docker0 -j ACCEPT
- -A FORWARD: FORWARD 체인에 규칙을 추가합니다.
- -i docker0 -o docker0: docker0 인터페이스를 통해 들어오고 나가는 트래픽 모두를 의미합니다.
- -j ACCEPT: 위 조건을 만족하는 트래픽을 허용(ACCEPT)합니다.
- -A POSTROUTING -s 172.17.0.0/16! -o docker0 -j MASQUERADE
- -A POSTROUTING: POSTROUTING 체인에 규칙을 추가합니다.
- -s 172.17.0.0/16: 소스 IP 주소가 172.17.0.0/16 범위에 해당하는 경우를 의미합니다.
- ! -o docker0: docker0 인터페이스를 통해 나가는(outgoing) 트래픽이 아닌 경우를 의미합니다.
- -j MASQUERADE: NAT(Network Address Translation)을 사용하여 IP 주소를 변경(MASQUERADE)합니다. 이는 Docker 컨테이너가 외부 네트워크와 통신할 때 호스트의 IP 주소를 사용하도록 설정합니다.
4. iptables 규칙 관련
- iptables -t nat -S
- -P PREROUTING ACCEPT
- PREROUTING 체인의 기본 정책은 ACCEPT입니다.
- PREROUTING은 패킷이 목적지로 라우팅 되기 전에 가장 먼저 처리되는 체인입니다.
- -P INPUT ACCEPT
- INPUT 체인의 기본 정책은 ACCEPT입니다.
- INPUT 체인은 로컬 시스템으로 들어오는 패킷을 처리합니다.
- -P OUTPUT ACCEPT
- OUTPUT 체인의 기본 정책은 ACCEPT입니다.
- OUTPUT 체인은 로컬 시스템에서 나가는 패킷을 처리합니다.
- -P POSTROUTING ACCEPT
- POSTROUTING 체인의 기본 정책은 ACCEPT입니다.
- POSTROUTING은 패킷이 라우팅 된 후, 인터페이스를 통해 나가기 전에 마지막으로 처리되는 체인입니다.
- -A DOCKER -i <인터페이스> -j RETURN
- DOCKER 체인에 특정 인터페이스에 대한 규칙이 추가되어 있습니다.
- 이 규칙은 지정된 인터페이스에서 들어오는 패킷에 대해 RETURN을 실행하여, 해당 패킷이 다른 규칙들로 처리되지 않고 체인을 빠져나가도록 합니다.
- -A POSTROUTING -s 172.17.0.0/16! -o docker0 -j MASQUERADE
- 소스 IP 주소가 172.17.0.0/16 네트워크에 속하고, 출력 인터페이스가 docker0이 아닌 경우, MASQUERADE를 사용하여 NAT 변환을 수행합니다.
- Docker 컨테이너가 외부 네트워크와 통신할 때, 컨테이너의 사설 IP 주소가 호스트의 공인 IP 주소로 변환되도록 합니다.
- -P PREROUTING ACCEPT
컨테이너 격리 (리눅스 프로세스 격리 기술의 발전)
도커 없이 컨테이너 만들기 #1 - Chroot를 사용하여 컨테이너를 만들기 + 탈옥 시나리오
chroot root directory를 이용하여 user 디렉터리를 user 프로세스에게 root 디렉터리를 속이는 방법입니다.
sudo su -
whoami
#
cd /tmp
mkdir myroot
# chroot 사용법 : [옵션] NEWROOT [커맨드]
chroot myroot /bin/sh
chroot: failed to run command ‘/bin/sh’: No such file or directory
#
tree myroot
which sh
ldd /bin/sh # ubnutu22.04 에서는 /user/bin/sh 경로라서 아래서 조정해주고,
# sh를 실행하기 위해 바이러리 파일과 라이브러리 파일 복사
mkdir -p myroot/bin
cp /usr/bin/sh myroot/bin/
mkdir -p myroot/{lib64,lib/x86_64-linux-gnu}
tree myroot
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
tree myroot/
chroot myroot /bin/sh
ls 명령을 실행해 보면 의존성 라이브러리 문제로 인해 실행이 되지 않아, 해당 라이브러리를 추가해 주는 작업을 진행해야 합니다.
ls
exit
--------------------
#
which ls
ldd /usr/bin/ls
#
cp /usr/bin/ls myroot/bin/
mkdir -p myroot/bin
cp /lib/x86_64-linux-gnu/{libselinux.so.1,libc.so.6,libpcre2-8.so.0} myroot/lib/x86_64-linux-gnu/
cp /lib64/ld-linux-x86-64.so.2 myroot/lib64
tree myroot
#
chroot myroot /bin/sh
--------------------
ls /
## 탈출 가능한지 시도
cd ../../../
ls /
# 아래 터미널2와 비교 후 빠져나오기
exit
--------------------
# chroot 요약 : 경로를 모으고(패키징), 경로에 가둬서 실행(격리)
# [터미널2]
# chroot 실행한 터미널1과 호스트 디렉터리 비교
ls /
테스트해보면 ls를 통해 해당 디렉터리를 빠져나갈 수 없도록 격리가 된 것을 확인할 수 있습니다.
다음으로는 ps, mount, mkdir 명령을 사용하기 위해 세팅해 봅니다.
# copy ps
ldd /usr/bin/ps;
cp /usr/bin/ps /tmp/myroot/bin/;
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/;
mkdir -p /tmp/myroot/usr/lib/x86_64-linux-gnu;
cp /usr/lib/x86_64-linux-gnu/liblz4.so.1 /tmp/myroot/usr/lib/x86_64-linux-gnu/;
cp /lib64/ld-linux-x86-64.so.2 /tmp/myroot/lib64/;
# copy mount
ldd /usr/bin/mount;
cp /usr/bin/mount /tmp/myroot/bin/;
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/;
cp /lib64/ld-linux-x86-64.so.2 /tmp/myroot/lib64/;
# copy mkdir
ldd /usr/bin/mkdir;
cp /usr/bin/mkdir /tmp/myroot/bin/;
cp /lib/x86_64-linux-gnu/{libselinux.so.1,libc.so.6,libpcre2-8.so.0} /tmp/myroot/lib/x86_64-linux-gnu/;
cp /lib64/ld-linux-x86-64.so.2 /tmp/myroot/lib64/;
# tree 확인
tree myroot
라이브러리 복사 후 다시 명령어를 실행해 보면 "ps"만 동작하지 않는 것을 확인할 수 있습니다.
이는 ps 명령어 수행 시 앞서 배웠던 /proc 디렉터리를 기반으로 정보를 가져오는데, /proc 디렉터리가 myroot에 마운트가 되어 있지 않기 때문에 정보를 가져올 수 없기 때문입니다.
myroot에 /proc를 만든 후 마운트를 시켜주면 ps가 동작하게 됩니다.
(오.. 그래서 미리 mount, mkdir를 를 설치했던 거군요..! 가시 다님 *.*)
# /proc 마운트
mkdir /proc
mount -t proc proc /proc
mount -t proc
# ps는 /proc 의 실시간 정보를 활용
ps
ps auf
ps aux
ls -l /proc
exit
---------------------
# 실습 시 사용한 proc 마운트 제거
mount -t proc
sudo umount /tmp/myroot/proc
mount -t proc
다음으로 남이 만든 이미지 chroot 해보는 방법입니다.
컨테이너 이미지는 실행되는 프로세스의 동작에 필요한 모든 관련 파일을 묶어서 패키징 한 것입니다. (이는 어디서든 컨테이너가 실행할 수 있는 원리입니다. *.*)
우선 nginx 이미지를 받아와 압축을 푼다음 chroot를 통해 접속한 후 nginx를 실행시켜 봅니다.
mkdir nginx-root
tree nginx-root
# nginx 컨테이너 압축 이미지를 받아서 압축 풀기
docker export $(docker create nginx) | tar -C nginx-root -xvf -;
docker images
#
tree -L 1 nginx-root
tree -L 2 nginx-root | more
#
chroot nginx-root /bin/sh
---------------------
ls /
#
nginx -g "daemon off;"
nginx가 실행되고 있고, 컨테이너가 동작하는 것 과 비슷한 모습입니다.
이제 탈옥에 대한 실습을 진행합니다.
탈옥 파일 생성
#include <sys/stat.h>
#include <unistd.h>
int main(void)
{
mkdir(".out", 0755);
chroot(".out");
chdir("../../../../../");
chroot(".");
return execl("/bin/sh", "-i", NULL);
}
# 컴파일
gcc -o myroot/escape_chroot escape_chroot.c
tree -L 1 myroot
file myroot/escape_chroot
# chroot 실행
chroot myroot /bin/sh
-----------------------
ls /
cd ../../
cd ../../
ls /
# 탈옥 성공!
./escape_chroot
ls /
따라서 chroot는 탈옥이 되기 때문에, 컨테이너 용도로는 적합하지 않다고 이해할 수 있습니다. 이 문제 때문에 다음 실습인 Pivot_root를 컨테이너 시스템에서 사용하게 되었다고 합니다.
도커 없이 컨테이너 만들기 #2 - Mount namespace + Pivot_root를 이용한 방법
앞선 실습에서 확인한 chroot의 취약점을 차단하기 위해, pivot_root와 mount ns(호스트 영향 격리)를 사용하여 프로세스의 환경을 격리합니다.
출처 : https://speakerdeck.com/kakao/ige-dwaeyo-dokeo-eobsi-keonteineo-mandeulgi?slide=80
pivot_root는 UNIX 계열 운영 체제, 특히 리눅스에서 사용하는 시스템 호출로, 현재 실행 중인 프로세스의 루트 파일 시스템을 변경하는 데 사용됩니다. 이 시스템 호출은 컨테이너 기술이나 새로운 루트 파일 시스템으로 전환이 필요한 경우에 주로 사용됩니다.
mount namespace를 사용하면 각 네임스페이스 안의 프로세스 그룹이 서로 다른 파일 시스템 트리 구조를 가질 수 있습니다. 예를 들어, 특정 프로세스 그룹에서만 특정 디렉터리를 마운트하거나 언마운트할 수 있으며, 이는 다른 프로세스 그룹에 영향을 미치지 않습니다. 이를 통해 서로 다른 환경을 제공하고 격리된 파일 시스템 구조를 유지할 수 있습니다.
마운트 네임스페이스를 이용하여 새로운 unshare 디렉토리를 부착하고, pivot_root를 사용하여 이를 격리하는 것으로 컨테이너 환경을 구성합니다.
주요 명령어
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 조회 가능
unshare
# unshare [options] [program] [arguments]]
## "새로운 네임스페이스를 만들고 나서 프로그램을 실행" 하는 명령어입니다
# [터미널1]
unshare --mount /bin/sh
-----------------------
# 아래 터미널2 호스트 df -h 비교 : mount unshare 시 부모 프로세스의 마운트 정보를 복사해서 자식 네임스페이스를 생성하여 처음은 동일
df -h
-----------------------
# [터미널2]
df -h
unshare --mount 시 두 개의 정보가 overlay 외의 정보가 비슷합니다. 이는 호스트의 정보가 모두 보인다는 것을 의미합니다.
# [터미널1]
-----------------------
#
mkdir new_root
mount -t tmpfs none new_root
ls -l
tree new_root
## 마운트 정보 비교 : 마운트 네임스페이스를 unshare
df -h
mount | grep new_root
findmnt -A
## 파일 복사 후 터미널2 호스트와 비교
cp -r myroot/* new_root/
tree new_root/
-----------------------
# [터미널2]
cd /tmp
ls -l
tree new_root
df -h
mount | grep new_root
findmnt -A
## 안보이는 이유 : 마운트 네임스페이스를 unshare 된 상태
tree new_root/
unshared로 마운트를 하게 되면 다음과 같이 격리환경 내부에서는 정보들이 보이지만, 외부에서는 아무것도 보이지 않습니다.
다음으로는 탈옥이 불가능하도록 실행 중인 프로세스의 루트 디렉터리를 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
ls /
탈옥 시도
# 터미널1
-----------------------
./escape_chroot
cd ../../../
ls /
exit
exit
-----------------------
pivot_root를 하게 되면 이제 더 이상 탈옥이 되지 않습니다. put_old에는 host의 root directory를 볼 수 있게 되어 있지만, 이를 unmount 하게 되면 완벽한 격리가 가능해집니다.
도커 없이 컨테이너 만들기 #3- 다양한 Namespace를 이용한 프로세스 격리 (가장 중요!!)
pivot_root와 mount namepace를 통해 디렉터리들을 격리했지만, 호스트의 모든 process들을 볼 수 있게 되는 문제를 해결하여 더 명확한 경리를 시킵니다.
네임스페이스와 관련된 프로세스 특징은 다음과 같습니다.
1. 모든 프로세스들은 네임스페이스 타입별로 특정 네임스페이스에 속합니다.
2. Child는 Parent의 네임스페이스를 상속받습니다
(* 참고 : $$ 명령은 현재 bash 쉘의 Process ID입니다.)
프로세스별 네임스페이스에 대해 확인해 봅니다.
ls -al /proc/$$/ns
## 특정 네임스페이스의 inode 값만 확인
readlink /proc/$$/ns/mnt
readlink /proc/$$/ns/net
# 네임스페이스 확인 방법 2 : lsns - List system namespaces
lsns -h
lsns -p 1
lsns -p $$
## -t 네임스페이스 타입 , -p 조회할 PID
## NPROCS : 해당 네임스페이스에 속해있는 프로세스 갯수
## PID : 해당 네임스페이스의 (최초) 주인 프로세스
# PID 1과 현재 Shell 속한 프로세스의 MNT NS 정보 확인
lsns -t mnt -p 1
lsns -t mnt -p $$
1. Mount Namespace : 2002년 출시된 네임스페이스로써, 마운트 포인트를 격리시킵니다.
# PID 1과 현재 Shell 속한 프로세스의 MNT NS 정보 확인
lsns -t mnt -p 1
lsns -t mnt -p $$
# [터미널1] /tmp 디렉터리
# unshare -m [명령어] : -m 옵션을 주면 [명령어]를 mount namespace 를 isolation 하여 실행합니다
unshare -m # *[명령어]를 지정하지 않으면 환경변수 $SHELL 실행
-----------------------------------
# NPROCS 값과 PID 값의 의미 확인
lsns -p $$
# PID 1과 비교
lsns -p 1
# 빠져나오기
exit
PID 1과 비교하면 mnt에 할당된 개수와 PID 모두 다른 것을 확인할 수 있습니다.
2. UTS(Unix Time Sharing) Namespace : 2006년 여러 사용자 작업 환경을 제공하고자, 서버를 시분할로 나눠 쓰는 용도로 출시되었으며, 호스트 명과 도메인 명을 격리시킵니다.
# unshare -u [명령어]
# -u 옵션을 주면 [명령어]를 UTS namespace 를 isolation 하여 실행
# [터미널1] /tmp 디렉터리
unshare -u
-----------------------------------
lsns -p $$
lsns -p 1
## 기본은 부모 네임스페스의 호스트 네임을 상속
hostname
## 호스트 네임 변경
hostname KANS
## 아래 터미널2에서 hostname 비교
hostname
exit
-----------------------------------
# [터미널2] /tmp 디렉터리
hostname
3. IPC(Inter-Process Communication) Namespace : 프로세스 간 통신 자원 분리를 관리합니다. (Shared Memory, Pipe, Message Queue)
# [터미널1] /tmp 디렉터리
unshare -i
-----------------------------------
lsns -p $$
lsns -p 1
exit
-----------------------------------
4. PID(Process ID) Namespace : PID Namespace는 부모-자식으로 중첩되는 구조입니다. 부모에서는 자식을 볼 수 있고, 자식에서는 부모를 볼 수 없는 구조입니다. 또한 자식 Namespace는 부모 기준의 PID와 자기 자신의 기준으로 새로운 PID를 가집니다.
여기서 PID 1은 커널이 생성한 init 프로세스로, 다른 프로세스들의 시크널 처리를 수행하거나 다른 좀비, 고아 프로세스를 처리하며 죽으면 시스템 패닉(reboot)이 발생합니다.
# unshare -p [명령어]
## -p 옵션을 주면 [명령어]를 PID namespace 를 isolation 하여 실행합니다
## -f(fork) : PID namespace 는 child 를 fork 하여 새로운 네임스페이스로 격리함
## --mount-proc : namespace 안에서 ps 명령어를 사용하려면 /proc 를 mount 하기위함
# [터미널1] /proc 파일시스템 마운트
echo $$
unshare -fp --mount-proc /bin/sh
--------------------------------
# 터미널2 호스트와 비교
echo $$
ps -ef
ps aux
# 내부에서 PID NS 확인 : 아래 터미널2에서 lsns -t pid -p <위 출력된 PID>와 비교
lsns -t pid -p 1
--------------------------------
# [터미널2]
ps -ef
ps aux
ps aux | grep '/bin/sh'
root 6186 0.0 0.0 6192 1792 pts/2 S 15:08 0:00 unshare -fp --mount-proc /bin/sh
root 6187 0.0 0.0 2892 1664 pts/2 S+ 15:08 0:00 /bin/sh
# 터미널1 PID NS와 비교
lsns -t pid -p <위 출력된 PID>
lsns -t pid -p 9581
unshare mount 한 터미널 1의 상태는 다음과 같습니다.
host에서 mount 된 정보를 확인할 수 있고, 동일한 네임스페이스를 사용하고 있는 정보를 확인할 수 있습니다.
# [터미널1]
--------------------------------
# fork
sleep 10000
# 아래 종료로 자동으로 sleep 가 exit 됨
echo $$
# 아래 종료로 자동으로 exit됨 : 컨테이너의 PID 1 프로세스 종료 시
--------------------------------
echo $$
# [터미널2]
ps aux | grep sleep
## 호스트에서 sleep 종료 시켜보기 : 어떻게 되는가?
kill -l
kill -SIGKILL $(pgrep sleep)
## 호스트에서 /bin/sh 종료 시켜보기 : 어떻게 되는가?
ps aux | grep '/bin/sh'
kill -SIGKILL <위 출력된 PID>
kill -9 6187
host에서 mountnamespace의 프로세스를 제어할 수 있음을 확인할 수 있습니다.
5. User Namespace : 2012년, UID/GID 넘버스페이스 격리하는 용도로 만들어졌습니다. 컨테이너의 루트권한 문제를 해결하고, 부모-자식 네임스페이스의 중첩 구조를 가지고 있습니다.
User Namespace를 사용하면 네임스페이스 안과 밖의 UID/GID를 다르게 설정할 수 있습니다
실습을 위해 두 터미널 모두 다음과 같이 준비합니다.
사전 준비 : 터미널 1(ubuntu 일반 유저, docker 실행 가능 상태) , 터미널 2(ubuntu 일반 유저)
# 터미널1
docker run -it ubuntu /bin/sh
-----------------------------
# 아래 터미널2와 비교
whoami
id
# 아래 터미널2와 비교
ps -ef
# User 네임스페이스는 도커 컨테이너 실행 시, 호스트 User 를 그대로 사용
readlink /proc/$$/ns/user
lsns -p $$
# 아래 동작 확인 후 종료
exit
-----------------------------
# 터미널2
whoami
id
## root 로 실행됨
ps -ef |grep "/bin/sh"
##
readlink /proc/$$/ns/user
lsns -p $$
lsns -p $$ -t user
컨테이너를 탈취 후, 해당 프로세스를 기반으로 호스트에 Action 이 가능할 경우, root 계정 권한 실행이 가능하기 때문에 보안상 매우 취약한 상황이 발생합니다. 이러한 상황은 초기 컨테이너 사용 시 패키지 인스톨과 시스템 리소스 이용을 쉽게 하기 위한 콘셉트로 인해 발생했습니다.
이를 Usernamespace 격리(remap)를 통해 해결할 수 있습니다. docker에서는 user namespace를 지원합니다. (기본 설정은 user namespace를 사용하지 않는 것입니다.)
# 터미널1
unshare -U --map-root-user /bin/sh
-----------------------------
# 내부에서는 여전히 root로 보임
whoami
id
# User 네임스페이스를 호스터(터미널2)와 비교
readlink /proc/$$/ns/user
lsns -p $$
# 아래 동작 확인 후 종료
exit
-----------------------------
# 터미널2
readlink /proc/$$/ns/user
lsns -p $$
## ubuntu 로 실행됨
ps -ef |grep "/bin/sh"
ubuntu 6874 5348 0 15:42 pts/0 00:00:00 /bin/sh
다음과 같이 container에서는 root로 실행되지만, host에서는 ubuntu로 확인이 됩니다.
도커 없이 컨테이너 만들기 #4- cgroups을 이용한 자원 관리
컨테이너 환경을 격리하긴 했지만, 자원에 대한 제한이 필요하여, 이를 cgroup을 이용하여 해결합니다.
cgroup은 host의 다양한 자원(CPU, Disk I/O, Memory, Network 등) 사용을 제한/격리시키는 커널 기능입니다.
(현재 cgroup v1, v2 두 가지 버전이 존재하는데, v2가 자원계층구조의 가시성을 향상한 버전이라고 합니다. 또한 cgroup v1에서는 request, limit 두 개의 자원 설정을 지원했지만, v2에서는 memoryQoS 기능을 추가하여 컨테이너에서 쉽사리 OOM 이 발생하지 않게 하는 기능을 제공합니다.)
하나 또는 복수의 장치를 묶어서 하나의 그룹을 만들 수 있고, 개별 그룹이 시스템에서 설정한 만큼 자원을 사용할 수 있습니다. 시스템의 프로세스들은 장치별로 특정한 cgroup에 속해서 하드웨어 자원의 총량에 따라 사용량 제한을 받게 됩니다.
실습을 통해 host의 기존 Cgroup 정보를 확인해 봅니다.
mount -t cgroup
mount -t cgroup2
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)
#
findmnt -t cgroup2
TARGET SOURCE FSTYPE OPTIONS
/sys/fs/cgroup cgroup2 cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot
# cgroup2 이외에 proc, bpf 도 있음
findmnt -A
TARGET SOURCE FSTYPE OPTIONS
/ /dev/nvme0n1p1 ext4 rw,relatime,discard,errors=remount-ro
...
├─/proc proc proc rw,nosuid,nodev,noexec,relatime
...
├─/sys sysfs sysfs rw,nosuid,nodev,noexec,relatime
│ ├─/sys/kernel/security securityfs securityfs rw,nosuid,nodev,noexec,relatime
│ ├─/sys/fs/cgroup cgroup2 cgroup2 rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot
│ ├─/sys/fs/pstore pstore pstore rw,nosuid,nodev,noexec,relatime
│ ├─/sys/firmware/efi/efivars efivarfs efivarfs rw,nosuid,nodev,noexec,relatime
│ ├─/sys/fs/bpf bpf bpf rw,nosuid,nodev,noexec,relatime,mode=700
...
# cgroupv1 만 지원 시, cgroup2 출력되지 않음
grep cgroup /proc/filesystems
nodev cgroup
nodev cgroup2
stat -fc %T /sys/fs/cgroup/
cgroup2fs
# 터미널2
sleep 100000
# /proc에 cgroup 정보 확인
cat /proc/cgroups
cat /proc/$(pgrep sleep)/cgroup
0::/user.slice/user-1000.slice/session-7.scope
tree /proc/$(pgrep sleep) -L 2
...
├── ns
│ ├── cgroup -> cgroup:[4026531835]
│ ├── ipc -> ipc:[4026531839]
│ ├── mnt -> mnt:[4026531841]
│ ├── net -> net:[4026531840]
...
# cgroup 목록 확인
ls /sys/fs/cgroup
cat /sys/fs/cgroup/cgroup.controllers
#
tree /sys/fs/cgroup/ -L 1
tree /sys/fs/cgroup/ -L 2
tree /sys/fs/cgroup/user.slice -L 1
tree /sys/fs/cgroup/user.slice/user-1000.slice -L 1
tree /sys/fs/cgroup/user.slice/user-1000.slice -L 2
# 터미널1,2 관리자로 실습 진행
sudo su -
whoami
# 툴 설치
apt install cgroup-tools stress -y
# 터미널2 : 아래 stress 실행 후 CPU 사용률 확인
htop
# 터미널1에서 실습 진행
# 먼저 부하 발생 확인
stress -c 1
자원 격리를 하지 않은 경우 프로세스 한 개만큼(100%)의 부하를 줍니다.
# 서브 디렉터리 생성 후 확인 확인
mkdir test_cgroup_parent && cd test_cgroup_parent
tree
# 제어 가능 항목 확인
cat cgroup.controllers
# cpu를 subtree이 추가하여 컨트롤 할 수 있도록 설정 : +/-(추가/삭제)
cat cgroup.subtree_control
echo "+cpu" >> /sys/fs/cgroup/test_cgroup_parent/cgroup.subtree_control
# cpu.max 제한 설정 : 첫 번쨰 값은 허용된 시간(마이크로초) 두 번째 값은 총 기간 길이 > 1/10 실행 설정
echo 100000 1000000 > /sys/fs/cgroup/test_cgroup_parent/cpu.max
# test용 자식 디렉토리를 생성하고, pid를 추가하여 제한을 걸어
mkdir test_cgroup_child && cd test_cgroup_child
echo $$ > /sys/fs/cgroup/test_cgroup_parent/test_cgroup_child/cgroup.procs
cat /sys/fs/cgroup/test_cgroup_parent/test_cgroup_child/cgroup.procs
cat /proc/$$/cgroup
# 부하 발생 확인 : 터미널2에 htop 확인
stress -c 1
# 값 수정
echo 1000000 1000000 > /sys/fs/cgroup/test_cgroup_parent/cpu.max
# 부하 발생 확인 : 터미널2에 htop 확인
stress -c 1
자원 제한값을 다시 변경하면, 다시 100%를 사용하게 됩니다.
이러한 원리를 통해 도커에서도 다음과 같이 자원 격리를 할 수 있습니다.
cpu-shares : 호스트 CPU의 사이클을 해당 컨테이너가 상대적으로 얼마나 사용할 수 있는지에 대한 비중입니다.
이 플래그를 기본값인 1024보다 크거나 작게 설정하여 컨테이너의 중요도를 높이거나 낮출 수 있으며, 호스트 머신의 CPU 사이클에 대한 접근 비율을 증가시키거나 감소시킬 수 있습니다.
# CPU 부하 발생 + 실행 시 CPU 제한 설정 : CPU shares (relative weight)
docker run -d --cpu-shares 1024 --name cpu1 vish/stress -cpus "4"
docker inspect cpu1 | grep CpuShares
"CpuShares": 1024,
# 컨테이너의 자원 사용량 확인 : CPU 4개 거의 점유 사용 확인
docker stats --no-stream
# 추가 컨테이너 생성
docker run -d --cpu-shares 512 --name cpu2 vish/stress -cpus "4"
# 컨테이너의 자원 사용량 확인 = 1024 대 512 즉, 2:1 비중으로 CPU를 사용
docker stats --no-stream
# cpu1 컨테이너 종료
docker rm -f cpu1
# 컨테이너의 자원 사용량 확인
docker stats --no-stream
# cpu2 컨테이너 종료
docker rm -f cpu2
'클라우드 컴퓨팅 & NoSQL > [KANS] 쿠버네티스 네트워크 심화 스터디' 카테고리의 다른 글
[3주차(2/2) - k8s Calico CNI mode & 운영] KANS 스터디 (24.09.08) (0) | 2024.09.18 |
---|---|
[3주차(1/2) - k8s Calico CNI] KANS 스터디 (24.09.08) (1) | 2024.09.18 |
[2주차(2/2) - Flannel CNI] KANS 스터디 (24.09.01) (4) | 2024.09.05 |
[2주차(1/2) - K8S Pause container] KANS 스터디 (24.09.01) (6) | 2024.09.04 |
[1주차(2/2) - 컨테이너 네트워크 & IPTables] KANS 스터디 (24.08.25) (6) | 2024.09.01 |