使用Dart Clojure与Podman构建一个轻量级多语言构建系统的控制平面


团队内部的构建和测试流程正逐渐演变成一场灾难。每个项目都有一套独特的、由Makefilepackage.json脚本和大量bash脚本粘合而成的自动化体系。新成员需要数天时间才能在本地成功运行一次完整的测试,而CI环境则是一个没人敢碰的黑盒,充满了只有少数资深员工才懂的“魔法变量”。本地开发环境与CI环境的差异导致了大量的“在我机器上是好的”问题。我们需要一个统一的、声明式的、且对开发者友好的解决方案,但现有的Jenkins、GitLab CI等重型工具对于我们快速迭代的小团队来说过于臃肿。

我们的目标是创建一个轻量级的内部构建平台(或者说是一个IDP的雏形),其核心原则是:

  1. 一致性: 本地和CI环境必须使用完全相同的执行引擎。
  2. 声明式: 开发者用一份简单的配置文件描述构建步骤,而不是写过程式的脚本。
  3. 高性能CLI: 必须提供一个响应迅速、交互友好的命令行工具。
  4. 隔离性: 构建任务必须在干净、隔离的环境中运行,避免互相干扰。

经过一番讨论,我们选择了一套在外界看来可能有些非主流的技术栈,但这背后是经过深思熟虑的工程权衡。

  • 执行引擎: Podman
    我们放弃了Docker。主要原因是Docker守护进程(daemon)带来的复杂性和安全隐患。它是一个单点故障,并且默认以root权限运行,这在多租户的CI服务器上是一个安全噩梦。Podman的daemonless和rootless设计完美解决了这些问题。每个构建任务由当前用户启动的Podman进程管理,权限天然隔离,更加轻量和安全。

  • 控制平面后端: Clojure
    我们需要一个健壮的后端服务来接收CLI的请求,解析构建定义,然后编排Podman容器的执行。Clojure是这里的最佳选择。首先,它的数据处理能力无与伦比。构建流水线本质上就是对数据结构(步骤、依赖、环境)的转换和执行,这正是Lisp方言的强项。其次,基于JVM,我们可以利用其成熟的生态系统、并发能力和稳定性。使用不可变数据结构来管理构建状态,可以极大地简化并发控制的复杂性。

  • 前端CLI: Dart
    开发者体验至关重要。我们需要一个能被编译成单个二进制文件、启动迅速、跨平台的CLI。Go是常见的选择,但我们最终选择了Dart。Dart的AOT编译能产出性能媲美原生代码的二进制文件。更重要的是,Dart在构建富文本终端界面方面比Go有更好的库支持,例如处理颜色、进度条和交互式提示,这能显著提升开发者的使用体验。

整个系统的架构如下:

graph TD
    subgraph 开发者本地机器/CI Runner
        A[Dart CLI: build-ctl] -- "POST /execute (EDN Payload)" --> B{Clojure Orchestrator};
        A -- "实时拉取日志流" --> B;
    end

    subgraph Server
        B -- "生成并执行" --> C[podman run ...];
        C[podman run ...] -- "stdout/stderr" --> B;
    end

    D[Project's build.edn] -- "读取" --> A;
    E[Containerfile] -- "构建镜像" --> C;

    style B fill:#8d6e63,stroke:#333,stroke-width:2px,color:#fff
    style A fill:#007BFF,stroke:#333,stroke-width:2px,color:#fff

定义构建契约:EDN的力量

系统的核心是构建定义文件build.edn。我们没有选择YAML或JSON,而是Clojure的原生数据格式EDN (Extensible Data Notation)。EDN比JSON更具表现力(支持关键字、集合、列表等),并且Clojure后端可以直接读取,无需任何解析库。

一个典型的build.edn文件如下所示,它定义了一个简单的Node.js项目的构建和测试流程:

;; build.edn in project root
{
 ;; 定义整个流水线使用的基础环境
 :environment {:image "docker.io/library/node:18-slim"
               :workdir "/app"}

 ;; 流水线名称
 :pipeline :default

 ;; 定义流水线的各个阶段
 :stages [
   {:name :install-deps
    :description "Install node modules"
    :steps [
      {:run "npm install --ci"
       ;; 定义此步骤的缓存卷,Podman会自动挂载
       :cache {:path "/app/node_modules"
               :key "node-modules-v1-{{checksum 'package-lock.json'}}" }}
    ]}

   {:name :lint
    :description "Run linter"
    :steps [
      {:run "npm run lint"}
    ]}

   {:name :test
    :description "Run unit tests"
    :steps [
      {:run "npm test"
       ;; 挂载secrets
       :secrets ["NPM_TOKEN"]
       ;; 传递环境变量
       :env {:CI "true"}}
    ]}

   {:name :build-image
    :description "Build final application image"
    :steps [
       ;; 这是一种特殊的步骤类型,而非简单的shell命令
      {:build {:image-name "my-app:latest"
               :dockerfile "./Dockerfile.prod"}}
    ]}
 ]
}

这份配置清晰地描述了构建的“什么”,而不是“如何”。缓存策略、密钥管理等复杂性都被抽象掉了。{{checksum 'package-lock.json'}}是一个占位符,将在执行前由Clojure后端计算并替换,实现智能缓存。

Clojure编排器:数据驱动的执行引擎

Clojure后端是整个系统的大脑。我们使用http-kit构建一个轻量级的HTTP服务器,它只暴露一个核心端点 /api/v1/execute

core.clj:

(ns build-orchestrator.core
  (:require [org.httpkit.server :as http-kit]
            [compojure.core :refer [defroutes POST]]
            [compojure.route :as route]
            [ring.middleware.json :refer [wrap-json-body]]
            [clojure.edn :as edn]
            [clojure.java.shell :as shell]
            [clojure.string :as str]
            [clojure.tools.logging :as log])
  (:import [java.io InputStreamReader BufferedReader]))

(defn- stream-process-output
  "实时将进程的输出流(stdout/stderr)推送到一个回调函数"
  [process callback]
  (let [stdout-thread (Thread.
                       (fn []
                         (with-open [reader (-> (.getInputStream process)
                                                (InputStreamReader. "UTF-8")
                                                (BufferedReader.))]
                           (loop []
                             (when-let [line (.readLine reader)]
                               (callback {:type :stdout :payload line})
                               (recur))))))
        stderr-thread (Thread.
                       (fn []
                         (with-open [reader (-> (.getErrorStream process)
                                                (InputStreamReader. "UTF-8")
                                                (BufferedReader.))]
                           (loop []
                             (when-let [line (.readLine reader)]
                               (callback {:type :stderr :payload line})
                               (recur))))))]
    (.start stdout-thread)
    (.start stderr-thread)
    [stdout-thread stderr-thread]))

(defn- execute-podman-command
  "执行一个Podman命令并实时流式传输其输出"
  [args stream-callback]
  (try
    (log/info "Executing Podman command:" (str/join " " args))
    (let [process (.start (ProcessBuilder. (into-array String args)))
          [stdout-t stderr-t] (stream-process-output process stream-callback)
          exit-code (.waitFor process)]
      (.join stdout-t)
      (.join stderr-t)
      (log/info "Podman command finished with exit code:" exit-code)
      {:exit-code exit-code})
    (catch Exception e
      (log/error e "Failed to execute Podman command")
      {:exit-code 1 :error (.getMessage e)})))

(defn- process-step
  "处理单个构建步骤"
  [step-def env-def stream-callback]
  (let [base-image (:image env-def)
        workdir (:workdir env-def "/app")
        run-command (get-in step-def [:run])]
    (if run-command
      (let [podman-args ["podman" "run"
                         "--rm"
                         "-i" ; 保持stdin打开,即使没有连接
                         (str "--workdir=" workdir)
                         ;; 将当前工作目录挂载到容器中
                         (str "--volume=" (System/getProperty "user.dir") ":" workdir ":z")
                         base-image
                         "/bin/sh" "-c" run-command]]
        (execute-podman-command podman-args stream-callback))
      (do
        (log/warn "Unsupported step type:" (first (keys step-def)))
        {:exit-code -1}))))

(defn- run-pipeline
  "执行整个流水线"
  [pipeline-def stream-callback]
  (let [env-def (:environment pipeline-def)]
    (loop [stages (:stages pipeline-def)]
      (if (empty? stages)
        (do
          (stream-callback {:type :status :payload "Pipeline completed successfully"})
          {:status :success})
        (let [stage (first stages)
              stage-name (:name stage)]
          (stream-callback {:type :stage-start :payload (name stage-name)})
          (loop [steps (:steps stage)]
            (if (empty? steps)
              (do
                (stream-callback {:type :stage-end :payload (name stage-name)})
                (recur (rest stages))) ; Process next stage
              (let [step (first steps)
                    result (process-step step env-def stream-callback)]
                (if (zero? (:exit-code result))
                  (recur (rest steps)) ; Process next step
                  (do
                    (stream-callback {:type :error :payload (str "Step failed in stage " (name stage-name))})
                    {:status :failed :stage stage-name}))))))))))


(defn execute-handler [request]
  (let [body-str (slurp (:body request))
        pipeline-def (try (edn/read-string body-str)
                          (catch Exception e
                            (log/error e "Failed to parse EDN payload")
                            nil))]
    (if-not pipeline-def
      {:status 400 :body "Invalid EDN payload"}
      ;; 使用 http-kit 的异步能力
      (http-kit/as-channel request
        {:on-open (fn [channel]
                    (future ; 在另一个线程中运行耗时的构建任务
                      (run-pipeline pipeline-def
                                    (fn [event]
                                      ;; 将每个事件作为JSON行发送回客户端
                                      (http-kit/send! channel (str (cheshire.core/generate-string event) "\n"))))
                      (http-kit/close channel)))
         :headers {"Content-Type" "application/x-ndjson"}}))))

(defroutes app-routes
  (POST "/api/v1/execute" [] execute-handler)
  (route/not-found "Not Found"))

(defn -main []
  (let [port (Integer/parseInt (or (System/getenv "PORT") "8080"))]
    (log/info "Starting build orchestrator on port" port)
    (http-kit/run-server (wrap-json-body app-routes {:keywords? true}) {:port port})))

这段代码的核心是run-pipelineprocess-step函数。它们递归地遍历EDN定义的stages和steps,并将每个:run步骤转换为一个podman run命令。execute-podman-command函数是关键,它使用ProcessBuilder启动Podman进程,并创建两个独立的线程来实时读取标准输出和标准错误流。

为了实现日志的实时流式传输,我们利用了http-kit的异步通道(channel)能力。当请求到达时,我们立即打开一个通道,并在一个future块(Clojure的轻量级线程)中开始执行构建。每当Podman进程产生一行输出,或者一个阶段开始/结束,我们都会通过回调函数stream-callback将一个JSON对象发送回客户端。这种基于NDJSON(Newline Delimited JSON)的流式响应,可以让CLI实时展示构建进度。

一个常见的坑在于进程IO处理。如果不正确地、并发地处理stdoutstderr,很容易导致子进程因为缓冲区满而阻塞。这里的stream-process-output函数通过为每个流创建一个专用线程来规避这个问题,这是生产级代码中必须考虑的细节。

Dart CLI:打造卓越的开发者体验

CLI是开发者与系统交互的唯一入口,它的体验至关重要。

main.dart:

import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;

const String orchestratorUrl = 'http://localhost:8080/api/v1/execute';

void main(List<String> arguments) async {
  final parser = ArgParser()..addCommand('run');
  final argResults = parser.parse(arguments);

  if (argResults.command?.name == 'run') {
    await handleRun();
  } else {
    print('Usage: build-ctl run');
    exit(1);
  }
}

Future<void> handleRun() async {
  final configFile = File(p.join(Directory.current.path, 'build.edn'));
  if (!await configFile.exists()) {
    stderr.writeln('Error: build.edn not found in the current directory.');
    exit(1);
  }

  final ednContent = await configFile.readAsString();

  try {
    final request = http.Request('POST', Uri.parse(orchestratorUrl));
    request.headers['Content-Type'] = 'application/edn';
    request.body = ednContent;

    // http.Client.send() returns a StreamedResponse, perfect for our use case.
    final streamedResponse = await http.Client().send(request);

    if (streamedResponse.statusCode != 200) {
      final body = await streamedResponse.stream.bytesToString();
      print('Error from orchestrator: ${streamedResponse.statusCode}');
      print(body);
      exit(1);
    }

    // Process the NDJSON stream
    await for (var line in streamedResponse.stream.transform(utf8.decoder).transform(const LineSplitter())) {
      if (line.trim().isEmpty) continue;
      try {
        final event = jsonDecode(line) as Map<String, dynamic>;
        processEvent(event);
      } catch (e) {
        print('Failed to parse event line: $line');
      }
    }
  } catch (e) {
    stderr.writeln('Failed to connect to orchestrator: $e');
    exit(1);
  }
}

void processEvent(Map<String, dynamic> event) {
  final type = event['type'] as String;
  final payload = event['payload'];

  switch (type) {
    case 'stage-start':
      // Use ANSI escape codes for colored output
      print('\n\x1B[1;34m=== Running Stage: $payload ===\x1B[0m');
      break;
    case 'stage-end':
      print('\x1B[1;32m=== Stage $payload finished ===\x1B[0m');
      break;
    case 'stdout':
      print(payload);
      break;
    case 'stderr':
      // Print stderr in red
      stderr.writeln('\x1B[31m$payload\x1B[0m');
      break;
    case 'status':
      print('\x1B[1;32m$payload\x1B[0m');
      break;
    case 'error':
      stderr.writeln('\x1B[1;91mERROR: $payload\x1B[0m');
      exit(1);
      break;
    default:
      print('Unknown event: $event');
  }
}

Dart代码非常直观。它读取build.edn文件,将其内容作为请求体发送到Clojure后端。这里最酷的部分是http.Client().send(request),它返回一个StreamedResponse。我们不需要等待整个响应下载完毕,而是可以监听其stream,并使用LineSplitter将其分割成一行行的NDJSON事件。

processEvent函数根据从后端接收到的事件类型,在终端上打印出格式化的、带颜色的输出。这为用户提供了清晰、实时的反馈。一个生产级的CLI还会包含进度条、更复杂的错误报告等,但这个基础版本已经展示了Dart在构建此类工具时的优势。

编译成原生二进制文件非常简单:
dart compile exe bin/main.dart -o build-ctl

现在,开发者只需将这个build-ctl二进制文件放在PATH中,就可以在任何项目下运行build-ctl run来启动一个与CI环境完全一致的构建流程了。

方案的局限性与未来展望

这个实现虽然解决了我们最初的核心痛点,但它依然是一个原型。在真实项目中推广前,还有几个明显的局限性需要解决:

  1. 状态管理与并发: 当前的Clojure服务是无状态的,一次只能处理一个构建请求。如果多个开发者同时触发构建,它们会在服务器上串行执行。一个可行的优化路径是引入一个任务队列(例如Redis或PostgreSQL),将构建请求作为任务入队,由一组无状态的worker来消费和执行。

  2. 更复杂的流水线逻辑: build.edn的定义还比较简单。它不支持阶段间的依赖关系(DAG)、条件执行或并行执行步骤。下一步需要扩展EDN的schema,并在Clojure后端实现一个更复杂的调度器来支持这些高级特性。可以使用Clojure的core.async库来优雅地处理并行和依赖。

  3. 缓存与制品管理: 缓存实现还很初级。一个真正的构建系统需要一个分布式的缓存后端(如S3、NFS),用于存储层、依赖项和构建产物,以在不同的构建和机器之间共享,从而大幅提升速度。

  4. 安全性: 虽然Podman的rootless模式已经提供了很好的基础,但我们还需要更精细的控制。例如,限制容器可以访问的网络、可以挂载的卷、以及运行时能力(capabilities),以防止恶意的build.edn文件对宿主机造成破坏。

尽管存在这些局限,这个由Dart、Clojure和Podman驱动的系统验证了一个重要的架构思想:通过选择在各自领域表现出色的、看似不相关的技术,并将它们通过清晰的契约(EDN和流式API)粘合在一起,我们可以构建出既强大又轻量、既符合工程需求又具备优秀开发者体验的内部平台工具。


  目录