1 はじめに
コンテナ(test1、test2)を起動すると、ブリッジ(podman0)とveth(Virtual Ethernet)デバイスのペアが作成されます。vethデバイスの一方はコンテナに、もう一方はブリッジに接続されます。そして、test1からtest2へのpingを実行すると、 ICMP Echo Request パケットがブリッジを経由してtest2に到達します。次に、ICMP Echo Reply パケットが逆の経路を通ってtest1に戻ります。
この記事では、次の点を確認します。
・コンテナ起動時のブリッジデバイス、vethデバイスの生成過程
・ICMP echoパケットがやり取りされる際の処理概要
+------------+ +------------+ | test1 | | test2 | | | | | +--- eth0 ---+ +--- eth0 ---+ | | | | | | | | | | ICMP Echo Request | |ICMP Echo Reply | V | V | | | | +--- veth0 --------------------------- veth1 ---+ | | | podman0 | | | +-----------------------------------------------+
2 検証環境
ホストのAlmaLinux版数は以下のとおりです。
[root@server ~]# cat /etc/redhat-release AlmaLinux release 9.2 (Turquoise Kodkod)
カーネル版数は以下のとおりです。
[root@server ~]# uname -r 5.14.0-284.11.1.el9_2.x86_64
3 コンテナ起動時の処理概要
以下のコマンドを実行して、コンテナを起動します。なお、podmanコマンドの使い方は、podmanコマンドの使い方 - hana_shinのLinux技術ブログを参照してください。
[root@server ~]# podman start test1
コンテナを起動すると、コンテナはsocketシステムコールを実行します。この時、socketシステムコールの第一引数にはAF_NETLINKを指定します。AF_NETLINKはNetlinkインタフェースを操作するためのパラメータで、ユーザ空間とカーネル空間で情報をやり取りする時に使用されます。具体的には、コンテナはNetlinkインタフェースを通じて、カーネルに対してデバイス(podman0,veth)の作成や、デバイスに対するパラメータ設定などを指示します。他には、ipコマンドも同様にNetlinkインタフェースを使用して、カーネル空間に対して値の読み書きをします。Netlinkインタフェースについては、Netlinkプログラミングの書き方 - hana_shinのLinux技術ブログを参照してください。
netlink_socket = socket(AF_NETLINK, socket_type, netlink_family);
以下のSystemtapスクリプトで、socketシステムコール実行時のバックトレースを確認してみます。
[root@server ~]# cat tp.stp #!/usr/bin/stap probe module("veth").function("veth_newlink@drivers/net/veth.c") { printf("%s\n",print_backtrace()) }
SystemTapスクリプト内でvethモジュール内の関数を参照しているため、SystemTapスクリプトを実行する際には、vethカーネルモジュールがロードされていることを確認してください。
[root@server ~]# lsmod |grep veth veth 36864 0
Systemtapスクリプトを実行すると、以下のバックトレースが得られます。
最終的にveth_newlink関数が呼び出されています。この関数のバックトレースを表示するようにした理由は、ソースコードを読んで、vethデバイスのペア(例:veth0とeth0)がこの関数で作成されると考えたからです。なお、SystemTapの使い方は、SystemTapの使い方 - hana_shinのLinux技術ブログを参照してください。
[root@server ~]# stap -vg tp.stp -d kernel -snip 0xffffffffc0bc677c : veth_newlink+0x20c/0x3d0 [veth] 0xffffffff9fcf9d2f : __rtnl_newlink+0x72f/0x9c0 [kernel] 0xffffffff9fcfa004 : rtnl_newlink+0x44/0x70 [kernel] 0xffffffff9fcf6dce : rtnetlink_rcv_msg+0x13e/0x390 [kernel] 0xffffffff9fd6cf8e : netlink_rcv_skb+0x4e/0x100 [kernel] 0xffffffff9fd6c60b : netlink_unicast+0x23b/0x360 [kernel] 0xffffffff9fd6c968 : netlink_sendmsg+0x238/0x480 [kernel] 0xffffffff9fcb9a7f : sock_sendmsg+0x5f/0x70 [kernel] 0xffffffff9fcbb5e0 : __sys_sendto+0xf0/0x160 [kernel] 0xffffffff9fcbb670 : __x64_sys_sendto+0x20/0x30 [kernel] 0xffffffff9ff25159 : do_syscall_64+0x59/0x90 [kernel] 0xffffffffa000009b : entry_SYSCALL_64_after_hwframe+0x63/0xcd [kernel]
次に、veth_newlink関数の第二引数devのデバイス名と、1761行目のローカル変数peerのデバイス名を確認してみます。
1711 static int veth_newlink(struct net *src_net, struct net_device *dev, 1712 struct nlattr *tb[], struct nlattr *data[], 1713 struct netlink_ext_ack *extack) 1714 { 1715 int err; 1716 struct net_device *peer; -snip- 1761 peer = rtnl_create_link(net, ifname, name_assign_type, 1762 &veth_link_ops, tbp, extack); 1763 if (IS_ERR(peer)) {
デバイス名以外にも、以下の情報を表示するSystemtapスクリプトを作成しました。
- veth_newlinkを実行するコンテキスト(プロセス名または割り込みコンテキスト名)
- プローブを設定した関数名(veth_newlink)
- 第一引数devのデバイス名
- ローカル変数peerのデバイス名
- ローカル変数ifnameに設定されているデバイス名
[root@server ~]# cat tp.stp #!/usr/bin/stap probe module("veth").statement("veth_newlink@drivers/net/veth.c:1763") { printf("proc=%s, pp=%s, dev->name=%s, peer->name=%s, ifname=%s\n", task_execname(task_current()), pp(), kernel_string($dev->name), kernel_string($peer->name), kernel_string($ifname)) }
上記SystemTapを実行すると、以下の結果が得られました。
第一引数devのデバイス名が"veth%d"であることがわかります。vethの後に続く"%d"は後で数値(0, 1, 2, ...)に展開されます。また、ifnameはeth0であることがわかります。ifnameは、コンテナがNetlinkインタフェースを通してカーネルに作成指示をするデバイス名です。1761行目でifnameを引数にしてrtnl_create_link関数を実行しています。この関数は、net_device構造体(デバイスeth0の実体)をメモリから取得して、net_device構造体へのポインタ(peer)を返します。
[root@server ~]# stap -vg tp.stp -d kernel -snip proc=netavark, pp=module("veth").statement("veth_newlink@drivers/net/veth.c:1763"), dev->name=veth%d, peer->name=eth0, ifname=eth0
さらにソースを確認すると、1817行目以降でveth%dデバイスとeth0デバイスが相互に参照できるように、互いのpeerメンバにポインタを設定します。しかし、1823行目にSystemTapのプローブを設定しても実行されなかったため、eth0デバイスのpeerメンバからveth%dデバイスへのポインタはここでは設定されていないことがわかりました。この後どこかで設定されると思われますが、そこまでは調べていません。
1711 static int veth_newlink(struct net *src_net, struct net_device *dev, 1712 struct nlattr *tb[], struct nlattr *data[], 1713 struct netlink_ext_ack *extack) 1714 { -snip- 1813 /* 1814 * tie the deviced together 1815 */ 1816 1817 priv = netdev_priv(dev); 1818 rcu_assign_pointer(priv->peer, peer); 1819 err = veth_init_queues(dev, tb); 1820 if (err) 1821 goto err_queues; 1822 1823 priv = netdev_priv(peer); 1824 rcu_assign_pointer(priv->peer, dev); 1825 err = veth_init_queues(peer, tb);
最終的にvethデバイスのペア(veth0、eth0)は、以下のように、お互いのpeerメンバを使って相互に参照するようになります。vethデバイスの一方のデバイスからパケットを送信すると、peerメンバを参照して、もう一方のデバイスの受信キューに送信パケットをリンクします。
net_device net_device (veth0) (eth0) +------------------+ <-- dev peer --> +------------------+ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | netdev_priv(dev) | | | | | netdev_priv(peer) | | | | | | | | V | | | | | | V priv--> +------------------+ | | +------------------+ <--priv | peer -----------|--------+ | peer | | | +---------------|---- | +------------------+ +------------------+ | | | | | | | | +------------------+ +------------------+
なお、図中のnetdev_privはインライン関数です。include/linux/netdevice.hに定義されています。net_device構造体のサイズをNETDEV_ALIGN(=32バイト)の境界にアライメントしたサイズを、net_device構造体の先頭アドレスに加えています。
static inline void *netdev_priv(const struct net_device *dev) { return (char *)dev + ALIGN(sizeof(struct net_device), NETDEV_ALIGN); }
次に、rtnl_create_link関数の呼び出し元(caller())を確認するため、以下のSystemTapスクリプトを作成します。
[root@server ~]# cat tp.stp #!/usr/bin/stap probe kernel.function("rtnl_create_link@net/core/rtnetlink.c") { printf("proc=%s, pp=%s, func=%s, ifname=%s\n", task_execname(task_current()), pp(), caller(), kernel_string($ifname)) }
SystemTapスクリプトを実行すると、rtnl_create_link関数を呼び出す関数は以下の2つあることがわかりました。
呼び出し元関数名 | 作成するデバイス名 |
---|---|
__rtnl_newlink 関数 | ブリッジ(podman0)とブリッジに接続するデバイス(veth%d) |
veth_newlink 関数 | コンテナに接続するデバイス(eth0) |
[root@server ~]# stap -vg tp.stp -d kernel -snip- proc=netavark, pp=kernel.function("rtnl_create_link@net/core/rtnetlink.c:3172"), func=__rtnl_newlink 0xffffffff9c0f9cdc, ifname=podman0 proc=netavark, pp=kernel.function("rtnl_create_link@net/core/rtnetlink.c:3172"), func=__rtnl_newlink 0xffffffff9c0f9cdc, ifname=veth%d proc=netavark, pp=kernel.function("rtnl_create_link@net/core/rtnetlink.c:3172"), func=0xffffffffc09dd68d 0xffffffffc09dd68d, ifname=eth0
上記SystemTapの3つ目の実行結果は、呼び出し元関数がシンボル名ではなく16進数(0xffffffffc09dd68d)で表示されています。理由はカーネルモジュールの関数だからです。もしかしたら、SystemTapをうまく使えば、カーネルモジュールの関数をシンボル名で表示できるかもしれませんが、そこまで調べれなかったので、ここでは別の手段を使いました。crashコマンドのdisサブコマンドを使用して、このアドレスが該当するソースコードを確認してみました。結果は、veth_newlink関数であることがわかりました。つまり、veth_newlink関数がrtnl_create_link関数を呼び出していることが分かりました。なお、crashコマンドの使い方は、crashコマンドの使い方 - hana_shinのLinux技術ブログを参照してください。
crash> dis -l 0xffffffffc09dd68d /usr/src/debug/kernel-5.14.0-284.11.1.el9_2/linux-5.14.0-284.11.1.el9_2.x86_64/drivers/net/veth.c: 1761 0xffffffffc09dd68d <veth_newlink+285>: mov %rax,%r14
次に、veth_newlink関数の呼び出し元を確認します。
3278 static int __rtnl_newlink(struct sk_buff *skb, struct nlmsghdr *nlh, 3279 struct nlattr **attr, struct netlink_ext_ack *extack) 3280 { -snip- 3486 if (ops->newlink) 3487 err = ops->newlink(link_net ? : net, dev, tb, data, extack); 3488 else 3489 err = register_netdevice(dev);
上記3487行目のnewlinkメソッドは、drivers/net/veth.cで以下のように定義されています。メソッドの実体は、veth_newlink関数です。
1889 static struct rtnl_link_ops veth_link_ops = { 1890 .kind = DRV_NAME, 1891 .priv_size = sizeof(struct veth_priv), 1892 .setup = veth_setup, 1893 .validate = veth_validate, 1894 .newlink = veth_newlink, 1895 .dellink = veth_dellink, 1896 .policy = veth_policy, 1897 .maxtype = VETH_INFO_MAX, 1898 .get_link_net = veth_get_link_net, 1899 .get_num_tx_queues = veth_get_num_queues, 1900 .get_num_rx_queues = veth_get_num_queues, 1901 };
次に、ブリッジやvethデバイスの実体(net_device構造体)の取得場所をSystemTapを使って確認しました。私の検証環境では、3208行目ではなく3203行目でブリッジおよびvethデバイスの実体をメモリから取得しました。
3172 struct net_device *rtnl_create_link(struct net *net, const char *ifname, 3173 unsigned char name_assign_type, 3174 const struct rtnl_link_ops *ops, 3175 struct nlattr *tb[], 3176 struct netlink_ext_ack *extack) 3177 { 3178 struct net_device *dev; -snip- 3202 if (ops->alloc) { 3203 dev = ops->alloc(tb, ifname, name_assign_type, 3204 num_tx_queues, num_rx_queues); 3205 if (IS_ERR(dev)) 3206 return dev; 3207 } else { 3208 dev = alloc_netdev_mqs(ops->priv_size, ifname, 3209 name_assign_type, ops->setup, 3210 num_tx_queues, num_rx_queues); 3211 }
4 コンテナ間通信の処理概要
コンテナでpingを実行したときの処理の流れを確認してみます。
ここでもソースコードを読んで大体の見当をつけてから、veth_xmit関数にプローブを設定しました。veth_xmit関数の第一引数skbは送信パケットへのポインタ、第二引数devはパケットを送信するデバイスです。そして、324行目のrcvは、ペアとなるvethデバイスです。veth_xmit関数は、送信パケットをデバイスdevから送信して、ペアとなるデバイスrcvにキューイングする処理を実行します。SystemTapスクリプトを作成して、この処理を確認してみます。
319 static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev) 320 { 321 struct veth_priv *rcv_priv, *priv = netdev_priv(dev); 322 struct netdev_queue *queue = NULL; 323 struct veth_rq *rq = NULL; 324 struct net_device *rcv; -snip- 351 if (likely(veth_forward_skb(rcv, skb, rq, use_napi) == NET_RX_SUCCESS)) { 352 if (queue) 353 txq_trans_cond_update(queue); 354 if (!use_napi) 355 dev_lstats_add(dev, length); 356 } else { 357 drop: 358 atomic64_inc(&priv->dropped); 359 }
SystemTapの実行結果の表示を絞り込むため、送信元アドレスがコンテナ(test1)のIPアドレス(10.88.0.2)のときのみ実行結果を表示するようにしました。このような絞り込みをしないと、SystemTapの実行結果が大量に出力され、目的とする情報がわからなくなります。そのため、SystemTapを使う場合は、情報の絞り込みが重要になります。
[root@server ~]# cat tp.stp #!/usr/bin/stap probe module("veth").statement("veth_xmit@drivers/net/veth.c:351") { if(is_skb_conn($skb) == 1){ __get_skb_iphdr($skb) iphdr = __get_skb_iphdr($skb) daddr = format_ipaddr(__ip_skb_daddr(iphdr), AF_INET()) saddr = format_ipaddr(__ip_skb_saddr(iphdr), AF_INET()) printf("pp=%s, saddr=%s, daddr=%s, dev->name=%s, rcv->name=%s\n", pp(), saddr, daddr, kernel_string($dev->name), kernel_string($rcv->name)) } } function is_skb_conn(skb) { iphdr = __get_skb_iphdr(skb) daddr = format_ipaddr(__ip_skb_daddr(iphdr), AF_INET()) saddr = format_ipaddr(__ip_skb_saddr(iphdr), AF_INET()) if(saddr == "10.88.0.2") { return 1 } return 0 }
SystemTapスクリプトを実行すると、以下の結果が得られました。
- 1行目:コンテナ(test1)のeth0デバイスからブリッジのveth0へのパケット送信
- 2行目:ブリッジのveth1からコンテナ(test2)のeth0へのパケット送信
[root@server ~]# stap -vg tp.stp -d kernel -snip- pp=module("veth").statement("veth_xmit@drivers/net/veth.c:351"), saddr=10.88.0.2, daddr=10.88.0.3, dev->name=eth0, rcv->name=veth0 pp=module("veth").statement("veth_xmit@drivers/net/veth.c:351"), saddr=10.88.0.2, daddr=10.88.0.3, dev->name=veth1, rcv->name=eth0
次に351行目のveth_forward_skb関数について説明します。veth_forward_skb関数は次の処理を実行します。__dev_forward_skb関数でdevデバイスがアップしているかどうかや、送信パケットがdevデバイスのMTU長以下であるかどうかなどをチェックします。問題がなければ、veth_xdp_rx関数を呼び出して、送信パケットをキューイングします。具体的には、送信パケットのポインタをrcvデバイスのリングバッファに登録します。最後に__netif_rx関数を呼び出します。__netif_rx関数は、ハードウェア受信割り込みの中で呼び出される関数であり、ドライバとカーネルのインタフェース関数です。__netif_rx関数を呼び出すと、veth0デバイスでの受信処理が実行されます。
292 static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb, 293 struct veth_rq *rq, bool xdp) 294 { 295 return __dev_forward_skb(dev, skb) ?: xdp ? 296 veth_xdp_rx(rq, skb) : 297 __netif_rx(skb); 298 }
受信処理の中でブリッジが実行されます。ブリッジとは、パケットの宛先MACアドレスをMACアドレス学習テーブルで検索し、ブリッジの出力ポートを決定する処理です。この主な処理を実行するのがbr_forward関数です。br_forward関数の第一引数toは出力デバイス、第二引数skbは転送するパケットです。
135 /** 136 * br_forward - forward a packet to a specific port 137 * @to: destination port 138 * @skb: packet being forwarded 139 * @local_rcv: packet will be received locally after forwarding 140 * @local_orig: packet is locally originated 141 * 142 * Should be called with rcu_read_lock. 143 */ 144 void br_forward(const struct net_bridge_port *to, 145 struct sk_buff *skb, bool local_rcv, bool local_orig) 146 {
以下のSystemTapスクリプトを作成して、br_forward関数の引数を確認してみました。
[root@server ~]# cat tp.stp #!/usr/bin/stap probe module("bridge").function("br_forward@net/bridge/br_forward.c") { if(is_skb_conn($skb) == 1){ __get_skb_iphdr($skb) iphdr = __get_skb_iphdr($skb) daddr = format_ipaddr(__ip_skb_daddr(iphdr), AF_INET()) saddr = format_ipaddr(__ip_skb_saddr(iphdr), AF_INET()) printf("pp=%s, saddr=%s, daddr=%s, br->name=%s, dev->name=%s\n", pp(), saddr, daddr, kernel_string($to->br->dev->name), kernel_string($to->dev->name)) } } function is_skb_conn(skb) { iphdr = __get_skb_iphdr(skb) daddr = format_ipaddr(__ip_skb_daddr(iphdr), AF_INET()) saddr = format_ipaddr(__ip_skb_saddr(iphdr), AF_INET()) if(saddr == "10.88.0.2") { return 1 } return 0 }
SystemTapスクリプトを実行すると、以下の結果になりました。送信パケットがブリッジ(podman0)のveth1デバイスに転送されていることがわかります。
[root@server ~]# stap -vg tp.stp -d kernel -snip- pp=module("bridge").function("br_forward@net/bridge/br_forward.c:144"), saddr=10.88.0.2, daddr=10.88.0.3, br->name=podman0, dev->name=veth1
参考までに、SystemTapスクリプトで参照しているnet_bridge_port構造体とnet_bridge構造体を示します。これらの構造体は、net/bridge/br_private.hで以下のように定義されています。
349 struct net_bridge_port { 350 struct net_bridge *br; 351 struct net_device *dev; -snip- 454 struct net_bridge { 455 spinlock_t lock; 456 spinlock_t hash_lock; 457 struct hlist_head frame_type_list; 458 struct net_device *dev;
5 まとめ
X 参考情報
参考までに、Netlinkインタフェースでやりとりされるパケットを以下にしめします。Netlinkプロトコルの詳細は調べていませんが、以下はブリッジ(podman0)を作成するメッセージのようです。tcpdumpの使い方は、tcpdumpの使い方(基本編) - hana_shinのLinux技術ブログを参照してください。