42-案例篇:如何优化NAT性能(下)





案例准备

  1. 服务器准备

    buntu 18.04
    机器配置:2CPU,4GB内存
    预先安装docker、tcpdump、curl、ab、SystemTap等工具
    
    # Ubuntu
    $ apt-get install -y docker.io tcpdump curl apache2-utils
    
    # CentOS
    $ curl -fsSL https://get.docker.com | sh
    $ yum install -y tcpdump curl httpd-tools
    

    SystemTap是Linux的一种动态追踪框架,
    它把用户提供的脚本,转换为内核模块来执行,用来监测和跟踪内核的行为

    # Ubuntu
    $ apt-get install -y systemtap-runtime systemtap
    
    # Configure ddebs source
    $ echo "deb http://ddebs.ubuntu.com $(lsb_release -cs) main restricted universe multiverse 
    deb http://ddebs.ubuntu.com $(lsb_release -cs)-updates main restricted universe multiverse
    deb http://ddebs.ubuntu.com $(lsb_release -cs)-proposed main restricted universe multiverse" | \
    sudo tee -a /etc/apt/sources.list.d/ddebs.list
    
    # Install dbgsym
    $ apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F2EDC64DC5AEE1F6B9C621F0C8CAB6595FDFF622
    $ apt-get update
    $ apt install ubuntu-dbgsym-keyring
    $ stap-prep
    $ apt-get install linux-image-`uname -r`-dbgsym
     
    # CentOS
    $ yum install systemtap kernel-devel yum-utils kernel
    $ stab-prep
    
    

    本次案例还是最常见的Nginx,并且会用ab作为它的客户端,进行压力测试
    案例中总共用到两台虚拟机
    image-20211231094747261

    注意,curl和ab只需要在客户端VM(即VM2)中安装

  2. 终端一,执行下面的命令,启动Nginx,注意选项--network=host
    表示容器使用Host网络模式,即不使用NAT

    $ docker run --name nginx-hostnet --privileged --network=host -itd feisky/nginx:80
    
    
  3. 终端二,执行curl命令,确认Nginx正常启动

    $ curl http://192.168.0.30/
    ...
    <p><em>Thank you for using nginx.</em></p>
    </body>
    </html>
    
    
  4. 终端二,执行ab命令,对Nginx进行压力测试
    不过在测试前要注意,Linux默认允许打开的文件描述数比较小,这个值只有1024

    # open files
    $ ulimit -n
    1024
    
    # 执行ab前先把这个选项调大,比如调成65536
    # 临时增大当前会话的最大文件描述符数
    $ ulimit -n 65536
    
    # 执行ab命令,进行压力测试
    # -c表示并发请求数为5000
    # -n表示总的请求数为10万
    # -r表示套接字接收错误时仍然继续执行
    # -s表示设置每个请求的超时时间为2s
    $ ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30/
    ...
    Requests per second:    6576.21 [#/sec] (mean)
    Time per request:       760.317 [ms] (mean)
    Time per request:       0.152 [ms] (mean, across all concurrent requests)
    Transfer rate:          5390.19 [Kbytes/sec] received
     
    Connection Times (ms)
                  min  mean[+/-sd] median   max
    Connect:        0  177 714.3      9    7338
    Processing:     0   27  39.8     19     961
    Waiting:        0   23  39.5     16     951
    Total:          1  204 716.3     28    7349
    ...
    ## 记住这几个数值,这将是接下来案例的基准指标
    # 1. 每秒请求数(Requests per second)为6576
    # 2. 每个请求的平均延迟(Time per request)为760ms
    # 3. 建立连接的平均延迟(Connect)为177ms
    
    
  5. 终端一,停止这个未使用NAT的Nginx应用

    $ docker rm -f nginx-hostnet
    
    
  6. 终端一,启动今天的案例应用
    案例应用监听8080端口,并且使用了DNAT,来实现Host的8080端口,到容器的8080端口的映射关系

    $ docker run --name nginx --privileged -p 8080:8080 -itd feisky/nginx:nat
    
    
  7. 终端一,执行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
    ##
    # 在PREROUTING链中,目的为本地的请求,会转到DOCKER链
    # 而在DOCKER链中,目的端口为8080的tcp请求,会被DNAT到172.17.0.2的8080端口
    # 其中172.17.0.2就是Nginx容器的IP地址
    
    
  8. 终端二,执行curl命令,确认Nginx已经正常启动

    $ curl http://192.168.0.30:8080/
    ...
    <p><em>Thank you for using nginx.</em></p>
    </body>
    </html>
    ##
    # 执行ab命令,把请求的端口号换成8080,进行压测
    # -c表示并发请求数为5000
    # -n表示总的请求数为10万
    # -r表示套接字接收错误时仍然继续执行
    # -s表示设置每个请求的超时时间为2s
    $ ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30:8080/
    ...
    apr_pollset_poll: The timeout specified has expired (70007)
    Total of 5602 requests completed
    ##
    # 果然刚才正常运行的ab,现在失败了,还报了连接超时的错误
    # 运行ab时的-s参数,设置了每个请求的超时时间为2s,而从输出可以看到,这次只完成了5602个请求
    ##
    # 既然是为了得到ab的测试结果,不妨把超时时间延长一下试试,比如延长到30s
    # 延迟增大意味着要等更长时间,为了快点得到结果,可以同时把总测试次数也减少到10000
    $ ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/
    ...
    Requests per second:    76.47 [#/sec] (mean)
    Time per request:       65380.868 [ms] (mean)
    Time per request:       13.076 [ms] (mean, across all concurrent requests)
    Transfer rate:          44.79 [Kbytes/sec] received
     
    Connection Times (ms)
                  min  mean[+/-sd] median   max
    Connect:        0 1300 5578.0      1   65184
    Processing:     0 37916 59283.2      1  130682
    Waiting:        0    2   8.7      1     414
    Total:          1 39216 58711.6   1021  130682
    ...
    ##
    # 再重新看看ab的输出,这次的结果显示
    # 1. 每秒请求数(Requests per second)为76
    # 2. 每个请求的延迟(Time per request)为65s
    # 3. 建立连接的延迟(Connect)为1300ms
    ##
    # 显然,每个指标都比前面差了很多
    # 那么,碰到这种问题时应该怎么办呢?
    # 可以根据前面的讲解,先自己分析一下再继续学习下面的内容
    ##
    # 在上一节使用tcpdump抓包的方法,找出了延迟增大的根源
    # 那么今天的案例仍然可以用类似的方法寻找线索
    # 不过现在换个思路,因为已经事先知道了问题的根源就是NAT
    
    
  9. 回忆一下Netfilter中,网络包的流向以及NAT的原理,发现要保证NAT正常工作,就至少需要两个步骤

    1. 第一利用Netfilter中的钩子函数(Hook),修改源地址或者目的地址
    2. 第二利用连接跟踪模块conntrack ,关联同一个连接的请求和响应

    是不是这两个地方出现了问题呢?用前面提到的动态追踪工具SystemTap来试试

    由于今天案例是在压测场景下,并发请求数大大降低,并且我知道NAT是罪魁祸首
    所以有理由怀疑,内核中发生了丢包现象


  10. 终端一,创建一个dropwatch.stp 的脚本文件,并写入下面的内容

    #! /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
    }
    
    

    这个脚本跟踪内核函数kfree_skb()的调用,并统计丢包的位置
    文件保存好后,执行stap命令,就可以运行丢包跟踪脚本
    这里的stap,是SystemTap的命令行工具

    $ stap --all-modules dropwatch.stp
    Monitoring for dropped packets
    ##
    # 当看到probe begin输出的“Monitoring for dropped packets”时
    # 表明SystemTap已经将脚本编译为内核模块,并启动运行了
    
    
  11. 终端二,再次执行ab命令

    $ ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/
    
    
  12. 终端一,观察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来完成
    
    
  13. 终端二,再次执行ab命令

    $ ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/
    
    
  14. 终端一,执行perf record和perf report命令

    # 记录一会(比如 30s)后按Ctrl+C结束
    perf record -a -g -- sleep 30
     
    # 输出报告
    perf report -g graph,0
    
    

    在perf report界面中,输入查找命令 / 然后,在弹出的对话框中,输入nf_hook_slow
    最后再展开调用栈,就可以得到下面这个调用图
    image-20211231103619612
    从这个图可以看到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的内核机制,所以接下来的优化,自然也是要从内核入手

    根据以前各个资源模块的内容知道Linux内核为用户提供了大量的可配置选项
    这些选项可以通过proc文件系统或者sys文件系统,来查看和修改
    除此之外,还可以用sysctl这个命令行工具,来查看和修改内核配置

  15. 比如今天的主题是DNAT,而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
    ...
    
    

    可以看到,这里最重要的三个指标

    1. net.netfilter.nf_conntrack_count,表示当前连接跟踪数
    2. net.netfilter.nf_conntrack_max,表示最大连接跟踪数
    3. net.netfilter.nf_conntrack_buckets,表示连接跟踪表的大小

    所以从这个输出看出,当前连接跟踪数是180,最大连接跟踪数是1000,连接跟踪表的大小,则是65536

    回想一下前面的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
    
    

    其中net_ratelimit表示有大量的日志被压缩掉了,这是内核预防日志攻击的一种措施
    而当看到“nf_conntrack: table full”的错误时,就表明nf_conntrack_max太小了

    那是不是直接把连接跟踪表调大就可以了呢?
    调节前得明白,连接跟踪表,实际上是内存中的一个哈希表
    如果连接跟踪数过大,也会耗费大量内存

    其实上面看到的nf_conntrack_buckets,就是哈希表的大小
    哈希表中的每一项,都是一个链表,而链表长度,就等于nf_conntrack_max除以nf_conntrack_buckets

    比如可以估算一下,上述配置的连接跟踪表占用的内存大小

    # 连接跟踪对象大小为376,链表项大小为16
    nf_conntrack_max* 连接跟踪对象大小 +nf_conntrack_buckets* 链表项大小 
    = 1000*376+65536*16 B
    = 1.4 MB
    
    

    接下来将nf_conntrack_max改大一些,比如改成131072(即nf_conntrack_buckets的2倍)

    $ sysctl -w net.netfilter.nf_conntrack_max=131072
    $ sysctl -w net.netfilter.nf_conntrack_buckets=65536
    
    
  16. 终端二,重新执行ab命令,注意这次把超时时间也改回原来的2s

    $ ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30: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
    
    

    果然现在可以看到

    1. 每秒请求数(Requests per second)为6315(不用NAT时为6576)
    2. 每个请求的延迟(Time per request)为791ms(不用NAT时为760ms)
    3. 建立连接的延迟(Connect)为355ms(不用NAT时为177ms)

    这个结果已经比刚才的测试好了很多,也很接近最初不用NAT时的基准结果了

    不过连接跟踪表里,到底都包含了哪些东西?这里的东西,又是怎么刷新的呢?
    实际上可以用conntrack命令行工具,来查看连接跟踪表的内容

    # -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等工具,对其进行统计分析


  17. 终端二,启动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协议各个状态的连接跟踪数,以及各个源IP的连接跟踪数
    可以看到大部分TCP的连接跟踪,都处于TIME_WAIT状态
    并且它们大都来自于192.168.0.2这个IP地址(也就是运行ab命令的VM2)

    这些处于TIME_WAIT的连接跟踪记录,会在超时后清理,而默认的超时时间是120s,执行下面的命令来查看

    $ sysctl net.netfilter.nf_conntrack_tcp_timeout_time_wait
    net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120
    
    

    所以如果连接数非常大,确实也应该考虑,适当减小超时时间
    除了上面这些常见配置conntrack还包含了其他很多配置选项
    可以根据实际需要参考nf_conntrack的文档来配置




小结

由于NAT基于Linux内核的连接跟踪机制来实现
所以,在分析NAT性能问题时,可以先从conntrack角度来分析
比如用systemtap、perf等,分析内核中conntrack的行文
然后,通过调整netfilter内核选项的参数,来进行优化

其实Linux这种通过连接跟踪机制实现的NAT,也常被称为有状态的NAT,而维护状态,也带来了很高的性能成本

除了调整内核行为外在不需要状态跟踪的场景下(比如只需要按预定的IP和端口进行映射,而不需要动态映射)
也可以使用无状态的NAT (比如用tc或基于DPDK开发),来进一步提升性能


posted @ 2021-12-31 11:00  李成果  阅读(183)  评论(0编辑  收藏  举报