쿠버네티스는 컨테이너, 파드, 서비스, 노드 사이에서 발생하는 네트워크를 처리하기 위해 ip 할당, 라우팅 정책, 로드 밸런싱, 그리고 스위칭을 적극적으로 활용합니다. 특히 우리가 앞에서 살펴보았던 하나의 호스트 내에서 다중화되었거나 격리된 환경에서의 네트워크 처리는 반드시 필요합니다.

오늘은 쿠버네티스의 실질적인 네트워크에 들어가기에 앞서 쿠버네티스 네트워크를 찾아보면 만날 수 있는 개념 혹은 패키지들에 대해 알아보고자 합니다.


이전 글 목차
https://dev-whoan.xyz/111, 네트워크로 시작하는 쿠버네티스 — 내가 데이터를 보낸다면
https://dev-whoan.xyz/112, 네트워크로 시작하는 쿠버네티스 - Network Namespace
https://dev-whoan.xyz/113, 네트워크로 시작하는 쿠버네티스 - Docker Network


사전 준비물

ubuntu 22.04 * 2이 필요합니다. 저는 “내가 데이터를 보낸다면”에서 사용한 network ubuntu 2대를 사용하겠습니다.
- 네트워크를 Host Only에서 bridged로 변경해 주세요.


iptables

iptables는 kube-proxy에서 사용할 수 있는 프록시 모드 중 하나입니다. 이를 통해 쿠버네티스에서 컨트롤 플레인의 서비스, 엔드포인트 슬라이스 오브젝트의 추가와 제거를 감시합니다. 또한 각 서비스의 Cluster IP와 Port에 대한 트래픽을 캡처하고, 서비스의 백엔드 세트 중 하나로 redirect 합니다.

iptables는 리눅스의 Netfliter 프레임워크를 활용하여 패킷을 제어하는 패키지입니다. iptables는 방화벽, NAT, 라우팅 등을 주로 수행합니다. iptables는 이러한 동작을 수행하기 위해 Chain이라 불리는 Rule Group을 설정합니다.

  • input: 시스템으로 들어오는 패킷을 처리합니다.
  • forward: 다른 네트워크 인터페이스로 전달되는 패킷을 처리합니다.
  • output: 시스템에서 나가는 패킷을 처리합니다.

이러한 체인은 테이블에 속하게 되는데, 주요 테이블은 다음과 같습니다.

  • filter: drop, accept 등 패킷 필터링을 수행합니다.
  • nat: network address translation에 대한 처리를 수행합니다.
  • mangle: 헤더 변경 등 패킷의 수정을 처리합니다.
  • raw: 패킷 추적을 비활성화하거나 설정합니다.

그러면 ubuntu 22.04 시스템을 바탕으로, iptables를 확인해 보겠습니다.

network@network01:~$ sudo iptables --list
Chain INPUT (policy DROP)
target     prot opt source               destination
ufw-before-logging-input  all  --  anywhere             anywhere
...

Chain FORWARD (policy DROP)
target     prot opt source               destination
ufw-before-logging-forward  all  --  anywhere             anywhere
...

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
ufw-before-logging-output  all  --  anywhere             anywhere
...

기본적인 input, forward, output 체인을 확인할 수 있으며 추가적으로 ufw(방화벽) 관련 chain도 확인하실 수 있습니다.

실제로 iptables의 동작을 확인해보기 위해 network01 시스템에 nginx를 설치하고 확인해 보겠습니다.

network@network01:~$ sudo apt-get update && sudo apt-get install nginx

## network02
network@network02:~$ curl 192.168.1.83

Welcome to nginx!

If you see this page, the nginx web server is successfully installed and working. Further configuration is required.

For online documentation and support please refer to nginx.org.
Commercial support is available at nginx.com.

Thank you for using nginx.

이제 network01 시스템에서 80 포트로 들어오는 모든 패킷을 drop 하는 설정을 한 뒤, 다시 확인해 보겠습니다.

network@network01:~$ sudo iptables -A INPUT -p tcp --dport 80 -j DROP
network@network02:~$ curl 192.168.1.83
^C # 정상적인 연결이 안됨

## 연결 요청이 들어왔나 보기 위해 tcpdump를 활용해 봅시다.
## request는 있지만, reply가 없는것을 확인할 수 있습니다.
network@network01:~$ sudo tcpdump -i enp0s1 tcp port 80
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on enp0s1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
00:42:20.647819 IP 192.168.1.84.52202 > network01.http: Flags [S], seq 2687585736, win 64240, options [mss 1460,sackOK,TS val 4045880976 ecr 0,nop,wscale 7], length 0
00:42:21.660618 IP 192.168.1.84.52202 > network01.http: Flags [S], seq 2687585736, win 64240, options [mss 1460,sackOK,TS val 4045881985 ecr 0,nop,wscale 7], length 0
00:42:23.672601 IP 192.168.1.84.52202 > network01.http: Flags [S], seq 2687585736, win 64240, options [mss 1460,sackOK,TS val 4045884000 ecr 0,nop,wscale 7], length 0

network02의 ip만을 허용한다면 어떻게 될까요?

network@network01:~$ sudo iptables -A INPUT -s 192.168.1.84 -j ACCEPT
network@network01:~$ sudo iptables --list
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
DROP       tcp  --  anywhere             anywhere             tcp dpt:http
ACCEPT     all  --  192.168.1.84         anywhere
network@network02:~$ curl 192.168.1.83
^C

그래도 연결이 되지 않습니다. 왜 연결이 안 되는 걸까요?

## 다른 테스트를 위해 INPUT 설정을 초기화 합니다.
network@network01:~$ sudo iptables --flush INPUT
network@network01:~$ sudo iptables --list INPUT
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
network@network01:~$ sudo iptables -A INPUT -s 192.168.1.84 -j ACCEPT
network@network01:~$ sudo iptables -A INPUT -p tcp --dport 80 -j DROP
network@network01:~$ sudo iptables -L INPUT
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
ACCEPT     all  --  192.168.1.84         anywhere
DROP       tcp  --  anywhere             anywhere             tcp dpt:http

위와 같이 network02 시스템에서 nginx에 접근을 요청하면 정상적으로 html이 출력되는 것을 확인할 수 있습니다. 그 이유는 iptables의 규칙은 설정된 순서대로 우선순위를 갖기 때문입니다.

Forward 체인을 확인해 보기 위해 Host OS에서 network 02로 요청을 보낸다면, network 01 시스템의 nginx로 연결해 보도록 하겠습니다.

## ip 패킷에 대한 forwarding을 허용합니다.
network@network02:~$ sudo sysctl -w net.ipv4.ip_forward=1
net.ipv4.ip_forward = 1
network@network02:~$ sudo sysctl -p

## 80포트로 들어오는 패킷을 network 01로 라우팅 합니다.
network@network02:~$ sudo iptables --table nat --append PREROUTING \\
  --proto tcp --dport 80 \\
  --jump DNAT --to-destination 192.168.1.83:80

PREROUTING을 추가했습니다. 이는 들어오는 패킷을 현재 시스템에서 처리하지 않고, 다음 destination으로 jump 함을 의미합니다. network 02 시스템에 대해 curl요청을 보내면, 아무 반응이 없습니다. 잠시 멈추시어 그 이유를 생각해 보시기 바랍니다.

## network 01로 패킷을 forwarding 합니다.
network@network02:~$ sudo iptables --append FORWARD --proto tcp \\
  --destination 192.168.1.83 --dport 80 --jump ACCEPT
  
## 패킷 중 목적지 IP가 network 01의 80포트인 패킷의 출발지 IP를 192.168.1.84로 변경합니다.
network@network02:~$ sudo iptables --table nat --append POSTROUTING \\
  --proto tcp --destination 192.168.1.83 --dport 80 \\
  --jump SNAT --to-source 192.168.1.84

iptables에는 LOG를 통해 규칙에 대한 패킷을 기록할 수 있습니다. 한번 위 동작에 대해 로그를 확인해 봅시다.

## iptables는 순서를 갖기 때문에, 기존의 FORWARD 규칙을 삭제하고 다시 설정합니다.
network@network02:~$ sudo iptables --append FORWARD \\
  --destination 192.168.1.83 --proto tcp \\
  --dport 80 --jump LOG \\
  --log-prefix "Forward to ->Network 01 Nginx: " --log-level 4
network@network02:~$ sudo iptables --append FORWARD --proto tcp \\
  --destination 192.168.1.83 --dport 80 \\
  --jump ACCEPT

## 기본적으로 /var/log/syslog를 통해 iptables의 로그를 확인할 수 있습니다.
network@network02:~$ tail -f /var/log/syslog
...
Dec  3 04:15:48 network02 kernel: [13070.401666] Forward to ->Network 01 NginxIN=enp0s1 OUT=enp0s1 MAC=2a:14:0f:0a:0d:2e:... SRC=192.168.1.2 DST=192.168.1.83 LEN=64 TOS=0x00 PREC=0x00 TTL=63 ID=0 DF PROTO=TCP SPT=51098 DPT=80 WINDOW=65535 RES=0x00 SYN URGP=0
...
  • 우리가 설정한 다른 규칙들에도 LOG를 남겨보세요.

output 체인의 경우 다음과 같이 설정할 수 있습니다. 한번 직접 실습해 보시길 바랍니다.

sudo iptables --append OUTPUT --proto tcp|udp --dport PORT --jump DROP|ACCEPT|LOG

IPVS

IPVS는 쿠버네티스에서 사용할 수 있는 프록시 모드 중 하나입니다. 이를 통해 쿠버네티스 서비스와 엔드포인트 슬라이스를 감시하고, netlink 인터페이스를 호출해 IPVS 규칙을 생성하고, 이를 쿠버네티스와 주기적으로 동기화합니다. 이 제어 루프를 통해 IPVS를 원하는 상태로 일치하도록 보장합니다.

특히 IPVS의 경우 SNAT, DNAT에 대한 지원이 없기 때문에 필요에 따라 iptables와 함께 사용해야 합니다.

쿠버네티스에서 IPVS 모드를 이용할 경우 다음의 기능을 사용할 수 있습니다.

  • 라운드 로빈, Least Connection 기반의 로드 밸런싱
    • iptables의 경우 백엔드를 임의로 선택합니다. 즉, iptables의 경우 선택된 파드가 응답하지 않으면 연결이 실패하지만, IPVS는 다른 백엔드 파드로 재시도합니다.
  • Cluster IP와 NodePort에 대한 라우팅

IPVS 실습을 위해 ipvs admin 패키지를 설치하고, 라운드 로빈을 활성화해 줍니다.

## network 01
network@network01:~$ sudo sysctl -w net.ipv4.ip_forward=1
network@network01:~$ sudo apt-get install ipvsadm -y
network@network01:~$ lsmod | grep "ip_vs"
network@network01:~$ sudo modprobe ip_vs
network@network01:~$ sudo modprobe ip_vs_rr
network@network01:~$ lsmod | grep "ip_vs"
ip_vs_rr               20480  0
ip_vs                 196608  2 ip_vs_rr
nf_conntrack          196608  6 xt_conntrack,nf_nat,xt_nat,nf_conntrack_netlink,xt_MASQUERADE,ip_vs
nf_defrag_ipv6         24576  2 nf_conntrack,ip_vs
libcrc32c              16384  6 nf_conntrack,nf_nat,btrfs,nf_tables,raid456,ip_vs

## network 01에서는 nginx 컨테이너를 두 개 띄워줍니다.
network@network01:~$ sudo docker run -d --name nginx1 -p 8080:80 nginx
network@network01:~$ sudo docker run -d --name nginx2 -p 8081:80 nginx
## 이후 각 컨테이너에 요청을 보내면 아래의 응답이 오도록 컨테이너를 수정합니다.
network@network01:~$ curl localhost:8080
nginx 8080 container
network@network01:~$ curl localhost:8081
nginx 8081 container

## network 02
network@network02:~$ sudo sysctl -w net.ipv4.ip_forward=1
network@network02:~$ sudo apt-get install ipvsadm -y
network@network02:~$ lsmod | grep "ip_vs"
network@network02:~$ sudo modprobe ip_vs
network@network02:~$ sudo modprobe ip_vs_rr
network@network02:~$ lsmod | grep "ip_vs"
ip_vs_rr               20480  0
ip_vs                 196608  2 ip_vs_rr
nf_conntrack          196608  6 xt_conntrack,nf_nat,xt_nat,nf_conntrack_netlink,xt_MASQUERADE,ip_vs
nf_defrag_ipv6         24576  2 nf_conntrack,ip_vs
libcrc32c              16384  6 nf_conntrack,nf_nat,btrfs,nf_tables,raid456,ip_vs

이후 network 02 시스템에서 다음의 작업을 수행해야 합니다.

## 패킷 라우팅을 허용합니다.
network@network02:~$ sudo sysctl -w net.ipv4.ip_forward=1
## 커널의 Connection을 Track하는 기능을 활성화 합니다.
## IPVS의 경우 커널 수준의 로드 밸런싱이기 때문에, 연결이 추적 불가할 때 NAT가 불가합니다.
## 이를 통해 클라이언트의 요청으로 생성된 연결을 추적하고
## 추적된 연결의 응답 패킷을 적절히 라우팅 합니다.
network@network02:~$ sudo sysctl -w net.ipv4.vs.conntrack=1

network@network02:~$ sudo iptables -t nat -A POSTROUTING -p tcp -d 192.168.1.83 --dport 8080 -j MASQUERADE
network@network02:~$ sudo iptables -t nat -A POSTROUTING -p tcp -d 192.168.1.83 --dport 8081 -j MASQUERADE

network@network02:~$ sudo iptables -L
Chain INPUT (policy ACCEPT)
target     prot opt source               destination

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
network@network02:~$ sudo iptables -t nat -L POSTROUTING
Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination
MASQUERADE  tcp  --  anywhere             192.168.1.83         tcp dpt:http-alt
MASQUERADE  tcp  --  anywhere             192.168.1.83         tcp dpt:tproxy

network@network02:~$ sudo ipvsadm -Ln
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
  -> RemoteAddress:Port           Forward Weight ActiveConn InActConn
TCP  192.168.1.84:80 rr
  -> 192.168.1.83:8080            Masq    1      1          1
  -> 192.168.1.83:8081            Masq    1      1          0

한번 각 설정을 off 한 다음, tcpdump로 직접 상태를 보며 무엇이 문제인지 확인해 보세요.
Host OS에서 Network 02로 요청을 보낼 때, 로드 밸런싱이 정상적으로 동작하는 것을 확인할 수 있습니다.

$ curl 192.168.1.84
nginx 8080 container

$ curl 192.168.1.83:8080
nginx 8080 container

$ curl 192.168.1.83:8081
nginx 8081 container

$ curl 192.168.1.84
nginx 8081 container

$ curl 192.168.1.84
nginx 8080 container

ipvs 실습의 경우 round robin만 활용했습니다. 이외의 방식 (Least Connection)도 한번 직접 수행해 보시면 좋을 것 같습니다.


Overlay 네트워크

Overlay 네트워크는 기존의 네트워크 상위에 논리 네트워크를 만들고, IP 주소와 트래픽 제어를 수행하는 기술입니다. 쿠버네티스에서는 Overlay 네트워크를 통해 Pod의 연결을 지원합니다.

IPIP (IP in IP)

IPIP는 IP 패킷을 다른 IP 패킷 안에 캡슐화하는 기술입니다.

좌측이 일반적인 IP 패킷이라면, 우측은 IPIP에 대한 패킷입니다.

좌측 그림이 일반적인 IP 패킷이라면, 오른쪽 그림은 IPIP를 나타냅니다. Outer IP Header를 추가하여 IP를 캡슐화하는 기술인데, 이때 Outer IP Header는 일반적인 IP Header와 동일합니다.

바꿔 말하면, 새로운 IP Header를 추가하여 출발지와 목적지 등을 새롭게 설정할 수 있습니다.

IPIP를 적용한 Overlay 네트워크에서는 컨테이너의 값을 Inner IP Header로 설정하여 원 출발지/목적지로 설정하고, Outer IP Header에는 컨테이너를 운영하는 시스템(노드)을 Outer IP Header로 설정하여 캡슐화를 진행합니다.

그러나 IP를 다른 IP 패킷 안에 캡슐화하기 때문에, 패킷의 최대 크기 Maximum Transmission Unit (MTU)를 조절해야 하거나, 네트워크 오버헤드로 인해 성능이 줄어들 수 있습니다. 또한 다중 테넌트를 지원하지 않기 때문에 대규모 환경에서는 VXLAN 등을 이용해야 합니다.

IPIP를 통해 Network 시스템에 가상의 대역을 갖는 ip를 생성하고, 통신을 수행해 보겠습니다.

## Network 01
network@network01:~$ sudo ip tunnel add ipip0 mode ipip local 192.168.1.83 remote 192.168.1.84
network@network01:~$ sudo ip link set ipip0 up
network@network01:~$ sudo ip addr add 10.0.0.1/24 dev ipip0
network@network01:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 1a:df:51:84:a5:4a brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.83/24 metric 100 brd 192.168.1.255 scope global dynamic enp0s1
       valid_lft 7034sec preferred_lft 7034sec
    inet6 fe80::18df:51ff:fe84:a54a/64 scope link
       valid_lft forever preferred_lft forever
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default
    link/ether 02:42:d6:7e:70:ed 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
4: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
    link/ipip 0.0.0.0 brd 0.0.0.0
5: ipip0@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1480 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ipip 192.168.1.83 peer 192.168.1.84
    inet 10.0.0.1/24 scope global ipip0
       valid_lft forever preferred_lft forever
    inet6 fe80::5efe:c0a8:153/64 scope link
       valid_lft forever preferred_lft forever

network@network01:~$ ping 10.0.0.2 -c 3
PING 10.0.0.2 (10.0.0.2) 56(84) bytes of data.
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=18.6 ms
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=6.06 ms
64 bytes from 10.0.0.2: icmp_seq=3 ttl=64 time=12.4 ms

--- 10.0.0.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2011ms
rtt min/avg/max/mdev = 6.064/12.346/18.572/5.106 ms
    
## Network 02
network@network02:~$ sudo ip tunnel add ipip0 mode ipip local 192.168.1.84 remote 192.168.1.83
network@network02:~$ sudo ip link set ipip0 up
network@network02:~$ sudo ip addr add 10.0.0.2/24 dev ipip0
network@network02:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 2a:14:0f:0a:0d:2e brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.84/24 metric 100 brd 192.168.1.255 scope global dynamic enp0s1
       valid_lft 5621sec preferred_lft 5621sec
    inet6 fe80::2814:fff:fe0a:d2e/64 scope link
       valid_lft forever preferred_lft forever
3: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
    link/ipip 0.0.0.0 brd 0.0.0.0
4: ipip0@NONE: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1480 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ipip 192.168.1.84 peer 192.168.1.83
    inet 10.0.0.2/24 scope global ipip0
       valid_lft forever preferred_lft forever
    inet6 fe80::5efe:c0a8:154/64 scope link
       valid_lft forever preferred_lft forever
       
## Network 01이 관리중인 ipip로 요청을 보내보면, 정상적으로 동작하는걸 확인할 수 있습니다.
network@network02:~$ curl 10.0.0.1:8080
nginx 8080 container

VXLAN

Virtual eXtensible LAN의 약자로, 네트워크 가상화의 표준(standard)으로, UDP를 활용하여 L2의 이더넷 프레임을 캡슐화합니다. UDP를 활용하기 때문에 L3를 기반으로 하며, 캡슐화를 통해 L2 네트워크를 생성합니다. 쉽게 말하면 물리 네트워크 위에 가상의 L2 네트워크를 만드는 기술입니다.

우측이 VXLAN이 설정된 패킷입니다.

  • Inner L2 Frame: VXLAN 내에서 통신하는 원래의 트래픽입니다.
  • VXLAN Header: VXLAN 네트워크 정보를 포함합니다.
  • Outer UDP Header: 캡슐화된 VXLAN 트래픽을 전송하기 위해 사용하며, 포트 4789번을 가집니다. 캡슐화를 효율적으로 처리하는 데 사용됩니다.
  • Outer IP Header: 물리 네트워크의 L3 헤더로, 캡슐화된 트래픽을 목적지로 전송하기 위한 헤더입니다. 실제 라우팅 가능한 IP 네트워크에서 트래픽을 전달하는 역할을 수행합니다.
  • Outer L2 Header: 물리 네트워크의 L2 헤더입니다. 캡슐화된 트래픽이 물리 네트워크의 스위치 혹은 라우터에 전달될 때 사용됩니다.

VXLAN은 Multicast 혹은 Unicast 방식으로 가상 네트워크에서 통신을 수행합니다.

Multicast는 224.0.0.0 ~ 239.255.255.255 대역에 대해 그룹을 생성하고, 같은 그룹의 장치들끼리 트래픽을 주고받을 수 있도록 합니다. 즉, VXLAN이 사용하는 VTEP인 VXLAN을 관리하는 시스템인 네트워크 장치 간 통신이 가능하게 합니다. Multicast의 경우, 위 주소가 Outer IP Header의 목적지 주소가 되며, Unicast의 경우 다른 VTEP의 물리적 IP 주소가 Outer IP Header의 목적지 주소가 됩니다. VXLAN의 가상 네트워크는 Inner IP Header에 담겨 실제 통신에 사용됩니다. VTEP는 Underlay 네트워크인 물리 네트워크와 Overlay 네트워크인 VXLAN 인터페이스가 될 수 있습니다.

그럼 이제 VXLAN을 통해 Overlay 네트워크를 구성하고, 마찬가지로 docker 컨테이너로 접근해 보겠습니다.

Multicast의 경우, vxlan의 endpoint group을 지정해 주면 IGMP (Internet Group Management Protocol)를 통해 자동으로 구성이 수행됩니다.

  • 멀티캐스트 트래픽은 사용 중인 스위치/라우터가 멀티캐스트 라우팅을 지원하고, IGMP 메시지를 처리할 수 있을 때 전달됩니다.
## Network 01
network@network01:~$ sudo ip link add vxlan0 type vxlan id 241203 \\
  dev enp0s1 group 239.1.1.1 \\
  dstport 4789
network@network01:~$ sudo ip addr add 10.1.1.1/24 dev vxlan0
network@network01:~$ sudo ip link set vxlan0 up
network@network01:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 1a:df:51:84:a5:4a brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.83/24 metric 100 brd 192.168.1.255 scope global dynamic enp0s1
       valid_lft 4498sec preferred_lft 4498sec
    inet6 fe80::18df:51ff:fe84:a54a/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:d6:7e:70:ed 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:d6ff:fe7e:70ed/64 scope link
       valid_lft forever preferred_lft forever
4: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
    link/ipip 0.0.0.0 brd 0.0.0.0
7: veth3d8b49d@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
    link/ether ea:4e:ad:f8:e2:ae brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::e84e:adff:fef8:e2ae/64 scope link
       valid_lft forever preferred_lft forever
10: vxlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ether 96:70:45:03:fc:fb brd ff:ff:ff:ff:ff:ff
    inet 10.1.1.1/24 scope global vxlan0
       valid_lft forever preferred_lft forever
    inet6 fe80::9470:45ff:fe03:fcfb/64 scope link
       valid_lft forever preferred_lft forever
network@network01:~$ ping 10.1.1.2 -c 4
PING 10.1.1.2 (10.1.1.2) 56(84) bytes of data.
64 bytes from 10.1.1.2: icmp_seq=1 ttl=64 time=13.5 ms
64 bytes from 10.1.1.2: icmp_seq=2 ttl=64 time=1.74 ms
64 bytes from 10.1.1.2: icmp_seq=3 ttl=64 time=1.23 ms
64 bytes from 10.1.1.2: icmp_seq=4 ttl=64 time=1.12 ms

--- 10.1.1.2 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3015ms
rtt min/avg/max/mdev = 1.124/4.396/13.496/5.258 ms

## Network 02
network@network02:~$ sudo ip link add vxlan0 type vxlan id 241203 \\
  dev enp0s1 group 239.1.1.1
network@network02:~$ sudo ip addr add 10.1.1.2/24 dev vxlan0
network@network02:~$ sudo ip link set vxlan0 up
network@network02:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 2a:14:0f:0a:0d:2e brd ff:ff:ff:ff:ff:ff
    inet 192.168.1.84/24 metric 100 brd 192.168.1.255 scope global dynamic enp0s1
       valid_lft 6691sec preferred_lft 6691sec
    inet6 fe80::2814:fff:fe0a:d2e/64 scope link
       valid_lft forever preferred_lft forever
3: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
    link/ipip 0.0.0.0 brd 0.0.0.0
9: vxlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ether 0a:ec:bb:3d:3d:f8 brd ff:ff:ff:ff:ff:ff
    inet 10.1.1.2/24 scope global vxlan0
       valid_lft forever preferred_lft forever
    inet6 fe80::8ec:bbff:fe3d:3df8/64 scope link
       valid_lft forever preferred_lft forever
network@network02:~$ ping 10.1.1.1 -c 4
PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data.
64 bytes from 10.1.1.1: icmp_seq=1 ttl=64 time=2.41 ms
64 bytes from 10.1.1.1: icmp_seq=2 ttl=64 time=2.50 ms
64 bytes from 10.1.1.1: icmp_seq=3 ttl=64 time=6.22 ms
64 bytes from 10.1.1.1: icmp_seq=4 ttl=64 time=7.82 ms

--- 10.1.1.1 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3017ms
rtt min/avg/max/mdev = 2.409/4.738/7.822/2.352 ms

## 아래 명령은 두 개의 shell을 접속하여 수행해 보세요.
network@network02:~$ curl 10.1.1.1:8080
nginx 8080 container
# 나머지 하나의 Shell 에서는 tcpdump를 수행하여 확인해봅시다.
network@network02:~$ sudo tcpdump -i enp0s1 udp port 4789 -n
[sudo] password for network:
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on enp0s1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
11:44:56.662220 IP 192.168.1.84.56103 > 192.168.1.83.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.2.39200 > 10.1.1.1.8080: Flags [S], seq 156139928, win 64860, options [mss 1410,sackOK,TS val 318379903 ecr 0,nop,wscale 7], length 0
11:44:56.663992 IP 192.168.1.83.39249 > 192.168.1.84.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.1.8080 > 10.1.1.2.39200: Flags [S.], seq 169487905, ack 156139929, win 65160, options [mss 1460,sackOK,TS val 1355529330 ecr 318379903,nop,wscale 7], length 0
11:44:56.664049 IP 192.168.1.84.56103 > 192.168.1.83.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.2.39200 > 10.1.1.1.8080: Flags [.], ack 1, win 507, options [nop,nop,TS val 318379905 ecr 1355529330], length 0
11:44:56.664133 IP 192.168.1.84.56103 > 192.168.1.83.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.2.39200 > 10.1.1.1.8080: Flags [P.], seq 1:78, ack 1, win 507, options [nop,nop,TS val 318379905 ecr 1355529330], length 77: HTTP: GET / HTTP/1.1
11:44:56.665779 IP 192.168.1.83.39249 > 192.168.1.84.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.1.8080 > 10.1.1.2.39200: Flags [.], ack 78, win 509, options [nop,nop,TS val 1355529331 ecr 318379905], length 0
11:44:56.665998 IP 192.168.1.83.39249 > 192.168.1.84.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.1.8080 > 10.1.1.2.39200: Flags [P.], seq 1:237, ack 78, win 509, options [nop,nop,TS val 1355529331 ecr 318379905], length 236: HTTP: HTTP/1.1 200 OK
11:44:56.665998 IP 192.168.1.83.39249 > 192.168.1.84.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.1.8080 > 10.1.1.2.39200: Flags [P.], seq 237:258, ack 78, win 509, options [nop,nop,TS val 1355529331 ecr 318379905], length 21: HTTP
11:44:56.666018 IP 192.168.1.84.56103 > 192.168.1.83.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.2.39200 > 10.1.1.1.8080: Flags [.], ack 237, win 506, options [nop,nop,TS val 318379907 ecr 1355529331], length 0
11:44:56.666040 IP 192.168.1.84.56103 > 192.168.1.83.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.2.39200 > 10.1.1.1.8080: Flags [.], ack 258, win 506, options [nop,nop,TS val 318379907 ecr 1355529331], length 0
11:44:56.666452 IP 192.168.1.84.56103 > 192.168.1.83.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.2.39200 > 10.1.1.1.8080: Flags [F.], seq 78, ack 258, win 506, options [nop,nop,TS val 318379907 ecr 1355529331], length 0
11:44:56.674543 IP 192.168.1.83.39249 > 192.168.1.84.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.1.8080 > 10.1.1.2.39200: Flags [F.], seq 258, ack 79, win 509, options [nop,nop,TS val 1355529340 ecr 318379907], length 0
11:44:56.674574 IP 192.168.1.84.56103 > 192.168.1.83.4789: VXLAN, flags [I] (0x08), vni 241203
IP 10.1.1.2.39200 > 10.1.1.1.8080: Flags [.], ack 259, win 506, options [nop,nop,TS val 318379915 ecr 1355529340], length 0

Unicast를 활용한 내용과 VXLAN의 아이피 CIDR을 서로 다르게 설정한 통신도 한번 수행해 보시기 바랍니다.


마치며

오늘은 다음 주제인 Pod와 서비스 간 통신을 시작하기에 앞서 쿠버네티스에서 네트워크를 구성하는데 기본적인 내용을 다루어보았습니다.

느리다면 느린, 길다면 긴 3주 동안 쿠버네티스의 네트워크를 바라보기 위한 필수적인 내용을 다루었습니다. 읽어주신 모든 분께 도움이 되었으면 합니다.

감사합니다.


참고

Network Namespace를 통해 하나의 호스트에서 여러 네트워크를 이용하는 법을 이해했으니, 실질적으로 도커는 어떻게 네트워크를 구성하고 컨테이너 간 통신을 허용하는지 그 방식엔 무엇이 있는지 알아볼 차례입니다.


이전 글 목차
https://dev-whoan.xyz/111, 네트워크로 시작하는 쿠버네티스 — 내가 데이터를 보낸다면
https://dev-whoan.xyz/112, 네트워크로 시작하는 쿠버네티스 — 컨테이너 통신, Network Namespace


사전 준비물
Ubuntu 22.04 시스템 * 1


Bridge 네트워크 살펴보기

현재 시스템은 도커가 설치되지 않은 상태로, 다음과 같은 네트워크 구성을 확인할 수 있습니다.

docker@docker:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 4a:9e:7e:55:59:ee brd ff:ff:ff:ff:ff:ff
    inet 192.168.64.3/24 metric 100 brd 192.168.64.255 scope global dynamic enp0s1
       valid_lft 3390sec preferred_lft 3390sec
    inet6 fd9e:d858:c06b:b3ed:489e:7eff:fe55:59ee/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 2591937sec preferred_lft 604737sec
    inet6 fe80::489e:7eff:fe55:59ee/64 scope link
       valid_lft forever preferred_lft forever
docker@docker:~$ ip netns list
docker@docker:~$ docker
Command 'docker' not found, but can be installed with:
sudo snap install docker         # version 24.0.5, or
sudo apt  install podman-docker  # version 3.4.4+ds1-1ubuntu1.22.04.2
sudo apt  install docker.io      # version 24.0.7-0ubuntu2~22.04.1
See 'snap info docker' for additional versions.
docker@docker:~$

시스템에 도커를 설치하면 네트워크가 어떻게 구성되는지 한번 확인해 보겠습니다. 저는 다음의 명령어를 이용해 설치했습니다.

docker@docker:~$ curl -sSL <https://get.docker.com> | bash
...
Client: Docker Engine - Community
 Version:           27.3.1
 API version:       1.47
 Go version:        go1.22.7
 Git commit:        ce12230
 Built:             Fri Sep 20 11:41:08 2024
 OS/Arch:           linux/arm64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          27.3.1
  API version:      1.47 (minimum version 1.24)
  Go version:       go1.22.7
  Git commit:       41ca978
  Built:            Fri Sep 20 11:41:08 2024
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.7.23
  GitCommit:        57f17b0a6295a39009d861b89e3b3b87b005ca27
 runc:
  Version:          1.1.14
  GitCommit:        v1.1.14-0-g2c9f560
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

================================================================================

To run Docker as a non-privileged user, consider setting up the
Docker daemon in rootless mode for your user:

    dockerd-rootless-setuptool.sh install

Visit <https://docs.docker.com/go/rootless/> to learn about rootless mode.

To run the Docker daemon as a fully privileged service, but granting non-root
users access, refer to <https://docs.docker.com/go/daemon-access/>

WARNING: Access to the remote API on a privileged Docker daemon is equivalent
         to root access on the host. Refer to the 'Docker daemon attack surface'
         documentation for details: <https://docs.docker.com/go/attack-surface/>

================================================================================

docker@docker:~$ ip a
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: enp0s1: <broadcast,multicast,up,lower_up> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 4a:9e:7e:55:59:ee brd ff:ff:ff:ff:ff:ff
    inet 192.168.64.3/24 metric 100 brd 192.168.64.255 scope global dynamic enp0s1
       valid_lft 3260sec preferred_lft 3260sec
    inet6 fd9e:d858:c06b:b3ed:489e:7eff:fe55:59ee/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 2591997sec preferred_lft 604797sec
    inet6 fe80::489e:7eff:fe55:59ee/64 scope link
       valid_lft forever preferred_lft forever
3: docker0: <no-carrier,broadcast,multicast,up> mtu 1500 qdisc noqueue state DOWN group default
    link/ether 02:42:c0:d7:ec:48 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
       
docker@docker:~$ ip netns list
docker@docker:~$ ip -d link show docker 0
3: docker0: <no-carrier,broadcast,multicast,up> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default
    link/ether 02:42:c0:d7:ec:48 brd ff:ff:ff:ff:ff:ff promiscuity 0 minmtu 68 maxmtu 65535
    bridge forward_delay 1500 hello_time 200 max_age 2000 ageing_time 30000 stp_state 0 priority 32768 vlan_filtering 0 
    vlan_protocol 802.1Q bridge_id 8000.2:42:c0:d7:ec:48 designated_root 8000.2:42:c0:d7:ec:48 root_port 0 root_path_cost 0
    topology_change 0 topology_change_detected 0 hello_timer    0.00 tcn_timer    0.00 topology_change_timer    0.00 gc_timer
    191.28 vlan_default_pvid 1 vlan_stats_enabled 0 vlan_stats_per_port 0 group_fwd_mask 0 group_address 01:80:c2:00:00:00 mcast_snooping 1
    mcast_router 1 mcast_query_use_ifaddr 0 mcast_querier 0 mcast_hash_elasticity 16 mcast_hash_max 4096 mcast_last_member_count 2
    mcast_startup_query_count 2 mcast_last_member_interval 100 mcast_membership_interval 26000 mcast_querier_interval 25500 mcast_query_interval
    12500 mcast_query_response_interval 1000 mcast_startup_query_interval 3124 mcast_stats_enabled 0 mcast_igmp_version 2 mcast_mld_version 1
    nf_call_iptables 0 nf_call_ip6tables 0 nf_call_arptables 0 addrgenmode eui64 numtxqueues 1 numrxqueues 1 gso_max_size 65536 gso_max_segs 65535
</no-carrier,broadcast,multicast,up></no-carrier,broadcast,multicast,up></broadcast,multicast,up,lower_up></loopback,up,lower_up>

설치된 docker0 네트워크를 확인해보니 bridge 형태의 네트워크인 것을 확인할 수 있습니다. 도커는 설치될 때 docker0이라는 이름의 bridge 네트워크를 기본으로 생성하는데, 그 이유는 컨테이너의 통신이 원활하게 이루어질 수 있게 하기 위함입니다.

  • 이 부분이 이해가 안되신다면, https://dev-whoan.xyz/112 편을 참고하셔서 왜 그럴지 이유를 생각해 보시기 바랍니다.
  • 도커를 설치했을 때 생성되는 네트워크 목록은 다음과 같이 확인할 수 있습니다.
docker@docker:~$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
0ddc27e0481c   bridge    bridge    local
c4c67c5ac924   host      host      local
102a26b1c427   none      null      local

## bridge 네트워크를 확인해 봅시다.
docker@docker:~$ docker network inspect bridge
[
    {
        "Name": "bridge",
        "Id": "0ddc27e0481c8189d5d4427bfc22d34705263b847f4df3dfe34cb39070273757",
        "Created": "2024-11-21T01:54:41.435279377Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {},
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]

그렇다면 지난편에서 살펴본 bridge 네트워크와 동일한 방식으로 동작하는지 확인해 봅시다.

docker@docker:~$ docker run --rm -d busybox sleep 3600
1c233db2d027129a366834107c30b21d7e821dbb6291c6131e56279a2d4d61b2

docker@docker:~$ docker ps
CONTAINER ID   IMAGE     COMMAND        CREATED         STATUS         PORTS     NAMES
1c233db2d027   busybox   "sleep 3600"   2 seconds ago   Up 2 seconds             frosty_cannon

docker@docker:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 4a:9e:7e:55:59:ee brd ff:ff:ff:ff:ff:ff
    inet 192.168.64.3/24 metric 100 brd 192.168.64.255 scope global dynamic enp0s1
       valid_lft 3532sec preferred_lft 3532sec
    inet6 fd9e:d858:c06b:b3ed:489e:7eff:fe55:59ee/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 2591983sec preferred_lft 604783sec
    inet6 fe80::489e:7eff:fe55:59ee/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:0d:1b:64:27 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:dff:fe1b:6427/64 scope link
       valid_lft forever preferred_lft forever
5: veth2d96e30@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
    link/ether b6:cd:fd:cd:a3:0b brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::b4cd:fdff:fecd:a30b/64 scope link
       valid_lft forever preferred_lft forever
       
### 컨테이너 내부로 접속하여 ip link를 확인해 봅시다
docker@docker:~$ docker exec -it 1c233db /bin/sh
/ # 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
4: eth0@if5: <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
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever
  • @ifN는 attached interface의 약자로, 연결된 인터페이스 번호 N을 알려줍니다.
  • 즉 Host OS는 bridge 인터페이스를 통해 생성한 veth는 인터페이스 아이디 5번을 가지고 있으며, 쌍으로 인터페이스 번호 4를 갖습니다.
  • 실행한 busybox 컨테이너는 veth 인터페이스 4번을 가지고 있으며, 이는 Host OS의 인터페이스 5번과 연결됩니다.
  • 이는 우리가 지난번에 실습한 veth 생성 방식과 동일한 것을 확인할 수 있습니다.

결국 Docker의 bridge 네트워크는 veth를 통해 생성되는 것을 알았습니다.

  • 그렇다면 우리가 bridge 네트워크를 Host에 생성하고, Docker에서 사용할 수 있을까요? 한번 확인해 보시기 바랍니다.

네트워크 네임스페이스 확인하기

도커에서 생성된 네트워크들은 모두 각각의 네트워크 네임스페이스를 가지며, 따라서 같은 네트워크에 속하지 않은, 즉 서로 다른 네트워크에 배포된 컨테이너들은 통신이 불가합니다.

busybox 컨테이너를 다시 실행시키고 네트워크 네임스페이스를 다음의 명령어로 확인해 보겠습니다.

docker@docker:~$ docker run --rm -d busybox sleep 3600
a1225e13b438f10145dfe3a7974aa03e4516059f29a19628050fcbb3a34ee1e8

docker@docker:~$ docker inspect a1225e | grep "Sand"
            "SandboxID": "3813a3982ba836c1122d3c82aaa6c520455e2591115092cd4ab8454366c4532c",
            "SandboxKey": "/var/run/docker/netns/3813a3982ba8",

이때 네트워크 네임스페이스는 /var/run/docker/netns 아래에 생성되기 때문에 ip 패키지를 통해 확인할 수 없습니다.

  • ip 패키지는 기본적으로 /var/run/netns 아래에 생성합니다.

컨테이너의 네트워크 네임스페이스를 ip 패키지로 확인하기 위해 해당 Sandbox 디렉토리를 마운트하고 ip 패키지로 확인해 보겠습니다.

docker@docker:~$ ip netns list
docker@docker:~$ sudo ln -s /var/run/docker/netns/3813a3982ba8 /var/run/netns/busybox

## 컨테이너의 네트워크는 다음과 같이 구성되어 있습니다.
docker@docker:~$ docker inspect a1225 | grep "Networks" --after 17
            "Networks": {
                "bridge": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": null,
                    "MacAddress": "02:42:ac:11:00:03",
                    "DriverOpts": null,
                    "NetworkID": "0ddc27e0481c8189d5d4427bfc22d34705263b847f4df3dfe34cb39070273757",
                    "EndpointID": "759c8eff452c243a23d321f2425f62acc13cbe13c295a4549c513f497e26436f",
                    "Gateway": "172.17.0.1",
                    "IPAddress": "172.17.0.3",
                    "IPPrefixLen": 16,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "DNSNames": null
                }
            }
docker@docker:~$ ip netns list
busybox

## 위와 동일한 네트워크 정보를 추력하는 것을 확인할 수 있습니다.
docker@docker:~$ sudo ip netns exec busybox ip a
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
15: eth0@if16: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.0.3/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

## 확인이 끝난 netns를 다시 원래 상태로 되돌립니다.
docker@docker:~$ sudo rm -r /var/run/netns/busybox
docker@docker:~$ ip netns list
docker@docker:~$
  • Bridge 네트워크에서 서로 다른 네트워크는 별도의 네트워크 네임스페이스를 갖는것을 확인할 수 있습니다.

Bridge Network 생성하기
Bridge 모드는 기본으로 생성되는 Bridge 네트워크 외에도 생성할 수 있습니다.

docker@docker:~$ docker network create my-bridge
ac6f6b0e64686afb591ed79112837db98b2c23bec43f167d02c401f6bc561918

docker@docker:~$ docker network ls
NETWORK ID     NAME        DRIVER    SCOPE
0ddc27e0481c   bridge      bridge    local
c4c67c5ac924   host        host      local
ac6f6b0e6468   my-bridge   bridge    local
102a26b1c427   none        null      local

docker@docker:~$ docker network inspect my-bridge
[
    {
        "Name": "my-bridge",
        "Id": "ac6f6b0e64686afb591ed79112837db98b2c23bec43f167d02c401f6bc561918",
        "Created": "2024-11-21T02:53:50.573300902Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.18.0.0/16",
                    "Gateway": "172.18.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {},
        "Options": {},
        "Labels": {}
    }
]

이제 Bridge 네트워크로 실행한 컨테이너는 모두 종료해 주세요.


Mac VLAN 살펴보기

현재 우리 시스템은 위와 같이 Host OS가 스위치(DHCP Server)인 물리 네트워크로부터 아이피를 할당받고, 여기에 Bridge 모드를 통해 네트워크를 구성하고 있습니다. 이 경우 컨테이너에서 인터넷 연결이 필요할 때 NAT 등을 Host OS의 네트워크 인터페이스에 의존하여 외부와 통신을 수행하고 있습니다.

Mac VLAN 방식은 물리 네트워크에 직접 연결(Bind)하는 방식으로 네트워크를 구성하기 때문에, 이와 같은 오버헤드가 없으며 성능이 뛰어납니다. Mac VLAN은 이름에서 볼 수 있듯이 Mac을 활용한 Virtual LAN으로 Host OS의 Network Interface를 Parent Interface로 하여 하위에 Sub Interfaces를 구성, 컨테이너를 할당함으로써 물리 네트워크를 직접 이용하는 방식입니다.

Mac VLAN이라 이름이 붙은 이유는 이러한 물리 네트워크로 부터 직접적인 처리를 수행하기 위해 컨테이너마다 L2 네트워크와 같이 Mac 주소를 할당하고 통신하기 때문입니다.

  • 스위치에서 같은 포트를 이용하여 맥에 대한 아이피를 할당받기 때문에 물리 네트워크인 스위치(DHCP 서버)에서 하나의 포트에 여러 Mac 주소를 관리하지 못할 수 있습니다.

Mac VLAN은 아래의 명령어로 생성할 수 있습니다.

docker@docker:~$ docker network create --driver macvlan \
  --subnet 192.168.64.0/24 \
  --gateway 192.168.64.1 \
  -o parent=enp0s1 \
  mac-vlan
fea3aa276e8c51ca5197e260a0b6074072e97acca0c7a6bf21426718512a1db3
docker@docker:~$ docker network ls
NETWORK ID     NAME       DRIVER    SCOPE
0ddc27e0481c   bridge     bridge    local
c4c67c5ac924   host       host      local
fea3aa276e8c   mac-vlan   macvlan   local
102a26b1c427   none       null      local

이제 Mac VLAN 네트워크에 busybox 컨테이너를 배포하고 그 특징에 대해 알아봅시다. 이를 위해 Host OS와 같은 물리 네트워크를 사용하는 시스템 한 대가 추가로 필요합니다. 우리의 경우 Host OS에서 도커 네트워크를 알아보기 위한 가상 머신을 생성했기 때문에, 해당 가상머신의 Host OS를 이용하면 되겠습니다. 구분을 위해 해당 시스템을 Hostest OS라고 부르겠습니다.

컨테이너는 192.168.64.232 의 ip를 갖도록 배포할 예정이며, Hostest OS와 동일한 물리 네트워크를 이용할 예정이기 때문에 Hostest OS와 상호 연결이 가능해야 합니다.

또한 컨테이너를 Mac VLAN으로 배포할 때 ip 주소를 직접 정의해 주어야 합니다. 그 이유는 도커의 Mac VLAN은 물리 네트워크에 바인딩되어 마치 가상머신처럼 동작하지만, 실질적으로 DHCP 서버에 아이피 요청(DHCP Discover)을 하지 않기 때문입니다.

그렇기 때문에 DHCP 서버의 아이피 할당 범위 밖의 주소를 할당해야 하며, 컨테이너 Mac VLAN IP 정책을 수립해야 합니다.

## 배포 전에는 ping이 불가합니다.
hostest-os: $ ping 192.168.64.232 -c 4
PING 192.168.64.232 (192.168.64.232): 56 data bytes
ping: sendto: No route to host
Request timeout for icmp_seq 0
ping: sendto: Host is down
Request timeout for icmp_seq 1
ping: sendto: Host is down
Request timeout for icmp_seq 2

--- 192.168.64.232 ping statistics ---
4 packets transmitted, 0 packets received, 100.0% packet loss

## Host OS에서 mac vlan 네트워크를 이용하는 busybox를 배포하니다.
docker@docker:~$ docker run --rm -d --network mac-vlan --ip 192.168.64.232 --name busybox2 busybox sleep 3600
56047bf024cf86e4bfe3d4aeb1d1198975e0ff6070775114efb8459a6696fde9
docker@docker:~$

## 배포 후 Host OS에서 Ping이 가능한 것을 볼 수 있습니다.
hostest-os: $ ping 192.168.64.232 -c 4
PING 192.168.64.232 (192.168.64.232): 56 data bytes
64 bytes from 192.168.64.232: icmp_seq=0 ttl=64 time=1.530 ms
64 bytes from 192.168.64.232: icmp_seq=1 ttl=64 time=9.557 ms
64 bytes from 192.168.64.232: icmp_seq=2 ttl=64 time=1.019 ms
64 bytes from 192.168.64.232: icmp_seq=3 ttl=64 time=1.442 ms

--- 192.168.64.232 ping statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 1.019/3.387/9.557/3.567 ms

그러나 Host OS에서 Mac VLAN 컨테이너로 ping 등 네트워크 연결은 불가능합니다.

docker@docker:~$ docker exec -it 56047b sh
/ # ping 192.168.64.3 -c 4
PING 192.168.64.3 (192.168.64.3): 56 data bytes

--- 192.168.64.3 ping statistics ---
4 packets transmitted, 0 packets received, 100% packet loss

왜 Host OS로는 연결이 불가능한지, 이를 해결하기 위해선 어떤 솔루션이 있을지 확인해 보시면 좋을 것 같습니다.


IP VLAN 살펴보기

IP VLAN L2
IP VLAN의 경우 Mac VLAN와 비슷하지만 Host OS의 Mac Address를 공유하는 방식입니다.

docker@docker:~$ docker network create -d ipvlan \
  --subnet 192.168.64.0/24 \
  --gateway 192.168.64.1 \
  -o parent=enp0s1 \
  ip-vlan
85f8dcc359c172ff06cbe61cd3d9406f570a7a1f81843affa6604d5a90843842
docker@docker:~$

docker@docker:~$ docker run --rm -d --network ip-vlan --ip 192.168.64.201 --name busybox busybox sleep 3600
c5d3a161d424b0df878f1c495720df277ec642c9231f9c0e5c2b740586106ef3

docker@docker:~$ docker exec -it busybox sh
/ # ping 192.168.64.1
PING 192.168.64.1 (192.168.64.1): 56 data bytes
64 bytes from 192.168.64.1: seq=0 ttl=64 time=11.805 ms
64 bytes from 192.168.64.1: seq=1 ttl=64 time=0.684 ms

## Mac Address가 동일한 것을 확인할 수 있습니다.
/ # 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
## 동일한 MAc 주소를 가집니다.
26: eth0@if2: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue
    link/ether 4a:9e:7e:55:59:ee brd ff:ff:ff:ff:ff:ff
    inet 192.168.64.201/24 brd 192.168.64.255 scope global eth0
       valid_lft forever preferred_lft forever

## Host OS Network Interface 확인
docker@docker:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
## 동일한 Mac 주소를 가집니다.
    link/ether 4a:9e:7e:55:59:ee brd ff:ff:ff:ff:ff:ff
    inet 192.168.64.3/24 metric 100 brd 192.168.64.255 scope global dynamic enp0s1
       valid_lft 2519sec preferred_lft 2519sec
    inet6 fd9e:d858:c06b:b3ed:489e:7eff:fe55:59ee/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 2591977sec preferred_lft 604777sec
    inet6 fe80::489e:7eff:fe55:59ee/64 scope link
       valid_lft forever preferred_lft forever
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default
    link/ether 02:42:0d:1b:64:27 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:dff:fe1b:6427/64 scope link
       valid_lft forever preferred_lft forever

## 아래는 Hostest OS에서 Ping이 가능한 모습입니다.
hostest-os:$ ping 192.168.64.201 -c 4
PING 192.168.64.201 (192.168.64.201): 56 data bytes
64 bytes from 192.168.64.201: icmp_seq=0 ttl=64 time=1.958 ms
64 bytes from 192.168.64.201: icmp_seq=1 ttl=64 time=0.997 ms
64 bytes from 192.168.64.201: icmp_seq=2 ttl=64 time=1.533 ms
64 bytes from 192.168.64.201: icmp_seq=3 ttl=64 time=1.158 ms

--- 192.168.64.201 ping statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.997/1.411/1.958/0.371 ms

## 마찬가지로 Hostest OS에서 ARP 명령을 보내 봤을 때, Mac VLAN, IP VLAN을 모두 확인할 수 있습니다.
## 그러나 IP VLAN은 Mac VLAN과 다르게 Host OS의 Mac 주소와 동일한 Mac 주소를 가지는 것도 확인할 수 있습니다.
$ arp -an
...
? (192.168.64.1) at 9e:76:e:d4:4f:64 on bridge100 ifscope permanent [bridge]
? (192.168.64.3) at 4a:9e:7e:55:59:ee on bridge100 ifscope [bridge]
? (192.168.64.101) at (incomplete) on bridge100 ifscope [bridge]
## IP VLAN
? (192.168.64.201) at 4a:9e:7e:55:59:ee on bridge100 ifscope [bridge]
## Mac VLAN
? (192.168.64.232) at 02:42:c0:a8:40:e8 on bridge100 ifscope [bridge]
...

IP VLAN L3
IP VLAN L3 모드는 Host OS를 마치 Router처럼 이용하여 격리된 네트워크를 제공하는 방식입니다. L3 모드를 이용하기 때문에 L2의 특징을 갖지 않습니다. 즉 ARP 등을 통해 네트워크 주소를 찾을 수 없으며, 서로 다른 네트워크 간 도달할 수 있는 방법이 없습니다.

이는 바꿔말하면 생성한 IP VLAN L3 네트워크에 대한 모든 제어를 직접 관리할 수 있다는 장점이 됩니다.

docker@docker:~$ docker network create -d ipvlan \
  --subnet 192.168.20.0/24 \
  -o parent=enp0s1 -o ipvlan_mode=l3 \
  ip-vlan-l3
  
docker@docker:~$ docker run --rm -d --network ip-vlan-l3 --ip 192.168.20.11 --name busybox busybox sleep 3600
89dec21dff06582e745d54183905b28446cfcd9c0005c99b28c0e9862c54d386
docker@docker:~$ docker run --rm -d --network ip-vlan-l3 --ip 192.168.20.12 --name busybox2 busybox sleep 3600
01ab7b4facc67b2a73c228d99873e88a9692f96e71bb2db46803daf5fe97f622
docker@docker:~$ docker inspect ip-vlan-l3
[
    {
        "Name": "ip-vlan-l3",
        "Id": "fe835a4899fc7d5302b93edb852496908eeab6b6d4661362c346ed89d916d757",
        "Created": "2024-11-21T13:22:00.408568176Z",
        "Scope": "local",
        "Driver": "ipvlan",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "192.168.20.0/24"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "01ab7b4facc67b2a73c228d99873e88a9692f96e71bb2db46803daf5fe97f622": {
                "Name": "busybox2",
                "EndpointID": "6017612d2f7bf47eac2a5eff53a06c785e5e31dc9c2f2cf9f64ebfc93da8c964",
                "MacAddress": "",
                "IPv4Address": "192.168.20.12/24",
                "IPv6Address": ""
            },
            "89dec21dff06582e745d54183905b28446cfcd9c0005c99b28c0e9862c54d386": {
                "Name": "busybox",
                "EndpointID": "d3fe6793ee2b204828ad1e792ec6a2576565214234f5e3f5e0361bf0cd880b88",
                "MacAddress": "",
                "IPv4Address": "192.168.20.11/24",
                "IPv6Address": ""
            }
        },
        "Options": {
            "ipvlan_mode": "l3",
            "parent": "enp0s1"
        },
        "Labels": {}
    }
]

docker@docker:~$ docker exec -it busybox sh
/ # ping 192.168.20.12
PING 192.168.20.12 (192.168.20.12): 56 data bytes
64 bytes from 192.168.20.12: seq=0 ttl=64 time=0.118 ms
64 bytes from 192.168.20.12: seq=1 ttl=64 time=0.133 ms
^C
--- 192.168.20.12 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.118/0.125/0.133 ms

주의할 점은 하나의 Host OS 네트워크 인터페이스에 대해 IP VLAN을 여러 개 배포한다면, 하나의 도커 네트워크 생성 명령에서 여러 IP VLAN 대역을 생성해야 합니다.

docker@docker:~$ docker network create -d ipvlan \
  --subnet 192.168.20.0/24 \
  # 네트워크 추가
  --subnet 192.168.30.0/24 \
  -o parent=enp0s1 -o ipvlan_mode=l3 \
  ip-vlan-l3

마치며

오늘은 도커에서 네트워크를 구성할 수 있는 방식들을 살펴보았습니다. 이를 통해 쿠버네티스에서 해결한 네트워크 문제 중 컨테이너 간 통신에 대해 살펴보았습니다. 이번 컨테이너 간 통신을 통해 다음을 이해하셨으면 합니다.

  • 컨테이너 통신 중 외부 통신을 어떻게 수행하는지
  • 컨테이너마다 네트워크가 어떻게 격리되는지
  • 컨테이너간 어떻게 통신을 수행해야 하는지
  • Network 구성을 어떻게 해야 하는지

끝으로 중간중간 tcpdump 등 패킷을 확인할 수 있는 도구를 이용하여 직접 Host OS의 네트워크 인터페이스와 생성된 가상 네트워크 인터페이스 등을 직접 확인하여 어떻게 동작하는지 확인하신다면 좋을 것 같습니다.

만약 '쿠버네티스가 갑자기 왜 나와?' 라는 생각이 드신다면, 쿠버네티스에서 애플리케이션을 어떻게 배포하는지, 배포된 애플리케이션은 어떤 단위를 가지는지 한번 생각해 보시면 좋겠습니다.


그렇다면 이것도…

  • 같은 도커 Bridge 네트워크 내에서 각각 배포된 컨테이너는 통신이 가능할까요? 그 결과와 이유에 대해 생각해 보시면 좋을 것 같습니다.
  • Mac VLAN, Bridge 모드 모두 Host의 네트워크 인터페이스에 의존합니다. 그 차이가 무엇일까요?
  • Mac VLAN에서 IP를 정의하지 않으면 어떻게 동작할까요? 무엇을 주의해야 할까요? IP를 정의하지 않고 안전하게 IP를 할당하려면 어떻게 해야 할까요?
  • Mac VLAN 네트워크를 이용하는 컨테이너와 Host OS 간 연결이 왜 불가능한가요? 해결책이 있을까요?
  • Mac VLAN 모드에서 80 포트를 노출시키고, Host Bridge 네트워크 모드에서 80 포트를 동시에 노출시킬 수 있나요? 그 결과와 이유는 무엇인가요?
  • IP VLAN L3 모드에서 다른 대역을 가지는 Subnet을 생성한 뒤, 컨테이너를 각각 할당한다면 해당 컨테이너들은 서로 통신 가능한가요? 그 결과와 이유는 무엇인가요?
  • 쿠버네티스의 파드는 어떻게 네트워크가 구성되나요?

참고

https://docs.docker.com/engine/network/drivers/bridge/

https://docs.docker.com/engine/network/drivers/macvlan/

https://docs.docker.com/engine/network/drivers/ipvlan/

 

쿠버네티스에서 우리가 실행하는 애플리케이션은 Pod 단위로 수행되며 Pod는 내부에 하나 이상의 컨테이너를 가지고 있습니다.
이 말은 결국 쿠버네티스에 배포한, 그리고 구성된 시스템은 컨테이너 통신을 기초로 하는 것을 알 수 있습니다.
그렇다면 이 기초적인 컨테이너는 어떻게 네트워크 통신을 수행하는지 도커를 이용하여 컨테이너의 통신을 알아봅시다.

  • 이번 주제인 "컨테이너 간 통신"은 최대 세 개의 글로 작성될 예정입니다.

이전 글 목차
https://dev-whoan.xyz/111, 네트워크로 시작하는 쿠버네티스 — 내가 데이터를 보낸다면


사전 준비물
오늘 사전 준비물은 Ubuntu 22.04 시스템 하나만 있으면 됩니다. 컨테이너 네트워크를 시작하기 전 Network Namespace를 이용하여 실제로 어떻게 네트워크를 격리하는지 확인해 볼 예정입니다.

컨테이너와 Network Namespace

컨테이너는 Union Filesystem(overlayfs), Control Group, 그리고 Namespace를 이용하여 가상화된 환경을 제공합니다.

  1. Union Filesystem을 통해 low layer, upper layer, merged view를 통해 레이어화 된 애플리케이션 실행에 필요한 파일시스템을 제공하고
  2. Namespace를 통해 애플리케이션의 실행 환경을 분리하며
  3. Control Group을 통해 애플리케이션 실행에 필요한 하드웨어 자원을 격리, 제한하여 독립된 환경에서 안정적으로 실행될 수 있도록 하는 가상화 기술입니다.

Namespace에는 Mount Namespace, Control Group Namespace, Network Namespac  Inter Process Communication Namespace, Process ID Namespace, UTS Namespace, ... 등이 존재합니다.

각각의 이름에서 알 수 있듯이 파일 시스템, 컨트롤 그룹, 네트워크 ... 등을 격리하여 컨테이너를 실행했을 때 Host OS와 격리되어 운영할 수 있도록  합니다. 다만, 컨테이너 가상화는 Virtual Machine과는 다르게 그 특성상 Host OS로부터 완전히 독립적일 수는 없습니다.


우리가 흔히 도커, cri-o 등 컨테이너 런타임 플랫폼을 설치하면 네트워크가 새롭게 할당되는 것을 볼 수 있습니다.

일반적으로 우분투 등 운영체제의 설치를 막 마친 뒤 시스템을 켜면 다음과 같은 네트워크 시스템을 확인할 수 있습니다. (NIC의 이름은 다를 수 있습니다.)

docker@docker:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 4a:9e:7e:55:59:ee brd ff:ff:ff:ff:ff:ff
    inet 192.168.64.3/24 metric 100 brd 192.168.64.255 scope global dynamic enp0s1
       valid_lft 1959sec preferred_lft 1959sec
    inet6 fd9e:d858:c06b:b3ed:489e:7eff:fe55:59ee/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 2591937sec preferred_lft 604737sec
    inet6 fe80::489e:7eff:fe55:59ee/64 scope link
       valid_lft forever preferred_lft forever
       
docker@docker:~$ ip netns list # 아무것도 출력되지 않음
docker@docker:~$

1번 lo 인터페이스는 loopback 인터페이스입니다. 127.0.0.1을 가리키는 인터페이스로 자기 자신으로의 통신에 사용됩니다.

우리가 애플리케이션을 띄운 뒤 127.0.0.1, 혹은 alias localhost로 요청을 보낼 때 해당 인터페이스가 처리하게 됩니다.

2번 enp0s1 인터페이스는 시스템이 가지고 있는 물리 인터페이스로 (우리는 가상 머신을 이용 중이기 때문에, 이 또한 가상 인터페이스입니다.) 실제 네트워크 연결이 발생하는 인터페이스입니다. 아이피를 할당받아 실질적인 통신을 담당하는 인터페이스입니다.

이 상태에서 nginx를 설치하고 배포하는 작업을 해 보겠습니다.

docker@docker:~$ sudo apt-get update && sudo apt-get install nginx -y
docker@docker:~$ curl localhost

Welcome to nginx!

If you see this page, the nginx web server is successfully installed and working. Further configuration is required.

For online documentation and support please refer to nginx.org.
Commercial support is available at nginx.com.

Thank you for using nginx.

Network Namespace 추가하기

Host Namespace에서 nginx의 동작을 확인했으니, 이제 A Namespace를 생성하고 해당 위치로 nginx를 옮겨 확인을 해 봅시다. Ubuntu를 기준으로 ip 패키지를 통해 네임스페이스를 생성할 수 있습니다.

### A라는 이름의 network namespace 추가
docker@docker:~$ sudo ip netns add A
docker@docker:~$ ip netns list
A

A Network namespace에서 실질적인 통신을 담당하는 네트워크 인터페이스를 추가하여야 하는데, 일반적으로 다음의 모드를 이용합니다.

격리 모드:
    네트워크 네임스페이스는 아예 독립된 네트워크를 갖습니다.

Host Only 모드:
    네트워크 네임스페이스가 Host와만 통신 가능하도록 설정합니다.

Bridged 모드:
    Host Network Namespace에 브리지 네트워크를 생성하고, 컨테이너는 브리지 네트워크 대역에서 IP 주소를 할당받아 사용됩니다. 외부 네트워크와 통신은 호스트 네트워크를 경유(NAT 등)하여 통신합니다.

격리 모드

Dummy Type을 활용하여 Network Interface Card 생성하기


Dummy Type의 네트워크 인터페이스는 로컬에서 loopback 인터페이스 처럼 동작하는 인터페이스입니다. 즉 로컬에서의 테스트를 하거나, 격리된 환경에서 동작하는 기능을 제공합니다. 생성은 다음과 같이 수행할 수 있습니다.

docker@docker:~$ sudo ip link add dummy0 type dummy

# dummy0의 network namespace를 A로 설정합니다.
docker@docker:~$ sudo ip link set dummy0 netns A

# A 네임스페이스의 ip 링크, 주소를 확인합니다.
docker@docker:~$ sudo ip netns exec A ip link show
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
3: dummy0: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 7e:8e:29:d3:31:94 brd ff:ff:ff:ff:ff:ff
    
docker@docker:~$ sudo ip netns exec A ip addr show
1: lo: <LOOPBACK> mtu 65536 qdisc noqueue state DOWN 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
3: dummy0: <BROADCAST,NOARP> mtu 1500 qdisc noqueue state DOWN group default qlen 1000
    link/ether 7e:8e:29:d3:31:94 brd ff:ff:ff:ff:ff:ff
    
# Host Network Namespace에서는 찾을 수 없는 것을 확인할 수 있습니다.
docker@docker:~$ ip link show dummy0
Device "dummy0" does not exist.

위에서 출력해 봤을 때 dummy0은 아직 아이피 주소를 가지고 있지 않기 때문에, 실질적인 통신은 불가능한 상태입니다. 이를 위해 IP를 할당하고, dummy0을 시작해 보겠습니다.

docker@docker:~$ sudo ip netns exec A ip addr add 192.168.222.10/24 dev dummy0
docker@docker:~$ sudo ip netns exec A ip link set dummy0 up
docker@docker:~$ sudo ip netns exec A ip link set lo up
docker@docker:~$ sudo ip netns exec A ip addr show
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
3: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000
    link/ether 7e:8e:29:d3:31:94 brd ff:ff:ff:ff:ff:ff
    inet 192.168.222.10/24 scope global dummy0
       valid_lft forever preferred_lft forever
    inet6 fe80::7c8e:29ff:fed3:3194/64 scope link
       valid_lft forever preferred_lft forever

 

docker@docker:~$ sudo apt-get update && sudo apt-get install nginx -y
docker@docker:~$ curl localhost
<!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>
docker@docker:~$ sudo ip netns exec A curl 192.168.64.3
curl: (7) Couldn't connect to server

Host Network Namespace에서 요청을 보낼 경우 nginx와 통신이 가능하지만, A 네임스페이스에서 요청을 보내면 통신이 불가능한 상태임을 확인할 수 있습니다.
마찬가지로 A Namespace에 nginx를 배포하고 Host Network Namespace에서 시도해 보아도 접근이 불가능한 것을 확인할 수 있습니다.

docker@docker:~$ sudo ip netns exec A nginx
docker@docker:~$ curl 192.168.222.10
curl: (7) Failed to connect to 192.168.222.10 port 80 after 0 ms: Connection refused

Hosted Only 네트워크

보통 Ubuntu에서 Hosted Only 네트워크 네임스페이스의 통신을 위한 경우 veth pair를 이용합니다. veth pair의 경우 설정이 간단하며, Pair (veth0, veth1의 쌍)으로 존재하여 연결하고자 하는 네임스페이스에 각각 할당하여 통신이 가능하게 바로 설정되기 때문입니다.

### veth pair를 생성합니다. 각각의 이름은 veth0, veth1 입니다.
docker@docker:~$ sudo ip link add veth0 type veth peer name veth1

## veth1의 네트워크 네임스페이스를 A로 설정합니다.
docker@docker:~$ sudo ip link set veth1 netns A

## veth0에 ip를 할당합니다.
docker@docker:~$ sudo ip addr add 192.168.202.1/24 dev veth0
docker@docker:~$ sudo ip link set veth0 up
docker@docker:~$ 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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 4a:9e:7e:55:59:ee brd ff:ff:ff:ff:ff:ff
    inet 192.168.64.3/24 metric 100 brd 192.168.64.255 scope global dynamic enp0s1
       valid_lft 1835sec preferred_lft 1835sec
    inet6 fd9e:d858:c06b:b3ed:489e:7eff:fe55:59ee/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 2591955sec preferred_lft 604755sec
    inet6 fe80::489e:7eff:fe55:59ee/64 scope link
       valid_lft forever preferred_lft forever
7: veth0@if6: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state LOWERLAYERDOWN group default qlen 1000
    link/ether ea:08:dd:65:53:66 brd ff:ff:ff:ff:ff:ff link-netns A
    inet 192.168.202.1/24 scope global veth0
       valid_lft forever preferred_lft forever
## veth1에 ip를 할당합니다. 
docker@docker:~$ sudo ip netns exec A ip addr add 192.168.202.11/24 dev veth1
docker@docker:~$ sudo ip netns exec A ip link set veth1 up
docker@docker:~$ sudo ip netns exec A ip addr show
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
6: veth1@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether fe:1c:38:ae:01:07 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 192.168.202.11/24 scope global veth1
       valid_lft forever preferred_lft forever
    inet6 fe80::fc1c:38ff:feae:107/64 scope link
       valid_lft forever preferred_lft forever
      
## 통신이 원활히 되는 것을 확인할 수 있습니다.
docker@docker:~$ ping 192.168.202.11
PING 192.168.202.11 (192.168.202.11) 56(84) bytes of data.
64 bytes from 192.168.202.11: icmp_seq=1 ttl=64 time=0.056 ms
64 bytes from 192.168.202.11: icmp_seq=2 ttl=64 time=0.191 ms
^C
--- 192.168.202.11 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1013ms
rtt min/avg/max/mdev = 0.056/0.123/0.191/0.067 ms
docker@docker:~$ sudo ip netns exec A ping 192.168.202.1
PING 192.168.202.1 (192.168.202.1) 56(84) bytes of data.
64 bytes from 192.168.202.1: icmp_seq=1 ttl=64 time=0.027 ms
64 bytes from 192.168.202.1: icmp_seq=2 ttl=64 time=0.137 ms
^C
--- 192.168.202.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1006ms
rtt min/avg/max/mdev = 0.027/0.082/0.137/0.055 ms

 

그렇다면 A 네임스페이스의 veth1에서 인터넷으로 요청을 보낼 수 있을까요? 잠시 멈추시고, 아래의 이유를 생각해 보시길 바랍니다.

### Host Naemspace에서 google로 요청을 보낼 수 있지만
docker@docker:~$ curl google.com
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="http://www.google.com/">here</A>.
</BODY></HTML>
### A Namespace에서 외부로 요청을 보낼 수 없습니다.
docker@docker:~$ sudo ip netns exec A ping -c 4 8.8.8.8
ping: connect: Network is unreachable

### 마찬가지로 google로 요청을 보낼 수 없습니다.
docker@docker:~$ sudo ip netns exec A curl google.com
curl: (6) Could not resolve host: google.com

veth pair는 namespace간 연결을 자동으로 수행해 주기 때문에, 우리는 곧바로 host os에 배포된 nginx에 요청을 보낼 수 있습니다.

### A Namespace -> veth0 OK
docker@docker:~$ sudo ip netns exec A curl 192.168.202.1
<!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>
### Host OS 테스트
docker@docker:~$ curl localhost
<!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>

아래의 경우에 대해 이유를 생각해 보시면 좋을 것 같습니다.

docker@docker:~$ sudo ip netns exec A curl 192.168.64.3
curl: (7) Couldn't connect to server

Bridged 모드

Hosted Only 모드가 호스트와 가상 네임스페이스간의 통신만을 허용했다면, Bridged 모드는 Host에 배포된 NIC에 탑승하여 인터넷 통신이 가능하도록 합니다.
이를 위해 마찬가지로 veth pair를 생성하고, Host에 존재하는 네트워크에 탑승하도록 설정하겠습니다.

## Host OS에서 IP Forwarding이 가능하도록 설정합니다.
docker@docker:~$ sudo sysctl -w net.ipv4.ip_forward=1

## Bridge 타입의 인터페이스를 생성하고, 아이피를 할당합니다.
docker@docker:~$ sudo ip link add br0 type bridge
docker@docker:~$ sudo ip addr add 192.168.64.100/24 dev br0
docker@docker:~$ sudo ip link set br0 up

## enp0s1의 주 네트워크 장치는 br0임을 설정합니다.
docker@docker:~$ sudo ip link set enp0s1 master br0
docker@docker:~$ sudo ip link set enp0s1 up

docker@docker:~$ sudo ip link add veth0 type veth peer name veth1

docker@docker:~$ sudo ip link set veth1 netns A

## veth0의 주 네트워크 장치는 br0임을 설정합니다.
## 이를 통해 veth0이 br0을 통해 다른 네트워크 인터페이스와 통신할 수 있습니다.
## 바꿔 말하면, veth0의 쌍인 veth1이 br0을 통해 외부와 통신할 수 있음을 의미합니다.
docker@docker:~$ sudo ip link set veth0 master br0
docker@docker:~$ sudo ip link set veth0 up

## veth1에 아이피를 할당하고, 시작합니다.
docker@docker:~$ sudo ip netns exec A ip addr add 192.168.64.50/24 dev veth1
docker@docker:~$ sudo ip netns exec A ip link set veth1 up
docker@docker:~$ sudo ip netns exec A ip link set lo up

## veth1이 default 라우팅 (0.0.0.0/0)에 대한 next hop으로 192.168.64.100을 지정합니다.
docker@docker:~$ sudo ip netns exec A ip route add default via 192.168.64.100

docker@docker:~$ sudo ip netns exec A 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=56 time=42.4 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=56 time=41.0 ms
...

끝으로 ip link set enp0s1 master br0에 대한 명령어에 대한 이해를 해 보시기 바랍니다.


마치며

오늘의 “컨테이너 통신, Network Namespace” 글은 컨테이너 네트워크를 이해하기 위한 네트워크 격리를 어떻게 수행해야 하는지 기본적인 부분을 다루었습니다. 우리가 Dockerfile을 작성하는 등 network를 정의할 때, 각 모드가 어떻게 동작되고 네트워크를 정의할 경우 다른 컨테이너와의 통신을 어떻게 구성해야 하는지 조금이나마 이해에 도움이 되었으면 합니다.


그렇다면 이것도…

  • Hosted Only 모드에서 A Namespace에 존재하는 네트워크는 왜 외부로 요청을 보낼 수 없나요?
  • Bridged 모드에서 Host 외부의 시스템과 telent을 이용해 메시지를 주고받으려면 어떻게 해야 하나요?
  • Hosted Only와 Bridged 모드는 어떤 때 구분해서 사용해야 할까요?
  • 네트워크 격리는 어떤 때 수행해야 할까요?
  • 아래로 진행될 수록 코드가 많아졌는데, 설명할 수 있나요?

시작하기에 앞서

네트워크는 현대 사회에서 빼놓고 살아갈 수 없는 매우 중요한 인프라이자 시스템입니다. 통신이라는 이름으로 네트워크를 바라본다면 인터넷 통신 뿐만 아니라 음성 통신도 포함됩니다. 음성 통신이라는 단어가 나와서 당황하셨을 수도 있겠습니다만, 한 명의 개발자로서 혹은 관련된 직무에서 일을 하고 있는 프로로서 음성 통신을 처음 들어보셨다면 네트워크의 역사에 대해 알아보시는 것도 좋을것 같습니다.

우리가 전화기를 이용하여 상대방과 음성 통신을 하는 것을 떠올려 보면 다음과 같은 구조가 될것입니다.

우측 전화국 사진 출처: https://ko.wikipedia.org/wiki/전화교환원

이 때의 방식을 회선 교환(Circuit Switching) 방식이라 부르는데, 이미 지정된 경로를 통해 통신을 수행하게 됩니다.


송신자의 종단 즈음에서 아날로그 → 디지털로 전환된 “음성 데이터”가 기지국을 거쳐 수신자의 종단 즈음에서 디지털 → 아날로그로 전환되어 목소리를 들을 수 있게 되는거죠.


어릴 적 부모님께 전화를 걸 때, 부모님께서 이미 통화중이라면 “뚜.. 뚜.. 뚜..” 혹은 이미 통화중이라는 기계 음성을 들으신 적 있으실겁니다. (지금도 VoIP를 사용하지 않는 환경에서는 회선 교환 방식으로 통신하는 것으로 알고 있습니다.)
회선 교환 방식은 다음과 같은 특징을 가지고 있기 때문에 목표하는 회선이 이미 점유되어 있기 때문입니다.

  • 통신을 시작하기 전 두 기기간 물리적 회선을 할당합니다.
  • 한번 회선이 할당되면, 보낼 수 있는 최고 속도를 이용합니다.
  • 물리적으로 할당된 회선을 이용하기 때문에 데이터 로스 등이 발생할 확률이 적습니다.
  • 할당된 회선만을 이용하기 때문에 비교적 보안성이 높습니다.

현대 사회에는 회선 교환 방식은 패킷 교환 방식으로 대체되었습니다. 그 이유는 회선 교환 방식의 특징이자 한계점으로 인해서인데, 다음과 같습니다.

  • 모든 회선이 사용중이라면 통신이 불가능합니다.
  • 전체 회선의 성능을 나누어 사용합니다.
    • 할당만 되어 있더라도 마찬가지입니다.
  • 물리적 회선에 대한 비용이 비쌉니다.
  • 데이터 전송에 필요한 연결 수립이 필요합니다.

패킷 교환 방식은 다음의 특징을 가지고 있습니다.

  • 논리적 회선을 이용하기 때문에 망의 경로에 대해 생각할 필요 없습니다.
  • 모든 회선이 사용중이어도 통신이 가능합니다.
  • 데이터를 패킷(Packet)이라 불리는 작은 조각의 열로 전송합니다.
  • 각 패킷은 목적지 까지 어떤 경로(somehow)를 따라 hop by hop으로 이동합니다.
  • 패킷을 받은 중간 지점은 패킷이 모두 도착하기를 기다렸다가 전송하며, Fire and Forget 방식을 이용합니다.

이 외의 회선 교환, 패킷 교환 방식은 본 시리즈의 목적이 아니기 때문에 여기까지만 서술하겠습니다.


본 시리즈는 최종적으로 “쿠버네티스에서 네트워크 통신이 어떻게 이루어지는가에 대해 자세히 설명할 수 있다.” 목적을 가지고 있습니다. 이에 대해 다음의 주제로 나누어 글을 작성하려 합니다. 만약 아래 항목에 대해 이미 충분히 잘 알고 계시다면, 이 시리즈를 읽지 않으셔도 됩니다.

  • 내가 데이터를 보낸다면 어떻게 수신자에게 전달될까?
    • 네트워크 경로가 설정되고, 약속된 프로토콜을 통해 통신할 수 있다.
  • 컨테이너 간 통신은 어떻게 이루어질까?
    • Pod와 Localhost 통신으로 해결한다. 이는 Docker Network의 예시와 같다.
  • Pod와 서비스 간 통신은 어떻게 이루어질까?
    • Service Object를 이용하여 해결한다. 이는 kube-proxy를 통해 달성한다.
  • 서비스와 클러스터 외부 간 통신은 어떻게 이루어질까?
    • Service Object와 로드밸런서 등을 이용하여 해결한다.
  • Pod 간 통신은 어떻게 이루어질까?
    • Network Addons을 이용해 해결한다. 이는 우리가 흔히 알고 있는 Calico, Flannel, Cilium 등 CNI가 있다.

내가 데이터를 보낸다면

사전 준비물
두 대의 시스템간 통신을 바탕으로 데이터 송/수신을 해보겠습니다. 오늘 통신에 필요한 사전 준비물은 다음과 같습니다.
3 * Ubuntu 22.04 시스템

  • 라우터 역할 시스템
    • 종단 역할간 통신을 할 수 있도록 라우터 역할을 합니다.
    • 라우터는 초기 네트워크가 구축되지 않았다는 가정으로, 네트워크를 할당하지 않습니다.
  • 종단 역할 시스템 * 2
    • 실제 통신을 수행하는 시스템을 가정합니다.
    • 각 시스템은 서로 다른 네트워크 대역에 존재해야 하며, Hosted Only 네트워크를 할당받아야 합니다.
  • 저의 경우 각 시스템에 vCPU 1개, 2GB Ram을 할당했으며, 각 시스템은 오직 Host OS와 통신이 가능한 상태입니다.

Router

router@router:~$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
22/tcp (v6)                ALLOW       Anywhere (v6)

router@router:~$ ip a
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
       
router@router:~$ ping 192.168.100.2
ping: connect: Network is unreachable

router@router:~$ ping 192.168.150.2
ping: connect: Network is unreachable

Network 01

network@network01:~$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
22/tcp (v6)                ALLOW       Anywhere (v6)

network@network01:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 5a:e9:ac:ac:e1:ef brd ff:ff:ff:ff:ff:ff
    inet 192.168.150.2/24 metric 100 brd 192.168.150.255 scope global dynamic enp0s1
       valid_lft 3099sec preferred_lft 3099sec
    inet6 fe80::58e9:acff:feac:e1ef/64 scope link
       valid_lft forever preferred_lft forever
       
network@network01:~$ ping 192.168.100.2
ping: connect: Network is unreachable

Network 02

network@network02:~$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
22/tcp (v6)                ALLOW       Anywhere (v6)

network@network02:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 4e:4d:cb:7a:01:74 brd ff:ff:ff:ff:ff:ff
    inet 192.168.100.2/24 metric 100 brd 192.168.100.255 scope global dynamic enp0s1
       valid_lft 3096sec preferred_lft 3096sec
    inet6 fe80::4c4d:cbff:fe7a:174/64 scope link
       valid_lft forever preferred_lft forever
       
network@network02:~$ ping 192.168.150.2
ping: connect: Network is unreachable

지금의 상태를 토폴로지로 나타내면 다음과 같습니다.

  • 위 토폴로지의 세계상 아직 네트워크 구축이 되지 않았기 때문에, 라우터는 존재조차 하지 않는 가정으로 시작합니다.
  • Network 01과 Network 02는 지역적으로 떨어져 있는 곳에 존재함을 가정합니다.

위 토폴로지를 바탕으로 한 오늘의 목표는 다음과 같습니다.

  • Network 01 — 네트워크 — Network 02 상호간 ping 을 보낼 수 있다.
  • Network 01 — 네트워크 — Network 02 상호간 임의의 포트를 통해 메시지를 주고 받을 수 있다.
  • 클라우드 환경에서 VPC 내부의 서브넷간 통신 장애가 발생할 때 기본적인 네트워크 검사를 수행할 수 있다.
  • 클라우드 환경에서 라우팅 기반의 서브넷 통신을 정의할 수 있다.
  • 클라우드 환경에서 보안 그룹을 통해 허가된 통신만 수행할 수 있다.

왜 최초에는 Ping이 안될까?

최초 Router, Network 01, Network 02의 시스템 상에서 서로 Ping을 보내보면 Network not reachable 에러가 발생하는 것을 볼 수 있습니다. 이를 비유하기 위해 우리집 근처에 식당 A가 다음과 같이 위치한다고 가정합시다.

우리는 우리 집이 어디있는지 알고, 식당 A에 가기 위해서는 위로 도로를 건넌 뒤, 우측으로 도로를 두 번 건너면 된다는 것을 알고 있습니다.

이게 가능한 이유는 도로의 건널목을 “지표” 삼아서 식당 A로 가기 위해 건너야 하는 횡단 보도를 이미 알고 있기 때문입니다. 마찬가지로 다음과 같이 우측으로 한번 건넌 뒤, 위로 한 번 건너도 됩니다.

즉, 우리는 식당 A에 가기 위한 경로를 충분히 알고 있고, 도로를 건너는 행위를 기준으로 다음 목적지는 어디인지 알고 있는 상태입니다. 우리는 건널목 신호등의 상태 등을 감안하여 경로를 선택하면 되는겁니다. 감이 오셨을까요?
여기서 도로는 라우터이고, 다음 목적지는 어디로 가야하는지 알고 있는 행위가 바로 경로 설정입니다. 즉 라우터를 기준으로 라우팅 테이블은 작성되어 있어야 하며 이를 바탕으로 방향을 알고 길을 건널 수 있게 됩니다.

  • 도로 건널목은 네트워크 경로 상에서 “hop”으로 표현되기도 하며, 목적지에 도달하기 위해 이러한 건널목을 지나는 행위는 hop by hop으로 표현되기도 합니다.
  • 목적지 까지 여러 경로가 존재하기 때문에, 결국 어떤 방법을 통해 도달할 수 있게 됩니다. 영어로는 eventually, somehow, data will reach to the destination. 이라고도 표현합니다.

우리의 시스템으로 돌아오면, 아직 그 어느 경로도 설정되어 있지 않은것을 볼 수 있습니다.

도로는 Network 01 과 Network 02 사이에 바로 연결될 수도 있고, Network 01 — Router — Network 02 사이에 연결될 수 있습니다.
그러나 우리는 가정상, 그리고 실제 네트워크 상에서 종단간 시스템이 직접 연결되는 사례는 극히 드물기에, 이 경우는 제외하겠습니다.

경로 설정

이제 경로 설정을 수행해 보겠습니다. 우리는 현재 서로 분리되어 있는 두 시스템을 연결하는 도로를 설치해야 하는 상태입니다.
잠시 여기서 멈추시어 실생활에서 여러분은 이러한 도로의 역할로 어떤 매개체를 이용하고 있는지 한번 생각해 보시길 바랍니다.
보통 우리는 스마트폰, 노트북 등을 네트워크에 연결하기 위해 무선 매개체인 와이파이를 흔히 이용하고, 집 혹은 회사에 있는 데스크톱을 네트워크에 연결하기 위해 유선 매개체인 LAN 선을 이용하곤 합니다.

  • 와이파이 등 무선 인프라의 경우에도 언젠가는 Access Point를 통해 유선 장비에 연결되어 데이터 통신을 수행합니다.

자, 드디어 경로 설정을 수행할 차례입니다. 우리는 Router와 Network 01을 연결하는 도로가 필요하며, Router에서 Network 02를 연결하는 도로가 필요합니다.

router@router:~$ ip a
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: enp0s1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 3a:9e:6f:dc:26:7b brd ff:ff:ff:ff:ff:ff
    inet 192.168.150.3/24 metric 100 brd 192.168.150.255 scope global dynamic enp0s1
       valid_lft 3587sec preferred_lft 3587sec
    inet6 fe80::389e:6fff:fedc:267b/64 scope link
       valid_lft forever preferred_lft forever
3: enp0s2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether ca:76:d5:f7:6a:85 brd ff:ff:ff:ff:ff:ff
    inet 192.168.100.3/24 metric 100 brd 192.168.100.255 scope global dynamic enp0s2
       valid_lft 3587sec preferred_lft 3587sec
    inet6 fe80::c876:d5ff:fef7:6a85/64 scope link
       valid_lft forever preferred_lft forever

도로가 정상적으로 연결되어 있는지 Router ←→ Network 01, 02를 수행해 보겠습니다.
Router ←→ Network 01

# Router -> Network 01 OK       
router@router:~$ ping 192.168.150.2 -c 1
PING 192.168.150.2 (192.168.150.2) 56(84) bytes of data.
64 bytes from 192.168.150.2: icmp_seq=1 ttl=64 time=0.787 ms

--- 192.168.150.2 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.787/0.787/0.787/0.000 ms

# Network 01 -> Router OK
network@network01:~$ ping 192.168.150.3 -c 1
PING 192.168.150.3 (192.168.150.3) 56(84) bytes of data.
64 bytes from 192.168.150.3: icmp_seq=1 ttl=64 time=1.54 ms

--- 192.168.150.3 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 1.541/1.541/1.541/0.000 ms

Router ←→ Network 02

# Router -> Network 02 OK
router@router:~$ ping 192.168.100.2 -c 1
PING 192.168.100.2 (192.168.100.2) 56(84) bytes of data.
64 bytes from 192.168.100.2: icmp_seq=1 ttl=64 time=2.86 ms

--- 192.168.100.2 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 2.861/2.861/2.861/0.000 ms

# Network 02 -> Router OK
network@network02:~$ ping 192.168.100.3 -c 1
PING 192.168.100.3 (192.168.100.3) 56(84) bytes of data.
64 bytes from 192.168.100.3: icmp_seq=1 ttl=64 time=18.7 ms

--- 192.168.100.3 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 18.722/18.722/18.722/0.000 ms

생긴것만 보아하면, Network 01과 Network 02는 통신이 가능할 것 같습니다. 그러나 실질적으로 통신은 수행되지 않습니다.

# Network 01 -> Network 02 Fail
network@network01:~$ ping 192.168.100.2
ping: connect: Network is unreachable

# Network 02 -> Network 01 Fail
network@network02:~$ ping 192.168.150.2
ping: connect: Network is unreachable

그 이유는 Network 01은 Router 까지 가는 방법만 알고, Network 02 또한 Router 까지 가는 방법만 알고 있기 때문입니다. 예상하신바와 같이 Network 01 → Router → Network 02를 수행하고, 마찬가지로 Network 02 → Router → Network 01을 수행하면 됩니다.
하지만 상대는 컴퓨터기 때문에, 이러한 경로를 직접 알려줘야 합니다. 이러한 경로 설정은 ubuntu 22.04 기준 ip 패키지를 이용할 수 있는데, 명령어는 다음과 같습니다.

# route 확인
$ ip route

## Network 01의 기존 Route
network@network01:~$ ip route
192.168.150.0/24 dev enp0s1 proto kernel scope link src 192.168.150.2 metric 100

## Network 02의 기존 Route 
network@network02:~$ ip route
192.168.100.0/24 dev enp0s1 proto kernel scope link src 192.168.100.2 metric 100

# route 추가
$ sudo ip route add DESTINATION via HOP

# route 제거
$ sudo ip route del DESTINATION
  • Destination은 Subnet으로 설정하는 것이 좋습니다.

한번 Network 01에서 Network 02로 가기 위한 Hop을 Router로 설정해 보고 테스트 해보겠습니다. 그러나 예상과는 다르게 아직 Network 01에서 Network 02로 Ping을 보내는 것이 불가능한 상태입니다.

# sudo ip route add Network 02 대역 via Router
$ sudo ip route add 192.168.100.0/24 via 192.168.150.3

$ ping 192.168.100.2 -c 10
PING 192.168.100.2 (192.168.100.2) 56(84) bytes of data.

--- 192.168.100.2 ping statistics ---
10 packets transmitted, 0 received, 100% packet loss, time 9203m

흔히 네트워크를 공부할때 프로토콜(규약)은 사람의 인사로 비유 되곤 합니다. 사람간 대화를 시작하기 위해서, 혹은 인사를 할 때 누군가 말을 건네면 응답해 주는것이 일반적인 상황입니다. 그러나 우측과 같이 인사를 무시하는 경우도 발생할 수 있습니다. 영희가 바빠서 듣지 못했든, 이어폰을 끼고 있든, 의도적으로 무시했든, 철수는 상대방이 대화할 수 없는, 인사를 받을 수 없는 상황으로 인지할것입니다. 이 경우 철수는 다시 인사를 시도하거나, 영희에게 더 이상 말을 걸지 않을것입니다.

 
현재 우리 시스템에서 Network 01 → Network 02로 Ping을 보낼 때, 아래와 같이 영희가 응답하지 않는 상황입니다. 확인을 위해 Shell을 두 개 켠 뒤 tcpdump를 통해 확인해보면, 다음과 같이 모두 Network 02의 아이피로 request만 전달되고, reply가 오지 않는 것을 확인할 수 있습니다.

### Network 01 Shell 1
network@network01:~$ ping 192.168.100.2 -c 10
PING 192.168.100.2 (192.168.100.2) 56(84) bytes of data.

--- 192.168.100.2 ping statistics ---
10 packets transmitted, 0 received, 100% packet loss, time 9217ms

### Network 01 Shell 2
network@network01:~$ sudo tcpdump -e -i enp0s1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on enp0s1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
02:14:07.734171 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 1, length 64
02:14:08.761944 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 2, length 64
02:14:09.786101 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 3, length 64
02:14:10.810160 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 4, length 64
02:14:11.833783 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 5, length 64
02:14:12.858743 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 6, length 64
02:14:13.882743 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 7, length 64
02:14:14.905286 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 8, length 64
02:14:15.931328 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 9, length 64
02:14:16.951320 5a:e9:ac:ac:e1:ef (oui Unknown) > 3a:9e:6f:dc:26:7b (oui Unknown), ethertype IPv4 (0x0800), length 98: network01 > 192.168.100.2: ICMP echo request, id 3, seq 10, length 64

이는 Network 02에서 확인해 볼 때도 마찬가지 입니다. 들어오는 request는 보이지만, reply는 보이지 않습니다.

network@network02:~$ sudo tcpdump -e -i enp0s1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on enp0s1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
02:22:25.253982 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 1, length 64
02:22:26.257601 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 2, length 64
02:22:27.284460 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 3, length 64
02:22:28.307913 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 4, length 64
02:22:29.330388 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 5, length 64
02:22:30.356599 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 6, length 64
02:22:31.378002 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 7, length 64
02:22:32.403816 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 8, length 64
02:22:33.426113 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 9, length 64
02:22:34.455507 ca:76:d5:f7:6a:85 (oui Unknown) > 4e:4d:cb:7a:01:74 (oui Unknown), ethertype IPv4 (0x0800), length 98: 192.168.150.2 > network02: ICMP echo request, id 11, seq 10, length 64

10 packets captured
10 packets received by filter
0 packets dropped by kernel

한번 직접 Router와 통신해 보시고 결과가 어떤지, 그리고 그 이유가 무엇인지 생각해 보시면 좋을것 같습니다.
Network간 통신이 불가능했던 이유는 우여곡절 끝에 192.168.100.0/24 의 이름을 가진 도로명을 알아냈지만, 다시 집으로 돌아가는 길을 잊었기 때문입니다.

  • 혹은 192.168.100.0/24 도로를 알아낸 뒤, 식당에 도착했지만 알고보니 국경선을 무단으로 넘은 상태여서 집으로 돌아오지 못하는 상태일수도 있겠습니다.

즉 우리는 이를 위해 Network 01에서 한 바와 같이 Network 02 → Network 01의 경로를 설정해야 합니다. 이제 Network 01로 돌아가서 Network 02로 정상적으로 ping이 보내지는 것을 확인할 수 있습니다.

network@network02:~$ sudo ip route add 192.168.150.0/24 via 192.168.100.3
network@network02:~$ ip route
192.168.100.0/24 dev enp0s1 proto kernel scope link src 192.168.100.2 metric 100
192.168.150.0/24 via 192.168.100.3 dev enp0s1

# Network 01 -> Network 02 OK
network@network01:~$ ping 192.168.100.2 -c 4
PING 192.168.100.2 (192.168.100.2) 56(84) bytes of data.
64 bytes from 192.168.100.2: icmp_seq=1 ttl=63 time=10.0 ms
64 bytes from 192.168.100.2: icmp_seq=2 ttl=63 time=7.68 ms
64 bytes from 192.168.100.2: icmp_seq=3 ttl=63 time=2.61 ms
64 bytes from 192.168.100.2: icmp_seq=4 ttl=63 time=5.04 ms

--- 192.168.100.2 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3011ms
rtt min/avg/max/mdev = 2.612/6.344/10.047/2.788 ms

# Network 02 -> Network 01 OK
network@network02:~$ ping 192.168.150.2 -c 4
PING 192.168.150.2 (192.168.150.2) 56(84) bytes of data.
64 bytes from 192.168.150.2: icmp_seq=1 ttl=63 time=1.45 ms
64 bytes from 192.168.150.2: icmp_seq=2 ttl=63 time=5.22 ms
64 bytes from 192.168.150.2: icmp_seq=3 ttl=63 time=2.65 ms
64 bytes from 192.168.150.2: icmp_seq=4 ttl=63 time=3.90 ms

--- 192.168.150.2 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3014ms
rtt min/avg/max/mdev = 1.454/3.305/5.219/1.403 ms

경로 설정을 위해 만든 Router가 정상적으로 동작하는지 확인하고 싶으시다면 tcpdump를 이용하여 확인 가능합니다.

## enp0s1은 Router의 192.168.150.3/24를 가지는 NIC 입니다.
## enp0s2를 확인해 보아도 이와 비슷한 결과가 나옵니다.
router@router:~$ sudo tcpdump -i enp0s1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on enp0s1, link-type EN10MB (Ethernet), snapshot length 262144 bytes

# Network 01 -> Network 02
14:52:46.193749 IP 192.168.150.2 > 192.168.100.2: ICMP echo request, id 41, seq 1, length 64
14:52:46.194851 IP 192.168.100.2 > 192.168.150.2: ICMP echo reply, id 41, seq 1, length 64
14:52:47.195687 IP 192.168.150.2 > 192.168.100.2: ICMP echo request, id 41, seq 2, length 64
14:52:47.198908 IP 192.168.100.2 > 192.168.150.2: ICMP echo reply, id 41, seq 2, length 64
14:52:48.200837 IP 192.168.150.2 > 192.168.100.2: ICMP echo request, id 41, seq 3, length 64
14:52:48.202546 IP 192.168.100.2 > 192.168.150.2: ICMP echo reply, id 41, seq 3, length 64
14:52:49.203013 IP 192.168.150.2 > 192.168.100.2: ICMP echo request, id 41, seq 4, length 64
14:52:49.204313 IP 192.168.100.2 > 192.168.150.2: ICMP echo reply, id 41, seq 4, length 64

# Network 02 -> Network 01
14:53:03.654250 IP 192.168.100.2 > 192.168.150.2: ICMP echo request, id 5, seq 1, length 64
14:53:03.655086 IP 192.168.150.2 > 192.168.100.2: ICMP echo reply, id 5, seq 1, length 64
14:53:04.639904 IP 192.168.100.2 > 192.168.150.2: ICMP echo request, id 5, seq 2, length 64
14:53:04.641619 IP 192.168.150.2 > 192.168.100.2: ICMP echo reply, id 5, seq 2, length 64
14:53:05.644196 IP 192.168.100.2 > 192.168.150.2: ICMP echo request, id 5, seq 3, length 64
14:53:05.645515 IP 192.168.150.2 > 192.168.100.2: ICMP echo reply, id 5, seq 3, length 64
14:53:06.648683 IP 192.168.100.2 > 192.168.150.2: ICMP echo request, id 5, seq 4, length 64
14:53:06.652468 IP 192.168.150.2 > 192.168.100.2: ICMP echo reply, id 5, seq 4, length 64

허가된 통신

우리는 이제 VIP만 들어갈 수 있는 식당을 가보고자 합니다. VIP 식당은 보디가드를 통해서 선별된 인원만 접근이 가능하도록 하고 있습니다. 네트워크 통신에서는 방화벽을 통해 이 보디가드의 역할을 수행할 수 있습니다.
우리는 메시지 송수신을 위해 telnet(telecommunications network)과 nc(netcat) 를 이용할 예정입니다. nc를 이용하여 네트워크에 들어오는 메시지를 읽고, telnet을 이용하여 메시지를 보낼 예정입니다.
Router는 라우터 역할을 위해 Network 01 ←→ Network 02간 패킷을 모두 Forwarding 하는 것을 전제로 합니다.

## FORWARD 룰을 추가합니다.
## enp0s1으로 들어오는 192.168.150.0/24 대역의 송신지를 enp0s2의 192.168.100.0/24로 전달합니다.
router@router:~$ sudo iptables -A FORWARD -i enp0s1 -o enp0s2 -s 192.168.150.0/24 -d 192.168.100.0/24 -j ACCEPT
router@router:~$ sudo iptables -A FORWARD -i enp0s2 -o enp0s1 -s 192.168.100.0/24 -d 192.168.150.0/24 -j ACCEPT

최초 특별한 설정 없이 nc와 telnet을 이용하여 연결을 시도해보면, 통신이 불가능한 것을 볼 수 있습니다.

## 수신자, Network 02
network@network02:~$ nc --listen 12345
# 메시지 대기중

## 송신자, Network 01
network@network01:~$ telnet 192.168.100.2 12345
Trying 192.168.100.2...
# 연결 대기중...

이는 Network 02의 방화벽은 12345 포트를 통한 데이터 통신이 허용되지 않았기 때문입니다. 방화벽에 추가하고 다시 한번 통신을 시도하면 정상적으로 메시지 송/수신이 가능한 것을 확인할 수 있습니다.

network@network02:~$ sudo ufw allow 12345/tcp
Rule added
Rule added (v6)
network@network02:~$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
12345/tcp                  ALLOW       Anywhere
22/tcp (v6)                ALLOW       Anywhere (v6)
12345/tcp (v6)             ALLOW       Anywhere (v6)

network@network02:~$ nc -l 12345

---
network@network01:~$ telnet 192.168.100.2 12345
Trying 192.168.100.2...
Connected to 192.168.100.2.
Escape character is '^]'.

마지막으로 메시지를 주고 받고, 오늘의 글을 마무리 하겠습니다.


마치며

오늘의 “내가 데이터를 보낸다면 어떻게 수신자에게 전달될까?” 글은 정말 기본적이면서도 필수로 알아야 하는 사항만을 다루었습니다. 시작할 때 짧게 서술한 음성 통신과 데이터 통신의 관계부터 하나의 라우터로 알아보는 시스템 통신에 필요한 경로설정, 그리고 방화벽을 통한 최소한의 보안 설정까지.


여기 까지 처음 보는 내용이나 몰랐던 부분이 있으셨다면, 해당 부분을 바탕으로 꼭 정리를 하셨으면 합니다. 오늘 본 부분은 기초이자 꼭 알아야 하는 부분이며, 앞으로 이어나갈 쿠버네티스의 네트워크에 있어서 굉장히 중요한 부분이기 때문입니다.


그렇다면 이것도…

  • 클라우드 환경에서 네트워크 통신을 수행하기 위해 Virtual Private Cloud를 만들고, 필요한 만큼의 인스턴스를 할당할 수 있는 네트워크를 할당할 수 있으신가요?
  • VPC와 External, VPC와 Internal, 그리고 External ←→ Internal 통신을 수행할 때 필요한 경로만을 설정했고, 보안 그룹은 어떻게 설정하셨나요?
  • 보안 그룹은 어떻게 구성하는게 좋을까요?
  • 오늘 한 시스템에서 Router가 여러개 존재한다면 어떻게 통신이 일어날까요?
  • Router에서 어떻게 경로 설정을 수행하나요?
  • Packet Loss는 어떤 환경에서 일어나는지 알고 계신가요?
  • 네트워크 통신 중 장애가 발생했다면, 원인 분석을 할 수 있으신가요?

컨테이너에 대해 공부를 해야겠다는 생각, 그리고 숙제로 미루어 왔던 Control Group에 대해 공부하자는 계획을 세운 뒤 꽤나 오랜 시간이 흘렀습니다. 링크드인의 글을 눈팅하던 중 우연히 “쿠버네티스가 쉬워지는 컨테이너이야기 시리즈” 게시글을 보게 되었고, 해당 글을 바탕으로 공부를 시작했습니다.

앞으로 작성할 cgroup 관련 게시글은 해당 글을 바탕으로 컨테이너에 대한 이해도를 높이고자 작성한 글 입니다.

원글의 출처는 아래와 같습니다.

쿠버네티스가 쉬워지는 컨테이너 이야기 — cgroup, cpu편 (https://medium.com/@7424069/쿠버네티스가-쉬워지는-컨테이너-이야기-cgroup-cpu편-c8f1e2208168), 천강진 님


이 글이 도움 될 사람

  1. 쿠버네티스가 쉬워지는 컨테이너 이야기 시리즈를 읽었지만 이해가 안되는 부분이 있는 사람
  2. 컨테이너가 리눅스의 union filesystem, cgroup, namespace를 이용해 만들어지는 것을 알고 있는 사람
  3. cgroup이 단순히 자원을 격리한다는 것만 알고 있는 사람

 

본 글의 마지막 부분에는 천강진 님의 이번 글의 대상이 아닌 독자에 대한 개인적인 견해를 작성해 놓았습니다.


컨테이너

컨테이너는 리눅스의 union fs (overlayfs), cgroup, namespace를 이용하여 host os의 메인 워크로드와 격리된 워크로드를 제공하는 가상화 솔루션입니다.

union filesystem을 이용하여 애플리케이션을 실행하기 위한 운영체제 환경부터 시작하여 서비스 바이너리까지 레이어로 나뉘어진 하나의 통합된 파일 시스템을 제공하여 언제 어디서든 동일한 환경을 제공하고 namespace를 이용하여 애플리케이션 프로세스를 격리하여 독립된 환경을 제공할 수 있다면,

control group은 이렇게 격리된 환경, 그리고 언제나 동일한 애플리케이션을 실행하기 위한 리눅스 프로세스 그룹에 시스템 자원을 격리, 할당하여 제어할 수 있도록 해 줍니다.

정리하자면,

  1. Union Filesystem을 통해 low layer, upper layer, merged view를 통해 레이어화 된 애플리케이션 실행에 필요한 파일시스템을 제공하고
  2. Namespace를 통해 애플리케이션의 실행 환경을 분리하며
  3. Control Group을 통해 애플리케이션 실행에 필요한 하드웨어 자원을 격리, 제한하여 독립된 환경에서 안정적으로 실행될 수 있도록 하는 가상화 기술입니다.

“천강진”님의 시리즈를 읽으면서 Control Group을 이해한다면 컨테이너 환경에서의 안정적으로 하드웨어 자원을 제공할 수 있을 뿐 아니라, 애플리케이션에 어떤 현상 혹은 문제가 발생했을 때 조금 더 넓은 각도에서 원인을 분석하고 문제를 해결할 수 있을 것이라 느꼈습니다.

Control Group은 /sys/fs/cgroup 아래에 논리 제어 그룹을 확인할 수 있으며 루트 제어 그룹 아래에 자식 제어 그룹이 존재하는 형태입니다. 새로운 논리 그룹을 생성하려면 대상의 루트, 혹은 자식 제어 그룹에 폴더를 생성하면 됩니다.

출처: 쿠버네티스가 쉬워지는 컨테이너 이야기 — cgroup, cpu편, 천강진님

A라는 논리 그룹을 새롭게 만들 경우, 리눅스 시스템에 의해 해당 디렉토리 아래에 자동으로 다음과 같은 구조가 추가됩니다.

단순히 논리 그룹을 만듦으로써 프로세스 격리된 제어 그룹 공간을 활용하는 것은 아닙니다. 생성된 제어 그룹에 실행중인 프로세스의 ID를 할당함으로써 실질적인 제어 그룹을 이용하게 됩니다.

물론 아직 제어 그룹에 대해 자원에 대한 제한을 설정하지 않았기 때문에 아직은 루트 제어 그룹을 그대로 따르는 상태입니다.


Docker의 Control Group과 CPU

잠깐! macOS의 경우 /sys/fs/cgroup을 찾을 수 없습니다. 아래의 명령어를 통해 host의 /sys/fs/cgroup을 확인할 수 있는 컨테이너를 실행해 주세요.

docker run --rm -d -it \
  --privileged --pid=host \
  --name cgroup-host \
  justincormack/nsenter1

예제에서 사용된 컨테이너의 경우 아래 명령어로 생성할 수 있습니다.

docker run -d --rm --privileged --name cgroup docker:27.3.1-dind-alpine3.20

도커는 컨테이너를 생성할 때, 하나의 논리 제어 그룹을 생성합니다. 도커를 운영중인 환경에서 /sys/fs/cgroup을 확인해보면 아래와 같이 docker 디렉토리가 존재하는 것을 확인할 수 있습니다.

/sys/fs/cgroup # ls
cgroup.controllers               cpu.weight.nice                  memory.low
cgroup.events                    cpuset.cpus                      memory.max
cgroup.freeze                    cpuset.cpus.effective            memory.min
cgroup.kill                      cpuset.cpus.exclusive            memory.oom.group
cgroup.max.depth                 cpuset.cpus.exclusive.effective  memory.peak
cgroup.max.descendants           cpuset.cpus.partition            memory.reclaim
cgroup.procs                     cpuset.mems                      memory.stat
cgroup.stat                      cpuset.mems.effective            memory.swap.current
cgroup.subtree_control           **docker**                           memory.swap.events
cgroup.threads                   **init.scope**                       memory.swap.high
cgroup.type                      io.max                           memory.swap.max
cpu.idle                         io.stat                          memory.swap.peak
cpu.max                          **k3s**                              pids.current
cpu.max.burst                    memory.current                   pids.events
cpu.stat                         memory.events                    pids.events.local
cpu.stat.local                   memory.events.local              pids.max
cpu.weight                       memory.high                      pids.peak
/sys/fs/cgroup #

이후 docker 아래의 경로를 확인해보면, 실제로 실행중인 container id 이름으로 제어 그룹이 각각 생성되어 있는것 또한 확인할 수 있습니다.

/sys/fs/cgroup # cd docker
/sys/fs/cgroup/docker # ls
5d16df9b31de84920181fc18e72b4fb5725c9353b3b27f5a758e41cbb039eb54
b1a5dc151e15a4373d30adbc920e8e9ced70e1632d3027c2c4acc55efaeb2395
ba834863df92a0f9919c98e4829d6fb8a4dbdf66e81507f103e75dd710a4c566
cgroup.controllers
cgroup.events
cgroup.freeze
cgroup.kill
cgroup.max.depth
cgroup.max.descendants
cgroup.procs
cgroup.stat
cgroup.subtree_control
cgroup.threads
cgroup.type
cpu.idle
cpu.max
cpu.max.burst
cpu.stat
cpu.stat.local
cpu.weight
cpu.weight.nice
cpuset.cpus
cpuset.cpus.effective
cpuset.cpus.exclusive
cpuset.cpus.exclusive.effective
cpuset.cpus.partition
cpuset.mems
cpuset.mems.effective
e1277fe09be51e878f1b38bb4936982295c68f1f0b0429f9c4ab8319494cc6a5
io.max
io.stat
memory.current
memory.events
memory.events.local
memory.high
memory.low
memory.max
memory.min
memory.oom.group
memory.peak
memory.reclaim
memory.stat
memory.swap.current
memory.swap.events
memory.swap.high
memory.swap.max
memory.swap.peak
pids.current
pids.events
pids.events.local
pids.max
pids.peak
/sys/fs/cgroup/docker #

아래는 Host OS에서 docker ps 명령어를 입력했을 때 보이는 컨테이너 입니다.

$ docker ps
CONTAINER ID   IMAGE                           COMMAND                   CREATED         STATUS         PORTS                                                  NAMES
b1a5dc151e15   justincormack/nsenter1          "/usr/bin/nsenter1"       3 minutes ago   Up 3 minutes                                                          cgroup-host
e1277fe09be5   docker:27.3.1-dind-alpine3.20   "dockerd-entrypoint.…"    7 minutes ago   Up 7 minutes   2375-2376/tcp                                          cgroup
5d16df9b31de   mysql:8.0                       "docker-entrypoint.s…"    4 days ago      Up 7 minutes   0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp   mysql
ba834863df92   redis:7.4                       "docker-entrypoint.s…"    4 days ago      Up 7 minutes   0.0.0.0:6379->6379/tcp, :::6379->6379/tcp              redis

우리가 생성한 예제의 경우 e1277fe09be5로 실행되고 있는 것을 확인할 수 있으며, 현재 아무 자원 제한을 두지 않았기 때문에 cpu의 max, 가중치를 Host OS에서 확인해 봤을 때 모두 기본 값임을 확인할 수 있습니다.

/sys/fs/cgroup/docker # cat e1277fe*/cpu.weight
100
/sys/fs/cgroup/docker # cat e1277fe*/cpu.max
max 100000
/sys/fs/cgroup/docker # cat e1277fe*/cpu.stat
usage_usec 2073096
user_usec 1489344
system_usec 583751
nr_periods 0
nr_throttled 0
throttled_usec 0
nr_bursts 0
burst_usec 0

컨테이너를 실행할 때 cpu 할당량을 설정한다면 아래와 같은 변화를 확인할 수 있습니다.

$ docker stop cgroup
$ docker run -d --rm --privileged --cpus 0.5 --name cgroup docker:27.3.1-dind-alpine3.20
8292ce42483c59539655d0f727d771cadee933a91c74fe92b53feec96a0b31cd
  • 아래 코드는 Host OS에서 실행한 결과입니다.
/sys/fs/cgroup/docker # cat 8292ce*/cpu.weight
100
/sys/fs/cgroup/docker # cat 8292ce*/cpu.max
50000 100000
/sys/fs/cgroup/docker # cat 8292ce*/cpu.stat
usage_usec 1927030
user_usec 1741681
system_usec 185349
nr_periods 113
nr_throttled 35
throttled_usec 1735216
nr_bursts 0
burst_usec 0

control group에서, 다시 말해 컨테이너에서 cpu 자원을 제한한다는 의미는 CPU 스케줄링에 있어서 얼만큼 자원을 선점할 수 있느냐를 의미하는데, 다시 말해 --cpus 0.5를 설정하는 행위는 100ms 동안 50ms를 선점할 수 있다는 것을 의미하게 됩니다.

  • 해당 부분이 이해되지 않으신다면, 운영 체제의 CPU 스케줄링에 대해 찾아보시면 좋을것 같습니다.

만약 1개를 넘어가면 어떻게 될까요? 간단하게 하기 위해 cpu 개수를 2로 할당한다면, 다음과 같은 결과가 나오게 됩니다.

$ docker stop cgroup
$ docker run -d --rm --privileged --cpus 2 --name cgroup docker:27.3.1-dind-alpine3.20
68b5952c3487249c14eb48c9649d916475ae7d4593386c9e234d90ba0a92d9f1

---

/sys/fs/cgroup # cd docker
/sys/fs/cgroup/docker # cat 68b5952*/cpu.max
200000 100000
$ docker stop cgroup
$ docker run -d --rm --privileged --cpus 2 --name cgroup docker:27.3.1-dind-alpine3.20
68b5952c3487249c14eb48c9649d916475ae7d4593386c9e234d90ba0a92d9f1

---

/sys/fs/cgroup # cd docker
/sys/fs/cgroup/docker # cat 68b5952*/cpu.max
200000 100000

100ms 동안 200ms를 선점할 수 있다는 것이 이상하다 느껴질 수 있습니다.

실제 애플리케이션이 동작하는 환경은 다수의 CPU를 가지는 환경이기 때문에, 단일 CPU의 환경이 아닌 이상 여러 CPU를 통해 각각 100%를 선점하여 200ms를 달성하게 됩니다.

그렇기 때문에 컨테이너 플랫폼 혹은 오케스트레이션 환경에서 cpu 자원을 요청하거나 제한할 때 실제 cpu의 core 수를 넘어서 요청할 수 없게 됩니다.


쿠버네티스의 cpu.weight (cpu.shares)

cpu.weight은 control group을 통해 생성하는 프로세스의 cpu 가중치를 설정할 수 있습니다. 일반적으로 도커, crio 등 컨테이너 플랫폼 환경을 실행한다면 cpu.weight은 일반적으로 100으로 설정되는 것을 확인할 수 있습니다. 이 경우 “천강진”님의 예제를 통해 cpu.weight을 쉽게 이해하실 수 있습니다.

하지만 실제 컨테이너 오케스트레이션 환경(쿠버네티스 등)이나 SaaS, PaaS로 제공되는 환경의 경우 cpu.weight 값이 100으로 설정되어 있지 않는 경우도 있는데, 이 경우 실행하는 서비스 혹은 환경에 의해 cpu.weight 값이 계산되어 할당됩니다. 이는 실제 CPU 리소스를 할당할 때 상대적인 CPU 우선순위를 설정하는 cpu.weight의 특징이자 영향을 받았기 때문입니다.

아래는 온프레미스 환경에 쿠버네티스를 설치하고 제어 그룹 디렉토리로 이동했을 때의 상태입니다. 아직 아무 파드도 설치하지 않았으며, crio v1.31과 쿠버네티스 1.31.2를 설치한 직후의 상태입니다.

root@worker1:/sys/fs/cgroup # ls
A                       cgroup.procs            cpu.pressure           dev-hugepages.mount  io.cost.qos    kubepods.slice    misc.capacity                  sys-kernel-debug.mount
cgroup.controllers      cgroup.stat             cpu.stat               dev-mqueue.mount     io.pressure    memory.numa_stat  proc-sys-fs-binfmt_misc.mount  sys-kernel-tracing.mount
cgroup.max.depth        cgroup.subtree_control  cpuset.cpus.effective  init.scope           io.prio.class  memory.pressure   sys-fs-fuse-connections.mount  system.slice
cgroup.max.descendants  cgroup.threads          cpuset.mems.effective  io.cost.model        io.stat        memory.stat       sys-kernel-config.mount        user.slice
root@worker1:/sys/fs/cgroup # cat kubepods.slice/cpu.weight
79

쿠버네티스의 경우 cpu.weight의 값이 79인 이유는 Control Group V1에서 V2로 넘어오면서 cpu.shares를 cpu.weight로 변환하는 과정에서 스케일링이 발생했기 때문입니다.

  • cpu.shares는 실질적으로 가중치를 할당하여 사용한다기 보다 cpu.shares가 설정된 제어 그룹 간의 비율을 바탕으로 CPU 자원을 나눠 사용할 수 있게 됩니다. 즉 cpu.shares가 512, 1024, 2048로 설정되어 있다면, 1:2:4의 비율로 cpu 자원을 할당받아 사용할 수 있게 됩니다. cpu.shares의 최대 값은 2^18 - 2인 262142를 갖습니다.
    • control group v1 에서 cpu.shares는 최대 값은 이론상 2^32를 갖지만, 시스템의 안정성 등을 위해 최대 값을 2^18로 제한하고 있습니다.
    • 실제로 Ubuntu 등 Linux 계열의 cpu.shares의 옵션을 보면 [2, 262144] 값을 가지고 있습니다.
    • 2를 빼는 이유는 cpu.shares의 최소값인 2를 보장하기 위함입니다.
  • 이러한 cpu.shares를 [1, 10000]의 범위를 갖는 cpu.weight로 변환하기 위해서는 다음의 식이 사용됩니다.
    • 1 + ((cpu shares - 2) * [cpu.weight의 맥스 - 최소값] ) / [cpu.shares의 맥스 - 최소값]
    • 1 + ((cpu shares - 2) * 9999) / 262142
    • 이 때 1을 더해주는 이유는 int 값이기 때문에 버려지는 값을 보완하기 위함입니다.
  • cpu.weight 값을 새롭게 스케일링 한 것이 아니라, cpu.shares와 최대한 유사한 값을 제공하기 위한 식입니다.

이를 바탕으로 2 개의 vCPU (CPU 코어)를 갖는 쿠버네티스 환경에서는 1 + (( 2048 - 2 ) * 9999 ) / 262142 값인 79를 도출하게 됩니다.

자 그렇다면 이제 실질적으로 resources.requests.cpu를 설정하여 파드를 배포해서 확인해 봅시다.

apiVersion: v1
kind: Pod
metadata:
  name: a
  namespace: default
spec:
  containers:
  - image: busybox
    command:
      - sleep
      - "3600"
    imagePullPolicy: IfNotPresent
    name: busybox
    resources:
      requests:
        cpu: 250m
  restartPolicy: Always
  nodeSelector:
    kubernetes.io/hostname: worker1

위 yaml을 바탕으로 pod를 배포하게 되면 cpu.weight의 값은 얼마가 될지 한번 고민해 봅시다.

우리는 전체 CPU shares인 2048에 대해 250m을 요청했고, 2개의 코어를 갖는 노드에서 CPU Weight는 79를 갖고 있습니다. 즉 250 / 2048 만큼 요청했고, 79의 가중치를 갖기 때문에 다음과 같은 식을 갖는다고 예상할 수 있습니다.

1 + 250 / 2048 * 79 ~= 9.6 (10)

이를 직접 워커 노드의 /sys/fs/cgroup을 통해 확인해봅시다.

root@worker1:~# crictl ps
CONTAINER           IMAGE                                                                                                     CREATED             STATE               NAME                ATTEMPT             POD ID              POD
**8a6b6f7c79ece       27a71e19c95622dce4d60d4a3760707495c9875f5c5322c5bd535214799593ce                                          29 minutes ago      Running             busybox             0                   c063f7c199d23       a**
9f61aea39bdf1       registry.k8s.io/coredns/coredns@sha256:f0b8c589314ed010a0c326e987a52b50801f0145ac9b75423af1b5c66dbd6d50   13 hours ago        Running             coredns             0                   fb79be5137b43       coredns-7c65d6cfc9-k65k4
89986ea9962fa       registry.k8s.io/coredns/coredns@sha256:f0b8c589314ed010a0c326e987a52b50801f0145ac9b75423af1b5c66dbd6d50   13 hours ago        Running             coredns             0                   220bc9ce4e46f       coredns-7c65d6cfc9-hrx2k
f34df68b00d35       registry.k8s.io/kube-proxy@sha256:22535649599e9f22b1b857afcbd9a8b36be238b2b3ea68e47f60bedcea48cd3b        13 hours ago        Running             kube-proxy          0                   90179e9aaf2f6       kube-proxy-mb9f7

root@worker1:~# crictl inspect 8a6b6f7 | grep "pid"
    "pid": 16120,
    
root@worker1:~# find /sys/fs/cgroup -name cgroup.procs -exec grep -H 16120 {} \\;
/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod10e59c76_03e0_447b_bbc0_d66f7ffe0583.slice/crio-8a6b6f7c79ece337d02b1ebdf1fe7984d18c2bb829aa2c75cfed19e18e166fbc.scope/container/cgroup.procs:16120

root@worker1:~# cd /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod10e59c76_03e0_447b_bbc0_d66f7ffe0583.slice/crio-8a6b6f7c79ece337d02b1ebdf1fe7984d18c2bb829aa2c75cfed19e18e166fbc.scope
root@worker1:/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod10e59c76_03e0_447b_bbc0_d66f7ffe0583.slice/crio-8a6b6f7c79ece337d02b1ebdf1fe7984d18c2bb829aa2c75cfed19e18e166fbc.scope# cat cpu.weight
10

우리가 계산한 9.6 + 1 = 10.6에 대한 10의 값이 정상적으로 출력되는 것을 확인할 수 있습니다.

혹시 모르니 resources.requests.cpu를 500m으로 설정하고 다시 확인해 봅시다.

root@master1:~/k8s/pods# cat a.yaml
apiVersion: v1
kind: Pod
metadata:
  name: a
  namespace: default
spec:
  containers:
  - image: busybox
    command:
      - sleep
      - "3600"
    imagePullPolicy: IfNotPresent
    name: busybox
    resources:
      requests:
        cpu: 500m
  restartPolicy: Always
  nodeSelector:
    kubernetes.io/hostname: worker1
root@master1:~/k8s/pods# kubectl apply -f a.yaml
pod/a created
---
root@worker1:~# crictl ps
CONTAINER           IMAGE                                                                                                     CREATED             STATE               NAME                ATTEMPT             POD ID              POD
**1618ac0f6157d       27a71e19c95622dce4d60d4a3760707495c9875f5c5322c5bd535214799593ce                                          18 seconds ago      Running             busybox             0                   cfbb9f68873cf       a**
9f61aea39bdf1       registry.k8s.io/coredns/coredns@sha256:f0b8c589314ed010a0c326e987a52b50801f0145ac9b75423af1b5c66dbd6d50   13 hours ago        Running             coredns             0                   fb79be5137b43       coredns-7c65d6cfc9-k65k4
89986ea9962fa       registry.k8s.io/coredns/coredns@sha256:f0b8c589314ed010a0c326e987a52b50801f0145ac9b75423af1b5c66dbd6d50   13 hours ago        Running             coredns             0                   220bc9ce4e46f       coredns-7c65d6cfc9-hrx2k
f34df68b00d35       registry.k8s.io/kube-proxy@sha256:22535649599e9f22b1b857afcbd9a8b36be238b2b3ea68e47f60bedcea48cd3b        13 hours ago        Running             kube-proxy          0                   90179e9aaf2f6       kube-proxy-mb9f7

root@worker1:~# crictl inspect 1618ac0f | grep "pid"
    "pid": 16906,
            "type": "pid"
          "pids": {

root@worker1:~# find /sys/fs/cgroup -name cgroup.procs -exec grep -H 16906 {} \\;
/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod089ebf2e_1909_430e_aad6_14f1ff19455b.slice/crio-1618ac0f6157df7b07558d8507856af3a5707cb941ef923786bd8d09ba259b4b.scope/container/cgroup.procs:16906

root@worker1:~# cd /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod089ebf2e_1909_430e_aad6_14f1ff19455b.slice/crio-1618ac0f6157df7b07558d8507856af3a5707cb941ef923786bd8d09ba259b4b.scope
root@worker1:/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod089ebf2e_1909_430e_aad6_14f1ff19455b.slice/crio-1618ac0f6157df7b07558d8507856af3a5707cb941ef923786bd8d09ba259b4b.scope# cat cpu.weight
20
  • 예상값: 1 + 500 / 2048 * 79 = 20.28 ( 20 )
  • 실제값: 20
  • 450m → 18 (17.35 + 1)

이번 글의 대상이 아닌 독자

  • k8s는 왜 CPU를 압축 가능한 자원으로 보는가?
    • CPU Core에 대한 실행시간, 즉 스케줄링에 대해 가중치를 바탕으로 할당하기 때문에 탄력적으로 조절 가능하기 때문.
  • 쿠버네티스에서 CPU requests/limits는 어떤 의미인가?
    • CPU requests는 cpu weight을 기반으로 “상대적으로” 컨테이너가 보장받을 수 있는 최소 CPU 자원을 설정한다.
      • 예를 들어, 전체 가중치가 100일 때 두 애플리케이션에 각각 50의 가중치가 설정되면, 각 애플리케이션은 최소 50%의 CPU 시간을 보장받는다.
        • 하나의 애플리케이션만 존재할 경우, 우리가 알고 있는 스케줄링 정책과 같이 자원을 더 많이 사용할 수 있다.
      • 만약 50의 가중치를 가진 애플리케이션이 세 개 존재한다면, 피크 타임에 모두 50%의 CPU를 요청하여 병목 현상이 발생할 수 있다.
        • 하지만 쿠버네티스는 스케줄링 정책에서 세 번째 Pod를 Pending 상태로 유지한다.
    • CPU limits는 컨테이너가 사용할 수 있는 최대 CPU 시간을 의미한다. CPU 사용량은 이 limits을 초과할 수 없다.
    • 그렇기 때문에 쿠버네티스 환경에서는 CPU requests만 설정해도 충분한 경우가 많다.
      • 이는 전체 가중치 중 각 애플리케이션의 requests 비율에 따라 CPU 시간이 할당되기 때문이다.
      • 따라서 limits을 불필요하게 설정하여 애플리케이션에 인위적인 병목 현상을 만들 필요는 없다.
        • 어차피 각 비율에 따라 가질수 있는 최대 CPU 타임은 정해져있다.
  • 컨테이너의 CPU 사용률은 어떻게 측정되는가? ( by 천강진 님 )
/sys/fs/cgroup/C # cat cpu.stat
usage_usec 0     # CPU 총 사용 시간(마이크로초 단위)
user_usec 0      # 사용자 모드에서 실행된 시간
system_usec 0    # 커널 모드에서 실행된 시간

### 아래 값은 cpu.max에 MAX(QUOTA)가 설정된 경우 증가
nr_periods 0     # 스케줄러에 의해 CPU가 할당된 횟수
nr_throttled 0   # 사용 가능한 CPU 시간을 초과 또는 남은 시간이 부족하여 쓰로틀링에 걸린 횟수
throttled_usec 0 # 쓰로틀링 걸린 시간

### 아래 값은 cpu.max.burst가 설정된 경우 증가
nr_bursts 0      # CPU 버스트 모드로 전환된 횟수
burst_usec 0     # 버스트 모드에서 사용한 시간
last_cpu_usage_us = [cpu.stat의 usage_usec]
last_time_us = [현재 시간 마이크로초]

N초 휴식

curr_cpu_usage_us = [cpu.stat의 usage_usec]
curr_time_us = [현재 시간 마이크로초]

delta_cpu_usage_us = curr_cpu_usage_us - last_cpu_usage_us
delta_time_us = curr_time_us - last_time_us

print(f"CPU 사용률: {delta_cpu_usage_us / delta_time_us * 100:.2f}%")
  • CPU 사용률이 100%가 아닌데 왜 throttled 상태가 되는가?
    • cpu.max에 설정된 CPU 시간을 초과할 수 있기 때문
  • 일정량의 RPS 또는 TPS가 목표일 때, 내 어플리케이션에 적용해야할 CPU 값은 어떻게 알 수 있는가?
    • 성능 테스트를 통해 CPU 자원 사용량을 계산해야 한 뒤, 목표 값에 도달하기 위해 얼마나 필요할지 예측할 수 있다.
    • 예를 들어 1350 RPS가 목표이고, 1000 RPS (TPS)를 처리할 때 0.5 vCPU가 사용되었다면 0.675 vCPU가 필요한 것을 알 수 있다. ( 1350 / 1000 ) * 0.5
    • 실제 vCPU를 할당할 때는 이보다 넉넉하게 할당하도록 하자.
  • k8s에서 CPU와 관련된 추가 기능은 무엇이 나올 수 있을까?
    • 자식 프로세스와 관련된 기능 ?
      • address space를 공유할 수 있을것 같고
      • CPU의 근본적인 것에 가까워지는 느낌이라, 부모 - 자식 process…….
  • 컨테이너 환경은 CPU 활용에 있어 어떤 문제가 있는가?
    • Host OS를 이용한다는 점
      • 가상 머신의 경우 일반적으로 하드웨어적으로 격리된 자원을 보장받지만
      • 컨테이너는 그렇지 않기 때문에 발생할 수 있는
      • 자원 이슈
  • 7번의 문제는 특정 애플리케이션을 컨테이너로 올리는데 문제가 되는가?
    • Host OS에 영향을 줄 수 있는 만큼의 연산을 필요로 하는 애플리케이션
      • 우리가 자원량을 설정하지 않는다면 문제가 되지 않을까?

참고

안녕하세요, 짧은머리 개발자입니다.

클라우드는 보안이 매우 중요한 환경인데요, 오늘은 Google Cloud Platform에서 관리중인 여러 프로젝트간 서비스 접근을 어떻게 구성하는지 공유하려 해요.

클라우드는 서비스로 제공하는 XaaS에 대한 접근을 제어할 수 있도록 IAM을 제공하는데 AWS, GCP와 같은 거대 클라우드는 IAM Role for Service Account(IRSA)라 불리는 메커니즘을 통해 더 세밀한 제어를 가능하게 해요.

이름에서 유추할 수 있듯이 서비스 계정을 위한 IAM 역할을 부여함으로써 그 메커니즘이 동작해요.

GCP의 서비스에 등록된 Service Account는 구글의 메타데이터 서버를 통해 자격 증명에 접근하고, 필요한 토큰을 관리해요.

  • 이전에 작성한 Github과 같이 GCP 외부에서 접근하는 경우에는 Security Token Services를 통해 단기 자격을 증명해주는 토큰을 발급받아야 하고, 이 때 roles/iam.serviceAccountTokenCreator과 같은 역할이 추가로 필요해요.

GCP는 구조를 계정 수준 리소스부터 서비스 수준 리소스로 나누어 관리할 수 있는데, 베스트 프랙티스로 GCP 내부에 생성할 그 구조를 회사의 조직 구조와 일치하게 만드는 것을 권장해요.

https://cloud.google.com/resource-manager/docs/cloud-platform-resource-hierarchy?hl=ko

이는 곧 분리되어 있는 프로젝트 간 배포된 서비스간 접근을 허용해야 하는 경우가 발생할 수 있어요.

제가 수행했던 프로젝트의 경우에도 분리되어있는 프로젝트간 버킷에 접근해야 할 필요가 있었는데요, 이를 어떻게 구성했는지 공유하려 해요.

프로젝트 구성

프로젝트 구성은 위와 같은 형태로, Project A에서 동작중인 Application A는 Project B의 Bucket B의 오브젝트에 접근할 수 있어야 하며, Bucket B는 Private하며 IAM을 통해 접근을 제한하고 있어요.

이를 해결하기 위해 우리는 싱글 프로젝트와 같이 Project A의 Application A에 서비스 계정을 추가한 뒤, Project B의 IAM을 구성해줘야 해요.

이 때 Project B에서 bucket-sa@project-a.iam.gserviceaccount.com 서비스 계정이 가져야할 역할은 다음과 같아요.

  • Bucket B에서 행할 작업에 따른 Storage 접근 권한, roles/storage.objectViewer
💡 roles/iam.serviceAccountUser의 경우, 특정 사용자 혹은 서비스 계정이
bucket-sa와 같이 IAM 역할을 갖는 서비스 계정으로 가장(impersonate)해야 할 때 필요해요.
이 경우에는 Application에 부착된 서비스 계정이 직접적으로 사용하기 때문에 필요하지 않아요.

 

여기까지 수행하고 나면 RBAC이 구성되고, Application A에서 Bucket B에 정상적으로 접근할 수 있어요.

그런데 만약 Project B에 Bucket B 말고 여러 버킷이 존재한다며면, bucket-sa 서비스 계정을 이용해서 모든 버킷에 접근 가능하게 돼요. 이는 보안상 매우 취약한 설정이며, 따라서 Attribute Based Access Control(ABAC)을 구성해야 해요.


ABAC은 이름에서 유추할 수 있듯 속성 기반의 접근 제어 메커니즘이에요. 기존의 bucket-sa 서비스 계정은 역할을 기반으로 Bucket B에 접근 가능하도록 구성했다면, 여기에 속성을 추가하여 더 세밀한 제어를 할 수 있어요.

우리에게 필요한 세밀한 제어는 다음과 같아요.

  • Bucket B에 접근할 수 있어야 한다.
  • Bucket B 내부의 오브젝트를 볼 수 있어야 한다.
  • Bucket B 내부의 디렉토리 구조를 볼 수 있어야 한다.

이를 해결하기 위해 GCP는 IAM 역할에 조건을 설정할 수 있고, 역할의 조건에 필요한 리소스 유형과 리소스 이름은 다음 링크에서 확인할 수 있어요.

우리에게 필요한 속성을 정리하면 다음과 같아요.

# 허용할 서비스 계정
member = "bucket-sa@project-a.iam.gserviceaccount.com"

# 허용할 역할
roles = [
  "roles/storage.objectViewer",
]

# 역할 + 대상 = 서비스 계정이 접근할 수 있는 대상
target_resources = [
  # 버킷의 이름이 bucket-b와 같아야 한다.
  {
    type   = "storage.googleapis.com/Bucket",
    name   = "projects/_/buckets/bucket-b"
    option = "equal"
  },
  # 접근하고자 하는 오브젝트는 bucket-b의 objects여야 한다.
  {
    type = "storage.googleapis.com/Object",
    name   = "projects/_/buckets/bucket-b/objects/"
    option = "startsWith"
  },
  # 접근하고자 하는 폴더는 bucket-b의 관리형 폴더여야 한다.
  {
    type = "storage.googleapis.com/ManagedFolder",
    name   = "projects/_/buckets/bucket-b/managedFolders/"
    option = "startsWith"
  }
]

버킷 뿐만 아니라 다른 XaaS에 대한 접근이 필요할 때도 위와 같이 서비스 계정을 구성한다면 프로젝트간 IRSA를 구성할 수 있어요.

오늘도 읽어주셔서 감사합니다.

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

GKE와 Cloud Load Balancing 연결하기  (1) 2024.07.15

안녕하세요, 짧은머리 개발자에요.

오늘은 초기 스타트업인 회사에서 CI/CD 파이프라인을 도입하고 이에 대한 정책 결정에 대한 고민과 솔루션을 공유하려 해요.


우선 제가 입사하기 전까지 회사는 데브옵스 엔지니어가 없었기에 CI/CD 파이프라인 마찬가지로 없었고, 클라우드 내에 딱히 정해진 정책 없이 애플리케이션을 운영중하고 있었어요. 그렇기 때문에 현재 운영중인 애플리케이션의 버전을 알 수 없었고, 언제 어떻게 배포되었는지 추적하기 어려운 환경이었어요.

이러한 환경에서 제가 CI/CD 파이프라인을 도입하기 위해 회사의 현재 상황을 분석하고 마련한 예비 요구사항은 다음과 같아요.

  1. Continuous Integration, Continuous Delivery, Continuous Deploy 각 단계에서 현재 상태와 결과를 알 수 있어야 한다.
  2. 현재 운영중인 애플리케이션의 버전과 상태를 알 수 있어야 한다.
  3. 자동화된 시스템을 통해 개발자는 CI/CD를 수행할 수 있어야 한다.
    • 기존에는 CI ~ Deploy까지 한 번에 무조건 실행되어서 애플리케이션의 테스트 없이 곧바로 Production에 올라가는 문제가 있었어요. 이를 해결하기 위해 “적절한 자동화”를 통해 각자가 담당한 애플리케이션을 관리, 배포할 수 있게 하고자 했어요.
  4. DevOps Engineer를 제외한 개발자는 CI/CD 각 단계가 어떻게 진행되는지 몰라도 된다.
    • DevOps Engineer가 생긴 만큼 개발자는 각자의 영역에 충실할 수 있게끔 하고자 했어요.
  5. Git Ops와 융합되어야 한다.
    • 회사에서 GitHub Enterprise를 이용중이었고, 따라서 GitOps와 융합하고자 했어요.

1, 2번을 묶어서 기존 시스템의 가장 큰 문제점인 현재 운영중인 애플리케이션의 버전을 알 수 없는 점각 단계에서의 결과와 상태를 앎으로써 배포 후보군이 되는 애플리케이션이 어떤 내용을 갖는지 추적할 수 있게 하고자 했어요.


GCP Artifact Registry에 대한 Git Actions 접근 허가하기

GCP의 경우 워크로드 아이덴티티 제휴(Workload Identity Federation)를 통해 외부 서비스와의 융합을 지원하는데요, Git Actions에서 GCP에 접근 또한 이를 활용하여 해결할 수 있어요.

https://cloud.google.com/blog/products/identity-security/enabling-keyless-authentication-from-github-actions?hl=en

워크로드 아이덴티티 제휴는 쉽게 말해 GCP의 Iam Roles for Service Accounts(IRSA)라 볼 수 있어요. 즉, GCP의 리소스를 이용하는 작업(Workload)에 대해 신원(Identity)을 제공하는 기능이에요.

Git Actions에서 GCP의 서비스인 Artifact Registry에 접근할 필요가 있었고, 이를 위한 워크로드 아이덴티티 제휴를 구성했어요.

생성할 서비스 계정은 워크로드 아이덴티티 풀과 Artifact Registry에 접근하기 위해서 다음의 Role들을 가지고 있어요.

  • roles/iam.workloadIdentityUser
    • 서비스 계정을 통해 GCP의 워크로드 아이덴티티를 사용하기 위함이에요.
  • roles/iam.serviceAccountUser
    • 서비스 계정을 통해 GCP 리소스에 접근하기 위함이에요.
  • roles/artifactregistry.writer
    • 서비스 계정을 통해 Artifact Registry에 Git Actions로 생성된 컨테이너 이미지를 업로드하기 위함이에요.
  • roles/iam.serviceAccountTokenCreator
    • 서비스 계정에 대한 단기 인증정보를 만들 수 있도록 해요. 이를 통해 Git Actions 워크 플로우에 정의된 서비스 계정의 OAuth 2.0 토큰을 획득하고 명시된 권한을 사용할 수 있어요.

또한 아무 Git 리포지터리에서 실행되는 Git Actions의 워크플로우를 통해 우리의 GCP 인프라에 접근하면 안되기 때문에 다음과 같이 GitHub 계정 혹은 리포지터리에 대한 제한 및 격리를 시켜야 합니다.

module "oidc_gh" {
  ...
  sa_mapping = {
    "github-sa" = {
      sa_name   = google_service_account.github_sa.id
      attribute = "attribute.repository_owner/{{ Organization }}"
    }
  }
  ...
}

# module.oidc_gh 중
resource "google_service_account_iam_member" "wif-sa" {
  for_each           = var.sa_mapping
  service_account_id = each.value.sa_name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.this.name}/${each.value.attribute}"
}

이렇게 GCP와 Git Actions에 대한 WIP를 설정했다면, Git Actions에서 실제로 접근 가능한지 테스트해야 해요.

GCP의 경우 Git Actions에서 사용 가능한 Git Action을 제공하고 있는데요, google-github-actions/auth@v2를 통해서 GCP 서비스 계정의 권한을 획득할거에요.

build-and-delivery:
  runs-on: ubuntu-latest
  permissions:
    id-token: "write"
  steps:
	- name: "GCP Auth"
	  id: "gcp-auth"
	  uses: "google-github-actions/auth@v2"
	  with:
	    token_format: "access_token"
	    workload_identity_provider: "{{ 워크로드 아이덴티티 제공자 }}"
	    service_account: "{{ 워크로드 아이덴티티 서비스 계정 }}"
	    access_token_lifetime: "300s"

해당 Git Actions를 실행하면 GCP의 OAuth 2.0 토큰을 해당 잡에서 사용할 수 있게 해요.

  • 이를 위해 Git Actions에서 토큰에 대한 권한을 설정해야 해요. permissions의 id-token 속성을 write로 구성해야 합니다.
  • Github OIDC Provider는 클라우드 제공자로 부터 토큰을 전달받아 사용하는데, 여기서는 GCP의 OAuth 2.0 토큰을 받아 사용합니다.

이렇게 획득한 OAuth 2.0 토큰을 바탕으로 Artifact Registry 로그인 하여 이미지를 업로드 할 수 있어요.

- name: "Login to GAR"
  uses: docker/login-action@v3
  with:
    registry: asia-northeast3-docker.pkg.dev
    username: oauth2accesstoken
    password: ${{ steps.auth.outputs.auth_token }}

- name: "Docker auth with GCP Auth"
  run: |
    gcloud auth configure-docker asia-northeast3-docker.pkg.dev --quiet

Continuous Integration ~ Continuous Delivery 스텝 정의하기

Git Actions를 통해 Google Artifact Registry에 대한 권한을 획득했으니 실질적으로 CI ~ Delivery 까지의 Step을 정의해야 해요.

Continuous Integration은 한 제품을 개발하는 많은 개발자들이 각자의 코드를 융합하고 문제없이 돌아가는지 확인하며 제품으로 빌드하는 행위를 하도록 다음과 같이 정의했어요.

  1. 코드 테스트 단계
    • Git Actions에서 GCP Artifact Registry로 잘 접근이 되는지 확인해요.
    • 코드에 대한 테스트를 수행해요.
  2. 코드 빌드 단계
    • 코드 테스트 단계를 통과했다면 합쳐진 코드를 바탕으로 제품을 빌드해요.
    • 우리의 경우 Container 환경에서 애플리케이션을 운영하기 때문에 Container Image를 생성하는 행위를 의미해요.
  3. 제품 배달 단계
    • 생성된 Container Image를 GCP Artifact Registry로 업로드해요.

특히 우리는 예비 요구사항에서 각 스텝에 대한 결과와 상태를 알 수 있어야 한다.는 요구사항이 있었어요. 이를 지원하기 위해 CI ~ Delivery에 Slack Notification을 전송하는 Job을 추가했어요.

Slack Notification은 총 두 단계로 구성되어 있어요.

  1. Git Actions 시작 알림 메시지
    • 해당 메시지를 통해서 Git Actions의 결과물로 나올 컨테이너 이미지에 대한 정보를 함축적으로 전달해요.
    • 변경에 대한 Commits List를 추가하여 Slack의 쓰레드 메시지로 전달해요.
  2. Git Actions 종료 알림 메시지
    • Git Actions의 성공 여부와 Git Actions 링크 정보를 포함한 메시지를 전달해요
  • 단순히 알림 메시지를 단순히 보내는 것에 끝내지 않고 데이터베이스에 저장하여서 해당 커밋 혹은 릴리즈를 통해 배달된 버전이 어떤 변경점을 갖는지 추후에 확인할 수 있게 했어요.

슬랙에 메시지를 보냄으로써 생성된 이미지에 대해 변경사항을 확인할 수 있고 추후 애플리케이션 배포 시에도 활용하고자 했어요.

CI/CD 파이프라인의 성공 여부 메시지. 부모 슬랙 메시지의 쓰레드로 전달 되어요

독립된 Continuous Deploy

이제 배달 된 제품 빌드에 대해 배포할 차례에요. 기존의 문제점에서 현재 운영중인 애플리케이션의 버전을 알 수 없는 점을 해결하기 위해 배포에 대한 파이프라인을 앞 단계와 분리했어요.

웹 대시보드를 통해 배포하고자 하는 버전과 그 변경사항을 알 수 있게 했고, 현재 배포된 애플리케이션 차트가 어떻게 구성되어 있는지 확인할 수 있도록 배포 서버를 개발 했어요. 해당 서버의 경우 코드를 정리한 다음에 오픈소스로 공개할 예정이에요.

우리 회사는 GKE를 이용해서 애플리케이션을 운영하고 있는데, 실제 쿠버네티스에 배포에 대한 책임은 Argo CD에 위임했어요.

즉, 직접적으로 Continuous Deploy가 해결해야 하는 것은 Argo CD가 알 수 있도록 애플리케이션 매니페스트를 수정해주면 돼요.


여기까지 CI/CD 파이프라인을 설계하며 고민했던 내용을 정리했어요.

읽어주셔서 고맙습니다 🙂

안녕하세요, 짧은머리 개발자에요.

제가 근무하고 있는 회사는 Google Cloud Platform을 이용하여 서비스를 운영하고 있어요. 특히 컨테이너의 시대인 만큼 Google Kubernetes Engine을 이용하여 컨테이너를 관리하고 있는데요, 오늘은 처음 GKE를 도입하면서 GCP의 Cloud Load Balancer를 연동하며 했던 고민을 공유하려 해요.


먼저 GCP는 관리중인 서비스를 외부 혹은 내부에서 접근할 수 있도록 Cload Load Balancing이라는 명칭의 PaaS를 제공하고 있어요. CLB라고 불리는 이 서비스는 GCP에 배포된 서비스들에 고성능 부하 분산 기능을 제공하는 역할을 해요. AWS에는 ALB(NLB)라는 솔루션이 있어요.

우리 회사가 GKE에서 운영중인 서비스들에 대해 외부에서 접근할 수 있도록 하는것이 목표였는데요, GCP에서는 일반적으로 GKE의 외부 노출을 위해 다음의 솔루션을 제공해요.

  1. 쿠버네티스 게이트웨이 API를 이용한 외부 노출
  2. 쿠버네티스 인그레스를 이용한 외부 노출
  3. 서비스 + CLB 직접 관리를 통한 외부 노출

우리 회사가 선택한 방법은 3번인데, 그 이유는 다음과 같아요.

  1. Load Balancer의 직접 관리를 통해 보다 폭 넓은 제어가 필요하다.
  • 2번의 경우 CLB가 자동으로 생성, 관리되는데 이 경우 직접적인 제어가 까다로웠어요. 특히 인그레스 혹은 게이트웨이를 생성할 때 마다 CLB가 생성되는데, 이는 불필요한 CLB의 증가로 인해 관리가 복잡해지고 비용이 늘어나는 원인이 되기도 했어요.
  1. SSL에 대한 폭 넓은 제어가 필요하다.1, 2번의 경우 인그레스, 게이트웨이가 늘어날 때 마다 해당 요청을 처리하는 도메인에 대한 SSL을 명시해 주고 관리해야 하는데 이것은 불필요한 관리를 유발했어요.
  • 특히 초기의 경우 도메인에 대한 asterisk를 SSL에 등록하지 않고, 우리 회사에서 사용하는 도메인들에 대해 SSL을 발급, 관리했는데 이는 매우 까다로운 절차였어요.
  • 인증서를 관리하기 위해 Google Certificates Manager를 이용하고 있어요. 문제는 DevOps 관점에서 SSL의 변경 혹은 추가가 일어날 때, Terraform을 이용한 생성 및 CLB 할당에서 인증서 관리를 마무리 하고 싶었어요.
  1. L7 뿐만 아니라 L4에 대한 트래픽 제어도 필요하다.
  • 블록체인을 운영하는 회사로서 노드에 대한 RPC 통신을 지원해야 했기 때문에 CLB를 통한 직접적인 관리가 필요했어요.
  1. 회사의 규모가 크지 않은만큼 필요한 수의 CLB만 유지하고 싶었어요.
  • GCP의 CLB는 고가용성을 제공하는 만큼 증가하는 트래픽에 대한 CLB를 분리하여 운영할 필요가 없었기에, 필요한 CLB의 숫자만 유지하고 싶었어요.

GCP에서는 최종적으로 GKE의 서비스에 접근하기 위해 GCP Backend Service와 Network Endpoint Group을 이용해야 해요.

GCP는 이를 지원하기 위해 AutoNeg 쿠버네티스 오퍼레이터를 제공하고 있어요. 이를 통해 쿠버네티스에 배포된 서비스를 바탕으로 GCP Backend Service와 Network Endpoint Group을 생성해 줘요.

이를 위해 쿠버네티스 서비스를 생성할 때 다음의 예시처럼 Backend Service와 NEG에 필요한 정보를 어노테이션에 담아 생성해야 해요.

apiVersion: v1
kind: Service
metadata:
  annotations:
    cloud.google.com/neg: '{"exposed_ports": {"80":{"name": "nginx-neg-svc"}}}'
  name: nginx-neg-test
  namespace: devops
  labels:
    app: nginx-neg-test
spec:
  ports:
    - port: 80
      protocol: TCP
  selector:
    app: nginx

이처럼 서비스에 AutoNeg를 통해 GKE와 CLB를 융합하며 운영했을 때 다음의 큰 문제가 발생했어요.

  • GKE 서비스를 배포할 때, Network Endpoint Group이 생성되는 Zone을 명시할 수 없다.
    • 특히 이는 AWS의 EKS의 경우 AWS Load Balancer Controller와 Target Group Binding 그리고 리스너를 통해 배포되어 있는 EKS Pod에 대한 외부 노출이 가능한데 이에 대해 한번에 지원되지 않는게 의아했어요.

이는 Pod의 Spec이 변경될 때 마다 NEG 위치가 변하는 이슈가 있었고, CLB에서는 변경된 NEG로 다시 설정해 줘야 트래픽의 정상적인 처리가 가능했어요. 즉 Downtime이 발생할 수 있는 위험성을 가지고 있어요.

NEG이 하나만 생성된 모습

이를 해결하기 위해 GCP의 GKE 외부 노출 방법중 Ingress를 이용하여 애플리케이션을 외부로 노출해 봤어요.

# nginx.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  namespace: devops
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 1
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-neg-test
  namespace: devops
  labels:
    app: nginx-neg-test
spec:
  ports:
    - port: 80
      protocol: TCP
  selector:
    app: nginx
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-ingress
  namespace: devops
spec:
  rules:
    - http:
        paths:
          - path: /*
            pathType: ImplementationSpecific
            backend:
              service:
                name: nginx-neg-test
                port:
                  number: 80
가능한 모든 zone에 NEG이 생성된 모습

이 경우 AutoNeg과는 다르게 Ingress로 배포한 서비스는 자동으로 현재 GKE가 배포되어 있는 Region 내의 모든 Zone에 대해 Network Endpoint Groups를 배포하는 것을 확인했고, 머리속에서 이거다! 하는 생각이 떠올랐어요.

GCP의 AutoNeg의 경우 배포되는 Service가 사용할 Network Endpoint Groud에 대해 다음의 규칙을 지키면 이미 생성된 NEG을 활용해요.

💡 Note
Updating NEG description is impossible, so this only works if the NEG is used for populating endpoints from a specific set: [Cluster, service name, namespace, port] which does not change.


그래서 저는 AWS의 ALB Controller + Target Group Binding 노하우를 활용하여 다음과 같이 GKE 애플리케이션 외부 노출 정책을 결정했답니다.

  1. 인프라에 배포할 애플리케이션 요구사항에 맞춰 CLB, Backend Service, Network Endpoint Group을 먼저 생성한다.
  1. Backend Services를 생성하며 생성된 NEG을 연결한다.
  • AutoNeg를 이용하여 서비스를 배포할 때 Backend Service와 NEG의 이름을 명시적으로 사용할 수 있어요.
  • 이를 적용하기 위해 Backend Service를 생성할 때 쿠버네티스 서비스에 명시할 이름을 스펙으로 설정해야 해요.

3. 쿠버네티스 서비스 배포시 Backend Service를 생성한 스펙을 바탕으로 AutoNeg Annotation을 설정하여 배포한다.

이렇게 할 경우 Pod가 재배포 되면서 스펙에 변경사항이 발생되더라도 자동으로 AutoNeg이 서비스를 검사하여 GCP의 NEG과 Backend Service에 해당하는 서비스를 연결하는것을 확인했고 Downtime 없이 Application을 배포할 수 있었어요.

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

GCP Multi Project에 대한 IRSA 구성하기  (0) 2024.09.03

+ Recent posts