最近两周优化了我们持续部署的程序,收效显著,记录下来分享给大家
那年公司快速成长,频繁上线新项目,每上线一个项目,就需要新申请一批机器,初始化,部署依赖的服务环境,一个脚本行天下
那年项目发展如火如荼,A项目流量暴增马上给A扩机器,B项目上线新功能又要扩容B,上线新项目没资源了,就先下线处于流量低峰的C项目主机
每天日夜加班,疲于奔命
那年得知了Docker能拯救我于水火,遂决定为了荣誉(发际线)而战。
为了快速落地以及尽量降低引入Docker对整个CICD流程的影响,用最小的改动把Docker加入到了我们上线的流程中,流程变化参考下图
那年容器编排江湖混战,K8S还不流行,加之时间精力有限,技术实力也跟不上,生产环境没敢贸然上线编排,单纯在之前的主机上跑了Docker,主要解决环境部署和扩容缩容的问题,Docker上线后也确实解决了这两块的问题,还带来了诸如保证开发线上环境一致性等额外惊喜
但Docker的运用也并不是百利而无一害,将同步代码的方式转变成打包镜像、更新容器也带来了上线时间的增长,同时由于各个环境配置文件的不同也没能完全做到一次打包多环境共用,本文主要介绍我们是如何对这两个问题进行优化的
分析了部署日志,发现在整个部署过程中造成时间增长的主要原因是下载镜像、重启容器时间较长
整个部署程序由python开发,核心思想是用paramiko模块来远程执行ssh命令,在还没有引入Docker的时候,发布是rsyslog同步代码,单线程滚动重启服务,上线Docker后整个部署程序逻辑没有大改,只是把同步代码重启服务给换成了下载镜像重启容器,代码大致如下:
import os
import paramiko
# paramiko.util.log_to_file("/tmp/paramiko.log")
filepath = os.path.split(os.path.realpath(__file__))[0]
class Conn:
def __init__(self, ip, port=22, username='ops'):
self.ip = ip
self.port = int(port)
self.username = username
self.pkey = paramiko.RSAKey.from_private_key_file(
filepath + '/ssh_private.key'
)
def cmd(self, cmd):
ssh = paramiko.SSHClient()
try:
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(self.ip, self.port, self.username, pkey=self.pkey, timeout=5)
except Exception as err:
data = {"state": 0, "message": str(err)}
else:
try:
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=180)
_err_list = stderr.readlines()
if len(_err_list) > 0:
data = {"state": 0, "message": _err_list}
else:
data = {"state": 1, "message": stdout.readlines()}
except Exception as err:
data = {"state": 0, "message": '%s: %s' % (self.ip, str(err))}
finally:
ssh.close()
return data
if __name__ == '__main__':
# 演示代码简化了很多,整体逻辑不变
hostlist = ['10.82.9.47', '10.82.9.48']
image_url = 'ops-coffee:latest'
for i in hostlist:
print(Conn(i).cmd('docker pull %s' % image_url))
# 在镜像下载完成后进行更新容器的操作,代码类似省略了
全部都是单线程操作,可想效率就不会很高,为什么不用多线程?主要还是考虑到服务的可用性,一台服务器更新完成再更新下一台服务器直到所有服务器更新完成,单线程滚动更新最大程度保证服务可用,如果同时所有服务器进行更新,那么服务重启过程中无法对外提供服务,系统会有宕机的风险,且当时项目规模都很小,忽略掉了这个时间的增加,随着项目越来越多,规模越来越大,不得不重新思考这块的优化
引入多线程势在必行,那么多线程该如何应用呢?从服务整体可用性考虑,把下载镜像跟重启容器两个操作拆分,下载镜像不影响服务正常提供,完全可以采用多线程,这样整个下载镜像的时间将大大缩短,优化后代码如下:
import threading
# 再导入上一个示例里边的Conn类
class DownloadThread(threading.Thread):
def __init__(self, host, image_url):
threading.Thread.__init__(self)
self.host = host
self.image_url = image_url
def run(self):
Conn(self.host).cmd('docker login -u ops -p coffee hub.ops-coffee.cn')
r2 = Conn(self.host).cmd('docker pull %s' % self.image_url)
if r2.get('state'):
self.alive_host = self.host
print('---->%s镜像下载完成' % self.host)
else:
self.alive_host = None
print('---->%s镜像下载失败,details:%s' % (self.host, r2.get('message')))
def get_result(self):
return self.alive_host
if __name__ == '__main__':
# 演示代码简化了很多,整体逻辑不变
hostlist = ['10.82.9.47', '10.82.9.48']
image_url = 'ops-coffee:latest'
threads = []
for host in hostlist:
t = DownloadThread(host, image_url)
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
alive_host = []
for t in threads:
alive_host.append(t.get_result())
## 多线程下载镜像结束
print('---->本项目共有主机%d台,%d台主机下载镜像成功' % (len(hostlist), len(alive_host)))
重启容器就不能这么简单粗暴的多线程同时重启了,上边也说了,同时重启就会有服务宕机的风险。线上服务器都有一定的冗余,不能同时重启那么可以分批重启嘛,每次重启多少?分析了流量情况,我们想到了一个算法,如果项目主机少于8台,那么就单线程滚动重启,也用不了太长时间,如果项目主机大于8台,那么用项目主机数/8向上取整,作为多线程重启的线程数多线程重启,这样差不多能保证项目里边有80%左右的主机一直对外提供服务,降低服务不可用的风险,优化后的代码如下:
import threading
from math import ceil
# 再导入上一个示例里边的Conn类
class DeployThread(threading.Thread):
def __init__(self, thread_max_num, host, project_name, environment_name, image_url):
threading.Thread.__init__(self)
self.thread_max_num = thread_max_num
self.host = host
self.project_name = project_name
self.environment_name = environment_name
self.image_url = image_url
def run(self):
self.smile_host = []
with self.thread_max_num:
Conn(self.host).cmd('docker stop %s && docker rm %s' % (self.project_name, self.project_name))
r5 = Conn(self.host).cmd(
'docker run -d --env ENVT=%s --env PROJ=%s --restart=always --name=%s -p 80:80 %s' % (
self.environment_name, self.project_name, self.project_name, self.image_url)
)
if r5.get('state'):
self.smile_host.append(self.host)
print('---->%s镜像更新完成' % (self.host))
else:
print('---->%s服务器执行docker run命令失败,details:%s' % (self.host, r5.get('message')))
# check镜像重启状态 and 重启失败需要回滚代码省略
def get_result(self):
return self.smile_host
if __name__ == '__main__':
# 演示代码简化了很多,整体逻辑不变
alive_host = ['10.82.9.47', '10.82.9.48']
image_url = 'ops-coffee:latest'
project_name = 'coffee'
environment_name = 'prod'
# alive_host / 8 向上取整作为最大线程数
thread_max_num = threading.Semaphore(ceil(len(alive_host) / 8))
threads = []
for host in alive_host:
t = DeployThread(thread_max_num, host, project_name, environment_name, image_url)
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
smile_host = []
for t in threads:
smile_host.append(t.get_result())
print('---->%d台主机更新成功' % (len(smile_host)))
经过以上优化我们实测后发现,一个28台主机的项目在优化前上线要花10分钟左右的时间,优化后只要2分钟左右,效率提高80%
我们采用了项目代码打包进镜像的镜像管理方案,开发、测试、预发布、生产环境配置文件都不同,所以即便是同一个项目不同的环境都会单独走一遍部署发布流程打包镜像,把不同环境的配置打包到不同的镜像中,这个操作太过繁琐且没有必要,还大大增加了我们的上线时间
用过k8s的都知道,k8s中有专门管理配置文件的ConfigMap,每个容器可以定义要挂载的配置,在容器启动时自动挂载,以解决打包一次镜像不同环境都能使用的问题,对于没有用到k8s的要如何处理呢?配置中心还是必不可少的,之前一篇文章《中小团队落地配置中心详解》有详细的介绍我们配置中心的方案
我们处理不同配置的整体思路是,在Docker启动时传入两个环境变量ENVT和PROJ,这两个环境变量用来定义这个容器是属于哪个项目的哪个环境,Docker的启动脚本拿到这两个环境变量后利用confd服务自动去配置中心获取对应的配置,然后更新到本地对应的位置,这样就不需要把配置文件打包进镜像了
以一个纯静态只需要nginx服务的项目为例
Dockerfile如下:
FROM nginx:base
COPY conf/run.sh /run.sh
COPY webapp /home/project/webapp
CMD ["/run.sh"]
run.sh脚本如下:
#!/bin/bash
/etc/init.d/nginx start && \
sed -i "s|/project/env/|/${PROJ}/${ENVT}/|g" /etc/confd/conf.d/conf.toml && \
sed -i "s|/project/env/|/${PROJ}/${ENVT}/|g" /etc/confd/templates/conf.tmpl && \
confd -watch -backend etcd -node=http://192.168.107.101:2379 -node=http://192.168.107.102:2379 || \
exit 1
Docker启动命令:
'docker run -d --env ENVT=%s --env PROJ=%s --restart=always --name=%s -p 80:80 %s' % (
self.environment_name, self.project_name, self.project_name, self.image_url)
做到了一次镜像打包多环境共用,上线时也无需再走一次编译打包的流程,只需更新镜像重启容器即可,效率明显提高