IT/DevOps

docker run 과 docker exec 재현을 통해 컨테이너 이해하기

Aaron's papa 2022. 1. 25. 21:41
반응형

쿠버네티스 기반으로 전환할 때 가장 중요한 요소 중 하나가 컨테이너에 대한 이해입니다. 쿠버네티스라는 시스템이 컨테이너를 어느 노드에서 실행하게 할 것인가를 관리하는 오케스트레이션 시스템이기 때문에 그 근간이 되는 컨테이너에 대한 이해가 없다면 쿠버네티스 기반의 환경을 제대로 활용하기 어렵습니다. 그래서 이번 글에서는 컨테이너 런타임으로 가장 많이 사용되는 docker의 run과 exec 명령어를 리눅스 명령어들로 하나씩 재현해 봄으로써 컨테이너에 대한 이해를 돕고자 합니다. 이 글에서 다룬 방법은 제가 컨테이너에 대해 학습할 때 사용했던 방법이고 저도 꽤 많은 도움을 받았던 방법입니다. 그럼 시작해 보겠습니다.


컨테이너란 무엇일까요?

본격적인 이야기를 시작하기 전에 컨테이너란 무엇인지에 대해서 이야기해 보고 넘어가겠습니다. 이미 구글링을 하면 컨테이너에 대한 수많은 정의들을 볼 수 있는데요, 그중에서 제가 가장 좋아하는 표현은 아래와 같습니다.

A container is an isolated (namespaces) and restricted (cgroups, capabilities, seccomp) process.
(발췌 : https://iximiuz.com/en/posts/container-learning-path/)

말 그대로 격리된 환경에서 제한된 리소스를 바탕으로 동작하는 프로세스 입니다. 여기서 프로세스라는 단어에 주목할 필요가 있습니다. 컨테이너는 프로세스입니다. 하지만 사람들이 컨테이너를 처음 접하게 될 때 가장 헷갈려하는 부분도 바로 이 부분입니다. 그리고 컨테이너를 공부할 때 가장 먼저 다루는 부분도 바로 컨테이너와 VM의 차이를 통해 컨테이너란 무엇인지에 대해 이해하는 부분입니다.

VM과 컨테이너의 차이 (https://www.netapp.com/blog/containers-vs-vms/)

그림에서 볼 수 있는 것처럼 VM은 하이퍼바이저가 있고 그 위에 각각의 OS가 존재합니다. 하지만 컨테이너는 OS를 공유하면서 컨테이너 엔진을 바탕으로 서로 다른 라이브러리와 바이너리를 이용해서 실행되는 구조입니다. 사실 그림만 보면 뭐가 다른 건지 대충 느낌은 오지만 이걸 실제로 깨닫기에는 참 어려운 주제 이기도 합니다.

그래서 오늘 다루려는 내용은 docker run과 docker exec 명령을 리눅스 명령어들로 흉내 내면서 docker가 어떻게 컨테이너 이미지를 실행시켜서 컨테이너를 동작하게 하는지 살펴보고 컨테이너란 프로세스라는 것을 이해하는 시간을 가져보려고 합니다.

글을 시작하기에 앞서 여기에 있는 많은 내용은 44bits.io 에 기재되어 있는 글들을 참고했음을 밝혀 둡니다. (참고 자료 : https://www.44bits.io/ko/post/container-network-2-ip-command-and-network-namespace)


docker run으로 nginx 컨테이너 실행하기

먼저 docker run 명령으로 nginx 컨테이너를 실행해 보겠습니다. 우선 nginx 컨테이너 이미지를 로컬에 다운로드합니다.

[root@ip-172-31-40-90 ec2-user]# docker pull nginx:1.20.2-alpine
1.20.2-alpine: Pulling from library/nginx
97518928ae5f: Pull complete
a15dfa83ed30: Pull complete
acae0b19bbc1: Pull complete
fd4282442678: Pull complete
b521ea0d9e3f: Pull complete
b3282d03aa58: Pull complete
Digest: sha256:74694f2de64c44787a81f0554aa45b281e468c0c58b8665fafceda624d31e556
Status: Downloaded newer image for nginx:1.20.2-alpine
docker.io/library/nginx:1.20.2-alpine

그리고 docker run 명령을 이용해서 nginx 컨테이너를 실행시킵니다.

[root@ip-172-31-40-90 ec2-user]# docker images
REPOSITORY   TAG             IMAGE ID       CREATED        SIZE
nginx        1.20.2-alpine   373f8d4d4c60   2 months ago   23.2MB
[root@ip-172-31-40-90 ec2-user]# docker run -d -p 8080:80 373f8d4d4c60
6cea2cf2e23e0a9a2fadd7859ff9df0a992971c7f8cf6c0ac3b46ecf54060133
[root@ip-172-31-40-90 ec2-user]# curl 172.17.0.2
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>
[root@ip-172-31-40-90 ec2-user]# curl -s http://localhost:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

여기까지는 누구나 잘 알고 있는, 특별할 것 없는 nginx 컨테이너 실행 과정입니다. 컨테이너의 IP인 172.17.0.2로도 curl 응답을 받을 수 있고 -p 8080:80 옵션에 의해 호스트의 8080 포트를 통해서도 컨테이너로부터 curl 응답을 받을 수 있습니다. 본격적인 흉내내기에 앞서 docker가 동작 중인 서버의 네트워크 인터페이스를 먼저 살펴보겠습니다.

[root@ip-172-31-40-90 sysconfig]# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP group default qlen 1000
    link/ether 0a:0d:24:d5:e5:98 brd ff:ff:ff:ff:ff:ff
    inet 172.31.40.90/20 brd 172.31.47.255 scope global dynamic eth0
       valid_lft 2262sec preferred_lft 2262sec
    inet6 fe80::80d:24ff:fed5:e598/64 scope link
       valid_lft forever preferred_lft forever
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:16:8c:8b:73 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:16ff:fe8c:8b73/64 scope link
       valid_lft forever preferred_lft forever
13: veth750486b@if12: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
    link/ether b2:c1:21:dd:4f:36 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::b0c1:21ff:fedd:4f36/64 scope link
       valid_lft forever preferred_lft forever

lo, eth0, docker0, veth750486b@if12 이렇게 4개의 인터페이스가 존재합니다. 이 중 lo와 eth0은 호스트가 가지고 있는 고유의 인터페이스이고, docker0는 docker에 의해 실행되는 컨테이너들과의 통신을 담당하는 브릿지 인터페이스, veth750486b@if12는 컨테이너들과 연결된 가상 인터페이스입니다.

docker가 동작 중인 호스트의 네트워크 인터페이스

docker0가 172.17.0.1/16의 주소를 가지고 있는 것을 눈여겨보시기 바랍니다. 컨테이너들이 172.17.0.0/16 대역의 IP를 가지고 생성되기 때문에 docker0 인터페이스가 일종의 게이트웨이 역할을 하게 됩니다.

그리고 netstat -nlp 명령을 이용하면 docker-proxy 프로세스가 8080 포트를 리스닝하고 있는 것을 볼 수 있습니다.

[root@ip-172-31-40-90 sysconfig]# netstat -nlp | grep 8080
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      24320/docker-proxy
tcp6       0      0 :::8080                 :::*                    LISTEN      24326/docker-proxy

또한 iptables를 이용하면 아래와 같이 패킷을 변조하기 위한 룰들이 들어 있는 것을 볼 수 있습니다.

[root@ip-172-31-40-90 sysconfig]# iptables -t nat -nL
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination
DOCKER     all  --  0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT)
target     prot opt source               destination

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
DOCKER     all  --  0.0.0.0/0           !127.0.0.0/8          ADDRTYPE match dst-type LOCAL

Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination
MASQUERADE  all  --  172.17.0.0/16        0.0.0.0/0
MASQUERADE  all  --  0.0.0.0/0            0.0.0.0/0            ADDRTYPE match src-type LOCAL
MASQUERADE  tcp  --  172.17.0.2           172.17.0.2           tcp dpt:80

Chain DOCKER (2 references)
target     prot opt source               destination
RETURN     all  --  0.0.0.0/0            0.0.0.0/0
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:172.17.0.2:80
iptables와 docker-proxy의 관계에 대해서는 이번 글에서 다룰 내용은 아니기 때문에 더 자세한 이야기는 https://blog.naver.com/alice_k106/221513460725 이곳을 참고하시기 바랍니다.

이제 이 과정을 chroot 명령과 ip 명령, iptables 명령을 이용해 재현해 보겠습니다. 재현하다 보면 docker가 과연 어떤 역할을 하는 건지, 그리고 컨테이너가 정말 프로세스가 맞는지 그 의미를 이해하는 데 도움이 될 겁니다.


컨테이너 파일 시스템 추출하기

우선 앞 절에서 사용했던 nginx 컨테이너 이미지를 tar 파일로 추출해 보겠습니다.

[root@ip-172-31-61-106 ~]# docker export $(docker create 373f8d4d4c60) --output=nginx_alpine.tar
[root@ip-172-31-61-106 ~]# ls -al
합계 23876
dr-xr-x---  3 root root      127  1월 22 13:03 .
dr-xr-xr-x 18 root root      257  1월 22 13:02 ..
-rw-r--r--  1 root root       18 10월 18  2017 .bash_logout
-rw-r--r--  1 root root      176 10월 18  2017 .bash_profile
-rw-r--r--  1 root root      176 10월 18  2017 .bashrc
-rw-r--r--  1 root root      100 10월 18  2017 .cshrc
drwx------  2 root root       29  1월 22 13:02 .ssh
-rw-r--r--  1 root root      129 10월 18  2017 .tcshrc
-rw-------  1 root root 24428544  1월 22 13:03 nginx_alpine.tar

그리고 추출된 파일을 /tmp/root 디렉터리로 옮겨 보겠습니다.

[root@ip-172-31-61-106 ~]# mkdir -p /tmp/root
[root@ip-172-31-61-106 ~]# mv ./nginx_alpine.tar /tmp/root/
[root@ip-172-31-61-106 ~]# cd /tmp/root/
[root@ip-172-31-61-106 root]# ls
nginx_alpine.tar
[root@ip-172-31-61-106 root]# ls -al
합계 23856
drwxr-xr-x 2 root root       30  1월 22 13:04 .
drwxrwxrwt 9 root root      184  1월 22 13:04 ..
-rw------- 1 root root 24428544  1월 22 13:03 nginx_alpine.tar

파일을 옮긴 후 tar 압축을 풀어 보겠습니다.

[root@ip-172-31-61-106 root]# tar xvf ./nginx_alpine.tar
... (생략) ...
[root@ip-172-31-61-106 root]# ls -al
합계 23872
drwxr-xr-x 20 root root      296  1월 22 13:05 .
drwxrwxrwt  9 root root      184  1월 22 13:05 ..
-rwxr-xr-x  1 root root        0  1월 22 13:03 .dockerenv
drwxr-xr-x  2 root root     4096 11월 12 09:18 bin
drwxr-xr-x  4 root root       43  1월 22 13:03 dev
drwxr-xr-x  2 root root      115 11월 16 18:22 docker-entrypoint.d
-rwxrwxr-x  1 root root     1202 11월 16 18:22 docker-entrypoint.sh
drwxr-xr-x 18 root root     4096  1월 22 13:03 etc
drwxr-xr-x  2 root root        6 11월 12 09:18 home
drwxr-xr-x  7 root root      247 11월 12 09:18 lib
drwxr-xr-x  5 root root       44 11월 12 09:18 media
drwxr-xr-x  2 root root        6 11월 12 09:18 mnt
-rw-------  1 root root 24428544  1월 22 13:03 nginx_alpine.tar
drwxr-xr-x  2 root root        6 11월 12 09:18 opt
dr-xr-xr-x  2 root root        6 11월 12 09:18 proc
drwx------  2 root root        6 11월 12 09:18 root
drwxr-xr-x  2 root root        6 11월 12 09:18 run
drwxr-xr-x  2 root root     4096 11월 12 09:18 sbin
drwxr-xr-x  2 root root        6 11월 12 09:18 srv
drwxr-xr-x  2 root root        6 11월 12 09:18 sys
drwxrwxrwt  2 root root        6 11월 16 18:22 tmp
drwxr-xr-x  7 root root       66 11월 12 09:18 usr
drwxr-xr-x 12 root root      137 11월 12 09:18 var

압축을 풀었더니 어디선가 본 듯한 구조를 가지고 있습니다. 맞습니다. 전형적인 리눅스 운영체제의 파일 시스템 구조입니다. 호스트의 파일 시스템과 비교해 보면 똑같은 구조라는 것을 느낄 수 있습니다.

[root@ip-172-31-61-106 root]# ls -al /
합계 12
dr-xr-xr-x  18 root root  257  1월 22 13:02 .
dr-xr-xr-x  18 root root  257  1월 22 13:02 ..
-rw-r--r--   1 root root    0  1월 22 13:02 .autorelabel
lrwxrwxrwx   1 root root    7  1월  5 19:14 bin -> usr/bin
dr-xr-xr-x   4 root root  332  1월  5 19:15 boot
drwxr-xr-x  14 root root 2940  1월 22 13:02 dev
drwxr-xr-x  84 root root 8192  1월 22 13:03 etc
drwxr-xr-x   3 root root   22  1월 22 13:02 home
lrwxrwxrwx   1 root root    7  1월  5 19:14 lib -> usr/lib
lrwxrwxrwx   1 root root    9  1월  5 19:14 lib64 -> usr/lib64
drwxr-xr-x   2 root root    6  1월  5 19:14 local
drwxr-xr-x   2 root root    6  4월  9  2019 media
drwxr-xr-x   2 root root    6  4월  9  2019 mnt
drwxr-xr-x   5 root root   45  1월 22 13:03 opt
dr-xr-xr-x 158 root root    0  1월 22 13:01 proc
dr-xr-x---   3 root root  103  1월 22 13:04 root
drwxr-xr-x  31 root root 1080  1월 22 13:03 run
lrwxrwxrwx   1 root root    8  1월  5 19:14 sbin -> usr/sbin
drwxr-xr-x   2 root root    6  4월  9  2019 srv
dr-xr-xr-x  13 root root    0  1월 22 13:01 sys
drwxrwxrwt   9 root root  184  1월 22 13:05 tmp
drwxr-xr-x  13 root root  155  1월  5 19:14 usr
drwxr-xr-x  19 root root  269  1월 22 13:02 var
[root@ip-172-31-61-106 root]# ls -al /tmp/root
합계 23872
drwxr-xr-x 20 root root      296  1월 22 13:05 .
drwxrwxrwt  9 root root      184  1월 22 13:05 ..
-rwxr-xr-x  1 root root        0  1월 22 13:03 .dockerenv
drwxr-xr-x  2 root root     4096 11월 12 09:18 bin
drwxr-xr-x  4 root root       43  1월 22 13:03 dev
drwxr-xr-x  2 root root      115 11월 16 18:22 docker-entrypoint.d
-rwxrwxr-x  1 root root     1202 11월 16 18:22 docker-entrypoint.sh
drwxr-xr-x 18 root root     4096  1월 22 13:03 etc
drwxr-xr-x  2 root root        6 11월 12 09:18 home
drwxr-xr-x  7 root root      247 11월 12 09:18 lib
drwxr-xr-x  5 root root       44 11월 12 09:18 media
drwxr-xr-x  2 root root        6 11월 12 09:18 mnt
-rw-------  1 root root 24428544  1월 22 13:03 nginx_alpine.tar
drwxr-xr-x  2 root root        6 11월 12 09:18 opt
dr-xr-xr-x  2 root root        6 11월 12 09:18 proc
drwx------  2 root root        6 11월 12 09:18 root
drwxr-xr-x  2 root root        6 11월 12 09:18 run
drwxr-xr-x  2 root root     4096 11월 12 09:18 sbin
drwxr-xr-x  2 root root        6 11월 12 09:18 srv
drwxr-xr-x  2 root root        6 11월 12 09:18 sys
drwxrwxrwt  2 root root        6 11월 16 18:22 tmp
drwxr-xr-x  7 root root       66 11월 12 09:18 usr
drwxr-xr-x 12 root root      137 11월 12 09:18 var

그리고 우리가 실행하게 될 nginx 프로세스는 chroot 명령에 의해 /tmp/root를 루트 파일 시스템으로 인식하게 실행됩니다. 이로써 chroot를 이용한 루트 파일 시스템 격리 준비는 완료되었습니다. 이제 다음으로 네트워크 네임스페이스를 분리할 차례입니다.


네트워크 네임스페이스 분리 하기

앞에서 살펴봤던 것처럼 docker가 동작 중인 호스트에는 총 lo, eth0, docker0, veth750486b@if12 4개의 인터페이스가 있었습니다. 

하지만 우리가 재현하려는 호스트에는 아래와 같이 lo, eth0 두 개의 인터페이스만 존재합니다.

root@ip-172-31-61-106 ec2-user]# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP group default qlen 1000
    link/ether 0e:b1:17:0d:3d:5c brd ff:ff:ff:ff:ff:ff
    inet 172.31.61.106/20 brd 172.31.63.255 scope global dynamic eth0
       valid_lft 3480sec preferred_lft 3480sec
    inet6 fe80::cb1:17ff:fe0d:3d5c/64 scope link
       valid_lft forever preferred_lft forever

컨테이너 실행을 재현하기 위해 먼저 docker0 라는 브릿지 인터페이스를 만들고, 172.17.0.1/16 IP 주소를 설정합니다.

[root@ip-172-31-61-106 ec2-user]# ip link add docker0 type bridge
[root@ip-172-31-61-106 ec2-user]# ip addr add 172.17.0.1/16 brd 172.17.255.255 dev docker0
[root@ip-172-31-61-106 ec2-user]# ip link set docker0 up
[root@ip-172-31-61-106 ec2-user]# ip a show docker0
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ether ea:25:b3:e4:9c:b6 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::e825:b3ff:fee4:9cb6/64 scope link
       valid_lft forever preferred_lft forever

docker0 브릿지 인터페이스 생성

그리고 nginx라는 새로운 네트워크 네임스페이스를 만듭니다.

[root@ip-172-31-61-106 ec2-user]# ip netns add nginx

nginx 네임스페이스 생성

다음으로 루트 네임스페이스에서는 veth123456이라는 인터페이스를 만들고 nginx 네임스페이스에서는 eth0이라는 인터페이스를 만들어서 두 인터페이스를 연결합니다. 그리고 veth123456을 docker0 브릿지 인터페이스로 이동시킵니다.

[root@ip-172-31-61-106 ec2-user]# ip link add veth123456 type veth peer name eth0 netns nginx
[root@ip-172-31-61-106 ec2-user]# ip link set veth123456 master docker0
[root@ip-172-31-61-106 ec2-user]# ip link set dev veth123456 up

veth123456과 eth0 연결

이제 nginx 네임스페이스에 있는 eth0에 172.17.0.2/16 IP를 할당하고 활성화합니다.

[root@ip-172-31-61-106 ec2-user]# ip netns exec nginx ip addr add 172.17.0.2/16 dev eth0
[root@ip-172-31-61-106 ec2-user]# ip netns exec nginx ip link set dev eth0 up

여기까지 완료되었으면 루트 네임스페이스와 nginx 네임스페이스 간의 통신이 가능해집니다. 아래와 같이 ping이 잘되는지 확인해 봅니다.

[root@ip-172-31-61-106 ec2-user]# ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2) 56(84) bytes of data.
64 bytes from 172.17.0.2: icmp_seq=1 ttl=64 time=0.033 ms
64 bytes from 172.17.0.2: icmp_seq=2 ttl=64 time=0.034 ms

chroot를 위한 파일 시스템과 호스트와 격리된 네트워크 네임스페이스까지 모든 준비가 완료되었습니다. 이 환경을 바탕으로 nginx를 격리된 공간에서 실행해 보겠습니다.


격리된 공간에서 nginx 프로세스 실행하기

아래와 같이 ip 명령과 chroot 명령으로 nginx 프로세스를 실행해 보겠습니다.

[root@ip-172-31-61-106 ec2-user]# ip netns exec nginx chroot /tmp/root /docker-entrypoint.sh nginx
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: ipv6 not available
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
[root@ip-172-31-61-106 ec2-user]# ps aux | grep nginx
root      3661  0.0  0.0   6020   808 ?        Ss   05:19   0:00 nginx: master process nginx
101       3662  0.0  0.0   6476  1784 ?        S    05:19   0:00 nginx: worker process
101       3663  0.0  0.0   6476  1784 ?        S    05:19   0:00 nginx: worker process
root      3675  0.0  0.0 123588  2372 pts/0    R+   05:20   0:00 grep --color=auto nginx
[root@ip-172-31-61-106 ec2-user]# curl http://172.17.0.2
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

보이시나요? chroot와 ip 명령을 이용해서 nginx 프로세스를 격리된 환경에서 실행했습니다. 이렇게 실행된 nginx 프로세스는 호스트의 /tmp/root를 자신의 /로 인식하고 동작합니다.

chroot로 실행된 프로세스의 파일 시스템

이제 -p 8080:80 옵션을 붙였을 때처럼 호스트의 8080 포트로 들어온 요청을 컨테이너의 80 포트로 넘기는 작업을 해보겠습니다. 아무 설정도 하지 않으면 아래와 같이 에러가 발생합니다.

[root@ip-172-31-61-106 ec2-user]# curl http://localhost:8080
curl: (7) Failed to connect to localhost port 8080 after 0 ms: Connection refused

이제 아래와 같은 iptables 규칙을 iptables-restore 명령으로 주입해 보겠습니다.

[root@ip-172-31-61-106 ec2-user]# cat rules
*filter
:INPUT ACCEPT [146:10778]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [87:8614]
:DOCKER - [0:0]
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
-A DOCKER-USER -j RETURN
COMMIT
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [4:304]
:POSTROUTING ACCEPT [4:304]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -o docker0 -m addrtype --src-type LOCAL -j MASQUERADE
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE
-A DOCKER -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
COMMIT
[root@ip-172-31-61-106 ec2-user]# iptables-restore < rules

iptables 규칙이 모두 주입되었으면 curl 명령으로 8080 포트로 들어온 요청이 컨테이너의 80 포트로 전달되는지 확인해 봅니다.

[root@ip-172-31-61-106 ec2-user]# curl http://localhost:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

정상적으로 컨테이너로 요청이 잘 넘어가는 것을 볼 수 있습니다.

iptables의 룰에 대해서는 다른 글을 통해서 설명하는 자리를 마련해 보겠습니다.

만약 여전히 동작하지 않는다면 아래 커널 파라미터들을 확인해 보시기 바랍니다.

net.ipv4.ip_forward = 1
net.ipv4.conf.docker0.route_localnet = 1

iptables에 의해 패킷이 조작되어 동작할 때는 net.ipv4.ip_forward 커널 파라미터가 반드시 활성화되어 있어야 합니다. 그리고 위 파라미터 중에 net.ipv4.conf.docker0.route_localnet 파라미터는 처음 보시는 분들도 계실 텐데, 로컬 라우팅 즉 호스트 내부에서 패킷을 라우팅 하기 위해 필요한 파라미터입니다. 지금처럼 curl http://localhost:8080 요청을 정상적으로 라우팅 처리하기 위해 필요한 파라미터입니다.

route_localnet - BOOLEAN

Do not consider loopback addresses as martian source or destination
while routing. This enables the use of 127/8 for local routing purposes.
default FALSE

출처 : https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt

지금까지 chroot, ip, iptables 명령을 통해서 nginx 프로세스를 격리된 환경에서 실행시켜 봤습니다. docker run 명령이 수행하는 작업이 바로 이런 작업입니다. 물론 여기에 PID 네임 스페이스 분리 등의 몇 가지 작업이 더 수반되긴 하지만 가장 중요한 chroot와 네트워크 네임스페이스 분리, iptables 룰 수정 등 우리가 손으로 명령어를 입력해서 진행한 작업을 docker 가 새로운 컨테이너를 띄울 때마다 자동으로 해준다고 생각하면 됩니다.


docker exec 재현하기

docker run 까지는 어찌어찌 프로세스라고 이해할 수 있지만 docker exec 이야말로 사람들을 컨테이너에 대해  이해하기 힘들게 만드는 가장 큰 범인이라고 생각합니다. docker exec을 이용하면 마치 VM에 접속하는 것처럼 호스트와는 다른 IP와 다른 쉘을 가진 환경으로 실행되기 때문입니다.

[root@ip-172-31-40-90 ec2-user]# docker ps
CONTAINER ID   IMAGE          COMMAND                  CREATED          STATUS          PORTS                                   NAMES
6cea2cf2e23e   373f8d4d4c60   "/docker-entrypoint.…"   39 minutes ago   Up 39 minutes   0.0.0.0:8080->80/tcp, :::8080->80/tcp   nice_jepsen
[root@ip-172-31-40-90 ec2-user]# docker exec -it 6cea2cf2e23e /bin/sh
/ # ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:AC:11:00:02
          inet addr:172.17.0.2  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:25 errors:0 dropped:0 overruns:0 frame:0
          TX packets:7 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:1912 (1.8 KiB)  TX bytes:1272 (1.2 KiB)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

하지만 docker exec은 위에서 살펴본 것과 마찬가지로 그저 쉘을 실행시키는 것뿐입니다. 다른 것은 컨테이너가 떠 있는 것과 같은 네임 스페이스, 같은 루트 파일 시스템을 가진 쉘을 실행시킨다는 것뿐입니다. 그래서 이번엔 docker exec도 한 번 재현해 보겠습니다. 이미 앞에서 실행시킨 nginx가 사용하고 있는 네임 스페이스가 있으니 새로운 네임 스페이스를 생성할 필요는 없습니다. nginx 프로세스를 실행시켰던 것처럼 똑같이 실행해 주면 됩니다. 다만 실행하는 명령어가 /bin/sh가 됩니다.

[root@ip-172-31-61-106 root]# ip netns exec nginx chroot /tmp/root /bin/sh
/ # ifconfig
ifconfig: /proc/net/dev: No such file or directory
veth0     Link encap:Ethernet  HWaddr 0E:47:64:54:0C:42
          inet addr:192.168.0.2  Bcast:0.0.0.0  Mask:255.255.255.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
/ # ls -al
total 23872
drwxr-xr-x   20 root     root           296 Jan 22 13:05 .
drwxr-xr-x   20 root     root           296 Jan 22 13:05 ..
-rwxr-xr-x    1 root     root             0 Jan 22 13:03 .dockerenv
drwxr-xr-x    2 root     root          4096 Nov 12 09:18 bin
drwxr-xr-x    4 root     root            83 Jan 22 13:33 dev
drwxr-xr-x    2 root     root           115 Nov 16 18:22 docker-entrypoint.d
-rwxrwxr-x    1 root     root          1202 Nov 16 18:22 docker-entrypoint.sh
drwxr-xr-x   18 root     root          4096 Jan 22 13:03 etc
drwxr-xr-x    2 root     root             6 Nov 12 09:18 home
drwxr-xr-x    7 root     root           247 Nov 12 09:18 lib
drwxr-xr-x    5 root     root            44 Nov 12 09:18 media
drwxr-xr-x    2 root     root             6 Nov 12 09:18 mnt
-rw-------    1 root     root      24428544 Jan 22 13:03 nginx_alpine.tar
drwxr-xr-x    2 root     root             6 Nov 12 09:18 opt
dr-xr-xr-x    2 root     root             6 Nov 12 09:18 proc
drwx------    2 root     root            26 Jan 22 13:38 root
drwxr-xr-x    2 root     root            23 Jan 22 13:33 run
drwxr-xr-x    2 root     root          4096 Nov 12 09:18 sbin
drwxr-xr-x    2 root     root             6 Nov 12 09:18 srv
drwxr-xr-x    2 root     root             6 Nov 12 09:18 sys
drwxrwxrwt    2 root     root             6 Nov 16 18:22 tmp
drwxr-xr-x    7 root     root            66 Nov 12 09:18 usr
drwxr-xr-x   12 root     root           137 Nov 12 09:18 var

그냥 이게 전부입니다. 우리는 VM에 들어온 게 아니고 그저 기존에 실행 중인 프로세스와 같은 네임 스페이스를 가진 /bin/sh 프로세스를 실행했을 뿐입니다.


컨테이너를 ps로 확인하기

컨테이너는 프로세스라는 것을 더 실감하기 위해 컨테이너가 동작하고 있는 호스트에서 ps로 살펴보면 아래와 같이 nginx 프로세스가 동작하고 있는 것을 볼 수 있습니다.

[root@ip-172-31-40-90 ec2-user]# ps aux | grep nginx
root       925  0.0  0.0 123588  2412 pts/0    R+   13:41   0:00 grep --color=auto nginx
root      3456  0.0  0.0   6012  4520 ?        Ss   12:57   0:00 nginx: master process nginx -g daemon off;
101       3510  0.0  0.0   6468  2032 ?        S    12:57   0:00 nginx: worker process
101       3511  0.0  0.0   6468  1668 ?        S    12:57   0:00 nginx: worker process

그리고 nginx 컨테이너에 docker exec을 이용해서 들어가면 아래와 같이 /bin/sh 프로세스가 보이는 것도 볼 수 있습니다.

[root@ip-172-31-40-90 ec2-user]# ps aux | grep "/bin/sh"
root       933  1.3  0.6 1286392 54104 pts/0   Sl+  13:42   0:00 docker exec -it 6cea2cf2e23e /bin/sh
root       949  9.0  0.0   1696  1124 pts/0    Ss+  13:42   0:00 /bin/sh

네, 그저 호스트에서 /bin/sh 파일이 실행되었을 뿐입니다. 다만, 이때 실행된 /bin/sh 파일은 호스트 OS에 있는 라이브러리와 실행 파일이 아니고 컨테이너 이미지 속에 있는, 우리가 명령어로 하나씩 살펴봤을 때는 /tmp/root 밑에 있는 /tmp/root/usr/sbin/nginx 파일과 /tmp/root/bin/sh 파일입니다.


마치며

컨테이너는 격리된 환경에서 제한된 리소스를 바탕으로 동작하는 프로세스입니다. 그리고 이번 글에서는 이것을 눈으로, 손으로 확인하기 위한 과정을 거쳐 봤습니다. 맨 처음 컨테이너에 대해 학습할 때 가장 먼저 학습하게 되는 내용이 컨테이너와 VM의 차이점이죠. 그만큼 둘 간의 차이점을 아는 것은 중요합니다. 하지만 대부분 이 차이를 글로만 익히고 개념으로 인식하기에는 힘들어하는 것이 사실입니다. 컨테이너를 마치 VM처럼 생각해서 운영 중인 컨테이너에 접근해서 새로운 프로세스를 실행시킨다거나 (디버깅 용도가 아닌) 하는 작업을 하는 것도 아마 컨테이너가 프로세스라는 것을 정확하게 인지하지 못해서 발생하는 안티 패턴 중에 하나죠. 컨테이너는 하나의 역할만 할 수 있도록 하고 그 외의 역할을 해야 한다면, 사이드카 패턴을 이용해서 같은 네임스페이스에서 여러 개의 컨테이너를 동작시키는 것이 좋은 사용 패턴 중 하나입니다. 그래서 파드 안에 여러 개의 컨테이너를 띄울 수 있도록 쿠버 네티스가 설계되어 있는 거겠죠.

 

이번 글에서는 컨테이너가 프로세스라는 것을 직접 느낄 수 있도록 호스트에서 프로세스를 실행하는 과정을 통해 docker run, docker exec의 동작 과정을 재현해 봤습니다. 이 글이 컨테이너의 개념을 어려워하시는 분들에게 많은 도움이 되었으면 좋겠습니다.  

 

참고 자료 :

https://iximiuz.com/en/posts/container-learning-path/

 

Learning Containers From The Bottom Up

What is a Container? Container vs. VM? Docker vs. Kubernetes. How to organize the learning efficiently?

iximiuz.com

https://www.44bits.io/ko/keyword/linux-namespace#%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EB%84%A4%EC%9E%84%EC%8A%A4%ED%8E%98%EC%9D%B4%EC%8A%A4

 

리눅스 네임스페이스(Linux Namespace)란?

리눅스 네임스페이스는 프로세스를 실행할 때 시스템의 리소스를 분리해서 실행할 수 있도록 도와주는 기능입니다. 한 시스템의 프로세스들은 기본적으로 시스템의 리소스들을 공유해서 실행

www.44bits.io

https://www.44bits.io/ko/post/container-network-1-uts-namespace

 

UTS 네임스페이스를 사용한 호스트네임 격리 - 컨테이너 네트워크 기초 1편

컨테이너는 하드웨어 가상화 없이 프로세스를 격리하는 기술로 루트 디렉터리 격리와 유니온 마운트를 비롯해 리눅스 네임스페이스와 같은 리눅스의 기능들을 활용합니다. 이 시리즈에서는 컨

www.44bits.io

https://blog.naver.com/alice_k106/221513460725

 

166. [Docker] Docker 네트워크 구조 : userland proxy, iptables 및 hairpining

이번 포스트에서는 도커 엔진만 사용 할 때 활성화되는 userland proxy, iptables에 대해서 다룬다. 사실 ...

blog.naver.com

 

반응형