原文地址:http://www.tony-yin.top/2018/...
之前分享过一篇【通过 Keepalived 实现 Ceph RBD 的高可用】,主要讲解了将RBD
导出为NFS
,然后通过keepalived
实现高可用,保证当提供虚拟IP
节点发生故障时,可以自动切换节点,使得业务不发生中断。
这样可以基本使用RBD
代替CephFS
对外提供Ceph
服务,至于为什么不用CephFS
就不多说了,不清楚的可以去看上一篇。虽然说这样可以保证无单点故障,但是有一点还是不如CephFS
,那就是CephFS
可以实现多节点同时提供服务,而RBD
说白了其实同时只有一个节点能提供服务,当客户端流量高的时候,RBD
方式的带宽并不能满足需求。就比如都是三个节点,CephFS
可以将客户端流量分流到三个节点,而RBD
只能用一个节点,而带宽上限又取决与网卡、磁盘和IO
等等原因,所以同样的硬件设施RBD
的带宽性能是跟不上的,本文就多虚拟IP
暴露访问方式进行分享。
CephFS & RBD
此前的文章我们Ceph
集群只有一个RBD image
,并且只通过一个vip
暴露这个image
让客户端通过NFS
访问。这与CephFS
的差距就在没有充分利用每个节点的资源,所以我们可以大胆设想一下是否可以通过RBD
对外提供多个vip
,每个节点都能被NFS
访问呢?理想很美好,现实很残酷。如果一个RBD
对多个节点同时提供读写的话,会导致不一致的后果,现在RBD
并不能做到CephFS
那样多个节点同时提供服务且保证读写一致。那怎么办呢?
虽然一个RBD image
不能同时被多客户端访问,但是我们是否可以创建多个RBD image
,然后利用多个vip
对外提供访问呢?这样听起来貌似可行,但是还是存在诸多问题,比如如何暴露多虚拟IP
,如何将IP
绑定到具体的RBD image
,如何保证多RBD image
的高可用等等,下文将就这些技术细节进行详细地分析。
需求背景
客户端有多种应用场景,对流量要求较高的情况下,我们可以为每一种应用场景都提供一个vip
用于NFS
方式访问Ceph
存储集群。然后每个vip
各自对应集群中的一个RBD image
,RBD image
尽量均匀的分布到各个节点上,这样才能把性能提升到最高,比如集群有三个节点的话,如果暴露三个vip
,那么必须要分布到三个不同的节点上,如果要提供四个vip
的话,那么前三个vip
均匀地分布到三个节点上,第四个vip
就在第一个节点上暴露,以此类推,这边说的第一个节点只是我们自己将三个节点进行逻辑上的排序,我们需要通过一些算法确保vip
分布均匀,具体看下文分析。
整体设计
一般在完成一个feature
之前,我们往往需要做一个design
,对要做的事情和流程进行设计和评估,这样不但可以梳理流程,使得之后动手的时候思路清晰,更重要的是可以预见一些问题和难点,尽早与 团队成员进行交流,选择最佳方案,防止真正做的时候走弯路。这边涉及的技术点主要有:
Keepalived暴露多个VIP
keepalived
暴露单个vip
很常见,具体格式网上都有,而暴露多个vip
就要注意一些细节,比如router_id
,ins_name
,priority
等等,对于一个节点而言,它上面keepalived
暴露vip
的情况完全是由配置文件keepalived.conf
所决定的,而对于keepalived.conf
而言,一个vip
其实就是ins
,而ins_name
和router_id
要求同一个keepalived
组内成员相同,我们这边就默认router_id
就是vip
隔着小数点的四位整数相加的和,而ins_name
则是将vip
的小数点换成下划线。
VIP动态均匀分布
vip
均匀分布要保证尽可能的均匀,比如三个节点,如果要提供两个vip
的话,那就随意挑选两个节点作为vip
绑定,如果四个vip
的话,则是三个节点各自绑定一个vip
后再任意选择一个节点作为第四个vip
绑定。我们这边的做法是先将所有节点进行排序,将两个节点作为一个keepalived
组,下两个节点为另外一组,假设有三个节点,我们设为1, 2, 3
,那么如果要暴露三个vip
,我们就需要三个keepalived
组,这边三个组分别是1, 2
,3, 1
和2, 3
,然后组内其中第一个节点为master
,第二个节点为backup
。这样可以基本保证所有vip
的均匀分布,具体算法实现参见下文。
多RBD高可用
上一篇文章中只有一个RBD
,所以高可用就围绕它一个,发生故障后随意切换节点即可,因为我们每个节点都是一个keepalived
组的成员。但是如果有多个RBD
的话,我们如果随意切换的话,那么RBD
分布就会变得不均匀。上文提及的算法可以保证vip
的均匀分布,两两节点作为一个keepalived
组,这样我们即使一个节点掉了,切换也只会在当前组内切换,而vip
一开始绑定节点的时候就根据相应算法保证了每个RBD
的均匀分布,所以这边组内切换不会影响分布的均匀性。
上一篇文章中提过keepalived
的机制,当主节点down
了,主节点会触发我们自己写的ChangetoBackup.sh
,而副节点则会触发ChangetoMaster.sh
。之前由于只有一个RBD
,所以当时做的比较无脑,ChangetoMaster.sh
直接遍历当前节点上面的所有RBD
,然后通过之前记录的RBD
和UI
上创建的目录
的映射关系进行挂载,而ChangetoBackup.sh
也是一样的umount
所有RBD
的挂载点。针对目前的多RBD
的情况,这样的做法肯定是不行的,因为现在我们一个节点可能是一个或多个vip
的master
,也可能是另外一个或多个vip
的backup
,如果我们还是像之前那样一股脑的全部卸载或者挂载,那么造成的后果显而易见,就是业务中断,暴露服务节点紊乱。所以最合理的应该对号入座,一个vip
对应一个RBD image
,哪个vip
出现了问题,作为该vip
的master
节点,应该只umount
该vip
绑定RBD
所对应的目录,而backup
节点应该只mount
对应的目录。其他不相关RBD
和其对应的目录,我们都不应该有所操作。那么我们只有在触发ChangetoMaster.sh
和ChangetoBackup.sh
这两个脚本的时候加上“目录”这个参数,具体实现详见下文分析。
大容量RBD image的创建和删除
我们系统的实现是UI
上创建目录,后端daemon
轮询根据目录信息做对应的事情,比如前端UI
创建了目录,后端就是在创建RBD image
,而生产环境上面的容量的要求都是很高的,往往都是几十T
,甚至上百T
,但是熟悉RBD
的朋友都知道创建如此大的RBD image
是需要很长的时间的,那这样就不但会影响当前目录能够提供服务的时间,也会阻塞住代码,影响之后目录的创建。之前我们的做法是一开始我们可以创建一个比较小的image
,然后我们后台选择在业务不繁忙的时候进行定时扩容,这也可以算是暂时止血了。但是后来测试发现删除image
才是真的慢,这边就不像创建那样有曲线救国的方式了,所以这边无论是创建还是删除RBD image
我们都不能做成同步的方式了,我们采取了另起一个线程单独做这个事情,不影响后端业务的正常处理。
快照保证扩容的安全性
在我们的测试过程中,发现对RBD image
扩容会偶尔发生文件系统出错的情况,这种情况是很危险的,一旦文件系统发生问题,并且用e2fsck
等工具修复不了的话,那么数据恢复是很困难的,我们必须要保证客户数据的安全性。所以我们用了RBD
的snapshot
的功能,在每次扩容之前为RBD image
做快照,这样即使发生了问题,我们起码可以做到最小程度的损失。
具体代码实现
Keepalived暴露多个VIP
当UI
创建一个vip
的时候,我们就要加一个ins
,以下就是我们添加一个ins
的API
,本文所有代码都是python
写的,大家凑合看吧。(部分代码和接口不是很全,文章尾部将会贴出详细代码的地址)
def add_keepalived_ins(self, vip, folder, state):
vrrp_ins = """
vrrp_instance VI_{ins_name} {{
state {state}
interface {pubif}
priority {priority}
virtual_router_id {router_id}
advert_int 1
authentication {{
auth_type PASS
auth_pass 1111
}}
track_script {{
chk_nfs
}}
notify_master "/etc/keepalived/ChangeToMaster.sh {folder}"
notify_backup "/etc/keepalived/ChangeToBackup.sh {folder}"
virtual_ipaddress {{
{vip}
}}
}}
""".format(ins_name = vip.replace('.', '_').replace('/', '_'),
state = state,
priority = 200 if state == "MASTER" else 100,
router_id = self.get_router_id(vip),
pubif = get_public_interface(),
folder = folder,
vip = vip)
return vrrp_ins
这边我们可以看到ins_name
和router_id
都是根据vip
转换成特定格式,标识ins
的唯一性。而priority
则是根据state
来决定,state
为master
时,priority
为200
,而backup
的priority
为100
。至于如何获取state
,这个涉及到vip
均匀算法,后续会讲。
VIP动态均匀分布
假设三个节点,为1, 2, 3
,三个vip
,为a, b, c
,那么最后a
对应的节点为1, 2
,b
对应的节点为3, 1
,c
对应的节点为2, 3
,具体实现算法是先将所有vip
进行排序,获取要操作vip
的index
,然后获取集群内所有节点,然后将上面获取的index
乘以2
,再对所有节点的个数做余数,然后可以获得一个整数,这个整数就是vip
对应master
节点在所有节点数组中的index
,这种算法大家应该很容易从规律中推算出来。
def get_my_state(self, vip_idx):
nodes = get_all_nodes()
nodes.sort()
idx = vip_idx * 2 % len(nodes)
my_ip = get_public_ip()
if my_ip == nodes[idx]:
return 'MASTER'
elif my_ip == nodes[(idx + 1) % len(nodes)]:
return 'BACKUP'
else:
return None
多RBD高可用
我们在创建目录的时候,需要获取当前节点是否为master
,之前那个只有一个vip
,所以当前节点要么是master
,要么是backup
,但是这边的话,一个节点可能是一个vip
的master
的同时也可能是另一个vip
的backup
,所以是否为master
是要根据目录而定的。在这边我们在创建目录、删除目录、创建vip
和删除vip
时,更新一个vip
和目录之间的映射关系。这个map
我是存在ceph
的leveldb
中,至于为什么不存在节点本地,是因为这份数据必须要保证所有节点强一致,放在本地节点,可能会因为一些故障原因导致之后内容不一致的情况。
这边我们要求在创建目录前,必须要存在空闲vip
可以提供目录绑定。所以当创建一个vip
时,此时应该没有目录需要绑定,我们建立一个key
和value
都是vip
的字典;当创建一个目录的时候,随机找到一个空闲vip
进行绑定,建立一个key
为vip
,value
为目录名的字典;当删除vip
时,肯定是存在其他空闲vip
的,所以在删除原来对应map
后,我们要找到其他一个空闲vip
与之前删除vip
对应的目录进行绑定;当删除目录时,只要将对应关系中的value
换成key
,也就是对应的vip
了。
有了这个map
,我们就可以实时获取目录和vip
的信息和之间的对应关系。
vip.py
负责当vip
发生变化时,更新ip_folder_map
,以及ip_folder_map
的读写API
def get_ip_folder_map():
result = {}
ip_folder_map = LevelDB("ip_folder_map")
result = json.loads(ip_folder_map)["ip_folder_map"]
return result
def set_ip_folder_map(ip_folder_map):
ip_folder_map = LevelDB("ip_folder_map")
ip_folder_map.set(json.dumps({"ip_folder_map": ip_folder_map}))
ip_folder_map.save()
def update_ip_folder_map_by_ip(ips):
ip_folder_map = get_ip_folder_map()
old_ips = ip_folder_map.keys()
if len(ips) > len(old_ips):
new_ip = list(set(ips) - set(old_ips))[0]
ip_folder_map[new_ip] = new_ip
else:
del_ip = list(set(old_ips) - set(ips))[0]
folder = ip_folder_map[del_ip]
del ip_folder_map[del_ip]
if folder != del_ip:
for k, v in ip_folder_map.iteritems():
if k == v:
ip_folder_map[k] = folder
break
set_ip_folder_map(ip_folder_map)
folder.py
负责当folder
发生变化时,更新ip_folder_map
import vip
def update_ip_folder_map_by_folder(folder, type):
ip_folder_map = vip.get_ip_folder_map()
folder = get_folder_path(folder)
if type == "add":
for k, v in ip_folder_map.iteritems():
if k == v:
ip_folder_map[k] = folder
break
elif type == "delete":
for k,v in ip_folder_map.iteritems():
if v == folder:
ip_folder_map[k] = k
break
vip.set_ip_folder_map(ip_folder_map)
上面说了在切换节点的时候,需要传递目录参数,保证只操作对应目录。而脚本是静态的,目录确是动态的,所以我们需要在目录或者vip
发生变化的时候对原来的keepalived.conf
进行更新,添加目录参数。也就是说当vip
发生变化时,我们根据当前vip
选择添加或者减少ins
,并且更新每个ins
调用脚本后面追加的参数;而folder
发生变化时,vip
调用脚本后面追加的参数也需要更新,要么是vip
,要么是folder
。这边也需要用到上面的ip_folder_map
,因为每个ins
就是一个vip
,而每个vip
对应一个folder
。所以我们这边当目录或者vip
发生变化时,会根据ip_folder_map
更新keepalived.conf
,具体实现代码如下:
def update_keepalived_conf(self):
kconf = """global_defs {
notification_email {
}
router_id NFS_HA_112
}
vrrp_script chk_nfs {
script "/etc/keepalived/check_nfs.sh"
interval 2
}
"""
vips = self.ip_folder_map.keys()
vips.sort()
for vip, folder in self.ip_folder_map.items():
vip_idx = vips.index(vip)
state = self.get_my_state(vip_idx)
if state is not None:
kconf += self.add_keepalived_ins(vip, folder, state)
with open(KEEPALIVED_CONF_PATH, 'w') as f:
f.writelines(kconf)
do_shell('service keepalived reload')
下面是添加一个ins
的模板,上面也贴过代码,至于这边再次贴一遍的目的是想侧重展示一下脚本后面参数的动态变化的实现方式。
def add_keepalived_ins(self, vip, folder, state):
vrrp_ins = """
vrrp_instance VI_{ins_name} {{
state {state}
interface {pubif}
priority {priority}
virtual_router_id {router_id}
advert_int 1
authentication {{
auth_type PASS
auth_pass 1111
}}
track_script {{
chk_nfs
}}
notify_master "/etc/keepalived/ChangeToMaster.sh {folder}"
notify_backup "/etc/keepalived/ChangeToBackup.sh {folder}"
virtual_ipaddress {{
{vip}
}}
}}
""".format(ins_name = vip.replace('.', '_').replace('/', '_'),
state = state,
priority = 200 if state == "MASTER" else 100,
router_id = self.get_router_id(vip),
pubif = get_public_interface(),
folder = folder,
vip = vip)
return vrrp_ins
触发脚本:
ChangetoMaster.sh
#!/bin/bash
folder="$(dirname $1)/$(basename $1)"
fname=$(basename $folder)
if [ -d $folder ]; then
if $(mount | grep -q "$folder "); then
umount -f $folder > /dev/null
fi
device=$(rbd showmapped | awk '/image_'$fname' / {print $5}')
if [ -b "$device" ]; then
mount $device $folder
fi
fi
service nfs-kernel-server restart
ChangetoBackup.sh
#!/bin/bash
folder=$1
service nfs-kernel-server stop
if [ -d $folder ]; then
if $(mount | grep -q "$folder "); then
umount -f $folder > /dev/null
fi
fi
service nfs-kernel-server start
大容量RBD image的创建和删除
在另外一个端口另起一个线程,通过异步的方式实现,主要利用python
的rpyc
模块实现,忧郁项目保密性等原因,只贴上部分关键代码,给大家提供一些思路。
以删除RBD image
为例,调用remove_image
方法,进入装饰器,从而在新现成做删除操作,不再阻塞之前进程的流程。
def rbd_background():
conn = connect('localhost', RBD_PORT)
module = conn.modules['rbd_utils']
async_func = rpyc.async(getattr(module, func_name))
return async_func
@rbd_utils.rbd_background
def remove_image(pool, image):
while True:
try:
logger.info('rbd {} delete start'.format(image))
do_shell('rbd rm {}/{} >> /var/log/rbd_rm.log'.format(pool, image))
logger.info('rbd {} delete finish'.format(image))
break
except Exception:
logger.error('rbd {} delete error'.format(image))
time.sleep(30)
快照保证扩容的安全性
首先介绍一下定时扩容的脚本:
monitor_rbd.sh
:当RBD image
可利用空间小于50%
或者小于50T
时,扩容50T
#!/bin/bash
function convert_to_MB()
{
size=$1
unit=${size:(-1):1}
nr=${size/$unit/}
case $unit in
(k|K|\)) echo "$nr / 1024" | bc;;
(m|M|\)) echo "$nr";;
(g|G|\)) echo "$nr * 1024" | bc;;
(t|T|\)) echo "$nr * 1024 * 1024" | bc;;
(p|P|\)) echo "$nr * 1024 * 1024 * 1024" | bc;;
*) echo "Error: cannot convert to MB: $size";;
esac
}
function get_available_size()
{
disk=$1
unit_size=$(convert_to_MB '50T')
disk_size=$(df -h | grep $disk | awk '{print $2}')
disk_size=$(convert_to_MB $disk_size)
pool=$(rbd showmapped | grep $disk | awk '{print $2}')
available_pool_size=$(ceph df | grep $pool | awk '{print $5}')
available_pool_size=$(convert_to_MB $available_pool_size)
if [ $(echo "$available_pool_size < $unit_size" | bc) -eq 1 ]; then
new_size=$(echo "$disk_size + $available_pool_size" | bc)
else
new_size=$(echo "$disk_size + $unit_size" | bc)
fi
echo ${new_size%.*}
}
function check_and_enlarge_disk()
{
disk="$1"
if [ "$disk" = "" ]; then
echo "Error: You must specify the disk name"
return 1
fi
echo "Checking the disk [/dev/$disk] ..."
if ! rbd showmapped | grep -q $disk; then
echo "Error: Cannot find the disk [$disk]"
return 2
fi
disk_usage=$(df | grep $disk | awk '{print $5}')
available_disk_size=$(df | grep $disk | awk '{print $4}')
available_disk_size=$(convert_to_MB "${available_disk_size}k")
echo " The disk use% is ${disk_usage}"
disk_usage=${disk_usage/\%/}
if [ $disk_usage -lt 50 -a $available_disk_size -gt 1024 * 1024 * 50 ]; then
echo 'Less then 50% use and more then 50TB available space left, just quit'
return 0
fi
echo 'Enlarging the disk ...'
new_size=$(get_available_size $disk)
echo " the new size is ${new_size}MB"
pool=$(rbd showmapped | grep $disk | awk '{print $2}')
image=$(rbd showmapped | grep $disk | awk '{print $3}')
rbd resize --size $new_size -p $pool $image
sleep 3
resize2fs /dev/${disk} "${new_size}M"
echo "Done"
}
disks=$(lsblk | grep rbd | awk '{print $1}')
for disk in $disks
do
echo "=============================================="
check_and_enlarge_disk "$disk"
echo "=============================================="
done
这边我们采用ceph
提供的原生python
的接口,完成RBD
的定时快照的创建和删除
#!/usr/bin/python
import os
import time
import rados
import rbd
from folder import get_all_folder_info
from vip import get_ip_folder_map
CEPH_CONF = '/etc/ceph/ceph.conf'
MAX_SNAP_COUNT = 5
def create_snap(pool, rbd_image):
now = time.localtime()
snap = time.strftime("%Y_%m_%d_%H_%M_%S", now)
with rados.Rados(conffile=CEPH_CONF) as cluster:
with cluster.open_ioctx(str(pool)) as ioctx:
with rbd.Image(ioctx, rbd_image) as image:
image.create_snap(snap)
def get_images():
pubif = get_public_interface()
pub_ips = do_cmd("ip addr show {} | awk '/inet/ {{print $2}}'".format(pubif)).split()
vip_folders = get_ip_folder_map(gwgroup)
my_folders = []
for pip in pub_ips:
if pip in vip_folders and pip != vip_folders[pip]:
my_folders.append(os.path.basename(vip_folders[pip]))
folders = get_all_folder_info()
images = []
for folder in folders:
if folder in my_folders:
images.append({
'image': 'image_{}'.format(folder),
'pool': folders[folder]['pool']
})
return images
def remove_old_snap(pool, rbd_image):
with rados.Rados(conffile=CEPH_CONF) as cluster:
with cluster.open_ioctx(str(pool)) as ioctx:
with rbd.Image(ioctx, rbd_image) as image:
snaps = sorted(image.list_snaps(), key=lambda snap: snap['name'])
if len(snaps) > MAX_SNAP_COUNT:
for snap in snaps[0:len(snaps)-MAX_SNAP_COUNT]:
image.remove_snap(snap['name'])
def main():
images = get_images()
for image in images:
create_snap(image['pool'], image['image'])
remove_old_snap(image['pool'], image['image'])
device = do_shell("rbd showmapped | awk '/{}[ \t]*{}/ {{print $5}}'".format(image['pool'], image['image']))
do_shell('/usr/local/bin/monitor_rbd.sh {}'.format(os.path.basename(device)))
if __name__ == "__main__":
main()
总结
内容和代码都比较多,其实每一个技术点都可以单独拿出来写一篇,但是我觉得这是一个完整feature
,想让大家能够代入,了解到完成这样一个feature
周边需要支持的各种技术点和注意点,一个feature
往往是经过不断迭代和维护,很多实现方法也随着时间和应用场景不断发生变化。
完成这样一个feature
,我也是反复修改,就比如异步实现RBD image
的创建和删除,很多场景在生产环境和测试环境中的 情况是完全不一样的,比如我开发的时候创建的image
都是1G
的,当然很快,也不能存在什么阻塞的问题,也遇到很多问题和想不通的地方,感谢我的同事和前辈提供的帮助和启发。
最后,衷心希望ceph
能够早日将CephFS
完善,保证其在生产环境中的稳定性和性能。这样我们也就不用绞尽脑汁这般曲线救国了,哈哈。
最后的最后,贴上部分代码地址,由于项目保密性等原因,我只能贴出比较关键的代码,大家请见谅,我觉得这些代码应该足够了,足够给大家提供一个思路了,其实往往思路比代码更重要,相信很多人的实现方式要比我更加优秀呢!
代码地址:https://github.com/tony-yin/M...
如果大家觉得有帮助的话,欢迎Star
哦 ~(≧▽≦)/~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。