在 BIRD2 主机上将IPv6 地址池绑定到新网卡并实现多源 IPv6 出网
前言
这次的目标很明确:
- 机器已经在运行 BIRD2,并且正常通过 BGP 广播 IPv6 前缀
- 现在希望把一段
/47IPv6 前缀作为本机可使用的 IPv6 地址池 - 同时为了让结构更清晰,计划将这段地址“挂”到一个新的逻辑网卡上
- 最终实现:
- 应用可以绑定这段
/47里的任意 IPv6 地址 - 流量仍然从真实物理网卡出网
- BIRD 只负责广播,不去干扰系统默认路由和地址管理
- 应用可以绑定这段
说明:本文中的 IPv4、IPv6、ASN、BGP 邻居、认证口令等信息都已经替换为文档示例值,用于记录配置思路,避免暴露真实生产环境信息。
现有 BIRD2 配置梳理
当前机器上的 BIRD2 配置大致有几个关键点:
protocol direct只跟踪dummy*接口protocol kernel对 IPv4 / IPv6 都是import none; export none;protocol static static_v6里通过reject的方式把需要广播的 IPv6 前缀放进 BIRD RIB- BGP 对外只导出
RTS_STATIC的 IPv6 前缀
也就是说,这套设计本身就已经把职责分得很清楚了:
- BIRD 负责广播
- 内核负责真实转发与本机地址绑定
- 不会让 BIRD 去改内核默认路由
这个思路是对的,应该继续保持。
先说结论:这件事不应该只改 BIRD
很多人在第一次做这类配置时,会直觉认为“既然是广播的前缀,就在 BIRD 里把它绑到接口上”。
实际上不是这样。
BIRD 的职责
BIRD 只需要完成一件事:
- 把目标
/47前缀继续宣告出去
Linux 内核的职责
真正让这段 /47:
- 可以在本机
bind() - 可以作为源地址发起连接
- 可以通过真实物理网卡出网
靠的是 Linux 网络栈,而不是 BIRD。
所以最终方案是:
- 新建一个 dummy 网卡,例如
dummy47 - 给它放一个池内“锚点地址”
- 通过
ip -6 route add local ...告诉内核:这一整段/47都属于本机 - 开启
net.ipv6.ip_nonlocal_bind=1,允许应用绑定未显式加到接口上的地址
为什么要用 dummy 网卡
我这里没有直接把整段 /47 塞进真实物理网卡,而是单独新建一个逻辑接口 dummy47,原因有三点:
职责更清晰
dummy47表示“这是一段本机管理的业务地址池”- 物理网卡只负责真正收发包
更容易排障
- 看接口配置时一眼就能知道这段地址池是专门干什么的
和现有 BIRD 配置兼容
- 当前
protocol direct已经匹配dummy* - 新建
dummy47后不需要大改
- 当前
要注意的是:
dummy47不是实际出网网卡。
真正的发包依然会走默认路由对应的物理接口,比如eth0或ens3。
参考思路:local route + ip_nonlocal_bind
这次配置的核心思路是:
- 不去枚举添加大量静态地址
- 不去把整段前缀当作普通地址直接
addr add - 而是通过:
1 | ip -6 route add local 2001:db8:4700::/47 dev lo |
让内核认为:
- 这整段
/47都是“本机可接收”的地址 - 进程可以直接绑定其中任意地址
这是最关键的两步。
脱敏后的 BIRD2 配置示意
下面是一份经过脱敏处理的配置骨架,用来说明和本文相关的关键部分。
1 | ################################################ |
BIRD2 配置是否需要修改
结论
大多数情况下几乎不用改。
因为当前已经有:
1 | protocol direct { |
所以只要新的逻辑网卡叫 dummy47,它天然就会被匹配。
而 IPv6 静态广播部分本身也是对的:
1 | protocol static static_v6 { |
为什么不用把 /47 从 reject 改掉
因为这里的 reject 路由只是为了:
- 让前缀进入 BIRD RIB
- 满足 BGP 导出条件
- 作为“我拥有并广播这个前缀”的声明
它不是为了给 Linux 内核做真实转发或本机地址绑定。
所以 /47 继续保留 reject 方式是合理的。
实际配置步骤
下面开始做 Linux 网络配置。
第一步:创建新的 dummy 网卡
先加载模块并创建逻辑网卡:
1 | modprobe dummy |
执行后可以用下面命令确认:
1 | ip link show dummy47 |
第二步:给 dummy47 加一个锚点地址
虽然我们要使用的是整个 /47 地址池,但还是建议在 dummy47 上放一个明确的单地址作为“锚点”:
1 | ip -6 addr add 2001:db8:4700::1/128 dev dummy47 |
这里使用 /128 即可,不需要把整个 /47 当作普通接口地址加上去。
为什么不用:
1 | ip -6 addr add 2001:db8:4700::/47 dev dummy47 |
因为这并不能达到“本机可以绑定整个 /47”的目的。
它只是给接口加了一个 connected route 的语义,不等于把整段前缀都变成可直接绑定的本地地址池。
第三步:把整个 /47 加为 local route
这一步是最关键的。
1 | ip -6 route replace local 2001:db8:4700::/47 dev lo |
这样内核会认为:
2001:db8:4700::/47整段都属于本机 local address space
也就是说,只要后续应用去绑定这一段里的任意 IPv6 地址,内核都会接受。
为什么挂到 lo
因为这是一条 local route,它表达的意思不是“从 loopback 发包”,而是:
- 这段前缀按本地地址处理
- 由本机持有
这是 Linux 处理这类场景的标准方式。
第四步:开启 ip_nonlocal_bind
即使有了 local route,很多程序默认仍然不能随便绑定“没有显式加在接口上的地址”。
所以还要开启:
1 | sysctl -w net.ipv6.ip_nonlocal_bind=1 |
为了持久化,建议写入单独的 sysctl 配置文件:
1 | cat >/etc/sysctl.d/99-ipv6-pool.conf <<'EOF' |
第五步:重新加载 BIRD2
如果 BIRD 配置本身没有改动,这一步理论上不是必须的。
但如果顺手调整了配置,执行一次重载最保险:
1 | birdc configure |
然后检查路由是否在 BIRD RIB 中:
1 | birdc show route for 2001:db8:4700::/47 |
验证方法
查看 dummy47 地址
1 | ip -6 addr show dev dummy47 |
预期至少能看到:
1 | inet6 2001:db8:4700::1/128 scope global |
查看 local route
1 | ip -6 route show table local | grep '2001:db8:4700::/47' |
预期看到类似:
1 | local 2001:db8:4700::/47 dev lo |
用指定源 IPv6 测试出网
这里不要写接口名 dummy47,而是直接绑定具体源地址:
1 | curl -6 --interface 2001:db8:4700::100 https://ipv6.ip.sb |
如果返回值分别就是你指定的源地址,说明配置成功。
一个容易踩坑的点:绑定“接口”不等于绑定“源地址”
很多人会下意识这样测:
1 | curl -6 --interface dummy47 https://ipv6.ip.sb |
这个测试方式并不适合这里。
因为 dummy47 不是物理出口网卡,它只是一个逻辑承载接口。
真正的流量出网仍然依赖系统默认路由,一般还是会走类似 eth0 或 ens3 这样的真实物理网卡。
所以正确姿势应该是:
- 绑定具体源 IPv6 地址
- 而不是绑定
dummy47这个接口名
即:
1 | curl -6 --interface 2001:db8:4700::100 https://ipv6.ip.sb |
关于 ndppd:这次场景一般不需要
一些“整段 IPv6 绑定并直接使用”的文章里,会额外提到 ndppd。
那通常是因为上游把 IPv6 前缀作为 on-link 网络 给到机器,需要对每个地址做 NDP 邻居发现。
但这里的场景不同:
- 机器已经通过 BGP 广播这段前缀
- 上游更像是把这段前缀路由到这台机器
- 而不是要求本机在二层上对整段地址逐个做 NDP 响应
所以在这种设计下,一般:
- 不需要
ndppd - 只需要:
- BIRD 正常广播前缀
- 内核有
local route - 开启
ip_nonlocal_bind
只有当服务商明确要求这段前缀是二层直连、必须对目标地址进行邻居发现时,才需要额外考虑 ndppd 或 proxy_ndp。
持久化方案:systemd service
为了避免重启后丢失,我最后把这套配置写成了一个简单的 systemd 服务。
创建文件:
1 | vim /etc/systemd/system/dummy47-v6pool.service |
内容如下:
1 | [Unit] |
然后执行:
1 | systemctl daemon-reload |
一份完整的操作命令汇总
为了方便复制,这里把完整步骤汇总成一段:
1 | modprobe dummy |
验证:
1 | ip -6 addr show dev dummy47 |
我的最终理解
这次配置做完之后,整个结构就很清晰了:
BIRD2 做什么
- 继续通过 BGP 广播目标
/47 - 继续使用
static ... reject的方式将前缀放入 BIRD RIB - 不导入、不导出 Linux 内核路由
Linux 内核做什么
- 通过
dummy47提供逻辑承载 - 通过
local route将整个/47标记为本机地址空间 - 通过
ip_nonlocal_bind允许应用绑定池中任意 IPv6
应用怎么用
- 不是绑定
dummy47 - 而是绑定目标
/47中的具体某个 IPv6 作为源地址 - 流量最终仍然走系统默认路由,从真实物理接口出网
后记
一开始我也很容易把“前缀广播”和“本机地址绑定”混成一件事。
但真正拆开以后就会发现,这其实是两个层面的事情:
- 路由控制面:BIRD/BGP
- 主机数据面:Linux 网络栈
把这两者分开处理后,整套方案就会非常稳定,也更容易维护和排障。
如果后续还要继续扩展这台机器承载更多 IPv6 地址池,我会优先继续沿用这个思路:
- BIRD 只负责广播
- 业务地址池通过
dummy + local route + ip_nonlocal_bind管理
这样结构最干净。
在 BIRD2 主机上将IPv6 地址池绑定到新网卡并实现多源 IPv6 出网