macvlan(ipvlan 同理)容器默认没有 IPv6:只有 link-local 地址,没有默认路由。原因不是网络不通,而是 Docker 在网络的 EnableIPv6 为 false 时,会主动把容器接口的 disable_ipv6 置 1。

下面是家庭宽带(前缀由运营商动态下发)场景下的正确配置。

先确认是这个问题

$ docker exec <容器> ip -6 addr show eth0
    inet6 fe80::ef5:b3ae:a64e:e782/64 scope link      # 只有 link-local

$ docker exec <容器> cat /proc/sys/net/ipv6/conf/eth0/disable_ipv6
1                                                     # 被管理性关闭

不要给 docker 网络声明 v6 子网

看着「正规」的写法是这样,但对家庭宽带是错的:

networks:
  macvlan_net:
    enable_ipv6: true
    ipam:
      config:
        - subnet: 2408:xxxx:xxxx:xxx::/64   # ← 别这么写

这个 /64 是运营商通过 DHCPv6-PD 下发的,它会变。前缀一换,容器还抱着旧前缀里切出来的地址,而那个前缀已经不再路由到你家了——表现是 IPv6 静默失效,配置文件里却白纸黑字写着一个看起来没问题的地址。

只有前缀是静态的场景(自己申请的 PI 段、机房固定分配、或者打算用 ULA 再做 NPT)才适合写死。

正确做法是不声明任何 v6 子网,只把 disable_ipv6 关掉,让内核听路由器的 RA 自己做 SLAAC。前缀变了内核跟着变,配置一个字不用改。

正确的写法

services:
  myapp:
    networks:
      macvlan_net:
        ipv4_address: 10.0.0.6
        driver_opts:
          com.docker.network.endpoint.sysctls: "net.ipv6.conf.IFNAME.disable_ipv6=0,net.ipv6.conf.IFNAME.accept_ra=2"
    sysctls:
      net.ipv6.conf.all.disable_ipv6: 0
      net.ipv6.conf.all.forwarding: 1     # 只有需要转发(旁路由/透明代理)时才加

networks:
  macvlan_net:
    driver: macvlan
    driver_opts:
      parent: enp1s0
    ipam:
      config:
        - subnet: 10.0.0.0/24
          gateway: 10.0.0.1

四个容易踩的点:

  • 接口级 sysctl 不能写在 sysctls: 里。新版 Docker 会直接拒绝:

    Error response from daemon: interface specific sysctl setting
    "net.ipv6.conf.eth0.accept_ra" must be supplied using driver option
    'com.docker.network.endpoint.sysctls'
    

    因为容器创建时接口还不一定存在,而且一个容器可以接多个网络,写死 eth0 语义上是含糊的。这类参数必须挂到网络端点上。

  • IFNAME 是字面量占位符,不要替换成 eth0。Docker 把接口塞进容器时会用真实网卡名替换它——这正是这套 API 存在的意义。

  • 多个参数用逗号分隔,写在同一个字符串里。

  • 端点选项挂在 services.<名>.networks.<网络>.driver_opts 下,不是顶层 networks: 里那个 driver_opts。这两个是不同的东西:顶层那个是网络驱动的选项(比如 parent),这里这个是端点的。

不带接口名的 net.ipv6.conf.all.*、net.ipv4.ip_forward 照旧写在 sysctls: 里,不受新规矩约束。

为什么 accept_ra 必须是 2

漏了这一步就前功尽弃,而且没有任何报错。

Linux 的逻辑是:一旦这个 netns 开启了 IPv6 转发,就认为自己是路由器,而路由器不该听别人的 RA——于是 accept_ra=1 被静默忽略。做旁路由/透明代理的容器几乎一定开着转发,两者一撞,结果就是 disable_ipv6 确实变成了 0、accept_ra 确实是 1,但地址就是配不上。

accept_ra=2 的含义正是「即使在转发状态下也接受 RA」。转发和 SLAAC 要同时要,就必须是 2。

如果容器不开转发,accept_ra=1 也能工作;但写 2 没有副作用,统一写 2 更省心。

验证

$ docker exec <容器> ip -6 addr show eth0 | grep global
    inet6 2408:xxxx:xxxx:xxx:a4be:81ff:fead:d100/64 scope global dynamic

$ docker exec <容器> ip -6 route | grep default
default via fe80::xxxx:xxxx:xxxx:xxxx dev eth0  metric 1024  expires 0sec

$ docker exec <容器> ping -c4 <目标的 v6>
4 packets transmitted, 4 packets received, 0% packet loss

拿到全局地址、有默认路由、能 ping 通,就成了。

那个吓人的 expires 不用管

    inet6 2408:xxxx:xxxx:xxx:a4be:81ff:fead:d100/64 scope global dynamic
       valid_lft 3061sec preferred_lft 2469sec

第一次看会以为地址 50 分钟后就没了。不是。这是 RA 携带的生命周期,每收到一次 RA 就重置,路由器的广播间隔远小于这个值,所以计数器一直在被顶回去。容器和宿主机的数值一模一样,因为它俩听的是同一份 RA。

前缀变了会怎样

这是选 SLAAC 的回报。运营商换前缀时:拨号重连 → 路由器拿到新 PD → 广播新前缀、同时把旧前缀 lifetime 置 0 → 容器内核弃用旧地址、自动配上新地址。配置不用改,容器不用重启。代价只是切换瞬间已建立的连接会断一次重连。

写死子网的话,这一步就是手工改 compose、重建网络、重建容器,而且得先意识到问题出在这儿。

小结

  • macvlan 容器没 IPv6,通常是 Docker 设的 disable_ipv6=1,不是网络不通
  • 家宽前缀是动态的,不要把 v6 子网写进 docker 网络,让容器 SLAAC
  • 接口级 sysctl 必须走 com.docker.network.endpoint.sysctls,用 IFNAME 占位
  • 开了转发就必须 accept_ra=2,否则 SLAAC 静默失效
  • valid_lft 的倒计时会被 RA 刷新,不用管

具体到 mihomo 旁路由怎么搭,在这篇:用 Docker + macvlan 跑 mihomo 旁路由。