最近两周优化了我们持续部署的程序,收效显著,记录下来分享给大家
那年公司快速成长,频繁上线新项目,每上线一个项目,就需要新申请一批机器,初始化,部署依赖的服务环境,一个脚本行天下
那年项目发展如火如荼,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)
做到了一次镜像打包多环境共用,上线时也无需再走一次编译打包的流程,只需更新镜像重启容器即可,效率明显提高
如果你觉得文章对你有帮助,请转发分享给更多的人。如果你觉得读的不尽兴,推荐阅读以下文章: