团队面临一个棘手的现实:我们的技术栈存在明显的代沟。一部分是基于 Terraform 和容器构建的云原生服务,遵循不可变基础设施理念;另一部分则是运行在传统虚拟机上的庞大单体应用,其配置复杂且状态繁多,长期以来依赖 Chef 进行精细化管理。这导致开发人员需要维护两套完全独立的部署流程和工具链,不仅效率低下,而且认知负担极重。目标是构建一个统一的内部开发者平台(IDP),让开发者能通过一个界面,无差别地部署这两种截然不同的应用。
最初的方案是尝试用单一工具统一所有部署。
方案A,全面拥抱 Terraform。对于云原生服务,它自然是最佳选择。但对于遗留的单体应用,我们尝试使用 remote-exec
和 local-exec
provisioner 来调用脚本,模拟 Chef 的配置过程。这很快就变成了一场灾难。Terraform 的核心是声明式地管理资源生命周期,而不是执行复杂的、过程式的配置任务。将几十个步骤的配置脚本塞进 provisioner,不仅让 Terraform 的 plan/apply 过程变得极其缓慢且不可预测,更破坏了其幂等性。任何微小的配置变更都可能触发整个资源的重建,这是生产环境无法接受的。在真实项目中,Terraform provisioner 只应被用于引导,而非持续的配置管理。
方案B,回归 Chef。Chef 拥有强大的云插件,理论上可以调用云厂商 API 创建虚拟机。但这样做,我们就失去了 Terraform 带来的基础设施状态管理、依赖图分析和强大的社区模块生态。用 Chef 的命令式 Ruby 代码去描述整个 VPC、子网、安全组和负载均衡器,代码量和复杂度远超 HCL。这本质上是用一个配置管理工具去解决基础设施编排的问题,是一种错配。
最终的决策是接受并拥抱这种异构性,构建一个上层编排器来粘合 Terraform 和 Chef,让它们各司其职。Terraform 负责“创建和销毁基础设施”,Chef 负责“在已创建的设施上进行精细化配置”。这个编排器将作为 IDP 的后端核心,而其前端则需要一个能够清晰、实时展示漫长部署过程的界面。
架构概览:编排核心与策略模式
编排器的核心设计必须是可扩展的。今天我们有 Terraform 和 Chef,明天可能就会引入 Ansible 或 Pulumi。因此,策略模式(Strategy Pattern)成为了架构的基石。我们定义一个统一的 Provisioner
接口,将不同工具的执行逻辑封装在各自的策略实现中。
graph TD A[Solid.js Frontend] -->|REST API/WebSocket| B(Orchestrator Backend); B --> C{Deployment Controller}; C -->|Selects Strategy| D(Provisioner Interface); D -- implemented by --> E[TerraformStrategy]; D -- implemented by --> F[ChefStrategy]; E --> G[Terraform CLI Adapter]; F --> H[Chef Knife CLI Adapter]; G --> I(Terraform State); H --> J(Chef Server); subgraph "Execution Plane" G; H; end subgraph "State Management" I; J; end
这个架构中,Orchestrator Backend
接收来自 Solid.js Frontend
的部署请求。Deployment Controller
根据请求类型(例如,”aws-vm-with-legacy-app”)选择一个执行链,该链可能包含 TerraformStrategy
和 ChefStrategy
。每个 Strategy 内部通过一个适配器(Adapter Pattern)与具体的命令行工具交互,统一了日志输出、错误处理和状态返回的格式。
后端编排器实现:Go 与设计模式
我们选择 Go 语言来构建后端,因其出色的并发性能和静态类型安全,非常适合处理这种长链接、重 I/O 的任务。
首先定义核心接口和结构体。
// pkg/provisioner/provisioner.go
package provisioner
import (
"context"
"io"
)
// ExecutionContext holds all necessary info for a provisioning step.
type ExecutionContext struct {
// A unique ID for tracking the entire deployment job.
JobID string
// A writer to stream real-time logs back to the client.
LogStream io.Writer
// Key-value pairs of configuration parameters.
Parameters map[string]string
// Outputs from a previous step, e.g., Terraform outputs.
UpstreamOutput map[string]string
}
// Result represents the outcome of a provisioning step.
type Result struct {
Success bool
// Outputs to be passed to the next step.
Output map[string]string
// A summary of what was done.
Summary string
// Detailed error message if Success is false.
Error string
}
// Provisioner is the strategy interface.
// Each tool (Terraform, Chef, etc.) will have its own implementation.
type Provisioner interface {
// Name returns the unique name of the provisioner.
Name() string
// Provision executes the provisioning logic.
Provision(ctx context.Context, execCtx *ExecutionContext) (*Result, error)
}
这里的 Provisioner
接口是策略模式的核心。ExecutionContext
负责在不同的策略之间传递状态和参数。
接下来是 Terraform 策略的实现。它需要做的是:生成 .tfvars
文件,执行 terraform init
, terraform apply
,最后解析 terraform output
。
// pkg/provisioner/terraform_strategy.go
package provisioner
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"log"
"yourapp/pkg/adapter" // CLI adapter package
)
type TerraformStrategy struct {
// Path to the directory containing Terraform modules.
workDir string
cli *adapter.TerraformCLI
}
func NewTerraformStrategy(workDir string) *TerraformStrategy {
return &TerraformStrategy{
workDir: workDir,
cli: adapter.NewTerraformCLI(workDir),
}
}
func (t *TerraformStrategy) Name() string {
return "terraform"
}
func (t *TerraformStrategy) Provision(ctx context.Context, execCtx *ExecutionContext) (*Result, error) {
// 1. Prepare tfvars file from parameters
// This is a critical step for isolating executions.
tfvarsPath := filepath.Join(t.workDir, fmt.Sprintf("%s.auto.tfvars.json", execCtx.JobID))
defer os.Remove(tfvarsPath) // Cleanup
tfvars, err := json.Marshal(execCtx.Parameters)
if err != nil {
return nil, fmt.Errorf("failed to marshal tfvars: %w", err)
}
if err := os.WriteFile(tfvarsPath, tfvars, 0644); err != nil {
return nil, fmt.Errorf("failed to write tfvars file: %w", err)
}
fmt.Fprintf(execCtx.LogStream, "[TERRAFORM] Wrote variables to %s\n", tfvarsPath)
// 2. Run terraform init and apply
// The adapter handles streaming stdout/stderr to LogStream.
if err := t.cli.Init(ctx, execCtx.LogStream); err != nil {
log.Printf("ERROR: Terraform init failed for job %s: %v", execCtx.JobID, err)
return &Result{Success: false, Error: "Terraform init failed"}, err
}
fmt.Fprintln(execCtx.LogStream, "[TERRAFORM] Apply starting...")
if err := t.cli.Apply(ctx, execCtx.LogStream); err != nil {
log.Printf("ERROR: Terraform apply failed for job %s: %v", execCtx.JobID, err)
return &Result{Success: false, Error: "Terraform apply failed"}, err
}
fmt.Fprintln(execCtx.LogStream, "[TERRAFORM] Apply completed successfully.")
// 3. Parse outputs
outputs, err := t.cli.Output(ctx)
if err != nil {
log.Printf("ERROR: Terraform output failed for job %s: %v", execCtx.JobID, err)
// This is a tricky state: infra is up, but we can't get its details.
// Requires manual intervention, so we must fail the job.
return &Result{Success: false, Error: "Infrastructure created, but failed to get outputs"}, err
}
fmt.Fprintln(execCtx.LogStream, "[TERRAFORM] Outputs retrieved.")
return &Result{
Success: true,
Output: outputs,
Summary: "Terraform apply successful.",
}, nil
}
注意这里的错误处理。如果 apply
成功但 output
失败,这是一个危险的中间状态,必须明确地向上层报告。CLI 适配器(未展示完整代码)是另一个关键,它使用 exec.CommandContext
来启动子进程,并用 io.MultiWriter
将子进程的 stdout
和 stderr
同时写入日志流和内部缓冲区,以便进行错误分析。
现在是 Chef 策略。它的任务是接收 Terraform 的输出(比如新创建的虚拟机的 IP 地址),然后使用 knife bootstrap
命令来安装 Chef Infra Client 并执行指定的 run-list。
// pkg/provisioner/chef_strategy.go
package provisioner
import (
"context"
"fmt"
"log"
"yourapp/pkg/adapter"
)
type ChefStrategy struct {
cli *adapter.ChefKnifeCLI
}
func NewChefStrategy(chefRepoPath string) *ChefStrategy {
return &ChefStrategy{
cli: adapter.NewChefKnifeCLI(chefRepoPath),
}
}
func (c *ChefStrategy) Name() string {
return "chef"
}
func (c *ChefStrategy) Provision(ctx context.Context, execCtx *ExecutionContext) (*Result, error) {
// 1. Extract necessary info from upstream (Terraform) output.
targetIP, ok := execCtx.UpstreamOutput["instance_public_ip"]
if !ok || targetIP == "" {
return &Result{Success: false, Error: "Missing 'instance_public_ip' from upstream output"},
fmt.Errorf("required upstream output not found")
}
// Parameters for this step should contain Chef-specific info.
runList, ok := execCtx.Parameters["run_list"]
if !ok || runList == "" {
return &Result{Success: false, Error: "Missing 'run_list' parameter for Chef step"}, nil
}
nodeName := fmt.Sprintf("node-%s", execCtx.JobID)
fmt.Fprintf(execCtx.LogStream, "[CHEF] Starting bootstrap for node %s at %s\n", nodeName, targetIP)
fmt.Fprintf(execCtx.LogStream, "[CHEF] Run list: %s\n", runList)
// 2. Execute knife bootstrap
// The adapter will construct the full command with necessary credentials.
err := c.cli.Bootstrap(ctx, execCtx.LogStream, adapter.BootstrapOptions{
TargetIP: targetIP,
NodeName: nodeName,
RunList: runList,
SshUser: execCtx.Parameters["ssh_user"], // e.g., "ec2-user"
SshKeyPath: execCtx.Parameters["ssh_key_path"],
})
if err != nil {
log.Printf("ERROR: Chef bootstrap failed for job %s: %v", execCtx.JobID, err)
// A failed Chef run is a critical failure. The infrastructure from Terraform
// is now in a "dirty" state and might need to be destroyed and reprovisioned.
return &Result{
Success: false,
Error: fmt.Sprintf("Chef bootstrap failed: %s", err.Error()),
}, err
}
fmt.Fprintf(execCtx.LogStream, "[CHEF] Bootstrap completed successfully for node %s.\n", nodeName)
return &Result{
Success: true,
Summary: fmt.Sprintf("Node %s configured with run list: %s", nodeName, runList),
Output: map[string]string{"chef_node_name": nodeName},
}, nil
}
这个策略消费了上一步的输出 UpstreamOutput
,这是整个工作流能够串联起来的关键。同样,错误处理非常重要。一个失败的 Chef run 意味着资源虽然创建成功了,但处于不可用状态。
前端界面:Solid.js 的精细化响应
漫长的后端任务对前端提出了一个要求:必须能够实时、高效地展示日志流和状态变化,而不能因为频繁的数据更新导致整个页面卡顿或闪烁。这正是 Solid.js 的用武之地。它的细粒度响应式系统不依赖虚拟 DOM,而是直接创建响应式计算图,当信号(Signal)变化时,只更新与之相关的 DOM 节点。
我们创建一个 DeploymentView
组件来展示部署过程。
// src/components/DeploymentView.jsx
import { createSignal, createResource, onCleanup, Show } from "solid-js";
import { createStore } from "solid-js/store";
// A mock API client. In a real app, this would use fetch or a library.
const apiClient = {
// Initiates a deployment, returns a job ID.
startDeployment: async (params) => {
// POST /api/deployments
// ... returns { jobId: "..." }
const response = await fetch('/api/deployments', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params),
});
if (!response.ok) throw new Error("Failed to start deployment");
return response.json();
},
// Fetches the current status and logs for a job.
getJobStatus: async (jobId) => {
// GET /api/deployments/{jobId}/status
// ... returns { status: "running" | "success" | "failed", logs: "...", error: "..." }
const response = await fetch(`/api/deployments/${jobId}/status`);
if (!response.ok) return { status: 'failed', error: 'Failed to fetch status' };
return response.json();
}
};
function DeploymentView() {
const [jobId, setJobId] = createSignal(null);
const [params, setParams] = createStore({
type: "aws-vm-with-legacy-app",
instance_type: "t2.micro",
// ... other form fields
});
// createResource is perfect for managing async data flows.
// It handles loading, error, and ready states automatically.
const [jobStatus] = createResource(jobId, (id) => {
// This is the fetcher function. It will re-run when jobId() changes.
// We need a polling mechanism for live updates.
const poll = async (resolver) => {
const status = await apiClient.getJobStatus(id);
resolver(status);
// Continue polling if the job is still running.
if (status.status === 'running' || status.status === 'pending') {
const timerId = setTimeout(() => poll(resolver), 2000);
// onCleanup ensures the timer is cleared if the component is unmounted
// or if jobId changes, preventing memory leaks.
onCleanup(() => clearTimeout(timerId));
}
};
// We return a promise that is manually resolved by our polling function.
return new Promise(poll);
});
const handleDeploy = async (e) => {
e.preventDefault();
try {
const { jobId } = await apiClient.startDeployment(params);
setJobId(jobId);
} catch (err) {
console.error("Deployment initiation failed:", err);
// Display an error message to the user
}
};
return (
<div>
<form onSubmit={handleDeploy}>
{/* Form inputs to update the 'params' store */}
<button type="submit" disabled={jobStatus.loading || (jobStatus() && jobStatus().status === 'running')}>
Deploy
</button>
</form>
<Show when={jobId()}>
<div class="status-container">
<h3>Deployment Status: {jobStatus.loading ? 'Loading...' : jobStatus()?.status}</h3>
<pre class="logs">
<code>
{/*
This is the magic of Solid.js. When jobStatus() updates,
only this text node will be changed. The surrounding DOM
elements remain untouched. No VDOM diffing needed.
*/}
{jobStatus()?.logs}
</code>
</pre>
<Show when={jobStatus()?.status === 'failed'}>
<p class="error">Error: {jobStatus()?.error}</p>
</Show>
</div>
</Show>
</div>
);
}
export default DeploymentView;
在这个组件中,createResource
与一个手动控制的轮询函数结合,持续从后端获取最新的作业状态。onCleanup
钩子是关键,它确保在组件卸载或 jobId
改变时,旧的轮询定时器会被清理,避免了内存泄漏和不必要的网络请求。最重要的是,当 jobStatus()
更新时,只有显示日志的 <code>
块的文本内容和状态文本会更新。整个组件的其余部分,包括表单,都不会被重新渲染。这种外科手术式的更新方式带来了极致的性能,对于实时日志这种高频更新场景是理想的选择。在实践中,为了获得真正的实时性,可以使用 WebSocket 替代轮询,Solid.js 的响应式原语同样能完美地与之集成。
架构的局限与未来路径
这个混合式编排器有效地解决了眼下的问题,但它并非银弹。其最主要的局限性在于引入了一个新的复杂层。
首先,状态管理变得更加分散。我们现在必须同时关注 Terraform 的 .tfstate
文件和 Chef Server 上的节点信息。如果编排器在 terraform apply
成功后、knife bootstrap
开始前崩溃,我们就得到了一个基础设施存在但未被配置的“孤儿”资源。这要求编排器自身必须具备持久化和恢复能力,例如通过数据库记录每个作业执行到哪一步,以便在重启后可以尝试继续或回滚。
其次,幂等性边界模糊。Terraform 和 Chef 各自都追求幂等性,但将它们串联起来的整个工作流的幂等性却难以保证。重试一次失败的 Chef 部署是安全的,但如果失败的原因是网络问题导致 Terraform 的输出未能正确传递,重试整个工作流可能会触发 Terraform 去创建新的资源,而不是在已有资源上重试 Chef。设计一个真正幂等的多阶段工作流需要复杂的补偿事务逻辑(Saga 模式),这会显著增加编排器的复杂度。
未来的优化路径可能包括引入一个成熟的工作流引擎(如 Temporal 或 Camunda)来替代我们手写的控制器,由它来负责状态持久化、重试和补偿逻辑。此外,随着团队对云原生理念的深入,长远目标应是逐步改造遗留应用,使其能够容器化并用更统一的方式(如 Helm 或 Kustomize on Kubernetes)进行部署,最终让 Chef 这个策略的使用场景越来越少,直至被淘汰。