团队内部开发环境不一致的问题由来已久。新成员入职需要花费一整天甚至更长时间配置本地环境,不同项目依赖的PHP版本、扩展库、系统工具链各不相同,由此引发的“在我这儿是好的”争论屡见不鲜。标准化是唯一的出路,我们决定构建一个内部的“黄金镜像工厂”,让开发者能够通过Web界面自助申请、构建和下载预装好一切依赖的虚拟机或容器镜像。
技术痛点与初步构想
痛点很明确:
- 环境蔓延:缺乏统一的基线镜像,导致配置漂移。
- 手动构建:镜像制作过程依赖人工执行脚本,效率低下且易出错。
- 过程黑盒:构建过程对申请者不透明,失败后排查困难,需要运维介入。
我们的构想是一个自助服务平台。开发者在UI上选择基础操作系统、PHP版本、所需扩展等参数,提交后系统自动触发一个异步任务来构建镜像。最关键的一点是,构建过程的日志必须实时地反馈到前端界面,让开发者能像在本地终端一样看到完整的输出,这对于调试自定义的安装脚本至关重要。
技术选型决策与架构设计
这个需求横跨了基础设施、后端和前端,技术选型必须服务于核心目标——自动化、异步处理和实时反馈。
镜像构建:
Packer
这是业内的不二之选。HashiCorp的Packer通过一份HCL或JSON声明文件,可以为多个平台(VMware, VirtualBox, Docker, AWS AMI等)创建一致的机器镜像。它的可扩展性和声明式语法非常适合自动化。后端服务:
PHP
团队技术栈以PHP为主,复用现有能力可以加快开发。我们需要一个API接收前端的构建请求,并将其推送到一个任务队列中。使用成熟的PHP生态,如Swoole
或Workerman
可以轻松处理WebSocket,实现与前端的实时通信。任务队列与实时消息:
Redis
Redis在这里扮演两个核心角色。首先,它是一个高性能的任务队列后端(例如php-resque
或Laravel 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_open
和stream_select
的组合。它允许我们以非阻塞的方式从Packer进程的输出管道中读取数据,一旦有新数据就立即通过Redis Pub/Sub发布出去,实现了日志的实时性。
3. 后端WebSocket服务:日志的搬运工
一个简单的WebSocket服务器(可以用Swoole
或Ratchet
等库实现)负责订阅所有相关的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和前端内存中可能会成为瓶颈。可以考虑将日志分块存储,并在前端实现虚拟滚动,只渲染可视区域内的日志行,以应对超大规模的日志输出。