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

2018-11-24  本文已影响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/

上一篇下一篇

猜你喜欢

热点阅读