基于docker打造实现自动化集成和无状态持续交付流水线
#项目背景
此项目是我在我第一家公司,一家做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/