[도커 만들기] 1탄: 프로세스 가두기

이지호·2025년 7월 11일
11

도커 만들기

목록 보기
1/3
post-thumbnail

0. 도커 없인 못 살아

지난 인턴 경험을 하면서, 정말 이제는 도커를 모르면 안 되겠구나 싶었던 때가 있었다.

어드민 페이지를 위한 React 작업을 하던 때였다. 코드를 깃허브에 올려 팀에 공유한 뒤 피드백을 받고자 했었다. README 파일에 실행 방법을 적어놓긴 했으나, 그럼에도 '번거롭다'라는 피드백을 받은 경험이 있다. 팀 대부분이 Go, Python 환경을 사용해서 React 개발환경이 셋팅되지 않은 상황이었기 때문이다.

내 코드를 실행하려면 node 설치 → npm install로 의존성 설치 → npm run으로 실행하는 과정이 필요했는데, 이 과정 자체가 굉장히 번거로웠던 것이다.

도커를 활용한다면 이 문제를 해결할 수 있다. 간단한 도커 명령어 몇 줄이면 개발환경을 맞출 필요가 없기 때문이다. 이 경험을 계기로 도커가 단순한 편의 도구가 아니라, 협업의 핵심 도구라는 것을 깨달았다.

1. 도커란 무엇인가?

도커는 컨테이너 기술이다

개발환경을 손쉽게 맞출 수 있다는 건 구체적으로 어떻게 가능한 일일까? 컨테이너 기술 덕분이다. 도커는 이러한 컨테이너 기술을 쉽게 사용할 수 있게 해주는 플랫폼이다.

컨테이너 기술을 구체적으로 알아보자. 컨테이너 기술은 쉽게 말해서, 애플리케이션과 그 실행에 필요한 모든 것(라이브러리, 의존성 설정 등)을 하나의 '상자'에 담아서 어디서든 동일하게 실행할 수 있게 하는 기술이다.

마치 컨테이너 박스 처럼 생각해볼 수 있다. 컨테이너 안에 담긴 물건들은 트럭으로 운반하든, 배로 운반하든, 기차로 운반하든 상관없이 안전하게 목적지에 도착한다. 도커 컨테이너도 마찬가지다. 컨테이너 안에 담긴 애플리케이션은 macOS에서 실행하든, Windows에서 실행하든, Linux에서 실행하든 동일하게 작동한다.

예를 들어보자. macOS를 사용하는 개발자 A와 Windows를 사용하는 개발자 B가 함께 NestJS 서버를 개발한다고 하자. 데이터베이스로는 PostgreSQL을 사용한다.

개별 개발환경

문제는 여기서 시작된다. 서로 다른 OS 위에서 각각 개발환경을 구축하다 보면 Node.js 버전, npm 버전, PostgreSQL 버전이 달라질 수 있다. 이런 환경 차이로 인해 "내 컴퓨터에서는 잘 되는데?"라는 상황이 발생한다. 나 또한 실제로 이런 문제를 경험해본 적이 있다. (PM2 환경 설정 트러블슈팅 (Windows 환경 문제))

Docker를 사용하면 이 문제를 해결할 수 있다. Docker 컨테이너는 각각 독립된 환경에서 실행되며, 아래 그림에서 볼 수 있듯이 각 컨테이너는 자체적인 프로세스 ID(PID)를 가진다.

Docker 위에서 개발환경 돌리기

이때 NestJS가 실행되는 환경을 'NestJS 컨테이너', PostgreSQL이 실행되는 환경을 'PostgreSQL 컨테이너'라고 부른다. 중요한 점은 이 컨테이너들을 만들 때 Node.js, npm, PostgreSQL의 정확한 버전을 명시할 수 있다는 것이다. 따라서 개발자 A와 B 모두 동일한 환경에서 개발할 수 있게 된다.

컨테이너 기술은 어디에 사용되는가?

이러한 컨테이너 방식은 비단 개발환경 맞추기에만 사용되는 게 아니다. 운영하면서 만들어지는 수많은 다양한 모양의 서버들을 구성하고 운영하는 데에 편리함을 제공해준다.

실제로 서버를 운영하다보면 '눈송이 서버'들이 많이 생긴다고 한다. 눈송이들은 겉으로는 비슷해보이지만, 실제로 들여다보면 조금씩 다 다른 모양을 가지고 있다. 이런 눈송이의 특징처럼, 운영중 생긴 서버들 또한 겉보기엔 동일해보여도, 구성 시점이나 설치된 패키지, 환경이 조금씩 다를 수 있다. 이렇게 되면 장애 발생 시 원인을 정확히 추적해내기 어렵다는 문제가 발생한다.

하지만 컨테이너는 이러한 불일치를 원천적으로 제거한다. 도커에서는 "어떤 OS를 사용하고, 어떤 프로그램을 설치하고, 어떤 설정을 해야 하는지"를 텍스트 파일로 정의해둘 수 있다. 이 설계도 파일을 바탕으로 컨테이너를 만들면, 언제 어디서든 항상 동일한 상태의 서버를 구성할 수 있기 때문이다.

덕분에 어떤 환경에서든 안정적이고 예측 가능한 방식으로 서버를 배포하고 운영할 수 있게 된다.

2. 컨테이너, 다른 방법은 없을까?

환경 분리, 꼭 컨테이너여야 할까?

환경을 동일하게 맞추고 싶다는 니즈는 꾸준히 있어왔다. 사실 컨테이너 기술보다도 먼저 사용되던 기술이 있는데, 바로 가상 머신 (Virtual Machine) 이라는 기술이다.

가상 머신은 아예 운영체제(OS) 단위로 독립된 환경을 만들어버리는 방식이다. 하나의 물리적인 컴퓨터(Host OS) 위에 하이퍼바이저(Hypervisor)라는 소프트웨어를 설치하고, 그 위에 Ubuntu, Windows 같은 새로운 운영체제를 여러 개 띄울 수 있다. 이 새로운 운영체제 하나하나가 각각의 가상 머신(VM)인 것이다.

가상 머신 안에서는 진짜 컴퓨터처럼 뭐든 설치할 수 있다. Node.js도 설치하고, PostgreSQL도 설치하고, 운영체제도 마음대로 설정할 수 있다. 사실상 완전히 독립된 컴퓨터와 다름 없다.

컨테이너와 가상머신의 차이점

가상머신이 컨테이너와 다른 점은 가상화하는 대상에 있다. 가상 머신은 하드웨어 자체를 가상화하는 반면, 컨테이너는 운영체제 수준에서 격리된 환경을 제공한다.

하지만 이 방식엔 몇 가지 단점이 존재한다.

  1. 무겁다. 가상 머신 하나를 띄우려면 OS 전체를 실행해야하므로 많은 메모리와 CPU 자원이 필요하다. NestJS 서버 하나를 돌리기 위해 운영체제부터 설치하고 부팅해야하는 셈이다.

  2. 느리다. 가상 머신은 가상화 계층(하이퍼바이저)을 거쳐야하기 때문에, 하드웨어 자원을 직접 쓰지 못한다. 그만큼 실행 속도가 느려지고, 부팅 시간도 오래걸린다.

  3. 중복된다. 여러 가상 머신이 실행되면 각 가상 머신마다 OS가 따로 올라오기 때문에, 동일한 기능의 시스템들이 여러 개의 커널과 드라이버를 동시에 가지게 된다.

때문에 더 가볍고 빠른 방식이 필요했다. 바로 여기서 컨테이너 기술이 주목받게 된다.

컨테이너는 운영체제를 따로 띄우지 않는다. 대신 호스트 OS의 커널을 공유한다. 겉보기엔 NestJS와 PostgreSQL이 각각 독립된 환경에서 실행되는 것처럼 보이지만, 실제로는 모두 동일한 커널 위에서 돌아가며 필요한 라이브러리와 설정만 격리되어 있을 뿐이다.

덕분에 컨테이너는 훨씬 가볍고 빠르며, 시스템 자원을 더 효율적으로 활용할 수 있게 된다.

둘을 비교하면 아래 이미지와 같다.

이런 이유로 최근에는 가상 머신보다 도커와 같은 컨테이너 기반 기술이 더 널리 사용되고 있다.

3. 컨테이너 기술의 본질

컨테이너 기술은 도커 이전부터 있었다고?

'도커는 사실 리눅스에서 오래전부터 존재하던 기능들을 조합한 것뿐이다'라는 이야기를 들은 적이 있다.

처음에는 믿기 어려웠다. 나에게 도커는 뭔가 고급지고, 복잡하고, 어려운 기술처럼 느껴졌기 때문이다.

그런데 실제로는 chroot, namespace, cgroup처럼 이미 리눅스 커널에 존재하던 기능들을 잘 엮어낸 결과물이라는거다.

참고로, 도커는 2013년 3월에 공개된 기술이다. 하지만 그 기반이 되는 리눅스 기술은 그보다 훨씬 이전부터 존재해왔다.

  • chroot: 1979년
  • namespace: 2002년
  • cgroup: 2008년

즉, 도커는 무언가 완전히 새롭게 발명된 기술이라기보다는, 리눅스 커널 안에 있던 기능들을 사용자 친화적인 인터페이스로 잘 묶은 도구인 셈이다!

도커가 내부적으로 활용하는 리눅스 기술

앞서 언급한 chroot, namespace, cgroup 기술에 대해 조금 더 자세히 알아보자.

컨테이너를 만들려면 프로세스가 '나만의 독립된 컴퓨터'에서 실행되는 것처럼 느끼게 해야 한다. 그렇지 않으면 다음과 같은 문제가 생길 수 있다.

예를 들어, 웹 서버 컨테이너를 실행한다고 해보자. 이 컨테이너 안의 웹 서버가 다음과 같은 일을 할 수 있다면 문제가 될 것이다.

  1. 호스트의 /etc/passwd 파일을 읽어 사용자 정보를 탈취하는 상황
  2. 호스트에서 실행중인 데이터베이스 프로세스를 웹 서버가 강제로 종료해버리는 상황
  3. CPU를 100%까지 사용해 다른 컨테이너들의 성능을 마비시키는 상황

이런 문제를 해결하려면 세 가지 조건이 필요하다.

  1. 파일 격리 - 컨테이너는 자신에게 허용된 파일만 접근할 수 있어야 한다.
  2. 프로세스 격리 - 컨테이너는 자신의 프로세스만 보고 조작할 수 있어야 한다.
  3. 자원 제한 - 컨테이너는 할당받은 만큼의 자원만 사용할 수 있어야 한다.

눈치 챘겠지만, 이 세 가지는 각각 chroot, namespace, cgroup 기능으로 해결해볼 수 있다.

  1. chroot: 파일 시스템 격리

    chroot(change root)는 프로세스가 인식하는 루트 디렉토리를 변경하는 명령어이다. 만약 /tmp/wlghroot 디렉토리를 새로운 루트로 설정한다면, 해당 프로세스는 그 디렉토리 밖의 파일을 볼 수 없게 된다.

  1. namespace: 프로세스 및 네트워크 격리

    namespace는 프로세스 ID, 네트워크, 호스트명 등을 격리한다. 각 컨테이너가 자신만의 작은 세상을 가진 것처럼 작동하게 해준다.

  2. cgroup: 자원 제한

    cgroup(control group)은 프로세스 그룹별로 CPU, 메모리, 디스크 I/O 사용량을 제한한다. 한 컨테이너가 시스템 자원을 독차지하여 다른 컨테이너에 영향을 주는 것을 방지한다.

4. 직접 만들어보는 컨테이너

도커 없이 컨테이너 만들어보기

도커의 원리를 이해해보고자 도커 없이 직접 컨테이너를 만들어보고자 한다.

위에서 세 가지 명령어를 설명했는데, 본 포스팅에서는 chroot로 프로세스를 가두는 실습부터 진행해보겠다. 다른 명령어들은 이후 포스팅에서 다룰 예정이다.

본 실습은 이게 돼요? 도커 없이 컨테이너 만들기 핸즈온을 따라 진행되었습니다.

chroot로 프로세스를 가둬보자

chroot(change root)로 루트 디렉토리를 변경해서 파일 시스템을 격리해보고자 한다. 구체적으로 우리의 목표는 프로세스가 wlghroot 디렉토리 밖으로 빠져나가지 못하도록 하는 것이다.

내 컴퓨터가 아닌 아예 새로운 환경에서 실습을 진행해보고자, EC2 인스턴스를 만들어 접속해줬다.

먼저, 해당 인스턴스에서 wlghroot라는 디렉토리를 만들어보자.

이후, chroot wlghroot /bin/sh 명령어를 실행해보자. 이는 루트 디렉토리를 wlghroot로 변경한 후, 해당 환경에서 /bin/sh 쉘을 새로운 프로세스로 실행하겠다는 뜻이다.

chroot wlghroot /bin/sh

아아. 안타깝게도 fail이 떴다. 에러 메시지를 보니, wlghroot 내부에 /bin/sh 파일이 없다는 것이다.

리눅스에서 실행 가능한 명령어는 모두 실제 파일 형태로 존재한다. 즉, 쉘을 실행하려면 해당 바이너리(/bin/sh) 파일이 wlghroot 내부에도 있어야 한다는 것이다.

/bin/shwlghroot에도 존재하도록 하여 쉘을 쓸 수 있도록 해보자.

cp /bin/sh wlghroot/bin/

하지만 아래와 같이 막상 실행해보니 또 한번 실패하고 있였다.

chroot wlghroot /bin/sh

문제는 이 파일이 실행되기 위한 추가적인 조건이 충족되지 않았기 때문이었다.

리눅스에서 대부분의 실행 파일은 혼자서 실행되지 않고, 다른 공유 라이브러리(so 파일들)에 의존한다.
즉, sh는 그 자체만으로 실행되지 않고, 메모리에서 필요한 동적 라이브러리를 함께 불러야만 실행될 수 있다.

이 의존성을 확인하려면 ldd 명령어를 사용하면 된다.

lld /bin/sh

결과를 보면 sh는 다음 두 개의 라이브러리에 의존하고 있다.

  • /lib/x86_64-linux-gnu/libc.so.6
  • /lib64/ld-linux-x86-64.so.2

따라서 위 두 파일을 wlghroot 내부에도 동일한 경로로 복사해줘야 한다.

먼저 디렉토리 구조를 맞추고(mkdir), 파일을 옮기자(cp).

copy

이제 다시 chroot 명령어로 들어가보면, 정상적으로 sh 쉘에 진입할 수 있다!

성공

위 이미지에서 한 가지 장난을 쳐봤는데, ls를 실험해봤다.

역시나 ls는 복사한 적이 없기 때문에 not found 문제가 발생한다.

ls 명령어 또한 동일한 방식으로 복사해주자.

ls 복붙하기

그러면 이제 사용할 수 있다!

ls 실행

ls가 잘 동작한다. 심지어 나열된 디렉토리 네임이 위에서 tree로 찍었을 때 나오던 bin, lib, lib64 세 가지인 것도 확인 가능하다.

이렇듯 복사 붙여넣기로 명령어를 만들어주는 게 귀찮아서 그렇지, 실제로 프로세스의 파일 접근을 격리하는 건 아무 일도 아니었다!

해치웠나?

프로세스의 탈옥?

실은 위의 방식은 프로세스가 wlghroot 밖으로 나올 방법이 존재한다.

실습을 통해 먼저 눈으로 확인해보자.

#include <sys/stat.h>
#include <unistd.h>
int main(void)
{
 mkdir(".out", 0755);           // 1. 현재 디렉토리(/tmp/wlghroot)에 .out 디렉토리 생성
 chroot(".out");                // 2. .out을 새로운 루트로 설정
 chdir("../../../../../");      // 3. 상위 디렉토리로 계속 이동
 chroot(".");                   // 4. 현재 위치를 다시 루트로 설정
 return execl("/bin/sh", "-i", NULL);  // 5. 쉘 실행
}

이 C언어 코드를 실행파일로 만들어서 wlghroot 내부로 넣어보자. (C언어 코드에 대한 부연 설명은 하단에서 진행하겠다.)

gcc -o wlghroot/escape_chroot escape_chroot.c

그럼 이제 실행파일을 실행해보자.

./escape_chroot

이전에 bin, escape_root, lib, lib64만 출력되던 것과 달리 엄청 많이 출력이 되었다.

실제로 해당 프로세스 밖으로 나와서 호스트의 루트 내용물을 확인해보면, 아예 똑같다는 걸 알 수 있다.

실제 ls /

어떻게 탈출이 가능했던걸까? 다시 C언어 코드를 확인해보자.

#include <sys/stat.h>    // mkdir() 함수를 사용하기 위해 포함
#include <unistd.h>      // chroot(), chdir(), execl() 등 시스템 호출을 사용하기 위해 포함

int main(void)
{
    mkdir(".out", 0755);  // (1) 현재 작업 디렉토리(pwd)에 '.out'이라는 폴더를 생성한다. 권한은 0755
                          //     => 이 디렉토리를 이후 루트 디렉토리로 설정할 예정

    chroot(".out");       // (2) '.out' 디렉토리를 새로운 루트(/)로 설정한다
                          //     => 시스템은 이제 '.out'을 루트로 인식하게 된다

    chdir("../../../../../");
                          // (3) 현재 작업 디렉토리(pwd)에서 계속 상위 디렉토리로 이동한다
                          //     => 중요한 점: chroot는 루트 디렉토리만 바꾸고, 현재 작업 디렉토리(pwd)는 그대로 남는다
                          //     => 따라서 이전 루트(wlghroot) 바깥으로 빠져나갈 수 있다

    chroot(".");          // (4) 탈출한 위치를 다시 루트로 설정한다
                          //     => 이 시점부터는 실제 시스템 루트(/)가 루트가 된다. chroot 격리 실패!

    return execl("/bin/sh", "-i", NULL);
                          // (5) /bin/sh를 인터랙티브 모드(-i)로 실행한다
                          //     => 결국 격리 바깥에서 셸을 실행하게 되어 전체 시스템에 접근 가능해진다
}

기존에는 wlghroot가 루트였기 때문에, 그 안에서 상대 경로(../)를 써도 더 이상 바깥으로 나갈 수 없었다.

하지만 chroot(".out")을 실행하면 새로운 루트가 .out이 되면서, 기존에 루트였던 wlghroot는 더이상 루트가 아니게 된다.

핵심은 여기서부터다. 새로운 루트는 .out으로 설정되었지만, 프로세스의 현재 작업 디렉토리는 여전히 wlghroot에 그대로 남아있다. 즉, 프로세스가 새로운 chroot 환경의 밖에 위치하게 된 것이다. 이제 자유로워진 프로세스는 ../../../으로 상대 경로를 마음껏 이동할 수 있다.

chroot(".out") 전후

결국 상대 경로를 활용해 실제 호스트의 루트(/)까지 도달한 프로세스가 chroot(".");를 실행함으로써 실제 시스템의 루트를 새로운 루트로 설정해버렸다. 탈옥 완료인 셈이다!

5. 정리

개념 총정리

  • 리눅스에서 명령어는 모두 파일이며, sh, ls 등을 chroot 안에서 사용하려면 그 바이너리와 의존 라이브러리를 직접 복사해줘야 한다.
  • chroot는 루트 디렉토리를 변경하여 파일 시스템을 격리하는 명령어다.
  • chroot에서의 보안 취약점이 존재한다. 중첩된 chroot 호출을 통해 현재 작업 디렉토리와 루트 디렉토리를 분리시키면, 상대 경로(../)를 이용해 컨테이너 외부로 탈출할 수 있다.

2탄 커밍쑨

이번 글에서는 chroot만으로 프로세스를 격리하는 과정을 실습해봤다. 하지만 실습 도중 발견한 것처럼, 이 방식은 탈옥이 가능하고 보안상으로도 완전하지 않다. 게다가 sh, ls 같은 바이너리를 하나하나 복사해야 하는 번거로움은 컨테이너 수가 많아질수록 비효율과 중복을 키우게 된다.

다음 글에서는 이러한 한계를 극복해보고자 한다. namespacecgroup을 이용해 파일 시스템 외의 격리와 자원 제한을 실습하며, 진짜 컨테이너에 한 걸음 더 다가가볼 예정이다. (많관부)

레퍼런스

9개의 댓글

comment-user-thumbnail
2025년 7월 12일

지호님 이번 글도 정말 잘 봤어요!! ㅎㅎ 2탄에서는 프로세스 탈옥을 어떤 식으로 막을지 궁금해지네요!! 어서 2탄을 주세요!!!!!

1개의 답글
comment-user-thumbnail
2025년 7월 12일

컨테이너 기반으로 정적 배포를 해주는 서비스는 많이 사용해봤어도 정작 컨테이너가 정확히 무엇인지는 몰랐는데, 이번 기회에 알아갑니다. 좋은 글 감사합니다.

1개의 답글
comment-user-thumbnail
2025년 7월 12일

저도 참고하셨던 영상을 처음 도커를 배울 때 재밌게 봤는데, 직접 해보진 않았었거든요. 대단하십니다 ㅎㅎ, 완성해서 실제로 테스트 해볼 수 있으면 더 좋을 것 같네요!

1개의 답글
comment-user-thumbnail
2025년 7월 14일

직접 도커 없이 컨테이너를 만들어보실 생각을 하다니 ... 정말 대단하신 것 같아요 👍 저도 도커 처음 공부할 때 너무 어려웠었는데, 위에 도커에 대한 설명을 정말 잘 적어주신 것 같아요! 다음 글도 기대하겠습니다 😊

1개의 답글
comment-user-thumbnail
2025년 7월 25일

재미있게 읽었습니다 :)

답글 달기