磁盘巡检系统使用文档-EC2&PVC
一、系统概述
1.1 背景与目标
公司 AWS 基础设施包含 EC2 主机和多个 EKS 集群,长期运行后存在两类存储浪费:
- EC2 层面:大容量磁盘(>200G)但使用率长期偏低(<50%),资源闲置
- K8s 层面:PVC 使用率低、PV 残留、EBS 孤儿卷、未引用 PVC,卷删了但 PV 对象还在等
两套脚本通过 Nightingale(n9e)时序数据 + kubectl + AWS API 三方交叉比对,自动巡检并生成周报,每周固定时间推送飞书,帮助运维及时清理。
1.2 两套脚本对比
| 维度 | EC2 磁盘巡检 (n9e_disk_weekly.py) | PVC 巡检 (pvc_inspect.py) |
|---|---|---|
| 巡检对象 | EC2 主机挂载点 | EKS 集群 PVC/PV/EBS |
| 数据源 | n9e 单一数据源 | n9e + kubectl + AWS API |
| 命中条件 | 磁盘 >200G 且使用率 0-50% | 容量 >50G 且连续 14 天使用率 <50% |
| 额外检测 | 无 | 未引用 PVC、PV 残留、EBS 孤儿卷 |
| 运行位置 | 单机跑全量(按 cluster 过滤) | 每个集群各跑一份(按 context 隔离) |
| 飞书推送 | 周一 10:30 上海时间 | 周二 10:30 上海时间 |
| 报告保留 | 7 份滚动 | 7 份滚动 |
二、架构与数据流
2.1 EC2 磁盘巡检数据流
n9e PromQL 查询
├── Query A: 磁盘大小 GB(node_filesystem_size_bytes / 1024³ > 200)
└── Query B: 使用率 %(1 - avail/size,0-50%)
↓
两条查询按 (instance, device, mountpoint) 取交集
↓
按 IP 去端口聚合(同一台机多个挂载点归一起)
↓
权重排序(/data 大小 65% + 低使用率 35%)
↓
生成 md 报告 → 周一推送飞书卡片
2.2 PVC 巡检数据流
┌─ Part 1: n9e 查 PVC 使用率(kubelet_volume_stats_*)
│ max_over_time(used/cap*100 [14d:1d]) < 50 且 capacity > 50G
│ 按 (namespace, pvc) 去重,保留使用率最高的序列
│
├─ Part 2: kubectl 查全量 PVC(get pvc -A -o json)
│
├─ Part 3: kubectl 查全量 PV(get pv -o json)
│ 提取后端卷 ID(兼容 CSI volumeHandle + in-tree awsElasticBlockStore)
│ 批量 aws ec2 describe-volumes 检查后端 EBS 是否还存在
│
├─ Part 4: kubectl 查 Pod 挂载引用(get pod -A -o json)
│ PVC Bound 但无 Pod 引用 → 未引用 PVC
│
└─ Part 5: aws ec2 describe-volumes(按 cluster tag 过滤)
available 卷 → 残留
in-use 但集群 Bound PV/PVC 均未匹配 → 待确认卷
↓
聚合生成 md 报告 → 周二推送飞书卡片
2.3 集群隔离机制
PVC 脚本通过 kubeconfig 的 current-context 自动推断集群名和 region,无需改代码即可在多个集群复用:
| 参数 | 推断来源 | 示例 |
|---|---|---|
| cluster 名 | context 末尾 cluster/xxx |
LTP-EKS-Prod |
| region | context 里 eks:<region>: |
ap-northeast-1 |
| n9e cluster 标签 | N9E_CLUSTER_LABEL_MAP 映射 |
ltp-eks-prod |
| EBS 过滤 tag | kubernetes.io/cluster/<cluster> |
按 cluster 名匹配 |
关键点:n9e 的 cluster 标签值可能与 kubeconfig 集群名不一致(大小写或单词顺序不同),通过 N9E_CLUSTER_LABEL_MAP 映射表对齐。
三、前置准备
3.1 权限要求
| 能力 | 需要的权限 | 验证命令 |
|---|---|---|
| n9e 查询 | n9e Bearer token | curl 带 token 查 /api/v1/query |
| kubectl 查 PVC/PV/Pod | kubeconfig + RBAC 读权限 | kubectl get pvc -A |
| AWS 查 EBS | ec2:DescribeVolumes |
aws ec2 describe-volumes --max-results 6 |
3.2 运行环境
| 组件 | 要求 | 说明 |
|---|---|---|
| Python | ≥ 3.10 | 系统 python 若是 3.6 会崩(capture_output 不支持),用 conda python 3.11 |
| requests 库 | 已安装 | python3 -c "import requests" 验证 |
| kubectl | 已安装且 kubeconfig 配好 | kubectl config current-context 确认指向目标集群 |
| aws CLI | 已安装且凭据配好 | ~/.aws/credentials 或环境变量 |
3.3 n9e cluster 标签核对
部署前在 n9e UI(metric explorer)跑:
count by (cluster) (kubelet_volume_stats_capacity_bytes)
把返回的 cluster 标签值与 N9E_CLUSTER_LABEL_MAP 的 value 对齐。不一致会导致 Part 1 命中 0。
四、配置说明
4.1 EC2 脚本配置区
脚本顶部直接填:
N9E_TOKEN:n9e Bearer tokenLARK_WEBHOOK:飞书机器人 webhook
可调参数(环境变量覆盖):
CLUSTER_SKIP:排除的 cluster,默认ltp-eks-uatTOPN:推送前 N 条,0 = 全部SIZE_MIN_GB/USAGE_MIN/USAGE_MAX:筛选阈值
4.2 PVC 脚本配置区
脚本顶部直接填:
N9E_TOKEN:n9e Bearer tokenLARK_WEBHOOK:飞书机器人 webhookN9E_CLUSTER_LABEL_MAP:集群名 → n9e cluster 标签映射表
可调参数:
LOW_PCT:低使用率阈值,默认 50DAYS_WINDOW:时序窗口天数,默认 14SIZE_MIN_GB:容量下限,默认 50PUSH_WEEKDAY:推送日,0=周一,1=周二
4.3 飞书 webhook 获取
飞书群 → 设置 → 群机器人 → 添加自定义机器人 → 复制 webhook URL。注意该机器人不支持 lark_md 元素,脚本已用 plain_text + column_set 适配。
五、部署步骤
5.1 通用流程(每个集群/每台机器)
- 拷贝脚本到固定目录(如
/root/ec2-disk/或/root/pvc-inspect/) - 编辑脚本顶部,填入
N9E_TOKEN和LARK_WEBHOOK - 确认
kubectl config current-context指向目标集群 - 手动跑一次验证
- 配置 cron 定时执行
5.2 手动验证(模拟 cron 环境)
必须用 env -i 模拟 cron 最小环境,否则手动能跑、cron 跑不通:
# EC2 脚本(示例)
env -i HOME=/root PATH=/usr/local/bin:/usr/bin:/bin PYTHONIOENCODING=utf-8 \
/usr/bin/python3 /root/ec2-disk/n9e_disk_weekly.py
# PVC 脚本(示例,按机器实际路径调整)
env -i HOME=/root PATH=/root/anaconda3/bin:/usr/local/bin:/usr/bin:/bin \
PYTHONIOENCODING=utf-8 KUBE_BIN=/usr/local/bin/kubectl AWS_BIN=/usr/local/bin/aws \
/root/anaconda3/bin/python3 /root/ec2-disk/pvc_inspect.py
5.3 路径探测
部署前先确认本机命令路径(每台机器可能不同):
which python3 kubectl aws
python3 --version # 确认 ≥ 3.10
date # 确认时区
六、cron 配置(重点)
6.1 时区处理原则
cron 表达式的时间是机器本地时间。要让所有集群在同一上海时刻触发,必须按时区换算,或用 CRON_TZ。
目标:所有集群统一上海时间 10:30 触发。
6.2 两种写法
写法 A:按时区换算(推荐,零依赖)
| 机器时区 | cron 表达式 | 实际上海时间 |
|---|---|---|
| UTC | 30 2 * * * |
10:30 |
| CST (+0800) | 30 10 * * * |
10:30 |
写法 B:crontab 顶部声明 CRON_TZ(统一表达式)
在 crontab 文件第一行写 CRON_TZ=Asia/Shanghai(注意:必须单独一行写在顶部,不能写在 cron 行命令部分前面),之后所有 cron 行统一写 30 10 * * *。
6.3 cron 行模板
<分> <时> * * * HOME=/root PATH=<本机路径> PYTHONIOENCODING=utf-8 \
KUBE_BIN=<本机kubectl> AWS_BIN=<本机aws> <本机python> <脚本路径> >> <日志> 2>&1
关键环境变量(缺一不可):
HOME=/root:让 kubectl/aws 找到~/.kube/config和~/.aws/credentialsPATH:包含 kubectl/aws/python 所在目录PYTHONIOENCODING=utf-8:避免中文 print 崩(UnicodeEncodeError)KUBE_BIN/AWS_BIN:绝对路径,防止 PATH 找不到
6.4 时区验证
cron 不读 bashrc,AWS 凭据必须在文件里:
ls -la /root/.aws/credentials /root/.kube/config
七、报告解读
7.1 EC2 报告
权重排序表,按 /data 大小(65%)+ 低使用率(35%)降序。排名靠前的机器 = 大盘但用得少,优先协调更换。
7.2 PVC 报告(四部分)
| 部分 | 含义 | 处理建议 |
|---|---|---|
| ① 低使用率 PVC | 容量 >50G 且连续 14 天 <50% | 缩容或回收 |
| ② 未引用 PVC | Bound 但无 Pod 挂载 | 确认后删 PVC |
| ③ PV 残留 | Released/Available,含 EBS 状态标注 | 看 EBS 状态决定先删 EBS 还是直接删 PV |
| ④ EBS 残留 | available 卷 + in-use 待确认卷 | available 可直接删;待确认需人工核实 |
7.3 PV 残留的 EBS 状态标注
Part 3 新增 EBS状态 列,区分两种场景:
| 标注 | 含义 | 操作 |
|---|---|---|
EBS已删(可直接删PV) |
后端 EBS 已不存在,只剩 PV 对象 | kubectl delete pv <pv名> |
EBS仍在(需先删EBS) |
EBS 卷还在 | 先 aws ec2 delete-volume,再 kubectl delete pv |
八、飞书推送逻辑
8.1 推送时机
- EC2 脚本:周一(
weekday == 0) - PVC 脚本:周二(
weekday == 1)
脚本用 now_sh() 强制上海时区判 weekday,不依赖机器时区,即使机器是 UTC,推送判断仍按上海时间的周几。
8.2 每日生成报告,仅推送日发飞书
cron 每天跑,但只在推送日发飞书;其余日子只生成 md 报告存到 reports/,保留最近 7 份滚动删除。
8.3 飞书卡片格式
用 column_set 多列布局实现表格对齐(飞书机器人不支持 markdown 表格语法和 lark_md 元素)。每部分独立成块,表头加粗。
九、故障排查
9.1 常见问题速查
| 现象 | 原因 | 解决 |
|---|---|---|
命令不存在: kubectl |
cron PATH 不含 kubectl 目录 | cron 行加 PATH 和 KUBE_BIN 绝对路径 |
UnicodeEncodeError: ascii codec |
cron 缺 LANG | cron 行加 PYTHONIOENCODING=utf-8 |
capture_output unexpected keyword |
系统 python 是 3.6 | 换 conda python 3.11 或装 uv |
ModuleNotFoundError: requests |
系统 python 没装 requests | 给该 python 装:/usr/bin/python3 -m pip install requests |
| Part 1 命中 0 | n9e cluster 标签对不上 | 核对 N9E_CLUSTER_LABEL_MAP,UI 查实际标签值 |
集群=unknown |
python 3.6 崩在推断逻辑 | 升级 python |
| EBS 查询失败 | aws 路径不对或凭据缺失 | which aws 确认路径,检查 ~/.aws/credentials |
| 跨集群数据混杂 | n9e 查询没加 cluster 过滤 | 确认 N9E_CLUSTER_LABEL_MAP 填对 |
9.2 验证 cron 环境跑通
env -i HOME=/root PATH=<本机路径> PYTHONIOENCODING=utf-8 \
KUBE_BIN=<本机kubectl> AWS_BIN=<本机aws> <本机python> <脚本>
env -i 清空环境只保留显式传的,能精确复现 cron。跑通后 cron 就没问题。
9.3 报告目录
报告生成在脚本同级 reports/ 子目录:
- EC2:
reports/disk-weekly-YYYYMMDD_HHMMSS.md - PVC:
reports/pvc-weekly-YYYYMMDD_HHMMSS.md
保留最近 7 份,超期自动滚动删除。
十、清理操作指引
10.1 EC2 低使用率磁盘
报告里命中机器,协调时间更换磁盘或缩容。脚本只报告,不自动操作。
10.2 PVC 未引用
kubectl -n <namespace> delete pvc <pvc名>
10.3 PV 残留
先看报告 Part 3 的 EBS状态:
# EBS已删场景
kubectl delete pv <pv名>
# EBS仍在场景
aws ec2 delete-volume --volume-id <vol-id> --region <region>
kubectl delete pv <pv名>
10.4 EBS 孤儿卷
Part 4 的「待确认卷」必须人工核实后再删:
# 核实:EBS tag 显示的 PVC 在集群里是否真的不存在
kubectl get pvc -A | grep <原PVC名>
# 确认孤儿后删除
aws ec2 delete-volume --volume-id <vol-id> --region <region>
⚠️ 删除 EBS 不可逆,务必确认卷不被任何 Pod 依赖。
十一、多集群部署清单
11.1 集群信息表(示例,按实际填写)
| 集群名 | 机器 IP | 时区 | python 路径 | aws 路径 | kubectl 路径 | cron 表达式 |
|---|---|---|---|---|---|---|
| ltp-eks-nonprod | 10.18.75.168 | UTC | /usr/bin/python3 | /usr/bin/aws | /usr/local/bin/kubectl | 30 2 |
| LTP-EKS-Prod | 10.19.14.240 | CST | /root/anaconda3/bin/python3 | /usr/local/bin/aws | /usr/local/bin/kubectl | 30 10 |
| ltp-eks-uat | 10.19.6.130 | ? | ? | ? | ? | 按 date |
| aws-jp-prod-ltp-infra-eks | ? | ? | ? | ? | ? | 按 date |
11.2 每台机器部署 checklist
-
which python3 kubectl aws确认路径 -
python3 --version确认 ≥ 3.10 -
python3 -c "import requests"确认依赖 -
date确认时区 -
kubectl config current-context确认指向目标集群 -
kubectl get pvc -A确认 RBAC -
aws ec2 describe-volumes --max-results 6确认 EBS 权限 - 脚本顶部填
N9E_TOKEN和LARK_WEBHOOK -
N9E_CLUSTER_LABEL_MAP填对集群映射 -
env -i模拟 cron 跑通 - 配 cron 行
- 等 cron 触发后看
/var/log/pvc-inspect.log无报错
十二、安全注意事项
- 凭据保护:脚本顶部
N9E_TOKEN和LARK_WEBHOOK是明文,文件权限收紧chmod 700,不要进 git/共享存储 - 删除操作:脚本只报告不自动删除,所有删除需人工确认后手动执行
- 待确认卷:Part 4.2 的 in-use 待确认卷绝不自动删,必须人工核实
- reclaimPolicy=Retain:这类 PV 删 PVC 时不会自动删 EBS,是孤儿卷的主要来源,巡检重点关注
附录:脚本文件清单
| 脚本 | 用途 | 报告目录 |
|---|---|---|
n9e_disk_weekly.py |
EC2 磁盘巡检周报 | reports/disk-weekly-*.md |
pvc_inspect.py |
Kubernetes PVC 巡检周报 | reports/pvc-weekly-*.md |
# /// script # requires-python = ">=3.10" # dependencies = ["requests"] # /// """ N9E 磁盘巡检周报 ================ 扫描 /data、/data1、/data-app 挂载点,磁盘 > 200G 且使用率 0–50% 的盘。 - 按"机器"聚合:同一台机的多个挂载点归到该机下展示 - 排序按 /data 的大小(65%) + 使用率(35%) 权重降序(无 /data 的机器按其最大盘参与排序) - 权重排序全表输出到 reports/,Top N 推送飞书(得分不显示) 环境变量(均可不设,直接在下方配置区填): N9E_TOKEN n9e Bearer token N9E_URL 默认 https://n9e.icbc.com N9E_API_PATH 默认 /api/n9e/proxy/2/api/v1/query CLUSTER_SKIP 排除的 cluster,默认 ltp-eks-uat LARK_WEBHOOK 飞书机器人 webhook TOPN 推送前 N 条,默认 10 """ import os import datetime import requests # ===== 直接在这里配置(不用环境变量也可)===== N9E_TOKEN = "1111111" # 你的 n9e Bearer token LARK_WEBHOOK = "https://open.larksuite.com/open-apis/bot/v2/hook/111111" # 飞书自定义机器人 webhook URL,不推送留空即可 # ================================================== N9E_URL = os.environ.get("N9E_URL", "https://n9e.icbc.com").rstrip("/") API_PATH = os.environ.get("N9E_API_PATH", "/api/n9e/proxy/2/api/v1/query") TOKEN = os.environ.get("N9E_TOKEN", N9E_TOKEN) CLUSTER_SKIP = os.environ.get("CLUSTER_SKIP", "ltp-eks-uat") LARK_WEBHOOK = os.environ.get("LARK_WEBHOOK", LARK_WEBHOOK) TOPN = int(os.environ.get("TOPN", "0")) # 0 = 推送全部,>0 = 推送前 N SIZE_MIN_GB = 200.0 USAGE_MIN = 0.0 USAGE_MAX = 50.0 W_SIZE = 0.65 W_USAGE = 0.35 # 监测的挂载点 MOUNTPOINTS = ["/data", "/data1", "/data-app"] MP_REGEX = "|".join(MOUNTPOINTS) # /data|/data1|/data-app # 两条 PromQL:size GB(带 >200 过滤)、usage % Q_SIZE_GB = ( f'node_filesystem_size_bytes{{mountpoint=~"{MP_REGEX}",fstype!~"^(fuse.*|tmpfs|cifs|nfs)",cluster!="{CLUSTER_SKIP}"}} / 1024 / 1024 / 1024 > {SIZE_MIN_GB} ' f'and on (instance, device, mountpoint) ' f'node_filesystem_readonly{{mountpoint=~"{MP_REGEX}",cluster!="{CLUSTER_SKIP}"}} == 0' ) Q_USAGE_PCT = ( f'(1 - (node_filesystem_avail_bytes{{mountpoint=~"{MP_REGEX}",fstype!~"^(fuse.*|tmpfs|cifs|nfs)",cluster!="{CLUSTER_SKIP}"}} ' f'/ node_filesystem_size_bytes{{mountpoint=~"{MP_REGEX}",fstype!~"^(fuse.*|tmpfs|cifs|nfs)",cluster!="{CLUSTER_SKIP}"}})) * 100 >= {USAGE_MIN} ' f'and on (instance, device, mountpoint) ' f'(1 - (node_filesystem_avail_bytes{{mountpoint=~"{MP_REGEX}",fstype!~"^(fuse.*|tmpfs|cifs|nfs)",cluster!="{CLUSTER_SKIP}"}} ' f'/ node_filesystem_size_bytes{{mountpoint=~"{MP_REGEX}",fstype!~"^(fuse.*|tmpfs|cifs|nfs)",cluster!="{CLUSTER_SKIP}"}})) * 100 <= {USAGE_MAX} ' f'and on (instance, device, mountpoint) ' f'node_filesystem_readonly{{mountpoint=~"{MP_REGEX}",cluster!="{CLUSTER_SKIP}"}} == 0' ) ENV_ORDER = ["prod", "qa", "uat", "dev", "fat", "sit", "mirror"] def query_n9e(promql): if not TOKEN: raise SystemExit("N9E_TOKEN 未设置,请 export N9E_TOKEN=xxx 或在脚本顶部填入") params = {"query": promql, "time": str(int(datetime.datetime.now().timestamp()))} resp = requests.get( f"{N9E_URL}{API_PATH}", headers={"Authorization": f"Bearer {TOKEN}"}, params=params, timeout=60, ) ctype = resp.headers.get("content-type", "") if "html" in ctype.lower(): raise SystemExit( f"返回 HTML 不是 JSON,鉴权或路径有问题。\n" f"URL: {N9E_URL}{API_PATH}\n" f"检查:1) TOKEN 是否有效 2) DSID 是否正确(默认 /proxy/2/)" ) resp.raise_for_status() body = resp.json() if body.get("status") != "success": raise SystemExit(f"查询失败: {body}") return body["data"]["result"] def parse_vec(result): """key = (instance, device, mountpoint) -> (metric, value)""" out = {} for item in result: m = item["metric"] key = (m.get("instance", ""), m.get("device", ""), m.get("mountpoint", "")) try: val = float(item["value"][1]) except (KeyError, ValueError, IndexError): val = 0.0 out[key] = (m, val) return out def env_rank(e): return ENV_ORDER.index(e) if e in ENV_ORDER else len(ENV_ORDER) def build_rows(size_map, usage_map): """取两条查询交集,返回 [{ip, env, region, name, mounts: {mp: {device,size,usage}}}]。 按 IP(去端口) 聚合成机器,带 Name 标签的优先用于机器元信息。""" machines = {} # ip -> machine dict for key, (m, size_gb) in size_map.items(): if key not in usage_map: continue usage = usage_map[key][1] ip_raw = m.get("PrivateIpAddress", m.get("instance", "-")) ip = ip_raw.split(":")[0] device = m.get("device", "-") mp = m.get("mountpoint", "-") has_name = bool(m.get("Name")) if ip not in machines: machines[ip] = { "ip": ip, "env": m.get("env", "-"), "region": m.get("Region", m.get("region", "-")), "name": m.get("Name", m.get("instance", "-")), "_has_name": has_name, "mounts": {}, } else: # 带 Name 的优先更新元信息 if has_name and not machines[ip]["_has_name"]: machines[ip]["env"] = m.get("env", "-") machines[ip]["region"] = m.get("Region", m.get("region", "-")) machines[ip]["name"] = m.get("Name", m.get("instance", "-")) machines[ip]["_has_name"] = True machines[ip]["mounts"][mp] = { "device": device, "size_gb": size_gb, "usage": usage, } for mac in machines.values(): mac.pop("_has_name", None) return list(machines.values()) def machine_score(mac, max_size): """按 /data 排序;无 /data 则用最大的盘参与排序。 size_norm 越大分越高(65%),usage 越低分越高(35%)。""" mounts = mac["mounts"] target = mounts.get("/data") if target is None: # 无 /data,取最大的盘 target = max(mounts.values(), key=lambda x: x["size_gb"]) size_norm = (target["size_gb"] / max_size) if max_size else 0 usage_norm = target["usage"] / 100.0 return W_SIZE * size_norm + W_USAGE * (1 - usage_norm) def fmt_pct(v): return f"{round(v)}%" def fmt_gb(v): return f"{round(v)}G" def fmt_mounts(mounts): """把一台机的多个挂载点拼成单元格内容,每行一个 mp""" parts = [] # 按 /data, /data1, /data-app 顺序,其余按名字 order = [mp for mp in MOUNTPOINTS if mp in mounts] other = sorted(m for m in mounts if m not in MOUNTPOINTS) for mp in order + other: d = mounts[mp] parts.append(f"{mp}: {fmt_gb(d['size_gb'])} / {fmt_pct(d['usage'])}") return "<br>".join(parts) def build_md(rows, scored, max_size): lines = [] lines.append("# N9E 磁盘巡检周报") lines.append("") lines.append(f"生成时间:{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") lines.append(f"筛选:挂载点 {', '.join(MOUNTPOINTS)},磁盘 >{int(SIZE_MIN_GB)}G,使用率 {int(USAGE_MIN)}–{int(USAGE_MAX)}%") lines.append(f"命中机器数:{len(rows)}") lines.append("") lines.append("## 权重排序(按 /data 大小65% + 低使用率35%)") lines.append("") lines.append("| 排名 | 实例名称 | 实例IP | env | 挂载点(大小/使用率) |") lines.append("|---|---|---|---|---|") for i, (mac, s) in enumerate(scored, 1): lines.append( f"| {i} | {mac['name']} | {mac['ip']} | {mac['env']} | {fmt_mounts(mac['mounts'])} |" ) return "\n".join(lines) def push_lark(scored): if not LARK_WEBHOOK: print("LARK_WEBHOOK 未设置,跳过飞书推送") return now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") n = min(TOPN, len(scored)) if TOPN > 0 else len(scored) items = scored[:n] if TOPN > 0 else scored # 用 column_set 多列布局实现真表格对齐(飞书卡片不支持 md table 语法) # 列定义:(key, 宽度权重, 对齐 left/center) cols = [ ("排名", 1, "center"), ("实例名称", 5, "left"), ("实例IP", 3, "left"), ("env", 1, "center"), ("挂载点(大小/使用率)", 4, "left"), ] col_widths_total = sum(c[1] for c in cols) def make_div(text, align, bold=False): # 只用 plain_text(机器人不支持 lark_md),加粗用 text_style d = {"tag": "div", "text": {"tag": "plain_text", "content": text}, "text_align": align} if bold: d["text_style"] = {"bold": True} return d def make_row(row_vals, bold=False): elements = [] for val, (name, w, align) in zip(row_vals, cols): elements.append({ "tag": "column", "width": "weighted", "weight": w, "elements": [make_div(str(val), align, bold=bold)], }) return {"tag": "column_set", "columns": elements} elements = [] # 顶部摘要(纯文本,无加粗,避免 lark_md) elements.append(make_div(f"生成时间:{now}", "left")) elements.append(make_div(f"筛选:挂载点 {', '.join(MOUNTPOINTS)},磁盘 >{int(SIZE_MIN_GB)}G,使用率 <{int(USAGE_MAX)}%", "left")) elements.append(make_div(f"命中机器数:{n}", "left")) elements.append({"tag": "hr"}) # 表头(加粗) elements.append(make_row([c[0] for c in cols], bold=True)) # 数据行 for i, (mac, s) in enumerate(items, 1): mp_text = " ".join( f"{mp}: {fmt_gb(d['size_gb'])}/{fmt_pct(d['usage'])}" for mp, d in mac["mounts"].items() ) elements.append(make_row([i, mac["name"], mac["ip"], mac["env"], mp_text], bold=False)) elements.append({"tag": "hr"}) # 底部备注 elements.append(make_div("以上机器使用率较低,建议协调时间进行磁盘更换。", "left", bold=True)) payload = { "msg_type": "interactive", "card": { "header": {"title": {"tag": "plain_text", "content": "磁盘巡检周报"}}, "elements": elements, }, } try: resp = requests.post(LARK_WEBHOOK, json=payload, timeout=15) resp.raise_for_status() body = resp.json() if body.get("StatusCode", body.get("code", 0)) not in (0, None): print(f"飞书返回非0:{body}") else: print(f"已推送 {n} 台机器到飞书") except Exception as e: print(f"飞书推送失败:{e}") def main(): print("[1/2] 查询磁盘大小 GB ...") size_map = parse_vec(query_n9e(Q_SIZE_GB)) print(f" 命中 {len(size_map)} 个 >{int(SIZE_MIN_GB)}G 的盘") print("[2/2] 查询使用率 % ...") usage_map = parse_vec(query_n9e(Q_USAGE_PCT)) print(f" 命中 {len(usage_map)} 个使用率 {int(USAGE_MIN)}-{int(USAGE_MAX)}% 的盘") rows = build_rows(size_map, usage_map) if not rows: print("无符合条件的盘") return # max_size 取所有盘里最大的,用于归一化 max_size = max(d["size_gb"] for mac in rows for d in mac["mounts"].values()) scored = sorted( [(mac, machine_score(mac, max_size)) for mac in rows], key=lambda x: -x[1], ) md = build_md(rows, scored, max_size) script_dir = os.path.dirname(os.path.abspath(__file__)) reports_dir = os.path.join(script_dir, "reports") os.makedirs(reports_dir, exist_ok=True) stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(reports_dir, f"disk-weekly-{stamp}.md") with open(out_path, "w", encoding="utf-8") as f: f.write(md) print(f"\n报告已生成: {out_path}") print(f"命中 {len(rows)} 台机器") # 保留最近 7 份报告,滚动删除旧的 keep = 7 files = sorted( [f for f in os.listdir(reports_dir) if f.startswith("disk-weekly-") and f.endswith(".md")] ) for old in files[:-keep]: try: os.remove(os.path.join(reports_dir, old)) print(f"已清理旧报告: {old}") except OSError: pass # 只在周一推送飞书(cron 每天跑,但仅周一通知) weekday = datetime.datetime.now().weekday() # 0=周一 if weekday == 0: push_lark(scored) else: print(f"今日(周{weekday+1})不推送飞书,仅生成日报") if __name__ == "__main__": main()
# /// script # requires-python = ">=3.10" # dependencies = ["requests"] # /// """ Kubernetes PVC 巡检周报 ====================== 巡检四类问题(聚焦当前 kubeconfig context 对应的集群): 1. 使用率低:PVC 容量 > 50G 且连续 14 天使用率 < 50%(n9e PromQL 时序 + cluster 过滤) 2. 未引用 PVC:PVC 状态 Bound 但没有任何 Pod 在挂载 3. PV 残留:PV 处于 Released / Available,并标注后端 EBS 是否还存在 4. EBS 残留:available 卷 + in-use 但集群 Bound PV/PVC 均未匹配的待确认卷 所有时间统一用上海时区(UTC+8):weekday 判断、报告时间戳、n9e 查询时间戳。 cron 配合:机器 CST 直接 33 10;机器 UTC 用 33 2。 环境变量(也可不设,直接在下方配置区填): N9E_TOKEN n9e Bearer token N9E_URL 默认 https://n9e.icbc.com N9E_API_PATH 默认 /api/n9e/proxy/2/api/v1/query KUBE_CONTEXT kubectl context 名(留空用 current-context) KUBE_BIN kubectl 路径,默认 kubectl AWS_BIN aws 路径,默认 aws AWS_REGION EBS 查询 region,默认从 context 推断 CLUSTER_NAME 集群名(展示用,留空从 context 推断) LARK_WEBHOOK 飞书机器人 webhook LOW_PCT 连续低使用率阈值,默认 50 DAYS_WINDOW 时序窗口天数,默认 14 SIZE_MIN_GB 容量下限 GB,默认 50 """ import os import re import json import shlex import subprocess import datetime from datetime import timezone, timedelta import requests # ===== 直接在这里配置(不用环境变量也可)===== N9E_TOKEN = "111111111" # 你的 n9e Bearer token LARK_WEBHOOK = "https://open.larksuite.com/open-apis/bot/v2/hook/111111" # 飞书自定义机器人 webhook URL,不推送留空即可 # n9e 里 kubelet_volume_stats 的 cluster 标签值映射表 # key = kubeconfig 推断出的集群名 # value = n9e metric 里 cluster 标签的实际值 N9E_CLUSTER_LABEL_MAP = { "ltp-eks-nonprod": "ltp-nonprod-eks", "ltp-eks-uat": "ltp-eks-uat", "LTP-EKS-Prod": "ltp-eks-prod", "aws-jp-prod-ltp-infra-eks": "aws-jp-prod-ltp-infra-eks", } # ================================================== SHANGHAI_TZ = timezone(timedelta(hours=8)) N9E_URL = os.environ.get("N9E_URL", "https://n9e.icbc.com").rstrip("/") API_PATH = os.environ.get("N9E_API_PATH", "/api/n9e/proxy/2/api/v1/query") TOKEN = os.environ.get("N9E_TOKEN", N9E_TOKEN) N9E_CLUSTER_LABEL = os.environ.get("N9E_CLUSTER_LABEL", "") KUBE_CONTEXT = os.environ.get("KUBE_CONTEXT", "") KUBE_BIN = os.environ.get("KUBE_BIN", "kubectl") AWS_BIN = os.environ.get("AWS_BIN", "aws") AWS_REGION = os.environ.get("AWS_REGION", "") CLUSTER_NAME = os.environ.get("CLUSTER_NAME", "") LARK_WEBHOOK = os.environ.get("LARK_WEBHOOK", LARK_WEBHOOK) LOW_PCT = float(os.environ.get("LOW_PCT", "50")) DAYS_WINDOW = int(os.environ.get("DAYS_WINDOW", "14")) STEP_DAYS = 1 SIZE_MIN_GB = float(os.environ.get("SIZE_MIN_GB", "50")) SIZE_MIN_BYTES = int(SIZE_MIN_GB * 1024 ** 3) REPORT_KEEP = 7 PUSH_WEEKDAY = 1 # 0=周一, 1=周二 def now_sh(): """统一取上海时间""" return datetime.datetime.now(SHANGHAI_TZ) # ---------------- 工具:跑 shell 命令 ---------------- def run_cmd(args, check=True): try: p = subprocess.run(args, capture_output=True, text=True, timeout=120) except FileNotFoundError: raise SystemExit(f"命令不存在: {args[0]}(检查 KUBE_BIN / AWS_BIN)") if p.returncode != 0: if not check: return p.stdout raise SystemExit(f"命令失败: {' '.join(shlex.quote(a) for a in args)}\nstderr: {p.stderr.strip()}") return p.stdout def kube_args(extra): args = [KUBE_BIN] if KUBE_CONTEXT: args += ["--context", KUBE_CONTEXT] args += extra return args def infer_cluster_name(): if CLUSTER_NAME: return CLUSTER_NAME ctx = KUBE_CONTEXT if not ctx: try: ctx = run_cmd([KUBE_BIN, "config", "current-context"], check=False).strip() except Exception: ctx = "" m = re.search(r"cluster/([^/]+)$", ctx) if m: return m.group(1) return ctx or "unknown" def infer_region(): if AWS_REGION: return AWS_REGION ctx = KUBE_CONTEXT if not ctx: try: ctx = run_cmd([KUBE_BIN, "config", "current-context"], check=False).strip() except Exception: ctx = "" m = re.search(r"arn:aws:eks:([^:]+):", ctx) if m: return m.group(1) return "ap-northeast-1" # ---------------- 表格自动对齐 ---------------- def _disp_width(s): w = 0 for ch in str(s): w += 2 if ord(ch) > 127 else 1 return w def _pad(s, w): s = str(s) return s + " " * (w - _disp_width(s)) def align_table(headers, rows): cols = len(headers) widths = [_disp_width(headers[c]) for c in range(cols)] for row in rows: for c in range(cols): widths[c] = max(widths[c], _disp_width(row[c] if c < len(row) else "")) head = "| " + " | ".join(_pad(headers[c], widths[c]) for c in range(cols)) + " |" sep = "|" + "|".join("-" * (widths[c] + 2) for c in range(cols)) + "|" body = [] for row in rows: body.append("| " + " | ".join(_pad(row[c] if c < len(row) else "", widths[c]) for c in range(cols)) + " |") return "\n".join([head, sep] + body) # ---------------- Part 1: n9e 查 PVC 使用率 ---------------- def build_low_usage_query(cluster_label): cl = cluster_label return ( f'max_over_time(' f' (kubelet_volume_stats_used_bytes{{cluster="{cl}"}} / kubelet_volume_stats_capacity_bytes{{cluster="{cl}"}} * 100)' f' [{DAYS_WINDOW}d:{STEP_DAYS}d]' f') < {LOW_PCT} ' f'and on (namespace, persistentvolumeclaim, persistentvolume) ' f'kubelet_volume_stats_capacity_bytes{{cluster="{cl}"}} > {SIZE_MIN_BYTES}' ) def query_n9e(promql): if not TOKEN: raise SystemExit("N9E_TOKEN 未设置,请 export N9E_TOKEN=xxx 或在脚本顶部填入") params = {"query": promql, "time": str(int(now_sh().timestamp()))} resp = requests.get( f"{N9E_URL}{API_PATH}", headers={"Authorization": f"Bearer {TOKEN}"}, params=params, timeout=60, ) ctype = resp.headers.get("content-type", "") if "html" in ctype.lower(): raise SystemExit( f"返回 HTML 不是 JSON,鉴权或路径有问题。\n" f"URL: {N9E_URL}{API_PATH}\n" f"检查:1) TOKEN 是否有效 2) DSID 是否正确(默认 /proxy/2/)" ) resp.raise_for_status() body = resp.json() if body.get("status") != "success": raise SystemExit(f"查询失败: {body}") return body["data"]["result"] def collect_low_usage_pvc(cluster_label): print(f"[1/4] n9e 查 PVC 容量>{int(SIZE_MIN_GB)}G 且连续 {DAYS_WINDOW} 天使用率 < {LOW_PCT}% (cluster={cluster_label}) ...") try: result = query_n9e(build_low_usage_query(cluster_label)) except SystemExit as e: print(f" n9e 查询失败:{e}") return [] dedup = {} for item in result: m = item["metric"] try: val = float(item["value"][1]) except (KeyError, ValueError, IndexError): continue ns = m.get("namespace", "") pvc = m.get("persistentvolumeclaim", m.get("pvc", "")) if not pvc: continue vol = m.get("persistentvolume", m.get("pv", "")) key = (ns, pvc) if key in dedup: if val > dedup[key]["usage_pct"]: dedup[key]["usage_pct"] = val dedup[key]["volume"] = vol or dedup[key]["volume"] else: dedup[key] = { "namespace": ns, "pvc": pvc, "volume": vol, "usage_pct": val, "capacity_gb": 0.0, } out = list(dedup.values()) print(f" 命中 {len(out)} 个低使用率 PVC(去重前 {len(result)} 条序列)") return out # ---------------- Part 2-3: kubectl 查 PVC / PV / Pod ---------------- def kubectl_json(args): out = run_cmd(kube_args(args + ["-o", "json"])) try: return json.loads(out) except json.JSONDecodeError as e: raise SystemExit(f"kubectl 输出解析失败: {e}\n原始输出前 500 字: {out[:500]}") def collect_pvc_list(): print("[2/4] kubectl 查全量 PVC ...") data = kubectl_json(["get", "pvc", "-A"]) pvcs = [] for it in data.get("items", []): meta = it.get("metadata", {}) spec = it.get("spec", {}) status = it.get("status", {}) pvcs.append({ "namespace": meta.get("namespace", ""), "name": meta.get("name", ""), "status": status.get("phase", ""), "volume": spec.get("volumeName", ""), "capacity": status.get("capacity", {}).get("storage", ""), "storageclass": spec.get("storageClassName", ""), "access_modes": ",".join(spec.get("accessModes", [])), }) print(f" PVC 总数 {len(pvcs)}") return pvcs def _pv_volume_handle(spec): """提取 PV 后端卷 ID,兼容 CSI 和 in-tree EBS""" csi = spec.get("csi", {}) or {} if csi and csi.get("volumeHandle"): return csi["volumeHandle"] ebs = spec.get("awsElasticBlockStore", {}) or {} if ebs and ebs.get("volumeID"): return ebs["volumeID"].split("/")[-1] return "" def _pv_source(spec): csi = spec.get("csi", {}) or {} if csi: return f"csi:{csi.get('driver','')}:{csi.get('volumeHandle','')}" nfs = spec.get("nfs", {}) or {} if nfs: return f"nfs:{nfs.get('server','')}:{nfs.get('path','')}" ebs = spec.get("awsElasticBlockStore", {}) or {} if ebs: return f"ebs:{ebs.get('volumeID','')}" return "" def collect_pv_list(): print("[3/4] kubectl 查全量 PV ...") data = kubectl_json(["get", "pv"]) pvs = [] for it in data.get("items", []): meta = it.get("metadata", {}) spec = it.get("spec", {}) status = it.get("status", {}) claim_ref = spec.get("claimRef", {}) or {} pvs.append({ "name": meta.get("name", ""), "status": status.get("phase", ""), "reclaim_policy": spec.get("persistentVolumeReclaimPolicy", ""), "capacity": spec.get("capacity", {}).get("storage", ""), "storageclass": spec.get("storageClassName", ""), "access_modes": ",".join(spec.get("accessModes", [])), "claim_ns": claim_ref.get("namespace", ""), "claim_name": claim_ref.get("name", ""), "source": _pv_source(spec), "volume_handle": _pv_volume_handle(spec), }) print(f" PV 总数 {len(pvs)}") return pvs def collect_pod_pvcs(): print("[4/4] kubectl 查 Pod 挂载引用 ...") try: data = kubectl_json(["get", "pod", "-A"]) except SystemExit: return {} used = {} for it in data.get("items", []): ns = it.get("metadata", {}).get("namespace", "") for vol in it.get("spec", {}).get("volumes", []) or []: pvc_ref = vol.get("persistentVolumeClaim", {}) if pvc_ref: used[(ns, pvc_ref.get("claimName", ""))] = True print(f" 被 Pod 引用的 PVC 数 {len(used)}") return used def bytes_to_gb(s): if not s: return 0.0 m = re.match(r"^([\d.]+)\s*([KMGTP]i?|Ei?)?$", str(s)) if not m: return 0.0 num = float(m.group(1)) unit = (m.group(2) or "").upper() factor = { "": 1 / (1024 ** 3), "K": 1000 ** -3, "KI": 1024 ** -3, "M": 1000 ** -2, "MI": 1024 ** -2, "G": 1, "GI": 1, "T": 1000, "TI": 1024, "P": 1000 ** 2, "PI": 1024 ** 2, } return round(num * factor.get(unit, 1), 2) def build_pv_volume_handle_map(pvs): return {p["name"]: p["volume_handle"] for p in pvs if p["name"] and p["volume_handle"]} def detect_unused_pvc(pvcs, pod_used, pv_handle_map): out = [] for p in pvcs: if p["status"] == "Bound" and (p["namespace"], p["name"]) not in pod_used: p = dict(p) p["volume_id"] = pv_handle_map.get(p["volume"], "") out.append(p) return out # ---------------- EBS 存在检查(新增) ---------------- def check_ebs_exists_batch(vol_ids, region): """批量查 EBS 卷是否存在。返回 {vol_id: True(存在)/False(已删)}""" vol_ids = [v for v in vol_ids if v] if not vol_ids: return {} try: out = run_cmd([ AWS_BIN, "ec2", "describe-volumes", "--region", region, "--volume-ids", *vol_ids, "--query", "Volumes[*].VolumeId", "--output", "json", ], check=True) existing = set(json.loads(out) if out.strip() else []) except SystemExit: existing = set() return {v: (v in existing) for v in vol_ids} def _ebs_status_text(ebs_exists): if ebs_exists is None: return "-" return "EBS仍在(需先删EBS)" if ebs_exists else "EBS已删(可直接删PV)" def detect_pv_residual(pvs, ebs_exists_map): out = [] for p in pvs: if p["status"] in ("Released", "Available"): p = dict(p) p["ebs_exists"] = ebs_exists_map.get(p["volume_handle"]) out.append(p) return out # ---------------- Part 5: AWS 查 EBS 残留 ---------------- def collect_ebs_volumes(region, cluster): print(f"[5/5] aws ec2 describe-volumes (region={region}, cluster~={cluster}) ...") args = [ AWS_BIN, "ec2", "describe-volumes", "--region", region, "--query", "Volumes[*].{ID:VolumeId,Size:Size,State:State,Name:Tags[?Key=='Name'].Value|[0],K8sPV:Tags[?Key=='kubernetes.io/created-for/pv/name'].Value|[0],K8sPVC:Tags[?Key=='kubernetes.io/created-for/pvc/name'].Value|[0],K8sPVCNS:Tags[?Key=='kubernetes.io/created-for/pvc/namespace'].Value|[0],ClusterTags:Tags[?starts_with(Key, 'kubernetes.io/cluster/')].Key}", "--output", "json", ] out = run_cmd(args) try: all_vols = json.loads(out) except json.JSONDecodeError as e: raise SystemExit(f"aws 输出解析失败: {e}\n原始: {out[:500]}") cluster_lower = (cluster or "").lower() matched = [] for v in all_vols: cluster_tags = v.pop("ClusterTags", []) or [] hit = False for k in cluster_tags: prefix = "kubernetes.io/cluster/" if k.lower().startswith(prefix): cname = k[len(prefix):] if cname.lower() == cluster_lower: hit = True break if hit: matched.append(v) print(f" EBS 总数 {len(all_vols)},匹配 cluster={cluster} 的 {len(matched)} 个") return matched def detect_ebs_residual(ebs_vols, pvs, pvcs): """ available 卷 → 残留(可清理) in-use 卷 → 仅当对应 PV 处于 Bound(或 PVC 仍 Bound)才算在用,否则进"待确认卷" """ bound_pv_handles = { p["volume_handle"] for p in pvs if p["volume_handle"] and p["status"] == "Bound" } bound_pvc_keys = {(p["namespace"], p["name"]) for p in pvcs if p["status"] == "Bound"} available = [v for v in ebs_vols if v.get("State") == "available"] need_review = [] for v in ebs_vols: if v.get("State") != "in-use": continue vid = v.get("ID", "") if vid and vid in bound_pv_handles: continue pvc_ns = v.get("K8sPVCNS", "") or "" pvc_name = v.get("K8sPVC", "") or "" if pvc_ns and pvc_name and (pvc_ns, pvc_name) in bound_pvc_keys: continue need_review.append({ "ID": vid, "Size": v.get("Size", "-"), "State": v.get("State", ""), "Name": v.get("Name", "") or "", "K8sPV": v.get("K8sPV", "") or "", "K8sPVC": v.get("K8sPVC", "") or "", "K8sPVCNS": v.get("K8sPVCNS", "") or "", }) return available, need_review # ---------------- 报告 ---------------- def fmt_pct(v): return f"{round(v)}%" if isinstance(v, (int, float)) else "-" def fmt_gb(v): return f"{v}G" if v else "-" def build_md(cluster, low_usage, unused_pvc, pv_residual, ebs_available, ebs_review): now = now_sh().strftime("%Y-%m-%d %H:%M:%S") L = [] L.append(f"# Kubernetes PVC 巡检周报 - {cluster}") L.append("") L.append(f"生成时间:{now} (Asia/Shanghai)") L.append(f"集群:{cluster}") L.append(f"低使用率判定:容量 > {int(SIZE_MIN_GB)}G 且连续 {DAYS_WINDOW} 天 max_over_time 使用率 < {LOW_PCT}%") L.append("") L.append("## 1. 低使用率 PVC(建议缩小容量或回收)") L.append("") if low_usage: rows = sorted(low_usage, key=lambda x: x["usage_pct"]) L.append(align_table( ["namespace", "PVC", "PV", "容量", "使用率"], [[r["namespace"], r["pvc"], r["volume"], fmt_gb(r["capacity_gb"]), fmt_pct(r["usage_pct"])] for r in rows], )) else: L.append("(无)") L.append("") L.append("## 2. 未引用 PVC(Bound 但无 Pod 挂载)") L.append("") if unused_pvc: rows = sorted(unused_pvc, key=lambda x: (x["namespace"], x["name"])) L.append(align_table( ["namespace", "PVC", "PV", "VolumeId", "容量", "storageclass"], [[p["namespace"], p["name"], p["volume"], p.get("volume_id", ""), p["capacity"], p["storageclass"]] for p in rows], )) else: L.append("(无)") L.append("") L.append("## 3. PV 残留(Released / Available,并标注后端 EBS 是否存在)") L.append("") if pv_residual: rows = sorted(pv_residual, key=lambda x: (x["status"], x["name"])) L.append(align_table( ["PV", "状态", "reclaim策略", "容量", "关联PVC", "后端", "EBS状态"], [[p["name"], p["status"], p["reclaim_policy"], p["capacity"], f"{p['claim_ns']}/{p['claim_name']}" if p["claim_name"] else "-", p["source"], _ebs_status_text(p.get("ebs_exists"))] for p in rows], )) else: L.append("(无)") L.append("") L.append("## 4. EBS 残留 / 待确认卷") L.append("") L.append("### 4.1 available 卷(已删除但 EBS 还在)") L.append("") if ebs_available: rows = sorted(ebs_available, key=lambda x: x.get("ID", "")) L.append(align_table( ["VolumeId", "大小", "名称"], [[v.get("ID", "-"), f"{v.get('Size', '-')}G", v.get("Name", "-") or "-"] for v in rows], )) else: L.append("(无)") L.append("") L.append("### 4.2 in-use 待确认卷(集群 PV/PVC 均未匹配,需人工核实,勿自动删除)") L.append("") if ebs_review: rows = sorted(ebs_review, key=lambda x: x.get("ID", "")) L.append(align_table( ["VolumeId", "大小", "namespace", "原PVC", "原PV"], [[v.get("ID", "-"), f"{v.get('Size', '-')}G", v.get("K8sPVCNS", "-") or "-", v.get("K8sPVC", "-") or "-", v.get("K8sPV", "-") or "-"] for v in rows], )) else: L.append("(无)") L.append("") L.append("---") L.append(f"汇总:低使用率 {len(low_usage)} / 未引用 {len(unused_pvc)} / PV残留 {len(pv_residual)} / EBS残留 {len(ebs_available)} / 待确认卷 {len(ebs_review)}") return "\n".join(L) def push_lark(cluster, low_usage, unused_pvc, pv_residual, ebs_available, ebs_review): if not LARK_WEBHOOK: print("LARK_WEBHOOK 未设置,跳过飞书推送") return now = now_sh().strftime("%Y-%m-%d %H:%M") def make_div(text, align="left", bold=False): d = {"tag": "div", "text": {"tag": "plain_text", "content": text}, "text_align": align} if bold: d["text_style"] = {"bold": True} return d def make_table(headers, rows, widths): elements = [] cols = [] for h, w in zip(headers, widths): cols.append({ "tag": "column", "width": "weighted", "weight": w, "elements": [make_div(h, "center", bold=True)], }) elements.append({"tag": "column_set", "columns": cols}) for row in rows: cols = [] for val, w in zip(row, widths): cols.append({ "tag": "column", "width": "weighted", "weight": w, "elements": [make_div(str(val), "left")], }) elements.append({"tag": "column_set", "columns": cols}) return elements elements = [] elements.append(make_div(f"集群:{cluster} 生成时间:{now} (Shanghai)", "left")) elements.append(make_div( f"汇总:低使用率 {len(low_usage)} / 未引用 {len(unused_pvc)} / PV残留 {len(pv_residual)} / EBS残留 {len(ebs_available)} / 待确认卷 {len(ebs_review)}", "left", bold=True)) elements.append({"tag": "hr"}) if low_usage: elements.append(make_div(f"① 低使用率 PVC(容量>{int(SIZE_MIN_GB)}G & 连续{DAYS_WINDOW}天<{int(LOW_PCT)}%)", "left", bold=True)) rows = [[r["namespace"], r["pvc"], r["volume"], fmt_gb(r["capacity_gb"]), fmt_pct(r["usage_pct"])] for r in sorted(low_usage, key=lambda x: x["usage_pct"])[:20]] elements += make_table(["ns", "PVC", "PV", "容量", "使用率"], rows, [2, 5, 5, 2, 2]) elements.append({"tag": "hr"}) if unused_pvc: elements.append(make_div("② 未引用 PVC(Bound 无 Pod)", "left", bold=True)) rows = [[p["namespace"], p["name"], p.get("volume_id", ""), p["capacity"]] for p in sorted(unused_pvc, key=lambda x: (x["namespace"], x["name"]))[:20]] elements += make_table(["ns", "PVC", "VolumeId", "容量"], rows, [2, 5, 5, 2]) elements.append({"tag": "hr"}) if pv_residual: elements.append(make_div("③ PV 残留(Released/Available)", "left", bold=True)) rows = [[p["name"], p["status"], p["reclaim_policy"], p["capacity"], f"{p['claim_ns']}/{p['claim_name']}" if p["claim_name"] else "-", _ebs_status_text(p.get("ebs_exists"))] for p in sorted(pv_residual, key=lambda x: (x["status"], x["name"]))[:20]] elements += make_table(["PV", "状态", "reclaim", "容量", "关联PVC", "EBS状态"], rows, [4, 2, 2, 2, 4, 3]) elements.append({"tag": "hr"}) if ebs_available or ebs_review: elements.append(make_div("④ EBS 残留 / 待确认卷", "left", bold=True)) rows = [[v.get("ID", "-"), f"{v.get('Size', '-')}G", v.get("Name", "-") or "-"] for v in sorted(ebs_available, key=lambda x: x.get("ID", ""))[:20]] elements += make_table(["VolumeId", "大小", "名称"], rows, [5, 2, 5]) if ebs_review: rows = [[v.get("ID", "-"), f"{v.get('Size', '-')}G", v.get("K8sPVCNS", "-") or "-", v.get("K8sPVC", "-") or "-"] for v in sorted(ebs_review, key=lambda x: x.get("ID", ""))[:20]] elements.append(make_div("in-use 待确认卷(集群 PV/PVC 未匹配,需人工核实,勿自动删除)", "left")) elements += make_table(["VolumeId", "大小", "namespace", "原PVC"], rows, [4, 2, 3, 4]) elements.append({"tag": "hr"}) elements.append(make_div("以上 PVC / PV / EBS 卷为残留或低效���源,建议协调时间确认后清理。待确认卷需人工核实后再处理。", "left", bold=True)) payload = { "msg_type": "interactive", "card": { "header": {"title": {"tag": "plain_text", "content": f"PVC 巡检周报 - {cluster}"}}, "elements": elements, }, } try: resp = requests.post(LARK_WEBHOOK, json=payload, timeout=15) resp.raise_for_status() body = resp.json() if body.get("StatusCode", body.get("code", 0)) not in (0, None): print(f"飞书返回非0:{body}") else: print(f"已推送 PVC 巡检到飞书") except Exception as e: print(f"飞书推送失败:{e}") def resolve_cluster_label(cluster): if N9E_CLUSTER_LABEL: return N9E_CLUSTER_LABEL mapped = N9E_CLUSTER_LABEL_MAP.get(cluster, "") return mapped or cluster def main(): cluster = infer_cluster_name() region = infer_region() cluster_label = resolve_cluster_label(cluster) print(f"集群={cluster} region={region} context={KUBE_CONTEXT or '(current)'} n9e_cluster_label={cluster_label} now={now_sh().strftime('%Y-%m-%d %H:%M:%S %Z')}") low_usage = collect_low_usage_pvc(cluster_label) pvcs = collect_pvc_list() pvs = collect_pv_list() pod_used = collect_pod_pvcs() pvc_info = {(p["namespace"], p["name"]): p for p in pvcs} for r in low_usage: p = pvc_info.get((r["namespace"], r["pvc"])) if p: if not r["volume"]: r["volume"] = p["volume"] r["capacity_gb"] = bytes_to_gb(p["capacity"]) pv_handle_map = build_pv_volume_handle_map(pvs) unused_pvc = detect_unused_pvc(pvcs, pod_used, pv_handle_map) # 对残留 PV 的后端 EBS 批量检查是否存在 residual_handles = [p["volume_handle"] for p in pvs if p["status"] in ("Released", "Available") and p["volume_handle"]] ebs_exists_map = check_ebs_exists_batch(residual_handles, region) pv_residual = detect_pv_residual(pvs, ebs_exists_map) print(f" 未引用 PVC {len(unused_pvc)} 个") print(f" PV 残留 {len(pv_residual)} 个") try: ebs_vols = collect_ebs_volumes(region, cluster) ebs_available, ebs_review = detect_ebs_residual(ebs_vols, pvs, pvcs) print(f" EBS available {len(ebs_available)} / 待确认 {len(ebs_review)}") except SystemExit as e: print(f" EBS 查询失败,跳过:{e}") ebs_available, ebs_review = [], [] md = build_md(cluster, low_usage, unused_pvc, pv_residual, ebs_available, ebs_review) script_dir = os.path.dirname(os.path.abspath(__file__)) reports_dir = os.path.join(script_dir, "reports") os.makedirs(reports_dir, exist_ok=True) stamp = now_sh().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(reports_dir, f"pvc-weekly-{stamp}.md") with open(out_path, "w", encoding="utf-8") as f: f.write(md) print(f"\n报告已生成: {out_path}") files = sorted( [f for f in os.listdir(reports_dir) if f.startswith("pvc-weekly-") and f.endswith(".md")] ) for old in files[:-REPORT_KEEP]: try: os.remove(os.path.join(reports_dir, old)) print(f"已清理旧报告: {old}") except OSError: pass weekday = now_sh().weekday() if weekday == PUSH_WEEKDAY: push_lark(cluster, low_usage, unused_pvc, pv_residual, ebs_available, ebs_review) else: print(f"今日(周{weekday+1}, 上海时间)不推送飞书,仅生成日报") if __name__ == "__main__": main()


浙公网安备 33010602011771号