1. 项目概述
1.1 起项目之前,我是怎么想的
先说说这个项目是怎么冒出来的。前阵子我一直在折腾自托管服务的部署,服务器上跑了十几个 Docker 容器,从数据库到反向代理,从监控面板到定时任务,林林总总一大堆。本地开发时这些服务都是手动docker run或者用 docker-compose 一把梭,但用着用着就发现一个尴尬的问题:容器越来越多,每次想查看某个服务的运行状态、日志、端口映射,都得在终端里敲一长串命令,或者打开好几个终端窗口来回切换。更别提家庭成员或者不懂技术的朋友偶尔想用某个服务,我总不能教他们 SSH 上去敲docker ps吧。
我当时的想法很简单:能不能有一个可视化的界面,把 Docker 容器的核心操作都收拢进去,不用记命令行,不用改 YAML 文件,像操作手机 App 一样点点鼠标就能完成管理。
这就是 BrewUI 的起点。
严格来说,BrewUI 这个名字听起来像是一个跟 Homebrew 有关的工具(毕竟 Homebrew 在开发圈太出名了),但实际它是一个 Docker 容器管理面板。我起这个名字的初衷是希望它像 Homebrew 一样"brew"出一个干净、优雅的管理体验——装完即用,命令精简,界面清爽。虽然名字上可能有点撞车,但我个人还挺喜欢这种调性的。
这个项目能做什么?一句话概括:通过浏览器端的图形化界面,完成 Docker 容器、镜像、卷、网络、日志、资源监控等日常管理操作。适合谁?适合那些已经用 Docker 跑了不少服务、但不想被命令行束缚的个人开发者、极客玩家,以及家里有 NAS 或自建服务器、偶尔需要管理容器服务的小团队。
1.2 项目的核心需求与定位
在动手写代码之前,我先把需求捋了一遍。这不是拍脑袋想出来的功能列表,每一项都是从实际使用场景里反推出来的。
当时我给自己列了几个必须解决的问题:
第一,容器状态一眼可见。我打开面板的第一屏,就应该看到所有容器的运行状态、CPU 和内存占用、端口映射,不用再像以前那样docker stats开着然后盯着刷新。
第二,常用操作点两下完成。启动、停止、重启、删除、查看日志,这是我日常操作频率最高的几件事。这些操作必须放进一个鼠标事件里,而不是让我去记docker rm -f这种危险命令。
第三,日志查看要友好。Docker 日志输出是流式的,docker logs -f在终端里看还行,但想要搜索、按时间筛选、复制某段日志,终端体验就很一般了。浏览器端用日志面板做这些事会顺手很多。
第四,镜像管理不能少。我经常要从 Docker Hub 拉取镜像、查看已有镜像列表、删除没用的悬空镜像。这些也应该有可视化入口。
这四个需求定了之后,项目的定位就很清楚了:做一个轻量级的 Docker 容器管理面板,不追求像 Portainer 那样的大而全,重点解决个人和小团队最常用的那些操作,把体验做顺。这是我当时给自己划的边界——先做减法,把核心场景做透,再谈扩展。
1.3 技术选型背后的思考
技术栈我选了 Go + React,后续会说为什么。这里先给一个总览,后面每一块展开讲。
后端用 Go,主要原因是我对它的部署和并发处理比较熟悉,而且最终产物是一个单文件二进制,部署到服务器上非常省事,不像 Python 或 Node 那样要装一堆依赖。Docker API 的操作本质上是 HTTP 请求,Go 的标准库和官方 Docker SDK 都能很好对接,加上 goroutine 在处理实时日志流、容器状态轮询这些场景下非常顺手。
前端用 React,因为管理面板核心就是数据展示和状态交互,组件化开发能让我把容器列表、日志查看器、资源监控图这些模块拆分开来,各自维护,后期加功能也方便。UI 库我用了 Ant Design,成熟、组件全、上手快,不太需要自己造轮子。
通信层用 WebSocket 实现容器日志和资源监控的实时推送。这一块是后面做得最痛苦的环节之一,但也是体验差异化的关键——如果只用 REST API 定时轮询,日志和 CPU 曲线都会有明显的延迟,整个面板用起来就会有一种"卡卡"的迟钝感。
最终整个架构是这样:浏览器前端通过 REST API 和后端通信,处理增删改查类操作;通过 WebSocket 接收日志流和实时监控数据;后端通过 Docker Engine API 和 Docker daemon 交互,拿到容器、镜像、卷、网络的真实状态。
提示:如果你只是想快速搭一个面板,Portainer 其实已经做得很好了。我做 BrewUI 更多是为了满足自己对"够用、轻量、顺手"的定义,同时把这套前后端交互的完整链路跑通。如果你也有类似的想法,就当这是个参考案例吧。
2. 功能拆解与界面设计思路
2.1 容器管理模块:先解决最痛的问题
容器管理是这个面板的第一优先级,也是页面最核心的模块。我把它放在整个 UI 的首页,打开面板第一眼看到的就是容器列表。
容器列表页展示的信息我仔细斟酌过,不是把docker ps的输出全部搬过来,而是挑了用户最关心的几个字段:容器名、镜像名、运行状态、端口映射、资源占用、创建时间。每个字段都有存在的理由——容器名让人知道这是哪个服务,镜像名告诉人这个容器跑的是什么环境,端口映射直接决定你能不能访问到这个服务,资源占用判断一个容器是否异常。
列表的操作区,我放了启动、停止、重启、删除、日志、终端这六个按钮。其中删除操作我加了二次确认弹窗,并且默认使用docker rm -f的强制语义,因为容器在运行中直接删通常会失败,与其让用户遇到报错再手动勾选强制删除,不如一开始就把这个选项摆出来。
容器详情页是列表页的延伸,展示的内容更细:环境变量、全量端口映射、挂载卷列表、网络模式、重启策略、元数据(如标签、创建时间、修改时间)。因为这个面板定位是轻量级,所以我没有做编辑容器这种复杂功能——改容器参数本质上是删了重建,风险不小,这个操作我还是建议回到命令行去做。
我在设计这个模块时的一个重要决定是:所有操作都请求后端接口,由后端调用 Docker SDK 完成,前端不直接和 Docker daemon 通信。这样前端逻辑简单,后端可以统一处理错误、鉴权、超时重试。事实上,把 Docker daemon 的 Unix socket 直接暴露给前端是不可能的,浏览器端根本没有权限访问宿主机的 socket 文件,所以后端中转是唯一合理的路径。
2.2 镜像管理模块:简洁但该有的都有
镜像管理页面相对简单,核心就三个功能:查看镜像列表、拉取新镜像、删除镜像。
列表展示的信息包括仓库名、标签、镜像 ID、大小、创建时间。镜像 ID 我默认展示前 12 位短 ID,完整 ID 太长,放在列表里纯粹是噪音。如果你想看完整 ID,鼠标悬浮在短 ID 上会用 tooltip 展示完整值,这个交互成本低又保持了界面整洁。
拉取镜像功能我做了两种方式:一种是直接输入镜像名(比如nginx:latest),点击拉取;另一种是展示当前常用的镜像快捷按钮,点击即可填入。拉取过程我用了进度条提示,因为大镜像的拉取时间可能长达几分钟,如果没有反馈,用户容易误以为卡死了。
删除镜像功能我加了一个保护逻辑——如果镜像正在被某个容器使用,删除会直接报错,错误信息会明确提示"该镜像正被容器 xxx 使用,请先删除或停止该容器"。这比 Docker 原生报错"image is being used by container"要友好得多,因为我做了镜像和容器的关联查询,直接把被哪个容器占用列了出来。
悬空镜像(dangling images)我单独做了一个清理按钮,一键清除所有<none>:<none>标签的镜像。这类镜像是反复构建产生的垃圾,在我们这种经常折腾环境的人身上尤其常见,清理完能释放不少磁盘空间。
2.3 日志查看器:把终端日志搬进浏览器
日志查看是使用频率非常高的功能,但也是最容易被面板类项目忽略的模块。很多人觉得日志不就是把 stdout 输出到页面上滚动吗?实际操作起来会发现不少问题:日志量大的时候页面渲染会卡死、日志输出过快时浏览器内存会暴涨、还要支持按时间范围筛选和关键词搜索。
我的方案是:进入日志页时默认展示最近 500 行,这是通过docker logs --tail 500实现的,既能快速有内容展示,又不会因为日志太多导致首次渲染卡顿。然后通过 WebSocket 实时追加后续日志。
实时日志推送我用了docker logs -f的跟随模式。这里有个细节:docker logs默认只输出 stdout,需要同时加上--stdout --stderr两个参数才能拿到全部日志。这个坑我一开始没注意到,导致排查问题的时候明明程序报错了,日志面板里却看不到任何错误信息,最后发现是 stderr 流被默认丢弃了。
搜索功能我做了前端实时过滤。当打开搜索框、输入关键词时,新接收的日志行如果匹配关键词就显示,不匹配就暂时隐藏;同时提供"高亮匹配"模式,让所有匹配的行背景色加深,方便你像在 IDE 里找 code 一样快速定位问题。
WebSocket 连接断开时的处理我也想了很久。最终策略是:检测到连接异常后自动重连,重连时带上最近 200 行的日志作为上下文,这样既能保证新日志不丢,又不会因为重连导致屏幕内容清空重来。这个体验细节用户可能感知不到,但实际用起来真的很重要——网络抖动的时候不会突然丢日志。
2.4 资源监控:让实时曲线说真话
资源监控模块我最初是想做成锦上添花的,但用下来发现它其实是排查问题的重要工具。比如某个容器突然 CPU 飙升,光看日志你可能不知道为什么,但切到资源监控页看到 CPU 曲线像坐火箭一样上去,大概就能判断是并发上来了还是代码死循环了。
这个模块用 WebSocket 每秒推送一次数据,包括每个容器的 CPU 使用率、内存使用量/总量、网络接收/发送字节数。前端用 ECharts 画实时折线图,数据窗口保存最近 30 分钟,从右往左滚动展示。
CPU 占用率的计算是个容易被绕进去的坑。Docker API 返回的是容器在某个时间段内累计消耗的 CPU 时间(纳秒),要算"当前 CPU 使用率",必须连续取两次值做差值,再除以两次采样的时间间隔,还要乘以 CPU 核数。公式大致是:
cpu_percent = (cpu_delta / system_delta) * cpu_count * 100如果你直接拿单次的值去除,算出来的数字要么永远接近 0,要么偶尔跳一下,完全没法看。这一点在做 Docker 监控的同学一定要留意。
内存占用就相对简单了,拿usage除以limit的百分比就行。但要注意,Docker 的limit默认是宿主机总内存,不代表容器被限制的内存上限,如果你设置了--memory参数,limit才会等于那个限值。所以展示内存占用率的时候,我同时显示了使用量和限制值,而不是只给一个百分比,防止用户误判。
2.5 项目信息与其他设置
项目信息页放了当前 BrewUI 的版本号、构建时间、Docker API 版本、宿主机系统信息。这些信息平时不怎么看,但排查跨版本兼容问题时非常有用。比如 Docker API 版本不匹配导致某些接口返回错误,用户把版本号发给我,我能立刻定位是不是兼容性问题。
设置页目前提供两个功能:刷新时间间隔(默认 5 秒,可调 2 秒到 30 秒)和 WebSocket 重连开关。这两个功能都是工具型面板里容易被忽视但实际影响使用体验的细节。另外我在设置页展示了一个"危险操作"区域,放置了清理所有停止容器和清理所有悬空镜像的按钮,用醒目的红色区分,并且每次点击都要输入"CONFIRM"才能执行,防止手滑。
3. 核心实现细节
3.1 后端:Go + Docker SDK 的接入方式
后端我用了 Go 1.21 + Docker 官方 SDK(github.com/docker/docker/client)。接入方式非常直接:创建一个 Docker client 实例,然后调用对应的方法。创建客户端时有两个关键参数需要处理:
- 连接地址:默认是
unix:///var/run/docker.sock,如果用户配置了远程 Docker 的 TCP 地址(比如tcp://192.168.1.10:2375),可以从配置文件里读取。 - API 版本:SDK 会自动协商,但如果你连接的是旧版本 Docker daemon,最好用
client.WithAPIVersionNegotiation()开启版本协商,避免接口字段不兼容导致解析报错。
容器列表的获取,本质上就是对docker ps -a的封装。SDK 里的方法定义很清晰,你传入一个过滤器,返回容器摘要列表。默认我会过滤掉 pause 状态的容器,因为这类容器比较特殊,既不在运行也不在停止,用户看到容易困惑。
容器操作的核心逻辑是四个方法:ContainerStart、ContainerStop、ContainerRestart、ContainerRemove。其中停止容器时我设置了 10 秒的等待时间,给容器里正在跑的任务一个优雅退出的机会;如果 10 秒后还没停止,SDK 会返回超时错误,这时候我可以选择强制杀。这个超时时间的取值我测试过后觉得 10 秒比较合适——太短会导致任务来不及保存退出,太长用户会感觉操作很拖沓。
日志拉取的实现稍微复杂一点,核心代码逻辑大致是:
logs, err := cli.ContainerLogs(ctx, containerID, container.LogsOptions{ ShowStdout: true, ShowStderr: true, Follow: true, Tail: "500", })这段代码拿到的是一个 IO reader,里面混合了 stdout 和 stderr 两条流。这里有个 SDK 的隐藏细节:Docker 的日志格式是多路复用(multiplexed)的,前 8 个字节是流类型和长度信息,后面才是真正的日志字节。所以不能直接把 Read 到的内容当作字符串输出,需要先用 SDK 提供的stdcopy.StdCopy或者手动解析帧头再做转换。我第一次实现偷懒直接读了,结果日志面板显示出了大量乱码。
3.2 前端:React + Ant Design 的组件化实现
前端用了 React 18 + Ant Design 5 + Vite 4。Vite 我选择它的原因就一个字:快。开发时的热更新几乎是秒级响应,改一下组件保存就能看到效果,这对一个需要频繁调试 UI 的项目来说非常重要。
前端页面结构我分成了四个主要组件:ContainerList、ImageList、LogViewer、MonitorDashboard。每个组件独立管理自己的状态和 API 请求,互不干扰。比如你在容器列表页重启了一个容器,左侧导航栏上的"运行中"数量徽标会自动更新,这是通过一个全局状态管理库(Zustand)实现的,所有组件共享一份容器状态摘要。
容器列表的表格我用了 Ant Design 的Table组件,开启了自己的分页逻辑(每页 20 条)。这里有个体验细节:默认不开启服务端分页,而是客户端一次性拉取全部容器再在浏览器端分页。因为对于个人服务器来说,容器数量通常不会超过 100 个,一次性拉取完整列表可以让搜索和排序丝滑很多,不需要每次切换页码都发一个请求。
操作按钮我用了Popconfirm弹窗做二次确认。删除是危险操作,我在弹窗中注明了"该操作不可撤销";重启操作则没有弹窗,直接执行,因为重启的风险相对可控。这种"有选择地确认"比所有操作都弹窗或所有操作都不弹窗要好用得多。
日志查看器我用了一个虚拟滚动的自定义组件。不直接渲染所有日志行,而是只渲染可视区域内的行,配合上下文的"滚动到顶部自动加载更早日志"功能,这样即使日志有数万行,页面渲染依然流畅。这个方案其实很多聊天软件和终端模拟器都在用,React 社区也有现成的react-window库,但那个库的 API 在动态行数下有些别扭,我最终还是自己实现了这个组件。
3.3 WebSocket 双向通信的设计细节
BrewUI 的 WebSocket 通信模块是独立的一个服务,路径是/ws。前端在页面加载时建立连接,之后所有实时数据(容器状态变更、日志流、监控指标)都通过这个连接推送。
我用 WebSocket 而不是 SSE 的原因主要有两点:一是 WebSocket 是双向的,前端除了接收数据,还可以向后端发送指令(比如"我进入日志页了,请从最近 500 行开始推");二是 WebSocket 的消息是二进制安全的,传输 Docker 日志这种可能包含任意字节的数据时,不需要像 SSE 那样做特殊转义处理。
连接建立后,我定义了几种消息类型:
container_status:容器状态变更通知,发生容器启动/停止/删除时推送log_stream:日志流数据,包含容器 ID 和日志内容monitor_data:每秒推送的监控指标ping/pong:心跳消息,每 30 秒一次,用于检测连接是否老化
心跳机制很重要。因为很多反向代理(Nginx、Caddy)默认有 60 秒的空闲超时,如果一个 WebSocket 连接超过 60 秒没有数据流动,连接会被代理强制断开。我的解决方案是服务端每 30 秒主动发一个 ping,客户端收到后自动回复 pong,这样能让连接保持活性。前端收到 pong 后也会更新"连接状态"显示,如果连续三次没收到 pong,就触发重连逻辑。
重连逻辑我做了指数退避:第一次断开后等 1 秒重连,第二次等 2 秒,第三次等 4 秒,最多等待 30 秒。同时重连成功后,前端会重新订阅之前订阅的容器日志和监控数据,让界面自动恢复到断开前的状态。
3.4 鉴权机制:虽然轻量但必须有
作为管理面板,鉴权是不能省的。但考虑到这个项目的定位是轻量级工具,我也没有做成复杂的用户权限体系,而是采用了一个简单可靠的方式:Token 鉴权。
用户首次启动 BrewUI 时,可以通过环境变量或配置文件设置一个访问 Token。之后访问面板的任何页面,都需要在 HTTP 请求头里带上Authorization: Bearer <token>。WebSocket 连接建立时,也会在 URL 参数或首个消息中验证 Token,验证不通过直接关闭连接。
登录页面就是一个简单的输入框,输入 Token 后点击登录,前端把 Token 存储到浏览器的 localStorage,之后的所有请求自动带上。Token 的默认有效期我设置成 7 天,过期后跳回登录页重新输入。
这个方案的安全级别足够应对局域网或通过反向代理加 HTTPS 暴露的公网访问。但如果你的服务器直接暴露在公网上,我强烈建议在反向代理层面再加一层基础认证或者 IP 白名单——毕竟任何管理面板都不应该裸奔在公网上,这不是技术问题,是基本的安全意识。
提示:这个鉴权方案有一个已知的短板,就是它无法做多用户区分——谁登录谁没登录、谁能操作哪些资源,都分不开。如果你是团队使用,建议改用 OIDC 或 LDAP 对接,或者直接强化反向代理层的访问控制。
4. 部署与使用教程
4.1 三种部署方式对比
BrewUI 的部署我尽量做成了"零门槛"。按照使用者环境的不同,我提供了三种部署方式:
第一种:Docker 一键部署(推荐)
这是最省事的方案。因为 BrewUI 本身管理 Docker,所以它通常是以容器方式运行在 Docker 里,通过挂载宿主机 Docker socket 来获取管理权限。
docker run -d \ --name brewui \ -p 8080:8080 \ -v /var/run/docker.sock:/var/run/docker.sock \ -e BREWUI_TOKEN=your-secret-token \ --restart=always \ brewui/brewui:latest这里有个非常需要注意的地方:如果你挂载了/var/run/docker.sock,那么这个容器实际上就拥有了宿主机的全部 Docker 管理权限——这等同于宿主机 root 权限。所以这个容器绝对不能暴露到公网,或者必须加严密的反向代理保护。
第二种:二进制直接运行
如果你已经有一台裸机服务器,不想在它上面装 Docker(有些机器就是纯跑数据库和应用的,不让装 Docker),可以直接下载 Go 编译好的二进制文件运行。这样连 Docker 都不用装,只要机器上有 Docker daemon 就行(Docker daemon 通常和 Docker CLI 捆绑安装,很少单独存在,所以这个方案更适合 Docker 环境)。
./brewui -port 8080 -token your-secret-token第三种:源码编译
这个适合想改代码或者调试的人。克隆仓库后:
git clone https://github.com/yourname/brewui.git cd brewui make build ./dist/brewui -port 8080编译产物是一个单文件,包含前端静态资源和后端程序,不需要额外部署 web 服务器,直接跑就行。这个特性特别适合不理解 Node 生态的运维朋友——不用装 Node,不用跑 npm install,就是一个可执行文件。
4.2 配置文件详解
BrewUI 支持通过环境变量或 YAML 配置文件两种方式传入参数。环境变量适合容器部署和快速试跑,配置文件适合长期维护。
配置文件示例:
server: port: 8080 host: 0.0.0.0 auth: token: your-secret-token expire_days: 7 docker: host: unix:///var/run/docker.sock api_version: "auto" monitor: refresh_interval: 5s websocket_reconnect: true关键参数说明:
server.host:默认绑定到0.0.0.0,表示监听所有网络接口。如果你只想让本机访问,改成127.0.0.1即可,这样更安全。docker.host:Docker daemon 的连接地址。默认是 Unix socket,如果你有远程 Docker 环境,可以设成tcp://192.168.1.10:2375。需要注意,远程 Docker 的 2375 端口默认不加密,使用前一定要确认网络环境可信。monitor.refresh_interval:监控数据的推送间隔。默认 5 秒,如果你的机器负载很高或者容器很多,建议调大到 10 秒以上,避免频繁采样占用 CPU 和网络带宽。
4.3 从零到一部署实操记录
我以自己的测试服务器为例,完整走一遍部署过程,方便你对照操作。
我的测试环境是 Ubuntu 22.04,Docker 版本 26.0.1,内存 4G,有两核 CPU,跑着 8 个容器。春节前我在这台机器上正式把 BrewUI 拉起来,作为日常管理入口。
第一步,检查 Docker 是否可用:
docker version这一步是为了确认 Docker daemon 正在运行,并且当前用户有权限访问/var/run/docker.sock。如果提示权限拒绝,需要把当前用户加入 docker 用户组(sudo usermod -aG docker $USER),或者用sudo执行 docker 命令。
第二步,拉取并启动 BrewUI 容器。我用了 Docker Compose 管理,配置文件如下:
services: brewui: image: brewui/brewui:latest container_name: brewui ports: - "8080:8080" volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - BREWUI_TOKEN=my-secret-token-here restart: always第三步,启动并验证:
docker compose up -d curl http://localhost:8080/api/health返回{"status":"ok"}说明服务正常。然后打开浏览器访问http://服务器IP:8080,输入 Token 登录,就能看到容器列表了。
第四步,配置反向代理(我用的是 Caddy,配置很简单):
brewui.example.com { reverse_proxy localhost:8080 }Caddy 会自动申请和管理 HTTPS 证书,几百兆以下的服务器配置这个完全没负担。如果你用 Nginx,配置也差不多,只是要额外处理一下 WebSocket 的升级头,不然日志和监控推送会被 Nginx 挡掉。Nginx 的关键配置是:
location /ws { proxy_pass http://localhost:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }这一块一定不能漏,我见过很多面板部署后页面能打开,但日志一直不刷新,排查半天才发现是反代层把 WebSocket 升级请求干掉了。
4.4 用起来之后:真实操作体验
部署完成以后,我实际用 BrewUI 管理了这段日子,感受最明显的是"切换操作的成本"降下来了。
以前我排查问题,习惯性的流程是:
- SSH 登录服务器
docker ps找到容器名docker logs -f --tail 100 容器名看输出docker stats看资源消耗
这套流程熟得很,但每次都要开终端、敲命令,而且 SSH 断开了还要重新连。现在我在浏览器里挂着一个 BrewUI 标签页,打开就是容器列表,看到哪个容器状态异常,点进去就能看日志、看监控曲线,整个排查过程不需要离开浏览器。
还有一个让我意外好用的场景:临时给别人开一个服务。比如朋友问能不能帮忙跑一个定时爬虫,我直接在前端填上镜像名、端口映射、环境变量,提交后容器就起来了。朋友那边只需要等日志刷出"启动完成"就能用,全程不需要接触服务器。
5. 常见问题与排查技巧
5.1 连接不上 Docker 守护进程
这是最常遇到的启动问题。表现是后端启动后,请求所有接口都返回"Docker daemon is not reachable"或类似的错误。
排查步骤:
- 确认 Docker 是否在运行:
systemctl status docker(或你的系统对应的服务管理命令)。 - 确认 Docker socket 是否存在:
ls -l /var/run/docker.sock。 - 确认运行 BrewUI 的用户对 socket 有访问权限:
sudo -u <运行用户> docker info,如果可以正常返回信息,说明权限没问题。 - 如果你用的是容器方式部署,确认 socket 已经正确挂载到容器里:
docker exec brewui ls -l /var/run/docker.sock,如果不存在说明挂载没生效。
还有一种情况是一些服务器发行版默认使用linux socket而不是unix socket(比如某些以 systemd 管理的发行版用tcp://127.0.0.1:2375暴露 Docker API)。如果是这种情况,需要修改 BrewUI 的配置文件,把docker.host改成tcp://127.0.0.1:2375。
5.2 WebSocket 频繁断开
这个问题的典型特征是:页面打开后,日志和监控数据用着用着就突然不更新了,过几秒又自己恢复。多半是反向代理的空闲超时导致的。
解决方案:
- 确认反向代理是否配置了正确的 WebSocket 升级头(前面 Nginx 那段配置)。
- 调整代理的超时时间。以 Nginx 为例,在
location块里加上:
proxy_read_timeout 3600s; proxy_send_timeout 3600s;- 确认浏览器侧是否开启了代理插件或扩展,某些扩展会干扰 WebSocket 连接,导致连接被断开。这个不常见但确实存在。
我在测试时就踩过这个坑——本地直接访问后端没问题,加了 Nginx 反代后 WebSocket 每 60 秒断一次。排查到最后发现是 Nginx 默认的proxy_read_timeout是 60 秒,而我当时还没有实现 WebSocket 的心跳机制,所以连接一空闲就被断掉了。后来我在后端补了 30 秒心跳,同时把 Nginx 超时时间调大,问题彻底解决。
5.3 日志面板显示乱码
出现这个问题,十有八九是日志流解析的问题。前面说过,Docker 日志流是多路复用的二进制帧,必须解析帧头才能正确分离 stdout 和 stderr 以及获取日志长度。
我的排查建议:
- 先确认你用的 SDK 版本是否有自动解析能力。Go 的 Docker SDK 提供了
stdcopy包,应该优先使用它。 - 如果你的代码是直接对接 HTTP API 拿日志流,那你需要手动解析帧头。Docker 日志帧头格式固定:第一个字节表示流类型(1 为 stdout,2 为 stderr),随后 3 个字节保留,4-7 字节是大端序的日志长度,后面跟着真正的日志内容。这个格式网上文档不好找,一旦解析错就会乱码。
如果你用的是 BrewUI 而不是自己开发的类似项目,那大概率不会遇到这个问题。但如果你也正在做一个 Docker 管理工具,并且遇到了乱码,可以参考上面的解析方式排查。
5.4 容器删除失败
界面提示"容器删除失败",但用命令行干同样的事情却成功。如果你遇到这种情况,先检查容器是不是还连着其他资源。
常见原因是容器正在被其他容器依赖(比如 Docker Compose 网络)。先停掉依赖这个容器的服务,再删这个容器,就会顺利很多。另一个原因是容器处于 paused 状态,需要先 unpause 再删除。
我也遇到过一个挺诡异的问题:容器明明已经停了,但删除还是失败,提示removal of container ... is already in progress。这是因为容器删除是一个异步过程,如果上一次删除请求还在执行中,再发一次就会得到这个错误。BrewUI 的处理是对这个错误做了重试逻辑,等几秒后自动重试一次,基本都能成功。
5.5 资源监控数字不动
监控页的 CPU 和内存曲线不动了,但容器还在正常跑。
我遇到过一次这种情况,最终定位是 Docker API 版本太低,不支持/containers/{id}/stats接口返回完整的 CPU 统计信息。如果你的 Docker 版本低于 1.13,建议先升级 Docker。更常见的原因其实是前端的 WebSocket 断了(参考 5.2 的排查方式),但界面还显示着旧数据。
另外,有些云服务器厂商的虚拟机或者容器实例,宿主机层面的 CPU 统计在某些系统配置下拿不到准确的增量数据,导致 CPU 使用率计算出来永远是 0。这种情况我没有特别好的办法,只能建议你换个 Docker 环境验证一下。
5.6 操作响应很慢
如果你感觉点击按钮后响应很慢(比如启动一个容器要等好几秒),先把网络因素排除掉。如果你通过反向代理访问,代理的缓冲设置可能影响响应速度,不过这个场景在管理面板里不是主要矛盾。
更常见的原因是 Docker daemon 本身负载很高。如果你想验证这一点,可以到监控页看看 CPU 曲线是否持续打满;如果是,Docker 本身的响应延迟就会升高。
还有一个容易被忽略的点:如果你管理的容器数量非常多(几百个以上),每次拉取容器列表的 API 调用都会遍历所有容器,同时计算 CPU 占用率需要做两次采样,这个开销是叠加的。我的建议是把刷新间隔调大,比如从 5 秒调到 15 秒,这样能显著降低后端的负载。
6. 实操心得与项目扩展思考
6.1 做这个项目我学到的三个教训
第一个教训是关于"功能边界"的。这个项目刚开始做的时候,我特别想加上镜像构建功能——用户可以在前端填写 Dockerfile,点一下按钮就构建出镜像。这个功能听起来很酷,但实现起来极其复杂:要处理构建日志的实时推送、构建上下文的上传、多阶段构建的中间层缓存清理等。我花了一周时间做了个半成品,最后还是砍掉了。回过头来看,这个决定是对的。因为面板类工具的核心价值是"把高频操作变简单",而不是"取代命令行"。镜像构建这种低频又复杂的操作,老老实实用docker build就好,强行搬进面板只会把面板搞得很臃肿,还容易出各种奇怪的 bug。
第二个教训是关于"实时数据推送"这个噱头的。刚开始做日志推送功能时,我天真地以为把docker logs -f的输出直接转发给前端就完事了。但实际用起来,日志量大时浏览器内存涨得飞快,页面越来越卡;日志包含\r(回车不换行)时显示会覆盖前一行;日志里有时会夹杂二进制控制字符,渲染出来是乱码。这些都不是一朝一夕能调完的。最终我做了一大堆处理:截断超长行、过滤控制字符、使用虚拟滚动、限制前端缓存的行数。这个功能的复杂度远超我最初的预期,但也正因为这些细节,现在用起来才真的顺手。
第三个教训是关于"性能优化"的。第一版监控页的数据推送频率是 100 毫秒一次,看起来"实时感"很强,但结果发现一分钟产生的数据量能让浏览器卡到没法操作。后来我把推送频率降到 1 秒一次,反而觉得更合适——1 秒间隔已经足够让人感知到"实时",但渲染和内存压力都小了很多。这个教训告诉我:实时更新不是越快越好,要和数据量、渲染成本、用户感知之间找一个平衡点。
6.2 后续可以怎么扩展
BrewUI 目前还只是一个轻量级管理面板,如果要继续往下做,我有几个想法:
一是增加 Docker Compose 项目管理。现在越来越多的服务用 Compose 文件定义,如果能在一个面板里看到所有 Compose 项目,支持对某个项目整体启动、停止、查看状态,会方便很多。这个功能的难点在于 Compose 项目和多容器之间的映射关系解析,以及 Compose 文件的上传和编辑。
二是支持 Kubernetes 集群的管理。虽然 Kubernetes 和 Docker 的 API 完全不同,但 BrewUI 的核心价值——"让复杂系统管理变得可视化"——是相通的。不过这是一条完全不同的技术路线,需要投入的精力也大得多,所以我个人目前的判断是先保持 Docker 单机管理这个专注点。
三是增加更细粒度的权限管理。比如让团队里的某些成员只能查看容器状态、不能执行删除操作。这需要引入用户体系和基于角色的访问控制,是向"团队可用"方向迈进的必要一步。
四是增加告警通知。当某个容器的 CPU 占用率超过阈值、或者容器意外停止时,通过邮件或 Webhook 推送给管理员。这个功能对自托管服务器来说尤其实用,毕竟没有人会 24 小时盯着面板看。
当然,上面这些都只是想法。做项目这件事,我一直信奉一个原则:让用户只做他们真正需要做的事,而不是把所有可能的功能都堆在一个工具里。
6.3 从 BrewUI 到其他自托管项目的一些思路
做完 BrewUI 之后,我对"自托管服务"这个领域有了更深的理解。很多人看到"自托管"三个字,第一反应是"要懂很多技术,很麻烦",但实际上现在像 Docker Compose、Portainer、BrewUI 这类工具已经把门槛拉低了很多。
我的建议是,如果你也想自建一些服务(比如个人博客、网盘、监控系统、笔记应用),可以先从 Docker Compose 起步,把常用的几个服务编排起来,然后用一个面板类工具统一管理。这样你不需要记住每个服务的启动命令、端口号、日志位置,只需要记住"打开面板 -> 找到服务 -> 点击查看状态"这一条路径。
BrewUI 本身也是一个很好的"练手项目"模板。前后端分离架构、WebSocket 实时通信、容器技术、安全性考量,几乎每一个模块都是一块值得深入学习的知识。如果你正在自己动手做一个工具类项目,可以参考它的分层方式和功能取舍逻辑。
最后再分享一个小技巧:开发完这类带 WebSocket 实时通信的项目后,一定要在不同网络环境下做测试,特别是穿透公网、加反向代理、走移动热点这些场景。WebSocket 在复杂网络环境下的表现,和你本地跑的效果差别非常大,很多问题不是代码逻辑有问题,而是网络链路中间掉链子了。提前做好心跳、重连、断线恢复这些机制,能让你的工具在真实使用中口碑完全不同。