清水雅然

折腾日记:旧笔记本 + 外接硬盘 + UPS:打造低成本且不怕断电的家用NAS全记录

2026-03-02

低成本家用NAS数据安全方案:基于Systemd Timer实现硬盘智能挂载/卸载

作为一名折腾党,之前提到我用家里闲置的旧工作站笔记本+硬盘盒,搭了一套低成本家用NAS系统,日常用来存照片、备份数据、跑轻量服务,性价比拉满。但前段时间修电路时,意外踩了个坑——硬盘盒是单独插电源供电的,电路断电时硬盘盒直接断电,清晰地听到硬盘“咔”的一声,那种瞬间断电的声音真的太揪心了。

事后查看RAID状态,万幸硬盘暂时没出问题,但这次虚惊一场也让我意识到数据安全的重要性:硬盘最怕的就是突然断电,长期下来轻则损伤磁头,重则导致数据丢失、RAID阵列损坏,最稳妥的解决方案就是给整套设备配个UPS(不间断电源),至少能在市电中断时,争取到足够的时间安全卸载硬盘,避免硬损伤。

IMG_6019.jpeg

考虑到只是家用场景,没必要追求高端款,我下单了一款基础版UPS,满足供电续航即可。但问题也随之而来:这款入门UPS没有USB通信接口,无法直接给NAS(也就是我的笔记本)发送断电通知——如果是带USB接口的UPS,其实可以通过NUT(Network UPS Tools)这套开源工具监控UPS状态,实现断电自动触发安全操作。

这里简单提一句NUT:它是一套开源的UPS监控与管理工具集,核心作用就是实时监测UPS的运行状态(比如市电是否中断、电池剩余电量等),实现多设备联动,在断电时触发安全关机、硬盘卸载等操作,是NAS玩家的常用工具。但我的入门UPS不支持USB通信,没法直接用NUT,只能另寻思路。

既然UPS没法主动通知,那能不能让笔记本“被动感知”断电?我想到了一个关键细节:我的笔记本可以查到市电上,硬盘盒接到UPS上。当市电中断时,笔记本切换到电池供电,此时笔记本会检测到供电方式从“市电”切换为“电池”——只要捕捉到这个状态变化,就能判定市电中断,进而主动卸载硬盘,等到市电恢复、笔记本切换回市电供电时,再自动挂载硬盘。

Pasted image 20260302222933.png

思路确定后,就开始动手落地。最终,我基于笔记本的供电状态检测,用Systemd Timer实现了一套硬盘智能挂载/卸载的方案,全程低成本、无额外硬件,完美解决了入门UPS无法通知的问题。下面就把完整的实现过程分享给大家,适合和我一样用笔记本+硬盘盒搭NAS、又用了基础款UPS的朋友参考。

一、功能背景与核心需求

结合我的家用NAS场景,方案需要满足以下核心需求,兼顾数据安全和使用便捷性:

  • 笔记本切换电池供电时(即市电中断):延迟5分钟自动卸载所有非系统硬盘,避免UPS电池耗尽后硬盘突然断电;

  • 恢复市电供电时:延迟30秒自动挂载硬盘,给UPS和设备一个稳定缓冲时间,避免刚恢复供电就挂载导致的不稳定;

  • 状态检测:每30秒检测一次笔记本供电状态,只有状态发生变化时才触发后续逻辑,减少资源占用;

  • 日志追溯:全流程记录关键操作(初始化、状态变化、延迟检查、挂载/卸载结果),方便后续排查问题;

  • 开机自启:脚本支持初始化自动触发,开机后无需手动操作,全程自动化运行。

二、核心实现思路

方案的核心逻辑是“捕捉供电状态变化→延迟触发操作→全程日志记录”,无需依赖任何第三方工具,仅用Linux系统自带的功能实现,稳定性拉满,具体思路如下:
这里为什么采用定时任务去读取,主要是/sys/class/power_supply/AC/online文件无法获取到内容的变化

  1. 电源状态检测:直接读取Linux内核文件 /sys/class/power_supply/AC/online 判断供电状态——文件内容为“1”表示市电供电,为“0”表示电池供电,无需安装额外依赖,高效且稳定;

  2. 时间戳控延迟:用锁文件存储供电状态变化的时间戳,每次检测时计算当前时间与触发时间的差值,判断是否满足设定的延迟条件(卸载延迟5分钟、挂载延迟30秒),避免瞬时电源波动误触发;

  3. Systemd Timer 触发:通过Systemd Timer设置每30秒执行一次检测脚本,并且能实现开机自启、状态监控;

  4. 全流程日志:脚本内置日志函数,关键节点(初始化、状态变化、延迟检查、挂载/卸载成功/失败)均输出到日志文件,便于后续追溯操作记录、排查问题。

三、完整实现代码(直接复制可用)

以下所有代码适配Ubuntu/Debian系Linux系统(我的笔记本装的是Ubuntu Server),大家根据自己的硬盘设备和挂载点修改配置即可。

1. 核心控制脚本(/usr/local/bin/unmount_hdd_on_battery.sh)

这是整个方案的核心,负责检测电源状态、判断延迟条件、执行硬盘挂载/卸载操作,同时记录日志。

#!/bin/bash

# ===================== 核心配置(请根据自身环境修改) =====================
# 挂载映射(设备 → 挂载点),key是硬盘设备路径,value是对应的挂载点
# 可通过 lsblk 命令查看自己的硬盘设备(如/dev/sdf1、/dev/md1等)
declare -A MOUNT_MAP=(
    ["/dev/sdf1"]="/mnt/ubuntu_data"  # 普通硬盘分区
    ["/dev/sde"]="/files"             # 单独硬盘
    ["/dev/md1"]="/raid1"             # RAID阵列
)
# 延迟时间配置(单位:秒)
DELAY_UNMOUNT=300  # 电池供电后,延迟5分钟(300秒)卸载硬盘
DELAY_MOUNT=30     # 恢复市电后,延迟30秒挂载硬盘
# 日志/锁/缓存文件路径(无需修改,默认即可)
LOG_FILE="/var/log/battery_unmount_hdd.log"
UNMOUNT_LOCK="/tmp/battery_unmount.lock"  # 存储卸载触发时间戳
MOUNT_LOCK="/tmp/battery_mount.lock"      # 存储挂载触发时间戳
STATUS_CACHE="/tmp/power_status.cache"    # 存储上一次电源状态,用于判断状态变化
AC_FILE="/sys/class/power_supply/AC/online"  # 电源状态核心文件
# ====================================================

# 统一日志函数(带时间戳、日志级别,确保子进程也能正常写入日志)
log() {
    local LEVEL=$1
    local MSG=$2
    # 除DEBUG级别外,均写入日志文件(DEBUG仅用于调试,不冗余记录)
    if [[ "$LEVEL" != "DEBUG" ]]; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$LEVEL] $MSG" | sudo tee -a "$LOG_FILE" > /dev/null
    fi
}

# 读取当前电源状态(仅依赖内核文件,无任何外部依赖,稳定性高)
get_power_status() {
    # 检查核心文件是否存在(避免系统差异导致的错误)
    if [ ! -f "$AC_FILE" ]; then
        log "ERROR" "AC状态文件不存在:$AC_FILE,无法检测电源状态"
        echo "unknown"
        return
    fi
    # 读取文件内容,判断供电状态
    local ac_val=$(cat "$AC_FILE" 2>/dev/null)
    [ "$ac_val" = "1" ] && echo "AC" || echo "battery"
}

# 执行硬盘卸载操作(支持多硬盘/RAID,强制卸载避免占用导致失败)
do_unmount() {
    log "INFO" "========== 开始执行硬盘卸载操作 =========="
    # 遍历所有配置的硬盘设备和挂载点
    for dev in "${!MOUNT_MAP[@]}"; do
        mount_point=${MOUNT_MAP[$dev]}
        # 先判断硬盘是否已挂载,避免重复卸载
        if mount | grep -q "^$dev on $mount_point "; then
            # -lf 参数:强制卸载(-f)、不更新/etc/mtab(-l),避免占用导致卸载失败
            sudo umount -lf "$mount_point"
            if [ $? -eq 0 ]; then
                log "SUCCESS" "卸载成功:挂载点=$mount_point(设备=$dev)"
            else
                log "ERROR" "卸载失败:挂载点=$mount_point(设备=$dev),可能被进程占用"
            fi
        else
            log "INFO" "无需卸载:挂载点=$mount_point(设备=$dev)未挂载"
        fi
    done
    # 卸载完成后,删除卸载锁文件(避免重复触发)
    sudo rm -f "$UNMOUNT_LOCK"
    log "INFO" "========== 硬盘卸载操作执行完毕 =========="
}

# 执行硬盘挂载操作(自动创建挂载点,避免目录不存在导致失败)
do_mount() {
    log "INFO" "========== 开始执行硬盘挂载操作 =========="
    # 遍历所有配置的硬盘设备和挂载点
    for dev in "${!MOUNT_MAP[@]}"; do
        mount_point=${MOUNT_MAP[$dev]}
        # 确保挂载点目录存在,不存在则自动创建
        if [ ! -d "$mount_point" ]; then
            sudo mkdir -p "$mount_point"
            log "INFO" "挂载点目录不存在,已自动创建:$mount_point"
        fi
        # 先判断硬盘是否已挂载,避免重复挂载
        if ! mount | grep -q "^$dev on $mount_point "; then
            sudo mount "$dev" "$mount_point"
            if [ $? -eq 0 ]; then
                log "SUCCESS" "挂载成功:挂载点=$mount_point(设备=$dev)"
            else
                log "ERROR" "挂载失败:挂载点=$mount_point(设备=$dev),请检查设备是否正常"
            fi
        else
            log "INFO" "无需挂载:挂载点=$mount_point(设备=$dev)已挂载"
        fi
    done
    # 挂载完成后,删除挂载锁文件(避免重复触发)
    sudo rm -f "$MOUNT_LOCK"
    log "INFO" "========== 硬盘挂载操作执行完毕 =========="
}

# 检查延迟是否满足(参数1:锁文件路径  参数2:所需延迟秒数)
check_delay() {
    local lock_file=$1
    local delay=$2
    # 锁文件不存在,说明未触发过状态变化,不满足延迟条件
    if [ ! -f "$lock_file" ]; then
        log "DEBUG" "锁文件不存在:$lock_file,不满足延迟检查条件"
        return 1
    fi
    # 读取锁文件中的触发时间戳(状态变化时记录的时间)
    local trigger_time=$(cat "$lock_file" 2>/dev/null)
    # 时间戳为空,说明锁文件异常,删除后返回不满足
    if [ -z "$trigger_time" ]; then
        sudo rm -f "$lock_file"
        log "WARNING" "锁文件内容异常,已删除:$lock_file"
        return 1
    fi
    # 计算当前时间与触发时间的差值(已过去的秒数)
    local now=$(date +%s)
    local elapsed=$((now - trigger_time))
    log "DEBUG" "延迟检查:触发时间=$trigger_time,已过去=$elapsed秒,要求延迟=$delay秒"
    # 已过去时间 ≥ 要求延迟,说明满足条件,返回成功
    [ $elapsed -ge $delay ] && return 0 || return 1
}

# 主逻辑(核心执行流程)
main() {
    # 初始化日志文件(确保文件存在且可写)
    sudo touch "$LOG_FILE" && sudo chmod 666 "$LOG_FILE"
    log "DEBUG" "脚本启动,开始执行电源状态检测"

    # 1. 获取当前电源状态
    local current_status=$(get_power_status)
    # 若无法检测到电源状态,直接退出脚本
    if [ "$current_status" = "unknown" ]; then
        log "ERROR" "无法正常检测电源状态,脚本退出"
        exit 1
    fi
    log "DEBUG" "当前电源状态:$current_status"

    # 2. 首次运行初始化(缓存文件不存在时)
    if [ ! -f "$STATUS_CACHE" ]; then
        log "INFO" "首次运行,初始化电源状态缓存"
        # 将当前状态写入缓存文件,用于后续判断状态变化
        echo "$current_status" | sudo tee "$STATUS_CACHE" > /dev/null
        
        # 初始化状态为电池供电:记录卸载触发时间,后续检查延迟
        if [ "$current_status" = "battery" ]; then
            local trigger_time=$(date +%s)
            echo "$trigger_time" | sudo tee "$UNMOUNT_LOCK" > /dev/null
            log "INFO" "初始化状态为电池供电,记录卸载触发时间:$trigger_time(延迟$DELAY_UNMOUNT秒后执行卸载)"
        fi
        
        # 初始化状态为市电供电:记录挂载触发时间,后续检查延迟
        if [ "$current_status" = "AC" ]; then
            local trigger_time=$(date +%s)
            echo "$trigger_time" | sudo tee "$MOUNT_LOCK" > /dev/null
            log "INFO" "初始化状态为市电供电,记录挂载触发时间:$trigger_time(延迟$DELAY_MOUNT秒后执行挂载)"
        fi
        # 首次初始化完成,退出脚本(等待下一次Timer触发)
        exit 0
    fi

    # 3. 读取上一次的电源状态(从缓存文件中读取)
    local last_status=$(cat "$STATUS_CACHE" 2>/dev/null)
    # 缓存文件为空,说明异常,重新初始化后退出
    if [ -z "$last_status" ]; then
        log "WARNING" "电源状态缓存文件为空,重新初始化"
        echo "$current_status" | sudo tee "$STATUS_CACHE" > /dev/null
        exit 0
    fi

    # 4. 电源状态变化处理(仅当当前状态与上一次不同时触发)
    if [ "$current_status" != "$last_status" ]; then
        log "INFO" "电源状态发生变化:$last_status$current_status"
        # 更新缓存文件,记录当前状态
        echo "$current_status" | sudo tee "$STATUS_CACHE" > /dev/null
        
        # 场景1:从市电(AC)切换到电池(battery)→ 触发卸载流程
        if [ "$current_status" = "battery" ]; then
            # 清除挂载锁文件(避免同时触发挂载操作)
            sudo rm -f "$MOUNT_LOCK"
            # 记录卸载触发时间戳
            local trigger_time=$(date +%s)
            echo "$trigger_time" | sudo tee "$UNMOUNT_LOCK" > /dev/null
            log "INFO" "检测到切换为电池供电,记录卸载触发时间:$trigger_time(延迟$DELAY_UNMOUNT秒后执行卸载)"
        fi
        
        # 场景2:从电池(battery)切换到市电(AC)→ 触发挂载流程
        if [ "$current_status" = "AC" ]; then
            # 清除卸载锁文件(避免同时触发卸载操作)
            sudo rm -f "$UNMOUNT_LOCK"
            # 记录挂载触发时间戳
            local trigger_time=$(date +%s)
            echo "$trigger_time" | sudo tee "$MOUNT_LOCK" > /dev/null
            log "INFO" "检测到切换为市电供电,记录挂载触发时间:$trigger_time(延迟$DELAY_MOUNT秒后执行挂载)"
        fi
    fi

    # 5. 检查延迟条件,执行对应操作
    # 检查卸载延迟:若满足延迟条件,执行卸载
    if check_delay "$UNMOUNT_LOCK" "$DELAY_UNMOUNT"; then
        log "INFO" "卸载延迟条件满足(已等待$DELAY_UNMOUNT秒),开始执行卸载操作"
        do_unmount
    fi
    
    # 检查挂载延迟:若满足延迟条件,执行挂载
    if check_delay "$MOUNT_LOCK" "$DELAY_MOUNT"; then
        log "INFO" "挂载延迟条件满足(已等待$DELAY_MOUNT秒),开始执行挂载操作"
        do_mount
    fi

    log "DEBUG" "本次脚本执行完成,静默退出,等待下一次检测"
}

# 启动主逻辑
main

2. Systemd Service 文件(/etc/systemd/system/battery-unmount-hdd.service)

Systemd Service 用于定义脚本的执行方式,指定运行用户、执行路径等,确保脚本能正常运行。

[Unit]
Description=Unmount HDD when on battery power, mount when on AC power
# 确保系统进入多用户模式后再启动(避免启动过早导致设备未识别)
After=multi-user.target

[Service]
# oneshot:一次性执行,执行完就退出(由Timer定时触发)
Type=oneshot
# 脚本执行路径(与上面的核心脚本路径一致)
ExecStart=/usr/local/bin/unmount_hdd_on_battery.sh
# 用root用户运行(挂载/卸载硬盘需要root权限)
User=root
Group=root

[Install]
# 关联到multi-user.target,确保开机后能被正常启动
WantedBy=multi-user.target

3. Systemd Timer 文件(/etc/systemd/system/battery-unmount-hdd.timer)

Systemd Timer 用于定时触发Service,实现每30秒执行一次检测脚本,替代传统crontab,更稳定、更易管理。

[Unit]
Description=Check power status every 30 seconds for HDD mount/unmount
# 描述Timer的作用,便于后续查看状态

[Timer]
# 定时规则:每分钟的0秒、30秒执行一次(即每30秒执行一次)
OnCalendar=*:*:0/30
# Persistent=true:若系统关机期间错过执行时间,开机后立即补执行一次,避免漏检
Persistent=true
# 执行精度:1秒(确保定时执行的准确性)
AccuracySec=1s
# 关闭随机延迟(避免Timer堆积,确保每30秒准时执行)
RandomizedDelaySec=0

[Install]
# 关联到timers.target,确保Timer能被正常启用和管理
WantedBy=timers.target

四、部署步骤

1. 写入脚本并赋予执行权限

先将核心脚本写入指定路径,然后赋予执行权限(否则无法运行):

# 1. 写入核心脚本(可先复制脚本内容,再执行以下命令)
sudo nano /usr/local/bin/unmount_hdd_on_battery.sh
# 粘贴脚本内容后,按 Ctrl+O 保存,Ctrl+X 退出

# 2. 赋予脚本执行权限(必须执行,否则无法运行)
sudo chmod +x /usr/local/bin/unmount_hdd_on_battery.sh

# 3. 写入Service文件
sudo nano /etc/systemd/system/battery-unmount-hdd.service
# 粘贴Service文件内容,Ctrl+O 保存,Ctrl+X 退出

# 4. 写入Timer文件
sudo nano /etc/systemd/system/battery-unmount-hdd.timer
# 粘贴Timer文件内容,Ctrl+O 保存,Ctrl+X 退出

2. 启动并启用定时任务

部署完成后,需要重新加载Systemd配置,然后启用并启动Timer,让脚本自动运行:

# 1. 重新加载Systemd配置(让系统识别新的Service和Timer)
sudo systemctl daemon-reload

# 2. 启用并启动Timer(--now 表示立即启动,同时设置开机自启)
sudo systemctl enable --now battery-unmount-hdd.timer

3. 验证部署结果

部署完成后,执行以下命令验证是否正常运行,确保没有问题:

# 1. 查看Timer状态(显示 active (waiting) 即为正常)
sudo systemctl status battery-unmount-hdd.timer

# 2. 查看下一次执行时间(确认Timer是否按30秒间隔触发)
sudo systemctl list-timers | grep battery-unmount-hdd

# 3. 实时查看日志(确认脚本是否正常执行,有无错误)
tail -f /var/log/battery_unmount_hdd.log

正常情况下,执行sudo systemctl status battery-unmount-hdd.timer 会显示类似以下内容(active表示运行正常):

● battery-unmount-hdd.timer - Check power status every 30 seconds for HDD mount/unmount Loaded: loaded (/etc/systemd/system/battery-unmount-hdd.timer; enabled; preset: enabled) Active: active (waiting) since Sun 2026-03-02 22:17:10 CST; 1h 20min ago Trigger: Mon 2026-03-03 00:00:30 CST; 22min left

实时查看日志时,会看到脚本每30秒执行一次的记录,首次运行会显示初始化日志,状态变化时会记录对应的触发信息。
Pasted image 20260302223055.png

五、效果展示与实际测试

部署完成后,我做了几次实际测试,验证方案的稳定性和有效性,以下是测试效果和日志截图。

这是我早上重启笔记本后,脚本初始化的效果日志(首次运行会初始化状态,记录触发时间):

其中有一次卸载失败的记录,拔掉了硬盘盒的USB线,导致设备无法识别,脚本正常记录了错误信息,后续重新插上USB线、恢复市电后,脚本自动挂载成功,完全符合预期。

另外,我也测试了市电中断的场景:拔掉UPS的市电插头,笔记本切换到电池供电,脚本记录状态变化,5分钟后自动卸载所有非系统硬盘;重新插上市电,笔记本切换回市电供电,30秒后自动挂载硬盘,整个过程完全自动化,无需手动干预

Pasted image 20260302223123.png

六、拓展方案(非笔记本NAS场景适用)

如果你的NAS不是笔记本(比如是台式机、树莓派等),无法通过检测自身供电状态判断UPS是否切换,也可以用类似的思路实现:

核心逻辑:将UPS的市电输入和路由器(或其他稳定设备)的市电输入接在同一回路,当市电中断时,路由器会断电,此时NAS通过ping路由器的IP地址,判断是否能ping通——若ping不通,说明市电中断,触发硬盘卸载;若ping通,说明市电恢复,触发硬盘挂载。

只需修改核心脚本中的“电源状态检测”部分,将读取 /sys/class/power_supply/AC/online 改为ping指定IP,其余逻辑完全不变,同样可以实现低成本的硬盘智能挂载/卸载。

七、总结与最终效果

这套方案的核心优势的是“低成本、无额外硬件、全自动化”,完美解决了自己笔记本搭建的NAS,使用UPS无法给NAS发送断电通知的问题,通过笔记本自身的供电状态检测,间接判断市电是否中断,进而实现硬盘的安全卸载和自动挂载,避免了硬盘突然断电导致的损坏和数据丢失。

整个方案仅依赖Linux系统自带的Systemd和内核文件,无需安装任何第三方工具,稳定性高、资源占用低,适合家用NAS场景。部署完成后,全程无需手动操作,开机自启、定时检测、自动执行,真正实现了“省心又安全”。

最后贴上我的家用NAS机柜完整版效果图,但通过这套方案,数据安全得到了充分保障,性价比直接拉满~
IMG_5961.png