团队的 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 的镜像层缓存)提供了理论上的可能性。
最终的架构蓝图是这样的:
- 开发者向 GitHub 上的应用代码仓库推送代码。
- GitHub Webhook 触发 Tekton Triggers,启动一次
PipelineRun
。 -
Pipeline
按顺序执行Tasks
:代码克隆 -> 单元测试与Lint -> 构建容器镜像 -> 更新部署清单。 - 其中,
单元测试
和构建镜像
两个Task
将共享同一个 PVCWorkspace
,分别用于缓存 Gem 包和 Docker 镜像层。 - 最后一个
Task
会将新构建的镜像标签更新到另一个 GitHub 仓库中的 Kubernetes 部署清单(YAML文件),并推送回去。 - 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 的层缓存机制,将 Gemfile
和 Gemfile.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 /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 将被用来存储两个关键目录:
-
/bundle_cache
:用于存放bundle install
下载的 gems。 -
/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"
注意 workspaces
的 subPath
用法。我们将同一个 shared-cache
PVC 的不同子目录(bundle_cache
和 kaniko_cache
)分别挂载到不同 Task
的 Workspace
中,这避免了缓存数据相互干扰,是一个很好的实践。
第五步:设置触发器,自动化一切
最后,我们需要配置 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];
这需要创建三个资源:TriggerBinding
、TriggerTemplate
和 EventListener
。
TriggerBinding
负责从 GitHub Webhook 的 JSON payload 中提取所需信息,如仓库 URL 和 commit SHA。
TriggerTemplate
是一个 PipelineRun
的模板,它定义了当触发器被激活时,应该如何创建一个 PipelineRun
实例,并将从 TriggerBinding
提取的值作为参数传递给 Pipeline
。
EventListener
则是一个 Kubernetes Service,它暴露一个 HTTP 端点来接收 Webhook,并将请求路由到正确的 TriggerBinding
和 TriggerTemplate
。
配置完成后,整个流程就实现了全自动化。一次 git push
会触发一系列的连锁反应,最终通过 GitOps 的方式安全地部署到生产环境。
首次运行 PipelineRun
时,lint-and-test
步骤的 bundle install
和 build-image
步骤的 Kaniko 构建会比较慢,因为它们需要填充缓存。但当我们再次触发流水线时(例如,只修改了 app.rb
的代码,而 Gemfile.lock
和 Dockerfile
不变),会看到惊人的性能提升:
-
bundle install
几乎是瞬时完成。 - Kaniko build 会报告
using cache
并跳过大部分层的构建,只重新构建COPY . .
之后的那几层。
整个 CI 的执行时间从原来的十几分钟,稳定地缩短到了两分钟以内。
局限性与未来迭代方向
这套方案虽然高效,但在生产环境中应用时,仍有几个需要考量的点。
首先是缓存的存储后端。我们使用的 ReadWriteOnce
PVC 意味着缓存是节点绑定的,这在小型或单节点集群中可行,但在大型、动态调度的集群中,流水线的 Pod 可能会落在没有挂载该 PVC 的节点上,导致缓存失效。解决方案是采用支持 ReadWriteMany
的共享存储,如 NFS、GlusterFS 或云厂商提供的文件服务,但这会引入额外的基础设施复杂度和成本。
其次,安全性。用于推送到 GitOps 仓库的 SSH 密钥权限过大。在更严格的安全模型中,应该使用有时效性的 Token,或者配置 GitHub App 来获取更细粒度的权限。同时,对基础镜像和第三方 Gem 的漏洞扫描也应该作为独立的 Task
加入到流水线中。
最后,Tekton 的 YAML 定义相对冗长。虽然它的声明式和可复用性是优点,但对于庞大的项目,维护大量的 Task
和 Pipeline
YAML 文件本身也是一种负担。可以考虑使用 Jsonnet 或 CUE 等工具来生成 Tekton YAML,以减少模板代码和重复配置。
未来的优化路径可以探索使用 Tekton Hub 上更多社区维护的 Task
来简化流水线定义,并引入更复杂的发布策略,例如通过修改 update-gitops-manifest
任务来支持金丝雀发布,动态调整 Flagger 或 Argo Rollouts 的配置。