构建混合式部署编排器:融合 Terraform 与 Chef 并应用策略模式驱动 Solid.js 动态界面


团队面临一个棘手的现实:我们的技术栈存在明显的代沟。一部分是基于 Terraform 和容器构建的云原生服务,遵循不可变基础设施理念;另一部分则是运行在传统虚拟机上的庞大单体应用,其配置复杂且状态繁多,长期以来依赖 Chef 进行精细化管理。这导致开发人员需要维护两套完全独立的部署流程和工具链,不仅效率低下,而且认知负担极重。目标是构建一个统一的内部开发者平台(IDP),让开发者能通过一个界面,无差别地部署这两种截然不同的应用。

最初的方案是尝试用单一工具统一所有部署。

方案A,全面拥抱 Terraform。对于云原生服务,它自然是最佳选择。但对于遗留的单体应用,我们尝试使用 remote-execlocal-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”)选择一个执行链,该链可能包含 TerraformStrategyChefStrategy。每个 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 将子进程的 stdoutstderr 同时写入日志流和内部缓冲区,以便进行错误分析。

现在是 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 这个策略的使用场景越来越少,直至被淘汰。


  目录