Google Cloud ネットワーク負荷分散で UDP を使用する方法

このドキュメントでは、User Datagram Protocol(UDP)を使用してネットワーク負荷分散を行う方法について説明します。このドキュメントは、アプリ デベロッパー、アプリの運用担当者、ネットワーク管理者を対象としています。

UDP の概要

UDP はアプリでよく使用されます。このプロトコルは RFC-768 で記述され、ステートレスで信頼性が低いデータグラム パケット サービスを実装しています。たとえば、Google の QUIC プロトコルは、UDP を使用してストリームベースのアプリを高速化することで、ユーザー エクスペリエンスを向上させます。

UDP のステートレスな部分は、トランスポート層が状態を保持しないことを意味します。したがって、UDP 接続の個々のパケットは独立しています。UDP には実際の接続はありません。代わりに、2 つのタプル(ip:port)または 4 タプル(src-ip:src-portdest-ip:dest-port)を使用して相互に認識します。

TCP ベースのアプリと同様に、UDP ベースのアプリはロードバランサにもメリットがあります。このため、UDP シナリオではネットワーク負荷分散が使用されます。

ネットワーク負荷分散

ネットワーク負荷分散はパススルー ロードバランサです。受信パケットを処理し、パケットを保管しているバックエンド サーバーに配信します。その後、バックエンド サーバーは戻りパケットを直接クライアントに送信します。この手法は、Direct Server Return(DSR)と呼ばれます。Compute Engine で動作し、Google Cloud ネットワーク ロードバランサのバックエンドである 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 はステートフル プロトコルであるため、カーネルは、クライアント アドレス、クライアント ポート、サーバー アドレス、サーバーポートなど、TCP 接続に必要なすべての情報を保持しています。この情報は、接続を表すソケットのデータ構造に記録されます。したがって、TCP 接続の戻りパケットごとに、送信元アドレスがサーバー アドレスに正しく設定されています。ロードバランサの場合、このアドレスはロードバランサの IP アドレスになります。

ただし、UDP はステートレスであるため、UDP 接続のアプリプロセスで作成されたソケット オブジェクトには接続情報がありません。カーネルには、送信パケットの送信元アドレスに関する情報がなく、以前に受信したパケットとの関連がわかりません。パケットの送信元アドレスに対して、カーネルは UDP 戻りパケットの宛先であるインターフェースのアドレスのみを提供します。一方、アプリがソケットを特定のアドレスにバインドしていた場合、カーネルはそのアドレスを送信元アドレスとして使用します。

次のコードは簡単なエコー プログラムです。

#!/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 アドレスに書き換える必要があります。このヘッダーを書き換えるには、次の方法があります。1 つは iptables を使用する Linux ベースの方法、もう 1 つはアプリベースの方法です。

次の図は、これらのオプションの基本的な概念を表しています。戻りパケットの送信元 IP アドレスを書き換えて、ロードバランサの IP アドレスと一致させています。

返されたパケットの送信元 IP アドレスを書き換えて、ロードバランサの IP アドレスと一致するようにします。

バックエンド サーバーで NAT ポリシーを使用する

NAT ポリシーを使用する解決策は、Linux iptables コマンドを使用して、宛先 IP アドレスをロードバランサの IP アドレスから VM の IP アドレスに書き換えるというものです。次の例では、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

このコマンドは、iptables システムの NAT テーブルに 2 つのルールを追加します。最初のルールは、ローカル eth0 アドレスを宛先としているすべての受信パケットをバイパスします。その結果、ロードバランサを経由しないトラフィックは影響を受けません。2 番目のルールは、受信パケットの宛先 IP アドレスを VM の内部 IP アドレスに変更します。DNAT ルールはステートフルです。カーネルは接続を追跡し、戻りパケットの送信元アドレスを自動的に書き換えます。

長所 短所
カーネルがアドレスを変換します。アプリに変更を行う必要はありません。 NAT に追加の CPU が使用されます。DNAT はステートフルのため、メモリ使用量も多くなる可能性があります。
複数のロードバランサがサポートされます。

nftables を使用して IP ヘッダー フィールドをステートレスにマングリングする

ntfables を使用して解決する場合は、ntfables コマンドを使用して、送信パケットの IP ヘッダーの送信元アドレスをマングリングします。このマングリングはステートレスであるため、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
長所 短所
カーネルがアドレスを変換します。アプリに変更を行う必要はありません。 複数のロードバランサはサポートされていません。
アドレス変換プロセスはステートレスのため、リソースの使用量ははるかに少なくなります。 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: " を付加してエコーします。12 行目と 13 行目を見てください。サーバーがロードバランサの IP アドレス 198.51.100.2 にバインドされています。

長所 短所
アプリに対して簡単なコード変更を行います。 複数のロードバランサはサポートされていません。

アドレス指定には 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 オプションを設定する必要があります。

長所 短所
内部ロードバランサと外部ロードバランサが同じバックエンドに構成されている場合など、複数のロードバランサがある場合でも機能します。 アプリに複雑な変更を行う必要があります。場合によっては変更が不可能なこともあります。

次のステップ