Linux カーネルをバイパスして TCP 通信を 10 倍速くする

2023年12月15日 金曜日


【この記事を書いた人】
安形

研究所でシステムソフトウェアの研究に取り組んでいます。

「Linux カーネルをバイパスして TCP 通信を 10 倍速くする」のイメージ

IIJ 2023 TECHアドベントカレンダー 12/16の記事です】

この記事について

  • 背景:TCP はコンピュータネットワークの通信において広く利用されているプロトコル・標準化された通信規格です。コンピュータは TCP/IP スタックと呼ばれるようなソフトウェアを実行することで、定められた規格に則って通信を行います。汎用 OS 環境では、TCP/IP スタックは多くの場合、カーネル空間に OS 機能の一部として実装されています。
  • 課題:通信に関するソフトウェアの研究コミュニティでは、そのようなカーネル空間に実装されている TCP/IP スタックは、近年の高速な NIC の性能を十分に引き出すことが難しいという課題が指摘されてきました。
  • テクニックの紹介:当記事では、近年の研究コミュニティにおいて比較的一般的な高速化テクニックとされている「カーネルをバイパス(迂回)して TCP 通信を高速化する」方法について紹介します。簡単に実験してみると、カーネルをバイパスした場合、既存の Linux カーネルの TCP/IP スタックと比較して10倍以上の性能が観測されました。
  • 補足情報:末尾に、 TCP/IP とは?プロトコルとは?TCP/IP スタックはどのように実装されているか?等の補足や背景情報についてまとめておりますので、よろしければ適宜ご覧ください。

カーネル内の TCP/IP スタック実装

通信に関するソフトウェアを以下3つの要素

  • アプリケーションプログラム
  • TCP/IP スタック
  • NIC のデバイスドライバ

に分けてみると、汎用 OS 環境での構成は以下のようになります。

 User-space [    Application    ]
---------------| Socket API |-----
   Kernel   [    TCP/IP Stack   ]
            [ NIC Device Driver ]
--------------------|   |---------
                   < NIC >

簡単な受信と送信の流れ

受信処理
  • パケットが NIC で受信された際には、
  • NIC のデバイスドライバが受信したパケットを TCP/IP スタックへ渡し、
  • TCP/IP スタックは渡されたパケットについてプロトコル処理を行った上で、Socket API(カーネルが提供するインタフェース)を通じてアプリケーションへデータを渡します。
送信処理
  • アプリケーションは、送りたいデータを Socket API (カーネルが提供するインタフェース)を通じて TCP/IP スタックへ渡し、
  • TCP/IP スタックは、そのデータについてプロトコルのヘッダを追加してパケットにした後、デバイスドライバへ渡し、
  • デバイスドライバは、NIC に対してそのパケットを送信するようリクエストします。

もう少し詳しい挙動については参考資料1・2をご覧ください。

性能面での課題

過去の研究では、汎用 OS のカーネルに実装されている TCP/IP スタックには、次のような性能面での課題が指摘されています。(参考資料1・2にはもう少し詳しい記載がありますので、よろしければそちらもご覧ください。)

  • ユーザ空間とカーネル空間を取り持つ Socket API は、システムコールとよばれる、ユーザ空間からカーネル空間へコンテキストを切り替える操作の上に成立しており、頻繁にシステムコールを通じて通信を行うアプリケーションでは、CPU がコンテキスト切り替えのために費やす時間が多くなり、アプリケーションの処理のために使える CPU 時間が減ってしまう。
  • CPU が実行しているユーザ空間プロセスやカーネルスレッドの切り替えに(プロセス・スレッド)スケジューリングのコストが伴う。
  • データの送受信に際して、ユーザ空間とカーネル空間の間で、比較的時間のかかる操作であるメモリコピーが必要である。

上記の点は、カーネルをバイパスして、ユーザ空間で TCP/IP スタックを動作させる、というアプローチで改善が可能です。

カーネルをバイパスして TCP 通信を行う方法

カーネルをバイパスして、アプリケーションが TCP で通信を行えるようにするためのポイントは主に以下2点です。

  • パケット I/O フレームワーク (例:Data Plane Development Kit (DPDK), netmap) を利用して、ユーザ空間のアプリケーションがカーネルをバイパスして NIC から I/O を行えるようにする。
  • カーネルをバイパスしてしまうと、カーネル内の TCP/IP スタック実装が利用できないため、新たにユーザ空間に TCP/IP スタック実装を配置する。

上記の構成を絵にすると以下のようになります。

                                   [     Application      ]
 User-space                        [     TCP/IP Stack     ]
                                   [ Packet I/O Framework ]
--------------------------------------------|   |-----------
   Kernel   [    TCP/IP Stack   ]           |   |
            [ NIC device Driver ]           |   |
--------------------------------------------|   |-----------
                                           < NIC >

この構成により、アプリケーションと TCP/IP スタックの間でのやりとりを簡略化することができ(システムコールを通じた TCP/IP 通信機能の呼び出しやプロセス・スレッド間のやりとりをなくせる)、結果として、カーネル内に実装されている TCP/IP スタックを利用する場合と比較して性能の向上が期待できます。

カーネルをバイパスした TCP/IP スタック実装を試してみる

実際に、カーネルをバイパスした TCP/IP スタックの性能がどの程度になるか試してみます。

今回は、lwIP というポータブルな TCP/IP スタック実装と DPDK を組み合わせて利用します。実験に利用した実装はこちらの URL ( GitHub ) からご覧いただけます。

構成を絵に付け加えると、以下のようになります。

                 Server Program => [     Application      ]
 User-space                lwIP => [     TCP/IP Stack     ]
                           DPDK => [ Packet I/O Framework ]
--------------------------------------------|   |-----------
   Kernel   [    TCP/IP Stack   ]           |   |
            [ NIC device Driver ]           |   |
--------------------------------------------|   |-----------
                                           < NIC >

簡単な実験

サーバ・OS

実験には2台の同じ設定のマシンを利用しました。1台がサーバプログラム実行用、もう1台はクライアントプログラム実行用として利用します。

  • CPU: 2 x 16-core Intel Xeon Gold 6326 CPU @ 2.90GHz (合計 32 CPU コア)
  • NIC: Mellanox ConnectX-5 100 Gbps(サーバプログラム用マシンとクライアントプログラム用マシンの間はケーブルで直接接続されています。)
  • OS: Linux 6.2
ワークロード

クライアントは TCP のペイロードサイズが4バイトのリクエストメッセージを送り、サーバは TCP のペイロードサイズが 64 バイトのレスポンスメッセージを送り返します。

サーバは Linux カーネルの TCP/IP スタックを利用したもの、もしくは lwIP と DPDK を組み合わせたものの2通りを試します。クライアントのプログラムは次の項「更に性能を求めて TCP/IP スタックを自作する」で利用したものと同じ実装を利用しました。

サーバプログラムは1CPU コアを利用し1スレッド (pthread) で動作、クライアントは 16 CPU コアを利用し 16 スレッド (pthread) で動作します。これらのサーバとクライアントが合計 16 並列 TCP 接続(接続確立後は切断しない)を通してできるだけ高速にリクエスト・レスポンスを交換します。(クライアントは1スレッド (pthread) あたり1TCP 接続を使って通信を行うことになります。)また、この設定でサーバに対して十分な負荷がかけられていることは確認済みです。

計測結果

計測結果は以下のようになりました。

  • Linux カーネル内の TCP/IP スタック実装:0.243 million リクエスト毎秒
  • lwIP + DPDK:2.409 million リクエスト毎秒

Linux カーネル内の TCP/IP スタックと比較して DPDK の上で動作する lwIP は 10 倍近くも高速という結果になりました。

更に性能を求めて TCP/IP スタックを自作する

上で見た通り、lwIP を DPDK と組み合わせると、既存のカーネル内の TCP/IP スタック実装と比べて高い通信性能を発揮できるのですが、今回実験に使った 100 Gbps NIC は小さいパケットであれば、148 million パケット毎秒程度まで送受信が可能なので、上記実験の結果が約 2.4 million リクエスト毎秒だったことからすると、NIC の性能の限界まではまだかなり余裕がありそうです。

更に性能を向上するためにできることとして、複数 CPU コアを利用することが考えられます。lwIP は複数スレッドが同時にプロトコルの処理を行うことができるように作られていないため、複数の CPU コアが利用可能な環境でも、最大1CPUコアまでしか利用できません。

今回は、この点を改善して性能を計測してみたかったため、複数スレッドで複数 CPU コアを利用可能な TCP/IP スタックを自作してみました。まだ作っている途中ですが、新しい TCP/IP スタック実装はこちらの URL ( GitHub ) からご覧いただけます。

クライアント:ベンチマークプログラム

Linux カーネルの TCP/IP を利用したベンチマーククライアントよりも、今回自作した TCP/IP スタックを利用したクライアントの方が、サーバに対して多く負荷をかけることができたため、クライアントには今回自作した TCP/IP スタックを使いました。

ワークロード

先ほどの実験と基本的に同じ設定で、サーバの利用する CPU コア数を変動させ、かつ、サーバの各 CPU コアが 16 並列 TCP 接続を捌くようにクライアント側で接続数を調節して計測を行いました。クライアントはサーバが1CPUコアを利用する場合は 16 CPU コアを使い(合計の並列 TCP 接続数が 16 のため)、それ以外の場合はクライアントは 32 CPU コアを利用します。

計測結果

CPU コア数ごとの性能は以下のようになりました。

32 CPU コアを使えば、50 million リクエスト毎秒まで性能を向上できました。1CPU コアの時の性能が約 2 million リクエスト毎秒なので、理想的には 64 million リクエスト毎秒( 2 million x 32 CPU コア)程度であってほしいですが、50 million リクエスト毎秒もそこまで悪くはないように思います。32 CPU コアの場合では Linux TCP/IP スタックと比較すると 10.6 倍速いという結果になりました。

その他・実装の特性

今回の実装は、lwIP と同じように、色々な他のシステムと組み合わせやすいように意識して作っています。他の実装の方針等については、参考資料2に記載がありますので、よろしければ併せてご覧ください。(参考資料2のときよりもキャッシュ効率が改善されて性能が上がりました。)

また、そもそもTCP/IP スタックの自作というのがどんな感じなんだろうと思われましたら、補足・背景情報で触れておりますので、よろしければそちらもご覧ください。

まとめ

カーネルをバイパスして TCP 通信を高速に行う方法を紹介しました。既存の Linux の TCP/IP スタック実装と今回実験に利用した実装ではサポートしている機能やその数が違うため、完全に公平な比較をすることは難しいですが、ソフトウェアの改変による性能の伸び代があることは確認できたように思います。

参考資料1・2では、研究コミュニティから発表されているカーネルをバイパスする TCP/IP スタック実装について列挙してありますので、よろしければそちらもご覧ください。

参考資料

以下、参考資料へのリンクです。参考資料2は PDF ファイルでサイズが若干大きめ(16 MB 程度)なのでアクセスされる際はご注意ください。

  1. Internet Infrastructure Review(IIR)Vol.60 ( 2023 年 9 月 26 日発行 )「システムソフトウェアの通信分野における2010年頃からの研究まとめ
  2. IIJ 技術研究所セミナー ( 2023 年 10 月 17 日 )「NIC の高速化とシステムソフトウェア研究 〜 2010 年くらいからの振り返り」発表資料(PDF ファイル 16 MB 程度)
  3. 情報処理学会 OS 研究会 ComSys 2023 ( 2023 年 12 月 7 日 )「高い性能と可搬性を目指した TCP/IP スタックの設計と実装」発表資料(PDF ファイル 3 MB 程度)

補足・背景情報

TCP/IP とは?

TCP は Transmission Control Protocol 、IP は Internet Protocol の略です。それぞれは名前にある通りプロトコル (Protocol) です。

ですが、TCP/IP と表現される場合、TCP と IP だけではなく、ある程度標準として利用されている、例えば User Datagram Protocol (UDP) や Internet Control Message Protocol (ICMP) 等も含めた、プロトコル群を意味する場合が多いようです。当記事でも、こちらのプロトコル群の意味で TCP/IP という表現を使っています。

 プロトコルとは?

プロトコルは日本語だと規約・規格というような訳で、情報の伝達を行う2者以上の間で事前に共有されている決まり事と言えるかもしれません。

例えば、モールス信号では、通電や発光時間の「短い」と「長い」の2種類の組み合わせで、アルファベット等の文字を伝達しますが、この伝達に際して、送受信者間において事前にどの組み合わせがどの文字に対応するかということが共有されている必要があり、この事前に共有されている情報はプロトコルと言えると思います。

コンピュータ間の通信おいては、データの先頭から何バイト目が何を意味(アドレスやポート番号等)している、などの事柄が通信に参加するコンピュータで共有されている必要があり、コンピュータの通信プロトコルはそれらを定義します。

プロトコルは、通信の参加者全てに共有されている必要があるため、インターネットのように世界中のコンピュータが通信に参加する可能性がある場合、それらコンピュータは同じプロトコルに則って通信を行う必要があります。

TCP/IP と呼称されるようなプロトコル群は Internet Engineering Task Force (IETF) のような標準化団体によって策定されたもので、現在、世界中のコンピュータの多くが TCP/IP に準拠して通信を行うようになっており、結果として、インターネットのように多くのコンピュータの間で情報の伝達を行うことができるようになっています。

TCP/IP スタックとは?

TCP/IP スタックは、コンピュータが TCP/IP に準拠した通信を行うことを可能にするソフトウェアの意で呼ばれることが多いと思います。

TCP/IP において、コンピュータ(通信経路ではない末端のホスト)が行うべきことは、送信データの先頭にヘッダと呼ばれる情報を付与することや、受信データに付与されているヘッダを元にそれが何を意味するデータであるかを解釈するということがあり、TCP/IP スタックは、(他にも多くの機能があると思いますが)これらを実行するソフトウェアであると言えると思います。

多くの場合で、TCP/IP スタックは OS のカーネル機能の一部として実装されています。

自作 TCP/IP スタックの雰囲気:150 行で ping へ応答

TCP/IP スタックというソフトウェアについてのイメージが掴みにくいかもしれないので、例として、サーバの疎通確認に利用される ping コマンドが使っている ICMP を一部実装して、ping コマンドへ応答するプログラムを作ってみます(通信開始に必要な Address Resolution Protocol (ARP)  も一部実装します。)。動作確認は Ubuntu 22.04 で行いました。

注意:ネットワークインタフェース操作に root 権限が必要なため sudo を使用します。以下のプログラムを試される場合はご自身の責任でお願いいたします。

#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <assert.h>
#include <sys/ioctl.h>
#include <arpa/inet.h>
#include <netinet/if_ether.h>
#include <net/if_arp.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <linux/if.h>
#include <linux/if_tun.h>

#define TAPDEV_NAME "tap00"

static uint8_t mac_addr[ETH_ALEN] = { 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, }; /* mac address: aa:bb:cc:dd:ee:ff */
static uint32_t ip_addr = ((uint32_t) 192 <<  0) | ((uint32_t) 168 <<  8) | ((uint32_t) 200 << 16) | ((uint32_t)  10 << 24); /* ip address: 192.168.200.10 */

static int tap_fd; /* file descriptor for tap device I/O */

static uint16_t checksum(uint8_t *buf, uint16_t len)
{
  uint32_t r = 0;
  {
    uint16_t i = 0;
    for (i = 0; i < len; i++)
      r += (i % 2 == 0 ? buf[i] << 8 : buf[i]);
  }
  r = (r >> 16) + (r & 0x0000ffff);
  r = (r >> 16) + r;
  return (uint16_t)~((uint16_t) r);
}

int main(void)
{
  assert((tap_fd = open("/dev/net/tun", O_RDWR)) != -1);
  { /* tap device setup */
    struct ifreq ifr = { .ifr_flags = IFF_TAP | IFF_NO_PI, };
    assert(strlen(TAPDEV_NAME) + 1 < sizeof(ifr.ifr_name));
    memcpy(ifr.ifr_name, TAPDEV_NAME, strlen(TAPDEV_NAME));
    assert(!ioctl(tap_fd, TUNSETIFF, (void *) &ifr));
  }
  while (1) { /* main loop */
    uint8_t rx_packet[2048]; /* packet buffer for received data */
    ssize_t rx_len = read(tap_fd, rx_packet, sizeof(rx_packet)); /* receive a packet */
    if (0 < rx_len) {
      struct ethhdr *eth = (struct ethhdr *) &rx_packet[0]; /* ethernet header*/
      switch (ntohs(eth->h_proto)) { /* check packet type */
      case ETH_P_IP: /* ip packet */
        if (!memcmp(mac_addr, eth->h_dest, ETH_ALEN)) {
          struct iphdr *ip = (struct iphdr *) &rx_packet[sizeof(struct ethhdr)]; /* ip header*/
          if (!memcmp(&ip_addr, &ip->daddr, sizeof(ip_addr))) { /* destination ip address is this host */
            switch (ip->protocol) { /* check protocol */
            case IPPROTO_ICMP:
              {
                struct icmphdr *icmp = (struct icmphdr *) &rx_packet[sizeof(struct ethhdr) + ip->ihl * 4]; /* icmp header */
                switch (icmp->type) { /* check icmp type */
                case ICMP_ECHO:
                  {
                    uint8_t tx_packet[2048] = { 0 }; /* packet buffer for transmission  */
                    { /* craft ethernet header */
                      struct ethhdr *ethh = (struct ethhdr *) &tx_packet[0];
                      memcpy(ethh->h_source, eth->h_dest, ETH_ALEN);
                      memcpy(ethh->h_dest, eth->h_source, ETH_ALEN);
                      ethh->h_proto = htons(ETH_P_IP);
                    }
                    { /* craft ip header */
                      struct iphdr *iph = (struct iphdr *) &tx_packet[sizeof(struct ethhdr)];
                      iph->ihl = sizeof(struct iphdr) / 4;
                      iph->version = 4;
                      iph->tot_len = htons(iph->ihl * 4 + rx_len - sizeof(struct ethhdr) - ip->ihl * 4);
                      iph->ttl = 64;
                      iph->protocol = IPPROTO_ICMP;
                      iph->saddr = ip->daddr;
                      iph->daddr = ip->saddr;
                      iph->check = htons(checksum((uint8_t *) iph, iph->ihl * 4));
                      { /* craft icmp header */
                        struct icmphdr *icmph = (struct icmphdr *) &tx_packet[sizeof(struct ethhdr) + iph->ihl * 4];
                        icmph->type = ICMP_ECHOREPLY;
                        icmph->un.echo.id = icmp->un.echo.id;
                        icmph->un.echo.sequence = icmp->un.echo.sequence;
                        assert((size_t)(ntohs(ip->tot_len) - ip->ihl * 4) < (sizeof(tx_packet)) - (sizeof(struct ethhdr) + ip->ihl * 4));
                        memcpy((void *)((uintptr_t) icmph + sizeof(struct icmphdr)),
                          &rx_packet[sizeof(struct ethhdr) + ip->ihl * 4 + sizeof(struct icmphdr)],
                          ntohs(iph->tot_len) - iph->ihl * 4);
                        icmph->checksum = htons(checksum((uint8_t *) icmph, ntohs(iph->tot_len) - iph->ihl * 4));
                      }
                      printf("send icmp reply to %u.%u.%u.%u\n", (uint8_t)((ip->saddr >>  0) & 0xff), (uint8_t)((ip->saddr >>  8) & 0xff), (uint8_t)((ip->saddr >> 16) & 0xff), (uint8_t)((ip->saddr >> 24) & 0xff));
                      assert(write(tap_fd, tx_packet, sizeof(struct ethhdr) + ntohs(iph->tot_len)) != -1); /* transmit a packet */
                    }
                  }
                  break;
                }
              }
              break;
            case IPPROTO_TCP: /* tcp_input */
              break;
            case IPPROTO_UDP: /* udp_input */
              break;
            }
          }
        }
        break;
      case ETH_P_ARP: /* arp packet */
        {
          uint8_t broadcast[ETH_P_ARP] = { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, };
          if (!memcmp(broadcast, eth->h_dest, ETH_ALEN)
              || !memcmp(mac_addr, eth->h_dest, ETH_ALEN)) {
            struct ether_arp *arp = (struct ether_arp *) &rx_packet[sizeof(struct ethhdr)]; /* arp header */
            switch (htons(arp->ea_hdr.ar_op)) { /* check arp operation */
            case ARPOP_REQUEST:
              if (!memcmp((uint8_t *) &ip_addr, arp->arp_tpa, sizeof(ip_addr))) { /* target ip address is this host */
                uint8_t tx_packet[2048] = { 0 }; /* packet buffer for transmission  */
                { /* craft ethernet header */
                  struct ethhdr *ethh = (struct ethhdr *) &tx_packet[0];
                  memcpy(ethh->h_source, mac_addr, ETH_ALEN);
                  memcpy(ethh->h_dest, eth->h_source, ETH_ALEN);
                  ethh->h_proto = htons(ETH_P_ARP);
                }
                { /* craft arp header */
                  struct ether_arp *arph = (struct ether_arp *) &tx_packet[sizeof(struct ethhdr)];
                  arph->ea_hdr.ar_hrd = htons(ARPHRD_ETHER);
                  arph->ea_hdr.ar_pro = htons(ETH_P_IP);
                  arph->ea_hdr.ar_hln = ETH_ALEN;
                  arph->ea_hdr.ar_pln = sizeof(uint32_t);
                  arph->ea_hdr.ar_op = htons(ARPOP_REPLY);
                  memcpy(arph->arp_sha, mac_addr, ETH_ALEN);
                  memcpy(arph->arp_spa, &ip_addr, sizeof(arph->arp_spa));
                  memcpy(arph->arp_tha, arp->arp_sha, ETH_ALEN);
                  memcpy(arph->arp_tpa, arp->arp_spa, sizeof(arph->arp_tpa));
                }
                printf("send  arp reply to %u.%u.%u.%u (asked who has %u.%u.%u.%u)\n", arp->arp_spa[0], arp->arp_spa[1], arp->arp_spa[2], arp->arp_spa[3], arp->arp_tpa[0], arp->arp_tpa[1], arp->arp_tpa[2], arp->arp_tpa[3]);
                assert(write(tap_fd, tx_packet, sizeof(struct ethhdr) + sizeof(struct ether_arp)) != -1); /* transmit a packet */
              }
              break;
            }
          }
        }
        break;
      }
    } else if (rx_len == -1) {
      perror("read");
    }
  }
  close(tap_fd);
  return 0;
}

上のプログラムは、tap00 という仮想インタフェースを作成し IP アドレス 192.168.200.10 というアドレスで ping コマンドからの送信を待機します。これらを変更するには、プログラム内の TAPDEV_NAME と ip_addr を適宜編集してください。

上のプログラムを ping-reply.c という名前で保存した場合、以下のコマンドでコンパイルができ、ping-reply という実行ファイルが生成されるはずです。

gcc ping-reply.c -o ping-reply

生成された実行ファイルは以下のコマンドで実行できます。

sudo ./ping-reply

次に、新しくコンソール・ターミナルを開いて、以下のコマンドを実行します。以下は、生成された仮想インタフェース(tap デバイス)を起動するとともに IP アドレス 192.168.200.8 を付与します。

sudo ifconfig tap00 192.168.200.8 up

待機している ping-reply に対して、以下の ping コマンドで疎通確認を行います。

ping 192.168.200.10

うまく動いていれば、以下のような出力になるはずです。

コンソール・ターミナル1

$ sudo ./ping-reply
send  arp reply to 192.168.200.8 (asked who has 192.168.200.10)
send icmp reply to 192.168.200.8
send icmp reply to 192.168.200.8
send icmp reply to 192.168.200.8
send icmp reply to 192.168.200.8
send icmp reply to 192.168.200.8

コンソール・ターミナル2

$ ping 192.168.200.10
PING 192.168.200.10 (192.168.200.10) 56(84) bytes of data.
64 bytes from 192.168.200.10: icmp_seq=1 ttl=64 time=0.315 ms
64 bytes from 192.168.200.10: icmp_seq=2 ttl=64 time=0.198 ms
64 bytes from 192.168.200.10: icmp_seq=3 ttl=64 time=0.232 ms
64 bytes from 192.168.200.10: icmp_seq=4 ttl=64 time=0.312 ms
64 bytes from 192.168.200.10: icmp_seq=5 ttl=64 time=0.205 ms

これは、ping コマンドが Linux カーネルに含まれる TCP/IP スタックを利用して 192.168.200.10 宛に ICMP echo パケットを送信し、上のプログラム ping-reply はそれを受け取り、ICMP echo-reply パケットを返信している状態です。

簡単な実装の解説

プロトコルによるヘッダの定義

上のプログラムで利用されている構造体が、それぞれのプロトコルの定義するヘッダのフォーマットを表します。(以下の構造体とそのフィールドの名前は Linux で利用可能なヘッダファイルの中で定義されたたものです。)

  • struct ethhdr : Ethernet ヘッダ
  • struct iphdr : IP ヘッダ
  • struct icmphdr : ICMP ヘッダ
  • struct ether_arp : ARP ヘッダ

Ethernet ヘッダ (struct ethhdr) は以下のフィールドを含みます。

  • h_dest:送信先 MAC アドレス
  • h_source:送信元 MAC アドレス
  • n_proto:プロトコル番号(IP, ARP 等を表す)

IP ヘッダ (struct iphdr) は以下のようなフィールドを保持します。(何点か割愛しています)

  • ihl:IP ヘッダの長さ
  • tot_len:IP パケット全体の長さ
  • protocol:プロトコル番号 (ICMP, TCP, UDP 等を表す)
  • check:チェックサム
  • saddr:送信元 IP アドレス
  • daddr:送信先 IP アドレス

ICMP ヘッダ (struct icmphdr) は以下のフィールドを含みます。(何点か割愛しています)

  • type:タイプ (ICMP echo 等を表す)
  • checksum:チェックサム
  • echo.id:ICMP echo の ID
  • echo.sequence:ICMP echo の順序番号

これらの定義は標準化されたもので世界共通となっています。

パケットの受信

パケット受信処理について見ていきます。具体的には、以下の箇所で、rx_packet[2048] というバッファ(メモリ領域)に read システムコールを通じて、仮想インタフェース(tap デバイス)が受信したパケットを読み出します。

uint8_t rx_packet[2048]; /* packet buffer for received data */
ssize_t rx_len = read(tap_fd, rx_packet, sizeof(rx_packet)); /* receive a packet */

50 行目からは、rx_packet で受け取ったされたデータが何であるかを判別していきます。

Ethernet ヘッダの読み取り

まず、パケットの先頭には Ethernet ヘッダが含まれていると想定します。Ethernet ヘッダ内の情報を読み取るために、以下のようにポインタをキャストします。

struct ethhdr *eth = (struct ethhdr *) &rx_packet[0]; /* ethernet header*/

次に、Ethernet ヘッダに含まれるプロトコルフィールド (h_proto) を参照します。このフィールドは、そのパケットが何のプロトコルのパケットであるかを表します。特に、今回は IP ・ ICMP パケットに興味があるので、受信データが IP パケットだった場合の処理を続けて書いていきます。

struct ethhdr *eth = (struct ethhdr *) &rx_packet[0]; /* ethernet header*/
switch (ntohs(eth->h_proto)) { /* check packet type */
case ETH_P_IP: /* ip packet */
IP ヘッダの読み取り

仕様上、IP パケットでは、IP ヘッダが Ethernet ヘッダに続いて配置されます。絵にすると以下のようになると思われます。

パケットの先頭
     | -- ethernet header -- | -- IP header -- | -------- data ---------|

IP ヘッダの内容を参照するために、再度ポインタをキャストします。上の絵のように、IP ヘッダは、パケットの先頭から Ethernet ヘッダサイズ分だけ進んだところにあるため、rx_packet にオフセットが追加されているところがポイントです。

struct iphdr *ip = (struct iphdr *) &rx_packet[sizeof(struct ethhdr)]; /* ip header*/

次のように、受信したパケットの宛先 IP アドレスが自分宛であるかを確認します。

if (!memcmp(&ip_addr, &ip->daddr, sizeof(ip_addr))) { /* destination ip address is this host */

次に、IP ヘッダに含まれるプロトコルフィールド(protocol)を参照します。今回は、ping へ応答するために、このフィールドが ICMP を示す値を含んでいた場合の処理を続けて書いていきます。

switch (ip->protocol) { /* check protocol */
case IPPROTO_ICMP:
  {
ICMP ヘッダの読み取り

下の絵のように、ICMP ヘッダは、IP ヘッダに続けて配置されるはずなので、そこからデータを読み取っていきます。

| -- ethernet header -- | -- IP header -- | -- ICMP header -- | ------ data ------|

以下のようにして、ICMP ヘッダ内の情報を参照するために、rx_packet から Ethernet ヘッダサイズと IP ヘッダサイズ分だけ進んだ箇所を ICMP ヘッダの先頭として、ポインタをキャストします。(IP ヘッダのサイズは、ihl フィールドに4分の1された値で表されます)。

struct icmphdr *icmp = (struct icmphdr *) &rx_packet[sizeof(struct ethhdr) + ip->ihl * 4]; /* icmp header */

ICMP の操作にはタイプ (type) があり、ping コマンドによって送られてくるのは echo と呼ばれるものです。今回は、これに応答するために、パケットが echo 操作を含んでいた場合の処理を書いていきます。

struct icmphdr *icmp = (struct icmphdr *) &rx_packet[sizeof(struct ethhdr) + ip->ihl * 4]; /* icmp header */
switch (icmp->type) { /* check icmp type */
case ICMP_ECHO:
  {

具体的に、ping ( ICMP echo ) へ返信するためには、タイプ (type) フィールドに echo-reply という操作を示す値を入れたデータを送り返すことになり、ここからはパケット送信処理に移ります。

送信パケット用バッファ

以下のように、tx_packet[2048] というバッファ(メモリの領域)に送り返すパケットを用意していきます。

uint8_t tx_packet[2048] = { 0 }; /* packet buffer for transmission  */
送信パケットのために Ethernet ヘッダを用意

送信用パケットバッファ(tx_packet[2048])の先頭に Ethernet ヘッダを用意していきます。具体的には、Ethernet ヘッダの送信先と送信元の MAC アドレスと、プロトコル(今回は IP )のフィールドを埋めます。

{ /* craft ethernet header */
  struct ethhdr *ethh = (struct ethhdr *) &tx_packet[0];
  memcpy(ethh->h_source, eth->h_dest, ETH_ALEN);
  memcpy(ethh->h_dest, eth->h_source, ETH_ALEN);
  ethh->h_proto = htons(ETH_P_IP);
}
送信パケットのために IP ヘッダを用意

以下のようにして、tx_packet の先頭から Ethernet ヘッダサイズ分だけ進んだところに IP ヘッダを用意します。これには、IP ヘッダの長さ、IP パケット全体の長さ、送信先と送信元の IP アドレス、IP ヘッダのチェックサム等が含まれます。

{ /* craft ip header */
  struct iphdr *iph = (struct iphdr *) &tx_packet[sizeof(struct ethhdr)];
  iph->ihl = sizeof(struct iphdr) / 4;
  iph->version = 4;
  iph->tot_len = htons(iph->ihl * 4 + rx_len - sizeof(struct ethhdr) - ip->ihl * 4);
  iph->ttl = 64;
  iph->protocol = IPPROTO_ICMP;
  iph->saddr = ip->daddr;
  iph->daddr = ip->saddr;
  iph->check = htons(checksum((uint8_t *) iph, iph->ihl * 4));
送信パケットのために ICMP ヘッダを用意

次のように、tx_packet から Ethernet ヘッダサイズ分と IP ヘッダサイズ分だけ進んだところに、ICMP ヘッダを用意します。ここでは、ping (ICMP echo) へ応答するために、タイプ(type)フィールドに ICMP echo-reply を示す値を入れます。

{ /* craft icmp header */
  struct icmphdr *icmph = (struct icmphdr *) &tx_packet[sizeof(struct ethhdr) + iph->ihl * 4];
  icmph->type = ICMP_ECHOREPLY;
  icmph->un.echo.id = icmp->un.echo.id;
  icmph->un.echo.sequence = icmp->un.echo.sequence;
  assert((size_t)(ntohs(ip->tot_len) - ip->ihl * 4) < (sizeof(tx_packet)) - (sizeof(struct ethhdr) + ip->ihl * 4));
  memcpy((void *)((uintptr_t) icmph + sizeof(struct icmphdr)),
    &rx_packet[sizeof(struct ethhdr) + ip->ihl * 4 + sizeof(struct icmphdr)],
    ntohs(iph->tot_len) - iph->ihl * 4);
  icmph->checksum = htons(checksum((uint8_t *) icmph, ntohs(iph->tot_len) - iph->ihl * 4));
仮想インタフェース(tap デバイス)から用意したパケットを送信

以下のように、write システムコールを使って、tx_packet に用意したデータを仮想インタフェース(tap デバイス)から送信します。

assert(write(tap_fd, tx_packet, sizeof(struct ethhdr) + ntohs(iph->tot_len)) != -1); /* transmit a packet */

これにより、ping を送った側に応答が送り返されます。

TCP や UDP の処理を追加で実装する場合

TCP であれば、以下の箇所に実装を追加すると、受信した TCP パケットへの操作を行うことができます。

case IPPROTO_TCP: /* tcp_input */
  break;

UDP であれば、以下の箇所に実装を追加します。

case IPPROTO_UDP: /* udp_input */
  break;

この実装を高速化したい場合

この実装は、比較的試しやすいように tap デバイスを通じてパケットの送受信を行っていますが、これを上の性能計測を行った実験のように DPDK 等を使って物理 NIC から直接 I/O を行うように変更すると、通信性能が向上すると思われます。

IIJ Engineers blog読者プレゼントキャンペーン

Xのフォロー&条件付きツイートで、「IoT米」と「バリーくんシール」のセットを抽選でプレゼント!
応募期間は2023/12/01~2023/12/31まで。詳細はこちらをご覧ください。
今すぐポストするならこちら→ フォローもお忘れなく!

安形

2023年12月15日 金曜日

研究所でシステムソフトウェアの研究に取り組んでいます。

Related
関連記事