본문 바로가기
DevOps/docker

[Docker] 컨테이너 실행 과정 파헤치기

by okbear3 2025. 12. 20.

앞 글에서 도커의 이론적인 구조를 정리해본 후, 실제로 컨테이너가 어떤 과정으로 실행되는지 궁금해졌습니다.

직접 도커를 설치하고 테스트하면서, 어떤 과정으로 컨테이너가 실행되는지 정리해보려고 합니다.


Docker 설치

테스트 환경은, M3 맥북에서 가상머신을 구성하여 진행했습니다.

  • 아키텍처 : aarch64
  • linux : Rocky Linux 9.3

 

기존에 설치된 패키지 삭제

sudo dnf remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine

 

docker 설치

# docker 저장소 추가
sudo dnf config-manager --add-repo <https://download.docker.com/linux/rhel/docker-ce.repo>

# 패키지 설치(docker engine, containerd, docker-compose)
sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# docker 시작 및 활성화
sudo systemctl --now enable docker

# docker 버전 확인
docker -v

 

sudo 권한이 없는 사용자에게 docker 관리 권한 부여

#docker 그룹에 사용자 추가
sudo usermod -a -G docker $(whoami)
sudo usermod -a -G docker dockeruser

컨테이너가 실행되는 과정

docker run -p 8080:80 nginx 명령을 입력했을 때, 내부에서 발생하는 과정을 단계별로 자세하게 살펴보겠습니다.

1. 도커 클라이언트가 도커 데몬에 명령 전달

터미널에서 docker run 명령을 입력하면, 가장 먼저 도커 클라이언트가 이 명령을 받습니다.

도커 클라이언트는 우리가 입력한 명령을 Rest API 형태로 변환하여 도커 데몬에 전달하는 역할을 합니다.

docker run -d -p 8080:80 --name nginx nginx

 

도커 데몬은 백그라운드에서 실행중인 엔진으로 실제로 컨테이너를 생성하고 관리하는 역할을 합니다.

유닉스 소켓(/var/run/docker.sock)을 통해 이 요청을 받고, 실제 작업을 수행합니다.

 

2. 이미지 확인과 다운로드

도커 데몬이 처음 하는 일은 nginx 이미지가 로컬에 있는지 확인하고, 없다면 Docker Hub에서 자동으로 다운로드 합니다.

Unable to find image 'nginx:latest' locally
latest: Pulling from library/nginx
f626fba1463b: Pull complete  # <-- 하나의 이미지 레이어
89d0a1112522: Pull complete 
1b7c70849006: Pull complete 
...
Digest: sha256:fb0...
Status: Downloaded newer image for nginx:latest

여기서 이미지를 다운로드할 떄 여러개의 레이어로 받아오는 것을 볼 수 있는데,

이미 다운로드된 레이어가 있다면 건너뛰고 새로운 레이어만 받아오기 때문에 효율적인 자원 관리가 가능합니다.

 

3. 이미지의 여러 계층을 OverlayFS로 조합

다운로드한 이미지는 여러개의 읽기 전용 레이어로 구성되어 있습니다. 컨테이너가 이를 파일 시스템으로 인식하려면 조립 과정이 필요합니다.

 

레이어 구조 확인

nginx 이미지가 어떤 레이어로 구성되어 있는지 확인해봤습니다.

[root@localhost ~]# docker history nginx
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
de437b5614ad   8 days ago    CMD ["nginx" "-g" "daemon off;"]                0B        buildkit.dockerfile.v0
<missing>      8 days ago    STOPSIGNAL SIGQUIT                              0B        buildkit.dockerfile.v0
<missing>      8 days ago    EXPOSE map[80/tcp:{}]                           0B        buildkit.dockerfile.v0
<missing>      8 days ago    ENTRYPOINT ["/docker-entrypoint.sh"]            0B        buildkit.dockerfile.v0
<missing>      8 days ago    COPY 30-tune-worker-processes.sh /docker-ent…   4.62kB    buildkit.dockerfile.v0
...
<missing>      8 days ago    RUN /bin/sh -c set -x     && groupadd --syst…   71.7MB    buildkit.dockerfile.v0
<missing>      8 days ago    ENV NGINX_VERSION=1.29.4                        0B        buildkit.dockerfile.v0
...
<missing>      8 days ago    LABEL maintainer=NGINX Docker Maintainers <d…   0B        buildkit.dockerfile.v0
<missing>      10 days ago   # debian.sh --arch 'arm64' out/ 'trixie' '@1…   101MB     debuerreotype 0.16

위와 같이 각 줄이 하나의 레이어로 동작합니다.

Dockerfile의 각 명령어(FROM, RUN, COPY 등)가 레이어를 만들고, 이들이 차곡차곡 쌓여있는 구조입니다.

nginx의 경우 Debian 기본 이미지를 다운로드하여 nginx를 설치하고, 호스트의 80 포트로 연결하는 동작을 합니다.

-> 여기서 <missing>은 에러가 아닌, 중간 레이어들은 독립적으로 사용할 수 없다는 뜻입니다.

 

OverlayFS가 레이어를 합치는 방법

이렇게 다운로드 한 여러 레이어를 하나로 합치는 작업을 OverlayFS가 담당합니다.

OverlayFS가 각 레이어를 겹쳐서 하나의 통합된 파일 시스템으로 보여줍니다.

각 레이어가 실제로 어디에 저장되는지 확인해봤습니다.

docker inspect nginx | grep -A 20 "GraphDriver"

"GraphDriver": {
    "Data": {
        "LowerDir": "/var/lib/docker/overlay2/5bcc3ba.../diff:/var/lib/docker/overlay2/b3870e9.../diff:/var/lib/docker/overlay2/0c6ac1d.../diff:/var/lib/docker/overlay2/7b84a69.../diff:/var/lib/docker/overlay2/aa26b94.../diff:/var/lib/docker/overlay2/14fbfd6.../diff",
        "MergedDir": "/var/lib/docker/overlay2/1ac527f.../merged",
        "UpperDir": "/var/lib/docker/overlay2/1ac527f.../diff",
        "WorkDir": "/var/lib/docker/overlay2/1ac527f.../work"
    }
 }

 

LowerDir (읽기 전용 이미지 레이어)

LowerDir에는 콜론으로 구분된 6개의 경로가 있습니다. 각각의 하나의 레이어입니다.

실제로 각 경로에 뭐가 있는지 확인해봤습니다.

# 가장 아래 레이어 (Debian 기본 OS)
$ sudo ls /var/lib/docker/overlay2/14fbfd6.../diff/
bin/  boot/  dev/  etc/  home/  lib/  root/  usr/  var/

# nginx 설치 레이어
$ sudo ls /var/lib/docker/overlay2/aa26b94.../diff/
docker-entrypoint.d  etc/nginx/  usr/sbin/nginx  usr/share/nginx/  var/log/nginx/

# 스크립트 파일 레이어
$ sudo ls /var/lib/docker/overlay2/7b84a69.../diff/
docker-entrypoint.sh

각 레이어는 자신이 추가하거나 변경한 파일만 가지고 있습니다.

 

UpperDir (컨테이너의 쓰기 가능 레이어)

컨테이너를 실행하면 새로 생성되는 레이어입니다. 처음에는 비어있습니다.

 

WorkDir (OverlayFS 작업 공간)

OverlayFS가 내부적으로 사용하는 임시 공간입니다. 

 

MergedDir (모든 레이어를 합친 결과)

위의 모든 레이어를 합친 결과입니다. 컨테이너가 실제로 보는 파일 시스템이 여기 있습니다.

$ sudo ls /var/lib/docker/overlay2/1ac527f.../merged/
bin/  boot/  dev/  docker-entrypoint.sh  etc/  home/  lib/  root/  usr/  var/

모든 레이어의 파일들이 하나의 디렉터리에 있는 것처럼 보입니다.

 

4. 최상단에 컨테이너 전용 쓰기 가능 계층 추가

그렇다면 컨테이너에서 파일을 수정하면 어떻게 될까요?

우선 간단하게 컨테이너에 새로운 파일을 추가하게 되면 LowerDir은 읽기 전용이라 변경되지 않고, 새 파일은 UpperDir에 저장됩니다. 

$ docker exec nginx sh -c "echo 'Hello' > /test.txt"

# UpperDir 확인
$ sudo ls /var/lib/docker/overlay2/1ac527f.../diff/
test.txt

 

기존 파일을 수정하면 Copy-on-Write 방식으로 작동합니다.

$ docker exec nginx sh -c "echo '# modified' >> /etc/nginx/nginx.conf"

# UpperDir에 수정된 버전이 복사됨
$ sudo ls /var/lib/docker/overlay2/1ac527f.../diff/etc/nginx/
nginx.conf

nginx.conf 파일의 경우 읽기 전용 이미지인 LowerDir에 있었는데, 기존 파일을 수정하게 되면 파일 전체를 UpperDir로 복사하여 UpperDir의 복사본을 수정하게 됩니다.

그럼 LowerDir의 원본은 그대로 유지하면서, 현재 실행중인 컨테이너의 설정 파일만 변경됩니다.

 

그렇다면 왜 이런 구조가 효율적일까요?

이를 테스트하기 위해 3개의 nginx 컨테이너를 실행 후 디스크 사용량을 확인해봤습니다.

$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          1         1         172.3MB   172.3MB (100%)	# 하나의 이미지
Containers      3         3         3.285kB   0B (0%)		# 컨테이너는 거의 공간 안씀
...

위와 같이 세개의 컨테이너가 동일한 LowerDir을 공유하고, 각 컨테이너는 자신만의 UpperDir을 갖습니다.

도커는 이렇게 이미지를 재사용하는 구조로 컨테이너를 효율적으로 운영할 수 있습니다.

 

5. 네임스페이스와 CGroups로 격리된 환경 구성

이미지를 이용하여 컨테이너를 생성했다면, 리눅스 커널의 네임스페이스와 CGroups를 사용하여 컨테이너를 독립된 환경으로 격리합니다.

간단하게 실행중인 컨테이너가 호스트와 격리된 공간을 갖는지 테스트 해봤습니다.

 

PID 네임스페이스는 프로세스 목록을 격리합니다.

컨테이너 안의 프로세스를 확인하면 아래와 같이 확인할 수 있습니다.

$ docker top nginx
UID   	PID  	PPID	CMD
root  	3527 	3502	nginx: master process nginx -g daemon off;
101   	3575 	3527	nginx: worker process

 

NET 네임스페이스는 네트워크를 격리합니다. 컨테이너의 네트워크 정보를 확인해봤습니다.

$ docker inspect nginx | grep IPAddress
"IPAddress": "172.17.0.2"

각 컨테이너는 독립적인 IP 주소를 받습니다. 덕분에 여러 컨테이너가 같은 포트(예: 80번)를 사용해도 충돌하지 않습니다.

호스트의 네트워크 인터페이스를 보면 veth... 라는 가상 인터페이스가 생성되는데, 이를 통해 컨테이너와 연결됩니다.

1: lo: <LOOPBACK,UP,LOWER_UP>
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP>
16: veth8bbae4f@if2: <BROADCAST,MULTICAST,UP,LOWER_UP>

 

네임 스페이스 확인

호스트 서버에서 컨테이너 프로세스의 네임스페이스를 확인했을 때, 다른 프로세스와는 다른 고유한 값을 갖는 것을 확인할 수 있습니다.

$ docker inspect nginx | grep '"Pid"'
"Pid": 3527

# 컨테이너 네임스페이스
$ ls -l /proc/3527/ns/
cgroup -> 'cgroup:[4026532727]'
ipc -> 'ipc:[4026532725]'
mnt -> 'mnt:[4026532723]'
uts -> 'uts:[4026532724]'
...

 

CGroups로 컨테이너의 리소스 제한하기

도커는 기본적으로 제한을 두지 않지만, 명시적으로 컨테이너의 리소스를 제한할 수 있습니다.

$ docker run -d --cpus="2.0" --memory="512m" --name nginx nginx

$ docker stats nginx
CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT   MEM % 
18ceafafe45e   nginx     0.00%     4.594MiB / 512MiB   0.90%

 

CGroups 설정은 실제로 파일 시스템에 저장됩니다.

$ ls /sys/fs/cgroup/system.slice/docker-18ceafa.scope/
cpu.max           # CPU 제한
memory.max        # 메모리 제한
io.max           # 디스크 I/O 제한

$ cat memory.max
536870912  # 512MB

 

6. 가상 네트워크 할당 및 포트 포워딩 설정

가상 네트워크 할당 (veth)

격리되어 있는 컨테이너가 외부와 통신하기 위해, 도커는 가상 네트워크 이더넷을 통해 연결합니다.

도커는 veth pair라는 가상 이더넷 쌍을 만들어, 컨테이너와 호스트 사이의 통신이 가능하도록 합니다.

# 호스트의 가상 인터페이스
$ ip link | grep veth
46: veth2c871a8@if2: <BROADCAST,MULTICAST,UP,LOWER_UP>

이와 같이 각 컨테이너마다 veth... 라는 가상 인터페이스가 생성되어 docker0 bridge에 연결됩니다.

veth는 커널 내부에서 직접 패킷을 전달하기 때문에, 실제 네트워크 카드를 거치지 않아 오버헤드가 거의 없습니다.

 

# 컨테이너 프로세스의 네트워크 확인
$ docker inspect nginx | grep Pid
"Pid": 6206

$ nsenter -t 6206 -n ip link
1: lo: <LOOPBACK,UP,LOWER_UP>
2: eth0@if46: <BROADCAST,MULTICAST,UP,LOWER_UP>

컨테이너 안에서 네트워크를 확인했을 때 veth2c871a8@if2 (host) <-> eth0@if46 (nginx) 와 같이 쌍을 이루는 것을 확인할 수 있습니다.

 

IP 주소 받기

컨테이너는 docker0 bridge 로부터 사설 IP를 받습니다.

$ docker inspect nginx | grep IPAddress
"IPAddress": "172.17.0.2"

# Bridge IP 확인
$ ip addr show docker0
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP>
	inet 172.17.0.1/16

이렇게 컨테이너를 실행하게 되면 기본적으로 172.17.0.0/16 대역에서 순서대로 할당됩니다.

 

포트 연결하기

아래와 같이 -p 옵션을 사용하여 포트포워딩을 설정하게 되면, 도커는 iptables 규칙을 추가하게 됩니다.

$ docker run -d -p 8080:80 --name nginx nginx

$ sudo iptables -t nat -L -n
Chain DOCKER (2 references)
target     prot opt source               destination         
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:172.17.0.2:80

 

 

 

이제 호스트의 8080번 포트로 들어오는 요청이 자동으로 컨테이너의 80번 포트로 전달됩니다.

$ curl localhost:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

 

실제 요청이 흘러가는 과정은 아래와 같습니다.

브라우저(localhost:8080) → iptables(DNAT 규칙) → docker0 Bridge  → veth pair → 컨테이너(172.17.0.2:80) → nginx


지금까지 도커를 사용하면서 컨테이너가 리눅스의 다양한 기능을 사용하여 격리 수준을 구현한다는 것이 어떤 의미인지 궁금했었습니다.

이번 기회로 컨테이너의 실행 과정을 단계별로 파헤쳐 보니, "아, 이래서 컨테이너 기술이 혁신적이구나" 라는 것을 체감할 수 있었습니다.

불필요한 낭비 없이 필요한 리소스만 사용하면서도, 빠르게 실행되는 속도가 정말 효율적이었고, 테스트 환경으로 올려놓은 VM에서 여러개의 컨테이너를 띄워도 시스템에 무리가 가지 않는 구조를 보며, 왜 도커가 현대 인프라의 표준이 되었는지 알 수 있었습니다.

지금까지 도커가 무엇인지 원리를 짚어봤으니, 다음 글에서는 자주 사용하는 도커 명령어들을 하나씩 정리해보겠습니다.

 

 

'DevOps > docker' 카테고리의 다른 글

[Docker] 도커와 컨테이너란 무엇일까?  (0) 2025.10.06