构建面向 ASP.NET Core 与 Vue.js SSR 混合应用的 Tekton 高效能 CI/CD 流水线


团队引入 ASP.NET Core 结合 Vue.js SSR 的技术栈后,最初的 Jenkins 流水线很快暴露了问题。单体 Jenkins Agent 承载了 .NET SDK 和 Node.js 两种环境,配置混乱且难以维护。更致命的是,每次构建都需要重新拉取所有的 NuGet 和 npm 依赖,一个完整的构建、测试、打包流程耗时稳定在20分钟以上,这对于追求快速迭代的团队是不可接受的。开发人员的反馈循环被严重拉长,合并代码前的等待成了一种常态。我们需要一个更现代、更高效、更贴近云原生生态的方案。

我们的目标很明确:将构建流程迁移到 Kubernetes 上,利用容器化环境实现干净、隔离的构建步骤,并通过持久化存储实现跨流水线运行的智能缓存,将构建时间压缩到5分钟以内。经过评估,我们选择了 Tekton。它的 Kubernetes 原生设计、声明式 API 以及可复用的 Task 概念,与我们现有的技术基础设施高度契合。

整个改造的核心在于解决两大难题:

  1. 依赖缓存: 如何在无状态的 Pod 构建环境中,高效缓存 NuGet 包和 node_modules,避免每次都从零开始下载?
  2. 工件传递: 前端 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 installnpm 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 --from=publish /app/publish .
# 从前端构建阶段拷贝产物
COPY --from=frontend-builder /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,在推送镜像前进行安全审计,是生产环境中必不可少的一环。


  目录