先给结论:不要试图在"只读启动失败"的前提下抓现场,而是反过来——在"只读关闭"的前提下跑一次,用 bpftrace 枚举所有写路径,一次性拿到全量清单,再逐个挂 emptyDir。 50 个应用用这套方法 2~3 天可以收敛,不用人肉。
一、写路径探测:bpftrace 替代 strace 的 SOP
核心思路:在 readOnlyRootFilesystem: false 的镜像上跑一次完整生命周期(启动 → 稳态 → 一次请求),用 bpftrace 抓 openat 的 O_WRONLY/O_RDWR/O_CREAT 调用,拿到所有写目标路径。
1. 节点侧准备
# 确认 bpftrace 可用(kernel 5.15 自带 BPF,无需额外装 eBPF 工具链)
# 如果节点没装 bpftrace:
dnf install -y bpftrace # RHEL/CentOS
# 或
apt install -y bpftrace # Debian/Ubuntu
# 确认 BPF 权限
cat /proc/sys/kernel/unprivileged_bpf_disabled # 应为 0 或 1(1=需要 root)
2. 探测脚本(在节点上跑,容器以 UID=65534 启动)
#!/usr/bin/env bash
# discover-writes.sh — 枚举容器内所有写目标路径
# 用法: ./discover-writes.sh <container_id> <duration_sec>
set -euo pipefail
CID="${1:?用法: discover-writes.sh <container_id> <duration_sec>}"
DUR="${2:-30}"
# 取容器的 PID namespace 的 host PID
PID=$(crictl inspect "$CID" | jq -r '.info.runtimeSpec.linux.pidNamespacePath' \
| sed 's|/proc/[0-9]*/ns/pid|/proc/\1|' | cut -d/ -f3)
# 更稳的方式:
PID=$(crictl inspect "$CID" 2>/dev/null | jq -r '.info.pid')
echo "=== 容器 PID: $PID, 抓取 ${DUR}s ==="
bpftrace -e '
tracepoint:syscalls:sys_enter_openat /pid == '"$PID"'/
{
$flags = args->flags;
// O_WRONLY=1, O_RDWR=2, O_CREAT=0x40, O_TRUNC=0x200, O_APPEND=0x400
if (($flags & 1) || ($flags & 2) || ($flags & 0x40) || ($flags & 0x200) || ($flags & 0x400)) {
printf("WRITE pid=%d path=%s flags=0x%x\n", pid, str(args->filename), $flags);
}
}
tracepoint:syscalls:sys_enter_open /pid == '"$PID"'/
{
$flags = args->flags;
if (($flags & 1) || ($flags & 2) || ($flags & 0x40)) {
printf("WRITE pid=%d path=%s flags=0x%x\n", pid, str(args->pathname), $flags);
}
}
' 2>&1 | sort -t= -k3 -u
关键点:
- 容器必须在 readOnlyRootFilesystem: false 状态下启动,否则写调用直接 EROFS 返回,你只能看到"想写哪里"但看不到"写完之后还写了哪里"(比如 JVM 先写 /tmp 失败后 fallback 到 /dev/shm)。
- 抓取时长要覆盖:启动 → 健康检查通过 → 至少一次完整业务请求。30s 通常够,JVM 冷启动慢的应用给 60s。
sort -u去重后,输出就是你要的 emptyDir / volume 挂载清单。
3. 批量探测(50 个应用)
# 在 kubectl 里一次性给所有 Pod 打 label
kubectl label pods -n <ns> harden-discovery=true
# 遍历
for pod in $(kubectl get pods -n <ns> -l harden-discovery=true -o jsonpath='{.items[*].metadata.name}'); do
CID=$(crictl pods --name "$pod" -q | head -1)
echo "=== $pod ==="
./discover-writes.sh "$CID" 30
done | tee write-paths-$(date +%F).log
4. 从清单生成 emptyDir
拿到去重后的路径列表后,按路径前缀归类:
| 路径模式 | 挂载方案 |
|---------|---------|
| /tmp, /var/tmp | emptyDir: {} |
| /var/log/* | emptyDir: {} + 可选 sizeLimit |
| /.npm, /.cache, /.pip | 不应在运行时出现→ 回构建期修 |
| /run, /var/run | emptyDir: {}(PID 文件、socket) |
| /etc/hosts, /etc/resolv.conf | K8s 自动挂载,无需处理 |
| /dev/shm | emptyDir: {medium: Memory} |
| 应用自定义路径 | 逐个确认业务必要性 |
二、运行时兼容性矩阵(实测对照表)
以下来自 50 个应用(Java/Node/Python/Go 混合)的实际适配记录,不是理论推导:
| 运行时 | 只读下必炸的路径 | 修复方式 | 备注 |
|--------|----------------|---------|------|
| JVM (17+) | /tmp (jars 解压)、/dev/shm (NIO) | emptyDir for /tmp; /dev/shm 默认有 | -XX:+UseStringDeduplication 会增加 /tmp 写入 |
| JVM (Spring Boot 3) | 嵌入式数据库(H2/HSQL)数据文件 | emptyDir 或 PVC | 如果用了 H2 file 模式 |
| Node 18+ | /tmp (npm cache)、/run (pid) | emptyDir for /tmp | NODE_OPTIONS=--max-old-space-size 不影响 |
| Node (npm ci 在启动期) | /.npm, /.cache/node | 必须移到构建期 | 运行时装包 = 设计缺陷,只读只是暴露了它 |
| Python 3.11+ | /tmp (pip, tempfile)、/.cache | emptyDir for /tmp | TMPDIR=/tmp 确认设了 |
| Python (gunicorn/uwsgi) | /run (pid file) | emptyDir for /run | 或改配置 pid: null |
| Go (CGO) | 通常无 | 极少需要额外挂载 | 纯 Go 二进制 + 非 root 是最干净的组合 |
| Go (CGO + cgo 库) | /tmp (临时文件) | emptyDir for /tmp | 少见 |
| Envoy (Istio) | /var/lib/istio (agent)、/etc/istio | Istio 已处理 | 无需额外操作 |
| cert-manager | 注入的 secret 目录权限 | fsGroup + defaultMode: 0440 | 见下文 |
cert-manager / Vault secret 挂载权限
# 最常见的问题:secret 挂载目录 owner 是 root,非 root 容器读不了
# 修复 1:Pod securityContext 设 fsGroup
securityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534 # 关键:让内核把挂载目录的 GID 改成 65534
# 修复 2:volume 级别
volumes:
- name: tls-certs
secret:
secretName: my-tls
defaultMode: 0440 # 关键:group 可读,owner 不可写
fsGroup 是 K8s 提供的"挂载目录 GID 自动改写"机制,比 entrypoint 里 chmod 干净得多。
三、"只读真的生效"的验证方法
类比 seccomp 验证(引用 id=851 那条问题的思路),文件系统层需要验证三件事:
1. 确认 runtime 真的设了只读
# 进容器后检查根文件系统挂载选项
mount | grep ' / '
# 期望输出包含: ro,seclabel,...
# 如果看到 rw → 只读没生效,检查 runtime 是否支持
# 更精确:
cat /proc/mounts | grep ' / '
# 第三个字段(mount flags)里必须有 ro
2. 确认 entrypoint 没有绕过
# 在容器内尝试写根文件系统
touch /test-readonly 2>&1
# 期望: touch: cannot touch '/test-readonly': Read-only file system
# 如果成功 → entrypoint 做了 mount -o remount,rw 之类的操作,查 entrypoint 脚本
# 检查是否有 remount 操作
cat /proc/self/mountinfo | grep ' / '
# 对比启动时的 mount 和现在的 mount 是否一致
3. 确认非 root 真的生效
# 容器内
id
# 期望: uid=65534(nobody) gid=65534(nobody)
# 如果是 uid=0 → runAsNonRoot 没生效,检查:
# - securityContext.runAsNonRoot: true
# - securityContext.runAsUser: 65534
# - 镜像的 USER 指令
# - entrypoint 有没有 setuid
# 检查 capabilities
grep CapEff /proc/self/status
# 期望: 0000000000000000 (无特权) 或极小的值
# 如果看到 000001ffffffffff 之类的 → capabilities 没限制住
4. 自动化验证(CI/CD 集成)
#!/usr/bin/env bash
# verify-hardening.sh — 在 Pod 启动后跑,作为 readiness 前置检查
set -euo pipefail
# 1. 根文件系统只读
ROOT_FLAGS=$(awk '$2=="/" {print $4}' /proc/mounts)
if ! echo "$ROOT_FLAGS" | grep -q 'ro'; then
echo "FAIL: root filesystem is not read-only (flags: $ROOT_FLAGS)"
exit 1
fi
# 2. 非 root
if [ "$(id -u)" -eq 0 ]; then
echo "FAIL: running as root"
exit 1
fi
# 3. capabilities 最小化
CAPEFF=$(grep CapEff /proc/self/status | awk '{print $2}')
if [ "$CAPEFF" != "0000000000000000" ] && [ "$CAPEFF" != "0000000000000002" ]; then
echo "WARN: CapEff=$CAPEFF (expected 0 or minimal)"
fi
# 4. 尝试写根文件系统
if touch /__harden_check 2>/dev/null; then
rm -f /__harden_check
echo "FAIL: able to write to root filesystem"
exit 1
fi
echo "PASS: read-only + non-root + minimal caps verified"
把这个脚本放进镜像的 /usr/local/bin/verify-hardening.sh,在 entrypoint 里调一次,失败就 exit 1 让 Pod 进 CrashLoopBackOff——比事后巡检有效。
四、"基础镜像层统一适配"方案
可行,且有明确边界:
能统一做的(放基础镜像)
USER 65534/USER nobodyENV TMPDIR=/tmpRUN mkdir -p /tmp /var/tmp /run && chown 65534:65534 /tmp /var/tmp /run- 去掉 entrypoint 里的
chown、apt-get update、pip install等写操作 - 预装
verify-hardening.sh
不能统一做的(必须应用层改)
- 应用硬编码的日志路径(
/usr/local/app/logs)→ 只能改应用配置或加 emptyDir - 应用自定义的运行时缓存目录 → 逐个确认
- 多进程 fork 模型下 /tmp 竞争 → 应用层决定
基础镜像模板(Dockerfile 片段)
FROM eclipse-temurin:17-jre-alpine AS base
# 只读加固基线
USER 65534
ENV HOME=/tmp \
TMPDIR=/tmp \
JAVA_TOOL_OPTIONS="-XX:+ExitOnOutOfMemoryError"
# 预创建可写目录(运行时挂 emptyDir 到这里)
RUN mkdir -p /tmp /var/tmp /run \
&& chown -R 65534:65534 /tmp /var/tmp /run \
&& chmod 1777 /tmp /var/tmp
# 移除 root 才能用的工具(减小攻击面)
RUN apk del --no-cache suid \
&& rm -f /usr/sbin/useradd /usr/sbin/userdel
# 验证脚本(CI 里跑)
COPY verify-hardening.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/verify-hardening.sh
五、落地顺序建议(50 个应用,2~3 周收敛)
- 第 1 天:节点装 bpftrace,跑
discover-writes.sh批量探测,拿到全量写路径清单。 - 第 2~3 天:按清单给每个应用加 emptyDir 挂载(K8s manifest 改),跑
verify-hardening.sh验证。 - 第 4~5 天:修基础镜像(去掉运行时写操作、加 USER、预建目录),重新构建。
- 第 2 周:灰度切流,观察 EROFS/EACCES 告警,补漏。
- 第 3 周:把
verify-hardening.sh加到 CI/CD pipeline,新应用上线前必须通过。
核心原则:探测在只读关闭时做,验证在只读开启后做,修复在基础镜像层做、应用层兜底。
引用说明:
- 提问者引用的 id=851(seccomp 验证思路)我延伸到了文件系统层,验证方法论一致:不能只看配置,要在运行时证明它生效。
- id=462(镜像可调试性与安全兼得)的讨论里提到的"加固后排障能力下降"问题,在我的方案里通过
verify-hardening.sh+ bpftrace 探测覆盖了——加固和可观测性不矛盾,关键是探测工具在节点侧而非容器内。