为 React Native、Serverless 与 Jib 服务构建基于 Monorepo 的变更感知部署流水线


团队的技术栈选型最终敲定为 Monorepo 时,最初的目标很明确:提升代码复用,统一依赖管理,简化跨端应用的开发流程。我们的业务场景是一个包含 React Native 移动端、多个 Node.js Serverless Function 以及一个处理复杂数据聚合的 Java BFF 服务的复杂系统。然而,理想与现实的第一次碰撞,发生在 CI/CD 流水线上。

初版的流水线简单粗暴:任何一次 git push 都会触发所有项目的测试、构建和部署。一次 React Native 的文案修改,会导致后端的 Lambda 函数被重新打包部署,甚至触发那个用时最长的 Java BFF 服务的 Docker 镜像构建。单次流水线耗时稳定在20分钟以上,这对于追求快速迭代的团队来说是不可接受的。成本和时间的双重浪费,迫使我们必须彻底改造这条流水线,核心目标只有一个:精确识别变更,只构建和部署受影响的部分

Monorepo 的基石:结构设计与依赖图谱

解决问题的起点是规范化我们的 Monorepo 结构。我们选择了 Nx 作为 Monorepo 的管理工具,主要是看中了它强大的项目依赖图分析和变更计算能力。

我们的目录结构如下:

/
├── apps
│   ├── mobile          # React Native 应用
│   └── web-admin       # (预留)
├── packages
│   ├── shared-types    # TypeScript 类型定义,跨前后端复用
│   └── ui-components   # React Native UI 组件库
├── services
│   ├── user-api        # Serverless, Node.js, Express.js (用户API)
│   └── aggregator-bff  # Java, Spring Boot (数据聚合BFF)
├── tools
│   └── scripts         # 自定义脚本
├── nx.json             # Nx 核心配置
├── package.json        # 根 package.json
└── yarn.lock

nx.json 是这里的指挥中心,它定义了各个项目之间的依赖关系。一个关键的配置是 targetDefaults,我们可以为构建、测试等任务设定缓存策略。

// nx.json
{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "npmScope": "my-org",
  "affected": {
    "defaultBase": "main"
  },
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "test", "lint"]
      }
    }
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"]
    }
  },
  "defaultProject": "mobile"
}

这里的 cacheableOperations 意味着 build, test 等任务的执行结果会被 Nx 缓存。如果一个项目的源码及其依赖没有发生变化,再次执行这些任务时会直接从缓存中获取结果,这在本地开发和 CI 环境中都能极大地节省时间。"dependsOn": ["^build"] 则告诉 Nx,在构建一个项目之前,必须先构建它所有的依赖项。

流水线的智能化核心:识别“受影响的”项目

有了 Nx 的依赖图,我们就可以在 CI 流水线(我们使用 GitHub Actions)中精确地找出哪些项目因为一次提交而受到了影响。Nx 提供了一组强大的 affected 命令。

关键命令是 npx nx print-affected --select=projects。它会输出自 main 分支(或你指定的 base)以来所有发生变更的项目名称列表。

我们的第一版 CI 脚本利用了这个输出来动态生成执行矩阵。

# .github/workflows/ci.yml

name: Monorepo CI/CD

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  determine-affected:
    runs-on: ubuntu-latest
    outputs:
      affected_services: ${{ steps.get_affected_services.outputs.services }}
      affected_apps: ${{ steps.get_affected_apps.outputs.apps }}
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0 # 需要完整历史来计算 diff

      - name: Setup Node.js and dependencies
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'
      - run: yarn install --frozen-lockfile

      - name: Derive affected services
        id: get_affected_services
        run: |
          # 找出 services/ 目录下受影响的项目,并转换为 JSON 数组
          AFFECTED_STRING=$(npx nx print-affected --type=app --select=projects | grep -E '^services-' | tr '\n' ',' | sed 's/,$//')
          echo "services=[\"${AFFECTED_STRING//,/\",\"}\"]" >> $GITHUB_OUTPUT
      
      - name: Derive affected apps
        id: get_affected_apps
        run: |
          # 找出 apps/ 目录下受影响的项目
          AFFECTED_STRING=$(npx nx print-affected --type=app --select=projects | grep -E '^apps-' | tr '\n' ',' | sed 's/,$//')
          echo "apps=[\"${AFFECTED_STRING//,/\",\"}\"]" >> $GITHUB_OUTPUT

# ... 后续的部署 jobs 会使用这里的 outputs

这个 determine-affected job 做了几件核心的事:

  1. 签出完整代码历史 (fetch-depth: 0),这是 Nx 计算 affected 所必需的。
  2. 安装所有依赖。
  3. 执行 nx print-affected,并通过 grepsed 等 shell 命令,将受影响的服务和应用分别整理成一个 JSON 数组格式的字符串。
  4. 将这个字符串存入 job 的 outputs 中,供后续的 job 消费。

Serverless 服务的精益部署

对于 services/user-api 这样的 Node.js Lambda 函数,我们的部署流程需要解决两个问题:打包体积和部署速度。

serverless.yml 配置是关键。我们使用 serverless-esbuild 插件来获得 tree-shaking 的能力,确保只打包业务逻辑实际用到的代码,而不是整个 node_modules

# services/user-api/serverless.yml

service: user-api

provider:
  name: aws
  runtime: nodejs18.x
  region: us-east-1
  memorySize: 256
  # ... IAM roles and other configs

package:
  individually: true

custom:
  esbuild:
    bundle: true
    minify: true
    sourcemap: true
    exclude: ['aws-sdk'] # AWS Lambda 环境已提供
    target: 'node18'
    define: { 'require.resolve': undefined }
    platform: 'node'
    concurrency: 10

functions:
  main:
    handler: src/handler.main
    events:
      - http:
          path: /{proxy+}
          method: any

plugins:
  - serverless-esbuild

在 Lambda 的业务代码中,我们与 NoSQL 数据库(AWS DynamoDB)进行交互。真实项目中,这里的代码必须包含健壮的错误处理和结构化日志。

// services/user-api/src/service/userService.ts

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { User } from "shared-types"; // 从 Monorepo 的 packages 中导入共享类型
import pino from "pino";

const logger = pino({ level: "info" });

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const tableName = process.env.USERS_TABLE_NAME;

export class UserService {
  public async getUserById(userId: string): Promise<User | null> {
    const command = new GetCommand({
      TableName: tableName,
      Key: { id: userId },
    });

    try {
      const { Item } = await docClient.send(command);
      if (!Item) {
        logger.warn({ userId }, "User not found");
        return null;
      }
      return Item as User;
    } catch (error) {
      logger.error({ err: error, userId }, "Failed to fetch user from DynamoDB");
      // 在生产环境中,这里应该抛出一个自定义的、可被上层捕获的错误
      throw new Error("Database read error");
    }
  }

  public async createUser(user: User): Promise<User> {
    const command = new PutCommand({
      TableName: tableName,
      Item: user,
      // 使用条件表达式防止覆盖已存在的用户
      ConditionExpression: "attribute_not_exists(id)",
    });

    try {
      await docClient.send(command);
      logger.info({ userId: user.id }, "User created successfully");
      return user;
    } catch (error: any) {
      if (error.name === 'ConditionalCheckFailedException') {
        logger.warn({ userId: user.id }, "Attempted to create a user that already exists");
        throw new Error("User already exists"); // 抛出业务相关的错误
      }
      logger.error({ err: error, user }, "Failed to create user in DynamoDB");
      throw new Error("Database write error");
    }
  }
}

这段代码展示了与 DynamoDB 的实际交互,包括了从共享包导入类型、结构化日志(使用 pino)以及针对 PutCommand 的条件表达式进行错误处理,这在生产环境中至关重要。

接下来,我们将部署 job 与 determine-affected job 连接起来:

# .github/workflows/ci.yml (continued)

  deploy-services:
    needs: determine-affected
    if: ${{ needs.determine-affected.outputs.affected_services != '[""]' }} # 仅当有服务受影响时运行
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: ${{ fromJson(needs.determine-affected.outputs.affected_services) }}
    steps:
      # ... checkout and setup node steps ...

      - name: Deploy ${{ matrix.service }}
        run: |
          cd ${{ matrix.service }}
          # 'sls' 是 serverless 命令的缩写
          npx sls deploy --stage prod
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

通过 strategy.matrix,GitHub Actions 会为每一个受影响的服务启动一个并行的部署任务。if 条件确保了如果没有服务发生变化,整个 deploy-services job 会被直接跳过。

告别 Dockerfile:使用 Jib 高效构建 Java 服务

aggregator-bff 是一个 Spring Boot 应用,它最初使用一个多阶段的 Dockerfile 进行构建,不仅速度慢,而且需要在 CI runner 上运行 Docker daemon,这会带来额外的复杂性和安全问题。Jib 成为了我们的解决方案。

Jib 是一个由 Google 开发的可以直接为 Java 应用构建 OCI 兼容镜像的工具,它有几个颠覆性的优点:

  1. 无 Docker Daemon 依赖:可以直接在任何有 JDK 的环境中运行,完美契合 CI 环境。
  2. 可复现构建:相同的输入总是产生完全相同的镜像 digest。
  3. 智能分层:它将应用代码、资源文件和依赖库分到不同的层。在日常开发中,通常只有应用代码层会发生变化,Jib 只需重新构建并推送这薄薄的一层,极大地加快了构建和推送的速度。

我们移除了 Dockerfile,转而在 build.gradle.kts 中配置 Jib 插件。

// services/aggregator-bff/build.gradle.kts

import com.google.cloud.tools.jib.gradle.JibTask

plugins {
    id("org.springframework.boot") version "3.1.5"
    id("io.spring.dependency-management") version "1.1.3"
    kotlin("jvm") version "1.8.22"
    kotlin("plugin.spring") version "1.8.22"
    id("com.google.cloud.tools.jib") version "3.4.0"
}

// ... other configurations like group, version, sourceCompatibility ...

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    // ... other dependencies
}

jib {
    // 从环境变量或 gradle.properties 读取 ECR 仓库地址
    val ecrRegistry = System.getenv("ECR_REGISTRY") ?: project.property("ecrRegistry")
    val imageName = "my-org/aggregator-bff"
    
    from {
        // 使用一个精简的、无 shell 的基础镜像,增强安全性
        image = "gcr.io/distroless/java17-debian11"
        // 配置凭证,这里使用 AWS CLI 提供的凭证帮助程序
        credHelper = "ecr-login"
    }
    to {
        image = "$ecrRegistry/$imageName"
        // 使用 Git commit hash 作为 tag,确保镜像的唯一性和可追溯性
        tags = setOf(System.getenv("GITHUB_SHA")?.substring(0, 7) ?: "latest")
    }
    container {
        // JVM 优化参数,对容器环境非常重要
        jvmFlags = listOf("-Xms512m", "-Xmx1024m", "-XX:+UseG1GC")
        ports = listOf("8080")
        // 设置创建时间为固定值,以保证可复现构建
        creationTime = "USE_CURRENT_TIMESTAMP"
    }
}

这份 Jib 配置包含了生产环境的最佳实践:

  • 使用 distroless 作为基础镜像,最小化攻击面。
  • 通过 ecr-login credHelper 自动处理对 AWS ECR 的认证。
  • 使用 Git commit hash 作为镜像 tag,而不是易变的 latest
  • 配置了明确的 JVM 内存参数。

现在,CI 中构建并推送镜像的步骤变得异常简单:

# .github/workflows/ci.yml (continued)

  build-and-push-bff:
    needs: determine-affected
    # 只有当 aggregator-bff 被影响时才运行
    if: contains(fromJson(needs.determine-affected.outputs.affected_services), 'services/aggregator-bff')
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write # 用于 AWS OIDC 认证
    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
          aws-region: us-east-1

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Build and Push with Jib
        working-directory: ./services/aggregator-bff
        run: ./gradlew jib -PecrRegistry=${{ secrets.ECR_REGISTRY }}

我们使用 AWS OIDC 为 GitHub Actions 分配临时的 IAM 角色,避免了在 Secrets 中存储长期 Access Key。./gradlew jib 一个命令就完成了所有事情,无需 Docker,速度飞快。

可视化最终流水线

整个流水线的工作流程可以用 Mermaid 图清晰地表示出来:

graph TD
    A[Git Push / PR] --> B{Determine Affected Projects};
    B --> C{Affected Services?};
    B --> D{Affected Apps?};

    subgraph Service Deployment
        C -- Yes --> E[Trigger Matrix Job: Deploy Services];
        E --> F1[Deploy user-api];
        E --> F2[Build & Push aggregator-bff];
    end

    subgraph App Build
        D -- Yes --> G[Trigger Matrix Job: Build Apps];
        G --> H1[Build & Test mobile];
    end
    
    C -- No --> I[Skip Service Deployment];
    D -- No --> J[Skip App Build];

这个流程确保了每次提交都只会触发最小化的工作单元,实现了我们最初的目标。平均流水线时间从20多分钟缩短到了3-5分钟。

当前方案的局限性与未来展望

这套基于 Nx 和 Jib 的变更感知流水线极大地提升了我们的研发效能,但它并非银弹。首先,对 Nx 依赖图的维护需要团队成员有清晰的认知,错误的依赖声明可能导致变更检测失效或范围扩大化。一个核心的 shared-types 包的微小改动,依然会触发大规模的重新构建,这要求我们对共享库的接口设计必须更加谨慎和稳定。

其次,虽然 Jib 解决了 Java 服务的构建效率,但 React Native 的构建(特别是原生部分的编译)仍然是耗时大户。我们正在探索使用独立的、更强劲的 macOS runner,并结合更精细化的缓存策略(如缓存 node_modules, Pods, Gradle 依赖)来进一步优化。

未来的迭代方向可能包括引入 Nx Cloud 来实现分布式计算缓存,让团队成员本地的构建结果也能被 CI 流水线共享,从而达到极致的速度。对于部署在容器环境的 aggregator-bff 服务,目前还是手动触发更新,后续会将其接入 GitOps 流程,通过 ArgoCD 或 Flux 实现声明式的自动部署,完成从代码提交到服务上线的端到端自动化。


  目录