NFS 共享日志盘 75 天写满 11T 排障记录(kjds 生产集群)
记录时间:2026-07-29
环境:K8s 外部集群 swj-kj-prod(kjds 租户)/ ArgoCD GitOps / NFSnfs.chinaunicom.com:/kjdx-sas(12T)/ 15 个 Spring Boot 服务(logback + Nacos + HikariCP + kingbase8)/ 存储服务器 host47
一、问题现象
2026-07-28 收到告警:kjds 租户日志所用的 NFS 盘 /kjdx-sas 即将写满。
# host47 上 df 输出
nfs.chinaunicom.com:/kjdx-sas 12T 11T 1.1T 91% /kjdx-sas
PVC 根目录下各服务日志占用(du -sh *):
| 服务 | 占用 | 服务 | 占用 |
|---|---|---|---|
| messageprocess-export-service | 3.2T | httpsend-service | 78G |
| export-service | 2.8T | base-service | 24G |
| import-service | 1.7T | mqsend-service | 123G |
| systems-service | 1.7T | savefile-service | 794G |
| messageprocess-import-service | 497G | 其余 5 个服务 | 均 <15G |
这套系统 2026-05-14 才上线,距今仅 75 天。75 天写满 11T,日均产量约 150G/天。
二、排查过程
2.1 初查 K8s 挂载配置:只有挂载,没有清理配套
kjds 全部 15 个服务共享同一个 PVC jar-log-data-pvc(RWX,申请 200Gi),且均为双挂载:
volumeMounts:
- mountPath: /AppHome/logs
name: log-data
subPathExpr: '{{ .Release.Name }}/$(POD_NAME)' # 每个 Pod 独立目录
- mountPath: /AppLogs
subPath: '{{ .Release.Name }}' # 同服务所有 Pod 共享
values 里只有 GC 日志带轮转(-Xlog:gc*:...:filecount=5,filesize=10M),业务日志没有任何外部清理机制。另外注意:PVC 申请 200Gi 在 NFS 类型 StorageClass 上不做配额强制,所以能一路写满整个 12T 盘。
2.2 拿到应用 logback.xml:有轮转,但只管活跃 Pod
<property name="LOG_HOME" value="/AppHome/logs"/>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>10MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<maxHistory>60</maxHistory> <!-- 无 totalSizeCap -->
</rollingPolicy>
maxHistory=60 看似有保留策略,但它由活着的 logback 实例执行,只清理自己 LOG_HOME 里的文件。而 LOG_HOME=/AppHome/logs 按 $(POD_NAME) 隔离。Pod 发版或重建死亡后,旧 Pod 的日志目录变成孤儿,再没有任何进程会清理它。
2.3 /AppLogs 共享层实测:95G 历史死数据
exec 进 export-service 当前 Pod 查看 /AppLogs:日志文件为 app.2026-05-14.0.log ~ app.2026-05-19.x.log,共 95G,5月19日 11:48 后停止增长,目录下还有 skywalking-agent/、nacos/ 子目录。
推断:5月19日前后有一次发版把 LOG_HOME 从 /AppLogs(共享)改到了 /AppHome/logs(按 Pod 隔离)。此前所有副本挤在同一个目录写同名文件,这批死数据永远不会被任何 logback 清理。
2.4 产量构成的误判与纠正
第一版 logback.xml 里有三行显式声明:
<logger name="java.sql.Connection" level="info"/>
<logger name="java.sql.Statement" level="info"/>
<logger name="java.sql.PreparedStatement" level="info"/>
我据此判断「SQL 全量打印占产量 70–90%」。
实测活跃 Pod 日志,翻遍 8 分钟内容,没有一条 java.sql.* 输出。这两个 logger 名是 log4jdbc 组件的约定,项目根本没集成 log4jdbc。这三行是模板抄来的死配置,从未产生任何输出,判断前提错误,结论推翻。
2026-07-29 补充修正:上述结论仅适用于 systems/export 类服务。次日核查 import-service 实时日志发现,报文处理类服务的 SQL 日志走 hibernate 自有 logger,以三种形态同时开启:
org.hibernate.SQLDEBUG(每条 SQL 一行)、org.hibernate.type.descriptor.sql.BasicBinderTRACE(每个绑定参数一行)、show_sql=true的Hibernate:前缀 System.out 直出(不经 logback)。一条 SQL 实际产生 3+N 行日志。各服务日志配置各自为政,排查必须逐服务进行,不能以一个服务的实测推及全部。
2.5 实测日志真实构成
抽取大户服务活跃 Pod 的实时日志(16:17–16:25),构成为:
| 成分 | logger | 级别 | 特征 |
|---|---|---|---|
| 业务切面出入参 | c.g.b.c.s.s.a.ControllerLogAspect |
INFO | 每请求”入参+出参”2 条,出参是完整业务 JSON(单条约 1.5KB),高峰期产量主体 |
| 请求过滤器 | c.g.b.c.s.server.filter.UserFilter |
INFO | 每请求 2 条流程日志 |
| Nacos 服务发现推送 | com.alibaba.nacos.client.naming |
INFO | 每次推送 2–3 条,单条 3–6KB 全量实例 JSON,频次高 |
| HikariCP 连接池心跳 | com.zaxxer.hikari.pool.HikariPool |
DEBUG | 每 30 秒固定 2 行 Pool stats,说明配置里存在显式 DEBUG 声明 |
结论:就是 INFO 级业务/框架日志打爆的盘。150G/天 ÷ 15 个服务,高峰业务时段出入参日志是主要构成。
2.6 后续追踪:root=ERROR 改版未生效
技术团队按 4.3 方案提交多个大户服务的 logback diff(root INFO→ERROR + maxHistory 60→365),但次日盘占用不降反升:91% → 94%(剩 726G,日增约 360G,仅够约 2 天)。import-service 活跃 Pod 实时日志仍被 hibernate SQL 刷满。
未生效原因:DEBUG/TRACE 配置不在 logback.xml,而在 Nacos 配置中心或 application.yml 的 logging.level.* / spring.jpa.show-sql。Spring Boot 的 logging.level 在 logback 初始化后应用,覆盖 logback.xml;显式声明的 logger 不受 root 级别压制。
| 可能 | 依据 | 验证方式 |
|---|---|---|
| 配置在 Nacos / application.yml | 这批服务用 Nacos 做配置中心 | Nacos 控制台查对应服务 prod 配置;kubectl exec 查 Pod 内 env/yml;curl localhost:56780/actuator/loggers/org.hibernate.SQL |
| diff 已提交但镜像未构建发版 | 涉事 Pod 可能仍跑旧镜像 | kubectl get pod <name> -n kjds -o jsonpath='{.spec.containers[0].image}{"\n"}{.status.startTime}' 对比提交时间 |
2026-07-29 坐实:拿到某报文服务实际配置,logging.level.org.hibernate.SQL: DEBUG 赫然在列——显式 DEBUG 声明确实在 yml/Nacos 层而非 logback.xml,root=ERROR 对其无效,分析路径验证成立。同一文件还发现配置缺陷:jpa 块缩进错位两级,show-sql/properties 实际挂在 spring.servlet 下成为无效键(该服务 spring.jpa.show-sql 实为默认值 false);但 import-service 日志中存在 Hibernate: 前缀 System.out 行,证明其 show-sql 真正生效——各服务配置必须逐一核查,不可跨服务复制结论。修复时须同时删除 spring.servlet 下错位的三行死配置。
第二轮改版评审(同日):技术按修复清单改版后,jpa 缩进、show-sql、hibernate 级别均改对,但引入新缺陷——同一 YAML 文件中 logging: 键出现两次(中部完整块 + 尾部旧残留),SnakeYAML 对重复键是后值整体覆盖前值(非合并),中部细粒度声明全部失效。另发现 org.gzeport: info 包名与实测业务 logger(c.g.* = com.gzeport)不符,疑似死配置。最终要求:全文件只保留一份 logging 块,包名 grep 源码确认,发版后用 actuator/loggers/<loggerName> 直接验证生效级别(返回期望值即证明无覆盖)。
三、根因分析
磁盘占用可以拆成一个公式:
磁盘占用 = 日产量(logger level 决定)× 保留天数(maxHistory 决定)+ 孤儿目录(外部清理缺失)
| 因素 | 归属 | 本次角色 |
|---|---|---|
| root=INFO 下业务/框架大产量(~150G/天) | logback logger 配置 | 主犯:产量源头 |
maxHistory=60 且无 totalSizeCap |
logback rollingPolicy | 乘数:60 天 × 150G/天 = 9T 设计稳态——即使零孤儿、logback 完美工作,合法保留就要吃掉 12T 盘的 75% |
subPathExpr=$(POD_NAME) 隔离 + 无外部清理 |
K8s values + 运维配套 | 叠加项:每次发版留下一批孤儿目录,75 天累积约 2T |
/AppLogs 改配置前的共享写入遗留 |
历史变更 | 每服务约百 G 死数据 |
按这套配置组合推算,打满就是设计值的必然结果,不算异常。150G/天的产量配任何保留天数都会满,只是时间问题。60 天给 9T,180 天就是 27T。
治理过程中还有一次波折:技术团队第一版改动是 root: INFO → ERROR + maxHistory: 60 → 365。当时评审担心 java.sql.* 显式 info 会绕过 root=ERROR 继续输出——实测证明那三行是死配置后,root=ERROR 的方向确认成立(logback 规则:显式声明 level 的 logger 不受 root 级别压制,这条规则本身是对的,只是本案中它没有生效对象)。
四、解决方案
4.1 紧急止血:host47 手工清理(保留 180 天)
cd /kjdx-sas/kjds-jar-log-data-pvc-pvc-ef0b2968-6c57-47a9-aa7a-43bddc02b62a
# 1. dry-run:先评估 180 天前的文件数量和可释放空间
find . -type f -mtime +180 \( -name '*.log*' -o -name '*.gz' -o -name '*.hprof' \) | wc -l
find . -type f -mtime +180 \( -name '*.log*' -o -name '*.gz' -o -name '*.hprof' \) -exec du -ch {} + | tail -1
# 2. 确认后执行删除
find . -type f -mtime +180 \( -name '*.log*' -o -name '*.gz' -o -name '*.hprof' \) -delete
# 3. 清理 Pod 重建后留下的空目录
find . -mindepth 2 -type d -empty -delete
注意:只能用
-mtime +N删 N 天前的旧文件。不要删正在写入的日志——NFS 上删除句柄未关闭的文件会变成.nfsXXXX隐藏文件,空间不释放。
4.2 常驻治理:host47 每日 cron
新建 /home/ywuser/kjds-log-cleanup.sh:
#!/bin/bash
# kjds jar 日志定时清理:业务日志保留 180 天,OOM 堆转储保留 7 天
# flock 防重入,避免上一次未跑完叠加执行
LOG_ROOT="/kjdx-sas/kjds-jar-log-data-pvc-pvc-ef0b2968-6c57-47a9-aa7a-43bddc02b62a"
RETENTION_DAYS=180
HPROF_RETENTION_DAYS=7
CLEAN_LOG="/home/ywuser/kjds-log-cleanup.log"
exec 9>/tmp/kjds-log-cleanup.lock
flock -n 9 || { echo "$(date '+%F %T') 上一次清理未结束,跳过" >> "$CLEAN_LOG"; exit 0; }
{
echo "===== $(date '+%F %T') 开始清理 ====="
# hprof 为 OOM 现场快照,单个可达数十 GB,无长期保留价值
find "$LOG_ROOT" -type f -mtime +${HPROF_RETENTION_DAYS} -name '*.hprof' -print -delete
find "$LOG_ROOT" -type f -mtime +${RETENTION_DAYS} \( -name '*.log*' -o -name '*.gz' \) -print -delete
find "$LOG_ROOT" -mindepth 2 -type d -empty -delete
echo "===== $(date '+%F %T') 清理结束 ====="
df -h /kjdx-sas
} >> "$CLEAN_LOG" 2>&1
chmod +x /home/ywuser/kjds-log-cleanup.sh
crontab -e
# 每天 03:17 执行(错开整点与业务高峰)
17 3 * * * /home/ywuser/kjds-log-cleanup.sh
这一步不可替代。logback 配置改得再对,也只能管活跃 Pod;死 Pod 的孤儿目录永远需要外部清理。
4.3 源头减量:logback 改版(技术团队执行)
| 改动项 | 改前 | 改后 | 说明 |
|---|---|---|---|
| root level | INFO | ERROR | 砍掉 INFO 级业务/框架日志(产量主体),已确认方向正确 |
| maxHistory | 60 | 365 | 超出审计需求(180 天)一倍,待实测产量后评估合理性 |
| totalSizeCap | 无 | 仍无 | 建议补充,防单日爆量 |
| java.sql.* 三行 | info | 未动 | 死配置,无实际影响,建议顺手删除避免误导 |
注意:root=ERROR 后,显式声明 level 的 logger 不受影响。实测发现 HikariCP 存在显式 DEBUG 声明(每 30 秒 2 行,量小可忽略),改版后应全量检查各服务显式 logger 声明,防止个别显式 DEBUG/INFO 的大户漏网。
修复清单(Nacos 或 application.yml,哪个生效改哪个):
logging:
level:
org.hibernate.SQL: WARN # 关 SQL 语句打印
org.hibernate.type.descriptor.sql.BasicBinder: WARN # 关参数绑定 TRACE
org.hibernate.type.descriptor.sql.BasicExtractor: WARN
spring:
jpa:
show-sql: false # 关 System.out 直出(不经 logback,root 级别管不到)
properties:
hibernate:
format_sql: false # 开着会让单条 SQL 格式化成几十行
4.4 合规冲突与策略转向:压缩替代删除
实测 find -mtime +180 = 0、-mtime +90 = 0、-mtime +60 = 42265 个文件——系统仅上线 76 天,合规要求保留 180 天意味着 11月10日(上线日+180 天)前删除策略必然空转,删除止血路线被合规封死。104 天 × 360G/天 = 37T 增量,物理不可达。止血方案从”删除”转向”压缩”:
# 压缩 60 天前日志(gzip 压缩率 5~10 倍,zgrep 可检索;句柄早已关闭,安全)
find . -type f -mtime +60 -name '*.log' ! -name '*.gz' -print0 | xargs -0 -r -P 8 -n 100 gzip
预计释放 0.8~1.3T,争取 3~4 周窗口。治本只剩两条必须同时落地的腿:NFS 扩容至 30T(减量后 30~50G/天 × 180 天 ≈ 5.4~9T + 存量)+ 技术减量配置发版。扩容不通时的退路是归档分层(60 天前 rsync 至冷存储,两地合计 180 天)。cron 改双段式:60 天压缩 + 180 天删除 + hprof 7 天删除。
合规确认点:gzip 压缩留存是否满足”保留 180 天”的合规解释(logback 官方 rollover 压缩同为 .gz,行业惯例认可,但需合规部门书面确认)。容量规划类决策必须在”系统上线时长 < 保留期”的约束下重新推演。删除策略在保留期窗口未到时一文不值——这个约束本次推演前期漏掉了,后来才被实测纠正。
4.5 遗留待办
maxHistory=365待验证:发版 3 天后实测日净增量 X,365X 必须 < 12T(X=10G/天 → 3.6T 可行;X=30G/天 → 11T 再打满)savefile-service(794G)内容性质未确认,可能不是纯日志,纳入清理策略前必须人工核实ControllerLogAspect出入参日志被 root=ERROR 一并砍掉:请求级审计轨迹消失(政务系统可能有合规价值),但出参明文含企业名称、提单号、海关回执,本身也是数据合规风险。取舍待业务拍板;如需保留审计,可单独放行:<logger name="c.g.b.c.s.s.a.ControllerLogAspect" level="info"/>
五、验证
# 1. 清理效果:使用率应明显回落
df -h /kjdx-sas
# 2. 孤儿目录佐证:NFS 上 POD_NAME 目录数远大于集群存活 Pod 数
ls /kjdx-sas/.../export-service/ | wc -l # 目录数
kubectl get pods -n kjds | grep export | wc -l # 存活 Pod 数
# 3. 减量效果(发版后 3 天):统计日净增量,代入 365 天评估稳态
du -sh /kjdx-sas/kjds-jar-log-data-pvc-*/ # 连续 3 天对比差值
# 4. cron 运行审计
tail -50 /home/ywuser/kjds-log-cleanup.log
# 5. logger 级别生效验证(发版后必须做)
curl -s localhost:56780/actuator/loggers/org.hibernate.SQL | jq '.configuredLevel'
六、注意事项
- NFS 删文件陷阱:删除写入中的文件会残留
.nfsXXXX隐藏文件不释放空间;只删 N 天前的旧文件,必要时滚动重启对应 Deployment 释放句柄。 subPathExpr: $(POD_NAME)隔离不可去掉:多副本共享 RWX 盘时,不设隔离会让所有 Pod 写同名app.yyyy-MM-dd.i.log,造成多进程无锁 append 互相覆盖、rollover 竞争损坏文件、maxHistory 清理互杀。孤儿目录是隔离的代价,正确做法是保留隔离,并配套外部清理;取消隔离会让多副本写同名文件,反而更糟。- logback 显式声明覆盖 root:
<logger name="x" level="y"/>一旦声明,root 级别变更压不住它。排查日志量问题时,先全量核对显式 logger 声明,再看 root。 - PVC 申请量不等于配额:NFS 类 StorageClass 不 enforce
resources.requests.storage,200Gi 的申请照样写满 12T 盘。容量治理要靠清理机制,不能靠 PVC 数值。 - 死配置会误导排障:
java.sql.*三行 log4jdbc 时代的遗留声明,差点把排查方向带偏。配置评审时应顺手清理无生效对象的 logger 声明。 - maxHistory 与审计需求要对齐:保留期越长占用越大。365 天 × 日产量必须落回磁盘容量公式里验证,不能拍脑袋。
- 各服务日志配置各自为政:logback.xml / application.yml / Nacos 三层都可能存在生效配置,以一个服务的实测推及全部服务会误判,必须逐大户服务核对。
- 治理 SQL 日志要同时关三个通道:
org.hibernate.SQL(logback)、BasicBinder(logback TRACE)、show_sql(stdout)。只改 logback.xml 的 root 级别,一个都关不掉。 - YAML 配置排障四要素:
- 重复顶层键:全文搜索每个顶层键(
logging:、spring:)确认只出现一次 - 缩进层级:逐个键确认挂在期望的父键下(
spring.jpavsspring.servlet两级之差) - 包名真实性:logger 声明的包名对照实际代码包名(
com.vsorg.前缀混淆) - 生效验证:改完不能只读文件,要用
actuator/loggers或实际日志输出验证
- 重复顶层键:全文搜索每个顶层键(