提交 6b716808 authored 作者: 陈泽健's avatar 陈泽健

fix(自动化部署): auto_clean_deleted_ubains V3→V4 升级(仓库侧闭环)

- v4 脚本入库 + 逻辑说明文档(三级处置策略:truncate 零中断优先)
- 缺陷修正:容器归属读取上移至杀进程之前(/proc cgroup 进程死后即消失)
- auto_crontab_settings.sh 注册入口 v4 化(条目内容比对自动升级+awk 成对精准删除)
- README 定时脚本总览同步更新
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 39c373d2
# auto_clean_deleted_ubains 脚本修复方案与修复目的
---
## 1. 文档信息
- **修复对象**`auto_clean_deleted_ubains_v3.sh`(已删除大文件自动清理与容器重启脚本,V3.2)→ 升级为 `auto_clean_deleted_ubains_v4.sh`(V4.0)
- **脚本位置**`/opt/scripts/`(服务器:`nat.ubainsyun.com:18126`
- **触发方式**:crontab 定时任务(原 `0 4 * * *`,现 `20 4 * * *`
- **修复日期**:2026-09-08
- **当前状态**:✅ **修复已完成并验证通过**
---
## 2. 修复目的
### 2.1 直接目的:消除凌晨 4 点的容器重启风暴
2026-09-08 凌晨 04:00,ujava2 容器(内含 meeting2.0、auth、gateway、quartz、mqtt、system、message 共 7 个服务)被本脚本无差别整体重启,meeting2.0 日志中断约 2 分钟。追查确认:**肇事者就是本脚本**
修复后目标:**脚本仍能在 deleted 大文件超阈值时释放磁盘空间,但不再杀无辜进程、不再重启无关容器、服务全程零中断。**
### 2.2 事故还原(2026-09-08 04:00 实际日志)
| 时间 | 脚本行为 |
|------|---------|
| 04:00:04 | 发现 `tail` 进程占用 468MB deleted 日志 → SIGTERM 杀掉 |
| 04:00:06 | 发现 extapi 进程(PID 30322)占用 468MB deleted 日志 → SIGKILL 强杀 |
| 04:00:10 | 发现 meeting2.0 进程占用 92MB deleted 日志 → **不足 100MB,跳过(无辜)** |
| 04:00:13 | **仍执行 `docker restart ujava2`**(因之前杀过 extapi,重启标记已被置位) |
| 04:00:24 | 容器被强制销毁重建,7 个服务全部重启 |
| 04:00:10 | 并发运行的监控脚本 ujava2-startup.sh 检测到 extapi 被"误杀",又将其拉起 → 两套脚本相互打架 |
### 2.3 为什么要修而不是删
本脚本要解决的问题真实存在:**进程持有已删除日志文件的句柄时,文件空间不会释放**(extapi 曾涨到 468MB)。磁盘需要被保护,脚本有存在价值 —— 问题只在于**释放空间的方式选错了**(杀进程重启容器),应改为**原地清空(truncate)**
---
## 3. 原脚本(V3.2)缺陷分析
通读 `/opt/scripts/auto_clean_deleted_ubains_v3.sh` 源码,确认 4 个缺陷:
| # | 缺陷 | 源码依据 | 后果 |
|---|------|---------|------|
| 1 | **用"杀进程+重启容器"释放空间** | `kill -15/-9 $pid` + `docker restart` | 实际上对 `/proc/<pid>/fd/<n>` 执行 truncate 即可原地释放空间,进程无感知、服务零中断。V3 的做法是为倒掉洗澡水把孩子一起倒掉 |
| 2 | **重启目标写死 ujava2** | 配置区 `CONTAINER_NAME="ujava2"` | 任何进程(哪怕是无关的 `tail`)的 deleted 大文件都会触发**整容器重启**,7 个无辜服务陪葬 |
| 3 | **APP_PATH 路径错误** | `APP_PATH="/var/www/java/external-meeting-api"` | extapi 实际路径为 `/data/services/api/java-meeting/java-meeting-extapi`。导致脚本杀掉 extapi 后判断"不属于特定应用",**不会自动拉起**(本次靠监控脚本 04:00:10 兜底救回) |
| 4 | **与其他脚本无互斥** | 无锁机制 | 与每 3 分钟运行的 ujava2-startup.sh 在 04:00 同时运行:一个杀、一个拉,相互打架 |
| 5 | **重启标记粗粒度**(事故核心机制) | `NEED_RESTART` 一旦置位必重启 | 本次 meeting2.0 文件仅 92MB 未达阈值被正确跳过,但**仍因 extapi 被处理而陪葬重启** —— "处理了 A,重启了 A+B+C+D..." |
---
## 4. 修复方案(V4.0 设计)
### 4.1 核心思路
> **释放 deleted 文件的空间 ≠ 杀死持有它的进程。**
> 对 `/proc/<pid>/fd/<n>` 执行 truncate(`: > 文件`),内核会直接释放该 inode 的数据块,进程完全无感知。
### 4.2 处置策略(三级递进)
```
发现超阈值 deleted 文件
├─ 策略1(首选):truncate 原地清空 → 结束,进程存活、零中断
├─ 策略2(truncate 失败时兜底):杀该进程
│ ├─ 通过 /proc/<pid>/cgroup 判断归属容器 → 只 docker restart 该容器
│ ├─ 宿主机应用且 cwd == APP_PATH → 执行其 run.sh 拉起
│ └─ 其他宿主机进程 → 仅记录,不动
└─ 全程受 DRY_RUN 开关与 flock 互斥锁保护
```
### 4.3 V3 → V4 变更对照表
| 变更点 | V3(旧) | V4(新) |
|--------|----------|----------|
| 空间释放方式 | 杀进程 | **truncate 原地清空**,杀进程仅作 truncate 失败的兜底 |
| 容器重启范围 | 写死 `docker restart ujava2` | 按 `/proc/<pid>/cgroup` 判断,**只重启进程实际所属容器** |
| APP_PATH | `/var/www/java/external-meeting-api`(错误) | `/data/services/api/java-meeting/java-meeting-extapi`(修正) |
| 并发控制 | 无锁 | flock 互斥锁(`/var/run/auto_clean_deleted_ubains.lock`) |
| 灰度能力 | 无 | 支持 `DRY_RUN=1` 只记录不动手 |
| 执行结果统计 | 无 | 日志输出 `发现/清空/杀进程/重启容器` 计数 |
### 4.4 脚本部署位置
- **本地留档**`E:\github\ubains-module-test\Test\auto_clean_deleted_ubains_v4.sh`
- **服务器部署**`/opt/scripts/auto_clean_deleted_ubains_v4.sh`(权限 755)
---
## 5. 配套调整
### 5.1 crontab 变更(仅 1 行,直接替换式)
```diff
# ========== 系统维护类(每日凌晨) ==========
- 0 4 * * * /opt/scripts/auto_clean_deleted_ubains_v3.sh
+ 20 4 * * * /opt/scripts/auto_clean_deleted_ubains_v4.sh
```
**为什么直接替换而不是注释旧行 + 新增?**
1. 避免同一任务在 crontab 中出现多条(原文件已有 1 条注释残留,再叠加会更混乱);
2. 保持在"系统维护类"分组原位置,结构工整;
3. 回滚只需 `crontab /root/crontab.bak.20260908` 一条命令。
**为什么 04:00 → 04:20(错峰)?**
ujava2-startup.sh 每 3 分钟运行一次(整点对齐),04:00 与 auto_clean 撞车导致"一个杀、一个拉"。挪到 04:20 避开整点轮询窗口。
### 5.2 ujava2-startup.sh:零改动(原方案计划加锁,实际不需要)
检查发现该脚本**已自带 flock 单实例锁**
```bash
LOCK_FILE="/tmp/ujava2-startup.lock"
exec 200>"$LOCK_FILE"
flock -n 200 || { ... exit 1; }
```
单实例控制已满足,无需变更(改动越少越好)。它与 v4 使用不同的锁文件,实现"各自单实例 + cron 错峰让行",而非互相饿死(避免 v4 被监控脚本阻塞跳过后一天不执行)。
---
## 6. 实施记录(2026-09-08 14:00–14:15)
| 步骤 | 操作 | 结果 |
|------|------|------|
| 1 | 备份 v3 脚本、crontab、ujava2-startup.sh | ✅ 三份 `.bak.20260908` |
| 2 | 阶段0 止血:truncate extapi 持有的 229MB deleted 文件(fd 1/2) | ✅ 空间释放,进程零中断 |
| 3 | 上传 v4 → `chmod +x``bash -n` 语法检查 | ✅ 通过 |
| 4 | `DRY_RUN=1` 灰度验证 | ✅ 正确识别进程/大小,标注将执行动作,无副作用 |
| 5 | 实跑 v4(当前文件 0MB 不足阈值) | ✅ `杀进程=0 重启容器=[无]` |
| 6 | crontab 切换(备份 → 临时文件 sed → 装载 → 核对) | ✅ 第13行生效,其余条目未动 |
| 7 | 低阈值实测(临时副本阈值降为 100KB,触发 truncate 分支) | ✅ 3.5MB 文件被原地清空,`清空=1 杀进程=0 重启容器=[无]`,测试副本已删除 |
### 实施过程中的一个操作事故(如实记录)
第 6 步首次尝试使用了 `sed -i <(crontab -l) | crontab -` 写法:sed 对进程替换文件编辑失败后,管道另一端的 `crontab -` 读到空输入,**导致 root crontab 被短暂清空(约 1 分钟)**。发现后立即从 3 分钟前的备份完整恢复(已逐行核对全部条目),随后改用"备份 → 临时文件 sed → 装载 → 核对"的安全流程完成切换。空窗期内无定时任务到点,未造成实际影响。
**经验教训:修改 crontab 必须走"临时文件验证后再装载"流程,严禁管道直灌 `crontab -`。**
---
## 7. 验证结果汇总
| # | 验证项 | 方法 | 结果 |
|---|--------|------|------|
| 1 | v4 语法正确 | `bash -n` | ✅ 无报错 |
| 2 | DRY_RUN 识别正确 | `DRY_RUN=1` 手动跑 | ✅ 正确列出进程/大小并标注将执行动作 |
| 3 | truncate 真实生效 | 阶段0 手动清空 229MB;低阈值实测清空 3.5MB | ✅ 空间释放、文件仍在、进程存活 |
| 4 | 无容器重启 | 实跑后 `docker ps` | ✅ ujava2 保持 Up 10 hours 未被重置 |
| 5 | cron 已切换 | `crontab -l` 核对 | ✅ 仅第 13 行变更,其余条目完整 |
| 6 | 监控脚本兼容 | 检查其锁机制 | ✅ 已有锁,无冲突 |
| 7 | 首夜定时观察 | (待 09-09 早上人工核对) | ⏳ 见 7.1 |
### 7.1 次日人工核对项(执行于 2026-09-09 早上)
```bash
# 1. v4 首次定时执行结果(应显示 truncate 或"未发现",无容器重启)
tail -30 /var/log/scripts/auto_clean_deleted_ubains.log
# 2. ujava2 容器未被重启(Up 时间应 > 1 天)
docker ps | grep ujava2
# 3. meeting2.0 日志 03:50–04:30 无中断
tail -100 /var/www/java/api/java-meeting/java-meeting2.0/logs/ubains-INFO-AND-ERROR.log
```
---
## 8. 回滚方案
```bash
# 1. 恢复 v3 脚本调用与原 cron
crontab /root/crontab.bak.20260908
# 2. 手动触发一次 v3 确认可用
/opt/scripts/auto_clean_deleted_ubains_v3.sh
```
> **风险评估**:v4 相比 v3 是**纯降风险改造** —— v3 能做的最激烈动作(杀进程/重启容器),v4 仅在 truncate 失败时才做,且重启范围从"整个容器"收窄到"实际所属容器"。因此回滚反而会恢复"每日重启风暴",仅在验证失败时考虑。
---
## 9. 后续建议(治本,本次未实施)
v4 解决了"超阈值后如何无害处置",但 deleted 文件仍在每天产生(源头未除)。治本方向:
1. **排查删除者**(只读命令):
```bash
grep -rn "ubains-INFO-AND-ERROR" /etc/cron* /var/spool/cron/ /opt/scripts /data/services/scripts
ls -l /proc/$(pgrep -f 'ubains-meeting-api-1.0-SNAPSHOT.jar' | head -1)/fd | grep deleted
```
2. **对占用中的文件改删为清空**:日志清理脚本中对仍被进程持有的文件用 `fuser -s "$f" && : > "$f" || rm -f "$f"`
3. **run.sh 统一 `>>` 追加重定向**(O_APPEND),使 truncate 无稀疏文件副作用;
4. 治本完成后,deleted 文件不再产生或产生即被清空,**永远不会触及 100MB 阈值**,v4 的杀进程/重启逻辑进入长期休眠(保留作最后兜底)。
---
## 10. 涉及变更清单(一句话版)
> 服务器上净变更:**新增 1 个脚本(v4)+ crontab 改 1 行(0 4 v3 → 20 4 v4)**;v3 脚本原样保留仅备份、ujava2-startup.sh 零改动,另有三份 `.bak.20260908` 备份可随时回滚。
# auto_clean_deleted_ubains 脚本修复(V3→V4)- 计划执行文档
> PRD来源: `Docs/PRD/自动化部署脚本/新统一平台/问题处理/auto_clean_deleted_ubains脚本修复方案与目的.md`
> 脚本: `自动化部署脚本/x86架构/新统一平台/定时脚本/auto_clean_deleted_ubains_v4.sh`(V3 保留作回滚备份)
> 服务器: nat.ubainsyun.com:18126 `/opt/scripts/auto_clean_deleted_ubains_v4.sh`
> 执行日期: 2026-09-08
---
## 1. 执行概述
2026-09-08 凌晨 04:00,`auto_clean_deleted_ubains_v3.sh` 无差别整体重启 ujava2 容器(7 个服务陪葬),meeting2.0 日志中断约 2 分钟,并与 ujava2-startup.sh 相互打架(一个杀、一个拉)。
按 PRD 修复方案将脚本升级至 V4.0:**truncate 原地清空优先、杀进程仅作兜底、容器归属精准判断、flock 互斥、DRY_RUN 灰度**。服务器端实施已于 2026-09-08 14:00–14:15 完成并验证通过(详见 PRD 第 6/7 节);本文档记录本地仓库 source of truth 的同步落地,以及同步复核中发现并修正的一处 V4 缺陷。
| 执行范围 | 状态 |
|---------|------|
| 服务器端:阶段0止血、v4 部署、crontab 切换(0 4 v3 → 20 4 v4) | ✅ 已完成(2026-09-08,PRD 第 6 节) |
| 本地仓库:v4 脚本入库 + 复核修正缺陷 + logic 文档 + README 总览更新 | ✅ 已完成(本次) |
| 服务器端:v4 缺陷修正版重新部署 | ⏳ 待办(见 7.1) |
| 次日 09-09 早上:首夜定时执行人工核对 | ⏳ 待办(见 6.2) |
---
## 2. 问题分析(V3.2 缺陷,摘自 PRD 第 3 节)
| # | 缺陷 | 源码依据 | 后果 |
|---|------|---------|------|
| 1 | 用"杀进程+重启容器"释放空间 | `kill -15/-9 $pid` + `docker restart` | 对 `/proc/<pid>/fd/<n>` 执行 truncate 即可原地释放,进程无感知;V3 是为倒掉洗澡水把孩子一起倒掉 |
| 2 | 重启目标写死 ujava2 | 配置区 `CONTAINER_PATTERN="ujava"` | 任何进程(哪怕是无关 `tail`)的 deleted 大文件都触发整容器重启,7 个无辜服务陪葬 |
| 3 | APP_PATH 路径错误(服务器旧版) | `/var/www/java/external-meeting-api` | extapi 实际路径为 `/data/services/api/java-meeting/java-meeting-extapi`;导致杀掉 extapi 后不会自动拉起(本次靠监控脚本 04:00:10 兜底救回) |
| 4 | 与其他脚本无互斥 | 无锁机制 | 与每 3 分钟运行的 ujava2-startup.sh 在 04:00 撞车:一个杀、一个拉 |
| 5 | 重启标记粗粒度(事故核心机制) | `NEED_RESTART` 一旦置位必重启 | meeting2.0 文件仅 92MB 未达阈值被正确跳过,仍因 extapi 被处理而陪葬重启——"处理了 A,重启了 A+B+C+D..." |
---
## 3. 修复方案(V4.0 设计)
### 3.1 核心思路
> **释放 deleted 文件的空间 ≠ 杀死持有它的进程。**
> 对 `/proc/<pid>/fd/<n>` 执行 truncate(`: > 文件`),内核直接释放该 inode 的数据块,进程完全无感知。
### 3.2 处置策略(三级递进)
```
发现超阈值(≥100MB) deleted 文件
├─ 策略1(首选):truncate 原地清空 → 结束,进程存活、零中断
├─ 策略2(truncate 失败兜底):杀进程(先 -15 后 -9)
│ ├─ /proc/<pid>/cgroup 判断归属容器 → 只 docker restart 该容器
│ ├─ 宿主机应用且 cwd == APP_PATH → 执行其 run.sh 拉起
│ └─ 其他宿主机进程 → 仅记录,不动
└─ 全程受 DRY_RUN 开关与 flock 互斥锁保护
```
### 3.3 V3 → V4 变更对照
| 变更点 | V3(旧) | V4(新) |
|--------|----------|----------|
| 空间释放方式 | 杀进程 | **truncate 原地清空**,杀进程仅作 truncate 失败的兜底 |
| 容器重启范围 | 写死 `docker restart ujava2` | 按 `/proc/<pid>/cgroup` 判断,**只重启进程实际所属容器** |
| 阈值 | 1GB | 100MB(更早介入) |
| APP_PATH | `/var/www/java/external-meeting-api`(服务器旧版,错误) | `/data/services/api/java-meeting/java-meeting-extapi`(修正) |
| 并发控制 | 无锁 | flock 互斥锁(`/var/run/auto_clean_deleted_ubains.lock`) |
| 灰度能力 | 无 | 支持 `DRY_RUN=1` 只记录不动手 |
| 执行结果统计 | 无 | 日志输出 `发现/清空/杀进程/重启容器` 计数 |
### 3.4 仓库同步复核中发现的 V4 缺陷(本次修正)⭐
将已验证的 v4(`E:\github\ubains-module-test\Test\` 留档)同步入库时逐行复核,发现一处缺陷:
| 项目 | 说明 |
|------|------|
| 缺陷 | `container=$(get_container_of_pid "$pid")` 位于**杀进程之后**调用 |
| 根因 | 该函数读取 `/proc/<pid>/cgroup`,进程死后 `/proc/<pid>` 目录即消失,grep 必然失败 → 返回空 |
| 后果 | 兜底分支(truncate 失败→杀进程)永远识别不出容器归属 → **被杀的容器进程不会被重启**,服务中断且不自愈,"只重启实际所属容器"的核心设计失效。当前靠 truncate 首选路径的高成功率掩盖(root 下 `: > /proc/<pid>/fd/<n>` 几乎不失败),且 ujava2-startup.sh 的 API 检测可部分兜底,但设计意图未达成 |
| 修正 | 将归属读取上移至扫描循环内 `proc_cwd` 读取之后(即**杀进程之前**,与 V3 的"先读 cwd 再杀"同一防 PID 回收思路),杀进程后直接使用已取得的 `container` 变量 |
```bash
# 修正前(杀进程后才读 cgroup —— 进程已死,读不到)
kill -15 "$pid" 2>/dev/null
...
KILLED=$((KILLED+1))
container=$(get_container_of_pid "$pid") # ❌ /proc/<pid> 已消失
# 修正后(杀进程前读取,与 proc_cwd 同处)
proc_cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null)
# 归属容器必须在杀进程前读取(进程死后 /proc/<pid>/cgroup 即消失,届时无法判断)
container=$(get_container_of_pid "$pid") # ✅
...
kill -15 "$pid" 2>/dev/null
...
KILLED=$((KILLED+1))
if [ -n "$container" ]; then
```
> 服务器端 `/opt/scripts/auto_clean_deleted_ubains_v4.sh` 当前仍是修正前版本(功能可用,仅兜底分支的容器识别失效),需按 7.1 待办同步此 2 行变更。
### 3.5 部署入口 auto_crontab_settings.sh 同步调整(本次)⭐
`auto_crontab_settings.sh` 是 4 个主部署脚本(new_auto / new_auto_meeting / new_auto_monitor / new_auto_voice)source 调用的 crontab 注册唯一入口,原定义仍指向 v3 + `0 4 * * *`,新部署服务器会继续装上"重启风暴"脚本。经确认采用"**定义调整 + 升级逻辑增强**"方案,共 3 处变更:
| # | 变更 | 内容 | 解决的问题 |
|---|------|------|-----------|
| 1 | 任务定义 | `TASK_CRON[auto_clean]`: `0 4 * * *``20 4 * * *``TASK_SCRIPT[auto_clean]`: v3 → v4 | 新部署服务器直接注册 v4 且错峰 04:20 |
| 2 | `_add_single_cron_job` 第 6 步增强 | marker 存在时不再无条件跳过:读取 marker 后紧跟的任务条目行,与期望 `script_path` 一致才跳过;不一致(如存量 v3 条目)则精准移除旧条目(marker 行 + 紧随任务行,awk 逐行处理)后走新增流程 | **存量 v3 服务器重跑部署即自动完成 v3→v4 升级**,消除"marker 已存在被跳过、crontab 仍指 v3"的升级盲区 |
| 3 | `cleanup_crontab_jobs` 移除加固 | 原逻辑 `grep -vF marker | grep -vF 新脚本路径`:存量条目是旧脚本路径时匹配不到,marker 行被删而旧任务行残留成无标记孤儿条目(仍会在 04:00 执行 v3,重启风暴隐患复活)。改为与变更 2 相同的 awk 精准删除(marker 行 + 紧随任务行) | 防孤儿条目;旧条目无论指向哪个版本都能被完整移除 |
> 变更 2/3 对其余 9 个任务同为增强:条目一致 → 跳过(行为不变);条目过期 → 升级替换(新能力,向后兼容)。`bash -n` 语法校验通过。
---
## 4. 任务分解与实施清单
### 4.1 服务器端(2026-09-08 14:00–14:15,已完成,摘自 PRD 第 6 节)
- [x] 步骤1: 备份 v3 脚本、crontab、ujava2-startup.sh(三份 `.bak.20260908`
- [x] 步骤2: 阶段0 止血——truncate extapi 持有的 229MB deleted 文件(fd 1/2),空间释放、进程零中断
- [x] 步骤3: 上传 v4 → `chmod +x``bash -n` 语法检查
- [x] 步骤4: `DRY_RUN=1` 灰度验证
- [x] 步骤5: 实跑 v4(当前文件 0MB 不足阈值,`杀进程=0 重启容器=[无]`
- [x] 步骤6: crontab 切换——备份 → 临时文件 sed → 装载 → 逐行核对(仅第 13 行 `0 4 v3``20 4 v4`
- [x] 步骤7: 低阈值实测(临时副本阈值降为 100KB,3.5MB 文件被原地清空,`清空=1 杀进程=0`,测试副本已删除)
### 4.2 本地仓库(本次执行)
- [x] 步骤8: v4 脚本同步入库 → `自动化部署脚本/x86架构/新统一平台/定时脚本/auto_clean_deleted_ubains_v4.sh`(v3 原样保留)
- [x] 步骤9: 逐行复核,发现并修正 `get_container_of_pid` 调用时机缺陷(见 3.4)
- [x] 步骤10: `bash -n` 语法校验(SYNTAX_OK)+ 修正版回写 Test 留档目录保持一致
- [x] 步骤11: 新增 `auto_clean_deleted_ubains_v4_logic.md` 逻辑说明文档
- [x] 步骤12: 更新 `README_定时脚本总览.md`(脚本清单、架构图、cron 示例、风险点汇总 4 处)
- [x] 步骤13: 调整部署入口 `auto_crontab_settings.sh`(3 处变更,见 3.5)——任务定义 v4 + `_add_single_cron_job` 自动升级 + `cleanup_crontab_jobs` 移除加固,`bash -n` 通过
- [x] 步骤14: 输出本计划执行文档
---
## 5. 验收标准与验证结果
### 5.1 服务器端验证结果(2026-09-08,摘自 PRD 第 7 节)
| # | 验证项 | 方法 | 结果 |
|---|--------|------|------|
| 1 | v4 语法正确 | `bash -n` | ✅ 无报错 |
| 2 | DRY_RUN 识别正确 | `DRY_RUN=1` 手动跑 | ✅ 正确列出进程/大小并标注将执行动作 |
| 3 | truncate 真实生效 | 阶段0 手动清空 229MB;低阈值实测清空 3.5MB | ✅ 空间释放、文件仍在、进程存活 |
| 4 | 无容器重启 | 实跑后 `docker ps` | ✅ ujava2 保持 Up 10 hours 未被重置 |
| 5 | cron 已切换 | `crontab -l` 核对 | ✅ 仅第 13 行变更,其余条目完整 |
| 6 | 监控脚本兼容 | 检查其锁机制 | ✅ ujava2-startup.sh 已自带 flock,无冲突 |
| 7 | 仓库 v4 修正版语法 | `bash -n`(本地 Git Bash) | ✅ SYNTAX_OK |
| 8 | auto_crontab_settings.sh 语法 | `bash -n`(本地 Git Bash) | ✅ SYNTAX_OK |
| 9 | 升级逻辑行为 | 代码走查(3.5 变更 2/3) | ✅ 条目一致跳过 / 条目过期移除重加 / 孤儿条目可完整清除 |
### 5.2 次日人工核对项(⏳ 待执行于 2026-09-09 早上)
```bash
# 1. v4 首次定时执行结果(应显示 truncate 或"未发现",无容器重启)
tail -30 /var/log/scripts/auto_clean_deleted_ubains.log
# 2. ujava2 容器未被重启(Up 时间应 > 1 天)
docker ps | grep ujava2
# 3. meeting2.0 日志 03:50–04:30 无中断
tail -100 /var/www/java/api/java-meeting/java-meeting2.0/logs/ubains-INFO-AND-ERROR.log
```
---
## 6. 风险评估与回滚方案
### 6.1 风险评估
| 风险项 | 评估 |
|--------|------|
| v4 相比 v3 | **纯降风险改造**——v3 能做的最激烈动作(杀进程/重启容器),v4 仅在 truncate 失败时才做,且重启范围从"整个容器"收窄到"实际所属容器" |
| truncate 副作用 | 若应用非 `>>` 追加写入,truncate 后可能产生稀疏文件;不影响空间释放目标,治本方案见 7.2 |
| 当前遗留 | 服务器端 v4 尚未合入 3.4 修正(兜底分支容器识别失效),触发概率极低(需 truncate 失败),已列 7.1 待办 |
### 6.2 回滚方案
```bash
# 1. 恢复 v3 脚本调用与原 cron
crontab /root/crontab.bak.20260908
# 2. 手动触发一次 v3 确认可用
/opt/scripts/auto_clean_deleted_ubains_v3.sh
```
> 仅在 5.2 次日核对失败时考虑回滚;回滚反而会恢复"每日重启风暴"。
---
## 7. 后续工作
### 7.1 待办:服务器端同步 3.4 修正(优先级中)
```bash
# 服务器 nat.ubainsyun.com:18126,修正 /opt/scripts/auto_clean_deleted_ubains_v4.sh:
# 将 container=$(get_container_of_pid "$pid") 从杀进程之后上移至循环内 proc_cwd 读取之后
# 修正前先备份:cp /opt/scripts/auto_clean_deleted_ubains_v4.sh{,.bak.$(date +%Y%m%d)}
# 修正后:bash -n 校验 + DRY_RUN=1 试跑
```
**部署包侧(deploy-package 类服务器)**:本次 auto_crontab_settings.sh 的 3 处变更(见 3.5)随主部署脚本分发。存量服务器只需**重新执行任一主部署脚本**(new_auto / new_auto_meeting / new_auto_monitor / new_auto_voice),`_add_single_cron_job` 的升级逻辑即会自动将 v3 条目(`0 4 * * * v3`)替换为 v4 条目(`20 4 * * * v4`),无需手工清 crontab;历史遗留的 v3 孤儿条目(若有)会被 `cleanup_crontab_jobs` 顺手清掉。
### 7.2 治本建议(PRD 第 9 节,本次未实施)
v4 解决了"超阈值后如何无害处置",但 deleted 文件仍在每天产生(源头未除):
1. **排查删除者**(只读命令):
```bash
grep -rn "ubains-INFO-AND-ERROR" /etc/cron* /var/spool/cron/ /opt/scripts /data/services/scripts
ls -l /proc/$(pgrep -f 'ubains-meeting-api-1.0-SNAPSHOT.jar' | head -1)/fd | grep deleted
```
2. **对占用中的文件改删为清空**:日志清理脚本中对仍被进程持有的文件用 `fuser -s "$f" && : > "$f" || rm -f "$f"`
3. **run.sh 统一 `>>` 追加重定向**(O_APPEND),使 truncate 无稀疏文件副作用;
4. 治本完成后 deleted 文件不再触及 100MB 阈值,v4 的杀进程/重启逻辑进入长期休眠(保留作最后兜底)。
### 7.3 经验教训(摘自 PRD 第 6 节实施事故)
修改 crontab 必须走"**备份 → 临时文件 sed → 装载 → 逐行核对**"流程,严禁 `sed -i <(crontab -l) | crontab -` 管道直灌(本次曾致 root crontab 短暂清空约 1 分钟,幸有 3 分钟前备份完整恢复)。
---
## 8. 优化功能回填
| 优化项 | 状态 | 说明 |
|--------|------|------|
| truncate 原地清空替代杀进程 | 已实施 | 服务零中断,杀进程仅作兜底 |
| 容器归属精准判断(cgroup) | 已实施 | 只重启进程实际所属容器 |
| `get_container_of_pid` 调用时机修正 | 已实施(仓库) | 杀进程前读取 cgroup;服务器端待同步(7.1) |
| flock 互斥锁 | 已实施 | 消除与 ujava2-startup.sh 并发竞争 |
| DRY_RUN 灰度开关 | 已实施 | 变更配置后可先试跑核对 |
| crontab 错峰 04:00 → 04:20 | 已实施 | 避开整点轮询窗口 |
| 部署入口 auto_crontab_settings.sh 调整 | 已实施(仓库) | 任务定义 v4 + 条目自动升级 + 孤儿条目加固(见 3.5) |
| v4 逻辑文档 + README 总览同步 | 已实施(仓库) | `auto_clean_deleted_ubains_v4_logic.md` 等 4 处 |
| 治本:排查 deleted 文件删除者 | 待实施 | 见 7.2,属源头治理 |
| 次日首夜核对 | 待实施 | 2026-09-09 早上,见 5.2 |
---
## 附录:涉及变更清单(一句话版)
> **服务器**:新增 1 个脚本(v4)+ crontab 改 1 行(`0 4 v3` → `20 4 v4`),v3 原样保留仅备份,ujava2-startup.sh 零改动,另有三份 `.bak.20260908` 可随时回滚。
> **本地仓库**:新增 `auto_clean_deleted_ubains_v4.sh`(含 3.4 缺陷修正)与 `auto_clean_deleted_ubains_v4_logic.md`,更新 `README_定时脚本总览.md`(4 处)与部署入口 `auto_crontab_settings.sh`(3 处:任务定义 v4 + 条目自动升级 + 孤儿条目加固,见 3.5),v3 及其 logic 文档原样保留。
*文档生成时间: 2026-09-08*
......@@ -57,8 +57,8 @@ TASK_LOG[mysql_logs_backup]=""
TASK_STATUS[mysql_logs_backup]=true # 默认启用
# auto_clean 任务定义
TASK_CRON[auto_clean]="0 4 * * *"
TASK_SCRIPT[auto_clean]="/data/services/scripts/auto_clean_deleted_ubains_v3.sh"
TASK_CRON[auto_clean]="20 4 * * *"
TASK_SCRIPT[auto_clean]="/data/services/scripts/auto_clean_deleted_ubains_v4.sh"
TASK_LOG[auto_clean]=""
TASK_STATUS[auto_clean]=true # 默认启用
......@@ -300,8 +300,9 @@ function cleanup_crontab_jobs() {
# 检查脚本是否存在
if [[ ! -f "$script_path" ]]; then
log "WARN" "🧹 脚本不存在,移除定时任务: $task_name ($script_path)"
# 移除该任务(包括标记行和任务行)
grep -vF "$marker" "$crontab_file" | grep -vF "${TASK_SCRIPT[$task_name]}" > "$temp_file"
# 精准删除该 marker 行及其后紧跟的任务行(勿用 grep -v 全量过滤,
# 否则旧脚本路径的任务行(如 v3 残留)会变成无标记孤儿条目继续执行)
awk -v m="$marker" 'BEGIN{skip=0} { if (skip==1) {skip=0; next} if ($0==m) {skip=1; next} print }' "$crontab_file" > "$temp_file"
mv "$temp_file" "$crontab_file"
cleaned=true
fi
......@@ -426,10 +427,20 @@ function _add_single_cron_job() {
# 6. 检查是否已存在该任务(通过标记)
if grep -Fq "$marker" "$crontab_file"; then
# 读取 marker 后紧跟的任务条目行,判断是否与期望配置一致
local existing_job
existing_job=$(grep -A1 -F "$marker" "$crontab_file" | tail -1)
if [[ "$existing_job" == *"$script_path"* ]]; then
log "INFO" "✅ 定时任务已存在,跳过添加: $task_name"
rm -f "$crontab_file"
return 0
fi
# 条目已过期(如脚本升级 v3→v4、cron 时间变更),移除旧条目后重新添加
log "INFO" "🔄 任务条目已过期(期望: $script_path),自动升级: $task_name"
# 精准删除该 marker 行及其后紧跟的任务行(勿用 grep -v 全量过滤,避免误删)
awk -v m="$marker" 'BEGIN{skip=0} { if (skip==1) {skip=0; next} if ($0==m) {skip=1; next} print }' "$crontab_file" > "$crontab_file.new"
mv "$crontab_file.new" "$crontab_file"
fi
# 7. 生成任务条目
if [[ -n "$log_path" ]]; then
......
......@@ -33,7 +33,8 @@
| 脚本 | 清理对象 | 频率 | 逻辑文档 |
|-----|--------|------|---------|
| [auto_clean_deleted_ubains_v3.sh](auto_clean_deleted_ubains_v3.sh) | 已删除大文件(>1GB) | 每天4:00 | [auto_clean_deleted_ubains_v3_logic.md](auto_clean_deleted_ubains_v3_logic.md) |
| [auto_clean_deleted_ubains_v4.sh](auto_clean_deleted_ubains_v4.sh)(当前) | 已删除大文件(>100MB) | 每天4:20 | [auto_clean_deleted_ubains_v4_logic.md](auto_clean_deleted_ubains_v4_logic.md) |
| [auto_clean_deleted_ubains_v3.sh](auto_clean_deleted_ubains_v3.sh)(已停用,保留作回滚备份) | 已删除大文件(>1GB) | 每天4:00 | [auto_clean_deleted_ubains_v3_logic.md](auto_clean_deleted_ubains_v3_logic.md) |
---
......@@ -55,8 +56,9 @@
│ MySQL日志 ─→ gzip 压缩(保留30天) │
│ Nginx日志 ─→ gzip 压缩 + 清空原文件 + reopen(保留30天) │
│ │
│ 【磁盘清理】每天4:00 │
│ deleted大文件 ─→ kill进程 → 重启容器 → 释放磁盘 │
│ 【磁盘清理】每天4:20 │
│ deleted大文件 ─→ truncate原地清空(零中断) │
│ (V3旧方案 kill进程+重启容器 已停用,仅作兜底) │
│ │
└─────────────────────────────────────────────────────────────┘
```
......@@ -78,7 +80,7 @@
0 3 * * * /data/services/scripts/backup_nginx_logs.sh
# ============ 磁盘清理(凌晨)============
0 4 * * * /data/services/scripts/auto_clean_deleted_ubains_v3.sh
20 4 * * * /data/services/scripts/auto_clean_deleted_ubains_v4.sh
```
---
......@@ -160,7 +162,7 @@ check() 成功:
|-----|---------|------|
| ⚠️ 密码硬编码 | Redis/MySQL备份/Java(配置) | 改用环境变量或密钥管理 |
| ⚠️ 数据清理可能丢数据 | Redis(L3清理) | 仅作为最后兜底 |
| ⚠️ 无并发锁 | 备份脚本/clean脚本 | 增加 flock 或 PID 锁 |
| ⚠️ 无并发锁 | 备份脚本 | 增加 flock 或 PID 锁(clean 脚本 V4 已具备 flock) |
| ⚠️ Nacos原版被覆盖 | nacos-service.sh | 增强版更全面,可接受 |
| ⚠️ 无连续失败计数 | Nacos/Java | 借鉴 EMQX 的防抖机制 |
......
#!/bin/bash
#===============================================================================
# 脚本名称:auto_clean_deleted_ubains_v4.sh
# 功能描述:已删除大文件自动清理脚本(truncate 优先 · 精准处置版)
# 版本:V4.0
#
# 与 V3 的区别:
# 1. 超阈值文件优先 truncate 原地清空(不杀进程、不重启容器、服务零中断)
# 2. 仅 truncate 失败时才杀进程,且只重启进程真正所属的容器
# 3. 修正 APP_PATH 为 extapi 实际路径
# 4. 增加 flock 互斥锁,避免与 ujava2-startup.sh 并发竞争
# 5. 支持 DRY_RUN=1 灰度模式(只记录动作,不实际执行)
#
# 定时任务示例(建议 04:20,错开 ujava2-startup.sh 的整点轮询):
# 20 4 * * * /opt/scripts/auto_clean_deleted_ubains_v4.sh
#===============================================================================
# ================= 配置区域 =================
TARGET_KEY="ubains-INFO-AND-ERROR"
MIN_SIZE=$((100*1024*1024)) # 100MB 阈值(字节)
LOG_FILE="/var/log/scripts/auto_clean_deleted_ubains.log"
MAX_LOG_SIZE=$((5*1024*1024))
LOG_RETENTION_DAYS=7
APP_PATH="/data/services/api/java-meeting/java-meeting-extapi" # 修正:extapi 实际路径
APP_START_SCRIPT="${APP_PATH}/run.sh"
DRY_RUN=${DRY_RUN:-0} # DRY_RUN=1 时只记录不执行
LOCK_FILE="/var/run/auto_clean_deleted_ubains.lock"
# ===========================================
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
rotate_logs() {
log_dir=$(dirname "$LOG_FILE")
[ ! -d "$log_dir" ] && mkdir -p "$log_dir"
if [ -f "$LOG_FILE" ]; then
FILE_SIZE=$(stat -c%s "$LOG_FILE")
if [ "$FILE_SIZE" -ge "$MAX_LOG_SIZE" ]; then
mv "$LOG_FILE" "$LOG_FILE.$(date '+%Y%m%d%H%M%S')"
touch "$LOG_FILE"
fi
fi
find "$log_dir" -name "auto_clean_deleted_ubains.log.*" -mtime +$LOG_RETENTION_DAYS -exec rm -f {} \; 2>/dev/null
}
# ---- 互斥锁:同一时刻只允许一个实例 ----
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
log "已有实例正在运行(锁:$LOCK_FILE),本次退出。"
exit 0
fi
# ---- 判断进程所属容器:输出容器名,宿主机进程输出空 ----
get_container_of_pid() {
local pid=$1 cid
cid=$(grep -oE '/docker/[0-9a-f]{64}' "/proc/$pid/cgroup" 2>/dev/null | head -1 | awk -F'/docker/' '{print $2}')
[ -z "$cid" ] && return 0
docker ps --format '{{.ID}} {{.Names}}' 2>/dev/null \
| awk -v c="${cid:0:12}" '$1 ~ "^"c {print $2; exit}'
}
rotate_logs
log "==============================================="
log "开始扫描 deleted 大文件 (V4: truncate 优先) DRY_RUN=$DRY_RUN"
log "==============================================="
FOUND=0
TRUNCATED=0
KILLED=0
RESTARTED_CONTAINERS=""
for fd_path in /proc/[0-9]*/fd/*; do
[ -L "$fd_path" ] || continue
target_file=$(readlink "$fd_path" 2>/dev/null)
[[ "$target_file" == *"(deleted)"* ]] && [[ "$target_file" == *"$TARGET_KEY"* ]] || continue
FOUND=1
pid=$(echo "$fd_path" | cut -d'/' -f3)
fd=$(echo "$fd_path" | cut -d'/' -f5)
[ -d "/proc/$pid" ] || continue
proc_name="unknown"
[ -f "/proc/$pid/comm" ] && proc_name=$(cat "/proc/$pid/comm")
size_bytes=$(stat -L -c %s "$fd_path" 2>/dev/null || echo 0)
size_mb=$((size_bytes / 1024 / 1024))
proc_cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null)
# 归属容器必须在杀进程前读取(进程死后 /proc/<pid>/cgroup 即消失,届时无法判断)
container=$(get_container_of_pid "$pid")
log "-----------------------------------------------"
log "发现: 进程=$proc_name PID=$pid FD=$fd 大小=${size_mb}MB"
log " 文件=$target_file"
[ "$size_bytes" -ge "$MIN_SIZE" ] || { log "⏩ 不足 100MB,跳过"; continue; }
# ---------- 策略 1(首选):truncate 原地清空,进程无感知 ----------
if [ "$DRY_RUN" -eq 1 ]; then
log "[DRY_RUN] 将执行: : > $fd_path"
TRUNCATED=$((TRUNCATED+1))
continue
fi
if : > "$fd_path" 2>/dev/null; then
log "✔ 已原地清空(truncate),空间已释放,进程无需重启"
TRUNCATED=$((TRUNCATED+1))
continue
fi
log "⚠ truncate 失败,降级为杀进程"
# ---------- 策略 2(兜底):杀进程 + 精准重启归属容器 ----------
if [ "$DRY_RUN" -eq 1 ]; then
log "[DRY_RUN] 将杀进程 PID=$pid 并按归属重启"
continue
fi
kill -15 "$pid" 2>/dev/null
sleep 2
[ -d "/proc/$pid" ] && { kill -9 "$pid" 2>/dev/null; sleep 1; }
log "✔ 进程 $pid 已终止"
KILLED=$((KILLED+1))
if [ -n "$container" ]; then
log "➡ 进程属于容器 $container,仅重启该容器"
docker restart "$container" >> "$LOG_FILE" 2>&1 \
&& log "✔ 容器 $container 重启完成" \
|| log "❌ 容器 $container 重启失败"
RESTARTED_CONTAINERS="$RESTARTED_CONTAINERS $container"
elif [ "$proc_cwd" == "$APP_PATH" ]; then
log "➡ 进程属于宿主机应用 $APP_PATH,执行其启动脚本"
[ -f "$APP_START_SCRIPT" ] && bash "$APP_START_SCRIPT" >> "$LOG_FILE" 2>&1 \
&& log "✔ 应用已拉起" \
|| log "❌ 应用拉起失败,请检查 $APP_START_SCRIPT"
else
log "⚠ 进程为宿主机进程(cwd=$proc_cwd)且不在 APP_PATH 名单,仅记录不重启"
fi
done
[ "$FOUND" -eq 0 ] && log "未发现匹配的 deleted 文件。"
log "🎉 执行结束: 发现=$FOUND 清空=$TRUNCATED 杀进程=$KILLED 重启容器=[${RESTARTED_CONTAINERS:-}]"
log "==============================================="
# auto_clean_deleted_ubains_v4.sh 代码逻辑说明
## 一、脚本概述
| 项目 | 说明 |
|-----|------|
| 脚本名称 | auto_clean_deleted_ubains_v4.sh |
| 版本 | V4.0 |
| 功能 | 已删除大文件自动清理(truncate 优先 · 精准处置版) |
| 目标 | 释放 deleted 大文件占用的磁盘空间,**进程无感知、服务零中断** |
| 建议执行频率 | 每天凌晨4点20分(cron: `20 4 * * *`,错开 ujava2-startup.sh 整点轮询) |
| 前序版本 | [auto_clean_deleted_ubains_v3.sh](auto_clean_deleted_ubains_v3.sh)(V3.2,已保留作回滚备份) |
---
## 二、整体流程
```
flock 互斥锁(/var/run/auto_clean_deleted_ubains.lock,抢不到直接退出)
rotate_logs() 日志轮转(5MB轮转、保留7天)
扫描 /proc/[0-9]*/fd/*
├── 找符号链接 (deleted) + 关键字 "ubains-INFO-AND-ERROR"
├── < 100MB → 跳过
└── ≥ 100MB → 三级递进处置:
├── 策略1(首选):truncate 原地清空 /proc/<pid>/fd/<n>
│ └── 成功 → 结束(进程存活、零中断)
└── 策略2(truncate 失败兜底):杀进程(先 -15 后 -9)
├── 归属容器(/proc/<pid>/cgroup)→ 只 docker restart 该容器
├── 宿主机应用且 cwd == APP_PATH → bash run.sh 拉起
└── 其他宿主机进程 → 仅记录,不动
输出统计:发现=N 清空=N 杀进程=N 重启容器=[...]
```
---
## 三、核心逻辑
### 3.1 与 V3 的本质区别
> **释放 deleted 文件的空间 ≠ 杀死持有它的进程。**
`/proc/<pid>/fd/<n>` 执行 `: > 文件`(truncate),内核直接释放该 inode 的数据块,
进程完全无感知。V3 的"杀进程 + 重启整个容器"仅作为 truncate 失败时的最后兜底。
### 3.2 V3 → V4 变更对照
| 变更点 | V3(旧) | V4(新) |
|--------|----------|----------|
| 空间释放方式 | `kill -9` 杀进程 | **truncate 原地清空**,杀进程仅作兜底 |
| 容器重启范围 | 写死 `docker restart ujava2` | 按 `/proc/<pid>/cgroup` 判断,**只重启进程实际所属容器** |
| 阈值 | 1GB | 100MB(更早介入,减少堆积) |
| APP_PATH | 服务器旧版路径错误 | `/data/services/api/java-meeting/java-meeting-extapi`(extapi 实际路径) |
| 并发控制 | 无锁 | flock 互斥锁 |
| 灰度能力 | 无 | `DRY_RUN=1` 只记录不动手 |
| 执行统计 | 无 | 日志输出 发现/清空/杀进程/重启容器 计数 |
### 3.3 容器归属判断
```bash
get_container_of_pid() {
# 从 cgroup 提取容器完整 ID(兼容 cgroup v1/v2 的 /docker/<64位hex> 格式)
cid=$(grep -oE '/docker/[0-9a-f]{64}' "/proc/$pid/cgroup" | head -1)
# 用前 12 位短 ID 匹配 docker ps 输出,得到容器名
docker ps --format '{{.ID}} {{.Names}}' | awk -v c="${cid:0:12}" '$1 ~ "^"c {print $2}'
}
```
> ⚠️ **必须在杀进程之前调用**:进程死后 `/proc/<pid>/cgroup` 即消失,事后读取必然失败。
---
## 四、配置参数
| 参数 | 值 | 说明 |
|-----|---|------|
| TARGET_KEY | ubains-INFO-AND-ERROR | 匹配日志文件名关键字 |
| MIN_SIZE | 100MB | 最小触发清理的文件大小 |
| LOG_FILE | /var/log/scripts/auto_clean_deleted_ubains.log | 日志路径 |
| APP_PATH | /data/services/api/java-meeting/java-meeting-extapi | extapi 宿主机应用路径 |
| MAX_LOG_SIZE | 5MB | 日志轮转阈值 |
| DRY_RUN | 0(默认) | 置 1 时只记录将执行的动作,不做任何变更 |
| LOCK_FILE | /var/run/auto_clean_deleted_ubains.lock | flock 互斥锁文件 |
---
## 五、注意事项
1. **truncate 是首选路径**——正常情况下脚本永不杀进程、永不重启容器;
2. **兜底分支的容器归属判断必须在 kill 前完成**(见 3.3 警告);
3. **与 ujava2-startup.sh 的关系**——两者使用不同锁文件(各自单实例),
靠 cron 错峰(04:20 vs 整点对齐的每3分钟轮询)让行,而非互相阻塞;
4. **DRY_RUN 灰度**——修改阈值/关键字等配置后,建议先 `DRY_RUN=1` 跑一轮核对识别结果;
5. **治本方向**——deleted 文件的源头(删除者)未除,详见修复方案文档第 9 节后续建议。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论