总结:我在自己的绿联 NAS 屎山上部署了我自己写的动漫爬虫,却因受到 Cloudflare 的 TOS 折磨最后把 aria2 换了 3 台宿主机重写了半个容器镜像的事
有些事情留意过我的 V 友应该有点印象,在 2 年前的今天我买了一台绿联 DX4600 Pro ,洋洋洒洒写了 4 篇文章进行了极其深度的体验和 DIY ,并且热爱看动漫的我拿这台机器架了个 Jellyfin ,还往开源项目里添了一个 HTTP 爬虫以每 30 分钟同步动画疯上游的版权动画,提交 aria 下载和刮削,这一套东西跑起来之后想追新番不再需要依赖一些广告盈利的盗版资源站。
我们都知道绿联基于 OpenWRT 的 UGOS 系统是一坨屎,而随着绿联团队大刀阔斧推倒重做的 DXP 系列( with UGOS PRO powered by Debian )发布,这个系统的艺术性更是逼近失去了物业养护的公共厕所坑位里那一座风干了一年的草莓塔,连苍蝇蛆虫都散去,待在无人在意的角落里默默无闻,只等待那些:①像我这样因为正反馈(用户惯性)无法再找落脚点,别无选择只能在此处解决问题的人、②专门喜欢找史吃的数码时尚小垃圾爱好者发掘它,然后悄悄地恶心他们一下。
很可惜,我不仅是①,而且还蹲在上面,为这个屎山耗费了 3 天的心血为其添砖加瓦,以至于当一切都跑通了的时候,我开始怀疑自己人生的意义到底在哪里……
刚把这套东西搭起来我还是挺喜出望外的,kubespider套件本质上就是个定时任务,跑 xml 解析+aria2 调用器,如此成熟而又稳定的架构会在哪里出现问题?
答案是绿联的系统。它的系统自己的离线下载器是用 aria2 封装的,有可能是因为 openwrt 的内核兼容,或者是上面的 nas server 在捣鬼,导致自己使用 docker 架建的 aria2 实例,会每几分钟自动重启一次。找不到任何原因,换了数个作者封装的 docker 镜像,最后自己决定去镜像里面直接 strace 发现……
它真的什么错误都没发生,什么日志都没报,只是单纯的被不知道谁的 SIGKILL 干掉了,然后被 S6 守护进程自动拉起,conf 一切正常,rpc 监听一切正常,而 SIGKILL 在残核 openwrt 的容器内根本无法追溯,工具链缺失。追溯到某个镜像作者,只提了一嘴“绿联云兼容性相关”。
在支持断点续传的 URL 上,任务会保存下载进度,所以自动重启的影响不是很大,但 ANi Open 是一个很神奇的项目,它提供的 HTTP 地址不返回文件大小(因而也不支持断点续传),属于收到 EOF 就算完结的一次性链接:
幸而我的机场足够快,纯 IEPL 专线,大多数时候一个 1GB 以内的视频都能够在 2 分钟的重启窗口内下载完成,但总会有遇到波动的时候,如果某个时间段速度起不来,而 aria2 又在反复自动重启,它就会不断地重新开始一个任务,在硬盘上留下数十个.1 .2 .3 ....的不完整的视频,被 jellyfin 录入后表现的现象是播放到某个时间后直接结束
当时我的手头还没有这么多装备,所以想到的临时规避方法是偶尔打开 Aria2 管理器看一眼,如果遇到大量任务正在慢速重试,就直接停止,切到电脑手动下载,同时再往系统里挂一个定时任务每 3 小时清理一下,把最大的视频文件留下来,其它的删掉
从此处开始,一个庞大的屎山架构已经初具雏形。
从今年开始,我突然发现有些时候新番没有准时同步,kubespider 的日志也没有请求和解析异常,但存储池里只会蹦出来一个 stream.mp4 ,点开来一看是一段 TOS 声明:
解决的方法也很简单,重新下载几次,直到接收到正确的文件就 OK 。
但随着时间的推移,下载被重定向到这个文件的频率越来越高,越来越高,我开始有点无法忍受,在港台日新之间来回尝试节点、询问 ANi 的管理员并配合一起调试 HTTP 请求、试着模拟 UA/仿照 PC 浏览器的 Header 塞入 refer 和 cookie 参数,发现好像无济于事:难道我的 aria2 真的很像非法爬虫吗?(好像还真是)
直到 4 月的现在,被重定向的概率已经到了 75%,现在新建的任务我先默认被重定向失效,守着 WebUI 等着重试,但是……
这 Aria2NG 它没有对已完成的任务提供重试按钮啊?
而且如果我要每次都在大量更新的时间段蹲守 WebUI ,等着手动 copy 链接和保存地址去重建任务,是不是有点太自动化了?
程序间歇性重启+请求下载被 ban 的概率越来越大,这两件事组合在一起彻底冲破了我的容忍度,我决定开始寻求解决方案。
既然间歇性重启是系统问题,那好办,把 aria2 换一个地方部署就好了。彼时我手头上能够 24*7 跑的机器,除了 NAS 以外,还有一台 N100 的迷你机,它在我的家庭网络定位里只干两件事情:连 ssh ,和连 todesk 。所以加个 WSL Docker 跑个 aria2 应该还是绰绰有余的。
说干就干,安装 docker desktop ,然后直接跑 compose 拉起容器:
---
services:
aria2:
container_name: aria2
image: superng6/aria2:latest
privileged: true
network_mode: host
environment:
PGID: 0
PUID: 0
BTPORT: 32516
CACHE: 16M
FA: falloc
PORT: 6800
QUIET: "true"
RUT: "true"
S6_CMD_WAIT_FOR_SERVICES_MAXTIME: 0
S6_STAGE2_HOOK: /docker-mods
S6_VERBOSITY: 1
SECRET: kubespider
SMD: "true"
UMASK: "000"
UT: "true"
VIRTUAL_ENV: /lsiopy
WEBUI: "false"
WEBUI_PORT: 8080
volumes:
- ./config:/config
- bahamut:/downloads/TV/ANi
restart: always
volumes:
bahamut:
driver_opts:
type: "cifs"
device: "//192.168.1.20/nas_STORAGE_公共空间/Anime_Bahamut"
o: rw,vers=3.0,iocharset=utf8,username=***,password=********,uid=911,gid=1001,file_mode=0777,dir_mode=0777
由于 Windows 的权限管理和 Linux 有巨大的区别,所以在 Windows 上跑原本为 Linux 设计的容器,直接给 UID GID 0 ,mask 也 000 ,挂上去的 SMB 权限也直接 777 ,拉起容器之后发现 ws 端口死活不通,排查了一轮后发现容器的 init.d 启动脚本里卡在了用户权限初始化,实际上 aria2c 就一直没启动:
查阅了 docker 相关说明,得知在 Windows 上chown
不可用,索性直接把/etc/cont-init.d
挂出来,把所有启动脚本从原容器里复制,接着直接把 chown 注释掉,aria2c 就能正常启动了。
接着要解决下载失效的问题,aria2c 原生支持下载完成后回调脚本,我要做的是添加对被 TOS 文件的检测,然后 curl 构造重试请求,再在 aria2.conf 里把下载完成的脚本指定为自己修改的 sh 即可:
重启容器,添加任务进行调试,却发现下载到 stream.mp4 时没有进行重试,log 没有脚本运行的痕迹,打开 aria2.conf 观察到on-download-complete
被还原为默认的/aria2/script/complete.sh
。进一步探究发现每次启动容器时/etc/cont-init.d/30-config
会强制把回调脚本刷成默认:
改脚本再启,鉴定为勉强能跑,于是这套魔改了半个容器的 Windows Docker 方案就算阶段性投产。
启完容器之后,我手动加了一个下载任务进去,结果没什么问题,于是我就安心睡去了。
第二天早上起来到工位连上一看,在深夜创建的任务全部失败:
查看失败的原因是 IO 相关的问题,显示的是“File Exists”,于是进容器内排查,发现 SMB 的挂载已经掉了:
但是ll
挂载目录发现目标文件夹还是在的,只不过属性和权限全部变成?
,并且无法 cd 。
因为挂载行为是在 dockerd 完成的,在容器内部没法完成重新挂载的行为,想要复位 smb 必须重启容器。查了一下挂载选项试着加一些内核参数保活:
bahamut:
driver_opts:
type: "cifs"
device: "//192.168.1.20/nas_STORAGE_公共空间/Anime_Bahamut"
o: rw,vers=3.0,iocharset=utf8,username=****,password=********,uid=911,gid=1001,file_mode=0777,dir_mode=0777,hard,intr
无效。不如试试直接在容器内跑挂载,不走 docker 宿主,同时再挂个脚本检测保活
搞完之后我突然有点无语,我 tm 费这劲干什么?每 2 分钟掉进程跟每几小时之后掉 SMB 没有任何本质区别,与其面对吃力的 N100 、阿三写出来的 Win11 、强兼的 Docker Desktop 三座大屎山,我还不如趁早找台 unix 机器出来把东西移到那上面去。
于是我开始寻找身边有什么 unix 系统的设备能经得起连着 WiFi 24*7 地跑一个 docker 镜像,最后把目光瞄向了正放在阳台吃灰的 Mac Mini 。
这台 Mac 是我趁着国补之风买来备用的,配置是 16+256 ,配 10G 网口,打算等到 NAS 基础设施更换后拿来做 Jellyfin 服务器,因为更新设备需要跟着家里装修设计机柜走,所以到货激活之后一直在待命消耗保修期。。
想要把 Mac 从原厂系统捣鼓成服务器 ready 需要费不少功夫,brew 的初始化极其烧脑,再加上开启 ssh 、VNC 、关闭硬盘加密、开机自动登录、部分功能配置 logind 脚本、打通 Time Machine ,一套下来够摸索一天。不过 Meta Party 和 Docker 配置起来十分方便,一旦跑通会发现所有网络请求都自然而然地通了,不需要再做更多的环境变量配置,或者去各种不同的配置文件里改镜像源( brew 除外)。
根据在 Windows 上吃亏的经验,macOS 系统中,对cont-init.d
进行相应的魔改内容应该保留,同时 PGID 尽量和当前用户保持一致,而 SMB 对应用的显示权限调整成 s6 的 abc 比较合适:
---
services:
aria2:
container_name: aria2
image: superng6/aria2:latest
privileged: true
network_mode: host
environment:
PGID: 501
PUID: 20
TZ: Asia/Shanghai
BTPORT: 32516
CACHE: 16M
PORT: 6800
QUIET: "true"
RUT: "true"
SECRET: kubespider
SMD: "true"
UMASK: "000"
UT: "false"
WEBUI: "false"
WEBUI_PORT: 8080
volumes:
- /Users/****/Appdata/aria2/start:/etc/services.d/aria2/run
- /Users/****/Appdata/aria2/config:/config
- bahamut:/downloads/TV/ANi
- /Users/****/Appdata/aria2/cont-init.d:/etc/cont-init.d
restart: always
volumes:
bahamut:
driver_opts:
type: "cifs"
device: "//192.168.1.20/nas_STORAGE_公共空间/Anime_Bahamut"
o: rw,vers=3.0,iocharset=utf8,username=****,password=********,uid=911,gid=1001,file_mode=0777,dir_mode=0777,hard,intr
一番倒腾,aria2 终于趋于稳定,试点运行了 3 天后没有任何进程退出、SMB 掉挂载的问题,就是我发现下载完成回调脚本没有起到任何作用,它的 curl 重试操作实际上一直是失败的。在 shell 进行请求文本处理,特殊符号极其容易受到 shell 语言的影响导致实际传达的请求被转移后变成非常混乱的东西。于是我决定捡起我最擅长的语言——Python 。
在这几天密切跟踪 Aria2 的经历中,我发现一款安卓的 APP Aria2App对任务提供了重试功能,而且很惊喜地是这个 app 是开源的,所以我从源代码中直接找到了它对重试任务的实现方法:
我没有任何的 Java 经验,但能够勉强理解它在做什么:从旧任务的 GID 获取它的 options ,提取有效的 URL ,再把 URL 和 options 原封不动地送到新建任务的 API ,顺道再删除旧的 GID 。用 Python 实现类似如下:
import requests
import json
import argparse
import sys
def parse_arguments():
"""解析命令行参数"""
parser = argparse.ArgumentParser(
description="Aria2 任务重启工具",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument(
"-g", "--gid",
required=True, type=str, help="要重启的任务 GID (必需)"
)
parser.add_argument(
"-t", "--token",
required=True, type=str, help="aria2 RPC 密钥 (必需)"
)
parser.add_argument(
"-a", "--api-url",
type=str, default="http://localhost:6800/jsonrpc", help="aria2 RPC 地址 (默认: %(default)s)"
)
return parser.parse_args()
def restart_download(gid, token, api_url):
tell_status_params = {
"jsonrpc": "2.0",
"method": "aria2.tellStatus",
"id": "restart",
"params": [f"token:{token}", gid]
}
response = requests.post(api_url, json=tell_status_params)
download_status = response.json()["result"]
get_options_params = {
"jsonrpc": "2.0",
"method": "aria2.getOption",
"id": "restart",
"params": [f"token:{token}", gid]
}
response = requests.post(api_url, json=get_options_params)
old_options = response.json()["result"]
# 收集所有使用的 URLs
new_urls = set()
for file in download_status.get("files", []):
for uri in file.get("uris", []):
if uri.get("status") == "used":
new_urls.add(uri.get("uri"))
new_urls_list = list(new_urls)
add_uri_params = {
"jsonrpc": "2.0",
"method": "aria2.addUri",
"id": "restart",
"params": [f"token:{token}", new_urls_list, old_options]
}
response = requests.post(api_url, json=add_uri_params)
new_gid = response.json()["result"]
remove_result_params = {
"jsonrpc": "2.0",
"method": "aria2.removeDownloadResult",
"id": "restart",
"params": [f"token:{token}", gid]
}
requests.post(api_url, json=remove_result_params)
return new_gid
if __name__ == "__main__":
try:
args = parse_arguments()
new_gid = restart_download(
gid=args.gid,
token=args.token,
api_url=args.api_url
)
print(f"任务重启成功,新 GID: {new_gid}")
except requests.exceptions.RequestException as e:
print(f"网络请求失败: {str(e)}", file=sys.stderr)
sys.exit(1)
except KeyError as e:
print(f"响应数据解析失败,缺失字段: {str(e)}", file=sys.stderr)
sys.exit(2)
except Exception as e:
print(f"未知错误: {str(e)}", file=sys.stderr)
sys.exit(3)%
但是 aria2 使用的轻量基底镜像 Alpine 默认是不携带 Python 环境的,怎么办?
为了保持镜像本身的轻量化,同时降低运维复杂度,我决定不在原 Aria2 镜像中添加 python ,而是在同样的基底镜像里把这个 python 脚本编译成单个 executable 。
说干就干,写个 Dockerfile:
FROM superng6/alpine:3.21 AS builder
FROM superng6/alpine:3.21
LABEL maintainer="Homo"
ENV TZ=Asia/Shanghai PUID=0 PGID=0 UMASK=000
RUN mkdir /pythonenv
WORKDIR /pythonenv
RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' /etc/apk/repositories && \
apk add --no-cache python3 py3-pip gcc chrpath python3-dev build-base patchelf && \
python3 -m venv .venv && \
source .venv/bin/activate && \
echo "source /pythonenv/.venv/bin/activate" >> /root/.bashrc && \
echo "alias ll='ls -al'" >> /root/.bashrc && \
pip3 config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple && \
python3 -m pip install nuitka requests
VOLUME /workspace
WORKDIR /workspace
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
entrypoint
:
#!/bin/bash
set -e
if [ -f /workspace/requirements.txt ]; then
echo "requirements.txt dected, installing dependencies..."
source /pythonenv/.venv/bin/activate
pip install -r /workspace/requirements.txt
fi
echo "Start your building simply with the command below:"
echo "python3 -m nuitka <your_options> <your_script.py>"
exec bash
build 后起容器,把 python 脚本挂在/workspace
下,通过--onefile
等参数最后编译出一个 12MB 的 python 单执行文件,在complete.sh
下载完成回调中这样使用:
#!/usr/bin/env bash
. "/aria2/script/setting"
. "/aria2/script/core"
. "/aria2/script/rpc_info"
TASK_GID=$1
FILE_NUM=$2
FILE_PATH=$3
MAX_RETRY=5
RETRY_FILE="/tmp/aria2_retry_${TASK_GID}.count"
GET_BASE_PATH
COMPLETED_PATH
GET_RPC_INFO
GET_FINAL_PATH
# 定位最终文件路径
if [ "${FILE_NUM}" -eq 1 ]; then
final_path="${SOURCE_PATH}"
else
if [ -n "${COMPLETED_DIR}" ]; then
final_path="${COMPLETED_DIR}"
else
final_path="${TARGET_DIR}/${TASK_NAME}"
fi
fi
# 提取最终文件名
final_name=$(basename "${final_path}")
file_md5=$(md5sum "${final_path}" 2>/dev/null | awk '{print $1}')
invalid_md5=5b16dfbf8e5d3edc9242b20b280500c3 # Cloudflare TOS 返回的视频 md5
delete_old_file() {
if [ -e "${final_path}" ]; then
echo -e "$(DATE_TIME) ${INFO} GID:${TASK_GID} 删除目标: ${final_path}"
rm -rf "${final_path}"
else
echo -e "$(DATE_TIME) ${WARNING} GID:${TASK_GID} 目标不存在: ${final_path}"
fi
}
if [ "${FILE_NUM}" -eq 0 ] || [ -z "${FILE_PATH}" ]; then
exit 0
elif [ "${GET_PATH_INFO}" = "error" ]; then
echo -e "$(DATE_TIME) ${ERROR} GID:${TASK_GID} 路径获取错误!"
exit 1
else
MOVE_FILE
CHECK_TORRENT
current_retry=$(cat "${RETRY_FILE}" 2>/dev/null || echo 0)
# 判断是否为 stream.mp4 并且未达到最大重试次数
if [[ "${final_name}" == "stream.mp4" || "$(basename "${FILE_PATH}")" == "stream.mp4" || "${file_md5}" == "${invalid_md5}" ]] && \
[ "${current_retry}" -lt "${MAX_RETRY}" ]; then
echo -e "$(DATE_TIME) ${WARNING} GID:${TASK_GID} 检测到 TOS ,10 秒后重试 ($((current_retry+1))/${MAX_RETRY})"
echo $((current_retry+1)) > "${RETRY_FILE}"
sleep 10
delete_old_file
/config/retry --gid "${TASK_GID}" --token "${SECRET}" --api-url "http://${RPC_ADDRESS}"
exit $?
else
rm -f "${RETRY_FILE}"
fi
fi
exit 0
至此一个完美的、支持自动重试的、不会宕机的 Aria2 终于调教完成,投入使用,现在一个只插着电的无头 Mac Mini 静静地放在我的 PC 上享受风冷散热,承载我 NAS 里最新的追番使命。
然后我本以为一切都应该结束了,结果第二天起来一看我的机场 IP 仿佛被彻底拉黑了一样,所有的任务都陷入了无限重试:
不得已直接停下了容器,再次研究对 ANi Open 服务器的请求,在我百思不得其解之时,突然注意到走电脑直接下载视频文件时,有概率被重定向到另一个域名:
接下来尝试把所有下载域名的请求都替换成这个域名,什么 Header 、UA 、Refer 全都不用动,结果是不再出现拦截现象,所有下载任务都顺利执行。那我直接在解析器里替换域名不就好了?
Cloudflare 拦截问题迎刃而解,之前做的所有努力都化为泡影,下载器转入稳定运行……
在难绷人生的时间被浪费之余还有点想笑,唯一的收获就是大致学会了怎么写 Dockerfile build 一个镜像,捉瞎还是实力不够的证明,继续精进吧。
1
NikoXu 9 天前
爬的哪个网站 ?
|
![]() |
2
HOMO114514 OP |
3
he1293024908 9 天前
win 平台不是有 aira2 吗,为啥还要专门去装 docker 版本的
|
4
zeromake 9 天前
@he1293024908 #3
我自己搞了 fork 了 aria2:zeromake/aria2-zero windows 下有不少 bug ,特别是那个 wintls 实现问题一大把,我觉得用 docker 是一个非常好的方案,下面是我改了一堆问题: https://imgur.com/0CL3rIp |
5
zeromake 9 天前
|
6
TrembleBeforeMe 9 天前
为什么不用 BT 下载而是自己爬网站视频
|
![]() |
7
HOMO114514 OP @TrembleBeforeMe 这个源用 BT 没什么意义
|
![]() |
8
jasonyang9 8 天前 via Android
非常棒!学到了很多。。。感谢。关于 sigkill ,是不是容器进程资源占用超过限定也会这样被杀掉然后重启?比如 docker-compose 中的 deploy 设置
|
![]() |
9
HOMO114514 OP @jasonyang9 感觉看程序行为,我跑过 go 语言的容器爆限制,确实会杀,但是被杀还是自杀就不清楚了,返回-128 ,没有具体研究过这个工况
|
![]() |
10
SakuraYuki 8 天前
这么臭的 nas 还是死了罢(无慈悲)
|
![]() |
11
SakuraYuki 8 天前
@SakuraYuki #10 开个玩笑,不过现在不都是 bt 下载么,自己爬感觉很容易被 ban
|
![]() |
12
HOMO114514 OP @SakuraYuki
我对巴哈源是直接全同步的,每 30 分钟拉一次上游,直接做到所有新番都能看,每半年清理一次,所以也没有走 BT 做种的必要(而且 BT 不方便改文件名 然后看着看着觉得质量很好、自己喜欢的,再跑去 mikan 订阅,放到另一个 Library 里去收藏 |
![]() |
13
SakuraYuki 7 天前
@HOMO114514 #12 可以看看 anirss 这个项目,全自动同步追番订阅下载改名整理
|
![]() |
14
redeyesovo 7 天前 ![]() 「这个系统的艺术性更是逼近失去了物业养护的公共厕所坑位里那一座风干了一年的草莓塔,连苍蝇蛆虫都散去」
真是 homo 特有的幽默比喻啊先辈 |
![]() |
15
CherryGods 7 天前
大佬技术力很足哇哈哈哈,推荐大佬了解一下懒猫微服哇
|