从“一键部署”到“可观测、可定制的发布流”:我如何打造一个企业级部署工具

从"一键部署"到"可观测、可定制的发布流":我如何打造一个企业级部署工具

大多数公司都用着各个 Git 厂商的自动构建、发布的 CI/CD 系统。仅需按一下,代码就自动构建、打包、上传、发布,然后我们就可以泡杯咖啡☕️,享受生活。

然而你知道这种 CI/CD 是如何实现的吗?下面我来教你实现一个企业级的 CI/CD 系统

📖 阅读本文,你将收获什么?

这篇文章不是一份枯燥的说明文档,而是一次沉浸式的开发之旅

跟随我的思路,你将具体了解到:

  • "钩子"的扩展性:如何通过生命周期钩子(Hooks)打造一个高可扩展的系统
  • 像乐高一样组合功能:将复杂流程拆解为独立、可复用的模块
  • 如何回滚代码:代码崩了,可迅速回到之前的版本
  • 如何实现美观的命令行
  • 如何为 CLI 工具构建优雅的"交互模式":让冰冷的命令行充满"人情味"

需要考虑的问题

部署流程中充满了不确定性:

  • ❓网络突然抖动怎么办
  • ❓多台服务器如何管理
  • ❓部署到一半出错了怎么回滚
  • ❓我想在部署前后发个通知,又该如何实现

正是这些"灵魂拷问"驱动我开发了这个开源项目。它不仅仅是一个一键部署的脚本,我更愿意称之为一个 可观测、可定制的自动化发布工作流引擎

✨ 核心功能亮点

在深入技术细节之前,来看看要实现哪些功能:

  • 🔨 全自动化流程: 自动完成本地构建、压缩、上传、远程部署和清理。
  • 🌐 多服务器支持: 支持并发或串行地将项目部署到多台服务器。
  • 🤝 灵活的交互模式: 在部署的每个关键节点暂停,由你确认后继续,让用户自己掌控。
  • 💾 智能备份与回滚: 自动备份历史版本,并控制备份数量,为快速回滚提供保障。
  • 🎣 丰富的生命周期钩子: 在构建、压缩、上传、部署等各个阶段,你都可以注入自己的逻辑(例如,发送飞书/钉钉通知)。
  • ⚠️ 结构化错误处理: 提供统一的错误码和错误类型,非常适合与 CI/CD 系统集成。

🚀 探索之旅:核心挑战与解决方案

💡如何让冰冷的命令行"听懂人话"?

一键部署 听起来很酷,但也意味着"一键到底",中间过程完全失控。如果我只是想构建一下,看看产物对不对,或者我只想部署到某一台测试机上,怎么办?我需要一个"暂停"按钮。

这些功能都可以通过 inquirer 来实现,下面是一个简单的例子:

typescript 复制代码
import inquirer from 'inquirer'

const { shouldContinue } = await inquirer.prompt([
  {
    type: 'confirm',
    name: 'shouldContinue',
    message: '是否继续执行构建?',
    default: true,
  },
])

if (shouldContinue) {
  console.log('继续执行构建')
}
else {
  console.log('停止构建')
}

通过这种方式,我们赋予了自动化流程一个"人性化"的交互界面,让开发者在享受自动化的同时,也能拥有完全的控制权。

💡如何让一个部署工具变得"可插拔"?

用户的需求是千变万化的。

  • 用户 A 想在部署成功后发一条钉钉消息。
  • 用户 B 希望在部署前,先调用一个内部 API 来锁定版本。
  • 用户 C 使用的是私有的代码仓库,需要特殊的认证方式。

如果我把这些功能全部硬编码到代码里,它会变得越来越臃肿,难以维护。我需要一种机制,让用户可以注入自己的逻辑到部署流程中。

所以可以通过 Hook 的方式暴露每个生命周期,让用户能自己 DIY

bash 复制代码
构建 -> 压缩 -> 连接 -> 上传 -> 部署 -> 清理

我提供了覆盖整个生命周期的钩子,如

  • onBeforeBuild
  • onAfterBuild
  • onBeforeCompress
  • onAfterCompress
  • onBeforeConnect
  • ...

通过一个通用的 executeHook 函数来调用 Hook 并提供上下文

typescript 复制代码
import { DeployError, DeployErrorCode }. from './types'

/** 定义通用的上下文接口 */
export interface HookContext {
  opts: DeployOpts
  stage: string // 当前阶段,如 'build', 'upload'
  // ... 其他信息
}

export async function executeHook<T extends HookContext>(
  hook: ((context: T) => Promise<void> | void) | undefined,
  context: T
): Promise<void> {
  // 只有当用户传递了这个钩子时才执行
  if (hook) {
    try {
      await hook(context) // 执行用户自定义的逻辑
    } 
    catch (error) {
      // 捕获钩子内部的错误,并包装成统一的 DeployError
      throw new DeployError(
        DeployErrorCode.UNKNOWN_ERROR,
        `Hook execution failed in stage ${context.stage}`,
        error
      )
    }
  }
}

在主流程的每个阶段,我们都会像这样调用 executeHook

typescript 复制代码
// ... 构建阶段 ...
await executeHook(opts.onBeforeBuild, buildContext)
await build(opts.buildCmd)
await executeHook(opts.onAfterBuild, buildContext)

// ... 压缩阶段 ...
await executeHook(opts.onBeforeCompress, compressContext)
await startZip(opts)
await executeHook(opts.onAfterCompress, compressContext)

// ... etc ...

💡 如何安全、稳定地连接到远程服务器?

  1. 专业的 SSH 库 :我们选择了久经考验的 ssh2 库,它为 Node.js 提供了稳定、功能丰富的 SSH2 客户端。

  2. 灵活的认证方式 :我们支持密码和私钥两种认证方式。在生产环境中,强烈推荐使用私钥,因为它比密码更安全。

    typescript 复制代码
    import { Client } from 'ssh2'
    const { readFileSync } = require('node:fs')
    const { homedir } = require('node:os')
    const { resolve } = require('node:path')
    
    const client = new Client()
    client.connect({
      host: '192.168.1.100',
      username: 'root',
      // ======================
      // * 使用密码或者 ssh 私钥
      // ======================
      password: 'your-server-password',
      privateKey: readFileSync(resolve(homedir(), '.ssh/id_rsa'), 'utf-8'),
    })
  3. 自动重试机制 :网络抖动是常有的事。为了对抗这种不确定性,我们内置了失败重试 机制。当连接或上传失败时,它会自动尝试,默认重试 3 次。这个次数可以通过 uploadRetryCount 选项进行配置。

    typescript 复制代码
    // src/connectAndUpload.ts 伪代码
    const uploadTasks = opts.connectInfos.map(connectInfo =>
      retryTask(
        () => attemptUploadToServer(connectInfo), // 尝试连接并上传
        opts.uploadRetryCount // 默认为 3
      )
    )
    
    /**
     * 失败后自动重试请求
     * @param task 任务函数,返回一个 Promise
     * @param maxCount 剩余重试次数,默认 3
     */
    export async function retryTask<T>(
      task: () => Promise<T>,
      maxCount = 3
    ): Promise<T> {
      return task()
        .then(res => res)
        .catch(async (err) => { // 捕获错误
          const remainingRetries = maxCount - 1
          if (remainingRetries <= 0) {
            console.error('任务失败,重试次数已耗尽:', err)
            throw new DeployError(
              DeployErrorCode.UPLOAD_RETRY_EXHAUSTED,
              '重试次数耗尽',
              err
            )
          }
          else {
            console.warn(`任务失败,剩余重试次数: ${remainingRetries}`, err)
            // 等待一小段时间再重试,避免立即重试导致连续失败
            await new Promise(resolve => setTimeout(resolve, 300))
            return retryTask(task, remainingRetries)
          }
        })
    }

通过这种设计,我们将连接的复杂性封装起来,为用户提供了一个既简单又可靠的接口。

💡 如何高效地压缩与上传?

将成百上千的构建产物(HTML, CSS, JS 文件等)传输到服务器,如果逐个传输,会非常缓慢且效率低下。

解决方案

  1. 先压缩,再上传 :我们将所有构建产物打包成一个 .tar.gz 文件。这是一种在 Linux/macOS 环境下非常标准的格式,它能将文件归档(tar)和压缩(gzip)合二为一。

  2. 追求极致的压缩率 :我们使用了 archiver 这个库,并默认开启了最高的压缩等级(level: 9)。这意味着会花费稍多一点的 CPU 时间来换取更小的文件体积,从而显著减少网络传输时间。对于部署流程来说,这是一个非常划算的"交易"。

    typescript 复制代码
    // src/startZip.ts
    const archive = archiver('tar', {
      gzip: true,
      gzipOptions: { level: 9 } // 最高的压缩等级
    })
  3. 大文件优化上传 :我们使用 SFTP 的 fastPut 方法,它为大文件传输做了优化。

  4. 友好的进度提示:无论是本地压缩还是远程上传,我们都提供了实时进度条。用户可以清晰地看到当前进度、传输速度和文件大小,极大地提升了大型项目部署时的用户体验。

  5. 并发上传 :当需要部署到多台服务器时,默认会启用并发模式(concurrent: true),同时向所有服务器上传文件,最大化地利用网络带宽,缩短部署总时长。

💡 如何设计一个"不容易崩"的远程部署策略?

"部署"操作是整个流程中最危险的一步。如何用新的文件替换旧的文件,同时保证服务的连续性?如果新版本有问题,如何快速回滚?

解决方案

  1. 默认的原子化部署命令 :我们提供了一个经过精心设计的默认部署命令,可以通过 deployCmd 配置自定义

    bash 复制代码
    # 1. 进入工作目录
    cd /remote/workdir 
    # 2. 干净利落地删除旧版本
    rm -rf /remote/project 
    # 3. 重新创建项目目录
    mkdir -p /remote/project
    # 4. 将压缩包解压到新的项目目录
    tar -xzf /remote/dist.tar.gz -C /remote/project
    # 5. 清理远程的压缩包
    rm -rf /remote/dist.tar.gz

    这种 先删除,再创建 的策略非常简单直接。它的好处是能确保每次部署都是一个全新的、干净的环境,不会有旧文件的残留。缺点是在 rmtar 之间的短暂瞬间,服务是不可用的。对于大多数前端项目来说,这个窗口期极短,几乎可以忽略不计。

  2. 智能备份与回滚:万一线上版本出了问题怎么办?我们提供了备份机制作为"后悔药"。

    • 在上传新版本时,我们会将当前的压缩包(也就是上一个稳定版)以时间戳命名 ,并复制到你指定的 remoteBackupDir 备份目录中。
    • 你只需要找到上一个版本的压缩包,重新部署一次,即可完成回滚。
    • 为了防止备份文件无限堆积,我们还提供了 maxBackupCount 选项(默认为 5),它会自动清理最旧的备份,只保留最近的几个版本。
  3. 自定义部署逻辑 :对于需要"零停机"部署的复杂应用(例如通过 symlink 软链接切换版本),你可以通过 customDeploy 回调函数,完全接管部署逻辑,实现你自己的高级部署策略。

💡 如何打造一个"话痨"又"贴心"的日志系统?

一个自动化工具,如果执行过程像个"黑盒",会让人非常没有安全感。我们需要提供丰富、实时、清晰的反馈,但又要避免无意义的刷屏。

解决方案

  1. 为信息"上色" :我们使用 kleur 库,为不同类型的日志赋予了不同的颜色。

    • 蓝色 用于流程说明
    • 绿色 用于成功状态
    • 黄色 用于警告
    • 红色 用于错误

    这让用户能在一瞥之间就抓住关键信息。

  2. "魔法"一样的单行进度条 :在压缩和上传大文件时,如果为每一小块数据都打印一行日志,屏幕会瞬间被占满。我们的秘诀是利用 process.stdout.write 和回车符 \r

    \r 的作用是让光标回到当前行的行首。当我们打印新的进度时,它会覆盖掉旧的进度,从而实现在同一行内不断更新的效果,干净又优雅。

    typescript 复制代码
    // src/logger.ts 伪代码
    class Logger {
      progress(config) {
        // ... 计算进度 ...
        const message = `\r${prefix} ${progressBar} ${progressText}`
    
        // 直接写入标准输出流,而不换行
        process.stdout.write(message)
    
        // 只有当进度完成时,才输出一个换行符
        if (current >= total) {
          process.stdout.write('\n')
        }
      }
    }
  3. 结构化的日志 :除了颜色,我们还通过 titledividerstagetable 等方法,将部署流程结构化地展示出来,让整个过程清晰明了。

🛠️ 如何使用?

安装依赖

bash 复制代码
npm install @jl-org/deploy
# 或
yarn add @jl-org/deploy
# 或
pnpm add @jl-org/deploy

创建脚本

scripts/deploy.cjs

js 复制代码
// @ts-check
const { deploy } = require('@jl-org/deploy')
const { resolve } = require('node:path')
const { homedir } = require('node:os')
const { readFileSync } = require('node:fs')

deploy({
  // ======================
  // 🔗 SSH 连接信息
  // ======================
  connectInfos: [
    {
      name: 'server-1', // 服务器名称(可选,用于日志显示)
      host: '192.168.1.100',
      port: 22,
      username: 'root',
      password: 'password',
      // 如果使用私钥登录,不使用密码登录的话
      // privateKey: readFileSync(resolve(homedir(), '.ssh/id_rsa'), 'utf-8'),
    }
  ],
  
  // ======================
  // 🔨 本地构建配置
  // ======================
  buildCmd: 'npm run build', // 构建命令
  distDir: resolve(__dirname, '../dist'), // 构建产物目录
  skipBuild: false, // 是否跳过构建步骤
  
  // 📦 压缩文件配置
  zipPath: resolve(__dirname, '../dist.tar.gz'), // 本地压缩文件路径
  
  // ======================
  // 🌐 远程服务器配置
  // ======================
  remoteZipPath: '/home/dist.tar.gz', // 远程压缩文件路径
  remoteUnzipDir: '/home/test-project', // 远程解压目录
  
  // ======================
  // 💾 备份配置(可选)
  // ======================
  remoteBackupDir: '/home/test-project-backup', // 远程备份目录
  maxBackupCount: 5, // 保留最近的备份数量
  
  // ======================
  // ⚙️ 其他选项
  // ======================
  needRemoveZip: true, // 是否需要删除本地压缩文件
  uploadRetryCount: 3, // 上传失败重试次数
  interactive: false, // 禁用交互模式(默认)
  concurrent: true // 并发部署(默认)
})

运行

bash 复制代码
node scripts/deploy.cjs

然后,就去泡杯咖啡吧!☕️

相关推荐
ulias2124 小时前
Linux系统中的权限问题
linux·运维·服务器
青花瓷5 小时前
Ubuntu下OpenClaw的安装(豆包火山API版)
运维·服务器·ubuntu
问简5 小时前
docker 镜像相关
运维·docker·容器
Dream of maid6 小时前
Linux(下)
linux·运维·服务器
齐鲁大虾6 小时前
统信系统UOS常用命令集
linux·运维·服务器
Benszen6 小时前
Docker容器化技术实战指南
运维·docker·容器
ZzzZZzzzZZZzzzz…6 小时前
Nginx 平滑升级:从 1.26.3 到 1.28.0,用户无感知
linux·运维·nginx·平滑升级·nginx1.26.3·nginx1.28.0
AI_零食7 小时前
声音分贝模拟与波动动画展示:鸿蒙Flutter框架 实现的声音可视化应用
学习·flutter·华为·开源·harmonyos
Hommy887 小时前
【开源剪映小助手】Docker 部署
docker·容器·开源·github·aigc
一叶知秋yyds8 小时前
Ubuntu 虚拟机安装 OpenClaw 完整流程
linux·运维·ubuntu·openclaw