最近のNICの中にはTCハードウェアオフロード機能を持っているものがあり、これを使えばNICハードウェア上でパケット転送処理に手を加えられることは以前のエントリで簡単に紹介した。
今回はこれを応用し、NICハードウェア上でL3スイッチングを実現できないか検討してみる。
前置き: tcを用いたレイヤ3パケットスイッチング
L3スイッチの動作はおおまかに、 1. 受信したパケットの宛先IPアドレスをスキャンし、 2. 宛先MACアドレスを書き換えた上で、 3. 出口ポートからパケットを送信する、 というステップから構成される。これらの動作はそれぞれ tc
の flower
フィルタ、pedit
アクション、mirred
アクションで表現することができる。
tcを用いたL3スイッチ動作表現の具体例
ここでは2個のNetwork Namespace ns0
, ns1
がL3 Master Device vrf-tc
経由で接続されている状況を考える。
+--------------------+ | vrf-tc | +-----+--------+-----+ | p0a | | p1a | +--+--+ +--+--+ |.1 .1| 10.0.0.0/30| |10.0.1.0/30 |.2 .2| +--+--+ +--+--+ | p0z | | p1z | +-----+ +-----+ ns0 ns1
上図の環境は以下のコマンドで作成できる:
sudo ip link add vrf-tc type vrf table 10 sudo ip link set vrf-tc up for i in $(seq 0 1); do sudo ip netns add ns${i} sudo ip link add p${i}a type veth peer name p${i}z sudo ip link set p${i}z netns ns${i} sudo ip link set p${i}a master vrf-tc sudo ip link set p${i}a up sudo ip -n ns${i} link set p${i}z up sudo ip addr add 10.0.${i}.1/30 dev p${i}a sudo ip -n ns${i} addr add 10.0.${i}.2/30 dev p${i}z sudo ip -n ns${i} route add 0.0.0.0/0 via 10.0.${i}.1 done
このとき、ns0
からns1
の10.0.1.2
宛通信フローが存在するとして、これをtc
で処理するならば以下のようになる:
sudo tc qdisc add dev p0a ingress sudo tc filter add dev p0a ingress protocol ip \ flower dst_ip 10.0.1.2/32 \ action pedit ex munge eth dst set $(sudo ip -n ns1 -j link show dev p1z | jq -r '.[0].address') \ pipe mirred egress redirect dev p1a
この状態でns0
からns1
の10.0.1.2
宛にping
を打ったのちtc -s
コマンドの出力を確認すると、この通信がどうやらtc
によって処理されたことが確認できる:
$ sudo ip netns exec ns0 ping -c 10 10.0.1.2
$ tc -s filter show dev p0a ingress filter protocol ip pref 49152 flower chain 0 filter protocol ip pref 49152 flower chain 0 handle 0x1 eth_type ipv4 dst_ip 10.0.1.2 not_in_hw action order 1: pedit action pipe keys 2 index 1 ref 1 bind 1 installed 68 sec used 18 sec key #0 at eth+0: val ce884422 mask 00000000 key #1 at eth+4: val a5220000 mask 0000ffff Action statistics: Sent 840 bytes 10 pkt (dropped 0, overlimits 0 requeues 0) backlog 0b 0p requeues 0 action order 2: mirred (Egress Redirect to device p1a) stolen index 1 ref 1 bind 1 installed 68 sec used 18 sec Action statistics: Sent 840 bytes 10 pkt (dropped 0, overlimits 0 requeues 0) backlog 0b 0p requeues 0
このように、tc
を使用するとL3スイッチの挙動を模擬することができる。
本題: SR-IOV switchdev mode + TCハードウェアオフロードによる、NICハードウェア上でのL3スイッチング
ここからは、SR-IOV switchdev modeとTCハードウェアオフロードを組み合わせることにより、NICハードウェア上でパケット転送の挙動を制御できることを確認する。その具体例として、前述したTCによるL3スイッチングをNICハードウェア上で実現してみることにする。
SR-IOV switchdev modeでNICハードウェアの挙動を制御する
VM環境で高パフォーマンスなネットワーク環境を実現する手段として、SR-IOVによるNIC仮想化が従来より用いられている。SR-IOV VFをVMにアタッチすればそのVMはNICのハードウェア性能を直接享受できる反面、VMのネットワークが完全にハイパーバイザのソフトウェアスタックをバイパスされるため、ハイパーバイザ側からVMのネットワークの挙動を制御することは難しかった。
この問題を解決するのがSR-IOV switchdev modeである。SR-IOV switchdev modeにおいては、通常のVFとは別にVF representorと呼ばれるインタフェースが作成される。ハイパーバイザ上からこのVF reporesentorに設定を行うと、内容に応じてそのrepresentorに対応するVFに設定が反映される。たとえば下図でVF0 repに対してtcの設定を行ったとすると、その設定がハードウェアオフロード可能であればVF0に適用される。
+---+ +---+ +---+ |PF0| |VF0| |VF1| |rep| |rep| |rep| +-+-+ +-+-+ +-+-+ VM0 VM1 ^ ^ ^ +---+ +---+ | | | |VF0| |VF1| +--+-----+-----+--+ +-+-+ +-+-+ | NIC Driver | ^ ^ +------+----------+ | | Kernel | | | +-------------------------------+ | | NIC | | | +------+-----------------------+-----+----+ | Embeded Switch | +-----+-----------------------------------+ | | v PF0
なお、SR-IOV switchdev modeではデフォルトで全てのトラフィックがソフトウェア処理される点に注意が必要である。上図VM0から送信されたポケットは、何も設定しなればVF0 repを経由してハイパーバイザに流入する。一方、ハイパーバイザからVF0 repに送信したパケットはVM0のVF0で受信される。 この特性を利用すれば、ARPやトラフィックフローの初期段階はハイパーバイザのソフトウェアパスで処理し、ハードウェア処理の準備が整ったらハードウェアパスでのパケット転送に移行する、という動作が実現できる。実例としてOpen vSwitchのハードウェアオフロード機能はこの挙動を利用している。
SR-IOV switchdev modeの設定方法
まずSR-IOV switchdev modeの設定方法を確認する。通常のSR-IOV動作モード(legacy modeと呼ばれる)からswitchdev modeに移行するにはdevlink
コマンドを使用する。ここでは2個のSR-IOV VFを作成し、switchdev modeで使用する。
PCI Bus IDやインタフェース名、ドライバ名は各環境に応じて。
なお今回の環境は以下の通り:
# enp7s0f0 = PF, 0000:07:00.0 echo 2 | sudo tee /sys/class/net/enp7s0f0/device/sriov_numvfs for i in $(seq 2 3); do echo 0000:07:00.$i | sudo tee /sys/bus/pci/drivers/mlx5_core/unbind done sudo devlink dev eswitch set pci/0000:07:00.0 mode switchdev
2個のVF representorが作成されていることが確認できる。
$ ip link (snip) 17: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 link/ether f6:60:41:6a:4d:6e brd ff:ff:ff:ff:ff:ff 18: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 link/ether 9e:a5:d8:0b:b4:a5 brd ff:ff:ff:ff:ff:ff $ ethtool -i eth0 driver: mlx5e_rep (snip)
これらのVF representatorをL3 Master Device vrf-tchw
経由で接続する。また、簡単のためVFをそれぞれNetwork Namespacens2
, ns3
にアタッチする。
sudo ip link add vrf-tchw type vrf table 20 sudo ip link set vrf-tchw up for i in $(seq 2 3); do sudo ip netns add ns${i} sudo ip link set eth$(echo ${i}-2|bc) master vrf-tchw echo 0000:07:00.$i | sudo tee /sys/bus/pci/drivers/mlx5_core/bind sudo ip link set enp7s0f${i} netns ns${i} sudo ip link set eth$(echo ${i}-2|bc) up sudo ip -n ns${i} link set enp7s0f${i} up sudo ip addr add 10.0.${i}.1/30 dev eth$(echo ${i}-2|bc) sudo ip -n ns${i} addr add 10.0.${i}.2/30 dev enp7s0f${i} sudo ip -n ns${i} route add 0.0.0.0/0 via 10.0.${i}.1 done
図で表すとこのようになる:
+---------------+ | vrf-tchw | +--------+------+ | eth0 | | eth1 | +------+ +------+ ns2 ns3 |.2 .2| +----------+ +----------+ 10.0.2.0/30| |10.0.3.0/30 | enp7s0f2 | | enp7s0f3 | |.1 .1| +-----+----+ +-----+----+ +-----------------+ ^ ^ | NIC Driver | | | +------+----------+ | | Kernel | | | +-------------------------------+ | | NIC | | | +------+-------------------------+------------+----+ | Embeded Switch | +--------------------------------------------------+
この状態でns2
からns3
の10.0.3.2
宛にping
が通るようになっている。また、tcpdump -i eth0
を実行すると、この通信がどうやらVF representorを経由していることが分かる:
$ sudo ip netns exec ns2 ping 10.0.3.2
$ sudo tcpdump -i eth0 -nn tcpdump: verbose output suppressed, use -v or -vv for full protocol decode listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes 14:35:11.750957 ARP, Request who-has 10.0.2.1 tell 10.0.2.2, length 46 14:35:11.751018 ARP, Reply 10.0.2.1 is-at f6:60:41:6a:4d:6e, length 28 14:35:11.751119 IP 10.0.2.2 > 10.0.3.2: ICMP echo request, id 5965, seq 1, length 64 14:35:11.751503 IP 10.0.3.2 > 10.0.2.2: ICMP echo reply, id 5965, seq 1, length 64
TCでFIBをハードウェアにオフロード
ここまで準備が整えば、あとは最初の例と同様にVF representor経由でTCの設定を行えばよい。 ここではLinuxカーネルのFIBとネイバーテーブルからtcフィルタを生成して適用する簡易的なpythonスクリプトを用意した。
#!/usr/bin/env python3 | |
import pyroute2 | |
import socket | |
from pyroute2.netlink import rtnl | |
import subprocess | |
import time | |
from operator import itemgetter | |
class TCL3Switch(object): | |
def __init__(self, l3mdev_ifname, block=1, chain=0): | |
self._ipr = pyroute2.IPRoute() | |
self._l3mdev_ifindex = self._get_ifindex(l3mdev_ifname) | |
self._vrf_table_id = self._get_vrf_table_id(self._l3mdev_ifindex) | |
self._l3mdev_slaves = self._get_l3mdev_slaves(self._l3mdev_ifindex) | |
self._block = block | |
self._chain = chain | |
def _get_ifindex(self, ifname): | |
try: | |
return self._ipr.link_lookup(ifname=ifname)[0] | |
except IndexError as e: | |
raise ValueError(f"ifname {ifname} is missing") from e | |
def _get_vrf_table_id(self, ifindex): | |
link = self._ipr.get_links(ifindex)[0] | |
for linkinfo in link.get_attrs("IFLA_LINKINFO"): | |
if linkinfo.get_attr("IFLA_INFO_KIND") == "vrf": | |
table_id = linkinfo.get_attr("IFLA_INFO_DATA").get_attr("IFLA_VRF_TABLE") | |
if table_id is not None: | |
return table_id | |
raise ValueError(f"Failed to find VRF table id for ifindex {l3mdev_ifindex}") | |
def _get_l3mdev_slaves(self, ifindex): | |
slaves = self._ipr.get_links(*self._ipr.link_lookup(master=ifindex)) | |
slave_map = dict([(l["index"], l.get_attr("IFLA_IFNAME")) for l in slaves]) | |
return slave_map | |
def _build_neighbour_flows(self): | |
neighbours = self._ipr.get_neighbours(state=rtnl.ndmsg.NUD_REACHABLE, family=socket.AF_INET) | |
for neigh in neighbours: | |
if neigh["ifindex"] in self._l3mdev_slaves.keys(): | |
flow = dict( | |
dst=neigh.get_attr("NDA_DST"), | |
dst_len=32, | |
action="redirect", | |
redirect_mac=neigh.get_attr("NDA_LLADDR"), | |
redirect_ifindex=neigh["ifindex"], | |
) | |
yield flow | |
def _build_route_flows(self): | |
routes = self._ipr.get_routes(table=self._vrf_table_id, family=socket.AF_INET) | |
for route in routes: | |
dst = route.get_attr("RTA_DST") | |
dst_len = route["dst_len"] | |
oif = route.get_attr("RTA_OIF") | |
gateway = route.get_attr("RTA_GATEWAY") | |
if dst is None and dst_len == 0: | |
# default route | |
dst = "0.0.0.0" | |
if route["type"] == rtnl.rt_type["unicast"] and gateway and oif: | |
try: | |
gateway_neigh = self._ipr.get_neighbours(state=rtnl.ndmsg.NUD_REACHABLE, ifindex=oif, | |
dst=gateway)[0] | |
flow = dict( | |
dst=dst, | |
dst_len=dst_len, | |
action="redirect", | |
redirect_mac=gateway_neigh.get_attr("NDA_LLADDR"), | |
redirect_ifindex=oif, | |
) | |
yield flow | |
except IndexError: | |
pass | |
def _build_flows(self): | |
flows = list(self._build_neighbour_flows()) + list(self._build_route_flows()) | |
flows.sort(key=itemgetter("dst_len"), reverse=True) | |
return flows | |
def _generate_flower_filters(self): | |
flows = self._build_flows() | |
for flow in flows: | |
command = f"flower dst_ip {flow['dst']}/{flow['dst_len']}" | |
if flow["action"] == "redirect": | |
ifname = self._l3mdev_slaves[flow['redirect_ifindex']] | |
command += f" action pedit ex munge eth dst set {flow['redirect_mac']}" | |
command += f" pipe mirred egress redirect dev {ifname}" | |
yield command | |
def set_ingress_qdisc(self): | |
for ifname in self._l3mdev_slaves.values(): | |
command = f"tc qdisc add dev {ifname} ingress_block {self._block} ingress" | |
subprocess.run(command, shell=True) | |
def install_filters(self, pref_start): | |
filters = list(self._generate_flower_filters()) | |
for pref, flower_filter in enumerate(filters, start=pref_start): | |
command = f"tc filter add block {self._block} protocol ip chain {self._chain} pref {pref} {flower_filter}" | |
subprocess.run(command, shell=True) | |
return len(filters) | |
def delete_filters(self, pref_start, num): | |
for pref in range(pref_start + num - 1, pref_start - 1, -1): | |
command = f"tc filter del block {self._block} protocol ip chain {self._chain} pref {pref}" | |
subprocess.run(command, shell=True) | |
def run(self, pref_offset=(1, 1001)): | |
pref_index = 0 | |
pref_start = pref_offset[pref_index] | |
num_old = 0 | |
while True: | |
num_new = self.install_filters(pref_start) | |
pref_index = (pref_index + 1) % 2 | |
pref_start = pref_offset[pref_index] | |
self.delete_filters(pref_start, num_old) | |
num_old = num_new | |
time.sleep(1) | |
if __name__ == '__main__': | |
import sys | |
l3mdev_ifname = sys.argv[1] | |
l3sw = TCL3Switch(l3mdev_ifname) | |
l3sw.set_ingress_qdisc() | |
l3sw.run() |
上記スクリプトを実行した状態でns2
からns3
の10.0.3.2
宛にping
を打ちつつtc filter show
コマンドを実行すると、tcフィルタが適用されており、かつin_hw
表示がある通りハードウェアオフロードされている様子が確認できる。
$ tc filter show block 1 filter protocol ip pref 1 flower chain 0 filter protocol ip pref 1 flower chain 0 handle 0x1 eth_type ipv4 dst_ip 10.0.3.2 in_hw action order 1: pedit action pipe keys 2 index 1 ref 1 bind 1 key #0 at eth+0: val 92ef51a2 mask 00000000 key #1 at eth+4: val 33e80000 mask 0000ffff action order 2: mirred (Egress Redirect to device eth1) stolen index 1 ref 1 bind 1 filter protocol ip pref 2 flower chain 0 filter protocol ip pref 2 flower chain 0 handle 0x1 eth_type ipv4 dst_ip 10.0.2.2 in_hw action order 1: pedit action pipe keys 2 index 2 ref 1 bind 1 key #0 at eth+0: val 4e6279b1 mask 00000000 key #1 at eth+4: val 159f0000 mask 0000ffff action order 2: mirred (Egress Redirect to device eth0) stolen index 2 ref 1 bind 1
なお、今回は簡単のためフィルタを1秒毎に更新する実装になっているが、本来はnelink
をリッスンしてイベントドリブンにフィルタを更新すべきだろう。また、ネイバーテーブル上でREACHABLE
なネイバー宛経路のみオフロードするよう実装しているが、エントリがSTELE
状態に遷移するとすぐにオフロードが中止されてしまう、という問題もある。
まとめ
SR-IOV switchdev modeとTCハードウェアオフロードを組み合わせることで、一般的なNICのハードウェア上でレイヤ3スイッチと同様の処理を実現できることを確認した。今回は簡易的なpythonエージェントでオフロード機構を実装したため実用性は低いが、もしこれらの処理がLinuxカーネルやルーティングデーモン等によって透過的に実行されるようになれば面白いかもしれない。
また、今回はL3スイッチングを例としたが、他のパケット処理、たとえばACLやトンネリング(カプセリング)処理にももちろん適用可能である。従来のSR-IOV legacy modeは柔軟性の低さがひとつのネックとなっていたが、switchdev modeとTCハードウェアオフロードの組み合わせにより、高パフォーマンスを維持しつつより多くの問題に適用範囲が広がることを期待したい。