도커 직접 만들어보니 알게 된 컨테이너의 숨겨진 작동 원리

한 개발자가 취미로 자신만의 컨테이너 런타임을 만들어보다가, 평소 당연하게 써왔던 도커(Docker)가 사실 꽤 기묘한 방식으로 동작한다는 사실을 발견해 블로그에 공유했다. 컨테이너를 가상머신(VM)과 비슷한 것으로 생각하는 개발자가 많지만, 실제로는 전혀 다른 원리로 작동하며 그 구현 방식에는 생각보다 많은 “꼼수”가 숨어 있다는 내용이다. 매일 docker run 명령어를 쓰는 개발자라면 그 이면에서 무슨 일이 벌어지는지 알아둘 만하다.

컨테이너는 가상머신이 아니라 그냥 “제약이 걸린 프로세스”

가장 먼저 짚어야 할 오해는 컨테이너가 VM과 비슷한 독립된 환경이라는 생각이다. 원문 저자에 따르면 컨테이너는 사실 호스트 커널 위에서 돌아가는 평범한 프로세스일 뿐이며, 여러 제약 조건이 걸려 있을 뿐이다. VM처럼 커널 자체를 별도로 띄우는 게 아니라, 호스트의 커널이 그대로 그 프로세스를 실행한다. 그래서 리눅스의 /proc 디렉터리를 잘 뒤져보면 컨테이너로 돌아가는 프로세스를 호스트에서 직접 찾아낼 수도 있다. 이는 컨테이너가 VM보다 가볍고 빠르게 뜨는 이유이기도 하지만, 동시에 격리 수준이 VM만큼 완전하지 않다는 뜻이기도 하다.

“컨테이너”라는 단일 기능은 리눅스에 없다

더 흥미로운 지점은 리눅스 커널에 “컨테이너”라는 기능 자체가 존재하지 않는다는 사실이다. 원문 표현을 빌리면 컨테이너는 5가지 서로 다른 리눅스 커널 기능이 트렌치코트를 입고 하나처럼 보이는 것에 가깝다. 대표적으로 다음과 같은 기능들이 조합된다.

  • 네임스페이스(namespaces): 호스트와 컨테이너가 서로 무엇을 볼 수 있는지를 제한한다.
  • cgroups: CPU, 메모리 등 컨테이너가 쓸 수 있는 자원량을 제한한다.
  • 네트워킹 관련 기능: 컨테이너의 네트워크를 호스트나 다른 컨테이너와 분리시킨다. 원문 저자도 이 부분은 “마법처럼 복잡해서 완전히 이해하지 못했다”고 솔직하게 밝히고 있다.

즉 도커가 제공하는 “컨테이너”라는 경험은 단일 커널 기능이 아니라, 여러 저수준 기능을 도커가 조합하고 포장해서 만들어낸 추상화라는 것이다.

도커 이미지의 정체는 그냥 폴더, chroot로 루트를 바꿔치기

도커 이미지도 생각보다 단순하다. 이미지는 결국 도커가 어딘가에 풀어놓은 평범한 폴더일 뿐이며, 컨테이너를 실행할 때 chroot(또는 더 안전한 pivot_root)를 호출해 그 폴더를 컨테이너 프로세스의 새로운 루트 디렉터리로 바꿔치기한다. 예를 들어 docker run -it --rm ubuntu /bin/bash를 실행하면, 도커는 우분투 이미지 폴더를 준비하고 그 폴더를 새 루트로 삼은 뒤 그 안에서 /bin/bash를 실행하는 식이다. 복잡한 가상화 기술이 아니라 리눅스의 오래된 루트 전환 기법을 영리하게 활용한 셈이다.

Go 언어로 만든 runc가 겪는 스레드와 fork 문제

도커의 실제 컨테이너 런타임인 runc는 Go 언어로 작성되어 있는데, 원문 저자는 Go의 설계가 오히려 컨테이너 런타임에는 불리한 측면이 있다고 지적한다.

Go 런타임은 기본적으로 멀티스레드로 동작한다. 그런데 컨테이너 런타임이 호출해야 하는 여러 리눅스 시스템 콜은 반드시 싱글스레드 환경에서 실행되어야 한다. 이 문제를 해결하기 위해 runc는 Go 런타임이 초기화되기도 전에 먼저 실행되는 별도의 C 모듈을 두어, 그 안에서 싱글스레드로 필요한 시스템 콜을 처리한다.

또 다른 문제는 Go에 fork가 없다는 점이다. 전통적인 fork는 메인 스레드만 복제하기 때문에 Go의 멀티스레드 런타임에서는 위험 요소(“footgun”)가 된다. 하지만 컨테이너 런타임은 프로세스를 fork한 뒤 각종 제약을 설정하고, 그다음 실제로 실행할 프로그램을 exec하는 흐름이 필요하다. runc는 이를 우회하기 위해 /proc/self/exe를 특수한 커맨드라인 인자와 함께 다시 실행(exec)해서, 이 프로세스가 “자식 프로세스”임을 식별시키는 방식으로 fork를 흉내 낸다. Go라는 언어의 특성 때문에 생긴 독특한 우회책인 셈이다.

도커 데몬이 루트 권한으로 도는 이유와 맥OS 사용자가 체감하기 어려운 이유

컨테이너 런타임이 사용하는 많은 리눅스 커널 API는 (적어도 과거에는) 루트 권한으로 실행되어야 했기 때문에, 도커 데몬은 보통 루트 권한으로 실행된다. 원문 저자는 이 때문에 “컨테이너 보안 담당자는 정말 힘든 일을 하고 있을 것”이라고 언급한다. 다만 맥OS에서 도커를 쓰는 사용자는 리눅스 VM 안에서 도커가 돌아가는 구조이기 때문에 이런 권한 문제를 직접 체감하기 어렵다.

최근에는 루트 권한 없이 동작하는 “rootless” 컨테이너 런타임도 많이 개선되었다고 한다. 다만 원문 저자는 네트워킹만큼은 여전히 예외라고 지적한다. 루트리스 런타임에서 기존과 동일한 네트워킹 기능을 제공하려면 전체 네트워킹 스택을 유저스페이스에서 다시 구현해야 하는 것처럼 보인다는 것이다.

한국 개발자에게 주는 시사점

이 글 자체가 새로운 제품이나 기술 발표는 아니지만, 매일 컨테이너를 다루는 개발자나 인프라 엔지니어에게는 실질적인 도움이 되는 내용이다. 컨테이너 격리가 VM만큼 강력하지 않다는 점을 이해하면 보안 설정(예: 불필요한 권한 제거, 네임스페이스 설정 점검)의 중요성을 체감하기 쉽다. 또한 도커 데몬이 왜 루트 권한을 요구하는지, rootless 모드를 쓸 때 네트워킹 성능이나 기능 제약이 왜 생기는지도 이런 배경을 알면 트러블슈팅에 도움이 된다. 원문 저자가 말하듯 평소 쓰는 도구를 직접 만들어보는 경험은 그 도구를 훨씬 깊이 이해하게 해주는 좋은 학습 방법이며, Go나 Rust로 소규모 컨테이너 런타임을 만들어보는 사이드 프로젝트는 리눅스 시스템 프로그래밍을 공부하려는 개발자에게 좋은 소재가 될 수 있다.

원문: Docker Is Weird


코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다