在 Kubernetes 环境下为 Svelte PWA 实现精细化金丝雀发布


一个看似简单的 PWA (Progressive Web App) 版本更新,在生产环境中可能会演变成一场灾难。问题的根源在于 Service Worker 激进的缓存策略。一旦用户的浏览器注册了一个 Service Worker,它会接管网络请求,坚定地从缓存中提供应用外壳 (App Shell) 和静态资源。传统的滚动更新 (Rolling Update) 策略在此刻显得力不从心:部分用户可能会加载到新的应用代码,但 Service Worker 仍然是旧的;或者更糟,旧的应用外壳试图调用一个已经更新、存在破坏性变更的 API。这种不一致的状态是线上故障的温床。

我们的目标是为一个基于 SvelteKit 构建的 PWA 实现一个安全、可控、可观测的发布流程。这个流程必须能够将新版本的风险敞口控制在最小范围,并在出现问题时能够快速回滚。

方案权衡:从滚动更新到金丝雀发布

在 Kubernetes 生态中,有多种部署策略可供选择,但并非所有都适用于 PWA 的特性。

方案 A: 滚动更新 (Rolling Update) - 陷阱重重

这是 Kubernetes Deployment 的默认策略。它通过逐个替换旧 Pod 的方式来更新应用。对于无状态后端服务,这是一种优雅且高效的方式。但对于 PWA 前端,它的问题是致命的:

  • 缓存不一致性: 在更新窗口期,负载均衡器会将流量随机分发到新旧 Pod。用户在一次会话中可能先请求到旧版本的 index.html,然后又请求到新版本的 app.js。Service Worker 的更新检查机制 (updateViaCache) 也会受到影响,导致其行为不可预测。
  • 无法快速回滚: 一旦50%的 Pod 被更新,发现问题后想回滚,意味着另外50%的用户已经受到了影响。回滚过程本身也是一次滚动更新,需要时间。

在真实项目中,依赖滚动更新来发布 PWA 无异于赌博。它将前端应用视为无状态服务,完全忽略了客户端侧强大的缓存状态。

方案 B: 蓝绿部署 (Blue-Green Deployment) - 过于激进

蓝绿部署通过维护两套完全相同的生产环境(蓝色为旧版,绿色为新版)来解决这个问题。发布时,我们将所有流量从蓝色环境瞬间切换到绿色环境。

优点:

  • 环境隔离: 新版本在独立的“绿色”环境中运行,不影响现有“蓝色”环境。
  • 瞬间切换与回滚: 流量切换通过修改路由规则完成,几乎是零停机时间。回滚同样迅速。

缺点:

  • 成本高昂: 需要维护双倍的计算资源。
  • “全有或全无”: 无法将新版本只暴露给一小部分用户。一旦流量切换,所有用户都会访问新版本。如果新版本存在一个未被测试发现的严重 Bug (例如,在特定移动设备上的渲染问题),影响范围是100%。

对于 PWA,蓝绿部署解决了缓存一致性的部分问题(用户要么完全在旧版,要么完全在新版),但其高风险、高成本的特性使其并非最佳选择。

最终选择: 金丝雀发布 (Canary Release) - 精细化风险控制

金丝雀发布借鉴了“矿井中的金丝雀”这一概念,即先将新版本发布给一小部分用户(例如1%、5%),观察其运行状况。通过监控这部分用户的错误率、性能指标和业务指标,我们可以判断新版本是否稳定。如果一切正常,再逐步扩大流量比例,直到100%的用户都切换到新版本。

为何它最适合 PWA:

  1. 风险控制: 将潜在问题的影响范围限制在极小的用户群体内。
  2. 数据驱动决策: 基于真实用户数据来决定是否继续发布或回滚。
  3. 兼容 PWA 缓存: 我们可以精确控制哪个版本的 index.htmlservice-worker.js 被分发。即使用户设备上存在旧的 Service Worker,当他被路由到新版本时,浏览器会检测到 service-worker.js 文件的变化并触发更新流程,从而实现平滑过渡。

金丝雀发布在 Kubernetes 中通常通过 Ingress Controller 的流量切分功能实现。接下来,我们将深入探讨如何利用 NGINX Ingress Controller 来为我们的 Svelte PWA 搭建一套完整的金丝雀发布流水线。

核心实现概览

整个架构围绕以下几个关键组件构建:

  1. SvelteKit PWA 应用: 一个标准的 PWA 应用,启用了 Service Worker。
  2. Docker 镜像: 使用多阶段构建,将 SvelteKit 应用打包成静态文件,并由 Nginx 提供服务。
  3. Kubernetes 部署:
    • 一个 stable Deployment,运行当前生产版本。
    • 一个 canary Deployment,运行待发布的新版本。
    • 一个 Service,同时将 stablecanary 的 Pod 作为其后端端点。
  4. NGINX Ingress: 核心组件,负责根据权重将外部流量分发到上述 Service,进而到达 stablecanary Pod。
  5. CI/CD 流水线 (GitLab CI): 自动化构建、部署和流量调整的整个过程。

下面是它们协同工作的流程图:

graph TD
    subgraph GitLab CI/CD Pipeline
        A[Git Push] --> B(Build & Push Docker Image);
        B --> C{Deploy as Canary};
        C --> D[Update Ingress Weight: 5%];
        D --> E{Manual Approval: Monitor Metrics};
        E -- OK --> F[Update Ingress Weight: 100%];
        F --> G[Promote Canary to Stable];
        E -- Fail --> H[Rollback: Set Ingress Weight to 0%];
    end

    subgraph Kubernetes Cluster
        User --> Ingress;
        Ingress -- 95% --> Service;
        Ingress -- 5% --> Service;
        Service --> Pod-Stable-1;
        Service --> Pod-Stable-2;
        Service --> Pod-Canary-1;
    end

    subgraph Deployments
        Dep-Stable[Deployment: stable] --> Pod-Stable-1 & Pod-Stable-2;
        Dep-Canary[Deployment: canary] --> Pod-Canary-1;
    end

    style Ingress fill:#f9f,stroke:#333,stroke-width:2px
    style Dep-Canary fill:#bbf,stroke:#333,stroke-width:2px

步骤化实现细节

1. SvelteKit PWA 与 Service Worker 配置

我们使用 Vite PWA 插件为 SvelteKit 应用生成 Service Worker。关键在于 vite.config.js 的配置,确保 Service Worker 能够正确生成并采取积极的缓存策略。

vite.config.js:

import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { defineConfig } from 'vite';

export default defineConfig({
    plugins: [
        sveltekit(),
        SvelteKitPWA({
            registerType: 'autoUpdate', // 自动检测并应用新的 Service Worker
            injectRegister: 'auto',
            workbox: {
                globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'],
                // 缓存应用外壳和核心资源
                runtimeCaching: [
                    {
                        urlPattern: ({ request }) => request.destination === 'document' || request.destination === 'script' || request.destination === 'style',
                        handler: 'NetworkFirst', // 网络优先,确保用户总能获取最新的页面结构和逻辑
                        options: {
                            cacheName: 'app-shell-cache-v1',
                            expiration: {
                                maxEntries: 10,
                                maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days
                            },
                            cacheableResponse: {
                                statuses: [0, 200]
                            }
                        }
                    }
                ]
            },
            devOptions: {
                enabled: true,
                type: 'module',
            },
        })
    ]
});

这里的核心是 registerType: 'autoUpdate',它指示客户端在检测到新的 Service Worker 时自动更新,而不需要用户手动刷新。runtimeCaching 策略采用 NetworkFirst,这对于动态内容和应用逻辑至关重要,能保证用户在在线时总是获取最新版本。

2. 应用容器化 (Dockerfile)

为了在 Kubernetes 中运行,我们需要将 Svelte PWA 打包成一个 Docker 镜像。这里采用多阶段构建,以保持最终镜像的轻量。第一阶段负责构建,第二阶段仅将构建产物复制到 Nginx 服务器中。

Dockerfile:

# Stage 1: Build the SvelteKit application
FROM node:18-alpine AS builder

WORKDIR /app

# 复制依赖定义文件
COPY package.json yarn.lock ./

# 安装依赖
RUN yarn install --frozen-lockfile

# 复制全部源代码
COPY . .

# 执行构建命令,生成静态文件
# ADAPTER_AUTO a an environment variable used by SvelteKit's adapter-auto
# to ensure it builds for a Node.js environment which we need for the static output.
RUN yarn build

# Stage 2: Serve the static files with Nginx
FROM nginx:1.25-alpine

# 从 builder 阶段复制构建好的静态文件到 Nginx 的默认 web 根目录
COPY --from=builder /app/build /usr/share/nginx/html

# 复制自定义的 Nginx 配置文件
COPY nginx.conf /etc/nginx/conf.d/default.conf

# 暴露 80 端口
EXPOSE 80

# 启动 Nginx
CMD ["nginx", "-g", "daemon off;"]

nginx.conf 配置文件至关重要,它需要正确地处理路由和缓存头。

nginx.conf:

server {
    listen 80;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;

    # 对 Service Worker 文件禁用缓存,确保浏览器总是获取最新的版本进行比较
    location = /sw.js {
        add_header 'Cache-Control' 'no-cache, no-store, must-revalidate';
        add_header 'Pragma' 'no-cache';
        add_header 'Expires' '0';
        try_files $uri =404;
    }

    # 对其他静态资源设置长缓存
    location ~* \.(?:css|js|png|jpg|jpeg|gif|ico|webp|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SvelteKit 使用客户端路由,所有未匹配的路径都应指向 index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
}

注释中的关键点: location = /sw.js 块是本次部署策略的命脉。我们强制浏览器不对 Service Worker 文件本身进行缓存 (no-cache)。这样,每次用户访问网站时,浏览器都会向服务器验证 sw.js 是否有更新。正是这个机制,结合 Ingress 的流量切分,让我们能够控制用户获取哪个版本的 Service Worker,从而触发 PWA 的更新。

3. Kubernetes Manifests

我们需要为 stablecanary 两个版本创建独立的 Deployment,但它们共享同一个 Service

deployment-stable.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pwa-frontend-stable
  labels:
    app: pwa-frontend
spec:
  replicas: 3
  selector:
    matchLabels:
      app: pwa-frontend
      track: stable # 关键标签:标识这是稳定版
  template:
    metadata:
      labels:
        app: pwa-frontend
        track: stable
    spec:
      containers:
      - name: pwa-frontend
        # 初始部署时指向一个稳定的镜像版本
        image: your-registry/pwa-frontend:v1.0.0
        ports:
        - containerPort: 80

deployment-canary.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pwa-frontend-canary
  labels:
    app: pwa-frontend
spec:
  replicas: 1 # 金丝雀版本通常副本数较少
  selector:
    matchLabels:
      app: pwa-frontend
      track: canary # 关键标签:标识这是金丝雀版
  template:
    metadata:
      labels:
        app: pwa-frontend
        track: canary
    spec:
      containers:
      - name: pwa-frontend
        # 部署时会被 CI/CD 流水线替换为新版本的镜像
        image: your-registry/pwa-frontend:v1.1.0-canary
        ports:
        - containerPort: 80

service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: pwa-frontend-service
spec:
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
    name: http
  # Service 的 selector 匹配所有 track 的 Pod
  # 这样它就能将流量同时路由到 stable 和 canary
  selector:
    app: pwa-frontend

ingress.yaml:
这是实现流量切分的核心。我们使用 NGINX Ingress Controller 的特定注解。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: pwa-frontend-ingress
  annotations:
    kubernetes.io/ingress.class: "nginx"
    # 启用金丝雀功能
    nginx.ingress.kubernetes.io/canary: "true"
    # 将 5% 的流量发送到金丝雀服务
    # 这个值将由 CI/CD 动态修改
    nginx.ingress.kubernetes.io/canary-weight: "5"
spec:
  rules:
  - host: my-pwa.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          # 所有流量都指向这个 Service
          # Ingress Controller 会根据注解和内部逻辑决定
          # 将请求转发给 stable Pod 还是 canary Pod
          service:
            name: pwa-frontend-service
            port:
              name: http

一个常见的错误是创建两个 Ingress 资源,一个 stable 一个 canary。正确的方式是只使用一个 Ingress,通过注解来控制流量分配。这确保了所有用户都通过同一个入口点,只是在后端被分配到不同版本。

4. GitLab CI/CD 自动化流水线

最后,我们将所有步骤串联在一个 CI/CD 流水线中。

.gitlab-ci.yml:

variables:
  # 使用 GitLab CI 预定义变量来生成唯一的镜像标签
  IMAGE_TAG_CANARY: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  IMAGE_TAG_STABLE: $CI_REGISTRY_IMAGE:stable
  KUBE_CONTEXT: "your-cluster-context"

stages:
  - build
  - deploy_canary
  - test_canary
  - promote
  - cleanup

build_and_push:
  stage: build
  image: docker:20.10.16
  services:
    - docker:20.10.16-dind
  script:
    - echo "Building and pushing canary image: $IMAGE_TAG_CANARY"
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE_TAG_CANARY .
    - docker push $IMAGE_TAG_CANARY

deploy_canary_to_k8s:
  stage: deploy_canary
  image: bitnami/kubectl:latest
  script:
    - echo "Deploying canary version..."
    - kubectl config use-context $KUBE_CONTEXT
    # 部署 canary deployment,如果不存在则创建
    - >
      envsubst < k8s/deployment-canary.yaml |
      kubectl apply -f -
    # 使用 set image 命令更新镜像版本,确保总是最新的构建
    - kubectl set image deployment/pwa-frontend-canary pwa-frontend=$IMAGE_TAG_CANARY
    # 等待 canary Pod 准备就绪
    - kubectl rollout status deployment/pwa-frontend-canary
    - echo "Setting canary weight to 5%"
    # 动态修改 Ingress 的权重
    - kubectl annotate ingress pwa-frontend-ingress nginx.ingress.kubernetes.io/canary-weight="5" --overwrite

smoke_test_canary:
  stage: test_canary
  script:
    # 这里的测试脚本至关重要,它可以是 E2E 测试 (Cypress, Playwright)
    # 也可以是简单的 curl 检查,确保新版本基本可用
    - echo "Running smoke tests against canary..."
    - sleep 60 # 等待一段时间让 Ingress 配置生效
    # 一个简单的健康检查
    - apk add --no-cache curl
    - 'CURL_OUTPUT=$(curl -s -o /dev/null -w "%{http_code}" http://my-pwa.example.com)'
    - 'if [ "$CURL_OUTPUT" != "200" ]; then echo "Canary health check failed!"; exit 1; fi'

promote_to_stable:
  stage: promote
  # 手动触发,给予人工决策的机会
  when: manual
  script:
    - echo "Promoting canary to stable..."
    - kubectl config use-context $KUBE_CONTEXT
    - echo "Setting canary weight to 100% for full rollout..."
    - kubectl annotate ingress pwa-frontend-ingress nginx.ingress.kubernetes.io/canary-weight="100" --overwrite
    - sleep 300 # 等待5分钟,让所有用户流量切换到新版本
    # 将 stable deployment 的镜像更新为新版本
    - kubectl set image deployment/pwa-frontend-stable pwa-frontend=$IMAGE_TAG_CANARY
    - kubectl rollout status deployment/pwa-frontend-stable
    - echo "Promotion complete. New stable version is live."

cleanup_canary:
  stage: cleanup
  when: on_success
  # 在 promote 成功后运行
  script:
    - echo "Cleaning up canary resources..."
    - kubectl config use-context $KUBE_CONTEXT
    # 将流量完全切回 stable 路径
    - kubectl annotate ingress pwa-frontend-ingress nginx.ingress.kubernetes.io/canary-weight="0" --overwrite
    # 删除 canary deployment
    - kubectl delete deployment pwa-frontend-canary --ignore-not-found=true

这个流水线实现了完整的金丝雀发布生命周期:构建 -> 部署金丝雀 -> 调整流量 -> 手动确认 -> 全量 -> 清理。promote_to_stable 阶段的 when: manual 是一个关键的安全阀,它强制要求开发或运维人员在观察监控数据(如 Prometheus, Grafana, Sentry)后,手动点击按钮才能继续全量发布。

架构的局限性与未来展望

这套基于 NGINX Ingress 注解的方案提供了一种相对简单且有效的金丝雀发布实现。然而,它也存在一些局限性:

  1. 监控与自动化决策的缺失: 流量的增加和回滚决策依赖人工。一个更先进的系统应该集成 Prometheus 指标,使用类似 Flagger 或 Argo Rollouts 这样的渐进式交付工具,根据预设的 SLI/SLO (服务等级指标/目标) 自动进行流量调整和回滚。
  2. 流量切分的粒度: 默认的权重切分是随机的,无法实现基于用户特征(例如,内部员工、特定地区用户)的灰度发布。这需要更强大的 Ingress Controller (如 Istio, Linkerd) 或专门的流量管理解决方案。
  3. Service Worker 缓存的终极问题: 即使用户被路由到了新版本,如果他长时间未关闭浏览器标签页,旧的 Service Worker 可能仍然在后台运行。虽然 autoUpdate 机制能处理大多数情况,但在极端场景下,可能需要通过应用内通知来提示用户刷新以应用关键更新。

未来的优化路径将聚焦于将可观测性与发布流程深度绑定,实现基于业务和性能指标的自动化渐进式交付。例如,当 Canary 版本的错误率(从 Sentry 获取)或 LCP(从前端监控获取)超过阈值时,系统应能自动将 canary-weight 调回 0 并发送告警,从而构建一个真正健壮且无需人工干预的 PWA 发布系统。


  目录