AI 摘要
AI
正在生成摘要...

Supervisor进程管理工具

一、Supervisor简单介绍

1. Supervisor是什么

Supervisor是一种用Python开发的进程管理工具,用于在Linux等类Unix系统上管理后台服务进程。它的主要用途是监控进程状态,并在进程异常退出时自动重启。它通过一个名为supervisord的服务器端进程来管理,并提供一个名为supervisorctl的客户端命令行工具来控制和监视这些进程。

与传统的进程管理方式(如 nohup& 后台运行)相比,Supervisor 具有本质优势:它通过 fork/exec 机制将被管理进程作为子进程启动,能够实时监控子进程状态,在进程异常退出时自动重启,从而确保服务的高可用性。

它是通过fork/exec的方式把这些被管理的进程当作supervisor的子进程来启动,这样只要在supervisor的配置文件中,把要管理的进程的可执行文件的路径写进去即可。也实现当子进程挂掉的时候,父进程可以准确获取子进程挂掉的信息的,可以选择是否自己启动和报警。supervisor还提供了一个功能,可以为supervisord或者每个子进程,设置一个非root的user,这个user就可以管理它对应的进程。

Supervisor 凭借子进程托管、精准状态感知、灵活管控能力,解决了传统 Linux 进程管理编写启停脚本繁琐、服务异常无保活、状态反馈不准、无法批量操作等痛点,再搭配进程分组、集中配置、权限细分与扩展能力,无论是单机服务托管,还是多进程统一运维,都能做到简单易用、状态可信、管理高效、运行稳定,是 Linux 环境下托管后台服务、保障服务高可用的轻量化、标准化进程管理方案。

2. Supervisor主要功能

主要功能:

  • 进程控制: 启动、停止、重启进程,并确保它们一直运行。
  • 进程监控: 持续跟踪进程状态,在进程崩溃时自动重启。
  • 日志捕获: 捕获被管理进程的标准输出和错误流,方便查看和调试。
  • 配置管理: 通过中心化的配置文件来定义和管理需要运行的进程。
  • Web界面和命令行: 提供一个Web界面和命令行接口,方便管理和监控。
  • 分组管理: 可以将多个进程分组,并作为一个整体进行控制。
  • 事件通知: 当进程状态发生变化时,可以发出事件通知,用于与其他系统集成

二、Supervisor的使用

1. 安装Supervisor

Ubuntu系统:

SHELL
sudo apt update
sudo apt install supervisor -y

自己可编辑配置文件:

SHELL
root@hzy-baidubcc:~# ls /etc/supervisor/
conf.d  supervisord.conf

# supervisor配置文件:/etc/supervisor/supervisord.conf

vim /etc/supervisor/supervisord.conf

# web端登陆的用户密码以及端口
[inet_http_server]
port=8080
username=admin
password=admin

CentOS/RHEL 系统

SHELL
sudo yum install epel-release -y
sudo yum install supervisor -y

源码安装(适用于所有系统)

SHELL
# 安装依赖
pip install setuptools
# 下载源码
wget https://pypi.python.org/packages/source/s/supervisor/supervisor-4.2.5.tar.gz
tar zxvf supervisor-4.2.5.tar.gz
cd supervisor-4.2.5
# 安装
python setup.py install

2. 主配置文件解读

supervisor的默认配置文件在/etc/supervisor/supervisord.conf ,默认配置文件解读如下:

BASH
;  Unix socket 配置(本地通信)
[unix_http_server]
file=/tmp/supervisor.sock   ; socket 文件路径
;chmod=0700                 ; 文件权限
;chown=nobody:nogroup       ; 所属用户组

;  supervisor 核心配置
[supervisord]
logfile=/var/log/supervisord.log  ; 日志文件路径
logfile_maxbytes=50MB             ; 单日志文件最大大小
logfile_backups=10                ; 日志备份数量
loglevel=info                     ; 日志级别(debug/info/warn/error/critical)
pidfile=/var/run/supervisord.pid  ; PID文件路径
nodaemon=false                    ; 是否前台运行(false为后台运行)
minfds=1024                       ; 最小文件描述符数量
minprocs=200                      ; 最小进程数
;user=www-data                    ; 运行用户(非root时指定)

;  supervisorctl 客户端配置
[supervisorctl]
serverurl=unix:///tmp/supervisor.sock  ; 与服务端通信的socket路径
;serverurl=http://127.0.0.1:8080       ; 也可使用HTTP连接

;  包含子进程配置文件
[include]
files = /etc/supervisor/conf.d/*.conf  ; 加载所有.conf结尾的配置文件

启动web控制台,修改配置文件:

BASH
;  Web 管理界面配置
[inet_http_server]
port=0.0.0.0:8080           ; 监听地址和端口(0.0.0.0 表示允许所有IP访问)
username=admin              ; 登录用户名
password=admin              ; 登录密码

保存之后重启supervisor:

BASH
systemctl restart supervisor.service

进入浏览器进行访问:

image-20251011114056171

image-20260331164413053

登陆完成,至此supervisor基本搭建完成。

可以查看到,web控制台上有三个默认按钮,这三个是 Supervisor 原生 Web 管理控制台的全局操作按钮,作用于所有被 supervisord 托管的后台进程,功能、风险和使用场景完全不同:

REFRESH唯一安全、无任何业务影响的按钮,手动刷新页面,从 supervisord 服务拉取最新的进程状态数据,更新页面显示。

RESTART ALL批量强制重启所有被 Supervisor 管理的进程

  • 会导致所有业务服务短暂中断,生产环境仅在系统维护、版本升级、批量重启服务等场景下使用,操作前必须确认业务影响。
  • 无论进程当前是 RUNNING 还是其他状态,都会被强制重启,不受 autorestart 配置影响。
  • 进程组会按照组内优先级统一执行重启。

STOP ALL:批量强制停止所有被 Supervisor 管理的进程

  • 点击后所有业务服务会直接停止,导致全业务中断,误操作会造成严重生产事故。

  • 仅在服务器停机维护、版本下线等极端场景下使用,操作前必须做好业务停机通知和数据备份。

3. 子进程配置文件解读

默认情况下,子进程(我愿意叫托管进程)的配置文件,配置文件路径为 /etc/supervisor/conf.d/,需要托管的进程,在该路径下配置相关配置文件:

BASH
[program:echo_time]  ; 进程名称(唯一标识)
command=sh /project/version1/echo_time.sh  ; 执行命令
directory=/project/version1                ; 工作目录
user=www-data                              ; 运行用户
autostart=true                             ; 随supervisor启动而启动
autorestart=true                           ; 进程退出时自动重启
startretries=3                             ; 启动失败重试次数
redirect_stderr=true                       ; 重定向 stderr 到 stdout
stdout_logfile=/var/log/echo_time.log      ; 标准输出日志路径
stdout_logfile_maxbytes=10MB               ; 日志文件最大大小
stdout_logfile_backups=5                   ; 日志备份数量
stopasgroup=true                           ; 停止进程时同时停止子进程
killasgroup=true                           ; 杀死进程时同时杀死子进程

高级选择:

BASH
; 环境变量设置
environment=
    PATH="/usr/local/bin:/usr/bin",
    DATABASE_URL="mysql://user:pass@localhost/db"
; 启动前执行的命令
;prestart=/path/to/prestart_script.sh
; 停止后执行的命令
;poststop=/path/to/poststop_script.sh
; 启动超时时间(秒)
startsecs=10
; 停止等待时间(秒)
stopwaitsecs=60
; 进程优先级(值越小优先级越高)
priority=999
; 仅当特定事件发生时才重启
;autorestart=unexpected  ; 仅在意外退出时重启
;exitcodes=0,2           ; 正常退出码(autorestart=unexpected时生效)

4. Supervisorctl命令集合

BASH
# 进入交互模式(可直接输入命令)
supervisorctl
# 查看所有进程状态
supervisorctl status
# 启动单个进程
supervisorctl start <进程名>
# 停止单个进程
supervisorctl stop <进程名>
# 重启单个进程
supervisorctl restart <进程名>
# 启动所有进程
supervisorctl start all
# 停止所有进程
supervisorctl stop all
# 重启所有进程
supervisorctl restart all
# 重新加载配置文件(新增进程时使用,进行测试)
supervisorctl reread
# 更新配置并重启受影响的进程
supervisorctl update
# 强制重启 supervisord 服务(高危命令)
supervisorctl reload

管理supervisor:

BASH
# 启动服务
sudo systemctl start supervisor
# 设置开机自启
sudo systemctl enable supervisor
# 停止服务
sudo systemctl stop supervisor
# 重启服务
sudo systemctl restart supervisor
# 查看服务状态
systemctl status supervisor

5. Supervisor使用流程

5.1 运行shell脚本-核心示例

我们编写一个用于supervisor管理的测试脚本,测试脚本如下:

BASH
vim version1/echo_time.sh 
#/bin/bash

while true; do
    echo `date +%Y-%m-%d,%H:%m:%s` > /project/time.log
    sleep 2
done

编写supervisor子进程配置文件,/etc/supervisor/conf.d/echo_time.conf

BASH
vim /etc/supervisor/conf.d/echo_time.conf 

[program:echo_time.sh]
command=sh /project/version1/echo_time.sh
directory=/project/version1
autostart=true
autorestart=true
stdout_logfile=/var/log/test-service.log
stderr_logfile=/var/log/test-service.err.log

重新加载Supervisor:

BASH
supervisorctl reread
supervisorctl update 
supervisorctl status

状态查询可以获得以下信息:

BASH
# supervisorctl status 
test                            RUNNING   pid 2385895, uptime 3:12:31
状态名 类型 核心含义 触发场景 监控意义 风险等级
RUNNING 稳定(健康) 进程正常运行中 进程启动成功,supervisord 确认子进程存活,且稳定运行超过 startsecs 配置时间 服务健康,正常运行 ✅ 无风险
STOPPED 稳定(离线) 进程已停止,未运行 手动停止、autostart=false 未自动启动、进程停止后未触发重启 服务离线,需人工确认是否为预期停止 ⚠️ 需关注
EXITED 稳定(异常 / 正常) 进程已退出(未重启) 进程正常退出、异常崩溃、autorestart 未触发、重启次数超限 服务退出,需排查退出原因(区分正常 / 异常) ⚠️ 需关注
FATAL 稳定(严重故障) 启动失败,彻底放弃重启 连续启动失败、启动命令错误、依赖缺失、权限不足、端口占用等 服务完全不可用,紧急故障,必须立即排查 🚨 高风险
STARTING 过渡(临时) 正在启动中 进程启动 / 重启瞬间,supervisord 等待进程就绪、进入 RUNNING 正常启动过程,仅长期停留(>10 秒)说明启动卡住 ⚠️ 临时状态
STOPPING 过渡(临时) 正在停止中 手动停止、重启过程中,supervisord 发送停止信号(默认 SIGTERM)等待进程退出 正常停止过程,仅长期停留(>10 秒)说明进程卡死无法退出 ⚠️ 临时状态
BACKOFF 过渡(异常预警) 启动失败,正在重试 进程启动后,在 startsecs 时间内立刻崩溃,supervisord 按 startretries 配置重试 启动异常预警:重试成功→RUNNING,重试次数耗尽→FATAL ⚠️ 预警状态
UNKNOWN 特殊(极端异常) 状态无法获取 supervisord 与进程失联、进程被外部强制杀死、supervisord 自身异常、系统资源耗尽 极端异常,需排查 supervisord 服务或系统状态 🚨 极高风险

服务启动流程状态分析:

  • 正常情况下STARTING(启动中,毫秒级) → RUNNING(正常运行) → STOPPING(停止中,毫秒级) → STOPPED(已停止)
  • 异常重启流程RUNNING(运行中) → EXITED(异常退出) → STARTING(重启中) → RUNNING(恢复正常)
  • 启动失败故障流程STARTING(启动中) → BACKOFF(重试中,按 backoffsecs 间隔重试) → (重试 startretries 次后) → FATAL(彻底放弃)
  • 正常退出不重启RUNNINGEXITED(保持此状态,不重启)

查看日志是否写入:

BASH
tail -f /project/time.log 

2025-10-11,11:10:1760155021
tail: /project/time.log: file truncated
2025-10-11,11:10:1760155023
tail: /project/time.log: file truncated
2025-10-11,11:10:1760155025
tail: /project/time.log: file truncated
2025-10-11,11:10:1760155027
tail: /project/time.log: file truncated
2025-10-11,11:10:1760155029

web端进行查看:

image-20260331165726894

可以很清晰的看到,在Web端我们也可以进行对于服务的操作:包括重启、停止、查看日志等。

image-20260331165739096

  • 进程列表:显示所有进程状态(RUNNING/STOPPED/STARTING 等)
  • 操作按钮:每个进程提供启动、停止、重启、清除日志等操作
  • 日志查看:点击 "stdout" 可直接查看进程输出日志
  • 配置信息:显示进程的详细配置参数
  • 系统信息:展示 supervisord 服务的运行状态

并且我们选择查看supervisor的状态的时候,也可以查看到服务的运行态:

image-20260331165753116

5.2 启运行java项目

编写配置文件/etc/supervisor/conf.d/test.conf :

BASH
[program:test]
directory=/data/projects/test
command=java -Dfile.encoding=utf-8 -jar test-biz.jar
autostart=true
autorestart=true
startsecs=5
startretries=3
user=root
redirect_stderr=false
stdout_logfile=/data/logs/test.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=3
stderr_logfile=/data/logs/test.err
stderr_logfile_maxbytes=20MB
stderr_logfile_backups=3

重新加载Supervisor:

BASH
supervisorctl reread
supervisorctl update 
supervisorctl status

5.3 运行go项目

编写配置文件/etc/supervisor/conf.d/test.conf

BASH
vim /etc/supervisor/conf.d/test.conf 

[program:test]
directory=/data/projects/test
command=/data/projects/test
autostart=true
autorestart=true
startsecs=5
startretries=3
user=root
redirect_stderr=false
stdout_logfile=/data/logs/test.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=3
stderr_logfile=/data/logs/test.err
stderr_logfile_maxbytes=20MB
stderr_logfile_backups=3

配置文件中的command=/data/projects/test,实际上就是go项目build之后的二进制文件,command部分将二进制部分进行运行起来。

Supervisor结合Jenkins

一、项目描述

核心需求:

  • 分布式部署的服务由 Supervisor 管理
  • 服务部署在 不同服务器
  • 发布由 Jenkins 流水线 执行
  • 现在服务变成了分布式集群
  • 希望在 Jenkins 页面上直观看到:哪台服务器跑了哪些服务
  • 目的是在发布前避免:
    • 选错机器
    • 把服务发到不该跑的机器上
    • 出现双版本并跑

这个需求本质上是:

给现有 Jenkins 发布体系增加一个“发布前可视化运行态面板”。

方案设计

  • 不推翻现有 Supervisor + Jenkins 体系

  • 在 Jenkins 页面上增加一个轻量状态面板

  • 由一个独立服务采集两台机器的 Supervisor 状态

  • 通过 Nginx 反代,把这个面板嵌到 Jenkins 页面里

然后我突然想到了 Dify 的嵌入代码,借鉴 Dify 的交互方式,但内容不是聊天,而是 Supervisor 状态面板。

项目已上传至Gitee: supervisor-ops-Jenkins

二、Supervisor服务器操作

1. 创建只读账号

创建一个只能查状态、不能登录、不能操作任何业务的专用账号:

BASH
# 创建用户:-m 创建家目录 / -s 指定shell
useradd -m -s /bin/bash svc_supervisor_view

# 创建SSH密钥目录(SSH免密登录必须)
mkdir -p /home/svc_supervisor_view/.ssh

# 权限700:仅当前用户能读写,SSH强制安全规则
chmod 700 /home/svc_supervisor_view/.ssh

# 归属权给专用用户,否则SSH认证失败
chown -R svc_supervisor_view:svc_supervisor_view /home/svc_supervisor_view/.ssh

2. 创建只读脚本

锁死该用户只能执行 supervisorctl status,不能执行任何其他命令:

BASH
cat >/usr/local/bin/supervisor_status_readonly.sh <<'EOF'
#!/bin/sh
set -u

sudo /usr/bin/supervisorctl status
rc=$?

# 集群互补部署或发布中的短暂停服,会让 supervisorctl 返回 3。
# 只要能拿到状态输出,就不要把它当成失败。
if [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; then
  exit 0
fi

exit "$rc"
EOF

chmod 755 /usr/local/bin/supervisor_status_readonly.sh

3. 限制 sudo 只允许查询状态

让只读账号不用密码,且只能执行 status 查询

BASH
cat >/etc/sudoers.d/svc_supervisor_view <<'EOF'
svc_supervisor_view ALL=(root) NOPASSWD: /usr/bin/supervisorctl status
EOF

chmod 440 /etc/sudoers.d/svc_supervisor_view
visudo -cf /etc/sudoers.d/svc_supervisor_view

4. 导入 Jenkins 机公钥

把 Jenkins 机上的 /data/ops-status/.ssh/id_ed25519.pub 内容,写入两台生产机:

BASH
cat >>/home/svc_supervisor_view/.ssh/authorized_keys <<'EOF'
from="Jenkins IP",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty,no-user-rc,command="/usr/local/bin/supervisor_status_readonly.sh" ssh-ed25519 公钥COPY
EOF

chmod 600 /home/svc_supervisor_view/.ssh/authorized_keys
chown svc_supervisor_view:svc_supervisor_view /home/svc_supervisor_view/.ssh/authorized_keys

5. 本机验证

BASH
sudo -u svc_supervisor_view /usr/local/bin/supervisor_status_readonly.sh

三、Jenkins 服务器上执行

1. 创建目录

BASH
# 存放前端页面
mkdir -p /data/ops-status/web

# 存放SSH密钥(权限700,最高安全)
mkdir -p /data/ops-status/.ssh
chmod 700 /data/ops-status/.ssh

2. 生成 SSH 密钥

BASH
# -t ed25519 安全算法 / -f 指定路径 / -N '' 无密码
ssh-keygen -t ed25519 -f /data/ops-status/.ssh/id_ed25519 -N ''

# 私钥权限600(必须!泄露会导致服务器被入侵)
chmod 600 /data/ops-status/.ssh/id_ed25519

# 公钥权限644
chmod 644 /data/ops-status/.ssh/id_ed25519.pub

3. 收集主机指纹

BASH
# 创建known_hosts文件
touch /data/ops-status/.ssh/known_hosts
chmod 600 /data/ops-status/.ssh/known_hosts

# 扫描所有生产服务器IP(替换成你的真实IP)
ssh-keyscan -H 192.168.1.10 >> /data/ops-status/.ssh/known_hosts
ssh-keyscan -H 192.168.1.11 >> /data/ops-status/.ssh/known_hosts

4. 放置文件

把本目录里的文件放到:

  • /data/ops-status/app.py
PYTHON
from __future__ import annotations

import logging
import os
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import paramiko
import yaml
from flask import Flask, jsonify

BASE_DIR = Path(__file__).resolve().parent
INVENTORY_PATH = Path(os.getenv("INVENTORY_PATH", BASE_DIR / "inventory.yml"))
CACHE_TTL = int(os.getenv("CACHE_TTL", "10"))
SSH_TIMEOUT = int(os.getenv("SSH_TIMEOUT", "8"))
MAX_WORKERS = int(os.getenv("MAX_WORKERS", "8"))

STATE_RE = re.compile(r"^(?P<name>\S+)\s+(?P<state>[A-Z_]+)\s*(?P<description>.*)$")
UPTIME_RE = re.compile(r"uptime\s+(?P<uptime>.+)$", re.IGNORECASE)

logging.basicConfig(
    level=os.getenv("LOG_LEVEL", "INFO"),
    format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
)
logger = logging.getLogger("ops-status")

app = Flask(__name__)
_cache_lock = threading.Lock()
_cache: dict[str, Any] = {"timestamp": 0.0, "data": None}


class InventoryError(RuntimeError):
    pass


def now_iso() -> str:
    return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")


def load_inventory() -> dict[str, Any]:
    if not INVENTORY_PATH.exists():
        raise InventoryError(f"inventory file not found: {INVENTORY_PATH}")

    with INVENTORY_PATH.open("r", encoding="utf-8") as fp:
        data = yaml.safe_load(fp) or {}

    ssh_cfg = data.get("ssh")
    if not isinstance(ssh_cfg, dict):
        raise InventoryError("inventory.yml must contain an 'ssh' mapping")

    username = ssh_cfg.get("username")
    if not username:
        raise InventoryError("inventory.yml ssh.username is required")

    servers = data.get("servers")
    if not isinstance(servers, list) or not servers:
        raise InventoryError("inventory.yml must contain a non-empty 'servers' list")

    private_key = ssh_cfg.get("private_key")
    if not private_key:
        raise InventoryError("inventory.yml ssh.private_key is required")

    ssh_defaults = {
        "port": int(ssh_cfg.get("port", 22)),
        "username": username,
        "private_key": private_key,
        "known_hosts": ssh_cfg.get("known_hosts"),
        "allow_agent": bool(ssh_cfg.get("allow_agent", False)),
        "look_for_keys": bool(ssh_cfg.get("look_for_keys", False)),
        "allow_unknown_host_keys": bool(ssh_cfg.get("allow_unknown_host_keys", False)),
        "connect_timeout": int(ssh_cfg.get("connect_timeout", SSH_TIMEOUT)),
        "remote_command": ssh_cfg.get("remote_command", "/usr/local/bin/supervisor_status_readonly.sh"),
    }

    normalized_servers: list[dict[str, Any]] = []
    for idx, server in enumerate(servers, start=1):
        if not isinstance(server, dict):
            raise InventoryError(f"server entry #{idx} must be a mapping")

        host = server.get("host")
        if not host:
            raise InventoryError(f"server entry #{idx} missing host")

        normalized_servers.append(
            {
                "name": server.get("name") or host,
                "host": host,
                "port": int(server.get("port", ssh_defaults["port"])),
                "remote_command": server.get("remote_command", ssh_defaults["remote_command"]),
            }
        )

    return {"ssh": ssh_defaults, "servers": normalized_servers}


def build_ssh_client(ssh_cfg: dict[str, Any]) -> paramiko.SSHClient:
    client = paramiko.SSHClient()

    if ssh_cfg.get("allow_unknown_host_keys"):
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    else:
        known_hosts = ssh_cfg.get("known_hosts")
        if known_hosts:
            path = Path(str(known_hosts))
            if path.exists():
                client.load_host_keys(str(path))
            else:
                logger.warning("known_hosts not found: %s", path)
        client.set_missing_host_key_policy(paramiko.RejectPolicy())

    return client


def parse_supervisor_output(output: str) -> list[dict[str, str]]:
    services: list[dict[str, str]] = []
    for raw_line in output.splitlines():
        line = raw_line.rstrip()
        if not line:
            continue

        match = STATE_RE.match(line)
        if not match:
            continue

        state = match.group("state")
        if state != "RUNNING":
            continue

        description = (match.group("description") or "").strip()
        uptime_match = UPTIME_RE.search(description)
        uptime = uptime_match.group("uptime").strip() if uptime_match else ""

        services.append(
            {
                "name": match.group("name"),
                "uptime": uptime,
            }
        )

    services.sort(key=lambda item: item["name"].lower())
    return services


def collect_from_server(server: dict[str, Any], ssh_cfg: dict[str, Any]) -> dict[str, Any]:
    result = {
        "name": server["name"],
        "host": server["host"],
        "reachable": False,
        "error": "",
        "collected_at": now_iso(),
        "running_count": 0,
        "running_services": [],
    }

    client = None
    try:
        client = build_ssh_client(ssh_cfg)
        client.connect(
            hostname=server["host"],
            port=server["port"],
            username=ssh_cfg["username"],
            key_filename=str(ssh_cfg["private_key"]),
            timeout=ssh_cfg["connect_timeout"],
            banner_timeout=ssh_cfg["connect_timeout"],
            auth_timeout=ssh_cfg["connect_timeout"],
            allow_agent=ssh_cfg["allow_agent"],
            look_for_keys=ssh_cfg["look_for_keys"],
        )

        stdin, stdout, stderr = client.exec_command(
            server["remote_command"], timeout=ssh_cfg["connect_timeout"] + 10
        )
        stdout_text = stdout.read().decode("utf-8", errors="replace")
        stderr_text = stderr.read().decode("utf-8", errors="replace").strip()
        exit_code = stdout.channel.recv_exit_status()

        services = parse_supervisor_output(stdout_text)
        has_status_lines = any(
            STATE_RE.match(line.rstrip()) for line in stdout_text.splitlines() if line.strip()
        )

        if has_status_lines:
            result["reachable"] = True
            result["running_services"] = services
            result["running_count"] = len(services)

            # 集群互补部署或发布中的短暂停服,会让 supervisorctl 返回非 0。
            # 只要能拿到状态输出,就视为主机可达,不把整机判失败。
            if exit_code not in (0, 3) and stderr_text:
                result["error"] = f"status parsed with exit code {exit_code}: {stderr_text}"
            return result

        result["error"] = stderr_text or f"remote command failed with exit code {exit_code}"
        return result
    except Exception as exc:  # noqa: BLE001
        result["error"] = str(exc)
        return result
    finally:
        if client is not None:
            client.close()


def collect_status(force: bool = False) -> dict[str, Any]:
    now = time.time()
    with _cache_lock:
        if not force and _cache["data"] and now - _cache["timestamp"] < CACHE_TTL:
            return _cache["data"]

    inventory = load_inventory()
    ssh_cfg = inventory["ssh"]
    servers = inventory["servers"]

    results: list[dict[str, Any]] = []
    with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, len(servers))) as executor:
        future_map = {executor.submit(collect_from_server, server, ssh_cfg): server for server in servers}
        for future in as_completed(future_map):
            results.append(future.result())

    results.sort(key=lambda item: item["name"].lower())
    payload = {
        "generated_at": now_iso(),
        "server_count": len(results),
        "running_total": sum(item["running_count"] for item in results),
        "servers": results,
    }

    with _cache_lock:
        _cache["timestamp"] = now
        _cache["data"] = payload

    return payload


@app.get("/health")
def health() -> Any:
    return jsonify({"ok": True, "time": now_iso()})


@app.get("/api/status")
def api_status() -> Any:
    return jsonify(collect_status(force=False))


@app.get("/api/status/refresh")
def api_status_refresh() -> Any:
    return jsonify(collect_status(force=True))


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=int(os.getenv("PORT", "18081")), debug=False)
  • /data/ops-status/inventory.yml
YAML
ssh:
  username: svc_supervisor_view
  port: 22
  private_key: /data/ops-status/.ssh/id_ed25519
  known_hosts: /data/ops-status/.ssh/known_hosts
  allow_agent: false
  look_for_keys: false
  allow_unknown_host_keys: false
  connect_timeout: 8
  remote_command: /usr/local/bin/supervisor_status_readonly.sh

servers:
  - name: pc1
    host: 1.2.3.4

  - name: pc2
    host: 5.6.7.8
  • /data/ops-status/web/index.html
HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Supervisor 服务状态</title>
  <style>
    :root {
      --bg: #f8fafc;
      --panel: #ffffff;
      --text: #0f172a;
      --muted: #64748b;
      --border: #e2e8f0;
      --primary: #2563eb;
      --ok-bg: #ecfdf5;
      --ok-border: #bbf7d0;
      --ok-text: #166534;
      --error-bg: #fef2f2;
      --error-border: #fecaca;
      --error-text: #991b1b;
      --shadow: 0 14px 36px rgba(15, 23, 42, 0.10);
    }

    * { box-sizing: border-box; }

    body {
      margin: 0;
      background: var(--bg);
      color: var(--text);
      font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
            "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
    }

    .page {
      max-width: 1100px;
      margin: 0 auto;
      padding: 18px;
    }

    .header {
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: 18px;
      box-shadow: var(--shadow);
      padding: 18px;
      margin-bottom: 16px;
    }

    .header-top {
      display: flex;
      justify-content: space-between;
      gap: 12px;
      align-items: flex-start;
      flex-wrap: wrap;
    }

    h1 {
      font-size: 22px;
      margin: 0 0 6px;
    }

    .muted {
      color: var(--muted);
      font-size: 13px;
    }

    .toolbar {
      display: flex;
      gap: 10px;
      margin-top: 14px;
      flex-wrap: wrap;
    }

    .toolbar input {
      flex: 1 1 280px;
      min-width: 220px;
      height: 40px;
      padding: 0 14px;
      border: 1px solid var(--border);
      border-radius: 12px;
      background: #fff;
      outline: none;
    }

    .toolbar button, .header a {
      height: 40px;
      padding: 0 16px;
      border: 0;
      border-radius: 12px;
      cursor: pointer;
      font-weight: 600;
      text-decoration: none;
      display: inline-flex;
      align-items: center;
      justify-content: center;
    }

    .btn-primary {
      background: var(--primary);
      color: #fff;
    }

    .btn-light {
      background: #eff6ff;
      color: #1d4ed8;
    }

    .summary {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
      gap: 12px;
      margin-top: 14px;
    }

    .stat {
      background: #fff;
      border: 1px solid var(--border);
      border-radius: 14px;
      padding: 14px;
    }

    .stat .label {
      color: var(--muted);
      font-size: 12px;
      margin-bottom: 4px;
    }

    .stat .value {
      font-size: 22px;
      font-weight: 700;
    }

    .server-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
      gap: 14px;
    }

    .server-card {
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: 18px;
      box-shadow: var(--shadow);
      overflow: hidden;
    }

    .server-head {
      padding: 16px 18px;
      border-bottom: 1px solid var(--border);
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 10px;
    }

    .server-title {
      font-size: 18px;
      font-weight: 700;
      margin: 0 0 4px;
    }

    .server-subtitle {
      color: var(--muted);
      font-size: 13px;
    }

    .badge {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      min-width: 80px;
      padding: 6px 10px;
      border-radius: 999px;
      font-size: 12px;
      font-weight: 700;
      background: var(--ok-bg);
      color: var(--ok-text);
      border: 1px solid var(--ok-border);
    }

    .server-error .badge {
      background: var(--error-bg);
      color: var(--error-text);
      border-color: var(--error-border);
    }

    .server-body {
      padding: 16px 18px;
      display: grid;
      gap: 10px;
    }

    .service-item {
      border: 1px solid var(--ok-border);
      background: var(--ok-bg);
      border-radius: 14px;
      padding: 12px 14px;
    }

    .service-name {
      font-weight: 700;
      margin-bottom: 4px;
      word-break: break-all;
    }

    .service-meta {
      color: var(--ok-text);
      font-size: 13px;
    }

    .empty-state, .error-state {
      border-radius: 14px;
      padding: 14px;
      font-size: 14px;
    }

    .empty-state {
      border: 1px dashed var(--border);
      color: var(--muted);
      background: #fff;
    }

    .error-state {
      border: 1px solid var(--error-border);
      color: var(--error-text);
      background: var(--error-bg);
      white-space: pre-wrap;
      word-break: break-word;
    }

    @media (max-width: 768px) {
      .page { padding: 12px; }
      .header { padding: 14px; }
    }
  </style>
</head>
<body>
  <div class="page">
    <section class="header">
      <div class="header-top">
        <div>
          <h1>Supervisor 服务状态面板</h1>
          <div class="muted" id="metaText">正在加载...</div>
        </div>
        <a href="#" class="btn-light" id="closePanelLink">收起窗口</a>
      </div>

      <div class="toolbar">
        <input id="searchInput" type="search" placeholder="按服务名筛选,仅显示运行中的服务" />
        <button class="btn-primary" id="refreshBtn" type="button">立即刷新</button>
      </div>

      <div class="summary" id="summary"></div>
    </section>

    <section class="server-grid" id="serverGrid"></section>
  </div>

  <script>
    const state = {
      query: '',
      data: null,
      isLoading: false,
    };

    function escapeHtml(value) {
      return String(value ?? '')
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#39;');
    }

    function fmtTime(value) {
      if (!value) return '-';
      try {
        return new Date(value).toLocaleString('zh-CN', { hour12: false });
      } catch (e) {
        return value;
      }
    }

    function buildSummary(data) {
      const okServers = data.servers.filter(item => item.reachable).length;
      const cards = [
        { label: '主机数', value: data.server_count ?? data.servers.length },
        { label: '可连接主机', value: okServers },
        { label: '运行中服务总数', value: data.running_total ?? 0 },
      ];

      return cards.map(item => `
        <div class="stat">
          <div class="label">${escapeHtml(item.label)}</div>
          <div class="value">${escapeHtml(item.value)}</div>
        </div>
      `).join('');
    }

    function buildServerCard(server, query) {
      const q = query.trim().toLowerCase();
      const services = (server.running_services || []).filter(item => {
        if (!q) return true;
        return item.name.toLowerCase().includes(q);
      });

      const cardClass = server.reachable ? 'server-card' : 'server-card server-error';

      let bodyHtml = '';
      if (!server.reachable) {
        bodyHtml = `<div class="error-state">${escapeHtml(server.error || '连接失败')}</div>`;
      } else if (!services.length && q) {
        bodyHtml = `<div class="empty-state">当前主机没有匹配“${escapeHtml(query)}”的运行中服务。</div>`;
      } else if (!services.length) {
        bodyHtml = '<div class="empty-state">当前主机没有运行中的 Supervisor 服务。</div>';
      } else {
        bodyHtml = services.map(item => `
          <div class="service-item">
            <div class="service-name">${escapeHtml(item.name)}</div>
            <div class="service-meta">运行时长:${escapeHtml(item.uptime || '未知')}</div>
          </div>
        `).join('');
      }

      return `
        <article class="${cardClass}">
          <div class="server-head">
            <div>
              <div class="server-title">${escapeHtml(server.name)}</div>
              <div class="server-subtitle">${escapeHtml(server.host)} · 采集时间 ${escapeHtml(fmtTime(server.collected_at))}</div>
            </div>
            <div class="badge">${server.reachable ? `运行中 ${escapeHtml(server.running_count || 0)}` : '连接失败'}</div>
          </div>
          <div class="server-body">${bodyHtml}</div>
        </article>
      `;
    }

    function render() {
      const summary = document.getElementById('summary');
      const serverGrid = document.getElementById('serverGrid');
      const metaText = document.getElementById('metaText');

      if (!state.data) {
        summary.innerHTML = '';
        serverGrid.innerHTML = '<div class="empty-state">暂无数据。</div>';
        metaText.textContent = state.isLoading ? '正在加载...' : '暂无数据';
        return;
      }

      summary.innerHTML = buildSummary(state.data);
      metaText.textContent = `最近更新时间:${fmtTime(state.data.generated_at)},仅展示 RUNNING 的 Supervisor 服务。`;
      serverGrid.innerHTML = state.data.servers.map(server => buildServerCard(server, state.query)).join('');
    }

    async function fetchStatus() {
      state.isLoading = true;
      render();
      try {
        const response = await fetch('/ops-api/status', {
          method: 'GET',
          headers: { 'Accept': 'application/json' },
          cache: 'no-store'
        });
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        state.data = await response.json();
      } catch (error) {
        state.data = {
          generated_at: new Date().toISOString(),
          server_count: 0,
          running_total: 0,
          servers: [{
            name: '状态接口',
            host: '/ops-api/status',
            reachable: false,
            error: error.message || String(error),
            running_services: [],
            running_count: 0,
            collected_at: new Date().toISOString(),
          }]
        };
      } finally {
        state.isLoading = false;
        render();
      }
    }

    document.getElementById('refreshBtn').addEventListener('click', fetchStatus);
    document.getElementById('searchInput').addEventListener('input', (event) => {
      state.query = event.target.value || '';
      render();
    });
    document.getElementById('closePanelLink').addEventListener('click', (event) => {
      event.preventDefault();
      window.parent.postMessage({ type: 'OPS_WIDGET_CLOSE' }, window.location.origin);
    });

    window.addEventListener('message', (event) => {
      if (event.origin !== window.location.origin) return;
      if (event.data && event.data.type === 'OPS_WIDGET_REFRESH') {
        fetchStatus();
      }
    });

    fetchStatus();
    window.setInterval(fetchStatus, 30000);
  </script>
</body>
</html>
  • /data/ops-status/web/widget.js
JS
(function () {
  if (window.__OPS_STATUS_WIDGET_LOADED__) return;
  window.__OPS_STATUS_WIDGET_LOADED__ = true;

  const BUTTON_ID = 'ops-status-widget-button';
  const PANEL_ID = 'ops-status-widget-panel';
  const IFRAME_ID = 'ops-status-widget-iframe';
  const STYLE_ID = 'ops-status-widget-style';
  const PANEL_URL = '/ops-widget/index.html';

  function injectStyle() {
    if (document.getElementById(STYLE_ID)) return;

    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = `
      #${BUTTON_ID} {
        position: fixed;
        right: 24px;
        bottom: 24px;
        width: 60px;
        height: 60px;
        border: 0;
        border-radius: 999px;
        background: #2563eb;
        color: #fff;
        cursor: pointer;
        font: 700 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        box-shadow: 0 14px 30px rgba(37, 99, 235, 0.35);
        z-index: 2147483000;
      }

      #${BUTTON_ID}:hover {
        transform: translateY(-1px);
      }

      #${PANEL_ID} {
        position: fixed;
        right: 24px;
        bottom: 96px;
        width: min(860px, calc(100vw - 32px));
        height: min(680px, calc(100vh - 128px));
        border: 1px solid rgba(148, 163, 184, 0.35);
        border-radius: 18px;
        overflow: hidden;
        background: #fff;
        box-shadow: 0 22px 48px rgba(15, 23, 42, 0.22);
        z-index: 2147483000;
        display: none;
      }

      #${PANEL_ID}.open {
        display: block;
      }

      #${IFRAME_ID} {
        width: 100%;
        height: 100%;
        border: 0;
        background: #f8fafc;
      }

      @media (max-width: 768px) {
        #${BUTTON_ID} {
          right: 14px;
          bottom: 14px;
        }

        #${PANEL_ID} {
          right: 8px;
          left: 8px;
          bottom: 82px;
          width: auto;
          height: calc(100vh - 96px);
        }
      }
    `;
    document.head.appendChild(style);
  }

  function buildWidget() {
    injectStyle();

    const button = document.createElement('button');
    button.id = BUTTON_ID;
    button.type = 'button';
    button.title = '查看 Supervisor 服务状态';
    button.textContent = '状态';

    const panel = document.createElement('section');
    panel.id = PANEL_ID;
    panel.setAttribute('aria-hidden', 'true');
    panel.innerHTML = `<iframe id="${IFRAME_ID}" src="${PANEL_URL}" title="Supervisor 服务状态面板"></iframe>`;

    document.body.appendChild(button);
    document.body.appendChild(panel);

    function openPanel() {
      panel.classList.add('open');
      panel.setAttribute('aria-hidden', 'false');
      button.textContent = '收起';
    }

    function closePanel() {
      panel.classList.remove('open');
      panel.setAttribute('aria-hidden', 'true');
      button.textContent = '状态';
    }

    function togglePanel() {
      if (panel.classList.contains('open')) {
        closePanel();
      } else {
        openPanel();
      }
    }

    button.addEventListener('click', togglePanel);

    window.addEventListener('message', (event) => {
      if (event.origin !== window.location.origin) return;
      if (event.data && event.data.type === 'OPS_WIDGET_CLOSE') {
        closePanel();
      }
    });

    window.addEventListener('keydown', (event) => {
      if (event.key === 'Escape') {
        closePanel();
      }
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', buildWidget);
  } else {
    buildWidget();
  }
})();

5. 安装 Python 依赖

BASH
# 更新系统软件源,安装Python虚拟环境工具(系统自带Python没有venv模块)
apt update && apt install -y python3.10-venv

# 创建独立的Python虚拟环境(目录:/data/ops-status/.venv)
python3 -m venv /data/ops-status/.venv

# 激活虚拟环境
source /data/ops-status/.venv/bin/activate

# 升级pip工具
pip install -U pip

# 一键安装监控脚本所有依赖
pip install -r /data/ops-status/requirements.txt

6. 本机验证 SSH

BASH
# 用监控机的私钥,连接生产机的只读账号
ssh -i /data/ops-status/.ssh/id_ed25519 svc_supervisor_view@IP1
ssh -i /data/ops-status/.ssh/id_ed25519 svc_supervisor_view@IP2

预期:不会给你 shell,而是直接输出 supervisorctl status 结果后退出。

7.启动后端

生产托管:

BASH
# 1. 编写systemd服务配置文件到系统目录
vim /etc/systemd/system/ops-status.service

[Unit]
Description=ops-status gunicorn service
After=network.target

[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/data/ops-status
Environment=INVENTORY_PATH=/data/ops-status/inventory.yml
Environment=PORT=18081
ExecStart=/data/ops-status/.venv/bin/gunicorn -w 2 -b 127.0.0.1:18081 app:app
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

# 2. 重载systemd,让系统识别新的服务配置
systemctl daemon-reload

# 3. 设置服务【开机自启】,并【立即启动】服务
systemctl enable --now ops-status

# 4. 查看服务状态(验证是否运行成功)
systemctl status ops-status

8. 验证接口

BASH
curl http://127.0.0.1:18081/health
curl http://127.0.0.1:18081/api/status

四、反向代理脚本嵌入Jenkins

1. nginx配置文件

BASH
cat /etc/nginx/conf.d/ops-widget.conf 
server {
    listen 8081;
    server_name _;

    access_log  /data/ops-status/nginx/ops-widget.access.log;
    error_log   /data/ops-status/nginx/ops-widget.error.log;
    location ^~ /ops-widget/ {
        alias /data/ops-status/web/;
        try_files $uri =404;
        add_header Cache-Control "no-store" always;
    }

    location = /ops-api/status {
        proxy_pass http://127.0.0.1:18081/api/status;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 20s;
        proxy_connect_timeout 5s;
    }

    location = /ops-api/status/refresh {
        proxy_pass http://127.0.0.1:18081/api/status/refresh;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 20s;
        proxy_connect_timeout 5s;
    }

    location = /ops-api/health {
        proxy_pass http://127.0.0.1:18081/health;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 20s;
        proxy_connect_timeout 5s;
    }

	# jenkins端口
    location / {
        proxy_pass http://127.0.0.1:15107;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection "";
        proxy_set_header Accept-Encoding "";
        proxy_redirect off;
        sub_filter_once on;
        sub_filter '</body>' '<script src="/ops-widget/widget.js" defer></script></body>';
    }
}

把原本的 Jenkins 页面反代出来,同时额外挂上你自己的状态面板前端和 API,并自动往 Jenkins 页面里注入一个悬浮按钮脚本。

  • 前端静态资源和 API 分开
  • 日志独立
  • 不改 Jenkins 本体
  • 后端服务和 UI 解耦

2. 运行nginx

BASH
nginx -t 
nginx -s reload

端口映射:

python服务采集端口:18081

nginx访问Jenkins端口:8081

最终实现效果:

image-20260331173338747

评论