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系统:
sudo apt update
sudo apt install supervisor -y自己可编辑配置文件:
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=adminCentOS/RHEL 系统
sudo yum install epel-release -y
sudo yum install supervisor -y源码安装(适用于所有系统)
# 安装依赖
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 install2. 主配置文件解读
supervisor的默认配置文件在/etc/supervisor/supervisord.conf ,默认配置文件解读如下:
; 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控制台,修改配置文件:
; Web 管理界面配置
[inet_http_server]
port=0.0.0.0:8080 ; 监听地址和端口(0.0.0.0 表示允许所有IP访问)
username=admin ; 登录用户名
password=admin ; 登录密码保存之后重启supervisor:
systemctl restart supervisor.service进入浏览器进行访问:


登陆完成,至此supervisor基本搭建完成。
可以查看到,web控制台上有三个默认按钮,这三个是 Supervisor 原生 Web 管理控制台的全局操作按钮,作用于所有被 supervisord 托管的后台进程,功能、风险和使用场景完全不同:
REFRESH: 唯一安全、无任何业务影响的按钮,手动刷新页面,从 supervisord 服务拉取最新的进程状态数据,更新页面显示。
RESTART ALL:批量强制重启所有被 Supervisor 管理的进程。
- 会导致所有业务服务短暂中断,生产环境仅在系统维护、版本升级、批量重启服务等场景下使用,操作前必须确认业务影响。
- 无论进程当前是
RUNNING还是其他状态,都会被强制重启,不受autorestart配置影响。 - 进程组会按照组内优先级统一执行重启。
STOP ALL:批量强制停止所有被 Supervisor 管理的进程。
-
点击后所有业务服务会直接停止,导致全业务中断,误操作会造成严重生产事故。
-
仅在服务器停机维护、版本下线等极端场景下使用,操作前必须做好业务停机通知和数据备份。
3. 子进程配置文件解读
默认情况下,子进程(我愿意叫托管进程)的配置文件,配置文件路径为 /etc/supervisor/conf.d/,需要托管的进程,在该路径下配置相关配置文件:
[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 ; 杀死进程时同时杀死子进程高级选择:
; 环境变量设置
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命令集合
# 进入交互模式(可直接输入命令)
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:
# 启动服务
sudo systemctl start supervisor
# 设置开机自启
sudo systemctl enable supervisor
# 停止服务
sudo systemctl stop supervisor
# 重启服务
sudo systemctl restart supervisor
# 查看服务状态
systemctl status supervisor5. Supervisor使用流程
5.1 运行shell脚本-核心示例
我们编写一个用于supervisor管理的测试脚本,测试脚本如下:
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
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:
supervisorctl reread
supervisorctl update
supervisorctl status状态查询可以获得以下信息:
# 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(彻底放弃) - 正常退出不重启:
RUNNING→EXITED(保持此状态,不重启)
查看日志是否写入:
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:1760155029web端进行查看:

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

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

5.2 启运行java项目
编写配置文件/etc/supervisor/conf.d/test.conf :
[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:
supervisorctl reread
supervisorctl update
supervisorctl status5.3 运行go项目
编写配置文件/etc/supervisor/conf.d/test.conf :
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. 创建只读账号
创建一个只能查状态、不能登录、不能操作任何业务的专用账号:
# 创建用户:-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/.ssh2. 创建只读脚本
锁死该用户只能执行 supervisorctl status,不能执行任何其他命令:
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.sh3. 限制 sudo 只允许查询状态
让只读账号不用密码,且只能执行 status 查询:
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_view4. 导入 Jenkins 机公钥
把 Jenkins 机上的 /data/ops-status/.ssh/id_ed25519.pub 内容,写入两台生产机:
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_keys5. 本机验证
sudo -u svc_supervisor_view /usr/local/bin/supervisor_status_readonly.sh三、Jenkins 服务器上执行
1. 创建目录
# 存放前端页面
mkdir -p /data/ops-status/web
# 存放SSH密钥(权限700,最高安全)
mkdir -p /data/ops-status/.ssh
chmod 700 /data/ops-status/.ssh2. 生成 SSH 密钥
# -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.pub3. 收集主机指纹
# 创建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_hosts4. 放置文件
把本目录里的文件放到:
/data/ops-status/app.py
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
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
<!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('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
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
(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 依赖
# 更新系统软件源,安装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.txt6. 本机验证 SSH
# 用监控机的私钥,连接生产机的只读账号
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.启动后端
生产托管:
# 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-status8. 验证接口
curl http://127.0.0.1:18081/health
curl http://127.0.0.1:18081/api/status四、反向代理脚本嵌入Jenkins
1. nginx配置文件
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
nginx -t
nginx -s reload端口映射:
python服务采集端口:18081
nginx访问Jenkins端口:8081
最终实现效果:

评论
游客无需注册即可评论。
你提交的昵称、邮箱、网址和评论内容会保存在服务端,用于展示评论身份、接收回复及必要的安全审计。
浏览器会本地保存已填游客信息和评论草稿,方便下次免填。
回复提醒会通过站内消息和邮件通知。