容器里 Android 开不了机:一次 netd 反复 abort 的排查
环境:云OS 容器化 Android 15,宿主内核
5.10.0-277.0.0.bonc,鲲鹏 ARM64 服务器,Docker 与 K8s 双环境部署。
现象:开机流程卡死,sys.boot_completed一直为空,netd 每 5 秒崩一次。
这篇的写法是"我当时怎么想的",推理链尽量摊开,方便对照源码自己走一遍。
一、背景:这条链路上都有什么,为什么平时看不见
1.1 Android 为什么要用 BPF
流量统计、防火墙规则、uid 联网限制这些功能,要求在内核态对每个包快速查表。早期 Android 用 qtaguid(一个内核模块),后来换成 eBPF——一段可以安全加载进内核的字节码,配合 map(键值存储)做查表。
用 BPF 就需要有人在开机时把字节码加载进内核、把 map 建出来、并且让后面的进程能拿到这些 map。这就是下面三个角色。
1.2 三个角色的分工
bpfloader —— 开机时加载 .o(编译好的 BPF 字节码),创建 map,然后把这些 map pin 到 bpffs 上。pin 的意思是"把内核对象挂成一个文件",之后任何进程打开这个文件就能拿到同一个 map 的 fd。
它有两个实现:平台侧的 /system/bin/bpfloader,以及 tethering APEX 里的 netbpfload(Android 15 实际跑的是后者,路径 /apex/com.android.tethering/bin/netbpfload)。本文所有操作针对的是后者。
bpffs —— /sys/fs/bpf,专门存放 BPF 对象的文件系统。pin 上去之后是这样的:
/sys/fs/bpf/netd_shared/
├── mainline_done ← 标记文件,表示"mainline 部分跑完了"
├── map_netd_configuration_map
├── map_netd_cookie_tag_map
├── map_netd_uid_owner_map ← 本文主角
├── map_netd_stats_map_A / _B
├── prog_netd_cgroupskb_egress_stats
└── ...(完整共 15 个 map + 15 个 prog)
netd —— Android 的网络守护进程。启动时要打开上面这批 map,才能做流量统计、防火墙、uid 联网限制。它的 BPF 字节码源码在 packages/modules/Connectivity/bpf/progs/netd.c,里面按顺序定义了 15 个 map。
为什么是"打开文件"而不是"重新创建":map 是内核里的共享对象,必须全系统唯一。pin 成文件之后,bpfloader 建、netd 开,两边通过文件系统交接——这也是为什么"map 文件不存在"会直接让 netd 起不来。
1.3 启动顺序:bpfloader 是 netd 的硬前置
system/core/rootdir/init.rc 里写死了这个顺序:
# system/core/rootdir/init.rc:570-571(on late-init 触发)
trigger load-bpf-programs
trigger bpf-progs-loaded
# system/core/rootdir/init.rc:1122-1126
on load-bpf-programs
exec_start bpfloader # 同步执行,跑完才继续
on bpf-progs-loaded
start netd
init.rc:1118-1121 的注释说得更直白:
we absolutely want to be before netd and the system boot slot is considered to have booted successfully.
这句话是理解整篇故障的前提:bpfloader 建不全 map,netd 就一定出问题;而 bpfloader"看起来成功了"(因为失败被吞掉),init 就会毫无察觉地启动 netd,让 netd 去撞墙。
1.4 RLIMIT_MEMLOCK:一个维度错配的限额
ulimit -l 看到的就是 RLIMIT_MEMLOCK。它的原始语义是"进程最多能 mlock 多少内存"——mlock 指把内存锁在物理内存里、不许换出到交换区。
BPF map 为什么用它计费:BPF map 分配的内核内存同样不可换出,性质和 mlock 一样,内核历史上就借用这个 rlimit 来限制 BPF 内存总量,防止失控进程把内核内存吃光。动机是合理的,但实现上埋了个坑——限额的维度是"进程",计费的维度是"用户"。
内核 5.10 的判断代码(kernel/bpf/syscall.c):
static int bpf_map_charge_memlock(struct bpf_map *map)
{
struct user_struct *user = get_current_user();
unsigned long memlock_limit;
memlock_limit = rlimit(RLIMIT_MEMLOCK) >> PAGE_SHIFT;
if (atomic_long_add_return(map->pages, &user->locked_vm) > memlock_limit) {
atomic_long_sub(map->pages, &user->locked_vm);
put_current_user();
return -EPERM; // ← 我们撞的就是这个
}
...
}
两个量拆开看:
| 量 | 来源 | 维度 |
|---|---|---|
memlock_limit |
rlimit(RLIMIT_MEMLOCK) |
当前进程自己的 rlimit |
user->locked_vm |
user_struct |
按用户累计,进程退出也不清零,要等 map 释放 |
而 user_struct 怎么找?内核 find_user() 对 root uid 有特判,直接返回静态的 root_user。容器没做 userns-remap 时,容器里的 uid0 就是宿主的 uid0——所有容器共享同一个 root_user,也就共享同一份 locked_vm 计数。
一个后来才纠正的认知:5.10 的这个函数不检查
CAP_IPC_LOCK。该 capability 只对mlock()生效,对 BPF map 的 rlimit 计费无效。我一开始就是用"容器里明明有CAP_IPC_LOCK“把 memlock 排除了,方向直接偏了半程。
1.5 为什么真机不会踩,容器才会
真机 / 模拟器:单实例,netd.o + tethering + gpu 那批 map 总量固定(几百 MB 量级),AOSP 在 netbpfload.35rc 里给的 rlimit memlock 1073741824(1GiB)刚好覆盖得住。装好之后永远不变,自然没人再动它。
容器:三件事叠加。
- 多实例共享计数——上一节说的
root_user共享,容器越多locked_vm越高; - 单容器需求更大——
PERCPU_*类型的 map 内存按value_size × CPU核数 × max_entries放大。这个放大很容易被忽略:一个uid_time_in_state(key 8B / value 256B / 1024 项)在 64 核机器上,光 per-CPU 副本就是256 × 64 × 1024 ≈ 16MB。实测单个 Android 15 容器整套约 800MB; - 失败形态恶劣——撞顶返回 EPERM,bpfloader 建不全 map 却因失败退出被吞掉,最后以"netd 开机卡死"的形式爆发,离真正的原因隔了三层。
还有个后面会反复用到的点:ulimit 和实际生效值之间隔着 init。init 在 fork 服务子进程时会按 rc 文件的 rlimit 指令显式 setrlimit(system/core/init/service_utils.cpp:259-264),这是覆盖而不是取较大值。所以 Docker 的 --ulimit 只影响 docker exec 进去的那个 shell,bpfloader 拿到的永远是 rc 里写的数。
二、现象:开机卡死,netd 每 5 秒崩一轮
init 日志:
11-07 19:41:36.015 7 7 I init : Service 'netd' (pid 1571) received signal 6
11-07 19:41:36.015 7 7 E init : process with updatable components 'netd' exited 4 times before boot completed
11-07 19:41:36.016 7 7 I init : Sending signal 9 to service 'zygote' ...
两个机制叠在一起造成死循环:
① updatable 服务的崩溃熔断。 system/netd/server/netd.rc:14 把 netd 标记为 updatable(因为它启动时会 dlopen DNS resolver APEX 的库)。init 对这类服务有崩溃计数:崩满 4 次就置 sys.init.updatable_crashing=1,然后 flags_health_check.rc 接管——boot 不会被标记为完成。
② netd 崩溃连带杀 zygote。 netd.rc:9:
onrestart restart zygote
onrestart restart zygote_secondary
于是每一轮都是:netd 启动 → 崩 → init 杀掉整个 zygote 进程组 → system_server 跟着死 → 重新拉起 → 再崩。sys.boot_completed 永远等不到。
为什么 netd 挂了会卡开机:system_server 里的 ConnectivityService 等组件启动时要拿 netd、dnsproxyd 这些 binder/socket 服务,拿不到就阻塞在那,整个 framework 起不来。
三、定位崩溃点
最初贴过来的 tombstone 只有部分线程栈,缺主线程和 signal 段。但从 logcat 尾巴已经能看出大概:
19:44:37.310 3111 3111 I resolv : resolv_init: Initializing resolver
19:44:37.310 3111 3111 E DnsBpfHelper: L42 init: No such file or directory
19:44:37.310 3111 3111 E resolv : resolveIsUidNetworkingBlockedFn: ADnsHelper_init failed No such file or directory
推理:日志停在 ADnsHelper_init failed,而我当时看到的代码里,这条 LOG(ERROR) 的下一句就是 abort()——时间上也吻合(这条日志之后 netd 就收到 signal 6 了)。但"日志停在这"只是旁证,得对着调用链确认每一环。
逐段走一遍,每段都能对上日志:
// packages/modules/DnsResolver/DnsResolver.cpp:31-48
bool resolv_init(const ResolverNetdCallbacks* callbacks) {
LOG(INFO) << __func__ << ": Initializing resolver"; // ← 对应日志第一行
...
android::net::gDnsResolv = android::net::DnsResolver::getInstance(); // 构造单例
// packages/modules/DnsResolver/DnsResolver.h:44
class DnsResolver {
...
DnsProxyListener mDnsProxyListener; // 单例的成员,构造时触发下面这段
// packages/modules/DnsResolver/DnsProxyListener.cpp:673-705
IsUidBlockedFn resolveIsUidNetworkingBlockedFn() {
if (!isAtLeastT()) return nullptr; // ★ Android 11 在这里就返回了,不走下面
...
const int ret = (*ADnsHelper_init)();
if (ret) {
LOG(ERROR) << __func__ << ": ADnsHelper_init failed " << strerror(-ret);
abort(); // ← 日志打完这一行,下一句就是它
}
// packages/modules/Connectivity/DnsResolver/DnsHelper.cpp:24-29
int ADnsHelper_init() {
auto result = sDnsBpfHelper.init();
if (!result.ok()) return -result.error().code(); // ENOENT → 返回 -2
return 0;
}
// packages/modules/Connectivity/DnsResolver/DnsBpfHelper.cpp:35-45
base::Result<void> DnsBpfHelper::init() {
...
RETURN_IF_RESULT_NOT_OK(mConfigurationMap.init(CONFIGURATION_MAP_PATH)); // L41
RETURN_IF_RESULT_NOT_OK(mUidOwnerMap.init(UID_OWNER_MAP_PATH)); // L42 ← ENOENT 在这
RETURN_IF_RESULT_NOT_OK(mDataSaverEnabledMap.init(DATA_SAVER_ENABLED_MAP_PATH));
RETURN_IF_RESULT_NOT_OK 这个宏(同文件 :27-33)的展开是 LOG(ERROR) << "L" << __LINE__ << ...,所以日志里那个 L42 就是源码行号,直接指到 uid_owner_map 那一行。这也是为什么日志能精确到行。
路径定义在:
// packages/modules/Connectivity/bpf/progs/netd.h:133, 181-187
#define BPF_NETD_PATH "/sys/fs/bpf/netd_shared/"
#define CONFIGURATION_MAP_PATH BPF_NETD_PATH "map_netd_configuration_map"
#define UID_OWNER_MAP_PATH BPF_NETD_PATH "map_netd_uid_owner_map"
#define DATA_SAVER_ENABLED_MAP_PATH BPF_NETD_PATH "map_netd_data_saver_enabled_map"
结论:netd 打不开 /sys/fs/bpf/netd_shared/map_netd_uid_owner_map,ENOENT → abort() → signal 6。
它死的位置很关键:system/netd/server/main.cpp:200 的 NetdNativeService::start() 在 initDnsResolver()(:187)之后,所以 netd 是在注册任何服务之前就死了,netd、dnsproxyd、fwmarkd 一个都起不来。
顺带解释 abort() 为什么是 signal 6:bionic 里 abort() 会给自己发 SIGABRT,tombstone 里看到的 received signal 6 就是它。
四、为什么 map 不存在
ls -l /sys/fs/bpf/netd_shared/
# 只有 3 个:
# map_netd_configuration_map
# map_netd_blocked_ports_map
# map_netd_cookie_tag_map
推理思路:map 是有定义顺序的,如果 bpfloader 是"按顺序建、中途失败”,那么留下的应该正好是定义顺序最靠前的那几个。拿 netd.c 对一下:
// packages/modules/Connectivity/bpf/progs/netd.c:67-83
DEFINE_BPF_MAP_RO_NETD(configuration_map, ARRAY, ...) // :67 ✅ 存在
DEFINE_BPF_MAP_NO_NETD(blocked_ports_map, ARRAY, ...) // :72 ✅ 存在
DEFINE_BPF_MAP_RW_NETD(cookie_tag_map, HASH, ...) // :74 ✅ 存在
DEFINE_BPF_MAP_NO_NETD(uid_counterset_map, HASH, ...) // :75 ❌ 从这里断
DEFINE_BPF_MAP_NO_NETD(app_uid_stats_map, HASH, ...) // :76 ❌
DEFINE_BPF_MAP_RO_NETD(stats_map_A, HASH, ...) // :77 ❌
...
DEFINE_BPF_MAP_RO_NETD(uid_owner_map, HASH, ...) // :80 ❌ 主角
完美吻合——留下的是前 3 个,断在第 4 个。这排除了"整个 netd.o 没加载"(那样一个都不会有),也排除了"pin 目录不对"(前 3 个就在正确目录里)。
再看创建逻辑,确认"中途失败不回滚":
// system/bpf/loader/Loader.cpp:521(createMaps 循环体)
// 逐个:创建 → 校验 → pin(:622) → chmod → chown
// :611 任一步失败即 return,但已 pin 的文件留在原地
if (!fd.ok()) return -saved_errno;
...
ret = bpfFdPin(fd, mapPinLoc.c_str());
所以现场形态是"建到第 4 个断了,前 3 个留在原地"。
失败为什么没被暴露
按 AOSP 原本的设计,loadAllElfObjects 失败应该让 bpfloader 快速失败并触发重启:
// packages/modules/Connectivity/bpf/loader/NetBpfLoad.cpp:1623-1635
for (const auto& location : locations) {
if (loadAllElfObjects(bpfloader_ver, location) != 0) {
ALOGE("=== CRITICAL FAILURE LOADING BPF PROGRAMS FROM %s ===", location.dir);
#if 0 // ← 本项目在 commit ac805fa99bf "patch the kbox" 里改的
ALOGE("--- DO NOT EXPECT SYSTEM TO BOOT SUCCESSFULLY ---");
sleep(20);
return 2;
#endif
}
}
...
// :1647 即使上面失败,也照样打标记
if (createSysFsBpfSubDir("netd_shared/mainline_done")) return 1;
#if 0 是本项目早前为了绕开机问题打的补丁。后果是:bpfloader 失败也返回 0,mainline_done 照样创建,init 认为前置就绪,正常 start netd——问题被推迟到 netd 身上爆发。
这也是为什么 mainline_done 存在却不能当成功标志:它只代表"跑到了那一行",不代表"所有 map 都建好了"。
把整条链串起来:
flowchart TD
A["多容器共享宿主 root_user<br/>user->locked_vm 全局累计"] --> B["bpfloader 的 rlimit 撞顶<br/>bpf(BPF_MAP_CREATE) = EPERM"]
B --> C["netd.o 建到第 4 个 map 中断<br/>map_netd_uid_owner_map 缺失"]
C --> D["NetBpfLoad.cpp:1632 的 #if 0 吞掉失败退出<br/>mainline_done 照样打,bpfloader 假成功"]
D --> E["init 认为前置就绪<br/>正常 start netd"]
E --> F["DnsBpfHelper.cpp:42 打不开 map → ENOENT<br/>DnsProxyListener.cpp:694 abort()"]
F --> G["netd signal 6,崩满 4 次<br/>sys.init.updatable_crashing=1"]
G --> H["netd.rc:9 onrestart 连带重启 zygote<br/>开机流程卡死"]
style A fill:#f9d0d0
style H fill:#f9d0d0
五、排查过程:每一步在想什么
这一节按当时的实际顺序写,包括走错的方向。
5.1 第一个障碍:日志根本拿不到
想看 bpfloader 的日志,结果发现它不写 logcat:
// packages/modules/Connectivity/bpf/loader/NetBpfLoad.cpp:1670-1671
int main(int argc, char** argv, char * const envp[]) {
InitLogging(argv, &KernelLogger); // ← 输出到 /dev/kmsg,不进 logd
配套证据是 netbpfload.35rc:5 特意给了它 file /dev/kmsg w 写权限。试着在容器里手写一条验证通道:
echo "MARKER_XYZ123" > /dev/kmsg
# sh: can't create /dev/kmsg: Operation not permitted
root 都写不进去,说明这条路彻底断了。更糟的是后来 logd 也挂了(logcat: Logcat read failure: Connection refused),连 init 的日志都拿不到。
这一段的教训:grep 不到日志 ≠ 没有日志。当时 dmesg | grep bpf 搜不到东西,我一度判断"bpfloader 根本没跑"——这个结论是错的,只是日志通道坏了。先验证通道,再下结论。
绕过去的方法是 strace:不依赖任何日志,直接看系统调用。
# 容器内
strace -f -o /data/local/tmp/nb.strace /apex/com.android.tethering/bin/netbpfload
grep -nE 'bpf\(BPF_MAP_CREATE|uid_counterset|exit_group|= -1 E' /data/local/tmp/nb.strace
5.2 逐个排除:SELinux、seccomp、capability
bpf(BPF_MAP_CREATE) 返回 EPERM,可能的来源无非几类:权限(capability / LSM / seccomp)、资源限额(memlock)、内核本身拒绝。一个个查:
getenforce # Disabled → SELinux 排除
cat /proc/self/status | grep -E 'Cap|Seccomp'
# CapEff: 0000001ce9ad75ff
# Seccomp: 0 → seccomp 排除
CapEff 按位解码(这一步容易看错,16 进制要按 nibble 对齐):
| 位 | 能力 | 值 |
|---|---|---|
| bit21 | CAP_SYS_ADMIN |
0x200000 ✅ |
| bit14 | CAP_IPC_LOCK |
0x4000 ✅ |
| bit12 | CAP_NET_ADMIN |
0x1000 ✅ |
| bit39 | CAP_BPF |
无,但有 SYS_ADMIN 就够 bpf_capable() |
三个排除项都有了。而且我当时据此把 memlock 也排除了——理由是"有 CAP_IPC_LOCK"。这个推理是错的:回去翻 kernel/bpf/syscall.c 才发现 5.10 的 bpf_map_precharge_memlock() 压根不查这个 capability,它只比对 rlimit。
教训:capability 是否生效,要看具体内核版本的实现,不能按名字望文生义。
5.3 样本选错:拿 Android 11 当参照
宿主上跑着 7 个容器,ulimit -l 都是 65536,其中几个开机会正常。我一度把它当"memlock 没问题"的证据。
后来查了版本才明白:
android_18/19/20/21 sdk=30 release=11 ← 正常
android_33/53/59 sdk=35 release=15 ← 出事
而调用链里有这么一句(前面 5.2 引过):
// packages/modules/DnsResolver/DnsProxyListener.cpp:675
if (!isAtLeastT()) return nullptr;
Android 11 根本不走 ADnsHelper_init,没有 map 也照样开机。所以那 4 个容器的"正常"对 Android 15 毫无参考价值——参照样本必须先确认同源。
(顺带解释了 android_18/19/20 的怪现象:它们 /sys/fs/bpf/ 里只有 maps.debug、progs.debug,连 netd_shared 目录都没有,却 boot_completed=1。因为 sdk 30 压根不需要。)
5.4 转折:一个简单的对照实验
排除法走到头之后,剩下的就是资源限额。做一个单变量对照:
# 容器内,跑同一个 netbpfload,只改 memlock
ulimit -l 65536 下跑 → bpf(BPF_MAP_CREATE) 3/3 全 EPERM
ulimit -l unlimited 下跑 → 40+ 个全部成功,map 一次建齐
strace 里那行很干脆:
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH, key_size=4, value_size=1,
max_entries=4000, map_name="uid_counterset_", ...}) = -1 EPERM (Operation not permitted)
唯一变量是 memlock → memlock 就是根因。 这个实验的价值在于它绕开了所有环境差异(同一个 shell、同一份二进制、同一套 capability),只动一个旋钮。
5.5 深挖:为什么是"全局"累计
知道是 memlock 之后,还有个反常现象解释不了:1GiB 不够用。总需求按 netd.h:95-116 的公式算才几 MB,怎么会撞 1GiB?
回到 1.4 节那个维度错配:
if (atomic_long_add_return(pages, &user->locked_vm) > memlock_limit)
locked_vm 是用户级累计,进程退出也不清零,只有 map 释放才减。容器 uid0 共享 root_user,所以同机所有容器的 BPF map 都在往同一个计数器里加。
验证方法是看启动时机的梯度。查了几个容器的 map 数量:
| 容器 | 启动时机 | 建出的 map 数 |
|---|---|---|
| android_33 | 早 | 37(齐全) |
| android_53 | 中 | 10 |
| android_59 | 晚 | 3 |
单调递减,正好是"水位逐个抬高"的轨迹。再倒推单容器占用:android_59 启动时水位已接近 1GiB,它自己只建了 3 个就撞顶;android_53 启动时建了 10 个撞顶。两次撞顶点之间的差值反推,单个 Android 15 容器整套约 800MB(其中 PERCPU_* 按核数放大贡献大头)。
这也回答了"前几天为什么可以":android_33 启动得早,那时水位还低;而且同机那批 Android 11 容器根本不在这条故障路径上。两个因素叠在一起,造成了"代码没改过、前几天还好好的"的假象。
5.6 关键验证:容器配置到底有没有用
知道 memlock 是根因后,第一反应是给容器加 --ulimit(下一节第一版)。加完 ulimit -l 确实变了,但 map 还是只有 3 个。
为什么?回到 1.5 节埋的伏笔——init 会覆盖:
// system/core/init/service_utils.cpp:259-264
for (const auto& rlimit : attr.rlimits) {
if (setrlimit(rlimit.first, &rlimit.second) == -1) {
return ErrnoErrorf("setrlimit({}, {{rlim_cur={}, rlim_max={}}}) failed", ...);
// 失败 → 服务直接不启动,没有"退回继承容器值"这条路
}
}
这是覆盖,不是取较大值。 bpfloader 拿到的永远是 netbpfload.35rc 里写的数,容器给多大都没用。
反过来也有证据:android_33 的 /proc/1/limits 显示 hard 只有 64MB,bpfloader 却能建出需要远超 64MB 的 map——说明 init 凭 CAP_SYS_RESOURCE(base_box.sh:532 明确加了)把 hard 提上去了。
结论:rc 是唯一起作用的配置点,容器侧改了也是白改。
当时还设计了一个实验来专门验证"覆盖"这件事:把 rc 还原成 1GiB、容器给 2GiB,如果 bpfloader 还是只建出 3 个,就证明是 rc 在起作用。结果:
容器 --ulimit = 2GiB(ulimit -l 确认 2097152)
map = 5 行(3 个 map + mainline_done + total) ← 仍然只有 3 个
证伪了"容器值和 rc 值相等所以失败"的猜想,证实了覆盖行为。
六、解法改了三版
第一版:给容器加 --ulimit —— 无效
# device/byteos/base/docker/base_box.sh(当时加的)
RUN_OPTION+=" --ulimit memlock=1073741824:1073741824 "
如上所述,被 rc 覆盖,白改。后来还原了。
第二版:把 rc 改成固定值 —— 有效,但治标
--- a/packages/modules/Connectivity/bpf/loader/netbpfload.35rc
+++ b/packages/modules/Connectivity/bpf/loader/netbpfload.35rc
- rlimit memlock 1073741824 1073741824
+ rlimit memlock 4294967296 4294967296
1GiB → 2GiB → 4GiB 逐级试出来的。2GiB 是实测通过的最小值(1GiB 失败 / 2GiB 成功),4GiB 是按 800MB × 5 个并发 算的余量。
问题是容器数量动态,固定阈值迟早又被撞破——本质是把坑换了个位置。
第三版:unlimited —— 最终方案
--- a/packages/modules/Connectivity/bpf/loader/netbpfload.35rc
+++ b/packages/modules/Connectivity/bpf/loader/netbpfload.35rc
@@ -3,7 +3,15 @@
file /dev/kmsg w
- rlimit memlock 1073741824 1073741824
+ # memlock 设为 unlimited:bpfloader 的 BPF map 计费走 user->locked_vm(按 uid 全局累计,
+ # 所有容器共享),任何固定阈值都会随同机动态增长的容器数被撞破(实测 1GiB 仅够 1 个
+ # Android 15 容器,单容器约 800MB)。故移除该约束,实际内存由 cgroup memory 兜底
+ # (BPF map 走 kmalloc/vmalloc,计入容器 --memory 限制,超限为容器内 OOM 而非 EPERM)。
+ # init 具备 CAP_SYS_RESOURCE,可将 hard limit 提至 RLIM_INFINITY,容器/K8s 无需配套修改。
+ # (rlimit_parser.cpp:74/80 支持 "unlimited" 关键字)
+ rlimit memlock unlimited unlimited
两个前提分别验证过:
system/core/init/rlimit_parser.cpp:74/80明确支持unlimited关键字 →RLIM_INFINITY;- Docker 和 K8s 两侧的 yaml 都确认有
CAP_SYS_RESOURCE,init 能把 hard 从 64MB 提到RLIM_INFINITY。
memlock_limit 变成 RLIM_INFINITY 之后,1.4 节那个比较永不成立,memlock 约束被彻底移除。
没有了 memlock 预警,内存靠 cgroup 兜底:BPF map 走 kmalloc/vmalloc,会计入容器的 cgroup memory,受 base_box.sh 的 --memory 约束。对比失败形态:
| 之前 | 之后 | |
|---|---|---|
| 瓶颈 | 全局累计撞固定阈值 | 无 memlock 瓶颈,由宿主内存自然决定 |
| 失败形态 | EPERM → netd abort → 开机卡死,极难查 | 容器内 OOM,标准行为,可见可查不伤宿主 |
用一个隐蔽的开机杀手换一个正常的 OOM,划算。
最终 diff 只有一个文件一行值,base_box.sh 和 K8s yaml 都没动。这也回答了"能不能让这两个值相互独立":容器 --ulimit 本来就不是有效作用点,撤掉之后自然只剩一处需要维护。
七、生效与验证
# 主机端:重编 APEX(rc 打包在 /apex/com.android.tethering/ 里)
m com.android.tethering
# 删除重建容器(restart 的 stop/start 不会更新进程 rlimit)
bash device/byteos/base/docker/base_box.sh delete <BOX_NAME>
bash device/byteos/base/docker/base_box.sh start --name <BOX_NAME> ...
容器内验证:
ls -l /sys/fs/bpf/netd_shared/ | wc -l # 38(15 map + 15 prog + mainline_done 等)
stat -c '%y %n' /proc/1 /sys/fs/bpf/netd_shared/map_netd_uid_owner_map
# 两者相差约 2 秒 → 说明是容器启动时 bpfloader 自建的,不是手工补的
ps -A | grep -E 'netd|system_server' # pid 稳定,状态 S
getprop sys.boot_completed # 1
logcat -d -b main | grep -iE 'DnsBpfHelper|signal 6' | tail -5 # 无输出
多容器回归(这是 unlimited 的真正考题,之前 android_53 只建出 10 个、android_59 只有 3 个):
# 宿主
for c in android_33 android_53 android_59; do
printf "%-12s maps=%s\n" $c \
"$(docker exec $c sh -c 'ls /sys/fs/bpf/netd_shared/ 2>/dev/null | wc -l')"
done
# 三个都是 37 = 彻底闭环
八、复盘
判断错了、后来纠正的:
- “有某 capability 所以排除某限制"要翻内核源码。
CAP_IPC_LOCK对mlock()生效,对 BPF map 的 rlimit 计费在 5.10 上不管用。我据此排除 memlock,白走了半程。 - 参照样本要先确认同源。 拿 Android 11 的容器论证 Android 15 的问题,等于拿错卷子对答案。
- 配置项的作用点要实测。
--ulimit加上去ulimit -l确实变了,但变的是docker exec那个 shell 的值,不是 bpfloader 的值。从"它存在"推不出"它生效”。 - 日志通道本身可能是坏的。 kmsg 写不进、logd 挂掉,这两种情况下"搜不到日志"不等于"没有日志",先验证通道再下结论。
mainline_done不是成功标志。 它只代表代码跑到了那一行,配合被#if 0吞掉的失败退出,反而制造了"bpfloader 成功了"的假象。
方法上有效的:
- 单变量对照实验(只改
ulimit跑同一个二进制)是这次最快的突破口,比逐项排除快得多; - 用启动时机的梯度数据反推机制(37→10→3),把"固定阈值不够"升级成了"全局累计"这个正确认识;
- strace 绕开日志,在日志通道全断的情况下保住了取证能力。
还欠着的两笔账(不阻塞开机):
system/netd/server/main.cpp:141的 cgroup v2 降级——容器目前是纯 cgroup v1(/dev/cgroup_info/cgroup.rc缺失),独立于 memlock 的另一个环境问题;NetBpfLoad.cpp:1632的#if 0——建议这套稳定跑一段时间后还原return 2,让 bpfloader 真失败时当场暴露,而不是推迟到 netd 身上以"开机卡死"的形式爆发。同时 memlock 预警移除后,BPF map 异常增长只能靠内存监控发现,宿主侧建议加一条。