[Container] 도커없이 컨테이너 만들기 후기
컨테이너 런타임을 좀 공부해보려니까 아는 개념이 너무 없어서, 예전에 공부했던 강의로 짧게 복습해봤다.
도커 없이 컨테이너 만들기
목차 및 실습환경 구성안내
정말 쉽고 재밌게 설명해주셔서 너무 감사하다.
컨테이너에 대해서 정리를 좀 해보자.
시분할 시스템에서 컴퓨터를 나눠 쓰게 되면서 자원 공유의 개념이 생겼는데, 사실 나는 배치 시스템만 있던 시대를 겪지는 않아서 그런갑다~ 싶다. 아무튼 한 호스트 안에서 여러 프로세스가 동시에 돌아가게 되었다.
왜 서버를 쓰는가부터 해보자면, 내가 지도 앱을 만들었다고 치자. 내 개발용 컴퓨터나 집 컴퓨터로 호스팅을 해도 되겠지만, 사용자가 쓰는 24시간 7일 365일 내내 컴퓨터를 켜놔야 한다. 그리고 지도 앱이랑 증바람을 같이 돌리면 컴퓨터가 너무 느려진다. 그래서 지도 앱을 띄워두는 전용 컴퓨터로 서버를 쓰는 셈이다.
이제 서버라고 하자. 한 서버에 내가 만든 프로그램을 돌리고 싶은데, 그 서버가 윈도우냐, 맥이냐, 아키텍처는 뭐냐에 따라서 배포하는 형태가 달라진다. 내 입장에서는 그렇고, 서버 입장에서는 이놈 저놈 실행을 위한 모든 걸 다 준비해둬야 한다.
첫 번째 문제, 컴퓨터 실행 환경과 상관없이 실행시키고 싶다. 이건 리눅스 커널 위에서 실행하는 것으로 통일하자. 서버가 맥이든 윈도우든 그 안에는 내 프로그램을 위한 리눅스 커널이 준비되어 있도록 약속한다.
두 번째 문제, 내 프로그램 실행을 위해 필요한 다른 것들은 어떡하냐. 내 프로그램을 서버에 넣을 때 필요한 모든 것을 같이 묶어서 넣자. 어떤 서버에 넣더라도 내가 준비물을 다 싸 들고 다니니까, 서버 입장에서는 준비해둘 게 딱히 없다.
여기까지 요약해보면, 프로그램 실행에 필요한 것을 모아서 리눅스 커널 환경 위에서 동작시킬 수 있게 “패키징”해둔 것이 컨테이너 “이미지”라고 볼 수 있겠다.
이제 새로운 문제가 등장한다. 내가 필요한 건 Ubuntu, MySQL, Redis, 친구가 필요한 건 Ubuntu, MySQL, EMQX라고 하자. 우리가 각자 패키징해 오면 서버에는
- Ubuntu x 2
- MySQL x 2
- Redis x 1
- EMQX x 1
이렇게 저장된다. 똑같은 Ubuntu와 MySQL이 중복으로 저장된다. 한두 개면 문제가 없지만 수십, 수백 개가 되면 낭비가 심해진다.
그래서 오버레이 파일시스템을 사용한다. 이미지를 여러 레이어로 겹쳐서 최종적인 결과물 하나로 만들어 내는 기술이다. 표로 보자.
| 레이어 | 내꺼 | 친구꺼 |
|---|---|---|
| Merged | my/merge{ubuntu,mysql,redis,container} | your/merge{ubuntu,mysql,emqx,container} |
| Container | my/container | your/container |
| EMQX | X | layer/emqx |
| Redis | layer/redis | X |
| MySQL | layer/mysql | layer/mysql |
| Ubuntu | layer/ubuntu | layer/ubuntu |
같은 layer/ 밑의 레이어들을 쌓고, 그 위에 각자의 container 레이어를 하나 더 쌓는다. 그리고 최종적으로 모든 게 합쳐진 merge 레이어에 컨테이너가 마운트된다.
오버레이 생성 자체는 어렵지 않다. mount -t overlay overlay -o lowerdir=redis:mysql:ubuntu,upperdir=my/container,workdir=my/work my/merge 이러면 위에처럼 생성된다.
lower layer는 read only다. 다른 것과 공유되는 거기도 하고, 나도 뭔가 잘못돼서 처음부터 다시 해야 될 때 원본은 유지되어야 하니까. 컨테이너 내부에서 파일의 변화들은 모두 upper layer에 기록된다. 그 변화의 기록까지 다 합쳐지면 merge가 되는 거고, 컨테이너가 쓰는 건 딱 merge layer다. 오버레이를 위해서 workdir도 필요한데, 이건 container에 원자적 쓰기를 하기 위해 필요한 공간이라고 한다. 자세히는 알아보지 않았다.
lower layer가 read only라고는 했지만, 호스트 시스템(낫 오버레이 시스템)에서 그 레이어 경로를 찾아가서 뭔가 바꾸면 바뀐다. 그리고 그 바뀐 것은 컨테이너를 새로 실행하면 수정된 게 보인다. 원래 실행 중이던 컨테이너에도 바뀐 게 반영된다. 마운트 네임스페이스는 마운트 정보만 격리할 뿐, lower layer 파일 자체는 호스트와 같은 걸 보기 때문이다. 다만 커널 문서에서는 마운트된 상태로 lower layer를 직접 고치는 건 동작을 보장하지 않는다(undefined)고 하니, 이렇게 쓰는 건 피하는 게 좋다.
이렇게 중복을 해결할 수 있다. 위에서 잠깐 말했지만 격리 문제가 남았다. 격리가 왜 필요하냐면, 컨테이너의 의의를 다시 생각해보자. 내 프로그램을 실행하기 위해 하나로 싸 들고 와서 살림을 차리지 않았는가. 조용히 내 할 일만 하러 왔는데 옆에서 자꾸 쿠사리를 놓고, 심지어는 우리 집 차단기까지 내린다?
컨테이너 격리라는 건 다른 컨테이너로부터 방해받지 않고 온전하게 내 할 일만 하기 위해 필요한 것으로 이해했다. 내 컨테이너가 악의를 가지고 호스트 시스템에 접근해서 나쁜 짓을 하는 걸 막는 목적도 있겠지만, 나 역시 다른 컨테이너로부터 방해받지 않고 실행될 수 있는 환경을 보장받는 목적도 있다. 물론 호스트 시스템은 집주인이니까 우리 집 가스/전기/물은 얼마나 썼는지, 공용 공간에서 말썽 피우진 않는지, 밤에 고성방가는 안 하는지 알 수는 있어야 한다. 가장 쉽게는 리소스 사용량을 혼자 다 먹고 이상한 짓 하면 두꺼비집을 내릴 수야 있겠지. 호스트니까. 다만 옆집 이웃이 내가 양치물을 컵에 받아서 하는지 틀어놓고 하는지 알 필요는 없다. 내가 하는 짓이 옆집 이웃한테 영향을 주면, 그건 또 그거대로 격리가 안 된 거다.
암튼 어떤 격리가 필요한지 살펴보자. 일단 컨테이너는 자신만의 세계가 필요하다. 호스트가 가지고 있는 루트 파일시스템에 의존하지 않고 자기만의 루트 파일시스템이 필요하다. 내가 java11, 친구가 java21을 써야 한다고 가정하자. 호스트 시스템에는 (host_root)/usr/bin/java 하나뿐이다. 이걸 공유하면 둘 중에 한 명은 까다로운 설정이 필요하다. 대신 자기만의 (container_root)/usr/bin/java를 각자 가지고 있으면 문제될 게 없다. 그리고 호스트가 java를 준비해둘 이유도 없고.
그리고 하나 더. 컨테이너는 격리되어야 한다. 자기 루트 파일시스템에서 밖으로 나가지 않고 그 안에서 살 수 있도록 해야 한다. 리눅스에는 pivot_root 시스템콜이 있다. 말 그대로 루트를 “실제로” 바꾸는 것. 리눅스에서는 파일시스템이 마운트의 개념으로 꽂히는데, 컴퓨터에 USB를 꽂으면 컴퓨터의 어느 경로를 통해야 USB의 파일시스템에 접근할 수 있나? FAT겠지? USB는 /dev/sdb1 같은 장치로 잡히고, “/media/usb로 접근하면 USB의 내용을 볼 수 있어요”라고 하는 게 “USB(/dev/sdb1)를 /media/usb에 마운트할게요”라는 의미다.
똑같다. “컨테이너의 루트를 myroot에 마운트할게요”라는 소리다. 시스템콜이 pivot_root이므로 기존 루트 파일시스템은 또 다른 곳에 옮겨둔다는 것. 아마 초기에 ns가 없을 때는 옛날 것을 보존해야 하니까 pivot이지 않았을까 예상한다. 자, 근데 이 마운트 포인트 정보를 호스트와 컨테이너가 공유하게 되면 다른 컨테이너들도 똑같이 공유한다는 소리고, 제대로 격리가 된 게 아니다. 호스트는 호스트대로 유지하고 각자 컨테이너는 자기만의 루트로 전환해야 하므로, 이 마운트 포인트 정보를 서로 다르게 가져갈 수 있도록 분리한 게 네임스페이스 개념이다.
컨테이너는 마운트 포인트 정보를 격리하며 실행하고, 실행 후에는 루트 파일시스템을 자기 것으로 전환하고(오버레이의 merge로), 옛날 호스트 루트 파일시스템을 마운트 해제한다. 그러면 완전히 자기만의 루트 파일시스템에서만 살아가게 된다.
또 격리를 하는 것들을 알아보자.
UTS ns는 호스트 이름을 격리한다. 엽떡이 서울숲D타워에 입점하든, 아파트 상가에 입점하든 엽떡의 간판은 엽떡이다.
IPC ns는 IPC라고 프로세스 간 통신을 위한 기술이 있는데, 그걸 격리한다. 에어컨 온도를 올리는 손님과 내리는 손님이 있다. 이때 에어컨 온도를 바꾸기 위해서는 에어컨이 한 번에 하나의 리모컨 신호만 받는다. 이걸 위해서는 공유 메모리와 바이너리 세마포어가 필요하다. 그래서 엽떡 손님 두 명은 이 공유 메모리와 바이너리 세마포어를 공유하면서 통신한다. 근데 옆집 냉면집 손님이 엽떡의 에어컨 온도를 바꾸거나 알 필요는 없지 않은가? 그래서 IPC를 격리한다.
PID ns는 프로세스 id 정보를 격리한다. 이건 단순히 격리하는 건 아니고, 컨테이너의 init 프로세스를 fork하면서 격리하는 형태다. 프로세스 부모 자식 관계는 트리로 표현할 수 있는데, C에서 fork() 시스템콜을 생각해보면 부모한테는 자식의 pid가 return되고, 자식은 fork() 결과로 0을 받는 것을 생각해볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {
pid_t pid;
pid = fork();
if (pid == 0) {
// 자식 프로세스
printf("나는 자식이다 pid=%d\n", getpid());
}
else {
// 부모 프로세스
printf("나는 부모다 pid=%d. 내 자식의 pid=%d\n", getpid(), pid);
}
return 0;
}
물론 네임스페이스 격리랑은 다른 이야기긴 하다. 아무튼 컨테이너가 실행되면서 자식 pid ns가 격리되는데, 그 안에서 자식은 pid=1이 된다. init process가 되는 거다. 근데 부모가 보기에는, 자기가 1인 줄 아는 자식도 실제로는 11582 뭐 이런 pid인 걸 알고 있다.
init process는 역할이 몇 개 있다. 대표적으로 고아/좀비 프로세스 처리와 시그널 전파다. 중요한 건 init process가 된다고 자동으로 이런 걸 할 줄 아는 건 아니고, 그 프로세스가 기능을 구현하고 있어야 한다는 점이다. 실습에서 사용한 /bin/sh은 제한적으로나마 수행할 수 있지만, 원래 호스트에서는 systemd가 이 역할을 수행한다. 뭐 약간 우리 우주가 시뮬레이션이라고 말하는 이론과 비슷한 결이다. 우주 밖 존재가 만든 우리 우주의 우주적 존재(pid=1)가 우주를 관리하는데, 사실 알고 보면 우주적 존재 녀석도 우주 밖에서 만든 범부(pid=99999)였던 것이다.
근데 왜 격리하느냐? 컨테이너 안에서 다른 컨테이너의 프로세스가 보인다? 나라면 내 것 빼고 다른 컨테이너 프로세스들한테 SIGKILL을 열심히 날릴 거다. 그리고 자원을 독점하면 좋지 않겠는가? 근데 컨테이너 밖의 프로세스가 뭐가 있는지 알 수 없다면(pid ns를 격리해서) 그 프로세스한테 뭔가 해코지를 할 수 없다. 반대로 외부에서 이런 짓을 할까 봐 내 것을 보호하는 것도 있다. 물론 호스트는 다 안다.
Network ns는 말 그대로 네트워크 분리다. 실습하면서 컨테이너가 띄운 nginx에 호스트에서 접근할 수 있었는데, 그 말은 같은 네트워크를 사용해서 localhost:80으로 접근이 가능했다는 뜻이다. 사실 어찌 보면 웹서버의 국룰 80번 포트를 나도 쓰고 친구도 쓸 텐데, mysql은 3306을 쓰고, 내가 만든 서버들은 보통 12039번을 준다. 근데 이것들이 같은 네트워크에 있으면 서로 포트를 뺏으려고 난리 부르스를 친다. 사실은 한 놈이 잡고 나머지가 못 뜨겠지. 근데 80, 443, 8080 이건 국룰이다. 내가 이걸 배포하는 서버마다 다르게 설정해주거나, 그 서버에서 어떤 포트를 쓰고 있는지 알아야 한다는 건 컨테이너의 의의와 정면으로 충돌한다. 그래서 네트워크도 분리해줘야 한다.
이 네트워크 ns에는 가상 장치를 만들어서 꽂을 수 있는데, 쉽게 생각해서 NIC, 그냥 LAN 포트를 달 수 있는 거다. 실습에서 두 개의 연결된 포트를 만들고, 각각의 포트를 다른 네트워크 네임스페이스에 꽂은 뒤 ip를 할당해줬더니 통신이 가능했다. 더 살펴볼 점은, 호스트와 컨테이너들을 연결하려면 컨테이너 수만큼 가상 포트가 필요할까? 1:1이면 굉장히 실망할 것 같다. 근데 도커에서 bridge 네트워크라는 게 호스트랑 연결하는 거잖아. 그래서 아마 bridge에서 1:N을 만들어주지 않을까 싶다. 컨테이너 네트워크 쪽으로 심화해서 살펴보기도 좋을 듯하다.
마지막 격리는 User ns, 사용자 분리다. 이건 실행 권한 때문에 꽤나 중요하다. 함정이 많은 부분이기도 하다. fork의 형태는 아니고, 호스트 프로세스가 새로운 유저 네임스페이스의 루트로 매핑된다. “컨테이너의 init process를 실행하는 호스트 유저”를 컨테이너 안에서 루트 유저로 매핑한다. 대충 UID=12039(dongbin)으로 실행하면 컨테이너 안에서는 UID=0(root)가 된다. 컨테이너 안에서는 루트가 맞기 때문에 apt install malware를 하면 설치가 된다!!! 그렇지만 컨테이너의 파일시스템 안에서 설치될 뿐, 호스트 파일시스템에 직접 설치되는 건 아니다. 그리고 그 악성 프로세스가 실행을 하더라도 네임스페이스가 다 격리되어 있으면 다른 컨테이너나 호스트를 공격하기는 어렵다.
하지만 주의할 점은, 악성 프로세스가 실행되는 것도 결국에는 공유되는 리눅스 커널을 통해서 실행된다는 것이다. 리눅스 커널의 취약점을 통해서 컨테이너를 깨고 나오면 문제가 되지만, 우선 리눅스 커널 입장에서는 이렇게 보인다.
- 악성 프로세스가 컨테이너 안에 뭔가 하려고 한다. → 파일의 권한과 프로세스의 아이디를 확인해서 허용한다. 자기 네임스페이스 안에서는 루트니까 접근 못 할 것이 없다.
- 악성 프로세스가 컨테이너 밖에 뭔가 하려고 한다. → 프로세스의 아이디는 컨테이너 밖에서는 범부일 뿐이다. 파일 권한에 접근할 수 없으면 컷 당한다.
여기서 함정이 있다. 컨테이너를 실행한 호스트 유저가 진짜 호스트의 루트였으면 문제가 된다. 그래서 컨테이너를 실행할 때는 일반 유저로 실행하는 게 중요하다. 근데 도커는 진짜 루트로 실행한다. 컨테이너 초반에 편의성 때문이라고 한다. 지금은 조정 가능하다.
사용자 권한에 대해서 좀 더 찾아봤다. 일단 컨테이너에 볼륨 마운트가 되었고, 거기에 루트 권한의 파일이 있다고 치자. 범부 유저가 매핑된 범부 루트는 그걸 읽지 못한다. 근데 범부 루트가 진짜 루트의 권한을 얻을 수 있을까? sudo를 통해서 비밀번호 인증으로 권한을 얻어야 하는데, 일단 fs가 격리되었기 때문에 호스트의 비밀번호 정보가 들어 있는 파일이 컨테이너 안에 없다. 그리고 sudo는 PAM을 통해서 인증을 받는데, PAM은 따로 떠 있는 서비스가 아니라 sudo 프로세스 안에서 도는 라이브러리라서, 컨테이너 안에서는 컨테이너 파일시스템에 있는 설정과 계정 정보로만 인증한다. 즉 호스트의 PAM과 연결을 할 수 없다. 만약에 컨테이너 안에서 별짓을 다 해서 인증을 받았다고 치자. 그래도 커널은 “이놈이 격리된 네임스페이스 안에서 인증을 받았구나? SetUID(0)을 다른 네임스페이스에서 받은 거구나?” 하고 짤한다.
이제 격리는 다 했으니 컨테이너는 안전하게 돌아간다. (도커 소켓 마운트 같은 거 안 하면) 그런데 아직 자원을 나눠주지 않았다. 이걸 위해서는 cgroup을 사용하면 된다. 실습에서 써보니까 어려울 거 하나도 없더라. 뭐 cpu, mem, network 등을 단순한 방법으로 제한할 수 있었다.
재밌는 건 echo "1" > /sys/fs/cgroup/my/cgroup.procs에 쓸 때 일어났는데, 그 그룹에 속할 PID를 쓰는 거다. pid를 쓰면 그 자식 프로세스들까지 모두 해당 그룹에 속하기 때문에 컨테이너의 1번만 써두면 된다. 근데 단순 멍청한 echo "1"을 했는데 실제 호스트에서 써진 걸 확인하니까 11582로, host pid ns에서의 컨테이너 1번 프로세스의 pid가 적혀 있었다. 좀 알아보니 /sys/fs/cgroup 여기는 가상 파일시스템으로, 커널이 메모리에 만들어둔 우체통 같은 역할이었다. 그래서 컨테이너 안에서(격리된 네임스페이스를 가진) echo "1"을 cgroup에 write()하면 리눅스 커널이 그걸 가로채서 호스트 pid로 바꿔서 적는 거였다. 그리고 이 작업은 컨테이너를 실행하고 나서 pivot_root를 하기 전에 써두고 해야 한다. 뭐.. 수동으로 하다가 만약 놓쳤으면 호스트에서 써주면 될 듯?