0%

容器里的 Android:三层网络故障的排查与修复

容器里的 Android:网络接入方案与三层网络故障的排查与修复

背景:云手机项目,底座为华为鲲鹏 920(ARM64)+ AMD GPU,Android 以容器方式运行在 Docker / K8S 之上(KBox 底座)。本文按"容器里 Android 的网络现状 → 引出 KBox 自带的 ipconfigstore 组件 → 我们遇到的三层故障 → 改造方案 → 改造后的结构与效果"展开。ip 命令与网络概念穿插在各相关章节,完整速查与经验清单见文末附录。


1. 容器里 Android 的网络是什么情况

1.1 双栈结构:CNI 与 Android 共管一个 netns

普通 Android 终端上,Android 是网络的唯一 owner——从 Wi-Fi/Ethernet HAL 到 IpClient、netd、DnsManager,整条链路自洽闭环:接口叫什么、地址怎么配、路由怎么建、DNS 从哪来,全部由 Android 自己说了算。所以"网络接入"在普通终端上根本不构成问题。

容器化 Android 打破了这个前提:网络由宿主侧的 CNI(K8S 的 Calico/Flannel 等 CNI 插件,或 Docker 的 libnetwork)在容器进程启动之前就配置好了——一套标准 Linux 网络配置直接落在容器 netns 里。于是同一个 netns 中出现两套都要管理网络的栈:宿主侧 CNI 管"内核态事实",Android 用户态网络栈(EthernetTracker → IpClient → netd → DnsManager → resolver)管"Android 认知"。两套栈之间的每一处衔接,都可能成为故障点。

1.2 健康容器的网络形态长什么样

先看一台健康云手机容器(K8S 底座)的实拍,后面所有故障排查都以它为基准。

所有路由表(ip route show table all):

10.2.3.42 dev wlan0 table wlan0_local proto static scope link      ← per-network 表的链路本地路由
169.254.1.1 dev wlan0 table wlan0_local proto static scope link
224.0.0.0/24 dev wlan0 table wlan0_local proto static scope link
default via 169.254.1.1 dev wlan0 table wlan0 proto static        ← ★ 出网路由在 per-network 表里
10.2.3.42 dev wlan0 table wlan0 proto static scope link
169.254.1.1 dev wlan0 table wlan0 proto static scope link
local 10.2.3.42 dev wlan0 table local proto kernel scope host src 10.2.3.42
...(local 表:本机/环回/广播,内核自动维护)
fe80::/64 dev wlan0 proto kernel metric 256 pref medium           ← main 表里只剩这条 IPv6(IPv4 default 不在)

关键概念——路由表不止一张:

表 编号 内容
local 255 本机地址、环回、广播(内核自动维护,优先级最高)
main 254 传统 Linux 的"默认"表,ip route show 不带参数显示的就是它
自定义表 — Android 的 netd 为每个注册的网络建两张:<iface>(出网路由)+ <iface>_local(链路本地),表名就是接口名

策略路由规则(ip rule)决定"包该查哪张表"——按数字从小到大匹配、命中即停:

ip rule
0:      from all lookup local                                    ← ① 本机流量,永远最先
10000:  from all fwmark 0xc0000/0xd0000 lookup legacy_system     ← ② 系统级标记流量
11000:  from all iif lo oif wlan0 uidrange 0-0 lookup wlan0     ← ③ uid 0(root) 的流量
16000:  from all fwmark 0x10063/0x1ffff iif lo lookup local_network
16000:  from all fwmark 0x10064/0x1ffff iif lo lookup wlan0     ← ④ 特定 netId 标记
17000:  from all iif lo oif wlan0 lookup wlan0                  ← ⑤ 指定出接口的流量
18000:  from all fwmark 0x0/0x10000 lookup legacy_system
19000:  from all fwmark 0x0/0x10000 lookup legacy_network
20000:  from all fwmark 0x0/0x10000 lookup local_network
23000:  from all fwmark 0x64/0x1ffff iif lo lookup wlan0        ← ⑥ ★ fwmark 0x64=100=default netId
31000:  from all fwmark 0x0/0xffff iif lo lookup wlan0          ← ⑦ 普通应用流量的兜底
32000:  from all unreachable                                     ← ⑧ 全部落空 → 不可达

其中几个必须理解的概念:

  • fwmark(firewall mark):内核的 32 位标记,Android 用它承载网络 ID(netId)——0x64 十进制就是 100,正是 dumpsys connectivity 里的 Active default network: 100。应用发包时 netd 给 socket 打上默认网络的 mark,路由决策就落到 ⑥ → lookup wlan0。
  • 掩码:fwmark 0x64/0x1ffff 表示"只比对低 17 位",高位留给其他控制位(如 0x10000 表示"显式指定网络")。
  • iif lo / oif wlan0:入接口 / 出接口。iif lo = 本机产生的包;oif wlan0 = 已确定从 wlan0 出去的包。
  • uidrange 0-0:按发起进程 uid 匹配。
  • ⑧ unreachable:所有规则都没命中的最终结局——“Network is unreachable” 就是它给的。

把 ⑥/⑦ 和路由表串起来,就是容器 Android 出网的完整路径:

flowchart LR
    S["应用发包<br/>(socket)"] --> M["netd 打 fwmark<br/>0x64 = netId 100"]
    M --> R{"ip rule 依次匹配"}
    R -->|"0"| L["table local"]
    R -->|"23000: fwmark 0x64"| T["table wlan0"]
    R -->|"31000: 兜底"| T
    R -->|"32000"| U["unreachable ❌"]
    T --> G["default via 169.254.1.1 dev wlan0"]
    G --> N["出网 ✅"]

    style T fill:#cce5ff
    style U fill:#ffcccc

一个重要推论:应用流量从头到尾不查 main 表(fwmark 规则先命中),所以"main 表是空的"在 Android 容器里是正常态——排查中若看到 ip route show 输出为空但 ping 223.5.5.5 照样通,不要慌,路由都在 per-network 表里。

1.3 两种底座的形态差异

Docker(如 172.17.x) K8S(如 10.2.x.x/32)
容器地址 172.17.0.7/16,带子网路由 10.2.3.42/32,无子网路由(点对点)
main 表 default 常见 default via 172.17.0.1 dev eth0 常为空,或 default dev wlan0 scope link(on-link,无下一跳)
网关 桥接网关 172.17.0.1 CNI 实现而定(本环境 169.254.1.1;也有 on-link 无网关形态)

on-link 默认路由(default dev wlan0 scope link)值得单独说:它没有 via <网关>,意思是"这个网络直达,包直接二层发出去,由上游转发"——K8S 的某些 CNI 就是这么配的。此时静态配置里写 gateway=0.0.0.0 反而是正确表达(读取端会解析成 gateway=null,靠链路直达出网);写成"有网关 0.0.0.0"才是毒——会被当成有效网关参与建路由,provisioning 直接失败。这个区别在 3.4 节的故障里会再次出现。

1.4 五重冲突:这个结构的问题在哪

两套栈共存,天然存在五个衔接冲突——它们是后文一切故障的总根源:

冲突维度 宿主侧 CNI 的行为 Android 用户态网络栈的预期 不桥接的后果
命名 接口名由 CNI/运行时决定(eth0,或平台定制的 wlan0) EthernetTracker 只认正则匹配的接口(原生 (eth|usb)\d+);产品还要求 wlan0 显示为 Wi-Fi 类型 接口不被认领 → 无 NetworkAgent → dns_netid=0 → 全断
配置归属 IP/掩码/网关直接写进内核(地址即事实,无文件) 期望从 ipconfig.txt 读静态配置,由 IpClient 应用到接口 两套配置互不知情:Android 读不到网关 → provisioning 失败
时序 容器进程启动前完成配置 system_server 启动后才 read() 配置文件;桥接工具须挤在两者之间 顺序错位则配置读不到、或被覆盖
生命周期 netns 不变则 CNI 不重发;容器 shutdown 时接口 down 会清掉路由 Android 重启流程不碰 CNI 配在 main 表的路由(netd 只维护自己的 per-network 表) 容器重启后路由永久丢失(3.4 节的根因)
语义 普通 Linux:一切在 main 表 Android:fwmark + per-network 表,main 表运行期基本不参与 概念错位导致取证与调优方向错误(1.2 节)

要消解这五个冲突,需要一个挤在"CNI 配完、system_server 没起"之间的桥接层。KBox 底座其实已经带了一个——ipconfigstore。


2. ipconfigstore:来源、作用与原版缺陷

2.1 来源与作用

ipconfigstore 不是我们从零发明的,而是 KBox 底座(鲲鹏容器方案)自带的组件,随 KBox 底座引入本仓库,位于 system/core/init/ipconfigstore/——原版仅 main.cc + data.h/cc 三个文件、约 170 行。原版基准为 KBox 引入时的初始版本,可用 git show <KBox 引入前的提交>:system/core/init/ipconfigstore/main.cc 取回对照。

原版的作用:init 的 post-fs-data 阶段自动运行,探测 eth0 → 从内核读取 IP/掩码/网关 → 写成 Android EthernetTracker 消费的 ipconfig.txt 静态配置(legacy + apex 双路径)。也就是说,原版本质上已经是"把内核网络事实翻译给 Android"的桥接雏形——本文的全部工作,就是在这个 KBox 组件上做补丁式修改(这也是 5.1 节代码组织方式的由来),而不是另起炉灶:KBox 底座已有此组件、init.kbox.rc 已接线、写入格式与 Android 读取端(IpConfigStore)天然匹配。

2.2 原版的八项缺陷

以下片段取自改造前的原版(git show <KBox 引入前的提交>:system/core/init/ipconfigstore/main.cc):

// 原版 get_conf():探测、网关、id 三处全部写死 eth0
if (... && !strcmp("eth0", ifAddrStruct->ifa_name)) { ... }
get_gateway((char *) "eth0", conf->gateway);
// 原版 write_conf():id 写死、fopen 不检查、网关无条件按"有网关"格式写
FILE *fp = fopen(file_path, "w+");          // 返回值不检查 → 目录未就绪时空指针崩溃
writePackedString("eth0", fp);              // id 与实际接口名可能不一致
writePackedUInt32(1, fp); ...               // 无条件写 dest + gateway
// 原版 main():结构体未初始化、失败无感知、唯一的输出是无换行的 printf
struct ipconfig conf;                       // 栈上垃圾
get_conf(&conf);                            // 永远返回 0,拿不到 IP 也不报错
printf("ipconfig: ipv4: %s, mask: %i, gateway: %s", ...);   // 无 \n,容器内 stdout 无处可去
# 原版行为 后果
1 探测/网关/id 写死 eth0 接口为 wlan0 时探测失败;id=eth0 与实际接口名不一致 → 读取端按 id 建 key、按接口名查找 → 配置 miss
2 struct ipconfig conf; 未初始化,get_conf() 无失败路径 拿不到 IP 时把栈上垃圾写进配置文件
3 fopen 返回值不检查 目录未就绪时直接空指针崩溃
4 网关无条件按"有网关"格式写 读不到网关(写垃圾)或读到 0.0.0.0(on-link 环境,见 1.3 节)→ 毒化 provisioning
5 root 写文件不 chown root:root 600 → system_server(system uid)读不了 → 静态配置从来没生效(AOSP 11 实测,全靠 net.dns* 兜底活着,见 3.3 节)
6 只有无换行的 printf,无落盘日志 容器内 stdout 进不了 kmsg(/dev/kmsg 为 EPERM)→ 完全不可观测
7 不做命名统一 无法满足 wlan0 + 状态栏 Wi-Fi 的产品要求
8 无网关缓存/恢复 容器重启清掉路由后无能为力(3.4 节第三层故障)

带着这份缺陷清单和 1.4 节的五重冲突,下面进入我们实际遇到的问题。


3. 我们遇到的问题:三层网络故障

AOSP 升级到 15 之后,三层故障先后叠加出现——每一层单独出现,都足以让"能 ping IP、不能解析域名"复现。

3.1 症状

android_59:/ # ping -c 2 223.5.5.5          # IP 层通
64 bytes from 223.5.5.5: icmp_seq=1 ttl=41 time=27.2 ms

android_59:/ # ping -c 2 www.baidu.com       # 解析层死
ping: unknown host www.baidu.com

先交代 DNS 的完整链路(后文反复用到):网络注册 → NetworkAgent 携带 LinkProperties(含 DNS 列表)→ ConnectivityService 交给 DnsManager → setResolverConfiguration 下发给 netd/resolver。链路上任何一环断掉,resolver 的 nameserverCount 就是 0,所有域名解析立即失败——日志签名把这一点钉死(没有 DNS 服务器,而不是"查询发出去被拒"):

# packages/modules/DnsResolver/res_send.cpp:500-509
doQuery: rcode=0, ancount=0, return value=-3      # -3 = -ESRCH(nameserverCount()==0 → 查询根本没发出)
# packages/modules/DnsResolver/DnsProxyListener.cpp:909-910
GetAddrInfoHandler::run: {0 786432 0 983040 1000 0}
#                                ^^^^ dns_netid=0:没有默认网络,resolver 拿不到 nameserver 列表

排查由此展开,最终发现是三层故障叠加:

flowchart TD
    A["症状: 能 ping IP<br/>域名解析 unknown host"] --> B{"第一层:<br/>接口有没有被跟踪?"}
    B -->|"正则被 AAPT 剥掉反斜杠"| C["wlan0 不被 EthernetTracker 认领<br/>→ 无 NetworkAgent → dns_netid=0"]
    B -->|"已跟踪"| D{"第二层:<br/>静态配置有没有生效?"}
    D -->|"id 不匹配 / 文件读不到"| E["lp 无 DNS<br/>AOSP 15 已废弃 net.dns* 兜底"]
    D -->|"已生效"| F{"第三层:<br/>网关在不在?"}
    F -->|"重启后 main 表 default 丢失"| G["provisioning 失败<br/>无默认路由 + 无 DNS → 全死"]
    F -->|"在"| H["健康"]

    style C fill:#ffcccc
    style E fill:#ffcccc
    style G fill:#ffcccc
    style H fill:#ccffcc

3.2 第一层:AAPT 把正则的反斜杠吃掉了

容器网卡有两种命名:存量的 eth0 和标准的 wlan0。AOSP 的 EthernetTracker 用资源 config_ethernet_iface_regex 决定认领哪些接口,我们在里面加了 wlan:

<!-- packages/modules/Connectivity/service/ServiceConnectivityResources/res/values/config.xml -->
<!-- 自以为没问题的写法 -->
<string name="config_ethernet_iface_regex">(eth|usb|wlan)\d+|en\w+</string>

部署后 dumpsys ethernet 却显示:

Ethernet interface name filter: (eth|usb|wlan)d+|enw+     ← \d 和 \w 的反斜杠没了!

Android 字符串资源会处理转义:\d 不是合法的资源转义,AAPT 直接把反斜杠剥掉。AOSP 源码里这个资源的原生写法是 (eth|usb)\\d+——双反斜杠,运行时才还原成单反斜杠。修正:

<!-- packages/modules/Connectivity/service/ServiceConnectivityResources/res/values/config.xml -->
<string name="config_ethernet_iface_regex">(eth|usb|wlan)\\d+|en\\w+</string>

后果链完全吻合症状:正则失效 → wlan0 匹配失败 → Tracking interfaces 为空 → IpClient 不启动 → 不注册网络 → resolver 的 dns_netid=0。而此时 ipconfigstore 工具的改名、配置写入都是好的——IP 在、名字对、就是不通,很有迷惑性。

3.3 第二层:AOSP 15 没有 DNS 兜底了

正则修好后,又冒出"容器重启一次之后 DNS 就没了"。为了搞清"正常应该长什么样",我们去同集群的 AOSP 11 实例上取证,结果颠覆了认知:

# AOSP 11 理想 pod
IP Configurations:                          ← 空的!静态配置根本没生效
linkProperties: {... DnsAddresses: [ ]}     ← IpClient 层没有 DNS
dumpsys connectivity:
  lp{... DnsAddresses: [8.8.8.8, 8.8.4.4, 114.114.114.114, 114.114.115.115]}   ← CS 层却有!
getprop:
  [net.dns1]: [8.8.8.8]  [net.dns2]: [114.114.114.114]  ...                     ← 来源在这
ls -l ipconfig.txt:
  -rw------- 1 root root ...               ← root:root 600,system 根本读不了!

三个事实拼出真相:

  1. AOSP 11 的静态配置从来没生效过(原版缺陷 #5:文件 root:root 600,system_server 读取直接 EACCES);
  2. 但它一直能解析域名,因为 AOSP 11 有系统级兜底:DnsManager 在网络的 LinkProperties 没有 DNS 时,回退读 net.dns1..4 系统属性;
  3. AOSP 15 彻底废弃了 net.dns* 机制——现在的代码是 paramsParcel.servers = makeStrings(lp.getDnsServers()),严格只认 LinkProperties,没有任何 fallback。

于是 AOSP 15 上静态配置从"可有可无"变成了"必须生效",而它生效有三个条件(涉及 ipconfig.txt 的读取机制——读取端按文件里的 id 字段建 key,EthernetTracker 再按接口名查找):

条件 机制
文件可读 write_conf 写完 chown system(system/core/init/ipconfigstore/main.cc 的 write_conf();旧工具不 chown,留下 root:root 毒文件)
id 等于接口名 读取端按 id 字段建 key(packages/modules/Connectivity/service-t/src/com/android/server/net/IpConfigStore.java:295-301),EthernetTracker 再按接口名查找(.../ethernet/EthernetTracker.java:929-936 的 getOrCreateIpConfiguration(),miss 时回退默认 DHCP)——id=eth0 而接口叫 wlan0 时直接 miss
框架读到的是新文件 工具写 apex + legacy 双路径(main.cc 的 main()),框架优先读 apex(.../ethernet/EthernetConfigStore.java:89-105)

miss 之后的路径也很隐蔽:config_ethernet_interfaces 第 3 段(IP 配置段)留空时,EthernetTracker.java:881-921 的 parseStaticIpConfiguration("") 会返回一个 IpAssignment=STATIC 但 ip/gateway/dns 全空的配置——:764 只判 null != config.mIpConfig 而空串非 null 照常 put 到接口名下,配合 framework/src/android/net/IpConfiguration.java:288-292 的 Builder.build()(StaticIpConfiguration 非 null 一律设 STATIC),最终 IpClient 拿到"STATIC 但没有 IP"——NetworkStack/src/android/net/ip/IpClient.java:2482-2499 的 startIPv4() 设地址失败,provisioning 必败。

3.4 第三层:容器重启把网关路由丢了

第二层修完,健康容器全绿,但"重启后坏"仍能复现。这次我们主动复现抓现行,故障现场的取证一举锁定根因:

# 重启后的故障现场
ipconfig: iface: wlan0, ipv4: 10.2.3.42, mask: 32, gateway:      ← 网关读成了空!
ipconfig.txt: 101 字节(健康时 127,少了 gateway 段的值)
dumpsys: Gateway (空) / DnsAddresses: [ ] / Routes 只剩 10.2.3.42/32,无 0.0.0.0/0
症状: ping IP → Network is unreachable;ping 域名 → unknown host(全死)

对照健康态(Delete Pod 后首次启动),差异只有一个:工具这次没读到网关。为什么?答案是一条内核固定行为:

接口 down 时,内核删除该接口上的所有路由,但保留 IP 地址;接口重新 up 后,路由不会自动恢复。

容器 shutdown 时业务 stop → 接口 down → CNI 预配在 main 表的 default via <网关> 被内核清掉;启动后接口 up、地址还在(ifconfig 实锤:IP 在、接口 UP、但 main 表没有 default),而 netns 没变 → CNI 认为配置还在、不重发 → 路由永久丢失。完整的双场景时序:

sequenceDiagram
    participant C as CNI/宿主
    participant K as 内核(netns)
    participant T as ipconfigstore
    participant A as Android(netd/IpClient)

    Note over C,K: 首次启动(新 netns)
    C->>K: 配 IP 10.2.3.42/32 + default via 169.254.1.1
    T->>K: 读 /proc/net/route → gateway 169.254.1.1 ✅
    T->>T: 写 ipconfig.txt(id=wlan0, gateway 169.254.1.1)
    A->>K: 注册网络,建 per-network 表 → 全通 ✅

    Note over C,K: 容器重启(netns 保留)
    C->>K: 业务 stop → 接口 down
    K->>K: ★ 内核删除该接口所有路由(地址保留)
    C->>K: 启动 → 接口 up(地址还在, 路由没了)
    Note over C: netns 没变 → CNI 认为配置还在, 不重发!
    T->>K: 读 /proc/net/route → 无 default → gateway 空 ❌
    T->>T: 写"无网关"配置 → provisioning 失败 → 全死
    T->>K: [修复后] 用缓存网关 SIOCADDRT 恢复 ✅

ipconfigstore.log(由 system/core/init/ipconfigstore/ 的工具产生:日志框架 log.cc,四行分别出自 main.cc 的接线、ipconfig_ext.cc 的缓存解析、kbox_net.cc 的路由恢复)里新打出的四行签名,把整条链路实锤:

[2026-11-10 19:10:52] ipconfig: cached gateway from ipconfig.txt: 169.254.1.1
[2026-11-10 19:10:52] ipconfig: live gateway missing, restoring from cache: 169.254.1.1
[2026-11-10 19:10:52] ipconfig: restored default route via 169.254.1.1 dev wlan0
[2026-11-10 19:10:52] ipconfig: iface: wlan0, ipv4: 10.2.3.42, mask: 32, gateway: 169.254.1.1

这条内核规则还有两个工程推论(改造时必须遵守):SIOCSIFNAME(改名)要求接口先 down → 改名必然伴随路由丢失,所以工具顺序必须"先读 IP/网关、后改名"(我们第一版就踩了反序的坑,网关写空);ip link set dev down 是排查时要慎用的命令——它会顺手清掉整张接口的路由表,曾经把 adb 弄失联过。


4. 改造:五个动作与关键决策

4.1 改造总览

针对 1.4 节的五重冲突 + 2.2 节的八项缺陷,ipconfigstore 被加固为运行在 post-fs-data 唯一时间窗口的完整桥接层:

flowchart TB
    subgraph host["宿主侧 (K8S / Docker)"]
        CNI["CNI / libnetwork<br/>容器启动前: IP + 掩码 + 网关 → 内核"]
    end
    subgraph ns["容器 netns"]
        K["内核态事实<br/>wlan0: 10.2.3.42/32<br/>default via 169.254.1.1"]
        BR["★ ipconfigstore (post-fs-data)<br/>① 命名统一 eth0 → wlan0<br/>② 配置翻译: 内核事实 → ipconfig.txt<br/>③ 时序: 先读网关后改名 + 5s 轮询等 IP<br/>④ 自愈: 缓存网关 + SIOCADDRT 恢复路由<br/>⑤ 语义对齐: id=接口名 / 无网关格式 / chown"]
        AND["Android 用户态网络栈<br/>EthernetTracker → IpClient → netd<br/>→ DnsManager → resolver"]
    end
    CNI --> K
    BR -->|"读: getifaddrs / /proc/net/route"| K
    BR -->|"写: ipconfig.txt (apex + legacy 双路径)"| AND
    AND -->|"静态配置 provisioning"| K
    BR -.->|"诊断落盘"| LOG["ipconfigstore.log<br/>(容器内 /dev/kmsg = EPERM)"]

    style BR fill:#fff3e0,stroke:#ef6c00

五个动作与冲突/缺陷的对应关系(实现均为仓库内真实路径):

冲突/缺陷 桥接动作 实现(仓库路径)
命名冲突 + 缺陷 #7 ① 命名统一 system/core/init/ipconfigstore/kbox_net.cc 的 rename_to_wlan0()(SIOCSIFNAME,放在读取之后执行,避免改名的 down 清掉要读的网关);框架侧配套 config.xml 正则 \\d 转义 + config_ethernet_interfaces 注册 WIFI 类型
配置归属 + 缺陷 #1/#2/#3/#4/#5 ② 配置翻译 kbox_net.cc::wait_conf() 读内核事实 → main.cc::write_conf() 写 ipconfig.txt(id=实际接口名、chown system、fopen 检查、无网关时按读取端格式写空、apex+legacy 双路径)
时序冲突 + 缺陷 #6 ③ 时序适配与可观测 init rc 的 post-fs-data 触发;先读 IP/网关、后改名;wait_conf() 5s 轮询兜底 IP 延迟;log.cc 落盘日志(时间戳 + 追加 + 1000 行裁剪)
生命周期冲突 + 缺陷 #8 ④ 自愈 ipconfig_ext.cc::read_cached_gateway() 解析旧文件缓存网关 + kbox_net.cc::add_default_route() 用 SIOCADDRT 恢复内核 default(EEXIST 幂等);网关信息持久化在 /data,同容器重启间有效
语义冲突 ⑤ 语义对齐 静态配置生效后由 netd 建 per-network 表与 fwmark 规则(1.2 节的出网路径自然成立);DnsManager.java 补 net.dns* 兜底作为异常保险

4.2 关键设计决策

  1. 静态配置,而不是让 Android 走 DHCP:CNI 预配是既成事实,容器内没有 DHCP server,Android 的 DHCP 栈必然超时失败——ipconfig.txt 的 STATIC 是唯一正确路径。
  2. 改名,而不是放宽正则去认 eth0:产品要求状态栏 Wi-Fi 图标(wlan0 + TRANSPORT_WIFI),wlan0 也是接口标准命名;改名发生在容器 netns 内(需 CAP_NET_ADMIN),宿主与 CNI 完全无感知(netns 内改名不影响宿主 veth 对端)。
  3. 时机必须是 post-fs-data:早于 system_server(配置读取者)、晚于 CNI(网络配置者)——这是两套网络栈之间唯一的缝。
  4. 以 root 执行(vendor/kbox/products/init.kbox.rc 的 exec - root root):SIOCSIFNAME/SIOCADDRT 需要 CAP_NET_ADMIN,init exec 切非 root 会清掉 effective capability;SELinux 侧 init 域已有 net_admin(system/core/init/sepolicy/init.te),无需新策略。
  5. 双路径写入:框架优先读 apex 路径(EthernetConfigStore.java:89-105),legacy 路径作迁移兜底——两个都写才不会读到旧文件。
  6. 双保险:正路(工具保证静态配置生效)+ 异常路(DnsManager 读 net.dns* 兜底)——任何一层 miss,DNS 都不为空。

5. 改造后的结构与效果

5.1 代码结构:补丁式模块划分

组件本体是 KBox 提供的(原版 ~170 行,见 2.1 节)。为了让 main.cc 的 diff 保持"像一份补丁",把所有变更抽成三个模块,原版函数一字不动地留在原位:

flowchart TD
    M["main.cc (原版骨架 + 接线)<br/>get_gateway / write_dns / write_proxy 原样<br/>write_conf 局部增强 + main() 接线<br/>diff vs 原版: +105/-48"] --> K["kbox_net.h/cc<br/>探测 wait_conf / 改名 rename_to_wlan0<br/>路由恢复 ensure_iface_up / add_default_route"]
    M --> E["ipconfig_ext.h/cc<br/>read_cached_gateway 缓存网关解析"]
    M --> L["log.h/cc<br/>iplog 时间戳 / 追加 + 1000 行裁剪"]
    K --> L
    E --> L
    M --> D["data.h/cc (KBox 原版, 未动)<br/>二进制编解码"]

    style M fill:#e8f5e9
    style K fill:#fff3e0
    style E fill:#fff3e0
    style L fill:#fff3e0
文件 职责
main.cc 原版骨架 + 接线(get_gateway/write_dns/write_proxy 一字未动,write_conf 局部增强,main() 串联五动作)
kbox_net.h/cc 网卡适配:wait_conf() 探测(候选兜底5s 轮询)、rename_to_wlan0() 改名、ensure_iface_up()/add_default_route() 路由恢复
ipconfig_ext.h/cc 文件读取:read_cached_gateway() 解析旧 ipconfig.txt 缓存网关
log.h/cc 诊断日志:iplog() 时间戳、log_init() 追加写入 + 1000 行裁剪
data.h/cc KBox 原版二进制编解码,未动

配套的框架侧改动(详见附录 B):config.xml(正则 \\d + wlan0 注册 WIFI)、DnsManager.java(net.dns* 兜底)、init.kbox.rc(root 执行)。

5.2 验证效果:两条启动路径全通

改造后按"同一份代码、两条启动路径"对拍验证:

场景 路径 验证结果
A: Delete Pod(重建) 新 netns → CNI 重新下发 IP+网关 → 工具实时读到网关 ✅ renamed eth0 -> wlan0 + gateway: 169.254.1.1,全通
B: 容器重启(/data 保留) netns 保留 → shutdown down 清路由 → CNI 不重发 → 实时读不到 → 缓存恢复 ✅ restoring from cache + restored default route 四行签名,全通

故障态 vs 改造后效果对比:

指标 故障态(重启后) 改造后
ipconfig.txt gateway 段 空(101 字节) 169.254.1.1(127 字节)
内核 main 表 default 丢失 SIOCADDRT 恢复(EEXIST 幂等)
dumpsys Gateway / DnsAddresses 空 / 空 169.254.1.1 / [8.8.8.8]
Active default network / 验证 有网络但无 IS_VALIDATED 100 + IS_VALIDATED
ping 223.5.5.5 Network is unreachable ✅
ping www.baidu.com unknown host ✅(连续两次都通)

场景 B 的健康判据(全部静态命令,不用 logcat):

cat /data/misc/ethernet/ipconfigstore.log        # 时间戳 + gateway 非空 + restoring 签名
dumpsys ethernet | grep -E "filter|Gateway"      # (eth|usb|wlan)\d+|en\w+ + Gateway 有值
dumpsys connectivity | grep -E "Active default|DnsAddresses"   # 非 none + DNS 非空
ip route show table wlan0                        # default via <网关>
ping -c 2 www.baidu.com                          # 连续两次都通

5.3 行业参考价值

把问题抽象一层:当 Android 不再是硬件/网络资源的唯一 owner,它对世界的"自洽假设"就需要一个适配层来补齐。 网络是第一个撞上的领域(本方案),按同样的思路,容器化 Android 后续还会撞上:

  • 存储:宿主侧挂载 vs vold/fstab 的块设备假设;
  • 显示:宿主 GPU/HAL 虚拟化 vs SurfaceFlinger 的 vsync/合成假设;
  • 电源与时钟:容器无 RTC/电池 vs AlarmManager/Doze 的假设。

从本方案可以提炼出这类适配层的通用设计原则:

  1. 找准唯一时间窗口——宿主配置完成与 Android 消费开始之间的那段 init 阶段;
  2. 翻译而非覆盖——把宿主侧事实转成 Android 的消费格式(内核路由 → ipconfig.txt),而不是试图接管宿主的配置;
  3. 幂等 + 自愈——每次开机可重入(EEXIST 忽略),关键信息持久化在 /data 以便重启后自我修复;
  4. 可观测——落盘日志(时间戳 + 追加 + 行数上限),所有诊断走静态快照(容器内 /dev/kmsg 通常 EPERM);
  5. 双保险——正路修通的同时保留异常兜底,任何单点 miss 都不致命。

附录

附录 A:ip / 取证命令速查与坑

ip route show                 # 只看 main 表(Android 运行期常常是空的!)
ip route show table all       # 所有表(取证标配)
ip route show table wlan0     # 只看出网所在的 per-network 表
ip route get 223.5.5.5        # 查某个目标最终走哪条路由(含规则匹配结果)
ip rule                       # 策略路由规则(概念见 1.2 节)
坑 说明
ip route show all AOSP 11 的 toybox 支持(all 被当关键字),AOSP 15 上不生效、静默输出空——统一写 table all。排查中曾被空输出误导以为"路由全没了"
strings 看二进制配置 长度前缀等字节会被暴露成可见字符(0x09 被终端渲染成 8 个空格),“IP 前面一堆空格"曾被误判为文件损坏——用 xxd / hexdump -C 看字节级内容
logcat 取证 低频操作下日志极易被冲掉——排障全程用静态快照:dumpsys / cat / getprop / ip
ip link set dev down 会清掉整张接口的路由表(3.4 节内核规则),排查时慎用,曾把 adb 弄失联
跨版本基线对拍 AOSP 版本升级可能悄悄删掉兜底机制(3.3 节)——升级前先对拍两版健康基线:dumpsys connectivity、ip route show table all、ip rule、getprop

附录 B:三层修复文件清单

层 修复 文件
正则转义 \\d/\\w 双反斜杠;config_ethernet_interfaces 增加 wlan0;...;;1(注册为 WIFI 类型,状态栏显示 WiFi 图标) packages/modules/Connectivity/service/ServiceConnectivityResources/res/values/config.xml
静态配置生效 探测(wait_conf:候选兜底5s 轮询等 IP)、rename_to_wlan0() 统一改名、write_conf() 写 id=实际接口名、chown system、无网关格式、双路径写入 system/core/init/ipconfigstore/{main.cc, kbox_net.cc}
网关恢复 read_cached_gateway() 缓存解析 + add_default_route() 恢复内核 default system/core/init/ipconfigstore/{ipconfig_ext.cc, kbox_net.cc}
DNS 兜底 servers 为空时回退 net.dns1..4 packages/modules/Connectivity/service/src/com/android/server/connectivity/DnsManager.java
权限 exec - root root(改名需 CAP_NET_ADMIN,切非 root 会丢 capability;SELinux init 域已有 net_admin,见 system/core/init/sepolicy/init.te) vendor/kbox/products/init.kbox.rc

附录 C:经验清单

  1. AOSP 资源正则必须 \\d:AAPT 会剥掉非法转义的反斜杠,用 dumpsys 看运行时生效值,别信源码。
  2. AOSP 版本升级可能悄悄删掉"兜底机制”:AOSP 11 的 net.dns* 兜底让"静态配置从没生效"都相安无事,AOSP 15 删掉后同样的环境立刻暴雷。
  3. ip route show all ≠ ip route show table all:前者在新 toybox 上静默失效,取证统一用 table all;再配 ip route get <目标> 看实际选路。
  4. 看懂 ip rule 的 fwmark:0x64=100 就是 netId,23000/31000 → lookup wlan0 就是应用流量的出网通道;main 表空 ≠ 断网。
  5. 接口 down 清路由、留地址、up 不恢复:改名、关机、ip link set down 都会触发;涉及改名的流程必须"先读后改",重启后必须考虑路由恢复。
  6. 二进制配置文件用 xxd/hexdump -C 看:strings 会把长度前缀等字节暴露成可见字符。
  7. 取证优先静态快照:dumpsys / cat / getprop / ip 不会随时间丢失;logcat 在低频操作下极易被冲掉(本次排障明确弃用)。
  8. 对照基线 + 主动复现:健康容器的全套输出就是"标准答案";复现时优先选能控制的路径(本例是容器重启),故障现场的 ipconfigstore.log 一行顶十次猜测。

附录 D:相关资源

  • 原版取回:git show <KBox 引入前的提交>:system/core/init/ipconfigstore/main.cc(将占位符替换为仓库中引入 KBox 底座之前的提交号)
  • 核心取证命令(静态快照五件套):
cat /data/misc/ethernet/ipconfigstore.log
dumpsys ethernet | head -20
dumpsys connectivity | grep -E "Active default|DnsAddresses"
ip route show table all
ip rule
  • 本文作者: 6x
  • 本文链接: https://6xyun.cn/article/244
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-ND 许可协议。转载请注明出处!