在Azure AKS上为WebSocket服务设计基于GitOps的零停机更新架构


在生产环境中,一个部署在Kubernetes上的无状态HTTP服务,其滚动更新(Rolling Update)策略通常足够应对。Pod被逐个替换,流量自然地切换到新版本。但当服务的核心是WebSocket时,这个模型就崩溃了。标准的滚动更新会无情地切断所有现有连接,导致用户体验断崖式下跌,甚至在实时协作或金融交易场景中造成数据不一致。问题不在于Kubernetes本身,而在于将为无状态设计的模型强行套用在有状态连接上的根本性冲突。

我们的目标是在Azure AKS上部署一套包含React前端和Node.js WebSocket后端的实时应用,并使用GitHub作为唯一可信源,通过GitOps(ArgoCD)实现完全自动化的部署。核心挑战是:在代码变更触发的自动部署流程中,如何保证WebSocket服务的更新过程对活跃用户是无感知的,即实现零停机或近乎零停机的平滑过渡。

定义复杂技术问题:声明式模型与长连接状态的冲突

GitOps的核心理念是声明式配置。Git仓库中的YAML文件定义了系统的期望状态,而像ArgoCD这样的工具则负责不断地将集群的实际状态与这个期望状态进行调谐。这个模型对于无状态应用是完美的。一个Deployment的期望副本数是3,其中一个Pod崩溃了,控制器会立刻启动一个新的来弥补。镜像标签更新了,控制器会执行滚动更新策略,用新版本的Pod替换旧的。

然而,WebSocket连接是有状态的。一个连接在TCP层建立后,在应用层升级协议,并维持一个长时间的双向通信通道。这个“状态”——连接本身——存在于某个特定Pod的内存中。当GitOps流程触发滚动更新时,Kubernetes的Deployment控制器会按部就班地:

  1. 启动一个新版本的Pod。
  2. 等待新Pod就绪(通过Readiness Probe)。
  3. 向一个旧版本的Pod发送SIGTERM信号。
  4. 从Service的Endpoint列表中移除该旧Pod。
  5. terminationGracePeriodSeconds(默认为30秒)后,如果进程还未退出,则发送SIGKILL

对于旧Pod上的所有WebSocket连接来说,这意味着末日。即使应用代码能优雅地处理SIGTERM,它最多也只能做到通知客户端“我要关闭了”,然后关闭连接。但连接终究是断了。在一个有数千个并发连接的节点上,这意味着一次更新会导致数千次断连和重连风暴。这不是一个可接受的生产方案。

方案A:基于NGINX Ingress的粘性会话与优雅停机

最初的构想是尽可能利用现有、成熟的工具栈来解决问题,避免引入过多复杂性。NGINX Ingress Controller是AKS上最常见的Ingress解决方案。它支持基于Cookie的会话保持(Sticky Sessions)。

构想:

  1. 应用层面: 在Node.js后端实现优雅停机逻辑。监听到SIGTERM信号后,停止接受新的WebSocket连接,但保持现有连接,直到它们自然断开或超时。
  2. Ingress层面: 配置NGINX Ingress,启用粘性会话。这样,即使用户因网络波动而断线重连,只要其Cookie还在,并且原Pod还存活,请求就会被路由回同一个Pod。
  3. Kubernetes层面: 增大terminationGracePeriodSeconds,给旧Pod足够的时间来“耗尽”现有连接。

核心实现

1. Node.js WebSocket后端 (ws-server.js)

// ws-server.js
const WebSocket = require('ws');
const http = require('http');
const process = require('process');

const server = http.createServer();
const wss = new WebSocket.Server({ noServer: true });

// 存储所有活跃的连接
const clients = new Set();
let isShuttingDown = false;

wss.on('connection', (ws) => {
    console.log('Client connected.');
    clients.add(ws);

    ws.on('message', (message) => {
        // 生产级代码应包含日志和错误处理
        console.log(`Received: ${message}`);
        // 简单地将消息广播给所有客户端
        clients.forEach(client => {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
                client.send(message);
            }
        });
    });

    ws.on('close', () => {
        console.log('Client disconnected.');
        clients.delete(ws);
    });

    ws.on('error', (error) => {
        console.error('WebSocket error:', error);
        clients.delete(ws);
    });
});

server.on('upgrade', (request, socket, head) => {
    // 如果正在关闭,则拒绝新的WebSocket升级请求
    if (isShuttingDown) {
        socket.destroy();
        return;
    }
    wss.handleUpgrade(request, socket, head, (ws) => {
        wss.emit('connection', ws, request);
    });
});

// 健康检查端点
server.on('request', (req, res) => {
    if (req.url === '/healthz') {
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        res.end('OK');
    } else {
        res.writeHead(404);
        res.end();
    }
});

const startServer = () => {
    server.listen(8080, () => {
        console.log('Server started on port 8080');
    });
};

const gracefulShutdown = () => {
    if (isShuttingDown) return;
    isShuttingDown = true;
    console.log('Starting graceful shutdown...');

    // 1. 停止HTTP服务器接受新连接 (包括新的 upgrade 请求)
    server.close(() => {
        console.log('HTTP server closed.');
    });

    // 2. 通知所有现有客户端服务器即将关闭
    clients.forEach(client => {
        if (client.readyState === WebSocket.OPEN) {
            // 协议层面可以定义一个更优雅的消息
            client.send(JSON.stringify({ type: 'server-shutdown', message: 'Server is restarting for an update.' }));
            client.close(1001, 'Going Away'); 
        }
    });

    // 等待一小段时间让关闭消息发出
    setTimeout(() => {
        console.log(`Remaining clients: ${clients.size}. Forcing close.`);
        process.exit(0);
    }, 5000); // 在真实项目中,这个等待时间需要仔细权衡
};

process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

startServer();

2. Kubernetes Manifests (deployment.yaml, service.yaml, ingress.yaml)

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: websocket-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: websocket-server
  template:
    metadata:
      labels:
        app: websocket-server
    spec:
      # 给予35秒的优雅停机时间,比默认的30秒稍长
      terminationGracePeriodSeconds: 35
      containers:
      - name: server
        image: your-acr.azurecr.io/websocket-server:v1.0.0
        ports:
        - containerPort: 8080
        readinessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 15
          periodSeconds: 20
---
# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: websocket-server-svc
spec:
  selector:
    app: websocket-server
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
---
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: websocket-ingress
  annotations:
    # 关键:启用粘性会话
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-name: "STICKY_SESSION"
    nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
    nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
    # WebSocket 代理所需的头和超时配置
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
  ingressClassName: nginx
  rules:
  - host: your-domain.com
    http:
      paths:
      - path: /ws
        pathType: Prefix
        backend:
          service:
            name: websocket-server-svc
            port:
              number: 80

优劣分析

  • 优点:

    • 架构简单,技术栈通用,运维心智负担小。
    • 优雅停机代码可以减少数据丢失,并给客户端一个明确的信号。
    • 粘性会话对因网络问题导致的“假性”断连重连有帮助。
  • 缺点 (致命的):

    • 这并非零停机。 滚动更新过程中,旧Pod最终还是会被终止。当一个Pod被终止时,其上的所有连接,无论多么“优雅”,都会被切断。用户仍然会经历一次服务中断,然后由客户端逻辑(例如React App中的重连机制)重新建立连接。对于上千个连接,这意味着上千次中断。
    • 优雅停机的时间窗口难以把握。如果设置太长,会拖慢部署速度;如果太短,一些长任务可能无法完成。
    • 粘性会话在Pod被销毁后就失效了。新连接会被路由到新的Pod,无法恢复旧会话的状态。

结论是,方案A只是一个“减痛”方案,而非“治本”方案。它无法满足严苛的零停机更新要求。

最终选择与理由:基于Argo Rollouts的Canary发布策略

要实现真正的零停机更新,核心思路必须从“替换”转变为“转移”。我们不能粗暴地杀死持有连接的旧Pod,而应该启动新版本的Pod,然后将流量从旧版本平滑地、逐步地迁移到新版本上。这种模式正是Canary发布(金丝雀发布)的精髓。

在Kubernetes生态中,Argo Rollouts是一个专门用于实现高级部署策略(如Canary和Blue/Green)的控制器。它通过提供一个名为Rollout的自定义资源(CRD)来替代标准的Deployment对象,从而赋予我们精细控制发布过程的能力。

架构决策:

  1. 核心控制器: 采用Argo Rollouts替代原生的DeploymentRollout对象将定义整个Canary发布的生命周期。
  2. 流量管理: Argo Rollouts本身不管理流量,它需要与Ingress控制器或服务网格集成。我们将继续使用NGINX Ingress Controller,因为Argo Rollouts对它有很好的原生支持,可以通过修改Ingress对象的权重来动态分配流量。
  3. 自动化流程: 整个流程将由GitOps驱动。
    • 开发者向main分支推送代码。
    • GitHub Actions流水线被触发,负责构建Docker镜像,推送到Azure Container Registry (ACR),然后修改Git仓库中Kubernetes清单的镜像标签,并将变更推送回main分支。
    • ArgoCD检测到Git仓库的变化,将新的Rollout对象配置同步到AKS集群。
    • Argo Rollouts控制器接管,开始执行Rollout中定义的Canary策略。

架构流程图

graph TD
    A[Developer: git push] --> B{GitHub Repository};
    B --> C[GitHub Actions: CI Pipeline];
    C -- 1. Build & Push Image --> D[Azure Container Registry];
    C -- 2. Update image tag in Git --> B;
    B -- Git state change --> E[ArgoCD];
    E -- Syncs Manifests --> F[Azure AKS Cluster];
    F -- Watches Rollout object --> G[Argo Rollouts Controller];
    G -- Manages Pods & Traffic --> H[NGINX Ingress];
    H -- Traffic Splitting --> I[Stable WebSocket Pods];
    H -- Traffic Splitting --> J[Canary WebSocket Pods];
    K[React Client] --> H;

这个架构的优势在于,它将应用更新的风险控制在最小范围。我们可以先将1%的流量导入新版本,观察其表现(连接数、错误率、延迟),如果一切正常,再逐步增加流量比例,直到100%切换完成,最后才将旧版本的Pod缩容至零。在这个过程中,绝大部分用户的连接始终在稳定的旧版本上,完全不受影响。只有那一小部分被选中进入Canary的用户可能会在切换瞬间经历一次重连,而且这个过程是完全可控的。

核心实现概览

1. GitHub Actions 工作流 (.github/workflows/ci-cd.yml)

这个工作流是整个GitOps流程的起点。

name: CI-CD for WebSocket Service

on:
  push:
    branches:
      - main
    paths:
      - 'server/**' # 只在后端代码变更时触发
      - 'client/**' # 或前端代码变更时

env:
  ACR_NAME: youracrname
  RESOURCE_GROUP: your-rg
  CLUSTER_NAME: your-aks-cluster
  IMAGE_REPO_BACKEND: websocket-server
  IMAGE_REPO_FRONTEND: react-client
  MANIFEST_PATH: kubernetes/base

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          # 需要token来推送变更
          token: ${{ secrets.PAT_TOKEN }}

      - name: 'Az CLI login'
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}

      - name: 'ACR login'
        run: az acr login --name ${{ env.ACR_NAME }}

      # 为后端构建和推送镜像
      - name: Build and push backend image
        id: build-backend
        run: |
          IMAGE_TAG=$(date +%s)
          docker build -t ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_REPO_BACKEND }}:${IMAGE_TAG} ./server
          docker push ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_REPO_BACKEND }}:${IMAGE_TAG}
          echo "::set-output name=tag::${IMAGE_TAG}"

      # ... (类似的前端构建步骤) ...

      - name: Update Kubernetes manifest
        run: |
          # 使用 kustomize 或 sed 更新镜像标签
          # 这里使用 yq (更健壮的YAML处理器)
          pip install yq
          yq -i '.spec.template.spec.containers[0].image = "${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_REPO_BACKEND }}:${{ steps.build-backend.outputs.tag }}"' ${{ env.MANIFEST_PATH }}/rollout.yaml
      
      - name: Commit and push manifest changes
        run: |
          git config --global user.name "GitHub Actions"
          git config --global user.email "[email protected]"
          git add ${{ env.MANIFEST_PATH }}/rollout.yaml
          git commit -m "Deploy new backend image tag: ${{ steps.build-backend.outputs.tag }}" || echo "No changes to commit"
          git push
  • 关键点: 这个流水线的最后一步是写回Git仓库。这是GitOps的核心,CI系统的产出不是直接部署,而是更新声明式的配置文件。

2. React前端的健壮连接逻辑

客户端必须能够优雅地处理短暂的连接中断,这在任何网络环境下都是必要的,在Canary切换中尤其重要。

// client/src/WebSocketService.js
let socket = null;
let reconnectInterval = 1000; // 初始重连间隔1秒
const MAX_RECONNECT_INTERVAL = 30000; // 最大间隔30秒

function connect() {
    socket = new WebSocket('wss://your-domain.com/ws');

    socket.onopen = () => {
        console.log('WebSocket connected.');
        reconnectInterval = 1000; // 连接成功后重置重连间隔
    };

    socket.onmessage = (event) => {
        const data = JSON.parse(event.data);
        // 处理服务器消息, 例如 server-shutdown
        if (data.type === 'server-shutdown') {
            console.warn('Server is shutting down. Connection will close.');
        }
        // ... 其他消息处理
    };

    socket.onclose = (event) => {
        console.log(`WebSocket closed. Code: ${event.code}. Reason: ${event.reason}. Retrying...`);
        // 1001 (Going Away) 是我们主动关闭的,可以立即重连
        // 其他情况使用指数退避策略
        if (event.code !== 1001) {
            setTimeout(connect, reconnectInterval);
            reconnectInterval = Math.min(reconnectInterval * 2, MAX_RECONNECT_INTERVAL);
        } else {
            setTimeout(connect, 500); // 快速重连
        }
    };

    socket.onerror = (error) => {
        console.error('WebSocket error:', error);
        socket.close(); // 触发 onclose 中的重连逻辑
    };
}

export { connect };

3. 核心:Argo Rollouts的Rollout资源定义

这是替换Deployment的关键文件。它定义了Canary发布的具体步骤。

# kubernetes/base/rollout.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: websocket-server
spec:
  replicas: 5 # 期望的总副本数
  selector:
    matchLabels:
      app: websocket-server
  template:
    metadata:
      labels:
        app: websocket-server
    spec:
      terminationGracePeriodSeconds: 35
      containers:
      - name: server
        # 镜像标签将由CI/CD流水线动态更新
        image: your-acr.azurecr.io/websocket-server:initial-tag 
        ports:
        - containerPort: 8080
        readinessProbe: # ... (probes as before)
        livenessProbe: # ... (probes as before)
  strategy:
    canary:
      # stableService 和 canaryService 是必须的,用于流量路由
      stableService: websocket-server-stable-svc
      canaryService: websocket-server-canary-svc
      trafficRouting:
        nginx:
          # 必须与Ingress中定义的stable Ingress匹配
          stableIngress: websocket-ingress
      steps:
      - setWeight: 10 # 第一步: 将10%的流量路由到新版本 (canary)
      - pause: {}    # 无限期暂停,等待人工确认。在生产中可以设置 duration
      - setWeight: 30 # 第二步: 增加到30%
      - pause: { duration: 10m } # 暂停10分钟进行观察
      - setWeight: 70 # 第三步: 增加到70%
      - pause: { duration: 5m }
      # 最后一步会自动将权重设置为100,并清理旧的ReplicaSet

4. 配合Rollout的Services和Ingress

Rollout需要两个Service来分别指向stablecanary版本的Pod,并需要一个主Ingress来接收外部流量。

# kubernetes/base/services-and-ingress.yaml

# Stable Service: 指向稳定版本的Pod
# Argo Rollouts会管理这个Service的selector,使其始终指向旧的ReplicaSet
apiVersion: v1
kind: Service
metadata:
  name: websocket-server-stable-svc
spec:
  selector:
    app: websocket-server
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080

---
# Canary Service: 指向Canary版本的Pod
# Argo Rollouts会管理这个Service的selector,使其指向新的ReplicaSet
apiVersion: v1
kind: Service
metadata:
  name: websocket-server-canary-svc
spec:
  selector:
    app: websocket-server
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080

---
# 主Ingress: 这是流量的入口点
# Argo Rollouts会动态创建或修改另一个Ingress来管理Canary流量
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: websocket-ingress
  annotations:
    # 注意:这里不再需要粘性会话,因为我们是通过流量切分来保证平滑过渡
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
  ingressClassName: nginx
  rules:
  - host: your-domain.com
    http:
      paths:
      - path: /ws
        pathType: Prefix
        backend:
          # Ingress的后端指向 stable service
          service:
            name: websocket-server-stable-svc
            port:
              number: 80

Rollout开始时,Argo Rollouts控制器会自动创建一个名为websocket-ingress-canary的Ingress,并使用nginx.ingress.kubernetes.io/canary系列注解来告诉NGINX将特定百分比的流量转发到websocket-server-canary-svc。这一切都是自动发生的。

架构的扩展性与局限性

这个基于Argo Rollouts的GitOps架构虽然解决了WebSocket零停机更新的核心痛点,但也引入了新的复杂性。运维团队需要理解Rollout资源、Canary策略以及其与Ingress的交互机制。这比简单地管理Deployment有更高的学习曲线。

当前的pause: {}步骤依赖于人工审核和手动推进。一个更先进的实现是集成自动化的度量分析。Argo Rollouts可以与Prometheus集成,在pause步骤中自动查询新版本的关键指标(如连接成功率、应用错误率、消息延迟)。如果指标超过预设阈值,Rollout可以自动中止并回滚,实现完全自动化的、安全的发布流程。

此外,该模式不仅适用于WebSocket。任何需要维持长连接的服务,如gRPC流、MQTT代理或任何自定义的TCP服务,都可以从这种渐进式交付模型中受益。它将发布风险从“全有或全无”的赌博,转变为一个可度量、可控制、可中止的精细化工程过程。这个架构的真正价值在于,它将基础设施的韧性提升到了一个新的水平,使得快速迭代和稳定运行不再是相互对立的目标。


  目录