1. 程式人生 > >Docker環境的持續部署優化實踐

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環境

鄭州看婦科那家好

鄭州男科醫院

鄭州婦科醫院

鄭州婦科醫院哪家好