IPv4 默认路由是前缀 0.0.0.0/0,匹配所有 IPv4 目的地,但具体程度最低。在已经选择的路由表中,更长的匹配前缀通常优先。默认路由不是一个“其他具体路径失败后再重试”的过程。路由策略决定相关表,最终路由描述怎样转发,而不保证完整往返一定成功。匹配范围与路径健康是两类不同属性。

坏掉的样子

没有默认路由的机器,仍能访问直连或明确路由覆盖的目的地;反过来,添加默认路由也不证明能够访问互联网。下一跳需要可达,转发与过滤要允许流量,回复还需要返回路径。显示一个网关,只满足契约的一部分。真正配置是否共同工作,应由实际应用通信证明,而不是从路由文件中有一行 default 就推导全部能力。

更具体的拒绝路由会胜过可工作的默认路由。Linux 的 unreachable 等路由类型,会有意停止匹配目的地的转发。选定网关的邻居查询失败,也不意味着内核自动寻找更健康、却更宽泛的路径。静态路由优先级和 metric 不是服务健康监控或自动故障转移机制,不能把配置顺序当成逐个尝试可用出口的流程。

返回路径错误尤其容易误导。客户端选中预期网关并把流量送往监听中的服务器,服务器却可能无法把回复路由回客户端。反复修改客户端默认路由,可能破坏其他正常目的地,却没有修复缺失的反向路径。将所有连接超时归因于发送方网关之前,应查看两个方向和真实源地址。请求能到与响应能回,需要分别成立。

快速验证

将脚本保存为 check-default-route.py,准备 Docker 后运行 python3 check-default-route.py。Python 3.11.16 和 iproute2 6.1.0 在一次性 Linux 容器内建立客户端、路由器、目标拓扑。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间和临时 proc 挂载。接口全是新建 veth,不向宿主发布端口或路由,也不修改现有网络设备:

from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time

def run(*args,check=True,input=None,timeout=8):
    result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=2)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def line(process,timeout=3):
    assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
    value=process.stdout.readline();assert value,'child exited without a response'
    return value
def spawn(stack,command):
    process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);return process
@contextmanager
def lab(names):
    with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack:
        root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc))
        stack.callback(run,'umount',str(proc))
        for name in names:
            run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False)
            ip(name,'link','set','lo','up')
        def setting(name,key,value):
            assert name in names
            path=proc/'sys'/key.replace('.','/')
            return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip()
        yield root,stack,setting
def wire(left,left_if,right,right_if,left_addresses,right_addresses):
    run('ip','link','add',left_if,'type','veth','peer','name',right_if)
    for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]:
        run('ip','link','set',interface,'netns',name)
        for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else []))
        ip(name,'link','set',interface,'up')
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
    data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={}
    for entry in data['nftables']:
        rule=entry.get('rule',{})
        if 'comment' in rule:
            for expression in rule['expr']:
                if 'counter' in expression:found[rule['comment']]=expression['counter']['packets']
    return found

def server(stack,name,address,port=18080,label='server'):
    code=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import json,socket,sys
address,port,label=sys.argv[1],int(sys.argv[2]),sys.argv[3]
class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body=json.dumps({'server':label,'peer':self.client_address,'path':self.path}).encode()
        self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
    def log_message(self,*args):pass
class Server(ThreadingHTTPServer):
    address_family=socket.AF_INET6 if ':' in address else socket.AF_INET
    def server_bind(self):
        if self.address_family==socket.AF_INET6:self.socket.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
        super().server_bind()
service=Server((address,port),Handler);print('ready',flush=True);service.serve_forever()
'''
    process=spawn(stack,['ip','netns','exec',name,'python3','-u','-c',code,address,str(port),label])
    assert line(process).strip()=='ready';return process
def request(name,address,port=18080):
    code=r'''
import http.client,json,sys
client=http.client.HTTPConnection(sys.argv[1],int(sys.argv[2]),timeout=.7)
try:
    client.request('GET','/lab-visible-message');response=client.getresponse();body=json.loads(response.read())
    assert response.status==200;print(json.dumps({'ok':True,'status':response.status,'body':body}))
except OSError as error:print(json.dumps({'ok':False,'type':type(error).__name__,'errno':error.errno}))
finally:client.close()
'''
    return json.loads(ns(name,'python3','-c',code,address,str(port)).stdout)

def routed(setting):
    wire('client','c0','router','r0',['10.201.1.2/30'],['10.201.1.1/30'])
    wire('router','r1','target','t0',['10.201.2.1/30'],['10.201.2.2/30'])
    setting('router','net.ipv4.ip_forward',1)
    ip('client','route','add','default','via','10.201.1.1')
    ip('target','route','add','default','via','10.201.2.1')
with lab(['client','router','target']) as (root,stack,setting):
    routed(setting);ip('client','route','delete','default')
    ip('target','addr','add','198.51.100.9/32','dev','lo');ip('router','route','add','198.51.100.9/32','via','10.201.2.2')
    daemon=server(stack,'target','198.51.100.9')
    connected=ns('client','ping','-n','-c','1','-W','1','10.201.1.1');assert connected.returncode==0
    missing=request('client','198.51.100.9');assert not missing['ok'] and missing['errno']==101
    ip('client','route','add','default','via','10.201.1.1')
    chosen=json.loads(ip('client','-j','route','get','198.51.100.9').stdout)[0];assert chosen['gateway']=='10.201.1.1'
    healthy=request('client','198.51.100.9');assert healthy['ok']
    ip('client','route','add','unreachable','198.51.100.9/32')
    specific=request('client','198.51.100.9');assert not specific['ok'] and specific['errno']==113
    ip('client','route','delete','unreachable','198.51.100.9/32')
    recovered=request('client','198.51.100.9');assert recovered['ok']
    ip('target','route','delete','default')
    bad_return=request('client','198.51.100.9');assert not bad_return['ok'] and bad_return['type']=='TimeoutError'
    assert json.loads(ip('client','-j','route','get','198.51.100.9').stdout)[0]['gateway']=='10.201.1.1'
    ip('target','route','add','default','via','10.201.2.1')
    final=request('client','198.51.100.9');assert final['ok']
    print(json.dumps({'connected_ping_without_default':0,'no_default':missing,'chosen_route':chosen,'with_default':healthy,
        'specific_unreachable_route':specific,'specific_removed':recovered,'missing_return_route':bad_return,'return_route_restored':final}))
"""
name='xbcatm-netpath-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
    with TemporaryDirectory(prefix=name+'-') as directory:
        root=Path(directory);(root/'Dockerfile').write_text(recipe)
        build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN',
            '--memory','256m','--pids-limit','128',name,'python3','-',input=program)
        assert result.returncode==0,result.stderr
        print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
    docker('rm','-f',name);docker('image','rm',name)

客户端使用 10.201.1.2,路由邻居为 10.201.1.1。目标位于路由器后的另一条链路,在回环接口拥有服务地址 198.51.100.9。路由器有到该服务的具体路由,目标起初具有返回默认路由。文档保留地址仅用于隔离拓扑,没有联系公网服务器。每项地址和下一跳都在脚本中明确配置,避免依赖宿主偶然存在的路由。

删除客户端默认路由后,ping 直连路由邻居仍成功,而连接远端 HTTP 服务得到 Linux ENETUNREACH,错误号一百零一。添加经 10.201.1.1 的默认路由后,查询选择该网关,真实 HTTP 请求返回状态二百。这同时说明原先已经存在的较窄连接能力,以及默认路由新增的路径,不能把“没有默认路由”统称为完全没有网络。

随后为 198.51.100.9 安装 unreachable /32 路由。默认路由仍在,相同连接却得到 EHOSTUNREACH,错误号一百一十三。删除具体路由后,HTTP 恢复。内核遵守更具体的拒绝指令,没有把错误当成改走默认网关的理由。这个对照直接纠正了“先尝试具体路由,失败再尝试默认”的错误理解。

接着脚本只移除目标服务器的默认路由。客户端查询仍指向 10.201.1.1,新 HTTP 请求却等待到超时;恢复目标默认路由后通信恢复。受控比较在客户端选择不变时隔离了返回路径依赖,比单侧路由列表或一次原因不明的 ping 失败更有说服力。修复对象由被改变的条件和恢复结果共同确定。

编辑默认路由前检查具体流量

记录源地址、目的地、相关策略规则、选中接口和网关。多接口或多路由表机器,可能根据来源、标记或路由域作出不同决策。诊断查询应采用应用相关输入。本例使用普通按目的地选路,没有声称重现每一种策略路由或多路径配置。命令输出只有连同查询条件一起保存,才适合与真实故障进行比较。

检查下一跳链路可达性、路由器转发决策,以及目标到真实来源的返回路由。NAT 或代理可能改变目标所看到的源地址。错误类型也能辅助定位:立即发生的本地网络不可达、明确主机拒绝路由,以及等待回复直至截止时间,是不同情况。事件证据应保留这些区别,避免把具有不同修复方式的现象压成同一个“网络不通”。

默认路由的价值,是概括主机无须逐一掌握的目的地。应有意配置它,验证例外,并在变更后测试代表性的完整往返。如果要求自动故障转移,需要有机制观察相关路径并适当更新路由。第二个网关条目或更优 metric 本身,不能证明已经具有这种行为。配置能够表达偏好,健康判断和动态切换还需要对应实现与证据。

来源