在真实项目中,任何涉及手动操作的发布流程都注定会出错。问题不在于是否会出错,而在于何时出错以及造成的损失有多大。对于一个由 Rust (Rocket) 后端和现代 JavaScript 前端构成的全栈应用,目标是实现一个完全自动化、零停机的发布流程。直接在生产环境上执行 kubectl apply
并祈祷不是一个可行的选项。这套流程必须包含严格的、运行在真实环境中的端到端验证,确保新版本在承接任何生产流量前是完全可靠的。
我们的痛点很明确:如何在 AWS EKS 集群上实现一个可靠的蓝绿部署策略,该策略的核心环节是利用 Cypress 对即将上线的“绿”环境进行全方位的端到端测试。这不仅是一个部署问题,更是一个涉及容器构建、Kubernetes 资源管理、CI/CD 流程编排和自动化测试集成的综合性工程挑战。
技术栈与项目结构
为了解决这个问题,我们选定了以下技术组合:
- 后端: Rust + Rocket,因其性能和内存安全保证,非常适合作为核心 API 服务。
- 前端: Vanilla JavaScript + Babel,代表了任何需要构建步骤的现代前端项目。
- 端到端测试: Cypress,它能模拟真实用户在浏览器中的操作,是验证整个应用链路完整性的不二之选。
- 容器编排: AWS EKS,提供稳定可靠的托管 Kubernetes 环境。
- CI/CD: 我们将以 GitLab CI 为例,但其核心理念可平移至任何主流 CI/CD 工具。
项目采用单体仓库(Mono-repo)结构,便于统一管理:
.
├── .gitlab-ci.yml # CI/CD 流水线定义
├── backend/ # Rocket 后端应用
│ ├── Cargo.toml
│ ├── src/
│ └── Dockerfile
├── frontend/ # 前端应用
│ ├── package.json
│ ├── babel.config.js
│ ├── public/
│ │ └── index.html
│ ├── src/
│ │ └── index.js
│ └── Dockerfile
├── cypress/ # Cypress E2E 测试
│ ├── cypress.config.js
│ └── e2e/
│ └── smoke.cy.js
└── .k8s/ # Kubernetes Manifests
├── deployment.yaml
└── service.yaml
第一步:应用的容器化
蓝绿部署的基础是不可变的基础设施,而容器是实现这一点的最佳载体。我们需要为前后端分别构建优化的 Docker 镜像。
后端 Rocket 应用的 Dockerfile
对于 Rust 应用,一个常见的错误是直接在 Dockerfile 中执行 cargo build --release
,这会导致镜像体积巨大且构建缓慢。我们采用多阶段构建,并利用 cargo-chef
来缓存依赖,极大地提升了构建效率。
backend/Dockerfile
:
# ==================================
# Stage 1: Planner
# 利用 cargo-chef 计算依赖关系图。
# ==================================
FROM rust:1.73-slim-bullseye AS planner
WORKDIR /app
RUN cargo install cargo-chef
COPY . .
# 计算依赖,生成 recipe.json 文件,后续用于缓存。
RUN cargo chef prepare --recipe-path recipe.json
# ==================================
# Stage 2: Cacher
# 仅构建并缓存依赖项。
# 只要 Cargo.lock 没有变化,这一层就不会重新执行。
# ==================================
FROM rust:1.73-slim-bullseye AS cacher
WORKDIR /app
RUN cargo install cargo-chef
# 从 planner 阶段复制 recipe.json
COPY /app/recipe.json recipe.json
# 只构建依赖,这是最耗时的部分。
RUN cargo chef cook --release --recipe-path recipe.json
# ==================================
# Stage 3: Builder
# 构建实际的应用程序二进制文件。
# ==================================
FROM rust:1.73-slim-bullseye AS builder
WORKDIR /app
COPY . .
# 复制已缓存的依赖项
COPY /app/target /app/target
COPY /usr/local/cargo /usr/local/cargo
# 构建应用代码,这一步会非常快,因为依赖已编译。
RUN cargo build --release --bin backend
# ==================================
# Stage 4: Runtime
# 创建一个最小化的运行时镜像。
# ==================================
FROM debian:bullseye-slim AS runtime
WORKDIR /app
# 从 builder 阶段复制编译好的二进制文件。
COPY /app/target/release/backend /usr/local/bin/
# 设置非 root 用户运行,增强安全性。
RUN groupadd --system appuser && useradd --system --group appuser appuser
USER appuser
# 暴露端口并启动应用
EXPOSE 8000
CMD ["/usr/local/bin/backend"]
这个 Dockerfile
的核心在于分层缓存。只要依赖不变(Cargo.toml
和 Cargo.lock
),CI/CD 流水线在重新构建时可以跳过耗时的依赖编译,只重新编译业务代码,将构建时间从数分钟缩短到几十秒。
前端静态资源服务的 Dockerfile
前端同样采用多阶段构建。第一阶段使用 Node.js 环境和 Babel 编译 JavaScript,第二阶段则使用轻量级的 Nginx 镜像来托管生成的静态文件。
frontend/Dockerfile
:
# ==================================
# Stage 1: Build
# 使用 Node.js 环境构建前端静态资源。
# ==================================
FROM node:18-alpine AS builder
WORKDIR /app
# 复制 package.json 和 package-lock.json 来缓存 npm install
COPY package*.json ./
RUN npm install
# 复制所有源代码
COPY . .
# 执行 Babel 构建
RUN npm run build
# ==================================
# Stage 2: Serve
# 使用 Nginx 托管构建好的静态文件。
# ==================================
FROM nginx:1.25-alpine
# 从 builder 阶段复制编译后的文件到 Nginx 的默认托管目录
COPY /app/dist /usr/share/nginx/html
# 复制自定义的 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
前端还需要一个 nginx.conf
文件来处理 API 代理,避免跨域问题。
frontend/nginx.conf
:
server {
listen 80;
server_name localhost;
# 托管前端静态文件
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# 将所有 /api 的请求代理到后端 Rocket 服务
location /api/ {
# 'backend-service' 是 Kubernetes 中后端服务的名称
proxy_pass http://backend-service:8000/;
proxy_set_header Host $host;
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;
}
# 错误页面处理
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
第二步:设计 Kubernetes 蓝绿部署资源
蓝绿部署的核心在于通过 Kubernetes Service 的 selector
切换流量。我们将有两个 Deployment
(一个蓝,一个绿)和两个 Service
。
-
app-svc-prod
: 这是生产服务,对外部暴露。它的selector
将指向当前提供服务的版本(blue
或green
)。 -
app-svc-green
: 这是测试服务,仅在集群内部可访问。它的selector
始终指向green
版本,供 Cypress 测试使用。
.k8s/deployment.yaml
:
# 这个文件是模板,CI/CD 流水线会替换其中的变量
apiVersion: apps/v1
kind: Deployment
metadata:
name: fullstack-app-{{VERSION}} # {{VERSION}} 将被替换为 blue 或 green
labels:
app: fullstack-app
version: "{{VERSION}}"
spec:
replicas: 3
selector:
matchLabels:
app: fullstack-app
version: "{{VERSION}}"
template:
metadata:
labels:
app: fullstack-app
version: "{{VERSION}}"
spec:
containers:
- name: frontend
image: your-registry/frontend:{{IMAGE_TAG}} # {{IMAGE_TAG}} 将被替换为 Git Commit SHA
ports:
- containerPort: 80
- name: backend
image: your-registry/backend:{{IMAGE_TAG}}
ports:
- containerPort: 8000
env:
- name: ROCKET_ADDRESS
value: "0.0.0.0"
---
# 这是为新版本(green)准备的内部测试服务
apiVersion: v1
kind: Service
metadata:
name: fullstack-app-svc-green
spec:
selector:
app: fullstack-app
version: green # 硬编码指向 green 版本
ports:
- protocol: TCP
port: 80
targetPort: 80
.k8s/service.yaml
:
# 这是暴露给生产流量的 Service
# 它的 selector 是动态变化的
apiVersion: v1
kind: Service
metadata:
name: fullstack-app-svc-prod
labels:
# 初始时,我们让它指向一个不存在的版本,或者一个已知的稳定版本
# CI/CD 将会更新这里的 selector
version: blue
spec:
selector:
app: fullstack-app
version: blue # 这个值将被 CI/CD 流程动态修改
ports:
- protocol: TCP
port: 80
targetPort: 80
type: LoadBalancer # 或者 NodePort, Ingress
在实际项目中,直接修改 Service 的 selector
是一个原子操作,可以瞬间切换流量。
第三步:编排 CI/CD 自动化流水线
这是整个流程的大脑。我们将使用 GitLab CI 来定义各个阶段。
graph TD A[Start] --> B(Build & Push Images); B --> C(Deploy Green Version); C --> D{Run Cypress E2E Tests}; D -- Success --> E(Promote Green to Blue); D -- Failure --> F(Rollback & Cleanup Green); E --> G(Cleanup Old Blue Version); G --> H[End]; F --> H;
.gitlab-ci.yml
:
variables:
# 使用 ECR (AWS Elastic Container Registry)
ECR_REGISTRY: "123456789012.dkr.ecr.us-east-1.amazonaws.com"
APP_NAME: "fullstack-app"
KUBE_CONTEXT: "your-cluster-arn"
stages:
- build
- deploy_green
- test_green
- promote
- cleanup_blue
# ============================================
# Build Stage
# ============================================
build_images:
stage: build
image: docker:20.10.16
services:
- docker:20.10.16-dind
before_script:
- apk add --no-cache aws-cli
# 登录到 ECR
- aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin $ECR_REGISTRY
script:
- IMAGE_TAG=$(echo $CI_COMMIT_SHA | cut -c 1-8)
# 构建并推送后端镜像
- docker build -f backend/Dockerfile -t $ECR_REGISTRY/$APP_NAME-backend:$IMAGE_TAG backend/
- docker push $ECR_REGISTRY/$APP_NAME-backend:$IMAGE_TAG
# 构建并推送前端镜像
- docker build -f frontend/Dockerfile -t $ECR_REGISTRY/$APP_NAME-frontend:$IMAGE_TAG frontend/
- docker push $ECR_REGISTRY/$APP_NAME-frontend:$IMAGE_TAG
only:
- main
# ============================================
# Deploy Green Stage
# ============================================
deploy_green_version:
stage: deploy_green
image:
name: bitnami/kubectl:latest
entrypoint: [""]
before_script:
- kubectl config use-context $KUBE_CONTEXT
script:
- echo "Deploying green version..."
- IMAGE_TAG=$(echo $CI_COMMIT_SHA | cut -c 1-8)
# 使用 envsubst 动态替换模板中的变量
- export VERSION=green
- export IMAGE_TAG=$IMAGE_TAG
- envsubst < .k8s/deployment.yaml | kubectl apply -f -
# 部署内部测试 service
- kubectl apply -f .k8s/service-green.yaml # 假设把 green service 单独放一个文件
# 等待 green deployment 就绪
- kubectl rollout status deployment/fullstack-app-green --timeout=120s
only:
- main
# ============================================
# E2E Test Stage
# ============================================
test_on_green:
stage: test_green
image: cypress/included:12.17.2 # 使用官方包含所有依赖的 Cypress 镜像
script:
- echo "Running Cypress E2E tests against the green environment..."
# 关键点:baseUrl 指向集群内部的 green service
# EKS 默认的 DNS 格式是 <service-name>.<namespace>.svc.cluster.local
- npx cypress run --config baseUrl=http://fullstack-app-svc-green.default.svc.cluster.local --spec "cypress/e2e/*.cy.js"
only:
- main
# ============================================
# Promote Stage
# ============================================
promote_green_to_prod:
stage: promote
image:
name: bitnami/kubectl:latest
entrypoint: [""]
before_script:
- kubectl config use-context $KUBE_CONTEXT
script:
- echo "Promoting green to production..."
# 这是原子性的流量切换操作:修改生产 Service 的 selector
- kubectl patch service fullstack-app-svc-prod -p '{"spec":{"selector":{"version":"green"}}}'
- echo "Traffic switched to green version."
when: on_success # 只有测试成功才执行
only:
- main
# ============================================
# Cleanup Stage
# ============================================
cleanup_old_blue_version:
stage: cleanup_blue
image:
name: bitnami/kubectl:latest
entrypoint: [""]
before_script:
- kubectl config use-context $KUBE_CONTEXT
script:
- echo "Cleaning up old blue deployment..."
# 这里需要一个逻辑来找到旧的 blue deployment 并删除
# 一个简单的实现是基于标签和发布时间,或者直接删除名为 -blue 的 deployment
- kubectl delete deployment fullstack-app-blue --ignore-not-found=true
when: manual # 设置为手动触发,提供一个观察期
only:
- main
一个常见的坑在于 Cypress 测试阶段的网络。CI Runner 必须能够访问 EKS 集群内部的 Service DNS。如果你的 GitLab Runner 运行在 EKS 集群外部,你需要配置网络策略或使用 kubectl port-forward
。最佳实践是将 GitLab Runner 也部署在 EKS 集群内,这样网络通信就非常直接。
Cypress 测试用例
测试用例本身很简单,但它验证了从前端 UI -> Nginx 代理 -> 后端 Rocket API -> 返回数据 -> UI 渲染的整个链路。
cypress/e2e/smoke.cy.js
:
describe('Smoke Test for Full-Stack Application', () => {
it('should load the homepage and fetch data from the backend API', () => {
// baseUrl 在 CI 脚本中通过命令行参数传入
cy.visit('/');
// 验证页面标题
cy.get('h1').should('contain', 'Full-Stack Rust App');
// 假设页面上有个按钮,点击后会调用后端 API
cy.get('#fetch-data-btn').click();
// 等待 API 调用完成并验证结果
// 使用 cy.intercept() 来监听网络请求,这是更稳健的做法
cy.intercept('GET', '/api/health').as('getHealth');
// 等待别名为 'getHealth' 的请求完成
cy.wait('@getHealth').then((interception) => {
// 验证 HTTP 状态码
expect(interception.response.statusCode).to.equal(200);
// 验证后端返回的 payload
expect(interception.response.body).to.have.property('status', 'ok');
expect(interception.response.body).to.have.property('version');
});
// 验证数据被正确渲染到页面上
cy.get('#api-response').should('contain', '"status": "ok"');
});
});
这套流程的真正价值在于,test_on_green
阶段是对一个完整的、隔离的、与生产环境配置完全一致的新版本进行测试。如果测试失败,生产环境的流量毫发无损,流水线会自动停止,并可以触发告警。失败的 green
版本可以被轻松销毁,不会留下任何垃圾。
当前方案的局限性与未来展望
这个方案并非银弹,它主要适用于无状态应用。对于需要数据库 schema 迁移的有状态应用,蓝绿部署会变得异常复杂。你不能简单地让两个版本的应用同时写入一个数据库,这可能导致数据不一致。在这种情况下,通常需要采用更复杂的策略,如“扩展-收缩模式”(Expand-Contract Pattern),先部署兼容新旧两种 schema 的代码,逐步迁移数据,过程远比本文描述的复杂。
此外,在蓝绿切换的短暂窗口期,系统资源消耗会翻倍,因为新旧两个版本的 Pod 都在运行。对于资源敏感或成本敏感的应用,这可能是一个需要考量的因素。
未来的优化方向可以转向金丝雀发布。通过引入服务网格(Service Mesh)如 Istio 或 Linkerd,我们可以实现更细粒度的流量控制,例如将 1% 的生产流量导入到 green
版本,观察其各项指标(错误率、延迟等)。如果一切正常,再逐步增加流量比例,最终完成全量切换。这种方式风险更小,回滚也更平滑,但它引入了服务网格这一新的复杂性。