分享程式代碼相關筆記
目前文章總數:172 篇
最後更新:2025年 03月 22日
當使用者在訪問 Web Server 時,如果這時發生部署狀況 (如果是 Docker Container 會將整個容器移除再建立新的)
1. 開始部署 | Jenkins 開始進行更新,使用者可以正常訪問 |
2. 更新程式中 | 這時使用者會發現程式掛掉,無法使用 |
3. 部署完成 | 使用者可能 重新整理 畫面,恢復正常 |
但整個 CICD 過程中,使用者就會發現異常的狀況,本篇要解決此問題,因此要實現 零停機部署 讓用戶不會發現系統升級。
Jenkins 在執行 1. 開始部署 階段時,使用者仍可以正常使用網站
※零停機部署-滾動部署實作範例,中的SignalR 聊天室
這時用戶任意操作時,就會觸發 Container 被刪除,重建的狀況,此時用戶會看到 502
Jenkins Server 這時在此階段
當前 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
有以下 4 項主流的零停機部署方式,本篇選擇滾動部署說明:
1. 滾動部署 | : | 逐一更新服務實例,新舊版本同時運作直到全部更新完成。 |
2. 藍綠部署 | : | 兩套環境,新版本部署到備用環境,測試完成後切換流量。 |
3. 紅黑部署 | : | 版本部署到獨立環境進行全面測試,確認無誤才切換流量。 |
4. 金絲雀部署 | : | 新版本導入小比例流量測試,觀察後逐步增加流量直到完全轉換。 |
核心關鍵差異在於 測試強度 :
1. 滾動部署 | : | 依賴健康檢查、基本功能性測試(可略)、自動化測試為主 |
2. 藍綠部署 | : | 依賴健康檢查、基本功能性測試(可略)、自動化測試為主 |
3. 紅黑部署 | : | 最嚴格的內測流程、全面功能性和非功能性測試、QA 團隊完整驗證 |
4. 金絲雀部署 | : | 先進行基本驗證 -> 透過實際用戶反饋逐步驗證 -> 可及早發現問題並控制影響範圍 |
選擇滾動部署的最大優點是 低成本,而且適合微服務、切割成小型的專案
在任何專案的初期都很適合使用,並且未來規模變大後,還可以再往上轉型成其他 零停機部署架構
滾動部署 | 藍綠部署 | 紅黑部署 | 金絲雀部署 | |
---|---|---|---|---|
優點 | 資源利用率高 | 快速切換和回滾 | 驗證完整性高 | 風險最低 |
部署彈性大 | 無中斷服務 | 安全性強 | 問題及早發現 | |
適合微服務架構 | 部署過程簡單 | 可精確控制流量 | ||
缺點 | 回滾困難 | 需要雙倍資源 | 可控制度高 | 監控複雜 |
新舊版本共存風險 | 資料庫同步複雜 | 部署時間最長 | 需要流量控制機制 | |
部署週期長 | ||||
成本 | 中 | 高 | 高 | 高 |
複雜度 | 高 | 中 | 高 | 高 |
適合專案 | 微服務 | 核心業務 | 核心業務 | 複雜大型系統 |
範例代碼,為一個 Asp.net Core 的 WebSite 網站,後端 Server 實現了聊天室、API,因此我們需要解決以下問題,才能避免用戶發現:
代碼端:
解決的問題 | 為何要解決 | |
---|---|---|
1. Session 持久化 | : | Session 保存在伺服器端,這時伺服器關掉,會導致當前用戶 Session 資料遺失,從而發現異常 |
2. SignalR 如何不斷線 | : | SignalR 在保持 WebSocket 的連線中,資料會停止發送與接收,從而發現異常 |
對於回滾困難的問題,利用 Nginx Load Balance 與 自動化腳本的處理,可以很輕鬆解決此問題
※雙福務運行為概念,可以在同一台機器上部署多套相同代碼,如果運行正常在進行切換,永遠只會有一個服務在上面正常運作
伺服器端:
解決的問題 | 為何要解決 | |
---|---|---|
1. 雙服務運行 | : | 利用 Nginx 負載平衡,來分流當前用戶使用的服務,從而輕鬆回滾。 |
2. 健康檢查 | : | 在腳本中處理 Docker Container / 服務,檢查是否正常運行。知道是否正常才能進行判斷。 |
3. 自動化回滾腳本 | : | 當健康檢查後,可以決定是否繼續部署 or 還原舊版功能 |
我們會需要至少 2 個容器在同個 Server 上,並且負載均衡到此 2 容器中
容器對應 : 8090 與 8091 都在同個服務器上 (分開亦可,因為對用戶訪問都是 9080)
一開始,皆尚未更新
這時先將第一個容器關閉(稱為A),對用戶來說沒有影響,因為所有的容量都導向到另一個容器上
更新完畢後,確認正常,並且將 A 容器啟動,這時再將 B 容器關閉,並且更新
對用戶來說也是沒有影響,因為流量導向到 A 容器上
更新完畢後,確認正常,並且將 B 容器啟動,這時 2 個容器都完成了更新
可參考此篇:0085. 分布式 Session 實戰:使用 Redis 解決部署期間的用戶會話遺失問題
並且本篇的範例代碼,已經使用該篇代碼,使用 Redis 進行 Session 持久化
SignalR 聊天室的代碼於此篇:0066. SignalR 橫向擴展部署 Server - Redis Backplane 解決方案
並且本篇的範例代碼,已經使用該篇代碼調整
重連代碼的部分,完整版本需要前後端都處理,但是核心仍是 前端需要進行重連,後端即使沒有做完整的重連機制影響有限。
※補充:後端不做前端重連的額外處理,有可能讓舊的 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); });
滾動部署若有雙服務的條件下,可以很輕鬆解決此問題,最多會出現以下 2 種狀況:
在以下狀況時,會需要進行回滾,因為 A 容器可能無法啟動,或執行異常
但此時不需要回滾,因為 Nginx 的流量都導向到 A 容器
※對用戶來說這時仍然訪問舊的內容
如果出現此種狀況,則需要人為介入排除,因為使用相同的代碼、相同的程式
會出現第一個容器正常啟動,但另一個容器無法啟動時,必定是某個初始資源配置錯誤,或 Appsettings.json 指向導致衝突
※對用戶來說這時已經訪問到新的內容
以下是基本的 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;
}
以下為單容器時的建置腳本,分成 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
}
}
新的結構,多了 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 配置變成以下:
在 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
放在具有權限的目錄下:
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 新增加
如果健康檢查失敗,容器不正常會出現以下提示:
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 新增加
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 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 新增加
健康檢查通過後,讓容器 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 新增加
最後雖然整個過程變長了,但是整體 CICD 部署,也是很迅速地完成,此 2 容器的配置下約為 30 秒(具體要依照代碼複雜度)
此滾動部署的 CICD 流程,可以讓用戶不會發現系統的中止,增加用戶體驗。
※任何過程中有異常將會立即中止