团队引入 ASP.NET Core 结合 Vue.js SSR 的技术栈后,最初的 Jenkins 流水线很快暴露了问题。单体 Jenkins Agent 承载了 .NET SDK 和 Node.js 两种环境,配置混乱且难以维护。更致命的是,每次构建都需要重新拉取所有的 NuGet 和 npm 依赖,一个完整的构建、测试、打包流程耗时稳定在20分钟以上,这对于追求快速迭代的团队是不可接受的。开发人员的反馈循环被严重拉长,合并代码前的等待成了一种常态。我们需要一个更现代、更高效、更贴近云原生生态的方案。
我们的目标很明确:将构建流程迁移到 Kubernetes 上,利用容器化环境实现干净、隔离的构建步骤,并通过持久化存储实现跨流水线运行的智能缓存,将构建时间压缩到5分钟以内。经过评估,我们选择了 Tekton。它的 Kubernetes 原生设计、声明式 API 以及可复用的 Task
概念,与我们现有的技术基础设施高度契合。
整个改造的核心在于解决两大难题:
- 依赖缓存: 如何在无状态的 Pod 构建环境中,高效缓存 NuGet 包和
node_modules
,避免每次都从零开始下载? - 工件传递: 前端 Vue SSR 构建产生的静态资源,如何无缝、高效地传递给后续的 ASP.NET Core 项目进行打包,最终生成一个独立的、可部署的容器镜像?
最初的构想是为流水线的所有步骤挂载一个共享的 PersistentVolumeClaim
(PVC)。这个 PVC 将作为我们的工作空间和缓存层。
# pvc-for-pipeline.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tekton-shared-workspace-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi # 根据项目依赖规模调整
在真实项目中,storageClassName
需要根据你的 Kubernetes 环境(如 ceph-rbd, aws-ebs)来指定。ReadWriteOnce
意味着这个卷只能被单个节点挂载,这在多节点的 K8s 集群中是一个需要注意的约束,但对于单个流水线实例内的串行任务是足够的。
第一步:解构构建流程为独立的 Tekton Task
我们将整个流程拆分为四个原子化的 Task
:克隆代码、构建前端、构建并测试后端、构建并推送镜像。这种拆分使得每个步骤都可以独立测试和复用。
Task 1: 克隆源码 (git-clone
)
这是一个标准任务,Tekton Catalog 中有现成的实现。我们将其直接引入。它的作用是将指定分支或提交的代码拉取到工作空间中。
Task 2: 构建 Vue.js SSR 前端 (vue-build
)
这是第一个挑战。我们需要一个包含 Node.js 环境的容器,执行 npm install
和 npm run build:ssr
。这里的关键在于如何利用 PVC 加速 npm install
。
一个常见的错误是直接将整个源码目录挂载到 PVC,这会导致性能问题和状态混乱。正确的做法是,只将缓存目录(node_modules
)和构建产物目录映射到持久化卷支持的路径上,或者在运行时动态处理。我们的策略是,在工作空间内创建一个专门的 .npm
目录用于 npm
缓存,并利用 npm
的 --cache
参数。
# task-vue-build.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: vue-build
spec:
workspaces:
- name: source
description: The workspace containing the source code and build artifacts.
params:
- name: project-dir
description: The subdirectory in the source workspace where the Vue.js project is located.
type: string
default: "ClientApp"
steps:
- name: npm-install
image: node:18-alpine
workingDir: $(workspaces.source.path)/$(params.project-dir)
script: |
#!/bin/sh
set -e
echo "## Starting npm install with caching ##"
# 设置npm缓存目录到工作区的一个稳定路径下
# 这样即使用户源码目录结构变化,缓存路径依然有效
npm config set cache $(workspaces.source.path)/.npm --global
npm install
- name: npm-build
image: node:18-alpine
workingDir: $(workspaces.source.path)/$(params.project-dir)
script: |
#!/bin/sh
set -e
echo "## Building Vue.js SSR application ##"
# 确保缓存配置在这一步也生效,尽管主要收益在install
npm config set cache $(workspaces.source.path)/.npm --global
# build:ssr 脚本通常会执行 vue-cli-service build --mode production
# 并生成用于服务端渲染的 bundle 和 manifest 文件
npm run build:ssr
这里的 workspaces.source.path
会被 Tekton 动态替换为挂载的 PVC 路径。第一次运行时,npm install
会比较慢,但它会填充 $(workspaces.source.path)/.npm
目录。后续的 PipelineRun
将复用这个缓存,npm install
的速度会从几分钟缩短到十几秒,因为它只需验证依赖树的完整性。构建产物(通常是 dist
目录)会直接输出到工作空间 $(workspaces.source.path)/$(params.project-dir)/dist
,供后续步骤使用。
Task 3: 构建与测试 ASP.NET Core 后端 (dotnet-build-test
)
这个 Task
消费上一步的产物。ASP.NET Core 项目配置通常会将 Vue 的构建输出作为其静态文件的一部分。
// Program.cs or Startup.cs in ASP.NET Core
// ...
// Vue的构建产物目录通常是 ClientApp/dist
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Combine(builder.Environment.ContentRootPath, "ClientApp/dist")),
RequestPath = ""
});
// ...
dotnet-build-test
Task 的实现与 vue-build
类似,也需要解决缓存问题,这次是 NuGet 包缓存。
# task-dotnet-build-test.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: dotnet-build-test
spec:
workspaces:
- name: source
description: The workspace containing the source code.
params:
- name: project-path
description: Path to the .csproj file to build.
type: string
default: "YourProject.csproj"
steps:
- name: dotnet-restore
image: mcr.microsoft.com/dotnet/sdk:7.0
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
echo "## Restoring NuGet packages with caching ##"
# 将NuGet的全局包文件夹重定向到工作空间中的.nuget目录
dotnet restore $(params.project-path) --configfile NuGet.config
# 我们建议使用 NuGet.config 来明确指定包源和缓存位置
# NuGet.config content:
# <?xml version="1.0" encoding="utf-8"?>
# <configuration>
# <config>
# <add key="globalPackagesFolder" value="./.nuget/packages" />
# </config>
# ...
# </configuration>
- name: dotnet-build
image: mcr.microsoft.com/dotnet/sdk:7.0
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
echo "## Building the application ##"
# 确保 restore 步骤已经完成
dotnet build $(params.project-path) --configuration Release --no-restore
- name: dotnet-test
image: mcr.microsoft.com/dotnet/sdk:7.0
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
echo "## Running unit tests ##"
# 假设测试项目在 Test/ 目录下
dotnet test --configuration Release --no-build --logger "trx;LogFileName=test_results.trx"
通过在项目根目录放置一个 NuGet.config
文件,并将 globalPackagesFolder
指向工作空间内的一个相对路径(如 ./.nuget/packages
),我们就能将 NuGet 缓存持久化到 PVC 中。这极大地加快了 dotnet restore
步骤。
Task 4: 构建容器镜像并推送 (build-and-push
)
构建镜像的环节,我们放弃了传统的 Docker-in-Docker 方案,因为它存在安全风险和性能瓶颈。Kaniko 是一个更优的选择,它能在非特权容器中、完全在用户空间内构建 Docker 镜像,非常适合在 Kubernetes 环境中使用。
这里的核心是编写一个高效的、多阶段的 Dockerfile
。
# Dockerfile
# ---- Stage 1: Build Vue.js Frontend ----
FROM node:18-alpine AS frontend-builder
WORKDIR /app/ClientApp
COPY ClientApp/package.json ClientApp/package-lock.json ./
RUN npm install
COPY ClientApp/ .
RUN npm run build:ssr
# ---- Stage 2: Build ASP.NET Core Backend ----
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS backend-builder
WORKDIR /src
COPY ["YourProject.csproj", "."]
# 仅恢复依赖,利用Docker层缓存
RUN dotnet restore "./YourProject.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "YourProject.csproj" -c Release -o /app/build
# ---- Stage 3: Publish ----
FROM backend-builder AS publish
RUN dotnet publish "YourProject.csproj" -c Release -o /app/publish /p:UseAppHost=false
# ---- Stage 4: Final Production Image ----
FROM mcr.microsoft.com/dotnet/aspnet:7.0
WORKDIR /app
COPY /app/publish .
# 从前端构建阶段拷贝产物
COPY /app/ClientApp/dist ./ClientApp/dist
# 设置环境变量,例如监听的端口和环境
ENV ASPNETCORE_URLS=http://+:80
ENV ASPNETCORE_ENVIRONMENT=Production
ENTRYPOINT ["dotnet", "YourProject.dll"]
这个 Dockerfile
在 Tekton 流水线中是冗余的,因为我们已经在前面的步骤中完成了构建和测试。在 Tekton 流程中,我们应该使用一个更精简的 Dockerfile
,它只负责组装已经构建好的工件。
为 Tekton 优化的 Dockerfile:
# Dockerfile.tekton
FROM mcr.microsoft.com/dotnet/aspnet:7.0
WORKDIR /app
# 假设流水线已经将发布产物放在 publish/ 目录下
COPY publish/ .
# 设置环境变量
ENV ASPNETCORE_URLS=http://+:80
ENV ASPNETCORE_ENVIRONMENT=Production
ENTRYPOINT ["dotnet", "YourProject.dll"]
在 dotnet-build-test
任务中,我们需要增加一步 dotnet publish
,并将结果输出到工作空间的一个明确目录,例如 $(workspaces.source.path)/publish
。
# ... (in dotnet-build-test task)
- name: dotnet-publish
image: mcr.microsoft.com/dotnet/sdk:7.0
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
echo "## Publishing the application ##"
# --no-build 因为之前已经构建过了
# 输出到工作空间的 publish 目录
dotnet publish $(params.project-path) -c Release -o $(workspaces.source.path)/publish --no-build
现在,build-and-push
任务可以专注于使用 Kaniko 构建这个精简的 Dockerfile.tekton
。
# task-kaniko-build-push.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: kaniko-build-push
spec:
params:
- name: IMAGE
description: Name of the image to build and push
- name: DOCKERFILE
description: Path to the Dockerfile to build
default: ./Dockerfile.tekton
workspaces:
- name: source
description: Holds the context and Dockerfile
- name: dockerconfig
description: Includes a docker `config.json`
optional: true
mountPath: /kaniko/.docker
steps:
- name: build-and-push
image: gcr.io/kaniko-project/executor:v1.9.1
# kaniko 在非root用户下运行时需要 --force
args:
- --dockerfile=$(params.DOCKERFILE)
- --context=$(workspaces.source.path)
- --destination=$(params.IMAGE)
- --force
# Tekton会自动处理workspace的挂载,所以context路径是正确的
这里的 dockerconfig
工作空间用于挂载包含仓库认证信息的 config.json
,通常通过 Kubernetes Secret
来提供。
最终章:串联一切的 Pipeline
现在,我们将所有 Task
组合成一个完整的 Pipeline
。
graph TD A[start] --> B(git-clone); B --> C(vue-build); C --> D(dotnet-build-test); D --> E(kaniko-build-push); E --> F[end];
# pipeline.yaml
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
name: aspnet-vue-ssr-pipeline
spec:
params:
- name: repo-url
type: string
- name: revision
type: string
default: "main"
- name: image-url
type: string
workspaces:
- name: shared-data
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.revision)
- name: build-frontend
taskRef:
name: vue-build
runAfter: [ "fetch-source" ]
workspaces:
- name: source
workspace: shared-data
params:
- name: project-dir
value: "ClientApp"
- name: build-and-test-backend
taskRef:
name: dotnet-build-test
runAfter: [ "build-frontend" ]
workspaces:
- name: source
workspace: shared-data
params:
- name: project-path
value: "YourProject.csproj"
- name: build-and-push-image
taskRef:
name: kaniko-build-push
runAfter: [ "build-and-test-backend" ]
workspaces:
- name: source
workspace: shared-data
# 假设我们创建了一个名为 'regcred' 的 docker-registry类型的secret
- name: dockerconfig
workspace: docker-secret-ws
params:
- name: IMAGE
value: $(params.image-url)
- name: DOCKERFILE
value: ./Dockerfile.tekton
最后,通过创建一个 PipelineRun
来触发执行。
# pipelinerun.yaml
apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
generateName: aspnet-vue-ssr-run-
spec:
pipelineRef:
name: aspnet-vue-ssr-pipeline
params:
- name: repo-url
value: "https://github.com/your-org/your-repo.git"
- name: revision
value: "feature/new-logic"
- name: image-url
value: "your-registry/your-app:v1.2.3"
workspaces:
- name: shared-data
persistentVolumeClaim:
claimName: tekton-shared-workspace-pvc
- name: docker-secret-ws
secret:
secretName: regcred
通过这个 PipelineRun
,我们成功地将代码从 Git 仓库,经过前端构建、后端构建与测试,最终打包成一个优化过的 Docker 镜像并推送到镜像仓库。得益于 PVC 缓存,后续运行的平均时间从20多分钟锐减到了4分钟左右,极大地提升了开发和交付效率。
局限性与未来迭代方向
当前这套方案并非完美。ReadWriteOnce
的 PVC 意味着在多节点集群上,Tekton Pod 必须被调度到同一个节点才能复用缓存,这在规模化场景下是个瓶颈。一种改进是使用 ReadWriteMany
的存储(如 NFS, GlusterFS),但这会引入新的复杂性。更高级的方案是使用分布式缓存工具,如 S3 存储桶或专用的缓存服务,在每个任务的开始和结束阶段进行缓存的拉取和推送,这能彻底解决节点亲和性问题,但会增加网络 I/O 开销。
此外,安全性方面,流水线中的每一步都应该以最小权限原则运行。例如,镜像推送任务只应在流水线末端执行,并使用临时的、有时效性的凭证。集成诸如 Trivy 或 Snyk 的漏洞扫描 Task
,在推送镜像前进行安全审计,是生产环境中必不可少的一环。