首頁

目前文章總數:172 篇

  

最後更新:2025年 03月 22日

0008. Docker Container - 零停機部署(Zero-downtime deployment) - 滾動部署策略

日期:2025年 03月 08日

標籤: Docker Docker-Compose Container Ubuntu Linux Nginx Jenkins Continuous Integration(CI) Continuous Deployment(CD) Redis StackExchangeRedis SignalR

摘要:Docker


解決問題:部署 Docker Container 時,會先將舊的移除,然後再運行新的 Container 這時雖然只有短短幾秒,但是用戶畫面已經錯誤了
本篇的實作範例:零停機部署-滾動部署實作範例
基本介紹:本篇分為 5 部分。
第一部分:問題描述 & 說明
第二部分:基礎架構 & 滾動部署策略
第三部分:實作調整方式 - 新舊版本共存解法
第四部分:實作調整方式 - 回滾困難解法
第五部分:DEMO 驗證成果






第一部分:問題描述 & 說明

Step 1:問題描述 - 上帝視角 & 架構狀況

當使用者在訪問 Web Server 時,如果這時發生部署狀況 (如果是 Docker Container 會將整個容器移除再建立新的)


1. 開始部署 Jenkins 開始進行更新,使用者可以正常訪問
2. 更新程式中 這時使用者會發現程式掛掉,無法使用
3. 部署完成 使用者可能 重新整理 畫面,恢復正常


但整個 CICD 過程中,使用者就會發現異常的狀況,本篇要解決此問題,因此要實現 零停機部署 讓用戶不會發現系統升級。


Step 2:使用者視角 (1. 開始部署)

Jenkins 在執行 1. 開始部署 階段時,使用者仍可以正常使用網站
零停機部署-滾動部署實作範例,中的SignalR 聊天室


Step 3:使用者視角 (2. 更新程式中)

這時用戶任意操作時,就會觸發 Container 被刪除,重建的狀況,此時用戶會看到 502


Jenkins Server 這時在此階段


Step 4:對應腳本

當前 Docker Container 部署腳本
問題在腳本中的 Step 6. =>重建容器,如果不先將舊的容器刪除,必定會建立容器失敗,因此需要零停機部署的方案,來解決此問題

	// step 6. start - 重建容器,並且將舊的容器先刪除再運行
    stage('ReConstruct Container') {
      steps {
        sshPublisher(
            failOnError: true,
            publishers: [sshPublisherDesc(
            configName: "${env.TARGET_MACHINE_CREDENTIAL}",
            transfers: [sshTransfer(
                excludes: '',
                execCommand: "sudo docker stop ${env.PROJECT_NAME_FOR_DOCKER} && \
                                  docker rm ${env.PROJECT_NAME_FOR_DOCKER} || true && \
                                  docker run -e ASPNETCORE_ENVIRONMENT=Development --name ${env.PROJECT_NAME_FOR_DOCKER} -d -p 8090:8080 -p 8091:8081 \
                                  --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.json,target=/app/appsettings.json \
                                  --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.Development.json,target=/app/appsettings.Development.json \
                                  ${env.PROJECT_NAME_FOR_DOCKER}:latest",
                execTimeout: 120000,
                patternSeparator: '[, ]+')],
            verbose: false)])
      }
    }
	// step 6. end



第二部分:基礎架構 & 滾動部署策略

Step 1:介紹 - 零停機部署 - 方式

有以下 4 項主流的零停機部署方式,本篇選擇滾動部署說明:

1. 滾動部署 逐一更新服務實例,新舊版本同時運作直到全部更新完成。
2. 藍綠部署 兩套環境,新版本部署到備用環境,測試完成後切換流量。
3. 紅黑部署 版本部署到獨立環境進行全面測試,確認無誤才切換流量。
4. 金絲雀部署 新版本導入小比例流量測試,觀察後逐步增加流量直到完全轉換。


核心關鍵差異在於 測試強度

1. 滾動部署 依賴健康檢查、基本功能性測試(可略)、自動化測試為主
2. 藍綠部署 依賴健康檢查、基本功能性測試(可略)、自動化測試為主
3. 紅黑部署 最嚴格的內測流程、全面功能性和非功能性測試、QA 團隊完整驗證
4. 金絲雀部署 先進行基本驗證 -> 透過實際用戶反饋逐步驗證 -> 可及早發現問題並控制影響範圍



Step 2:介紹 - 零停機部署 - 優缺點

選擇滾動部署的最大優點是 低成本,而且適合微服務、切割成小型的專案
在任何專案的初期都很適合使用,並且未來規模變大後,還可以再往上轉型成其他 零停機部署架構

  滾動部署 藍綠部署 紅黑部署 金絲雀部署
優點 資源利用率高 快速切換和回滾 驗證完整性高 風險最低
  部署彈性大 無中斷服務 安全性強 問題及早發現
  適合微服務架構 部署過程簡單   可精確控制流量
缺點 回滾困難 需要雙倍資源 可控制度高 監控複雜
  新舊版本共存風險 資料庫同步複雜 部署時間最長 需要流量控制機制
        部署週期長
成本
複雜度
適合專案 微服務 核心業務 核心業務 複雜大型系統



Step 3:滾動部署 - 新舊版本共存風險(代碼端)

範例代碼,為一個 Asp.net Core 的 WebSite 網站,後端 Server 實現了聊天室、API,因此我們需要解決以下問題,才能避免用戶發現:
代碼端:

解決的問題   為何要解決
1. Session 持久化 Session 保存在伺服器端,這時伺服器關掉,會導致當前用戶 Session 資料遺失,從而發現異常
2. SignalR 如何不斷線 SignalR 在保持 WebSocket 的連線中,資料會停止發送與接收,從而發現異常



Step 4:滾動部署 - 回滾困難(伺服器端)

對於回滾困難的問題,利用 Nginx Load Balance 與 自動化腳本的處理,可以很輕鬆解決此問題
※雙福務運行為概念,可以在同一台機器上部署多套相同代碼,如果運行正常在進行切換,永遠只會有一個服務在上面正常運作
伺服器端:

解決的問題   為何要解決
1. 雙服務運行 利用 Nginx 負載平衡,來分流當前用戶使用的服務,從而輕鬆回滾。
2. 健康檢查 在腳本中處理 Docker Container / 服務,檢查是否正常運行。知道是否正常才能進行判斷。
3. 自動化回滾腳本 當健康檢查後,可以決定是否繼續部署 or 還原舊版功能



Step 5:滾動部署 - 流程(初始架構)

我們會需要至少 2 個容器在同個 Server 上,並且負載均衡到此 2 容器中
容器對應 : 8090 與 8091 都在同個服務器上 (分開亦可,因為對用戶訪問都是 9080)


Step 6:滾動部署 - 流程 - 開始狀態

一開始,皆尚未更新


Step 7:滾動部署 - 流程 - 停止 A 容器並更新

這時先將第一個容器關閉(稱為A),對用戶來說沒有影響,因為所有的容量都導向到另一個容器上


Step 8:滾動部署 - 流程 - 啟動 A 容器,並關閉 B容器更新

更新完畢後,確認正常,並且將 A 容器啟動,這時再將 B 容器關閉,並且更新
對用戶來說也是沒有影響,因為流量導向到 A 容器上


Step 9:滾動部署 - 流程 - 完成

更新完畢後,確認正常,並且將 B 容器啟動,這時 2 個容器都完成了更新


第三部分:實作調整方式 - 新舊版本共存解法

Step 1:Session 持久化

可參考此篇:0085. 分布式 Session 實戰:使用 Redis 解決部署期間的用戶會話遺失問題
並且本篇的範例代碼,已經使用該篇代碼,使用 Redis 進行 Session 持久化

Step 2:SignalR 如何不斷線

SignalR 聊天室的代碼於此篇:0066. SignalR 橫向擴展部署 Server - Redis Backplane 解決方案
並且本篇的範例代碼,已經使用該篇代碼調整

Step 3:SignalR 如何不斷線 - 重連機制

重連代碼的部分,完整版本需要前後端都處理,但是核心仍是 前端需要進行重連,後端即使沒有做完整的重連機制影響有限。
※補充:後端不做前端重連的額外處理,有可能讓舊的 SignalR 狀態資料還視為存活,如果與資料庫有互動,仍會有贓資料產生的疑慮。

調整 Index.html 此段重連方式,採用漸進式重連

// 1. 創建 SignalR 連接 const connection = new signalR.HubConnectionBuilder()
.withUrl("/UpdateHub", { accessTokenFactory: () => "I am jwtToken" })
.withAutomaticReconnect([0, 2000, 10000, 30000]) // 2. 建立重試間隔時間(毫秒)
.build(); // 3. 監聽重連事件 connection.onreconnecting((error) => {
console.log("正在重新連接:", error); }); // 4. 監聽連結成功事件
connection.onreconnected((connectionId) => { console.log("重新連接成功:",
connectionId); });




第四部分:實作調整方式 - 回滾困難解法

Step 1:部署中回滾時機 - 無法啟動

滾動部署若有雙服務的條件下,可以很輕鬆解決此問題,最多會出現以下 2 種狀況:
在以下狀況時,會需要進行回滾,因為 A 容器可能無法啟動,或執行異常
但此時不需要回滾,因為 Nginx 的流量都導向到 A 容器
※對用戶來說這時仍然訪問舊的內容


Step 2:部署中回滾時機 - 另一個異常

如果出現此種狀況,則需要人為介入排除,因為使用相同的代碼、相同的程式
會出現第一個容器正常啟動,但另一個容器無法啟動時,必定是某個初始資源配置錯誤,或 Appsettings.json 指向導致衝突
※對用戶來說這時已經訪問到新的內容

第五部分:DEMO 驗證成果

Step 1:Nginx 配置 - 獨立 UpStream

以下是基本的 Nginx 配置網站,對外永遠都是 9080

map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

server {
listen 9080;
server_name localhost;

    location / {
        proxy_pass http://zero_down_time;
        proxy_http_version 1.1;
        proxy_set_header Host $host:$server_port;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }

    location /UpdateHub {
        proxy_pass http://zero_down_time;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host:$server_port;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
    }

}




但是對內的負載平衡需要配置支援雙容器

upstream zero_down_time {
server 127.0.0.1:8090;
server 127.0.0.1:8091;
keepalive 32;
}




Step 2:Jenkins 自動化處理腳本 - 基本建置

以下為單容器時的建置腳本,分成 8 個步驟

步驟 工作 備註
1. 拉代碼  
2. 建置代碼  
3. 傳送檔案  
4. 傳送 DockerFile  
5. 建立 Image  
6. 重建新容器 A  
7. 重建新容器 B  
8. 刪除過時的 Image  
pipeline {
  agent any

  // 環境變數
  environment {
		PORT_HTTP = "8080"   // 對應宿主機  Http Port號
        PROJECT_NAME = "ZeroDowntimeDeploymentForDockerWebsiteExample"// 專案名稱
		PROJECT_NAME_FOR_DOCKER = "zerodowntimedeploymentfordockerwebsiteexample"// DockerName 強制小寫
		GIT_SOURCE_REPOSITORY = "https://github.com/gotoa1234/MyBlogExample.git"// 專案來源
		TARGET_MACHINE_IP = "192.168.51.93"// 對應的部署機器IP
		TARGET_MACHINE_CREDENTIAL = "DeployMechineUbuntu"// 對應部署機器的SSH Server Name
  }

  // 定義單一建置時可異動參數
  parameters {
        string(name: 'GIT_HASH_TAG', defaultValue: '', description: '指定發布的GIT Hash 標籤(雜湊版號),預設 head 表示更新最新代碼')
  }

  stages {

    // step 1. start
    stage('Checkout') {
       steps {
            checkout([$class: 'GitSCM',
                branches: [[name: "remotes/origin/main"]],
                userRemoteConfigs: [[url: "${env.GIT_SOURCE_REPOSITORY}"]]
            ])

            sh """
                git pull origin main
            """

            sh """
                git checkout ${params.GIT_HASH_TAG}
            """
        }
     }
    // step 1. end

    // step 2. start
    stage('Building') {
      steps {
        script {
                    sh """
                      dotnet publish ${PROJECT_NAME}/${PROJECT_NAME}.csproj -c Release -o publish/${PROJECT_NAME} --disable-build-servers
                    """
                }
      }
    }
    // step 2. end

    // step 3. start
    stage('Publish Main Host') {
	  steps {
	      sshPublisher(publishers:
	             [sshPublisherDesc(configName: "${env.TARGET_MACHINE_CREDENTIAL}",
	                                transfers: [
	                                    sshTransfer(cleanRemote: true,
	                                                   excludes: '',
	                                                execCommand: '',
	                                                execTimeout: 120000,
	                                                    flatten: false,
	                                              makeEmptyDirs: false,
	                                          noDefaultExcludes: false,
	                                           patternSeparator: '[, ]+',
	                                            remoteDirectory: "var\\dockerbuildimage\\${PROJECT_NAME}\\publish",
	                                         remoteDirectorySDF: false,
	                                               removePrefix: "publish\\${PROJECT_NAME}",
	                                                sourceFiles: "publish\\${PROJECT_NAME}\\**")],
	                     usePromotionTimestamp: false,
	                   useWorkspaceInPromotion: false,
	                                   verbose: false)
	             ])
	  }
    }
    // step 3. end

	// step 4. start
    stage('Publish DockerFile') {
	  steps {
	      sshPublisher(publishers:
		         [sshPublisherDesc(configName: "${env.TARGET_MACHINE_CREDENTIAL}",
				                    transfers: [
									    sshTransfer(cleanRemote: false,
										               excludes: '',
													execCommand: '',
													execTimeout: 120000,
													    flatten: false,
												  makeEmptyDirs: false,
											  noDefaultExcludes: false,
											   patternSeparator: '[, ]+',
											    remoteDirectory: "var\\dockerbuildimage\\${PROJECT_NAME}",
											 remoteDirectorySDF: false,
											       removePrefix: "${PROJECT_NAME}",
												    sourceFiles: "${PROJECT_NAME}\\Dockerfile")],
					   usePromotionTimestamp: false,
					 useWorkspaceInPromotion: false,
					                 verbose: false)
				])
	  }
    }
    // step 4. end

	// step 5. start
    stage('Build Image Remotely') {
      steps {
	    sh """
		   echo cd /var/dockerbuildimage/${env.PROJECT_NAME}
		   echo docker build --no-cache -t ${env.PROJECT_NAME_FOR_DOCKER} .
		   echo docker tag ${env.PROJECT_NAME_FOR_DOCKER}:latest ${env.PROJECT_NAME_FOR_DOCKER}:hash_${params.GIT_HASH_TAG}
		   """

        sshPublisher(
            failOnError: true,
            publishers: [sshPublisherDesc(
            configName: "${env.TARGET_MACHINE_CREDENTIAL}",
            transfers: [sshTransfer(
                excludes: '',
                execCommand: "cd /var/dockerbuildimage/${env.PROJECT_NAME} && \
                              docker build --no-cache -t ${env.PROJECT_NAME_FOR_DOCKER} . && \
                              docker tag ${env.PROJECT_NAME_FOR_DOCKER}:latest ${env.PROJECT_NAME_FOR_DOCKER}:hash_${params.GIT_HASH_TAG}",
                execTimeout: 120000,
                patternSeparator: '[, ]+')],
            verbose: false)])
      }
    }
	// step 5. end

	// step 6. start
    stage('ReConstruct Container') {
      steps {
        sshPublisher(
            failOnError: true,
            publishers: [sshPublisherDesc(
            configName: "${env.TARGET_MACHINE_CREDENTIAL}",
            transfers: [sshTransfer(
                excludes: '',
                execCommand: "sudo docker stop ${env.PROJECT_NAME_FOR_DOCKER} && \
                                  docker rm ${env.PROJECT_NAME_FOR_DOCKER} || true && \
                                  docker run -e ASPNETCORE_ENVIRONMENT=Development --name ${env.PROJECT_NAME_FOR_DOCKER} -d -p 8090:8080 -p 8091:8081 \
                                  --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.json,target=/app/appsettings.json \
                                  --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.Development.json,target=/app/appsettings.Development.json \
                                  ${env.PROJECT_NAME_FOR_DOCKER}:latest",
                execTimeout: 120000,
                patternSeparator: '[, ]+')],
            verbose: false)])
      }
    }
	// step 6. end

	// step 7. start
    stage('Image Purne') {
      steps {
         sshPublisher(
             failOnError: true,
             publishers: [sshPublisherDesc(
             configName: "${env.TARGET_MACHINE_CREDENTIAL}",
             transfers: [sshTransfer(
                 excludes: '',
                 execCommand: "docker image prune -f",
                 execTimeout: 120000,
                 patternSeparator: '[, ]+')],
             verbose: false)])
      }
    }
	// step 7. end
  }
}




Step 3:Jenkins 自動化處理腳本 - 停用容器A

新的結構,多了 5 個步驟,如下:

步驟 工作 備註
1. 拉代碼  
2. 建置代碼  
3. 傳送檔案  
4. 傳送 DockerFile  
5. 建立 Image  
6. 停用容器 A 新增加
7. 重建新容器 A  
8. 健康檢查腳本 新增加
9. 啟用 A 停用 B 新增加
10. 重建新容器 B  
11. 健康檢查腳本 新增加
12. 啟用 B 新增加
13. 刪除過時的 Image  


建立 Image 後,要先將 A 容器連接關閉:
CICD 中,這邊需先將 Nginx 的流量關閉,導向到容器 B

// step 6. start 新增加
stage('Close Container A') {
  steps {
    sshPublisher(
             failOnError: true,
             publishers: [sshPublisherDesc(
             configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
             transfers: [sshTransfer(
                 excludes: '', 
                 execCommand: '''
                              sed -i "s/server 127.0.0.1:8090;/server 127.0.0.1:8090 down;/" /etc/nginx/conf.d/zerodowntime_upstream.conf
                              nginx -s reload
                              ''', 
                 execTimeout: 120000, 
                 patternSeparator: '[, ]+')], 
             verbose: true)])
     }
 }
// step 6. end 新增加 


執行後,Nginx 後透過 SH 指令, Ubuntu 上 Nginx 配置變成以下:


Step 3:目標機器 - 添加健康檢查檔案

在 Ubuntu Server (部署的目標機器)上,添加以下 Shell 腳本,作為容器的健康檢查,讓 Jenkins CICD 可以呼叫確認

# health-check-container.sh
# 參數: $1 為容器名稱或 ID

CONTAINER_NAME=$1
MAX_RETRIES=30
DELAY=2

for i in $(seq 1 $MAX_RETRIES); do
    # 檢查容器是否運行中
    if [ "$(docker inspect -f '\{\{.State.Running\}\}' $CONTAINER_NAME 2>/dev/null)" == "true" ]; then
        # 額外檢查容器是否真的準備好(非重啟狀態)
        RESTART_COUNT=$(docker inspect -f '\{\{.RestartCount\}\}' $CONTAINER_NAME)
        STATUS=$(docker inspect -f '\{\{.State.Status\}\}' $CONTAINER_NAME)
        
        if [ "$STATUS" == "running" ]; then
            echo "Container $CONTAINER_NAME is running properly"
            exit 0
        fi
    fi
    
    echo "Waiting for container $CONTAINER_NAME to be ready... (Attempt $i/$MAX_RETRIES)"
    sleep $DELAY
done

echo "Container $CONTAINER_NAME failed to start properly"
exit 1


放在具有權限的目錄下:


Step 4:Jenkins 自動化處理腳本 - 添加健康檢查腳本

CICD 中,在重建容器 A 後,添加以下 Pipeline 語法,進行檢查

// step 8. start 新增加
stage('Health Check A') {
  steps {
    sshPublisher(
        failOnError: true,
        publishers: [sshPublisherDesc(
        configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
        transfers: [sshTransfer(
            excludes: '', 
            execCommand: "/var/dockerbuildimage/ZeroDowntimeDeploymentForDockerWebsiteExample/health-check-container.sh ${env.PROJECT_NAME_FOR_DOCKER}_A", 
            execTimeout: 120000, 
            patternSeparator: '[, ]+')], 
        verbose: false)])
  }
}
// step 8. end 新增加



如果健康檢查失敗,容器不正常會出現以下提示:


Step 5:Jenkins 自動化處理腳本 - 啟用 A 關閉 B

CICD 中,健康檢查 A 通過後,添加以下 Pipeline 語法
Nginx 要啟用 A 並且關閉 B ,使其流量導向到更新後的容器 A

// step 9. start 新增加
stage('Open A AND Close B') {
  steps {
    sshPublisher(
             failOnError: true,
             publishers: [sshPublisherDesc(
             configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
             transfers: [sshTransfer(
                 excludes: '', 
                 execCommand: '''
                              sed -i "s/server 127.0.0.1:8090 down;/server 127.0.0.1:8090;/" /etc/nginx/conf.d/zerodowntime_upstream.conf
							  sed -i "s/server 127.0.0.1:8091;/server 127.0.0.1:8091 down;/" /etc/nginx/conf.d/zerodowntime_upstream.conf
                              nginx -s reload
                              ''', 
                 execTimeout: 120000, 
                 patternSeparator: '[, ]+')], 
             verbose: true)])
     }
 }
// step 9. end 新增加 



Step 6:Jenkins 自動化處理腳本 - 更新容器B

CICD 中,啟用 A 關閉 B 後,添加以下 Pipeline 語法
接著更新容器 B

// step 10. start
stage('ReConstruct Container B') {
  steps {
    sshPublisher(
        failOnError: true,
        publishers: [sshPublisherDesc(
        configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
        transfers: [sshTransfer(
            excludes: '', 
            execCommand: "sudo docker stop ${env.PROJECT_NAME_FOR_DOCKER}_B && \
                              docker rm ${env.PROJECT_NAME_FOR_DOCKER}_B || true && \
                              docker run -e ASPNETCORE_ENVIRONMENT=Development --name ${env.PROJECT_NAME_FOR_DOCKER}_B -d -p 8091:8080 -p 8191:8081 \
                              --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.json,target=/app/appsettings.json \
                              --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.Development.json,target=/app/appsettings.Development.json \
                              ${env.PROJECT_NAME_FOR_DOCKER}:latest", 
            execTimeout: 120000, 
            patternSeparator: '[, ]+')], 
        verbose: false)])
  }
} 	
// step 10. end



Step 7:Jenkins 自動化處理腳本 - 健康檢查腳本

Step 4:Jenkins 自動化處理腳本 - 添加健康檢查腳本 相同
但是這次針對容器 B 做健康檢查

// step 11. start 新增加
stage('Health Check B') {
  steps {
    sshPublisher(
        failOnError: true,
        publishers: [sshPublisherDesc(
        configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
        transfers: [sshTransfer(
            excludes: '', 
            execCommand: "/var/dockerbuildimage/ZeroDowntimeDeploymentForDockerWebsiteExample/health-check-container.sh ${env.PROJECT_NAME_FOR_DOCKER}_A", 
            execTimeout: 120000, 
            patternSeparator: '[, ]+')], 
        verbose: false)])
  }
}
// step 11. end 新增加



Step 7:Jenkins 自動化處理腳本 - 啟用容器 B

健康檢查通過後,讓容器 B 也啟用
※這邊可以選擇,是否永遠只 1 個容器運行,要依照自己的專案架構決定

// step 12. start 新增加
stage('Open B') {
  steps {
    sshPublisher(
             failOnError: true,
             publishers: [sshPublisherDesc(
             configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
             transfers: [sshTransfer(
                 excludes: '', 
                 execCommand: '''                                  
							  sed -i "s/server 127.0.0.1:8091 down;/server 127.0.0.1:8091;/" /etc/nginx/conf.d/zerodowntime_upstream.conf
                              nginx -s reload
                              ''', 
                 execTimeout: 120000, 
                 patternSeparator: '[, ]+')], 
             verbose: true)])
     }
 }
// step 12. end 新增加 



Step 8:Jenkins 自動化處理腳本 - 完成部署結果

最後雖然整個過程變長了,但是整體 CICD 部署,也是很迅速地完成,此 2 容器的配置下約為 30 秒(具體要依照代碼複雜度)
此滾動部署的 CICD 流程,可以讓用戶不會發現系統的中止,增加用戶體驗。
※任何過程中有異常將會立即中止