Tag Archives: Linux

找出进程当前系统调用

当一个程序发生故障时,有时候想通过了解该进程正在执行的系统调用来排查问题。通常可以用 strace 来跟踪。但是当进程已经处于 D 状态(uninterruptible sleep)时,strace 也帮不上忙。这时候可以通过

cat /proc/<PID>/syscall

来获取当前的系统调用以及参数。

这里用最近排查的一个问题为例。碰到的问题是,发现一台服务器在执行 pvcreate 创建物理卷的时候卡死,进程状态为 D

# ps aux|grep pvcreate
root      8443  0.0  0.0  27096  2152 ?        D    Apr04   0:00 pvcreate /dev/sddlmac
...

D 状态实际是在等待系统调用返回。那么来看看究竟在等待什么系统调用

B0313010:~ # cat /proc/8443/syscall
0 0x7 0x70f000 0x1000 0x0 0x7f33e1532e80 0x7f33e1532ed8 0x7fff3a6b8718 0x7f33e128cf00

第一个数字是系统调用号,后面是参数。不同的系统调用所需的参数个数不同。这里的字段数是按最大参数数量来的,所以不一定每个参数字段都有价值。那么怎么知道系统调用号对应哪个系统调用呢?在头文件 /usr/include/asm/unistd_64.h 中都有定义。也可以用个小脚本来快速查找:

#!/bin/bash
# usage: whichsyscall <syscall_nr>
nr="$1"
file="/usr/include/asm/unistd_64.h"
gawk '$1=="#define" && $3=="'$nr'" {sub("^__NR_","",$2);print $2}' "$file"

对于不同的系统调用的参数,可以通过 man 2 <系统调用名> 查阅。如 man 2 read。对刚才那个例子来说,0 就对应了 read 调用。而 read 调用的第一个参数是文件描述符。

之后用 lsof 找到 7 对应的是什么文件

#  lsof -p 8443
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF     NODE NAME
......
pvcreate 8443 root    5u   CHR 10,236      0t0    19499 /dev/mapper/control
pvcreate 8443 root    6u   BLK  253,1   0t8192 36340797 /dev/dm-1
pvcreate 8443 root    7u   BLK  253,5      0t0 35667968 /dev/dm-5

结果发现是个 device mapper 的设备文件。最后顺藤摸瓜,发现这个文件是 multipathd 创建的。而系统应当使用的是存储厂商提供的多路径软件。问题是由于同时开启了 multipathd 造成冲突导致的。

/proc/<PID>/syscall 对排查 D 状态进程很有用。不过在 2.6.18 内核上并不支持,具体从哪个内核版本开始有这个功能,还没查到。不过至少从在 2.6.32 以上版本都是支持的。

VLAN 和策略路由

VLAN(虚拟局域网)通过控制分派出入局域网的数据包到正确的出入端口,实现对不同设备进行逻辑分组管理。一个 VLAN 相当于广播域,它能将广播控制在一个 VLAN 内部。而不同 VLAN 之间或 VLAN 与 LAN / WAN 的数据通信必须通过网络层完成。VLAN 可以降低局域网内大量数据流通时,因无用数据包过多导致雍塞的问题,以及提升局域网的安全性。

对 Linux 来说,通常通过 vconfig 或 ip link add 来配置 vlan,创建虚拟 VLAN 网络接口。如

ip link add link eth0 name vlan100 type vlan id 100
ip link change vlan100 up
ip addr add 10.10.100.201/24 dev vlan100

创建了一个 VLAN 接口 vlan100,对应 VLAN 号是 100,并配置了 IP 10.10.100.201。此时,同一个 VLAN 中的机器相互间用 VLAN 的 IP 都可以访问。但是如果两个机器各自位于不同的 VLAN,则相互之间是无法通过 IP 访问,也无法 ping 通。

Linux 的路由通常情况下是按 IP 包的目的地址查询路由表的。如果 IP 包的来源和目的位于不同的 VLAN,或者一个位于 VLAN ,另一个是普通的链路,就会对应不同的网络接口。响应包根据路由表查询得到的网络接口,由于响应包的目的地址不在 VLAN 内,就不会从 VLAN 的接口返回,通常会路由到默认路由。而默认路由上并没有配置相应IP,因此无法通过该接口发生 IP 包,由此导致无法访问。

既然是由于这个原因,那想要解决就有两种途径。一是允许在这种情况下从不同的接口中发生 IP 包,再通过默认网关转发。可以通过更改系统 Linux 系统配置实现:

echo 1 >/proc/sys/net/ipv4/conf/all/accept_local
echo 2 >/proc/sys/net/ipv4/conf/all/rp_filter

配置后,相互就可以 ping 通了,通过 IP 也可以访问了。

另一种途径就是配置路由规则,让响应包从 VLAN 接口返回,再通过 VLAN 网关转发。也就是策略路由。
首先为 VLAN 定义一个路由表

echo "100 vlan100" >/etc/iproute2/rt_tables

这里 100 是路由表的 ID,vlan100 是路由表的名字。都可以随便起,只要不和现有 ID 和名字重复即可。这儿就用和 VLAN ID、VLAN 接口名相同的数字和名字。

然后为路由表设置默认网关

ip route add default via 10.10.100.1 dev vlan100 table vlan100

这里的 10.10.100.1 就是该 VLAN 的默认网关。dev vlan100 是 VLAN 接口名 table vlan100 是路由表名。

然后为 VLAN 对应的 IP 段添加源地址路由规则,使用刚定义的路由表。

ip rule add from 10.10.100.0/24 table vlan100

这样源地址为 VLAN 100 的包就会根据规则从 VLAN 接口转往 VLAN 的默认网关了。

配置完成后,同样可以实现互通。那这两种方式有什么区别呢?通过抓包看看。在一台不在 VLAN 中的机器持续 ping IP,在 VLAN 的机器上用 tcpdump 抓包。从实际网络接口上抓 ping 的 icmp 包,加上 -e 输出以太网头。
使用系统配置的结果如下:

# tcpdump -e -n icmp -i eth0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 96 bytes
10:49:50.640326 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 559, length 64
10:49:50.640351 1a:42:cb:eb:bb:e9 > 90:b1:1c:42:9e:18, ethertype IPv4 (0x0800), length 98: 10.10.100.201 > 10.186.21.101: ICMP echo reply, id 20246, seq 559, length 64
10:49:51.641921 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 560, length 64
10:49:51.641942 1a:42:cb:eb:bb:e9 > 90:b1:1c:42:9e:18, ethertype IPv4 (0x0800), length 98: 10.10.100.201 > 10.186.21.101: ICMP echo reply, id 20246, seq 560, length 64
10:49:52.643386 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 561, length 64

使用策略路由的结果如下:

# tcpdump -e -n icmp -i eth0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 96 bytes
10:36:13.098805 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 10.186.21.101 > 10.10.100.201: ICMP echo request, id 32277, seq 1695, length 64
10:36:13.098830 1a:42:cb:eb:bb:e9 > 90:b1:1c:42:9e:18, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 10.10.100.201 > 10.186.21.101: ICMP echo reply, id 32277, seq 1695, length 64
10:36:14.100301 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 10.186.21.101 > 10.10.100.201: ICMP echo request, id 32277, seq 1696, length 64
10:36:14.100323 1a:42:cb:eb:bb:e9 > 90:b1:1c:42:9e:18, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 10.10.100.201 > 10.186.21.101: ICMP echo reply, id 32277, seq 1696, length 64

可以看出,使用系统配置的方式由于没有从 VLAN 接口发出包,因此发出的包虽然 IP 头是对的,但是丢失了 VLAN TAG 数据。

以上环境是在 xen 虚拟化环境中实现的。在宿主机上的 VLAN 相应设备上抓包也可以看出差异:
使用系统配置:

# tcpdump -n -e -i xapi0 icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on xapi0, link-type EN10MB (Ethernet), capture size 65535 bytes
10:55:40.426937 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype IPv4 (0x0800), length 98: 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 908, length 64
10:55:41.428269 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype IPv4 (0x0800), length 98: 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 909, length 64
10:55:42.429593 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype IPv4 (0x0800), length 98: 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 910, length 64
10:55:43.430682 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype IPv4 (0x0800), length 98: 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 910, length 64

使用策略路由:

# tcpdump -n -e -i xapi0 icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on xapi0, link-type EN10MB (Ethernet), capture size 65535 bytes
10:55:09.392195 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype IPv4 (0x0800), length 98: 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 877, length 64
10:55:09.392586 1a:42:cb:eb:bb:e9 > 90:b1:1c:42:9e:18, ethertype IPv4 (0x0800), length 98: 10.10.100.201 > 10.186.21.101: ICMP echo reply, id 20246, seq 877, length 64
10:55:10.393333 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype IPv4 (0x0800), length 98: 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 878, length 64
10:55:10.393646 1a:42:cb:eb:bb:e9 > 90:b1:1c:42:9e:18, ethertype IPv4 (0x0800), length 98: 10.10.100.201 > 10.186.21.101: ICMP echo reply, id 20246, seq 878, length 64
10:55:10.394210 90:b1:1c:42:9e:18 > 1a:42:cb:eb:bb:e9, ethertype IPv4 (0x0800), length 98: 10.186.21.101 > 10.10.100.201: ICMP echo request, id 20246, seq 879, length 64
10:55:10.394433 1a:42:cb:eb:bb:e9 > 90:b1:1c:42:9e:18, ethertype IPv4 (0x0800), length 98: 10.10.100.201 > 10.186.21.101: ICMP echo reply, id 20246, seq 879, length 64

可以看到,只有请求的包通过了 VLAN,而响应包并没有出现在 VLAN 中。

使用系统配置方式的副作用是,造成了实际上返回包并不属于该 VLAN。这样的话,在更严格的网络配置下可能无法使用,同时也失去了 VLAN 在流量隔离、流量监控、流量限制方面的意义。因此,正确的方式还是要通过策略路由实现。

某些 linux 版本中 netstat 的 bug

之前在 SLES 11 SP2 上观察到 netstat -lntp 时,PID 一列有部分进程显示为 – 。

# netstat -lntp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 0.0.0.0:42659           0.0.0.0:*               LISTEN      32555/rpc.statd
tcp        0      0 0.0.0.0:4996            0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:4998            0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:4999            0.0.0.0:*               LISTEN      -
tcp        0      0 0.0.0.0:111             0.0.0.0:*               LISTEN      2095/rpcbind
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      2445/sshd
tcp        0      0 :::111                  :::*                    LISTEN      2095/rpcbind
tcp        0      0 :::9010                 :::*                    LISTEN      26369/java
tcp        0      0 :::22                   :::*                    LISTEN      2445/sshd
tcp        0      0 ::1:631                 :::*                    LISTEN      2457/cupsd
tcp        0      0 ::1:25                  :::*                    LISTEN      2559/master
tcp        0      0 :::57626                :::*                    LISTEN      32555/rpc.statd

因为是以 root 用户运行的,显然与权限无关。经过 google,发现这是 net-tools 包的一个 bug。当Socket id 大于 2^31 时,会造成无法显示进程信息。这个 bug 不仅仅存在于 SUSE。如:

https://bugs.launchpad.net/ubuntu/+source/net-tools/+bug/1015026
http://lists.alioth.debian.org/pipermail/pkg-net-tools-maintainers/2009-March/000075.html

如果无法通过升级解决,还可以用一个替代命令来查看哪些端口被那个进程使用。

# lsof -n -P -i TCP | gawk '$10=="(LISTEN)"'
rpcbind    2095    root    8u  IPv4     4766      0t0  TCP *:111 (LISTEN)
rpcbind    2095    root   11u  IPv6     4771      0t0  TCP *:111 (LISTEN)
mysqld     2168 demo3ad   13u  IPv4 13203151      0t0  TCP *:4996 (LISTEN)
sshd       2445    root    3u  IPv4     4868      0t0  TCP *:22 (LISTEN)
sshd       2445    root    4u  IPv6     4870      0t0  TCP *:22 (LISTEN)
cupsd      2457    root    1u  IPv6     6482      0t0  TCP [::1]:631 (LISTEN)
master     2559    root   12u  IPv6     7254      0t0  TCP [::1]:25 (LISTEN)
mysqld    12060 demo1ad   13u  IPv4  6956029      0t0  TCP *:4999 (LISTEN)
mysqld    23358 test1ad   13u  IPv4  6967097      0t0  TCP *:4998 (LISTEN)
java      26369    root   55u  IPv6  4321975      0t0  TCP *:9010 (LISTEN)
rpc.statd 32555    root    9u  IPv4   339698      0t0  TCP *:42659 (LISTEN)
rpc.statd 32555    root   11u  IPv6   339706      0t0  TCP *:57626 (LISTEN)

如果需要增加过滤条件,也可以直接在 gawk 中进行如

# lsof -n -P -i TCP | gawk '$1=="mysqld" && $10=="(LISTEN)"'

用 cgruops 管理进程内存占用

cgroups 中有个 memory 子系统,用于限制和报告进程的内存使用情况。

其中,很明显有两组对应的文件,一组带 memsw ,另一组不带

memory.failcnt
memory.limit_in_bytes
memory.max_usage_in_bytes
memory.usage_in_bytes

memory.memsw.failcnt
memory.memsw.limit_in_bytes
memory.memsw.max_usage_in_bytes
memory.memsw.usage_in_bytes

带 memsw 的表示虚拟内存,即物理内存加交换区。不带 memsw 的那组仅包括物理内存。其中,limit_in_bytes 是用来限制内存使用的,其他的则是统计报告。

# echo 10485760 >/sys/fs/cgroup/memory/foo/memory.limit_in_bytes

即可限制该组中的进程使用的物理内存总量不超过 10MB。对 memory.memsw.limit_in_bytes 来说,则是限制虚拟内存使用。memory.memsw.limit_in_bytes 必须大于或等于 memory.limit_in_byte。这些值还可以用更方便的 100M,20G 这样的形式来设置。要解除限制,就把这个值设为 -1 即可。

这种方式限制进程内存占用会有个风险。当进程试图占用的内存超过限制,访问内存时发生缺页,又没有足够的非活动内存页可以换出时会触发 oom ,导致进程直接被杀,从而造成可用性问题。即使关闭控制组的 oom killer,进程在内存不足的时候,虽然不会被杀,但是会长时间进入 D (等待系统调用的不可中断休眠)状态,无法继续执行,导致仍然无法服务。因此,我认为,用 memory.limit_in_bytes 或 memory.memsw.limit_in_bytes 限制进程内存占用仅应当作为一个保险,避免在进程异常时耗尽系统资源。如,预期一组进程最多只会消耗 1G 内存,那么可以设置为 1.4G 。这样在发生内存泄露等异常情况时,可以避免造成更严重问题。

在 memory 子系统中,还有一个 memory.soft_limit_in_bytes 。和 memory.limit_in_bytes 的差异是,这个限制并不会阻止进程使用超过限额的内存,只是在系统内存不足时,会优先回收超过限额的进程占用的内存,使之向限定值靠拢。

前面说控制组的 oom killer 是可以关闭的,就是通过 memory.oom_control 来实现的。cat memory.oom_control 可以看到当前设置以及目前是否触发了 oom 。echo 1 >memory.oom_control 就可以禁用 oom killer。

usage_in_bytes、max_usage_in_bytes、failcnt 则分别对应 当前使用量,最高使用量和发生的缺页次数。

memory 子系统中还有一个很重要的设置是 memory.use_hierarchy 这是个布尔开关,默认为 0。此时不同层次间的资源限制和使用值都是独立的。当设为 1 时,子控制组进程的内存占用也会计入父控制组,并上溯到所有 memory.use_hierarchy = 1 的祖先控制组。这样一来,所有子孙控制组的进程的资源占用都无法超过父控制组设置的资源限制。同时,在整个树中的进程的内存占用达到这个限制时,内存回收也会影响到所有子孙控制组的进程。这个值只有在还没有子控制组时才能设置。之后在其中新建的子控制组默认的 memory.use_hierarchy 也会继承父控制组的设置。

memory.swappiness 则是控制内核使用交换区的倾向的。值的范围是 0 – 100。值越小,越倾向使用物理内存。设为 0 时,只有在物理内存不足时才会使用交换区。默认值是系统全局设置: /proc/sys/vm/swappiness 。

memory.stat 就是内存使用情况报告了。包括当前资源总量、使用量、换页次数、活动页数量等等。

用 cgroups 管理进程磁盘 io

linux 的 cgroups 还可以限制和监控进程的磁盘 io。这个功能通过 blkio 子系统实现。

blkio 子系统里东西很多。不过大部分都是只读的状态报告,可写的参数就只有下面这几个:

blkio.throttle.read_bps_device
blkio.throttle.read_iops_device
blkio.throttle.write_bps_device
blkio.throttle.write_iops_device
blkio.weight
blkio.weight_device

这些都是用来控制进程的磁盘 io 的。很明显地分成两类,其中带“throttle”的,顾名思义就是节流阀,将流量限制在某个值下。而“weight”就是分配 io 的权重。

“throttle”的那四个参数看名字就知道是做什么用的。拿 blkio.throttle.read_bps_device 来限制每秒能读取的字节数。先跑点 io 出来

dd if=/dev/sda of=/dev/null &
[1] 2750 

用 iotop 看看目前的 io

  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
 2750 be/4 root       66.76 M/s    0.00 B/s  0.00 % 68.53 % dd if=/dev/sda of=/dev/null
...

然后修改一下资源限制,把进程加入控制组

echo '8:0   1048576' >/sys/fs/cgroup/blkio/foo/blkio.throttle.read_bps_device
echo 2750 >/sys/fs/cgroup/blkio/foo/tasks

这里的 8:0 就是对应块设备的主设备号和副设备号。可以通过 ls -l 设备文件名查看。如

# ls -l /dev/sda
brw-rw----. 1 root disk 8, 0 Oct 24 11:27 /dev/sda

这里的 8, 0 就是对应的设备号。所以,cgroups 可以对不同的设备做不同的限制。然后来看看效果

  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
 2750 be/4 root      989.17 K/s    0.00 B/s  0.00 % 96.22 % dd if=/dev/sda of=/dev/null
...

可见,进程的每秒读取立马就降到了 1MB 左右。要解除限制,写入如 “8:0 0” 到文件中即可

对写入的限制也类似。只是测试的时候要注意,dd 需要加上 oflag=sync,避免文件系统缓存影响。如

dd if=/dev/zero of=/data/test.dat oflag=sync

还有别犯傻把 of 输出直接写设备文件 :-)

不过需要注意的是,这种方式对小于采样间隔里产生的大量 io 是没用的。比如,就算在 1s 内产生一个每秒写入 100M 的峰值,也不会因此被限制掉。

再看看 blkio.weight 。blkio 的 throttle 和 weight 方式和 cpu 子系统的 quota 和 shares 有点像,都是一种是绝对限制,另一种是相对限制,并且在不繁忙的时候可以充分利用资源,权重值的范围在 10 – 1000 之间。

测试权重方式要麻烦一点。因为不是绝对限制,所以会受到文件系统缓存的影响。如在虚拟机中测试,要关闭虚机如我用的 VirtualBox 在宿主机上的缓存。如要测试读 io 的效果,先生成两个几个 G 的大文件 /tmp/file_1,/tmp/file_2 ,可以用 dd 搞。然后设置两个权重

# echo 500 >/sys/fs/cgroup/blkio/foo/blkio.weight
# echo 100 >/sys/fs/cgroup/blkio/bar/blkio.weight

测试前清空文件系统缓存,以免干扰测试结果

sync
echo 3 >/proc/sys/vm/drop_caches

在这两个控制组中用 dd 产生 io 测试效果。

# cgexec -g "blkio:foo" dd if=/tmp/file_1 of=/dev/null &
[1] 1838
# cgexec -g "blkio:bar" dd if=/tmp/file_2 of=/dev/null &
[2] 1839

还是用 iotop 看看效果

  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
 1839 be/4 root       48.14 M/s    0.00 B/s  0.00 % 99.21 % dd if=/tmp/file_2 of=/dev/null
 1838 be/4 root      223.59 M/s    0.00 B/s  0.00 % 16.44 % dd if=/tmp/file_1 of=/dev/null

两个进程每秒读的字节数虽然会不断变动,但是大致趋势还是维持在 1:5 左右,和设定的 weight 比例一致。blkio.weight_device 是分设备的。写入时,前面再加上设备号即可。

blkio 子系统里还有很多统计项

blkio.time
​​​各​​​设​​​备​​​的​​​ io 访​​​问​​​时​​​间,单位毫秒
blkio.sectors
换入​​​者​​​或​​​出​​​各​​​设​​​备​​​的​​​扇​​​区​​​数
blkio.io_serviced
各设​​​备​​​中​​​执​​​行​​​的各类型​​​ io 操​​​作​​​数,分read、​​​write、​​​sync、async 和 total​​​
blkio.io_service_bytes
各类型​​​ io ​​​换入​​​者​​​或​​​出​​​各​​​设​​​备​​​​​​的​​​字​​​节​​​数​​​
blkio.io_service_time
各设​​​备​​​中​​​执​​​行​​​的各类型​​​ io 时间,单位微秒​​​
blkio.io_wait_time
各设​​​备​​​中各类型​​​ io 在队列中的 等待时间​​​
blkio.io_merged
各设​​​备​​​中各类型​​​ io 请求合并的次数​​​
blkio.io_queued
各设​​​备​​​中各类型​​​ io 请求当前在队列中的数量​​​

通过这些统计项更好地统计、监控进程的 io 情况

echo 1 >blkio.reset_stats

可以将所有统计项清零。

用 cgroups 管理 cpu 资源

这回说说怎样通过 cgroups 来管理 cpu 资源。先说控制进程的 cpu 使用。在一个机器上运行多个可能消耗大量资源的程序时,我们不希望出现某个程序占据了所有的资源,导致其他程序无法正常运行,或者造成系统假死无法维护。这时候用 cgroups 就可以很好地控制进程的资源占用。这里单说 cpu 资源。

cgroups 里,可以用 cpu.cfs_period_us 和 cpu.cfs_quota_us 来限制该组中的所有进程在单位时间里可以使用的 cpu 时间。这里的 cfs 是完全公平调度器的缩写。cpu.cfs_period_us 就是时间周期,默认为 100000,即百毫秒。cpu.cfs_quota_us 就是在这期间内可使用的 cpu 时间,默认 -1,即无限制。

跑一个耗 cpu 的程序

# echo 'while True: pass'|python &
[1] 1532

top 一下可以看到,这进程占了 100% 的 cpu

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
 1532 root      20   0  112m 3684 1708 R 99.6  0.7   0:30.42 python
...

然后就来对这个进程做一下限制。先把 /foo 这个控制组的限制修改一下,然后把进程加入进去。

echo 50000 >/sys/fs/cgroup/cpu/foo/cpu.cfs_quota_us
echo 1532 >/sys/fs/group/cpu/foo/tasks

可见,修改设置只需要写入相应文件,将进程加入 cgroup 也只需将 pid 写入到其中的 tasks 文件即可。这里将 cpu.cfs_quota_us 设为 50000,相对于 cpu.cfs_period_us 的 100000 即 50%。再 top 一下看看效果。

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
 1532 root      20   0  112m 3684 1708 R 50.2  0.7   5:00.31 python
...

可以看到,进程的 cpu 占用已经被成功地限制到了 50% 。这里,测试的虚拟机只有一个核心。在多核情况下,看到的值会不一样。另外,cfs_quota_us 也是可以大于 cfs_period_us 的,这主要是对于多核情况。有 n 个核时,一个控制组中的进程自然最多就能用到 n 倍的 cpu 时间。

这两个值在 cgroups 层次中是有限制的,下层的资源不能超过上层。具体的说,就是下层的 cpu.cfs_period_us 值不能小于上层的值,cpu.cfs_quota_us 值不能大于上层的值。

另外的一组 cpu.rt_period_us、cpu.rt_runtime_us 对应的是实时进程的限制,平时可能不会有机会用到。

在 cpu 子系统中,cpu.stat 就是用前面那种方法做的资源限制的统计了。nr_periods、nr_throttled 就是总共经过的周期,和其中受限制的周期。throttled_time 就是总共被控制组掐掉的 cpu 使用时间。

还有个 cpu.shares, 它也是用来限制 cpu 使用的。但是与 cpu.cfs_quota_us、cpu.cfs_period_us 有挺大区别。cpu.shares 不是限制进程能使用的绝对的 cpu 时间,而是控制各个组之间的配额。比如

/cpu/cpu.shares : 1024
/cpu/foo/cpu.shares : 2048

那么当两个组中的进程都满负荷运行时,/foo 中的进程所能占用的 cpu 就是 / 中的进程的两倍。如果再建一个 /foo/bar 的 cpu.shares 也是 1024,且也有满负荷运行的进程,那 /、/foo、/foo/bar 的 cpu 占用比就是 1:2:1 。前面说的是各自都跑满的情况。如果其他控制组中的进程闲着,那某一个组的进程完全可以用满全部 cpu。可见通常情况下,这种方式在保证公平的情况下能更充分利用资源。

此外,还可以限定进程可以使用哪些 cpu 核心。cpuset 子系统就是处理进程可以使用的 cpu 核心和内存节点,以及其他一些相关配置。这部分的很多配置都和 NUMA 有关。其中 cpuset.cpus、cpuset.mems 就是用来限制进程可以使用的 cpu 核心和内存节点的。这两个参数中 cpu 核心、内存节点都用 id 表示,之间用 “,” 分隔。比如 0,1,2 。也可以用 “-” 表示范围,如 0-3 。两者可以结合起来用。如“0-2,6,7”。在添加进程前,cpuset.cpus、cpuset.mems 必须同时设置,而且必须是兼容的,否则会出错。例如

# echo 0 >/sys/fs/cgroup/cpuset/foo/cpuset.cpus
# echo 0 >/sys/fs/cgroup/cpuset/foo/cpuset.mems

这样, /foo 中的进程只能使用 cpu0 和内存节点0。用

# cat /proc/<pid>/status|grep '_allowed_list'

可以验证效果。

cgroups 除了用来限制资源使用外,还有资源统计的功能。做云计算的计费就可以用到它。有一个 cpuacct 子系统专门用来做 cpu 资源统计。cpuacct.stat 统计了该控制组中进程用户态和内核态的 cpu 使用量,单位是 USER_HZ,也就是 jiffies、cpu 滴答数。每秒的滴答数可以用 getconf CLK_TCK 来获取,通常是 100。将看到的值除以这个值就可以换算成秒。

cpuacct.usage 和 cpuacct.usage_percpu 是该控制组中进程消耗的 cpu 时间,单位是纳秒。后者是分 cpu 统计的。

P.S. 2014-4-22

发现在 SLES 11 sp2、sp3 ,对应内核版本 3.0.13、 3.0.76 中,对 cpu 子系统,将 pid 写入 cgroup.procs 不会实际生效,要写入 tasks 才行。在其他环境中,更高版本或更低版本内核上均未发现。

linux cgroups 概述

从 2.6.24 版本开始,linux 内核提供了一个叫做 cgroups(控制组)的特性。cgroups 就是 control groups 的缩写,用来对一组进程所占用的资源做限制、统计、隔离。也是目前轻量级虚拟化技术 lxc (linux container)的基础之一。每一组进程就是一个控制组,也就是一个 cgroup。cgroups 分为几个子系统,每个子系统代表一种设施或者说是资源控制器,用来调度某一类资源的使用,如 cpu 时钟、内存、块设备 等。在实现上,cgroups 并没有增加新的系统调用,而是表现为一个 cgroup 文件系统,可以把一个或多个子系统挂载到某个目录。如

mount -t cgroup -o cpu cpu /sys/fs/cgroup/cpu

就将 cpu 子系统挂载在了 /sys/fs/cgroup/cpu 。也可以在一个目录上挂载多个子系统,甚至全部挂载到一个目录也是可以的,不过我觉得,把每个子系统都挂载在不同目录会有更好的灵活性。用 mount|awk '$5=="cgroup" {print $0}' 可以看到当前挂载的控制组。用 cat /proc/cgroups 可以看到当前所有控制组的状态。下面这个脚本,可以把全部子系统各种挂载到各自的目录上去。

#!/bin/bash

cgroot="${1:-/sys/fs/cgroup}"
subsys="${2:-blkio cpu cpuacct cpuset devices freezer memory net_cls net_prio ns perf_event}"

mount -t tmpfs cgroup_root "${cgroot}"
for ss in $subsys; do
  mkdir -p "$cgroot/$ss"
  mount -t cgroup -o "$ss" "$ss" "$cgroot/$ss"
done

看看那些目录里都有些啥,比如 ls 一下 /sys/fs/cgroup/cpu。

cgroup.event_control  cpu.cfs_period_us  cpu.rt_period_us   cpu.shares  notify_on_release  tasks
cgroup.procs          cpu.cfs_quota_us   cpu.rt_runtime_us  cpu.stat    release_agent

其中 “cpu.” 开头的就是这个子系统里特有的东西。其他的那些是每个子系统所对应目录里都有的。这些文件就是用来读取资源使用信息和进行资源限制的。要创建一个控制组,就在需要的子系统里创建一个目录即可。如 mkdir /sys/fs/cgroup/cpu/foo 就创建了一个 /foo 的控制组。在新建的目录里就会出现同样一套文件。在这个目录里,也一样可以继续通过创建目录来创建 cgroup。也就是说,cgroup 是可以和目录结构一样有层次的。对与每个子系统挂载点点目录,就相当于根目录。每一条不同的路径就代表了一个不同的 cgroup。在不同的子系统里,路径相同就代表了同一个控制组。如,在 cpu、memory 中都有 foo/bar 目录,就可以用 那 /foo/bar 来操作 cpu、memory 两个子系统。对于同一个子系统,每个进程都属于且只属于一个 cgroup,默认是在根 cgroup。层次结构方便了控制组的组织和管理,对于某些配置项来说,层次结构还和资源分配有关。另外,也可以修改某个目录的 owner ,让非 root 用户也能操作某些特定的安全组。

cgroups 的设置和信息读取是通过对那些文件的读写来进行的。例如

# echo 2048 >/sys/fs/cgroup/cpu/foo/cpu.shares

就把 /foo 这个控制组的 cpu.shares 参数设为了 2048。

前面说,有些文件是每个目录里共有的。那些就是通用的设置。其中,tasks 和 cgroups.procs 是用来管理控制组中的进程的。要把一个进程加入到某个控制组,把 pid 写入到相应目录的 tasks 文件即可。如

# echo 5678 >/sys/fs/cgroup/cpu/foo/tasks

就把 5678 进程加入到了 /foo 控制组。那么 tasks 和 cgroups.procs 有什么区别呢?前面说的对“进程”的管理限制其实不够准确。系统对任务调度的单位是线程。在这里,tasks 中看到的就是线程 id。而 cgroups.procs 中是线程组 id,也就是一般所说的进程 id 。将一个一般的 pid 写入到 tasks 中,只有这个 pid 对应的线程,以及由它产生的其他进程、线程会属于这个控制组,原有的其他线程则不会。而写入 cgroups.procs 会把当前所有的线程都加入进去。如果写入 cgroups.procs 的不是一个线程组 id,而是一个一般的线程 id,那会自动找到所对应的线程组 id 加入进去。进程在加入一个控制组后,控制组所对应的限制会即时生效。想知道一个进程属于哪些控制组,可以通过 cat /proc/<pid>/cgroup 查看。

要把进程移出控制组,把 pid 写入到根 cgroup 的 tasks 文件即可。因为每个进程都属于且只属于一个 cgroup,加入到新的 cgroup 后,原有关系也就解除了。要删除一个 cgroup,可以用 rmdir 删除相应目录。不过在删除前,必须先让其中的进程全部退出,对应子系统的资源都已经释放,否则是无法删除的。

前面都是通过文件系统访问方式来操作 cgroups 的。实际上,也有一组命令行工具。

lssubsys -am 可以查看各子系统的挂载点,还有一组“cg”开头的命令可以用来管理。其中 cgexec 可以用来直接在某些子系统中的指定控制组运行一个程序。如 cgexec -g "cpu,blkio:/foo" bash 。其他的命令和具体的参数可以通过 man 来查看。

下面是个 bash 版的 cgexec,演示了 cgroups 的用法,也可以在不确定是否安装命令行工具的情况下使用。

#!/bin/bash

# usage: 
# ./cgexec.sh cpu:g1,memory:g2/g21 sleep 100

blkio_dir="/sys/fs/cgroup/blkio"
memory_dir="/sys/fs/cgroup/memory"
cpuset_dir="/sys/fs/cgroup/cpuset"
perf_event_dir="/sys/fs/cgroup/perf_event"
freezer_dir="/sys/fs/cgroup/freezer"
net_cls_dir="/sys/fs/cgroup/net_cls"
cpuacct_dir="/sys/fs/cgroup/cpuacct"
cpu_dir="/sys/fs/cgroup/cpu"
hugetlb_dir="/sys/fs/cgroup/hugetlb"
devices_dir="/sys/fs/cgroup/devices"

groups="$1"
shift

IFS=',' g_arr=($groups)
for g in ${g_arr[@]}; do
  IFS=':' g_info=($g)
  if [ ${#g_info[@]} -ne 2 ]; then
    echo "bad arg $g" >&2
    continue
  fi
  g_name=${g_info[0]}
  g_path=${g_info[1]}
  if [ "$g_path" == "${g_path#/}" ]; then
    g_path="/$g_path"
  fi
  echo $g_name $g_path
  var="${g_name}_dir"
  d=${!var}
  if [ -z "$d" ]; then
    echo "bad cg name $g_name" >&2
    continue
  fi
  path="${d}${g_path}"
  if [ ! -d "$path" ]; then
    echo "cg not exists" >&2
    continue
  fi
  echo "$$" >"${path}/tasks"
done

exec $*

cgroups 中的东西很多,本来打算只写一篇的,后来觉着还是分成几篇说得更明白些。之后还会写一些具体使用的东西。

参考资料:
cgroups docs – kernel.org
Resource Management Guide – redhat.com
How I Used CGroups to Manage System Resources – oracle.com

动态修改运行中进程的 rlimit

有时候发现线上运行的程序没有修改 ulimit -n ,导致文件描述符不够用,但是又不能随便重启服务,或者碰到运行中的程序可能不定时会 segment fault ,但是由于没 ulimit -c ,无法得到 core 文件调试。但是 linux 的 ulimit 命令只对当前会话有效,已经启动的进程是没法用这个命令修改限制的。这时候如果能动态修改进程的 rlimit 就会方便很多。linux 内核版本 2.6.36 以后,多了一个 prlimit 调用,可以实现动态修改某个进程的 rlimit。

例如,这个 C 写的小程序就可以放开指定进程的 core dump 限制。

#include <stdio.h>
#include <sys/time.h>
#include <sys/resource.h>
#include <sys/types.h>

int main(int argc, char** argv) {
    pid_t pid;
    struct rlimit new_limit;
    int result;
    if (argc < 2) {
        return 1;
    }
    pid = atoi(argv[1]);
    new_limit.rlim_cur = RLIM_INFINITY;
    new_limit.rlim_max = RLIM_SAVED_MAX;
    result = prlimit(pid, RLIMIT_CORE, &new_limit, NULL);
    return result;
}

rlimit 相关常量在 /usr/include/bits/resource.h 中有定义。这里 prlimit 的具体定义也可以通过 man 2 prlimit 查看文档。

如果有幸可以用上 util-linux 2.21 以上版本(http://www.kernel.org/pub/linux/utils/util-linux/),那么直接就会有一个 prlimit 命令可用。那就非常方便了。

但是如果内核版本较低,没有这功能又怎么办呢?其实还可以通过万能的 gdb 钩进进程调用 setrlimit 来实现。于是,可以包装一个脚本出来:

#!/bin/bash

f="0"
while getopts ":c:d:e:f:i:l:m:n:p:q:r:s:t:u:v:x" opt; do
  if [ "$f" == "1" ]; then
    echo "too many arguments" >&2;
    exit 1
  fi
  f="1"
  v="$OPTARG"
  case $opt in
  c) r=4  ;; # RLIMIT_CORE       core file size
  d) r=2  ;; # RLIMIT_DATA       data seg size  
  e) r=13 ;; # RLIMIT_NICE       scheduling priority 
  f) r=1  ;; # RLIMIT_FSIZE      file size
  i) r=11 ;; # RLIMIT_SIGPENDING pending signals
  l) r=8  ;; # RLIMIT_MEMLOCK    max locked memory
  m) r=5  ;; # RLIMIT_RSS        max memory size
  n) r=7  ;; # RLIMIT_NOFILE     open files
  q) r=12 ;; # RLIMIT_MSGQUEUE   POSIX message queues
  r) r=14 ;; # RLIMIT_RTPRIO     real-time priority
  s) r=3  ;; # RLIMIT_STACK      stack size
  t) r=0  ;; # RLIMIT_CPU        cpu time
  u) r=6  ;; # RLIMIT_NPROC      max user processes
  v) r=9  ;; # RLIMIT_AS         virtual memory
  x) r=10 ;; # RLIMIT_LOCKS      file locks

  ?) echo "bad argument $opt" >&2; exit 1 ;;
  esac
done

shift $(($OPTIND - 1))

if echo "$v" | grep -q -E "^\-?[0-9]+$"; then
  true
else
  echo "bad rlimti value $v" >&2
  exit 2
fi

if [ `echo "$v==-1 || ($v>=0 && $v<1048576)"|bc` == '0' ];then
  echo "bad rlimti value $v" >&2
  exit 2
fi

pid=$1
bin=`readlink /proc/$pid/exe`
if [ -z "$bin" ]; then
  echo "process $pid not found" >&2
  exit 3
fi

cmd='
set $rlim=&{-1ll,-1ll}
print getrlimit('$r',$rlim)
set *$rlim[0]='$v'
print setrlimit('$r',$rlim)
quit
'

result=`echo "$cmd" | gdb $bin $pid 2>/dev/null | grep '(gdb) \$2 ='`
result="${result##*= }"
exit $result

sudo ./rlimit.sh -c -1 12345

就可以 12345 放开进程的 core dump

参数同 ulimit ,-1 表示 unlimited。不过用这种方法,只能调整 soft rlimit ,而且没法超过原有的 hard rlimit。

修改以后,可以通过 cat /proc/<pid>/limits 查看效果

补充(感谢zolker):
对于 redhat 系,如 centos 6.2 之后可以通过

echo -n 'Max processes=10240:10240' >/proc/<pid>/limits

来修改。这里的 limit 的名称就是

cat /proc/<pid>/limits

里看到的

调试一个 Bus error 错误

遇到一部分主机运行 php 的时候报 Bus error 错误,直接退出。

# /path/to/php
Bus error

于是用 strace 跟踪了一下,看看到底是怎么回事。

# strace /path/to/php
...
...
...
open("/path/to/php/lib/php/extensions/no-debug-non-zts-20090626/mongo.so", O_RDONLY) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0`\270\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0644, st_size=32636, ...}) = 0
mmap(NULL, 2309488, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x2b1e8e689000
mprotect(0x2b1e8e6ba000, 2097152, PROT_NONE) = 0
mmap(0x2b1e8e8ba000, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x31000) = 0x2b1e8e8ba000
--- SIGBUS (Bus error) @ 0 (0) ---
+++ killed by SIGBUS +++

strace 的最后几行如上面所示,进程受到了一个 SIGBUS 信号,看起来是 mmap 引起的。从这几行里看,是 php 在加载 mongo 扩展 mongo.so 时出的错。搜索了一下,mmap 映射内存的时候,在映射超出文件大小范围的内存空间时,可能引发这个 SIGBUS 。就上面的结果看,fstat 的结果表明,文件大小是 32636 字节。而最后那个 mmap 的 offset 参数 0x31000 即 200704,的定位范围直接就超过了文件大小,所以就引发了这个错误。

考虑到这个问题只在部分主机上出现,所以应当不是 php 或者 mongo 扩展本身的 bug 。最后发现是通过 puppet 同步下来的 mongo.so 不完整,比正常的小了很多。重新同步后恢复正常。

由此也可以看出,在加载动态库时是根据 elf 文件中标记的段大小来映射内存的。所以就发生了在文件不完整时 mmap 出错的情况。

让 linux 交互式命令行程序支持方向键等功能

自己写交互式命令行程序,通常都是从标准输入输出读写。运行的时候,只能敲完整一个命令,然后回车。想要按方向键移动光标,按 del 往后删除都只会出现个 ^[[A 这样的东西。要实现那些功能,得处理终端命令,这个活并不轻松。虽然有 readline 这样的库,但还是会带来很多麻烦。

好在 unix 的哲学就是一个程序干一件事情,并把它做到最好。于是就有一个 rlwrap 的程序。用这个程序启动你的工具,你的程序就立马有了那些功能。还可以按上下键翻阅历史命令,ctrl+r 来搜索历史输入等。