美文网首页
基于docker打造实现自动化集成和无状态持续交付流水线

基于docker打造实现自动化集成和无状态持续交付流水线

作者: 抚剑听琴 | 来源:发表于2018-11-24 14:02 被阅读0次

    #项目背景

    此项目是我在我第一家公司,一家做p2p的互金公司做的项目。当时我主要负责公司所有项目在预发布环境和生产环境部署。公司早期的技术骨干多来自BAT,所以有着很鲜明互联网公司的基因,采用的也是敏捷开发模式。所以是靠着持续迭代的方式,来不断优化改进产品的。并且是用dubbo这样的SOA架构,对后台应用做了比较细致地拆分,因此有大量独立部署的应用服务。这样一来,作为负责发布部署的运维人员,就需要承担高负荷的发布部署工作。

    ##三大痛点

    我先说说在当时的技术条件下,发布部署工作的几个痛点:

    ##1.环境层次较多

    我们当时的流程里包括多套环境:开发环境、测试环境、预发布生产环境,一个版本的代码从开发开始,到生产为止,每个环境都需要做部署,以进行开发、测试验收和投入最终的生产。

    ##2.应用配置和应用代码耦合

    应用代码是采用 Tomcat运行的,配置以配置文件的方式存放在本地读取,在不同的环境中,诸如有关数据库访问地址、中间件地址等配置项就完全不一样,需要部署人员对其一一手工修改以适应相应的环境,如果有遗漏和错误,哪怕是多一个空格这种肉眼难以察觉的错误,都可能引发致命的问题。

    ###3.Docker镜像创建时间长,且需要重复创建

    虽然在我接手此块工作之前,已经在除开发环境以外的其他三套环境,引入了Docker容器。但是由于前面说的第二个痛点,所以要将Tomcat应用以Docker容器的方式运行,必须在创建镜像时将代码和与环境相适应的配置文件同时ADD。这样一来,镜像就做不到通用,即使是统一版本的代码,也必须在多套环境创建多个镜像,传说中的Docker的核心价值“一次编排,随处可用”在当时完全没有体现。每次创建镜像都要对配置文件进行修改,等待将镜像上传个集团科技支持公司维护的镜像仓库(通过公网走VPN),十分费事又费时。

    综上,要提升效率,提升运维人员的价值感,就必须追求做到发布流程的自动化,将各个环节都打通。整个流程的痛点和难点,都集中在配置文件和代码耦合这个关键问题上。我也做过很多尝试,包括用python脚本拉取数据库存储的方式自动化修改配置文件。经过大量踩坑和技术调研,我最终采用下面这套方案,找到了Docker正确的打开方式,实现了自动化集成和无状态持续交付流水线。

    ##解决方案:

    ###方案主要流程:

    ![自动化集成和无状态交付流水线流程图](https://img-blog.csdn.net/20180415204125990?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTA3MTY3MDY=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)

    1.在不同的环境搭建etcd作为应用配置中心,存储常用的应用配置项的键值对。

    2.由测试工程师通过web控制台,编写应用配置文件的模版文件,调用docker-py将模版文件随maven打包的应用代码和confd进行编排,在测试环境创建新的docker镜像;

    3.通过docker-py用新建的镜像创建容器运行,容器内部启动脚本自动调用confd生成配置文件,上报war包MD5值,测试对应用进行测试; 4.测试环境测试通过,通过web控制台测试将镜像从内网测试环境push至阿里云(预发布和生产环境)的私有镜像仓库Harbor,用tag更换标签,测试工程师向运维申请预发布测试。

    5.运维工程师同意预发布测试,通过web控制台,在预发布环境从Harbor拉取镜像,创建容器并启动,通过web控制台查看日志和MD5值无异常后,交由测试工程师继续在预发布环境测试验证。

    6.预发布环境测试通过,测试工程师提出生产发布申请,运维工程师参照第5步流程进行生产发布和验收。

    7.自动录入数据库一次完整的发布操作过程,以便异常时回退和管理分析;

    ###项目亮点:

    1.大幅简化了发布工作流程,发布时间大幅缩短,一次配置修改,一次编排,一次镜像上传,随处可用;

    2.全自动化流程,减少了人工干预,节约人力资源也减少人为错误;

    3.实现了代码和配置的解耦,用docker容器化部署和配置中心做到了与环境无关,从内网到云环境无缝衔接;

    4.人性化操作,包括docker镜像创建,容器部署,war包校验,日志查看等操作,全流程都可以用web控制台操作,非常简单人性化,负责业务测试的同事不需掌握docker等底层技术,也可以非常容易地上手操作。

    #技术栈

    代码管理:git/gitlab

    打包编译工具:maven

    容器:docker

    镜像仓库:harbor

    应用配置中心:redis

    Docker管理API:docker-py

    配置文件模版渲染程序:confd

    管理控制台开发框架:django

    管理控制台数据库:mysql

    管理控制台前端功能实现:

    html+ajax+javascript+jquery

    #编码设计与实现

    这里只展示部分关键代码,完整代码待整理脱敏后上传github开源

    ####Docker容器及镜像管理模块

    ```

    # -*- coding: utf-8 -*-

    import docker

    import config

    import DBquery as dbq

    import DBwrite as dbw

    import os

    import logging

    logger = logging.getLogger("crosscloud") # 为loggers中定义的名称

    #初始化DockerAPI客户端

    def initClient(hostname):

        try:

            hostip = dbq.getHostip(hostname)

            cli = docker.DockerClient(base_url='tcp://%s:2375'%hostip)

            return  cli

        except Exception,e:

            logger.error(e)

            raise

    #获取容器运行状态

    def getContainerStatus(hostname,servername):

        try:

            cli = initClient(hostname)

            status = cli.containers.get(servername).status

            return status

        except Exception,e:

            logger.error(e)

            raise

    #生成指定应用最新的镜像版本号

    def getNewImageversion(servername):

        lastversion = dbq.getLastImageVersion(servername)

        fst = int(lastversion.split('.')[0])

        secd = int(lastversion.split('.')[1])

        thrd = int(lastversion.split('.')[2])

        thrd = thrd + 1

        if thrd >= 10:

            secd = secd + 1

            thrd = thrd % 10

        if secd >= 10:

            fst = fst + 1

            secd = secd % 10

        newversion = "%d.%d.%d" % (fst, secd, thrd)

        return newversion

    #获取指定主机的镜像列表

    def getImageList(hostname):

        try:

            cli = initClient(hostname)

            imagelist = []

            for image in cli.images.list():

                if image.tags != []:

                    imagelist.append(image.tags[0])

            logger.info(hostname+"查询镜像结果:"+str(imagelist))

            return imagelist

        except Exception:

            raise

    #获取指定主机上指定应用容器的镜像版本号

    def getContainerVersion(hostname,servername):

        try:

            cli = initClient(hostname)

            c = cli.containers.get(servername)

            containerVersion = c.image.attrs['RepoTags'][-1].split(':')[-1]

            return containerVersion

        except Exception,e:

            logger.error(e)

            exit(1)

    #登陆harbor镜像仓库

    def registryLogin(cli):

        try:

            cli.login(

                username=config.REGISTRY_USERNAME,

                password=config.REGISTRY_PASSWD,

                registry=config.REGISTRY,

            )

        except Exception,e:

            print e

            logger.error("登录registry失败!"+str(e))

            exit(1)

        logger.info("登录registry成功!")

    #拉取指定镜像在指定机器上实例化一个容器,并将其启动

    def start_container(hostname,servername,version):

        cli = initClient(hostname)

        #登录私有仓库

        registryLogin(cli)

        image ='%s/qguanzi/%s:%s'%(config.REGISTRY,servername,version)

        #拉取指定镜像

        try:

            cli.images.pull(image)

        except Exception,e:

            logger.error(e)

            return False,str(e)

        try:

            c = cli.containers.get(servername)

            c.stop()

            c.remove()

        except Exception,e:

            logger.error("%s容器在%s不存在:\n%s"%(servername,hostname,e))

            return False,str(e)

        finally:

            logger.info("%s容器在%s开始启动..."%(servername,hostname))

            try:

                cli.containers.run(

                    image=image,

                    name=servername,

                    volumes={

                        '/data/docker/logs/%s' % servername:

                            {

                                'bind': '/data/logs',

                                'mode': 'rw'

                            }

                    },

                    mem_limit='1g',

                    network_mode='host',

                    detach=True  # True表示运行容器后,就结束run方法

                )

            except Exception,e:

                logger.info("%s容器在%s启动失败:\n%s" % (servername, hostname,e))

                return False,str(e)

            logger.info("基于镜像%s创建的%s容器在%s启动完成!"%(image,servername,hostname))

            return True,"基于镜像%s创建的%s容器在%s启动完成!"%(image,servername,hostname)

    #容器运行状态切换开关

    def StatusSwitch(hostname,servername):

        try:

            container_status = str(getContainerStatus(hostname,servername))

            c  = initClient(hostname).containers.get(servername)

        except Exception,e:

            logger.error(e)

            return str(e)

        if container_status == 'running':

            c.stop()

            logger.info(hostname+"上的"+ servername +"容器已经停止!")

            return (hostname+"上的"+ servername +"容器已经停止!")

        else:

            c.start()

            logger.info(hostname+"上的"+ servername +"容器已经启动!")

            return (hostname+"上的"+ servername +"容器已经启动!")

    #删除容器

    def deleteContainer(hostname,servername):

        try:

            container_status = str(getContainerStatus(hostname, servername))

            c = initClient(hostname).containers.get(servername)

        except Exception,e:

            logger.error(e)

            return str(e)

        if container_status == 'running':

            c.stop()

            c.remove()

            logger.info(hostname + "上的" + servername + "容器已经删除!")

            return (hostname + "上的" + servername + "容器已经删除!")

        else:

            c.remove()

            logger.info(hostname + "上的" + servername + "容器已经删除!")

            return (hostname + "上的" + servername + "容器已经删除!")

    #创建镜像

    def createImage(hostname,servername,instruction,branch):

        try:

            cli = initClient(hostname)

            path = '/data/configcenter/%s/%s'%(hostname,servername)

            version = getNewImageversion(servername)

            image = '%s/qguanzi/%s:%s' % (config.REGISTRY, servername, version)

            repo_path = '%s/package/'%path

            repo_url = '%s/%s.git'%(config.REPO_URL,servername)

            buildWar(servername, repo_path, branch, repo_url)

            cli.images.build(path=path, tag=image)

        except Exception,e:

            logger.error("因为"+instruction+","+hostname + "上的" + image +"镜像创建失败:"+str(e))

            return str("因为"+instruction+","+hostname + "上的" + image +"镜像创建失败:"+str(e))

        dbw.SetNewImageversion(servername,version,instruction)

        logger.info("因为"+instruction+","+hostname + "上的" + image +"镜像已经创建!")

        return ("因为"+instruction+","+hostname + "上的" + image +"镜像已经创建!")

    #拉取代码,编译生成war包

    def buildWar(servername,repo_path, branch, repo_url):

        try:

            logger.info('开始从' + repo_url + "拉取" + branch + "代码分支")

            if not os.path.isdir(repo_path):

                os.makedirs(repo_path)

                print repo_path

            re = os.system('cd %s;git clone -b %s %s' % (repo_path, branch, repo_url))

            if re != 0:

                os.system('cd %s;git checkout %s;git pull %s' % (repo_path, branch, repo_url))

            logger.info("拉取新代码完成,开始maven打包.....")

            os.system(

                'cd /data/configcenter/%s/package/%s && /usr/local/maven/bin/mvn clean package -U -Dmaven.test.skip=true' % (

                servername, servername)

            )

        except Exception:

            raise

    #上传镜像到harbor镜像仓库

    def pushImage(hostname,image):

        imagefullname = image

        try:

            cli = initClient(hostname)

            version = image.split(':')[-1]

            image = image.split(':')[0]

            print image

            registryLogin(cli)

            cli.images.push(imagefullname, tag=version)

        except Exception,e:

            logger.error(imagefullname+"上传失败:"+str(e))

            return str(e)

        return imagefullname+"上传成功!"

    #删除指定机器上的指定镜像

    def delImage(hostname,image):

        try:

            cli = initClient(hostname)

            cli.images.remove(image)

        except Exception,e:

            logger.error(str(e))

            raise

    #新增部署节点,在数据库插入相应记录

    def addNode(env,hostname,servername):

        try:

            dbw.addNode(env, hostname, servername)

        except Exception,e:

            logger.error("增加节点失败:"+str(e))

            return str(e)

        return "增加节点成功!"

    #删除部署节点,在数据库删除相应记录

    def delNode(env,hostname,servername):

        try:

            dbw.delNode(env, hostname, servername)

        except Exception,e:

            logger.error("删除节点失败:"+str(e))

            return str(e)

        return "删除节点成功!"

    if __name__ == '__main__':

        # cli = initClient('132')

        # registryLogin(cli)

        hostname = '132'

        image = 'docker.example.com/example/webapi:0.1.1'

        print pushImage(hostname,image)

        # version = '0.1.1'

        # for info in cli.images.push('docker.example.com/example/webapi', tag='0.1.1',stream=True):

        #    print info

    ```

    ####redis配置中心管理模块

    ```

    # -*- coding: utf-8 -*-

    import redis

    import DBquery as dbq

    import config

    import logging

    logger = logging.getLogger("crosscloud")

    #初始化链接

    def initConnection(env):

        host,port = dbq.getConfigCenterUrl(env)

        redisConn = redis.StrictRedis(host=host, port=port, db=0)

        return redisConn

    #获取配置列表的键值对

    def getConfiglist(env):

        redisConn = initConnection(env)

        configlist = redisConn.keys('%s*'%config.APPCONFIGKEY)

        confKV = {}

        for conf in configlist:

            confKV[conf] = redisConn.get(conf)

        return confKV

    #新增/修改指定环境的指定配置项

    def setConfigKV(env,config,value):

        try:

            redisConn = initConnection(env)

            redisConn.set(config,value)

        except Exception,e:

            logger.error(str(e))

            return str(e)

        logger.info("配置项:"+config+"的值已经成功设置为:"+value)

        return "配置项:"+config+"的值已经成功设置为:"+value

    #删除指定环境的指定配置项

    def delConfigKV(env,config):

        try:

            redisConn = initConnection(env)

            redisConn.delete(config)

        except Exception,e:

            logger.error(str(e))

            return str(e)

        logger.info("配置项:"+config+"已经成功删除")

        return "配置项:"+config+"已经成功删除"

    #测试用例

    if __name__ == '__main__':

        # print setConfigKV('132','king','cao')

        print delConfigKV('132','king')

    ```

    ####配置模版渲染脚本

    这里提供了redis和etcd做配置中心的两种渲染方式,因python的etcd库不太好用,和生产业务系统中已经部署了redis的原因,我最终采用redis做配置中心,来存储应用的键值对。只需将下列命令放在docker容器内的tomcat启动脚本里,就能使容器在启动时拉取最新的配置,和对配置模版的渲染生成最终的配置文件。关于里面的配置中心的configCenter地址,这里只需在docker容器宿主机上的host文件,或者容器里的host文件,或者容器使用的DNS里进行解析,根据具体网络环境设定即可。

    ```

    #从配置中心拉取配置命令

    #./confd  -onetime -backend etcd -node "http://configCenter1:2379" "http://configCenter:2379" "http://configCenter3:2379"

    #./confd  -onetime -backend redis -node configCenter:6379

    ```

    #####部分页面(不会写样式,求别吐槽)

    整体页面

    ![整体页面](https://img-blog.csdn.net/20180415214114998?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTA3MTY3MDY=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)

    配置中心管理页

    ![配置中心](https://img-blog.csdn.net/20180415214821270?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTA3MTY3MDY=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)

    应用配置编辑页

    ![应用配置编辑](https://img-blog.csdn.net/20180415214907975?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTA3MTY3MDY=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)

    应用配置模版编辑页

    ![应用配置模版编辑](https://img-blog.csdn.net/20180415214951618?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTA3MTY3MDY=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)

    端口号设定对话框

    ![端口号设定](https://img-blog.csdn.net/20180415215030313?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTA3MTY3MDY=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)

    #后续优化设想

    这个项目是在忙里偷闲,在白天做好日常工作的同时,利用周末和晚上加班时间完成的。因而项目细节难免粗陋,后续经过思考主要想在以下几块做改进:

    1.前端交互上,由于本人前端知识不扎实,所以前端只是勉强能用而已。后期打算采用bootstrap和vue.js来改进前端页面和交互体验;

    2.完善日志和异常捕获机制;

    3.将流程前期的测试人员通过控制台触发git代码拉取和Maven编译打包,以及镜像编排的过程由脚本驱动。改为jenkeins的hook+脚本的方式进行;

    4.后期的集群管理,考虑调研swarm和k8s,与当前docker-py的方式进行对比,做一定程度融合,实现最完善的集群管理;

    5.新增对nginx反向代理和负载均衡配置的管理,以适应docker容器实例动态变化的需要;

    6.完善对容器状态的监控,目前只监控里启动/停止状态,后期还可以监控容器内存/cpu/磁盘/网络等硬件资源使用情况,以及业务日志的异常情况捕获,引入时间序列的数据库存储监控数据,结合前端的highchart库做实时的监控看板;

    7.结合第6点的监控状态情况,调用AliYun的API,和第5点实现的容器集群管理机制,达到自动弹性扩容/缩容的目标;

    8.完善权限管理,做到一套平台可以给不同角色的工程师管理不同环境。

    #参考文档

    ###etcd

    https://github.com/coreos/etcd/blob/master/Documentation/op-guide/clustering.md

    ###confd

    https://github.com/kelseyhightower/confd/blob/master/docs/quick-start-guide.md

    ###docker-py

    https://docker-py.readthedocs.io/en/stable/client.html

    https://www.ipcpu.com/2015/03/docker-py-usage/

    相关文章

      网友评论

          本文标题:基于docker打造实现自动化集成和无状态持续交付流水线

          本文链接:https://www.haomeiwen.com/subject/syvwzxtx.html