月度归档:2015年04月

单机磁盘故障引发RabbitMQ镜像队列数据丢失

某分布式消息推送系统,用RabbitMQ做消息分发。RabbitM采用镜像队列模式、双机HA部署,前端使用负载均衡,因此RabbitMQ主从节点上都会有生产者和消费者连接。此为应用背景。

版本:
OS: Debian 7, 3.2.39-2 x86_64 GNU/Linux
Erlang: R15B01 (erts-5.9.1)
RabbitMQ: 3.0.2 (ha policy, ha-mode:all)

故障现象还原:

1. 节点A的数据盘故障(磁盘控制器故障、无法读写),所有原本A上的生产者消费者failover到B节点。
2. 从节点B的WebUI上看,所有队列信息(包括队列元信息和数据)丢失,但是exchange、binding、vhost等依旧存在。
3. 节点B的日志中出现大量关于消费请求的错误日志:

=ERROR REPORT==== 23-Apr-2015::19:31:26 ===
connection <0.1060.0>, channel 1 - soft error:
{amqp_error,not_found,
"home node 'rabbit@192.168.156.210' of durable queue 'test' in vhost 
'2891f31d' is down or inaccessible", 'basic.consume'}

4. 从生产者端看来一切正常,依旧会收到来自节点B的confirm消息(basic.ack amqp方法)。

经过一番排查定位,上述现象实际上有两个坑在里面:

一、 在数据可靠性方面,镜像队列也不完全可靠。
二、 要保证消息可靠性,生产者端仅仅采用confirm机制还不够。

对于第一个坑:

第一感觉应该是bug,其他人也出现过。在云环境下要重现这个Bug很简单,申请两台云主机做RabbitMQ HA,部署镜像队列。另外申请两块云硬盘,分别挂载到两台云主机,作为数据盘为RabbitMQ提供元信息和队列数据提供存储(${RABBITMQ_HOME}/var/lib),模拟生产者和消费者对主节点(假设为节点A)进行生产消费,此时可以从云平台管理界面上卸载节点A上的云硬盘,观察节点B RabbitMQ WebUI,可以发现队列不见了。:)

换上最新版的3.5.1就没这个问题,但是要确认是哪个版本对它进行修复的却耗费了很久。初步翻阅了3.0.3 ~ 3.5.1之间大概20多个版本的Changelog,没有看到哪个bugfix的描述符合。只好用最笨的办法,对所有版本号采用二分法,挨个版本测试,最终将修复版本确定为3.4.0 (Changelog)。但是即便如此,也还是没有看到具体是哪个bugfix修复了此问题。

对于第二个坑:

对于队列不存在了,RabbitMQ依然向生产者返回confirm消息的情况,实际上通过生产者端的正确编程姿势可以避免。RabbitMQ官方有过说明:

For unroutable messages, the broker will issue a confirm once the exchange verifies a message won't route to any queue (returns an empty list of queues). If the message is also published as mandatory, the basic.return is sent to the client before basic.ack.

也就是说,对于那些路由不可达的消息(根据routerkey,找不到相应队列),如果basic.publish方法没有设置mandatory参数(关于mandatory参数,可以参考『AMQP协议mandatory和immediate标志位区别』),那么RabbitMQ会直接丢弃消息,并且向客户端返回basic.ack消息;如果basic.publish方法设置了mandatory参数,那么RabbitMQ会通过basic.return消息返回消息内容,然后再发送basic.ack消息进行确认。

因为节点上的队列元信息在第一个踩的坑里已经丢失了,所以所有发送到此节点上的消息必然是不可到达的,加上未在basic.publish方法上加mandatory参数,因此消息直接被RabbitMQ丢弃,并被正常confirm掉。

要避免踩这个坑,Java SDK的Channel对象提供了addReturnListener()回调方法,当收到服务器端的basic.return消息时,ReturnListener方法被调用,生产者端可以进行消息重传。当然,前提是basicPublish()方法的mandatory参数已经设置为true。

--EOF--

小内存VPS站点内存使用调优(续)

昨天在『小内存VPS站点内存使用调优』中介绍了在VPS中打开swap分区,解决物理内存不足导致的OOM问题。本以为有了1G swap分区(SSD)做后盾,php-fpm的pm.*参数可以调大一点:

1
2
3
4
5
6
pm = dynamic
pm.max_children = 10
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 5
pm.max_requests = 1000

结果今天DNSPod继续发来报警邮件,网站不可用,不过这次不可用的原因不是内存不足,而是IO太忙,Nginx提示504 Time-out。

先SSH到VPS上查看系统状态:
top-wa

上图是top命令的执行结果,异常表现在load很高(单核,10+),CPU使用率很低12.7%,大部分时间在等待IO,71.8% wa。

top命令看不出哪个进程在io wait。于是用apt-get安装iotop命令,它可以用来查看每个进程的IO情况:
iotop

通过左右箭头可以指定按某一列排序,按DISK READ列排序后,问题很直观了,清一色的php-fpm worker进程,并且都是99%左右的时间在swapin,所以问题定位在swap分区的使用不合理上。

swap分区本质是用磁盘模拟内存,它的IO特点是随机读写,即便是号称SSD,相比HDD延时低了不少,看来还是达不到系统使用要求。不知道DigitalOcean有没有对磁盘的IOPS进行Qos限制,从iotop上显示读速度62.85M/s来看,跟基准数据相比,处在偏下的水平。

定位出问题的原因之后,只能继续折腾php-fpm配置参数:

1
2
3
4
5
6
pm = dynamic
pm.max_children = 5
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 3
pm.max_requests = 1000

另外,调整内核vm.swappiness参数(取值0-100,默认60)。swappiness数值越大,表示越优先使用swap;swappiness数值越低,表示越优先使用物理内存。

1
2
# echo 5 > /proc/sys/vm/swappiness
# echo vm.swappiness=5 >> /etc/sysctl.conf

把swappiness参数设置为5,告诉系统大部分时间优先分配物理内存,这可以解决极端情况下物理内存耗尽时,用swap分区做下缓冲。

初步看来,Nginx提示504 Timeout的问题解决了。

--EOF--

小内存VPS站点内存使用调优

本站托管在DigitalOcean,VPS规格是最低的1VCPU,512M内存,20G SSD存储。即便是对于这种日均PV不过百余的小站点,LNMP架构再加上顺便在主机里装上的一系列Shadowsocks、squid、l2tp等代理和VPN软件,内存逐渐成为最大瓶颈。

终于这两天DNSPod不断发邮件报警网站无法访问,打开网站提示“数据库连接错误”,ssh到主机里,dmesg提示各种OOM Killer:

# dmesg | grep oom
[.] php5-fpm invoked oom-killer: gfp_mask=0x201da,order=0,oom_adj=0,oom_score_adj=0
[.]  [<ffffffff810b77f3>] ? oom_kill_process+0x49/0x271
[.] [ pid ]   uid  tgid total_vm      rss cpu oom_adj oom_score_adj name
[.] mysqld invoked oom-killer: gfp_mask=0x201da,order=0,oom_adj=0,oom_score_adj=0
[.]  [<ffffffff810b77f3>] ? oom_kill_process+0x49/0x271
[.] [ pid ]   uid  tgid total_vm      rss cpu oom_adj oom_score_adj name
[.] php5-fpm invoked oom-killer: gfp_mask=0x280da,order=0,oom_adj=0,oom_score_adj=0
[.]  [<ffffffff810b77f3>] ? oom_kill_process+0x49/0x271
[.] [ pid ]   uid  tgid total_vm      rss cpu oom_adj oom_score_adj name
[.] php5-fpm invoked oom-killer: gfp_mask=0x201da,order=0,oom_adj=0,oom_score_adj=0
[.]  [<ffffffff810b77f3>] ? oom_kill_process+0x49/0x271
[.] [ pid ]   uid  tgid total_vm      rss cpu oom_adj oom_score_adj name

php5-fpm和mysqld进程频繁被杀,php5-fpm进程实际上是fastcgi worker进程,即使被杀,也能被php-fpm master进程重启,因此不影响网站可用性,但是mysqld进程一旦被杀了,数据库就挂了,导致首页出现“数据库连接错误”提示。

低层次的内存使用优化可以从两方面入手:

1. 降低mysql的oom score权重,降低被OOM Killer选中的几率。

这点可参考『Linux OOM Killer问题』,将mysqld进程的oom_score_adj调整成最小值-1000:

1
2
# pid=$(cat /var/run/mysqld/mysqld.pid)
# echo -1000 > /proc/$pid/oom_score_adj

2. 减少php-fpm的内存使用量。

Nginx作为Web Server,本身不具备解释执行php的能力,我们能通过GET /index.php获得HTML响应的原因是Nginx后端对接了php解释程序,这个对接的协议就是fastcgi(早期使用cgi,因为fork-exec的请求分发执行方式效率太低,后来有了fastcgi,采用对象池模型,避免每次解析配置文件、初始化执行环境等),fastcgi与语言无关,php-fpm就是php语言环境下的一个fastcgi实现。关于cgi、fastcgi、php-fpm之类的讨论可以参考这两篇文章: 『FastCgi与PHP-fpm之间的关系』,『概念了解:CGI,FastCGI,PHP-CGI与PHP-FPM』。

默认情况下,php-fpm中关于进程池的配置(/etc/php5/fpm/pool.d/www.conf)如下:

1
2
3
pm = dynamic ;;表示worker进程动态调整。
pm.max_children = 20 ;;表示最大worker进程数量。
pm.max_requests = 0 ;;表示每个worker进程最多处理多少次请求后重启,0表示永不重启。

从中可以看出默认配置对于小内存(512M)的VPS的不合理之处,每个cgi worker进程的初始内存占用量约为10M,并且很容易就涨至50+M,当并发数稍稍上去一点,可能就会产生 20(workers) * 50M ~= 1G 的内存使用量。这还未考虑某些php第三方库存在内存泄露问题,当pm.max_requests参数为0,内存泄露会造成worker进程内存使用量越用越大,最终失控。

权衡之后,我把配置调整为:

1
2
3
4
5
6
pm = dynamic
pm.max_children = 3
pm.start_servers = 2 ;;启动worker数量
pm.min_spare_servers = 2 ;;最小空闲worker数量
pm.max_spare_servers = 3 ;;最大空闲worker数量
pm.max_requests = 500 ;;每个worker处理500次请求后重启

虽然保守了一点,但至少比较保险。

调整pm.*参数时建议taif syslog和php5-fpm.log,实时监控站点运行状况。tailf syslog目的是为了监控还有没有php-fpm进程被oom kill;tailf php5-fpm.log目的是为了查看fpm worker进程的启动和异常关闭情况。如果调整后出现Nginx “502 bad gateway”错误,或者php-fpm频繁的“WARNING: [pool www] child 10595 exited on signal 9 (SIGKILL) after 78.871109 seconds from start”日志,那肯定是pm.*参数调整不合理无疑了。

以上2种手段毕竟治标不治本,是以牺牲性能为前提的。实际上当内存不足时,最直观也是最简单的方法当然是加内存,既然物理内存没法加,那就加虚拟内存(swap)吧,DigtalOcean上的VPS主机默认没有开swap分区:

1
2
3
4
5
6
7
# free -h
             total       used       free     shared    buffers     cached
Mem:          497M       408M        88M         0B       456K        15M
-/+ buffers/cache:       393M       103M
Swap:            0          0          0
# swapon -s
Filename                                Type            Size    Used    Priority

DigitalOcean提供了SSD硬盘,所以开个2倍物理内存大小的swap分区也没什么问题,以下是使用磁盘文件作为swap分区的步骤:

1. 生成1G大小的磁盘文件

1
# dd if=/dev/zero of=/swapfile bs=1M count=1024

2. 将文件格式化为swap分区

1
# mkswap /swapfile

3. 启动swap分区

1
2
3
4
5
6
7
8
9
# swapon /swapfile
# free -h
             total       used       free     shared    buffers     cached
Mem:          497M       462M        34M         0B       252K        21M
-/+ buffers/cache:       440M        56M
Swap:         1.0G          0       1.0G
# swapon -s
Filename                                Type            Size    Used    Priority
/swapfile                               file            1048572 645840  -1

4. 将swap分区加到/etc/fstab,使重启后仍生效

1
# echo /swapfile swap swap defaults 0 0 >> /etc/fstab

有了1G的基于SSD的swap分区后,就可以把php-fpm参数调得任性一点了:

1
2
3
4
5
6
pm = dynamic
pm.max_children = 10
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 5
pm.max_requests = 1000

初步看来,小内存VPS站点内存耗尽问题解决了。

--EOF--

Tmux应用

Tmux是一个键盘驱动的终端分屏工具,可以替代Linux下的screen。当然,如果是在Mac下使用的话,它的核心功能(例如window,分屏等)也是可以被iTerm2替代的,不过好在Tmux在*nix操作系统中足够通用,用包管理工具(apt-get, brew等)即可安装,所以了解并熟练使用它,还是能为平时终端下的工作节省不少时间的。我从几年前开始使用,但实际上也一直没有形成依赖,因为前面说过了它的可替代性。网上有大堆的所谓Tmux技巧基本上是来自于一本叫『tmux: Productive Mouse-Free Development』的小书,各大网盘也有其电子版下载,抽空通读了一遍,根据示例一步一步个性化Tmux的设置,了解了之前不知道的用法和配置细节。

首先是Tmux的用法简介。

一. session: 会话,Tmux是一个C/S架构的工具,一个会话可以认为是C端和S端一次交互的上下文。我们的所有操作都属于某个session,session可以长时间存在,也可以临时退出再重进。我们可用通过session来区分不同的工作空间,比如本地操作开一个session,远程SSH操作开一个session,又或者SSH生产环境机器开一个session,SSH测试环境机器开一个session。以下是针对session的一些常用操作。

1. open session

1
2
3
$ tmux new-session -s basic
或者
$ tmux new -s basic

-s参数表示session名称,如果不加-s参数,那么Tmux默认会新建一个以数字(下标从0开始)命名的session,并默认打开一个window。打开一个session后,后续的所有控制Tmux本身的快捷键都需要加前缀,默认是Ctrl+b,以下把前缀按键称为Prefix。

2. detach session
想要暂时离开Tmux,回到终端环境时,可以通过快捷键Prefix+d (d for detach)。要注意的时,即使是detach的状态,Tmux中在运行的程序还会继续运行。想要回到Tmux session时,只需执行:

1
$ tmux attach -t basic

-t参数可以指定要attach的session。

3. list session
终端中执行tmux ls (ls for list session)可以列出当前有多少个session。如果已经在session中,执行Prefix+s (s for session)可以列出当前有多少个session,并且可通过上、下键选择要进入的session。

4. kill session
要真正关闭一个session,可以在终端下执行命令tmux kill-session -t basic,其中-t参数表示session名称。

二. window
如果说session是个不可见的东西,那么window就是我们输入、执行命令的地方。一个session可以包含多个window。把window类比成iTerm2中的标签应该就理解了。

1. 创建window
在创建session的时候默认会创建一个以"数字下标+bash"命名的window,并且名称随着bash中执行的不同命令而变化。在新建session时可以通过-n参数指定默认打开的window名称,比如通过tmux new -s basic -n win命名一个win名称的window。也可以随时通过Prefix+,来修改window名称。

2. 切换window
类似标签,我们可以通过一些快捷键在同一个session下的多个window之间切换。比如:

Prefix+p (p for previous):切换到上一个window。
Prefix+n (n for next): 切换到下一个window。
Prefix+0: 切换到0号window,依次类推,1、2、3...
Prefix+w (w for windows): 列出当前session所有window,通过上、下键可以选择切换到指定window。

3. 关闭window
Prefix+&: 关闭当前window。

三. pane
一个window可以切割成多个pane,也就是所谓的分屏,算是Tmux的核心功能之一。

1. 分屏
Prefix+%: 垂直分屏,用一条垂线把当前窗口分成左右两屏。
Prefix+": 水平分屏,用一条水平线把当前窗口分成上下两屏。

2. 切换pane
默认情况下,被选中(激活状态下)的pane会被绿色边框高亮突显出来。
Prefix+o: 依次切换当前窗口下的各个pane。
Prefix+Up|Down|Left|Right: 根据按箭方向选择切换到某个pane。
Prefix+Space(空格键): 对当前窗口下的所有pane重新排列布局,每按一次,换一种样式。
Prefix+z: 最大化当前pane。再按一次后恢复。

3. 关闭pane
Prefix+x: 关闭当前使用中的pane。

关于Tmux的三个核心概念(session、window和pane)及其基本用法已经介绍完毕。接下来的是一些个性化配置和奇技淫巧,包括重新绑定快捷键、自定义快捷键、UI样式、鼠标支持、复制粘贴等等,这些可配置的高级功能也是Tmux受人推崇的原因。Tmux配置文件推荐放在~/.tmux.conf文件中,避免某个用户修改配置影响到其他用户,修改配置文件后要经过reload操作才会在已打开session中生效。

一. 重新绑定快捷键
Tmux的很多默认配置不够友好,需要个人重新定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
unbind C-b
set -g prefix C-a
 
bind C-a send-prefix
 
bind r source-file ~/.tmux.conf \; display "tmux.conf reload!"
 
bind | split-window -h
bind - split-window -v
 
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
 
set -g base-index 1
set -g pane-base-index 1

第1-2行表示重新定义Prefix,把默认的Ctrl+b换成Ctrl+a,便于单手操作。
第4行重新定义Ctrl+a组合键,当Prefix + Ctrl+a按下后,等同于原先Ctrl+a功能,解决Ctrl+a被设置为Prefix后已有快捷键失效的问题,也就是说只要按下两次Ctrl+a,就能实现原先终端下回到行首的功能。
第6行定义新的快捷键Prefix+r,重新加载Tmux配置文件,避免每次要进入命令模式reload配置。
第8-9行重新定义分屏快捷键。使用Prefix+|代替Prefix+%实现垂直分屏,使用Prefix+-代替Prefix+"实现水平分屏。|和-的符号本身就可以表示分屏线形状,非常直观。
第11-14行重新定义上下左右方向键,遵循vi习惯。定义以后,任何需要上下左右方向键的场景都可以用hjkl替代。
第16行表示将window的起始下标设为1。因为标准键盘的0在9后面,Prefix + 0/1/2...切换不便。
第17行表示将pane的起始下标设为1。理由同上。

二. 鼠标支持

1
2
3
4
set-window-option -g mode-mouse on
set -g mouse-select-pane on
set -g mouse-resize-pane on
set -g mouse-select-window on

第1行表示启用鼠标。虽然Tmux推荐用键盘完成所有操作,但是对现代开发人员来说,纯键盘操作的习惯并非那么容易养成,因此启用鼠标配置成为标配。
第2行表示支持鼠标选择pane。
第3行表示支持鼠标调整pane大小。
第4行表示支持鼠标选择window。

三. UI样式调整

1
2
3
4
5
6
7
setw -g window-status-current-fg white
setw -g window-status-current-bg red
setw -g window-status-current-attr bright
 
set -g status-justify left
 
setw -g monitor-activity on

第1-3行表示状态栏中window标签的高亮样式,默认是绿底黑字,设置后当前window红底白字显示。
第5行表示状态栏中window列表左对齐排列。
第7行表示非当前window有内容更新时显示在状态栏。

四. 复制粘贴
默认情况下,按Prefix+[进入复制模式,按回车(Enter)退出复制模式。可以通过配置在复制模式中使用vi习惯操作:

1
setw -g mode-keys vi

在复制模式下,按空格键(Space)开始复制,按回车(Enter)完成复制,并退出模式,按Prefix+]粘贴。这些快捷键也可以通过以下配置进行修改,使操作更加靠近vi。

1
2
3
4
5
6
unbind [
bind Escape copy-mode
unbind p
bind p paste-buffer
bind -t vi-copy 'v' begin-selection
bind -t vi-copy 'y' copy-selection

第1-2行表示重新绑定Escape键,Prefix+Escape为进入复制模式。
第3-4行表示重新绑定p键,Prefix+p为粘贴。
第5行表示重新绑定v键,Prefix+v为开始复制。
第6行表示重新绑定y键,Prefix+y为完成复制。

要查看当前复制的内容,可以在Prefix+:后出现的命令行中输入show-buffer,输入list-buffers可以列出所有的复制历史内容。
关于复制粘贴,更深入的话题是Tmux和系统剪贴板之间的交互,Linux可以使用xclip,Mac可以使用tmux-MacOSX-pasteboard,不过我没有试验成功,暂时可以通过ALT + 鼠标复制内容到系统剪贴板。

五. 多屏操作
默认情况下,一个window上只有一个pane被激活,接收键盘交互。但是某些场景下需要在多个pane中执行相同的操作,比如同时修改两台或更多台远程机器的nginx配置,这时候可以在分屏后按Prefix+:进入命令模式,输入set synchronize-panes,即可进入批量操作模式,要退出批量操作模式,再次输入set synchronize-panes即可。

最后上图一张:
Tmux

--EOF--