容器越跑越多谁在悄悄吃 CPU 和内存用 cgroup 指标起一个只读资源看板cpolar 单入口给异地同事看负载先说场景。我本地用 Colima 跑了一堆容器时间一长就变成一团有的是随手docker run起来忘了关的有的是做实验留着的还有几个是常驻服务。等到风扇狂转、笔记本发热我才打开docker stats一屏刷下来眼睛都花了而且这个命令一关终端数字就没了别人想看也看不到。更麻烦的是异地同事。他不在我旁边也没法连到我本机每次问你那几个容器现在谁在吃 CPU我只能截图发过去过一会儿数据又变了。所以我想要的东西其实很朴素把容器资源指标读出来渲染成一个只读页面再用 cpolar 开一个单入口让异地同事自己打开看。不给他任何操作权限他只能看不能停容器、不能 exec、不能删东西。这篇就是把这个小实验的完整过程写下来。先说明一句本文是隔离环境中的可执行实验提纲这一轮没有实际读取真实宿主机的 cgroup、没有启动看板、没有起隧道所有命令和骨架都给全但示例数值都标注为「示例/模拟值」不虚构实测结果。1 这篇里每个东西分别负责什么别被标题里的名词吓到拆开看就三件事。cgroup负责提供数据源。Linux 把每个容器的 CPU、内存、进程数、IO 都记在 cgroup 的层级目录里Docker 容器在 cgroup v2 下对应/sys/fs/cgroup/下的一串路径读出cpu.stat、memory.current、memory.max、pids.current这些文件就拿到了谁在吃资源的原始依据。只读看板负责把数据变成人看得懂的东西。一个最小采集脚本按周期读指标聚合成容器列表 资源排行渲染成网页绑定在本机127.0.0.1:8106。它只读页面上没有任何按钮能让容器动起来。cpolar负责把本机页面变成异地可达的入口。看板只绑在 127.0.0.1同事在外网是打不开的需要 cpolar 把这个端口映射成一个公网地址。这次走单一入口要么用 http 隧道要么上固定二级子域名让地址稳定下来。三者的分工很清楚cgroup 给数看板给看cpolar 给路。2 环境准备先确认 cgroup 版本和 Docker 状态别急着写脚本先确认两件事不然读到一半发现没有数据。2.1 确认 cgroup 是 v2在 Linux 上执行stat -fc %T /sys/fs/cgroup/看到cgroup2fs就是 cgroup v2。如果是tmpfs说明是 v1目录结构和文件名不一样这篇按 v2 走。macOS 上 Colima 本质是跑一个 Linux 虚拟机容器在 VM 里的 cgroup v2 上所以采集要在 VM 内部或通过宿主路径读这点后面会说清楚。2.2 确认 Docker 可用docker version docker info --format {{.CgroupVersion}}docker info里的CgroupVersion直接告诉你用的是 v1 还是 v2。这里如果报连不上 daemon先确认 Colima 起来了colima status没起来就colima start。这一步别跳过后面所有采集都建立在 Docker 正常运行之上。2.3 起几个示例容器为了让看板有东西可看先起几个不同负载的容器。骨架示例自己按需替换# 一个轻负载的常驻容器 docker run -d --name demo-web nginx:alpine # 一个吃 CPU 的忙碌容器示例跑 busybox 死循环 docker run -d --name demo-busy busybox sh -c while true; do :; done # 一个占内存的容器分配一块内存挂着 docker run -d --name demo-mem busybox sh -c dd if/dev/zero of/dev/null bs1M sleep 3600 # 一个基本空闲的容器 docker run -d --name demo-idle alpine sleep 3600docker ps应该能看到这四个。demo-busy会明显拉高 CPUdemo-web和demo-idle基本闲着——这样排行才有对比一眼看出谁在吃资源。3 找到容器的 cgroup 路径这是整个实验里最容易卡住的一步单独拎出来讲。每个容器有一个完整 IDcgroup v2 下它的目录通常长这样/sys/fs/cgroup/system.slice/docker-容器完整ID.scope先拿到 ID 和对应的路径# 列出容器 ID 和名字 docker ps --no-trunc --format {{.ID}} {{.Names}} # 用 docker inspect 反查 cgroup 路径更稳不用自己拼 docker inspect -f {{.Name}} {{.HostConfig.CgroupParent}} demo-busy如果没有CgroupParent就用 systemd 那条固定规则自己拼/sys/fs/cgroup/system.slice/docker-ID.scope。容器 ID 一定要用完整 ID短 ID 拼出来的路径不存在会直接 stat 失败。如果读不到先检查是不是用了短 ID、或者是不是 cgroup v1。4 采集脚本读 cgroup 指标并聚合下面是一个最小可运行脚本骨架用来演示怎么读、怎么算不是最终成品。它读的是 cgroup v2 的文件把每个容器的 CPU 使用率、内存占用、进程数算出来。#!/usr/bin/env python3 # 骨架示例读取 cgroup v2 指标输出容器资源排行 # 仅在 Linux / Colima VM 内可直接读取 /sys/fs/cgroup 时运行 import os import time import subprocess CGROUP_ROOT /sys/fs/cgroup SAMPLE_INTERVAL 3 # 采样间隔秒 def list_containers(): 返回 [(容器名, cgroup路径)] out subprocess.check_output( [docker, ps, --no-trunc, --format, {{.ID}}\t{{.Names}}], textTrue ) result [] for line in out.strip().splitlines(): if not line.strip(): continue cid, name line.split(\t) path os.path.join(CGROUP_ROOT, system.slice, fdocker-{cid}.scope) result.append((name, path)) return result def read_int(path): try: with open(path) as f: return int(f.read().strip()) except (FileNotFoundError, ValueError): return None def read_cpu_usage_usec(cgroup_path): 从 cpu.stat 读累计 CPU 时间微秒 stat_path os.path.join(cgroup_path, cpu.stat) try: with open(stat_path) as f: for line in f: k, _, v line.partition( ) if k usage_usec: return int(v) except FileNotFoundError: pass return None def collect_once(): rows [] for name, path in list_containers(): cpu_usec read_cpu_usage_usec(path) mem_cur read_int(os.path.join(path, memory.current)) mem_max read_int(os.path.join(path, memory.max)) pids read_int(os.path.join(path, pids.current)) rows.append({ name: name, cpu_usec: cpu_usec, mem_current: mem_cur, mem_max: mem_max, pids: pids, }) return rows def sample(): 采样两次计算 CPU 使用率% first collect_once() time.sleep(SAMPLE_INTERVAL) second collect_once() first_map {r[name]: r for r in first} result [] for r in second: prev first_map.get(r[name], {}) cpu_pct None if r[cpu_usec] and prev.get(cpu_usec): delta r[cpu_usec] - prev[cpu_usec] cpu_pct round(delta / (SAMPLE_INTERVAL * 1_000_000) * 100, 1) mem_mb round(r[mem_current] / 1024 / 1024, 1) if r[mem_current] else None result.append({ name: r[name], cpu_pct: cpu_pct, mem_mb: mem_mb, pids: r[pids], }) result.sort(keylambda x: (x[cpu_pct] or 0), reverseTrue) return result if __name__ __main__: for row in sample(): print(row)几个关键点读的时候留意cpu.stat里的usage_usec是累计值两次采样相减、再除以时间间隔才是这一段的 CPU 使用率。直接读一个数把它当当前 CPU 占用是错的。memory.max的取值是数字或字符串maxmax表示不限制内存所以read_int里做了 try 保护遇到非数字就返回 None别让脚本崩掉。单位换算别搞混memory.current是字节除以 1024 两次才是 MB。跑一下python3 collect.py你会看到一行行字典cpu_pct最高的那个就是吃 CPU 最凶的。示例输出长这样模拟值{name: demo-busy, cpu_pct: 98.4, mem_mb: 1.2, pids: 2} {name: demo-web, cpu_pct: 0.3, mem_mb: 8.6, pids: 5} {name: demo-mem, cpu_pct: 0.1, mem_mb: 32.4, pids: 3} {name: demo-idle, cpu_pct: 0.0, mem_mb: 1.1, pids: 2}这个数字是模拟的别当真——真实环境里demo-busy会稳定占到接近一个核demo-web其实会高一些具体看你的机器。数据这一步通了说明链路的前半段就没问题。哪一步读出来是 None就回到第 3 节确认路径对不对。5 渲染成只读看板并绑定本机端口采集逻辑有了接下来把它套上一个最小的 HTTP 服务。用 Python 标准库就够不用装框架。#!/usr/bin/env python3 # 骨架示例只读资源看板绑定 127.0.0.1:8106 # 只提供 GET 渲染指标无任何容器操作接口 from http.server import BaseHTTPRequestHandler, HTTPServer from collect import sample # 复用第 4 节的采集函数 HOST, PORT 127.0.0.1, 8106 def render_html(rows): body .join( ftrtd{r[name]}/tdtd{r[cpu_pct]}%/td ftd{r[mem_mb]} MB/tdtd{r[pids]}/td/tr for r in rows ) return f!doctype html html langzh headmeta charsetutf-8title容器资源只读看板/title/head body h1容器资源只读看板/h1 p数据每 3 秒刷新一次仅展示不可操作。/p table border1 cellpadding6 trth容器/ththCPU/thth内存/thth进程数/th/tr {body} /table /body/html class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path not in (/, /index.html): self.send_error(404, Not Found) return html render_html(sample()) payload html.encode(utf-8) self.send_response(200) self.send_header(Content-Type, text/html; charsetutf-8) self.send_header(Content-Length, str(len(payload))) self.end_headers() self.wfile.write(payload) def do_POST(self): # 只读服务任何写操作一律拒绝 self.send_error(405, Read Only) if __name__ __main__: server HTTPServer((HOST, PORT), Handler) print(f看板已启动http://{HOST}:{PORT}) server.serve_forever()启动python3 dashboard.py本机打开http://127.0.0.1:8106能看到一张表容器名、CPU、内存、进程数按 CPU 从高到低排。这一步看到什么很关键——如果表格是空的回到第 3 节查 cgroup 路径如果数字不刷新看sample()是不是没被调用到。只读边界在这里落死do_POST直接返回 405路由只认/任何其他路径 404。页面上没有任何表单、按钮、链接指向操作接口。这一点别偷懒只要留了一个写入口后面 cpolar 一暴露就等于把口子开到公网了。6 用 cpolar 开单一入口给异地同事本地跑通之后才轮到 cpolar。看板绑的是127.0.0.1外网访问不到需要映射出去。先装 cpolar。macOS 上推荐按官网下载页的安装包走安装后在浏览器登录后台Web UI 默认在http://127.0.0.1:9200登录后 token 会自动写入也可以命令行手动绑定cpolar authtoken 你的authtoken确认 cpolar 服务起来了9200能打开、隧道列表在线再开隧道。6.1 临时验证http 随机隧道cpolar http 8106前台会输出一个 Forwarding 公网地址形如https://xxxxxxxx.r3.cpolar.cn - http://localhost:8106把这个地址发给异地同事他就能打开看板。注意免费版的随机地址在会话结束时失效重启隧道地址就变24 小时内不保证稳定。如果你只是拉同事临时代看一下这样够了如果同事要隔三差五回来看每次都发新地址会很烦。6.2 稳定入口固定二级子域名想要地址固定下来用固定二级子域名。这里要写准口径固定二级子域名需要基础套餐及以上固定 TCP 地址和自定义域名需要专业套餐及以上在 cpolar 后台创建预留的二级子域名然后在隧道配置里指定用它地址就不再随会话变化。这样同事收藏一次之后直接打开就行——这就是本题说的单一入口。配置写成 YAML 的骨架示例路径以本机实际为准authtoken: 你的authtoken tunnels: dashboard: addr: 8106 proto: http region: cn_vip subdomain: your-reserved-subdomain改完重启 cpolar 服务生效。YAML 缩进敏感不能有 TAB这点坑过很多人。一个提醒cpolar 只负责把本地端口映射到公网它不替代应用自身的鉴权。我们这个看板本身是只读的风险已经被压到很低但如果你后面往里加了别的接口暴露之前一定要想清楚。7 让异地同事核对并验证只读边界隧道起来后让同事在外网打开地址核对两件事看板能不能打开表里的容器名和本机docker ps是否一致排行对不对——本机观察到demo-busy吃 CPU 最多页面上它应该排在前面。然后做一次边界验证确认只读不只是嘴上说说# 试一个不存在的路径应该 404 curl -i https://你的公网地址/admin # 试一个写请求应该 405 curl -i -X POST https://你的公网地址/预期返回 404 和 405。如果写请求居然成功了说明服务里有没关掉的写入口立刻停掉隧道回去查路由。再确认一点看板全程没有碰 Docker socket。它是通过读/sys/fs/cgroup文件拿数据的只要暴露的是那个 HTTP 页面Docker 的管理能力就没有对外开出去。这一条是本题的安全底线。验证完关掉隧道和示例容器清理干净# CtrlC 关掉前台隧道 docker rm -f demo-web demo-busy demo-mem demo-idle8 总结到这里一个能用的只读资源看板就成型了本机把容器 cgroup 指标读出来聚合渲染成一张带排行的网页绑在127.0.0.1:8106再用 cpolar 开一个单入口给异地同事看。同事能在任何地方打开浏览器看到谁的 CPU 高、谁的内存大但他停不了容器、exec 不进去、也删不了东西路径只有一条就是看。关键步骤回顾定数据源确认 cgroup v2用完整容器 ID 拿到/sys/fs/cgroup/system.slice/docker-ID.scope读cpu.stat/memory.current/memory.max/pids.current定采集口径CPU 用两次采样的累计值相减算内存注意字节换算别把累计值当瞬时值定边界看板只读路由只认/写请求一律 405不碰 Docker socket定入口临时看用免费 http 隧道随机地址 24h 会变长期稳定用固定二级子域名基础套餐及以上。回到最开始那个问题容器越跑越多谁在悄悄吃 CPU 和内存与其每次截图发给同事不如把这个只读页面挂在一个固定入口上谁来问直接把地址给他。数据是实时的同事不用等你你也不用一遍遍解释。要扩展的话后面可以加采样历史、加上限告警或者把入口收到自己的域名下——但第一步先把这条只读链路跑通。说明本文是隔离环境中的可执行实验提纲。这一轮未实际读取真实宿主机 cgroup、未启动看板、未起隧道文中命令与代码均为可运行骨架示例数值为模拟值不构成实测结论。正式执行时需在隔离环境按本文步骤实跑并记录采集口径、容器清单与样本指标。