외부 패스 스루 네트워크 부하 분산기에서 UDP 사용

이 문서에서는 사용자 데이터그램 프로토콜(UDP)을 사용하여 외부 패스 스루 네트워크 부하 분산기에서 작업을 수행하는 방법을 설명합니다. 이 문서는 앱 개발자, 앱 운영자, 네트워크 관리자를 대상으로 합니다.

UDP 정보

UDP는 앱에서 일반적으로 사용됩니다. RFC-768에 설명된 이 프로토콜은 스테이트리스(Stateless) 상태의 신뢰할 수 없는 데이터그램 패킷 서비스를 구현합니다. 예를 들어 Google의 QUIC 프로토콜은 스트림 기반 앱의 속도를 높이기 위해 UDP를 사용하여 사용자 환경을 향상시켜 줍니다.

UDP의 스테이트리스(Stateless) 부분은 전송 계층의 상태가 유지되지 않음을 의미합니다. 따라서 UDP '연결'의 각 패킷은 독립성을 갖습니다. 실제로 UDP에는 실제 연결이 없습니다. 대신 해당 참여자가 일반적으로 2 튜플(ip:port) 또는 4 튜플(src-ip:src-port, dest-ip:dest-port)을 사용하여 서로를 인식합니다.

TCP 기반 앱과 같이, UDP 기반 앱은 또한 부하 분산기의 이점을 얻을 수 있습니다. 이것 때문에 UDP 시나리오에서 외부 패스 스루 네트워크 부하 분산기가 사용되는 것입니다.

외부 패스 스루 네트워크 부하 분산기

외부 패스 스루 네트워크 부하 분산기는 수신되는 패킷을 처리하고 패킷을 그대로 백엔드 서버로 전달하는 패스 스루 부하 분산기입니다. 그런 후 백엔드 서버가 반환 패킷을 직접 클라이언트로 전송합니다. 이러한 기법을 직접 서버 반환(DSR)이라고 부릅니다. Google Cloud 외부 패스 스루 네트워크 부하 분산기의 백엔드인 Compute Engine에서 실행되는 각 Linux 가상 머신(VM)에서 로컬 라우팅 테이블의 항목이 부하 분산기의 IP 주소로 지정된 트래픽을 네트워크 인터페이스 컨트롤러(NIC)로 라우팅합니다. 다음 예시는 이 기법을 보여줍니다.

root@backend-server:~# ip ro ls table local
local 10.128.0.2 dev eth0 proto kernel scope host src 10.128.0.2
broadcast 10.128.0.2 dev eth0 proto kernel scope link src 10.128.0.2
local 198.51.100.2 dev eth0 proto 66 scope host
broadcast 127.0.0.0 dev lo proto kernel scope link src 127.0.0.1
local 127.0.0.0/8 dev lo proto kernel scope host src 127.0.0.1
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1
broadcast 127.255.255.255 dev lo proto kernel scope link src 127.0.0.1

앞의 예시에서 198.51.100.2는 부하 분산기의 IP 주소입니다. google-network-daemon.service 에이전트는 이 항목 추가를 담당합니다. 하지만 다음 예시에서 보여주는 것처럼 VM은 부하 분산기의 IP 주소를 소유하는 인터페이스를 실제로 갖지 않습니다.

root@backend-server:~# ip ad ls
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1460 qdisc mq state UP group default qlen 1000
    link/ether 42:01:0a:80:00:02 brd ff:ff:ff:ff:ff:ff
    inet 10.128.0.2/32 brd 10.128.0.2 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::4001:aff:fe80:2/64 scope link
       valid_lft forever preferred_lft forever

외부 패스 스루 네트워크 부하 분산기는 대상 주소를 그대로 둔 상태로 수신 패킷을 백엔드 서버로 전송합니다. 로컬 라우팅 테이블 항목이 패킷을 올바른 앱 프로세스로 라우팅하고 앱의 응답 패킷이 클라이언트로 직접 전송됩니다.

다음 다이어그램은 외부 패스 스루 네트워크 부하 분산기의 작동 방식을 보여줍니다. 수신 패킷은 패킷을 백엔드 서버에 배포하는 Maglev라는 부하 분산기에 의해 처리됩니다. 그런 후 송신 패킷이 DSR을 통해 클라이언트에 직접 전송됩니다.

Maglev는 수신 패킷을 DSR을 통해 패킷을 배포하는 백엔드 서버로 배포합니다.

UDP 반환 패킷의 문제

DSR로 작업을 수행할 때 Linux 커널에서 TCP 및 UDP 연결이 처리되는 방법에는 약간의 차이가 있습니다. TCP가 스테이트풀(Stateful) 프로토콜이기 때문에 커널에는 클라이언트 주소, 클라이언트 포트, 서버 주소, 서버 포트를 비롯하여 TCP 연결에 대해 필요한 모든 정보가 포함됩니다. 이 정보는 연결을 나타내는 소켓 데이터 구조에 기록됩니다. 따라서 TCP 연결의 각 반환 패킷에는 서버 주소로 올바르게 설정된 소스 주소가 포함됩니다. 부하 분산기의 경우 이 주소가 부하 분산기의 IP 주소입니다.

하지만 UDP는 스테이트리스(Stateless)입니다. 따라서 UDP 연결을 위해 앱 프로세스에서 생성되는 소켓 객체에 연결 정보가 포함되지 않습니다. 커널은 송신 패킷의 소스 주소에 대한 정보를 갖지 않으며 이전에 수신된 패킷에 대한 관계를 알지 못합니다. 패킷의 소스 주소에 대해 커널은 반환 UDP 패킷이 이동하는 인터페이스의 주소만 채울 수 있습니다. 또는 앱이 이전에 소켓을 특정 주소에 바인딩한 경우 커널이 이 주소를 소스 주소로 사용합니다.

다음 코드는 간단한 echo 프로그램을 보여줍니다.

#!/usr/bin/python3
import socket,struct
def loop_on_socket(s):
  while True:
    d, addr = s.recvfrom(1500)
    print(d, addr)
    s.sendto("ECHO: ".encode('utf8')+d, addr)

if __name__ == "__main__":
   HOST, PORT = "0.0.0.0", 60002
   sock = socket.socket(type=socket.SocketKind.SOCK_DGRAM)
   sock.bind((HOST, PORT))
   loop_on_socket(sock)

다음은 UDP 변환 중 tcpdump 출력입니다.

14:50:04.758029 IP 203.0.113.2.40695 > 198.51.100.2.60002: UDP, length 3
14:50:04.758396 IP 10.128.0.2.60002 > 203.0.113.2.40695: UDP, length 2T

198.51.100.2는 부하 분산기의 IP 주소이고 203.0.113.2는 클라이언트 IP 주소입니다.

패킷이 VM을 나간 후 Google Cloud 네트워크의 또 다른 NAT 기기(Compute Engine 게이트웨이)가 소스 주소를 외부 주소로 변환합니다. 게이트웨이는 사용할 외부 주소를 알 수 없습니다. 따라서 부하 분산기의 주소가 아니라 VM의 외부 주소만 사용할 수 있습니다.

클라이언트 측에서 tcpdump의 출력을 확인할 경우 서버의 패킷은 다음과 같이 표시됩니다.

23:05:37.072787 IP 203.0.113.2.40695 > 198.51.100.2.60002: UDP, length 5
23:05:37.344148 IP 198.51.100.3.60002 > 203.0.113.2.40695: UDP, length 4

198.51.100.3은 VM의 외부 IP 주소입니다.

클라이언트의 관점에서 UDP 패킷은 클라이언트가 전송한 주소로부터 수신되지 않습니다. 따라서 커널이 이러한 패킷을 삭제하는 문제가 발생하고, 클라이언트가 NAT 기기 뒤에 있을 경우, NAT 기기도 패킷을 삭제하는 문제가 발생합니다. 따라서 클라이언트 앱이 서버로부터 응답을 받지 못합니다. 다음 다이어그램은 주소 불일치로 인해 클라이언트가 패킷 반환을 거부하는 프로세스를 보여줍니다.

클라이언트가 패킷 반환을 거부합니다.

UDP 문제 해결

무응답 문제를 해결하기 위해서는 앱을 호스팅하는 서버에서 송신 패킷의 소스 주소를 부하 분산기의 IP 주소로 다시 작성해야 합니다. 다음은 이러한 헤더 재작성을 수행하기 위해 사용할 수 있는 몇 가지 옵션입니다. 첫 번째 솔루션은 iptables로 Linux 기반 접근 방식을 사용하는 것입니다. 다른 솔루션에는 앱 기반 접근 방식이 사용됩니다.

다음 다이어그램은 이러한 옵션의 핵심 아이디어를 보여줍니다. 여기에서는 부하 분산기의 IP 주소와 일치하도록 반환 패킷의 소스 IP 주소를 재작성합니다.

부하 분산기의 IP 주소와 일치하도록 반환 패킷의 소스 IP 주소를 재작성합니다.

백엔드 서버에서 NAT 정책 사용

NAT 정책 솔루션은 부하 분산기의 IP 주소에서 VM의 IP 주소로 대상 주소를 재작성하기 위해 Linux iptables 명령어를 사용하는 것입니다. 다음 예시에서는 iptables DNAT 규칙을 추가하여 수신 패킷의 대상 주소를 변경합니다.

iptables -t nat -A POSTROUTING -j RETURN -d 10.128.0.2 -p udp --dport 60002
iptables -t nat -A PREROUTING -j DNAT --to-destination 10.128.0.2 -d 198.51.100.2 --dport 60002 -p udp

이 명령어는 2개 규칙을 iptables 시스템의 NAT 테이블에 추가합니다. 첫 번째 규칙은 로컬 eth0 주소를 대상으로 하는 모든 수신 패킷을 우회합니다. 따라서 부하 분산기에서 수신되지 않는 트래픽은 영향을 받지 않습니다. 두 번째 규칙은 수신 패킷의 대상 IP 주소를 VM의 내부 IP 주소로 변경합니다. DNAT 규칙은 스테이트풀(Stateful)입니다. 즉, 커널이 연결을 추적하고 반환 패킷의 소스 주소를 자동으로 재작성합니다.

장점 단점
앱을 변경할 필요 없이 커널이 주소를 변환합니다. NAT 수행을 위해 추가 CPU가 사용됩니다. 또한 DNAT가 스테이트풀(Stateful)이기 때문에 메모리 소비도 높을 수 있습니다.
다중 부하 분산기를 지원합니다.

nftables를 사용하여 스테이트리스(Stateless) 방식으로 IP 헤더 필드 수정

nftables 솔루션에서는 nftables 명령어를 사용하여 송신 패킷의 IP 헤더에서 소스 주소를 수정합니다. 이러한 수정은 스테이트리스(Stateless)입니다. 따라서 DNAT보다 리소스를 적게 소비합니다. nftables을 사용하려면 4.10을 초과하는 Linux 커널 버전이 필요합니다.

다음 명령어를 사용합니다.

nft add table raw
nft add chain raw postrouting {type filter hook postrouting priority 300)
nft add rule raw postrouting ip saddr 10.128.0.2 udp sport 60002 ip saddr set 198.51.100.2
장점 단점
앱을 변경할 필요 없이 커널이 주소를 변환합니다. 다중 부하 분산기를 지원하지 않습니다.
주소 변환 프로세스가 스테이트리스(Stateless)이므로 리소스 소비가 훨씬 작습니다. NAT를 수행하기 위해 추가 CPU가 사용됩니다.
nftables는 새 Linux 커널 버전에서만 제공됩니다. Centos 7.x와 같은 일부 배포판은 nftables를 사용할 수 없습니다.

앱이 부하 분산기의 IP 주소에 명시적으로 바인딩

바인딩 솔루션에서는 부하 분산기의 IP 주소로 명시적으로 바인딩하도록 앱을 수정합니다. UDP 소켓의 경우 bind 작업을 사용하면 해당 소켓을 사용하는 UDP 패킷을 전송할 때 커널이 소스 주소로 사용할 주소를 알 수 있습니다.

다음 예시에서는 Python에서 특정 주소로 바인딩하는 방법을 보여줍니다.

#!/usr/bin/python3
import socket
def loop_on_socket(s):
  while True:
    d, addr = s.recvfrom(1500)
    print(d, addr)
    s.sendto("ECHO: ".encode('utf8')+d, addr)

if __name__ == "__main__":
   # Instead of setting HOST to "0.0.0.0",
   # we set HOST to the Load Balancer IP
   HOST, PORT = "198.51.100.2", 60002
   sock = socket.socket(type=socket.SocketKind.SOCK_DGRAM)
   sock.bind((HOST, PORT))
   loop_on_socket(sock)

# 198.51.100.2 is the load balancer's IP address
# You can also use the DNS name of the load balancer's IP address

앞의 코드는 UDP 서버입니다. 앞의 "ECHO: "로 수신된 바이트를 다시 에코합니다. 여기에서는 부하 분산기의 IP 주소인 198.51.100.2 주소로 서버가 바인딩되는 12행 및 13행에 주의하세요.

장점 단점
간단한 앱 코드 변경으로 목표를 달성할 수 있습니다. 다중 부하 분산기를 지원하지 않습니다.

recvfrom/sendto 대신 recvmsg/sendmsg를 사용하여 주소 지정

이 솔루션에서는 recvfrom/sendto 호출 대신 recvmsg/sendmsg 호출을 사용합니다. recvfrom/sendto 호출과 달리 recvmsg/sendmsg 호출은 페이로드 데이트와 함께 보조 제어 메시지를 처리할 수 있습니다. 이러한 보조 제어 메시지에는 패킷의 소스 또는 대상 주소가 포함됩니다. 이 솔루션을 사용하면 수신 패킷으로부터 대상 주소를 가져올 수 있습니다. 그리고 이러한 주소가 실제 부하 분산기 주소이기 때문에 회신을 전송할 때 이를 소스 주소로 사용할 수 있습니다.

다음 예시 프로그램은 이 솔루션을 보여줍니다.

#!/usr/bin/python3
import socket,struct
def loop_on_socket(s):
  while True:
    d, ctl, flg, addr = s.recvmsg(1500, 1024)
    # ctl contains the destination address information
    s.sendmsg(["ECHO: ".encode("utf8"),d], ctl, 0, addr)

if __name__ == "__main__":
   HOST, PORT = "0.0.0.0", 60002
   s = socket.socket(type=socket.SocketKind.SOCK_DGRAM)
   s.setsockopt(0,   # level is 0 (IPPROTO_IP)
                8,   # optname is 8 (IP_PKTINFO)
                1)

   s.bind((HOST, PORT))
   loop_on_socket(s)

이 프로그램은 recvmsg/sendmsg 호출 사용 방법을 보여줍니다. 패킷에서 주소 정보를 가져오기 위해서는 setsockopt 호출을 사용하여 IP_PKTINFO 옵션을 설정해야 합니다.

장점 단점
다중 부하 분산기가 있는 경우에도 작동합니다. 예를 들어 동일한 백엔드에 내부 및 외부 부하 분산기가 모두 구성되어 있어도 작동합니다. 앱을 복잡하게 변경해야 합니다. 일부 경우에는 그러한 변경이 불가능할 수도 있습니다.

다음 단계