群晖 Hyper Backup 总是自动暂停的解决办法

在群晖生态中,官方备份组件 Hyper Backup 大概是很多玩家做备份的第一选择。然而我实测发现备份过程中经常出现自动暂停的情况。在检查了错误发生的原因之后,我尝试使用浏览器自动化工具,进行暂停状态的监听和恢复。

群晖 Hyper Backup 总是自动暂停的解决办法
Photo by Alex Cheung / Unsplash

对于家庭中的 NAS,很多玩家会使用 RAID 实现数据冗余。然而,RAID 更多是为了保障可用性做出的设计,并不能代替备份。对于宝贵的数据来说,必须要有异地副本才能保障数据的安全性。

在群晖生态中,官方备份组件 Hyper Backup 大概是很多玩家做备份的第一选择。然而我实测发现备份过程中经常出现自动暂停的情况。并且 UI 上提示的错误原因是 unknown,必须要手动点击恢复才能继续备份,非常麻烦。尤其是对于初次备份。庞大的数据量加上频繁暂停使得初次备份可能半个月都无法完成。

UI 上给出的错误日志,基本没啥用

问题排查

通过 Google 搜索得知,我们可以打开系统的 SSH 登录功能,使用 sudo -i 切换到 root 账户,并查看 /var/log/messages 来得知具体的错误。

tail -n500 /var/log/messages

从错误日志上来看,是 TCP 连接被关闭了,因为 Google Drive 服务器本身在海外,对于大量文件的备份,出现连接被重置的问题也是难免。不过出现这种问题的时候,群晖的处理方式并不是退避重试,而是直接暂停备份,我觉得还是有改进的空间。好在备份被暂停后,下次可以从断点继续,并不需要从头开始。

多说一句,威联通(QNAP)系统带的备份软件 Hybrid Backup and Sync 也会出现类似问题,而且其备份因网络问题中断后,下次会从头开始,导致基本上无法完成备份。我就是因为威联通软件质量的底下,怒而换到群晖。

解决方案

既然我们已知当出现网络连接问题时备份就会暂停,在无法改变逻辑的情况下,我们只能实现一个监听脚本,当备份的状态从“备份中”变成“已暂停”时,就点击“操作”菜单下的“恢复”,让任务重新跑起来。

因为群晖本身没有提供 API 来操作系统设置,我们只能借助浏览器自动化测试工具 Selenium 来实现了。

简单介绍下 Selenium。Selenium 是一个用于 Web 应用程序测试的工具。Selenium 测试直接运行在浏览器中,就像真正的用户在操作一样。Selenium 是一个框架,它需要通过 driver 来驱动浏览器引擎运行。一个较为常用的 driver 便是用于操作谷歌浏览器的 chromedriver。

注意,以下操作全部在 Mac 上进行。由于我并不希望破坏 NAS 系统的稳定性,因此不会轻易使用 root 权限在 NAS 上安装东西。

首先确保你的系统中有安装 Python,我这里使用的是通过 brew 安装的 Python 3.9。接着使用 pip install selenium 安装 selenium。然后来到 chromedriver 的主页,下载高于你的 Chrome 浏览器版本的 chromedriver,并放置在 PATH 目录下(比如 /usr/bin/local)。

然后我们将 Hyper Backup 的快捷方式添加到 DSM 的桌面(因为我比较懒,所以此脚本仅支持从桌面快捷方式打开 Hyper Backup)。

最后我们 python hb_watcher.py 运行以下代码即可(记得替换 dsm_address、username、password):

import time
from selenium import webdriver
from selenium.common.exceptions import ElementClickInterceptedException, ElementNotInteractableException
from selenium.webdriver.remote.webdriver import WebElement
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

chrome_opt = Options()
chrome_opt.set_capability('chromeOptions', {'w3c': False})
chrome_opt.set_capability('showChromedriverLog', True)
driver = webdriver.Chrome(options=chrome_opt)  # Chrome浏览器

dsm_address = "http://192.168.1.80:5000" # NAS 的地址
username = "your-username"
password = "your-password"

# 打开网页
driver.get(dsm_address)



def get_element_with_wait(locator) -> WebElement:
    return WebDriverWait(driver, 5, 0.5).until(EC.presence_of_element_located(locator))


username_box = get_element_with_wait((By.CSS_SELECTOR, '#dsm-user-fieldset > div > div > div.input-container > input[type=text]'))
username_box.send_keys(username)

next_button = get_element_with_wait((By.CSS_SELECTOR, '#sds-login-vue-inst > div > span > div > div.login-body-section > div.login-tab-panel > div > div.tab-wrapper > div.tab-content-ct > div > div.login-tabs-content-wrapper.login-content-section > div.login-btn'))
next_button.click()

password_box = get_element_with_wait((By.CSS_SELECTOR, '#dsm-pass-fieldset > div.login-textfield-wrapper.password-field.field > div > div.input-container > input[type=password]'))
password_box.send_keys(password)

next_button = get_element_with_wait((By.CSS_SELECTOR, '#sds-login-vue-inst > div > span > div > div.login-body-section > div.login-tab-panel > div > div.tab-wrapper > div.tab-content-ct > div > div.login-content-section > div.login-tabs-content-wrapper > div.login-btn'))
next_button.click()

menu = get_element_with_wait((By.XPATH, '//ul[@role="menu"]'))
hb_desktop_shortcut = menu.find_element(By.XPATH, '//li[@aria-label="Hyper Backup"]')
# 打开 hyper backup(如果用户设置了登录时打开上次未关闭的窗口,此时也可能已经打开了
# 如果已经打开了,使用 element.click() 会报 ElementClickInterceptedException),用下面的方法则不会报错
driver.execute_script("arguments[0].click();", hb_desktop_shortcut)

# hyper backup 的窗口
hb_window = get_element_with_wait((By.XPATH, '//span[text()="Hyper Backup"]/../../../../..'))

while True:
    try:
        task_status_text: str = hb_window.find_element(By.XPATH, '//div[contains(@class, "task-status-text")]').text
    except Exception as e:
        print(f"got exception {e} while getting current status, retry...")
        time.sleep(1)
        continue
    print("current status: ", task_status_text)

    if task_status_text.find("暂停") != -1:
        # 已暂停
        print("检测到已暂停,尝试恢复")
        op_button = hb_window.find_element(By.XPATH, '//button[text()="操作"]')
        try:
            op_button.click()
        except (ElementClickInterceptedException, ElementNotInteractableException) as e:
            print(f"operate button is not clickable, sleep 1s and retry...")
            time.sleep(1)
            continue

        time.sleep(1)

        recover_button = hb_window.find_element(By.XPATH, '//span[text()="恢复"]')
        try:
            recover_button.click()
        except (ElementClickInterceptedException, ElementNotInteractableException) as e:
            print(f"resume button is not clickable, sleep 1s and retry...")
            time.sleep(1)
            continue
        time.sleep(30)  # wait for a few seconds for the page to be loaded

    time.sleep(10)

由于 Selenium 脚本基于 XPATH 和 CSS 选择器来对页面上的元素进行定位和操作,因此此脚本可能并不能适用于所有 DSM 版本。我使用的是最新的 DSM 7,如果你的版本更旧,可能需要自行进行一些修改。

效果

最终实现的效果如下,我们将脚本启动后,每隔 10 秒会检查一次当前备份任务的状态。如果状态为“已暂停”,则会尝试点击操作菜单中的“恢复”按钮进行恢复。这样,备份任务就能无人值守自动运行下去啦。