团队内部的CI/CD流程最初依赖于一套散乱的Jenkins Job和共享的Shell脚本。这种方式在项目初期还能勉强维持,但随着微服务数量的增长,问题开始集中爆发:构建环境不一致导致“在我机器上是好的”问题频发;共享的Docker守护进程成为性能瓶颈和安全隐患;脚本的维护成本呈指数级上升。我们必须构建一个统一的、API驱动的、对开发者友好的内部构建平台来解决这个烂摊子。
初步构想与技术选型决策
我们的目标不是再造一个Jenkins或GitLab CI,而是构建一个高度定制化的“部署引擎”,它作为内部开发者平台(IDP)的核心模块。这个引擎需要满足几个关键需求:
- 声明式API: 开发者通过简单的YAML或JSON定义构建流程,而非编写脚本。
- 高可用元数据存储: 存储流水线定义、构建历史、制品信息。这个存储本身不能成为单点故障。
- 隔离与安全的构建环境: 每个构建任务必须在完全隔离的环境中执行,杜绝交叉污染,并且不能依赖特权级的Docker守护进程。
- 可扩展的前端界面: 平台UI需要分模块迭代,例如流水线编辑器、日志查看器、制品库浏览器等,不同团队可以独立开发和部署。
基于这些需求,我们的技术选型决策过程很直接:
后端语言: Scala
我们团队是Scala技术栈,利用Akka HTTP构建异步、高吞吐的API服务是自然选择。Akka Streams的背压机制非常适合处理构建日志这种流式数据。元数据数据库: CockroachDB
最初考虑过PostgreSQL,但在高可用性上需要依赖Patroni等外部组件,增加了运维复杂性。CockroachDB作为一个分布式SQL数据库,天然具备高可用、数据分片和在线扩容的能力。对于一个旨在提升平台稳定性的项目,数据库自身的韧性至关重要。我们可以像使用单机PostgreSQL一样使用它,却获得了分布式系统的弹性。容器构建工具: Buildah
在真实项目中,共享的docker.sock
是安全团队的噩梦。我们评估了Kaniko和Buildah。Kaniko在非特权容器中构建镜像,但其快照机制在处理复杂Dockerfile时有时性能不佳。Buildah则更像是一系列底层工具的集合 (buildah bud
,buildah from
,buildah push
),它不依赖任何守护进程,可以直接在容器内以普通进程方式运行,这给了我们极大的灵活性和安全性。它也更适合脚本化,完美契合我们API驱动的后端。前端架构: Micro-frontends (基于Webpack Module Federation)
一个庞大的单体前端会很快成为交付瓶颈。我们决定从一开始就采用微前端架构。Module Federation允许不同应用(微前端)在运行时动态共享模块。这意味着我们的平台“外壳”可以动态加载由不同团队维护的“日志查看器”或“安全扫描报告”等功能模块,实现真正的独立部署。
核心实现:从API到构建执行
我们的部署引擎核心流程可以简化为:用户通过微前端UI触发构建 -> Scala后端接收请求,在CockroachDB中创建一条构建记录 -> 后端动态生成一个Kubernetes Job来执行Buildah命令 -> 后端通过K8s API实时将构建日志流式传输回前端 -> Job完成后更新CockroachDB中的状态。
1. CockroachDB的数据模型与Scala服务层
首先是元数据存储。我们设计了两张核心表:pipelines
用于存储流水线定义,builds
用于记录每次构建的执行情况。
-- 在 CockroachDB 中创建表
-- 使用 SERIAL 作为主键,CockroachDB 会自动将其处理为全局唯一的64位整数
CREATE TABLE pipelines (
id SERIAL PRIMARY KEY,
project_name VARCHAR(255) NOT NULL,
definition JSONB NOT NULL, -- 存储声明式的构建步骤,例如Dockerfile路径、目标镜像仓库等
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (project_name)
);
CREATE TABLE builds (
id SERIAL PRIMARY KEY,
pipeline_id INT REFERENCES pipelines(id) ON DELETE CASCADE,
build_uuid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), -- 用于外部追踪的唯一ID
status VARCHAR(50) NOT NULL, -- e.g., PENDING, RUNNING, SUCCEEDED, FAILED
triggered_by VARCHAR(255),
logs TEXT, -- 仅用于存储构建失败后的少量关键日志,完整日志应存储在对象存储中
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 为频繁查询的字段创建索引
CREATE INDEX ON builds (pipeline_id, created_at DESC);
CREATE INDEX ON builds (status);
在Scala中,我们使用doobie
库来与CockroachDB进行类型安全的交互。这里的坑在于,必须正确配置JDBC驱动以适应CockroachDB的事务重试逻辑。CockroachDB在遇到事务冲突时会要求客户端重试,一个常见的错误是忽略了这一点,导致在高并发下出现零星的事务失败。
import doobie._
import doobie.implicits._
import doobie.postgres.implicits._
import cats.effect.IO
import java.util.UUID
// 定义数据模型
case class Pipeline(id: Long, projectName: String, definition: io.circe.Json)
case class Build(id: Long, pipelineId: Long, buildUuid: UUID, status: String)
// 数据库交互层
class BuildRepository(transactor: Transactor[IO]) {
// CockroachDB 推荐的事务包装,包含重试逻辑
private def runTransaction[A](ioa: ConnectionIO[A]): IO[A] = {
// 这是一个简化的重试逻辑,生产环境应使用更完善的策略,如指数退避
def retry(attempts: Int): IO[A] = {
transactor.transact(ioa).handleErrorWith {
case e: java.sql.SQLException if e.getSQLState == "40001" && attempts > 0 =>
// 40001 是 CockroachDB 的事务重试错误码
println(s"Transaction conflict detected, retrying... ($attempts attempts left)")
IO.sleep(scala.concurrent.duration.Duration(200, "millis")) *> retry(attempts - 1)
case e => IO.raiseError(e)
}
}
retry(5) // 最多重试5次
}
def createBuild(pipelineId: Long, triggeredBy: String): IO[Build] = {
val status = "PENDING"
val query =
sql"""
INSERT INTO builds (pipeline_id, triggered_by, status)
VALUES ($pipelineId, $triggeredBy, $status)
RETURNING id, pipeline_id, build_uuid, status
""".query[Build].unique
runTransaction(query)
}
def updateBuildStatus(buildUuid: UUID, newStatus: String): IO[Int] = {
val query =
sql"""
UPDATE builds
SET status = $newStatus, finished_at = CASE WHEN $newStatus IN ('SUCCEEDED', 'FAILED') THEN now() ELSE finished_at END
WHERE build_uuid = $buildUuid
""".update.run
runTransaction(query)
}
}
这段代码的核心是runTransaction
函数,它封装了doobie
的transactor.transact
并捕获了特定的SQLState 40001
。这是在任何与CockroachDB交互的应用中都必须处理的关键点。
2. Scala后端与Buildah的集成
我们选择通过创建Kubernetes Job来执行Buildah。这种方式提供了极佳的隔离性,并且可以利用K8s的调度和资源管理能力。Scala后端使用官方的Kubernetes Java客户端来与K8s API交互。
一个关键的实现细节是,我们需要将源码注入到Buildah的执行环境中。我们通过创建一个包含源码的ConfigMap或使用Git-Sync边车容器来实现。下面是Scala代码动态生成一个Job YAML的简化示例:
import io.fabric8.kubernetes.client.DefaultKubernetesClient
import io.fabric8.kubernetes.api.model.batch.v1.{Job, JobBuilder}
import java.util.UUID
class BuildScheduler(k8sClient: DefaultKubernetesClient) {
def scheduleBuild(buildUuid: UUID, sourceCodeVolume: String, dockerfilePath: String, targetImage: String): IO[Job] = IO {
val jobName = s"buildah-job-${buildUuid.toString.take(8)}"
val namespace = "build-namespace"
// 定义Buildah容器,它将执行构建和推送命令
// 这里的镜像是我们预先构建好的,包含了Buildah和认证凭证
val buildahContainer = new io.fabric8.kubernetes.api.model.ContainerBuilder()
.withName("buildah-runner")
.withImage("our-registry/buildah-runner:latest")
.withCommand("sh", "-c")
.withArgs(
s"""
set -ex
# /workspace 包含了通过 volumeMounts 挂载的源码
# buildah bud (build using Dockerfile)
buildah bud --storage-driver vfs -f $dockerfilePath -t $targetImage /workspace
# 登录到镜像仓库,凭证已预先配置在镜像或secret中
buildah login -u service-account -p \$REGISTRY_PASSWORD our-registry
# 推送镜像
buildah push --storage-driver vfs $targetImage
"""
)
.withVolumeMounts(
new io.fabric8.kubernetes.api.model.VolumeMountBuilder()
.withName("source-code")
.withMountPath("/workspace")
.build()
)
.build()
// 定义Job
val job = new JobBuilder()
.withApiVersion("batch/v1")
.withNewMetadata()
.withName(jobName)
.withNamespace(namespace)
.withLabels(java.util.Collections.singletonMap("build-uuid", buildUuid.toString))
.endMetadata()
.withNewSpec()
.withTemplate(
new io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder()
.withNewSpec()
.withContainers(buildahContainer)
.withVolumes(
new io.fabric8.kubernetes.api.model.VolumeBuilder()
.withName("source-code")
// 这里假设源码已经通过某种方式(例如 PVC)准备好了
.withNewPersistentVolumeClaim(sourceCodeVolume, false)
.build()
)
.withRestartPolicy("Never")
.endSpec()
.build()
)
.withBackoffLimit(0) // 不重试,失败由我们的平台逻辑处理
.endSpec()
.build()
// 通过K8s客户端创建Job
k8sClient.batch().v1().jobs().inNamespace(namespace).create(job)
}
}
日志流的处理则更为复杂。我们需要监听Job创建的Pod,并从该Pod中获取日志流。Akka Streams是处理这个场景的利器,我们可以创建一个Source
来不断地从K8s API拉取日志,并通过WebSocket或Server-Sent Events推送到前端。
3. 微前端的动态加载与通信
前端的复杂度在于如何将各个独立开发的功能模块无缝地集成在一起。Webpack 5的Module Federation是解决这个问题的关键。
我们的主应用 (Host Application) 负责提供导航、用户认证等外壳功能。它的Webpack配置如下:
// host-app/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
// ... 其他配置
plugins: [
new ModuleFederationPlugin({
name: 'hostApp',
remotes: {
// 定义远程微前端的入口
// 'logViewer' 是别名,URL是远程微前端部署后 remoteEntry.js 的地址
logViewer: 'logViewer@http://localhost:3001/remoteEntry.js',
},
shared: {
// 共享依赖,避免重复加载
...deps,
react: { singleton: true, requiredVersion: deps.react },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
},
}),
],
};
日志查看器微前端 (Remote Application) 则会暴露自己的组件:
// log-viewer-mfe/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
// ... 其他配置
plugins: [
new ModuleFederationPlugin({
name: 'logViewer',
filename: 'remoteEntry.js',
exposes: {
// 暴露 './src/LogStream' 模块,别名为 LogViewerComponent
'./LogViewerComponent': './src/LogStream',
},
shared: { /* ... 共享依赖配置 ... */ },
}),
],
};
在主应用中,我们可以像加载本地组件一样动态加载这个远程组件:
// host-app/src/BuildDetailPage.jsx
import React, { Suspense } from 'react';
// 'logViewer/LogViewerComponent' 对应 webpack 配置中的 remote 和 expose
const RemoteLogViewer = React.lazy(() => import('logViewer/LogViewerComponent'));
const BuildDetailPage = ({ buildId }) => {
return (
<div>
<h1>Build Details for: {buildId}</h1>
<Suspense fallback={<div>Loading Log Viewer...</div>}>
<RemoteLogViewer buildId={buildId} />
</Suspense>
</div>
);
};
这个架构的强大之处在于,log-viewer-mfe
团队可以独立开发、测试和部署他们的应用。只要remoteEntry.js
的地址不变,主应用就能无缝地加载最新版本,实现了真正的技术和团队解耦。
最终架构概览
下图展示了整个系统的数据流和组件交互。
graph TD subgraph Browser A[User] --> B{Micro-frontend Shell}; B --> C[Log Viewer MFE]; end subgraph Backend Platform D[Scala API Server / Akka HTTP] end subgraph K8s Cluster F[K8s API Server] G(Buildah Job Pod) end subgraph Data Store E[CockroachDB Cluster] end B -- 1. Trigger Build (REST API) --> D; C -- 2. Subscribe to Logs (WebSocket) --> D; D -- 3. Create Build Record --> E; D -- 4. Create K8s Job --> F; F -- 5. Schedules Pod --> G; G -- 6. Pulls Source & Runs Buildah --> H[Image Registry]; D -- 7. Stream Pod Logs --> F; F -- 8. Forwards Logs --> D; D -- 9. Push Logs --> C; G -- 10. On Completion --> F; D -- 11. Watch Job Status --> F; D -- 12. Update Build Record --> E; style G fill:#f9f,stroke:#333,stroke-width:2px
这套系统上线后,极大地改善了开发体验。构建过程变得透明、可重复且安全。开发者只需关心他们的Dockerfile,而无需与底层的基础设施打交道。
局限性与未来迭代方向
当前这套方案并非没有缺点。一个主要考量是安全性,尽管Buildah本身是daemonless的,但在K8s Job中运行它,我们仍然需要小心处理权限问题。下一步是全面切换到Rootless模式下的Buildah,并结合更细粒度的Pod Security Admission,进一步收紧安全边界。
其次,对于大规模的构建集群,每次构建都创建一个K8s Job可能会对API Server造成不小的压力。更成熟的方案是引入像Tekton或Argo Workflows这样的云原生CI/CD引擎,我们的平台则退居上层,负责任务的编排和元数据管理,将具体的执行下沉到这些专业工具中。
最后,微前端架构虽然灵活,但也引入了治理的复杂性。共享依赖的版本管理、跨应用的UI一致性、以及端到端的调试都需要建立完善的规范和工具链支持。这也是我们接下来需要投入精力建设的方向。