基于Bun与WebSocket的容器和Nginx实时监控仪表盘实现

📅 2026/8/27 7:34:43
基于Bun与WebSocket的容器和Nginx实时监控仪表盘实现
在 Hacker News 上看到 Aphorio 时项目介绍只有一行Dashboard for containers and webservers, maybe faster than Bun。这句话真正值得拆解的不是它和 Bun 谁更快而是一个面向容器和 Web 服务器的 Dashboard 到底要采集哪些数据、怎么把数据推送到浏览器、以及为什么在大批量容器和高频请求下响应速度会出现明显分化。Aphorio 目前在社区里的资料还不多但这并不妨碍我们从这一类工具的共性出发自己动手实现一个最小版本。这篇文章会以 Aphorio 为引子带你走完一条完整链路使用 Docker Engine 获取容器列表使用 Nginx stub_status 获取 Web 服务器连接状态使用 Bun 作为后端运行时通过 WebSocket 把快照实时推送到前端页面。整个过程不依赖太重的基础设施读完你可以复现也能把它改造成生产可用的监控面板。1. Aphorio 这类 Dashboard 要解决什么问题1.1 容器和 Web 服务器的可观测性入口容器和 Web 服务器是两层不同的运维对象。Docker Engine 是运行容器的底层技术它通过 REST API 暴露容器生命周期信息Nginx 则是常见的 Web 服务器它自己的状态模块可以给出连接数、请求数。一个 Dashboard 要同时展示这两类信息就不得不处理两种数据源协议差异。比如 EMQX Dashboard、Kubernetes Dashboard 都是类似思路把底层系统通过 API 暴露的指标集中到浏览器不需要用户记忆命令。Aphorio 的定位也是这样只是它把范围限定在容器和 Web 服务器上。使用 Dashboard 的核心收益不是“界面好看”而是把docker ps、docker stats、curl /nginx_status这类手工操作变成持续更新的可视化信息。一个生产级 Dashboard 通常要回答几类问题当前有哪些容器在运行状态是否正常。容器端口映射有没有冲突镜像版本是否和预期一致。Web 服务器当前活跃连接数是多少是否接近上限。请求量是在上升还是下降Reading、Writing、Waiting 三类连接分别是什么趋势。如果这些信息分散在多个命令行窗口里排查故障时就要来回切换上下文。Dashboard 的价值在于把这些信息聚合到同一时间轴上让“异常”更容易被发现。1.2 需要采集的核心指标不同数据源适合用不同协议采集。这里先抽象出最小指标集后续实现就围绕这张表展开。数据源关键指标获取方式Docker Engine容器 ID、名称、镜像、状态、端口映射GET /containers/jsonNginx活跃连接数、已接受连接数、已处理连接数、总请求数GET /nginx_statusNginx 细分Reading、Writing、Waiting 三类连接stub_status输出的最后一行采集状态最近一次采集时间、失败原因由后端程序记录Docker 容器字段里State表示运行状态常见取值是running或exitedStatus是docker ps里展示的完整文本例如Up 2 hours。端口映射在Ports数组中包含容器端口、宿主机端口和绑定 IP。Nginx 的stub_status输出结构非常稳定典型内容如下Active connections: 1 server accepts handled requests 10 10 20 Reading: 0 Writing: 1 Waiting: 0其中accepts表示已接受连接数handled表示已处理连接数requests表示请求总数。Reading表示正在读取请求头的连接数Writing表示正在写响应的连接数Waiting表示空闲 keep-alive 连接数。对运维来说active和waiting的变化趋势最有参考价值。1.3 实时 Dashboard 不能退化成高频轮询最简单实现是让前端每秒钟请求一次后端后端每秒钟请求一次 Docker API。但这会带来三个问题Docker API 压力。listContainers会返回全部容器列表容器数量上千时每次请求的响应体可能达到几百 KB高频轮询会浪费大量带宽和 CPU。后端事件循环阻塞。Bun 或 Node 的事件循环在处理 IO 时虽然很快但如果每次请求都实时去查 Docker socket后端就会在请求高峰期被拖慢。前端页面卡顿。频繁更新整张表格会触发大量重绘一旦容器数量增加界面会出现明显的输入延迟和滚动掉帧。正确的做法是用后端缓存加推送。后端定时采集数据生成一份统一快照保存到内存前端通过 WebSocket 建立长连接后端只在快照变化时把数据推送给前端。这样无论有多少浏览器打开页面Docker API 都只有一份定时采集压力。Dashboard 要“快”指的不仅是接口毫秒级返回还包括数据变更后能迅速出现在页面上。WebSocket 推送比轮询更适合这种低频变化、高并发查看的场景。2. 环境准备与核心依赖2.1 本地环境清单文章示例会用到 Docker、Nginx、Bun 和 curl。建议先检查本机环境避免中途因为版本问题中断。组件作用验证方式Docker Engine提供容器运行和容器 APIdocker versionNginx作为 Web 服务器指标源nginx -vBun运行 TypeScript 后端代码bun --versioncurl验证 Docker API 和 Nginx statuscurl --version浏览器打开 Dashboard 页面可用 Chrome 或 Edge示例代码没有锁死版本。如果你使用 Node.js代码主体也可以运行只需要把bun命令换成node并补充types/node依赖。实际项目落地前请先按自己的系统版本确认依赖兼容性。2.2 让后端程序能访问 Docker EngineDocker Engine 默认在本地 Unix socket 上监听路径是/var/run/docker.sock。后端程序要读取容器列表本质上是向这个 socket 发起 HTTP 请求。先用 curl 验证 Docker API 是否可访问docker version docker ps curl --unix-socket /var/run/docker.sock http://localhost/containers/json?allfalse如果当前用户不在docker组中curl 会返回curl: (56) Recv failure: Connection reset by peer或权限错误。此时可以临时用sudo curl验证但要注意生产环境不要轻易给用户加入docker组。更好的方式是通过受控的 API 代理读取这个话题在后面安全章节展开。如果你不想让程序直接访问 Unix socket也可以把 Docker daemon 的 TCP 端口打开例如监听127.0.0.1:2375。但这里要特别说明Docker Engine API 没有内置认证一旦监听在非回环地址等于开放了 root 权限。开发环境临时用可以生产环境必须禁用明文 TCP。2.3 配置 Nginx stub_status为了让 Dashboard 有 Web 服务器指标可看需要让 Nginx 暴露一个状态端点。用标准配置即可server { listen 8080; location /nginx_status { stub_status on; access_log off; allow 127.0.0.1; deny all; } }配置完成后重新加载 Nginxnginx -s reload curl http://127.0.0.1:8080/nginx_status正常会输出Active connections: 1 server accepts handled requests 10 10 20 Reading: 0 Writing: 1 Waiting: 0如果返回 404先检查 Nginx 是否编译了stub_status模块nginx -V 21 | grep -o with-http_stub_status_module没有输出时说明当前 Nginx 版本不包含该模块需要换用带该模块的发行版或改用 nginx-prometheus-exporter 这类替代方案。2.4 Bun 运行时安装与卸载注意点Bun 是一个 JavaScript 和 TypeScript 运行时内置 fetch、WebSocket 和打包器启动速度比 Node.js 更快。用它写这个示例可以减少依赖安装一个文件就能跑起后端服务。安装 Buncurl -fsSL https://bun.sh/install | bash bun --version如果你之前安装过 Bun但不确定版本可以先执行which bun查看安装路径。Bun 的卸载方式取决于安装方式脚本安装通常可以直接删除~/.bun目录并清理 shell 配置文件里的 PATH 路径。这里不展开因为重点是把运行时版本固定下来。本文代码使用 Bun 1.x 的 API。如果你使用 Node.js安装依赖后把启动命令改成node即可但 WebSocket 部分的代码需要替换成ws库或socket.io。3. 实现一个最小 Aphorio 风格 Dashboard3.1 项目结构和依赖项目目录如下aphorio-demo/ ├── package.json ├── src/ │ ├── docker.ts │ ├── nginx.ts │ └── server.ts └── public/ └── index.htmldocker.ts负责容器列表采集nginx.ts负责 Nginx 状态采集server.ts负责启动 HTTP 服务和 WebSocket 推送index.html是浏览器端展示页面。依赖只需要dockerode它封装了 Docker Engine API 的客户端逻辑{ name: aphorio-demo, module: src/server.ts, type: module, scripts: { dev: bun run --hot src/server.ts }, dependencies: { dockerode: ^4.0.0 }, devDependencies: { types/bun: ^1.0.0, types/dockerode: ^3.3.0 } }安装依赖bun install如果使用 Node.js可以换成npm install或pnpm install。3.2 容器列表采集容器列表信息来自 Docker Engine 的containers/json接口。通过dockerode读取时不需要手工拼接 URL直接调用listContainers即可import Docker from dockerode; const docker new Docker({ socketPath: /var/run/docker.sock, }); export async function collectContainers() { const containers await docker.listContainers({ all: false }); return containers.map((c) { const name c.Names[0]?.replace(/^\//, ) ?? ; return { id: c.Id.slice(0, 12), name, image: c.Image, state: c.State, status: c.Status, ports: c.Ports.map((p) ({ containerPort: p.PrivatePort, hostPort: p.PublicPort ?? null, hostIp: p.IP ?? null, })), }; }); }这里all: false表示只返回运行中的容器。如果要查看所有容器包括退出状态需要改成all: true。开发阶段建议先保持false避免页面一开始出现大量无用的 exited 容器。dockerode返回的Ports数组里每个元素包含PrivatePort和PublicPort。如果容器没有做端口映射PublicPort为null。前端展示时需要兼容这个空值。3.3 Web 服务器状态采集Nginx 状态端点返回的是纯文本解析起来很简单。用fetch拉取文本后按行匹配数字即可export async function getNginxStatus() { const res await fetch(http://127.0.0.1:8080/nginx_status); if (!res.ok) { throw new Error(nginx status error: ${res.status}); } const text await res.text(); const lines text.split(\n); const activeMatch lines[0]?.match(/(\d)/); const requestsMatch lines[2]?.match(/^\s*(\d)\s(\d)\s(\d)/); const detailMatch lines[3]?.match( /Reading:\s*(\d)\s*Writing:\s*(\d)\s*Waiting:\s*(\d)/ ); return { active: activeMatch ? Number(activeMatch[1]) : 0, accepts: requestsMatch ? Number(requestsMatch[1]) : 0, handled: requestsMatch ? Number(requestsMatch[2]) : 0, requests: requestsMatch ? Number(requestsMatch[3]) : 0, reading: detailMatch ? Number(detailMatch[1]) : 0, writing: detailMatch ? Number(detailMatch[2]) : 0, waiting: detailMatch ? Number(detailMatch[3]) : 0, }; }解析时要特别注意第二行是标题行第三行才是accepts handled requests。直接按第一行和第二行解析会导致数字错位。如果 Nginx 返回 404这里会抛异常调用方需要捕获并降级处理。3.4 WebSocket 推送与前端页面后端定时采集并保存一份内存快照然后通过 WebSocket 广播给所有连接的前端。核心服务使用 Bun 内置的Bun.serve代码量很少import { collectContainers } from ./docker; import { getNginxStatus } from ./nginx; const clients new SetWebSocket(); let latestSnapshot { containers: [], nginx: null, updatedAt: 0, }; async function collect() { const [containersResult, nginxResult] await Promise.allSettled([ collectContainers(), getNginxStatus(), ]); latestSnapshot { containers: containersResult.status fulfilled ? containersResult.value : [], nginx: nginxResult.status fulfilled ? nginxResult.value : null, updatedAt: Date.now(), }; } async function broadcast() { const payload JSON.stringify(latestSnapshot); for (const client of clients) { if (client.readyState 1) { client.send(payload); } } } await collect(); setInterval(() { collect().then(broadcast); }, 2000); const server Bun.serve({ port: 3000, async fetch(req) { const url new URL(req.url); if (url.pathname /api/status) { return Response.json(latestSnapshot); } if (url.pathname /ws) { if (server.upgrade(req)) { return; } return new Response(upgrade failed, { status: 400 }); } if (url.pathname /) { const html await Bun.file(public/index.html).text(); return new Response(html, { headers: { Content-Type: text/html; charsetutf-8 }, }); } return new Response(not found, { status: 404 }); }, websocket: { open(ws) { clients.add(ws); ws.send(JSON.stringify(latestSnapshot)); }, message(_ws, _msg) {}, close(ws) { clients.delete(ws); }, }, }); console.log(Aphorio-style dashboard listening on http://localhost:${server.port});采集周期是 2 秒。collect()之后立即调用broadcast()保证新客户端连接时能拿到最新快照。这里使用Promise.allSettled即使 Nginx 状态采集失败也不会影响容器列表展示。前端页面通过WebSocket连接/ws收到消息后直接渲染表格!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titleAphorio-style Dashboard/title style body { font-family: sans-serif; padding: 24px; } table { border-collapse: collapse; width: 100%; margin-top: 16px; } th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; } /style /head body h1Container / Nginx Dashboard/h1 pWebSocket status: span idstateconnecting.../span/p h2Containers/h2 table thead tr thID/th thName/th thImage/th thState/th thStatus/th /tr /thead tbody idcontainerBody/tbody /table h2Nginx/h2 div idnginxInfowaiting for data.../div script const stateEl document.getElementById(state); const bodyEl document.getElementById(containerBody); const nginxEl document.getElementById(nginxInfo); function render(data) { stateEl.textContent connected; bodyEl.innerHTML ; for (const item of data.containers) { const tr document.createElement(tr); tr.innerHTML td${item.id}/td td${item.name}/td td${item.image}/td td${item.state}/td td${item.status}/td ; bodyEl.appendChild(tr); } if (data.nginx) { nginxEl.textContent active: ${data.nginx.active}, requests: ${data.nginx.requests}, reading: ${data.nginx.reading}, writing: ${data.nginx.writing}, waiting: ${data.nginx.waiting} ; } else { nginxEl.textContent no nginx data; } } const ws new WebSocket(ws://${location.host}/ws); ws.onopen () { stateEl.textContent connected; }; ws.onclose () { stateEl.textContent disconnected; }; ws.onerror () { stateEl.textContent error; }; ws.onmessage (event) { render(JSON.parse(event.data)); }; /script /body /html这个页面没有引入任何前端框架核心逻辑就是接收 WebSocket 消息并更新 DOM。对最小示例来说这比引入 React 或 Vue 更容易理解也更容易排查问题。3.5 关键参数和性能调优点参数示例值影响采集间隔2000ms越小实时性越高Docker socket 和 Nginx 请求压力越大广播间隔2000ms与采集频率保持一致避免推送过期数据WebSocket 心跳30s防止代理或浏览器误判连接过期快照缓存内存保存避免每个前端请求都实时查询 Docker API容器数量上限未设置超过千行时建议启用分页或虚拟滚动开发环境用 2 秒采集足够。生产环境建议把采集间隔调到 5 到 10 秒降低 API 压力同时保留“近实时”的体验。真正需要秒级变化的场景应该用 Docker 事件流和 Prometheus 指标而不是轮询整个容器列表。4. 运行验证与结果分析4.1 启动服务和预期日志安装依赖后启动服务bun run src/server.ts正常会输出Aphorio-style dashboard listening on http://localhost:3000如果端口被占用会抛出EADDRINUSE错误。可以先检查端口占用lsof -i :3000占用后换一个端口或者结束占用进程。4.2 用 curl 验证关键接口服务启动后先验证 HTTP 接口curl -s http://localhost:3000/api/status预期返回类似下面的 JSON{ containers: [ { id: a1b2c3d4e5f6, name: web-demo, image: nginx:alpine, state: running, status: Up 2 minutes, ports: [] } ], nginx: { active: 1, accepts: 10, handled: 10, requests: 20, reading: 0, writing: 1, waiting: 0 }, updatedAt: 1690000000000 }如果containers为空先执行docker ps确认是否有运行中的容器。如果nginx为null先单独执行curl http://127.0.0.1:8080/nginx_status确认 Nginx 配置是否生效。4.3 浏览器验证 WebSocket打开http://localhost:3000页面会先显示 “connecting...”然后变成 “connected”。容器列表会自动渲染Nginx 指标文本会持续更新。在浏览器开发者工具的 Network 面板中筛选类型为WS可以看到 WebSocket 连接。切换到 Messages 页签每隔 2 秒会收到一条完整快照消息。如果页面显示 “disconnected”说明 WebSocket 连接被断开需要检查后端日志和浏览器控制台报错。4.4 性能观察和对比方法标题里提到了 “maybe faster than Bun”实际验证时可以用事件循环延迟来对比不同运行时。这里给出一个简单的测量思路用hrtime统计setInterval是否发生明显漂移let last process.hrtime.bigint(); setInterval(() { const now process.hrtime.bigint(); console.log(delay ms: ${Number(now - last) / 1e6}); last now; }, 1000);分别用bun和node运行这段代码同时向服务端持续发起 WebSocket 连接或 HTTP 请求观察 delay 是否稳定。这个结果只能说明当前机器、当前代码、当前压测条件下的差异不能直接推导出“Aphorio 比 Bun 快”的结论。性能对比要控制变量并在多轮测试后取中位数这样才有参考价值。5. 常见问题排查5.1 Docker socket 权限不足现象后端启动后容器列表一直为空控制台日志出现Error: connect EACCES /var/run/docker.sock原因当前用户没有访问 Docker socket 的权限。检查方式ls -l /var/run/docker.sock如果所属组是docker可以将当前用户加入 docker 组sudo usermod -aG docker $USER然后重新登录终端。开发环境也可以临时用sudo启动服务但不推荐养成这种习惯。更安全的方式是使用 Docker 的只读 API 代理而不是直接开放 socket 权限。5.2 Docker API 版本不匹配现象请求容器列表时抛出Error: Docker API error (404): 404 page not found或者client version 1.43 is too new. Maximum supported API version is 1.41原因dockerode默认与 Docker Engine 协商版本但如果你在代码中手动指定了过期版本或 Docker Engine 版本过旧就会出现不匹配。处理方式升级dockerode或者删除手动指定的version参数让客户端自动协商。const docker new Docker({ socketPath: /var/run/docker.sock, version: 1.41, // 不推荐长期写死 });生产环境建议用与 Docker Engine 匹配的稳定版本并在 CI 或发布脚本里做一次接口兼容性检查。5.3 Nginx stub_status 没有数据现象Dashboard 的 Nginx 区域显示 “no nginx data”但 Nginx 本身访问正常。检查顺序现象可能原因检查和处理请求/nginx_status返回 404没有配置stub_status或 location 路径不对检查 Nginx 配置并 reload请求返回 403allow和deny顺序不正确先allow 127.0.0.1;再deny all;能访问但数据全为 0当前没有活跃连接属正常现象可以用压测工具制造请求模块不存在当前 Nginx 未编译 stub_status换发行版或改用 nginx-prometheus-exporter其中deny all写在前面会把所有请求都拒绝包括本地访问。正确顺序是先用allow放行指定 IP再deny all。5.4 WebSocket 连接频繁断开现象页面刚打开时正常几十秒后状态变成disconnected刷新后又能恢复。可能原因反向代理没有配置 WebSocket 升级。代理的超时时间太短长时间没有消息时连接被杀掉。前端没有发送心跳代理误判连接空闲。处理方式如果使用 Nginx 反代需要配置Upgrade和Connection请求头。后端增加心跳机制每 30 秒向客户端发送一次ping或普通消息。前端增加重连逻辑断开后 1 到 3 秒自动重新连接。对于最小示例可以先在浏览器 Network 面板观察断开时间点结合后端日志判断是哪一侧先关闭连接。多数情况下是代理空闲超时导致的。6. 生产环境加固与扩展方向6.1 不要直接暴露 Docker socket开发环境直接使用/var/run/docker.sock很方便但生产环境如果把这个 socket 暴露给 Web 服务风险极高。Docker Engine API 没有内置认证能访问 socket 的人几乎等价于可以控制宿主机。Dashboard 只应该读取容器信息不应该具备创建、删除容器的能力。推荐的做法是做一个只读代理服务绑定在127.0.0.1通过内部网络访问。代理层只允许GET /containers/json等安全接口。所有请求都校验 Token 或 mTLS 证书。对 Docker API 的返回结果做字段裁剪移除敏感信息。如果只是内部使用可以加上 IP 白名单限制 Dashboard 只能从固定网段访问。6.2 把采集层和展示层解耦最小 Demo 中后端直接采集 Docker 和 Nginx 数据然后推送给浏览器。这个架构在单机、少量容器下没有问题但一旦需要历史曲线、告警、多节点聚合就会变得笨重。更成熟的模式是引入指标中间层Docker 指标用 cAdvisor 或 Docker Engine metrics 采集。Nginx 指标用 nginx-prometheus-exporter 转换成 Prometheus 格式。Prometheus 负责存储时序数据。Dashboard 只从 Prometheus API 查询数据不再直接访问 Docker socket。这样做的好处是告警、历史查询、图表展示可以共用同一份数据源Dashboard 只是消费端。即使前端挂了指标采集仍然在后台持续运行。6.3 性能优化与多节点扩展如果 Dashboard 要管理多台服务器不要让中心节点直接去采集所有机器的 Docker 和 Nginx。可以在每台机器上部署一个 agentagent 负责本地采集再用 WebSocket 或消息队列把数据上报到中心节点。前端在容器数量超过几百行时应该启用虚拟滚动。最简单的做法是把快照数据按容器名称排序只渲染可视区域内的行。WebSocket 广播也可以从“每次推送完整快照”优化