基于 Kubernetes 和 ScyllaDB 构建前端工具链的分布式持久化构建缓存系统


问题的定义:规模化下的前端构建瓶颈

在一个拥有数百名前端开发者和数千个微前端组件的组织中,CI/CD流水线和本地开发环境的构建效率成为一个无法回避的瓶颈。以Sass/SCSS编译为例,尽管单个任务耗时不长,但在每日数万次的执行中,重复的计算、IO和网络开销累积成巨大的时间和资源浪费。本地开发机器的风扇狂转,CI runner队列长时间阻塞,本质原因在于我们缺乏一个跨开发者、跨CI作业的共享、高速、持久化的构建缓存层。

一个构建任务,尤其是像Sass编译这种纯函数式的转换(相同的输入永远产生相同的输出),其结果是高度可缓存的。问题的核心在于,如何设计一个缓存系统,它必须同时满足以下几个苛刻的条件:

  1. 极低延迟:缓存检查的耗时必须远低于执行构建本身的耗时,否则缓存毫无意义。这意味着P99延迟需要在毫秒级。
  2. 高吞吐量:系统需要同时处理来自数百个CI作业和开发者本地环境的并发读写请求。
  3. 持久化与规模:缓存需要持久化,并且能够存储TB级别的构建产物(编译后的CSS、SourceMap等),普通内存缓存方案无法满足。
  4. 云原生集成:方案必须与我们现有的Kubernetes生态无缝集成,便于部署、管理和伸缩。

方案A:基于对象存储(如S3/MinIO)的缓存方案

这是最直接的思路。将源文件内容的哈希作为key,将构建产物作为value,存入对象存储。

优势:

  • 实现简单:几乎所有语言都有成熟的SDK。
  • 存储成本低:非常适合存储大量不常变动的大文件。
  • 无限扩展:存储容量几乎没有限制。

劣势:

  • 延迟不可控:对象存储的延迟通常在几十到几百毫秒之间,对于小文件的频繁读写,这个延迟是致命的。一次缓存检查(HEAD Object)可能就耗费了50ms,而一次Sass编译本身可能也只需要200ms。缓存带来的收益被网络延迟严重侵蚀。
  • 元数据操作弱:无法进行复杂的元数据查询,例如“清理所有超过30天未被访问的缓存”。所有逻辑都需要在客户端实现,效率低下。
  • 原子性问题:写入缓存涉及“上传产物”和“更新元数据”两步,缺乏原子性保证,可能导致缓存状态不一致。

在真实项目中,这种方案仅适用于缓存体积巨大(如Docker镜像层)且构建耗时极长(分钟级)的场景。对于前端工具链这种高频、低延迟的场景,它并不合格。

方案B:基于分布式内存缓存(如Redis Cluster)的方案

为了解决延迟问题,自然会想到Redis。

优势:

  • 极致的低延迟:读写操作在亚毫秒级,非常适合作为缓存检查。
  • 丰富的数据结构:可以更灵活地管理元数据。

劣劣:

  • 成本高昂:所有数据必须载入内存,当缓存数据达到TB级别时,内存成本将是天文数字。
  • 存储限制:不适合存储大的二进制产物。将几十KB甚至几MB的CSS文件塞进Redis,会迅速耗尽内存并影响性能。
  • 持久化复杂性:虽然Redis提供RDB和AOF,但在大规模集群下,恢复和维护的复杂度很高,不适合作为唯一的持久化存储。

我们可以设计一个混合方案:元数据存Redis,产物存S3。但这引入了新的架构复杂度和潜在的一致性问题,违背了我们寻求简洁高效解决方案的初衷。

最终选择与理由:ScyllaDB——兼具速度与规模的存储核心

我们的目光最终投向了ScyllaDB。它是一个用C++重写的、与Apache Cassandra API兼容的NoSQL数据库。其核心的Shard-per-Core架构,绕过了内核网络栈,直接操作硬件,提供了接近内存缓存的低延迟和惊人的高吞吐量。

选择ScyllaDB的决定性因素:

  1. 一致的低延迟:ScyllaDB能够在磁盘存储(NVMe SSD)上提供稳定的个位数毫秒级P99延迟。这意味着缓存的checkget操作都将非常快。
  2. 水平扩展能力:其无主(masterless)架构可以轻松扩展到数十甚至上百个节点,线性增加吞吐量和存储容量,完美应对我们组织规模的增长。
  3. 处理混合工作负载:它能同时高效处理小规模、高频次的元数据读写(例如检查哈希是否存在)和大规模的二进制数据读写(存储/读取编译产物),而不会相互干扰。这一点是对象存储和纯内存缓存都无法做到的。
  4. 云原生友好:ScyllaDB Operator的出现极大简化了在Kubernetes上部署、管理和运维ScyllaDB集群的复杂度。

我们的架构将以ScyllaDB为核心,构建一个专用于前端构建的缓存服务。这个服务将通过一个轻量级的Kubernetes Operator进行调度和集成。

核心实现概览

1. 整体架构

graph TD
    subgraph Developer Laptop / CI Runner
        A[Client CLI] -- 1. Calculate source hash --> B{Build Script};
        B -- 2. Query cache --> C[K8s API Server];
        C -- 3. Create BuildCacheJob --> D[Our Custom Operator];
        B -- 7. Get result --> A;
    end

    subgraph Kubernetes Cluster
        D -- 4. Reconcile: Create Pod --> E[Build Pod];
        E -- 5. Read/Write Cache --> F[ScyllaDB Cluster];
        E -- 6. Run sass compiler if cache miss --> E;
    end

    subgraph Storage Layer
        F -- Deployed via ScyllaDB Operator --> F
    end

    style F fill:#f9f,stroke:#333,stroke-width:2px

流程解析:

  1. 客户端(开发者本地或CI脚本)计算源文件(如一个SCSS组件目录)的内容哈希。
  2. 客户端不直接与ScyllaDB通信,而是向Kubernetes提交一个自定义资源BuildCacheJob
  3. 我们自研的Operator监听到BuildCacheJob后,创建一个专用的Pod。
  4. 该Pod内的启动脚本负责与ScyllaDB集群交互,执行缓存检查、读取、写入逻辑。
  5. 如果缓存命中,直接从ScyllaDB拉取产物并返回;如果未命中,则在Pod内执行实际的sass编译命令,然后将结果写入ScyllaDB。

这种设计的关键在于将缓存逻辑封装在受控的、临时的Kubernetes Pod中,确保了环境一致性,并利用Operator模式实现了声明式的作业管理。

2. ScyllaDB数据模型设计

我们需要两张表:一张用于存储元数据,一张用于存储实际的二进制产物。

-- cqlsh -u scylla -p scylla
CREATE KEYSPACE IF NOT EXISTS build_cache
WITH REPLICATION = {
    'class': 'NetworkTopologyStrategy',
    'replication_factor': 3
};

USE build_cache;

-- 表1: 存储元数据映射
-- source_hash 是源文件内容计算出的SHA256哈希,作为分区键,确保请求均匀分布
-- build_tool_version 用于区分不同版本的工具链,避免因工具升级导致缓存污染
-- artifact_hash 是构建产物的SHA256哈希
-- created_at 用于TTL和调试
-- 使用 30 天的TTL (2592000秒) 自动清理旧缓存
CREATE TABLE IF NOT EXISTS artifacts_meta (
    source_hash text,
    build_tool_version text,
    artifact_hash text,
    artifact_size bigint,
    created_at timestamp,
    PRIMARY KEY ((source_hash), build_tool_version)
) WITH default_time_to_live = 2592000
  AND compaction = {'class': 'TimeWindowCompactionStrategy', 'compaction_window_unit': 'DAYS', 'compaction_window_size': 1};


-- 表2: 存储构建产物的二进制内容
-- artifact_hash 作为分区键,因为它是唯一的
-- content 存储二进制数据
-- 使用BLOB类型存储编译后的CSS、JS等文件
CREATE TABLE IF NOT EXISTS artifacts_data (
    artifact_hash text PRIMARY KEY,
    content blob
) WITH default_time_to_live = 2592000
  AND compaction = {'class': 'SizeTieredCompactionStrategy'};

-- 为元数据查询创建索引,虽然在我们的主路径上不常用,但在调试和分析时有用
CREATE INDEX IF NOT EXISTS ON artifacts_meta (artifact_hash);

设计考量:

  • 分区键选择: source_hash 作为 artifacts_meta 的分区键,是因为所有查询都始于源文件,这能确保查询直达单个分区,避免跨节点扫描。artifact_hash 作为 artifacts_data 的分区键,保证了数据存储的唯一性。
  • TTL (Time-To-Live): 我们为缓存设置了30天的自动过期时间。这是管理存储成本和保持缓存相关性的关键机制,避免了手动实现复杂的清理逻辑。
  • 压实策略 (Compaction Strategy): artifacts_meta 使用 TimeWindowCompactionStrategy (TWCS),因为它的数据是时间序列性的,TWCS能高效地合并和删除过期数据。artifacts_data 使用默认的 SizeTieredCompactionStrategy (STCS),适合写入密集型且数据大小不一的场景。

3. Build Pod核心逻辑(Go示例)

这个Go程序将作为Build Podentrypoint。它负责与ScyllaDB交互并执行构建。

package main

import (
	"crypto/sha256"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"github.com/gocql/gocql"
)

const (
	// 从环境变量中获取这些值,由Operator注入
	ScyllaHosts       = "SCYLLA_HOSTS"
	ScyllaKeyspace    = "build_cache"
	SourcePath        = "SOURCE_PATH"
	OutputPath        = "OUTPUT_PATH"
	BuildToolVersion  = "BUILD_TOOL_VERSION"
	BuildCommand      = "BUILD_COMMAND"
)

func main() {
	// 1. 初始化 ScyllaDB session
	hosts := strings.Split(os.Getenv(ScyllaHosts), ",")
	cluster := gocql.NewCluster(hosts...)
	cluster.Keyspace = ScyllaKeyspace
	cluster.Consistency = gocql.Quorum
	cluster.Timeout = 10 * time.Second
	session, err := cluster.CreateSession()
	if err != nil {
		log.Fatalf("Failed to connect to ScyllaDB: %v", err)
	}
	defer session.Close()

	// 2. 计算源文件哈希
	sourceHash, err := calculateDirectoryHash(os.Getenv(SourcePath))
	if err != nil {
		log.Fatalf("Failed to calculate source hash: %v", err)
	}
	log.Printf("Source hash: %s", sourceHash)
	buildToolVersion := os.Getenv(BuildToolVersion)

	// 3. 检查缓存 (Cache Check)
	var artifactHash string
	var artifactSize int64
	query := `SELECT artifact_hash, artifact_size FROM artifacts_meta WHERE source_hash = ? AND build_tool_version = ? LIMIT 1`
	if err := session.Query(query, sourceHash, buildToolVersion).Scan(&artifactHash, &artifactSize); err != nil {
		if err == gocql.ErrNotFound {
			log.Println("Cache miss. Running build command...")
			runBuildAndStore(session, sourceHash, buildToolVersion)
			return
		}
		log.Fatalf("Error checking cache meta: %v", err)
	}

	// 4. 缓存命中 (Cache Hit)
	log.Printf("Cache hit! Artifact hash: %s, size: %d bytes. Fetching data...", artifactHash, artifactSize)
	var content []byte
	query = `SELECT content FROM artifacts_data WHERE artifact_hash = ?`
	if err := session.Query(query, artifactHash).Scan(&content); err != nil {
		log.Printf("WARN: Meta found but data is missing (artifact_hash: %s). Rebuilding... Error: %v", artifactHash, err)
		runBuildAndStore(session, sourceHash, buildToolVersion)
		return
	}

	// 5. 将缓存内容写入输出文件
	if err := ioutil.WriteFile(os.Getenv(OutputPath), content, 0644); err != nil {
		log.Fatalf("Failed to write output from cache: %v", err)
	}
	log.Println("Successfully restored from cache.")
}

func runBuildAndStore(session *gocql.Session, sourceHash, buildToolVersion string) {
	// 执行实际的构建命令,例如 "sass /src/main.scss /out/main.css"
	cmdParts := strings.Fields(os.Getenv(BuildCommand))
	cmd := exec.Command(cmdParts[0], cmdParts[1:]...)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		log.Fatalf("Build command failed: %v", err)
	}
	log.Println("Build command finished successfully.")

	// 读取构建产物
	outputPath := os.Getenv(OutputPath)
	content, err := ioutil.ReadFile(outputPath)
	if err != nil {
		log.Fatalf("Failed to read build artifact: %v", err)
	}

	// 计算产物哈希并存储
	artifactHash := fmt.Sprintf("%x", sha256.Sum256(content))
	log.Printf("Storing new artifact. Hash: %s, size: %d", artifactHash, len(content))

	// 使用批处理保证元数据和数据的原子性(在Cassandra/Scylla中是原子性的,但非隔离)
	batch := session.NewBatch(gocql.LoggedBatch)
	batch.Query(`INSERT INTO artifacts_data (artifact_hash, content) VALUES (?, ?)`, artifactHash, content)
	batch.Query(`INSERT INTO artifacts_meta (source_hash, build_tool_version, artifact_hash, artifact_size, created_at) VALUES (?, ?, ?, ?, ?)`,
		sourceHash, buildToolVersion, artifactHash, len(content), time.Now())

	if err := session.ExecuteBatch(batch); err != nil {
		// 在真实项目中,这里需要重试逻辑
		log.Fatalf("Failed to store artifact to ScyllaDB: %v", err)
	}

	log.Println("Artifact stored in cache successfully.")
}

// calculateDirectoryHash 是一个简化实现,它遍历目录并对文件内容进行哈希。
// 生产环境中需要更健壮的实现,能处理文件名、权限等,并按固定顺序遍历。
func calculateDirectoryHash(dir string) (string, error) {
	h := sha256.New()
	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		// 为了哈希稳定,需要包含相对路径
		relPath, err := filepath.Rel(dir, path)
		if err != nil {
			return err
		}
		h.Write([]byte(relPath))

		content, err := ioutil.ReadFile(path)
		if err != nil {
			return err
		}
		h.Write(content)
		return nil
	})
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%x", h.Sum(nil)), nil
}

代码要点:

  • 错误处理: 代码中包含了对数据库连接、查询、文件IO和命令执行的详尽错误处理。一个常见的错误是元数据存在但数据丢失(可能因为TTL不一致或部分节点故障),我们将其视为cache miss并触发重建。
  • 原子性写入: 使用gocql.LoggedBatch来确保元数据和产物数据的写入操作要么都成功,要么都失败。这避免了缓存库中出现只有元数据没有实际数据的“幽灵条目”。
  • 配置注入: 所有配置项(ScyllaDB地址、路径、命令)都通过环境变量注入,这符合云原生应用的十二要素原则,使得Pod的定义非常灵活。

4. Kubernetes BuildCacheJob CRD 和 Operator 伪代码

我们不会在这里写一个完整的Operator,但可以定义其核心职责和CRD。

CRD (buildcachejob.example.com) 定义 (YAML):

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: buildcachejobs.devops.example.com
spec:
  group: devops.example.com
  names:
    kind: BuildCacheJob
    plural: buildcachejobs
    singular: buildcachejob
    shortNames:
    - bcj
  scope: Namespaced
  versions:
  - name: v1alpha1
    served: true
    storage: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            required:
              - sourceHash
              - buildToolVersion
              - buildCommand
              - sourceVolumeClaim
              - outputVolumeClaim
            properties:
              sourceHash:
                type: string
              buildToolVersion:
                type: string
              buildCommand:
                type: string
              sourceVolumeClaim:
                type: string
              outputVolumeClaim:
                type: string
          status:
            type: object
            properties:
              phase:
                type: string # Pending, Running, Succeeded, Failed
              cacheStatus:
                type: string # HIT, MISS

Operator调谐循环 (Reconcile) 核心逻辑 (Go伪代码):

func (r *BuildCacheJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // ... 获取 BuildCacheJob 对象 ...

    // 如果Job已经完成,则跳过
    if job.Status.Phase == "Succeeded" || job.Status.Phase == "Failed" {
        return ctrl.Result{}, nil
    }

    // 检查关联的Pod是否存在
    foundPod := &corev1.Pod{}
    err := r.Get(ctx, /*...pod name...*/, foundPod)
    
    if err != nil && errors.IsNotFound(err) {
        // Pod不存在,创建它
        pod := r.newPodForJob(job)
        // ... 设置 owner reference ...
        r.Create(ctx, pod)
        // ... 更新Job状态为Pending ...
        return ctrl.Result{Requeue: true}, nil // 重新排队以检查Pod状态
    } else if err != nil {
        // 其他错误
        return ctrl.Result{}, err
    }

    // Pod已存在,检查其状态
    switch foundPod.Status.Phase {
    case corev1.PodSucceeded:
        // Pod成功,可以从Pod日志中解析出CacheStatus并更新Job状态
        job.Status.Phase = "Succeeded"
        // job.Status.CacheStatus = parseCacheStatusFromLogs(foundPod)
    case corev1.PodFailed:
        job.Status.Phase = "Failed"
    default:
        // Pod仍在运行中
        job.Status.Phase = "Running"
    }
    
    // 更新Job的状态
    r.Status().Update(ctx, job)
    return ctrl.Result{}, nil
}

func (r *BuildCacheJobReconciler) newPodForJob(job *v1alpha1.BuildCacheJob) *corev1.Pod {
    // ... 这里是创建Pod的逻辑 ...
    return &corev1.Pod{
        // ... metadata ...
        Spec: corev1.PodSpec{
            Containers: []corev1.Container{
                {
                    Name:  "build-runner",
                    Image: "our-custom-build-image:latest",
                    Env: []corev1.EnvVar{
                        {Name: "SCYLLA_HOSTS", Value: "scylla-cluster.scylla.svc.cluster.local:9042"},
                        {Name: "SOURCE_PATH", Value: "/src"},
                        {Name: "OUTPUT_PATH", Value: "/out/result.css"},
                        {Name: "BUILD_TOOL_VERSION", Value: job.Spec.BuildToolVersion},
                        {Name: "BUILD_COMMAND", Value: job.Spec.BuildCommand},
                    },
                    VolumeMounts: []corev1.VolumeMount{
                        {Name: "source-data", MountPath: "/src"},
                        {Name: "output-data", MountPath: "/out"},
                    },
                },
            },
            Volumes: []corev1.Volume{
                {
                    Name: "source-data",
                    VolumeSource: corev1.VolumeSource{
                        PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
                            ClaimName: job.Spec.SourceVolumeClaim,
                        },
                    },
                },
                // ... output volume ...
            },
            RestartPolicy: corev1.RestartPolicyNever,
        },
    }
}

架构的扩展性与局限性

此架构的优势在于其清晰的职责分离和强大的可扩展性。ScyllaDB集群可以独立于计算节点进行扩缩容,以应对不断增长的缓存数据。Operator和BuildCacheJob的设计模式可以轻松扩展,以支持不同的前端工具链(Webpack, esbuild)或甚至后端编译(Go, Java),只需更换Pod中的容器镜像和构建命令即可。

然而,该方案并非没有局限性:

  1. 哈希计算的健壮性:当前基于文件内容的哈希策略是简化的。一个生产级的系统需要一个更复杂的“指纹”系统,它能理解项目的依赖关系图(如package-lock.json),并将相关依赖的版本也纳入哈希计算,以实现更精确的缓存命中/失效。
  2. 网络开销:尽管ScyllaDB延迟低,但对于TB级的数据,构建Pod与ScyllaDB之间的数据传输依然存在网络开销。在多可用区(AZ)部署时,跨AZ的流量成本和延迟也需要被纳入考量。一种优化方式是利用ScyllaDB的拓扑感知特性,让Operator尽量将Build Pod调度到与ScyllaDB数据副本相同的AZ或节点上。
  3. 不适用于非纯函数构建:该系统完全依赖于“相同输入=相同输出”的假设。对于任何包含网络调用、时间戳依赖或随机性的构建过程,此缓存机制将导致错误的结果。它的适用边界是清晰的,即确定性的、无副作用的编译和转换任务。

  目录