chaussen

摘自Dropbox技术博客:如何优化网站服务器来提高吞吐量并减少延迟时间

原文链接: blogs.dropbox.com

2017年九月六号,我在Nginx大会上发言,而这篇文章是我讲话内容的拓展。作为Dropbox流量团队的网站可靠性工程师,我负责的是公司的边缘网络,保证网络的可靠性、运行性能和运行效率。Dropbox的边缘网络是一个基于nginx的代理服务层。网站的元数据事务处理对低延迟时间有着很高的要求,我们设计这个服务层,就是为了处理这些数据事务,以及高吞吐量的数据传输。 我们系统每秒要处理几十G的数据 ,同时又要进行几万个需要低延迟的数据事务。整个代理栈从驱动、中断到运行库,从网络的TCP/IP协议栈到系统内核都进行了效率/性能优化,另外还作了应用层的调整。

免责声明

这个帖子里我们将讨论许多调试网站服务器和代理的方法,但是请不要迷信这些方法。要用科学的手段,一条条地应用,每一次调试都要测试一下效果,这样才能确定是不是适用于目标环境。 本帖讲的不是Linux系统的性能调整。我给大家的一些参考资料中虽然很多是关于bcc工具,eBPF过滤器, 和perf命令的,但别把这个帖子当成系统性能剖析工具的使用指南。如果要进一步了解这些工具,建议读一下Brendan Gregg的博客。 本帖也不是讲浏览器性能的。谈到关于延迟时间优化时我会讲到一些客户端性能问题,但也仅仅是个大概。如果想了解得更多,应该看看Ilya Grigorik写的高性能浏览器网络化。 此外,本帖也不是传输层安全(TLS)的最佳方案汇编。有几次会提到TLS协议库及其设定,但你和安全组应该对每一项的性能以及其安全后果进行测算。可以用Qualys SSL Test这个工具,比照目前的最佳方案来验证终结点。想更多地了解TLS的一般知识,考虑订阅一份Feisty Duck Bulletproof TLS Newsletter吧。

本帖的结构

我们要从底层开始,讨论系统里每一个层面上的效率/性能优化。像硬件和驱动等这些属于底层,它们的优化几乎对任何高负荷的服务器都有作用。然后我们要讲到Linux系统内核这一层级,以及其上的TCP/IP协议栈。有些系统的TCP流量负荷很重,就要试试开启或关闭这里的设置。最后一部分,讨论库层级和应用层的调试,这些设置大多适用于一般网站服务器,尤其是nginx服务器。 每讲到一个潜在的可优化部分,我都会尽量给大家一些背景知识,帮助理解延迟/吞吐量之间可能发生的转换,还有一些监控性能的指导原则。最终还会根据工作负载的不同,给大家推荐一些调整手段。

硬件

中央处理器 (CPU)

RSA/EC协议 网站如果用的是非对称型RSA/EC算法加密系统(asymmetric RSA/EC),提高性能就要使用至少支持AVX2扩展指令集的CPU,可以看/proc/cpuinfo信息中是不是有avx2。最好CPU还带大整数算术功能的硬件,可以看CPU信息里有没有bmiadx。如果用的是对称型,用AES加密应该找带AES-NI指令集的CPU,用ChaCha+Poly加密就用带AVX512指令集的CPU。英特尔做过一个性能比较,比较了不同世代的硬件使用OpenSSL协议1.0.2版的性能,显示了数据负载卸载到这些硬件上后的效果。 有些用例中,系统对延迟时间很敏感,比如路由。这种情况下,CPU少一些非统一内存访问架构(NUMA)结点,禁用超线程都会对性能有利。有些任务吞吐量很高,那种情况下CPU的NUMA结点多少一般就不怎么重要了,只需要更多的核,而且只要不是缓存绑定,超线程技术也会起作用。 再具体点,如果用的是Intel系列,就至少要买Haswell/Broadwell架构的CPU,最理想的是Skylake架构。如果用的是AMD系列,那EPYC的性能相当不错。

网卡

至少10G,条件允许的话甚至用25G。要想超过这个量,但只有单个服务器,还要有TLS,那这里讲的调整方法就不够用了。可能要将TLS协议组帧操作转移到内核层去,比如FreeBSD系统Linux系统)。 网卡软件方面,一定要找邮件讨论组和用户组比较活跃的开源驱动软件,万一驱动出现问题要进行故障排除时这一点会显得非常重要,而驱动又是很有可能出现问题的。

内存

根据经验来看,对延迟时间要求高的任务需要内存速度快,对吞吐量要求高则需要内存容量大。

硬盘

硬盘要看对缓冲/缓存有什么样的需求,但要是缓冲或缓存的数据很大,那应该用闪存型存储器。有些人甚至用上了专门配合闪存的文件系统。不过那些文件系统一般是日志结构的,并不一定比普通ext4/xfs文件系统性能好。 反正只要注意不要因为忘开启TRIM命令,或没升级固件而让闪存烧穿了。

操作系统:低层级

固件

固件应该一直更新,否则将来出了问题要检查起来是很痛苦很漫长的,尽量避免。尽量用较新版本的CPU微指令、主板、网卡和SSD驱动器固件,但并不是说总是用最新的版本。经验告诉我们,除非最新版有非常严重的漏洞修复,比最新低一版是最好的,但同时也别低得太多。

驱动

原则上和固件更新一样,尽量接近最新版本。这里给大家一个忠告,内核的升级和驱动的升级应该尽可能不挂钩。比如,可以用DKMS程序框架把所有的驱动打包起来,或者也可以根据网站要使用的内核版本,预先编译好所有的驱动程序。这样万一升级了内核,而有些东西运行不了,可以少检查一个部分。

CPU

最好用内核本身配备的软件库和工具。Ubuntu/Debian版本的Linux系统里可以安装linux-tools软件包,里面有很多实用工具,但现在我们只要用cpupowerturbostat、和x86_energy_perf_policy程序。要验证CPU相关优化是否有效,可以用自己挑一个模拟载荷工具来进行软件压力测试。比如Yandex公司用的是Yandex.Tank。 上一次Nginx大会上,开发人员展示过nginx负载测试的最佳方案,可以看这里"NGINX性能测试"。

cpupower 程序

与其慢慢研究/proc/信息,这个程序要好用得多。 要查看处理器信息和变频调速器信息可以执行:

$ cpupower frequency-info
...
  driver: intel_pstate
  ...
  available cpufreq governors: performance powersave
  ...            
  The governor "performance" may decide which speed to use
  ...
  boost state support:
    Supported: yes
    Active: yes

看看英特尔睿频加速功能(Turbo Boost)是不是开了,还有英特尔CPU一定要确保用的是intel_pstate驱动,,而不是acpi-cpufreq驱动,更不能是pcc-cpufreq驱动。如果显示出来用的还是acpi-cpufreq驱动,,那就说明系统内核要升级了。要是无法升级,就一定要用performance调频器。intel_pstate驱动的话,即使用的是powersave调频器,系统性能也不会差,不过一定要自己验证过才行。

CPU会有闲置的情况发生,这时可以用turbostat程序直接查看处理器的特别模块寄存器,获得电源、频率和闲置状态信息,来看看CPU到底在做什么:

$ turbostat --debug -P
... Avg_MHz Busy% ... CPU%c1 CPU%c3 CPU%c6 ... Pkg%pc2 Pkg%pc3 Pkg%pc6 ...

这里看到的才是CPU真正的频率,没错,/proc/cpuinfo的信息是骗人的。我们还可以看到CPU核/包的闲置状态

如果连intel_pstate驱动都不能让CPU缩短闲置时间,达到理想性能,那么可以这么做:

  • 将调频器设置成performance模式。

  • x86_energy_perf_policy设置成performance模式。 或者只针对一些受延迟影响特别严重的任务,可以:

  • 使用[/dev/cpu_dma_latency](https://access.redhat.com/articles/65410)接口。

  • 对UDP数据流量,用busy-polling技术。

要了解处理器电源管理的一般知识,尤其是CPU效能状态,可以看2015年欧洲Linux大会上英特尔开源技术中心的展示" Linux内核电源与性能平衡"。

处理器关联(CPU Affinity)技术

在各线程/进程上运用CPU Affinity技术技术可以进一步减少延迟。例如,nginx有worker_cpu_affinity指令,可以自动将每个网站服务器进程绑定到各自的CPU核上。这样应该能杜绝处理器间运算迁移,减少缓存流失和页缺失,使每个处理周期内的指令数略微增加。这些都可以通过perf stat命令来验证。

可惜开启CPU Affinity技术也会有负作用。每个进程都要等待CPU空闲下来,这里的等待时间增长,会影响性能。要监控这一点,可以在nginx工作进程号上运行runqlat程序:

usecs               : count     distribution
    0 -> 1          : 819      |                                        |
    2 -> 3          : 58888    |******************************          |
    4 -> 7          : 77984    |****************************************|
    8 -> 15         : 10529    |*****                                   |
   16 -> 31         : 4853     |**                                      |
   ...
 4096 -> 8191       : 34       |                                        |
 8192 -> 16383      : 39       |                                        |
16384 -> 32767      : 17       |                                        |

从命令结果看,存在很多几毫秒的尾延迟,那就说明可能服务器除了nginx本身以外还运行了太多其它的东西,这时CPU Affinity技术不会降低延迟反而会增加延迟。

内存

通过mm/程序做的调整一般都是根据工作流程来特别定制的。这里只有几点建议:

NUMA架构

现代的CPU实际上都是几个分散的CPU芯片互联,非常快速地共享各种资源。基本上NUMA就是从超线程核心上的一级缓存,通过CPU封装上的三级缓存,到内存和总线接口链接,依靠快速互联和资源共享所进行的多重执行和储存单元。

想全面了解NUMA及其效果,可以看"NUMA深入研究系列",作者Frank Denneman

不过现在还是长话短说,关于NUMA下面几种方法多选一:

  • 无视。可以直接在BIOS关掉,也可以执行numactl --interleave=all后运行软件,这样性能不好不坏,但比较稳定。

  • 放弃,只用单一结点服务器。就像Facebook用的OCP Yosemite平台.

  • 采用。用户空间和内核空间要同时优化CPU/内存。

我们来讲讲第三个选择。第一二个没什么可优化的。

要想合理使用NUMA架构,应该把每个NUMA结点当成一个独立的服务器,首先要用numactl --hardware检查一下拓扑结构图:

$ numactl --hardware
available: 4 nodes (0-3)
node 0 cpus: 0 1 2 3 16 17 18 19
node 0 size: 32149 MB
node 1 cpus: 4 5 6 7 20 21 22 23
node 1 size: 32213 MB
node 2 cpus: 8 9 10 11 24 25 26 27
node 2 size: 0 MB
node 3 cpus: 12 13 14 15 28 29 30 31
node 3 size: 0 MB
node distances:
node   0   1   2   3
  0:  10  16  16  16
  1:  16  10  16  16
  2:  16  16  10  16
  3:  16  16  16  10

留意以下几点

  • 结点数。

  • 每个结点的内存大小

  • 每个结点的CPU数

  • 结点之间的距离

这个例子不算好,里面有四个结点,有些还没有内存。所以一定要牺牲系统一半的CPU核心才能把每个结点都变成一个独立的服务器。可以用numastat来验证一下:

$ numastat -n -c
                  Node 0   Node 1 Node 2 Node 3    Total
                -------- -------- ------ ------ --------
Numa_Hit        26833500 11885723      0      0 38719223
Numa_Miss          18672  8561876      0      0  8580548
Numa_Foreign     8561876    18672      0      0  8580548
Interleave_Hit    392066   553771      0      0   945836
Local_Node       8222745 11507968      0      0 19730712
Other_Node      18629427  8939632      0      0 27569060

也可以用numastat来打印出每个结点的内存使用统计数据,显示出来的结果是和/proc/meminfo一样的格式:

$ numastat -m -c
                 Node 0 Node 1 Node 2 Node 3 Total
                 ------ ------ ------ ------ -----
MemTotal          32150  32214      0      0 64363
MemFree             462   5793      0      0  6255
MemUsed           31688  26421      0      0 58109
Active            16021   8588      0      0 24608
Inactive          13436  16121      0      0 29557
Active(anon)       1193    970      0      0  2163
Inactive(anon)      121    108      0      0   229
Active(file)      14828   7618      0      0 22446
Inactive(file)    13315  16013      0      0 29327
...
FilePages         28498  23957      0      0 52454
Mapped              131    130      0      0   261
AnonPages           962    757      0      0  1718
Shmem               355    323      0      0   678
KernelStack          10      5      0      0    16

现在看一个简单点的拓扑图

$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
node 0 size: 46967 MB
node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
node 1 size: 48355 MB

结点大多是对称的,所以我们可以把一个程序实例绑定到每个NUMA结点上去,命令是numactl --cpunodebind=X --membind=X,然后再给它分配一个不同的端口,这样两个结点都用上了,吞吐量更大,同时内存保留局部性,延迟更少。

要验证NUMA到位后的效率,可以看看内存操作的延迟。比如,用bcc的funclatency程序测量很耗费内存的操作,像memmove等。

内核方面,可以用perf stat命令观察效率,并查看内存和计划事件如何相对应:

$ perf stat -e sched:sched_stick_numa,sched:sched_move_numa,sched:sched_swap_numa,migrate:mm_migrate_pages,minor-faults -p PID
...
                 1      sched:sched_stick_numa
                 3      sched:sched_move_numa
                41      sched:sched_swap_numa
             5,239      migrate:mm_migrate_pages
            50,161      minor-faults

最后一点,将NUMA相关的优化用于网络负荷很重的情况。要知道,每个网卡都是一个外设高速互联接口(PCIe)设备,每个设备都绑定到各自的NUMA结点,所以用有些CPU连接网络延迟较低。之后讨论网卡与CPU联合性时我们会讲这部分优化的,但现在先讲讲PCIe设备吧。

外设高速互联接口(PCIe)

正常来说,PCIe接口故障不需要非常彻底地排查,除非是硬件有问题。所以一般来说尽量少花精力,只要为PCIe接口做好"链接带宽"(link width),"链接速度"(link speed)的警报设置就够了,可能的话再加RxErr/BadTLP的警报设置。这样可以排除所有硬件损坏或总线接口协商失败的原因,能省下不少排查时间。用lspci命令来设置警报:

$ lspci -s 0a:00.0 -vvv
...
LnkCap: Port #0, Speed 8GT/s, Width x8, ASPM L1, Exit Latency L0s <2us, L1 <16us
LnkSta: Speed 8GT/s, Width x8, TrErr- Train- SlotClk+ DLActive- BWMgmt- ABWMgmt-
...
Capabilities: [100 v2] Advanced Error Reporting
UESta:  DLP- SDES- TLP- FCP- CmpltTO- CmpltAbrt- ...
UEMsk:  DLP- SDES- TLP- FCP- CmpltTO- CmpltAbrt- ...
UESvrt: DLP+ SDES+ TLP- FCP+ CmpltTO- CmpltAbrt- ...
CESta:  RxErr- BadTLP- BadDLLP- Rollover- Timeout- NonFatalErr-
CEMsk:  RxErr- BadTLP- BadDLLP- Rollover- Timeout- NonFatalErr+

但是如果有多个高速设备争夺带宽,PCIe接口可能也会成为瓶颈,比如高速网络与快速存储器并用。所以可以从硬件上将PCIe接口设备散开分布到各个CPU上去,来达到最大吞吐量。

来源:https://en.wikipedia.org/wiki/PCI_Express#History_and_revisions

另外看一下Mellanox网站的这篇文章,“理解PCIe接口配置,取得最佳性能”。那里关于PCIe接口配置讲得更深入些,如果用更高速的设备,并且观察到硬件卡和操作系统之间有数据包丢失的情况,那些配置可能更有用。

英特尔公司发现,有时候PCIe接口的活动状态电源管理(ASPM)功能可能会导致延迟上升,并造成更多的丢包。可以在内核命令行中加入这个参数pcie_aspm=off来关掉这个服务。

网卡(NIC)

讲一部分之前,要先说一下英特尔公司Mellanox公司都有自己关于性能调整的指导资料,所以不管选的是哪家销售商,最好两个资料都看一下。此外驱动软件本身通常会附有说明文件和一套实用工具。

下一步要看的是操作系统手册,看有没有性能指导部分。比如红帽子企业版Linux网络性能调整指南,上面提到的优化方法这份指南里大都解释过了,甚至还给了更多别的方法。

Cloudflare博客上也有一篇[关于网络栈部分调整的文章] (https://blog.cloudflare.com/how-to-achieve-low-latency/),虽然主要针对减少延迟的用例,但值得一看。

优化网卡时,ethtool命令最有用的。

这里稍微提醒一下,内核应该用新一点的版本。如果已经用新版本了那很好,那同时也要更新用户空间内的一些程序。比如,进行网络操作,可能需要更新版本的ethtool程序, iproute2程序,可能还要更新整个 iptables/nftables软件包。 用ethtool -S命令可以获得一些宝贵的信息,帮助了解网卡的情况:

$ ethtool -S eth0 | egrep 'miss|over|drop|lost|fifo'
     rx_dropped: 0
     tx_dropped: 0
     port.rx_dropped: 0
     port.tx_dropped_link_down: 0
     port.rx_oversize: 0
     port.arq_overflows: 0

查一下网卡制造商,理解每种状态的详细说明。比如Mellanox有[一个专门的维基页讲解那些状态] (https://community.mellanox.com/docs/DOC-2532)。

从内核方面看,查看/proc/interrupts信息,/proc/softirqs信息, 和/proc/net/softnet_stat信息。这里有两个bcc程序很有用:hardirqssoftirqs。网络优化的目标是把系统调整到CPU使用率最小,而同时没有数据包丢失的状态。

中断联合(Interrupt Affinity)

这种调整方法一般先在各个处理器之间分布中断,到底怎么分布取决于网络负载:

  • 要最大吞吐量,就可以把中断分布在系统中所有的NUMA结点上。

  • 要最小延迟,可以把中断限制在某一个单一NUMA结点上。要做到这一点,可能就需要减少队列数量,使之适合单一结点,通常这意味着要用ethtool -L使队列数量减半。

销售商一般会为这种操作提供运行脚本,如英特尔的set_irq_affinity脚本。

环形(Ring) 缓冲区(buffer) 大小(sizes)

网卡要与内核交换信息,一般是通过一种叫“环”的数据结构来进行的。通过ethtool -g命令可以看到预设的最大值:

$ ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX:                4096
TX:                4096
Current hardware settings:
RX:                4096
TX:                4096

在这些值范围里可以用-G参数来调整数值,一般来说数值越大越好,使用Interrupt Affinity技术更是如此,因为这可以使系统免受流量爆发和核内部小问题的影响,由于缓冲空间不足或中断丢失造成的丢包也会减少。但有几点要事先声明:

  • 老一点的内核或驱动没有BQL支持, 所以数值调高可能会造成发送端缓存过满得更厉害。

  • 更大的缓冲值也会加大缓存压力increase cache pressure, 如果碰到这种情况,试试看调低一些。

聚合(Coalescing)

中断的聚合技术能把多重事件归集到一个中断中去,这样新事件出现时,不会立刻给内核发出告知。可以通过ethtool -c命令查看当前的设定:

$ ethtool -c eth0
Coalesce parameters for eth0:
...
rx-usecs: 50
tx-usecs: 50

有两种方法设定聚合,一是固定极限值,就是硬性设定每核每秒的最大中断数。二是靠硬件来根据吞吐量[自动调节中断率] (https://community.mellanox.com/docs/DOC-2511)。

聚合可以用-C参数开启,开启后会增加延迟,并可能导致丢包,所以系统受延迟影响大的话可能要关闭。反过来,完全关闭聚合可能导致中断减速,所以也会限制性能。

卸载(Offloads)

现代的网卡都比较智能化,其驱动本身就可以靠硬件方法或模拟方法将很多任务卸载给其它硬件处理。

ethtool -k可以查看所有支持的卸载类型:

$ ethtool -k eth0
Features for eth0:
...
tcp-segmentation-offload: on
generic-segmentation-offload: on
generic-receive-offload: on
large-receive-offload: off [fixed]

在命令结果中,所有不能调节的卸载类型都在后面标上了[fixed]

[这方面的整个内容有很多可以讲] (https://lwn.net/Articles/358910/),不过有一些经验之谈:

  • 不开LRO卸载,开GRO卸载。

  • 开TSO卸载时要小心,它对驱动/固件的质量要求非常高。

  • 较旧版本的内核上不要开TSO/GSO卸载,因为可能会导致额外的缓存过满现象。

封包操控(Packet Steering)

所有现代网卡都针对多核硬件作了优化处理,因此网卡内部会把封包分散到几个虚拟队列,一般是一个CPU一个队列。如果通过硬件实现,那就叫 作接收端封包缩放(RSS)技术,如果是操作系统负责CPU之间封包负载平衡,那就叫接收端封包操控(RPS)技术,在发送端相对应的技术就叫发送端封包操控(XPS)技术。 如果操作系统也想智能一把,将数据流导向正在处理那个接口的CPU,那就叫接收端流操控(RFS)技术。RFS如果是硬件完成的,就叫“加速RFS技术”或简称aRFS技术。

下面是我们在产品中应用的最佳方案:

  • 如果硬件比较新,超过25G,那可能队列已经足够了,并且间接引用表也很大。这时只要把RSS技术用到所有CPU核上就行了。一些老的网卡对此会有一个限制,只能使用前16个CPU。

  • 以下情况下可以开启RPS

    • CPU数量比硬件队列多,同时想要为吞吐量牺牲一下延迟时间。

    • 用的是内部通道,比如用GRE/IPinIP机制创建的通道。这些通道网卡无法应用RSS技术。

  • CPU太老并且没有x2APIC中断控制器时不要开启RPS

  • 通过XPS将每个CPU分别绑定到自己的发送队列一般来说比较好。

  • RFS的效果很大程度上取决于工作负载,还有是否用上了CPU Affinity。

数据流导向(Flow Director)技术和应用目标接收(ATR)

数据流导向过滤器(flow director),英特尔术语称为fdir它开启后默认的运行模式是应用目标路由模式(ATR),这个模式通过对封包的取样和数据流的操控来实现 aRFS。它估测取样封包应该由哪个核处理,然后将数据流导向那个核。它的状态也可以由ethtool -S:$ ethtool -S eth0 | egrep 'fdir' port.fdir_flush_cnt: 0 来查看。

虽然英特尔宣称fdir技术有些情况下会提升性能,但是外部研究表明它也有可能造成最高1%的封包重排序现象,这对TCP性能来说损害相当大。 因此,一定要自己测试一下,看看FD技术是不是对工作负载有利,同时也要注意TCPOFOQueue 计数。

操作系统:网络栈(Networking stack)

关于Linux系统的网络栈调试的书、视频和教程数不胜数,可惜对sysctl.conf文件的盲目崇拜太多了。 较新的内核版本已经不需要像十年前一样做那么多调整了,而且多数新TCP/IP功能在默认状态下已经开启并优化好了,然而仍然有人把以前用在2.6.18/2.6.32版本上的老配置文件sysctls.conf粘贴复制了过来。

想验证网络相关优化的有效性,我们应该:

  • 查看/proc/net/snmp/proc/net/netstat信息,采集整个系统范围内的TCP性能指标。

  •  用ss -n --extended --info命令或在网站服务器程序里调用getsockopt(`TCP_INFO)`/`getsockopt(TCP_CC_INFO)`函数来分别取得每个连接的指标并进行合计。

  • tcptrace这个一般命令跟踪取样TCP流。

  • 从软件/浏览器来分析实时用户监控(RUM)的指标。

说到网络优化的资料,我通常会推荐去听搞内容分发网络(CDN)的人在会议上的发言,因为这些人一般都明白自己在做什么,比如澳大利亚Linux大会快讯。再听听Linux内核开发人员谈网络也非常有启发,比如网络开发者大会上的讲话网络配置脚本

这里我要重点提一下PackageCloud写的Linux网络栈文章,值得大家好好看一下,并深入理解。其中一个特别的原因就是他们强调的是监控而不是盲目地调试。

开始讲操作系统网络栈之前,让我再重复一次: 升级内核!新内核里面有太多针对网络栈的新改进,甚至连初始窗口10(IW10)都不算新,因为那 (已经是2010年的事了)。我这里要说的是最新最火的技术:TCP分段卸载自动调整大小,加权公平队列(FQ),步距,TLP的RACK,之后还有别的。 升级内核还有一个好处,它对可扩展性作了多方面的增强。比如去掉了路由缓存, 无锁侦听接口SO_REUSEPORT,和[许多其它方] (https://kernelnewbies.org/LinuxVersions).

总览

最近关于Linux网络的文章中有一篇很引人关注:“让Linux的TCP传输更快。”这篇文章一共四页,将Linux传送方TCP栈按功能分成几部分,对多年来关于Linux内核的改进方法进行了巩固:

加权公平队列(Fair Queueing)算法和步调(Pacing)调整

Fair Queueing算法可以负责提高TCP流之间的公平性,减少每一线上开始部分的堵塞,这样对降低丢包率会有积极作用。拥堵控制之间时间间隔相等,而Pacing调整就是根据这个频率来安排封包传输的时间,这可以进一步降低丢包的数量,也就能增加吞吐量。

旁注:FQ算法及Pacing调整在Linux上可以用fq qdisc打开。 有些人可以已经知道了,这两个是技术所需的,(不过现在不用了),而且两个都 可以和CUBIC算法的TCP类型一起用,最后能形成最高15-20%的丢包率下降,因此用丢包来进行堵塞控制时,可以提高吞吐量。只是不要在老内核上使用。fq+cubic一起开,可以用这个/sys/module/tcp_cubic/parameters/hystart_detect命令。[要解决tcp_cubic慢启动过程过早结束的问题,可以看这个,也许会有帮助] (https://groups.google.com/forum/#!topic/bbr-dev/g1tS1HUcymE)。

(译注:原文这里还有以下几个部分)

TSO autosizing and TSQ

Congestion Control

ACK Processing and Loss Detection

Userspace prioritization and HOL

Sysctls

值得注意的是curl程序的作者Daniel Stenberg有一份草稿正在征求评论,名叫针对HTTP的TCP调整。虽然评论的人不多,但这篇文章要将所有可能对HTTP有益的系统调试都归纳到了一起。

应用层:中层级

工具

像内核一样,用户环境中程序的更新同样重要。首先是升级工具软件,比如新版本的perf, bcc等。

有了新的工具软件,就已经可以准备进行妥善调试和观察系统情况了。这部分内容里讲的东西主要靠CPU上的性能监测,有perf top命令, CPU火焰图分析, 还有bccfunclatency程序做的即时直方图。

编译器工具链(Compiler Toolchain)

网站服务器通常都会使用一些常用的库,这些库里的汇编是为优化硬件而设计的。想要编译这种汇编就必须要有较新的编译器工具链。

除了性能方面,较新的编译器还有一些较新的安全功能,比如[-fstack-protector-strong](https://docs.google.com/document/d/1xXBH6rRZue4f296vGt9YQcuLVQHeE516stHwt8M9xyU/edit)[SafeStack](https://clang.llvm.org/docs/SafeStack.html) 。这些功能都可以应用在系统边界处。新版的工具链还有别的用处,可以将地址消毒器,friends等消毒器与二进制文件一起编译,再比照这些文件测试装具模块。比如AddressSanitizer,和 friends

系统库

系统库也要升级,像glibc等,否则的话可能会错过最新的优化功能。这些新功能通过-lc, -lm, -lrt等参数在低层级实现。这里仍然要提醒大家:一定要自己测试,因为 偶尔会 有回归缺陷发生。

Zlib库

一般压缩数据是由网站服务器负责的。根据通过代理的数据大小,有时可以用perf top命令来查看zlib库的标记,比如:

# perf top
...
   8.88%  nginx        [.] longest_match
   8.29%  nginx        [.] deflate_slow
   1.90%  nginx        [.] compress_block

有一些方法可以在低层级上对这一方面进行优化:英特尔Cloudflare,以及一个独立项目zlib-ng对zlib库项目进行了分支,运用新的指令集来取得更好的性能。

Malloc程序

目前为止我们谈论的优化方法大多是针对CPU的,但现在让我们换一个方向,说说和内存相关的优化吧。Lua语言带的FFI库或大型第三方库都有各自的内存管理机制,经常使用的人可能会看到由于碎片化,内存使用率上升。要解决这个问题,可以用 jemalloc程序或 tcmalloc程序。

用这些特制malloc程序还有以下好处:

PERL语言正则表达式(PCRE)

如果nginx配置中用了很多复杂的正则表达式,或者用了很多Lua语言,那可以用perf top查看和Perl正则表达式(PCRE)相关的标记。要优化这些,可以用JIT先编译这些正则表达式,然后在nginx用pcre_jit on;开启这个功能。

火焰图或funclatency命令都可以查看优化的效果:

# funclatency /srv/nginx-bazel/sbin/nginx:ngx_http_regex_exec -u
...
     usecs               : count     distribution
         0 -> 1          : 1159     |**********                              |
         2 -> 3          : 4468     |****************************************|
         4 -> 7          : 622      |*****                                   |
         8 -> 15         : 610      |*****                                   |
        16 -> 31         : 209      |*                                       |
        32 -> 63         : 91       |                                        |

传输层安全(TLS)协议

如果TLS协议只在系统范围内使用,并且系统边界外连接的不是CDN网络,那TLS协议优化会有很高的价值。讨论这部分调试时,我们多数集中在服务器端的效率问题。

好了,目前第一件事就是决定用哪个TLS协议库:Vanilla公司的 OpenSSL,OpenBSD公司的LibreSSL, 或者是Google的BoringSSL。选好了库之后,要好好地把它生成出来。比如OpenSSL有许多关于生成时间的启发式方法可以促进优化,这些方法都是基于生成环境的。再比如BoringSSL库的生成可以每次完全再现,但可惜的是这个生成方法太保守了,只不过是在默认状态下关掉了一些优化而已。无论怎样,选一个较新的CPU在这里就能体现出价值了:多数TLS库从AES-NI指令和SSE扩展指令集,到ADX扩展和AVX512扩展指令集都能运用。可以运行TLS库内置的性能测试来查看,比如BoringSSL的命令是bssl speed

大部分性能提升不是因为硬件,而是安全协议要用的密码套件,所以一定要仔细优化。还有记住这里讲的方法几乎肯定会影响网站服务器的安全性,因为速度最快的密码套件未必是最好的。如果不清楚要用什么加密设定,可以先从[Mozilla的SSL配置生成器] (https://mozilla.github.io/server-side-tls/ssl-config-generator/)开始。

非对称加密(Asymmetric Encryption)

如果安全性系统服务是在系统边界处,那可能会看到大量的TLS协议握手。这样CPU很大程度上被这种非对称的加密活动消耗掉了,很明显,这里是可以优化的。

要优化服务器端CPU的使用率,可以转用 ECDSA算法的证书,它一般要比RSA算法快十倍。同时这些证书要小得多,可能在丢包的情况下加速握手。但同时ECDSA算法非常依赖系统随机数生成器的质量,所以用OpenSSL库的话,要确保 熵值够大 。如果用BoringSSL库[就不用担心这一点] (https://www.imperialviolet.org/2015/10/17/boringssl.html))。

附注:值得一提的是这里面的密钥长度值不总是越大越好。比如,值为4096生成RSA证书会让系统性能下降十倍:

$ bssl speed
Did 1517 RSA 2048 signing ... (1507.3 ops/sec)
Did 160 RSA 4096 signing ...  (153.4 ops/sec)

更麻烦的是,它也不是越小越好。用非常见的p-224域作ECDSA加密比起常见的p-256性能要差了60%:

$ bssl speed
Did 7056 ECDSA P-224 signing ...  (6831.1 ops/sec)
Did 17000 ECDSA P-256 signing ... (16885.3 ops/sec)

经验之谈就是最常用的加密方法通常也是最优化的方法。

基于OpenTLS的库进行适当优化后,再使用RSA证书并运行,这时应该能通过perf top命令看到以下的轨迹, 有AVX2指令但无ADX指令的机器,比如用Haswell架构CPU的电脑,应该用AVX2代码路径:

 6.42%  nginx                [.] rsaz_1024_sqr_avx2
  1.61%  nginx                [.] rsaz_1024_mul_avx2

不过新一点的硬件应该用更通用的蒙哥马利算法,加上ADX代码路径:

 7.08%  nginx                [.] sqrx8x_internal
  2.30%  nginx                [.] mulx4x_internal

对称型加密(Symmetric Encryption)

如果系统要传输许多像视频,照片一样成批的数据,笼统一些讲就是很多文件,那在一开始就可以开始观察性能测试结果中对称型加密标记。只要确保CPU支持AES-NI指令,并且服务器端针对AES-GCM密码进行了偏好设置就行了。调试正确的话,硬件的perf top应该显示以下信息:

 `8.47%  nginx                [.] aesni_ctr32_ghash_6x`

不过加密/解密不光是服务器端要处理,对客户端也一样是个负担,而且客户端CPU要弱很多。没有硬件加速的话,可能会相当困难,因此可以考虑用一种从设计上就不需要硬件加速也能快速运行的算法。比如ChaCha20-Poly1305算法。这样会为某些移动端用户减少请求响应时间(TTLB)。

ChaCha20-Poly1305算法受BoringSSL库直接支持。OpenSSL库1.0.2版,可以考虑使用Cloudflare的补丁。BoringSSL库也支持“偏好相同密码组”。 所以可以用以下配置让客户端根据其硬件性能来决定用什么密码,这里就不客气地借用 cloudflare/sslconfig)的设置了:

ssl_ciphers '[ECDHE-ECDSA-AES128-GCM-SHA256|ECDHE-ECDSA-CHACHA20-POLY1305|ECDHE-RSA-AES128-GCM-SHA256|ECDHE-RSA-CHACHA20-POLY1305]:ECDHE+AES128:RSA+AES128:ECDHE+AES256:RSA+AES256:ECDHE+3DES:RSA+3DES';
ssl_prefer_server_ciphers on;

应用层:高层级

在这一层级上分析优化有效与否就需要收集实时用户监控(RUM)的数据。浏览器上可以用导航计时的应用程序接口资源计时的应用程序接口。主要看的指标是TTFB时间和TTV/TTI的间隔时间。将这些数据做成容易查询作图的格式,可以极大地简化每一次循环监控。

压缩

nginx中的压缩要从mime.types 文件开始。这个文件定义了如何将文件扩展名和响应中MIME类型对应起来。然后要定义哪些类文件传递给压缩程序。比如可以用[gzip_types](http://nginx.org/en/docs/http/ngx_http_gzip_module.html#gzip_types)来定义。如果所有文件都要,可以用mime-db自动生成mime.types文件,并把这些类型配上.compressible == true加到gzip_types中去。

开启gzip时有两方面要注意:

  • 内存用量增加。可以通过限制gzip_buffers来解决。

  •  缓存会造成TTFB时间延长,可以用[gzip_no_buffer](http://hg.nginx.org/nginx/file/c7d4017c8876/src/http/modules/ngx_http_gzip_filter_module.c#l182)来解决。

附注:http压缩不仅限于gzip。nginx有一个第三方模块[ngx_brotli](https://github.com/google/ngx_brotli) ,相比gzip压缩率可以提高至最高30%。

关于压缩设置本身,我们来说两个独立的用例:静态和动态数据。

  • 静态数据,可以在生成程序过程中预先压缩一些静态资源,这样能达到最大压缩率。在这篇Deploying Brotli for static content帖子里我们会非常详细地介绍用gzip和brotli压缩静态数据。

  •  动态数据,要小心平衡整个过程的往返时间,也就是压缩数据的时间 + 传输的时间 + 客户端解压的时间。所以压缩级别调到最高不仅从CPU使用率的角度来说不理想,而且从TTFB时间上看也不明智。

缓冲

代理的内部缓冲会极大地影响网站服务器性能,特别是和延迟时间相关的性能。nginx代理模块有很多缓冲结,它们可以根据每个位置的不同开启或关闭,每个结都有自己的用途。可以通过proxy_request_buffering命令和proxy_buffering命令对双向的缓冲分别进行控制。当缓冲开启时,内存消耗上限可以用client_body_buffer_sizeproxy_buffers来设置,达到这些阈值后,请求/响应就会被缓冲到磁盘。把proxy_max_temp_file_size设置成0可以关闭响应缓冲。

最常用的缓冲方式是:

  • 在内存中将请求/响应数据缓冲达到阈值,溢出后便会流入磁盘。如果请求方缓冲开启了,那完全接收后后只要再向后台发送一条请求即可。对于响应方缓冲,一旦处理完了响应就立刻释放后台线程。这种方法有提高吞吐率和后台保护两个好处,代价是延迟时间和内存/输入输出设备使用率增加,当然对SSD盘可能不是什么问题。

  • 不缓冲。有些路由对延迟时间十分严格,特别是用成流技术的服务器。这时缓冲可能不是好办法,所以关掉比较好。但这样后台就要照顾一些慢速的客户端,还包括一些恶意进行的慢速POST请求,如慢读 之类的攻击。

  • 让应用控制来响应缓冲。可以通过[X-Accel-Buffering](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/#x-accel-buffering)报头来实现。

不管用哪种,别忘记TTFB和TTLB时间都要测试过了才行。另外前面也说过,缓冲会影响输入输出设备的使用,甚至后台的使用也会受影响。所以那方面也要多关注。

TLS协议

现在我们来说说TLS协议的高级层面,以及如何靠合理配置nginx来优化延迟时间。我要讲的优化方法大多数都在以下两个资料中介绍过了:高性能浏览器网络中的“TLS协议优化”部分,以及2014年nginx大会上讲的让HTTPS更快速。在这部分要讲的调试不但会影响性能,还会影响网站服务器的安全。如果有不清楚的,请查看[Mozilla的服务器端TLS协议指南] (https://wiki.mozilla.org/Security/Server_Side_TLS)还有自己的安全团队。

要验证优化结果,可以用:

会话复用机制(Session resumption)

数据库管理员喜欢说的一句话是“从来不用发送的查询是速度最快的”。TLS协议也是如此。如果握手的结果能缓冲起来,那就减少一整个往返时间。有两种方法实现:

  • 可以让客户端存储所有的会话参数,全部都要数字签名和加密存储。然后在一下次进行握手时发给服务端,类似于网站的cookie。nginx这边要通过ssl_session_tickets指令来配置。虽然不会损耗服务器端的内存,但有一些副作用:

    • 需要系统的基础设施能够为TLS会话进行随机签名/加密钥的创建,开启和传播。只要记住千万1)不要用来源控制存储会话的票据密钥,2)不要用非暂时性的材料来生成密钥,比如日期或证书。

    • PFS 功能不是基于会话的,而是基于每个TLS协议票据密钥的。所以如果有人得到了票据密钥想攻击系统,就有可能在票据存续时间内解码并获得所有的数据。

    • 加密受限于票据密钥的大小。128位的密钥是没有道理用AES256来加密的。nginx支持128位和256位TLS协议票据密钥。

    • 不是所有的客户端都支持票据密钥的,当然所有现代浏览器都已经支持了。

  • 另一种方法是将TLS协议会话参数保存在服务器端,只发送给客户端一个引用ID号。这可以通过ssl_session_cache 指令来做到。好处是能保留会话间的PFS,极大地减少了受攻击面。但票据密钥也有副作用:

    • 每个会话要耗费服务器最多256字节的内存,所以会话数量多的话不能保存得时间太长。

    •  很难服务器之间共享票据,所以要么负载平衡器能够一直将同一客户的数据发送到同一服务器来保留缓冲区,要么自己写一个分布式TLS协议会话存储模块,比如可以以这个[ngx_http_lua_module](https://github.com/openresty/lua-resty-core/blob/master/lib/ngx/ssl/session.md)模块为基础自己写。

附注:如果选择会话票据方法,那最好用三把密钥,不要只用一把。比如:

ssl_session_tickets on;
ssl_session_timeout 1h;
ssl_session_ticket_key /run/nginx-ephemeral/nginx_session_ticket_curr;
ssl_session_ticket_key /run/nginx-ephemeral/nginx_session_ticket_prev;
ssl_session_ticket_key /run/nginx-ephemeral/nginx_session_ticket_next;

其中,当前密钥总是要加密,但同时也接受用前一把,后一把密钥加密过的会话。

在线证书状态协议装订(OCSP Stapling)

应该将OCSP协议的响应数据装订起来,否则:

  • TLS协议握手时间可能会更长,因为客户端要连接证书机构来取得OCSP状态。

  •  没能取得OCSP状态时,可能会影响可用性。

  •  客户端的用户隐私可能会受到一些威胁,因为浏览器要连接到你的网站,必须联系第三方服务来作此声明。

要装订OCSP响应,可以定期从证书机构取回并发布到网站服务器上去,然后用ssl_stapling_file指令来使用:

`ssl_stapling_file /var/cache/nginx/ocsp/www.der;`

TLS协议记录大小

TLS协议将数据分成数块,每一块数据称为记录。所有记录完整接收后才可以验证并解码,这样就有一个延迟。测量一下以网络栈为视点的TTFB时间,再测量以应用为视点的TTFB时间。两个时间的差就是这个延迟。

默认状态下,nginx把数据分成16k大小的数据块,这个大小连IW10窗口都无法一次通过,所以需要多一轮次收发。nginx本身提供了设置记录大小的方法,可以用ssl_buffer_size指令:

  • 为降低延迟做优化时,应该将这个大小设置得小些,比如4k。比这个更小的话从CPU角度来看占有资源太多了。

  •  为提高吞吐量优化时,应该保持16k不变。

静态调试会有两个问题:

  • 必须手动调试。

  • 只能为每个nginx服务器或每个服务器消息块设置一个ssl_buffer_size值,所以要是服务器的工作负载具有不同延迟/吞吐量,那就必须在某方面作出妥协。

还有一种方法是动态记录大小调试。Cloudflare有一个nginx补丁可以额外支持动态记录大小。一开始配置这个功能可能会有些痛苦,但一旦做好就很有用。

TLS协议1.3版

TLS协议1.3版的功能的确听起来很不错,, 但是我建议不要用这些功能,除非有人力物力能全天候做故障排查。因为:

避免事件循环停滞(Eventloop Stalls)

nginx是基于事件循环的网站服务器,也就是说一次只能做一件事。虽然看起来好像能同时处理很多事,就像时分多路复用技术那样,但实际上nginx能做的只是快速在事件之间切换,先处理一件再处理另一件而已。能做到这一点是因为每个事件处理时间只有几毫秒,但在某些情况下,如要访问旋转式磁盘时,处理时间会变长,延迟会暴涨。

如果注意到nginx在运行ngx_process_events_and_timers 函数时花了很多时间,而且时间分布是双峰式分布,那很可能就是受到了事件循环停滞的影响。

# funclatency '/srv/nginx-bazel/sbin/nginx:ngx_process_events_and_timers' -m
     msecs               : count     distribution
         0 -> 1          : 3799     |****************************************|
         2 -> 3          : 0        |                                        |
         4 -> 7          : 0        |                                        |
         8 -> 15         : 0        |                                        |
        16 -> 31         : 409      |****                                    |
        32 -> 63         : 313      |***                                     |
        64 -> 127        : 128      |*                                       |

非同步输入输出(AIO)与线程池(Threadpools)

事件循环停滞主要发生在输入输出端(IO),尤其是旋转型磁盘上,所以可以先看看这部分。运行fileslower命令来测量其影响有多大:

# fileslower 10
Tracing sync read/writes slower than 10 ms
TIME(s)  COMM           TID    D BYTES   LAT(ms) FILENAME
2.642    nginx          69097  R 5242880   12.18 0002121812
4.760    nginx          69754  W 8192      42.08 0002121598
4.760    nginx          69435  W 2852      42.39 0002121845
4.760    nginx          69088  W 2852      41.83 0002121854

为了解决这个问题,nginx支持将数据从IO卸载到一个线程池里。nginx也支持AIO,不过纯AIO在UNIX系统上会发生许多无法预料的情况,除非非常清楚怎么做,否则最好还是不用。设定线程池基本步骤只有几步:

aio threads;
aio_write on;

如果情况比较复杂,可以设置自定义的[线程池](http://nginx.org/en/docs/ngx_core_module.html#thread_pool)。比如可以为每个磁盘分别设置线程池,这样即使其中一个盘不太稳,也不会影响其余的请求。线程池可以极大地减少卡在‘D’状态的nginx进程数量,改善延时状况和吞吐量。但不能完全消除事件循环停滞,因为目前并不是所有的IO操作都可以卸载到池里去的。

日志文件记录

写日志文件也要读存磁盘,也可能花很多时间。那可以用ext4slower来查看并寻找访问/错误日志引用。

# ext4slower 10
TIME     COMM           PID    T BYTES   OFF_KB   LAT(ms) FILENAME
06:26:03 nginx          69094  W 163070  634126     18.78 access.log
06:26:08 nginx          69094  W 151     126029     37.35 error.log
06:26:13 nginx          69082  W 153168  638728    159.96 access.log

有个办法也许可以暂时解决这个问题,就是将访问日志文件暂存于内存里,然后再写入。命令是带buffer参数的access_log 指令。如果再加上gzip参数还可以在写入磁盘之前压缩日志文件,这样进一步减小IO的压力。

但要完全去除日志写入时出来的输入输出停滞,其实只要通过syslog来写日志文件,这样日志文件就可以与nginx事件循环完全集成在一起。

打开状态的文件缓冲

open(2)命令本身有阻断性,并且网站服务器会有规律地打开/读取/关闭文件,所以将打开的文件存入缓冲可能会好些。可以查看ngx_open_cached_file的函数延迟来看看有多少提高::

# funclatency /srv/nginx-bazel/sbin/nginx:ngx_open_cached_file -u
     usecs               : count     distribution
         0 -> 1          : 10219    |****************************************|
         2 -> 3          : 21       |                                        |
         4 -> 7          : 3        |                                        |
         8 -> 15         : 1        |                                        |

如果看到有很多打开文件的请求,或有些请求时间过长,那可以开启打开状态文件缓冲后再看看情况:

open_file_cache max=10000;
open_file_cache_min_uses 2;
open_file_cache_errors on;

开启open_file_cache后,可以用opensnoop看到缓冲丢失情况,然后再决定[是不是要将缓冲限制调整一下] (http://nginx.org/en/docs/http/ngx_http_core_module.html#open_file_cache):

# opensnoop -n nginx
PID    COMM               FD ERR PATH
69435  nginx             311   0 /srv/site/assets/serviceworker.js
69086  nginx             158   0 /srv/site/error/404.html
...

总结

本帖中讲的所有优化方法都只针对单一网站服务器的本地配置。有些方法是用来提高可扩展性与性能的,有些是只有在需要尽量减少请求延迟或加快给客户端发送数据的速度时才有关系。但经验告诉我们,性能提升要让用户看得见,很大程度上要靠更高层的优化。像进口/出口流量筹划,更智能的内部负载平衡技术等,这些高层优化会在整体上影响Dropbox边缘网络的运行。目前这些问题还处于大家知识的边缘领域(这里是双关),业界也才刚开始 采用这些方法

都读到这里了,想必你也想试试解决这些问题,或试试其他有意思的问题。那你走运了, Dropbox正在招有经验的软件工程师,网站可靠性工程师以及经理