在 BIRD2 主机上将IPv6 地址池绑定到新网卡并实现多源 IPv6 出网

前言

这次的目标很明确:

  • 机器已经在运行 BIRD2,并且正常通过 BGP 广播 IPv6 前缀
  • 现在希望把一段 /47 IPv6 前缀作为本机可使用的 IPv6 地址池
  • 同时为了让结构更清晰,计划将这段地址“挂”到一个新的逻辑网卡上
  • 最终实现:
    • 应用可以绑定这段 /47 里的任意 IPv6 地址
    • 流量仍然从真实物理网卡出网
    • BIRD 只负责广播,不去干扰系统默认路由和地址管理

说明:本文中的 IPv4、IPv6、ASN、BGP 邻居、认证口令等信息都已经替换为文档示例值,用于记录配置思路,避免暴露真实生产环境信息。


现有 BIRD2 配置梳理

当前机器上的 BIRD2 配置大致有几个关键点:

  1. protocol direct 只跟踪 dummy* 接口
  2. protocol kernel 对 IPv4 / IPv6 都是 import none; export none;
  3. protocol static static_v6 里通过 reject 的方式把需要广播的 IPv6 前缀放进 BIRD RIB
  4. 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,原因有三点:

  1. 职责更清晰

    • dummy47 表示“这是一段本机管理的业务地址池”
    • 物理网卡只负责真正收发包
  2. 更容易排障

    • 看接口配置时一眼就能知道这段地址池是专门干什么的
  3. 和现有 BIRD 配置兼容

    • 当前 protocol direct 已经匹配 dummy*
    • 新建 dummy47 后不需要大改

要注意的是:

dummy47 不是实际出网网卡。
真正的发包依然会走默认路由对应的物理接口,比如 eth0ens3


参考思路:local route + ip_nonlocal_bind

这次配置的核心思路是:

  • 不去枚举添加大量静态地址
  • 不去把整段前缀当作普通地址直接 addr add
  • 而是通过:
1
2
ip -6 route add local 2001:db8:4700::/47 dev lo
sysctl net.ipv6.ip_nonlocal_bind=1

让内核认为:

  • 这整段 /47 都是“本机可接收”的地址
  • 进程可以直接绑定其中任意地址

这是最关键的两步。


脱敏后的 BIRD2 配置示意

下面是一份经过脱敏处理的配置骨架,用来说明和本文相关的关键部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
################################################
# Variables #
################################################

define OWNAS = 64500;
define OWNIP = 198.51.100.10;
define OWNIPv6 = 2001:db8:100::10;

define OWNNETSET_V4 = [];

define OWNNETSET_V6 = [
2001:db8:4700::/47
];

################################################
# Global #
################################################

router id OWNIP;

protocol device {
scan time 10;
}

################################################
# Direct #
################################################

protocol direct {
ipv4;
ipv6;
interface "dummy*";
}

################################################
# Kernel isolation #
################################################

protocol kernel krt_ipv4 {
scan time 20;

ipv4 {
import none;
export none;
};
}

protocol kernel krt_ipv6 {
scan time 20;

ipv6 {
import none;
export none;
};
}

################################################
# Static announcements #
################################################

protocol static static_v6 {
route 2001:db8:4700::/47 reject;

ipv6 {
import all;
export none;
};
}

################################################
# BGP template #
################################################

template bgp tpl_bgp {
local as OWNAS;
path metric 1;
graceful restart;

ipv4 {
import none;
export filter { reject; };
export limit 100 action disable;
extended next hop on;
};

ipv6 {
import none;
export filter {
if source != RTS_STATIC then reject;
accept;
};
export limit 100 action disable;
};
}

################################################
# Upstream BGP #
################################################

protocol bgp bgp_upstream_v4 from tpl_bgp {
source address OWNIP;
neighbor 169.254.0.1 as 64496;
multihop 2;
password "REDACTED_PASSWORD";
}

protocol bgp bgp_upstream_v6 from tpl_bgp {
source address OWNIPv6;
neighbor 2001:db8:ffff::1 as 64496;
multihop 2;
password "REDACTED_PASSWORD";
}

BIRD2 配置是否需要修改

结论

大多数情况下几乎不用改。

因为当前已经有:

1
2
3
4
5
protocol direct {
ipv4;
ipv6;
interface "dummy*";
}

所以只要新的逻辑网卡叫 dummy47,它天然就会被匹配。

而 IPv6 静态广播部分本身也是对的:

1
2
3
4
5
6
7
8
protocol static static_v6 {
route 2001:db8:4700::/47 reject;

ipv6 {
import all;
export none;
};
}

为什么不用把 /47 从 reject 改掉

因为这里的 reject 路由只是为了:

  • 让前缀进入 BIRD RIB
  • 满足 BGP 导出条件
  • 作为“我拥有并广播这个前缀”的声明

不是为了给 Linux 内核做真实转发或本机地址绑定。

所以 /47 继续保留 reject 方式是合理的。


实际配置步骤

下面开始做 Linux 网络配置。


第一步:创建新的 dummy 网卡

先加载模块并创建逻辑网卡:

1
2
3
4
modprobe dummy

ip link add dummy47 type dummy
ip link set dummy47 up

执行后可以用下面命令确认:

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
2
3
4
5
cat >/etc/sysctl.d/99-ipv6-pool.conf <<'EOF'
net.ipv6.ip_nonlocal_bind = 1
EOF

sysctl --system

第五步:重新加载 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
2
3
curl -6 --interface 2001:db8:4700::100 https://ipv6.ip.sb
curl -6 --interface 2001:db8:4700::200 https://ipv6.ip.sb
curl -6 --interface 2001:db8:4701::1234 https://ipv6.ip.sb

如果返回值分别就是你指定的源地址,说明配置成功。


一个容易踩坑的点:绑定“接口”不等于绑定“源地址”

很多人会下意识这样测:

1
curl -6 --interface dummy47 https://ipv6.ip.sb

这个测试方式并不适合这里。

因为 dummy47 不是物理出口网卡,它只是一个逻辑承载接口。
真正的流量出网仍然依赖系统默认路由,一般还是会走类似 eth0ens3 这样的真实物理网卡。

所以正确姿势应该是:

  • 绑定具体源 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

只有当服务商明确要求这段前缀是二层直连、必须对目标地址进行邻居发现时,才需要额外考虑 ndppdproxy_ndp


持久化方案:systemd service

为了避免重启后丢失,我最后把这套配置写成了一个简单的 systemd 服务。

创建文件:

1
vim /etc/systemd/system/dummy47-v6pool.service

内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[Unit]
Description=IPv6 pool on dummy47
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/sbin/modprobe dummy
ExecStart=/sbin/ip link add dummy47 type dummy
ExecStart=/sbin/ip link set dummy47 up
ExecStart=/sbin/ip -6 addr add 2001:db8:4700::1/128 dev dummy47
ExecStart=/sbin/ip -6 route replace local 2001:db8:4700::/47 dev lo
ExecStart=/sbin/sysctl -w net.ipv6.ip_nonlocal_bind=1

ExecStop=/sbin/ip -6 route del local 2001:db8:4700::/47 dev lo
ExecStop=/sbin/ip -6 addr del 2001:db8:4700::1/128 dev dummy47
ExecStop=/sbin/ip link del dummy47

[Install]
WantedBy=multi-user.target

然后执行:

1
2
systemctl daemon-reload
systemctl enable --now dummy47-v6pool.service

一份完整的操作命令汇总

为了方便复制,这里把完整步骤汇总成一段:

1
2
3
4
5
6
7
8
9
10
11
12
modprobe dummy

ip link add dummy47 type dummy || true
ip link set dummy47 up

ip -6 addr add 2001:db8:4700::1/128 dev dummy47 || true
ip -6 route replace local 2001:db8:4700::/47 dev lo

sysctl -w net.ipv6.ip_nonlocal_bind=1

birdc configure
birdc show route for 2001:db8:4700::/47

验证:

1
2
3
4
5
6
ip -6 addr show dev dummy47
ip -6 route show table local | grep '2001:db8:4700::/47'

curl -6 --interface 2001:db8:4700::100 https://ipv6.ip.sb
curl -6 --interface 2001:db8:4700::200 https://ipv6.ip.sb
curl -6 --interface 2001:db8:4701::1234 https://ipv6.ip.sb

我的最终理解

这次配置做完之后,整个结构就很清晰了:

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 出网

https://blog.xsy.fun/posts/bird2-ipv6-pool-sanitized/

Author

Linus Xiong

Posted on

2026-04-15

Updated on

2026-04-15

Licensed under

Comments