読者です 読者をやめる 読者になる 読者になる

人間とウェブの未来

「ウェブの歴史は人類の歴史の繰り返し」という観点から色々勉強しています。

LinuxカーネルのTCPスタックとシステムコールの組み合わせによる手法よりも高速にポートのListenチェックを行う

まずは前回の記事で盛大な誤認をしていたことを訂正しなければなりません。

hb.matsumoto-r.jp

前回の記事では、高速にリモートホストのポートチェックを3パケットで実現する実装を行うために、RAWソケットとユーザランドの簡易TCPスタックを実装してパケットを放出しましたが、カーネルのTCPスタックによって自動的にRSTパケットが返されるため、RSTパケットが返されるよりもはやくrecvfromしないとSYN+ACKを受け取れないと述べました。

しかし、以下のようにご指摘を頂き、

実際に改めて試すと、RSTパケットがカーネルによって放出されていても、ユーザランドTCPスタックでSYN+ACKパケットを受け取ることができました。色々な過程の中で以下の2つの要因が重なり、誤認していました。

  • C言語のif文の書き方のバグ
  • recvfromの2回実行してはじめてSYN+ACKが得られる

まず一つ目のC言語の書き方のバグですが、以下のように書いておりました。

  if ((rcv_len = recvfrom(raw_socket, buf, sizeof(buf), 0, (struct sockaddr *)&dst, &fromlen) == -1)) {
    ERROR("recvfrom", "recvfrom", 4);                                            
  }                                                                              
  printf("rec_len: %d\n", rcv_len);  /* rcv_len is always 0 */

これだと、演算の()抜けの問題によりrecvfromの値が常に0になってしまいます。あああああああ、という初歩的なミスです。

また二つ目は、sendto後にrecvfromを実行した時に、一回目の実行では最初のこちらから放出したSYNパケットが、二回目の実行でSYN+ACKパケットが得られます。これも、一回目だけ実行していたことによって、SYN+ACKが取れないということ、recvfromの値が0であったことから、カーネルのTCPスタックに邪魔されているのだと誤認してしまいました。

その流れでスレッドなどを使って取り組んでいましたが、実はスレッドなど使わなくても、RSTパケットがカーネルから放出されても、RAWパケットにはデータが渡ってくることがわかりました。

うおおお、これで勝てる!!!!!

ということで、LinuxのカーネルのTCPスタックを使うよりも高速かつ少ないパケット数でポートチェックできるのでは?という前回の方針で実装していきたいと思います。また、今後のmrubyのmrbgem化や、ミドルウェアに対するリクエストの処理過程で実現したいといった目的から、できればマルチスレッドやマルチプロセスな実装にはしたくないので、シングルスレッド前提で考えてみます。

上記の前提に従って、リモートホストのポートのListenチェックを高速に行う実装は以下の3つの戦略が考えられます。

  1. connect()システムコールとカーネルTCPスタックで実現
  2. connect()システムコールとSO_LINGERとカーネルTCPスタックで実現
  3. RAWソケットとユーザランドTCPスタックを実装して実現

connect()システムコールとカーネルTCPスタックで実現

これは、TCPの処理はカーネルのTCPスタックにおまかせして、connect()システムコールとclose()で実現する最も簡単な方法です。パケットとしては、3way-handshakeでセッション確立後、FINパケットで接続を終了する処理になります。この場合は通常全部で6パケットでチェック可能になります。実装的には以下のようになるでしょう。

static void check_port_sysconnect(int max)
{
  int src_port = SRC_PORT;
  int dst_port = DST_PORT;
  char srcip[] = SRC_HOST;
  char dstip[] = DST_HOST;
  struct sockaddr_in peer;
  struct sockaddr *peer_ptr;
  int sock, peer_size;

  printf("bench type: %s bench num: %d\n", __func__, max);

  inet_aton(dstip, &peer.sin_addr);
  peer.sin_port = htons(dst_port);
  peer.sin_family = AF_INET;
  peer_size = sizeof(peer);
  peer_ptr = (struct sockaddr *)&peer;

  counter = 0;
  while (counter < max) {
    sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (connect(sock, peer_ptr, peer_size) != -1) {
      counter++;
    }
    close(sock);
  }
}

connect()システムコールとSO_LINGERとカーネルTCPスタックで実現

次の戦略としては、同様にカーネルのTCPスタックに処理はおまかせしつつ、セッション終了時にFINパケットで終了するのではなく、SO_LINGERオプションをソケットに設定してからclose()することにより、FINパケットの代わりにRSTパケットを送って接続を終了します。この場合、3way-handshakeとRSTパケットにより、合計で4パケットでのチェックが可能になります。実装は以下の通りです。

static void check_port_sysconnect_so_linger(int max)
{
  int src_port = SRC_PORT;
  int dst_port = DST_PORT;
  char srcip[] = SRC_HOST;
  char dstip[] = DST_HOST;
  struct linger so_linger;
  struct sockaddr_in peer;
  struct sockaddr *peer_ptr;
  int sock, peer_size;
  int so_linger_size = sizeof(so_linger);

  printf("bench type: %s bench num: %d\n", __func__, max);

  so_linger.l_onoff = 1;
  so_linger.l_linger = 0;

  inet_aton(dstip, &peer.sin_addr);
  peer.sin_port = htons(dst_port);
  peer.sin_family = AF_INET;
  peer_size = sizeof(peer);
  peer_ptr = (struct sockaddr *)&peer;

  counter = 0;
  while (counter < max) {
    sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (connect(sock, peer_ptr, peer_size) != -1) {
      counter++;
    }
    setsockopt(sock, SOL_SOCKET, SO_LINGER, &so_linger, so_linger_size);
    close(sock);
  }
}

RAWソケットとユーザランドTCPスタックを実装して実現

そして、前回記事であきらめた戦略ですが、これも壮大な誤認を解決した(していただいた)ことにより実現可能そうです。RAWパケットを作って、自身でTCP/IPのヘッダを定義した上で、戻りのパケットを同様にヘッダ解析することによって、ユーザランド側に新ためて実装した簡易TCPスタックによってポートチェックを行います。また、sendto後にrecvfromを複数回行う必要があるため、recvfromをループ内で実行します。

static void check_port_raw_socket(int max)
{
  int src_port = SRC_PORT;
  int dst_port = DST_PORT;
  char srcip[] = SRC_HOST;
  char dstip[] = DST_HOST;
  struct tcphdr tcphdr;
  struct pseudo_header pheader;
  struct sockaddr_in peer;
  struct sockaddr *peer_ptr;
  int sock;
  int saddr_size;
  int windowsize = 4321;
  int tcphdr_size = sizeof(struct tcphdr);
  int iphdr_size = sizeof(struct ip);

  printf("bench type: %s bench num: %d\n", __func__, max);

  tcphdr.source = htons(src_port);
  tcphdr.dest = htons(dst_port);
  tcphdr.window = htons(windowsize);
  tcphdr.seq = 1;
  tcphdr.fin = 0;
  tcphdr.syn = 1;
  tcphdr.doff = 5;
  tcphdr.rst = 0;
  tcphdr.urg = 0;
  tcphdr.urg_ptr = 0;
  tcphdr.psh = 0;
  tcphdr.ack_seq = 0;
  tcphdr.ack = 0;
  tcphdr.check = 0;
  tcphdr.res1 = 0;
  tcphdr.res2 = 0;

  inet_aton(srcip, (struct in_addr *)&pheader.iphdr.src_ip);
  inet_aton(dstip, (struct in_addr *)&pheader.iphdr.dst_ip);

  pheader.iphdr.zero = 0;
  pheader.iphdr.protocol = 6;
  pheader.iphdr.len = htons(sizeof(struct ip));

  inet_aton(dstip, &peer.sin_addr);
  peer.sin_port = htons(dst_port);
  peer.sin_family = AF_INET;
  saddr_size = sizeof(peer);
  peer_ptr = (struct sockaddr *)&peer;

  bcopy((char *)&tcphdr, (char *)&pheader.ptcphdr, sizeof(struct ip));
  tcphdr.check = checksum((unsigned short *)&pheader, 32);

  counter = 0;
  while (counter < max) {
    sock = socket(AF_INET, SOCK_RAW, IPPROTO_TCP);
    sendto(sock, &tcphdr, tcphdr_size, 0, peer_ptr, saddr_size);
    while (1) {
      struct tcphdr *tcp;
      unsigned char buffer[4096] = {0};
      recvfrom(sock, buffer, 4096, 0, peer_ptr, &saddr_size);
      tcp = (struct tcphdr *)(buffer + iphdr_size);
      if (tcp->syn == 1 && tcp->ack == 1) {
        counter++;
        break;
      }
    }
    close(sock);
  }
}

さてベンチマークだ!!!

ということで、ついに自分が当初考えていた高速なポートチェックの実装およびベンチマーク比較が行えそうです。一度は前回記事のように諦めてしまいましたが、なんとかここまでたどり着けて、ブログ書いてよかったなぁという気持ちしかありません。というわけで、以下のように、ポートチェックのループを複数回回して、その時の処理性能を比較してみましょう。

int main(int argc, const char **argv)
{
  int max;

  if (argc != 3) {
    printf("type('raw' or 'connect') check_num\n");
    return 1;
  }

  max = atoi(argv[2]);

  if (strcmp(argv[1], "raw") == 0) {
    check_port_raw_socket(max);
  }
  if (strcmp(argv[1], "connect") == 0) {
    check_port_sysconnect(max);
  }
  if (strcmp(argv[1], "so_linger_connect") == 0) {
    check_port_sysconnect_so_linger(max);
  }

  return 0;
}

では、上記のコード全てをコンパイルして、性能評価を行いました。環境はCorei7の2.8GHz、メモリ16GBのMacBookProに、Vagrantでubuntu16.04環境をコア2個+メモリ4GBで構築しました。

また、通常のconnectパターンでは、ソケットのTIME_WAITが溜まってconnectがEADDRNOTAVAILを返し、アドレスがアサインできないエラーが多発して処理が遅くなりすぎるので、TIME_WAITを使いまわせるように以下のチューニングも同時に行いました。

net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_tw_reuse = 1

では、比較してみましょう!

以下実行結果です。

実行結果

  • 10000回チェック
[ubuntu@ubuntu-xenial:~/work/683969e675e76aa5f7dab58a9d1afcf7]$ gcc connect_vs_raw.c && time sudo ./a.out raw 10000
bench type: check_port_raw_socket bench num: 10000

real    0m0.074s
user    0m0.000s
sys     0m0.068s
[ubuntu@ubuntu-xenial:~/work/683969e675e76aa5f7dab58a9d1afcf7]$ gcc connect_vs_raw.c && time sudo ./a.out so_linger_connect 10000
bench type: check_port_sysconnect_so_linger bench num: 10000

real    0m0.099s
user    0m0.000s
sys     0m0.092s

[ubuntu@ubuntu-xenial:~/work/683969e675e76aa5f7dab58a9d1afcf7]$ gcc connect_vs_raw.c && time sudo ./a.out connect 10000
bench type: check_port_sysconnect bench num: 10000

real    0m17.206s
user    0m0.000s
sys     0m0.156s
  • 100万回チェック
[ubuntu@ubuntu-xenial:~/work/683969e675e76aa5f7dab58a9d1afcf7]$ gcc connect_vs_raw.c && time sudo ./a.out raw 1000000
bench type: check_port_raw_socket bench num: 1000000

real    0m5.748s
user    0m0.140s
sys     0m5.532s
[ubuntu@ubuntu-xenial:~/work/683969e675e76aa5f7dab58a9d1afcf7]$ gcc connect_vs_raw.c && time sudo ./a.out so_linger_connect 1000000
bench type: check_port_sysconnect_so_linger bench num: 1000000

real    0m8.523s
user    0m0.196s
sys     0m8.188s

tcpdumpのパケット数

  • connect+SO_LINGERパターン
8000000 packets received by filter
6776308 packets dropped by kernel
  • RAWソケット+ユーザランドTCPスタックパターン
6000000 packets received by filter
5032960 packets dropped by kernel

やったーーーーーーーー!

connecnt+SO_LINGER+カーネルTCPスタックパターンよりも速くポートチェックができたーーーーーーーー!

チェック回数を変えた上でのベンチマークは、大体以下のような表になりました。また、上記のようなベンチマークを何度も行いましたが、概ねconnectのみの実装以外は安定して同様の数値が得られました。

性能

ポートチェック回数 raw socketと自作TCPスタック connectとSO_LINGER connect
10000 0.074 sec 0.099 sec 17.206 sec
100000 0.578 sec 0.790 sec 1m 43.190 sec
1000000 5.748 sec 8.523 sec 遅すぎて計測不能

パケット数(packets received by filter)

ポートチェック回数 raw socketと自作TCPスタック connectとSO_LINGER connect
10000 60000 packets 80000 packets 124214 packets
100000 600000 packets 800000 packets 1202586 packets
1000000 6000000 packets 8000000 packets 遅すぎて計測不能

SO_LINGERを使わないconnectについては、FINパケットを放出するのでカーネルのFINパケットに関わる設定の問題などでTIME_WAITが沢山たまって、再connectが度々発生したり、上記のようなチューニングをしてもパケット数が多くなることなどからどうしても性能が出ませんでした。connectのみの実装はカーネルのチューニング次第でもう少し改善できそうではありますが、やはり他の手法と比べると格段に遅かったです。

また、RAWパケットパターンとconnect+SO_LINGERパターンは、カーネルのチューニング有る無しに関わらず安定して性能が出ていました。このあたりも普通に使う分にはメリットになるでしょう。

まとめ

以上のように、色々試行錯誤しつつも、インターネット上の皆様に様々な助言を頂きながら、ようやく実現したい実装のPoCが完成しました。皆様ありがとうございました。

またベンチマーク比較の結果、RAWソケットと簡易ユーザランドTCPスタックでの実装が一番はやいことがわかりました。

この実装をベースに、mrubyのFastポートチェック的なmrbgemを作り、mrubyを組み込んだソフトウェアからリモートホストのポートのListenチェックを高速かつ少ないパケット数で簡単に実現できそうです。

ということで、リモートホストのポートチェックにもこだわると色々と面白いやり方がありますね、という過程のご紹介でした。

また、このエントリによって、カーネルチューニングによるconnectのみのチェックの性能向上案や実装の更なる改善などありましたらぜひぜひアンサーエントリを頂けると幸いです。

参考

以下に今回のコードを全て貼り付けておきますので自由にお使いください。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>

#define SRC_PORT 54321
#define DST_PORT 80
#define SRC_HOST "10.0.2.15"
#define DST_HOST "10.0.2.15"

static int counter = 0;

struct pseudo_ip_header {
  unsigned int src_ip;
  unsigned int dst_ip;
  unsigned char zero;
  unsigned char protocol;
  unsigned short len;
};

struct pseudo_header {
  struct pseudo_ip_header iphdr;
  struct tcphdr ptcphdr;
};

static unsigned short checksum(unsigned short *buffer, int size)
{
  unsigned long cksum = 0;
  while (size > 1) {
    cksum += *buffer++;
    size -= sizeof(unsigned short);
  }
  if (size)
    cksum += *(char *)buffer;

  cksum = (cksum >> 16) + (cksum & 0xffff);
  cksum += (cksum >> 16);
  return (unsigned short)(~cksum);
}

static void check_port_raw_socket(int max)
{
  int src_port = SRC_PORT;
  int dst_port = DST_PORT;
  char srcip[] = SRC_HOST;
  char dstip[] = DST_HOST;
  struct tcphdr tcphdr;
  struct pseudo_header pheader;
  struct sockaddr_in peer;
  struct sockaddr *peer_ptr;
  int sock;
  int saddr_size;
  int windowsize = 4321;
  int tcphdr_size = sizeof(struct tcphdr);
  int iphdr_size = sizeof(struct ip);

  printf("bench type: %s bench num: %d\n", __func__, max);

  tcphdr.source = htons(src_port);
  tcphdr.dest = htons(dst_port);
  tcphdr.window = htons(windowsize);
  tcphdr.seq = 1;
  tcphdr.fin = 0;
  tcphdr.syn = 1;
  tcphdr.doff = 5;
  tcphdr.rst = 0;
  tcphdr.urg = 0;
  tcphdr.urg_ptr = 0;
  tcphdr.psh = 0;
  tcphdr.ack_seq = 0;
  tcphdr.ack = 0;
  tcphdr.check = 0;
  tcphdr.res1 = 0;
  tcphdr.res2 = 0;

  inet_aton(srcip, (struct in_addr *)&pheader.iphdr.src_ip);
  inet_aton(dstip, (struct in_addr *)&pheader.iphdr.dst_ip);

  pheader.iphdr.zero = 0;
  pheader.iphdr.protocol = 6;
  pheader.iphdr.len = htons(sizeof(struct ip));

  inet_aton(dstip, &peer.sin_addr);
  peer.sin_port = htons(dst_port);
  peer.sin_family = AF_INET;
  saddr_size = sizeof(peer);
  peer_ptr = (struct sockaddr *)&peer;

  bcopy((char *)&tcphdr, (char *)&pheader.ptcphdr, sizeof(struct ip));
  tcphdr.check = checksum((unsigned short *)&pheader, 32);

  counter = 0;
  while (counter < max) {
    sock = socket(AF_INET, SOCK_RAW, IPPROTO_TCP);
    sendto(sock, &tcphdr, tcphdr_size, 0, peer_ptr, saddr_size);
    while (1) {
      struct tcphdr *tcp;
      unsigned char buffer[4096] = {0};
      recvfrom(sock, buffer, 4096, 0, peer_ptr, &saddr_size);
      tcp = (struct tcphdr *)(buffer + iphdr_size);
      if (tcp->syn == 1 && tcp->ack == 1) {
        counter++;
        break;
      }
    }
    close(sock);
  }
}

static void check_port_sysconnect_so_linger(int max)
{
  int src_port = SRC_PORT;
  int dst_port = DST_PORT;
  char srcip[] = SRC_HOST;
  char dstip[] = DST_HOST;
  struct linger so_linger;
  struct sockaddr_in peer;
  struct sockaddr *peer_ptr;
  int sock, peer_size;
  int so_linger_size = sizeof(so_linger);

  printf("bench type: %s bench num: %d\n", __func__, max);

  so_linger.l_onoff = 1;
  so_linger.l_linger = 0;

  inet_aton(dstip, &peer.sin_addr);
  peer.sin_port = htons(dst_port);
  peer.sin_family = AF_INET;
  peer_size = sizeof(peer);
  peer_ptr = (struct sockaddr *)&peer;

  counter = 0;
  while (counter < max) {
    sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (connect(sock, peer_ptr, peer_size) != -1) {
      counter++;
    }
    setsockopt(sock, SOL_SOCKET, SO_LINGER, &so_linger, so_linger_size);
    close(sock);
  }
}

static void check_port_sysconnect(int max)
{
  int src_port = SRC_PORT;
  int dst_port = DST_PORT;
  char srcip[] = SRC_HOST;
  char dstip[] = DST_HOST;
  struct sockaddr_in peer;
  struct sockaddr *peer_ptr;
  int sock, peer_size;

  printf("bench type: %s bench num: %d\n", __func__, max);

  inet_aton(dstip, &peer.sin_addr);
  peer.sin_port = htons(dst_port);
  peer.sin_family = AF_INET;
  peer_size = sizeof(peer);
  peer_ptr = (struct sockaddr *)&peer;

  counter = 0;
  while (counter < max) {
    sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (connect(sock, peer_ptr, peer_size) != -1) {
      counter++;
    }
    close(sock);
  }
}

int main(int argc, const char **argv)
{
  int max;

  if (argc != 3) {
    printf("type('raw' or 'connect') check_num\n");
    return 1;
  }

  max = atoi(argv[2]);

  if (strcmp(argv[1], "raw") == 0) {
    check_port_raw_socket(max);
  }
  if (strcmp(argv[1], "connect") == 0) {
    check_port_sysconnect(max);
  }
  if (strcmp(argv[1], "so_linger_connect") == 0) {
    check_port_sysconnect_so_linger(max);
  }

  return 0;
}