背景
手上的 SD-WAN iWAN 客户端(sdwand)官方只给了裸机部署方式:一个二进制+ 一份配置文件,配 systemd 拉起来就行。但我想把它塞进 MikroTik RouterOS的 /container 里跑——路由器本身就是流量的枢纽,隧道建在路由器自己的容器环境里,比额外接一台旁路设备更省事。
这个想法本身没问题,sdwand 装进 Debian 容器里确实能跑起来。真正麻烦的是后面那步:怎么让局域网里其他设备的流量,经过容器里的这条隧道出网。容器有自己独立的网络命名空间,RouterOS 的主路由表根本看不到容器内部那张隧道网卡,这中间要打通的环节比想象中多得多,而且一个坑套着下一个坑,最后那个根因差点让我以为是硬件问题。
镜像本身:多架构,选 Debian 不选 Alpine
先说构建这块,相对简单。用 docker buildx 一次出 amd64/arm64 两个架构:
1 | # 单架构本地测试 |
基础镜像选的是 debian:bookworm-slim,不是看起来更小的 Alpine。原因是sdwand 这个二进制是 glibc 动态链接的,Alpine 用的是 musl,虽然有gcompat 兼容层,但覆盖不全——DNS 解析(NSS/getaddrinfo)、locale 这些边角场景容易出问题。实测两个架构的二进制往 Debian 镜像里一放,不用额外装任何包就能直接跑,干脆没必要为了省几十 MB 镜像体积去踩兼容层的坑。
容器支持两种配置方式:挂载配置文件(原有方式),或者设 IWAN_SERVER 等一串环境变量让 entrypoint 自动生成配置文件。后者是专门为 RouterOS 这类”路由器 Docker 管理界面只方便填环境变量,不方便挂文件”的场景加的。
第一关:RouterOS 的环境变量,加了却不生效
RouterOS 的 /container 设置环境变量分两步,而且这两步缺一不可:
1 | # 第一步: 建一组环境变量(list= 是这组变量的名字,key= 才是变量名) |
第一次配的时候只做了第一步,容器起来之后配置文件死活生成不出来,进去echo $IWAN_SERVER 发现是空的——建好的那组变量根本没关联到容器上。确认关联生效、且重启过容器之后,/container shell 进去看日志才正常:
1 | INIT peer <server>:<port> |
这里有个容易搞混的细节:ip addr 里这时候会多出两张网卡——容器自己的veth-*(RouterOS 分给容器的那张,比如 172.16.16.199)和隧道的iwan0(SD-WAN 网关分配的隧道 IP),是完全不同的两码事,排查的时候一定要分清楚。
还有个藏在 entrypoint 脚本设计里的细节:sdwand 启动后会自己 fork 出真正干活的隧道进程,原进程立即退出。所以 entrypoint 不能直接 exec 替换掉自己去启动 sdwand——那样容器的 PID 1 会在 fork 完成的瞬间跟着退出,把刚 fork 出来的隧道进程一起带走,容器直接就起不来了。
真正的硬仗:把局域网流量导进这条隧道
这才是开始。iwan0 只存在于容器自己的网络命名空间里,RouterOS 的主路由表完全看不到它。容器这边负责转发+NAT,RouterOS 那边负责把流量指过去,两头都要配。容器这边设一个 IWAN_NAT=1 的环境变量,entrypoint 会做三件事:开转发、加 NAT/MSS 规则、常驻刷新默认路由。听起来简单,但每一件事背后都有一个踩出来的坑,而且一个比一个邪门。
坑 1:规则配好了但完全不通
只做 NAT 和转发,没有同步给 iwan0 配默认路由的话,RouterOS 转发过来的包到了容器里一看,没有路由指向 iwan0,直接原路走默认路由退回 veth 网卡——包根本不会碰到 MASQUERADE 规则,iptables -t nat -L POSTROUTING-n -v 里包计数器永远是 0。表现就是”规则都配对了,但一点流量都过不去”。sdwand 建隧道这件事本身不会自动接管容器的默认路由,这一步必须手动做,跟裸机部署要手动加 --all-traffic 参数是同一个道理。
坑 2:局域网正常,外网打不开
加上默认路由之后,又出现”局域网设备互相访问正常,但访问外网网页卡死”的现象。典型的 MTU 黑洞:隧道 MTU(通常 1400)比局域网 MTU(1500)小,ping/DNS 这类小包能过,但网页/HTTPS 握手这种 TCP 大包,在没有正确 PMTU反馈的情况下直接被隧道吞掉。补一条 TCPMSS clamp 规则就好了。
坑 3:路由只加一次根本不够
sdwand 的隧道连接会自己周期性重连——日志里能看到 peer DATA timeout,实测只有在隧道长期空闲、没有真实流量的时候才会频繁触发(大约 40 多分钟一次;一直有真实流量在跑的话可以几个月零重连)。每次重连,iwan0 分配到的IP 都可能变,之前手动加的路由自然也就失效了。
坑 4:连”IP 变了才重新加”这个思路都不够用
于是改成监测 iwan0 的 IP,变了就重新加路由——结果实测发现哪怕 IP 没变,加上去的路由过一阵子也会自己消失,大概率是 RouterOS 自己周期性整理/重置了容器的路由配置,跟隧道重连完全没关系。表现是”手动加一次立刻验证有效,过了一会儿再查又没了”,而”只在 IP 变化时才重新加”的逻辑因为压根没检测到变化,永远不会去补这个被吃掉的路由。想明白这点之后思路反过来了:别去检测状态变没变,每一轮都无条件重新执行一遍 ip routereplace——反正这两条命令本身是幂等的,重复执行没有副作用。用两条 /1路由(0.0.0.0/1 + 128.0.0.0/1)覆盖默认路由,比原来的 0.0.0.0/0更精确、优先级更高,也不用费事去删除/恢复原来的默认网关。
坑 5:同样的代码,手动跑没事,放进启动脚本就是不生效
把”无条件刷新”这段逻辑放进一个后台子进程里跑,按理说应该没问题了。但诡异的是:用 /container shell 手动跑这段代码(哪怕原样复制、加set -eu、跨隧道重连测试)每次都成功,一旦放进 entrypoint.sh 自己的主执行流程里,作为容器 PID 1 本身在跑,就是不生效。排除了镜像没更新、变量值有问题、iptables 规则缺失这些可能性之后,把这段刷新逻辑单独丢进一个后台子进程 ( ... ) &,让 exec 替换掉主脚本进程的 tail -F 去当 PID1——这个思路其实是对的,但当时还顺带推翻了一个此前的错误结论:曾经以为”后台子进程在主进程 exec 替换成 tail 之后,会被 RouterOS 的容器supervisor 一起清理掉”,所以一度改成同步执行。后来才发现那次判断是错的:那次测试失败的真正原因,是后台任务里跑的还是”只在 IP 变化时才刷新”那个有 bug 的旧逻辑,跟放不放后台根本没关系——排查方向一度被自己的错误假设带偏了。
坑 6:真正卡住的地方,藏在一个想当然的假设里
改成后台子进程之后,ip route 依然没有那两条路由。用cat /proc/1/comm 一查才发现:entrypoint.sh 长时间停在调用/opt/sdwan/sdwand "$@" 这一行不动,根本没走到后面加路由的代码。
问题出在一个从一开始就带着的假设上:”sdwand 启动后会自己 fork 出隧道进程、原进程立即退出”——这个假设来自 x86_64 版本在 VPS 上跑了几个月的真实行为,deploy_iwan.sh 给 systemd 配 Type=forking 用的就是这个特性。但arm64 版本在 RouterOS 容器里实测根本不会自己退出,同步调用会一直卡在那一行——这条假设可能从一开始就只对 x86_64 成立,没人在 arm64上验证过。修法很直接:不再依赖这个假设,把 sdwand "$@" 本身也丢到后台(&) 执行,不管它内部到底 fork 不 fork、退不退出,脚本自己都不会被卡住,能继续往下把 NAT/路由刷新设置完。
坑 7:路由修好之后,隧道开始疯狂重连
以为大功告成,结果隧道变成精确每 18 秒 peer DATA timeout 重连一次。RouterOS 自己 ping SD-WAN 服务器是 0% 丢包,WAN 链路完全正常,说明不是网络质量问题。
原因回头看其实很基础:0.0.0.0/1 + 128.0.0.0/1 是无差别的兜底路由,没有排除 SD-WAN 服务器自己的 IP——sdwand 用来跟服务器保活的包,也被这两条路由重新导向了 iwan0,相当于想穿过隧道去联系隧道服务器本身,包出不去,保活断掉,服务器几秒后判定超时把会话踢了,重连后立刻重演,还顺带产生一堆重试流量。deploy_iwan.sh 的 --all-traffic 分支其实专门处理过这个问题(先排除到 server 的直连流量,防止隧道自环),这次照着写环境变量版本的时候漏抄了这一步,反而在完全不相关的方向——PID 1、后台任务、SR标签、账号冲突——排查了很久。修法是每轮刷新路由时,先把 IWAN_SERVER单独 ip route replace ... via <原网关> 排除出去,再设那两条 /1 兜底路由。
七个坑修完,IWAN_NAT=1 终于在 amd64 和真实的 arm64 硬件(MikroTikRB5009UPr 的 /container)上完整端到端跑通:转发生效、NAT/MSS 规则都在、隧道默认路由持续刷新、局域网设备通过 RouterOS 策略路由把流量导进容器、NAT 出隧道访问公网全部正常。顺带一提,amd64 本机的 QEMU 模拟环境里怎么都拿不到 nat 表,这个问题只出现在模拟层,真机上完全没有复现,白白虚惊一场。
RouterOS 那一侧:把想要的流量指过去
容器这边搞定之后,RouterOS 这边其实很常规。按目标网段分流,用普通静态路由:
1 | /ip route add dst-address=<目标网段/掩码> gateway=172.16.16.199 |
按来源 IP 分流(某台设备全部流量走隧道),RouterOS v7 推荐用/routing table + /routing rule,而不是老式的 routing-mark 写法:
1 | /routing table add name=via-iwan fib |
172.16.16.199 换成自己容器实际的 veth IP 就行,两种分流方式可以同时配,互不冲突。要不要做 NAT,取决于 SD-WAN 网关认不认识 RouterOS 后面的真实网段——网关如果只认识分配给你的隧道 IP,不开 NAT 回程流量根本回不来,除非网关那边专门给你的账号绑定了 LAN 网段的回程路由。不确定的话默认开 NAT 更省事。
最后一个坑:DNS 污染
流量按来源 IP 分流配好之后,又遇到一个诡异现象:目标设备 ping 8.8.8.8完全正常,直接访问 IP 也没问题,但是打开 Google 这类网站就是不行。
原因是 /routing rule 按来源地址匹配,只对目标设备自己发出的包生效。而如果这台设备的 DNS 服务器填的是 RouterOS 自己的网关地址(最常见的默认设置),域名解析这一步实际上是 RouterOS 自己发出的上游查询,包的来源地址是 RouterOS 自己,不是目标设备,不会命中那条按来源匹配的策略路由。于是解析请求照常走正常出口,被域内 DNS 污染返回了错误 IP——用curl --resolve 跳过 DNS 直连正确 IP 可以验证这个判断。
两种修法:改设备自己的 DNS(换成 8.8.8.8/1.1.1.1 这类外部地址,让解析查询的来源地址变成设备自己),缺点是要逐台设备改;更推荐的做法是反过来按目标地址加路由,不管来源是谁:
1 | /ip dns set servers=8.8.8.8,1.1.1.1 |
这样 RouterOS 自己发起的上游 DNS 查询,目标地址就是 8.8.8.8/1.1.1.1,这条路由不管来源是谁都会命中,RouterOS 自己解析时也跟着走隧道,局域网里所有用 RouterOS 当 DNS 的设备全部自动受益,不用逐台设备改配置。
小结
这一路踩下来,最大的感受是:容器化本身的坑反而是最少的(镜像选型、entrypoint 的 PID 1 处理),真正费时间的是”流量怎么从 RouterOS 的主路由表绕进容器自己的网络命名空间、再绕出来”这条链路上的一连串细节——路由会被周期性吃掉、不同架构的二进制行为不一致、兜底路由没排除隧道自身的保活流量——这些坑单独看都不复杂,连环踩的时候却很容易把排查方向带偏到完全无关的地方。最后能稳定跑下来的版本,靠的不是什么精巧设计,而是把”检测状态变化再处理”全部换成”无条件、幂等地每轮都重新来一遍”这个笨办法,反而是最可靠的。

