hana_shinのLinux技術ブログ

Linuxの技術情報を掲載しています。特にネットワークをメインに掲載していきます。

コンテナ起動時、コンテナ間通信の処理概要(Podman編)



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 まとめ

  • コンテナを起動すると、コンテナとブリッジを接続するvethデバイスのペアが作成されます。一方はコンテナに、もう一方はブリッジに接続されます。
  • vethデバイスの一方からパケットを送信すると、もう一方のデバイスで受信します。
  • ブリッジは、vethデバイスで受信したパケットを宛先MACアドレスに基づいてブリッジの出力先デバイスから送信します。
  • ブリッジの出力先デバイスから送信されるパケットは、コンテナに接続されている、もう一方のデバイスで受信されます。

X 参考情報

参考までに、Netlinkインタフェースでやりとりされるパケットを以下にしめします。Netlinkプロトコルの詳細は調べていませんが、以下はブリッジ(podman0)を作成するメッセージのようです。tcpdumpの使い方は、tcpdumpの使い方(基本編) - hana_shinのLinux技術ブログを参照してください。


Y 参考図書

単行本

電子書籍