netstatコマンドを高速化する

# はじめに
Linuxカーネルとnetstatコマンドをちょこっとだけ改造したらnetstatコマンドが早くなった、というお話です。

## 概要
- netstatコマンドは、TCPセッションについての情報をカーネルから取得する際には`/proc/net/tcp`という特殊なファイル[^about-procfs]を参照している
    - IPv4では`/proc/net/tcp`、IPv6については`/proc/net/tcp6`と別のファイルに分かれているが、都度併記するとややこしいので以降は`/proc/net/tcp`と表記する
[^about-procfs]: ここではprocfs自体については詳しく述べない。`man proc`などを適宜参照。
- procfsは内部でseqファイルシステムという仕組みを利用している
    - seqファイルシステムについてはこちらにまとめられている [seqファイルシステムについて - Qiita](https://qiita.com/akachochin/items/98085494081b8bc39cbb)
- seqファイルシステムを利用した特殊なファイルに対してreadシステムコールを実行した際、通常はカーネル側で確保された`PAGE_SIZE`(多くの環境では4096Byte)の値と同じ容量のバッファを経由して、readシステムコールに指定されたユーザー空間側のバッファ領域にデータをコピーする
    - つまり、ユーザー空間側のバッファ領域にいくら大きな容量を(readシステムコールの第2、第3引数に[^about-read])指定しても、4096Byte以下の容量でしかデータを読み出せない事を意味する
[^about-read]: readシステムコールの引数等については`man 2 read`などを適宜参照。
- 多量・高頻度のTCPコネクションを扱う環境では`/proc/net/tcp`の容量は数MiByteになりうるが、readシステムコールは4096Byte以下の容量でしかデータを読み出すことができず、`/proc/net/tcp`の内容すべてを読み込む為には数百回のreadシステムコールを実行する必要がある
    - `/proc/net/tcp`ではTCPの1セッション＝1行が150Byte、1万セッションを保持(TIME_WAIT含む)している環境であればファイルの容量は1.5MiByte前後となり、readシステムコールを300回以上実行することになる
    - 実際には行単位で区切りのいい容量に丸められて4096Byteよりも若干少ないデータ量しか読み出せないので、回数はさらに増える
- Linuxカーネル内の`/proc/net/tcp`の処理を行っている箇所とnetstatコマンドを修正して1回のreadシステムコールで読みだすデータ量を増やすと、readシステムコールの呼び出し回数が減少し、netstatコマンドが高速になった

## 環境
- 以降の操作やプログラムの作成、動作確認は本記事作成時点でのCentOS7の最新環境で行った
 
```shell-session
$ uname -r
3.10.0-693.17.1.el7.x86_64
$ rpm -q kernel
kernel-3.10.0-693.11.6.el7.x86_64
kernel-3.10.0-693.17.1.el7.x86_64
$ rpm -q net-tools
net-tools-2.0-0.22.20131004git.el7.x86_64

$ cat /etc/redhat-release
CentOS Linux release 7.4.1708 (Core)
$ LANG=C lscpu
Architecture:          x86_64
CPU op-mode(s):        32-bit, 64-bit
Byte Order:            Little Endian
CPU(s):                2
On-line CPU(s) list:   0,1
Thread(s) per core:    2
Core(s) per socket:    1
Socket(s):             1
NUMA node(s):          1
Vendor ID:             GenuineIntel
CPU family:            6
Model:                 63
Model name:            Intel(R) Xeon(R) CPU E5-2673 v3 @ 2.40GHz
Stepping:              2
CPU MHz:               2394.456
BogoMIPS:              4788.91
Virtualization:        VT-x
Hypervisor vendor:     Microsoft
Virtualization type:   full
L1d cache:             32K
L1i cache:             32K
L2 cache:              256K
L3 cache:              30720K
NUMA node0 CPU(s):     0,1
Flags:                 fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl xtopology eagerfpu pni pclmulqdq vmx ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand hypervisor lahf_lm abm tpr_shadow vnmi ept vpid fsgsbase bmi1 avx2 smep bmi2 erms xsaveopt
$ free -m
              total        used        free      shared  buff/cache   available
Mem:           7966         416        6876           8         673        7205
Swap:             0           0           0
```

----

# 手順

## 準備

- RPMパッケージのビルドに必要な諸々をインストール・設定しておく

```shell-session
sudo useradd mockbuild
sudo yum -y install rpm-build
sudo yum -y groups install "Development Tools"
```

## Linuxカーネルをどうにかする

### カーネルのSRPMを取得してインストール

- ビルド時に必要になるパッケージもインストールする

```shell-session
yumdownloader --source kernel
rpm -ivh kernel-3.10.0-693.17.1.el7.centos.plus.src.rpm
sudo yum -y install m4 gcc xmlto asciidoc hmaccalc  python-devel newt-devel 'perl(ExtUtils::Embed)' pesign elfutils-devel zlib-devel binutils-devel bison audit-libs-devel  java-devel openssl-devel numactl-devel pciutils-devel ncurses-devel
```

### パッチを用意&SPECファイルを修正

- `~/rpmbuild/SOURCES/proc-net-tcp.patch`として下記の内容でパッチを作成

```diff
--- a/net/ipv4/tcp_ipv4.c	2018-01-14 15:02:54.000000000 +0000
+++ b/net/ipv4/tcp_ipv4.c	2018-02-10 06:08:39.043951339 +0000
@@ -2382,10 +2382,21 @@
 	return 0;
 }
 
+static ssize_t tcp_seq_read(struct file *file, char __user *buf, size_t size, loff_t *ppos)
+{
+	struct seq_file *m = file->private_data;
+	if (!m->buf) {
+		m->buf = kmalloc( m->size = 16 * PAGE_SIZE, GFP_KERNEL);
+		if (!m->buf)
+			return -ENOMEM;
+	}
+	return seq_read(file, buf, size, ppos);
+}
+
 static const struct file_operations tcp_afinfo_seq_fops = {
 	.owner   = THIS_MODULE,
 	.open    = tcp_seq_open,
-	.read    = seq_read,
+	.read    = tcp_seq_read,
 	.llseek  = seq_lseek,
 	.release = seq_release_net
 };
--- a/net/ipv6/tcp_ipv6.c	2018-01-14 15:02:54.000000000 +0000
+++ b/net/ipv6/tcp_ipv6.c	2018-02-10 06:10:39.910935928 +0000
@@ -1812,10 +1812,21 @@
 	return 0;
 }
 
+static ssize_t tcp_seq_read(struct file *file, char __user *buf, size_t size, loff_t *ppos)
+{
+	struct seq_file *m = file->private_data;
+	if (!m->buf) {
+		m->buf = kmalloc( m->size = 16 * PAGE_SIZE, GFP_KERNEL);
+		if (!m->buf)
+			return -ENOMEM;
+	}
+	return seq_read(file, buf, size, ppos);
+}
+
 static const struct file_operations tcp6_afinfo_seq_fops = {
 	.owner   = THIS_MODULE,
 	.open    = tcp_seq_open,
-	.read    = seq_read,
+	.read    = tcp_seq_read,
 	.llseek  = seq_lseek,
 	.release = seq_release_net
 };

```

- ビルド時に上記のパッチを適用するようにSPECファイルを修正

```shell-session
$ diff ~/rpmbuild/SPECS/kernel.spec.org ~/rpmbuild/SPECS/kernel.spec
8c8
< %define buildid .centos.plus
---
> %define buildid .procnettcp
463a464,465
> Patch50000: proc-net-tcp.patch
>
832a835,836
>
> ApplyPatch proc-net-tcp.patch
```

### Linuxカーネルのパッケージをビルド

- 今回使用した環境での所要時間は1.5時間強

```shell-session
rpmbuild -ba ~/rpmbuild/SPECS/kernel.spec
find ~/rpmbuild/ -name "*.rpm"
```

### 結果
- ビルドして得られたRPMパッケージをインストールして再起動すると、`/proc/net/tcp`に対するreadが64KiByteずつ行えるようになった

#### catコマンドでの確認
- `/proc/net/tcp`の内容を読み出すcatコマンドについて、straceコマンドでreadシステムコールの様子を観察する

```shell-session
$ uname -r
3.10.0-693.17.1.el7.procnettcp.x86_64
$ LANG=C strace -e open,read cat /proc/net/tcp|wc -l
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20\35\2\0\0\0\0\0"..., 832) = 832
open("/proc/net/tcp", O_RDONLY)         = 3
read(3, "  sl  local_address rem_address "..., 65536) = 11700
read(3, "", 65536)                      = 0
78
+++ exited with 0 +++
```

- 改造前のLinuxカーネルの環境下で同じコマンドを実行した結果が下記
    - readシステムコールの第3引数として同じ64KiByteが指定されているにもかかわらず、4096Byteよりも少ないデータしか読み出せていないことがわかる

```shell-session
$ uname -r
3.10.0-693.17.1.el7.x86_64
$ LANG=C strace -e open,read cat /proc/net/tcp|wc -l
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20\35\2\0\0\0\0\0"..., 832) = 832
open("/proc/net/tcp", O_RDONLY)         = 3
read(3, "  sl  local_address rem_address "..., 65536) = 4050
read(3, "  26: 0502010A:861A 10813FA8:005"..., 65536) = 1650
read(3, "", 65536)                      = 0
38
+++ exited with 0 +++
```

#### netstatコマンドでの確認
- 改造後のカーネルの環境でnetstatコマンドについて、straceコマンドでreadシステムコールの様子を観察する
    - readするときに4096Byteしかバッファを渡してないのであまり影響がない

```shell-session
$ uname -r
3.10.0-693.17.1.el7.procnettcp.x86_64
$ LANG=C strace -e open,read netstat -atn |wc -l
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libselinux.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\300j\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20\35\2\0\0\0\0\0"..., 832) = 832
open("/lib64/libpcre.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360\25\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0`\16\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0m\0\0\0\0\0\0"..., 832) = 832
open("/proc/net/tcp", O_RDONLY)         = 3
read(3, "  sl  local_address rem_address "..., 4096) = 4096
read(3, "00000000 03:00001608 00000000   "..., 4096) = 4096
read(3, " 3 ffff880287e55d00             "..., 4096) = 4096
read(3, "           \n", 4096)          = 12
read(3, "", 4096)                       = 0
open("/proc/net/tcp6", O_RDONLY)        = 3
read(3, "  sl  local_address             "..., 4096) = 853
read(3, "", 4096)                       = 0
+++ exited with 0 +++
87
```


- 改造前のカーネルの環境でnetstatコマンドについて、straceコマンドでreadシステムコールの様子が下記
    - readシステムコールで指定したバッファサイス4096Byteに対して、4050Byteしか読み出せていない事がわかる

```shell-session
$ uname -r
3.10.0-693.17.1.el7.x86_64
$ LANG=C strace -e open,read netstat -atn |wc -l
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libselinux.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\300j\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20\35\2\0\0\0\0\0"..., 832) = 832
open("/lib64/libpcre.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360\25\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0`\16\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0m\0\0\0\0\0\0"..., 832) = 832
open("/proc/net/tcp", O_RDONLY)         = 3
read(3, "  sl  local_address rem_address "..., 4096) = 4050
read(3, "  26: 0502010A:870C 10813FA8:005"..., 4096) = 4050
read(3, "  53: 0502010A:E488 19E57328:01B"..., 4096) = 900
read(3, "", 4096)                       = 0
open("/proc/net/tcp6", O_RDONLY)        = 3
read(3, "  sl  local_address             "..., 4096) = 676
read(3, "", 4096)                       = 0
+++ exited with 0 +++
64
```

----
## netstatをどうにかする

### net-toolsのSRPMを取得してインストール

```shell-session
yumdownloader --source net-tools
rpm -ivh net-tools-2.0-0.22.20131004git.el7.src.rpm
sudo yum -y install libselinux-devel
```

### パッチを用意&SPECファイルを修正
- `~/rpmbuild/SOURCES/net-tools-netstat-proc-net-tcp-buffer-size.patch`として下記の内容でパッチを作成
    - ページサイズの16倍の容量でバッファ領域を確保するように変更している

```diff
--- a/lib/proc.c	2013-09-30 08:07:32.000000000 +0000
+++ b/lib/proc.c	2018-02-11 04:48:40.959339035 +0000
@@ -88,9 +88,9 @@
 
     if (!buffer) {
       pagesz = getpagesize();
-      buffer = malloc(pagesz);
+      buffer = malloc(16*pagesz);
     }
 
-    setvbuf(fd, buffer, _IOFBF, pagesz);
+    setvbuf(fd, buffer, _IOFBF, 16*pagesz);
     return fd;
 }
```

- ビルド時に上記のパッチを適用するようにSPECファイルを修正
 
```
$ diff ~/rpmbuild/SPECS/net-tools.spec.org  ~/rpmbuild/SPECS/net-tools.spec
67a68,69
> Patch100: net-tools-netstat-proc-net-tcp-buffer-size.patch
>
104a107,108
>
> %patch100 -p1 -b .proc-net-tcp

```

### net-toolsのパッケージをビルド
- 改造前のnetstatコマンドとの比較のため、ビルドして得られたRPMパッケージはインストールしない

```shell-session
rpmbuild -bb ~/rpmbuild/SPECS/net-tools.spec
ls -l ~/rpmbuild/BUILD/net-tools/netstat 
```

### 結果

- 改造前のnetstatコマンドについて、straceコマンドでreadシステムコールの様子を観察
    - `/proc/net/tcp`をopenした後のreadは4096Byteずつデータが読みだされている

```shell-session
$ LANG=C strace -e open,read netstat -atn  |wc -l
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libselinux.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\300j\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20\35\2\0\0\0\0\0"..., 832) = 832
open("/lib64/libpcre.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360\25\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0`\16\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0m\0\0\0\0\0\0"..., 832) = 832
open("/proc/net/tcp", O_RDONLY)         = 3
read(3, "  sl  local_address rem_address "..., 4096) = 4096
read(3, "00000000 03:0000022B 00000000   "..., 4096) = 4096
read(3, " 3 ffff8802c43a7d00             "..., 4096) = 3508
read(3, "", 4096)                       = 0
open("/proc/net/tcp6", O_RDONLY)        = 3
read(3, "  sl  local_address             "..., 4096) = 853
read(3, "", 4096)                       = 0
+++ exited with 0 +++
83
```

- 改造後のnetstatコマンドについて、straceコマンドでreadシステムコールの様子を観察
  - readシステムコールの第3引数として65536=64KiByteが設定され、4096Byteより大きい容量を一度のreadシステムコールで読み出せている

```shell-session
$ LANG=C strace -e open,read ~/rpmbuild/BUILD/net-tools-2.0/netstat -atn  |wc -l
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libselinux.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\300j\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20\35\2\0\0\0\0\0"..., 832) = 832
open("/lib64/libpcre.so.1", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\360\25\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0`\16\0\0\0\0\0\0"..., 832) = 832
open("/lib64/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0m\0\0\0\0\0\0"..., 832) = 832
open("/proc/net/tcp", O_RDONLY)         = 3
read(3, "  sl  local_address rem_address "..., 65536) = 11700
read(3, "", 65536)                      = 0
open("/proc/net/tcp6", O_RDONLY)        = 3
read(3, "  sl  local_address             "..., 65536) = 853
read(3, "", 65536)                      = 0
+++ exited with 0 +++
83
```




## TCPセッションが多い状態での動作の比較

- TCPセッション数を増やすために、ApacheとApacheに付属するベンチマークツールのabコマンドを使用する
- Apacheをインストール・起動し、abコマンドを繰り返し実行するワンライナーを実行しながら改造前後のnetstatコマンドの動作を比較する

```
sudo yum -y install httpd
sudo systemctl start  httpd.service
while : ; do ab -n 100000 -c 1000 127.0.0.1/test; done
```

- timeコマンドでの計測では、TCPセッション数が16000強の状態では従来のnetstatコマンドで300ミリ秒以上かかっていたのが、改造後のnetstatコマンドでは100ミリ秒台と、半分以下の時間で済むようになった
    - 特にsystem timeが顕著に減少している

```shell-session
$ time  netstat -atn |wc -l
16528

real    0m0.355s
user    0m0.110s
sys     0m0.254s
$ time  ~/rpmbuild/BUILD/net-tools-2.0/netstat  -atn |wc -l
16202

real    0m0.133s
user    0m0.115s
sys     0m0.027s
```

# 最後に
- ちなみに、netstatコマンドと同様の目的で利用される事の多いssコマンドは通常[^ss-using-procfs]`/proc/net/tcp`の参照とは異なる仕組み(特殊なソケットを利用する"netlink")でTCPセッション数を取得しており、上記の改造後のnetstatコマンドよりも高速に処理ができる

[^ss-using-procfs]: netlinkによる情報の取得に失敗した場合にはprocfsを利用した情報の取得を行います。

```shell-session
$ time  ss -atn |wc -l
15922

real    0m0.046s
user    0m0.033s
sys     0m0.021s
```

netstatコマンドというかnet-toolsがobsoleteと見なされつつある状況も踏まえれば、素直にssコマンドを利用するのが良いでしょう。