团队的技术栈选型最终敲定为 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 做了几件核心的事:
- 签出完整代码历史 (
fetch-depth: 0),这是 Nx 计算affected所必需的。 - 安装所有依赖。
- 执行
nx print-affected,并通过grep和sed等 shell 命令,将受影响的服务和应用分别整理成一个 JSON 数组格式的字符串。 - 将这个字符串存入 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 兼容镜像的工具,它有几个颠覆性的优点:
- 无 Docker Daemon 依赖:可以直接在任何有 JDK 的环境中运行,完美契合 CI 环境。
- 可复现构建:相同的输入总是产生完全相同的镜像 digest。
- 智能分层:它将应用代码、资源文件和依赖库分到不同的层。在日常开发中,通常只有应用代码层会发生变化,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-logincredHelper 自动处理对 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 实现声明式的自动部署,完成从代码提交到服务上线的端到端自动化。