Diving Deep Into Kubernetes Networking: Docker와 k8s 네트워크 분석

이 포스트는 ‘Diving deep into Kubernetes networking‘와 udemy.com의 CKA 강좌를 참고하여 작성되었습니다.

1. Docker 네트워크

1.1 Docker 네트워크 타입

k8s의 네트워크를 이해하기 위해 먼저 가장 많이 사용되는 컨테이너 런타임인 Docker의 네트워크 구조를 알아보도록 하겠습니다. Docker 컨테이너가 실행되면 Docker 엔진이 컨테이너를 네트워크에 할당합니다. 같은 호스트에 있고 또 같은 네트워크에 연결된 모든 Docker 컨테이너는 서로 통신 가능한 상태가 됩니다. Docker에서는 기본적으로 5가지의 네트워크 타입을 제공하고 있습니다.

None

네트워크를 사용하지 않습니다. 따라서 컨테이너는 외부에서 접근할 수 없는 상태가 됩니다. --net=none 인자를 통해 None 네트워크 컨테이너를 생성할 수 있습니다.

$ docker run --net=none --rm --name busybox busybox ip a

# loopback을 제외하고는 인터페이스가 없음을 확인할 수 있습니다.
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue 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

Host Network

컨테이너가 생성될 때 새로운 네트워크 네임 스페이스를 할당하는 것이 아니라 호스트의 네트워크 네임 스페이스를 그대로 공유합니다. 따라서 컨테이너와 호스트 사이의 네트워크에 대한 Isolation이 제공되지 않습니다. 예를 들어 80번 포트를 사용하는 애플리케이션을 호스트 네트워크 컨테이너로 실행하면 호스트의 80번 포트를 통해 해당 애플리케이션에 접근이 가능합니다. 아래 명령어를 통해 호스트 네트워크를 사용하는 컨테이너를 생성할 수 있습니다.

$ docker run --rm --net=host --name busybox busybox ip a

# 호스트의 ip a 결과가 그대로 출력됩니다. 아래 내용은 호스트의 네트워크에 따라 달라질 수 있습니다.
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue 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: enp24s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel qlen 1000
    link/ether 00:d8:61:4e:12:a8 brd ff:ff:ff:ff:ff:ff
    inet 192.168.0.11/24 brd 192.168.0.255 scope global dynamic enp24s0
       valid_lft 6350sec preferred_lft 6350sec
    inet6 fe80::c673:d505:3168:7d7/64 scope link 
       valid_lft forever preferred_lft forever
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue 
    link/ether 02:42:1d:0f:73:9a 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:1dff:fe0f:739a/64 scope link 
       valid_lft forever preferred_lft forever

Bridge Network

리눅스 브릿지는 소프트웨어로 구현된 스위치입니다. 예를 들어 192.168.0.2/24 주소를 가진 인터페이스와 192.168.0.3/24 주소를 가진 인터페이스가 같은 브릿지에 연결되어 있으면 이 두 인터페이스는 통신이 가능한 상태가 됩니다. Docker는 설치시 자동으로 docker0 브릿지를 생성하고 브릿지 네트워크로 실행된 컨테이너를 이 브릿지에 연결합니다.

그림 1. Bridge Networking

이후 컨테이너를 생성하면 컨테이너 Docker는 가상의 인터페이스인 veth 페어를 만듭니다. veth는 한 쪽으로 패킷이 들어가면 다른 쪽으로 즉시 전송됩니다. 이 페어를 하나는 컨테이너 내부에, 하나는 호스트에 연결합니다. 이제 호스트와 컨테이너 내부가 통신 가능하게 되었습니다. 그림에서는 eth0vethxxx가 veth로 연결되어 있습니다. 이제 또 다른 컨테이너가 생성되고 마찬가지로 컨테이너 내부의 eth0와 호스트의 vethyyy가 veth로 연결됩니다.

Docker에 의해 컨테이너간의 통신이 가능하게 되었지만 아직 호스트 바깥에서는 내부 컨테이너에 접근할 수 없습니다. 이 컨테이너에 외부에서 접근하기 위해서는 컨테이너 내부의 특정 포트를 호스트의 특정 포트와 연결하는 방법을 사용합니다. 예를 들어 아래와 같은 명령어를 사용하면 컨테이너의 80번 포트를 호스트의 8080포트에 연결합니다. 따라서 호스트의 8080포트에 접근하면 실제로는 내부 컨테이너 80포트에 접근하게 됩니다.

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

Custom Bridge Network

이전 섹션에서 기본적으로 docker0 브릿지가 생성되고 새로 생성한 컨테이너가 이 브릿지에 연결된다고 했습니다. 하지만 docker0 브릿지가 아니라 새로운 브릿지를 사용하게도 만들 수 있습니다. 이렇게 하면 네트워크가 브릿지 레벨에서 분리되기 때문에 더 높은 isolation을 제공할 수 있습니다. Custom Bridge Network는 다음 명령어를 통해 생성할 수 있습니다.

docker network create mynetwork

이제 컨테이너 두 개를 생성해서 해당 네트워크를 사용하도록 합니다.

docker run -it --rm --name=container-a --network=mynetwork busybox /bin/sh
docker run -it --rm --name=container-b --network=mynetwork busybox /bin/sh

두 개의 컨테이너는 같은 네트워크를 공유하고 있기 떄문에 기본적으로 통신이 가능한 상태가 됩니다. 실제로 생성된 컨테이너 내부에서 ifconfig로 ip를 확인한 뒤 다른 컨테이너에 ping {ip}명령어를 통해 통신이 가능한지 확인할 수 있습니다.

Containerd-Defined Network

Custom Bridge Network와 비슷하지만 새로운 네트워크를 생성하고 그 네트워크를 사용하는 것이 아니라, 이미 존재하는 다른 컨테이너의 네트워크 네임스페이스를 사용하게 만듭니다. 이 방법은 k8s에서 Pod의 컨테이너들이 사용하는 방법입니다. 아래 명령어를 통해 container-a를 생성하고, container-b를 생성할 때 container-a의 네임스페이스를 공유하게 만듭니다. 이렇게 생성된 두 컨테이너는 같은 네트워크 네임스페이스를 공유합니다. 따라서 이 두 컨테이너는 localhost를 통해 통신이 가능하고 같은 IP 주소를 갖습니다.

docker run -it --rm --name=container-a busybox /bin/sh
docker run -it --rm --name=container-b --network=container:container-a busybox /bin/sh

1.2 Docker 네트워크 시뮬레이션

컨테이너간의 통신

이번에는 리눅스 커맨드를 활용해서 Docker의 네트워크를 직접 구성해보겠습니다. 먼저 두 개의 네트워크 네임스페이스를 만들고 서로간에 통신이 가능하도록 만들어 보겠습니다.

그림 2. 컨테이너간의 네트워크 구성
# 호스트에 브릿지를 생성하고 ip주소 192.168.15.1을 할당합니다.
ip link add mybr type bridge
ip link set mybr up
ip addr add 192.168.15.1/24 dev mybr

# red 네임스페이스를 생성합니다.
ip netns add red

# red 네임스페이스와 호스트를 연결하게 될 veth 인터페이스를 생성합니다.
ip link add veth-red type veth peer name veth-red-br
ip link set veth-red-br up

# veth 인터페이스의 한 쪽 끝을 red 네임스페이스로 이동시키고, ip주소 192.168.15.2를 할당합니다.
ip link set veth-red netns red
ip -n red addr add 192.168.15.2/24 dev veth-red
ip -n red link set veth-red up

# 나머지 한 쪽은 생성했던 브릿지에 연결합니다.
ip link set veth-red-br master mybr

# blue에 대해서도 똑같이 수행합니다. (대신 ip로 192.168.15.3을 할당합니다.)
ip netns add blue
ip link add veth-blue type veth peer name veth-blue-br
ip link set veth-blue-br up
ip link set veth-blue netns blue
ip -n blue addr add 192.168.15.3/24 dev veth-blue
ip -n blue link set veth-blue up
ip link set veth-blue-br master mybr

# 이제 서로간의 연결이 가능합니다.
ip netns exec red ping 192.168.15.3

# 호스트에서도 내부로 접근이 가능합니다.
ping 192.168.15.3

컨테이너에서 외부 인터넷으로의 통신

이제 호스트에서 컨테이너로, 컨테이너에서 컨테이너로의 연결이 가능해졌습니다. 하지만 컨테이너 내부에서 외부 인터넷으로의 접근은 불가능합니다. 컨테이너 내부에서는 아직 192.168.15.0/24 주소만을 알고 있고 외부 인터넷 (예를 들어 8.8.8.8)로는 어떻게 접근해야 하는지 모릅니다. route 명령어를 통해 이것을 확인할 수 있습니다.

# route에 매칭되는 주소가 없다면 해당 IP는 접근할 수 없습니다.
$ ip netns exec red route
  Kernel IP routing table
  Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
  192.168.15.0    0.0.0.0         255.255.255.0   U     0      0        0 veth-red

이 문제를 해결하고 컨테이너가 외부 네트워크에 접근하기 위해서는 NAT(Network Address Translation)을 사용합니다. 먼저 컨테이너 내부에서는 모든 패킷을 bridge(192.168.15.1)로 전달하도록 Default Gateway를 설정합니다. 이후 호스트에서 192.168.15.0 대역에 대해서 NAT를 수행하도록 설정합니다.

그림 3. 컨테이너와 외부 네트워크 연결
# 컨테이너 내부에 default route를 추가합니다.
# 이제 IP가 route table에 없는 모든 패킷은 192.168.15.1로 보내지게 됩니다.
$ ip netns exec blue ip route add default via 192.168.15.1

# 호스트에서 192.168.15.0/24에 대해 NAT를 설정합니다.
$ iptables -t nat -A POSTROUTING -s 192.168.15.0/24 -j MASQUERADE

# 이제 네임스페이스 내부에서 외부 네트워크로의 접근이 가능합니다.
$ ip netns exec blue ping 8.8.8.8
  PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
  64 bytes from 8.8.8.8: icmp_seq=1 ttl=52 time=30.3 ms

외부 네트워크에서 컨테이너로의 연결

이제 내부에서는 외부 인터넷으로 연결이 가능해졌습니다. 하지만 반대로 외부에서 컨테이너 내부로 통신을 할 수는 없습니다. 192.168.15.0/24 대역은 호스트 내부에서만 알고 있는 Private 네트워크이기 때문입니다. 따라서 외부에서 컨테이너 내부로 접근하기 위해 컨테이너 내부의 포트를 호스트의 포트에 연결시켜줄 필요가 있습니다.

그림 4. 외부 네트워크에서 컨테이너로의 연결

호스트의 172.22.4.101 대역은 외부 네트워크와 연결된 IP입니다. 따라서 외부 네트워크에서 이 IP로 접근이 가능합니다. 호슽 내부에서는 172.22.4.101:8080으로 들어오는 모든 패킷을 192.168.15.2:80으로 NAT하도록 설정합니다. 그럼 이제 외부에서 172.22.4.101:8080으로 보내는 모든 패킷은 컨테이너 내부의 192.168.15.2:80으로 보내집니다. Docker에서도 이 방법을 통해서 컨테이너 내부에 생성된 프로세스에 접근할 수 있습니다.

# 8080으로 들어오는 모든 포트를 192.168.15.2:80으로 보낸다
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 192.168.15.2:80 

1.3 Docker 네트워크 파헤치기

이제 Docker 네트워크가 실제로 위와 같이 동작하는지 확인해보겠습니다. 먼저 Docker를 설치하면 기본적으로 docker0 브릿지가 생성됩니다. 한 개의 컨테이너를 실행해서 이 컨테이너가 docker0 브릿지에 연결되는지 확인해보겠습니다.

# docker0 브릿지가 있는지 확인합니다.
$ ip link show type bridge
  3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default

# 컨테이너 실행
$ docker run --name cont_a -d -it busybox /bin/sh

# 컨테이너 내부의 인터페이스를 확인합니다. eth0 인터페이스가 if100과 연결되어 있습니다.
$ docker exec cont_a ip link
  1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
      link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
  99: eth0@if100: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
      link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff

# 호스트에서 인터페이스를 확인합니다.
$ ip link
  100: vethcc316a0@if99: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default 
      link/ether a6:78:07:0b:ea:ae brd ff:ff:ff:ff:ff:ff link-netnsid 0

# 컨테이너 내부에서 호스트의 브릿지로 통신이 가능한지 확인합니다. 통신이 잘 됩니다.
$ docker exec cont_a ping 172.17.0.1
  PING 172.17.0.1 (172.17.0.1): 56 data bytes
  64 bytes from 172.17.0.1: seq=0 ttl=64 time=0.063 ms

그런데 이전 섹션에서 컨테이너를 생성하면 하나의 네트워크 네임 스페이스를 만들고, veth의 한 쪽 끝을 해당 네트워크 네임 스페이스에 할당한다고 했습니다. 현재 컨테이너 한 개를 만든 상태이므로 새로운 네트워크 네임스페이스 한 개가 보여야 할 것입니다.

$ ip netns list
(...)

하지만 놀랍게도 새로운 네트워크 네임스페이스가 존재하지 않습니다. 그 이유는 ip netns list 커맨드는 /var/run/netns에 존재하는 네임스페이스를 보여주는데 Docker에서는 네트워크 네임스페이스를 만들 때 해당 위치에 symlink를 생성하지 않기 때문입니다. 즉 네트워크 네임 스페이스는 존재하지만 링크를 만들지 않아서 ip netns list에서는 보이지 않는 것입니다.

컨테이너간의 통신

이번에는 한 개의 컨테이너를 더 실행하고 서로간의 통신이 잘 이루어지는지 확인해보겠습니다.

# 컨테이너 실행
$ docker run --name cont_b -d -it busybox /bin/sh

# cont_a의 IP 주소를 확인합니다.
$ docker inspect cont_a | grep IPAddress
  "IPAddress": "172.17.0.2",

# cont_b에서 cont_a로 통신이 가능한지 확인합니다.
docker exec cont_b ping 172.17.0.2
  PING 172.17.0.2 (172.17.0.2): 56 data bytes
  64 bytes from 172.17.0.2: seq=0 ttl=64 time=0.147 ms

컨테이너에서 외부 네트워크로의 통신

먼저 컨테이너 내부를 확인합니다. 이전 섹션에 따르면 컨테이너 내부에서 모든 IP에 대한 Default Gateway가 docker0 브릿지의 IP 주소로 설정되어 있어야 합니다.

# 아무런 설정을 해주지 않아도 컨테이너는 외부 네트워크에 접근이 가능한 상태입니다.
$ docker exec cont_b ping 8.8.8.8
  PING 8.8.8.8 (8.8.8.8): 56 data bytes
  64 bytes from 8.8.8.8: seq=0 ttl=49 time=30.488 ms
  
# 라우팅 테이블을 확인합니다.
# default gateway로 172.17.0.1이 설정되어 있습니다.
$ docker exec cont_b route
  Kernel IP routing table
  Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
  default         172.17.0.1      0.0.0.0         UG    0      0        0 eth0
  172.17.0.0      *               255.255.0.0     U     0      0        0 eth0

이번에는 호스트에서의 NAT룰을 확인해보겠습니다. 172.17.0.0 대역으로 들어온 패킷은 NAT가 설정되어 있어야 합니다.

$ iptables -t nat -S
-P PREROUTING ACCEPT
(중략)
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE   #  172.17.0.0/16로 들어온 IP는 MASQUERADE(NAT)되도록 설정되어 있습니다.
-A DOCKER -i docker0 -j RETURN

외부 네트워크에서 컨테이너 내부로의 통신

외부 네트워크에서 컨테이너 내부로 통신하려면 호스트의 포트를 컨테이너 내부의 포트로 맵핑해야 된다고 했습니다. 새로운 Docker 컨테이너를 생성해서 실제로 포트가 맵핑되어 있는지 확인해보겠습니다.

# 호스트의 8080포트를 컨테이너의 80포트로 맵핑시키는 컨테이너를 생성합니다.
docker run --name cont_c -p 8080:80 -d -it busybox /bin/sh

# NAT룰을 확인합니다.
iptables -t nat -S
-P PREROUTING ACCEPT
(중략)
# tcp:8080으로 들어온 패킷에 대해 172.17.0.4:80으로 패킷을 전달합니다.
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.4:80


2. Kubernetes 네트워크

2.1 Kubernetes 네트워크 기본 개념

The Pause Container

k8s에서 Pod을 생성하면 아무 작업도 수행하지 않는 Pause Container가 생성됩니다. 과거에 이 컨테이너는 같은 Pod에 속하는 컨테이너들의 init 프로세스가 되기 위해 설계되었습니다. init 프로세스는 컨테이너가 죽었을 때 좀비상태가 되면 자원을 회수하기 위해 사용됩니다. 하지만 이제는 더 이상 PID 네임스페이스를 공유하지 않습니다. 따라서 기본적으로 컨테이너는 PID 1번을 가지고 실행됩니다.

반면에 네트워크 네임스페이스는 여전히 Pause Container의 네임 스페이스를 공유합니다. Pause Container의 네트워크 네임 스페이스를 이전 섹션에서 설명했던 Containerd-Defined Network를 통해 공유하기 때문에 같은 Pod에 속한 모든 컨테이너는 같은 네트워크 네임스페이스를 사용합니다.

# nginx 컨테이너를 실행합니다.
kubectl run nginx --image=nginx

# nginx 컨테이너가 실행중인 호스트에서 docker ps를 확인해보면 두 개의 컨테이너가 존재합니다.
# 하나는 Pause Container 임을 확인할 수 있습니다.
docker ps | grep nginx
  2db0254ea7f8        nginx                           "nginx -g 'daemon of…"
  f091f136c8f9        k8s.gcr.io/pause:3.1            "/pause"

Pod Networking

Pod은 k8s에서 가장 작은 디플로이 단위입니다. Pod은 한개 이상의 컨테이너로 이루어지며 Pod에 속한 모든 컨테이너는 하나의 네트워크 네임스페이스를 공유합니다. 따라서 같은 Pod에 속한 모든 컨테이너는 서로간에 localhost를 통해서 통신이 가능합니다. 그럼 이렇게 만든 Pod끼리는 어떻게 통신을 해야할까요? k8s는 이 문제를 직접적으로 해결하지 않습니다. 대신에 이 문제를 해결하기 위한 요구조건을 제시하고, 이 요구 조건을 만족시키기만 하면됩니다. Pod 네트워크의 요구조건은 아래와 같습니다.

  • 각각의 Pod은 전체 클러스터에서 유니크한 자신만의 IP를 가진다.
  • 같은 노드에 있는 모든 Pod은 서로간에 통신이 가능해야 한다.
  • 다른 노드에 있는 모든 Pod은 별도의 NAT 없이 통신이 가능해야 한다.

Pod 네트워크 요구조건: 모든 Pod은 자신만의 IP를 가지고 NAT 없이 서로간에 통신이 가능해야 한다.

그럼 이제 이전 Docker 섹션에서 배운 지식을 활용해서 위 요구조건을 만족시켜보도록 하겠습니다. 먼저 각 노드에 브릿지를 생성합니다. 이후 veth를 생성해서 하나는 컨테이너 내부에, 하나는 브릿지에 연결합니다. 그리고 컨테이너 내부의 인터페이스와 브릿지에 IP 주소를 부여합니다. 이제 같은 노드 내부에서 컨테이너 끼리의 통신이 가능해졌습니다.

그림 5. Pod Networking

하지만 아직 다른 노드의 컨테이너와는 통신할 수 없습니다. 예를 들어 10.244.1.2를 가진 컨테이너에서 10.244.2.1로 통신을 하려고 하면 Default Gateway에 의해 NODE1에 패킷이 도착합니다. 하지만 NODE1은 10.244.2.1이 어느 노드에 있는지 알 수 없습니다. 따라서 각 노드에 각각의 Pod의 주소에 대한 정보를 라우팅 테이블로 추가합니다.

# NODE1에서 설정
ip route add 10.244.2.2 via 192.168.1.11
ip route add 10.244.2.3 via 192.168.1.11

# NODE2에서 설정
ip route add 10.244.1.2 via 192.168.1.11
ip route add 10.244.1.3 via 192.168.1.11

이제 모든 Pod이 고유의 IP 주소를 가지며 서로간의 통신이 가능해졌습니다. 하지만 이렇게 각각의 노드에 route 룰을 추가하는 방법은 Pod과 노드 수가 증가함에 따라 기하급수적으로 증가합니다. 따라서 아래와 같이 하나의 라우터를 두고 각 노드에서는 라우터로의 라우팅 룰을 추가하고, 라우터에서 각 Pod에 대한 IP를 관리할 수 있습니다.

그림 6. 라우터와 Pod Networking

CNI (Container Network Interface)

지금 까지 리눅스 브릿지를 활용하여 컨테이너간에 통신이 가능하도록 만들었습니다. 하지만 다양한 방법으로 컨테이너간의 통신이 가능하도록 할 수 있습니다. 그런데 이 모든 방법마다 서로 다른 구현체를 제공하려면 k8s에서 각각의 구현체에 대한 특징을 알고 별도의 로직을 만들어야 합니다. 이러한 문제를 해결하기 위해 CNI(Container Network Interface)가 고안되었습니다. 서로 간에 통신이 되게 만드는 방법은 다르지만 결국 하는 역할은 비슷하기 때문에 하나의 통일된 규약을 만든 것입니다. 예를 들어 새로운 컨테이너를 생성하고 특정 네트워크 네임스페이스에 속하게 만드는 기능은 반드시 필요한 기능일 것입니다. 이러한 요구조건에 대한 스펙을 정의한 것이 CNI이고, 이 CNI스펙을 만족하는 구현체는 CNI 플러그인이라고 합니다.

그림 7. CNI 설정 파일

각각의 노드에는 해당 노드를 제어하는 kubelet 프로세스가 존재합니다. kubelet을 실행할 때 --cni-conf-dir=/etc/cni/net.d 인자를 통해 CNI에 대한 설정을 전달합니다. kubelet은 이 디렉토리를 읽어서 어떤 CNI를 사용할지 결정합니다. 그리고 마찬가지로 kubelet을 실행할 때 --cni-bin-dir=/opt/cni/bin 인자를 통해 CNI의 바이너리 위치를 전달합니다. kubelet은 CNI에 대한 실행이 들어오면 해당 바이너리를 통해 네트워크 플러그인에 CNI 명령을 전달하게 됩니다.

Overlay Network: Weave

이전의 Pod Networking 섹션에서 노드와 Pod의 수가 증가하면 하나의 라우터를 두고 해당 라우터에서 모든 Pod에 대한 IP를 관리할 수 있다고 했습니다. 하지만 Pod의 수가 수만, 수십만이 된다면 이런 방법은 비효율적입니다. 그래서 등장한 것이 오버레이 네트워크 입니다. 다양한 오버레이 네트워크 솔루션이 존재하지만 여기서는 Weave를 예를 들어 설명하겠습니다. Weave는 먼저 각 노드에 Weave Agent를 배포합니다. Agent 간에는 통신이 가능하며 전체 클러스터의 Pod에 대한 정보를 파악하게 됩니다.

그림 8. Overay Network: Weave의 경우

예를 들어 빨강 Pod은 초록 Pod으로 패킷을 전송하려고 합니다. 그럼 Weave Agent는 이 패킷을 가로채서 목적지의 Pod이 위치한 노드를 찾습니다. 이 경우에는 초록 Pod이 노드2에 존재하기 때문에 노드2가 목적지가 됩니다. Weave Agent는 기존의 패킷을 감싸서 마치 노드1에서 출발하고 노드2에 도착하는 패킷처럼 보이도록 만듭니다. 이 패킷은 기존의 네트워크를 통해 전달되며 결국 노드2에 도착하게 됩니다. Weave Agent는 이전에 감쌌던 정보를 삭제합니다. 패킷은 처음 전송되었을 때와 같은 상태가 되었으며 정상적으로 초록 Pod에 전달됩니다. 실제로 Weave를 배포한 뒤에 kubectl get pods -n kube-system 명령을 확인해보면 각 노드에 Weave Agent가 배포되어 실행되고 있음을 확인할 수 있습니다.

그림 9. Weave Pod

IPAM(IP Address Management): Weave의 경우

지금 까지는 IP를 적당한 값으로 수동으로 할당했지만 실제 k8s 환경에서는 적절한 IP를 할당하고 관리해주는 IPAM(IP Address Management)가 필요하게 됩니다. IPAM 또한 k8s가 직접 구현하고 있지 않으며 대신 CNI가 책임을 가지고 있습니다. Weave에서는 10.32.0.0/12 대역의 IP를 사용합니다. 10.32.0.1 ~ 10.47.255.254의 IP를 사용하게 되며 약 100만개에 해당하는 IP입니다. Weave에서는 먼저 각 노드에 적당한 Subnet을 할당하고 Bridge와 Pod이 해당하는 Subnet을 사용하게 만듭니다. 어떤 Subnet을 사용할지는 Weave에 의해 자동으로 정해지지만 설정을 통해 직접 지정할 수도 있습니다.

2.2 Kubernetes Service

Pod의 IP는 통신하기 위한 엔드포인트로는 적절하지 않습니다. 예를 들어 다수의 Pod을 Deployment로 구성하는 경우 Pod은 애플리케이션의 부하 정도에 따라 스케일 업, 다운 될 수 있습니다. 즉 Pod의 IP는 임시적이며 언제든지 변할 수 있는 정보입니다. 따라서 특정 Pod에 대한 영구적인 엔드포인트를 제공하기 위해 k8s에서는 Service를 사용합니다. Pod은 특정 라벨을 가지고 있고, 서비스는 이 라벨을 바라보고 있습니다. 이제 서비스의 이름이나 서비스의 IP 주소를 통해 Pod에 접근할 수 있습니다. Pod이 다른 노드에 생성되어 Pod의 IP주소가 변하더라도 라벨은 변하지 않기 때문에 여전히 서비스를 통해 이 Pod에 접근할 수 있습니다.

Kubernetes Service: Cluster IP

k8s에서 새로운 서비스를 할당하면 서비스 이름과 서비스 IP 주소를 가집니다. 이제 k8s 내의 어떤 Pod이라도 이 서비스 이름 혹은 서비스 IP 주소를 통해 이 서비스에 접근할 수 있습니다. 이 IP를 클러스터 IP라고 합니다. 클러스터 IP와 Pod IP는 모든 Pod에서 접근할 수 있다는 공통점을 가지지만, Pod IP는 언제든지 변할 수 있는 반면 클러스터 IP는 한 번 할당 되면 변하지 않습니다. 예를 들어 mysql을 사용하는 자바 어플리케이션을 만든다면 mysql의 접속 정보에 mysql Pod IP를 지정하면 처음에는 동작하지만 mysql Pod이 다른 노드에 뜨면서 IP가 변경되면 더 이상 동작하지 않을 것입니다. 하지만 접속 정보로 mysql Pod에 대한 서비스를 생성하고 서비스의 IP나 이름을 지정하면 항상 mysql에 접근 가능한 상태가 됩니다.

Kubernetes Service: Node Port

클러스터 IP는 이름에서 알 수 있듯이 k8s 내부에서만 접근이 가능합니다. 즉 Pod에 떠 있는 컨테이너만이 접근 가능하고 심지어 호스트 노드에서도 접근할 수 없습니다. Pod을 외부에서 접근하기 위한 첫 번째 방법은 Node Port를 사용하는 방법입니다. 이 방법은 Docker에서 호스트의 포트와 컨테이너의 포트를 맵핑하는 방법과 비슷한 방법을 사용합니다. 다른 점은 Docker에서는 하나의 호스트와 그 호스트에 떠 있는 컨테이너를 맵핑했지만, Node Port에서는 모든 노드의 포트로 해당 Pod에 접근이 가능하게 됩니다. 따라서 외부에서는 k8s에 속해 있는 아무 노드의 특정 포트를 통해 컨테이너 내부에 접근이 가능해집니다.

Kubernetes Service 동작 방식: Kube-Proxy

Kubernetes Service는 Pod과 같이 특정 호스트에 존재하는 것이 아니라 클러스터 전체가 알고 있는 글로벌 객체입니다. 이를 제어하기 위해 존재하는 것이 kube-proxy 데몬입니다. kube-proxy는 모든 노드에 한 개 씩 띄워지며 일반적으로 Pod으로 띄워지게 됩니다. kube-proxy는 리눅스의 넷필터를 이용하여 서비스의 IP와 포트를 특정 Pod으로 맵핑시켜주는 역할을 수행합니다.

그림 10. Kube Proxy 동작 방식

위 예제는 3개의 노드에 1개의 Pod과 1개의 서비스가 있을 때의 모습을 나타내고 있습니다. kube-proxy는 각 노드에서 iptables를 활용하여 10.99.13.178:80으로 들어오는 모든 패킷을 10.244.1.2로 보냅니다. 이전에 노드와 관계 없이 클러스터 전체에서 Pod에 접근 가능하다고 했기 때문에, 노드와 관계 없이 10.244.1.2로 접근이 가능합니다. 따라서 서비스의 IP에 접근하면 해당 Pod으로 접근이 가능하게 됩니다. Kubernetes Service가 실제로 이렇게 동작하는지 확인해보겠습니다.

# nginx 이미지를 2-replica Deployment로 만듭니다.
$ kubectl run nginx --image=nginx --replicas=2

# 서비스를 생성합니다.
$ kubectl expose deployment nginx --type=ClusterIP --name=nginx-service --port=80

# k8s 노드 중 하나에서 iptables를 확인합니다.
$ iptables -t nat -nL | grep nginx
  KUBE-SVC-GKN7Y2BSGW4NJTYL  tcp  --  0.0.0.0/0            10.99.245.149        /* default/nginx-service: cluster IP */ tcp dpt:80

서비스 이름으로 코멘트가 되어 있어 쉽게 찾을 수 있습니다. 10.99.245.149는 생성된 서비스의 ClusterIP 주소입니다. 해당 IP의 TCP 80번 포트로 들어온 패킷에 대해서 KUBE-SVC-GKN7Y2BSGW4NJTYL 체인으로 점프하고 있습니다. 해당 체인을 확인해보겠습니다.

$ iptables -t nat -nL KUBE-SVC-GKN7Y2BSGW4NJTYL
  Chain KUBE-SVC-GKN7Y2BSGW4NJTYL (1 references)
  target     prot opt source               destination         
  KUBE-SEP-5ZIYXBES642MO2YX  all  --  0.0.0.0/0            0.0.0.0/0            statistic mode random probability 0.50000000000
  KUBE-SEP-5SGKNUX33A4VFC6J  all  --  0.0.0.0/0            0.0.0.0/0     

두 개의 룰이 존재합니다. 이전에 nginx를 2-replicas로 띄웠기 때문에 50%의 확률로 각각의 Pod에 접근하는 것입니다. 두 개의 룰에 있는 타겟을 다시 확인해보겠습니다.

$ iptables -t nat -nL KUBE-SEP-5ZIYXBES642MO2YX
Chain KUBE-SEP-5ZIYXBES642MO2YX (1 references)
target     prot opt source               destination         
KUBE-MARK-MASQ  all  --  172.17.0.10          0.0.0.0/0           
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp to:172.17.0.10:80

$ iptables -t nat -nL KUBE-SEP-5SGKNUX33A4VFC6J
Chain KUBE-SEP-5SGKNUX33A4VFC6J (1 references)
target     prot opt source               destination         
KUBE-MARK-MASQ  all  --  172.17.0.9           0.0.0.0/0           
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp to:172.17.0.9:80

$ kubectl get pods -o wide
NAME                     READY   STATUS    RESTARTS   AGE     IP            NODE       NOMINATED NODE   READINESS GATES
nginx-6db489d4b7-htqj7   1/1     Running   0          3m27s   172.17.0.10   minikube   <none>           <none>
nginx-6db489d4b7-ptsxb   1/1     Running   0          3m27s   172.17.0.9    minikube   <none>           <none>

각각의 룰은 Pod의 IP인 172.17.0.10172.17.0.9에 맵핑되고 있습니다. 이제 서비스의 ClusterIP로 접근하면 50%의 확률로 각각의 Pod에 접근하게 됩니다.

2.3 Domain Name System (DNS)

지금 까지는 Pod이나 서비스를 IP를 통해 접근했습니다. 하지만 모든 서비스를 IP를 통해 접근한다면 기억하기 어려운 IP 주소를 관리해야 하는 어려움이 따릅니다. 따라서 k8s에서는 DNS를 통해 IP 주소를 의미있는 이름으로 맵핑시키는 기능을 제공합니다. 예를 들어 mysql에 접근하기 위한 서비스로 IP 주소 10.244.67.32를 기억하는 것 보다는 mysql-service를 기억하는 것이 쉬울 것입니다.

k8s에서 도메인은 트리 형태로 구성됩니다. 최상위의 루트는 cluster.local로써 k8s 클러스터 자체를 나타냅니다. 그리고 도메인의 종류가 이어집니다. 서비스의 경우에는 svc, Pod의 경우에는 pod입니다. 이어서 네임스페이스와 설정한 도메인 이름이 나타납니다.

그림 11. k8s 도메인 이름의 구성요소

Kubernetes DNS 서버: CoreDNS

k8s에서는 DNS를 제공하기 위해 오픈소스 DNS 서버인 CoreDNS를 사용합니다. CoreDNS는k8s 설치시 kube-system 네임스페이스에 Pod으로 디플로이 됩니다. 이 때 Pod은 고가용성을 제공하기 위해 두개 이상으로 복제되어 Deployment로 배포됩니다. 또 CoreDNS에 각각의 Pod에서 접근할 수 있도록 kube-dns라는 서비스를 생성합니다.

$ kubectl get deployments.apps -n kube-system
  NAME            READY   UP-TO-DATE   AVAILABLE   AGE
  coredns         2/2     2            2           6d1h

$ kubectl get svc -n kube-system | grep kube-dns
  kube-dns        ClusterIP   10.96.0.10       <none>        53/UDP,53/TCP,9153/TCP   6d1h

이후 Pod이 생성되면 Pod의 DNS 서버로 kube-dns가 설정됩니다. 이는 Pod 내부의 /etc/resolv.conf에 DNS 서버 주소를 설정함으로써 세팅됩니다. Pod 내부에서 도메인 네임으로 접근하면, 먼저 /etc/resolv.conf를 확인하여 DNS 서버의 주소를 얻어오고 해당 서버에 도메인 네임을 전달하여 실제 주소를 얻어오게 됩니다.

# Pod으로 busybox를 실행하여 /etc/resolv.conf를 보면 kube-dns 서비스의 주소가 네임서버로 설정되어 있습니다.
$ kubectl run -it --rm busybox --image=busybox --restart=Never -- cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
pod "busybox" deleted

2.4 Network Policy

이전 섹션에서 k8s에서 Pod이 정상적으로 동작하려면 호스트와 관계 없이 모든 Pod이 서로간에 통신이 가능해야 한다고 했습니다. 기본적으로 Pod은 패킷에 대한 어떠한 필터링도 제공하지 않습니다. 하지만 Production 레벨에서 이러한 정책은 많은 보안 문제점을 가져오게 됩니다. 따라서 Pod에 네트워크 정책을 설정할 수 있는 방법이 고안되었습니다. 네트워크 정책은 k8s에 오브젝트로서 존재하며 크게 세 가지를 설정해야 합니다.

  • 이 네트워크 정책이 어떤 Pod에 적용되야 하나?
  • 나가는 패킷(Egress)에 적용되나? 들어오는 패킷(Ingress)에 적용되나?
  • 누구에 접근할 수 있는가? (Egress) 누가 접근할 수 있는가? (Ingress)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-network-policy
spec:
  podSelector:
    matchLabels:
      role: db
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - ipBlock:
        cidr: 172.17.0.0/16
        except:
        - 172.17.1.0/24
(중략)

2.5 Ingress

먼저 여기서의 Ingress는 네트워크 정책에서 들어오는 패킷(Ingress)를 나타내는 것이 아닙니다. 이름이 다소 혼동되지만 완전히 별개의 개념입니다. 인그레스는 외부에 공개되는 HTTP(S) 주소와 k8s의 클러스터를 연결하고 관리하는 역할을 수행합니다. 아직 직접적으로 와닿지 않으므로 예를 들어 설명하겠습니다. 먼저 음악 서비스를 제공하는 페이지를 만든다고 가정해보겠습니다.

그림 12. Proxy를 통한 음악 서비스

먼저 애플리케이션 개발자는 Music 애플리케이션을 개발하여 Pod으로 만듭니다. Pod에 고가용성을 추가하기 위해 Pod은 3-replicas로 디플로이먼트로 구성하였습니다. 여기에 외부에서 이 디플로이먼트에 접근하기 위해 NodePort 서비스를 추가합니다. NodePort는 3만번대의 포트를 가지기 때문에 여기서는 38080 포트를 할당받았다고 가정하였습니다. 그리고 외부 DNS 등록 업체에 my-online-store.com을 도메인 이름으로 등록합니다. 이제 인터넷에서 사용자들은 도메인 이름과 포트를 통해 음악 서비스에 접근할 수 있게 됩니다. 하지만 포트는 기억하기 어려우므로 포트를 입력하고 싶지 않게 할 수 있습니다. 웹브라우저는 기본적으로 포트가 주어지지 않으면 80번 포트를 사용합니다. NodePort 서비스는 3만번대의 포트를 사용하기 때문에 80번 포트를 직접 줄 수는 없고 중간에 Proxy 서버를 추가합니다. Proxy 서버는 단순히 80번 포트를 서비스의 30080포트로 맵핑해주는 역할을 합니다. 이제 외부에서 포트를 입력하지 않고 http://my-online-store.com을 통해 음악 서비스에 접근할 수 있습니다. 여기까지는 모든 일이 잘 흘러가고 있습니다. 하지만 만약 새로운 서비스를 추가한다면 어떻게 될까요? 그리고 그 서비스를 http://my-online-store.com/video에 할당하고 싶다면 어떻게 해야 할까요?

그림 13. 새로운 서비스가 추가되는 경우

또 다른 LB(Load Balancer)를 추가해야 합니다. 이 과정에서 /video로의 접속은 비디오 서비스로, /music으로의 접속은 음악 서비스로 연결하도록 세팅하게 됩니다. 그런데 LB는 한정된 자원입니다. 예를 들어 네이버 클라우드 플랫폼에서는 LB 1대당 18,720의 요금이 발생합니다. 또 서비스를 추가할 때 마다 모든 LB 관련된 설정을 수정해야 하는 번거로움도 있습니다. 그 외에도 SSL을 설정하거나 인가를 설정할 때도 Proxy 혹은 LB에서의 추가적인 변경이 있게 됩니다. 이러한 문제를 해결하기 위한 것이 Ingress입니다. Ingress는 이러한 LB들의 설정을 k8s 클러스터 내부에 설정할 수 있게 해줍니다.

그림 14. Ingress

Ingress 자체도 38080포트로 노출되기 때문에 80번 포트를 사용하기 위한 한 개의 프록시는 여전히 필요합니다. 하지만 그 외의 모든 LB는 이제 인그레스가 담당하게 됩니다. 다수의 서비스를 추가하고, 서비스에 TLS 설정을 하고 로드 밸런싱 기능을 추가해도 인그레스 자체의 설정을 제외한 추가적인 변경은 필요하지 않습니다.



참조