第一步、创建负载环境
性能正常的 Nginx 服务
# docker run --name nginx-hostnet --privileged --network=host -itd feisky/nginx:80
# curl http://10.10.50.199
# ulimit -n 65536
# ab -c 5000 -n 100000 -r -s 2 http://10.10.50.199/
// -c 表示并发请求数为5000,-n 表示总的请求数为10万
// -r 表示套接字接收错误时仍然继续执行,-s 表示设置每个请求的超时时间为 2s
...
Requests per second: 9531.80 [#/sec] (mean)
Time per request: 524.560 [ms] (mean)
Time per request: 0.105 [ms] (mean, across all concurrent requests)
Transfer rate: 7827.84 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 220 676.8 56 7350
Processing: 8 174 514.5 58 10205
Waiting: 1 171 511.4 57 10205
Total: 10 394 894.1 114 10272
...
// 每秒请求数(Requests per second)为 9531;
// 每个请求的平均延迟(Time per request)为 524ms;
// 建立连接的平均延迟(Connect)为 220ms。
运行端口映射的 Nginx 服务
# docker run --name nginx --privileged -p 8080:8080 -itd feisky/nginx:nat // Nginx 启动后,可以执行 iptables 命令,确认 DNAT 规则已经创建: # iptables -nL -t nat Chain PREROUTING (policy ACCEPT) target prot opt source destination DOCKER all -- 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL ... Chain DOCKER (2 references) target prot opt source destination RETURN all -- 0.0.0.0/0 0.0.0.0/0 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:8080 // 确认 Nginx 已经正常启动 # curl http://10.10.50.199:8080/ // 再次执行上述的 ab 命令,请求 8080 端口 # ulimit -n 65536 # ab -c 5000 -n 100000 -r -s 2 http://10.10.50.199:8080/ ... Benchmarking 10.10.50.199 (be patient) apr_pollset_poll: The timeout specified has expired (70007) Total of 587 requests completed // 从输出可以看到,这次只完成了 587 个请求,产生连接超时的错误 // 我们延长超时时间,并降低总测试次数 # ab -c 5000 -n 50000 -r -s 30 http://10.10.50.199:8080/
第二步、问题排查
在 Netfilter 中,根据网络包的流向以及 NAT 的原理,要保证 NAT 正常工作,就至少需要两个步骤:
1)利用 Netfilter 中的钩子函数(Hook),修改源地址或者目的地址。
2)利用连接跟踪模块 conntrack ,关联同一个连接的请求和响应。
使用 systemtap 定位
编写脚本:
#! /usr/bin/env stap
############################################################
# Dropwatch.stp
# Author: Neil Horman <nhorman@redhat.com>
# An example script to mimic the behavior of the dropwatch utility
# http://fedorahosted.org/dropwatch
############################################################
# Array to hold the list of drop points we find
global locations
# Note when we turn the monitor on and off
probe begin { printf("Monitoring for dropped packets\n") }
probe end { printf("Stopping dropped packet monitor\n") }
# increment a drop counter for every location we drop at
probe kernel.trace("kfree_skb") { locations[$location] <<< 1 }
# Every 5 seconds report our drop locations
probe timer.sec(5)
{
printf("\n")
foreach (l in locations-) {
printf("%d packets dropped at %s\n",
@count(locations[l]), symname(l))
}
delete locations
}
运行命令:
# stap --all-modules dropwatch.stp Monitoring for dropped packets // 如上输出 表明 SystemTap 已经将脚本编译为内核模块,并启动运行
运行 ab 命令,观察 stap 输出
// 再次运行 ab 命令进行测试 # ab -c 5000 -n 10000 -r -s 30 http://10.10.50.199:8080/ // 观察 stap 输出 10031 packets dropped at nf_hook_slow 676 packets dropped at tcp_v4_rcv 7284 packets dropped at nf_hook_slow 268 packets dropped at tcp_v4_rcv
丢包都发生在 nf_hook_slow 位置,这是 在 Netfilter Hook 的钩子函数。但是不是 NAT,还不能确定。接下来,我们还得再跟踪 nf_hook_slow 的执行过程。
perf record 和 perf report
// 再次运行 ab 命令进行测试 # ab -c 5000 -n 10000 -r -s 30 http://10.10.50.199:8080/ // 记录一会(比如30s)后按Ctrl+C结束 # perf record -a -g -- sleep 30 // 输出报告 # perf report -g graph,0 ... // 在 perf report 界面中,输入查找命令 / 然后,在弹出的对话框中,输入 nf_hook_slow;最后再展开调用栈
可以看到 nf_hook_slow 调用最多的有三个地方:ipv4_conntrack_in、br_nf_pre_routing、iptable_nat_ipv4_in。换言之 nf_hook_slow 主要在执行三个动作:
1)接收网络包时,在连接跟踪表中查找连接,并为新的连接分配跟踪对象(Bucket)。
2)在 Linux 网桥中转发包。这是因为案例 Nginx 是一个 Docker 容器,而容器的网络通过网桥来实现;
3)接收网络包时,执行 DNAT,即把 8080 端口收到的包转发给容器
这三个来源,都是 Linux 的内核机制,所以接下来的优化,自然也是要从内核入手。
第三步、性能优化
DNAT 的基础是 conntrack,所以我们可以先看看,内核提供了哪些 conntrack 的配置选项:
# sysctl -a | grep conntrack net.netfilter.nf_conntrack_count = 180 net.netfilter.nf_conntrack_max = 1000 net.netfilter.nf_conntrack_buckets = 65536 net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 60 net.netfilter.nf_conntrack_tcp_timeout_syn_sent = 120 net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120 ... // net.netfilter.nf_conntrack_count,表示当前连接跟踪数; // net.netfilter.nf_conntrack_max,表示最大连接跟踪数; // net.netfilter.nf_conntrack_buckets,表示连接跟踪表的大小; // ab 命令,并发请求数是 5000,而请求数是 100000。显然,跟踪表设置成,只记录 1000 个连接,是远远不够的 // 内核在工作异常时,会把异常信息记录到日志中。比如前面的 ab 测试,内核已经在日志中报出了 “nf_conntrack: table full” 的错误,执行 dmesg 命令可以看到 # dmesg | tail [104235.156774] nf_conntrack: nf_conntrack: table full, dropping packet [104243.800401] net_ratelimit: 3939 callbacks suppressed [104243.800401] nf_conntrack: nf_conntrack: table full, dropping packet [104262.962157] nf_conntrack: nf_conntrack: table full, dropping packet
调整连接跟踪表,如果连接跟踪数过大,也会耗费大量内存。上面看到的 nf_conntrack_buckets 是哈希表的大小。哈希表中的每一项都是一个链表(称为 Bucket),而链表长度,就等于 nf_conntrack_max 除以 nf_conntrack_buckets。
连接跟踪表占用的内存大小
= nf_conntrack_max * 连接跟踪对象大小 + nf_conntrack_buckets * 链表项大小
= 1000 * 376 + 65536 * 16 B = 1.4 MB
注意,连接跟踪对象大小为 376,链表项大小为 16,这是内核数据结构的大小,一般不会变化
我们将 nf_conntrack_max 改大一些,比如改成 131072(即 nf_conntrack_buckets 的 2 倍):
# sysctl -w net.netfilter.nf_conntrack_max=131072 # sysctl -w net.netfilter.nf_conntrack_buckets=65536
第四步、再次进行压力测试
# ab -c 5000 -n 100000 -r -s 2 http://10.10.50.199:8080/
...
Requests per second: 6315.99 [#/sec] (mean)
Time per request: 791.641 [ms] (mean)
Time per request: 0.158 [ms] (mean, across all concurrent requests)
Transfer rate: 4985.15 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 355 793.7 29 7352
Processing: 8 311 855.9 51 14481
Waiting: 0 292 851.5 36 14481
Total: 15 666 1216.3 148 14645
// 这个结果,已经比刚才的测试好了很多,也很接近最初不用 NAT 时的基准结果
查看连接跟踪表的内容
// -L表示列表,-o表示以扩展格式显示 # conntrack -L -o extended | head ipv4 2 tcp 6 7 TIME_WAIT src=192.168.0.2 dst=192.168.0.96 sport=51744 dport=8080 src=172.17.0.2 dst=192.168.0.2 sport=8080 dport=51744 [ASSURED] mark=0 use=1 ipv4 2 tcp 6 6 TIME_WAIT src=192.168.0.2 dst=192.168.0.96 sport=51524 dport=8080 src=172.17.0.2 dst=192.168.0.2 sport=8080 dport=51524 [ASSURED] mark=0 use=1
连接跟踪表里的对象,包括协议、连接状态、源 IP、源端口、目的 IP、目的端口、跟踪状态等。由于这个格式是固定的,所以我们可以用 awk、sort 等工具,对其进行统计分析。
使用 ab 测试
# 统计总的连接跟踪数
$ conntrack -L -o extended | wc -l
14289
# 统计TCP协议各个状态的连接跟踪数
$ conntrack -L -o extended | awk '/^.*tcp.*$/ {sum[$6]++} END {for(i in sum) print i, sum[i]}'
SYN_RECV 4
CLOSE_WAIT 9
ESTABLISHED 2877
FIN_WAIT 3
SYN_SENT 2113
TIME_WAIT 9283
# 统计各个源IP的连接跟踪数
$ conntrack -L -o extended | awk '{print $7}' | cut -d "=" -f 2 | sort | uniq -c | sort -nr | head -n 10
14116 192.168.0.2
172 192.168.0.96
可以看到大部分 TCP 的连接跟踪,都处于 TIME_WAIT 状态,并且它们大都来自于 192.168.0.2 这个地址。
处于 TIME_WAIT 的连接跟踪记录,会在超时后清理,而默认的超时时间是 120s,如果连接数非常大,确实也应该考虑适当减小超时时间:
# sysctl net.netfilter.nf_conntrack_tcp_timeout_time_wait net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120
相关链接
记一次Docker/Kubernetes上无法解释的连接超时原因探寻之旅
参考文献
42 | 案例篇:如何优化 NAT 性能?(下)
networking – How to list all network connections Centos 7 (Connection tracking)