一个屏蔽 IP 的脚本

在测试可用性的时候,经常需要模拟断网。这时候用 iptables 是比较方便的。但是如果想更方便一点,不用去敲那么长的命令会更好些。于是就写了个 ban_ip 脚本。

#!/bin/bash

if [[ "$EUID" != 0 ]]; then
  echo "should run as root"
  exit 1
fi

action="DROP"
comment="ban_ip"
cmd="$1"
case "$cmd" in
list)
  iptables -L -n | awk -v "cmt=$comment" '$0~cmt{print $4}'
  ;;
add)
  ip="$2"
  if [[ -z "$ip" ]]; then
    echo "missing arg ip"
    exit 1
  fi
  iptables -A INPUT -s "$ip" -j "$action" -m comment --comment "$comment"
  ;;
del)
  ip="$2"
  if [[ -z "$ip" ]]; then
    echo "missing arg ip"
    exit 1
  fi
  iptables -D INPUT -s "$ip" -j "$action" -m comment --comment "$comment"
  ;;
*)
  echo "bad command: should be list, add <ip>, del <ip>"
  exit 1
  ;;
esac

用的时候就方便了不少,也便于查看当前已经 ban 掉的 ip。

$ sudo ./ban_ip.sh list
10.10.10.1

$ sudo ./ban_ip.sh add 10.10.10.2

$ sudo ./ban_ip.sh list
10.10.10.1
10.10.10.2

$ sudo ./ban_ip.sh del 10.10.10.1

$ sudo ./ban_ip.sh list
10.10.10.2

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 的表达式说明。

为 bash 提示符加上 git 状态

在有一次手误合错分支以后,就决定为 bash 提示符加上显示当前分支,以及提交状态,这样就可以更清楚地知道当前在哪个分支,以及是不是 commit 了,是不是 push 了。代码如下:

function __git_prompt() {
  local s=$(git status -b --porcelain 2>/dev/null)
  if [[ -z "$s" ]]; then
    PS1="\h:\W \u$ "
  else
    local l=$(echo "$s"|wc -l)
    local br=$(echo "$s"|head)
    if [[ "${br%[ahead*}" != "$br" ]]; then
      local np=1
    fi
    br="${br#\#\# }"
    br="${br%...*}"
    if [[ "$l" -gt 1 ]]; then
      local g="(git:\[$(tput setaf 9)\]$br\[$(tput sgr0)\])" # dirty: red
    elif [[ -z "$np" ]]; then
      local g="(git:\[$(tput setaf 10)\]$br\[$(tput sgr0)\])" # clean: green
    else
      local g="(git:\[$(tput setaf 11)\]$br\[$(tput sgr0)\])" # not pushed: yellow
    fi
    PS1="\h:\W \u $g$ "
  fi
}


PROMPT_COMMAND='__git_prompt'

加入到 .bash_profile 即可。这里是按 MacOS 默认的 PS1 基础上改的,实际使用的时候可以根据需要调整。

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 应用在线性能分析和火焰图

在碰到线上性能问题的时候,如果能在线通过采样方式获取热点函数/方法就可以更方便地定位问题所在,进行优化。采用在线采样的方式,由于性能影响小,可以比较放心地在线上进行,获取第一手数据。Linux 平台上,对于多数 C/C++ 编写的应用,可以通过 perf 来方便的采样,还可以进一步生成火焰图来更直观地观察。Java 是没法直接用 perf 的。虽然有一个 perf-map-agent,但是并不方便,尝试过程中还弄出了 kernel panic,所以这玩意是不敢在线上用了。不过 JDK 自己其实已经带了一个采样工具 FlightRecorder ,算是 JMC 的一部分。这个功能是商业版本的功能,虽说启用的时候并没检查 license,但是理论上要在生产环境用,还是要花钱的。

首先,应用启动的时候,要给 java 加上参数:

-XX:+UnlockCommercialFeatures -XX:+FlightRecorder -XX:FlightRecorderOptions=loglevel=info

然后在想开始采样的时候

sudo -u <java_user> -i jcmd <pid> JFR.start filename=/tmp/app.jfr duration=60s

这里可以指定输出的文件路径,和采样时间。之后可以用

sudo -u <java_user> -i jcmd <pid> JFR.check

来检查采样是不是已经完成了。

更详细的用法可以参考官方文档:https://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/toc.htm

确认完成后,就可以把 jfr 文件传回本地,用 jmc 来分析了。如果想要生成火焰图,还有这么个工具:https://github.com/chrishantha/jfr-flame-graph。具体用法可以看文档。大致上,代码拖回来后,

cd jfr-flame-graph
install-mc-jars.sh
mvn clean install -U

就编译好了。另外还需要 https://github.com/brendangregg/FlameGraph

工具准备好以后,

path/to/jfr-flame-graph/run.sh -f app.jfr -o app.txt
cat app.txt | path/to/FlameGraph/flamegraph.pl >app.svg

就能生成一个漂亮的火焰图了。

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

我们有一个 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 缓存就能绕过。彻底解决问题反倒不是个很紧迫的事了。

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

go http client 设置连接超时

go 语言的 http 客户端可以在初始化话的时候通过

client := http.Client{
	Timeout: 5 * time.Second,
}

来设置请求超时,即整个 http 请求到完成响应的时间限制。那么如果想另外设置 tcp 连接阶段的超时可以这样玩:

client := http.Client{
	Transport: &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		Dial: (&net.Dialer{
			Timeout:   2  * time.Second,
			Deadline:  time.Now().Add(3  * time.Second),
			KeepAlive: 2 * time.Second,
		}).Dial,
		TLSHandshakeTimeout: 2 * time.Second,
	},
	Timeout: 5 * time.Second,
}

通过设置 Transport 结构中的 Dial 的属性来实现。如上面的代码中,Dial 的 Timetout 是在 tcp 连接时设置的连接超时,Deadline 则会在超过这个时间后强制关闭连接,在连接无响应的时候回有用。KeepAlive 则会发起心跳,检测连接是否存活。此外,可以设置 TLSHandshakeTimeout 作为 https 握手的超时。具体可以参考 net.Dialer 的文档。由于直接构造了 Transport 结构,不会自动设置 Proxy 属性,这里还得再这里补上。可以用 http.ProxyFromEnvironment 表示根据环境变量来设置,即 http_proxy 和 https_proxy 两个变量设置的 http 代理。如果想强制不使用代理,可以设置为

...
	Proxy: func(*http.Request) (*url.URL, error) {return nil, nil},
...

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

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

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

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

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

不通过 web server 获取 php-fpm 运行状态

php-fpm 可以配置一个 pm.status_path ,如 /status,然后通过 web server 访问这个地址来获取运行状态。但这样会侵入 web server 的配置,在一个 web server 后端有多个 php-fpm 的时候也不方便分别监控每一个后端的状态,为了安全,还要配置访问控制。

好在有个现成的工具 cgi-fcgi,可以把 fcgi 请求包装成 cgi 方式,这样就可以直接在命令行中调用 fastcgi。

cgi-fcgi 在 redhat/centos 中可以用 yum install fcgi 安装,在 ubuntu 中可以用 apt-get install libfcgi-dev 安装。

用以下方式就能获取 php-fpm 的状态了。

path=/status

export REQUEST_METHOD=GET
export SCRIPT_NAME="$path"
export SCRIPT_FILENAME="$path"
export QUERY_STRING=''
# export QUERY_STRING='full'
# export QUERY_STRING='json'
# export QUERY_STRING='full&xml'

addr=/var/run/php-fpm.socket
# addr=127.0.0.1:9000

cgi-fcgi -bind -connect "$addr"

QUERY_STRING 设置为 full 会显示每一个 worker 进程的状态。添加 json、xml、html 可以以不同格式显示结果。

下面是单行脚本的写法和运行结果:

# env REQUEST_METHOD=GET SCRIPT_NAME=/status SCRIPT_FILENAME=/status QUERY_STRING='' cgi-fcgi -bind -connect /var/run/php-fpm.socket
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Cache-Control: no-cache, no-store, must-revalidate, max-age=0
Content-type: text/plain;charset=UTF-8

pool:                 www
process manager:      dynamic
start time:           15/Jan/2016:10:48:15 +0800
start since:          4604
accepted conn:        693119
listen queue:         0
max listen queue:     0
listen queue len:     0
idle processes:       157
active processes:     3
total processes:      160
max active processes: 50
max children reached: 0
slow requests:        17

此种方法也可以用于临时在 php-fpm 环境下执行一个 php 脚本,比如执行一个 phpinfo() 来检查配置是否正确。只需要把脚本中 path=/status 替换成 php 文件路径即可。

MySQL relay_log_purge=0 时的风险

有时候,我们希望将 MySQL 的 relay log 多保留一段时间,比如用于高可用切换后的数据补齐,于是就会设置 relay_log_purge=0,禁止 SQL 线程在执行完一个 relay log 后自动将其删除。但是在官方文档关于这个设置有这么一句话:

Disabling purging of relay logs when using the --relay-log-recovery option risks data consistency and is therefore not crash-safe.

究竟是什么样的风险呢?查找了一番后,基本上明白了原因。

首先,为了让从库是 crash safe 的,必须设置 relay_log_recovery=1,这个选项的作用是,在 MySQL 崩溃或人工重启后,由于 IO 线程无法保证记录的从主库读取的 binlog 位置的正确性,因此,就不管 master_info 中记录的位置,而是根据 relay_log_info 中记录的已执行的 binlog 位置从主库下载,并让 SQL 线程也从这个位置开始执行。MySQL 启动时,相当于执行了 flush logs ,会新开一个 relay log 文件,新的 relay log 会记录在新的文件中。如果默认情况 relay_log_purge=1 时,SQL 线程就会自动将之前的 relay log 全部删除。而当 relay_log_purge=0 时,旧的 relay log 则会被保留。虽然这并不会影响从库复制本身,但还是会有地雷:

  1. 由于崩溃或停止 MySQL 时,SQL 线程可能没有执行完全部的 relay log,最后一个 relay log 中的一部分数据会被重新下载到新的文件中。也就是说,这部分数据重复了两次。
  2. 如果 SQL 跟得很紧,则可能在 IO 线程写入 relay log ,但还没有将同步到磁盘时,就已经读取执行了。这时,就会造成新的文件和旧的文件中少了一段数据。

如果我们读取 relay log 来获取数据,必须注意这一点,否则就会造成数据不一致。而保留 relay log 的目的也在于此。因此,在处理 relay log 时必须格外小心,通过其中 binlog 头信息来确保正确性。

关于如何配置 crash safe 的复制本身的配置,可以参照:
http://blog.itpub.net/22664653/viewspace-1752588/
http://www.innomysql.net/article/34.html

参考资料:
http://blog.booking.com/better_crash_safe_replication_for_mysql.html
https://bugs.mysql.com/bug.php?id=73038
http://bugs.mysql.com/bug.php?id=74324