diff --git a/README.disk.md b/README.disk.md index 26e1236..8658016 100644 --- a/README.disk.md +++ b/README.disk.md @@ -2,16 +2,26 @@ > 返回 [README.md](README.md) -看哪个目录、哪个文件最占空间。由 kopsc 内置实现,Linux、macOS、Windows 输出完全一致, -不依赖 `du`、`sort` 或 PowerShell。 +看哪个目录、哪个文件最占空间,查看 inode 使用,一键磁盘健康检查。 ```bash kopsc disk usage # 当前目录的占用排行(路径可省略) kopsc disk usage /var # 指定目录 kopsc disk usage /var --depth 2 # 下钻一层 -kopsc disk usage --path /var # 用命名参数指定路径 +kopsc disk inode # 查看 inode 使用情况 +kopsc disk check # 一键磁盘健康检查 +kopsc disk check /var # 检查指定路径 +kopsc disk check --all # 检查所有挂载点 ``` +## 命令一览 + +| 命令 | 作用 | +| --- | --- | +| `kopsc disk usage [路径]` | 查看目录占用排行 | +| `kopsc disk inode [路径]` | 查看 inode 使用情况 | +| `kopsc disk check [路径]` | 一键磁盘健康检查 | + ## 磁盘占用排行(disk.usage) `kopsc disk usage [路径]` 等价于 `du -x --max-depth=1 -h <路径> | sort -hr`: @@ -35,7 +45,7 @@ $ kopsc disk usage /var - 输出按大小降序,大小采用与 `du -h` 相同的二进制单位(1.0K、1.5M、2.0G)。 - 末尾输出该路径的合计行。 -## 选项 +### 选项 | 选项 | 说明 | | --- | --- | @@ -43,3 +53,77 @@ $ kopsc disk usage /var | `--depth N` / `-d N` / `--depth=N` | 列到的层数,默认 1;必须大于 0 | > `--path`、`--depth` 是 kopsc 自己的选项,不是 `du` 的参数,因此可以写在任意位置。 + +## inode 使用查看(disk.inode) + +```bash +kopsc disk inode # 当前文件系统 +kopsc disk inode /var # 指定路径所在文件系统 +``` + +显示文件系统的 inode 使用统计。inode 耗尽会导致无法创建新文件,即使磁盘空间充足。 + +输出示例: + +``` +Filesystem Inodes IUsed IFree IUse% Mounted on +/dev/vda1 3276800 180432 3096368 6% / +``` + +| 平台 | 实际执行 | +| --- | --- | +| Linux / macOS | `df -i` | +| Windows | 不支持(NTFS 使用 MFT,无 inode 概念) | + +## 一键磁盘健康检查(disk.check) + +```bash +kopsc disk check # 检查根目录 +kopsc disk check /var # 检查指定路径 +kopsc disk check --all # 检查所有挂载点 +``` + +一条命令完成磁盘排障,综合检查 5 项内容: + +输出示例: + +``` +=== 磁盘健康检查 === + +[1/5] 空间使用 + /dev/vda1 50G 总计 22G 已用 28G 可用 44% 使用率 ✓ 正常 + +[2/5] inode 使用 + /dev/vda1 3.2M 总计 180K 已用 3.0M 可用 5% 使用率 ✓ 正常 + +[3/5] 大文件 TOP 5(>100M) + 2.1G /var/log/syslog + 1.5G /var/lib/docker/overlay2/... + 800M /opt/app/data.db + +[4/5] 大目录 TOP 5 + 15G /var/lib/docker + 2.3G /var/log + 1.2G /opt/app + +[5/5] 建议 + ✓ 磁盘空间充足 + ✓ inode 使用正常 +``` + +### 检查项目 + +| 项目 | 说明 | +| --- | --- | +| 空间使用 | 磁盘使用率,>80% 警告,>90% 危险 | +| inode 使用 | inode 使用率(Unix only) | +| 大文件 | 列出 >100M 的文件 TOP 5 | +| 大目录 | 列出直接子目录占用 TOP 5 | +| 建议 | 根据检查结果给出清理建议 | + +### 选项 + +| 选项 | 说明 | +| --- | --- | +| `[路径]` | 要检查的路径,默认 `/` | +| `--all` / `-a` | 检查所有挂载点 | diff --git a/internal/executor/diskcheck.go b/internal/executor/diskcheck.go new file mode 100644 index 0000000..ebbc972 --- /dev/null +++ b/internal/executor/diskcheck.go @@ -0,0 +1,423 @@ +package executor + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" +) + +// runDiskCheck implements `kopsc disk check`: comprehensive disk health check. +func runDiskCheck(ctx context.Context, req BuiltinRequest) error { + opts, err := parseDiskCheckArgs(req.Args) + if err != nil { + return err + } + + w := req.Stdout + + fmt.Fprintln(w, "=== 磁盘健康检查 ===") + fmt.Fprintln(w) + + if opts.all { + // Check all mount points + return diskCheckAll(ctx, req) + } + + path := opts.path + if path == "" { + path = "/" + } + + return diskCheckPath(ctx, req, path) +} + +type diskCheckOptions struct { + path string + all bool +} + +func parseDiskCheckArgs(args []string) (diskCheckOptions, error) { + var opts diskCheckOptions + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case arg == "--all" || arg == "-a": + opts.all = true + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + + if len(positional) > 0 { + opts.path = positional[0] + } + return opts, nil +} + +func diskCheckAll(ctx context.Context, req BuiltinRequest) error { + // Get all mount points from df + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(ctx, "wmic", "logicaldisk", "get", "caption,size,freespace", "/format:list") + } else { + cmd = exec.CommandContext(ctx, "df", "-h") + } + + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("获取磁盘信息失败: %w", err) + } + + // Parse df output to get mount points + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 2 { + return fmt.Errorf("无法解析磁盘信息") + } + + // Skip header line + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) < 6 { + continue + } + mountPoint := fields[len(fields)-1] + if runtime.GOOS != "windows" { + fmt.Fprintf(req.Stdout, "\n--- %s ---\n\n", mountPoint) + if err := diskCheckPath(ctx, req, mountPoint); err != nil { + fmt.Fprintf(req.Stderr, "检查 %s 失败: %v\n", mountPoint, err) + } + } + } + return nil +} + +func diskCheckPath(ctx context.Context, req BuiltinRequest, path string) error { + w := req.Stdout + + // 1. Space usage + fmt.Fprintln(w, "[1/5] 空间使用") + diskCheckSpace(ctx, w, path) + fmt.Fprintln(w) + + // 2. Inode usage (Unix only) + if runtime.GOOS != "windows" { + fmt.Fprintln(w, "[2/5] inode 使用") + diskCheckInode(ctx, w, path) + fmt.Fprintln(w) + } else { + fmt.Fprintln(w, "[2/5] inode 使用") + fmt.Fprintln(w, " Windows 不支持 inode") + fmt.Fprintln(w) + } + + // 3. Large files + fmt.Fprintln(w, "[3/5] 大文件 TOP 5(>100M)") + diskCheckLargeFiles(ctx, w, path) + fmt.Fprintln(w) + + // 4. Large directories + fmt.Fprintln(w, "[4/5] 大目录 TOP 5") + diskCheckLargeDirs(ctx, w, path) + fmt.Fprintln(w) + + // 5. Recommendations + fmt.Fprintln(w, "[5/5] 建议") + diskCheckRecommendations(ctx, w, path) + + return nil +} + +func diskCheckSpace(ctx context.Context, w io.Writer, path string) { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(ctx, "wmic", "logicaldisk", "get", "size,freespace,caption", "/format:list") + } else { + cmd = exec.CommandContext(ctx, "df", "-h", path) + } + + out, err := cmd.Output() + if err != nil { + fmt.Fprintln(w, " 无法获取磁盘空间信息") + return + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 2 { + fmt.Fprintln(w, " 无法解析磁盘信息") + return + } + + // Parse and display + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + + if runtime.GOOS == "windows" { + // Parse WMIC output + fmt.Fprintf(w, " %s\n", line) + } else { + // Parse df output: Filesystem Size Used Avail Use% Mounted + filesystem := fields[0] + size := fields[1] + used := fields[2] + avail := fields[3] + usePct := fields[4] + mount := fields[5] + + status := "✓ 正常" + if pct, err := strconv.Atoi(strings.TrimSuffix(usePct, "%")); err == nil { + if pct >= 90 { + status = "✗ 危险!空间即将耗尽" + } else if pct >= 80 { + status = "! 警告:空间使用率较高" + } + } + + fmt.Fprintf(w, " %s %s 总计 %s 已用 %s 可用 %s 使用率 %s\n", + filesystem, size, used, avail, usePct, status) + if mount != path { + fmt.Fprintf(w, " 挂载点: %s\n", mount) + } + } + } +} + +func diskCheckInode(ctx context.Context, w io.Writer, path string) { + cmd := exec.CommandContext(ctx, "df", "-i", path) + out, err := cmd.Output() + if err != nil { + fmt.Fprintln(w, " 无法获取 inode 信息") + return + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) < 2 { + fmt.Fprintln(w, " 无法解析 inode 信息") + return + } + + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + + filesystem := fields[0] + inodes := fields[1] + used := fields[2] + free := fields[3] + usePct := fields[4] + + status := "✓ 正常" + if pct, err := strconv.Atoi(strings.TrimSuffix(usePct, "%")); err == nil { + if pct >= 90 { + status = "✗ 危险!inode 即将耗尽" + } else if pct >= 80 { + status = "! 警告:inode 使用率较高" + } + } + + fmt.Fprintf(w, " %s %s 总计 %s 已用 %s 可用 %s 使用率 %s\n", + filesystem, inodes, used, free, usePct, status) + } +} + +func diskCheckLargeFiles(ctx context.Context, w io.Writer, path string) { + type fileEntry struct { + path string + size int64 + } + + var files []fileEntry + threshold := int64(100 * 1024 * 1024) // 100MB + + err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil // Skip errors + } + if d.IsDir() { + // Skip common non-user directories + base := filepath.Base(p) + if base == ".git" || base == "node_modules" || base == ".cache" { + return filepath.SkipDir + } + return nil + } + if info, err := d.Info(); err == nil && info.Mode().IsRegular() { + if info.Size() >= threshold { + files = append(files, fileEntry{path: p, size: info.Size()}) + } + } + return nil + }) + + if err != nil { + fmt.Fprintf(w, " 扫描失败: %v\n", err) + return + } + + if len(files) == 0 { + fmt.Fprintln(w, " 没有发现大于 100M 的文件 ✓") + return + } + + // Sort by size descending + sort.Slice(files, func(i, j int) bool { + return files[i].size > files[j].size + }) + + // Show top 5 + limit := 5 + if len(files) < limit { + limit = len(files) + } + + for i := 0; i < limit; i++ { + fmt.Fprintf(w, " %s %s\n", humanSize(files[i].size), files[i].path) + } + + if len(files) > 5 { + fmt.Fprintf(w, " ... 还有 %d 个大文件\n", len(files)-5) + } +} + +func diskCheckLargeDirs(ctx context.Context, w io.Writer, path string) { + type dirEntry struct { + path string + size int64 + } + + var dirs []dirEntry + + // Only check direct children + entries, err := os.ReadDir(path) + if err != nil { + fmt.Fprintf(w, " 无法读取目录: %v\n", err) + return + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dirPath := filepath.Join(path, entry.Name()) + size, err := dirSize(ctx, dirPath, 0, false) + if err != nil { + continue + } + if size > 0 { + dirs = append(dirs, dirEntry{path: dirPath, size: size}) + } + } + + if len(dirs) == 0 { + fmt.Fprintln(w, " 没有子目录") + return + } + + // Sort by size descending + sort.Slice(dirs, func(i, j int) bool { + return dirs[i].size > dirs[j].size + }) + + // Show top 5 + limit := 5 + if len(dirs) < limit { + limit = len(dirs) + } + + for i := 0; i < limit; i++ { + fmt.Fprintf(w, " %s %s\n", humanSize(dirs[i].size), dirs[i].path) + } +} + +func diskCheckRecommendations(ctx context.Context, w io.Writer, path string) { + var recommendations []string + + // Check disk usage percentage + cmd := exec.CommandContext(ctx, "df", path) + out, err := cmd.Output() + if err == nil { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) >= 2 { + fields := strings.Fields(lines[1]) + if len(fields) >= 5 { + usePct := fields[4] + if pct, err := strconv.Atoi(strings.TrimSuffix(usePct, "%")); err == nil { + if pct >= 90 { + recommendations = append(recommendations, "✗ 磁盘空间严重不足!建议立即清理或扩容") + } else if pct >= 80 { + recommendations = append(recommendations, "! 磁盘空间使用率较高,建议清理不必要的文件") + } else { + recommendations = append(recommendations, "✓ 磁盘空间充足") + } + } + } + } + } + + // Check inode usage + if runtime.GOOS != "windows" { + cmd = exec.CommandContext(ctx, "df", "-i", path) + out, err = cmd.Output() + if err == nil { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) >= 2 { + fields := strings.Fields(lines[1]) + if len(fields) >= 5 { + usePct := fields[4] + if pct, err := strconv.Atoi(strings.TrimSuffix(usePct, "%")); err == nil { + if pct >= 90 { + recommendations = append(recommendations, "✗ inode 严重不足!大量小文件可能耗尽 inode") + } else if pct >= 80 { + recommendations = append(recommendations, "! inode 使用率较高") + } else { + recommendations = append(recommendations, "✓ inode 使用正常") + } + } + } + } + } + } + + // Check for common cleanup targets + commonTargets := []string{ + "/var/log", + "/tmp", + "/var/cache", + } + + for _, target := range commonTargets { + if info, err := os.Stat(target); err == nil && info.IsDir() { + size, _ := dirSize(ctx, target, 0, false) + if size > 1024*1024*1024 { // > 1GB + recommendations = append(recommendations, fmt.Sprintf("! %s 占用 %s,可考虑清理", target, humanSize(size))) + } + } + } + + if len(recommendations) == 0 { + fmt.Fprintln(w, " ✓ 一切正常") + } else { + for _, rec := range recommendations { + fmt.Fprintf(w, " %s\n", rec) + } + } +} diff --git a/internal/executor/diskinode.go b/internal/executor/diskinode.go new file mode 100644 index 0000000..2a4a6f2 --- /dev/null +++ b/internal/executor/diskinode.go @@ -0,0 +1,66 @@ +package executor + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" +) + +// runDiskInode implements `kopsc disk inode`: show inode usage. +func runDiskInode(ctx context.Context, req BuiltinRequest) error { + opts, err := parseDiskInodeArgs(req.Args) + if err != nil { + return err + } + + path := opts.path + if path == "" { + path = "." + } + + switch runtime.GOOS { + case "linux", "darwin": + return diskInodeUnix(ctx, req, path) + case "windows": + return fmt.Errorf("Windows 不支持 inode(NTFS 使用 MFT,无 inode 概念)") + default: + return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS) + } +} + +type diskInodeOptions struct { + path string +} + +func parseDiskInodeArgs(args []string) (diskInodeOptions, error) { + var opts diskInodeOptions + var positional []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + positional = append(positional, args[i+1:]...) + i = len(args) + case strings.HasPrefix(arg, "-") && arg != "-": + return opts, fmt.Errorf("未知选项 %q", arg) + default: + positional = append(positional, arg) + } + } + + if len(positional) > 0 { + opts.path = positional[0] + } + return opts, nil +} + +func diskInodeUnix(ctx context.Context, req BuiltinRequest, path string) error { + // Use df -i to get inode info + cmd := exec.CommandContext(ctx, "df", "-i", path) + cmd.Stdout = req.Stdout + cmd.Stderr = req.Stderr + return cmd.Run() +}