Docker環境的持續部署優化實踐


最近兩周優化了我們持續部署的程序,收效顯著,記錄下來分享給大家

背景介紹

那年公司快速成長,頻繁上線新項目,每上線一個項目,就需要新申請一批機器,初始化,部署依賴的服務環境,一個腳本行天下

那年項目發展如火如荼,A項目流量暴增馬上給A擴機器,B項目上線新功能又要擴容B,上線新項目沒資源了,就先下線處於流量低峰的C項目主機

每天日夜加班,疲於奔命

那年得知了Docker能拯救我於水火,遂決定為了榮譽(發際線)而戰。

為了快速落地以及盡量降低引入Docker對整個CICD流程的影響,用最小的改動把Docker加入到了我們上線的流程中,流程變化參考下圖

那年容器編排江湖混戰,K8S還不流行,加之時間精力有限,技術實力也跟不上,生產環境沒敢貿然上線編排,單純在之前的主機上跑了Docker,主要解決環境部署和擴容縮容的問題,Docker上線后也確實解決了這兩塊的問題,還帶來了諸如保證開發線上環境一致性等額外驚喜

但Docker的運用也並不是百利而無一害,將同步代碼的方式轉變成打包鏡像、更新容器也帶來了上線時間的增長,同時由於各個環境配置文件的不同也沒能完全做到一次打包多環境共用,本文主要介紹我們是如何對這兩個問題進行優化的

python多線程使用

分析了部署日志,發現在整個部署過程中造成時間增長的主要原因是下載鏡像、重啟容器時間較長

整個部署程序由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)

做到了一次鏡像打包多環境共用,上線時也無需再走一次編譯打包的流程,只需更新鏡像重啟容器即可,效率明顯提高

寫在最后

  1. 缺少編排的容器是沒有靈魂的,繼續推進編排工具的運用將會是2019年工作的重點
  2. 實際上我們在Docker改造穩定后,內網開發測試環境部署了一套k8s集群用到現在已經一年多的時間比較穩定
  3. 線上用到了多雲環境,一部分線上項目已經使用了基於k8s的容器編排,當然還有一部分是我上邊介紹的純Docker環境

長按關注公眾號查看更多原創文章

如果你覺得文章對你有幫助,請轉發分享給更多的人。如果你覺得讀的不盡興,推薦閱讀以下文章:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM