用 readOnlyRootFilesystem 配合 emptyDir 挂载可写临时目录的最小化配置

把 Pod 的 securityContext.readOnlyRootFilesystem 设为 true 之后,最快的反馈往往不是安全加固的成就感,而是一串启动失败:nginx 起不来、Java 进程报 /tmp 不可写、Node 应用在写日志时崩溃。原因很直接——容器根文件系统变成只读以后,所有原本"随手写到容器层"的路径都失效了,而镜像里绝大多数程序

把 Pod 的 securityContext.readOnlyRootFilesystem 设为 true 之后,最快的反馈往往不是安全加固的成就感,而是一串启动失败:nginx 起不来、Java 进程报 /tmp 不可写、Node 应用在写日志时崩溃。原因很直接——容器根文件系统变成只读以后,所有原本”随手写到容器层”的路径都失效了,而镜像里绝大多数程序都默认依赖这些路径。

本文给出的方案是:根文件系统只读 + 针对每个真正需要写入的路径挂载 emptyDir。听起来简单,但真正落地时要回答三个问题:到底哪些路径需要挂?挂载点会不会被镜像目录里的内容覆盖?/tmp 和 /var/tmp 都挂上是不是多余?下面从原理到最小清单,再到排错方法,逐层拆开。

为什么 readOnlyRootFilesystem 会打断容器启动

容器镜像的分层文件系统在运行时会在最上面叠一层可写层(overlayfs 的 upperdir)。程序写文件时,如果目标路径在镜像里不存在,就在可写层新建;如果已存在,就把文件从下层 copy_up 到上层再改。这层可写层是临时的,容器一删就没了,但它对运行时是必需的。

打开 readOnlyRootFilesystem: true 后,kubelet 传给容器运行时的挂载参数里,根挂载点变成 ro。此时所有写操作返回的错误是内核级的 EROFS(Read-only file system),在应用层表现为各种具体报错:

  • nginx:nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
  • Java / Spring Boot:java.io.IOException: Read-only file system,堆栈落在 java.io.File.createNewFile 或 FileOutputStream
  • Node.js:Error: EROFS: read-only file system, open '/app/logs/app.log'
  • Python:OSError: [Errno 30] Read-only file system: '/tmp/tmpXXXX'

注意 errno 30 就是 EROFS,看到这个数字基本可以断定是只读根文件系统导致,而不是权限问题(权限问题通常是 EACCES / errno 13)。这个区分在排错时很有价值:EROFS 说明路径本身根本不可写,EACCES 说明 fsGroup、runAsUser 或目录 mode 有问题,两者修法完全不同。

哪些写入是”非必要”的,哪些是”必要”的

并非所有写入都需要保留。区分标准是数据是否有跨进程、跨重启的价值:

路径类型 典型例子 处理方式
进程间通信的 socket 目录 nginx 的 /var/run、PHP-FPM 的 pid 文件 emptyDir(内存型可选)
临时文件 /tmp、/var/tmp、Tomcat 的 work 目录 emptyDir(建议 medium: Memory 并设 sizeLimit)
运行时缓存 nginx 的 /var/cache/nginx、字体缓存 emptyDir
应用日志(stdout 之外) 写文件的 logback / log4j2 appender 优先改为 stdout;确实要留则 emptyDir + sidecar 收走
需要持久化的业务数据 数据库数据目录、上传文件 PVC,不能用 emptyDir

第一、二、三类正是本文要覆盖的范围。第四类如果无法改成 stdout,说明日志采集链路需要重构,而不是简单挂个 emptyDir——emptyDir 的内容随 Pod 删除而消失,日志会丢。

最小化配置:先从一个真实失败的例子改起

以 nginx 为例,先看不加任何挂载时的 Pod 定义:

apiVersion: v1
kind: Pod
metadata:
  name: web-hardened
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 101
    runAsGroup: 101
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: nginx
      image: nginx:1.27-alpine
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]

这个 Pod 会反复重启,kubectl logs 里是:

2024/08/12 09:11:03 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)

同时还会遇到第二个问题:默认的 nginx:1.27-alpine 镜像监听 80 端口,非 root 用户(uid 101)无法 bind 1024 以下的端口,报 bind() to 0.0.0.0:80 failed (13: Permission denied)。这属于 runAsNonRoot 引入的独立问题,和只读无关,但在加固场景里通常会一起出现,需要配合 NET_BIND_SERVICE capability 或直接改用 8080。

加上 emptyDir 后的完整版

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 101
        runAsGroup: 101
        fsGroup: 101
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: nginx
          image: nginx:1.27-alpine
          ports:
            - containerPort: 8080
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: nginx-cache
              mountPath: /var/cache/nginx
            - name: nginx-run
              mountPath: /var/run
            - name: tmp
              mountPath: /tmp
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              memory: 128Mi
      volumes:
        - name: nginx-cache
          emptyDir:
            sizeLimit: 64Mi
        - name: nginx-run
          emptyDir: {}
        - name: tmp
          emptyDir:
            medium: Memory
            sizeLimit: 32Mi

配套的 nginx 配置片段(挂到 ConfigMap 覆盖默认配置)需要把 pid 和临时目录指到已挂载路径,并把监听端口改为 8080:

# /etc/nginx/conf.d/default.conf
server {
    listen 8080;
    server_name _;
    client_body_temp_path /tmp/client_temp;
    proxy_temp_path       /tmp/proxy_temp;
    fastcgi_temp_path     /tmp/fastcgi_temp;
    uwsgi_temp_path       /tmp/uwsgi_temp;
    scgi_temp_path        /tmp/scgi_temp;

    location / {
        root /usr/share/nginx/html;
        index index.html;
    }
}

主配置里 pid 默认是 /var/run/nginx.pid,已由 nginx-run 挂载覆盖,不需要额外改。如果镜像版本把 pid 放在 /run/nginx.pid,注意 /var/run 通常是 /run 的符号链接,只挂 /var/run 时行为可能不可预期——稳妥做法是把 /run 也挂上,或者用 pid /tmp/nginx.pid; 直接统一到 /tmp。

验证配置生效

$ kubectl apply -f web.yaml
$ kubectl rollout status deploy/web
deployment "web" successfully rolled out

# 确认根文件系统确实是只读
$ kubectl exec deploy/web -- sh -c 'touch /probe.txt'
touch: /probe.txt: Read-only file system

# 确认挂载点可写
$ kubectl exec deploy/web -- sh -c 'touch /tmp/probe.txt && ls -l /tmp/probe.txt'
-rw-r--r--    1 nginx    nginx            0 Aug 12 09:24 /tmp/probe.txt

# 查看挂载明细
$ kubectl exec deploy/web -- cat /proc/mounts | grep -E ' /(tmp|var|etc)'

/proc/mounts 里根挂载点会显示 ro,relatime,emptyDir 则是 rw。这是最直接的证据,比看 YAML 更可信。

挂载点覆盖问题:为什么有时候挂了还是不生效

这是最容易踩的坑。emptyDir 挂载会替换目标目录下的原有内容:挂载前镜像里的 /var/cache/nginx 有若干子目录和权限设置,挂载后看到的是一个全新的空目录,owner 由 kubelet 依据 fsGroup 与 Pod 安全上下文决定。

典型后果:

  • 镜像里预先 chown 过的目录,挂载后变成 root:root 0755,非 root 进程写不进去,报 EACCES 而非 EROFS。
  • 镜像里预置的配置文件(例如把整个 /etc/nginx 挂成 emptyDir)全部消失,进程直接找不到配置。

所以原则是:只挂”纯写入”的叶目录,不要挂包含镜像内容的父目录。/var/cache/nginx、/tmp、/var/run 符合;/etc、/usr/share/nginx 不符合。

用 fsGroup 解决 owner 问题

emptyDir 的目录属主由 kubelet 决定,机制是:kubelet 在挂载完成后对卷做 chown/chmod,依据是 Pod 级 securityContext.fsGroup(以及 fsGroupChangePolicy)。因此必须同时设置:

spec:
  securityContext:
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    fsGroupChangePolicy: OnRootMismatch

fsGroupChangePolicy: OnRootMismatch 表示只在卷根目录的 group 不匹配时才递归改权限,能显著缩短大卷的挂载耗时;默认值 Always 会在每次挂载时递归遍历,对大 emptyDir(例如放了大量缓存文件后重建 Pod)可能拖慢启动。

注意:fsGroup 只对支持它的卷类型生效,emptyDir、hostPath、PVC(取决于 CSI 驱动)都支持,但 medium: Memory 的 tmpfs 同样会经过这一步。若使用 readOnlyRootFilesystem 且 runAsUser 为随机 UID 的平台(如 OpenShift 的 restricted-v2 SCC),需要确认 SCC 允许的 fsGroup 范围,否则 Pod 会卡在 CreateContainerConfigError 或直接被拒绝。

emptyDir 的参数怎么选:磁盘还是内存

emptyDir 有两个关键字段:medium 和 sizeLimit。

字段 取值 行为 适用
medium 省略(默认) 落在节点磁盘,通常是 kubelet 的 root-dir 下 体积较大、写放大明显的缓存
medium Memory tmpfs,占用容器内存,计入 memory limit /tmp 之类小量高频写入
sizeLimit 如 64Mi 超过后触发驱逐(默认卷为磁盘时)或写入失败(tmpfs 达上限) 所有 emptyDir 都建议设置

选择 medium: Memory 时有个必须知道的细节:tmpfs 的用量计入容器的 memory cgroup。也就是说,如果容器 memory limit 是 128Mi,而 /tmp 是 tmpfs 且被写满 32Mi,剩余可用堆内存只剩约 96Mi。这会让一个原本正常的 Java 应用频繁 OOMKilled,而且 kubectl describe pod 里只显示 OOMKilled,不会告诉你是 tmpfs 吃掉的。

实操建议:

  • /tmp 用小容量 tmpfs(16~64Mi)确实能提升安全性与性能,但必须保证 resources.limits.memory >= 应用堆 + tmpfs sizeLimit + 余量。
  • 如果应用会往 /tmp 写大文件(例如图像处理、导出 PDF),改用默认磁盘型 emptyDir,并设置 sizeLimit 防止写满节点磁盘。
  • sizeLimit 对磁盘型 emptyDir 的语义是:超限时 kubelet 驱逐 Pod(Evicted,reason 为 EmptyDirSizeLimitExceeded),而不是让写入返回 ENOSPC。想提前发现问题,配合 ephemeral-storage 的 requests/limits 与告警更可靠。
resources:
  requests:
    cpu: 100m
    memory: 256Mi
    ephemeral-storage: 128Mi
  limits:
    memory: 384Mi
    ephemeral-storage: 512Mi

按运行时给出可写目录清单

不同基础镜像的”必要写入路径”差异很大。下面几份是经过验证的常见组合,可直接参考。

Java / Spring Boot(以 eclipse-temurin 为例)

  • /tmp:JVM 的 java.io.tmpdir 默认指向这里,内嵌 Tomcat 解压、Files.createTempFile 都依赖它。
  • /opt/app/logs 或应用自定义日志目录:仅在 logback 配置为文件 appender 时需要。
  • Spring Boot 3 用 spring-boot-maven-plugin 打出的镜像常把工作目录设为 /workspace,若应用在启动时生成 config/ 或解压 native 库,也需要挂。
env:
  - name: JAVA_TOOL_OPTIONS
    value: "-Djava.io.tmpdir=/tmp -XX:MaxRAMPercentage=70"
volumeMounts:
  - name: tmp
    mountPath: /tmp
volumes:
  - name: tmp
    emptyDir:
      medium: Memory
      sizeLimit: 64Mi

显式设置 -Djava.io.tmpdir=/tmp 不算多余:某些镜像会把该属性改到别处,显式写死后挂载点与 JVM 认知一致,能避免”我明明挂了 /tmp 它却去写别的目录”。

Node.js

  • /tmp:os.tmpdir() 的返回值。
  • npm/pnpm 缓存目录:仅在容器启动时 npm install 的镜像里需要,生产镜像应该把依赖打进镜像层,不需要挂。
  • 日志目录:除非用 pino 写文件或 winston 配 File transport,否则日志走 stdout 即可。
volumeMounts:
  - name: tmp
    mountPath: /tmp
  - name: app-cache
    mountPath: /home/node/.cache
volumes:
  - name: tmp
    emptyDir:
      sizeLimit: 64Mi
  - name: app-cache
    emptyDir: {}

Python / Gunicorn

Python 的 tempfile.gettempdir() 会依次检查 TMPDIR、TEMP、TMP 环境变量,再回退到 /tmp、/var/tmp、/usr/tmp。只读根文件系统下最稳的做法是显式设置 TMPDIR=/tmp 并挂载 /tmp,而不是依赖回退顺序。

env:
  - name: TMPDIR
    value: /tmp
  - name: PYTHONUNBUFFERED
    value: "1"
  - name: PYTHONDONTWRITEBYTECODE
    value: "1"

PYTHONDONTWRITEBYTECODE=1 值得加:非 root 用户导入模块时若无法写 __pycache__,Python 会静默跳过(不会报错),但每次启动都要重新编译字节码,启动时间变长。加了这个变量后行为明确,也不产生无谓的写入尝试。

排错手册:三类高频故障

1. CrashLoopBackOff 且日志里是 EROFS

定位缺失路径的方法是把容器的写入行为抓出来,而不是靠猜。两个实用手段:

# 手段一:临时以可写根文件系统启动一个调试容器,用 strace 看写操作
$ kubectl debug -it pod/web-hardened --image=nicolaka/netshoot --target=nginx -- sh
$ strace -f -e trace=openat,open -o /tmp/trace.log /docker-entrypoint.sh nginx -g 'daemon off;'
$ grep -E 'O_WRONLY|O_CREAT|O_RDWR' /tmp/trace.log | grep -v ENOENT | head -50
# 手段二:用 ebpf 或简单点,把容器的 /proc/<pid>/fd 观察一轮(适合长驻进程)
$ kubectl exec deploy/web -- sh -c 'ls -l /proc/1/fd 2>/dev/null | head'

更省事的办法是临时把 readOnlyRootFilesystem 改为 false,运行成功后用 kubectl diff 对比容器层变化,或者直接在容器里 find / -newer /etc/hostname -type f 找出启动过程中新建的文件。找到路径后,只把该路径的叶目录加入 emptyDir 列表,然后立即改回 true。

2. 挂了目录但仍然 EACCES

报错从 EROFS 变成 EACCES 说明挂载生效了,但属主不对。检查顺序:

  1. kubectl exec 进容器 ls -ld /tmp,看 owner 和 mode。
  2. 对照 spec.securityContext.runAsUser 与 fsGroup,两者应一致或同组。
  3. 若使用 runAsNonRoot: true 且镜像是 root(uid 0),kubelet 会直接拒绝启动,报 container has runAsNonRoot and image will run as root。这类镜像必须换成非 root 变体(如 nginxinc/nginx-unprivileged)或用 runAsUser 显式指定。
  4. 用 fsGroupChangePolicy: OnRootMismatch 时,若卷根目录 group 恰好匹配就跳过递归,可能存在子目录仍不可写的情况——临时切回 Always 验证。

3. Pod 被驱逐,reason 是 EmptyDirSizeLimitExceeded

$ kubectl get pod web-7d9c8f6b5-xxxxx -o jsonpath='{.status.reason}{"\n"}'
Evicted
$ kubectl describe pod web-7d9c8f6b5-xxxxx | grep -A3 'Status:'
Status:       Failed
Reason:       Evicted
Message:      Pod was evicted due to emptyDir size limit exceeded on volume "tmp"

这不是错误,是 sizeLimit 在起作用。处理方式取决于业务语义:临时文件被写满说明有代码在不断累积而不清理,先修应用;确属正常用量再上调 sizeLimit 与 ephemeral-storage limits,两者要同步改,否则会先在 ephemeral-storage 上被驱逐。

策略层面:如何保证新工作负载默认只读

手工给每个 Deployment 加 readOnlyRootFilesystem 不可持续。用 ValidatingAdmissionPolicy(K8s 1.30 GA)或 Kyverno 做强制校验更靠谱。Kyverno 的最小规则:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-ro-rootfs
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-readonly-rootfs
      match:
        any:
          - resources:
              kinds: ["Pod"]
      validate:
        message: "containers must set securityContext.readOnlyRootFilesystem=true"
        pattern:
          spec:
            containers:
              - securityContext:
                  readOnlyRootFilesystem: true

先用 validationFailureAction: Audit 跑一周,把存量工作负载的失败清单拉出来挨个修,再切 Enforce。直接上 Enforce 会让大量已有 Deployment 无法滚动更新,风险很高。

还有一个常被忽略的点:readOnlyRootFilesystem: true 只对容器根文件系统生效,hostPath、PVC、/proc、/sys 等特殊挂载不受影响。也就是说该字段不能替代 Pod Security Standards 的 restricted 配置档,两者应一起启用。

小结

把根文件系统设为只读的核心工作量,不在那一行 YAML,而在于找出容器运行时真正需要写入的路径并逐个用 emptyDir 承接。落地时抓住四点:只挂叶目录不挂父目录;用 fsGroup 保证挂载后属主正确;medium: Memory 时把 tmpfs 用量算进 memory limit;所有 emptyDir 都配 sizeLimit 并同步设置 ephemeral-storage。从 /tmp 和一个运行时缓存目录开始改,跑通后再按 EROFS 报错逐个补齐,比一次性铺开要稳得多。

FAQ

Q1:/tmp 和 /var/tmp 都要挂吗?

看应用。POSIX 语义上 /var/tmp 用于重启后仍需保留的临时文件,容器场景基本用不到。多数程序(包括 JVM 的 java.io.tmpdir)走 /tmp。先只挂 /tmp,等日志里真出现 /var/tmp 的 EROFS 再补,避免无效挂载。

Q2:emptyDir 的内容会不会被 kubelet 清理?

容器重启(同一 Pod 内)不会清空,数据保留;Pod 被删除(包括被驱逐、被 Deployment 滚动替换)时清空。这也是它不能存日志和业务数据的原因。需要跨 Pod 生命周期保留的内容必须用 PVC。

Q3:为什么加了 readOnlyRootFilesystem 后启动变慢?

常见于两个原因:一是 fsGroupChangePolicy 为默认 Always 且卷里有大量文件,kubelet 递归 chown 耗时;二是应用每次启动都要写缓存(如字体缓存、字节码缓存)却发现不可写,退化为每次重新生成。前者改 OnRootMismatch,后者把缓存目录也挂成 emptyDir 并考虑在镜像构建阶段预热。

Q4:设了 sizeLimit 但没看到驱逐,写入直接失败是为什么?

如果该 emptyDir 是 medium: Memory(tmpfs),sizeLimit 通过 tmpfs 的 size 参数实现,超限时写入返回 ENOSPC 而不是驱逐 Pod。磁盘型 emptyDir 才走驱逐路径。所以 tmpfs 卷满会直接让应用抛 No space left on device,排查时别和节点磁盘满混淆。

Q5:kubectl exec 进容器后 touch /tmp/x 失败,但 Pod 明明在 Running?

说明该路径没有被 emptyDir 覆盖,仍落在只读根上,而应用可能只用到了别的可写路径所以没崩。用 cat /proc/mounts 确认挂载点列表,比在容器里凭直觉试更快。另外注意,kubectl exec 默认进入的是容器的第一个 command 决定的 namespace,特殊情况下(用了 shareProcessNamespace)会看到其他容器的挂载视图,确认目标容器名后再执行。