实现RBD导出NFS高可用(二):提供多虚拟IP访问

原文地址: 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 imageRBD image尽量均匀的分布到各个节点上,这样才能把性能提升到最高,比如集群有三个节点的话,如果暴露三个vip,那么必须要分布到三个不同的节点上,如果要提供四个vip的话,那么前三个vip均匀地分布到三个节点上,第四个vip就在第一个节点上暴露,以此类推,这边说的第一个节点只是我们自己将三个节点进行逻辑上的排序,我们需要通过一些算法确保vip分布均匀,具体看下文分析。

整体设计

一般在完成一个feature之前,我们往往需要做一个design,对要做的事情和流程进行设计和评估,这样不但可以梳理流程,使得之后动手的时候思路清晰,更重要的是可以预见一些问题和难点,尽早与 团队成员进行交流,选择最佳方案,防止真正做的时候走弯路。这边涉及的技术点主要有:

Keepalived暴露多个VIP

keepalived暴露单个vip很常见,具体格式网上都有,而暴露多个vip就要注意一些细节,比如router_idins_namepriority等等,对于一个节点而言,它上面keepalived暴露vip的情况完全是由配置文件keepalived.conf所决定的,而对于keepalived.conf而言,一个vip其实就是ins,而ins_namerouter_id要求同一个keepalived组内成员相同,我们这边就默认router_id就是vip隔着小数点的四位整数相加的和,而ins_name则是将vip的小数点换成下划线。

VIP动态均匀分布

vip均匀分布要保证尽可能的均匀,比如三个节点,如果要提供两个vip的话,那就随意挑选两个节点作为vip绑定,如果四个vip的话,则是三个节点各自绑定一个vip后再任意选择一个节点作为第四个vip绑定。我们这边的做法是先将所有节点进行排序,将两个节点作为一个keepalived组,下两个节点为另外一组,假设有三个节点,我们设为1, 2, 3,那么如果要暴露三个vip,我们就需要三个keepalived组,这边三个组分别是1, 23, 12, 3,然后组内其中第一个节点为master,第二个节点为backup。这样可以基本保证所有vip的均匀分布,具体算法实现参见下文。

多RBD高可用

上一篇文章中只有一个RBD,所以高可用就围绕它一个,发生故障后随意切换节点即可,因为我们每个节点都是一个keepalived组的成员。但是如果有多个RBD的话,我们如果随意切换的话,那么RBD分布就会变得不均匀。上文提及的算法可以保证vip的均匀分布,两两节点作为一个keepalived组,这样我们即使一个节点掉了,切换也只会在当前组内切换,而vip一开始绑定节点的时候就根据相应算法保证了每个RBD的均匀分布,所以这边组内切换不会影响分布的均匀性。

上一篇文章中提过keepalived的机制,当主节点down了,主节点会触发我们自己写的ChangetoBackup.sh,而副节点则会触发ChangetoMaster.sh。之前由于只有一个RBD,所以当时做的比较无脑,ChangetoMaster.sh直接遍历当前节点上面的所有RBD,然后通过之前记录的RBDUI上创建的目录的映射关系进行挂载,而ChangetoBackup.sh也是一样的umount所有RBD的挂载点。针对目前的多RBD的情况,这样的做法肯定是不行的,因为现在我们一个节点可能是一个或多个vipmaster,也可能是另外一个或多个vipbackup,如果我们还是像之前那样一股脑的全部卸载或者挂载,那么造成的后果显而易见,就是业务中断,暴露服务节点紊乱。所以最合理的应该对号入座,一个vip对应一个RBD image,哪个vip出现了问题,作为该vipmaster节点,应该只umountvip绑定RBD所对应的目录,而backup节点应该只mount对应的目录。其他不相关RBD和其对应的目录,我们都不应该有所操作。那么我们只有在触发ChangetoMaster.shChangetoBackup.sh这两个脚本的时候加上“目录”这个参数,具体实现详见下文分析。

大容量RBD image的创建和删除

我们系统的实现是UI上创建目录,后端daemon轮询根据目录信息做对应的事情,比如前端UI创建了目录,后端就是在创建RBD image,而生产环境上面的容量的要求都是很高的,往往都是几十T,甚至上百T,但是熟悉RBD的朋友都知道创建如此大的RBD image是需要很长的时间的,那这样就不但会影响当前目录能够提供服务的时间,也会阻塞住代码,影响之后目录的创建。之前我们的做法是一开始我们可以创建一个比较小的image,然后我们后台选择在业务不繁忙的时候进行定时扩容,这也可以算是暂时止血了。但是后来测试发现删除image才是真的慢,这边就不像创建那样有曲线救国的方式了,所以这边无论是创建还是删除RBD image我们都不能做成同步的方式了,我们采取了另起一个线程单独做这个事情,不影响后端业务的正常处理。

快照保证扩容的安全性

在我们的测试过程中,发现对RBD image扩容会偶尔发生文件系统出错的情况,这种情况是很危险的,一旦文件系统发生问题,并且用e2fsck等工具修复不了的话,那么数据恢复是很困难的,我们必须要保证客户数据的安全性。所以我们用了RBDsnapshot的功能,在每次扩容之前为RBD image做快照,这样即使发生了问题,我们起码可以做到最小程度的损失。

具体代码实现

Keepalived暴露多个VIP

UI创建一个vip的时候,我们就要加一个ins,以下就是我们添加一个insAPI,本文所有代码都是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_namerouter_id都是根据vip转换成特定格式,标识ins的唯一性。而priority则是根据state来决定,statemaster时,priority200,而backuppriority100。至于如何获取state,这个涉及到vip均匀算法,后续会讲。

VIP动态均匀分布

假设三个节点,为1, 2, 3,三个vip,为a, b, c,那么最后a对应的节点为1, 2b对应的节点为3, 1c对应的节点为2, 3,具体实现算法是先将所有vip进行排序,获取要操作vipindex,然后获取集群内所有节点,然后将上面获取的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,但是这边的话,一个节点可能是一个vipmaster的同时也可能是另一个vipbackup,所以是否为master是要根据目录而定的。在这边我们在创建目录、删除目录、创建vip和删除vip时,更新一个vip和目录之间的映射关系。这个map我是存在cephleveldb中,至于为什么不存在节点本地,是因为这份数据必须要保证所有节点强一致,放在本地节点,可能会因为一些故障原因导致之后内容不一致的情况。

这边我们要求在创建目录前,必须要存在空闲vip可以提供目录绑定。所以当创建一个vip时,此时应该没有目录需要绑定,我们建立一个keyvalue都是vip的字典;当创建一个目录的时候,随机找到一个空闲vip进行绑定,建立一个keyvipvalue为目录名的字典;当删除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的创建和删除

在另外一个端口另起一个线程,通过异步的方式实现,主要利用pythonrpyc模块实现,忧郁项目保密性等原因,只贴上部分关键代码,给大家提供一些思路。

以删除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哦 ~(≧▽≦)/~

你可能感兴趣的:(高可用,存储技术,ceph)