Skip to content

实战:运维工具

这一篇把前面八篇的内容串成一个完整的小工具:CLI 入口(commander)→ 核心业务函数 → 配置文件读取 → 同时支持 CLI、定时任务和 Web 面板三种运行方式。核心原则是:业务逻辑不依赖运行方式,CLI、定时任务和 HTTP 只是同一套函数的三个外壳。

项目结构

text
ops-tool/
  package.json
  bin/
    ops-tool.js         CLI 入口——只处理参数和输出
  src/
    check-service.js    核心检查逻辑——不写 console.log
    config.js           配置读取和校验
  server.js             Web 面板入口

bin/ 是约定的 CLI 可执行文件目录,src/ 放核心函数。CLI 和 Web 面板各有一个入口,都引用 src/ 的函数。

初始化项目

bash
mkdir ops-tool && cd ops-tool
npm init -y
npm install commander

package.json:

json
{
  "name": "ops-tool",
  "version": "0.1.0",
  "type": "module",
  "bin": {
    "ops-tool": "./bin/ops-tool.js"
  },
  "scripts": {
    "start": "node bin/ops-tool.js",
    "server": "node server.js"
  },
  "dependencies": {
    "commander": "^12.0.0"
  }
}

bin 字段告诉 npm:装这个包时,把 bin/ops-tool.js 链到 /usr/local/bin/ops-tool,终端里就能直接敲 ops-tool 了。

核心检查函数

src/check-service.js

js
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);
// promisify 把 callback 风格函数转成返回 Promise 的函数

export async function checkService(service) {
  try {
    const { stdout } = await execFileAsync(
      "systemctl",
      ["is-active", service],
      { timeout: 3000 }
    );
    return {
      service,
      active: stdout.trim() === "active",
      checkedAt: new Date().toISOString()
    };
  } catch (err) {
    // 不向上抛异常——巡检场景希望一个服务失败不影响其他
    return {
      service,
      active: false,
      error: err.message,
      checkedAt: new Date().toISOString()
    };
  }
}

export async function checkAll(services) {
  // 串行检查——输出顺序稳定,且不会对 systemctl 产生并发压力
  const results = [];
  for (const service of services) {
    results.push(await checkService(service));
  }
  return results;
}

promisify 适用于 Node.js 内置的 callback 风格 API(fs.*child_process.* 等)。它把最后一个参数是 (err, result) => {} 回调的函数转成 Promise——前提是那个函数遵循这种约定。

配置读取

src/config.js

js
import { readFile } from "node:fs/promises";

export async function loadConfig(file) {
  try {
    const text = await readFile(file, "utf8");
    const config = JSON.parse(text);

    if (!Array.isArray(config.services)) {
      throw new Error("config.services must be an array");
    }
    if (config.services.some((s) => typeof s !== "string" || s.length === 0)) {
      throw new Error("each service must be a non-empty string");
    }

    return config;
  } catch (err) {
    // 补文件名上下文——排查多个配置文件时快速定位
    throw new Error(`failed to load config ${file}: ${err.message}`);
  }
}

配置文件 ops-tool.json

json
{
  "services": ["nginx", "sshd", "crond"]
}

CLI 入口

bin/ops-tool.js

js
#!/usr/bin/env node
// shebang 行——让 Linux 直接用 node 执行这个文件

import { Command } from "commander";
import { checkService, checkAll } from "../src/check-service.js";
import { loadConfig } from "../src/config.js";

const program = new Command();

program
  .name("ops-tool")
  .description("ops helper")
  .version("0.1.0");

// check-service 子命令:手动指定要查哪些服务
program
  .command("check-service")
  .argument("<services...>")        // 必填参数,可以传多个
  .option("--json", "output json")  // 可选 flag
  .action(async (services, options) => {
    const results = [];
    for (const service of services) {
      results.push(await checkService(service));
    }

    if (options.json) {
      console.log(JSON.stringify(results, null, 2));
    } else {
      for (const r of results) {
        const status = r.active ? "active" : (r.error || "inactive");
        console.log(`${r.service}\t${status}`);
      }
    }
  });

// check 子命令:从配置文件读服务列表
program
  .command("check")
  .option("-c, --config <file>", "config file", "ops-tool.json")
  .option("--json", "output json")
  .action(async (options) => {
    const config = await loadConfig(options.config);
    const results = await checkAll(config.services);

    if (options.json) {
      console.log(JSON.stringify(results, null, 2));
    } else {
      for (const r of results) {
        const status = r.active ? "active" : (r.error || "inactive");
        console.log(`${r.service}\t${status}`);
      }
    }
  });

// parseAsync() 是 commander 9.x+ 的异步解析——支持 async action
program.parseAsync().catch((err) => {
  console.error(err.message);
  process.exit(1);
});

使用:

bash
# 本地开发
node bin/ops-tool.js check-service nginx sshd
node bin/ops-tool.js check --config ops-tool.json --json

# 全局安装后
chmod +x bin/ops-tool.js
npm link
ops-tool check-service nginx sshd --json

npm link 在当前项目建全局软链接,开发机上可以像已安装的命令一样调 ops-tool,改代码立刻生效,不需要每次都 npm install -g

Web 面板

server.js

js
import express from "express";
import { checkAll } from "./src/check-service.js";
import { loadConfig } from "./src/config.js";

const app = express();
const port = Number(process.env.PORT || 3000);

// 封装异步路由,省掉重复 try/catch + next(err)
function asyncHandler(fn) {
  return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}

app.get("/healthz", (req, res) => {
  res.json({ ok: true });
});

app.get("/api/services", asyncHandler(async (req, res) => {
  const config = await loadConfig("ops-tool.json");
  const results = await checkAll(config.services);
  res.json(results);
}));

// 简单 HTML 面板——只读展示
app.get("/", asyncHandler(async (req, res) => {
  const config = await loadConfig("ops-tool.json");
  const results = await checkAll(config.services);

  const rows = results.map((r) => {
    const status = r.active
      ? "active"
      : (r.error ? `error: ${r.error}` : "inactive");
    return `<tr><td>${r.service}</td><td>${status}</td></tr>`;
  }).join("");

  res.type("html").send(`<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Ops Panel</title>
<style>body{font-family:system-ui;padding:2em} table{border-collapse:collapse}
td{padding:8px 16px;border:1px solid #ccc}</style></head>
<body>
<h1>Service Status</h1><table><tbody>${rows}</tbody></table>
<p><small>checked at ${new Date().toISOString()}</small></p>
</body></html>`);
}));

// 统一错误处理
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "internal server error" });
});

const server = app.listen(port, "0.0.0.0", () => {
  console.log(`ops panel listening on port ${port}`);
});

// 优雅退出
process.on("SIGTERM", () => {
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(1), 10_000).unref();
});

这个面板只做只读展示。加了操作按钮(重启服务、执行命令)时,要同时加鉴权、审计和操作确认——内部平台出事通常不是技术复杂度,是操作入口太随意。

定时任务

crontab 方式:

bash
*/5 * * * * /usr/local/bin/ops-tool check --config /etc/ops-tool/config.json --json >>/var/log/ops-tool.log 2>&1

systemd timer 方式(推荐——日志、状态、上次执行时间都更好查):

ini
# /etc/systemd/system/ops-tool.service
[Unit]
Description=Run ops-tool check

[Service]
Type=oneshot
ExecStart=/usr/local/bin/ops-tool check --config /etc/ops-tool/config.json --json
ini
# /etc/systemd/system/ops-tool.timer
[Unit]
Description=Run ops-tool every 5 minutes

[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Unit=ops-tool.service

[Install]
WantedBy=timers.target
bash
systemctl daemon-reload
systemctl enable --now ops-tool.timer
systemctl list-timers ops-tool.timer
systemctl status ops-tool.service

发布

内网不能直接 npm install 时,打包分发:

bash
# 在构建机或开发机上准备好依赖
npm ci --omit=dev

# 整体打包项目目录
tar czf ops-tool.tar.gz \
  package.json package-lock.json \
  bin/ src/ node_modules/

目标服务器:

bash
tar xzf ops-tool.tar.gz -C /opt/ops-tool
cd /opt/ops-tool
node bin/ops-tool.js check --config /etc/ops-tool/config.json --json

也可以用 npm pack 生成 .tgz 包:

bash
npm pack                     # 生成 ops-tool-0.1.0.tgz
npm install -g ./ops-tool-0.1.0.tgz   # 全局安装
ops-tool check-service nginx

设计要点回顾

这个小工具的几个设计选择:

  1. 核心函数不写 console.log、不读 process.argv——checkService 返回数据,输出格式由 CLI 层决定。同一天写 CLI 和 API 时不用写两遍逻辑

  2. 捕获异常返回结果对象——巡检场景的预期是"一个服务失败不影响其他";如果抛异常,checkAll 里一个服务挂了后面全停

  3. CLI 输出格式稳定——JSON 和 TSV 都不在中间加装饰文字。有人接了管道处理这些输出后,格式就成了隐式接口

  4. async/await 贯穿始终——从文件读取到命令执行到 HTTP 响应,统一的异步模型比 callback 嵌套好排错

  5. 优雅退出——Web 面板处理 SIGTERM,容器和 systemd 能干净退出,不会残留半截连接和临时文件

Node.js 在运维里比较合适的位置:读配置、调命令、调 API、出报告、做轻量 HTTP 服务。当工具需要大量 CPU 计算、复杂的后台调度、强类型安全的业务逻辑时,换 Go/Rust/Java 比用 Node.js 硬撑更省维护成本。