使用 Terraform 与 Kubernetes 为 Android Relay 应用构建动态后端预览环境


Android 团队的开发节奏正被单一的共享 Staging 环境拖垮。每个新功能分支都依赖同一个后端进行调试,GraphQL Schema 的一处微小改动就可能导致其他人的 Relay 客户端编译失败,或是运行时出现数据不一致。联调排队、数据污染、版本冲突成了每日例行的摩擦。问题的核心在于,移动端开发与后端部署的生命周期被强行绑定,缺乏隔离性。

我们的初步构想是:为每一个 Android 应用的 Pull Request (PR) 自动创建一套完整、隔离的后端服务。这套环境应该包含功能分支对应的 BFF (Backend for Frontend) 服务、独立的数据库以及专属的网络入口。当 PR 合并或关闭时,这套环境必须能被自动、干净地回收。这本质上是在构建一个内部的“预览环境即服务”平台。

技术选型决策

要实现这个目标,我们需要一个能够贯穿基础设施、应用部署和客户端集成的自动化流程。

  1. 基础设施层:Terraform
    选择 Terraform 是因为它对基础设施的声明式管理能力。我们需要为每个 PR 创建的资源(如 Kubernetes 命名空间、云数据库实例、配置项等)并非一成不变。使用 Terraform 模块,我们可以将一套“预览环境”封装成一个可重用的单元。通过输入变量(如 PR 编号、Git SHA),就能实例化出一套完整的、互相隔离的云资源。这比编写一堆 shell 脚本调用云厂商 CLI 要可靠和易于维护得多。在真实项目中,基础设施的一致性和可预测性是首要考虑的。

  2. 应用运行时:Kubernetes (K8s)
    Kubernetes 是运行容器化应用的事实标准。它的 API 驱动模型是实现自动化的关键。我们可以通过 API 动态创建 Namespace 来实现最高级别的租户隔离,并利用 Deployment、Service、Ingress 等原生资源来管理 BFF 应用的生命周期。相比于直接在虚拟机上部署,K8s 提供了我们需要的一切:服务发现、自愈能力、配置管理和网络路由。

  3. 客户端与服务端契约:Relay 与 GraphQL
    Android 团队已经在使用 Relay,这是一个强依赖 GraphQL Schema 的框架。动态预览环境最大的价值之一,就是能让前端(在这里是 Android 客户端)针对一个临时的、部署了最新 Schema 的后端进行编译和测试。这解决了共享环境中 Schema 冲突的根本痛点。BFF 服务本身将是一个标准的 GraphQL 服务器。

  4. 粘合剂:CI/CD 流水线 (以 GitHub Actions 为例)
    整个流程的触发和编排需要一个强大的 CI/CD 工具。GitHub Actions 原生集成了代码仓库的事件(如 pull_request),是驱动这个流程的理想选择。流水线将负责检出代码、调用 Terraform 创建基础设施、构建并推送 BFF 的容器镜像,最后将应用部署到 K8s 中。

整个工作流程的架构如下:

sequenceDiagram
    participant Dev as 开发者
    participant Git as GitHub 仓库
    participant GHA as GitHub Actions
    participant TF as Terraform Cloud
    participant GCR as 容器镜像仓库
    participant K8s as Kubernetes 集群
    participant Android as Android 应用

    Dev->>Git: 创建/更新 Pull Request
    Git->>GHA: 触发 on:pull_request 工作流
    GHA->>TF: 执行 terraform apply (传入 PR-123)
    TF-->>GHA: 创建 Namespace, Cloud SQL, Secret 等
    GHA->>GCR: 构建并推送 BFF 镜像 (tag: pr-123)
    GHA->>K8s: kubectl apply (使用 PR-123 镜像和配置)
    K8s-->>GHA: 部署完成, Ingress 就绪
    GHA->>Git: 在 PR 中评论: "预览环境已就绪: https://pr-123.example.com"
    Dev->>Android: 配置新后端 URL, 构建测试 APK
    Android->>K8s: 通过 Ingress 访问独立的 BFF 服务

步骤化实现

1. 基础设施核心:Terraform 预览环境模块

我们首先需要创建一个可重用的 Terraform 模块,用于定义“一个预览环境”所需的所有资源。这个模块是整个平台的地基。

目录结构:

modules/
└── preview-env/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf

modules/preview-env/variables.tf

# modules/preview-env/variables.tf

variable "pr_number" {
  type        = number
  description = "The Pull Request number used for naming and identification."
}

variable "project_id" {
  type        = string
  description = "The GCP project ID."
}

variable "region" {
  type        = string
  description = "The GCP region for resource deployment."
  default     = "asia-east1"
}

variable "db_tier" {
  type        = string
  description = "The machine type for the Cloud SQL instance."
  default     = "db-g1-small"
}

variable "kubernetes_cluster_name" {
  type        = string
  description = "The name of the GKE cluster to deploy to."
}

variable "kubernetes_cluster_location" {
  type        = string
  description = "The location of the GKE cluster."
}

modules/preview-env/main.tf

这里的代码负责创建 K8s Namespace 和一个专用的 Google Cloud SQL PostgreSQL 实例。在生产环境中,一个常见的错误是忽略资源的唯一性,导致命名冲突。这里的 random_idpr_number 变量是关键。

# modules/preview-env/main.tf

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 4.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = ">= 2.10"
    }
    random = {
      source = "hashicorp/random"
      version = ">= 3.1"
    }
  }
}

# 为每个环境生成一个唯一的后缀,防止资源名称冲突
resource "random_id" "suffix" {
  byte_length = 4
}

# 1. 在目标 K8s 集群中创建隔离的 Namespace
resource "kubernetes_namespace" "preview_ns" {
  metadata {
    name = "preview-pr-${var.pr_number}"
    labels = {
      "managed-by" = "terraform"
      "purpose"    = "preview-environment"
      "pr"         = var.pr_number
    }
  }
}

# 2. 为每个环境创建一个独立的 Cloud SQL 实例
resource "google_sql_database_instance" "preview_db" {
  project  = var.project_id
  region   = var.region
  name     = "preview-db-pr-${var.pr_number}-${random_id.suffix.hex}"
  database_version = "POSTGRES_14"

  settings {
    tier = var.db_tier
    # 在真实项目中,这里应配置备份、高可用性等
    # 但对于预览环境,成本和速度是首要考虑的
    backup_configuration {
      enabled = false
    }
    ip_configuration {
      authorized_networks {
        # 允许 GKE 节点访问
        value = "0.0.0.0/0" # 注意:这里为了演示方便,生产环境应严格限制 IP 范围
      }
    }
  }

  # 重要的成本控制措施:当 Terraform 销毁资源时,彻底删除实例
  deletion_protection = false
}

# 3. 为数据库创建一个默认用户,并生成随机密码
resource "random_password" "db_password" {
  length  = 16
  special = true
}

resource "google_sql_user" "db_user" {
  project  = var.project_id
  instance = google_sql_database_instance.preview_db.name
  name     = "bff_user"
  password = random_password.db_password.result
}

# 4. 将数据库连接信息安全地存入 Kubernetes Secret
# 这是一个关键步骤,它将 Terraform 管理的资源与 K8s 中的应用连接起来
data "google_client_config" "default" {}

data "google_container_cluster" "primary" {
  project  = var.project_id
  name     = var.kubernetes_cluster_name
  location = var.kubernetes_cluster_location
}

provider "kubernetes" {
  host                   = "https://{data.google_container_cluster.primary.endpoint}"
  token                  = data.google_client_config.default.access_token
  cluster_ca_certificate = base64decode(data.google_container_cluster.primary.master_auth[0].cluster_ca_certificate)
}

resource "kubernetes_secret" "db_credentials" {
  metadata {
    name      = "db-credentials"
    namespace = kubernetes_namespace.preview_ns.metadata[0].name
  }

  data = {
    DB_HOST     = google_sql_database_instance.preview_db.private_ip_address
    DB_PORT     = "5432"
    DB_USER     = google_sql_user.db_user.name
    DB_PASSWORD = random_password.db_password.result
    DB_NAME     = "bff_db" # 假设使用一个固定的数据库名
  }

  type = "Opaque"
}

resource "google_sql_database" "default_db" {
  project  = var.project_id
  instance = google_sql_database_instance.preview_db.name
  name     = "bff_db"
}

modules/preview-env/outputs.tf

输出模块创建的关键信息,供 CI/CD 流水线后续步骤使用。

# modules/preview-env/outputs.tf

output "namespace" {
  value       = kubernetes_namespace.preview_ns.metadata[0].name
  description = "The created Kubernetes namespace for this environment."
}

output "db_instance_name" {
  value       = google_sql_database_instance.preview_db.name
  description = "The name of the Cloud SQL instance."
}

output "db_secret_name" {
  value = kubernetes_secret.db_credentials.metadata[0].name
  description = "The name of the Kubernetes secret holding DB credentials."
}

2. 应用层:BFF 的 Kubernetes Manifests

这些是部署 BFF 应用所需的 K8s 资源模板。注意其中的占位符 __IMAGE_TAG____PR_NUMBER__,它们将在 CI/CD 流程中被动态替换。

k8s/deployment.yaml

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bff-deployment
  labels:
    app: bff
spec:
  replicas: 1 # 预览环境通常不需要高可用
  selector:
    matchLabels:
      app: bff
  template:
    metadata:
      labels:
        app: bff
    spec:
      containers:
      - name: bff-container
        image: gcr.io/my-project/bff-service:__IMAGE_TAG__
        ports:
        - containerPort: 8080
        envFrom:
        - secretRef:
            name: db-credentials # 引用 Terraform 创建的 Secret
        resources:
          requests:
            cpu: "100m"
            memory: "128Mi"
          limits:
            cpu: "500m"
            memory: "512Mi"
        # 生产级的探针是服务稳定性的保障,即使在预览环境也应配置
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 15
          periodSeconds: 20
        readinessProbe:
          httpGet:
            path: /readyz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10

k8s/service.yaml

# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: bff-service
spec:
  selector:
    app: bff
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080

k8s/ingress.yaml

# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: bff-ingress
  annotations:
    # 这里的 annotations 取决于你使用的 Ingress Controller
    # 例如 nginx-ingress 或 GKE Ingress
    kubernetes.io/ingress.class: "gce"
    kubernetes.io/ingress.global-static-ip-name: "my-static-ip" # 假设使用一个静态IP
spec:
  rules:
  - host: pr-__PR_NUMBER__.example.com # 动态域名
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: bff-service
            port:
              number: 80

3. 自动化流程:GitHub Actions 工作流

这是将所有部分串联起来的核心。工作流会在 PR 创建或更新时触发,并在 PR 关闭时执行清理。

.github/workflows/preview-environment.yml

# .github/workflows/preview-environment.yml

name: Preview Environment Manager

on:
  pull_request:
    types: [opened, synchronize, closed]

env:
  PROJECT_ID: "your-gcp-project-id"
  GKE_CLUSTER: "your-gke-cluster-name"
  GKE_LOCATION: "asia-east1"
  TF_STATE_BUCKET: "your-terraform-state-bucket"
  IMAGE_REPO: "gcr.io/your-gcp-project-id/bff-service"

jobs:
  # 部署或更新预览环境
  deploy-preview:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Authenticate to Google Cloud
        uses: 'google-github-actions/auth@v1'
        with:
          credentials_json: '${{ secrets.GCP_SA_KEY }}'

      - name: Set up gcloud CLI
        uses: 'google-github-actions/setup-gcloud@v1'

      - name: Configure Docker
        run: gcloud auth configure-docker --quiet

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: 1.3.0

      # 这里的 workspace 隔离是 Terraform 状态管理的关键
      - name: Terraform Init
        id: init
        run: |
          terraform init \
            -backend-config="bucket=${{ env.TF_STATE_BUCKET }}" \
            -backend-config="prefix=previews/pr-${{ github.event.pull_request.number }}"
        working-directory: ./terraform # 假设你的 Terraform 根目录在这里

      - name: Terraform Apply
        id: apply
        run: |
          terraform apply -auto-approve \
            -var="pr_number=${{ github.event.pull_request.number }}" \
            -var="project_id=${{ env.PROJECT_ID }}" \
            -var="kubernetes_cluster_name=${{ env.GKE_CLUSTER }}" \
            -var="kubernetes_cluster_location=${{ env.GKE_LOCATION }}"
        working-directory: ./terraform
      
      - name: Get K8s credentials
        run: gcloud container clusters get-credentials ${{ env.GKE_CLUSTER }} --location ${{ env.GKE_LOCATION }}

      - name: Build and Push Docker Image
        id: build-image
        run: |
          IMAGE_TAG="pr-${{ github.event.pull_request.number }}-${{ github.sha }}"
          docker build -t ${{ env.IMAGE_REPO }}:${IMAGE_TAG} .
          docker push ${{ env.IMAGE_REPO }}:${IMAGE_TAG}
          echo "::set-output name=image_tag::${IMAGE_TAG}"
      
      - name: Deploy to Kubernetes
        run: |
          NAMESPACE="preview-pr-${{ github.event.pull_request.number }}"
          IMAGE_TAG="${{ steps.build-image.outputs.image_tag }}"
          
          # 使用 sed 进行模板替换,这是一个简单有效的方法
          sed -i "s|__IMAGE_TAG__|${IMAGE_TAG}|g" k8s/*.yaml
          sed -i "s|__PR_NUMBER__|${{ github.event.pull_request.number }}|g" k8s/*.yaml
          
          kubectl apply -n ${NAMESPACE} -f k8s/

      - name: Post comment to PR
        uses: actions/github-script@v6
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `🚀 Preview environment is ready at: https://pr-${{ github.event.pull_request.number }}.example.com`
            })

  # 销毁预览环境
  destroy-preview:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Authenticate to Google Cloud
        uses: 'google-github-actions/auth@v1'
        with:
          credentials_json: '${{ secrets.GCP_SA_KEY }}'

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: 1.3.0

      - name: Terraform Init
        run: |
          terraform init \
            -backend-config="bucket=${{ env.TF_STATE_BUCKET }}" \
            -backend-config="prefix=previews/pr-${{ github.event.pull_request.number }}"
        working-directory: ./terraform
      
      - name: Terraform Destroy
        run: |
          terraform destroy -auto-approve \
            -var="pr_number=${{ github.event.pull_request.number }}" \
            -var="project_id=${{ env.PROJECT_ID }}" \
            -var="kubernetes_cluster_name=${{ env.GKE_CLUSTER }}" \
            -var="kubernetes_cluster_location=${{ env.GKE_LOCATION }}"
        working-directory: ./terraform

4. Android 客户端集成

最后一步是让 Android 应用能够方便地连接到这些动态创建的环境。一个常见的错误是硬编码 URL。我们应该利用 Gradle 的构建变体 (Build Variants) 和 buildConfigField 来动态注入后端地址。

app/build.gradle.kts

// app/build.gradle.kts

android {
    // ...
    buildTypes {
        // ...
    }

    productFlavors {
        // 生产环境
        create("production") {
            dimension = "environment"
            buildConfigField("String", "GRAPHQL_ENDPOINT", "\"https://api.example.com/graphql\"")
        }
        // 预览环境
        create("preview") {
            dimension = "environment"
            // 这个值可以由 CI 在构建 APK 时通过命令行参数传入
            // ./gradlew assemblePreview -PpreviewUrl="https://pr-123.example.com/graphql"
            val previewUrl = project.findProperty("previewUrl") as? String ?: "http://localhost:8080/graphql"
            buildConfigField("String", "GRAPHQL_ENDPOINT", "\"$previewUrl\"")
        }
    }
}

网络层代码 (Kotlin):

在应用代码中,可以直接引用这个编译时生成的常量。

// com/example/myapp/network/ApiClient.kt

import com.apollographql.apollo3.ApolloClient
import com.example.myapp.BuildConfig // 自动生成的 BuildConfig 类

object ApiClient {
    val instance: ApolloClient by lazy {
        ApolloClient.Builder()
            .serverUrl(BuildConfig.GRAPHQL_ENDPOINT)
            // ... 其他配置,如拦截器
            .build()
    }
}

当 GitHub Actions 工作流创建好预览环境后,它可以触发一个下游任务,该任务执行 ./gradlew assemblePreview -PpreviewUrl="..." 来构建一个专门连接到该 PR 环境的 APK,并将其上传为构建产物供测试人员下载。对于 Relay 来说,relay-compiler 也可以被配置为从这个动态 URL 拉取最新的 schema.graphql,确保客户端代码与后端契约的完全同步。

局限性与未来迭代

这个方案有效地解决了移动端团队的开发隔离问题,但它并非没有成本和可以改进之处。

首先是资源成本与创建速度。为每个 PR 创建一个独立的 Cloud SQL 实例虽然隔离性最好,但开销不菲且启动缓慢。一个可行的优化路径是使用数据库 Operator(如 Zalando Postgres Operator)在 K8s 集群内部署一个共享的 PostgreSQL 集群,并通过 Operator 为每个 PR 动态创建独立的 database 和 user。这种方式资源复用率更高,创建速度也更快(秒级 vs 分钟级)。

其次,目前的部署方式是**命令式的 kubectl apply**。虽然简单直接,但它缺乏对部署状态的持续跟踪和声明式保证。更成熟的模式是转向 GitOps。CI 流水线的职责可以简化为构建镜像和更新一个专门的部署配置仓库中的 YAML 文件。然后由 ArgoCD 或 Flux 这类 GitOps 工具监听配置仓库的变化,并自动将这些变更同步到 Kubernetes 集群中。这提供了更好的可追溯性、回滚能力和安全性。

最后,这个系统只解决了 BFF 层的动态环境问题。一个复杂的应用可能依赖多个后端微服务。要将这个模式扩展到全链路预览环境,就需要服务网格(Service Mesh)如 Istio 或 Linkerd 的介入,通过流量路由规则(如基于 Header 的动态路由)将来自特定预览环境 BFF 的请求,精准地转发到对应版本的下游微服务上,这是一个更复杂的架构挑战。


  目录