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

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

大多数公司都用着各个 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

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

相关推荐
ihui数学建模14 分钟前
【Mac版】Linux 入门命令行快捷键+联想记忆
linux·运维·macos
*wj29 分钟前
【linux驱动开发】编译linux驱动程序报错:ERROR: Kernel configuration is invalid.
linux·运维·驱动开发
开发者小天40 分钟前
Node.js中Buffer的用法
node.js·编辑器·vim
conkl1 小时前
嵌入式 Linux 深度解析:架构、原理与工程实践(增强版)
linux·运维·服务器·架构·php·底层·堆栈
阿星做前端1 小时前
如何构建一个自己的 Node.js 模块解析器:node:module 钩子详解
前端·javascript·node.js
鹧鸪云光伏2 小时前
光伏运维数据透明化,发电量提高45%
运维·光伏·光伏设计·光伏模拟·光伏配储
Albert_Lsk3 小时前
【2025/08/01】GitHub 今日热门项目
人工智能·开源·github·开源协议
AI视觉网奇3 小时前
whisper tokenizer
linux·运维·服务器
MX_93593 小时前
使用Nginx部署前端项目
运维·前端·nginx
srrsheng4 小时前
电商前端Nginx访问日志收集分析实战
运维·前端·nginx