n5105中istoreos_docker运行sing-box-subscribe定时进行节点转换

sing-box-subscribe地址

sing-box-subscribe是根据配置模板生成 sing-box 使用的 config.json,主要用于将机场订阅节点添加到 config.json之中。

现在将它改成在docker容器中定时自动运行main.py来生成sing-box的config.json配置文件。

先将项目目录图片和如下内容丢给ai自动生成代码,然后再根据ai生成的代码再自行修改。

我现在的目录结构如图,帮我写一个在 docker 中运行 pyhon 命令的镜像,主要是运行 main.py,把 parsers、config_template 文件夹和 main.py 文件复制到容器里,在对应的映射卷中只有运行 main.py 时自动生成的 config.json 文件。main.py 在运行过程中会要求从终端输入一个数字,这个数字写进容器的环境变量中,main.py 从环境变量中读取。要能在容器的环境变量 CRON_TIME 中指定自动执行 main.py 的时间,如:CRON_TIME=25 20 * * *。所有依赖文件如 parsers 文件夹,config_template 文件夹、main.py、providers.json 全部打包到镜像。帮我做一个镜像。

项目文件清单(本地目录结构)

生成singbox-subscribe镜像的文件目录如下:

./项目根目录
├── Dockerfile
├── entrypoint.sh # 容器启动入口脚本
├── requirements.txt # Python依赖
├── main.py # 运行的主程序
├── providers.json # 内置默认配置打包进镜像
├── docker-compose.yml # 安装镜像
├── parsers/ # 完整解析器文件夹
│ ├── anytls.py
│ ├── app.py
│ ├── tool.py
│ ├── clash2base64.py
│ ├── http.py
│ ├── https.py
│ ├── hysteria.py
│ ├── hysteria2.py
│ ├── socks.py
│ ├── ss.py
│ ├── ssr.py
│ ├── tool.py
│ ├── trojan.py
│ ├── tuic.py
│ ├── vless.py
│ ├── vmess.py
│ ├── wg.py
│ └── gh_proxy_helper.py
└── config_template/ # 模板文件夹,内置到镜像
├── sb-config-1.12.json
├── sb-config-1.14.json
└── ...所有json模板

requirements.txt(Python 依赖)

根据 main.py 里 import 提取所有依赖:

requests
paramiko
scp
chardet
PyYAML
ruamel.yaml
Flask==2.3.2
cryptography
flask
flask-login
flask-wtf

Dockerfile(完整镜像构建文件)

使用 python:slim 轻量基础镜像,内置 cron,打包全部代码、模板、providers.json,挂载/config为持久化卷(仅生成 config.json)

FROM python:3.11-slim
# 镜像标签备注镜像维护信息,纯标识
LABEL maintainer="singbox-subscribe"
# Python 环境变量日志实时打印,docker logs 即时看输出
ENV PYTHONUNBUFFERED=1
# Python 环境变量禁止生成 pyc 缓存文件,容器目录更干净
ENV PYTHONDONTWRITEBYTECODE=1
ENV TZ=Asia/Shanghai

# 安装系统依赖:cron、时区工具
RUN apt-get update && apt-get install -y --no-install-recommends \
cron tzdata \
&& rm -rf /var/lib/apt/lists/* && apt-get clean

# 工作目录
WORKDIR /app

# 复制依赖并安装
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

# 复制全部业务代码、模板、默认providers.json
COPY main.py .
COPY providers.json .
COPY parsers ./parsers
COPY config_template ./config_template

# 复制启动脚本并授权
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# 持久化卷:/config 用于存放运行生成的config.json,用户可挂载宿主机目录
VOLUME ["/config"]

# 环境变量默认值
ENV TEMPLATE_INDEX=3
ENV CRON_TIME="59 10 * * *"

# 入口脚本
ENTRYPOINT ["/entrypoint.sh"]

docker-compose.yml

cd到singbox-subscribe目录下,然后执行docker compose up -d

services:
singbox-subscribe:
# build: .
image: singbox-subscribepro:latest
container_name: singbox-subscribepro
restart: on-failure
environment:
- CRON_TIME=59 10 * * *
# 容器启动时,不立刻执行**
- RUN_ON_START=false
- TEMPLATE_INDEX=3
# 设置订阅 URL 环境变量(支持多个)
- SUB_URL_1=http://192.168.50.1:8299/download/sub?target=sing-box
- SUB_URL_2=https://tv.*****.top:0000/sub
# 或者使用逗号分隔: - SUB_URLS=http://url1,https://url2
volumes:
- /etc/localtime:/etc/localtime:ro # 可选。用于与主机同步时间
- ./:/config
# 限制容器日志磁盘占用:**最多占用 3 × 10M = 30MB 存储空间**
logging:
driver: "json-file"
options:
max-size: "10M"
max-file: "3"

logging: 完整代码逐行解释

  1. driver: “json-file”
    Docker 默认日志驱动,容器所有标准输出 / 错误(print、脚本打印、报错)会以 JSON 格式持久化存储在宿主机磁盘。
  • 不配置时默认也是 json-file
  • 日志文件会保存在 Docker 数据目录(通常 /var/lib/docker/containers/容器ID/);
  • 执行 docker logs 容器名 底层就是读取该 JSON 日志文件。
  1. options.max-size: “10M”
    单日志文件最大上限 10 兆
    容器持续运行、定时每日打印日志,日志文件会不断膨胀;
    当日志文件达到 10MB 时,Docker 自动滚动分割日志,不再往这个文件追加内容。

  2. options.max-file: “3”
    最多保留 3 份滚动日志文件
    举个例子配合上面 max-size:10M
    初始日志:xxx-json.log(当前正在写入)
    填满 10M → 重命名为 xxx-json.log.1,新建空日志继续输出
    再次填满 10M → 生成 xxx-json.log.2
    第三次填满 10M → 生成 xxx-json.log.3
    第四次达到 10M 时:最旧的 .log.1 文件直接自动删除,只维持最多 3 个历史日志文件

  3. 整体作用总结

logging:
driver: "json-file"
options:
max-size: "10M"
max-file: "3"

限制容器日志磁盘占用:最多占用 3 × 10M = 30MB 存储空间,避免长期定时运行(每天 20:25 执行)日志无限堆积撑满服务器硬盘,是生产环境标准优化配置,推荐保留。

Docker 镜像标签(仓库名)强制要求全小写

  1. 镜像名称只能:小写字母、数字、横线 -、下划线 _、点 .
  2. 不能包含任意大写英文字母;
  3. 镜像名分隔建议用短横线 -,可读性更好。

entrypoint.sh

启动程序入口

#!/bin/bash
set -e
# 设置东八区时区
ln -sf /usr/share/zoneinfo/Asia/Shanghai
echo "Asia/Shanghai" > /etc/timezone

# 统一处理 RUN_ON_START 变量
RUN_ON_START_RAW=" ${RUN\_ON\_START:-true}"
RUN\_ON\_START=$ (echo " $RUN\_ON\_START\_RAW" | tr '[:upper:]' '[:lower:]')

# 生成独立执行脚本(仅负责运行python)
cat > /app/run\_task.sh <<'EOF'
#!/bin/bash
cd /app
/usr/local/bin/python3 main.py >> /proc/1/fd/1 2>&1
EOF
chmod +x /app/run\_task.sh

# 启动时直接运行(完整继承容器环境)
if [ "$ RUN_ON_START" = "true" ]; then
echo "RUN_ON_START=true,容器启动立即执行转换脚本"
/app/run_task.sh
fi

# 配置定时任务:命令行前置注入所有自定义环境变量,绕过cron环境隔离
rm -f /etc/cron.d/app-task
touch /etc/cron.d/app-task
if [ -n " ${CRON\_TIME}" ]; then
echo "配置定时任务:$ {CRON_TIME}"
# 全部环境变量写在cron执行命令最前面,cron执行时自带
CRON_FULL_CMD=" ${CRON\_TIME} root TEMPLATE\_INDEX=$ {TEMPLATE_INDEX} SUB_URL_1=' ${SUB\_URL\_1}' SUB\_URL\_2='$ {SUB_URL_2}' /app/run_task.sh"
echo " $CRON\_FULL\_CMD" >> /etc/cron.d/app-task
chmod 0644 /etc/cron.d/app-task
crontab /etc/cron.d/app-task
exec cron -f
else
echo "未设置CRON\_TIME,执行完成后容器退出"
fi

以上代码的核心问题:cron 进程隔离环境变量

现象拆解
  1. 容器前台 exec /app/run\_task.sh 正常读取 SUB\_URL\_1/SUB\_URL\_2/TEMPLATE\_INDEX=3
  2. cron 定时执行 /app/run\_task.sh丢失所有自定义环境变量,程序读空,回退 default-url
  3. 根本原理:Linux cron 运行在独立极简环境,不会继承容器启动时 -e 注入的环境变量,/app/run\_task.sh 内部 export $ VAR 拿不到父进程容器变量。
两种彻底解决方案(推荐方案 1)
方案 1:把所有环境变量写入 cron 任务行(一劳永逸,无需改 run_task.sh)

替换完整 entrypoint.sh,cron 命令行前置注入全部变量,cron 执行时自带,不受环境隔离限制:

#!/bin/bash
set -e
# 设置东八区时区
ln -sf /usr/share/zoneinfo/Asia/Shanghai
echo "Asia/Shanghai" > /etc/timezone

# 统一处理 RUN_ON_START 变量
RUN_ON_START_RAW=" ${RUN\_ON\_START:-true}"
RUN\_ON\_START=$ (echo " $RUN\_ON\_START\_RAW" | tr '[:upper:]' '[:lower:]')

# 生成独立执行脚本(仅负责运行python)
cat > /app/run\_task.sh <<'EOF'
#!/bin/bash
cd /app
/usr/local/bin/python3 main.py >> /proc/1/fd/1 2>&1
EOF
chmod +x /app/run\_task.sh

# 启动时直接运行(完整继承容器环境)
if [ "$ RUN_ON_START" = "true" ]; then
echo "RUN_ON_START=true,容器启动立即执行转换脚本"
/app/run_task.sh
fi

# 配置定时任务:命令行前置注入所有自定义环境变量,绕过cron环境隔离
rm -f /etc/cron.d/app-task
touch /etc/cron.d/app-task
if [ -n " ${CRON\_TIME}" ]; then
echo "配置定时任务:$ {CRON_TIME}"
# 全部环境变量写在cron执行命令最前面,cron执行时自带
CRON_FULL_CMD=" ${CRON\_TIME} root TEMPLATE\_INDEX=$ {TEMPLATE_INDEX} SUB_URLS=' ${SUB\_URLS}' SUB\_URL\_1='$ {SUB_URL_1}' SUB_URL_2=' ${SUB\_URL\_2}' SUB\_URL\_3='$ {SUB_URL_3}' /app/run_task.sh"
echo " $CRON\_FULL\_CMD" >> /etc/cron.d/app-task
chmod 0644 /etc/cron.d/app-task
crontab /etc/cron.d/app-task
exec cron -f
else
echo "未设置CRON\_TIME,执行完成后容器退出"
fi
关键改动说明

cron 任务不再只写 /app/run\_task.sh,而是:

13 10 * * * root TEMPLATE\_INDEX=3 SUB\_URL\_1="xxx" SUB\_URL\_2="xxx" /app/run\_task.sh

cron 执行这条命令时,临时定义环境变量传递给脚本,不受 cron 隔离机制影响,和手动执行环境完全一致。

方案 2:全局持久环境文件(备选)

如果订阅链接含特殊引号导致方案 1 解析异常,可在 /etc/environment 写入变量,cron 默认读取该文件:

entrypoint 内增加:

# 写入全局环境,cron自动加载
cat > /etc/environment <<ENV
TEMPLATE\_INDEX="$ {TEMPLATE_INDEX}"
SUB_URLS=" ${SUB\_URLS}"
SUB\_URL\_1="$ {SUB_URL_1}"
SUB_URL_2=" ${SUB\_URL\_2}"
SUB\_URL\_3="$ {SUB_URL_3}"
ENV

run_task.sh 顶部加载全局环境:

#!/bin/bash
# 读取全局环境
while read line; do export " $line"; done < /etc/environment
cd /app
/usr/local/bin/python3 main.py >> /proc/1/fd/1 2>&1
部署生效步骤
  1. 替换本地 entrypoint.sh 为方案 1 完整代码
  2. 重新构建镜像
docker compose down
docker rmi singbox-subscribepro:latest
docker build -t singbox-subscribepro:latest .
docker compose up -d
  1. 校验 cron 任务内容
docker exec -it singbox-subscribepro cat /etc/cron.d/app-task

输出应看到变量拼接在命令最前方,示例:

13 10 * * root TEMPLATE\_INDEX=3 SUB\_URLS='' SUB\_URL\_1='http://192.168.50.1:8299/download/sub?target=sing-box' SUB\_URL\_2='https://tv.****.top:2053/sub' SUB\_URL\_3='' /app/run\_task.sh
验证方式

手动模拟 cron 完整命令(复制 cat 输出的整行,去掉时间 + root 前缀执行):

docker exec -it singbox-subscribepro bash -c "TEMPLATE\_INDEX=3 SUB\_URL\_1='http://192.168.50.1:8299/download/sub?target=sing-box' SUB\_URL\_2='https://tv.*****.top:2053/sub' /app/run\_task.sh"

输出和你手动运行完全一致,不再出现未设置SUB\_URL、TEMPLATE\_INDEX无效报错。

补充原因总结

容器直接 exec:进程是容器主进程子进程,完整继承 -e 注入环境;

cron 定时任务:属于系统独立后台进程,不继承容器启动时的自定义环境变量,必须在 cron 命令行显式传入变量。

删除 main.py 中的 gh_proxy 代理

因为我是本地执行,不需要代理

一、确认要删除的 4 处 gh_proxy 代码,删除后程序完全正常运行

下面 4 段全部删掉,本地订阅逻辑不受任何影响,不会报错:

1. 顶部导入删除

原代码:

from parsers.gh\_proxy\_helper import set\_gh\_proxy

直接整行删掉。

2. argparse 参数删除
parser.add\_argument('--gh\_proxy\_index', type=str, help='github加速链接')

整行删除。

3. 接收 gh_proxy_index 变量删除
gh\_proxy\_index = args.gh\_proxy\_index

整行删除。

4. github 加速逻辑整块删除

原完整块:

# 处理github加速
if hasattr(args, 'gh\_proxy\_index') and str(args.gh\_proxy\_index).isdigit():
gh\_proxy\_index = int(args.gh\_proxy\_index)
print(gh\_proxy\_index)
urls = [item["url"] for item in config["route"]["rule\_set"]]
new\_urls = set\_gh\_proxy(urls, gh\_proxy\_index)
for item, new\_url in zip(config["route"]["rule\_set"], new\_urls):
item["url"] = new\_url

全部删掉。

二、删除后对应修改片段展示(对照修改,避免改错)
修改后头部导入区
# 系统自带库(不用管来源,Python 自带)
import json, os, time, requests, sys, importlib, argparse, yaml, ruamel
import re
from datetime import datetime
from urllib.parse import urlparse
from collections import OrderedDict
# 项目内部文件(来自你项目里的文件)
from parsers.app import TEMP\_DIR
from parsers import tool
from parsers.clash2base64 import clash2v2ray
# 下面一行gh\_proxy导入已删除
修改后 argparse 区域
parser = argparse.ArgumentParser()
parser.add\_argument('--temp\_json\_data', type=parse\_json, help='临时内容')
parser.add\_argument('--template\_index', type=int, help='模板序号')
# --gh\_proxy\_index 参数已删除
args = parser.parse\_args()
temp\_json\_data = args.temp\_json\_data
# gh\_proxy\_index = args.gh\_proxy\_index 已删除
修改后主逻辑(删除加速块)
nodes = process\_subscribes(providers["subscribes"])
# ==========下面整块github加速代码已经删除==========
if providers.get('Only-nodes'):
combined\_contents = []
for sub\_tag, contents in nodes.items():
# 遍历每个机场的内容
for content in contents:
# 将内容添加到新列表中
combined\_contents.append(content)
final\_config = combined\_contents # 只返回节点信息
else:
final\_config = combin\_to\_config(config, nodes) # 节点信息添加到模板
三、为什么删除后程序 100% 正常?
  1. gh_proxy 仅作用于github 规则集链接加速,你本地内网订阅(192.168、tv.weito)完全不依赖这段代码;
  2. 删除后不会调用set\_gh\_proxy函数,不会出现导入缺失报错;
  3. --gh\_proxy\_index启动参数彻底移除,无多余参数干扰;
  4. 订阅拉取、节点解析、分流分组、定时、TEMPLATE_INDEX 全部原有逻辑保留;
  5. 不会影响providers.json、config 模板、/config 配置保存逻辑。
四、修改后验证步骤
  1. 保存修改后的 main.py
  2. 重建镜像
docker compose down
docker rmi singbox-subscribepro:latest
docker build -t singbox-subscribepro:latest .
docker compose up -d
  1. 手动执行测试
docker exec -it singbox-subscribepro /app/run\_task.sh

输出正常加载 SUB_URL、zy_base 模板、正常生成 config.json,无任何报错即为修改成功。

五、额外清理(可选)

parsers 文件夹里的gh\_proxy\_helper.py文件可以直接删除,不再使用,不删也不影响运行。

在sing-box-subscribe中添加节点存活测速

要在 main.py 主文件中实现节点延迟测试(HTTP/TCP 测速过滤),可以使用 Python 的并发线程池(concurrent.futures.ThreadPoolExecutor)结合 requests 请求测试 URL https://www.gstatic.com/generate_204

下面是针对 main.py 的具体修改方案。

main.py 中增加测速逻辑函数 test\_node\_latency 和并发过滤函数 filter\_nodes\_by\_latency,并在获取节点列表 process\_subscribes 之后调用过滤逻辑。

  1. main.py 顶部导入并发线程池:from concurrent.futures import ThreadPoolExecutor, as\_completed
  2. 添加测速和并发过滤函数:在 main.py 中添加以下代码(建议放在 process\_subscribes(subscribes) 函数上方或下方):
# 添加测速和并发过滤函数;建议放在 process\_subscribes 函数上方或下方
# 测速配置常量
TEST\_URL = "https://www.gstatic.com/generate\_204"
TIMEOUT\_MS = 10000 # 测速超时(毫秒)
MAX\_LATENCY = 2500 # 调高延迟阈值(毫秒),确保 700ms~2000ms 的可用节点能保留
MAX\_WORKERS = 20 # 并发数量 20

def test\_node\_latency(node):

# 通过 HTTP 请求测试节点连通性及延迟

server = node.get("server")
port = node.get("server\_port") or node.get("port")

if not server or not port:
return node, -1

# 使用 HTTP / HTTPS 代理机制(或通过 TCP 连接/代理测速)测试 URL 延迟
# 注意:在没有本地代理服务转发的情况下,可以直接测试服务器与端口的 TCP 握手延迟或 HTTP GET
start\_time = time.time()
try:
# 如果节点指定了 HTTP/SOCKS 协议,通过 requests 代理测试;
# 若为其他出站协议,可通过 TCP/Socket 连接测试服务器+端口握手延迟
proxy\_type = node.get("type", "").lower()
proxies = None

if proxy\_type in ["http", "socks5"]:
username = node.get("username", "")
password = node.get("password", "")
auth\_str = f"{username}:{password}@" if username and password else ""
proxy\_url = f"{proxy\_type}://{auth\_str}{server}:{port}"
proxies = {"http": proxy\_url, "https": proxy\_url}

resp = requests.get(
TEST\_URL,
proxies=proxies,
timeout=TIMEOUT\_MS / 1000.0,
headers={"User-Agent": "Mozilla/5.0"}
)

# 计算延迟(毫秒)
latency = int((time.time() - start\_time) * 1000)

if resp.status\_code in [200, 204]:
return node, latency
else:
return node, -1
except Exception:
return node, -1


def filter\_nodes\_by\_latency(nodes\_dict):

# 并发测速并过滤超出 MAX\_LATENCY 的节点

print(f"\\n开始对节点进行延迟测试 (并发数: {MAX\_WORKERS}, 超时时间: {TIMEOUT\_MS}ms, 阈值: {MAX\_LATENCY}ms)...")

filtered\_nodes\_dict = {}
total\_count = 0
valid\_count = 0

for tag, node\_list in nodes\_dict.items():
filtered\_list = []
total\_count += len(node\_list)

with ThreadPoolExecutor(max\_workers=MAX\_WORKERS) as executor:
future\_to\_node = {
executor.submit(test\_node\_latency, node): node for node in node\_list
}

for future in as\_completed(future\_to\_node):
node, latency = future.result()
if 0 <= latency <= MAX\_LATENCY:
# 可选:在节点对象中记录延迟
node["latency"] = latency
filtered\_list.append(node)
valid\_count += 1
print(f" [√] 节点 [{node.get('tag')}] 延迟: {latency}ms")
else:
reason = "超时/不可用" if latency < 0 else f"{latency}ms > {MAX\_LATENCY}ms"
print(f" [X] 节点 [{node.get('tag')}] 过滤 ({reason})")

filtered\_nodes\_dict[tag] = filtered\_list

print(f"测速完成!共测试 {total\_count} 个节点,保留 {valid\_count} 个可用节点。\\n")
return filtered\_nodes\_dict
  1. 修改 main.py 的主流程入口 (\_\_main\_\_)

main.py 的主逻辑中,找到 nodes = process\_subscribes(providers["subscribes"]),在其下方加入 filter\_nodes\_by\_latency 调用:

# 1. 获取和解析所有订阅节点
nodes = process\_subscribes(providers["subscribes"])

# 2. 【新增】进行并发延迟测试并过滤节点
nodes = filter\_nodes\_by\_latency(nodes)

# 3. 生成最终配置
if providers.get('Only-nodes'):
combined\_contents = []
for sub\_tag, contents in nodes.items():
for content in contents:
combined\_contents.append(content)
final\_config = combined\_contents
else:
final\_config = combin\_to\_config(config, nodes)

save\_config(providers["save\_config\_path"], final\_config)

其它地方都不用修改

测试的结果与sing-box测试的结果不同,这是非常正常的现象!之前的 Python 脚本测速并不是真实的“节点代理测速”,而是直连测试

为什么测试结果会完全不同?

  • Python 脚本的直连测试:此前用 requests.get()socket 请求 [https://www.gstatic.com/generate\_204](https://www.gstatic.com/generate\_204) 时,使用的是运行 Python 脚本那台服务器/本机自身的网络去直连节点的 IP,或者根本没有通过节点的加密协议(如 VLESS、Hysteria2)进行握手与转发。这测出来的只是你的机器到节点服务器的 IP 延迟,无法判断加密代理管道是否真正通畅。
  • sing-box 客户端的代理测速:sing-box 客户端(图中界面)会将数据包真正输入到节点的出站内核中,完成协议握手(如 vless/hy2 复杂的 TLS/QUIC 握手),通过代理通道访问目标 URL 成功后才算有效并返回延迟。如果节点协议参数有误、证书失效或节点被墙,sing-box 就会直接报红色警告(超时/连接失败)。

在sing-box-subscribe中调用sing-box内核进行节点存活测速

在容器内直接运行sing-box二进制文件(非跨主机,容器独立运行),在 Dockerfile 构建时将 sing-box 的 Linux 二进制文件直接复制/安装到容器内部。 这种方式下,Python 脚本可以直接在容器本地通过 subprocess.run(["sing-box", "check", ...]) 执行节点语法校验或启动临时轻量实例测速,方案就是在容器内部启动一个专用的轻量级临时 sing-box 进程

  1. 构建临时配置:Python 将所有待测节点注入到一个无额外分流代理/直连代理链的专用临时配置文件中(只有节点本身,出站策略直连目标)。
  2. 后台启动临时测速内核:Python 在容器后台以该临时配置启动一个独立 sing-box 实例(开在临时端口,如 9090)。
  3. 调用 RESTful API 测速:Python 通过 API ([http://127.0.0.1:9090/clash/delay](http://127.0.0.1:9090/clash/delay)) 批量对各个节点触发延迟测试。
  4. 清理与过滤:测试完成后终止临时进程、删除临时配置文件,筛选出延迟落在 0~3500ms 范围内的节点。
修改 Dockerfile(安装 sing-box 二进制)

sing-box-1.13.11-linux-amd64.tar.gz下载放到Dockerfile同级目录下。修改Dockerfile文件内容:

FROM python:3.11-slim
# 镜像标签备注镜像维护信息,纯标识
LABEL maintainer="singbox-nodes"
# Python 环境变量日志实时打印,docker logs 即时看输出
ENV PYTHONUNBUFFERED=1
# Python 环境变量禁止生成 pyc 缓存文件,容器目录更干净
ENV PYTHONDONTWRITEBYTECODE=1
ENV TZ=Asia/Shanghai

# 安装系统依赖:cron、时区工具
RUN apt-get update && apt-get install -y --no-install-recommends \\
cron tzdata \\
curl ca-certificates tar \\
&& rm -rf /var/lib/apt/lists/* && apt-get clean

# 从本地项目根目录复制sing-box压缩包到容器内 /tmp 目录
COPY sing-box-1.13.11-linux-amd64.tar.gz /tmp/
# 解压、移动二进制文件到 PATH 并清理临时文件
RUN tar -xzf /tmp/sing-box-1.13.11-linux-amd64.tar.gz -C /tmp \\
&& mv /tmp/sing-box-*/sing-box /usr/local/bin/ \\
&& rm -rf /tmp/sing-box*

# 工作目录
WORKDIR /app

# 复制依赖并安装
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

# 复制全部业务代码、模板、默认providers.json
COPY main.py .
COPY providers.json .
COPY parsers ./parsers
COPY config\_template ./config\_template

# 复制启动脚本并授权
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# 持久化卷:/config 用于存放运行生成的config.json,用户可挂载宿主机目录
VOLUME ["/config"]

# 环境变量默认值
ENV TEMPLATE\_INDEX=1
ENV CRON\_TIME=""

# 入口脚本
ENTRYPOINT ["/entrypoint.sh"]
修改 main.py(增加内核测速逻辑)

main.py 中引入 subprocess 和 处理URL 网址的解析、拼接、编码、解码的 urllib.parse,在 main.py 顶部导入:

# 测速需要
import subprocess
import urllib.parse

添加内置临时内核测速函数:在 main.py 中添加以下代码(建议放在 process\_subscribes(subscribes) 函数上方或下方):

# 增加测速函数
# 测速常量配置
TEST\_URL = "https://www.gstatic.com/generate\_204"
TIMEOUT\_MS = int(os.getenv("TIMEOUT\_MS", 30000)) # 测速超时(毫秒)
MAX\_LATENCY = int(os.getenv("MAX\_LATENCY", 3500)) # 延迟上限(毫秒)
API\_PORT = 19090 # 临时 API 端口

def filter\_nodes\_by\_singbox(nodes\_dict):
# 通过容器内独立的 sing-box 内核进行真实协议测速与过滤
all\_nodes = []
for tag, n\_list in nodes\_dict.items():
all\_nodes.extend(n\_list)

if not all\_nodes:
return nodes\_dict

# 1. 深度清洗节点:确保节点是字典且包含必要字段
clean\_nodes = [node for node in all\_nodes if isinstance(node, dict) and node.get("type") and node.get("tag")]

if not clean\_nodes:
print("[测速内核] 没有有效的节点参与测速!")
return nodes\_dict

# 2. 构建sing-box 1.13+ 临时配置文件,不走代理
temp\_config = {
"log": {
"level": "warn"
},
"dns": {
"servers": [
{
"tag": "local-dns",
"type": "udp",
"server": "223.5.5.5",
"server\_port": 53
}
]
},
"inbounds": [],
"outbounds": clean\_nodes + [
{"type": "direct", "tag": "direct"}
],
"route": {
"rules": [
{
"protocol": "dns",
"outbound": "direct"
}
]
},
"experimental": {
"clash\_api": {
"external\_controller": f"127.0.0.1:{API\_PORT}"
}
}
}

temp\_config\_path = "/tmp/speedtest\_config.json"
with open(temp\_config\_path, "w", encoding="utf-8") as f:
json.dump(temp\_config, f, indent=2, ensure\_ascii=False)

# 3. 设置环境变量(双保险)
env = os.environ.copy()
env["ENABLE\_DEPRECATED\_LEGACY\_DNS\_SERVERS"] = "true"
env["ENABLE\_DEPRECATED\_OUTBOUND\_DNS\_RULE\_ITEM"] = "true"

# 4. 预检配置语法
check\_res = subprocess.run(["sing-box", "check", "-c", temp\_config\_path], capture\_output=True, text=True, env=env)
if check\_res.returncode != 0:
print(f"\\n[测速内核错误] 临时配置语法不通过:\\n{check\_res.stderr}")
print("[警告] 跳过测速程序,保留全部节点以防止生成空文件。")
if os.path.exists(temp\_config\_path):
os.remove(temp\_config\_path)
return nodes\_dict

print(f"\\n[测速内核] 启动临时 sing-box 进程 (API 端口: {API\_PORT})...")

# 5. 启动后台测速进程,同时传入环境变量
process = subprocess.Popen(
["sing-box", "run", "-c", temp\_config\_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env
)

# 6. 轮询等待 API 端口成功监听
api\_ready = False
for \_ in range(10):
time.sleep(0.5)
try:
resp = requests.get(f"http://127.0.0.1:{API\_PORT}/version", timeout=1)
if resp.status\_code == 200:
api\_ready = True
break
except Exception:
pass

if not api\_ready:
print("[测速内核错误] API 服务未能成功监听,终止测速。")
process.terminate()
process.wait()
if os.path.exists(temp\_config\_path):
os.remove(temp\_config\_path)
return nodes\_dict

valid\_node\_tags = set()
try:
print(f"[测速内核] 开始批量测试 {len(clean\_nodes)} 个节点 (目标: {TEST\_URL}, 超时: {TIMEOUT\_MS}ms)...")

for node in clean\_nodes:
tag = node.get("tag")
# 严格按照 Shell 脚本的 encode 逻辑处理 tag
encoded\_tag = urllib.parse.quote(tag, safe="")

try:
# 关键修改:直接手动拼接 URL,不要让 requests 自动编码 TEST\_URL 中的斜杠
api\_req\_url = f"http://127.0.0.1:{API\_PORT}/proxies/{encoded\_tag}/delay?timeout={TIMEOUT\_MS}&url={TEST\_URL}"

resp = requests.get(
api\_req\_url, timeout=(TIMEOUT\_MS / 1000.0) + 2
)

if resp.status\_code == 200:
delay = resp.json().get("delay", -1)
if 0 < delay <= MAX\_LATENCY:
valid\_node\_tags.add(tag)
print(f" [√] 节点 [{tag}] 真实延迟: {delay}ms")
else:
print(
f" [X] 节点 [{tag}] 延迟超标 ({delay}ms > {MAX\_LATENCY}ms)"
)
else:
print(f" [X] 节点 [{tag}] 测试失败/超时")
except Exception as e:
print(f" [X] 节点 [{tag}] 测速异常: {e}")

finally:
process.terminate()
process.wait()
if os.path.exists(temp\_config\_path):
os.remove(temp\_config\_path)
print("[测速内核] 临时进程已停止,环境已清理。\\n")

# 7. 安全兜底:如果测速后合格节点数为 0,返回原节点避免破坏配置文件
if not valid\_node\_tags:
print("[警告] 测速后可用节点数为 0,为防止主配置报错,保留原始节点。")
return nodes\_dict

filtered\_nodes\_dict = {}
for group\_tag, n\_list in nodes\_dict.items():
filtered\_list = [node for node in n\_list if node.get("tag") in valid\_node\_tags]
filtered\_nodes\_dict[group\_tag] = filtered\_list

return filtered\_nodes\_dict

main.py 主程序中调用,在 main.py 的主入口逻辑中:

# 1. 订阅解析[cite: 3]
nodes = process\_subscribes(providers["subscribes"])

# 2. 调用内核真实代理测速过滤
nodes = filter\_nodes\_by\_singbox(nodes)

# 3. 组合生成最终配置文件[cite: 3]
if providers.get('Only-nodes'):
combined\_contents = []
for sub\_tag, contents in nodes.items():
for content in contents:
combined\_contents.append(content)
final\_config = combined\_contents
else:
final\_config = combin\_to\_config(config, nodes)

save\_config(providers["save\_config\_path"], final\_config)
方案优势
  • 测速 100% 准确:节点直接建立底层隧道并发起连通测试,测试结果与客户端一致(能测出 VLESS / HY2 等各种协议的真正可用性)。
  • 绝对无前置代理干扰:临时配置文件只有纯节点列表(outbounds)和 clash\_api,不会出现代理嵌套导致测速失真的情况。
  • 隔离性好:全程在容器内自给自足,不占用宿主机资源,也不会破坏 Docker 外的环境。
修改entrypoint.sh代码

因为在docker-compose.yml中将TIMEOUT\_MS、MAX\_LATENCY、SUB\_URL\_3写入了容器的环境变量(SUB_URL_3是在providers.json文件中增加了一个订阅链接)。docker-compose.yml内容如下:

services:
singbox-subscribe:
# build: .
image: singbox-nodes:latest
container\_name: singbox-nodes
restart: on-failure
environment:
- CRON\_TIME=05 19 * * *
# 容器启动时,不立刻执行**
- RUN\_ON\_START=false
- TEMPLATE\_INDEX=3
# 测速超时(毫秒)
- TIMEOUT\_MS=30000
# 延迟上限(毫秒)
- MAX\_LATENCY=2500
# 设置订阅 URL 环境变量(支持多个)
- SUB\_URL\_1=http://192.168.50.1:8299/download/sub?target=sing-box
- SUB\_URL\_2=https://***.***.top/*****/sub/raw?app=xray#%F0%9F%92%A6%20BPB%20Raw
- SUB\_URL\_3=https://***.***.top:6666/sub
volumes:
- /etc/localtime:/etc/localtime:ro # 可选。用于与主机同步时间
- ./:/config
# 限制容器日志磁盘占用:**最多占用 3 × 10M = 30MB 存储空间**
logging:
driver: "json-file"
options:
max-size: "10M"
max-file: "3"

增加的这些内容中SUB\_URL\_2参数里 中包含 &# 等特殊字符 日志中显示你的 SUB\_URL\_2 包含了 #(例如 ...#%F0%9F%92%A6%20BPB%20Raw)。 在 Linux Shell 和 Cron 配置中,# 会被直接当作注释符!Cron 读取到 # 时,后面拼接的所有内容(包括 /app/run\_task.sh)都被当作注释干掉了,相当于这行任务根本没有包含要执行的脚本。所以直接CRON\_FULL\_CMD="$ {CRON_TIME} root TEMPLATE_INDEX= ${TEMPLATE\_INDEX} TIMEOUT\_MS=$ {TIMEOUT_MS} MAX_LATENCY= ${MAX\_LATENCY} SUB\_URL\_1='$ {SUB_URL_1}' SUB_URL_2=' ${SUB\_URL\_2}' SUB\_URL\_3='$ {SUB_URL_3}' /app/run_task.sh"这样子拼接时运行会报错,main.py运行时自动退出了。

最稳妥的修复方案不要把环境变量硬塞进 cron 的表达式单行里。直接在 entrypoint.sh 中把容器的环境变量全量导出到 /etc/environmentrun_task.sh 执行时会自动引入它们,完美避开 # 注释和引号转义问题。

修改你的 entrypoint.sh 如下:

#!/bin/bash
# 【错误预防设置】若脚本中任何一条命令执行失败(返回非 0 状态码),立即终止整个脚本,不再继续向下执行
set -e

# ==========================================
# 1. 系统基础环境配置(时区对齐)
# ==========================================
# 将容器系统时区强制链接为中国东八区(Shanghai)
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
# 将时区名称写入配置文件,确保整个系统及子进程识别正确的时区
echo "Asia/Shanghai" > /etc/timezone


# ==========================================
# 2. 启动变量预处理
# ==========================================
# 获取环境变量 RUN_ON_START,若用户未在 compose/docker run 中设置,则默认赋值为 "true"
RUN_ON_START_RAW=" ${RUN\_ON\_START:-true}"
# 将变量值统一转换为纯小写(如将 "TRUE"、"True" 转为 "true"),提高容错率
RUN\_ON\_START=$ (echo " $RUN\_ON\_START\_RAW" | tr '[:upper:]' '[:lower:]')


# ==========================================
# 3. 持久化保存环境变量(解决 Cron 环境隔离的核心)
# ==========================================
# 说明:Cron 启动的子进程默认处于极简环境,无法读取 Docker 传入的自定义环境变量(如 SUB\_URL、TIMEOUT\_MS 等)
# 作用:将当前容器内所有的环境变量 dump 保存到系统环境变量配置文件 /etc/environment 中
# `grep -v "no\_proxy"` 用于过滤可能干扰网络连接的无效代理变量
printenv | grep -v "no\_proxy" > /etc/environment


# ==========================================
# 4. 动态生成 Python 任务包装脚本 (/app/run\_task.sh)
# ==========================================
# 使用 EOF 块动态创建 run\_task.sh 文件,无论手动运行还是定时任务,统一由该脚本触发 Python
cat > /app/run\_task.sh <<'EOF'
#!/bin/bash
# 切换工作目录至 /app
cd /app

# 核心突破点:在执行 Python 前,自动加载上面导出的所有环境变量
# `xargs` 自动消除字符串中的换行与空格,`export` 恢复变量到当前 Shell 进程,彻底解决 URL 带 `#` 字符导致 Cron 语法报错的问题
export $ (cat /etc/environment | xargs)

# 显式使用容器内的全路径 python3 执行 main.py
# `>> /proc/1/fd/1 2>&1` 作用:将脚本的标准输出和错误日志强行重定向到容器的 1 号进程(PID 1)
# 这样你在宿主机执行 `docker logs -f singbox-nodes` 时才能实时看得到 Cron 触发的日志!
/usr/local/bin/python3 main.py >> /proc/1/fd/1 2>&1
EOF

# 给新生成的 run_task.sh 赋予可执行权限
chmod +x /app/run_task.sh


# ==========================================
# 5. 处理容器启动时的“立即执行”逻辑
# ==========================================
# 如果 RUN_ON_START=true,则容器启动时不用等定时时间,先立马跑一次 Python 转换脚本
if [ " $RUN\_ON\_START" = "true" ]; then
echo "RUN\_ON\_START=true,容器启动立即执行转换脚本"
/app/run\_task.sh
fi


# ==========================================
# 6. 配置并启动 Cron 定时守护进程
# ==========================================
# 清理可能存在的历史定时配置文件,确保环境干净
rm -f /etc/cron.d/app-task
touch /etc/cron.d/app-task

# 判断用户是否在环境变量中配置了 CRON\_TIME(如:"35 17 * * *")
if [ -n "$ {CRON_TIME}" ]; then
echo "配置定时任务: ${CRON\_TIME}"

# 向系统级 Cron 目录写入标准的 Cron 表达式:
# 语法格式:[时间表达式] [运行用户] [执行的命令]
# 保持命令极简:只调用 /app/run\_task.sh,不再在命令行硬塞带 # 号的变量字符串,彻底解决定时器失效问题
echo "$ {CRON_TIME} root /app/run_task.sh" >> /etc/cron.d/app-task

# 强制补上一个空行!Linux 的 cron 语法非常严格,如果文件末尾没有换行符,该条任务会被静默忽略
echo "" >> /etc/cron.d/app-task

# 赋予 Cron 配置文件合规的权限(Linux 要求 /etc/cron.d/ 下的文件权限必须是 0644,否则会被出于安全原因拒绝加载)
chmod 0644 /etc/cron.d/app-task

# 将配置文件注册到 crontab 服务中
crontab /etc/cron.d/app-task

# `exec cron -f` 作用:在前台(foreground)启动 cron 进程
# `exec` 会用 cron 替换当前 Shell 作为 PID 1 进程,保持 Docker 容器持续运行不退出
exec cron -f
else
# 若未设置 CRON_TIME 变量,则说明这是一次性任务,执行完 run_task.sh 后容器自动正常退出
echo "未设置 CRON_TIME,执行完成后容器退出"
fi
  • 消除了 # 截断SUB_URL_2 里的 # 保存在 /etc/environment 文件中,不会再触发 Cron 配置文件的语法注释机制。

  • 无限扩展:以后无论你再增加多少个变量(如 TIMEOUT_MS, MAX_LATENCY, SUB_URL_99),都不用再去改 CRON_FULL_CMD 拼接逻辑,Python 脚本都能 100% 正常读取到。

    修改好的测速main.py文件

    第一次修改的main.py文件:只修改了在容器中运行,把config_template和解析器拷贝到容器,在容器安装运行时再将config_template从容器中拷贝出来到挂载的目录中。