磁盘巡检系统使用文档-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 token
  • LARK_WEBHOOK:飞书机器人 webhook

可调参数(环境变量覆盖):

  • CLUSTER_SKIP:排除的 cluster,默认 ltp-eks-uat
  • TOPN:推送前 N 条,0 = 全部
  • SIZE_MIN_GB / USAGE_MIN / USAGE_MAX:筛选阈值

4.2 PVC 脚本配置区

脚本顶部直接填:

  • N9E_TOKEN:n9e Bearer token
  • LARK_WEBHOOK:飞书机器人 webhook
  • N9E_CLUSTER_LABEL_MAP:集群名 → n9e cluster 标签映射表

可调参数:

  • LOW_PCT:低使用率阈值,默认 50
  • DAYS_WINDOW:时序窗口天数,默认 14
  • SIZE_MIN_GB:容量下限,默认 50
  • PUSH_WEEKDAY:推送日,0=周一,1=周二

4.3 飞书 webhook 获取

飞书群 → 设置 → 群机器人 → 添加自定义机器人 → 复制 webhook URL。注意该机器人不支持 lark_md 元素,脚本已用 plain_text + column_set 适配。


五、部署步骤

5.1 通用流程(每个集群/每台机器)

  1. 拷贝脚本到固定目录(如 /root/ec2-disk/ 或 /root/pvc-inspect/)
  2. 编辑脚本顶部,填入 N9E_TOKEN 和 LARK_WEBHOOK
  3. 确认 kubectl config current-context 指向目标集群
  4. 手动跑一次验证
  5. 配置 cron 定时执行

5.2 手动验证(模拟 cron 环境)

必须用 env -i 模拟 cron 最小环境,否则手动能跑、cron 跑不通:

 
bash
# 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 路径探测

部署前先确认本机命令路径(每台机器可能不同):

 
bash
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/credentials
  • PATH:包含 kubectl/aws/python 所在目录
  • PYTHONIOENCODING=utf-8:避免中文 print 崩(UnicodeEncodeError)
  • KUBE_BIN / AWS_BIN:绝对路径,防止 PATH 找不到

6.4 时区验证

cron 不读 bashrc,AWS 凭据必须在文件里:

 
bash
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 环境跑通

 
bash
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 未引用

 
bash
kubectl -n <namespace> delete pvc <pvc名>

10.3 PV 残留

先看报告 Part 3 的 EBS状态:

 
bash
# 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 的「待确认卷」必须人工核实后再删:

 
bash
# 核实: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 无报错

十二、安全注意事项

  1. 凭据保护:脚本顶部 N9E_TOKEN 和 LARK_WEBHOOK 是明文,文件权限收紧 chmod 700,不要进 git/共享存储
  2. 删除操作:脚本只报告不自动删除,所有删除需人工确认后手动执行
  3. 待确认卷:Part 4.2 的 in-use 待确认卷绝不自动删,必须人工核实
  4. 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()
n9e_disk_weekly.py
# /// 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()
pvc_inspect.py

image

 

image

 

 

posted @ 2026-11-02 18:32  JameMei  阅读(6)  评论(0)    收藏  举报