도커를 매일 쓰면서도 그 내부가 정확히 어떻게 돌아가는지 설명할 수 있는 실무자는 많지 않다. 최근 한 개발자가 재미 삼아 직접 컨테이너 런타임을 만들어 보며 "도커는 이상하다(Docker is weird)"는 기록을 남겼다. 아직 초기 단계라고 밝혔지만, 도구를 직접 구현해 보는 과정에서 드러난 리눅스 커널의 민낯은 컨테이너를 운용하는 사람이라면 한 번쯤 짚고 넘어갈 만하다.
컨테이너는 가상머신이 아니다
가장 먼저 강조되는 사실은 컨테이너가 가상머신과 근본적으로 다르다는 점이다. VM이 독립된 커널 위에서 돌아가는 것과 달리, 컨테이너는 그저 특별한 제약이 걸린 하나의 프로세스일 뿐이다. 호스트의 커널이 그 프로세스를 그대로 실행하고 있으며, 어디를 봐야 하는지 안다면 /proc 안에서 그 프로세스를 직접 찾아낼 수도 있다. 이 관점은 실무에서 꽤 중요하다. 컨테이너가 별도의 세계가 아니라 호스트 커널을 공유하는 프로세스라는 사실은, 커널 취약점이 왜 컨테이너 격리를 통째로 무너뜨릴 수 있는지, 왜 컨테이너 보안이 까다로운 일인지를 설명해 준다.
더 흥미로운 지적은 리눅스에 "컨테이너"라는 단일 기능이 존재하지 않는다는 것이다. 글쓴이의 표현을 빌리면 컨테이너는 다섯 가지 서로 다른 커널 기능이 트렌치코트를 걸치고 한 사람인 척하는 것에 가깝다. 네임스페이스(namespace)는 호스트와 컨테이너가 서로 무엇을 볼 수 있는지를 가려 주고, cgroups는 컨테이너가 쓸 수 있는 자원을 제한한다. 여기에 글쓴이 스스로도 "아직 이해하지 못했다"고 인정한 복잡한 네트워킹 기법이 더해져 컨테이너의 네트워크를 격리한다. 즉 우리가 하나의 명령으로 손쉽게 띄우는 컨테이너는, 사실 독립적으로 발전해 온 커널 기능들을 조합해 만든 추상화일 뿐이다.
이미지는 결국 폴더, 그리고 chroot
이미지의 정체도 생각보다 소박하다. 도커 이미지는 특정 위치에 풀어 놓은 폴더에 지나지 않으며, 도커는 chroot(또는 pivot_root)를 호출해 그 폴더를 컨테이너 프로세스의 새로운 루트 디렉터리로 삼는다. docker run -it --rm ubuntu /bin/bash를 실행했을 때 벌어지는 일도 결국 이 과정의 연장선이다. 거창한 마법처럼 보이던 동작이 오래된 유닉스 기능의 조합이라는 점은, 이미지 레이어나 파일시스템 문제를 디버깅할 때의 사고방식을 바꿔 준다.
Go로 만든 런타임의 딜레마
기사에서 가장 기술적으로 날카로운 대목은 도커의 컨테이너 런타임인 runc가 Go로 작성됐다는 점에서 비롯되는 문제다. Go 런타임은 태생적으로 멀티스레드다. 훌륭한 특성이지만, 컨테이너 런타임이 다뤄야 하는 상당수의 리눅스 시스템 콜은 단일 스레드 환경에서 실행돼야 한다. 그래서 runc는 Go 런타임이 초기화되기 전에 먼저 실행되는 별도의 C 모듈을 두고 그 안에서 해당 시스템 콜을 처리한다.
또 하나의 걸림돌은 Go에 fork가 없다는 사실이다. fork는 메인 스레드만 복제하기 때문에 Go 같은 멀티스레드 런타임에서는 위험한 함정이 된다. 하지만 컨테이너 런타임은 프로세스를 fork한 뒤 각종 제약을 걸고, 그다음에 사용자가 docker run으로 지정한 프로세스를 exec하고 싶어 한다. runc는 이 한계를 우회하기 위해 자기 자신(/proc/self/exe)을 특별한 명령행 인자와 함께 다시 exec하는 방식으로, 자신이 자식 프로세스임을 알려 fork를 흉내 낸다. 언어 선택이 시스템 프로그래밍의 구조를 어떻게 비트는지 보여 주는 생생한 사례다.
맥OS 사용자가 놓치는 것, 그리고 루트리스의 과제
맥OS에서 도커를 쓰는 사람은 이 모든 맥락을 놓치기 쉽다. 컨테이너 런타임이 사용하는 리눅스 커널 API 상당수는 과거에, 혹은 지금도 루트 권한을 요구하기 때문에 도커 데몬은 대개 루트로 실행된다. 글쓴이가 "컨테이너 보안은 무척 힘든 일일 것"이라고 짐작한 이유다. 다만 이 상황은 과거보다 나아졌고, 가장 큰 예외는 네트워킹으로 보인다. 루트 권한 없이 동작하는 루트리스(rootless) 런타임을 쓰면, 동일한 기능을 제공하기 위해 네트워킹 스택 전체를 사용자 공간에서 다시 구현해야 하는 것으로 보인다고 글쓴이는 전한다.
이 글은 벤치마크나 새로운 제품 발표가 아니라, 도구를 직접 만들며 얻은 학습 기록이라는 점에서 가치가 있다. 추상화 뒤에 숨은 네임스페이스, cgroups, chroot, 그리고 런타임이 언어와 씨름하는 지점을 이해하면, 권한 문제나 네트워크 격리 이슈를 만났을 때 당황하지 않고 원인을 짚을 수 있다. 자신이 매일 쓰는 도구의 축소판을 직접 구현해 보는 것이 그 도구를 가장 깊이 배우는 방법이라는 결론은, 컨테이너에 국한되지 않는 조언이기도 하다.