构建集成 Cypress E2E 测试的 Rust 全栈应用在 AWS EKS 上的蓝绿部署实践


在真实项目中,任何涉及手动操作的发布流程都注定会出错。问题不在于是否会出错,而在于何时出错以及造成的损失有多大。对于一个由 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 --from=planner /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 --from=cacher /app/target /app/target
COPY --from=cacher /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 --from=builder /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.tomlCargo.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 --from=builder /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

  1. app-svc-prod: 这是生产服务,对外部暴露。它的 selector 将指向当前提供服务的版本(bluegreen)。
  2. 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 版本,观察其各项指标(错误率、延迟等)。如果一切正常,再逐步增加流量比例,最终完成全量切换。这种方式风险更小,回滚也更平滑,但它引入了服务网格这一新的复杂性。


  目录