用Docker和BPF打造家庭智能流量控制系统
2026/9/16 3:06:25 网站建设 项目流程

先说结论:这个项目我折腾了两个周末,最终把所有代码和配置都收进了同一个 Docker 工作目录里。它的名字叫“zapret”,实际作用并不是去“解禁”什么,而是对我家里这块频繁被 Discord 和 YouTube 霸占的共享网络做了一次“精准治理”——既能让这两个服务继续可用,又能在带宽吃紧的时候自动限速,还顺手做了一套媒体资源本地化归档。这篇文章就把整个设计思路和实操过程完整记录下来。

先说清楚它解决的是什么问题:家里几十台设备同时在线,有人开着 YouTube 4K 直播,有人在 Discord 里做语音会议,还有人走 HTTP 下载大文件。传统的路由器 QoS 只能按 IP 或端口粗暴限速,完全看不懂应用层协议。我需要的效果是“识别的目标是 YouTube/Discord,但处置策略可以分级”:白天工作时段给足带宽,晚上娱乐时段限制缓存视频的预加载,极端拥塞时直接把非交互流量降到最低。zapret 这个项目就是为了这个需求写的。

适合看这篇文章的人有两类:一是家里网络设备多、想自己控制流量分配的朋友;二是对自托管、数据本地化、Docker 容器编排感兴趣的技术爱好者。如果你只有一台旧电脑或者一块树莓派,也能按下面的方案跑起来。整个过程不需要昂贵的硬件,也不需要改路由器固件,我会把每一步命令、配置文件都贴出来。

1. 项目整体设计与思路拆解

1.1 为什么不做“一刀切”封禁

最开始我也想过最简单的办法:在路由器上把 YouTube 和 Discord 的 IP 段拉黑,一了百了。但这样做的后果很严重——家里其他人的正常工作直接受影响,而且 Discord 的语音服务用的是动态端口和动态 IP,硬封要么漏掉一堆连接,要么误伤大量共用的 CDN 节点。

所以我给自己定了三条设计目标。

第一,识别要准确。不能只看目标 IP,因为 YouTube 的视频内容会走 Google 的全球 CDN,IP 段非常庞杂且经常变。我需要从流量特征上判断:TLS 握手时的 SNI 字段、证书里的 common name、DNS 查询记录,这些都能作为判断依据。第二,处置要温和。默认策略是全放行,只有带宽超过阈值时,才对识别出的视频流做限速,而不是直接丢包。第三,要有可视化和通知。限速发生的时候,至少要有一个仪表盘或者消息推送,让我知道网络正在经历什么。

最终我把整个项目拆成了四个子模块:流量采集模块负责抓包和特征提取;策略引擎负责判断处置方式;执行模块负责用流量控制工具调整队列;通知模块通过 Discord 机器人把关键事件发到群里。这四块都跑在同一个 Docker Compose 网络里,既能独立重启,又能通过内部的 HTTP API 互相协作。

1.2 方案选型:容器化加本地方案

我选 Docker 的一个重要原因是可移植性。这套系统最初在 x86 工控机上调通,后来我把同样的配置复制到一台树莓派 4B 上跑,几乎没有改任何代码。容器化带来的另一个好处是依赖隔离——流量控制工具、Python 脚本、数据库互不污染,删掉重建都只需要一条命令。

不过要注意,容器网络默认走 NAT,这对需要抓取宿主机流量的场景是个障碍。我的解决方案很粗暴:在 docker-compose.yml 里给抓包容器单独配置 network_mode: host。只有这个容器需要共享宿主机的网络栈,其他服务仍然走隔离网络。如果你也在做类似的东西,记住“抓包容器必须 host 模式,其他容器保持大网段内网”这个原则,能省掉很多奇怪的网络问题。

工具链的选型也花了不少时间。流量统计我用的是 BPF + BCC 工具集,比较轻量,不像 ntopng 那样要起一堆服务。带宽整形用的是 Linux 自带的 tc,配合 HTB 队列和 u32 过滤器。DNS 分组用的是 dnsmasq,它本身支持按客户端 MAC 下发不同解析结果,这点非常适合做设备分组。下载模块交给 yt-dlp,它是目前对 YouTube 适配最好的下载工具之一。

2. 环境准备与基础网络拓扑

2.1 硬件要求和系统准备

先说说硬件。我用的是手上闲置的一台 J4125 四口软路由,4GB 内存,128GB SSD。如果你手头没有软路由,用一台普通的双网口迷你主机也行,甚至树莓派加一个 USB 千兆网卡也能跑。关键在于这台机器至少要有一个网口用于“旁路监听”,另一个网口维持正常的上网链路。

操作系统我建议用 Debian 12 或者 Ubuntu 22.04 LTS,内核版本至少 5.15。早期我在旧内核上跑 BPF 程序遇到过不少兼容性问题,升级内核之后就稳定了。系统装好后,先做两件事:第一,开启 IP 转发,否则后续的流量整形策略无法作用到网关路径上;第二,安装 Docker Engine 和 Docker Compose 插件,这里我默认你已经会基础操作,不再赘述。

还有一个特别容易被忽略的点:SELinux 或者 AppArmor 要检查一下状态。Ubuntu 默认开启 AppArmor,有时会拦截容器内对 /sys/fs/bpf 的访问。最简单的做法是先把 Docker 服务加入 AppArmor 的 complain 模式,或者直接给 docker 进程加一个 profile 白名单,等系统调通之后再收紧策略。

2.2 目录规划与 Compose 编排

我的工作目录长这样:

~/zapret/ ├── docker-compose.yml ├── .env ├── capture/ │ ├── Dockerfile │ └── app/ │ ├── sniffer.py │ └── classify.py ├── manager/ │ ├── Dockerfile │ └── app/ │ ├── policy.py │ └── api.py ├── dns/ │ ├── dnsmasq.conf │ └── mac-groups.conf ├── downloader/ │ ├── Dockerfile │ └── app/ │ ├── download_job.py │ └── playlist.yaml ├── notify/ │ └── discord_bot.py └── data/ ├── metrics.db └── media/

每个子目录对应一个独立容器,用 docker-compose 统一编排。data 目录通过卷挂载到多个容器,这样采集到的历史数据、下载好的媒体文件都能持久化保存,也方便我在宿主机上直接查看。

compose 文件的核心部分是网络配置。我定义了一个名为 zapnet 的自定义桥接网络,容器之间通过服务名通信。抓包容器独立使用 host 网络,dnsmasq 容器则映射宿主机的 53 端口,接管局域网内所有 DNS 查询。配置里还有一个共享卷 backup-data,用于存放下载任务文件和视频归档。

3. 核心模块一:流量识别与带宽整形

3.1 基于 BPF 特征识别的流量统计

流量识别是整个系统最敏感的部分。我不想知道用户具体看了什么内容,只需要区分“这是不是 YouTube/Discord 流量”。最可靠的特征就是 TLS 握手阶段的 SNI 字段。当你访问 youtube.com 时,ClientHello 里会明文携带目标服务器的域名,这个字段在加密流量里也是可见的,因为认证和加密过程需要先知道要连接哪个域名。

我在 capture/app/sniffer.py 里写了一个基于 BPF 的抓包程序,核心逻辑是监听 TCP 443 端口,截获每个连接的前几个数据包,解析出 SNI 域名。然后用 classify.py 对域名做匹配:包含 googlevideo.com、ytimg.com 的归为 YouTube 视频流;包含 discord.gg、discordapp.com、discord.com 的归为 Discord 流量;其他域名的连接标记为 unknown,留给上层策略做默认处理。

这里有一个细节:不是所有 YouTube 流量都值得限速。比如打开 youtube.com 首页、看评论区、搜索视频,这些交互式流量优先级很高,如果限速反而会让页面加载卡顿。只有 googlevideo.com 这个域名下的连接才是真正的视频文件传输。我的判别逻辑是,只要 SNI 是 googlevideo.com,就把这个连接标记为 “bulk_video”,并计入该客户端 IP 的总视频流量桶里,超过阈值就触发限速策略。

3.2 用 tc 和 HTB 做带宽分级限制

带宽整形的工具是 Linux 的 tc。我把物理网卡 eth0 的出口速率设置为 100Mbps(假设这是你的上行带宽),然后在根句柄上挂一个 HTB 队列。HTB 的好处是支持叶子节点单独保证带宽,同时可以借用父节点的空闲带宽——这正好符合“平时全速跑、拥塞时按优先级丢弃”的需求。

具体配置思路如下:根 qdisc 句柄 1: 下挂三个 class,分别是 1:10 交互流量、1:20 默认流量、1:30 视频流量。交互流量保证 20Mbps,默认流量保证 10Mbps,视频流量保证 5Mbps。同时给视频流量设置 ceil 为 60Mbps,也就是允许它突发占用空闲带宽,但一旦超过就不会继续增长。配合 fq_codel 算法,队列中每个数据包的排队时间都能得到控制,避免低优先级流量把延迟拉满。

如果你对 QoS 不熟,可以这样理解:class 就是车道,rate 是每条车道的最低保障速度,ceil 是最高限速。视频流这个车道平时可以跑得很快,但一旦和交互流抢道,它会自动减速让行。

3.3 阈值触发与自动处置逻辑

光有识别和限速还不够,我需要一个能自动决策的控制器。manager/app/policy.py 每 30 秒从采集模块拉一次当前各客户端 IP 的视频流量速率。如果某个 IP 的视频速率超过 30Mbps,并且持续超过 2 分钟,就会触发降级规则:该 IP 的所有流量被重新分类到 1:30 视频 class,并同时向通知模块发一条告警。

如果是 Discord 流量触发阈值(比如语音会议占满了上行带宽),处置方式稍微不同:我不会直接限速,而是标记该 IP 为“高优先级豁免 30 分钟”。因为在实时语音场景下,降低速率上限对通话质量没有帮助,反而会让编码器产生更多延迟。这种情况更适合直接限制同时并发的连接数,所以我用一个 eBPF 计数器统计来自该 IP 的新连接频率,超过每秒 50 个新连接就丢包,避免异常流量拖垮整个上行。

自动处置的效果可以在仪表盘上实时看到。我用一个简单的 Flask 应用暴露 /metrics 接口,把每个 class 的实时速率、设备分组、触发历史都输出成 JSON,前端用 uPlot 画折线图。每当有降级事件,图表都会出现一个明显的小平台期,同时 Discord 群里会收到通知。

4. 核心模块二:DNS 策略与设备分组管理

4.1 用 dnsmasq 给不同设备发不同解析结果

DNS 层面的策略主要用于设备分组。我在 dnsmasq.conf 里定义了三个组别:work 组(办公设备)、media 组(电视/盒子/游戏机)、guests 组(访客设备)。分组依据是 DHCP 分配时记录的 MAC 地址。

分组的效果主要体现在 DNS 解析结果上。比如 media 组的设备请求 googlevideo.com 时,dnsmasq 会返回一个内部域名解析地址,指向我本地的缓存服务器。这个缓存服务器只保留过去 48 小时观看过的视频分片,命中就直接从局域网读取,不再占用外网带宽。work 组的设备解析则直接返回公网 DNS 结果,完全不经过缓存层。

具体实现也很简单。dnsmasq 支持 dhcp-host 指令固定 MAC 和 IP 的对应关系,然后配合 dhcp-option 给不同组下发不同的 DNS 服务器地址。如果你希望某个设备完全走默认公网解析,就把它的 MAC 放进 work 组列表,其他设备统统归入 media 组。这样即使家里来客人连了 WiFi,也不会因为自动走了缓存链路而觉得网络奇怪。

4.2 动态分组:如何识别一台未知设备

新设备第一次接入网络时,它不属于任何组。我的策略是默认进 guests 组,只分配最基础的 DNS 解析和 5Mbps 带宽保障。如果家长想给孩子的平板提升优先级,可以打开 mygroup.sh 脚本,输入 MAC 地址,选择目标组,脚本会自动追加一条 dhcp-host 配置并重载 dnsmasq。

这个脚本有一个小坑:dnsmasq 重载配置时会短暂中断所有设备的 DNS 解析,大约 200 毫秒。对普通浏览没有影响,但在 Zoom 会议进行中可能会造成瞬间的网络抖动。后来我改为用 DBus 动态更新 dnsmasq 配置,避免全量重载。如果你也想做类似改动,可以读一下 dnsmasq 的 DBus 接口文档,里面有详细的 AddDHCPHost 方法,按着例子写不难。

另外,我在 dns 服务里加了一个上游 DNS 的白名单。所有 DNS 查询都先发给本机 dnsmasq,再由它转发给配置的上游公共 DNS。这避免了恶意设备自己配置 DNS 绕过分组策略。实现上用 iptables 在局域网段内拦截发往非 53 端口的 DNS 响应包,只允许目的端口为 53 的响应通过,细节见下面的配置示例。

5. 核心模块三:媒体资源本地化与消息通知

5.1 构建可订阅的 YouTube 下载队列

很多人问过我,为什么要在网络治理工具里嵌入视频下载功能?这其实是为了“削峰填谷”。我家里有一个固定的学习频道,每晚上传新视频,家里人都会第一时间观看。与其让直播流在晚上高峰期反复占用带宽,不如在网络空闲时段(比如凌晨 3 点)自动把视频拉取到本地,白天再从局域网内播放。

下载队列用 yt-dlp 实现。我在 downloader/app/playlist.yaml 里维护了一个订阅列表,每行一个频道 URL 或者视频 URL,还可以指定输出目录、清晰度上限、是否下载字幕。定时任务每个小时扫一次列表,如果有新视频就加入队列,没有就跳过。

这里要特别提醒一下版权问题:我只把下载功能用于自己拥有或明确允许离线观看的内容,比如公开的课程、创用 CC 授权的视频、以及管理员授权归档的内部培训资料。做这类自动化下载时,一定要分清个人学习和传播盗版之间的界限,不要触碰红线。合理使用是前提,技术本身是中立的。

yt-dlp 的下载命令我用的是:

yt-dlp \ --config-location /app/config/ytdlp.conf \ --batch-file /app/config/playlist.txt \ --write-info-json \ --write-thumbnail \ --embed-metadata \ --dateafter today-30

--dateafter 参数只抓最近 30 天更新的视频,避免把整个频道几万条历史记录都拉下来。下载完成后,脚本会生成一个 info.json 文件,里面包含标题、时长、封面和描述。这个文件同时会被通知模块读取,用来在 Discord 里发一条“新视频已归档”的消息。

5.2 用 Discord Webhook 做事件推送

Discord 既有聊天功能,又能通过 Webhook 接收自定义消息。我把整个 zapret 系统的状态通知都集中到一个私有频道的 Webhook 里。流量降级、DNS 分组变更、视频下载完成、磁盘空间不足,这些事件都会发成不同颜色的 embed 消息。

用 Webhook 的好处是不需要单独的 Discord 机器人账号,也不需要维护长连接。只要拿到 Webhook URL,用 Python 的 requests 库就能发消息。我的 notify/discord_bot.py 里封装了一个 send_embed 函数,输入标题、描述、颜色,就生成一条标准化的嵌入式消息。实测下来,群里消息延迟不超过两秒,完全够用。

如果内部网络出了严重问题(比如磁盘快满了),我还会让脚本同时调用系统的 curl 命令发送一条紧急企微机器人消息,确保值班人能随时收到告警。两条渠道互不干扰,一条挂了另一条还能顶上。

5.3 数据归档与后续扩展方向

数据目录里除了视频文件,还会保存每天的流量摘要 CSV 和告警日志。我用一个简单的 Python 脚本把这些文件打包成 tar.gz,然后通过 rsync 同步到另一台 NAS 上做异地备份。归档文件按日期命名,保留 180 天,超期的自动清理。

整个系统从两个月前跑到现在,最大的收获是网络不再“阴晴不定”。以前晚上全家同时看视频,要么我的办公网课卡成幻灯片,要么 Discord 语音断续。现在根据白天的流量统计,我调整了几次阈值参数,让视频流在晚上 20 点到 23 点之间最高只占用 50% 的带宽,其余时段全速放行。实测下来,4K 视频刚开始加载需要等两三秒缓冲,但一旦开始播放就非常稳定,语音通话的延迟始终保持在 80 毫秒以内。

6. 常见问题与排查实录

6.1 BPF 程序加载权限不足

抓包容器第一次启动时,报错 “Operation not permitted”。原因是容器启用了默认的 seccomp 限制,禁止了 bpf 系统调用。解决办法是修改容器配置文件,加上 capabilities 参数,允许 CAP_BPF 和 CAP_SYS_ADMIN。我还需要在宿主机上执行 sysctl kernel.unprivileged_bpf_disabled=0,给非特权用户放行 BPF。

顺带提一个我在树莓派上踩过的坑:树莓派默认的内核配置没有开启 CONFIG_DEBUG_INFO_BTF,BCC 工具需要这个选项加载内核头文件。如果不确定自己的内核支不支持,可以先在宿主机上跑一下 bpftool btf dump file /sys/kernel/btf/vmlinux format c 能看到输出就支持。

6.2 tc 规则不生效或冲突

tc 的匹配规则很容易因为 qdisc 句柄重复而互相覆盖。我的建议是,每次修改规则前先执行 tc qdisc del dev eth0 root 清空所有旧规则,再重新加载新配置。另外,HTB 的 ceil 值不能超过网卡实际速率,否则物理网卡会直接把包丢弃,而不是排队。

还有一个更隐蔽的问题:如果网卡开启了 hardware offload,tcpdump 能抓到包,但 tc 规则匹配不到。你需要在网卡上关闭 GRO/LRO 特性(ethtool -K eth0 gro off),否则流量整形只在协议栈层面有效,而硬件已经把所有分片合并成了巨大帧,队列根本来不及做区分。

6.3 DNS 分组不生效,所有设备解析结果相同

这个问题的排查顺序很重要。先确认客户端拿到的 DNS 服务器是不是你的 dnsmasq 地址,可以用 nslookup 测试。如果解析结果一样,再看 dnsmasq 日志里有没有 match 到对应的 MAC。通常原因只有一个:客户端没有走 DHCP 获取地址,而是手动设置了静态 IP,所以 dnsmasq 的 DHCP 表里没有它的 MAC 记录。

解决办法是在 dnsmasq 配置里加一条 dhcp-host 指令,把静态 IP 和 MAC 绑定起来,并为它显式指定分组。还有一个偏方:如果你不想管理 MAC,也可以按 IP 段分组,比如 192.168.50.0/24 是 media 组,192.168.51.0/24 是 work 组。这个方案的灵活性差一些,但配置起来更简单。

6.4 yt-dlp 下载失败或速度极慢

最常见的失败原因是 yt-dlp 版本太旧,YouTube 改了算法导致解析失败。建议容器内使用 nightly 版本,并且每个星期自动更新一次。我在 Dockerfile 里直接写了一条定时的 cron 命令,每周一凌晨三点执行 pip install --upgrade yt-dlp。

如果下载速度很慢,优先检查是否触发了上面的带宽降级策略。因为下载器进程的流量目标同样是 googlevideo.com,在晚高峰会被自动降级。我的解决方案是给下载器容器的 IP 加了一个 tc 规则的例外,只允许它在凌晨 2 点到 6 点之间全速下载,其他时段限制到 10Mbps,这样既不影响白天网络,也能保证归档任务能完成。

6.5 容器网络异常,无法和其他服务通信

这个问题跟我最开始提到的网络模式有关。如果你把抓包容器也放到自定义网络里,它自然无法看到宿主机的物理网卡流量;而导致它无法访问其他容器的原因,往往是 DNS 配置没有指向 compose 的内置 DNS。解决办法是确认 docker-compose.yml 里的 dns: 配置没有被人为覆盖,并且容器启动命令里没有显式传入 --dns 参数。

还有一种情况是容器内部时区不对,导致所有基于时间的定时任务全乱了。所有需要跑 cron 的容器,统一在环境变量里加上 TZ=Asia/Shanghai,并在 Dockerfile 里安装 tzdata 包,问题就解决了。这个坑我查了一个下午才定位,现在说出来希望你绕开。

7. 复盘与实战体会

zapret 项目做到现在,最大的体会是“网络治理”四个字听起来简单,做起来全是细节。识别一条 YouTube 连接和识别一个正在进行的多人视频会议,技术思路完全不同;给视频流降级和维护语音质量之间,需要做非常精细的权衡。在这个过程中,我几乎把 Linux 网络栈相关的知识重新过了一遍,从 TC 到 BPF 再到 DNS,每一项都是在真实需求驱动下学进去的,比看书扎实得多。

如果你也想在自己的环境里跑一套类似的系统,我的建议是先从最小的闭环开始:先在单台 Linux 机器上跑通 DNS 分组,再叠加一个抓包统计,最后才是自动限速和下载队列。不要一开始就追求大而全,否则排查问题时根本不知道是网络问题、容器问题还是代码逻辑问题。

最后分享一个小技巧:所有配置和脚本都用 Git 管理,每次调整都写清楚 commit message。这样出问题之后,可以直接用 git diff 看是哪次改动引入了故障。我的这套 zapret 配置从最初到现在有四十多次提交,正是靠着这些记录,回滚任何一次变更只需要一分钟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询