Skip to content

docker部署版本在手动备份后的恢复功能会误删其他nginx容器的配置文件并且报错device or resource busy #1419

@sevenand888

Description

@sevenand888

Issue报告:Nginx UI备份/恢复功能在Docker环境下存在设计缺陷

Issue标题

[Bug] Backup/Restore功能在Docker环境中失败:设备繁忙错误 (device or resource busy)

问题描述

在使用Docker部署的Nginx UI中,备份功能可以正常工作,但恢复功能总是失败。错误信息显示Nginx UI试图清理Docker挂载的目录,导致"device or resource busy"错误。

环境信息

  • Nginx UI版本: latest (uozi/nginx-ui:latest)
  • 部署方式: Docker Compose
  • 操作系统: Anolis OS 8.10
  • Docker版本: 最新稳定版

复现步骤

  1. 使用Docker Compose部署Nginx UI,挂载多个Nginx配置目录:
volumes:
  - /host/path/nginx_v1/conf:/etc/nginx/ngx_v1
  - /host/path/nginx_v2/conf:/etc/nginx/ngx_v2
  - /host/path/nginx_v3/nginx:/etc/nginx/ngx_v3
  1. 在Nginx UI界面创建备份(成功)
  2. 尝试恢复刚才创建的备份(失败)

错误日志

2025-11-03 10:14:03.072 ERROR backup/restore.go:106 Failed to restore Nginx configs: Failed to copy Nginx config directory: failed to clean directory: unlinkat /etc/nginx/ngx_v1: device or resource busy
2025-11-03 10:14:04.159 ERROR backup/restore.go:106 Failed to restore Nginx configs: Failed to copy Nginx config directory: failed to clean directory: unlinkat /etc/nginx/ngx_v1: device or resource busy

问题分析

根本原因

恢复功能的清理逻辑存在设计缺陷:

  1. 备份阶段:正确读取了配置路径
  2. 恢复阶段:硬编码清理整个/etc/nginx目录,没有考虑Docker卷挂载的特殊性
  3. 错误处理:对"设备繁忙"错误没有适当的跳过机制

具体问题

  • backup/restore.go中的清理函数试图删除/etc/nginx下的所有内容
  • 对于Docker挂载的目录(如ngx_v1, ngx_v2, ngx_v3),无法直接删除
  • 清理失败导致整个恢复过程中止

期望行为
完整恢复流程:清理应该成功完成,然后正常恢复备份内容到所有目录(包括挂载目录)
正确处理挂载点:对于Docker挂载的目录,应该能够正常清理和恢复,而不是跳过或报错
数据一致性:恢复后,所有配置文件(包括挂载目录中的)都应该与备份内容保持一致
建议的修复方案
方案1:改进清理逻辑,正确处理挂载点

// 在backup/restore.go中改进清理逻辑
func cleanNginxConfigDir() error {
    entries, err := os.ReadDir("/etc/nginx")
    if err != nil {
        return err
    }
    
    for _, entry := range entries {
        path := filepath.Join("/etc/nginx", entry.Name())
        
        // 对于挂载目录,清空内容而不是删除目录本身
        if isMountedDirectory(path) {
            // 清空挂载目录内容,保留目录结构
            if err := clearDirectoryContents(path); err != nil {
                return fmt.Errorf("failed to clear mounted directory %s: %v", path, err)
            }
        } else {
            // 对于非挂载目录,正常删除
            if err := os.RemoveAll(path); err != nil {
                return fmt.Errorf("failed to remove %s: %v", path, err)
            }
        }
    }
    return nil
}

// 检查是否为挂载目录
func isMountedDirectory(path string) bool {
    // 方法1:检查inode设备号
    var stat syscall.Stat_t
    if err := syscall.Stat(path, &stat); err != nil {
        return false
    }
    
    // 方法2:检查/proc/mounts
    data, err := os.ReadFile("/proc/mounts")
    if err != nil {
        return false
    }
    
    return strings.Contains(string(data), path)
}

// 清空目录内容,保留目录结构
func clearDirectoryContents(dirPath string) error {
    entries, err := os.ReadDir(dirPath)
    if err != nil {
        return err
    }
    
    for _, entry := range entries {
        path := filepath.Join(dirPath, entry.Name())
        if err := os.RemoveAll(path); err != nil {
            // 如果是设备繁忙错误,可能是嵌套挂载,记录警告但继续
            if strings.Contains(err.Error(), "device or resource busy") {
                log.Warnf("Skipping busy path in mounted directory: %s", path)
                continue
            }
            return err
        }
    }
    return nil
}

方案2:使用rsync式恢复,避免完全清理

func restoreNginxConfigs(backupPath string) error {
    // 使用rsync方式恢复,只更新变化的文件
    cmd := exec.Command("rsync", "-av", "--delete", 
        filepath.Join(backupPath, "nginx/"), "/etc/nginx/")
    
    output, err := cmd.CombinedOutput()
    if err != nil {
        return fmt.Errorf("rsync restore failed: %v, output: %s", err, string(output))
    }
    
    return nil
}

方案3:分阶段恢复,更好的错误处理

func restoreNginxConfigs(backupPath string) error {
    // 阶段1:备份当前配置(安全措施)
    if err := backupCurrentConfig(); err != nil {
        return fmt.Errorf("failed to backup current config: %v", err)
    }
    
    // 阶段2:尝试智能清理
    if err := smartCleanNginxDir(); err != nil {
        log.Warnf("Partial clean failure: %v, attempting selective restore", err)
        // 即使清理部分失败,也尝试恢复
    }
    
    // 阶段3:恢复备份内容
    if err := copyBackupContents(backupPath); err != nil {
        // 阶段4:恢复失败时回滚
        if rollbackErr := rollbackFromBackup(); rollbackErr != nil {
            return fmt.Errorf("restore failed and rollback also failed: restore=%v, rollback=%v", err, rollbackErr)
        }
        return fmt.Errorf("restore failed, rolled back: %v", err)
    }
    
    return nil
}

附加信息
核心问题:恢复功能应该在清理挂载目录时清空内容而不是删除目录本身
数据安全:恢复失败时不应该让系统处于中间状态(部分清理但未恢复)
用户体验:用户期望备份/恢复是原子操作,要么完全成功要么完全失败
建议优先级
高 - 这个bug导致恢复功能在Docker环境中完全不可用,且存在数据丢失风险。

希望开发团队能够修复这个设计缺陷,让恢复功能能够正确处理Docker挂载目录,实现完整的配置恢复。

相关文件
受影响的源码文件:backup/restore.go中的清理和恢复逻辑
需要改进的错误处理机制和挂载点检测
备注:这个问题的核心是恢复流程需要区分"删除目录"和"清空目录内容"的不同场景,特别是在Docker挂载环境下

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions