零中斷服務滾動更新

簡介

在建立了基本的應用系統(AP Server & MySQL)之後,我們將進一步進行 CI/CD(continuous integration and continuous deployment)的實踐,特別考慮到 Kubernetes 提供的特性。

回顧過去,新需求經歷了一系列的開發、測試、通過和發佈的流程。在發佈過程中,常常會導致服務的暫時中斷,這可能導致用戶體驗不佳,甚至引發資料錯誤。這次我們看到 Kubernetes 的出現,可以將這部分的困擾降低到最低甚至是零,這讓我特別想要進行測試。

這次的測試重點項目是在高流量狀態下,發佈新版本程式是否會導致系統問題或用戶端中斷服務。為了模擬高流量,我們將使用老朋友 JMeter 這個工具。

測試步驟

1. 開發環境準備

確保你的開發環境中已經安裝了 Docker、Kubernetes 和 kubectl。

2. CI/CD 整合

將現有的應用系統整合到 CI/CD 流程中,這可以使用一些常見的 CI/CD 工具,如 Jenkins、GitLab CI、或 GitHub Actions。設定自動化的流水線,當新的代碼推送到存儲庫時,自動觸發建置、測試和部署。

3. Kubernetes 部署

使用 Kubernetes 部署你的應用程式,這可以通過 YAML 文件定義你的 Deployment、Service 和 Ingress 等 Kubernetes 物件。

4. JMeter 測試

使用 JMeter 進行高流量下的壓力測試。這可以通過模擬大量用戶訪問你的應用程式,觀察系統的性能和穩定性。

老樣子 先說測試結果

經過測試,我們成功實現了在高流量狀態下,無中斷地更新服務。這次測試的結果令人滿意,同時也讓我意識到 Kubernetes 在實現零中斷部署方面的強大性能

進階測試

在這次的測試中,我們確保了服務的高可用性和穩定性。然而還有更多進階的測試項目,如負載均衡(Load Balancing)。測試 Load Balancing 需要更多的時間和準備,我計劃在未來進一步延伸這方面的測試。

人狠話不多,上扣!!!

1. Java CommonController

import com.caster.normal.mapper.MenuMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * @author caster.hsu
 * @Since 2023/12/26
 */
@RequestMapping("/common")
@RestController
@Slf4j
public class CommonController {
    @Value("${customer.appVersion:V0}")
    String appVersion;

    @Autowired
    MenuMapper mapper;
    @GetMapping("/healthy")
    public ResponseEntity healthyCheck(HttpServletRequest req){
        // init CP checker:readinessProbe
        String checkerName = req.getHeader("checker");
        StringBuilder sb = new StringBuilder();
        sb.append(System.getenv("POD_ID"));
        sb.append(" >> ");
        sb.append(System.getenv("POD_IP"));
        sb.append(" >> ");
        sb.append(appVersion);
        log.debug("checker:{} > appVersion:{}, time:{}", StringUtils.rightPad(checkerName, 15, " "), appVersion, LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
        return ResponseEntity.ok(sb.toString());
    }

    @GetMapping("/time")
    public ResponseEntity timeCheck(){
        return ResponseEntity.ok(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
    }

    @GetMapping("/preStop")
    public ResponseEntity preStop(){
        log.debug("got preStop action.......");
        return ResponseEntity.ok(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
    }
}

這邊建立幾個API,為了讓K8S根據Hook Event 來呼叫的。

  1. /common/healthy -> 顧名思義健康檢查

  2. /common/time -> 獲取Server時間

  3. /common/preStop -> 告知準備停機API,讓server 可以接收到此api,自控若有新的api進來直接回傳停機告警,避免當前Threade 無預警中斷,衍生後續更多問題。

2. K8S CreateNormal.yml

apiVersion: apps/v1
kind: Deployment
metadata:
    labels:
        app.kubernetes.io/name: normal-project
    name: normal
spec:
    replicas: 2
    selector:
        matchLabels:
            app.kubernetes.io/name: normal-project
    template:
        metadata:
            labels:
                app.kubernetes.io/name: normal-project
        spec:
            containers:
                -   image: casterhsu/normal:v5
                    imagePullPolicy: Never
                    name: normal-container
                    ports:
                        - name: normal-port
                          containerPort: 8080
                    readinessProbe:
                        httpGet:
                            path: /common/healthy
                            port: normal-port
                            httpHeaders:
                                -   name: checker
                                    value: readinessProbe
                        initialDelaySeconds: 5
                        failureThreshold: 10
                        periodSeconds: 10
                    livenessProbe:
                        httpGet:
                            path: /common/healthy
                            port: normal-port
                            httpHeaders:
                                -   name: checker
                                    value: livenessProbe
                        failureThreshold: 1
                        periodSeconds: 10
                    startupProbe:
                        httpGet:
                            path: /common/healthy
                            port: normal-port
                            httpHeaders:
                                -   name: checker
                                    value: startupProbe
                        failureThreshold: 30
                        periodSeconds: 5
                    lifecycle:
                        preStop:
                            exec:
                                command: [ "/bin/sh", "-c", "sleep 10" ]
                    env:
                        - name: POD_IP
                          valueFrom:
                              fieldRef:
                                  fieldPath: status.podIP
                        - name: POD_ID
                          valueFrom:
                              fieldRef:
                                  fieldPath: metadata.uid

---
apiVersion: v1
kind: Service
metadata:
    labels:
        app.kubernetes.io/name: normal-project
    name: normal-project-service
spec:
    selector:
        app.kubernetes.io/name: normal-project
    ports:
        -   protocol: TCP
            port: 9000
            targetPort: 8080
            nodePort: 32701
    type: LoadBalancer

簡易說明此次增加的配置內容

readinessProbe: # 對容器的健康檢查
    httpGet: # 進行 HTTP GET 請求
        path: /common/healthy # 健康檢查的路徑
        port: normal-port
        httpHeaders: # 自定義的 HTTP 標頭
            -   name: checker
                value: readinessProbe
    initialDelaySeconds: 5 # 容器創建後,等待 5 秒再執行健康檢查
    failureThreshold: 10  # 失敗計數器在 10 次之後,標記為失敗
    periodSeconds: 10 # 每 10 秒執行一次健康檢查
livenessProbe:
    httpGet:
        path: /common/healthy
        port: normal-port
        httpHeaders:
            -   name: checker
                value: livenessProbe
    failureThreshold: 1
    periodSeconds: 10
startupProbe:
    httpGet:
        path: /common/healthy
        port: normal-port
        httpHeaders:
            -   name: checker
                value: startupProbe
    failureThreshold: 30
    periodSeconds: 5
lifecycle:
    preStop:
        exec: # 執行command
            command: [ "/bin/sh", "-c", "sleep 10" ] # 執行緒暫停10秒鐘,在執行關機。
env: # 定義容器的環境變量
    - name: POD_IP # 環境變量的名稱
      valueFrom: # 從其他資源取得值
          fieldRef: # 從 Pod 狀態中的特定欄位取得值
              fieldPath: status.podIP  # 取得 Pod 的 IP 位址
    - name: POD_ID
      valueFrom:
          fieldRef:
              fieldPath: metadata.uid

在這次的 Kubernetes 測試中,如何以優雅的方式關閉 Pod。相較於立即終止,我們希望在收到關機訊息時,先暫停一段時間再進行關機,以確保流量已經到達並讓服務有時間優雅地終止。

3. JMeter 建立簡易的發送請求執行程序

4. 測試

在 Kubernetes 上進行應用程式的版本更新時,我們發現 K8S 控制機制的流量控制機制相當有效。具體來說,當新版本的 AP Server 成功啟動,接收到成功回應後,K8S 才會將流量逐步導向到新的版本,確保了新版本的穩定性。

然而,我們在外部測試中注意到一個有趣的現象。當我們將副本數(replicas)設定為1以上時,預期中的 LoadBalancer 分配機制與實際的結果稍有不同。根據測試結果,在相同時間點只有一台 AP Server 收到請求,其他則不會收到。

假設現在有100個請求進來,並且我有三台AP Server 分別為 A、B、C他會將這100個請求分配如下:

但他在分配的同時,在同個時間點只會有一台AP Server 收到請求,其他則不會收到。 我原本預期同個時間點,三台同時會收到請求,差別在於每台Server 運算時間不同,但不至於完全不會收到請求, 這部分確實是讓我有點好奇,這個分配的機制是能夠如何調整,還是必須要自己去實作分配的機制?!

時間軸(秒)Server數量

1 ~ 10

A

20

11 ~ 30

B

30

31 ~ 60

C

50

結語

CI/CD 整合 Kubernetes 的實踐是一個持續演進的過程,這次的測試成果為我們帶來了信心。Kubernetes 提供的容器化解決方案為應用程式的部署和更新提供了更大的靈活性和可靠性。在未來,我們將繼續挑戰更多複雜的測試場景,以進一步提升我們的系統架構和性能。

優雅的關機策略是 Kubernetes 中一個重要的設計考量,特別是在需要高可用性和無感知服務中斷的場景下。這次測試的成功實現了我們的期望,也提高了系統的穩定性。在未來,我們將繼續探索更多 Kubernetes 特性以進一步提升我們的應用系統。

Last updated