一个看似简单的 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:
- 风险控制: 将潜在问题的影响范围限制在极小的用户群体内。
- 数据驱动决策: 基于真实用户数据来决定是否继续发布或回滚。
- 兼容 PWA 缓存: 我们可以精确控制哪个版本的
index.html和service-worker.js被分发。即使用户设备上存在旧的 Service Worker,当他被路由到新版本时,浏览器会检测到service-worker.js文件的变化并触发更新流程,从而实现平滑过渡。
金丝雀发布在 Kubernetes 中通常通过 Ingress Controller 的流量切分功能实现。接下来,我们将深入探讨如何利用 NGINX Ingress Controller 来为我们的 Svelte PWA 搭建一套完整的金丝雀发布流水线。
核心实现概览
整个架构围绕以下几个关键组件构建:
- SvelteKit PWA 应用: 一个标准的 PWA 应用,启用了 Service Worker。
- Docker 镜像: 使用多阶段构建,将 SvelteKit 应用打包成静态文件,并由 Nginx 提供服务。
- Kubernetes 部署:
- 一个
stableDeployment,运行当前生产版本。 - 一个
canaryDeployment,运行待发布的新版本。 - 一个
Service,同时将stable和canary的 Pod 作为其后端端点。
- 一个
- NGINX Ingress: 核心组件,负责根据权重将外部流量分发到上述
Service,进而到达stable或canaryPod。 - 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 /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
我们需要为 stable 和 canary 两个版本创建独立的 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 注解的方案提供了一种相对简单且有效的金丝雀发布实现。然而,它也存在一些局限性:
- 监控与自动化决策的缺失: 流量的增加和回滚决策依赖人工。一个更先进的系统应该集成 Prometheus 指标,使用类似 Flagger 或 Argo Rollouts 这样的渐进式交付工具,根据预设的 SLI/SLO (服务等级指标/目标) 自动进行流量调整和回滚。
- 流量切分的粒度: 默认的权重切分是随机的,无法实现基于用户特征(例如,内部员工、特定地区用户)的灰度发布。这需要更强大的 Ingress Controller (如 Istio, Linkerd) 或专门的流量管理解决方案。
- Service Worker 缓存的终极问题: 即使用户被路由到了新版本,如果他长时间未关闭浏览器标签页,旧的 Service Worker 可能仍然在后台运行。虽然
autoUpdate机制能处理大多数情况,但在极端场景下,可能需要通过应用内通知来提示用户刷新以应用关键更新。
未来的优化路径将聚焦于将可观测性与发布流程深度绑定,实现基于业务和性能指标的自动化渐进式交付。例如,当 Canary 版本的错误率(从 Sentry 获取)或 LCP(从前端监控获取)超过阈值时,系统应能自动将 canary-weight 调回 0 并发送告警,从而构建一个真正健壮且无需人工干预的 PWA 发布系统。