团队接手了一个包含超过五十个 Gatsby 站点的项目,它们被统一管理在一个 pnpm monorepo 中。这些站点作为微前端,共同构成了一个大型内容门户。初期的 CI 流程简单粗暴:任何代码合并到主干,都会触发一个 Jenkins 作业,该作业拉取整个仓库,执行 pnpm install,然后串行地为每一个微前端执行 gatsby build。全量构建一次耗时接近四十分钟,这对于一个追求快速迭代的团队来说,是个灾难。问题很明确:99% 的构建时间都浪费在未变更的站点和重复的依赖安装上。
初步构想是建立一个智能的、增量的构建系统。它必须满足几个核心要求:
- 变更检测:只构建自上次成功部署以来代码发生变化的微前端。
- 智能缓存:
node_modules目录和每个 Gatsby 站点的.cache与public目录必须被有效缓存,以加速依赖安装和 Gatsby 的增量构建。 - 并行执行:如果多个微前端同时发生变更,它们的构建过程应该并行执行以缩短总耗时。
- 云原生:整个流程需要在 Kubernetes 上运行,以利用其弹性和可扩展性。
技术选型上,Tekton 成了首选。它作为 Kubernetes 原生的 CI/CD 框架,其 Task, Pipeline, Workspace 的设计理念与我们的需求高度契合。Workspace 可以通过 PersistentVolumeClaim (PVC) 实现,这为解决跨 Task 运行的缓存问题提供了直接且可靠的方案。Gatsby 本身对构建缓存有良好的支持,只要我们能持久化 .cache 和 public 目录,就能获得显著的速度提升。
架构设计与流水线规划
我们的目标是设计一个 Tekton Pipeline,它能自动化地完成整个增量构建和部署流程。
整个流水线的核心逻辑可以分解为以下几个关键步骤(Tasks):
-
clone-repo: 克隆代码仓库。 -
detect-changes: 这是流水线的大脑。该任务会比较当前HEAD与上一次成功构建的commit SHA,计算出哪些微前端(即packages/*目录)发生了变更,并将结果输出。 -
restore-cache: 在安装依赖和构建之前,从 PVC 支持的 Workspace 中恢复node_modules和各个微前端的历史缓存。 -
pnpm-install: 安装所有依赖。由于缓存的存在,这个过程会非常快。 -
build-and-deploy: 这是一个并行执行的任务。基于detect-changes的输出,为每一个需要构建的微前端启动一个任务实例。每个实例负责运行gatsby build并将构建产物上传到对象存储。 -
update-cache: 构建成功后,将最新的node_modules和已构建站点的缓存目录写回到 PVC 中,供下次使用。
为了可视化这个流程,我们可以使用 Mermaid.js 来描绘这个 Pipeline 的结构。
graph TD
A[Start] --> B(clone-repo);
B --> C(detect-changes);
C --> D(restore-cache);
D --> E(pnpm-install);
E --> F{Changed Micro-frontends?};
F -- Yes --> G(Matrix: Parallel build-and-deploy);
F -- No --> I(End);
G --> H(update-cache);
H --> I;
核心 Task 实现
在真实项目中,配置的健壮性至关重要。下面是几个核心 Task 的 YAML 定义,包含了生产环境中必要的细节和注释。
Task 1: detect-changes
这是整个流水线中最关键的自定义任务。它的逻辑是:获取上一次成功运行的 commit hash(我们可以将其存储在 ConfigMap 或一个特定的 git tag 中),然后与当前 commit 进行 git diff,最终输出一个 JSON 数组,其中包含所有发生变更的微前端包名。
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: detect-changes
spec:
description: >-
Detects which Gatsby micro-frontend packages have changed since the
last successful build commit.
workspaces:
- name: source
description: The workspace containing the cloned git repository.
params:
- name: last-success-commit
type: string
description: "The commit SHA of the last successful pipeline run."
- name: base-branch
type: string
description: "The base branch to compare against, e.g., main."
default: "main"
results:
- name: changed-packages
description: "A JSON array of package names that have changed."
- name: has-changes
description: "A string 'true' or 'false' indicating if there are any changes."
steps:
- name: detect
image: alpine/git:v2.36.1
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
echo "Comparing HEAD with last successful commit: $(params.last-success-commit)"
# In a real scenario, you'd fetch the base branch to ensure the commit is available
git fetch origin $(params.base-branch)
# Check if last-success-commit is empty or doesn't exist, if so, build all
if [ -z "$(params.last-success-commit)" ] || ! git cat-file -e $(params.last-success-commit)^{commit}; then
echo "Last successful commit not found or invalid. Building all packages."
# List all directories under packages/ as potential micro-frontends
PACKAGES=$(find packages -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | jq -R . | jq -s .)
else
# Find files changed between the last successful commit and HEAD
CHANGED_FILES=$(git diff --name-only $(params.last-success-commit)..HEAD)
# A common pitfall is not handling changes in root-level shared files (e.g., pnpm-lock.yaml, tsconfig.json).
# If root files change, we must rebuild everything.
ROOT_CHANGES=$(echo "${CHANGED_FILES}" | grep -v '^packages/' || true)
if [ -n "${ROOT_CHANGES}" ]; then
echo "Root level files changed. Rebuilding all packages."
echo "${ROOT_CHANGES}"
PACKAGES=$(find packages -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | jq -R . | jq -s .)
else
# Filter for changes within the 'packages' directory and extract the package name
PACKAGES=$(echo "${CHANGED_FILES}" | grep '^packages/' | sed -E 's|packages/([^/]+)/.*|\1|' | sort -u | jq -R . | jq -s .)
fi
fi
echo "Changed packages: ${PACKAGES}"
# Output the results for Tekton
if [ "${PACKAGES}" = "[]" ]; then
echo -n "false" | tee $(results.has-changes.path)
else
echo -n "true" | tee $(results.has-changes.path)
fi
echo -n "${PACKAGES}" | tee $(results.changed-packages.path)
这个脚本考虑了一个常见的错误:只检查 packages/ 目录下的变更。在真实项目中,根目录的 pnpm-lock.yaml、tsconfig.json 或共享的工具库发生变化时,需要重新构建所有依赖它的微前端。这里的实现简化为只要根目录有变化就全量构建,更精细的方案需要构建一个完整的依赖图。
Task 2: build-and-deploy
这个任务利用了 Tekton 的 Matrix 功能,它会根据 detect-changes 的输出结果,为每个变更的微前端创建一个并行的执行实例。
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: gatsby-build-and-deploy
spec:
description: Builds a specific Gatsby site and deploys it to a storage bucket.
workspaces:
- name: source
description: The workspace with source code and node_modules.
params:
- name: package-name
type: string
description: The name of the package to build (e.g., 'site-a').
- name: gcs-bucket-name
type: string
description: The name of the GCS bucket to deploy to.
steps:
- name: build
image: node:18-alpine
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
echo "--- Building package: $(params.package-name) ---"
# Navigate to the specific package directory
cd packages/$(params.package-name)
# Run the Gatsby build command
# Gatsby automatically uses .cache and public directories for incremental builds
npm run build
# Error handling: Check if build output exists
if [ ! -d "public" ] || [ -z "$(ls -A public)" ]; then
echo "Error: Build failed, 'public' directory is empty or does not exist."
exit 1
fi
echo "--- Build successful for $(params.package-name) ---"
- name: deploy
image: google/cloud-sdk:slim
workingDir: $(workspaces.source.path)/packages/$(params.package-name)
script: |
#!/bin/sh
set -e
echo "--- Deploying package: $(params.package-name) to gs://$(params.gcs-bucket-name) ---"
# In a production setup, you would use Workload Identity for auth
# gcloud auth activate-service-account --key-file=/path/to/sa-key.json
# Use rsync to upload. The -d flag deletes files in the destination that are not in the source.
# This ensures a clean deployment.
gsutil -m rsync -d -r public/ gs://$(params.gcs-bucket-name)/$(params.package-name)/
echo "--- Deployment successful for $(params.package-name) ---"
串联一切的 Pipeline
现在,我们将上述 Task 组合成一个完整的 Pipeline。这里最核心的部分是 Workspaces 的定义和 Matrix 的使用。
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
name: gatsby-micro-frontends-ci
spec:
description: >-
CI pipeline for a monorepo of Gatsby micro-frontends with
incremental build and caching.
workspaces:
- name: shared-data
description: |
This workspace will be backed by a PVC to store the git repo,
caches, and node_modules across tasks.
params:
- name: repo-url
type: string
- name: repo-revision
type: string
- name: last-success-commit
type: string
- name: gcs-bucket-name
type: string
tasks:
- name: fetch-source
taskRef:
name: git-clone
workspaces:
- name: output
workspace: shared-data
params:
- name: url
value: $(params.repo-url)
- name: revision
value: $(params.repo-revision)
- name: depth
value: "0" # A full clone is needed for git diff
- name: find-changes
runAfter: [fetch-source]
taskRef:
name: detect-changes
workspaces:
- name: source
workspace: shared-data
params:
- name: last-success-commit
value: $(params.last-success-commit)
# Note: Cache restoration/update steps are simplified here for brevity.
# A robust implementation would involve separate tasks to tar/untar cache directories
# to and from a specific location within the PVC to avoid conflicts.
# For this example, we assume direct usage of the workspace.
- name: install-dependencies
runAfter: [find-changes]
taskRef:
name: pnpm-install # Assuming a predefined pnpm task
workspaces:
- name: source
workspace: shared-data
when:
- input: "$(tasks.find-changes.results.has-changes)"
operator: in
values: ["true"]
- name: build-deploy-matrix
runAfter: [install-dependencies]
when:
- input: "$(tasks.find-changes.results.has-changes)"
operator: in
values: ["true"]
taskRef:
name: gatsby-build-and-deploy
workspaces:
- name: source
workspace: shared-data
params:
- name: gcs-bucket-name
value: $(params.gcs-bucket-name)
- name: package-name
value: $(item)
matrix:
params:
- name: item
value: $(tasks.find-changes.results.changed-packages)
这段 Pipeline 定义中,workspaces.shared-data 是关键。当 PipelineRun 被创建时,我们会为其绑定一个 PersistentVolumeClaim。这个 PVC 会在 fetch-source、install-dependencies 和 build-deploy-matrix 任务之间共享,从而实现了状态(源代码、node_modules、缓存)的传递。
matrix.params 的 value 设置为 $(tasks.find-changes.results.changed-packages),这正是 Tekton 强大的地方。它会解析 find-changes 任务输出的 JSON 数组,并为数组中的每个元素启动一个 gatsby-build-and-deploy 任务的实例,这些实例是并行执行的。
运行与缓存策略的现实考量
要让这个系统在生产中稳定运行,还有几个坑需要注意:
PVC 的并发问题:
ReadWriteOnce(RWO) 访问模式的 PVC 在大多数云提供商上只能被一个节点上的 Pod 挂载。如果你的 Tekton Task Pod 被调度到不同节点,并行任务会失败。解决方案是使用ReadWriteMany(RWX) 的存储类(如 NFS 或 GlusterFS),或者确保你的 Kubernetes 集群节点有足够资源,让所有并行任务调度到同一个节点。缓存污染: 如果一个构建任务失败并留下了损坏的缓存文件,它可能会影响后续的流水线运行。一个健壮的
update-cache任务应该在构建完全成功后才执行。甚至可以考虑在每次运行开始时,基于分支名或 commit hash 创建一个唯一的缓存子目录,实现缓存隔离。last-success-commit的管理: 这个值的传递和更新至关重要。一个常见的做法是,在流水线成功结束后,通过一个最终任务(finallytask)将当前的 commit hash 更新到一个 Git Tag 或一个专门的 ConfigMap 中。PipelineRun启动时,再从这个地方读取last-success-commit作为参数。
例如,一个 finally task 来更新 commit tag:
finally:
- name: update-success-tag
taskSpec:
workspaces:
- name: source
params:
- name: git-tag
default: "last-prod-success"
steps:
- name: tag-commit
image: alpine/git:v2.36.1
workingDir: $(workspaces.source.path)
script: |
# Configure git user
git config --global user.email "[email protected]"
git config --global user.name "CI Bot"
# Delete the old tag and create a new one at the current commit
git tag -d $(params.git-tag) || true
git push origin :refs/tags/$(params.git-tag) || true
git tag $(params.git-tag) HEAD
git push origin --tags
这确保了只有在整个流水线(包括所有并行的构建)都成功后,基准 commit 才会更新。
局限性与未来迭代路径
当前这套基于 PVC 的缓存方案虽然有效,但也存在其物理边界。当微前端数量增长到数百个,或者代码库体积巨大时,单个 PVC 可能会成为 I/O 瓶颈。所有并行的构建任务同时读写同一个磁盘,性能会下降。
未来的一个优化方向是引入分布式缓存。可以改造 build-and-deploy 任务,使其在开始时从 S3 或其他对象存储中下载对应包的缓存 tar.gz 包,在构建结束后再将更新后的缓存上传回去。这需要更复杂的脚本逻辑来管理缓存键(例如,基于包名和 pnpm-lock.yaml 的哈希),但它解耦了计算与存储,扩展性更好。
另一个迭代方向是优化变更检测的粒度。当前的实现对于根目录文件的修改采取了一刀切的全量构建策略。可以引入像 Nx 或 Turborepo 这样的 Monorepo 管理工具。在 Tekton 任务内部调用 npx nx affected:apps --base=$(params.last-success-commit) --head=HEAD,可以精确地获取受变更影响的应用依赖图,从而实现更精细化的增量构建,避免不必要的重复工作。这代表了将领域特定的构建逻辑与通用的 CI 平台相结合的演进方向。