tcpdump 抓包显示 toa 源地址

LVS 是个很流行的高性能负载均衡程序。不过因为工作在 3-4 层,对被代理的后端来说,就只能看到 LVS 的源地址,看不到实际的用户来源 IP 地址了。所以 LVS 提供了一个 toa 内核模块,在后端安装后,可以从 TCP 头的扩展段里获取源 IP 地址。不过这只限于应用层面。系统层面的工具比如 netstat,tcpdump 等仍然只能看到 LVS 的源地址,想通过 tcpdump 抓包过滤或者分析实际来源地址就不行了。不过既然在 TCP 头里有这数据我们就还是可以利用。

tcpdump 输出的内容里,如果有 toa 扩展,会多出一截 Unknown Option。其中后面的 32bit 就是实际的源 IP。如

... ack 1562404315, win 29, options [Unknown Option 20030390a0a0101], length 0

按 TOA 的结构:

struct toa_data {
	__u8 opcode;
	__u8 opsize;
	__u16 port;
	__u32 ip;
};

这里的 0a0a0101 就是 10.10.1.1,3039就是源端口 12345。

因为 TCP 头里多了扩展字段,所以 TCP 头中的 data_offset 就会大于 5(words)。用 ((tcp[12] & 0xf0)>>4) > 5 这个条件过滤可以只处理带 toa 头的包。TCP 扩展字段从 20 字节开始。所以可以直接用比如 tcp[24:4]==0x0a0a0101 过滤指定源 IP 的包。

下面这段脚本就会输出发往 1234 端口的实际源 IP 的地址。

sudo tcpdump -nn -l "tcp dst port 1234 and ((tcp[12] & 0xf0)>>4) > 5" 2>/dev/null | \
    awk 'match($0, "Unknown Option ([0-9a-f]+)",a) {print strtonum("0x" substr(a[1],8,2)) "." strtonum("0x" substr(a[1],10,2)) "." strtonum("0x" substr(a[1],12,2)) "." strtonum("0x" substr(a[1],14,2)); fflush()}'

升级 centos 内核到 4.x

centos 发行版是跟随 redhat 发行版的。在内核版本上比较保守。比如 centos 6.x 分支最高是 2.6.32 内核。虽然也会 backport 一些高版本的功能,但是总会需要的功能没有的情况。

不过 centos 的仓库里其实藏着一个 4.x 的内核。

在 centos 仓库里,有一个 centos-release-xen 的包,是 由 Centos、CitrixXen、Godaddy、Rackspace 共同维护的 Xen4CentOS 项目的一部分。

Xen4CentOS
The project, while hosted at centos.org, is a collaboration between the Xen Project, the Citrix Xen open source teams, the CentOS developers, GoDaddy Cloud Operations team, Rackspace Hosting and members of the CentOS QA Team.

这个包中其实并没有 xen 的工具,只是一个内核而已。安装这个包就能将 linux 内核升级到高版本。

yum install centos-release-xen
yum update

重启,然后 uname -r 检查一下就会发现内核版本已经到了比如 4.9.58-29.el6.x86_64

linux capabilities

在 linux 系统中,很多操作是需要 root 用户权限才能操作的。比如 chown,改变进程 uid,使用 raw socket 等等。要不就得用 sudo 提升权限,如果想让每个用户都能用特权来执行一个程序,配置管理和权限控制就很麻烦了。还有一个办法是使用粘滞位,通过 chmod +s,可以让一个 owner 为 root 的可执行文件再运行时具有 root 权限。在一些发型版中,ping 命令就是这么干的。给 ping 命令加上粘滞位以后,普通用户也可以使用这个命令通过 raw socket 发送 icmp 包了。不过这样一来,这个程序也就无所不能了。万一程序有啥漏洞,就容易造成严重后果。有没有办法只给这个程序开所需要的权限呢?其实是可以的。linux 有一套 capabilities 机制就是用来实现这个。

事实上,linux 本身对权限的检查就是基于 capabilities 的,root 用户有全部的 capabilities,所以啥都能干。如果想给一个可执行文件加上某个 capability,可以用 setcap 命令,如

setcap cap_net_raw=+ep ping

就可以给 ping 命令加上使用 raw socket 的权限。cap_net_raw 是 capability 的名字,后面是 mode,可以有

  • e:表示是否激活该 capability
  • p:是否允许进程设置该 capability
  • i:子进程是否能继承 capabilities

+ 表示启用,- 表示禁用。
这样执行以后,普通用户执行这个 ping 命令,也可以正常玩耍了。而且这个 ping 命令只获得了 raw socket 的权限。
通过 getcap ping 可以查看这个程序所拥有的 capabilities。

实现上,是通过 setxattr 系统调用,为文件加上 security.capability 的扩展属性。

man 7 capabilities 中可以看到所有可用的 capabilities。

man 3 cap_from_text 中可以看到关于 capability mode 的表达式说明。

page cache 造成 java 长时间 gc

最近在升级一个 java 应用时,在刚启动不久的时候发生了长时间的 gc,时间到了数秒,业务访问纷纷超时。

看了下 gc log:

2016-12-06T22:50:44.256+0800: 13.632: [GC pause (G1 Evacuation Pause) (young)
Desired survivor size 13631488 bytes, new threshold 1 (max 15)
- age   1:   27212320 bytes,   27212320 total
 13.632: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 4890, predicted base time: 28.66 ms, remaining time: 71.34 ms, target pause time: 100.00 ms]
 13.632: [G1Ergonomics (CSet Construction) add young regions to CSet, eden: 89 regions, survivors: 13 regions, predicted young region time: 6880.88 ms]
 13.632: [G1Ergonomics (CSet Construction) finish choosing CSet, eden: 89 regions, survivors: 13 regions, old: 0 regions, predicted pause time: 6909.54 ms, target pause time: 100.00 ms]
 22.969: [G1Ergonomics (Heap Sizing) attempt heap expansion, reason: recent GC overhead higher than threshold after GC, recent GC overhead: 65.85 %, threshold: 10.00 %, uncommitted: 0 bytes, calculated expansion amount: 0 bytes (20.00 %)]
, 9.3374979 secs]
...
      [Object Copy (ms):  8821.5  6481.7  8743.0  6477.4  8173.8  6477.4  6477.5  6477.3  6792.5  6477.2  6477.0  6476.7  9331.9  9250.5  6476.0  6476.0  6471.3  7438.9
       Min: 6471.3, Avg: 7211.0, Max: 9331.9, Diff: 2860.6, Sum: 129797.8]
...

这还是一次 young gc,说明 java 自身的堆是够用的,并没有升级为 mix。但是为什么用了这么长时间呢?注意到大部分时间都是花在了 Object Copy 上。内存 copy 哪怕是几个 G,都不可能花那么久。这里必有蹊跷。

搜索到的结果里,有提到内存不足,使用 swap 造成的情况。但是我们的内存是足够的,而且已经禁用了 swap。但是这倒是给了个提示。看了下当时的内存情况,有大量内存在 cache 中。于是想到了一种可能:有大量文件写入,使 page cache 填满了内存。而我们的 java 应用使用了 4G 的堆大小,刚启动的时候,大部分虚拟内存都还没有分配实际的物理内存。而在 java 在 gc 的过程中需要映射物理内存时,需要等待系统将这部分内存释放出来。如果这时候脏页的数量大,就需要等待脏页刷写到磁盘。一涉及 IO,时间就完全没谱了。

然后在测试环境试着重现一下,先用 dd 写几个大文件,直到剩余的空闲内存很少,大部分都在 cache 中,然后再次启动应用,果然重现了。临时处理办法就是在启动应用的时候,如果发现空闲内存不够,就先用 vmtouch 刷一下占用 cache 的文件缓存(通常是日志)。为了更好的避免类似情况发生,就要控制 page cache 里脏页数量,这样就算是需要释放 cache 也不会花多少时间。可以通过 vm.dirty_background_ratiovm.dirty_ratio 内核参数来控制。

一次连接超时问题排查的历程

我们有一个 java 应用,启动的时候要初始化连接池,在连接一堆 sharding 过的 DB 时,经常会有一部分连接超时失败的,集中在一两台后端机器上,但每次失败的后端服务器却又不固定,也并不是每次启动都能遇到。超时时间设为了 50ms,看起来有点短但是对局域网,和压力并不算大的 DB 来说,这个时间已经长得匪夷所思了。后来尝试调大成 100ms,还是有失败的。但是如果启动成功后,却没再记录到过连接超时的情况。

排查网络问题首先是抓包,本来打算看看是不是对端响应慢有啥重传的,结果发现了更神奇的事情:发起 TCP 连接的 SYN 包不够数!也就是说,有几个连接根本连 SYN 包都没发出去过。还发现有一两个连接收到了 DB 服务器的 SYN/ACK 后,居然发了 RST !所有服务器有响应的 SYN/ACK,包括被 RST 的,延迟都不到 0.2ms,速度挺正常的。那些个丢了的 SYN 和被 RST 的是怎么回事呢?

然后再用 strace 套着启动试试。这回也顺带了解了 java 在 linux 上连接超时的实现方式。首先发起一个非阻塞的 connect,然后用 poll 来等待直到超时,如果超时,则把 socket shutdown 了。然而在 strace 的记录里,所有的 connect 系统调用一个不少,只是在 poll 的时候超时了。这倒是可以解释前面抓包里 RST 的原因。在服务端 SYN/ACK 返回的时候,客户端已经超时 shutdown 了 socket,这时候自然就会返回 RST。但是奇怪的是为啥 connect 了却没看到 SYN 包,从被 RST 的现象推断,是从 connect 到发出 SYN 有了延迟。有的延迟发出后,返回的时候超时,于是就被 RST,绝大部分一直延迟到超时都没发出,于是就再也没了。但是为啥从 connect 到发出 SYN 包会有那么大的延迟呢?

于是去看了一下内核 connect 的实现,connect 系统调用对 TCP 来说,就是一路从数据构造 TCP 包、IP 包、Ethernet 包进入网卡的 QDisc。对非阻塞的 connect 来说就到此为止,返回 EINPROGRESS。对阻塞 connect,还会继续等待三次握手完成。进入 QDisc 后就是网卡驱动通过 DMA Engine 来发包了。因为是非阻塞 connect,这一路构造包的过程中没想出可以阻塞的点,于是怀疑是不是在 QDisc 或者 DMA Engine 发生了什么事情,研究实验了很久而无所得。

再一次碰到问题时,又抓了次包。这回凑巧没过滤 ARP 包,于是在结果里看到了一些查询 DB 服务器 MAC 地址的 ARP 请求。突然想起来,如果本地 ARP 缓存没命中的话,ARP 请求也是一个可能会有延迟的点。这个过程是发生在从 IP 包到构造 Ethernet 包的过程中的。于是把所有 ARP 包过滤出来看,真的发现对连接失败的 IP,ARP 请求第一次没有响应,隔 1s 重试以后才成功。就是这 1s 足以让超时时间是 50ms 的连接失败好多回了。

至于为啥 ARP 请求会超时倒不是啥难题。网络上有广播限流,之前也碰到过 ARP、VRRP 包被干的情况。至于启动成功后,应用和后端 DB 一直会有数据来往,ARP 缓存也就不会再被清掉,这也就解释了为啥问题只会出现在启动的时候。搞清楚原因以后,就不担心运行当中会出故障了。至于启动失败,重试就行了,或者 ping 一把后端 IP,产生一次 ARP 缓存就能绕过。彻底解决问题反倒不是个很紧迫的事了。

中间费了这么大劲,最后发现的问题却如此操蛋没技术含量,感觉挺失望的。不过大部分时候总是这样。隔壁老王有句名言,每个匪夷所思的问题背后,都有一个啼笑皆非的原因。

等待一个独立进程退出并获取 exit code

linux 里,对于进程的子进程,父进程可以用 wait、waitpid 来等待结果。但是对于一个独立的进程就不行了。

有时候想监控一个进程,或者在父进程异常退出后想找回子进程状态,就只能另辟蹊径。于是,想了个通过 ptrace 来跟踪进程退出的办法,做了个小程序:

https://github.com/xiezhenye/waitpid/

可以通过 waitpid 来等待一个独立进程退出并获取 exit code。

supervisord 的 fd 泄露问题

线上发现有几个 supervisord 启动的 php 后台进程没有正常工作。上去 strace 了一下,发现卡在了写 stdout 上:

# strace -p 2509
Process 2509 attached - interrupt to quit
write(1, "[2015-05-22 18:48:19]  **************"..., 82

为啥写 stdout 会卡住呢?看了进程 2509 的 fd 1,原来是个管道,那就是管道对面的进程没有读取数据了。于是就查找了一下管道对面是哪个进程。结果却很神奇,管道对面居然也是进程 2509 自己!

# lsof | awk '$8=="124628537" && $9=="pipe"'
php        2509       www    1w     FIFO                0,8       0t0  124628537 pipe
php        2509       www    2w     FIFO                0,8       0t0  124628537 pipe
php        2509       www 1066r     FIFO                0,8       0t0  124628537 pipe
php        2509       www 1067w     FIFO                0,8       0t0  124628537 pipe

这就难怪了,显然 php 自己不会干自己读自己写的事,于是就卡住了。但是这又是怎么造成的呢?看到一个进程自己和自己管道,就想到了这应该是父子进程间用管道 ipc,然后没关干净造成的吧。php 是由 supervisord 启动的,又了解到写往 stdout 的是传给 supervisord 记日志用的,同时发现,这个 php 的父进程已经变成了 1,也就是说,supervisord 曾经挂过。那么这问题很可能就是 supervisord 造成的。那么究竟是怎么形成的,又如何避免呢?

于是就去翻了 supervisors 的代码,终于明白了原因。supervisors 启动子进程的流程是这样的。

  1. 调用 pipe 生成一对管道
  2. fork 生成子进程
    父进程
    1. 关闭管道自己不用的端
    子进程
    1. dup2 将管道的 fd 覆盖掉自己的 0 1 2 即 stdin, stdout, stderr
    2. 关闭 3 ~ minfds 的所有 fd,只保留 0 1 2
    3. 初始化,execve 执行程序替换自身
  3. 这里的 minfds 是通过 supervisord 的启动参数设置的,默认为 1024。在 supervisord 的初始过程中,如果发现环境中的 rlimit 小于设置的值,会试图用 minfd 和 minproc 去设置相应的 rlimit 来满足需求,但是当环境中的 rlimit 大于设置时却没有处理,造成实际使用的 RLIMIT_NOFILE 大于 minfd。

    出问题的 supervisord 并没有设置这个参数,而系统的 rlimit 的默认 max open files 是 10240。pipe 生成的 fd 是 1066,大于 1024,于是就没有被清理掉。原本正常的管道应该是

    php:1 ----------- supervisord: 1066
    

    结果成了

    php:1 ----------- supervisord: 1066
    php:1066 --/
    

    最后在 supervisord 异常退出时,
    原本应该是

    php:1 ----------- XXX
    

    结果成了

    php:1 ----------- XXX
    php:1066 --/
    

    正常情况下,supervisord 挂了,管道也就坏了。php 往坏了的管道写数据就会触发 Broken pipe,默认就也会被干掉,不会造成现在的后果。但是由于有一头没清理掉,管道就还没坏,只是存活两头都是 php 自己,再往里写数据,就由于没人去读而卡死了。

    要解决问题,就必须在启动 supervisord 时设置 `-a/–minfds` 不小于环境中 max open file 值。一个更好的习惯是,让系统的 soft RLIMIT_NOFILE 保持默认的 1024,调大 hard RLIMIT_NOFILE。然后通过 supervisord 的参数来设置需要的 rlimit。这个值也并不是越大越好,在所有进程退出时,和类似 supervisord 启动子进程时都需要遍历关闭 RLIMIT_NOFILE 的所有 fd。值太大会影响系统性能,同时每个进程的 fd 表也会增大,进程数量多的情况下,还是会多费些内存的。

    最后还是去给 supervisord 提交了一个 issue:https://github.com/Supervisor/supervisor/issues/690

设置 linux 命令缓冲模式

默认情况下,*nix 命令的 stdout 和 stdin 如果是在终端中是行缓冲,stderr 则是无缓冲。而这些标准输入输出如果是在管道中或重定向文件则是全缓冲。有时候使用管道处理数据的时候,并不希望管道后面的命令一直阻塞等待前一个的输出填满缓冲区刷新的时候才能处理,而是希望能即时看到数据。

有些命令提供了参数来设置缓冲模式,比如 tcpdump 可以使用 -l 参数来强制设置为行缓冲,awk、grep、sed 等也有这样的参数。但是也还是有很多命令行工具并没有提供这样的功能,这时候可以利用 stdbuf 命令来设置。

比如

stdbuf -oL tcpdump ... | grep ...

就可以把 tcpdump 的 stdout 设置为行缓冲,起到同样的效果。同样可以用 -i, -e 来设置 stdin 和 stderr 的缓冲模式。可以设置为 L 表示行缓冲,0 表示无缓冲,或者设置一个指定的缓冲区大小,如 4K 。这样无论命令是否支持设置缓冲模式都可以解决这些问题了。

获取 MySQL 崩溃时的 core file

对于一般进程,要让进程崩溃时能生成 core file 用于调试,只需要设置 rlimit 的 core file size > 0 即可。比如,用在 ulimit -c unlimited 时启动程序。

对 MySQL 来说,由于 core file 中会包含表空间的数据,所以默认情况下为了安全,mysqld 捕获了 SEGV 等信号,崩溃时并不会生成 core file,需要在 my.cnf 或启动参数中加上 core-file。

但是即使做到了以上两点,在 mysqld crash 时还是可能无法 core dump。还有一些系统参数会影响 core dump。以下脚本可供参考:

echo 2 >/proc/sys/fs/suid_dumpable
chmod 0777 /var/crash
echo /var/crash/core> /proc/sys/kernel/core_pattern
echo 1 >/proc/sys/kernel/core_uses_pid

由于 mysql 通常会以 suid 方式启动,所以需要打开 suid_dumpable 。对于 core_pattern,最好指定一个保证可写的绝对路径。

之后,就可以用 kill -SEGV 让 mysqld 崩溃,测试一下能不能正常产生 core file 了。

不使用 expect 实现自动化 ssh 密码认证

一般来说,自动化通过 ssh 执行操作或者通过 scp 传文件首先得过 ssh 认证这一关。采用公钥认证是最方便安全的方式。但是有时候不得不使用密码认证。而 ssh 默认是直接读写终端来输出提示信息和读入密码的,所以没法直接用 echo password | ssh ... 的方式来认证。expect 是最常用的用于解决这类问题的工具。但是这玩意实在是很不好用,也不能保证一定安装过。

好在 ssh 还是开了一扇窗,让我们可以实现这点。ssh 有个环境变量,SSH_ASKPASS ,设置了这个环境变量,并且当前会话不是终端时,ssh 在认证时会启动这个程序,从这个程序的标准输出来读取密码。这个功能本来是用于图形终端的,所以还要设置另一个环境变量 DISPLAY=’none:0’,让 ssh 不要试图访问 X11 。至于让进程脱离终端,使用 setsid 就可以了。下面这个例子就展示了自动化实现密码认证并执行命令。

echo  'echo BEGIN!; ls /' | setsid env SSH_ASKPASS='/root/pswd.sh' DISPLAY='none:0' ssh root@127.0.0.1 2>&1
Pseudo-terminal will not be allocated because stdin is not a terminal.
BEGIN!
bin
boot
data
dev
etc
home
lib
lib64
lost+found
media
mnt
opt
proc
root
sbin
selinux
srv
sys
tmp
usr
var

例子里的 /root/pswd.sh 只需要简单输出密码,并确保当前用户可执行就可以了。比如

#!/bin/bash
echo 'PASSWORD'