PAYATHON 2026

Docker 容器中的非 root 程序如何通过 /dev/port 操作宿主机 GPIO?

数据阿明

结论

保留 devices 和 cap_add: SYS_RAWIO,在最终镜像中完成程序复制后,以 root 身份执行 setcap cap_sys_rawio=ep,再切换到 my_user 运行程序。

现有现象首先指向镜像内的程序缺少有效的 file capability,还需要用 getcap 确认。宿主机上设置过 capability,不能证明镜像中的文件也带有相同属性。

以下方案适用于直接运行在目标 Linux 宿主机上的常规 rootful Docker,且没有阻止 file capability 生效的额外限制。

为什么 chmod 777 和 cap_add 仍然不够?

访问这个设备需要同时满足以下条件:

  • devices 将宿主机设备提供给容器,并配置相应的设备访问权限。
  • 设备文件的属主、属组和 mode 允许当前用户读写。
  • 调用 open() 的进程实际拥有有效的 CAP_SYS_RAWIO。

chmod 777 只能影响文件访问权限。Linux 打开 /dev/port 时还会检查 CAP_SYS_RAWIO,检查失败会返回 EPERM,也就是这里的 Operation not permitted。参见 Linux 内核中的 open_port 实现。

cap_add 配置容器允许使用的额外 capability,但不能据此认定非 root shell 启动的每个程序都在 effective set 中拥有这项能力。这里的非 root 二进制程序可以通过 file capability 在执行时取得所需能力,前提是该能力仍在进程的 bounding set 中。cap_sys_rawio=ep 表示将这项能力加入文件的 permitted 集,并使执行后的对应能力进入 effective 集。相关规则见 capabilities(7)。

宿主机上的 sudo docker exec 也不会自动把容器内用户改成 root。没有指定 --user 时,仍使用容器配置的用户。

在最终镜像中设置 file capability

可以将 Dockerfile 调整为:

# syntax=docker/dockerfile:1

FROM oraclelinux:9

USER root
RUN dnf -y install libcap && dnf clean all
RUN useradd --home-dir /home/my_user --uid 1000 --create-home --shell /bin/bash my_user
RUN mkdir -p /home/my_user/bin /home/my_user/gpio

COPY --chown=root:root --chmod=0755 gpio_test /home/my_user/bin/gpio_test
RUN setcap cap_sys_rawio=ep /home/my_user/bin/gpio_test
RUN getcap /home/my_user/bin/gpio_test

USER my_user
WORKDIR /home/my_user
ENTRYPOINT ["tail", "-f", "/dev/null"]

这里使用 libcap 提供的工具设置和查看文件 capability。顺序是先 COPY 程序,再执行 setcap,最后切换运行用户。

file capability 保存在 security.capability 扩展属性中,不包含在 0755 这样的 mode 位里,因此 COPY --chmod=0755 不能替代 setcap。如果采用多阶段构建,也应在最终阶段的最后一次程序复制之后设置,不依赖前一阶段或宿主机上的属性是否被保留。设置命令的含义见 setcap(8)。

保留设备映射和 SYS_RAWIO,重新创建容器

下面使用 gpio-test:1 作为示例镜像标签。Compose 文件需要正确的 YAML 层级:

name: foo

services:
  bar:
    image: gpio-test:1
    container_name: test
    devices:
      - /dev/port:/home/my_user/gpio/port:rw
    cap_add:
      - SYS_RAWIO

路径与现有 C 程序一致,无需修改 open() 的参数。devices 与 cap_add 分别配置设备访问和额外 capability,定义见 Docker Compose services 文档。

在构建目录执行:

docker build -t gpio-test:1 .
docker compose up -d --force-recreate bar

检查容器中实际运行的用户和文件属性:

docker exec test id
docker exec test getcap /home/my_user/bin/gpio_test

第二条命令预期会显示类似以下属性,需要在实际环境中确认:

/home/my_user/bin/gpio_test cap_sys_rawio=ep

确认后,可以在允许切换 GPIO 状态的情况下执行:

docker exec test /home/my_user/bin/gpio_test on

如何理解 docker cp 后能够运行?

两次执行使用的是不同路径:镜像中的 /home/my_user/bin/gpio_test,以及后来复制进去的 /home/my_user/gpio_test。可以先比较它们的 file capability:

docker exec test getcap /home/my_user/bin/gpio_test /home/my_user/gpio_test

如果只有后者显示 cap_sys_rawio=ep,就能解释为什么它可以打开设备,而镜像中的程序失败。

复制后成功本身不能证明 docker cp 在所有环境下都会保留 capability。应以容器内目标文件的实际属性为依据,并把设置步骤固定在镜像构建过程中。

设置后仍然失败,继续检查什么?

先确认容器内映射的是正确的字符设备:

docker exec test ls -l /home/my_user/gpio/port

它应对应题目中的主、次设备号 1, 4,并允许 my_user 读写。777 无法替代 capability;日常配置可以使用适当的设备属组和读写权限。

再检查执行环境的能力边界和提权限制:

docker exec test sh -c 'grep -E "^(CapBnd|CapEff|NoNewPrivs):" /proc/self/status'

这里看到的是运行检查命令的进程状态。shell 的 CapEff 中没有 SYS_RAWIO,不能直接证明带 file capability 的 gpio_test 执行后也没有这项能力。需要关注 bounding set 是否允许该能力,以及 file capability 是否能在执行时生效。

如果 NoNewPrivs 为 1,进程不能通过执行文件新增 permitted capability,这会影响上述方案。参见 Linux 内核 no_new_privs 文档。此外,还应根据宿主机审计日志检查 SELinux、AppArmor 或 kernel lockdown 是否拒绝访问。rootless Docker 或 user namespace 内的 capability 不能直接等同于宿主机所需的权限,需要单独评估。

现有 C 程序没有检查 lseek()、read() 和 write() 的返回值。修复打开设备的权限后,仍应检查定位是否成功,以及读写是否恰好完成 1 字节,避免仅凭打印出的 on 或 off 判断硬件操作成功。端口 0xA02 和位号 6 的含义取决于具体硬件,不能作为其他设备的通用 GPIO 配置。

备注:内容仅供参考。