第一步、创建负载环境
性能正常的 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)