ふつうのNICでハードウェアL3スイッチング; あるいはSR-IOV switchdev modeとTC hardware offloadの使用例

最近のNICの中にはTCハードウェアオフロード機能を持っているものがあり、これを使えばNICハードウェア上でパケット転送処理に手を加えられることは以前のエントリで簡単に紹介した。

yunazuno.hatenablog.com

今回はこれを応用し、NICハードウェア上でL3スイッチングを実現できないか検討してみる。

前置き: tcを用いたレイヤ3パケットスイッチング

L3スイッチの動作はおおまかに、 1. 受信したパケットの宛先IPアドレスをスキャンし、 2. 宛先MACアドレスを書き換えた上で、 3. 出口ポートからパケットを送信する、 というステップから構成される。これらの動作はそれぞれ tcflower フィルタ、pedit アクション、mirred アクションで表現することができる。

tcを用いたL3スイッチ動作表現の具体例

ここでは2個のNetwork Namespace ns0, ns1 がL3 Master Device vrf-tc 経由で接続されている状況を考える。

yunazuno.hatenablog.com

        +--------------------+
        |       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からns110.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からns110.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にアタッチすればそのVMNICのハードウェア性能を直接享受できる反面、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やインタフェース名、ドライバ名は各環境に応じて。

なお今回の環境は以下の通り:

  • Fedora 28 4.17.3-200.fc28.x86_64
  • MCX512A-ACAT fw 16.23.1000
# 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からns310.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()
view raw tcl3switch.py hosted with ❤ by GitHub
gist.github.com

上記スクリプトを実行した状態でns2からns310.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ハードウェアオフロードの組み合わせにより、高パフォーマンスを維持しつつより多くの問題に適用範囲が広がることを期待したい。