为 Ruby Fastify 应用构建基于 Tekton 的缓存优化型 GitOps 交付流水线


团队的 Ruby 服务构建速度已经成了一个无法忽视的瓶颈。每次代码合并到主干,CI 流水线都要从零开始执行 bundle install,在拥有上百个 Gem 的项目中,这个过程轻易就能耗掉五到十分钟。接着,docker build 又因为没有有效的层缓存,再次全量构建,整个流程下来,开发人员的注意力早就切换到别的任务上去了。这种上下文切换的成本,日积月累,相当惊人。

我们现有的 CI 工具要么是SaaS服务,缓存机制有限且成本高昂;要么是传统的CI服务器,与我们全面拥抱的 Kubernetes 生态格格不入。问题的核心在于,我们需要一个能够深度利用 Kubernetes 持久化存储,并且与我们 GitOps 理念相契合的云原生 CI/CD 方案。目标很明确:将 Ruby 应用的构建和测试时间缩减80%以上,并将CI流程本身也通过声明式API进行管理。

初步构想是利用 Tekton Pipelines。它作为 Kubernetes 的原生扩展,允许我们将 CI/CD 的每一个步骤定义为独立的、可复用的 Task CRD,然后通过 Pipeline CRD 将它们串联起来。最关键的是,Tekton 的 Workspaces 概念,允许我们将一个 PersistentVolumeClaim (PVC) 挂载到流水线的多个 Task 中。这为实现跨任务、跨流水线运行的持久化缓存(例如 Bundler 的 gems 目录、Kaniko 的镜像层缓存)提供了理论上的可能性。

最终的架构蓝图是这样的:

  1. 开发者向 GitHub 上的应用代码仓库推送代码。
  2. GitHub Webhook 触发 Tekton Triggers,启动一次 PipelineRun
  3. Pipeline 按顺序执行 Tasks:代码克隆 -> 单元测试与Lint -> 构建容器镜像 -> 更新部署清单。
  4. 其中,单元测试构建镜像 两个 Task 将共享同一个 PVC Workspace,分别用于缓存 Gem 包和 Docker 镜像层。
  5. 最后一个 Task 会将新构建的镜像标签更新到另一个 GitHub 仓库中的 Kubernetes 部署清单(YAML文件),并推送回去。
  6. ArgoCD(或类似的 GitOps 工具)会监测到清单仓库的变化,自动将新版本的应用同步到 Kubernetes 集群中。

这个方案将 CI(构建、测试)和 CD(部署)清晰地解耦,CI 的产物是一个容器镜像和一个更新后的 Git commit,而 CD 则完全由 GitOps 工具负责。这完全符合我们的技术理念。

第一步:准备一个可供实践的 Fastify 应用

为了验证这套流程,需要一个不大不小,恰到好处的 Ruby 应用。这里我使用 Fastify,一个轻量级的 Ruby Web 框架,来模拟一个真实的 API 服务。

项目结构如下:

.
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── app.rb
├── config.ru
├── spec/
│   ├── app_spec.rb
│   └── spec_helper.rb
└── .rubocop.yml

Gemfile 是关键,它定义了我们的依赖,也是造成构建缓慢的根源。

# Gemfile
source 'https://rubygems.org'

gem 'fastify'
gem 'puma'

group :development, :test do
  gem 'rspec'
  gem 'rack-test'
  gem 'rubocop'
end

app.rb 是一个简单的 API 服务。

# app.rb
require 'fastify'

# A simple health check endpoint
get '/health' do
  { status: 'ok', timestamp: Time.now.utc.iso8601 }.to_json
end

# An endpoint with some mock logic
get '/api/v1/users/:id' do
  user_id = params['id'].to_i
  if user_id > 0
    { id: user_id, name: "User #{user_id}", email: "user#{user_id}@example.com" }.to_json
  else
    status 400
    { error: 'Invalid user ID' }.to_json
  end
end

接下来是Dockerfile。一个未经优化的 Dockerfile 通常是这样的:

# Dockerfile.bad
FROM ruby:3.1.2-slim

WORKDIR /app

# 这里的COPY指令会因为任何文件变动而失效,导致后续所有步骤缓存失效
COPY . .

# 每次构建都全量安装,非常耗时
RUN bundle config set --local without 'development test' && \
    bundle install --jobs $(nproc)

CMD ["puma", "-C", "config.ru"]

在真实项目中,bundle install 耗时严重。一个常见的优化是利用 Docker 的层缓存机制,将 GemfileGemfile.lock 先复制进去并安装,这样只要这两个文件不变,依赖安装这一层就可以被缓存。

这是优化后的版本,我们将用它来配合 Tekton 的缓存策略。

# Dockerfile
FROM ruby:3.1.2-slim as base
WORKDIR /app
ENV BUNDLE_PATH "/bundle/vendor"
ENV BUNDLE_WITHOUT "development:test"
ENV BUNDLE_JOBS=4

FROM base as builder
RUN apt-get update -qq && apt-get install -y --no-install-recommends build-essential

# 关键优化点:只复制 Gemfile*,只要依赖不变,这一层就会被缓存
COPY Gemfile Gemfile.lock ./
RUN bundle install

# 复制应用代码
COPY . .

FROM base as final
# 从 builder 阶段复制已经安装好的 gems
COPY --from=builder /bundle /bundle
COPY . .

EXPOSE 9292
CMD ["puma", "-p", "9292", "config.ru"]

这个多阶段构建的 Dockerfile 自身已经具备了一定的缓存能力,但它依赖于 Docker daemon 的缓存。在 Kubernetes 的临时 Pod 中运行 CI 时,这种缓存通常是不存在的。Tekton 的 PVC Workspace 正是用来解决这个跨 Pod 的持久化缓存问题。

第二步:定义 Tekton 基础组件与缓存工作空间

在 Kubernetes 集群中安装好 Tekton Pipelines 和 Triggers 后,我们需要做的第一件事就是创建一个 PersistentVolumeClaim,它将作为我们所有缓存数据的载体。

在真实项目中,存储类的选择至关重要。如果你的 Tekton Task Pod 可能被调度到不同的节点上,你需要一个支持 ReadWriteMany (RWX) 访问模式的存储后端,比如 NFS 或者 CephFS。为了演示,我们这里使用一个标准的 ReadWriteOnce (RWO) 的 PVC,它在大多数云厂商的块存储上都能工作,但这隐含了一个前提:所有使用该 PVC 的 Pod 都必须被调度到同一个节点上。

# tekton/01-pvc-cache.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: tekton-ruby-cache-pvc
spec:
  accessModes:
    - ReadWriteOnce # 警告:生产环境多节点集群应考虑 RWX 存储
  resources:
    requests:
      storage: 10Gi

这个 PVC 将被用来存储两个关键目录:

  1. /bundle_cache:用于存放 bundle install 下载的 gems。
  2. /kaniko_cache:用于存放 Kaniko 构建 Docker 镜像时产生的层缓存。

第三步:原子化、可复用的 Tekton Tasks

现在,我们将整个 CI 流程拆解成独立的 Task

Task 1: 克隆代码

这个 Task 使用官方的 git-clone ClusterTask,我们无需自己编写。它负责从 GitHub 拉取应用的最新代码。

Task 2: 代码质量检查与单元测试

这个 Task 是第一个使用我们缓存策略的地方。它会运行 rubocop 进行静态分析和 rspec 运行单元测试。关键在于,它会把 bundle install 的路径指向我们 PVC 挂载的目录。

# tekton/02-task-ruby-lint-test.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: ruby-lint-test
spec:
  description: >-
    This task runs RuboCop for linting and RSpec for unit tests.
    It utilizes a shared workspace to cache Bundler gems.
  workspaces:
    - name: source
      description: The workspace containing the source code.
    - name: bundle-cache
      description: The workspace for caching gems.
  params:
    - name: ruby-image
      description: The ruby docker image to use
      default: "ruby:3.1.2-slim"
  steps:
    - name: setup-dependencies
      image: $(params.ruby-image)
      workingDir: $(workspaces.source.path)
      env:
        # 将 Bundler 的路径指向我们的缓存工作空间
        - name: BUNDLE_PATH
          value: "$(workspaces.bundle-cache.path)/vendor"
      script: |
        #!/bin/sh
        set -e
        echo "----------- Setting up Bundler dependencies -----------"
        # 只有在 Gemfile.lock 更新时,才会真正耗时下载
        bundle config set --local path "$(workspaces.bundle-cache.path)/vendor"
        bundle install --jobs=$(nproc)
        echo "----------- Dependencies are ready -----------"
    - name: lint
      image: $(params.ruby-image)
      workingDir: $(workspaces.source.path)
      env:
        - name: BUNDLE_PATH
          value: "$(workspaces.bundle-cache.path)/vendor"
      script: |
        #!/bin/sh
        set -e
        echo "----------- Running RuboCop -----------"
        bundle exec rubocop
    - name: test
      image: $(params.ruby-image)
      workingDir: $(workspaces.source.path)
      env:
        - name: BUNDLE_PATH
          value: "$(workspaces.bundle-cache.path)/vendor"
      script: |
        #!/bin/sh
        set -e
        echo "----------- Running RSpec tests -----------"
        bundle exec rspec

这里的核心是 BUNDLE_PATH 环境变量。我们强制 Bundler 将所有的 gems 安装到从 PVC 挂载来的 bundle-cache 工作空间。第一次运行时,它会下载所有 gems;但从第二次运行开始,只要 Gemfile.lock 没有变化,bundle install 会立刻完成,因为它发现所有 gems 都已经存在于缓存目录中了。

Task 3: 构建并推送镜像

我们使用 Kaniko 在 Kubernetes 集群内部无特权地构建镜像。这个 Task 同样会利用 PVC 来缓存镜像层,从而极大地加速后续构建。

# tekton/03-task-kaniko-build.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: kaniko-build-push
spec:
  description: >-
    Builds a Docker image using Kaniko and pushes it to a registry.
    It leverages a shared workspace for layer caching.
  workspaces:
    - name: source
      description: The workspace containing the source code and Dockerfile.
    - name: docker-cache
      description: The workspace for caching Kaniko layers.
  params:
    - name: image-url
      description: URL of the image to build and push.
    - name: image-tag
      description: Tag for the image.
      default: "latest"
    - name: dockerfile
      description: Path to the Dockerfile.
      default: "./Dockerfile"
  steps:
    - name: build-and-push
      image: gcr.io/kaniko-project/executor:v1.9.0
      # Kaniko 需要 docker-config secret 才能推送到镜像仓库
      # 这个 secret 需要预先创建好
      volumeMounts:
        - name: docker-config
          mountPath: /kaniko/.docker
      args:
        - "--dockerfile=$(params.dockerfile)"
        - "--context=$(workspaces.source.path)"
        - "--destination=$(params.image-url):$(params.image-tag)"
        # 关键:开启缓存并指定缓存目录到我们的 PVC
        - "--cache=true"
        - "--cache-dir=$(workspaces.docker-cache.path)"
      # 为 Kaniko 配置 docker-config secret
      volumes:
        - name: docker-config
          secret:
            secretName: docker-registry-credentials
            items:
              - key: .dockerconfigjson
                path: config.json

这个 Task 的关键是 --cache=true--cache-dir=$(workspaces.docker-cache.path) 这两个 Kaniko 参数。它们告诉 Kaniko 启用层缓存,并将缓存数据写入我们从 PVC 挂载的目录。当 Dockerfile 的某个基础层没有变化时,Kaniko 会直接从该目录中复用缓存,而不是重新构建。

Task 4: 更新 GitOps 仓库清单

这是实现 GitOps 的闭环步骤。流水线成功构建镜像后,需要将新的镜像标签更新到部署清单中。这个清单存放在另一个独立的 Git 仓库。

# tekton/04-task-gitops-update.yaml
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
  name: gitops-manifest-update
spec:
  description: >-
    Clones a git repository, updates a Kubernetes manifest file with a new image tag,
    and pushes the changes back.
  workspaces:
    - name: manifest-repo
      description: Workspace for cloning the manifest repository.
  params:
    - name: manifest-repo-url
      description: The URL of the manifest git repository.
    - name: new-image-url
      description: The full URL of the new container image.
    - name: manifest-file-path
      description: The path to the manifest file to update (e.g., k8s/deployment.yaml).
    - name: image-placeholder
      description: A placeholder string in the manifest to be replaced (e.g., IMAGE_PLACEHOLDER).
  steps:
    - name: clone-update-push
      image: alpine/git:latest
      workingDir: $(workspaces.manifest-repo.path)
      # git-credentials secret 包含 SSH 私钥用于推送到 GitHub
      volumeMounts:
        - name: ssh-key
          mountPath: /root/.ssh
      script: |
        #!/bin/sh
        set -e

        # 1. 配置 Git
        git config --global user.email "tekton-[email protected]"
        git config --global user.name "Tekton CI Bot"
        chmod 600 /root/.ssh/id_rsa

        # 2. 克隆清单仓库
        git clone $(params.manifest-repo-url) .

        # 3. 更新镜像标签
        # 在真实项目中,使用 yq 或者 kustomize 会更健壮
        # 这里为了简化,使用 sed
        sed -i "s|$(params.image-placeholder)|$(params.new-image-url)|g" $(params.manifest-file-path)

        # 4. 提交并推送
        git add $(params.manifest-file-path)
        # 检查是否有变更,没有变更则直接退出
        if git diff --cached --quiet; then
          echo "No changes to commit."
          exit 0
        fi

        git commit -m "Update image to $(params.new-image-url) [ci-skip]"
        git push origin main
      volumes:
        - name: ssh-key
          secret:
            secretName: git-ssh-credentials
            defaultMode: 0400

一个常见的错误是忘记处理没有变更的情况。如果流水线重跑但镜像内容没有变化(摘要不变),sed 命令执行后文件内容不变,git commit 就会失败。脚本中加入了 git diff --cached --quiet 来优雅地处理这种情况。

第四步:串联 Task,定义 Pipeline

Pipeline 的职责就是将上述所有 Task 按照正确的顺序和依赖关系组织起来,并管理好 Workspace 的共享。

# tekton/05-pipeline-ruby-app.yaml
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: ruby-fastify-app-pipeline
spec:
  description: |
    CI/CD Pipeline for the Ruby Fastify application.
    It lints, tests, builds, and updates the GitOps manifest.
    Leverages shared PVC for Bundler and Kaniko caching.
  workspaces:
    - name: shared-source
      description: Source code shared across tasks.
    - name: shared-cache
      description: Persistent cache for gems and docker layers.
    - name: gitops-repo
      description: Workspace for the GitOps manifest repository.
  params:
    - name: app-repo-url
      description: The URL for the application's git repository.
    - name: app-repo-revision
      description: The git revision for the application code.
      default: "main"
    - name: manifest-repo-url
      description: The URL for the GitOps manifest repository.
    - name: image-repo
      description: The docker image repository (e.g., your-dockerhub-user/ruby-app).
  tasks:
    - name: fetch-source
      taskRef:
        name: git-clone
      workspaces:
        - name: output
          workspace: shared-source
      params:
        - name: url
          value: $(params.app-repo-url)
        - name: revision
          value: $(params.app-repo-revision)

    - name: lint-and-test
      taskRef:
        name: ruby-lint-test
      runAfter: [ "fetch-source" ]
      workspaces:
        - name: source
          workspace: shared-source
        # 将 PVC workspace 的 bundle_cache 子目录挂载给 bundle-cache
        - name: bundle-cache
          workspace: shared-cache
          subPath: bundle_cache

    - name: build-image
      taskRef:
        name: kaniko-build-push
      runAfter: [ "lint-and-test" ]
      workspaces:
        - name: source
          workspace: shared-source
        # 将 PVC workspace 的 kaniko_cache 子目录挂载给 docker-cache
        - name: docker-cache
          workspace: shared-cache
          subPath: kaniko_cache
      params:
        - name: image-url
          value: $(params.image-repo)
        - name: image-tag
          # 使用 Tekton 内置变量生成唯一的 tag
          value: $(tasks.fetch-source.results.commit)

    - name: update-gitops-manifest
      taskRef:
        name: gitops-manifest-update
      runAfter: [ "build-image" ]
      workspaces:
        - name: manifest-repo
          workspace: gitops-repo
      params:
        - name: manifest-repo-url
          value: $(params.manifest-repo-url)
        - name: new-image-url
          value: "$(params.image-repo):$(tasks.fetch-source.results.commit)"
        - name: manifest-file-path
          value: "k8s/deployment.yaml"
        - name: image-placeholder
          value: "IMAGE_PLACEHOLDER"

注意 workspacessubPath 用法。我们将同一个 shared-cache PVC 的不同子目录(bundle_cachekaniko_cache)分别挂载到不同 TaskWorkspace 中,这避免了缓存数据相互干扰,是一个很好的实践。

第五步:设置触发器,自动化一切

最后,我们需要配置 Tekton Triggers,让它监听 GitHub 的 push 事件并自动执行我们的 Pipeline

graph TD
    A[Developer: git push] --> B{GitHub};
    B -- Webhook --> C{Tekton EventListener};
    C -- Extracts Payload --> D[TriggerBinding];
    D -- Creates --> E{TriggerTemplate};
    E -- Instantiates --> F[PipelineRun];
    F -- Executes --> G{Pipeline};
    G -- Runs --> H[Task: git-clone];
    G -- Runs --> I[Task: lint-test];
    G -- Runs --> J[Task: build-image];
    G -- Runs --> K[Task: update-manifest];
    I -- Uses Cache --> L((PVC: /bundle_cache));
    J -- Uses Cache --> M((PVC: /kaniko_cache));
    K -- git push --> N{GitOps Repo};
    N -- Watched by --> O{ArgoCD};
    O -- Syncs --> P[Kubernetes Cluster];

这需要创建三个资源:TriggerBindingTriggerTemplateEventListener

TriggerBinding 负责从 GitHub Webhook 的 JSON payload 中提取所需信息,如仓库 URL 和 commit SHA。

TriggerTemplate 是一个 PipelineRun 的模板,它定义了当触发器被激活时,应该如何创建一个 PipelineRun 实例,并将从 TriggerBinding 提取的值作为参数传递给 Pipeline

EventListener 则是一个 Kubernetes Service,它暴露一个 HTTP 端点来接收 Webhook,并将请求路由到正确的 TriggerBindingTriggerTemplate

配置完成后,整个流程就实现了全自动化。一次 git push 会触发一系列的连锁反应,最终通过 GitOps 的方式安全地部署到生产环境。

首次运行 PipelineRun 时,lint-and-test 步骤的 bundle installbuild-image 步骤的 Kaniko 构建会比较慢,因为它们需要填充缓存。但当我们再次触发流水线时(例如,只修改了 app.rb 的代码,而 Gemfile.lockDockerfile 不变),会看到惊人的性能提升:

  • bundle install 几乎是瞬时完成。
  • Kaniko build 会报告 using cache 并跳过大部分层的构建,只重新构建 COPY . . 之后的那几层。

整个 CI 的执行时间从原来的十几分钟,稳定地缩短到了两分钟以内。

局限性与未来迭代方向

这套方案虽然高效,但在生产环境中应用时,仍有几个需要考量的点。

首先是缓存的存储后端。我们使用的 ReadWriteOnce PVC 意味着缓存是节点绑定的,这在小型或单节点集群中可行,但在大型、动态调度的集群中,流水线的 Pod 可能会落在没有挂载该 PVC 的节点上,导致缓存失效。解决方案是采用支持 ReadWriteMany 的共享存储,如 NFS、GlusterFS 或云厂商提供的文件服务,但这会引入额外的基础设施复杂度和成本。

其次,安全性。用于推送到 GitOps 仓库的 SSH 密钥权限过大。在更严格的安全模型中,应该使用有时效性的 Token,或者配置 GitHub App 来获取更细粒度的权限。同时,对基础镜像和第三方 Gem 的漏洞扫描也应该作为独立的 Task 加入到流水线中。

最后,Tekton 的 YAML 定义相对冗长。虽然它的声明式和可复用性是优点,但对于庞大的项目,维护大量的 TaskPipeline YAML 文件本身也是一种负担。可以考虑使用 Jsonnet 或 CUE 等工具来生成 Tekton YAML,以减少模板代码和重复配置。

未来的优化路径可以探索使用 Tekton Hub 上更多社区维护的 Task 来简化流水线定义,并引入更复杂的发布策略,例如通过修改 update-gitops-manifest 任务来支持金丝雀发布,动态调整 Flagger 或 Argo Rollouts 的配置。


  目录