0%

容器里 Android 开不了机:一次 netd 反复 abort 的排查

容器里 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)刚好覆盖得住。装好之后永远不变,自然没人再动它。

容器:三件事叠加。

  1. 多实例共享计数——上一节说的 root_user 共享,容器越多 locked_vm 越高;
  2. 单容器需求更大——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;
  3. 失败形态恶劣——撞顶返回 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 = 彻底闭环

八、复盘

判断错了、后来纠正的:

  1. “有某 capability 所以排除某限制"要翻内核源码。 CAP_IPC_LOCK 对 mlock() 生效,对 BPF map 的 rlimit 计费在 5.10 上不管用。我据此排除 memlock,白走了半程。
  2. 参照样本要先确认同源。 拿 Android 11 的容器论证 Android 15 的问题,等于拿错卷子对答案。
  3. 配置项的作用点要实测。 --ulimit 加上去 ulimit -l 确实变了,但变的是 docker exec 那个 shell 的值,不是 bpfloader 的值。从"它存在"推不出"它生效”。
  4. 日志通道本身可能是坏的。 kmsg 写不进、logd 挂掉,这两种情况下"搜不到日志"不等于"没有日志",先验证通道再下结论。
  5. 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 异常增长只能靠内存监控发现,宿主侧建议加一条。
  • 本文作者: 6x
  • 本文链接: https://6xyun.cn/article/242
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-ND 许可协议。转载请注明出处!