TCP疑难问题案例汇总
TCP疑难问题案例汇总
碰到各种奇葩的TCP相关问题,所以汇总记录一下。分析清楚这些问题的所有来龙去脉,就能帮你在TCP知识体系里建立几个坚固的抓手,让TCP知识慢慢在抓手之间生长和互通
服务不响应的现象或者奇怪异常的原因分析
一个黑盒程序奇怪行为的分析 listen端口上很快就全连接队列溢出了,导致整个程序不响应了
举三反一–从理论知识到实际问题的推导 服务端出现大量CLOSE_WAIT 个数正好 等于somaxconn(调整somaxconn大小后 CLOSE_WAIT 也会跟着变成一样的值)
活久见,TCP连接互串了 应用每过一段时间总是会抛出几个连接异常的错误,需要查明原因。排查后发现是TCP连接互串了,这个案例实在是很珍惜,所以记录一下。
传输速度分析
案例:TCP传输速度案例分析(长肥网络、rt升高、delay ack的影响等)
就是要你懂TCP–最经典的TCP性能问题 Nagle和Delay ack
TCP队列问题连接数
防火墙和reset定位分析
TCP相关参数
工具技巧篇
netstat timer keepalive explain
一个黑盒程序奇怪行为的分析
一个黑盒程序奇怪行为的分析
问题描述:
从金主baba手里拿到一个区块链程序,监听4000,在我们的环境中4000端口上很快就全连接队列溢出了,导致整个程序不响应了。这个程序是黑盒子,没有源代码,但是在金主baba自己的环境运行正常(一样的OS)
如下图所示:
ss -lnt 看到全连接队列增长到了39,但是netstat -ant找不到这39个连接,本来是想看看队列堆了这么多连接,都是哪些ip连过来的,实际看不到这就奇怪了
同时验证过程发现我们的环境4000端口上开了slb,也就是slb会不停滴探活4000端口,关掉slb探活后一切正常了。
所以总结下来问题就是:
为什么全连接队列里面的连接netstat/ss都看不到(ss能看到总数)
为什么关掉slb就正常了
为什么应用不accept连接,也不close(应用是个黑盒子)
分析
为什么全连接队列里面的连接netstat/ss都看不到(ss能看到总数)
这是因为这些连接都是探活连接,三次握手后很快被slb reset了,在OS层面这个连接已经被释放,所以肯定看不见。反过来想要是netstat能看见这个连接,那么它的状态是什么? reset吗?tcp连接状态里肯定是没有reset状态的。
为什么会产生这个错误理解–全连接队列里面的连接netstat一定要能看到?
那是因为正常情况都是能看到的,从没有考虑过握手后很快reset的情况。也没反问过如果能看到这个连接该是什么状态呢?
这个连接被reset后,kernel会将全连接队列数量减1吗?
不会,按照我们的理解连接被reset释放后,那么kernel要释放全连接队列里面的这个连接,因为这些动作都是kernel负责,上层没法处理这个reset。实际上内核认为所有 listen 到的连接, 必须要 accept 走, 用户有权利知道存在过这么一个连接。
也就是reset后,连接在内核层面释放了,所以netstat/ss看不到,但是全连接队列里面的应用数不会减1,只有应用accept后队列才会减1,accept这个空连接后读写会报错。也就是基本可以理解全连接队列溢出了,主要是应用accept太慢导致的。
什么时候连接状态变成 ESTABLISHED
三次握手成功就变成 ESTABLISHED 了,三次握手成功的第一是收到第三步的ack并且全连接队列没有满,不需要用户态来accept,如果握手第三步的时候OS发现全连接队列满了,这时OS会扔掉这个第三次握手ack,并重传握手第二步的syn+ack, 在OS端这个连接还是 SYN_RECV 状态的,但是client端是 ESTABLISHED状态的了。
这是在4000(tearbase)端口上全连接队列没满,但是应用不再accept了,nc用12346端口去连4000(tearbase)端口的结果
1 | # netstat -at |grep ":12346 " |
这是在4000(tearbase)端口上全连接队列满掉后,nc用12346端口去连4000(tearbase)端口的结果
1 | # netstat -at |grep ":12346 " |
为什么关掉slb就正常了
slb探活逻辑是向监听端口发起三次握手,握手成功后立即发送一个reset断开连接
这是一个完整的探活过程:
关掉就正常后要结合第三个问题来讲
为什么应用不accept连接,也不close(应用是个黑盒子)
因为应用是个黑盒子,看不到源代码,只能从行为来分析了
从行为来看,这个应用在三次握手后,会主动给client发送一个12字节的数据,但是这个逻辑写在了accept主逻辑内部,一旦主动给client发12字节数据失败(比如这个连接reset了)那么一直卡在这里导致应用不再accept也不再close。
正确的实现逻辑是,accept在一个单独的线程里,一旦accept到一个新连接,那么就开启一个新的线程来处理这个新连接的读写。accept线程专注accept。
关掉slb后应用有机会发出这12个字节,然后accept就能继续了,否则就卡死了。
一些验证
nc测试连接4000端口
1 | # nc -p 12346 dcep-blockchain-1 4000 |
如果在上面的1.5ms之间nc reset了这个连接,那么这12字节就发不出来了
tcpping 模拟slb 探活
1 | python tcpping.py -R -i 0.1 -t 1 dcep-blockchain-1 4000 |
-i 间隔0.1秒
-R reset断开连接
-t 超时时间1秒
执行如上代码,跟4000端口握手,然后立即发出reset断开连接(完全模拟slb探活行为),很快重现了问题
增加延时
-D 0.01表示握手成功后10ms后再发出reset(让应用有机会成功发出那12个字节),应用工作正常
1 | python tcpping.py -R -i 0.1 -t 1 -D 0.01 dcep-blockchain-1 4000 |
总结
最大的错误认知就是 ss 看到的全连接队列数量,netstat也能看到。实际是不一定,而这个快速reset+应用不accept就导致了看不到这个现象
journald和rsyslogd
journald和rsyslogd
碰到rsyslog-8.24.0-34.1.al7.x86_64 的 rsyslogd 占用内存过高,于是分析了一下原因并学习了一下系统日志、rsyslog、journald之间的关系,流水账记录此文。
rsyslogd 占用内存过高的分析
rsyslogd使用了大概1.6-2G内存,不正常(正常情况下内存占用30-50M之间)
现象:
1 | KiB Mem : 7971268 total, 131436 free, 7712020 used, 127812 buff/cache |
journalctl --verify
命令检查发现系统日志卷文件损坏
问题根因
以下是现场收集到的日志:
主要是rsyslogd的sd_journal_get_cursor报错,然后导致内存泄露。
journald 报Bad message, 跟rsyslogd内存泄露完全没关系,实际上升级rsyslogd后也有journald bad message,但是rsyslogd的内存一直稳定在30M以内
这个CSDN的文章中有完全一样的症状 但是作者的结论是:这是systemd的bug,在journald需要压缩的时候就会发生这个问题。实际上我用的是 systemd-219-62.6.al7.9.x86_64 比他描述的已经修复的版本还要要新,也还是有这个问题,所以这个结论是不对的
解决办法
1、重启rsyslog systemctl restart rsyslog
可以释放内存
2、升级rsyslog到rsyslog-8.24.0-38.1.al7.x86_64或更新的版本才能彻底修复这个问题
一些配置方法
修改配置/etc/rsyslog.conf,增加如下两行,然后重启systemctl restart rsyslog
1 | $imjournalRatelimitInterval 0 |
1、关掉journal压缩配置
vi /etc/systemd/journald.conf,把#Compress=yes改成Compress=no,之后systemctl restart systemd-journald即可
2、限制rsyslogd 内存大小
1 | cat /etc/systemd/system/multi-user.target.wants/rsyslog.service |
OOM kill
rsyslogd内存消耗过高后导致了OOM Kill
RSS对应物理内存,单位是4K(page大小),红框两个进程用了5G+2G,总内存是8G,所以触发OOM killer了
每次OOM Kill日志前后总带着systemd-journald的重启
1 | Jan 28 19:03:04 iZwz95gaul6x9167sqdqz5Z journal: Permanent journal is using 520.0M (max allowed 500.0M, trying to leave 4.0G free of 83.7G available → current limit 520.0M). |
OOM kill前大概率伴随着systemd-journald 重启是因为watch dog timeout(limit 3min),造成timeout的原因是journald定期要把日志刷到磁盘上,然后要么是内存不够,要么是io负载太重,导致刷磁盘这个过程非常慢,于是就timeout了。
当然systemd-journald 重启也不一定意味着OOM Killer,只是肯定是内存比较紧张了。
rsyslog和journald的基础知识
systemd-journald
是用来协助rsyslog
记录系统启动服务和服务启动失败的情况等等. systemd-journald
使用内存保存记录, 系统重启记录会丢失. 所有还要用rsyslog
来记录分类信息, 如上面/etc/rsyslog.d/listen.conf
中的syslog
分类.
systemd-journald
跟随systemd开机就启动,能及时记录所有日志:
1 | # systemd-analyze critical-chain systemd-journald.service |
systemd-journald 由于是使用于内存的登录文件记录方式,因此重新开机过后,开机前的登录文件信息当然就不会被记载了。 为此,我们还是建议启动 rsyslogd 来协助分类记录!也就是说, systemd-journald 用来管理与查询这次开机后的登录信息,而 rsyslogd 可以用来记录以前及现在的所以数据到磁盘文件中,方便未来进行查询喔!
Tips 虽然 systemd-journald 所记录的数据其实是在内存中,但是系统还是利用文件的型态将它记录到 /run/log/ 下面! 不过我们从前面几章也知道, /run 在 CentOS 7 其实是内存内的数据,所以重新开机过后,这个 /run/log 下面的数据当然就被刷新,旧的当然就不再存在了!
其实鸟哥是这样想的,既然我们还有 rsyslog.service 以及 logrotate 的存在,因此这个 systemd-journald.service 产生的登录文件, 个人建议最好还是放置到 /run/log 的内存当中,以加快存取的速度!而既然 rsyslog.service 可以存放我们的登录文件, 似乎也没有必要再保存一份 journal 登录文件到系统当中就是了。单纯的建议!如何处理,依照您的需求即可喔!
system-journal
服务监听 /dev/log
socket获取日志, 保存在内存中, 并间歇性的写入/var/log/journal
目录中.
rsyslog
服务启动后监听/run/systemd/journal/socket
获取syslog类型日志, 并写入/var/log/messages
文件中.
获取日志时需要记录日志条目的position
到/var/lib/rsyslog/imjournal.state
文件中.
比如haproxy日志配置:
1 | # cat /etc/haproxy/haproxy.cfg |
以下是drds 的iptables日志配置,将tcp reset包记录下来,默认iptable日志输出到/varlog/messages中(dmesg也能看到),然后可以通过rsyslog.d 配置将这部分日志输出到单独的文件中:
1 | # 配置iptables 日志,增加 [drds] 标识 |
journald log持久化
创建 /var/log/journal 文件夹后默认会持久化,设置持久化后 /run/log 里面就没有日志了
1 | # cat /etc/systemd/journald.conf |
清理日志保留1M:journalctl –vacuum-size=1M
设置最大保留500M日志: journalctl –vacuum-size=500
rsyslogd
以下内容来自鸟哥的书:
CentOS 7 除了保有既有的 rsyslog.service 之外,其实最上游还使用了 systemd 自己的登录文件日志管理功能喔!他使用的是 systemd-journald.service 这个服务来支持的。基本上,系统由 systemd 所管理,那所有经由 systemd 启动的服务,如果再启动或结束的过程中发生一些问题或者是正常的讯息, 就会将该讯息由 systemd-journald.service 以二进制的方式记录下来,之后再将这个讯息发送给 rsyslog.service 作进一步的记载。
基本上, rsyslogd 针对各种服务与讯息记录在某些文件的配置文件就是 /etc/rsyslog.conf, 这个文件规定了“(1)什么服务 (2)的什么等级讯息 (3)需要被记录在哪里(设备或文件)” 这三个咚咚,所以设置的语法会是这样:
1 | $cat /etc/rsyslog.conf |
CentOS 7.x 默认的 rsyslogd 本身就已经具有远程日志服务器的功能了, 只是默认并没有启动该功能而已。你可以通过 man rsyslogd 去查询一下相关的选项就能够知道啦! 既然是远程日志服务器,那么我们的 Linux 主机当然会启动一个端口来监听了,那个默认的端口就是 UDP 或 TCP 的 port 514
Server配置如下:
1 | $ cat /etc/rsyslog.conf |
client配置:
1 | $ cat /etc/rsyslog.conf |
常见的几个系统日志有哪些呢?一般而言,有下面几个:
- /var/log/boot.log: 开机的时候系统核心会去侦测与启动硬件,接下来开始各种核心支持的功能启动等。这些流程都会记录在 /var/log/boot.log 里面哩! 不过这个文件只会存在这次开机启动的信息,前次开机的信息并不会被保留下来!
- /var/log/cron: 还记得第十五章例行性工作调度吧?你的 crontab 调度有没有实际被进行? 进行过程有没有发生错误?你的 /etc/crontab 是否撰写正确?在这个登录文件内查询看看。
- /var/log/dmesg: 记录系统在开机的时候核心侦测过程所产生的各项信息。由于 CentOS 默认将开机时核心的硬件侦测过程取消显示, 因此额外将数据记录一份在这个文件中;
- /var/log/lastlog: 可以记录系统上面所有的帐号最近一次登陆系统时的相关信息。第十三章讲到的 lastlog 指令就是利用这个文件的记录信息来显示的。
- /var/log/maillog 或 /var/log/mail/*: 记录邮件的往来信息,其实主要是记录 postfix (SMTP 协定提供者) 与 dovecot (POP3 协定提供者) 所产生的讯息啦。 SMTP 是发信所使用的通讯协定, POP3 则是收信使用的通讯协定。 postfix 与 dovecot 则分别是两套达成通讯协定的软件。
- /var/log/messages: 这个文件相当的重要,几乎系统发生的错误讯息 (或者是重要的信息) 都会记录在这个文件中; 如果系统发生莫名的错误时,这个文件是一定要查阅的登录文件之一。
- /var/log/secure: 基本上,只要牵涉到“需要输入帐号密码”的软件,那么当登陆时 (不管登陆正确或错误) 都会被记录在此文件中。 包括系统的 login 程序、图形接口登陆所使用的 gdm 程序、 su, sudo 等程序、还有网络连线的 ssh, telnet 等程序, 登陆信息都会被记载在这里;
- /var/log/wtmp, /var/log/faillog: 这两个文件可以记录正确登陆系统者的帐号信息 (wtmp) 与错误登陆时所使用的帐号信息 (faillog) ! 我们在第十章谈到的 last 就是读取 wtmp 来显示的, 这对于追踪一般帐号者的使用行为很有帮助!
- /var/log/httpd/, /var/log/samba/: 不同的网络服务会使用它们自己的登录文件来记载它们自己产生的各项讯息!上述的目录内则是个别服务所制订的登录文件。
参考资料
一模一样的症状,但是根因找错了:rsyslog占用内存高
https://access.redhat.com/solutions/3705051
https://sunsea.im/rsyslogd-systemd-journald-high-memory-solution.html
TCP传输速度案例分析
TCP传输速度案例分析
前言
TCP传输速度受网络带宽和传输窗口的影响(接收、发送、拥塞窗口),带宽我们没办法改变,以下案例主要是讨论rt、窗口如何影响速度。
详细的buffer、rt对TCP传输速度的影响请看这篇:
以及 就是要你懂TCP–最经典的TCP性能问题 Nagle和Delay ack
上面两篇以及下面几个案例读完,应该所有TCP传输速度问题都能解决了,Good Luck!
前后端rtt差异大+vip下载慢的案例
来源:https://mp.weixin.qq.com/s/er8vTKZUcahA6-Pf8DZBng 文章中的trace-cmd工具也不错
如下三个链路,有一个不正常了
首先通过 ss -it dst “ip:port” 来分析cwnd、ssthresh、buffer,到底是什么导致了传输慢
原因TCPLossProbe:
如果尾包发生了丢包,没有新包可发送触发多余的dup ack来实现快速重传,如果完全依赖RTO超时来重传,代价太大,那如何能优化解决这种尾丢包的情况。也就是在某些情况下一个可以的重传包就能触发ssthresh减半,从而导致传输速度上不来。
本案例中,因为client到TGW跨了地域,导致rtt增大,但是TGW和STGW之间的rtt很小,导致握手完毕后STGW认为和client的rtt很小,所以很快就触发了丢包重传,实际没有丢包,只是rtt变大了,所以触发了如上的TLP( PTO=max(2rtt, 10ms) , 因为只有一次重传并收到了 dup,还是不应该触发TLP,但是因为老版本kernel bug导致,4.0的kernel修复了这个问题, 函数 is_tlp_dupack)
握手完毕后第七号包很快重传了
观察:
netstat -s |grep TCPLossProbes
解决:
tcp_early_retrans可用于开启和关闭ER和TLP,默认是3(enable TLP and delayed ER),sysctl -w net.ipv4.tcp_early_retrans=2 关掉TLP
小结
kernel版本小于4.0+TLP开启+VIP代理导致RS认为rtt很小,实际比较大,这两个条件下就会出现如上问题。
这个问题一看就是跟client和VIP代理之间的rtt扩大有关系,不过不是因为扩大后发送窗口不够之类导致的。
长肥网络(高rtt)场景下tcp_metrics记录的ssthresh太小导致传输慢的案例
https://www.atatech.org/articles/109967
tcp_metrics会记录下之前已关闭tcp 连接的状态,包括发送端拥塞窗口和拥塞控制门限,如果之前网络有一段时间比较差或者丢包比较严重,就会导致tcp 的拥塞控制门限ssthresh降低到一个很低的值,这个值在连接结束后会被tcp_metrics cache 住,在新连接建立时,即使网络状况已经恢复,依然会继承 tcp_metrics 中cache 的一个很低的ssthresh 值,在长肥管道情况下,新连接经历短暂的“慢启动”后,随即进入缓慢的拥塞控制阶段, 导致连接速度很难在短时间内上去。而后面的连接,需要很特殊的场景之下才能将ssthresh 再次推到一个比较高的值缓存下来,因此很有很能在接下来的很长一段时间,连接的速度都会处于一个很低的水平
因为 tcp_metrics记录的ssthresh非常小,导致后面新的tcp连接传输数据时很快进入拥塞控制阶段,如果传输的文件不大的话就没有机会将ssthresh撑大。除非传输一个特别大的文件,忍受拥塞控制阶段的慢慢增长,最后tcp_metrics记录下撑大后的ssthresh,整个网络才会恢复正常。
所以关闭 tcp_metrics其实是个不错的选择: net.ipv4.tcp_no_metrics_save = 1
或者清除: sudo ip tcp_metrics flush all
从系统cache中查看 tcp_metrics item
$sudo ip tcp_metrics show | grep 100.118.58.7
100.118.58.7 age 1457674.290sec tw_ts 3195267888/5752641sec ago rtt 1000us rttvar 1000us ssthresh 361 cwnd 40 ----这两个值对传输性能很重要
192.168.1.100 age 1051050.859sec ssthresh 4 cwnd 2 rtt 4805us rttvar 4805us source 192.168.0.174 ---这条记录有问题,缓存的ssthresh 4 cwnd 2都太小,传输速度一定慢
清除 tcp_metrics, sudo ip tcp_metrics flush all
关闭 tcp_metrics 功能,net.ipv4.tcp_no_metrics_save = 1
sudo ip tcp_metrics delete 100.118.58.7
每个连接的ssthresh默认是个无穷大的值,但是内核会cache对端ip上次的ssthresh(大部分时候两个ip之间的拥塞窗口大小不会变),这样大概率到达ssthresh之后就基本拥塞了,然后进入cwnd的慢增长阶段。
长肥网络(rt很高、带宽也高)下接收窗口对传输性能的影响
最后通过一个实际碰到的案例,涉及到了接收窗口、发送Buffer以及高延时情况下的性能问题
案例描述:从中国访问美国的服务器下载图片,只能跑到220K,远远没有达到带宽能力,其中中美之间的网络延时时150ms,这个150ms已经不能再优化了。业务结构是:
client ——150ms—–>>>LVS—1ms–>>>美国的统一接入server—–1ms—–>>>nginx
通过下载一个4M的文件大概需要20秒,分别在client和nginx上抓包来分析这个问题(统一接入server没权限上去)
Nginx上抓包
从这里可以看到Nginx大概在60ms内就将4M的数据都发完了
client上抓包
从这个图上可以清楚看到大概每传输大概30K数据就有一个150ms的等待平台,这个150ms基本是client到美国的rt。
从我们前面的阐述可以清楚了解到因为rt比较高,统一接入server每发送30K数据后要等150ms才能收到client的ack,然后继续发送,猜是因为上面设置的发送buffer大概是30K。
检查统一接入server的配置,可以看到接入server的配置里面果然有个32K buffer设置
将buffer改大
速度可以到420K,但是还没有跑满带宽:
接着看一下client上的抓包
可以清楚看到 client的接收窗口是64K, 64K*1000/150=426K 这个64K很明显是16位的最大值,应该是TCP握手有一方不支持window scaling factor
那么继续分析一下握手包,syn:
说明client是支持的,再看 syn+ack:
可以看到服务端不支持,那就最大只能用到64K。需要修改服务端代理程序,这主要是LVS或者代理的锅。
如果内网之间rt很小这个锅不会爆发,一旦网络慢一点就把问题恶化了
比如这是这个应用的开发人员的反馈:
长肥网络就像是很长很宽的高速公路,上面可以同时跑很多车,而如果发车能力不够,就容易跑不满高速公路。
在rt很短的时候可以理解为高速公路很短,所以即使发车慢也还好,因为车很快就到了,到了后就又能发新车了。rt很长的话就要求更大的仓库了。
整个这个问题,我最初拿到的问题描述结构是这样的(不要笑用户连自己的业务结构都描述不清):
client ——150ms—–>>>nginx
实际开发人员也不能完全描述清楚结构,从抓包中慢慢分析反推他们的结构,到最后问题的解决。
这个案例综合了发送窗口(32K)、接收窗口(64K,因为握手LVS不支持window scale)、rt很大将问题暴露出来(跨国网络,rt没法优化)。
nginx buffer 分析参考案例:https://club.perfma.com/article/433792?from=timeline
delay ack拉高实际rt的案例
这个案例跟速度没有关系,只是解析监控图表上的rt为什么不符合逻辑地偏高了。
如下业务监控图:实际处理时间(逻辑服务时间1ms,rtt2.4ms,加起来3.5ms),但是系统监控到的rt(蓝线)是6ms,如果一个请求分很多响应包串行发给client,这个6ms是正常的(1+2.4*N),但实际上如果send buffer足够的话,按我们前面的理解多个响应包会并发发出去,所以如果整个rt是3.5ms才是正常的。
抓包来分析原因:
实际看到大量的response都是3.5ms左右,符合我们的预期,但是有少量rt被delay ack严重影响了
从下图也可以看到有很多rtt超过3ms的,这些超长时间的rtt会最终影响到整个服务rt
一个有意思的问题
一个有意思的问题
问题描述
1 | $mysql -N -h127.0.0.1 -e "select id from sbtest1 limit 1" |
如上第一和第二个语句,为什么mysql client的输出重定向后就没有ascii制表符了呢? 语句三加上 -t后再经过管道,也有制表符了。
这里也有很多人有同样的疑问,不过不但没有给出第三行的解法,更没有人讲清楚这个里面的原理
分析
strace看看第一个语句:
再对比下第二个语句的strace:
从上面两个strace比较来看,似乎mysql client能检测到要输出到命名管道(S_IFIFO )还是character device(S_IFCHR),如果是命名管道的话就不要输出制表符了,如果是character device那么就输出ascii制表符。
1 | printf("File type: "); |
第4行和第6行两个类型就是导致mysql client选择了不同的输出内容
误解
所以这个问题不是:
为什么mysql client的输出重定向后就没有ascii制表符了呢?
而是:
mysql client 可以检测到不同的输出目标然后输出不同的内容吗? 管道或者重定向是一个应用能感知的输出目标吗?
误解:觉得管道写在后面,mysql client不应该知道后面是管道,mysql client输出内容到stdout,然后os将stdout的内容重定向给管道。
实际上mysql是可以检测(detect)输出目标的,如果是管道类的非交互输出那么没必要徒增一些制表符;如果是交互式界面那么就输出一些制表符好看一些。
要是想想在Unix下一切皆文件就更好理解了,输出到管道这个管道也是个文件,所以mysql client是可以感知各种输出文件的属性的。
背后的实现大概是这样:
1 | #include <stdio.h> |
结论就是 mysql client根据输出目标的不同(stdout、重定向)输出不同的内容,不过这种做法对用户体感上不是太好。
参考资料
https://www.oreilly.com/library/view/mysql-cookbook/0596001452/ch01s22.html
到底一台服务器上最多能创建多少个TCP连接
到底一台服务器上最多能创建多少个TCP连接
经常听到有同学说一台机器最多能创建65535个TCP连接,这其实是错误的理解。估计得有90%以上的程序员会是这个错误理解,这个错误理解是怎么产生的呢?
port range
我们都知道linux下本地随机端口范围由参数控制
1 | # cat /proc/sys/net/ipv4/ip_local_port_range |
所以也经常看到一个误解:一台机器上最多能创建65535个TCP连接
到底一台机器上最多能创建多少个TCP连接
结论:在内存、文件句柄足够的话可以创建的连接是没有限制的(每个TCP连接至少要消耗一个文件句柄)。
那么/proc/sys/net/ipv4/ip_local_port_range指定的端口范围到底是什么意思呢?
核心规则:一个TCP连接只要保证四元组(src-ip src-port dest-ip dest-port)唯一就可以了,而不是要求src port唯一
后面所讲都是遵循这个规则的,所以在心里反复默念:四元组唯一 五个大字,就能分析出来到底能创建多少TCP连接了。
比如如下这个TCP连接实际状态:
1 | # netstat -ant |grep 18089 |
从前三行可以清楚地看到18089被用了三次,第一第二行src-ip、dest-ip也是重复的,但是dest port不一样,第三行的src-port还是18089,但是src-ip变了。他们的四元组均不相同。
所以一台机器能创建的TCP连接是没有限制的,而ip_local_port_range是指没有bind的时候OS随机分配端口的范围,但是分配到的端口要同时满足五元组唯一,这样 ip_local_port_range 限制的是连同一个目标(dest-ip和dest-port一样)的port的数量(请忽略本地多网卡的情况,因为dest-ip为以后route只会选用一个本地ip)。
那么为什么大家有这样的误解呢?
- 如果是listen服务,那么肯定端口不能重复使用,这样就跟我们的误解对应上了,一个服务器上最多能监听65535个端口。
- 另外如果我们要连的server只有一个,比如:1.1.1.1:80 ,同时本机只有一个ip的话,那么这个时候即使直接调connect 也只能创建出65535个连接,因为四元组中的三个是固定的了。
这也就是65535错误理解的两个主要原因吧。
我们在创建连接前,经常会先调bind,bind后可以调listen当做服务端监听,也可以直接调connect当做client来连服务端。
bind(ip,port=0) 的时候是让系统绑定到某个网卡和自动分配的端口,此时系统没有办法确定接下来这个socket是要去connect还是listen. 如果是listen的话,那么肯定是不能出现端口冲突的,如果是connect的话,只要满足4元组唯一即可。在这种情况下,系统只能尽可能满足更强的要求,就是先要求端口不能冲突,即使之后去connect的时候四元组是唯一的。
但如果我只是个client端,只需要连接server建立连接,也就不需要bind,直接调connect就可以了,这个时候只要保证四元组唯一就行。
bind()的时候内核是还不知道四元组的,只知道src_ip、src_port,所以这个时候单网卡下src_port是没法重复的,但是connect()的时候已经知道了四元组的全部信息,所以只要保证四元组唯一就可以了,那么这里的src_port完全是可以重复使用的。
是不是加上了 SO_REUSEADDR、SO_REUSEPORT 就能重用端口了呢?
TCP SO_REUSEADDR
SO_REUSEADDR 主要解决的是重用TIME_WAIT状态的port, 在程序崩溃后之前的TCP连接会进入到TIME_WAIT状态,需要一段时间才能释放,如果立即重启就会抛出Address Already in use的错误导致启动失败。可以通过在调用bind函数之前设置SO_REUSEADDR来解决。
What exactly does SO_REUSEADDR do?
This socket option tells the kernel that even if this port is busy (in the TIME_WAIT state), go ahead and reuse it anyway. If it is busy, but with another state, you will still get an address already in use error. It is useful if your server has been shut down, and then restarted right away while sockets are still active on its port. You should be aware that if any unexpected data comes in, it may confuse your server, but while this is possible, it is not likely.
It has been pointed out that “A socket is a 5 tuple (proto, local addr, local port, remote addr, remote port). SO_REUSEADDR just says that you can reuse local addresses. The 5 tuple still must be unique!” This is true, and this is why it is very unlikely that unexpected data will ever be seen by your server. The danger is that such a 5 tuple is still floating around on the net, and while it is bouncing around, a new connection from the same client, on the same system, happens to get the same remote port.
By setting SO_REUSEADDR
user informs the kernel of an intention to share the bound port with anyone else, but only if it doesn’t cause a conflict on the protocol layer. There are at least three situations when this flag is useful:
- Normally after binding to a port and stopping a server it’s neccesary to wait for a socket to time out before another server can bind to the same port. With
SO_REUSEADDR
set it’s possible to rebind immediately, even if the socket is in aTIME_WAIT
state. - When one server binds to
INADDR_ANY
, say0.0.0.0:1234
, it’s impossible to have another server binding to a specific address like192.168.1.21:1234
. WithSO_REUSEADDR
flag this behaviour is allowed. - When using the bind before connect trick only a single connection can use a single outgoing source port. With this flag, it’s possible for many connections to reuse the same source port, given that they connect to different destination addresses.
TCP SO_REUSEPORT
SO_REUSEPORT主要用来解决惊群、性能等问题。
SO_REUSEPORT is also useful for eliminating the try-10-times-to-bind hack in ftpd’s data connection setup routine. Without SO_REUSEPORT, only one ftpd thread can bind to TCP (lhost, lport, INADDR_ANY, 0) in preparation for connecting back to the client. Under conditions of heavy load, there are more threads colliding here than the try-10-times hack can accomodate. With SO_REUSEPORT, things work nicely and the hack becomes unnecessary.
SO_REUSEPORT使用场景:linux kernel 3.9 引入了最新的SO_REUSEPORT选项,使得多进程或者多线程创建多个绑定同一个ip:port的监听socket,提高服务器的接收链接的并发能力,程序的扩展性更好;此时需要设置SO_REUSEPORT(注意所有进程都要设置才生效)。
setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT,(const void *)&reuse , sizeof(int));
目的:每一个进程有一个独立的监听socket,并且bind相同的ip:port,独立的listen()和accept();提高接收连接的能力。(例如nginx多进程同时监听同一个ip:port)
(a) on Linux SO_REUSEPORT is meant to be used purely for load balancing multiple incoming UDP packets or incoming TCP connection requests across multiple sockets belonging to the same app. ie. it’s a work around for machines with a lot of cpus, handling heavy load, where a single listening socket becomes a bottleneck because of cross-thread contention on the in-kernel socket lock (and state).
(b) set IP_BIND_ADDRESS_NO_PORT socket option for tcp sockets before binding to a specific source ip
with port 0 if you’re going to use the socket for connect() rather then listen() this allows the kernel
to delay allocating the source port until connect() time at which point it is much cheaper
The Ephemeral Port Range
Ephemeral Port Range就是我们前面所说的Port Range(/proc/sys/net/ipv4/ip_local_port_range)
A TCP/IPv4 connection consists of two endpoints, and each endpoint consists of an IP address and a port number. Therefore, when a client user connects to a server computer, an established connection can be thought of as the 4-tuple of (server IP, server port, client IP, client port).
Usually three of the four are readily known – client machine uses its own IP address and when connecting to a remote service, the server machine’s IP address and service port number are required.
What is not immediately evident is that when a connection is established that the client side of the connection uses a port number. Unless a client program explicitly requests a specific port number, the port number used is an ephemeral port number.
Ephemeral ports are temporary ports assigned by a machine’s IP stack, and are assigned from a designated range of ports for this purpose. When the connection terminates, the ephemeral port is available for reuse, although most IP stacks won’t reuse that port number until the entire pool of ephemeral ports have been used.
So, if the client program reconnects, it will be assigned a different ephemeral port number for its side of the new connection.
linux 如何选择Ephemeral Port
有资料说是随机从Port Range选择port,有的说是顺序选择,那么实际验证一下。
如下测试代码:
1 | #include <stdio.h> // printf |
3.10.0-327.ali2017.alios7.x86_64
编译后,执行(3.10.0-327.ali2017.alios7.x86_64):
1 | #date; ./client && echo "+++++++" ; ./client && sleep 0.1 ; echo "-------" && ./client && sleep 10; date; ./client && echo "+++++++" ; ./client && sleep 0.1 && echo "******"; ./client; |
从测试看起来linux下端口选择跟时间有关系,起始端口肯定是顺序增加,起始端口应该是在Ephemeral Port范围内并且和时间戳绑定的某个值(也是递增的),即使没有使用任何端口,起始端口也会随时间增加而增加。
4.19.91-19.1.al7.x86_64
换个内核版本编译后,执行(4.19.91-19.1.al7.x86_64):
1 | $date; ./client && echo "+++++++" ; ./client && sleep 0.1 ; echo "-------" && ./client && sleep 10; date; ./client && echo "+++++++" ; ./client && sleep 0.1 && echo "******"; ./client; |
之所以都是偶数端口,是因为port_range 从偶数开始:
1 | $cat /proc/sys/net/ipv4/ip_local_port_range |
将1024改成1025后,分配出来的都是奇数端口了:
1 | $cat /proc/sys/net/ipv4/ip_local_port_range |
可见4.19内核下每次port是+2,在3.10内核版本中是+1. 并且都是递增的,同时即使port不使用,也会随着时间的变化这个起始port增大。
Port Range有点像雷达转盘数字,时间就像是雷达上的扫描指针,这个指针不停地旋转,如果这个时候刚好有应用要申请Port,那么就从指针正好指向的Port开始向后搜索可用port
结论
- 在内存、文件句柄足够的话一台服务器上可以创建的TCP连接数量是没有限制的
- SO_REUSEADDR 主要用于快速重用 TIME_WAIT状态的TCP端口,避免服务重启就会抛出Address Already in use的错误
- SO_REUSEPORT主要用来解决惊群、性能等问题
- local port的选择是递增搜索的,搜索起始port随时间增加也变大
参考资料
https://segmentfault.com/a/1190000002396411
linux中TCP的socket、bind、listen、connect和accept的实现
How Linux allows TCP introspection The inner workings of bind and listen on Linux.
一次春节大促性能压测不达标的瓶颈推演
一次春节大促性能压测不达标的瓶颈推演
本文示范了教科书式的在分布式应用场景下如何通过一个节点的状态来推演分析瓶颈出在上下游的哪个环节上。
场景描述
联通通过PTS来压选号业务(HTTP服务在9108端口上),一个HTTP请求对应一次select seq-id 和 一次insert
PTS端看到RT900ms+,QPS大概5万(期望20万), DRDS rt 5ms,QPS 10万+
链路:
pts发起压力 -> 5个eip -> slb -> app(300个机器运行tomcat在9108端口上) -> slb -> drds集群 -> RDS集群
性能不达标,怀疑DRDS或者RDS性能不行,作为数据库需要自证清白,所以从RDS和DRDS开始分析问题在哪里。
略过一系列在DRDS、RDS上分析数据和监控图表都证明DRDS和RDS没问题。
在明确给出证据DRDS和RDS都没问题后还是要解决问题,所以只能进一步帮助前面的app来分析为什么性能不达标。
在其中一个app应用上抓包(00:18秒到1:04秒),到DRDS的一个连接分析:
DRDS每个HTTP请求的响应时间都控制在15ms(一个前端HTTP请求对应一个select seq-id,一个 select readonly, 一个insert, 这个响应时间符合预期)。一个连接每秒才收到20 tps(因为压力不够,压力加大的话这个单连接tps还可以增加), 20*3000 = 6万 , 跟压测看到基本一致
300个app,每个app 10个连接到DRDS
如果300个app上的并发压力不够的话就没法将3000个连接跑满,所以看到的QPS是5万。
从300个app可以计算得到这个集群能支持的tps: 300*10(10个连接)* 1000/15(每秒钟每个连接能处理的请求数)=20万个tps (关键分析能力)
9108的HTTP服务端口上的抓包分析
9108服务的每个HTTP response差不多都是15ms(这个响应时间基本符合预期),一个HTTP连接上在45秒的抓包时间范围只收到23个HTTP Request。
统计9108端口在45秒总共收到的HTTP请求数量是6745(如下图),也就是每个app每秒钟收到的请求是150个,300*150=4.5万(理论值,300个app可能压力分布不一样?),从这里看app收到的压力还不够
因为app监控确实(后来发现是不会看),所以从抓包分析http响应时间也基本得到15ms的rt关键结论
从http应用容器上的netstat统计来看,也是压力端回复太慢
send-q表示回复从9108发走了,没收到对方的ack
ARMS监控分析9108端口上的RT
后来PTS的同学说ARMS可以捞到监控数据,如下是对rt时间降序排
中的rt平均时间,可以看到http的rt确实14.4ms,表现非常平稳,从这个监控也发现实际app是330个而不是用户自己描述的300个,这也就是为什么实际是tps是5万,但是按300个去算的话tps是4.5万
5分钟时间,QPS是5万+,HTTP的平均rt是15ms, HTTP的最大rt才79ms,和前面抓包分析一致。
从后端分析的总结
从9108端口响应时间15ms来看是符合预期的,为什么PTS看到的RT是900ms+,所以压力还没有打到APP上(也就是9108端口)
结论
最后发现是 eip 带宽不足,只有200M,调整到1G后 tps 也翻了5倍到了25万。
pts -> 5个eip(总带宽200M) -> slb -> app(330个HTTP容器) -> slb -> drds -> RDS
这个案例有意思的地方是可以通过抓包就能分析出集群能扛的QPS20万(实际只有5万),那么可以把这个分析原则在每个角色上挨个分析一下,来看瓶颈出在了哪个环节。
应用端看到的rt是900ms,从后段开始往前面应用端来撸,看看每个环节的rt数据。
教训
- 搞清楚 请求 从发起端到DB的链路路径,比如 pts -> 5个eip(总带宽200M) -> slb -> app(330个HTTP容器) -> slb -> drds -> RDS
- 压不上去得从发压力端开始往后端撸,撸每个产品的rt,每个产品给出自己的rt来自证清白
- 应用有arms的话学会看arms对平均rt和QPS的统计,不要纠结个别请求的rt抖动,看平均rt
- 通过抓包完全可以分析出来系统能扛多少并发,以及可能的瓶颈位置
一包在手 万事无忧
活久见,TCP连接互串了
活久见,TCP连接互串了
背景
应用每过一段时间总是会抛出几个连接异常的错误,需要查明原因。
排查后发现是TCP连接互串了,这个案例实在是很珍惜,所以记录一下。
抓包
业务结构: 应用->MySQL(10.112.61.163)
在 应用 机器上抓包这个异常连接如下(3269为MySQL服务端口):
粗一看没啥奇怪的,就是应用发查询给3269,但是一直没收到3269的ack,所以一直重传。这里唯一的解释就是网络不通。最后MySQL的3269还回复了一个rst,这个rst的id是42889,引起了我的好奇,跟前面的16439不连贯,正常应该是16440才对。(请记住上图中的绿框中的数字)
于是我过滤了一下端口61902上的所有包:
可以看到绿框中的查询从61902端口发给3269后,很奇怪居然收到了一个来自别的IP+3306端口的reset,这个包对这个连接来说自然是不认识(这个连接只接受3269的回包),就扔掉了。但是也没收到3269的ack,所以只能不停地重传,然后每次都收到3306的reset,reset包的seq、id都能和上图的绿框对应上。
明明他们应该是两个连接:
61902->10.141.16.0:3306
61902->10.112.61.163:3269
他们虽然用的本地ip端口(61902)是一样的, 但是根据四元组不一样,还是不同的TCP连接,所以应该是不会互相干扰的。但是实际看起来seq、id都重复了,不会有这么巧,非常像是TCP互串了。
分析原因
10.141.16.0 这个ip看起来像是lvs的ip,查了一下系统,果然是lvs,然后这个lvs 后面的rs就是10.112.61.163
那么这个连结构就是10.141.16.0:3306:
应用 -> lvs(10.141.16.0:3306)-> 10.112.61.163:3269 跟应用直接连MySQL是一回事了
所以这里的疑问就变成了:10.141.16.0 这个IP的3306端口为啥能知道 10.112.61.163:3269端口的seq和id,也许是TCP连接串了
接着往下排查
先打个岔,分析下这里的LVS的原理
这里使用的是 full NAT模型(full NetWork Address Translation-全部网络地址转换)
基本流程(类似NAT):
- client发出请求(sip 200.200.200.2 dip 200.200.200.1)
- 请求包到达lvs,lvs修改请求包为(sip 200.200.200.1, dip rip) 注意这里sip/dip都被修改了
- 请求包到达rs, rs回复(sip rip,dip 200.200.200.1)
- 这个回复包的目的IP是VIP(不像NAT中是 cip),所以LVS和RS不在一个vlan通过IP路由也能到达lvs
- lvs修改sip为vip, dip为cip,修改后的回复包(sip 200.200.200.1,dip 200.200.200.2)发给client
注意上图中绿色的进包和红色的出包他们的地址变化
本来这个模型下都是正常的,但是为了Real Server能拿到client ip,也就是Real Server记录来源ip的时候希望记录的是client ip而不是LVS ip。这个时候LVS会将client ip放在tcp的options里面,然后在RealServer机器的内核里面将options中的client ip取出替换掉 lvs ip。所以Real Server上感知到的对端ip就是client ip。
回包的时候RealServer上的内核模块同样将目标地址从client ip改成lvs ip,同时将client ip放入options中。
回到问题
看完理论,再来分析这两个连接的行为
fulnat模式下连接经过lvs到达mysql后,mysql上看到的连接信息是,cip+port,也就是在MySQL上的连接
lvs-ip:port -> 10.112.61.163:3269 被修改成了 client-ip:61902 **-> 10.112.61.163:3269
那么跟不走LVS的连接:
client-ip:61902 -> 10.112.61.163:3269 (直连) 完全重复了。
MySQL端看到的两个连接四元组一模一样了:
10.112.61.163:3269 -> client-ip:61902 (走LVS,本来应该是lvs ip的,但是被替换成了client ip)
10.112.61.163:3269 -> client-ip:61902 (直连)
这个时候应用端看到的还是两个连接:
client-ip:61902 -> 10.141.16.0:3306 (走LVS)
client-ip:61902 -> 10.112.61.163:3269 (直连)
总结下,也就是这个连接经过LVS转换后在服务端(MYSQL)跟直连MySQL的连接四元组完全重复了,也就是MySQL会认为这两个连接就是同一个连接,所以必然出问题了。
实际两个连接建立的情况:
和mysqlserver的61902是04:22建起来的,和lvs的61902端口 是42:10建起来的,和lvs的61902建起来之后马上就出问题了
问题出现的条件
- fulnat模式的LVS,RS上装有slb_toa内核模块(RS上会将LVS ip还原成client ip)
- client端正好重用一个相同的本地端口分别和RS以及LVS建立了两个连接
这个时候这两个连接在MySQL端就会变成一个,然后两个连接的内容互串,必然导致rst
这个问题还挺有意思的,估计没几个程序员一辈子能碰上一次。推荐另外一个好玩的连接:如何创建一个自己连自己的TCP连接
一台机器上最多能创建多少个TCP连接 ip_local_port_range
在内存、文件句柄足够的话可以创建的连接是没有限制的,那么/proc/sys/net/ipv4/ip_local_port_range指定的端口范围到底是什么意思呢?
一个TCP连接只要保证四元组(src-ip src-port dest-ip dest-port)唯一就可以了,而不是要求src port唯一.
一台机器能创建的TCP连接是没有限制的,而ip_local_port_range是指没有bind的时候OS随机分配端口的范围,但是分配到的端口要同时满足五元组唯一,这样 ip_local_port_range 限制的是连同一个目标(dest-ip和dest-port一样)的port的数量(请忽略本地多网卡的情况,因为dest-ip为以后route只会选用一个本地ip)。
但是如果程序调用的是bind函数(bind(ip,port=0))这个时候是让系统绑定到某个网卡和自动分配的端口,此时系统没有办法确定接下来这个socket是要去connect还是listen. 如果是listen的话,那么肯定是不能出现端口冲突的,如果是connect的话,只要满足4元组唯一即可。在这种情况下,系统只能尽可能满足更强的要求,就是先要求端口不能冲突,即使之后去connect的时候4元组是唯一的。
bind()的时候内核是还不知道四元组的,只知道src_ip、src_port,所以这个时候单网卡下src_port是没法重复的,但是connect()的时候已经知道了四元组的全部信息,所以只要保证四元组唯一就可以了,那么这里的src_port完全是可以重复使用的。
The Ephemeral Port Range
A TCP/IPv4 connection consists of two endpoints, and each endpoint consists of an IP address and a port number. Therefore, when a client user connects to a server computer, an established connection can be thought of as the 4-tuple of (server IP, server port, client IP, client port).
Usually three of the four are readily known – client machine uses its own IP address and when connecting to a remote service, the server machine’s IP address and service port number are required.
What is not immediately evident is that when a connection is established that the client side of the connection uses a port number. Unless a client program explicitly requests a specific port number, the port number used is an ephemeral port number.
Ephemeral ports are temporary ports assigned by a machine’s IP stack, and are assigned from a designated range of ports for this purpose. When the connection terminates, the ephemeral port is available for reuse, although most IP stacks won’t reuse that port number until the entire pool of ephemeral ports have been used.
So, if the client program reconnects, it will be assigned a different ephemeral port number for its side of the new connection.
linux 如何选择Ephemeral Port
如下测试代码:
1 | #include <stdio.h> // printf |
编译后,执行(3.10.0-327.ali2017.alios7.x86_64):
1 | #date; ./client && echo "+++++++" ; ./client && sleep 0.1 ; echo "-------" && ./client && sleep 10; date; ./client && echo "+++++++" ; ./client && sleep 0.1 && echo "******"; ./client; |
从测试看起来linux下端口选择跟时间有关系,起始端口肯定是顺序增加,起始端口应该是在Ephemeral Port范围内并且和时间戳绑定的某个值(也是递增的),即使没有使用任何端口,起始端口也会随时间增加而增加。
编译后,执行(4.19.91-19.1.al7.x86_64):
1 | $date; ./client && echo "+++++++" ; ./client && sleep 0.1 ; echo "-------" && ./client && sleep 10; date; ./client && echo "+++++++" ; ./client && sleep 0.1 && echo "******"; ./client; |
之所以都是偶数端口,是因为port_range 从偶数开始:
1 | $cat /proc/sys/net/ipv4/ip_local_port_range |
将1024改成1025后,分配出来的都是奇数端口了:
1 | $cat /proc/sys/net/ipv4/ip_local_port_range |
参考资料
https://idea.popcount.org/2014-04-03-bind-before-connect/
另一种形式的tcp连接互串,新连接重用了time_wait的port,导致命中lvs内核表中的维护的旧连接发给了老的realserver
MySQL线程池导致的延时卡顿排查
MySQL 线程池导致的延时卡顿排查
问题描述
简单小表的主键点查SQL,单条执行很快,但是放在业务端,有时快有时慢,取了一条慢sql,在MySQL侧查看,执行时间很短。
通过Tomcat业务端监控有显示慢SQL,取slow.log里显示有12秒执行时间的SQL,但是这次12秒的执行在MySQL上记录下来的执行时间都不到1ms。
所在节点的tsar监控没有异常,Tomcat manager监控上没有fgc,Tomcat实例规格 16C32g8, MySQL 32c128g 32 。
5-28号现象复现,从监控图上CPU、内存、网络都没发现异常,MySQL侧查到的SQL依然执行很快,Tomcat侧记录12S执行时间,当时Tomcat节点的网络流量、CPU压力都很小。
所以客户怀疑Tomcat有问题或者Tomcat上的代码写得有问题导致了这个问题,需要排查和解决掉。
Tomcat上抓包分析
慢的连接
经过抓包分析发现在慢的连接上,所有操作都很慢,包括set 命令,慢的时间主要分布在3秒以上,1-3秒的慢查询比较少,这明显不太符合分布规律。并且目前看慢查询基本都发生在MySQL的0库的部分连接上(后端有一堆MySQL组成的集群),下面抓包的4637端口是MySQL的服务端口:
以上两个连接都很慢,对应的慢查询在MySQL里面记录很快。
慢的SQL的response按时间排序基本都在3秒以上:
或者只看response time 排序,中间几个1秒多的都是 Insert语句。也就是1秒到3秒之间的没有,主要是3秒以上的查询
!
快的连接
同样一个查询SQL,发到同一个MySQL上(4637端口),下面的连接上的所有操作都很快,下面是两个快的连接上的执行截图
别的MySQL上都比较快,比如5556分片上的所有response RT排序,只有偶尔极个别的慢SQL
MySQL相关参数
1 | mysql> show variables like '%thread%'; |
综上结论
问题原因跟MySQL线程池比较相关,慢的连接总是慢,快的连接总是快。需要到MySQL Server下排查线程池相关参数。
同一个慢的连接上的回包,所有 ack 就很快(OS直接回,不需要进到MySQL),但是set就很慢,基本理解只要进到MySQL的就慢了,所以排除了网络原因(流量本身也很小,也没看到乱序、丢包之类的)
问题解决
18点的时候将4637端口上的MySQL thread_pool_oversubscribe 从10调整到20后,基本没有慢查询了:
当时从MySQL的观察来看,并发压力很小,很难抓到running thread比较高的情况(update: 可能是任务积压在队列中,只是96个thread pool中的一个thread全部running,导致整体running不高)
MySQL记录的执行时间是指SQL语句开始解析后统计,中间的等锁、等Worker都不会记录在执行时间中,所以当时对应的SQL在MySQL日志记录中很快。
这里表现出高 RT 而不是超时,原因是 MySQL 线程池有另一个参数 thread_pool_stall_limit 防止线程卡死.请求如果在分组内等待超过 thread_pool_stall_limit 时间没被处理,则会退回传统模式,创建新线程来处理请求.这个参数的默认值是 500ms。另外这个等待时间是不会被记录到MySQL的慢查询日志中的
Thread Pool原理
MySQL 原有线程调度方式有每个连接一个线程(one-thread-per-connection)和所有连接一个线程(no-threads)。
no-threads一般用于调试,生产环境一般用one-thread-per-connection方式。one-thread-per-connection 适合于低并发长连接的环境,而在高并发或大量短连接环境下,大量创建和销毁线程,以及线程上下文切换,会严重影响性能。另外 one-thread-per-connection 对于大量连接数扩展也会影响性能。
为了解决上述问题,MariaDB、Percona、Oracle MySQL 都推出了线程池方案,它们的实现方式大体相似,这里以 Percona 为例来简略介绍实现原理,同时会介绍我们在其基础上的一些改进。
线程池由一系列 worker 线程组成,这些worker线程被分为thread_pool_size
个group。用户的连接按 round-robin 的方式映射到相应的group 中,一个连接可以由一个group中的一个或多个worker线程来处理。
thread_pool_oversubscribe 一个group中活跃线程和等待中的线程超过thread_pool_oversubscribe
时,不会创建新的线程。 此参数可以控制系统的并发数,同时可以防止调度上的死锁,考虑如下情况,A、B、C三个事务,A、B 需等待C提交。A、B先得到调度,同时活跃线程数达到了thread_pool_max_threads
上限,随后C继续执行提交,此时已经没有线程来处理C提交,从而导致A、B一直等待。thread_pool_oversubscribe
控制group中活跃线程和等待中的线程总数,从而防止了上述情况。
thread_pool_stall_limit
timer线程检测间隔。此参数设置过小,会导致创建过多的线程,从而产生较多的线程上下文切换,但可以及时处理锁等待的场景,避免死锁。参数设置过大,对长语句有益,但会阻塞短语句的执行。参数设置需视具体情况而定,例如99%的语句10ms内可以完成,那么我们可以将就thread_pool_stall_limit
设置为10ms。
MySQL Thread Pool之所以分成多个小的Thread Group Pool而不是一个大的Pool,是为了分解锁(每个group中都有队列,队列需要加锁。类似ConcurrentHashMap提高并发的原理),提高并发效率。
group中的队列是用来区分优先级的,事务中的语句会放到高优先队列(非事务语句和autocommit 都会在低优先队列);等待太久的SQL也会挪到高优先队列,防止饿死。
比如启用Thread Pool后,如果出现多个慢查询,容易导致拨测类请求超时,进而出现Server异常的判断(类似Nginx 边缘触发问题);或者某个group满后导致慢查询和拨测失败之类的问题
thread_pool_size过小的案例
应用出现大量1秒超时报错:
分析代码,这个报错是是数据库连接池在创建到MySQL的连接后会发送一个ping来验证下连接是否有效,有效后才给应用使用。说明连接创建成功,但是MySQL处理指令缓慢。
继续分析MySQL的参数:
可以看到thread_pool_size是1,太小了,将所有MySQL线程都放到一个buffer里面来抢锁,锁冲突的概率太高。调整到16后可以明显看到MySQL的RT从原来的12ms下降到了3ms不到,整个QPS大概有8%左右的提升。这是因为pool size为1的话所有sql都在一个队列里面,多个worker thread加锁等待比较严重,导致rt延迟增加。
这个问题发现是因为压力一上来的时候要创建大量新的连接,这些连结创建后会去验证连接的有效性,也就是给MySQL发一个ping指令,一般都很快,这个ping验证过程设置的是1秒超时,但是实际看到大量超时异常堆栈,从而发现MySQL内部响应有问题。
MySQL ping和MySQL协议相关知识
Ping use the JDBC method Connection.isValid(int timeoutInSecs)). Digging into the MySQL Connector/J source, the actual implementation uses com.mysql.jdbc.ConnectionImpl.pingInternal() to send a simple ping packet to the DB and returns true as long as a valid response is returned.
MySQL ping protocol是发送了一个 0e
的byte标识给Server,整个包加上2byte的Packet Length(内容为:1),2byte的Packet Number(内容为:0),总长度为5 byte
1 | public class MySQLPingPacket implements CommandPacket { |
也就是一个TCP包中的Payload为 MySQL协议中的内容长度 + 4(Packet Length+Packet Number)
总结
这个问题的本质在于 MySQL线程池开启后,因为会将多个连接分配在一个池子中共享这个池子中的几个线程。导致一个池子中的线程特别慢的时候会影响这个池子中所有的查询都会卡顿。即使别的池子很空闲也不会将任务调度过去。
MySQL线程池设计成多个池子(Group)的原因是为了将任务队列拆成多个,这样每个池子中的线程只是内部竞争锁,跟其他池子不冲突,当然这个设计带来的问题就是多个池子中的任务不能均衡了。
同时从案例我们也可以清楚地看到这个池子太小会造成锁冲突严重的卡顿,池子太大(每个池子中的线程数量就少)容易造成等线程的卡顿。
类似地这个问题也会出现在Nginx的多worker中,一旦一个连接分发到了某个worker,就会一直在这个worker上处理,如果这个worker上的某个连接有一些慢操作,会导致这个worker上的其它连接的所有操作都受到影响,特别是会影响一些探活任务的误判。
Nginx的worker这么设计也是为了将单worker绑定到固定的cpu,然后避免多核之间的上下文切换。
一包在手,万事无忧
参考文章
https://www.atatech.org/articles/36343
Linux内存--PageCache
Linux内存–PageCache
read(2)/write(2)
是 Linux 系统中最基本的 I/O 读写系统调用,我们开发操作 I/O 的程序时必定会接触到它们,而在这两个系统调用和真实的磁盘读写之间存在一层称为 Kernel buffer cache
的缓冲区缓存。在 Linux 中 I/O 缓存其实可以细分为两个:Page Cache
和 Buffer Cache
,这两个其实是一体两面,共同组成了 Linux 的内核缓冲区(Kernel Buffer Cache),Page Cache 是在应用程序读写文件的过程中产生的:
- 读磁盘:内核会先检查
Page Cache
里是不是已经缓存了这个数据,若是,直接从这个内存缓冲区里读取返回,若否,则穿透到磁盘去读取,然后再缓存在Page Cache
里,以备下次缓存命中; 写磁盘:内核直接把数据写入
Page Cache
,并把对应的页标记为 dirty,添加到 dirty list 里,然后就直接返回,内核会定期把 dirty list 的页缓存 flush 到磁盘,保证页缓存和磁盘的最终一致性。在 Linux 还不支持虚拟内存技术之前,还没有页的概念,因此
Buffer Cache
是基于操作系统读写磁盘的最小单位 – 块(block)来进行的,所有的磁盘块操作都是通过Buffer Cache
来加速,Linux 引入虚拟内存的机制来管理内存后,页成为虚拟内存管理的最小单位,因此也引入了Page Cache
来缓存 Linux 文件内容,主要用来作为文件系统上的文件数据的缓存,提升读写性能,常见的是针对文件的read()/write()
操作,另外也包括了通过mmap()
映射之后的块设备,也就是说,事实上 Page Cache 负责了大部分的块设备文件的缓存工作。而Buffer Cache
用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用。
在 Linux 2.4 版本之后,kernel 就将两者进行了统一,Buffer Cache
不再以独立的形式存在,而是以融合的方式存在于 Page Cache
中
融合之后就可以统一操作 Page Cache
和 Buffer Cache
:处理文件 I/O 缓存交给 Page Cache
,而当底层 RAW device 刷新数据时以 Buffer Cache
的块单位来实际处理。
手动回收系统Cache、Buffer,这个文件可以设置的值分别为1、2、3。它们所表示的含义为:
echo 1 > /proc/sys/vm/drop_caches:表示清除pagecache。
echo 2 > /proc/sys/vm/drop_caches:表示清除回收slab分配器中的对象(包括目录项缓存和inode缓存)。slab分配器是内核中管理内存的一种机制,其中很多缓存数据实现都是用的pagecache。
echo 3 > /proc/sys/vm/drop_caches:表示清除pagecache和slab分配器中的缓存对象。
pagecache 的产生和释放
- 标准 I/O 是写的 (write(2)) 用户缓冲区 (Userpace Page 对应的内存),然后再将用户缓冲区里的数据拷贝到内核缓冲区 (Pagecache Page 对应的内存);如果是读的 (read(2)) 话则是先从内核缓冲区拷贝到用户缓冲区,再从用户缓冲区读数据,也就是 buffer 和文件内容不存在任何映射关系。
- 对于存储映射 I/O(Memory-Mapped I/O) 而言,则是直接将 Pagecache Page 给映射到用户地址空间,用户直接读写 Pagecache Page 中内容,效率相对标准IO更高一些
当 将用户缓冲区里的数据拷贝到内核缓冲区 (Pagecache Page 对应的内存) 最容易发生缺页中断,OS需要先分配Page(应用感知到的就是卡顿了)
- Page Cache 是在应用程序读写文件的过程中产生的,所以在读写文件之前你需要留意是否还有足够的内存来分配 Page Cache;
- Page Cache 中的脏页很容易引起问题,你要重点注意这一块;
- 在系统可用内存不足的时候就会回收 Page Cache 来释放出来内存,我建议你可以通过 sar 或者 /proc/vmstat 来观察这个行为从而更好的判断问题是否跟回收有关
缺页后kswapd在短时间内回收不了足够多的 free 内存,或kswapd 还没有触发执行,操作系统就会进行内存页直接回收。这个过程中,应用会进行自旋等待直到回收的完成,从而产生巨大的延迟。
如果page被swapped,那么恢复进内存的过程也对延迟有影响,当被匿名内存页被回收后,如果下次再访问就会产生IO的延迟。
min 和 low的区别
- min下的内存是保留给内核使用的;当到达min,会触发内存的direct reclaim (vm.min_free_kbytes)
- low水位比min高一些,当内存可用量小于low的时候,会触发 kswapd回收内存,当kswapd慢慢的将内存 回收到high水位,就开始继续睡眠
内存回收方式
内存回收方式有两种,主要对应low ,min
- kswapd reclaim : 达到low水位线时执行 – 异步(实际还有,只是比较危险了,后台kswapd会回收,不会卡顿应用)
- direct reclaim : 达到min水位线时执行 – 同步
为了减少缺页中断,首先就要保证我们有足够的内存可以使用。由于Linux会尽可能多的使用free的内存,运行很久的应用free的内存是很少的。下面的图中,紫色表示已经使用的内存,白色表示尚未分配的内存。当我们的内存使用达到水位的low值的时候,kswapd就会开始回收工作,而一旦内存分配超过了min,就会进行内存的直接回收。
针对这种情况,我们需要采用预留内存的手段,系统参数vm.extra_free_kbytes就是用来做这个事情的。这个参数设置了系统预留给应用的内存,可以避免紧急需要内存时发生内存回收不及时导致的高延迟。从下面图中可以看到,通过vm.extra_free_kbytes的设置,预留内存可以让内存的申请处在一个安全的水位。需要注意的是,因为内核的优化,在3.10以上的内核版本这个参数已经被取消。
或者禁止: vm.swappiness 来避免swapped来减少延迟
Page回收–缺页中断
从图里你可以看到,在开始内存回收后,首先进行后台异步回收(上图中蓝色标记的地方),这不会引起进程的延迟;如果后台异步回收跟不上进程内存申请的速度,就会开始同步阻塞回收,导致延迟(上图中红色和粉色标记的地方,这就是引起 load 高的地址 – Sys CPU 使用率飙升/Sys load 飙升)。
那么,针对直接内存回收引起 load 飙高或者业务 RT 抖动的问题,一个解决方案就是及早地触发后台回收来避免应用程序进行直接内存回收,那具体要怎么做呢?
它的意思是:当内存水位低于 watermark low 时,就会唤醒 kswapd 进行后台回收,然后 kswapd 会一直回收到 watermark high。
那么,我们可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收,该选项最终控制的是内存回收水位,不过,内存回收水位是内核里面非常细节性的知识点,我们可以先不去讨论。
对于大于等于 128G 的系统而言,将 min_free_kbytes 设置为 4G 比较合理,这是我们在处理很多这种问题时总结出来的一个经验值,既不造成较多的内存浪费,又能避免掉绝大多数的直接内存回收。
该值的设置和总的物理内存并没有一个严格对应的关系,我们在前面也说过,如果配置不当会引起一些副作用,所以在调整该值之前,我的建议是:你可以渐进式地增大该值,比如先调整为 1G,观察 sar -B 中 pgscand 是否还有不为 0 的情况;如果存在不为 0 的情况,继续增加到 2G,再次观察是否还有不为 0 的情况来决定是否增大,以此类推。
sar -B : Report paging statistics.
pgscand/s Number of pages scanned directly per second.
系统中脏页过多引起 load 飙高
直接回收过程中,如果存在较多脏页就可能涉及在回收过程中进行回写,这可能会造成非常大的延迟,而且因为这个过程本身是阻塞式的,所以又可能进一步导致系统中处于 D 状态的进程数增多,最终的表现就是系统的 load 值很高。
可以通过 sar -r 来观察系统中的脏页个数:
1 | $ sar -r 1 |
kbdirty 就是系统中的脏页大小,它同样也是对 /proc/vmstat 中 nr_dirty 的解析。你可以通过调小如下设置来将系统脏页个数控制在一个合理范围:
vm.dirty_background_bytes = 0
vm.dirty_background_ratio = 10
vm.dirty_bytes = 0
vm.dirty_expire_centisecs = 3000
vm.dirty_ratio = 20
至于这些值调整大多少比较合适,也是因系统和业务的不同而异,我的建议也是一边调整一边观察,将这些值调整到业务可以容忍的程度就可以了,即在调整后需要观察业务的服务质量 (SLA),要确保 SLA 在可接受范围内。调整的效果你可以通过 /proc/vmstat 来查看:
1 | #grep "nr_dirty_" /proc/vmstat |
在4.20的内核并且sar 的版本为12.3.3可以看到PSI(Pressure-Stall Information)
1 | some avg10=45.49 avg60=10.23 avg300=5.41 total=76464318 |
你需要重点关注 avg10 这一列,它表示最近 10s 内存的平均压力情况,如果它很大(比如大于 40)那 load 飙高大概率是由于内存压力,尤其是 Page Cache 的压力引起的。
通过tracepoint分析内存卡顿问题
我们继续以内存规整 (memory compaction) 为例,来看下如何利用 tracepoint 来对它进行观察:
1 | #首先来使能compcation相关的一些tracepoing |
从这个例子中的信息里,我们可以看到是 49355 这个进程触发了 compaction,begin 和 end 这两个 tracepoint 触发的时间戳相减,就可以得到 compaction 给业务带来的延迟,我们可以计算出这一次的延迟为 17ms。
或者用 perf script 脚本来分析, 基于 bcc(eBPF) 写的direct reclaim snoop来观察进程因为 direct reclaim 而导致的延迟。
参考资料
https://www.atatech.org/articles/66885
Linux内存--碎片
Linux内存–碎片
/proc/buddyinfo
/proc/buddyinfo记录了内存的详细碎片情况。
1 | #cat /proc/buddyinfo |
Normal行的第二列表示: 643847*2^1*Page_Size(4K) ; 第三列表示: 357451*2^2*Page_Size(4K) ,高阶内存指的是2^3及更大的内存块。
应用申请大块连续内存(高阶内存,一般之4阶及以上, 也就是64K以上–2^4*4K)时,容易导致卡顿。这是因为大块连续内存确实系统需要触发回收或者碎片整理,需要一定的时间。
DMA
is the low 16 MBytes of memory. At this point it exists for historical reasons; once upon what is now a long time ago, there was hardware that could only do DMA into this area of physical memory.DMA32
exists only in 64-bit Linux; it is the low 4 GBytes of memory, more or less. It exists because the transition to large memory 64-bit machines has created a class of hardware that can only do DMA to the low 4 GBytes of memory.(This is where people mutter about everything old being new again.)Normal
is different on 32-bit and 64-bit machines. On 64-bit machines, it is all RAM from 4GB or so on upwards. On 32-bit machines it is all RAM from 16 MB to 896 MB for complex and somewhat historical reasons. Note that this implies that machines with a 64-bit kernel can have very small amounts of Normal memory unless they have significantly more than 4GB of RAM. For example, a 2 GB machine running a 64-bit kernel will have no Normal memory at all while a 4 GB machine will have only a tiny amount of it.HighMem
exists only on 32-bit Linux; it is all RAM above 896 MB, including RAM above 4 GB on sufficiently large machines.
cache回收:
echo 1/2/3 >/proc/sys/vm/drop_cached
查看回收后:
cat /proc/meminfo
当我们执行 echo 2 来 drop slab 的时候,它也会把 Page Cache(inode可能会有对应的pagecache,inode释放后对应的pagecache也释放了)给 drop 掉
在系统内存紧张的时候,运维人员或者开发人员会想要通过 drop_caches 的方式来释放一些内存,但是由于他们清楚 Page Cache 被释放掉会影响业务性能,所以就期望只去 drop slab 而不去 drop pagecache。于是很多人这个时候就运行 echo 2 > /proc/sys/vm/drop_caches,但是结果却出乎了他们的意料:Page Cache 也被释放掉了,业务性能产生了明显的下降。
查看 drop_caches 是否执行过释放:
1 | $ grep drop /proc/vmstat |
在内存紧张的时候会触发内存回收,内存回收会尝试去回收 reclaimable(可以被回收的)内存,这部分内存既包含 Page Cache 又包含 reclaimable kernel memory(比如 slab)。inode被回收后可以通过 grep inodesteal /proc/vmstat 观察到
kswapd_inodesteal 是指在 kswapd 回收的过程中,因为回收 inode 而释放的 pagecache page 个数;pginodesteal 是指 kswapd 之外其他线程在回收过程中,因为回收 inode 而释放的 pagecache page 个数;
内存分配
内存不够、脏页太多、碎片太多,都会导致分配失败,从而触发回收,导致卡顿。
系统中脏页过多引起 load 飙高
直接回收过程中,如果存在较多脏页就可能涉及在回收过程中进行回写,这可能会造成非常大的延迟,而且因为这个过程本身是阻塞式的,所以又可能进一步导致系统中处于 D 状态的进程数增多,最终的表现就是系统的 load 值很高。
可以通过 sar -r 来观察系统中的脏页个数:
1 | $ sar -r 1 |
kbdirty 就是系统中的脏页大小,它同样也是对 /proc/vmstat 中 nr_dirty 的解析。你可以通过调小如下设置来将系统脏页个数控制在一个合理范围:
vm.dirty_background_bytes = 0
vm.dirty_background_ratio = 10
vm.dirty_bytes = 0
vm.dirty_expire_centisecs = 3000
vm.dirty_ratio = 20
至于这些值调整大多少比较合适,也是因系统和业务的不同而异,我的建议也是一边调整一边观察,将这些值调整到业务可以容忍的程度就可以了,即在调整后需要观察业务的服务质量 (SLA),要确保 SLA 在可接受范围内。调整的效果你可以通过 /proc/vmstat 来查看:
1 | #grep "nr_dirty_" /proc/vmstat |
在4.20的内核并且sar 的版本为12.3.3可以看到PSI(Pressure-Stall Information)
1 | some avg10=45.49 avg60=10.23 avg300=5.41 total=76464318 |
你需要重点关注 avg10 这一列,它表示最近 10s 内存的平均压力情况,如果它很大(比如大于 40)那 load 飙高大概率是由于内存压力,尤其是 Page Cache 的压力引起的。
碎片化
内存碎片严重的话会导致系统hang很久(回收、压缩内存)
尽量让系统的free多一点(比例高一点)可以调整 vm.min_free_kbytes(128G 以内 2G,256G以内 4G/8G), 线上机器直接修改vm.min_free_kbytes会触发回收,导致系统hang住 https://www.atatech.org/articles/163233 https://www.atatech.org/articles/97130
每个zone都有自己的min low high,如下,但是单位是page, 计算案例:
1 | [root@jiangyi01.sqa.zmf /home/ahao.mah] |
内存碎片化导致rt升高的诊断
判定方法如下:
- 运行 sar -B 观察 pgscand/s,其含义为每秒发生的直接内存回收次数,当在一段时间内持续大于 0 时,则应继续执行后续步骤进行排查;
- 运行
cat /sys/kernel/debug/extfrag/extfrag_index
观察内存碎片指数,重点关注 order >= 3 的碎片指数,当接近 1.000 时,表示碎片化严重,当接近 0 时表示内存不足; - 运行
cat /proc/buddyinfo, cat /proc/pagetypeinfo
查看内存碎片情况, 指标含义参考 (https://man7.org/linux/man-pages/man5/proc.5.html),同样关注 order >= 3 的剩余页面数量,pagetypeinfo 相比 buddyinfo 展示的信息更详细一些,根据迁移类型 (伙伴系统通过迁移类型实现反碎片化)进行分组,需要注意的是,当迁移类型为 Unmovable 的页面都聚集在 order < 3 时,说明内核 slab 碎片化严重,我们需要结合其他工具来排查具体原因,在本文就不做过多介绍了; - 对于 CentOS 7.6 等支持 BPF 的 kernel 也可以运行我们研发的 drsnoop,compactsnoop 工具对延迟进行定量分析,使用方法和解读方式请参考对应文档;
- (Opt) 使用 ftrace 抓取 mm_page_alloc_extfrag 事件,观察因内存碎片从备用迁移类型“盗取”页面的信息。
一个阿里云ECS 因为宿主机碎片导致性能衰退的案例
LVS后面三个RS在同样压力流量下,其中一个节点CPU非常高,通过top看起来是所有操作都很慢,像是CPU被降频了一样,但是直接跑CPU Prime性能又没有问题
原因:ECS所在的宿主机内存碎片比较严重,导致分配到的内存主要是4K Page,在ECS中大页场景下会慢很多
通过 openssl speed aes-256-ige 能稳定重现 在大块的加密上慢很多
小块上性能一致,这也就是为什么算Prime性能没问题。导致慢只涉及到大块内存分配的场景,这里需要映射到宿主机,但是碎片多分配慢导致了问题。
如果reboot ECS的话实际只是就地重启ECS,仍然使用的reboot前分配好的宿主机内存,不会解决问题。重启ECS中的进程也不会解决问题,只有将ECS迁移到别的物理机(也就是通过控制台重启,会重新选择物理机)才有可能解决这个问题。
或者购买新的ECS机型(比如第6代之后ECS)能避免这个问题。
ECS内部没法查看到这个碎片,只能在宿主机上通过命令查看大页情况:
1 | 第二台有问题NC |
内存使用分析
pmap
1 | pmap -x 24282 | less |
- Address:占用内存的文件的内存起始地址。
- Kbytes:占用内存的字节数。
- RSS:实际占用内存大小。
- Dirty:脏页大小。
- Mapping:占用内存的文件,[anon] 为已分配的内存,[stack] 为程序堆栈
/proc/pid/
/proc/[pid]/
下面与进程内存相关的文件主要有maps , smaps, status
。
maps: 文件可以查看某个进程的代码段、栈区、堆区、动态库、内核区对应的虚拟地址
smaps: 显示每个分区更详细的内存占用数据
status: 包含了所有CPU活跃的信息,该文件中的所有值都是从系统启动开始累计到当前时刻
参考资料
https://www.atatech.org/articles/66885
https://cloud.tencent.com/developer/article/1087455
https://www.cnblogs.com/xiaolincoding/p/13719610.html
https://sunsea.im/rsyslogd-systemd-journald-high-memory-solution.html
Linux内存--HugePage
Linux内存–HugePage
/proc/buddyinfo
/proc/buddyinfo记录了内存的详细碎片情况。
1 | #cat /proc/buddyinfo |
Normal行的第二列表示: 643847*2^1*Page_Size(4K) ; 第三列表示: 357451*2^2*Page_Size(4K) ,高阶内存指的是2^3及更大的内存块。
应用申请大块连续内存(高阶内存,一般之4阶及以上, 也就是64K以上–2^4*4K)时,容易导致卡顿。这是因为大块连续内存确实系统需要触发回收或者碎片整理,需要一定的时间。
slabtop和/proc/slabinfo
slabtop和/proc/slabinfo 查看cached使用情况 主要是:pagecache(页面缓存), dentries(目录缓存), inodes
消失的内存
OS刚启动后就报内存不够了,什么都没跑就500G没了,cached和buffer基本没用,纯粹就是used占用高,top按内存排序没有超过0.5%的进程
参考: https://cloud.tencent.com/developer/article/1087455
1 | [aliyun@uos15 18:40 /u02/backup_15/leo/benchmark/run] |
关于hugetlb
This is an entry in the TLB that points to a HugePage (a large/big page larger than regular 4K and predefined in size). HugePages are implemented via hugetlb entries, i.e. we can say that a HugePage is handled by a “hugetlb page entry”. The ‘hugetlb” term is also (and mostly) used synonymously with a HugePage (See Note 261889.1). In this document the term “HugePage” is going to be used but keep in mind that mostly “hugetlb” refers to the same concept.
hugetlb 是TLB中指向HugePage的一个entry(通常大于4k或预定义页面大小)。 HugePage 通过hugetlb entries来实现,也可以理解为HugePage 是hugetlb page entry的一个句柄。
Linux下的大页分为两种类型:标准大页(Huge Pages)和透明大页(Transparent Huge Pages)
标准大页管理是预分配的方式,而透明大页管理则是动态分配的方式
目前透明大页与传统HugePages联用会出现一些问题,导致性能问题和系统重启。Oracle 建议禁用透明大页(Transparent Huge Pages)
hugetlbfs比THP要好,开thp的机器碎片化严重(不开THP会有更严重的碎片化问题),最后和没开THP一样 https://www.atatech.org/articles/152660
Linux 中的 HugePages 都被锁定在内存中,所以哪怕是在系统内存不足时,它们也不会被 Swap 到磁盘上,这也就能从根源上杜绝了重要内存被频繁换入和换出的可能。
虽然 HugePages 的开启大都需要开发或者运维工程师的额外配置,但是在应用程序中启用 HugePages 却可以在以下几个方面降低内存页面的管理开销:
- 更大的内存页能够减少内存中的页表层级,这不仅可以降低页表的内存占用,也能降低从虚拟内存到物理内存转换的性能损耗;
- 更大的内存页意味着更高的缓存命中率,CPU 有更高的几率可以直接在 TLB(Translation lookaside buffer)中获取对应的物理地址;
- 更大的内存页可以减少获取大内存的次数,使用 HugePages 每次可以获取 2MB 的内存,是 4KB 的默认页效率的 512 倍;
THP
Linux kernel在2.6.38内核增加了Transparent Huge Pages (THP)特性 ,支持大内存页(2MB)分配,默认开启。当开启时可以降低fork子进程的速度,但fork之后,每个内存页从原来4KB变为2MB,会大幅增加重写期间父进程内存消耗。同时每次写命令引起的复制内存页单位放大了512倍,会拖慢写操作的执行时间,导致大量写操作慢查询。例如简单的incr命令也会出现在慢查询中。因此Redis日志中建议将此特性进行禁用。
THP 的目的是用一个页表项来映射更大的内存(大页),这样可以减少 Page Fault,因为需要的页数少了。当然,这也会提升 TLB(Translation Lookaside Buffer,由存储器管理单元用于改进虚拟地址到物理地址的转译速度) 命中率,因为需要的页表项也少了。如果进程要访问的数据都在这个大页中,那么这个大页就会很热,会被缓存在 Cache 中。而大页对应的页表项也会出现在 TLB 中,从上一讲的存储层次我们可以知道,这有助于性能提升。但是反过来,假设应用程序的数据局部性比较差,它在短时间内要访问的数据很随机地位于不同的大页上,那么大页的优势就会消失。
THP 对redis、monglodb 这种cache类推荐关闭,对drds这种java应用最好打开
1 | grep "Huge" /proc/meminfo |
在透明大页功能打开时,造成系统性能下降的主要原因可能是 khugepaged
守护进程。该进程会在(它认为)系统空闲时启动,扫描系统中剩余的空闲内存,并将普通 4k 页转换为大页。该操作会在内存路径中加锁,而该守护进程可能会在错误的时间启动扫描和转换大页的操作,从而影响应用性能。
此外,当缺页异常(page faults)增多时,透明大页会和普通 4k 页一样,产生同步内存压缩(direct compaction)操作,以节省内存。该操作是一个同步的内存整理操作,如果应用程序会短时间分配大量内存,内存压缩操作很可能会被触发,从而会对系统性能造成风险。https://yq.aliyun.com/articles/712830
1 | #查看系统级别的 THP 使用情况,执行下列命令: |
/proc/sys/vm/nr_hugepages
中存储的数据就是大页面的数量,虽然在默认情况下它的值都是 0,不过我们可以通过更改该文件的内容申请或者释放操作系统中的大页:
1 | $ echo 1 > /proc/sys/vm/nr_hugepages |
碎片化
内存碎片严重的话会导致系统hang很久(回收、压缩内存)
尽量让系统的free多一点(比例高一点)可以调整 vm.min_free_kbytes(128G 以内 2G,256G以内 4G/8G), 线上机器直接修改vm.min_free_kbytes会触发回收,导致系统hang住 https://www.atatech.org/articles/163233 https://www.atatech.org/articles/97130
每个zone都有自己的min low high,如下,但是单位是page, 计算案例:
1 | [root@jiangyi01.sqa.zmf /home/ahao.mah] |
定制内存
物理内存700多G,要求OS只能用512G:
1 | 24条32G的内存条,总内存768G |
内存碎片化导致rt升高的诊断
判定方法如下:
- 运行 sar -B 观察 pgscand/s,其含义为每秒发生的直接内存回收次数,当在一段时间内持续大于 0 时,则应继续执行后续步骤进行排查;
- 运行
cat /sys/kernel/debug/extfrag/extfrag_index
观察内存碎片指数,重点关注 order >= 3 的碎片指数,当接近 1.000 时,表示碎片化严重,当接近 0 时表示内存不足; - 运行
cat /proc/buddyinfo, cat /proc/pagetypeinfo
查看内存碎片情况, 指标含义参考 (https://man7.org/linux/man-pages/man5/proc.5.html),同样关注 order >= 3 的剩余页面数量,pagetypeinfo 相比 buddyinfo 展示的信息更详细一些,根据迁移类型 (伙伴系统通过迁移类型实现反碎片化)进行分组,需要注意的是,当迁移类型为 Unmovable 的页面都聚集在 order < 3 时,说明内核 slab 碎片化严重,我们需要结合其他工具来排查具体原因,在本文就不做过多介绍了; - 对于 CentOS 7.6 等支持 BPF 的 kernel 也可以运行我们研发的 drsnoop,compactsnoop 工具对延迟进行定量分析,使用方法和解读方式请参考对应文档;
- (Opt) 使用 ftrace 抓取 mm_page_alloc_extfrag 事件,观察因内存碎片从备用迁移类型“盗取”页面的信息。
参考资料
https://www.atatech.org/articles/66885
Linux内存--零拷贝
Linux内存–零拷贝
零拷贝
“Zero-copy“ describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.
零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。
零拷贝可以做到用户空间和内核空间共用同一块内存(Java中的DirectBuffer),这样少做一次拷贝。普通Buffer是在JVM堆上分配的内存,而DirectBuffer是堆外分配的(内核和JVM可以同时读写),这样不需要再多一次内核到用户Buffer的拷贝
比如通过网络下载文件,普通拷贝的流程会复制4次并有4次上下文切换,上下文切换是因为读写慢导致了IO的阻塞,进而线程被内核挂起,所以发生了上下文切换。在极端情况下如果read/write没有导致阻塞是不会发生上下文切换的:
改成零拷贝后,也就是将read和write合并成一次,直接在内核中完成磁盘到网卡的数据复制
零拷贝就是操作系统提供的新函数(sendfile),同时接收文件描述符和 TCP socket 作为输入参数,这样执行时就可以完全在内核态完成内存拷贝,既减少了内存拷贝次数,也降低了上下文切换次数。
而且,零拷贝取消了用户缓冲区后,不只降低了用户内存的消耗,还通过最大化利用 socket 缓冲区中的内存,间接地再一次减少了系统调用的次数,从而带来了大幅减少上下文切换次数的机会!
应用读取磁盘写入网络的时候还得考虑缓存的大小,一般会设置的比较小,这样一个大文件导致多次小批量的读取,每次读取伴随着多次上下文切换。
零拷贝使我们不必关心 socket 缓冲区的大小(socket缓冲区大小本身默认就是动态调整、或者应用代码指定大小)。比如,调用零拷贝发送方法时,尽可以把发送字节数设为文件的所有未发送字节数,例如 320MB,也许此时 socket 缓冲区大小为 1.4MB,那么一次性就会发送 1.4MB 到客户端,而不是只有 32KB。这意味着对于 1.4MB 的 1 次零拷贝,仅带来 2 次上下文切换,而不使用零拷贝且用户缓冲区为 32KB 时,经历了 176 次(4 * 1.4MB/32KB)上下文切换。
read+write 和零拷贝
1 | read(file, tmp_buf, len); |
通过mmap替换read优化一下
用 mmap()
替换原先的 read()
,mmap()
也即是内存映射(memory map):把用户进程空间的一段内存缓冲区(user buffer)映射到文件所在的内核缓冲区(kernel buffer)上。
通过使用 mmap()
来代替 read()
, 可以减少一次数据拷贝的过程。
但这还不是最理想的零拷贝,因为首先仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次;另外内存映射技术是一个开销很大的虚拟存储操作:这种操作需要修改页表以及用内核缓冲区里的文件数据汰换掉当前 TLB 里的缓存以维持虚拟内存映射的一致性。
sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()
,函数形式如下:
1 | #include <sys/socket.h> |
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以替代前面的 read()
和 write()
这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
当然这里还是有一次CPU来拷贝内存的过程,仍然有文件截断的问题。sendfile()
依然是一个适用性很窄的技术,最适合的场景基本也就是一个静态文件服务器了。
然而 sendfile()
本身是有很大问题的,从不同的角度来看的话主要是:
- 首先一个是这个接口并没有进行标准化,导致
sendfile()
在 Linux 上的接口实现和其他类 Unix 系统的实现并不相同; - 其次由于网络传输的异步性,很难在接收端实现和
sendfile()
对接的技术,因此接收端一直没有实现对应的这种技术; - 最后从性能方面考量,因为
sendfile()
在把磁盘文件从内核缓冲区(page cache)传输到到套接字缓冲区的过程中依然需要 CPU 参与,这就很难避免 CPU 的高速缓存被传输的数据所污染。
SG-DMA(The Scatter-Gather Direct Memory Access)技术
上一小节介绍的 sendfile()
技术已经把一次数据读写过程中的 CPU 拷贝的降低至只有 1 次了,但是人永远是贪心和不知足的,现在如果想要把这仅有的一次 CPU 拷贝也去除掉,有没有办法呢?
当然有!通过引入一个新硬件上的支持,我们可以把这个仅剩的一次 CPU 拷贝也给抹掉:Linux 在内核 2.4 版本里引入了 DMA 的 scatter/gather – 分散/收集功能,并修改了 sendfile()
的代码使之和 DMA 适配。scatter 使得 DMA 拷贝可以不再需要把数据存储在一片连续的内存空间上,而是允许离散存储,gather 则能够让 DMA 控制器根据少量的元信息:一个包含了内存地址和数据大小的缓冲区描述符,收集存储在各处的数据,最终还原成一个完整的网络包,直接拷贝到网卡而非套接字缓冲区,避免了最后一次的 CPU 拷贝:
如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。 数据传输过程就再也没有 CPU 的参与了,也因此 CPU 的高速缓存再不会被污染了,也不再需要 CPU 来计算数据校验和了,CPU 可以去执行其他的业务计算任务,同时和 DMA 的 I/O 任务并行,此举能极大地提升系统性能。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
splice()
sendfile()
+ DMA Scatter/Gather 的零拷贝方案虽然高效,但是也有两个缺点:
- 这种方案需要引入新的硬件支持;
- 虽然
sendfile()
的输出文件描述符在 Linux kernel 2.6.33 版本之后已经可以支持任意类型的文件描述符,但是输入文件描述符依然只能指向文件。
这两个缺点限制了 sendfile()
+ DMA Scatter/Gather 方案的适用场景。为此,Linux 在 2.6.17 版本引入了一个新的系统调用 splice()
,它在功能上和 sendfile()
非常相似,但是能够实现在任意类型的两个文件描述符时之间传输数据;而在底层实现上,splice()
又比 sendfile()
少了一次 CPU 拷贝,也就是等同于 sendfile()
+ DMA Scatter/Gather,完全去除了数据传输过程中的 CPU 拷贝。
splice()
所谓的写入数据到管道其实并没有真正地拷贝数据,而是玩了个 tricky 的操作:只进行内存地址指针的拷贝而不真正去拷贝数据。所以,数据 splice()
在内核中并没有进行真正的数据拷贝,因此 splice()
系统调用也是零拷贝。
还有一点需要注意,前面说过管道的容量是 16 个内存页,也就是 16 * 4KB = 64 KB,也就是说一次往管道里写数据的时候最好不要超过 64 KB,否则的话会 splice()
会阻塞住,除非在创建管道的时候使用的是 pipe2()
并通过传入 O_NONBLOCK
属性将管道设置为非阻塞。
send() with MSG_ZEROCOPY
Linux 内核在 2017 年的 v4.14 版本接受了来自 Google 工程师 Willem de Bruijn 在 TCP 网络报文的通用发送接口 send()
中实现的 zero-copy 功能 (MSG_ZEROCOPY) 的 patch,通过这个新功能,用户进程就能够把用户缓冲区的数据通过零拷贝的方式经过内核空间发送到网络套接字中去,这个新技术和前文介绍的几种零拷贝方式相比更加先进,因为前面几种零拷贝技术都是要求用户进程不能处理加工数据而是直接转发到目标文件描述符中去的。Willem de Bruijn 在他的论文里给出的压测数据是:采用 netperf 大包发送测试,性能提升 39%,而线上环境的数据发送性能则提升了 5%~8%,官方文档陈述说这个特性通常只在发送 10KB 左右大包的场景下才会有显著的性能提升。一开始这个特性只支持 TCP,到内核 v5.0 版本之后才支持 UDP。
这个技术是基于 redhat 红帽在 2010 年给 Linux 内核提交的 virtio-net zero-copy 技术之上实现的,至于底层原理,简单来说就是通过 send()
把数据在用户缓冲区中的分段指针发送到 socket 中去,利用 page pinning 页锁定机制锁住用户缓冲区的内存页,然后利用 DMA 直接在用户缓冲区通过内存地址指针进行数据读取,实现零拷贝
目前来说,这种技术的主要缺陷有:
- 只适用于大文件 (10KB 左右) 的场景,小文件场景因为 page pinning 页锁定和等待缓冲区释放的通知消息这些机制,甚至可能比直接 CPU 拷贝更耗时;
- 因为可能异步发送数据,需要额外调用
poll()
和recvmsg()
系统调用等待 buffer 被释放的通知消息,增加代码复杂度,以及会导致多次用户态和内核态的上下文切换; - MSG_ZEROCOPY 目前只支持发送端,接收端暂不支持。
零拷贝应用
kafaka就利用了「零拷贝」技术,从而大幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之一。
如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 transferTo
方法:
1 | @Overridepublic |
如果 Linux 系统支持 sendfile()
系统调用,那么 transferTo()
实际上最后就会使用到 sendfile()
系统调用函数。
Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下:
1 | http { |
sendfile 配置的具体意思:
- 设置为 on 表示,使用零拷贝技术来传输文件:sendfile ,这样只需要 2 次上下文切换,和 2 次数据拷贝。
- 设置为 off 表示,使用传统的文件传输技术:read + write,这时就需要 4 次上下文切换,和 4 次数据拷贝。
如果是大文件很容易消耗非常多的PageCache,不推荐使用PageCache(或者说零拷贝),建议使用异步IO+直接IO。
在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:
1 | location /video/ { |
当文件大小大于 directio
值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。
DMA
什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
RDMA
参考资料
https://www.atatech.org/articles/66885
https://cloud.tencent.com/developer/article/1087455
https://www.cnblogs.com/xiaolincoding/p/13719610.html
https://mp.weixin.qq.com/s/dZNjq05q9jMFYhJrjae_LA 从Linux内存管理到零拷贝
如何在工作中学习--V2.0
如何在工作中学习–V2.0
本文被网友翻译的英文版 (medium 需要梯子)
先说一件值得思考的事情:高考的时候大家都是一样的教科书,同一个教室,同样的老师辅导,时间精力基本差不多,可是最后别人考的是清华北大或者一本,而你的实力只能考个三本,为什么? 当然这里主要是智商的影响,那么其他因素呢?智商解决的问题能不能后天用其他方式来补位一下?
大家平时都看过很多方法论的文章,看的时候很爽觉得非常有用,但是一两周后基本还是老样子了。其中有很大一部分原因那些方法对脑力有要求、或者方法论比较空缺少落地的步骤。 下文中描述的方式方法是不需要智商也能学会的,非常具体的。
关键问题点
为什么你的知识积累不了?
有些知识看过就忘、忘了再看,实际碰到问题还是联系不上这个知识,这其实是知识的积累出了问题,没有深入的理解自然就不能灵活运用,也就谈不上解决问题了。这跟大家一起看相同的高考教科书但是高考结果不一样是一个原因。问题出在了理解上,每个人的理解能力不一样(智商),绝大多数人对知识的理解要靠不断地实践(做题)来巩固。
同样实践效果不一样?
同样工作一年碰到了10个问题(或者说做了10套高考模拟试卷),但是结果不一样,那是因为在实践过程中方法不够好。或者说你对你为什么做对了、为什么做错了没有去复盘
假如碰到一个问题,身边的同事解决了,而我解决不了。那么我就去想这个问题他是怎么解决的,他看到这个问题后的逻辑和思考是怎么样的,有哪些知识指导了他这么逻辑推理,这些知识哪些我也知道但是我没有想到这么去运用推理(说明我对这个知识理解的不到位导致灵活运用缺乏);这些知识中又有哪些是我不知道的(知识缺乏,没什么好说的快去Google什么学习下–有场景案例和目的加持,学习理解起来更快)。
等你把这个问题基本按照你同事掌握的知识和逻辑推理想明白后,需要再去琢磨一下他的逻辑推理解题思路中有没有不对的,有没有啰嗦的地方,有没有更直接的方式(对知识更好地运用)。
我相信每个问题都这么去实践的话就不应该再抱怨灵活运用、举一反三,同时知识也积累下来了,这种场景下积累到的知识是不会那么容易忘记的。
这就是向身边的牛人学习,同时很快超过他的办法。这就是为什么高考前你做了10套模拟题还不如其他人做一套的效果好
知识+逻辑 基本等于你的能力,知识让你知道那个东西,逻辑让你把东西和问题联系起来
这里的问题你可以理解成方案、架构、设计等
系统化的知识哪里来?
知识之间是可以联系起来的并且像一颗大树一样自我生长,但是当你都没理解透彻,自然没法产生联系,也就不能够自我生长了。
真正掌握好的知识点会慢慢生长连接最终组成一张大网
但是我们最容易陷入的就是掌握的深度、系统化(工作中碎片时间过多,学校里缺少时间)不够,所以一个知识点每次碰到花半个小时学习下来觉得掌握了,但是3个月后就又没印象了。总是感觉自己在懵懵懂懂中,或者一个领域学起来总是不得要领,根本的原因还是在于:宏观整体大图了解不够(缺乏体系,每次都是盲人摸象);关键知识点深度不够,理解不透彻,这些关键点就是这个领域的骨架、支点、抓手。缺了抓手自然不能生长,缺了宏观大图容易误入歧途。
我们有时候发现自己在某个领域学起来特别快,但是换个领域就总是不得要领,问题出在了上面,即使花再多时间也是徒然。这也就是为什么学霸看两个小时的课本比你看两天效果还好,感受下来还觉得别人好聪明,是不是智商比我高啊。
所以新进入一个领域的时候要去找他的大图和抓手。
好的同事总是能很轻易地把这个大图交给你,再顺便给你几个抓手,你就基本入门了,这就是培训的魅力,这种情况肯定比自学效率高多了。但是目前绝大部分的培训都做不到这点
好的逻辑又怎么来?
实践、复盘
讲个前同事的故事
有一个前同事是5Q过来的,负责技术(所有解决不了的问题都找他),这位同学从chinaren出道,跟着王兴一块创业5Q,5Q在学校靠鸡腿打下大片市场,最后被陈一舟的校内收购(据说被收购后5Q的好多技术都走了,最后王兴硬是呆在校内网把合约上的所有钱都拿到了)。这位同学让我最佩服的解决问题的能力,好多问题其实他也不一定就擅长,但是他就是有本事通过Help、Google不停地验证尝试就把一个不熟悉的问题给解决了,这是我最羡慕的能力,在后面的职业生涯中一直不停地往这个方面尝试。
应用刚启动连接到数据库的时候比较慢,但又不是慢查询
- 这位同学的解决办法是通过tcpdump来分析网络包,看网络包的时间戳和网络包的内容,然后找到了具体卡在了哪里。
- 如果是专业的DBA可能会通过show processlist 看具体连接在做什么,比如看到这些连接状态是 authentication 状态,然后再通过Google或者对这个状态的理解知道创建连接的时候MySQL需要反查IP、域名这里比较耗时,通过配置参数 skip-name-resolve 跳过去就好了。
如果是MySQL的老司机,一上来就知道连接慢的话跟 skip-name-resolve 关系最大。
在我眼里这三种方式都解决了问题,最后一种最快但是纯靠积累和经验,换个问题也许就不灵了;第一种方式是最牛逼和通用的,只需要最少的知识就把问题解决了。
我当时跟着他从sudo、ls等linux命令开始学起。当然我不会轻易去打搅他问他,每次碰到问题我尽量让他在我的电脑上来操作,解决后我再自己复盘,通过history调出他的所有操作记录,看他在我的电脑上用Google搜啥了,然后一个个去学习分析他每个动作,去想他为什么搜这个关键字,复盘完还有不懂的再到他面前跟他面对面的讨论他为什么要这么做,指导他这么做的知识和逻辑又是什么。
有哪些好的行为帮你更好地掌握知识
笔记+写博客
看东西的时候要做笔记,要不当时看得再爽也很容易忘记,我们需要反复复习来加深印象和理解,复习的根据就是笔记(不可能再完整又看一次),笔记整理出里面的要点和你的盲点。
一段时间后把相关的笔记整理成一篇体系性的博客文章,这样既加深了理解又系统化了相关知识。以后再看到跟这篇博客相关的案例、知识点时不断地更新博客(完善你的知识点)
场景式学习、体感的来源、面对问题学习
前面提到的对知识的深入理解这有点空,如何才能做到深入理解?
举个学习TCP三次握手例子
经历稍微丰富点的工程师都觉得TCP三次握手看过很多次、很多篇文章了,但是文章写得再好似乎当时理解了,但是总是过几个月就忘了或者一看就懂,过一阵子被人一问就模模糊糊了,或者两个为什么就答不上了,自己都觉得自己的回答是在猜或者不确定
为什么会这样呢?而学其它知识就好通畅多了,我觉得这里最主要的是我们对TCP缺乏体感,比如没有几个工程师去看过TCP握手的代码,也没法想象真正的TCP握手是如何在电脑里运作的(打电话能给你一些类似的体感,但是细节覆盖面不够)。
如果这个时候你一边学习的时候一边再用wireshark抓包看看三次握手具体在干什么,比抽象的描述实在多了,你能看到具体握手的一来一回,并且看到一来一回带了哪些内容,这些内容又是用来做什么、为什么要带,这个时候你再去看别人讲解的理论顿时会觉得好理解多了,以后也很难忘记。
但是这里很多人执行能力不强,想去抓包,但是觉得要下载安装wireshark,要学习wireshark就放弃了。只看不动手当然是最舒适的,但是这个最舒适给了你在学习的假象,没有结果。
这是不是跟你要解决一个难题非常像,这个难题需要你去做很多事,比如下载源代码(翻不了墙,放弃);比如要编译(还要去学习那些编译参数,放弃);比如要搭建环境(太琐屑,放弃)。你看这中间九九八十一难你放弃了一难都取不了真经。这也是为什么同样学习、同样的问题,他能学会,他能解决,你不可以。
再来看一个解决问题的例子
会员系统双11优化这个问题对我来说,我是个外来者,完全不懂这里面的部署架构、业务逻辑。但是在问题的关键地方(会员认为自己没问题–压力测试正常的;淘宝API更是认为自己没问题,alimonitor监控显示正常),结果就是会员的同学说我们没有问题,淘宝API肯定有问题,然后就不去思考自己这边可能出问题的环节了。思想上已经甩包了,那么即使再去review流程、环节也就不会那么仔细,自然更是发现不了问题了。
但是我的经验告诉我要有证据地甩包,或者说拿着证据优雅地甩包,这迫使我去找更多的细节证据(证据要给力哦,不能让人家拍回来)。如果我是这么说的,这个问题在淘宝API这里,你看理由是…………,我做了这些实验,看到了这些东东。那么淘宝API那边想要证明我的理由错了就会更积极地去找一些数据。
事实上我就是做这些实验找证据过程中发现了会员的问题,这就是态度、执行力、知识、逻辑能力综合下来拿到的一个结果。我最不喜欢的一句话就是我的程序没问题,因为我的逻辑是这样的,不会错的。你当然不会写你知道的错误逻辑,程序之所以有错误都是在你的逻辑、意料之外的东西。有很多次一堆人电话会议中扯皮的时候,我一般把电话静音了,直接上去人肉一个个过对方的逻辑,一般来说电话会议还没有结束我就给出来对方逻辑之外的东西。
钉子式学习方法和系统性学习方法
系统性学习方法就是想掌握MySQL,那么搞几本MySQL专著和MySQL 官方DOC看下来,一般课程设计的好的话还是比较容易掌握下来,绝大部分时候都是这种学习方法,可是在种学习方法的问题在于学完后当时看着似乎理解了,但是很容易忘记,一片一片地系统性的忘记,并且缺少应用能力(理解不深)。这是因为一般人对知识的理解没那么容易真正理解(掌握或者说应用)。
钉子式的学习方式,就是在一大片知识中打入几个桩,反复演练将这个桩不停地夯实,夯稳,做到在这个知识点上用通俗的语言跟小白都能讲明白,然后再这几个桩中间发散像星星之火燎原一样把整个一片知识都掌握下来。这种学习方法的缺点就是很难找到一片知识点的这个点,然后没有很好整合的话知识过于零散。
钉子式学习方法看着慢但是因为这样掌握的更透彻和牢固实际最终反而快。
我们常说的一个人很聪明,就是指系统性的看看书就都理解了,是真的理解那种,还能灵活运用,但是大多数普通人就不是这样的,看完书似乎理解了,实际几周后基本都忘记了,真正实践需要用的时候还是用不好。
举个Open-SSH的例子
为了做通 SSH 的免密登陆,大家都需要用到 ssh-keygen/ssh-copy-id, 如果我们把这两个命令当一个小的钉子的话,会去了解ssh-keygen做了啥(生成了密钥对),或者ssh-copy-id 的时候报错了(原来是需要秘钥对),然后将 ssh-keygen 生成的pub key复制到server的~/.ssh/authorized_keys 中。
然后你应该会对这个原理要有一些理解(更大的钉子),于是理解了密钥对,和ssh验证的流程,顺便学会怎么看ssh debug信息,那么接下来网络上各种ssh攻略、各种ssh卡顿的解决都是很简单的事情了。
比如你通过SSH可以解决这些问题:
- 免密登陆
- ssh卡顿
- 怎么去掉ssh的时候需要手工多输入yes
- 我的ssh怎么很快就断掉了
- 我怎么样才能一次通过跳板机ssh到目标机器
- 我怎么样通过ssh科学上网
- 我的ansible(底层批量命令都是基于ssh)怎么这么多问题,到底是为什么
- 我的git怎么报网络错误了
- X11 forward我怎么配置不好
- https为什么需要随机数加密,还需要签名
- …………
这些问题都是一步步在扩大ssh的外延,让这个钉子变成一个巨大的桩。
然后就会学习到一些高级一些的ssh配置,比如干掉经常ssh的时候要yes一下(StrictHostKeyChecking=no), 或者怎么配置一下ssh就不会断线了(ServerAliveInterval=15),或者将 ssh跳板机->ssh server的过程做成 ssh server一步就可以了(ProxyCommand),进而发现用 ssh的ProxyCommand很容易科学上网了,或者git有问题的时候轻而易举地把ssh debug打开,对git进行debug了……
这基本都还是ssh的本质范围,像ansible、git在底层都是依赖ssh来通讯的,你会发现学、调试X11、ansible和git简直太容易了。
另外理解了ssh的秘钥对,也就理解了非对称加密,同时也很容易理解https流程(SSL),同时知道对称和非对称加密各自的优缺点,SSL为什么需要用到这两种加密算法了。
你看一个简单日常的知识我们只要沿着它用钉子精神,深挖细挖你就会发现知识之间的连接,这个小小的知识点成为你知识体系的一根结实的柱子。
我见过太多的老的工程师、年轻的工程师,天天在那里ssh 密码,ssh 跳板机,ssh 目标机,一小会ssh断了,重来一遍;或者ssh后卡住了,等吧……
在这个问题上表现得没有求知欲、没有探索精神、没有一次把问题搞定的魄力,所以就习惯了
空洞的口号
很多老师和文章都会教大家:举一反三、灵活运用、活学活用、多做多练。但是只有这些口号是没法落地的,落地的基本步骤就是前面提到的,却总是被忽视了。
什么是工程效率,什么是知识效率
有些人纯看理论就能掌握好一门技能,还能举一反三,这是知识效率,这种人非常少;
大多数普通人都是看点知识然后结合实践来强化理论,要经过反反复复才能比较好地掌握一个知识,这就是工程效率,讲究技巧、工具来达到目的。
肯定知识效率最牛逼,但是拥有这种技能的人毕竟非常少(天生的高智商吧)。从小我们周边那种不怎么学的学霸型基本都是这类,这种学霸都还能触类旁通非常快的掌握一个新知识,非常气人。剩下的绝大部分只能拼时间+方法+总结等也能掌握一些知识
非常遗憾我就是工程效率型,只能羡慕那些知识效率型的学霸。但是这事又不能独立看待有些人在某些方向上是工程效率型,有些方向就又是知识效率型(有一种知识效率型是你掌握的实在太多也就比较容易触类旁通了,这算灰色知识效率型)
使劲挖掘自己在知识效率型方面的能力吧,两者之间当然没有明显的界限,知识积累多了逻辑训练好了在别人看来你的智商就高了
知识分两种
一种是通用知识(不是说对所有人通用,而是说在一个专业领域去到哪个公司都能通用);另外一种是跟业务公司绑定的特定知识
通用知识没有任何疑问碰到后要非常饥渴地扑上去掌握他们(受益终生,这还有什么疑问吗?)。对于特定知识就要看你对业务需要掌握的深度了,肯定也是需要掌握一些的,特定知识掌握好的一般在公司里混的也会比较好。
一个具体知识体系里面又有一些核心知识点(抓手、essential knowledge),也就是掌握可以快速帮你膨胀、延伸到其他相关知识的知识点。
还有一些知识、工具一旦掌握就能帮你贯穿、具象、理解别的知识点,比如网络知识体系中的wireshark;理工科中的数学;知识体系中的学习方法、行为方式。我们要多去发现这些知识、工具(how?)
举三反一--从理论知识到实际问题的推导
举三反一–从理论知识到实际问题的推导
怎么样才能获取举三反一的秘籍, 普通人为什么要案例来深化对理论知识的理解。
什么是工程效率,什么是知识效率
有些人纯看理论就能掌握好一门技能,还能举三反一,这是知识效率,这种人非常少;
大多数普通人都是看点知识然后结合实践来强化理解理论,要经过反反复复才能比较好地掌握一个知识,这就是工程效率,讲究技巧、工具来达到目的。
对于费曼(参考费曼学习法)这样的聪明人就是很容易看到一个理论知识就能理解这个理论知识背后的本质。
肯定知识效率最牛逼,但是拥有这种能力的人毕竟非常少。从小我们周边那种不怎么学的学霸型基本都是这类,这种学霸都还能触类旁通非常快地掌握一个新知识。剩下的绝大部分只能拼时间(刷题)+方法+总结等也能掌握一些知识
但是这事又不能独立看待有些人在某些方向上是工程效率型,有些方向就又是知识效率型(有一种知识效率型是你掌握的实在太多也就比较容易触类旁通了,这算灰色知识效率型)
使劲挖掘自己在知识效率型方面的能力吧,即使灰色地带也行啊。
接下来看看TCP状态中的CLOSE_WAIT状态的含义
先看TCP连接状态图
这是网络、书本上凡是描述TCP状态一定会出现的状态图,理论上看这个图能解决任何TCP状态问题。
反复看这个图的右下部分的CLOSE_WAIT ,从这个图里可以得到如下结论:
CLOSE_WAIT是被动关闭端在等待应用进程的关闭
基本上这一结论要能帮助解决所有CLOSE_WAIT相关的问题,如果不能说明对这个知识点理解的不够。
server端大量close_wait案例
用实际案例来检查自己对CLOSE_WAIT 理论(CLOSE_WAIT是被动关闭端在等待应用进程的关闭)的掌握 – 能不能用这个结论来解决实际问题。同时也可以看看自己从知识到问题的推理能力(跟前面的知识效率呼应一下)。
问题描述:
服务端出现大量CLOSE_WAIT 个数正好 等于somaxconn(调整somaxconn大小后 CLOSE_WAIT 也会跟着变成一样的值)
根据这个描述先不要往下看,自己推理分析下可能的原因。
我的推理如下:
从这里看起来,client跟server成功建立了somaxconn个连接(somaxconn小于backlog,所以accept queue只有这么大),但是应用没有accept这个连接,导致这些连接一直在accept queue中。但是这些连接的状态已经是ESTABLISHED了,也就是client可以发送数据了,数据发送到server后OS ack了,并放在os的tcp buffer中,应用一直没有accept也就没法读取数据。client于是发送fin(可能是超时、也可能是简单发送数据任务完成了得结束连接),这时Server上这个连接变成了CLOSE_WAIT .
也就是从开始到结束这些连接都在accept queue中,没有被应用accept,很快他们又因为client 发送 fin 包变成了CLOSE_WAIT ,所以始终看到的是服务端出现大量CLOSE_WAIT 并且个数正好等于somaxconn(调整somaxconn后 CLOSE_WAIT 也会跟着变成一样的值)。
如下图所示,在连接进入accept queue后状态就是ESTABLISED了,也就是可以正常收发数据和fin了。client是感知不到server是否accept()了,只是发了数据后server的os代为保存在OS的TCP buffer中,因为应用没来取自然在CLOSE_WAIT 后应用也没有close(),所以一直维持CLOSE_WAIT 。
得检查server 应用为什么没有accept。
如上是老司机的思路靠经验缺省了一些理论推理,缺省还是对理论理解不够, 这个分析抓住了 大量CLOSE_WAIT 个数正好 等于somaxconn(调整somaxconn后 CLOSE_WAIT 也会跟着变成一样的值)但是没有抓住 CLOSE_WAIT 背后的核心原因
更简单的推理
如果没有任何实战经验,只看上面的状态图的学霸应该是这样推理的:
看到server上有大量的CLOSE_WAIT说明client主动断开了连接,server的OS收到client 发的fin,并回复了ack,这个过程不需要应用感知,进而连接从ESTABLISHED进入CLOSE_WAIT,此时在等待server上的应用调用close连关闭连接(处理完所有收发数据后才会调close()) —- 结论:server上的应用一直卡着没有调close().
同时这里很奇怪的现象: 服务端出现大量CLOSE_WAIT 个数正好 等于somaxconn,进而可以猜测是不是连接建立后很快accept队列满了(应用也没有accept() ), 导致 大量CLOSE_WAIT 个数正好 等于somaxconn —- 结论: server 上的应用不但没有调close(), 连close() 前面必须调用 accept() 都一直卡着没调 (这个结论需要有accept()队列的理论知识)
从上面两个结论可以清楚地看到 server的应用卡住了
实际结论:
这个case的最终原因是因为OS的open files设置的是1024,达到了上限,进而导致server不能accept,但这个时候的tcp连接状态已经是ESTABLISHED了(这个状态变换是取决于内核收发包,跟应用是否accept()无关)。
同时从这里可以推断 netstat 即使看到一个tcp连接状态是ESTABLISHED也不能代表占用了 open files句柄。此时client可以正常发送数据了,只是应用服务在accept之前没法receive数据和close连接。
这个结论的图解如下:
假如全连接队列满了,握手第三步后对于client端来说是无法感知的,client端只需要回复ack后这个连接对于client端就是ESTABLISHED了,这时client是可以发送数据的。但是Server会扔掉收到的ack,回复syn+ack给client。
如果全连接队列没满,但是fd不够,那么在Server端这个Socket也是ESTABLISHED,但是只是暂存在全连接队列中,等待应用来accept,这个时候client端同样无法感知这个连接没有被accept,client是可以发送数据的,这个数据会保存在tcp receive memory buffer中,等到accept后再给应用。
如果自己无法得到上面的分析,那么再来看看如果把 CLOSE_WAIT 状态更细化地分析下(类似有老师帮你把知识点揉开跟实际案例联系下—-未必是上面的案例),看完后再来分析下上面的案例。
CLOSE_WAIT 状态拆解
通常,CLOSE_WAIT 状态在服务器停留时间很短,如果你发现大量的 CLOSE_WAIT 状态,那么就意味着被动关闭的一方没有及时发出 FIN 包,一般有如下几种可能:
- 程序问题:如果代码层面忘记了 close 相应的 socket 连接,那么自然不会发出 FIN 包,从而导致 CLOSE_WAIT 累积;或者代码不严谨,出现死循环之类的问题,导致即便后面写了 close 也永远执行不到。
- 响应太慢或者超时设置过小:如果连接双方不和谐,一方不耐烦直接 timeout,另一方却还在忙于耗时逻辑,就会导致 close 被延后。响应太慢是首要问题,不过换个角度看,也可能是 timeout 设置过小。
- BACKLOG 太大:此处的 backlog 不是 syn backlog,而是 accept 的 backlog,如果 backlog 太大的话,设想突然遭遇大访问量的话,即便响应速度不慢,也可能出现来不及消费的情况,导致多余的请求还在队列里就被对方关闭了。
如果你通过「netstat -ant」或者「ss -ant」命令发现了很多 CLOSE_WAIT 连接,请注意结果中的「Recv-Q」和「Local Address」字段,通常「Recv-Q」会不为空,它表示应用还没来得及接收数据,而「Local Address」表示哪个地址和端口有问题,我们可以通过「lsof -i:
如果是我们自己写的一些程序,比如用 HttpClient 自定义的蜘蛛,那么八九不离十是程序问题,如果是一些使用广泛的程序,比如 Tomcat 之类的,那么更可能是响应速度太慢或者 timeout 设置太小或者 BACKLOG 设置过大导致的故障。
看完这段 CLOSE_WAIT 更具体深入点的分析后再来分析上面的案例看看,能否推导得到正确的结论。
一些疑问
连接都没有被accept(), client端就能发送数据了?
答:是的。只要这个连接在OS看来是ESTABLISHED的了就可以,因为握手、接收数据都是由内核完成的,内核收到数据后会先将数据放在内核的tcp buffer中,然后os回复ack。另外三次握手之后client端是没法知道server端是否accept()了。
CLOSE_WAIT与accept queue有关系吗?
答:没有关系。只是本案例中因为open files不够了,影响了应用accept(), 导致accept queue满了,同时因为即使应用不accept(三次握手后,server端是否accept client端无法感知),client也能发送数据和发 fin断连接,这些响应都是os来负责,跟上层应用没关系,连接从握手到ESTABLISHED再到CLOSE_WAIT都不需要fd,也不需要应用参与。CLOSE_WAIT只跟应用不调 close() 有关系。
CLOSE_WAIT与accept queue为什么刚好一致并且联动了?
答:这里他们的数量刚好一致是因为所有新建连接都没有accept,堵在queue中。同时client发现问题后把所有连接都fin了,也就是所有queue中的连接从来没有被accept过,但是他们都是ESTABLISHED,过一阵子之后client端发了fin所以所有accept queue中的连接又变成了 CLOSE_WAIT, 所以二者刚好一致并且联动了
openfiles和accept()的关系是?
答:accept()的时候才会创建文件句柄,消耗openfiles
一个连接如果在accept queue中了,但是还没有被应用 accept,那么这个时候在server上看这个连接的状态他是ESTABLISHED的吗?
答:是
如果server的os参数 open files到了上限(就是os没法打开新的文件句柄了)会导致这个accept queue中的连接一直没法被accept对吗?
答:对
如果通过gdb attach 应用进程,故意让进程accept,这个时候client还能连上应用吗?
答: 能,这个时候在client和server两边看到的连接状态都是 ESTABLISHED,只是Server上的全连接队列占用加1。连接握手并切换到ESTABLISHED状态都是由OS来负责的,应用不参与,ESTABLISHED后应用才能accept,进而收发数据。也就是能放入到全连接队列里面的连接肯定都是 ESTABLISHED 状态的了
接着上面的问题,如果新连接继续连接进而全连接队列满了呢?
答:那就连不上了,server端的OS因为全连接队列满了直接扔掉第一个syn握手包,这个时候连接在client端是SYN_SENT,Server端没有这个连接,这是因为syn到server端就直接被OS drop 了。
1 | //如下图,本机测试,只有一个client端发起的syn_send, 3306的server端没有任何连接 |
能进入到accept queue中的连接都是 ESTABLISHED,不管用户态有没有accept,用户态accept后队列大小减1
如果一个连接握手成功进入到accept queue但是应用accept前被对方RESET了呢?
答: 如果此时收到对方的RESET了,那么OS会释放这个连接。但是内核认为所有 listen 到的连接, 必须要 accept 走, 因为用户有权利知道有过这么一个连接存在过。所以OS不会到全连接队列拿掉这个连接,全连接队列数量也不会减1,知道应用accept这个连接,然后read/write才发现这个连接断开了,报communication failure异常
什么时候连接状态变成 ESTABLISHED
三次握手成功就变成 ESTABLISHED 了,不需要用户态来accept,如果握手第三步的时候OS发现全连接队列满了,这时OS会扔掉这个第三次握手ack,并重传握手第二步的syn+ack, 在OS端这个连接还是 SYN_RECV 状态的,但是client端是 ESTABLISHED状态的了。
这是在4000(tearbase)端口上全连接队列没满,但是应用不再accept了,nc用12346端口去连4000(tearbase)端口的结果
1 | # netstat -at |grep ":12346 " |
这是在4000(tearbase)端口上全连接队列满掉后,nc用12346端口去连4000(tearbase)端口的结果
1 | # netstat -at |grep ":12346 " |
TCP性能和发送接收窗口、Buffer的关系
前言
本文希望解析清楚,当我们在代码中写下 socket.setSendBufferSize 和 sysctl 看到的rmem/wmem系统参数以及最终我们在TCP常常谈到的接收发送窗口的关系,以及他们怎样影响TCP传输的性能,同时如何通过图形来展示哪里是传输瓶颈。
拥塞窗口相关文章比较多,他们跟带宽紧密相关,所以大家比较好判断,反而是接收、发送窗口一旦出现瓶颈,就没这么好判断了。
先明确一下:文章标题中所说的Buffer指的是sysctl中的 rmem或者wmem,如果是代码中指定的话对应着SO_SNDBUF或者SO_RCVBUF,从TCP的概念来看对应着发送窗口或者接收窗口
TCP性能和发送接收Buffer的关系
先从碰到的一个实际问题看起:
应用通过专线跨网络访问云上的服务,专线100M,时延20ms,一个SQL查询了22M数据,结果花了大概25秒,这太慢了,不正常。
如果通过云上client访问云上服务那么1-2秒就返回了(说明不跨网络服务是正常的)。
如果通过http或者scp从公司向云上传输这22M的数据大概两秒钟也传送完毕了(说明网络带宽不是瓶颈),
所以这里问题的原因基本上是我们的服务在这种网络条件下有性能问题,需要找出为什么。
抓包分析 tcpdump+wireshark
抓包分析这22M的数据传输,如下图(wireshark 时序图),横轴是时间,纵轴是sequence number:
粗一看没啥问题,因为时间太长掩盖了问题。把这个图形放大,只看中间50ms内的传输情况(横轴是时间,纵轴是sequence number,一个点代表一个包)
换个角度,看看窗口尺寸图形:
从bytes in flight也大致能算出来总的传输速度 16K*1000/20=800Kb/秒
我们的应用代码中会默认设置 socketSendBuffer 为16K:
socket.setSendBufferSize(16*1024) //16K send buffer
来看一下tcp包发送流程:
(图片来自)
如果sendbuffer不够就会卡在上图中的第一步 sk_stream_wait_memory, 通过systemtap脚本可以验证:
#!/usr/bin/stap
# Simple probe to detect when a process is waiting for more socket send
# buffer memory. Usually means the process is doing writes larger than the
# socket send buffer size or there is a slow receiver at the other side.
# Increasing the socket's send buffer size might help decrease application
# latencies, but it might also make it worse, so buyer beware.
# Typical output: timestamp in microseconds: procname(pid) event
#
# 1218230114875167: python(17631) blocked on full send buffer
# 1218230114876196: python(17631) recovered from full send buffer
# 1218230114876271: python(17631) blocked on full send buffer
# 1218230114876479: python(17631) recovered from full send buffer
probe kernel.function("sk_stream_wait_memory")
{
printf("%u: %s(%d) blocked on full send buffern",
gettimeofday_us(), execname(), pid())
}
probe kernel.function("sk_stream_wait_memory").return
{
printf("%u: %s(%d) recovered from full send buffern",
gettimeofday_us(), execname(), pid())
}
原理解析
如果tcp发送buffer也就是SO_SNDBUF只有16K的话,这些包很快都发出去了,但是这16K的buffer不能立即释放出来填新的内容进去,因为tcp要保证可靠,万一中间丢包了呢。只有等到这16K中的某些包ack了,才会填充一些新包进来然后继续发出去。由于这里rt基本是20ms,也就是16K发送完毕后,等了20ms才收到一些ack,这20ms应用、内核什么都不能做,所以就是如前面第二个图中的大概20ms的等待平台。这块请参考这篇文章
比如下图,wmem大小是8,发出1-8后,buffer不能释放,等到收到ack1-4后,释放1-4,buffer也就是释放了一半,这一半可以填充新的发送数据进来了。 上面的问题在于ack花了很久,导致buffer一直不能释放。
sendbuffer相当于发送仓库的大小,仓库的货物都发走后,不能立即腾出来发新的货物,而是要等对方确认收到了(ack)才能腾出来发新的货物。 传输速度取决于发送仓库(sendbuffer)、接收仓库(recvbuffer)、路宽(带宽)的大小,如果发送仓库(sendbuffer)足够大了之后接下来的瓶颈就会是高速公路了(带宽、拥塞窗口)。而实际上这个案例中带宽够、接收仓库也够,但是发送仓库太小了,导致发送过程断断续续,所以非常慢。
如果是UDP,就没有可靠的概念,有数据统统发出去,根本不关心对方是否收到,也就不需要ack和这个发送buffer了。
几个发送buffer相关的内核参数
$sudo sysctl -a | egrep "rmem|wmem|tcp_mem|adv_win|moderate"
net.core.rmem_default = 212992
net.core.rmem_max = 212992
net.core.wmem_default = 212992 //core是给所有的协议使用的,
net.core.wmem_max = 212992
net.ipv4.tcp_adv_win_scale = 1
net.ipv4.tcp_moderate_rcvbuf = 1
net.ipv4.tcp_rmem = 4096 87380 6291456 //最小值 默认值 最大值】
net.ipv4.tcp_wmem = 4096 16384 4194304 //tcp这种就自己的专用选项就不用 core 里面的值了
net.ipv4.udp_rmem_min = 4096
net.ipv4.udp_wmem_min = 4096
vm.lowmem_reserve_ratio = 256 256 32
net.ipv4.tcp_mem = 88560 118080 177120
vm.lowmem_reserve_ratio = 256 256 32
net.ipv4.tcp_wmem 默认就是16K,而且内核是能够动态调整的,只不过我们代码中这块的参数是很多年前从Cobra中继承过来的,初始指定了sendbuffer的大小。代码中设置了这个参数后就关闭了内核的动态调整功能,这就是为什么http或者scp都很快,因为他们的send buffer是动态调整的。
接收buffer是有开关可以动态控制的,发送buffer没有开关默认就是开启,关闭只能在代码层面来控制
net.ipv4.tcp_moderate_rcvbuf
优化
调整 socketSendBuffer 到256K,查询时间从25秒下降到了4秒多,但是比理论带宽所需要的时间略高
继续查看系统 net.core.wmem_max 参数默认最大是130K,所以即使我们代码中设置256K实际使用的也是130K,继续调大这个系统参数后整个网络传输时间大概2秒(跟100M带宽匹配了,scp传输22M数据也要2秒),整体查询时间2.8秒。测试用的mysql client短连接,如果代码中的是长连接的话会块300-400ms(消掉了握手和慢启动阶段),这基本上是理论上最快速度了
如果调用setsockopt()设置了socket选项SO_SNDBUF,将关闭发送端缓冲的自动调节机制,tcp_wmem将被忽略,SO_SNDBUF的最大值由net.core.wmem_max限制。
BDP 带宽时延积
BDP=rtt*(带宽/8)
这个 buffer 调到1M测试没有帮助,从理论计算BDP(带宽时延积) 0.02秒(100MB/8)=250Kb 所以 **SO_SNDBUF为256Kb的时候基本能跑满带宽了,再大也没有什么实际意义了** 。也就是前面所说的仓库足够后瓶颈在带宽上了。
因为这里根据带宽、rtt计算得到的BDP是250K,BDP跑满后拥塞窗口(带宽、接收窗口和rt决定的)即将成为新的瓶颈,所以调大buffer没意义了。
用tc构造延时和带宽限制的模拟重现环境
sudo tc qdisc del dev eth0 root netem delay 20ms
sudo tc qdisc add dev eth0 root tbf rate 500kbit latency 50ms burst 15kb
这个案例关于wmem的结论
默认情况下Linux系统会自动调整这个buffer(net.ipv4.tcp_wmem), 也就是不推荐程序中主动去设置SO_SNDBUF,除非明确知道设置的值是最优的。
从这里我们可以看到,有些理论知识点虽然我们知道,但是在实践中很难联系起来,也就是常说的无法学以致用,最开始看到抓包结果的时候比较怀疑发送、接收窗口之类的,没有直接想到send buffer上,理论跟实践没联系上。
接下来看看接收buffer(rmem)和接收窗口的关系
用这样一个案例下来验证接收窗口的作用:
有一个batch insert语句,整个一次要插入5532条记录,所有记录大小总共是376K,也就是这个sql语句本身是376K。
SO_RCVBUF很小的时候并且rtt很大对性能的影响
如果rtt是40ms,总共需要5-6秒钟:
基本可以看到server一旦空出来点窗口,client马上就发送数据,由于这点窗口太小,rtt是40ms,也就是一个rtt才能传3456字节的数据,整个带宽才用到80-90K,完全没跑满。
比较明显间隔 40ms 一个等待台阶,台阶之间两个包大概3K数据,总的传输效率如下:
斜线越陡表示速度越快,从上图看整体SQL上传花了5.5秒,执行0.5秒。
此时对应的窗口尺寸:
窗口由最开始28K(20个1448)很快降到了不到4K的样子,然后基本游走在即将满的边缘,虽然读取慢,幸好rtt也大,导致最终也没有满。(这个是3.1的Linux,应用SO_RCVBUF设置的是8K,用一半来做接收窗口)
SO_RCVBUF很小的时候并且rtt很小时对性能的影响
如果同样的语句在 rtt 是0.1ms的话
虽然明显看到接收窗口经常跑满,但是因为rtt很小,一旦窗口空出来很快就通知到对方了,所以整个过小的接收窗口也没怎么影响到整体性能
如上图11.4秒整个SQL开始,到11.41秒SQL上传完毕,11.89秒执行完毕(执行花了0.5秒),上传只花了0.01秒
接收窗口情况:
如图,接收窗口由最开始的28K降下来,然后一直在5880和满了之间跳动
从这里可以得出结论,接收窗口的大小对性能的影响,rtt越大影响越明显,当然这里还需要应用程序配合,如果应用程序一直不读走数据即使接收窗口再大也会堆满的。
SO_RCVBUF和tcp window full的坏case
上图中红色平台部分,停顿了大概6秒钟没有发任何有内容的数据包,这6秒钟具体在做什么如下图所示,可以看到这个时候接收方的TCP Window Full,同时也能看到接收方(3306端口)的TCP Window Size是8192(8K),发送方(27545端口)是20480.
这个状况跟前面描述的recv buffer太小不一样,8K是很小,但是因为rtt也很小,所以server总是能很快就ack收到了,接收窗口也一直不容易达到full状态,但是一旦接收窗口达到了full状态,居然需要惊人的6秒钟才能恢复,这等待的时间有点太长了。这里应该是应用读取数据太慢导致了耗时6秒才恢复,所以最终这个请求执行会非常非常慢(时间主要耗在了上传SQL而不是执行SQL).
实际原因不知道,从读取TCP数据的逻辑来看这里没有明显的block,可能的原因:
- request的SQL太大,Server(3306端口上的服务)从TCP读取SQL需要放到一块分配好的内存,内存不够的时候需要扩容,扩容有可能触发fgc,从图形来看,第一次满就卡顿了,而且每次满都卡顿,不像是这个原因
- request请求一次发过来的是多个SQL,应用读取SQL后,将SQL分成多个,然后先执行第一个,第一个执行完后返回response,再读取第二个。图形中卡顿前没有response返回,所以也不是这个原因
- ……其它未知原因
接收方不读取数据导致的接收窗口满同时有丢包发生
服务端返回数据到client端,TCP协议栈ack这些包,但是应用层没读走包,这个时候 SO_RCVBUF 堆积满,client的TCP协议栈发送 ZeroWindow 标志给服务端。也就是接收端的 buffer 堆满了(但是服务端这个时候看到的bytes in fly是0,因为都ack了),这时服务端不能继续发数据,要等 ZeroWindow 恢复。
那么接收端上层应用不读走包可能的原因:
- 应用代码卡顿、GC等等
- 应用代码逻辑上在做其它事情(比如Server将SQL分片到多个DB上,Server先读取第一个分片,如果第一个分片数据很大很大,处理也慢,那么即使第二个分片数据都返回到了TCP 的recv buffer,应用也没去读取其它分片的结果集,直到第一个分片读取完毕。如果SQL带排序,那么Server会轮询读取多个分片,造成这种卡顿的概率小了很多)
上图这个流因为应用层不读取TCP数据,导致TCP接收Buffer满,进而接收窗口为0,server端不能再发送数据而卡住,但是ZeroWindow的探测包,client都有正常回复,所以1903秒之后接收方窗口不为0后(window update)传输恢复。
这个截图和前一个类似,是在Server上(3003端口)抓到的包,不同的是接收窗口为0后,server端多次探测(Server上抓包能看到),但是client端没有回复 ZeroWindow(也有可能是回复了,但是中间环节把ack包丢了,或者这个探测包client没收到),造成server端认为client死了、不可达之类,进而反复重传,重传超过15次之后,server端认为这个连接死了,粗暴单方面断开(没有reset和fin,因为没必要,server认为网络连通性出了问题)。
等到1800秒后,client的接收窗口恢复了,发个window update给server,这个时候server认为这个连接已经断开了,只能回复reset
网络不通,重传超过一定的时间(tcp_retries2)然后断开这个连接是正常的,这里的问题是:
- 为什么这种场景下丢包了,而且是针对某个stream一直丢包
可能是因为这种场景下触发了中间环节的流量管控,故意丢包了(比如proxy、slb、交换机都有可能做这种选择性的丢包)
这里server认为连接断开,没有发reset和fin,因为没必要,server认为网络连通性出了问题。client还不知道server上这个连接清理掉了,等client回复了一个window update,server早就认为这个连接早断了,突然收到一个update,莫名其妙,只能reset
接收窗口和SO_RCVBUF的关系
ss 查看socket buffer大小
初始接收窗口一般是 mss乘以初始cwnd(为了和慢启动逻辑兼容,不想一下子冲击到网络),如果没有设置SO_RCVBUF,那么会根据 net.ipv4.tcp_rmem 动态变化,如果设置了SO_RCVBUF,那么接收窗口要向下面描述的值靠拢。
ss -itmpn dst "10.81.212.8"
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 0 10.xx.xx.xxx:22 10.yy.yy.yyy:12345 users:(("sshd",pid=1442,fd=3))
skmem:(r0,rb369280,t0,tb87040,f4096,w0,o0,bl0,d92)
Here we can see this socket has Receive Buffer 369280 bytes, and Transmit Buffer 87040 bytes.
Keep in mind the kernel will double any socket buffer allocation for overhead.
So a process asks for 256 KiB buffer with setsockopt(SO_RCVBUF) then it will get 512 KiB buffer space. This is described on man 7 tcp.
初始窗口计算的代码逻辑,重点在18行:
/* TCP initial congestion window as per rfc6928 */
#define TCP_INIT_CWND 10
/* 3. Try to fixup all. It is made immediately after connection enters
established state.
*/
void tcp_init_buffer_space(struct sock *sk)
{
int tcp_app_win = sock_net(sk)->ipv4.sysctl_tcp_app_win;
struct tcp_sock *tp = tcp_sk(sk);
int maxwin;
if (!(sk->sk_userlocks & SOCK_SNDBUF_LOCK))
tcp_sndbuf_expand(sk);
//初始最大接收窗口计算过程
tp->rcvq_space.space = min_t(u32, tp->rcv_wnd, TCP_INIT_CWND * tp->advmss);
tcp_mstamp_refresh(tp);
tp->rcvq_space.time = tp->tcp_mstamp;
tp->rcvq_space.seq = tp->copied_seq;
maxwin = tcp_full_space(sk);
if (tp->window_clamp >= maxwin) {
tp->window_clamp = maxwin;
if (tcp_app_win && maxwin > 4 * tp->advmss)
tp->window_clamp = max(maxwin -
(maxwin >> tcp_app_win),
4 * tp->advmss);
}
/* Force reservation of one segment. */
if (tcp_app_win &&
tp->window_clamp > 2 * tp->advmss &&
tp->window_clamp + tp->advmss > maxwin)
tp->window_clamp = max(2 * tp->advmss, maxwin - tp->advmss);
tp->rcv_ssthresh = min(tp->rcv_ssthresh, tp->window_clamp);
tp->snd_cwnd_stamp = tcp_jiffies32;
}
传输过程中,最大接收窗口会动态调整,当指定了SO_RCVBUF后,实际buffer是两倍SO_RCVBUF,但是要分出一部分(2^net.ipv4.tcp_adv_win_scale)来作为乱序报文缓存。
- net.ipv4.tcp_adv_win_scale = 2 //2.6内核,3.1中这个值默认是1
如果SO_RCVBUF是8K,总共就是16K,然后分出2^2分之一,也就是4分之一,还剩12K当做接收窗口;如果设置的32K,那么接收窗口是48K
static inline int tcp_win_from_space(const struct sock sk, int space)
{//space 传入的时候就已经是 2SO_RCVBUF了
int tcp_adv_win_scale = sock_net(sk)->ipv4.sysctl_tcp_adv_win_scale;
return tcp_adv_win_scale <= 0 ?
(space>>(-tcp_adv_win_scale)) :
space - (space>>tcp_adv_win_scale); //sysctl参数tcp_adv_win_scale
}
接收窗口有最大接收窗口和当前可用接收窗口。
一般来说一次中断基本都会将 buffer 中的包都取走。
绿线是最大接收窗口动态调整的过程,最开始是1460*10,握手完毕后略微调整到1472*10(可利用body增加了12),随着数据的传输开始跳涨
上图是四个batch insert语句,可以看到绿色接收窗口随着数据的传输越来越大,图中蓝色竖直部分基本表示SQL上传,两个蓝色竖直条的间隔代表这个insert在服务器上真正的执行时间。这图非常陡峭,表示上传没有任何瓶颈.
设置 SO_RCVBUF 后通过wireshark观察到的接收窗口基本
下图是设置了 SO_RCVBUF 为8192的实际情况:
从最开始的14720,执行第一个create table语句后降到14330,到真正执行batch insert就降到了8192*1.5. 然后一直保持在这个值
If you set a “receive buffer size” on a TCP socket, what does it actually mean?
The naive answer would go something along the lines of: the TCP receive buffer setting indicates the maximum number of bytes a read()
syscall could retrieve without blocking.
Note that if the buffer size is set with setsockopt()
, the value returned with getsockopt()
is always double the size requested to allow for overhead. This is described in man 7 socket
.
长肥网络(rt很高、带宽也高)下接收窗口对传输性能的影响
最后通过一个实际碰到的案例,涉及到了接收窗口、发送Buffer以及高延时情况下的性能问题
案例描述:从中国访问美国的服务器下载图片,只能跑到220K,远远没有达到带宽能力,其中中美之间的网络延时时150ms,这个150ms已经不能再优化了。业务结构是:
client ——150ms—–>>>LVS—1ms–>>>美国的统一接入server—–1ms—–>>>nginx
通过下载一个4M的文件大概需要20秒,分别在client和nginx上抓包来分析这个问题(统一接入server没权限上去)
Nginx上抓包
从这里可以看到Nginx大概在60ms内就将4M的数据都发完了
client上抓包
从这个图上可以清楚看到大概每传输大概30K数据就有一个150ms的等待平台,这个150ms基本是client到美国的rt。
从我们前面的阐述可以清楚了解到因为rt比较高,统一接入server每发送30K数据后要等150ms才能收到client的ack,然后继续发送,猜是因为上面设置的发送buffer大概是30K。
检查统一接入server的配置,可以看到接入server的配置里面果然有个32K buffer设置
将buffer改大
速度可以到420K,但是还没有跑满带宽:
接着看一下client上的抓包
可以清楚看到 client的接收窗口是64K, 64K*1000/150=426K 这个64K很明显是16位的最大值,应该是TCP握手有一方不支持window scaling factor
那么继续分析一下握手包,syn:
说明client是支持的,再看 syn+ack:
可以看到服务端不支持,那就最大只能用到64K。需要修改服务端代理程序,这主要是LVS或者代理的锅。
如果内网之间rt很小这个锅不会爆发,一旦网络慢一点就把问题恶化了
比如这是这个应用的开发人员的反馈:
长肥网络就像是很长很宽的高速公路,上面可以同时跑很多车,而如果发车能力不够,就容易跑不满高速公路。
在rt很短的时候可以理解为高速公路很短,所以即使发车慢也还好,因为车很快就到了,到了后就又能发新车了。rt很长的话就要求更大的仓库了。
整个这个问题,我最初拿到的问题描述结构是这样的:
client ——150ms—–>>>nginx
实际开发人员也不能完全描述清楚结构,从抓包中慢慢分析反推他们的结构,到最后问题的解决。
这个案例综合了发送窗口(32K)、接收窗口(64K,因为握手LVS不支持window scale)、rt很大将问题暴露出来(跨国网络,rt没法优化)。
delay ack拉高实际rt的case
如下业务监控图:实际处理时间(逻辑服务时间1ms,rtt2.4ms,加起来3.5ms),但是系统监控到的rt(蓝线)是6ms,如果一个请求分很多响应包串行发给client,这个6ms是正常的(1+2.4*N),但实际上如果send buffer足够的话,按我们前面的理解多个响应包会并发发出去,所以如果整个rt是3.5ms才是正常的。
抓包来分析原因:
实际看到大量的response都是3.5ms左右,符合我们的预期,但是有少量rt被delay ack严重影响了
从下图也可以看到有很多rtt超过3ms的,这些超长时间的rtt会最终影响到整个服务rt
OS层面相关参数:
$sudo sysctl -a | egrep "rmem|wmem|tcp_mem|adv_win|moderate"
net.core.rmem_default = 212992
net.core.rmem_max = 212992
net.core.wmem_default = 212992 //core是给所有的协议使用的,
net.core.wmem_max = 212992
net.ipv4.tcp_adv_win_scale = 1
net.ipv4.tcp_moderate_rcvbuf = 1
net.ipv4.tcp_rmem = 4096 87380 6291456
net.ipv4.tcp_wmem = 4096 16384 4194304 //tcp这种就自己的专用选项就不用 core 里面的值了
net.ipv4.udp_rmem_min = 4096
net.ipv4.udp_wmem_min = 4096
vm.lowmem_reserve_ratio = 256 256 32
net.ipv4.tcp_mem = 88560 118080 177120
发送buffer系统比较好自动调节,依靠发送数据大小和rt延时大小,可以相应地进行调整;但是接受buffer就不一定了,接受buffer的使用取决于收到的数据快慢和应用读走数据的速度,只能是OS根据系统内存的压力来调整接受buffer。系统内存的压力取决于 net.ipv4.tcp_mem.
需要特别注意:tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位的页面
内核观测tcp_mem是否不足
因 tcp_mem 达到限制而无法发包或者产生抖动的问题,我们也是可以观测到的。为了方便地观测这类问题,Linux 内核里面预置了静态观测点:sock_exceed_buf_limit(需要 4.16+ 的内核版本)。
$ echo 1 > /sys/kernel/debug/tracing/events/sock/sock_exceed_buf_limit/enable
然后去看是否有该事件发生:
$ cat /sys/kernel/debug/tracing/trace_pipe
如果有日志输出(即发生了该事件),就意味着你需要调大 tcp_mem 了,或者是需要断开一些 TCP 连接了。
总结
- 一般来说绝对不要在程序中手工设置SO_SNDBUF和SO_RCVBUF,内核自动调整比你做的要好;
- SO_SNDBUF一般会比发送滑动窗口要大,因为发送出去并且ack了的才能从SO_SNDBUF中释放;
- TCP接收窗口跟SO_RCVBUF关系很复杂;
- SO_RCVBUF太小并且rtt很大的时候会严重影响性能;
- 接收窗口比发送窗口复杂多了;
- 发送窗口/SO_SNDBUF–发送仓库,带宽/拥塞窗口–马路通畅程度,接收窗口/SO_RCVBUF–接收仓库;
- 发送仓库、马路宽度、长度(rt)、接收仓库一起决定了传输速度–类比一下快递过程。
总之记住一句话:不要设置socket的SO_SNDBUF和SO_RCVBUF
相关和参考文章
经典的 nagle 和 dalay ack对性能的影响 就是要你懂 TCP– 最经典的TCP性能问题
The story of one latency spike
What is rcv_space in the ‘ss –info’ output, and why it’s value is larger than net.core.rmem_max
kubernetes service 和 kube-proxy详解
kubernetes service 和 kube-proxy详解
service 是Kubernetes里面非常重要的一个功能,用以解决负载均衡、弹性伸缩、升级灰度等等
本文先从概念介绍到实际负载均衡运转过程中追踪每个环节都做哪些处理,同时这些包会相应地怎么流转最终到达目标POD,以阐明service工作原理以及kube-proxy又在这个过程中充当了什么角色。
service 模式
根据创建Service的type
类型不同,可分成4种模式:
- ClusterIP: 默认方式。根据是否生成ClusterIP又可分为普通Service和Headless Service两类:
普通Service
:通过为Kubernetes的Service分配一个集群内部可访问的固定虚拟IP(Cluster IP),实现集群内的访问。为最常见的方式。Headless Service
:该服务不会分配Cluster IP,也不通过kube-proxy做反向代理和负载均衡。而是通过DNS提供稳定的网络ID来访问,DNS会将headless service的后端直接解析为podIP列表。主要供StatefulSet中对应POD的序列用。
NodePort
:除了使用Cluster IP之外,还通过将service的port映射到集群内每个节点的相同一个端口,实现通过nodeIP:nodePort从集群外访问服务。NodePort会RR转发给后端的任意一个POD,跟ClusterIP类似LoadBalancer
:和nodePort类似,不过除了使用一个Cluster IP和nodePort之外,还会向所使用的公有云申请一个负载均衡器,实现从集群外通过LB访问服务。在公有云提供的 Kubernetes 服务里,都使用了一个叫作 CloudProvider 的转接层,来跟公有云本身的 API 进行对接。所以,在上述 LoadBalancer 类型的 Service 被提交后,Kubernetes 就会调用 CloudProvider 在公有云上为你创建一个负载均衡服务,并且把被代理的 Pod 的 IP 地址配置给负载均衡服务做后端。ExternalName
:是 Service 的特例。此模式主要面向运行在集群外部的服务,通过它可以将外部服务映射进k8s集群,且具备k8s内服务的一些特征(如具备namespace等属性),来为集群内部提供服务。此模式要求kube-dns的版本为1.7或以上。这种模式和前三种模式(除headless service)最大的不同是重定向依赖的是dns层次,而不是通过kube-proxy。
service yaml案例:
1 | apiVersion: v1 |
ports
字段指定服务的端口信息:
port
:虚拟 ip 要绑定的 port,每个 service 会创建出来一个虚拟 ip,通过访问vip:port
就能获取服务的内容。这个 port 可以用户随机选取,因为每个服务都有自己的 vip,也不用担心冲突的情况targetPort
:pod 中暴露出来的 port,这是运行的容器中具体暴露出来的端口,一定不能写错–一般用name来代替具体的portprotocol
:提供服务的协议类型,可以是TCP
或者UDP
nodePort
: 仅在type为nodePort模式下有用,宿主机暴露端口
nodePort和loadbalancer可以被外部访问,loadbalancer需要一个外部ip,流量走外部ip进出
NodePort向外部暴露了多个宿主机的端口,外部可以部署负载均衡将这些地址配置进去。
默认情况下,服务会rr转发到可用的后端。如果希望保持会话(同一个 client 永远都转发到相同的 pod),可以把 service.spec.sessionAffinity
设置为 ClientIP
。
Service和kube-proxy的工作原理
kube-proxy有两种主要的实现(userspace基本没有使用了):
- iptables来做NAT以及负载均衡(默认方案)
- ipvs来做NAT以及负载均衡
Service 是由 kube-proxy 组件通过监听 Pod 的变化事件,在宿主机上维护iptables规则或者ipvs规则。
Kube-proxy 主要监听两个对象,一个是 Service,一个是 Endpoint,监听他们启停。以及通过selector将他们绑定。
IPVS 是专门为LB设计的。它用hash table管理service,对service的增删查找都是O(1)的时间复杂度。不过IPVS内核模块没有SNAT功能,因此借用了iptables的SNAT功能。IPVS 针对报文做DNAT后,将连接信息保存在nf_conntrack中,iptables据此接力做SNAT。该模式是目前Kubernetes网络性能最好的选择。但是由于nf_conntrack的复杂性,带来了很大的性能损耗。
iptables 实现负载均衡的工作流程
如果kube-proxy不是用的ipvs模式,那么主要靠iptables来做DNAT和SNAT以及负载均衡
iptables+clusterIP工作流程:
- 集群内访问svc 10.10.35.224:3306 命中 kube-services iptables(两条规则,宿主机、以及pod内)
- iptables 规则:KUBE-SEP-F4QDAAVSZYZMFXZQ 对应到 KUBE-SEP-F4QDAAVSZYZMFXZQ
- KUBE-SEP-F4QDAAVSZYZMFXZQ 指示 DNAT到 宿主机:192.168.0.83:10379(在内核中将包改写了ip port)
- 从svc description中可以看到这个endpoint的地址 192.168.0.83:10379(pod使用Host network)
iptables规则解析如下(case不一样,所以看到的端口、ip都不一样):
1 | -t nat -A {PREROUTING, OUTPUT} -m conntrack --ctstate NEW -j KUBE-SERVICES |
在对应的宿主机上可以清楚地看到容器中的mysqld进程正好监听着 10379端口
1 | [root@az1-drds-83 ~]# ss -lntp |grep 10379 |
对应的这个pod的description:
1 | #kubectl describe pod apsaradbcluster010-cv6w |
DNAT 规则的作用,就是在 PREROUTING 检查点之前,也就是在路由之前,将流入 IP 包的目的地址和端口,改成–to-destination 所指定的新的目的地址和端口。可以看到,这个目的地址和端口,正是被代理 Pod 的 IP 地址和端口。
如下是一个iptables来实现service的案例中的iptables流量分配规则:
三个pod,每个pod承担三分之一的流量
1 | iptables-save | grep 3306 |
到这里我们基本可以看到,利用iptables规则,宿主机内核把发到宿主机上的流量按照iptables规则做dnat后发给service后端的pod,同时iptables规则可以配置每个pod的流量大小。再辅助kube-proxy监听pod的起停和健康状态并相应地更新iptables规则,这样整个service实现逻辑就很清晰了。
看起来 service 是个完美的方案,可以解决服务访问的所有问题,但是 service 这个方案(iptables 模式)也有自己的缺点。
首先,如果转发的 pod 不能正常提供服务,它不会自动尝试另一个 pod,当然这个可以通过 readiness probes
来解决。每个 pod 都有一个健康检查的机制,当有 pod 健康状况有问题时,kube-proxy 会删除对应的转发规则。
另外,nodePort
类型的服务也无法添加 TLS 或者更复杂的报文路由机制。因为只做了NAT
ipvs 实现负载均衡的原理
ipvs模式下,kube-proxy会先创建虚拟网卡,kube-ipvs0下面的每个ip都对应着svc的一个clusterIP:
1 | # ip addr |
kube-ipvs0下面绑的这些ip就是在发包的时候让内核知道如果目标ip是这些地址的话,这些地址是自身的所以包不会发出去,而是给INPUT链,这样ipvs内核模块有机会改写包做完NAT后再发走。
ipvs会放置DNAT钩子在INPUT链上,因此必须要让内核识别 VIP 是本机的 IP。这样才会过INPUT 链,要不然就通过OUTPUT链出去了。k8s 通过kube-proxy将service cluster ip 绑定到虚拟网卡kube-ipvs0。
同时在路由表中增加一些ipvs 的路由条目:
1 | # ip route show table local |
而接下来,kube-proxy 就会通过 Linux 的 IPVS 模块,为这个 IP 地址设置三个 IPVS 虚拟主机,并设置这三个虚拟主机之间使用轮询模式 (rr) 来作为负载均衡策略。我们可以通过 ipvsadm 查看到这个设置,如下所示:
1 | ipvsadm -ln |grep 10.68.114.131 -A5 |
172.20.. 是后端真正pod的ip, 10.68.114.131 是cluster-ip.
完整的工作流程如下:
- 因为service cluster ip 绑定到虚拟网卡kube-ipvs0上,内核可以识别访问的 VIP 是本机的 IP.
- 数据包到达INPUT链.
- ipvs监听到达input链的数据包,比对数据包请求的服务是为集群服务,修改数据包的目标IP地址为对应pod的IP,然后将数据包发至POSTROUTING链.
- 数据包经过POSTROUTING链选路由后,将数据包通过tunl0网卡(calico网络模型)发送出去。从tunl0虚拟网卡获得源IP.
- 经过tunl0后进行ipip封包,丢到物理网络,路由到目标node(目标pod所在的node)
- 目标node进行ipip解包后给pod对应的网卡
- pod接收到请求之后,构建响应报文,改变源地址和目的地址,返回给客户端。
ipvs实际案例
ipvs负载均衡下一次完整的syn握手抓包。
宿主机上访问 curl clusterip+port 后因为这个ip绑定在kube-ipvs0上,本来是应该发出去的包(prerouting)但是内核认为这个包是访问自己,于是给INPUT链,接着被ipvs放置在INPUT中的DNAT钩子勾住,将dest ip根据负载均衡逻辑改成pod-ip,然后将数据包再发至POSTROUTING链。这时因为目标ip是POD-IP了,根据ip route 选择到出口网卡是tunl0。
可以看下内核中的路由规则:
1 | # ip route get 10.68.70.130 |
于是cip变成了tunl0的IP,这个tunl0是ipip模式,于是将这个包打包成ipip,也就是外层sip、dip都是宿主机ip,再将这个包丢入到物理网络
网络收包到达内核后的处理流程如下,核心都是查路由表,出包也会查路由表(判断是否本机内部通信,或者外部通信的话需要选用哪个网卡)
ipvs的一些分析
ipvs是一个内核态的四层负载均衡,支持NAT以及IPIP隧道模式,但LB和RS不能跨子网,IPIP性能次之,通过ipip隧道解决跨网段传输问题,因此能够支持跨子网。而NAT模式没有限制,这也是唯一一种支持端口映射的模式。
但是ipvs只有NAT(也就是DNAT),NAT也俗称三角模式,要求RS和LVS 在一个二层网络,并且LVS是RS的网关,这样回包一定会到网关,网关再次做SNAT,这样client看到SNAT后的src ip是LVS ip而不是RS-ip。默认实现不支持ful-NAT,所以像公有云厂商为了适应公有云场景基本都会定制实现ful-NAT模式的lvs。
我们不难猜想,由于Kubernetes Service需要使用端口映射功能,因此kube-proxy必然只能使用ipvs的NAT模式。
如下Masq表示MASQUERADE(也就是SNAT),跟iptables里面的 MASQUERADE 是一个意思
1 | # ipvsadm -L -n |grep 70.130 -A12 |
为什么clusterIP不能ping通
集群内访问cluster ip(不能ping,只能cluster ip+port)就是在到达网卡之前被内核iptalbes做了dnat/snat, cluster IP是一个虚拟ip,可以针对具体的服务固定下来,这样服务后面的pod可以随便变化。
iptables模式的svc会ping不通clusterIP,可以看如下iptables和route(留意:–reject-with icmp-port-unreachable):
1 | #ping 10.96.229.40 |
如果用ipvs实现的clusterIP是可以ping通的:
- 如果用iptables 来做转发是ping不通的,因为iptables里面这条规则只处理tcp包,reject了icmp
- ipvs实现的clusterIP都能ping通
- ipvs下的clusterIP ping通了也不是转发到pod,ipvs负载均衡只转发tcp协议的包
- ipvs 的clusterIP在本地配置了route路由到回环网卡,这个包是lo网卡回复的
ipvs实现的clusterIP,在本地有添加路由到lo网卡
然后在本机抓包(到ipvs后端的pod上抓不到icmp包):
从上面可以看出显然ipvs只会转发tcp包到后端pod,所以icmp包不会通过ipvs转发到pod上,同时在本地回环网卡lo上抓到了进去的icmp包。因为本地添加了一条路由规则,目标clusterIP被指示发到lo网卡上,lo网卡回复了这个ping包,所以通了。
NodePort Service
这种类型的 Service 也能被宿主机和 pod 访问,但与 ClusterIP 不同的是,它还能被 集群外的服务访问。
- External node IP + port in NodePort range to any endpoint (pod), e.g. 10.0.0.1:31000
- Enables access from outside
实现上,kube-apiserver 会从预留的端口范围内分配一个端口给 Service,然后 每个宿主机上的 kube-proxy 都会创建以下规则:
1 | -t nat -A {PREROUTING, OUTPUT} -m conntrack --ctstate NEW -j KUBE-SERVICES |
- 前面几步和 ClusterIP Service 一样;如果没匹配到 ClusterIP 规则,则跳转到
KUBE-NODEPORTS
chain。 KUBE-NODEPORTS
chain 里做 Service 匹配,但这次只匹配协议类型和目的端口号。- 匹配成功后,转到对应的
KUBE-SVC-
chain,后面的过程跟 ClusterIP 是一样的。
NodePort 的一些问题
- 首先endpoint回复不能走node 1给client,因为会被client reset(如果在node1上将src ip替换成node2的ip可能会路由不通)。回复包在 node1上要snat给node2
- 经过snat后endpoint没法拿到client ip(slb之类是通过option带过来)
1 | client |
可以将 Service 的 spec.externalTrafficPolicy 字段设置为 local,这就保证了所有 Pod 通过 Service 收到请求之后,一定可以看到真正的、外部 client 的源地址。
而这个机制的实现原理也非常简单:这时候,一台宿主机上的 iptables 规则,会设置为只将 IP 包转发给运行在这台宿主机上的 Pod。所以这时候,Pod 就可以直接使用源地址将回复包发出,不需要事先进行 SNAT 了。这个流程,如下所示:
1 | client |
当然,这也就意味着如果在一台宿主机上,没有任何一个被代理的 Pod 存在,比如上图中的 node 2,那么你使用 node 2 的 IP 地址访问这个 Service,就是无效的。此时,你的请求会直接被 DROP 掉。
kube-proxy
在 Kubernetes v1.0 版本,代理完全在 userspace 实现。Kubernetes v1.1 版本新增了 iptables 代理模式,但并不是默认的运行模式。从 Kubernetes v1.2 起,默认使用 iptables 代理。在 Kubernetes v1.8.0-beta.0 中,添加了 ipvs 代理模式
kube-proxy相当于service的管理方,业务流量不会走到kube-proxy,业务流量的负载均衡都是由内核层面的iptables或者ipvs来分发。
kube-proxy的三种模式:
一直以来,基于 iptables 的 Service 实现,都是制约 Kubernetes 项目承载更多量级的 Pod 的主要障碍。
ipvs 就是用于解决在大量 Service 时,iptables 规则同步变得不可用的性能问题。与 iptables 比较像的是,ipvs 的实现虽然也基于 netfilter 的钩子函数,但是它却使用哈希表作为底层的数据结构并且工作在内核态,这也就是说 ipvs 在重定向流量和同步代理规则有着更好的性能。
除了能够提升性能之外,ipvs 也提供了多种类型的负载均衡算法,除了最常见的 Round-Robin 之外,还支持最小连接、目标哈希、最小延迟等算法,能够很好地提升负载均衡的效率。
而相比于 iptables,IPVS 在内核中的实现其实也是基于 Netfilter 的 NAT 模式,所以在转发这一层上,理论上 IPVS 并没有显著的性能提升。但是,IPVS 并不需要在宿主机上为每个 Pod 设置 iptables 规则,而是把对这些“规则”的处理放到了内核态,从而极大地降低了维护这些规则的代价。这也正印证了我在前面提到过的,“将重要操作放入内核态”是提高性能的重要手段。
IPVS 模块只负责上述的负载均衡和代理功能。而一个完整的 Service 流程正常工作所需要的包过滤、SNAT 等操作,还是要靠 iptables 来实现。只不过,这些辅助性的 iptables 规则数量有限,也不会随着 Pod 数量的增加而增加。
ipvs 和 iptables 都是基于 Netfilter 实现的。
Kubernetes 中已经使用 ipvs 作为 kube-proxy 的默认代理模式。
1 | /opt/kube/bin/kube-proxy --bind-address=172.26.137.117 --cluster-cidr=172.20.0.0/16 --hostname-override=172.26.137.117 --kubeconfig=/etc/kubernetes/kube-proxy.kubeconfig --logtostderr=true --proxy-mode=ipvs |
port-forward
port-forward后外部也能够像nodePort一样访问到,但是port-forward不适合大流量,一般用于管理端口,启动的时候port-forward会固定转发到一个具体的Pod上,也没有负载均衡的能力。
1 | #在本机监听1080端口,并转发给后端的svc/nginx-ren(总是给发给svc中的一个pod) |
kubectl
looks up a Pod from the service information provided on the command line and forwards directly to a Pod rather than forwarding to the ClusterIP/Service port and allowing the cluster to load balance the service like regular service traffic.
The portforward.go Complete
function is where kubectl portforward
does the first look up for a pod from options via AttachablePodForObjectFn
:
The AttachablePodForObjectFn
is defined as attachablePodForObject
in this interface, then here is the attachablePodForObject
function.
To my (inexperienced) Go eyes, it appears the attachablePodForObject
is the thing kubectl
uses to look up a Pod to from a Service defined on the command line.
Then from there on everything deals with filling in the Pod specific PortForwardOptions
(which doesn’t include a service) and is passed to the kubernetes API.
Service 和 DNS 的关系
Service 和 Pod 都会被分配对应的 DNS A 记录(从域名解析 IP 的记录)。
对于 ClusterIP 模式的 Service 来说(比如我们上面的例子),它的 A 记录的格式是:..svc.cluster.local。当你访问这条 A 记录的时候,它解析到的就是该 Service 的 VIP 地址。
而对于指定了 clusterIP=None 的 Headless Service 来说,它的 A 记录的格式也是:..svc.cluster.local。但是,当你访问这条 A 记录的时候,它返回的是所有被代理的 Pod 的 IP 地址的集合。当然,如果你的客户端没办法解析这个集合的话,它可能会只会拿到第一个 Pod 的 IP 地址。
1 | #kubectl get pod -l app=mysql-r -o wide |
不是每个pod都会向DNS注册,只有:
- StatefulSet中的POD会向dns注册,因为他们要保证顺序行
- POD显式指定了hostname和subdomain,说明要靠hostname/subdomain来解析
- Headless Service代理的POD也会注册
Ingress
kube-proxy
只能路由 Kubernetes 集群内部的流量,而我们知道 Kubernetes 集群的 Pod 位于 CNI 创建的外网络中,集群外部是无法直接与其通信的,因此 Kubernetes 中创建了 ingress 这个资源对象,它由位于 Kubernetes 边缘节点(这样的节点可以是很多个也可以是一组)的 Ingress controller 驱动,负责管理南北向流量,Ingress 必须对接各种 Ingress Controller 才能使用,比如 nginx ingress controller、traefik。Ingress 只适用于 HTTP 流量,使用方式也很简单,只能对 service、port、HTTP 路径等有限字段匹配来路由流量,这导致它无法路由如 MySQL、Redis 和各种私有 RPC 等 TCP 流量。要想直接路由南北向的流量,只能使用 Service 的 LoadBalancer 或 NodePort,前者需要云厂商支持,后者需要进行额外的端口管理。有些 Ingress controller 支持暴露 TCP 和 UDP 服务,但是只能使用 Service 来暴露,Ingress 本身是不支持的,例如 nginx ingress controller,服务暴露的端口是通过创建 ConfigMap 的方式来配置的。
Ingress是授权入站连接到达集群服务的规则集合。 你可以给Ingress配置提供外部可访问的URL、负载均衡、SSL、基于名称的虚拟主机等。 用户通过POST Ingress资源到API server的方式来请求ingress。
1 | internet |
可以将 Ingress 配置为服务提供外部可访问的 URL、负载均衡流量、终止 SSL/TLS,以及提供基于名称的虚拟主机等能力。 Ingress 控制器 通常负责通过负载均衡器来实现 Ingress,尽管它也可以配置边缘路由器或其他前端来帮助处理流量。
Ingress 不会公开任意端口或协议。 将 HTTP 和 HTTPS 以外的服务公开到 Internet 时,通常使用 Service.Type=NodePort 或 Service.Type=LoadBalancer 类型的服务。
Ingress 其实不是Service的一个类型,但是它可以作用于多个Service,作为集群内部服务的入口。Ingress 能做许多不同的事,比如根据不同的路由,将请求转发到不同的Service上等等。
Ingress 对象,其实就是 Kubernetes 项目对“反向代理”的一种抽象。
1 | apiVersion: extensions/v1beta1 |
在实际的使用中,你只需要从社区里选择一个具体的 Ingress Controller,把它部署在 Kubernetes 集群里即可。然后,这个 Ingress Controller 会根据你定义的 Ingress 对象,提供对应的代理能力。
目前,业界常用的各种反向代理项目,比如 Nginx、HAProxy、Envoy、Traefik 等,都已经为 Kubernetes 专门维护了对应的 Ingress Controller。
一个 Ingress Controller 可以根据 Ingress 对象和被代理后端 Service 的变化,来自动进行更新的 Nginx 负载均衡器。
eBPF(extended Berkeley Packet Filter)和网络
eBPF允许程序对内核本身进行编程(即 通过程序动态修改内核的行为。传统方式要么是给内核打补丁,要么是修改内核源码 重新编译)。一句话来概括:编写代码监听内核事件,当事件发生时,BPF 代码就会在内核执行。
eBPF 最早出现在 3.18 内核中,此后原来的 BPF 就被称为 “经典” BPF(classic BPF, cBPF),cBPF 现在基本已经废弃了。很多人知道 cBPF 是因为它是 tcpdump
的包过滤语言。现在,Linux 内核只运行 eBPF,内核会将加载的 cBPF 字节码 透明地转换成 eBPF 再执行。如无特殊说明,本文中所说的 BPF 都是泛指 BPF 技术。
2015年eBPF 添加了一个新 fast path:XDP,XDP 是 eXpress DataPath 的缩写,支持在网卡驱动中运行 eBPF 代码(在软件中最早可以处理包的位置),而无需将包送 到复杂的协议栈进行处理,因此处理代价很小,速度极快。
BPF 当时用于 tcpdump,在内核中尽量前面的位置抓包,它不会 crash 内核;
bcc 是 tracing frontend for eBPF。
内核添加了一个新 socket 类型 AF_XDP。它提供的能力是:在零拷贝( zero-copy)的前提下将包从网卡驱动送到用户空间。
AF_XDP 提供的能力与 DPDK 有点类似,不过:
- DPDK 需要重写网卡驱动,需要额外维护用户空间的驱动代码。
- AF_XDP 在复用内核网卡驱动的情况下,能达到与 DPDK 一样的性能。
而且由于复用了内核基础设施,所有的网络管理工具还都是可以用的,因此非常方便, 而 DPDK 这种 bypass 内核的方案导致绝大大部分现有工具都用不了了。
由于所有这些操作都是发生在 XDP 层的,因此它称为 AF_XDP。插入到这里的 BPF 代码 能直接将包送到 socket。
Facebook 公布了生产环境 XDP+eBPF 使用案例(DDoS & LB)
- 用 XDP/eBPF 重写了原来基于 IPVS 的 L4LB,性能 10x。
- eBPF 经受住了严苛的考验:从 2017 开始,每个进入 facebook.com 的包,都是经过了 XDP & eBPF 处理的。
Cilium 1.6 发布 第一次支持完全干掉基于 iptables 的 kube-proxy,全部功能基于 eBPF。Cilium 1.8 支持基于 XDP 的 Service 负载均衡和 host network policies。
传统的 kube-proxy 处理 Kubernetes Service 时,包在内核中的 转发路径是怎样的?如下图所示:
步骤:
- 网卡收到一个包(通过 DMA 放到 ring-buffer)。
- 包经过 XDP hook 点。
- 内核给包分配内存,此时才有了大家熟悉的 skb(包的内核结构体表示),然后 送到内核协议栈。
- 包经过 GRO 处理,对分片包进行重组。
- 包进入 tc(traffic control)的 ingress hook。接下来,所有橙色的框都是 Netfilter 处理点。
- Netfilter:在 PREROUTING hook 点处理 raw table 里的 iptables 规则。
- 包经过内核的连接跟踪(conntrack)模块。
- Netfilter:在 PREROUTING hook 点处理 mangle table 的 iptables 规则。
- Netfilter:在 PREROUTING hook 点处理 nat table 的 iptables 规则。
- 进行路由判断(FIB:Forwarding Information Base,路由条目的内核表示,译者注) 。接下来又是四个 Netfilter 处理点。
- Netfilter:在 FORWARD hook 点处理 mangle table 里的iptables 规则。
- Netfilter:在 FORWARD hook 点处理 filter table 里的iptables 规则。
- Netfilter:在 POSTROUTING hook 点处理 mangle table 里的iptables 规则。
- Netfilter:在 POSTROUTING hook 点处理 nat table 里的iptables 规则。
- 包到达 TC egress hook 点,会进行出方向(egress)的判断,例如判断这个包是到本 地设备,还是到主机外。
- 对大包进行分片。根据 step 15 判断的结果,这个包接下来可能会:发送到一个本机 veth 设备,或者一个本机 service endpoint, 或者,如果目的 IP 是主机外,就通过网卡发出去。
Cilium 如何处理POD之间的流量(东西向流量)
如上图所示,Socket 层的 BPF 程序主要处理 Cilium 节点的东西向流量(E-W)。
- 将 Service 的 IP:Port 映射到具体的 backend pods,并做负载均衡。
- 当应用发起 connect、sendmsg、recvmsg 等请求(系统调用)时,拦截这些请求, 并根据请求的IP:Port 映射到后端 pod,直接发送过去。反向进行相反的变换。
这里实现的好处:性能更高。
- 不需要包级别(packet leve)的地址转换(NAT)。在系统调用时,还没有创建包,因此性能更高。
- 省去了 kube-proxy 路径中的很多中间节点(intermediate node hops) 可以看出,应用对这种拦截和重定向是无感知的(符合 Kubernetes Service 的设计)。
Cilium处理外部流量(南北向流量)
集群外来的流量到达 node 时,由 XDP 和 tc 层的 BPF 程序进行处理, 它们做的事情与 socket 层的差不多,将 Service 的 IP:Port 映射到后端的 PodIP:Port,如果 backend pod 不在本 node,就通过网络再发出去。发出去的流程我们 在前面 Cilium eBPF 包转发路径 讲过了。
这里 BPF 做的事情:执行 DNAT。这个功能可以在 XDP 层做,也可以在 TC 层做,但 在XDP 层代价更小,性能也更高。
总结起来,Cilium的核心理念就是:
- 将东西向流量放在离 socket 层尽量近的地方做。
- 将南北向流量放在离驱动(XDP 和 tc)层尽量近的地方做。
性能比较
测试环境:两台物理节点,一个发包,一个收包,收到的包做 Service loadbalancing 转发给后端 Pods。
可以看出:
- Cilium XDP eBPF 模式能处理接收到的全部 10Mpps(packets per second)。
- Cilium tc eBPF 模式能处理 3.5Mpps。
- kube-proxy iptables 只能处理 2.3Mpps,因为它的 hook 点在收发包路径上更后面的位置。
- kube-proxy ipvs 模式这里表现更差,它相比 iptables 的优势要在 backend 数量很多的时候才能体现出来。
cpu:
- XDP 性能最好,是因为 XDP BPF 在驱动层执行,不需要将包 push 到内核协议栈。
- kube-proxy 不管是 iptables 还是 ipvs 模式,都在处理软中断(softirq)上消耗了大量 CPU。
参考资料
https://imroc.io/posts/kubernetes/troubleshooting-with-kubernetes-network/ Kubernetes 网络疑难杂症排查方法
https://blog.csdn.net/qq_36183935/article/details/90734936 kube-proxy ipvs模式详解
http://arthurchiao.art/blog/ebpf-and-k8s-zh/ 大规模微服务利器:eBPF 与 Kubernetes
http://arthurchiao.art/blog/cilium-life-of-a-packet-pod-to-service-zh/ Life of a Packet in Cilium:实地探索 Pod-to-Service 转发路径及 BPF 处理逻辑
http://arthurchiao.art/blog/understanding-ebpf-datapath-in-cilium-zh/ 深入理解 Cilium 的 eBPF 收发包路径(datapath)(KubeCon, 2019)
利用 ebpf sockmap/redirection 提升 socket 性能
利用 eBPF 支撑大规模 K8s Service (LPC, 2019)
delay ack拉高实际rt的case
delay ack拉高实际rt的case
案例描述
开发人员发现client到server的rtt是2.5ms,每个请求1ms server就能处理完毕,但是监控发现的rt不是3.5(1+2.5),而是6ms,想知道这个6ms怎么来的?
如下业务监控图:实际处理时间(逻辑服务时间1ms,rtt2.4ms,加起来3.5ms),但是系统监控到的rt(蓝线)是6ms,如果一个请求分很多响应包串行发给client,这个6ms是正常的(1+2.4*N),但实际上如果send buffer足够的话,按我们前面的理解多个响应包会并发发出去,所以如果整个rt是3.5ms才是正常的。
分析
抓包来分析原因:
实际看到大量的response都是3.5ms左右,符合我们的预期,但是有少量rt被delay ack严重影响了
从下图也可以看到有很多rtt超过3ms的,这些超长时间的rtt会最终影响到整个服务rt
kubernetes calico 网络
kubernetes calico 网络
kubernetes 集群下安装 calico 网络
1 | kubectl apply -f https://docs.projectcalico.org/manifests/calico.yaml |
默认calico用的是ipip封包(这个性能跟原生网络差多少有待验证,本质也是overlay网络,比flannel那种要好很多吗?)
跨宿主机的两个容器之间的流量链路是:
cali-容器eth0->宿主机cali27dce37c0e8->tunl0->内核ipip模块封包->物理网卡(ipip封包后)—远程–> 物理网卡->内核ipip模块解包->tunl0->cali-容器
Calico IPIP模式对物理网络无侵入,符合云原生容器网络要求;使用IPIP封包,性能略低于Calico BGP模式;无法使用传统防火墙管理、也无法和存量网络直接打通。Pod在Node做SNAT访问外部,Pod流量不易被监控。
calico ipip网络不通
集群有五台机器192.168.0.110-114, 同时每个node都有另外一个ip:192.168.3.110-114,部分节点之间不通。每台机器部署好calico网络后,会分配一个 /26 CIRD 子网(64个ip)。
案例1
目标机是10.122.127.128(宿主机ip 192.168.3.112),如果从10.122.17.64(宿主机ip 192.168.3.110) ping 10.122.127.128不通,查看10.122.127.128路由表:
1 | [root@az3-k8s-13 ~]# ip route |grep tunl0 |
在10.122.127.128抓包如下,明显可以看到icmp request到了 tunl0网卡,tunl0网卡也回复了,但是回复包没有经过kernel ipip模块封装后发到eth1上:
正常机器应该是这样,上图不正常的时候缺少红框中的reply:
解决:
1 | ip route del 10.122.17.64/26 via 10.122.127.128 dev tunl0 ; |
删除错误路由增加新的路由就可以了,新增路由的意思是从tunl0发给10.122.17.64/26的包下一跳是 192.168.3.110。
via 192.168.3.110 表示下一跳的ip
onlink参数的作用:
使用这个参数将会告诉内核,不必检查网关是否可达。因为在linux内核中,网关与本地的网段不同是被认为不可达的,从而拒绝执行添加路由的操作。
因为tunl0网卡ip的 CIDR 是32,也就是不属于任何子网,那么这个网卡上的路由没有网关,配置路由的话必须是onlink, 内核存也没法根据子网来选择到这块网卡,所以还会加上 dev 指定网卡。
案例2
集群有五台机器192.168.0.110-114, 同时每个node都有另外一个ip:192.168.3.110-114,只有node2没有192.168.3.111这个ip,结果node2跟其他节点都不通:
1 | #calicoctl node status |
从node4 ping node2,然后在node2上抓包,可以看到 icmp request都发到了node2上,但是node2收到后没有发给tunl0:
所以icmp没有回复,这里的问题在于kernel收到包后为什么不给tunl0
同样,在node2上ping node4,同时在node2上抓包,可以看到发给node4的request包和reply包:
从request包可以看到src ip 是0.111, dest ip是 3.113,因为 node2 没有192.168.3.111这个ip
非常关键的我们看到node4的回复包 src ip 不是3.113,而是0.113(根据node4的路由就应该是0.113)
这就是问题所在,从node4过来的ipip包src ip都是0.113,实际这里ipip能认识的只是3.113.
如果这个时候在3.113机器上把0.113网卡down掉,那么3.113上的:
10.122.124.128/26 via 192.168.0.111 dev tunl0 proto bird onlink 路由被自动删除,3.113将不再回复request。这是因为calico记录的node2的ip是192.168.0.111,所以会自动增加
解决办法,在node4上删除这条路由记录,也就是强制让回复包走3.113网卡,这样收发的ip就能对应上了
1 | ip route del 192.168.0.0/24 dev eth0 proto kernel scope link src 192.168.0.113 |
最终OK后,node4上的ip route是这样的:
1 | [root@az3-k8s-14 ~]# ip route |
正常后的抓包, 注意这里drequest的est ip 和reply的 src ip终于一致了:
1 | //request |
总结下来这两个案例都还是对路由不够了解,特别是案例2,因为有了多个网卡后导致路由更复杂。calico ipip的基本原理就是利用内核进行ipip封包,然后修改路由来保证网络的畅通。
参考资料
https://morven.life/notes/networking-3-ipip/