构建基于Packer和PHP的自助式黄金镜像工厂并集成Jotai前端实现实时日志流


团队内部开发环境不一致的问题由来已久。新成员入职需要花费一整天甚至更长时间配置本地环境,不同项目依赖的PHP版本、扩展库、系统工具链各不相同,由此引发的“在我这儿是好的”争论屡见不鲜。标准化是唯一的出路,我们决定构建一个内部的“黄金镜像工厂”,让开发者能够通过Web界面自助申请、构建和下载预装好一切依赖的虚拟机或容器镜像。

技术痛点与初步构想

痛点很明确:

  1. 环境蔓延:缺乏统一的基线镜像,导致配置漂移。
  2. 手动构建:镜像制作过程依赖人工执行脚本,效率低下且易出错。
  3. 过程黑盒:构建过程对申请者不透明,失败后排查困难,需要运维介入。

我们的构想是一个自助服务平台。开发者在UI上选择基础操作系统、PHP版本、所需扩展等参数,提交后系统自动触发一个异步任务来构建镜像。最关键的一点是,构建过程的日志必须实时地反馈到前端界面,让开发者能像在本地终端一样看到完整的输出,这对于调试自定义的安装脚本至关重要。

技术选型决策与架构设计

这个需求横跨了基础设施、后端和前端,技术选型必须服务于核心目标——自动化、异步处理和实时反馈。

  • 镜像构建: Packer
    这是业内的不二之选。HashiCorp的Packer通过一份HCL或JSON声明文件,可以为多个平台(VMware, VirtualBox, Docker, AWS AMI等)创建一致的机器镜像。它的可扩展性和声明式语法非常适合自动化。

  • 后端服务: PHP
    团队技术栈以PHP为主,复用现有能力可以加快开发。我们需要一个API接收前端的构建请求,并将其推送到一个任务队列中。使用成熟的PHP生态,如SwooleWorkerman可以轻松处理WebSocket,实现与前端的实时通信。

  • 任务队列与实时消息: Redis
    Redis在这里扮演两个核心角色。首先,它是一个高性能的任务队列后端(例如php-resqueLaravel Queues)。其次,它的Pub/Sub功能是实现实时日志流的关键。Packer构建任务的输出可以被实时捕获并发布到Redis的特定频道,再由后端推送给前端。

  • 构建元数据存储: 文档型NoSQL (MongoDB)
    每个构建任务都会产生大量半结构化的数据:请求参数、构建状态(排队中、构建中、成功、失败)、时间戳、构建日志全文、最终产物地址等。这种数据模型非常适合文档型数据库,查询和扩展都比关系型数据库更灵活。

  • 前端状态管理: Jotai
    前端需要展示一个构建任务列表,每个任务都有自己独立的状态和实时更新的日志流。如果使用Redux这类全局状态管理库,处理多个独立的、高频更新的数据流会变得非常笨拙。Jotai的原子化(Atom-based)状态管理模型则完美契合这个场景。每个构建任务的日志可以拥有自己的atom,互不干扰,组件只订阅自己关心的那部分状态,性能和代码可维护性都极佳。

最终的架构流程如下:

sequenceDiagram
    participant FE as Frontend (React + Jotai)
    participant API as PHP API (Symfony/Laravel)
    participant Queue as Redis Queue
    participant Worker as PHP Worker
    participant Packer as Packer Process
    participant RedisPubSub as Redis Pub/Sub
    participant WSS as WebSocket Server (PHP)
    participant DB as MongoDB

    FE->>+API: POST /api/builds (发起构建请求)
    API->>+Queue: Enqueue BuildJob
    API-->>-FE: { "build_id": "xyz", "status": "queued" }
    FE->>+DB: (Periodically) Fetch build list
    DB-->>-FE: Show list with "queued" status

    Worker->>+Queue: Dequeue BuildJob
    Worker->>+DB: Update build status to "building"
    Worker->>Packer: exec('packer build template.pkr.hcl')
    
    Note right of Packer: Packer starts building...
    Packer-->>Worker: Streams stdout/stderr
    Worker->>+RedisPubSub: PUBLISH build:xyz:logs, "Log line..."
    
    WSS->>+RedisPubSub: SUBSCRIBE build:xyz:logs
    RedisPubSub-->>WSS: Receives "Log line..."
    WSS->>FE: PUSH "Log line..." via WebSocket

    FE->>Jotai: Update log atom for build 'xyz'
    
    Note right of Packer: ...Build finishes
    Packer-->>Worker: Exit code (0 for success)
    Worker->>+DB: Update build status (success/failed), save artifact URL
    Worker->>+RedisPubSub: PUBLISH build:xyz:status, "completed"
    WSS->>RedisPubSub: SUBSCRIBE build:xyz:status
    RedisPubSub-->>WSS: Receives "completed"
    WSS->>FE: PUSH "completed" via WebSocket
    FE->>Jotai: Update status atom for build 'xyz'

步骤化实现:串联整个流程

1. Packer模板与输出捕获

关键在于让Packer的输出能被PHP进程捕获。我们将使用HCL格式的模板,并通过一个简单的Shell provisioner来模拟安装过程。

ubuntu-php8.1.pkr.hcl:

packer {
  required_plugins {
    virtualbox-iso = {
      version = ">= 1.0.0"
      source  = "github.com/hashicorp/virtualbox"
    }
  }
}

variable "build_id" {
  type    = string
  default = "local-build"
}

source "virtualbox-iso" "ubuntu" {
  guest_os_type = "Ubuntu_64"
  iso_url       = "https://releases.ubuntu.com/22.04.1/ubuntu-22.04.1-live-server-amd64.iso"
  iso_checksum  = "sha256:10f19c5b2b8d6db711582e0e27f511629d38fe9fb240a94833b45d7d9d0c5586"
  
  // 使用无人值守安装
  http_directory = "http"
  boot_command = [
    "<enter><wait>",
    "linux /casper/vmlinuz autoinstall ds=nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ ---<enter>",
    "initrd /casper/initrd<enter>",
    "boot<enter>"
  ]

  ssh_username = "packer"
  ssh_password = "packer_password"
  ssh_timeout  = "45m"

  vm_name      = "php8.1-base-{{timestamp}}"
  output_directory = "output-{{.SourceType}}-{{.Name}}-${var.build_id}"
}

build {
  sources = ["source.virtualbox-iso.ubuntu"]

  provisioner "shell" {
    inline = [
      "echo '==> Build ID: ${var.build_id}'",
      "echo '==> Updating package cache...'",
      "sudo apt-get update -y",
      "sleep 5",
      "echo '==> Installing PHP 8.1 and extensions...'",
      "sudo apt-get install -y php8.1-cli php8.1-fpm php8.1-mbstring php8.1-xml php8.1-curl",
      "sleep 5",
      "echo '==> PHP version check:'",
      "php -v",
      "echo '==> Cleaning up...'",
      "sudo apt-get clean"
    ]
  }

  // 其他provisioners...
}

这个模板定义了一个基础的Ubuntu虚拟机,并安装了PHP 8.1。build_id变量将由我们的PHP worker动态传入,用于追踪和日志关联。

2. 后端PHP Worker:执行并流式发布日志

这是整个系统的核心。我们需要一个PHP脚本作为任务队列的worker。它会调用packer build命令,并使用proc_open来实时读取其标准输出和标准错误。

BuildJobWorker.php:

<?php
require 'vendor/autoload.php';

use Predis\Client as RedisClient;
use MongoDB\Client as MongoClient;

class BuildJobWorker
{
    private RedisClient $redis;
    private MongoClient $mongo;
    private $buildsCollection;

    public function __construct()
    {
        // 生产环境中, 配置应该来自环境变量或配置文件
        $this->redis = new RedisClient(['scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => 6379]);
        $this->mongo = new MongoClient("mongodb://127.0.0.1:27017");
        $this->buildsCollection = $this->mongo->image_factory->builds;
    }

    public function perform(array $args): void
    {
        $buildId = $args['build_id'] ?? uniqid();
        $packerTemplate = $args['template'] ?? 'ubuntu-php8.1.pkr.hcl';

        $this->updateBuildStatus($buildId, 'building');
        $this->publishLog($buildId, "Worker started for build: {$buildId}");

        // 构建Packer命令, 通过 -var 传递变量
        $command = "packer build -var 'build_id={$buildId}' {$packerTemplate}";

        // 使用 proc_open 来获取进程句柄和管道
        $descriptorspec = [
           0 => ["pipe", "r"], // stdin
           1 => ["pipe", "w"], // stdout
           2 => ["pipe", "w"], // stderr
        ];

        $process = proc_open($command, $descriptorspec, $pipes);

        if (is_resource($process)) {
            // 设置stdout和stderr为非阻塞模式
            stream_set_blocking($pipes[1], false);
            stream_set_blocking($pipes[2], false);

            $fullLog = '';
            while (true) {
                $read = [$pipes[1], $pipes[2]];
                $write = null;
                $except = null;
                
                // 等待流数据变化
                if (stream_select($read, $write, $except, 1) > 0) {
                    foreach ($read as $stream) {
                        $line = fread($stream, 8192);
                        if (strlen($line) > 0) {
                            // 清理ANSI颜色代码, 它们在Web UI上显示不佳
                            $cleanLine = preg_replace('/\x1B\[[0-9;]*[JKmsu]/', '', $line);
                            $this->publishLog($buildId, $cleanLine);
                            $fullLog .= $cleanLine;
                        }
                    }
                }
                
                // 检查进程是否仍在运行
                $status = proc_get_status($process);
                if (!$status['running']) {
                    break;
                }
            }

            $exitCode = proc_close($process);

            if ($exitCode === 0) {
                $this->updateBuildStatus($buildId, 'success', $fullLog);
                $this->publishStatus($buildId, 'success');
            } else {
                $this->updateBuildStatus($buildId, 'failed', $fullLog);
                $this->publishStatus($buildId, 'failed');
            }
        } else {
            $this->updateBuildStatus($buildId, 'failed', 'Failed to execute Packer process.');
            $this->publishStatus($buildId, 'failed');
        }
    }

    private function publishLog(string $buildId, string $message): void
    {
        // 发布日志到特定频道
        $channel = "build:{$buildId}:logs";
        $this->redis->publish($channel, $message);
    }

    private function publishStatus(string $buildId, string $status): void
    {
        $channel = "build:{$buildId}:status";
        $this->redis->publish($channel, json_encode(['status' => $status]));
    }

    private function updateBuildStatus(string $buildId, string $status, string $log = null): void
    {
        $updateData = ['$set' => ['status' => $status, 'updated_at' => new \MongoDB\BSON\UTCDateTime()]];
        if ($log !== null) {
            $updateData['$set']['log'] = $log;
        }
        
        $this->buildsCollection->updateOne(
            ['_id' => new \MongoDB\BSON\ObjectId($buildId)],
            $updateData,
            ['upsert' => true] // 如果文档不存在则创建
        );
    }
}

这里的关键是proc_openstream_select的组合。它允许我们以非阻塞的方式从Packer进程的输出管道中读取数据,一旦有新数据就立即通过Redis Pub/Sub发布出去,实现了日志的实时性。

3. 后端WebSocket服务:日志的搬运工

一个简单的WebSocket服务器(可以用SwooleRatchet等库实现)负责订阅所有相关的Redis频道,并将消息转发给已连接的前端客户端。

为了简化,这里只展示核心逻辑伪代码:

// WebSocketServer.php (Conceptual)
$webSocketServer->on('open', function ($connection) {
    // 当一个新客户端连接时
    // 客户端应发送它想订阅的build_id
});

$webSocketServer->on('message', function ($connection, $message) {
    // 消息格式: { "action": "subscribe", "build_id": "xyz" }
    $data = json_decode($message, true);
    if ($data['action'] === 'subscribe') {
        $buildId = $data['build_id'];
        
        // 为这个客户端订阅Redis频道
        $redisSubscriber = new RedisClient();
        $redisSubscriber->subscribe(["build:{$buildId}:logs", "build:{$buildId}:status"], function($message, $channel) use ($connection) {
            // 当Redis有消息时, 转发给WebSocket客户端
            $connection->send(json_encode(['channel' => $channel, 'payload' => $message]));
        });
    }
});

4. 前端:Jotai的优雅状态管理

前端的挑战在于,用户可能同时关注多个构建任务。每个任务的日志流和状态都应该独立管理。

首先,我们定义几个核心的atom。

store/buildAtoms.js:

import { atom } from 'jotai';

// 一个atom家族, 用于存储每个构建任务的日志
// key是buildId, value是日志字符串数组
export const buildLogsAtomFamily = atom({});

// 一个派生atom, 用于更新特定build的日志
export const updateBuildLogAtom = atom(
  null,
  (get, set, { buildId, newLogLine }) => {
    const currentLogs = get(buildLogsAtomFamily);
    const existingLogs = currentLogs[buildId] || [];
    set(buildLogsAtomFamily, {
      ...currentLogs,
      [buildId]: [...existingLogs, newLogLine],
    });
  }
);

// 同样地, 为状态创建atom家族
export const buildStatusAtomFamily = atom({});

export const updateBuildStatusAtom = atom(
  null,
  (get, set, { buildId, newStatus }) => {
    const currentStatuses = get(buildStatusAtomFamily);
    set(buildStatusAtomFamily, {
      ...currentStatuses,
      [buildId]: newStatus,
    });
  }
);

atomFamily模式在Jotai中通常通过一个封装函数实现,这里为了简洁,用一个对象来模拟。

然后,在React组件中使用这些atom。

components/BuildLogViewer.js:

import { useAtom, useSetAtom } from 'jotai';
import { useEffect, useRef } from 'react';
import { buildLogsAtomFamily, updateBuildLogAtom, buildStatusAtomFamily, updateBuildStatusAtom } from '../store/buildAtoms';
import useWebSocket from 'react-use-websocket';

const WS_URL = 'ws://localhost:8080';

export const BuildDetail = ({ buildId }) => {
  // 从atom家族中读取特定buildId的状态
  const [allLogs] = useAtom(buildLogsAtomFamily);
  const [allStatuses] = useAtom(buildStatusAtomFamily);
  const logs = allLogs[buildId] || [];
  const status = allStatuses[buildId] || 'loading...';

  // 获取更新函数
  const appendLog = useSetAtom(updateBuildLogAtom);
  const setStatus = useSetAtom(updateBuildStatusAtom);
  
  const logContainerRef = useRef(null);

  // 使用 react-use-websocket 来处理连接
  const { lastMessage, sendMessage } = useWebSocket(WS_URL, {
    onOpen: () => {
      console.log('WebSocket connection established.');
      // 连接建立后, 发送订阅请求
      sendMessage(JSON.stringify({ action: 'subscribe', build_id: buildId }));
    },
  });

  useEffect(() => {
    if (lastMessage !== null) {
      const data = JSON.parse(lastMessage.data);
      const { channel, payload } = data;
      
      if (channel === `build:${buildId}:logs`) {
        appendLog({ buildId, newLogLine: payload });
      } else if (channel === `build:${buildId}:status`) {
        const statusPayload = JSON.parse(payload);
        setStatus({ buildId, newStatus: statusPayload.status });
      }
    }
  }, [lastMessage, appendLog, setStatus, buildId]);

  // 自动滚动到日志底部
  useEffect(() => {
    if (logContainerRef.current) {
        logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
    }
  }, [logs]);

  return (
    <div className="build-detail">
      <h3>Build ID: {buildId}</h3>
      <p>Status: <span className={`status-${status}`}>{status}</span></p>
      <pre ref={logContainerRef} className="log-viewer">
        {logs.join('')}
      </pre>
    </div>
  );
};

这个组件完美地展示了Jotai的优势。它只订阅和更新与自己buildId相关的数据,完全不影响页面上其他BuildDetail组件的性能。useWebSocket钩子简化了WebSocket的生命周期管理,当收到新消息时,调用Jotai的set函数来更新对应的atom,UI便会自动重新渲染。

当前方案的局限性与未来迭代路径

这个实现打通了从前端请求到后端处理再到实时反馈的全链路,但它仍然是一个V1版本,在生产环境中还有很多需要完善的地方。

首先,Worker直接在宿主机上执行packer命令存在严重的安全和资源隔离问题。一个失控的构建脚本可能会影响整个worker节点。未来的迭代方向是,Worker不应直接执行构建,而是通过API与一个专用的CI/CD系统(如Jenkins、GitLab CI或Argo Workflows on Kubernetes)集成。Worker的角色变成任务的协调者,将构建任务下发到隔离的、临时的环境中执行。

其次,WebSocket服务是单点的。如果它崩溃,所有客户端的实时日志都会中断。可以考虑使用更健壮的实时消息服务,或者将WebSocket服务本身设计成可水平扩展的集群模式。

最后,日志存储和传输可以进一步优化。对于非常长的构建日志,一次性加载到MongoDB和前端内存中可能会成为瓶颈。可以考虑将日志分块存储,并在前端实现虚拟滚动,只渲染可视区域内的日志行,以应对超大规模的日志输出。


  目录